diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..1ab4834 --- /dev/null +++ b/agents.md @@ -0,0 +1,123 @@ +# AGENTS.md — SciFi-XZBT + +Guidance for AI coding agents working in this repository. + +## What this project is + +A browser-only, zero-dependency **sci-fi ambient audio + visual display**. It +synthesizes starship room tone (hull drone, warp core, life support, telemetry, +alerts) with the Web Audio API and renders LCARS-styled UI, an audio +visualizer, and a procedural "observation" viewscreen on HTML canvas. + +It runs by opening `index.html` in a browser. There is **no build step, no +package manager, no server, and no test suite.** + +## Architecture + +`index.html` loads one stylesheet and six scripts **in a fixed order**: + +``` +css/style.css +js/audio.js -> js/config.js -> js/visualizer.js +js/observation-bezels.js -> js/observation-engine.js -> js/app.js +``` + +Order matters: every file defines plain classes/objects and publishes them on +`window`; `js/app.js` runs last and consumes all of them. Do not reorder the +tags without checking the dependencies. + +| File | Owns | ~Lines | +| --- | --- | --- | +| `index.html` | DOM skeleton, script/link tags | 370 | +| `css/style.css` | LCARS themes, CRT effects, palettes | 2,300 | +| `js/audio.js` | `AudioManager` + all synth classes (hull, warp, life support, telemetry, alert, Whoniverse, expanded sci-fi) | 2,250 | +| `js/config.js` | `StarshipPresets`, `UniverseRegistry` — data only, no behavior | 1,040 | +| `js/visualizer.js` | `StarshipVisualizer` — spectrum + warp core canvas | 640 | +| `js/observation-bezels.js` | `ObservationBezels` — procedural SVG viewport frames per era | 270 | +| `js/observation-engine.js` | `ObservationEngine` — celestial simulation and canvas rendering | 2,780 | +| `js/app.js` | DOM bindings, hotkeys, UI controllers, main loop | 3,960 | +| `tools/package.ps1` | Builds the standalone single-file HTML | — | +| `dist/SciFiAmbientDisplay_v4.html` | **Generated output — never edit by hand** | — | + +## Hard constraints + +- **No dependencies.** No npm, no CDN links, no external fonts, images, or + audio files. The packaged build must work when double-clicked while offline. +- **No ES modules.** No `import` / `export`, no `type="module"`. Scripts are + classic globals. +- **Publish globals explicitly.** End each file with `window.Thing = Thing;`, + matching the existing pattern. +- **All audio is synthesized**, not sampled. New sounds are built from + oscillators / noise / filters in `js/audio.js`. + +## Where to make a change + +- New room or ship preset, universe, palette → `js/config.js` +- Sound generation, oscillators, envelopes, filters → `js/audio.js` +- Spectrum bars, warp-core animation → `js/visualizer.js` +- Viewscreen frame / bezel shapes per era → `js/observation-bezels.js` +- Planets, stars, drift, observation-mode canvas → `js/observation-engine.js` +- Buttons, sliders, hotkeys, state wiring, animation loop → `js/app.js` +- Colors, layout, theme classes, CRT overlay → `css/style.css` + +Presets in `config.js` follow a fixed shape — copy an existing one rather than +inventing fields: + +```js +'era-room': { + id, name, era, theme, alertType, description, + hull: { volume, baseFreq, filterCutoff, resonance, noiseMix, harmonicSpread }, + warp: { volume, bpm, carrierFreq, filterCutoff, pulseShape, resonance, swirlMix }, + lifeSupport: { volume, noiseType, highpassFreq, lowpassFreq, airflowModSpeed, airflowModDepth }, + telemetry: { volume, density, era } +} +``` + +## Adding a new JS file + +1. Create `js/your-file.js` and end it with `window.YourThing = YourThing;`. +2. Add the tag to `index.html` **in this exact form**, positioned before any + file that depends on it (and always before `js/app.js`): + + ```html + + ``` + + `tools/package.ps1` inlines assets by matching + `` and + ``. Extra attributes, a + different quote style, or a self-closing script tag will be silently left as + an external reference and the standalone build will break. + +## Build + +```powershell +powershell -ExecutionPolicy Bypass -File .\tools\package.ps1 +``` + +Writes `dist/SciFiAmbientDisplay_v4.html` (CSS and JS inlined). Re-run it after +any change to CSS, JS, or `index.html`. `tools/extract.ps1` is the original +extraction script, kept for reference only. + +## Verifying work + +There are no automated tests. Verification is manual: + +1. Open `index.html` in a browser; check the console is clean. +2. Exercise the affected area — play a preset, switch era/universe, open + observation mode. +3. Re-run `package.ps1` and open `dist/SciFiAmbientDisplay_v4.html` **with the + network disabled** to confirm it is genuinely self-contained. + +Browser autoplay policy blocks audio until a user gesture; audio starting only +after a click is expected behavior, not a bug. + +## Conventions and cautions + +- 2-space indentation; single quotes in JS; existing brace and spacing style. +- `js/app.js` and `js/observation-engine.js` are large. Make **surgical, scoped + edits** — do not rewrite, reorder, or reformat whole files. +- Do not reformat or lint files wholesale; diffs should stay minimal and + reviewable. +- Never edit anything in `dist/` — regenerate it instead. +- Preserve the offline, single-file-deliverable property in every change. diff --git a/js/app.js b/js/app.js index 7420607..ac27d10 100644 --- a/js/app.js +++ b/js/app.js @@ -1,3961 +1,3963 @@ -/** - * Main Application Controller - 10 Sci-Fi Universes Architecture - * Coordinates Starfleet, Whoniverse, Industrial, Bioships, Retro Future, Military, - * Deep Space, Outlaw, Space Stations, and Comedy universes. - */ - -document.addEventListener('DOMContentLoaded', () => { - // 1. Initialize Audio Subsystems - const audioManager = new AudioManager(); - const hullDrone = new HullDroneSynth(audioManager); - const warpCore = new WarpCoreSynth(audioManager); - const lifeSupport = new LifeSupportSynth(audioManager); - const telemetry = new TelemetrySynth(audioManager); - const alerts = new AlertSynth(audioManager); - const whoniverseAudio = new WhoniverseAudioSynth(audioManager); - const expandedAudio = new ExpandedSciFiAudioSynth(audioManager); - const visualizer = new StarshipVisualizer(audioManager, warpCore); - const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport); - window.observationEngine = observationEngine; - - let isPlaying = false; - let activeUniverseId = 'starfleet'; - let activePresetId = 'tng-bridge'; - window.activeUniverseId = activeUniverseId; - window.activePresetId = activePresetId; - - // 2. DOM Elements - const headerUniverseBtn = document.getElementById('header-universe-btn'); - const headerUniverseLabel = document.getElementById('header-universe-label'); - const universeDropdown = document.getElementById('universe-dropdown'); - const headerShipName = document.getElementById('header-ship-name'); - const headerMatrixLabel = document.getElementById('header-matrix-label'); - - const sidebarTitle = document.getElementById('sidebar-title'); - const presetContainer = document.getElementById('preset-container'); - const currentPresetTitle = document.getElementById('current-preset-title'); - const currentPresetDesc = document.getElementById('current-preset-desc'); - const statAudioStatus = document.getElementById('stat-audio-status'); - const statCoreFreq = document.getElementById('stat-core-freq'); - - const btnPlay = document.getElementById('btn-master-play'); - const playIcon = document.getElementById('play-icon'); - const playText = document.getElementById('play-text'); - - // Master Volume Console Elements - const sliderMasterVol = document.getElementById('slider-master-vol'); - const valMasterVol = document.getElementById('val-master-vol'); - const valMasterDb = document.getElementById('val-master-db'); - const btnMasterMute = document.getElementById('btn-master-mute'); - const btnHeaderMute = document.getElementById('btn-header-mute'); - const headerVolVal = document.getElementById('header-vol-val'); - const btnVolDown = document.getElementById('btn-vol-down'); - const btnVolUp = document.getElementById('btn-vol-up'); - const volMeterLeds = document.getElementById('vol-meter-leds'); - const volStepButtons = document.querySelectorAll('.btn-vol-step'); - - // Channel Sliders - const sliderHullVol = document.getElementById('slider-hull-vol'); - const valHullVol = document.getElementById('val-hull-vol'); - const sliderHullFreq = document.getElementById('slider-hull-freq'); - const valHullFreq = document.getElementById('val-hull-freq'); - const sliderHullCutoff = document.getElementById('slider-hull-cutoff'); - const valHullCutoff = document.getElementById('val-hull-cutoff'); - - const sliderWarpVol = document.getElementById('slider-warp-vol'); - const valWarpVol = document.getElementById('val-warp-vol'); - const sliderWarpBpm = document.getElementById('slider-warp-bpm'); - const valWarpBpm = document.getElementById('val-warp-bpm'); - const sliderWarpCarrier = document.getElementById('slider-warp-carrier'); - const valWarpCarrier = document.getElementById('val-warp-carrier'); - - const sliderAirVol = document.getElementById('slider-air-vol'); - const valAirVol = document.getElementById('val-air-vol'); - const sliderAirCutoff = document.getElementById('slider-air-cutoff'); - const valAirCutoff = document.getElementById('val-air-cutoff'); - - const sliderTelemetryVol = document.getElementById('slider-telemetry-vol'); - const valTelemetryVol = document.getElementById('val-telemetry-vol'); - const sliderTelemetryDensity = document.getElementById('slider-telemetry-density'); - const valTelemetryDensity = document.getElementById('val-telemetry-density'); - - // Visualizer & Dynamic Containers - const coreVisualizerTitle = document.getElementById('core-visualizer-title'); - const coreVisualizerLabel = document.getElementById('core-visualizer-label'); - const soundboardTitle = document.getElementById('soundboard-title'); - const universeEventsContainer = document.getElementById('universe-events-container'); - const soundboardContainer = document.getElementById('soundboard-container'); - - // Sleep Timer - const timerDisplay = document.getElementById('timer-display'); - const timerButtons = document.querySelectorAll('.btn-timer'); - - // 3. Universe Management & Switching - function initUniverseDropdown() { - universeDropdown.innerHTML = ''; - Object.values(UniverseRegistry).forEach(u => { - const btn = document.createElement('button'); - btn.className = `universe-item ${u.id === activeUniverseId ? 'active' : ''}`; - btn.innerHTML = `${u.icon} ${u.name}`; - btn.addEventListener('click', (e) => { - e.stopPropagation(); - setUniverse(u.id); - }); - universeDropdown.appendChild(btn); - }); - - headerUniverseBtn.addEventListener('click', (e) => { - e.stopPropagation(); - universeDropdown.classList.toggle('show'); - }); - - document.addEventListener('click', () => { - universeDropdown.classList.remove('show'); - }); - } - - function setUniverse(universeId) { - const universe = UniverseRegistry[universeId]; - if (!universe) return; - - activeUniverseId = universeId; - universeDropdown.classList.remove('show'); - - // Update Dropdown UI active class - document.querySelectorAll('.universe-item').forEach(el => { - el.classList.toggle('active', el.textContent.includes(universe.name)); - }); - - // Update Header and Theme Class - headerUniverseLabel.textContent = universe.shortCode; - document.body.className = universe.themeClass; - headerMatrixLabel.textContent = universe.headerTitle; - - // Custom titles per universe - const titlesMap = { - 'starfleet': { - sidebar: 'STARSHIP PRESETS', - visTitle: 'MATTER / ANTIMATTER CORE', - visLabel: 'INTERMIX CHAMBER // ACTIVE', - soundboard: 'LCARS KEYPAD' - }, - 'whoniverse': { - sidebar: 'TARDIS CONSOLES', - visTitle: 'CENTRAL TIME ROTOR COLUMN', - visLabel: 'VORTEX ROTOR // OPERATIONAL', - soundboard: 'TARDIS CONSOLE CONTROLS' - }, - 'industrial': { - sidebar: 'INDUSTRIAL FREIGHTERS', - visTitle: 'HEAVY FUSION REACTOR', - visLabel: 'CONTAINMENT CORE // ACTIVE', - soundboard: 'INDUSTRIAL INSTRUMENTATION' - }, - 'bioships': { - sidebar: 'SENTIENT LEVIATHANS', - visTitle: 'ORGANIC BIO-HEART SAC', - visLabel: 'VASCULAR NEXUS // PULSING', - soundboard: 'NEURAL LINK SENSORS' - }, - 'retrofuture': { - sidebar: 'GOLDEN AGE EXPLORATION', - visTitle: 'ASTROGATOR OSCILLOSCOPE', - visLabel: 'ANALOG BEAM // ROTATING', - soundboard: 'RETRO CONSOLE SWITCHES' - }, - 'military': { - sidebar: 'BATTLEFLEET WARSHIPS', - visTitle: 'DRADIS TACTICAL COMBAT CORE', - visLabel: 'CONDITION ONE // ARMED', - soundboard: 'CIC TACTICAL KEYPAD' - }, - 'deepspace': { - sidebar: 'DEEP EXPEDITION ARKS', - visTitle: 'GRAVITY SINGULARITY CORE', - visLabel: 'EVENT HORIZON // LOCKED', - soundboard: 'VOID ACOUSTIC CONSOLE' - }, - 'outlaw': { - sidebar: 'FRONTIER & SALVAGE SHIPS', - visTitle: 'MODIFIED SUB-LIGHT REACTOR', - visLabel: 'KINETIC THRUSTERS // PRIMED', - soundboard: 'OUTLAW COCKPIT CONTROLS' - }, - 'spacestations': { - sidebar: 'ORBITAL STATIONS & DOCKS', - visTitle: 'CENTRIFUGAL ROTATION CORE', - visLabel: 'STATION ROTOR // DOCKED', - soundboard: 'STATION OPS CONSOLE' - }, - 'comedy': { - sidebar: 'WHIMSICAL & ADVENTURE FLEET', - visTitle: 'IMPROBABILITY CORE', - visLabel: 'REALITY FIELD // SHIFTING', - soundboard: 'CHEERFUL CONSOLE KEYPAD' - } - }; - - const conf = titlesMap[universeId] || titlesMap['starfleet']; - sidebarTitle.textContent = conf.sidebar; - coreVisualizerTitle.textContent = conf.visTitle; - coreVisualizerLabel.textContent = conf.visLabel; - soundboardTitle.textContent = conf.soundboard; - - // Configure Visualizer mode - visualizer.setMode(universe.visualizer || 'warp-core'); - - // Render Event Buttons & Soundboard - renderUniverseEvents(universe); - renderUniverseSoundboard(universe); - - // Render Presets - renderUniversePresets(universe); - - // Select Default Preset - const firstPresetId = Object.keys(universe.presets)[0]; - if (firstPresetId) { - selectPreset(firstPresetId); - } - - if (observationActive && typeof refreshObservation === 'function') { - refreshObservation(); - } - } - - function renderUniverseEvents(universe) { - universeEventsContainer.innerHTML = ''; - universe.events.forEach(evt => { - const btn = document.createElement('button'); - btn.className = 'btn-lcars-pill'; - btn.id = evt.id; - btn.textContent = evt.label; - - btn.addEventListener('click', async () => { - await audioManager.resume(); - handleUniverseEvent(evt.type, btn); - }); - - universeEventsContainer.appendChild(btn); - }); - } - - function renderUniverseSoundboard(universe) { - soundboardContainer.innerHTML = ''; - universe.soundboard.forEach(key => { - const btn = document.createElement('button'); - btn.className = 'btn-chirp'; - btn.id = key.id; - btn.textContent = key.label; - if (key.span) { - btn.style.gridColumn = `span ${key.span}`; - } - - btn.addEventListener('click', async () => { - await audioManager.resume(); - handleSoundboardTrigger(key.id); - }); - - soundboardContainer.appendChild(btn); - }); - } - - function renderUniversePresets(universe) { - presetContainer.innerHTML = ''; - Object.values(universe.presets).forEach(preset => { - const btn = document.createElement('button'); - btn.className = `preset-btn ${preset.id === activePresetId ? 'active' : ''}`; - btn.setAttribute('data-preset-id', preset.id); - - btn.innerHTML = ` - ${preset.name} - ${(preset.era || 'SHIP').toUpperCase()} // ${preset.warp.pulseShape.toUpperCase()} CORE - `; - - btn.addEventListener('click', () => { - selectPreset(preset.id); - }); - - presetContainer.appendChild(btn); - }); - } - - // 4. Select & Apply Preset - function selectPreset(presetId) { - const universe = UniverseRegistry[activeUniverseId]; - if (!universe) return; - - const preset = universe.presets[presetId]; - if (!preset) return; - - activePresetId = presetId; - window.activePresetId = presetId; - - document.querySelectorAll('.preset-btn').forEach(b => { - b.classList.toggle('active', b.getAttribute('data-preset-id') === presetId); - }); - - headerShipName.textContent = preset.name.toUpperCase(); - currentPresetTitle.textContent = preset.name.toUpperCase(); - currentPresetDesc.textContent = preset.description; - statCoreFreq.textContent = `${preset.warp.carrierFreq.toFixed(1)} Hz`; - - hullDrone.applyPreset(preset.hull); - warpCore.applyPreset(preset.warp); - lifeSupport.applyPreset(preset.lifeSupport); - telemetry.applyPreset(preset.telemetry); - - syncSlidersToSynthParams(); - - if (isPlaying) { - if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeDematSwitch(); - else if (activeUniverseId === 'industrial') expandedAudio.synthesizeDockingClamp(); - else telemetry.synthesizeLCARSSingleChirp(); - } - - if (observationActive && typeof refreshObservation === 'function') { - refreshObservation(); - } - } - - function syncSlidersToSynthParams() { - sliderHullVol.value = hullDrone.params.volume; - valHullVol.textContent = `${Math.round(hullDrone.params.volume * 100)}%`; - sliderHullFreq.value = hullDrone.params.baseFreq; - valHullFreq.textContent = `${hullDrone.params.baseFreq} Hz`; - sliderHullCutoff.value = hullDrone.params.filterCutoff; - valHullCutoff.textContent = `${hullDrone.params.filterCutoff} Hz`; - - sliderWarpVol.value = warpCore.params.volume; - valWarpVol.textContent = `${Math.round(warpCore.params.volume * 100)}%`; - sliderWarpBpm.value = warpCore.params.bpm; - valWarpBpm.textContent = `${warpCore.params.bpm} BPM`; - sliderWarpCarrier.value = warpCore.params.carrierFreq; - valWarpCarrier.textContent = `${warpCore.params.carrierFreq} Hz`; - - sliderAirVol.value = lifeSupport.params.volume; - valAirVol.textContent = `${Math.round(lifeSupport.params.volume * 100)}%`; - sliderAirCutoff.value = lifeSupport.params.lowpassFreq; - valAirCutoff.textContent = `${lifeSupport.params.lowpassFreq} Hz`; - - sliderTelemetryVol.value = telemetry.params.volume; - valTelemetryVol.textContent = `${Math.round(telemetry.params.volume * 100)}%`; - sliderTelemetryDensity.value = telemetry.params.density; - valTelemetryDensity.textContent = `${Math.round(telemetry.params.density * 100)}%`; - } - - // 5. Play / Pause Master Toggle - async function togglePlay() { - if (!isPlaying) { - await audioManager.resume(); - hullDrone.start(); - warpCore.start(); - lifeSupport.start(); - telemetry.start(); - - isPlaying = true; - btnPlay.classList.add('playing'); - playIcon.textContent = '■'; - playText.textContent = 'DISENGAGE'; - statAudioStatus.textContent = 'ONLINE'; - statAudioStatus.style.color = '#00ff88'; - - if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeTelepathicChime(); - else if (activeUniverseId === 'bioships') expandedAudio.synthesizeStarburst(); - else telemetry.synthesizeLCARSDoubleChirp(); - } else { - hullDrone.stop(); - warpCore.stop(); - lifeSupport.stop(); - telemetry.stop(); - alerts.stopAlert(); - whoniverseAudio.stopCloisterBell(); - resetAlertButtons(); - - isPlaying = false; - btnPlay.classList.remove('playing'); - playIcon.textContent = '▶'; - playText.textContent = 'ENGAGE AMBIENCE'; - statAudioStatus.textContent = 'STANDBY'; - statAudioStatus.style.color = '#ffcc00'; - } - } - - btnPlay.addEventListener('click', togglePlay); - - // 6. Master Volume Control System - function updateMasterVolumeUI(val, isMuted = false) { - sliderMasterVol.value = val; - const pct = Math.round(val * 100); - valMasterVol.textContent = isMuted ? 'MUTED' : `${pct}%`; - headerVolVal.textContent = isMuted ? 'MUTE' : `${pct}%`; - - if (isMuted || val <= 0.001) { - valMasterDb.textContent = '-∞ dB'; - } else { - const db = 20 * Math.log10(val); - valMasterDb.textContent = `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`; - } - - btnMasterMute.classList.toggle('muted', isMuted); - btnHeaderMute.classList.toggle('muted', isMuted); - btnMasterMute.textContent = isMuted ? 'UNMUTE' : 'MUTE'; - btnHeaderMute.textContent = isMuted ? 'MUTED' : 'MUTE'; - - if (volMeterLeds) { - const pips = volMeterLeds.querySelectorAll('.led-pip'); - const activeCount = isMuted ? 0 : Math.round(val * pips.length); - pips.forEach((pip, idx) => { - pip.classList.toggle('active', idx < activeCount); - }); - } - - volStepButtons.forEach(btn => { - const stepVal = parseFloat(btn.getAttribute('data-vol')); - btn.classList.toggle('active', !isMuted && Math.abs(stepVal - val) < 0.04); - }); - } - - function setMasterVolume(val) { - const clamped = Math.max(0, Math.min(1, val)); - if (audioManager.isMuted) audioManager.setMute(false); - audioManager.setMasterVolume(clamped); - updateMasterVolumeUI(clamped, false); - } - - function toggleMute() { - const isMuted = audioManager.toggleMute(); - updateMasterVolumeUI(audioManager.getMasterVolume(), isMuted); - } - - sliderMasterVol.addEventListener('input', (e) => setMasterVolume(parseFloat(e.target.value))); - btnMasterMute.addEventListener('click', toggleMute); - btnHeaderMute.addEventListener('click', toggleMute); - btnVolDown.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() - 0.05)); - btnVolUp.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() + 0.05)); - - volStepButtons.forEach(btn => { - btn.addEventListener('click', () => { - const stepVal = parseFloat(btn.getAttribute('data-vol')); - setMasterVolume(stepVal); - }); - }); - - // 7. Channel Synthesizer Sliders - sliderHullVol.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - hullDrone.setVolume(val); - valHullVol.textContent = `${Math.round(val * 100)}%`; - }); - sliderHullFreq.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - hullDrone.setBaseFreq(val); - valHullFreq.textContent = `${val} Hz`; - }); - sliderHullCutoff.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - hullDrone.setFilterCutoff(val); - valHullCutoff.textContent = `${val} Hz`; - }); - - sliderWarpVol.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - warpCore.setVolume(val); - valWarpVol.textContent = `${Math.round(val * 100)}%`; - }); - sliderWarpBpm.addEventListener('input', (e) => { - const val = parseInt(e.target.value); - warpCore.setBpm(val); - valWarpBpm.textContent = `${val} BPM`; - }); - sliderWarpCarrier.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - warpCore.setCarrierFreq(val); - valWarpCarrier.textContent = `${val} Hz`; - statCoreFreq.textContent = `${val.toFixed(1)} Hz`; - }); - - sliderAirVol.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - lifeSupport.setVolume(val); - valAirVol.textContent = `${Math.round(val * 100)}%`; - }); - sliderAirCutoff.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - lifeSupport.setFilterCutoff(val); - valAirCutoff.textContent = `${val} Hz`; - }); - - sliderTelemetryVol.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - telemetry.setVolume(val); - valTelemetryVol.textContent = `${Math.round(val * 100)}%`; - }); - sliderTelemetryDensity.addEventListener('input', (e) => { - const val = parseFloat(e.target.value); - telemetry.setDensity(val); - valTelemetryDensity.textContent = `${Math.round(val * 100)}%`; - }); - - // 8. Universe Event Handlers - function resetAlertButtons() { - document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert')); - } - - function handleUniverseEvent(type, btnElement) { - switch (type) { - case 'warp': - case 'quantum': - alerts.synthesizeWarpJump(); - visualizer.spawnWarpPulses(); - break; - case 'alert-red': - case 'action-stations': - case 'scramble': - if (alerts.activeAlert === 'red') { - alerts.stopAlert(); - resetAlertButtons(); - } else { - resetAlertButtons(); - btnElement.classList.add('active-alert'); - alerts.triggerRedAlert('tng'); - } - break; - case 'alert-yellow': - if (alerts.activeAlert === 'yellow') { - alerts.stopAlert(); - resetAlertButtons(); - } else { - resetAlertButtons(); - btnElement.classList.add('active-alert'); - alerts.triggerYellowAlert(); - } - break; - case 'demat': - whoniverseAudio.synthesizeDematCycle(4); - visualizer.spawnWarpPulses(); - break; - case 'vortex': - whoniverseAudio.synthesizeDematCycle(1); - visualizer.spawnWarpPulses(); - break; - case 'cloister': - if (whoniverseAudio.activeCloister) { - whoniverseAudio.stopCloisterBell(); - resetAlertButtons(); - } else { - resetAlertButtons(); - btnElement.classList.add('active-alert'); - whoniverseAudio.triggerCloisterBell(); - } - break; - case 'epstein': - case 'subdrive': - expandedAudio.synthesizeEpsteinBurn(); - visualizer.spawnWarpPulses(); - break; - case 'purge': - case 'decompress': - case 'docking': - expandedAudio.synthesizeDockingClamp(); - break; - case 'vent': - whoniverseAudio.synthesizeFastReturn(); - break; - case 'starburst': - case 'biodefense': - expandedAudio.synthesizeStarburst(); - visualizer.spawnWarpPulses(); - break; - case 'neural': - whoniverseAudio.synthesizeTelepathicChime(); - break; - case 'centrifuge': - case 'astrogator': - expandedAudio.synthesizeRetroAstrogator(); - break; - case 'hal': - expandedAudio.synthesizeHalChime(); - break; - case 'ftl-jump': - case 'flak': - expandedAudio.synthesizeFtlJump(); - visualizer.spawnWarpPulses(); - break; - case 'singularity': - case 'gravity': - case 'rotation': - expandedAudio.synthesizeSingularityEngage(); - visualizer.spawnWarpPulses(); - break; - case 'solar': - alerts.synthesizeWarpJump(); - break; - case 'afterburner': - expandedAudio.synthesizeAfterburner(); - visualizer.spawnWarpPulses(); - break; - case 'ludicrous': - expandedAudio.synthesizeLudicrousSpeed(); - visualizer.spawnWarpPulses(); - break; - case 'improbability': - expandedAudio.synthesizeImprobabilityFlip(); - visualizer.spawnWarpPulses(); - break; - default: - telemetry.synthesizeLCARSSingleChirp(); - break; - } - } - - function handleSoundboardTrigger(id) { - switch (id) { - case 'btn-chirp-single': - case 'btn-switch-pop': - telemetry.synthesizeLCARSSingleChirp(); - break; - case 'btn-chirp-double': - telemetry.synthesizeLCARSDoubleChirp(); - break; - case 'btn-chirp-ack': - telemetry.synthesizeLCARSSequence(); - break; - case 'btn-chirp-sweep': - telemetry.synthesizeSensorSweep(); - break; - case 'btn-chirp-door': - telemetry.synthesizeDoorChime(); - break; - case 'btn-sonic': - whoniverseAudio.synthesizeSonicScrewdriver(); - break; - case 'btn-fast-return': - whoniverseAudio.synthesizeFastReturn(); - break; - case 'btn-demat-switch': - case 'btn-relay-click': - whoniverseAudio.synthesizeDematSwitch(); - break; - case 'btn-telepathic': - case 'btn-telepathic-chime': - whoniverseAudio.synthesizeTelepathicChime(); - break; - case 'btn-scanner': - whoniverseAudio.synthesizeDematSwitch(); - telemetry.synthesizeSensorSweep(); - break; - case 'btn-geiger': - expandedAudio.synthesizeGeigerBurst(); - break; - case 'btn-dock-clamp': - case 'btn-pneumatic-door': - case 'btn-tape-clunk': - case 'btn-air-handler': - expandedAudio.synthesizeDockingClamp(); - break; - case 'btn-dradis-ping': - expandedAudio.synthesizeDradisPing(); - break; - case 'btn-hal-chime': - expandedAudio.synthesizeHalChime(); - break; - case 'btn-cheerful-door': - expandedAudio.synthesizeCheerfulDoor(); - break; - case 'btn-beryllium-pulse': - case 'btn-tea-machine': - expandedAudio.synthesizeImprobabilityFlip(); - break; - case 'btn-tram-gong': - whoniverseAudio.synthesizeCloisterStrike(); - break; - default: - telemetry.synthesizeLCARSSingleChirp(); - break; - } - } - - // 9. Sleep Timer - timerButtons.forEach(btn => { - btn.addEventListener('click', () => { - const minutes = parseInt(btn.getAttribute('data-minutes')); - if (minutes === 0) { - audioManager.stopSleepTimer(); - timerDisplay.textContent = 'TIMER INACTIVE (CONTINUOUS)'; - } else { - audioManager.startSleepTimer( - minutes, - (secondsLeft) => { - const m = Math.floor(secondsLeft / 60); - const s = secondsLeft % 60; - timerDisplay.textContent = `SLEEP TIMER: ${m}:${s < 10 ? '0' : ''}${s}`; - }, - () => { - timerDisplay.textContent = 'SLEEP TIMER COMPLETED'; - if (isPlaying) togglePlay(); - } - ); - } - }); - }); - - - // ========================================================================= - // OBSERVATION MODE - // One passive full-screen view with theme-specific inline SVG displays. - // Deliberately exits only on an actual click/tap (or Escape). - // ========================================================================= - const btnObservation = document.getElementById('btn-observation'); - const observationOverlay = document.getElementById('observation-overlay'); - const observationStage = document.getElementById('observation-stage'); - const observationSimLayer = document.getElementById('observation-sim-layer'); - const observationUniverse = document.getElementById('observation-universe'); - const observationProfile = document.getElementById('observation-profile'); - const observationStatus = document.getElementById('observation-status'); - const observationEventLayer = document.getElementById('observation-event-layer'); - const sliderObservationActivity = document.getElementById('slider-observation-activity'); - const valObservationActivity = document.getElementById('val-observation-activity'); - const toggleObservationHud = document.getElementById('toggle-observation-hud'); - const valObservationHud = document.getElementById('val-observation-hud'); - const observationChrome = observationOverlay.querySelector('.observation-chrome'); - const observationReturn = observationOverlay.querySelector('.observation-return'); - - let observationActive = false; - let observationActivity = 0.60; - window.observationActivity = observationActivity; - let observationAmbientTimer = null; - let observationTransientSerial = 0; - - function obsClamp(v, lo, hi) { - return Math.max(lo, Math.min(hi, v)); - } - - function obsRand(min, max) { - return min + Math.random() * (max - min); - } - - function obsInt(min, max) { - return Math.floor(obsRand(min, max + 1)); - } - - function obsPick(items) { - return items[Math.floor(Math.random() * items.length)]; - } - - function obsHex(n, width = 3) { - return Math.floor(n).toString(16).toUpperCase().padStart(width, '0'); - } - - - // ========================================================================= - // PERSISTENT OBSERVATION SCENE ENGINE - // Original visual worlds, not franchise reproductions. Persistent entities - // use simple stateful motion rather than one-shot CSS decorations. - // ========================================================================= - let observationSceneRaf = null; - let observationSceneLast = 0; - let observationSceneEntities = []; - let observationSceneBoostUntil = 0; - let observationSplineMorphs = []; - - function obsEaseSmooth(t) { - return t * t * (3 - 2 * t); - } - - function obsEaseInCubic(t) { - return t * t * t; - } - - function obsEaseOutCubic(t) { - return 1 - Math.pow(1 - t, 3); - } - - function obsBezierPoint(t, p0, p1, p2, p3) { - const u = 1 - t; - const tt = t * t; - const uu = u * u; - const uuu = uu * u; - const ttt = tt * t; - return { - x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x, - y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y - }; - } - - function obsBezierDerivative(t, p0, p1, p2, p3) { - const u = 1 - t; - return { - x: 3*u*u*(p1.x-p0.x) + 6*u*t*(p2.x-p1.x) + 3*t*t*(p3.x-p2.x), - y: 3*u*u*(p1.y-p0.y) + 6*u*t*(p2.y-p1.y) + 3*t*t*(p3.y-p2.y) - }; - } - - - // ========================================================================= - // LONG-LIVED SPLINE MORPH ENGINE - // Applies only to persistent pathwork in the main observation stage. - // The goal is subtle recalculation/breathing, not chaotic wriggling. - // ========================================================================= - - function obsThemeSplineScale(theme) { - return ({ - starfleet: 0.60, - whoniverse: 1.05, - industrial: 0.48, - bioships: 1.28, - retrofuture: 0.72, - military: 0.44, - deepspace: 0.34, - outlaw: 0.86, - spacestations: 0.50, - comedy: 0.90 - })[theme] || 0.65; - } - - function obsTokenizePath(d) { - return (d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/g) || []); - } - - function obsParsePathSegments(d) { - const tokens = obsTokenizePath(d); - const segments = []; - let i = 0; - while (i < tokens.length) { - const cmd = tokens[i++]; - if (!/^[A-Za-z]$/.test(cmd)) break; - const upper = cmd.toUpperCase(); - - let count = 0; - if (upper === 'M' || upper === 'L' || upper === 'T') count = 2; - else if (upper === 'Q' || upper === 'S') count = 4; - else if (upper === 'C') count = 6; - else if (upper === 'H' || upper === 'V') count = 1; - else if (upper === 'Z') count = 0; - else count = 0; - - if (upper == 'Z') { - segments.push({ cmd, values: [] }); - continue; - } - - const values = []; - while (i < tokens.length && !/^[A-Za-z]$/.test(tokens[i])) { - values.push(Number(tokens[i++])); - } - if (!values.length) { - segments.push({ cmd, values: [] }); - continue; - } - - if (count == 0) { - segments.push({ cmd, values }); - continue; - } - - // Split repeated coordinate groups into separate segments. For repeated M, - // SVG treats the subsequent groups as implicit L commands. - for (let start = 0, segIdx = 0; start < values.length; start += count, segIdx++) { - const slice = values.slice(start, start + count); - if (slice.length < count) break; - const segCmd = (upper === 'M' && segIdx > 0) ? (cmd === upper ? 'L' : 'l') : cmd; - segments.push({ cmd: segCmd, values: slice }); - } - } - return segments; - } - - function obsBuildPathFromSegments(segments) { - return segments.map(seg => { - if (!seg.values || !seg.values.length) return seg.cmd; - const nums = seg.values.map(v => { - const n = Math.abs(v) < 1e-6 ? 0 : v; - return Number(n.toFixed(2)).toString(); - }).join(' '); - return `${seg.cmd}${nums}`; - }).join(' '); - } - - function obsCreateSplineMorphState(pathEl) { - if (!pathEl) return null; - const d = pathEl.getAttribute('d'); - if (!d || !/[CQST]/i.test(d)) return null; - - const segments = obsParsePathSegments(d); - if (!segments.length) return null; - - const bbox = pathEl.getBBox(); - const width = Math.max(80, bbox.width || 80); - const height = Math.max(80, bbox.height || 80); - const routeLike = pathEl.classList.contains('obs-route-wander'); - const themeScale = obsThemeSplineScale(activeUniverseId); - const kindScale = routeLike ? 1.0 : 0.7; - - let drawable = segments - .map((seg, idx) => ({ idx, upper: seg.cmd.toUpperCase(), count: seg.values.length })) - .filter(item => ['Q', 'T', 'S', 'C'].includes(item.upper)); - const lastDrawableIdx = drawable.length ? drawable[drawable.length - 1].idx : -1; - - const ampFractions = []; - segments.forEach((seg, segIdx) => { - const upper = seg.cmd.toUpperCase(); - let frac = []; - - if (upper === 'M' || upper === 'L') { - frac = new Array(seg.values.length).fill(0); - } else if (upper === 'C') { - frac = [1, 1, 1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18]; - } else if (upper === 'S') { - frac = [1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18]; - } else if (upper === 'Q') { - frac = [0.92, 0.92, segIdx === lastDrawableIdx ? 0 : 0.15, segIdx === lastDrawableIdx ? 0 : 0.15]; - } else if (upper === 'T') { - frac = [segIdx === lastDrawableIdx ? 0 : 0.14, segIdx === lastDrawableIdx ? 0 : 0.14]; - } else { - frac = new Array(seg.values.length).fill(0); - } - ampFractions.push(frac); - }); - - const offsets = segments.map(seg => new Array(seg.values.length).fill(0)); - const targets = segments.map(seg => new Array(seg.values.length).fill(0)); - - function amplitudeFor(segIndex, valueIndex) { - const seg = segments[segIndex]; - const frac = (ampFractions[segIndex] && ampFractions[segIndex][valueIndex]) || 0; - if (frac <= 0) return 0; - const upper = seg.cmd.toUpperCase(); - const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y'); - const span = axis === 'x' ? width : height; - const baseFrac = routeLike ? 0.038 : 0.026; - return span * baseFrac * frac * kindScale * themeScale; - } - - return { - el: pathEl, - baseD: d, - segments, - ampFractions, - offsets, - targets, - nextRetargetAt: performance.now() + obsRand(2200, 7800), - lastRetargetAt: performance.now(), - width, - height, - routeLike, - targetMultiplier: 1 - }; - } - - function initializeObservationSplineMorphs() { - observationSplineMorphs = []; - if (!observationStage) return; - - const paths = observationStage.querySelectorAll('path.obs-dashflow, path.obs-route-wander'); - paths.forEach(pathEl => { - const state = obsCreateSplineMorphState(pathEl); - if (state) observationSplineMorphs.push(state); - }); - } - - function stopObservationSplineMorphs() { - observationSplineMorphs.forEach(state => { - if (state.el && state.el.isConnected) state.el.setAttribute('d', state.baseD); - }); - observationSplineMorphs = []; - } - - function retargetObservationSpline(state, now, forceScale = 1) { - const activity = observationActivity; - state.targetMultiplier = forceScale; - state.lastRetargetAt = now; - - state.segments.forEach((seg, segIndex) => { - seg.values.forEach((_, valueIndex) => { - const amp = (function() { - const frac = (state.ampFractions[segIndex] && state.ampFractions[segIndex][valueIndex]) || 0; - if (frac <= 0) return 0; - const upper = seg.cmd.toUpperCase(); - const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y'); - const span = axis === 'x' ? state.width : state.height; - const baseFrac = state.routeLike ? 0.038 : 0.026; - return span * baseFrac * frac * (state.routeLike ? 1.0 : 0.7) * obsThemeSplineScale(activeUniverseId) * forceScale; - })(); - - if (amp <= 0) { - state.targets[segIndex][valueIndex] = 0; - return; - } - - const bias = state.routeLike ? 1.0 : 0.75; - state.targets[segIndex][valueIndex] = obsRand(-amp * bias, amp * bias); - }); - }); - - const slow = state.routeLike ? 32000 : 42000; - const fast = state.routeLike ? 8500 : 14000; - const next = slow - (slow - fast) * Math.pow(activity, 0.82); - state.nextRetargetAt = now + next * obsRand(0.70, 1.35); - } - - function updateObservationSplineMorphs(now, speedScale) { - if (!observationSplineMorphs.length) return; - - const themeScale = obsThemeSplineScale(activeUniverseId); - observationSplineMorphs = observationSplineMorphs.filter(state => state.el && state.el.isConnected); - - observationSplineMorphs.forEach(state => { - if (now >= state.nextRetargetAt) { - retargetObservationSpline(state, now, 1); - } - - const follow = (0.012 + observationActivity * 0.030 + Math.max(0, speedScale - 0.4) * 0.006) * themeScale; - - state.segments.forEach((seg, segIndex) => { - seg.values.forEach((baseValue, valueIndex) => { - const current = state.offsets[segIndex][valueIndex]; - const target = state.targets[segIndex][valueIndex]; - state.offsets[segIndex][valueIndex] = current + (target - current) * follow; - }); - }); - - const morphed = state.segments.map((seg, segIndex) => ({ - cmd: seg.cmd, - values: seg.values.map((baseValue, valueIndex) => baseValue + state.offsets[segIndex][valueIndex]) - })); - - state.el.setAttribute('d', obsBuildPathFromSegments(morphed)); - }); - } - - function pulseObservationSplineMorphs() { - if (!observationActive || !observationSplineMorphs.length) return; - - const candidates = observationSplineMorphs.filter(state => state.el && state.el.isConnected); - if (!candidates.length) return; - - const count = observationActivity > 0.72 && Math.random() < observationActivity ? 2 : 1; - const shuffled = [...candidates].sort(() => Math.random() - 0.5); - - for (let i = 0; i < Math.min(count, shuffled.length); i++) { - retargetObservationSpline(shuffled[i], performance.now(), 1.22 + observationActivity * 0.38); - } - } - - - function obsSceneShip(kind='scout', color='#dbeafe', accent='#38bdf8') { - if (kind === 'freighter') { - return ` - - - - - - - `; - } - if (kind === 'shuttle') { - return ` - - - - - `; - } - if (kind === 'tug') { - return ` - - - - - `; - } - return ` - - - - - `; - } - - function obsSceneDefs() { - return ` - - - - - - - - - `; - } - - function obsFlightMarkup(id, shipMarkup, cfg) { - return ` - - ${shipMarkup} - `; - } - - function obsOrbitMarkup(id, body, cfg) { - return ` - - ${body} - `; - } - - function obsFloatMarkup(id, body, cfg) { - return ` - - ${body} - `; - } - - function buildObservationSceneMarkup(theme) { - const defs = obsSceneDefs(); - - if (theme === 'starfleet') { - return `${defs} - - ${obsFlightMarkup('sf-flight-1', obsSceneShip('scout','#e0f2fe','#38bdf8'), { - x0:410,y0:640,x1:520,y1:520,x2:880,y2:245,x3:1110,y3:330,duration:26000,delay:900,mode:'arrival',scale0:.35,scale1:.75,minActivity:.15 - })} - ${obsFlightMarkup('sf-flight-2', obsSceneShip('shuttle','#fde68a','#f97316'), { - x0:1190,y0:600,x1:1030,y1:500,x2:680,y2:420,x3:510,y3:260,duration:31000,delay:4600,mode:'cruise',scale0:.28,scale1:.55,minActivity:.35 - })} - ${obsOrbitMarkup('sf-orbit-1', ``, { - cx:800,cy:455,rx:245,ry:155,duration:24000,phase:.2,scale0:.65,scale1:1.05,minActivity:.05 - })} - ${obsOrbitMarkup('sf-orbit-2', ``, { - cx:800,cy:455,rx:165,ry:245,duration:39000,phase:.62,scale0:.5,scale1:.9,minActivity:.5 - })} - `; - } - - if (theme === 'whoniverse') { - const glyph = (char,color,size) => `${char}`; - return `${defs} - ${obsOrbitMarkup('who-glyph-1', glyph('◎','#00e5ff',38), {cx:800,cy:455,rx:335,ry:160,duration:23000,phase:.1,scale0:.55,scale1:1.2,minActivity:.05})} - ${obsOrbitMarkup('who-glyph-2', glyph('∆','#d4af37',30), {cx:800,cy:455,rx:255,ry:280,duration:31000,phase:.42,scale0:.5,scale1:1,minActivity:.2})} - ${obsOrbitMarkup('who-glyph-3', glyph('∞','#00e5ff',28), {cx:800,cy:455,rx:420,ry:110,duration:39000,phase:.73,scale0:.4,scale1:1.15,minActivity:.45})} - ${obsFloatMarkup('who-fragment-1', ``, - {cx:480,cy:520,ampX:95,ampY:75,duration:17000,phase:.35,minActivity:.25})} - ${obsFloatMarkup('who-fragment-2', ``, - {cx:1110,cy:330,ampX:70,ampY:110,duration:21000,phase:.8,minActivity:.55})}`; - } - - if (theme === 'industrial') { - // v13co: the sim layer sits ABOVE the stage (z-index 2 vs 1), so anything - // placed here draws over the camera console. Every entity is therefore - // parented to a clip matching one feed's picture area. The v12 entities - // (a wheeled cart, a crane hook, a fan and two steam blobs) were free - // floating in screen space, which is what made the smoke look detached - // and put a rolling "cart" across the cargo bay. They are replaced by - // motion that belongs to a specific feed. - const feedClip = (id, x, y) => - ``; - return `${defs} - - ${feedClip('indSimCam1', 290, 259)} - ${feedClip('indSimCam2', 825, 259)} - ${feedClip('indSimCam3', 290, 483)} - ${feedClip('indSimCam4', 825, 483)} - - - ${obsFloatMarkup('ind-bay-drone', ` - - - - - `, {cx:532,cy:420,ampX:186,ampY:11,duration:31000,phase:.15,minActivity:.2,opacity:.66})} - - - ${obsFloatMarkup('ind-ember-1', ``, - {cx:1035,cy:352,ampX:38,ampY:52,duration:13000,phase:.2,minActivity:.25,opacity:.6})} - ${obsFloatMarkup('ind-ember-2', ``, - {cx:1092,cy:320,ampX:46,ampY:44,duration:17000,phase:.62,minActivity:.45,opacity:.5})} - ${obsFloatMarkup('ind-ember-3', ``, - {cx:1160,cy:372,ampX:30,ampY:40,duration:21000,phase:.85,minActivity:.6,opacity:.42})} - - - ${obsFloatMarkup('ind-lock-tell', ` - `, - {cx:532,cy:640,ampX:0,ampY:13,duration:23000,phase:.3,minActivity:.5,opacity:.4})} - - - ${obsFloatMarkup('ind-corridor-lamp', ` - `, - {cx:1067,cy:600,ampX:118,ampY:26,duration:27000,phase:.4,minActivity:.3,opacity:.55})} - `; - } - - if (theme === 'bioships') { - const particle = (color,r=5) => ``; - return `${defs} - ${obsFlightMarkup('bio-flow-1', particle('#a7f3d0',5), {x0:300,y0:520,x1:520,y1:220,x2:760,y2:680,x3:1110,y3:350,duration:15000,delay:0,mode:'cruise',scale0:.7,scale1:1,minActivity:.05,opacity:.7})} - ${obsFlightMarkup('bio-flow-2', particle('#10b981',4), {x0:1240,y0:610,x1:970,y1:430,x2:880,y2:720,x3:480,y3:390,duration:18000,delay:1600,mode:'cruise',scale0:.5,scale1:.9,minActivity:.15,opacity:.65})} - ${obsFlightMarkup('bio-flow-3', particle('#8b5cf6',6), {x0:340,y0:290,x1:620,y1:570,x2:920,y2:180,x3:1300,y3:510,duration:22000,delay:4200,mode:'cruise',scale0:.55,scale1:1.05,minActivity:.35,opacity:.58})} - ${obsOrbitMarkup('bio-node-1', ``, {cx:800,cy:470,rx:210,ry:125,duration:19000,phase:.3,scale0:.65,scale1:1.2,minActivity:.3})} - ${obsFloatMarkup('bio-membrane', ``, - {cx:800,cy:470,ampX:26,ampY:18,duration:13000,phase:.1,minActivity:.55,opacity:.6})}`; - } - - if (theme === 'retrofuture') { - const vectorShip = ``; - return `${defs} - ${obsFlightMarkup('retro-v1', vectorShip, {x0:350,y0:610,x1:510,y1:430,x2:950,y2:330,x3:1210,y3:500,duration:25000,delay:0,mode:'cruise',scale0:.5,scale1:.8,minActivity:.1,opacity:.65})} - ${obsFlightMarkup('retro-v2', vectorShip, {x0:1180,y0:260,x1:1010,y1:570,x2:610,y2:610,x3:430,y3:330,duration:33000,delay:4300,mode:'cruise',scale0:.35,scale1:.65,minActivity:.45,opacity:.52})} - ${obsOrbitMarkup('retro-blip', ``, {cx:800,cy:460,rx:235,ry:235,duration:30000,phase:.15,scale0:.55,scale1:.95,minActivity:.05})} - ${obsFloatMarkup('retro-liss', ``, - {cx:800,cy:460,ampX:45,ampY:25,duration:14000,phase:.42,minActivity:.7,opacity:.5})}`; - } - - if (theme === 'military') { - const tri = ``; - return `${defs} - ${obsFlightMarkup('mil-form', ` - - ${tri} - ${tri} - ${tri} - ${tri} - `, {x0:390,y0:620,x1:550,y1:410,x2:900,y2:290,x3:1160,y3:390,duration:28000,delay:800,mode:'cruise',scale0:.4,scale1:.7,minActivity:.15,opacity:.65})} - ${obsFlightMarkup('mil-hostile', ``, - {x0:1190,y0:650,x1:1020,y1:520,x2:660,y2:430,x3:470,y3:240,duration:22000,delay:6200,mode:'arrival',scale0:.35,scale1:.6,minActivity:.45,opacity:.58})} - ${obsOrbitMarkup('mil-cap', ``, - {cx:800,cy:470,rx:300,ry:185,duration:41000,phase:.5,scale0:.45,scale1:.75,minActivity:.65,opacity:.52})}`; - } - - if (theme === 'deepspace') { - return `${defs} - ${obsFlightMarkup('deep-ship', obsSceneShip('shuttle','#e0e7ff','#818cf8'), { - x0:230,y0:570,x1:520,y1:500,x2:900,y2:350,x3:1370,y3:300,duration:52000,delay:0,mode:'cruise',scale0:.12,scale1:.28,minActivity:.2,opacity:.6 - })} - ${obsFlightMarkup('deep-comet', ``, { - x0:180,y0:700,x1:480,y1:520,x2:930,y2:230,x3:1490,y3:150,duration:36000,delay:7500,mode:'cruise',scale0:.55,scale1:.9,minActivity:.5,opacity:.52 - })} - ${obsFloatMarkup('deep-planet', ` - - - - - `, {cx:1200,cy:590,ampX:80,ampY:24,duration:95000,phase:.22,minActivity:.05,opacity:.55})}`; - } - - if (theme === 'outlaw') { - return `${defs} - ${obsFlightMarkup('out-runner', ` - - - - - `, {x0:300,y0:610,x1:480,y1:430,x2:940,y2:300,x3:1290,y3:410,duration:31000,delay:1400,mode:'cruise',scale0:.35,scale1:.75,minActivity:.1,opacity:.62})} - ${obsFlightMarkup('out-pursuer', obsSceneShip('scout','#f9a8d4','#8b5cf6'), { - x0:1280,y0:590,x1:1070,y1:520,x2:730,y2:520,x3:420,y3:310,duration:27000,delay:7900,mode:'arrival',scale0:.25,scale1:.52,minActivity:.5,opacity:.5 - })} - ${obsFloatMarkup('out-needle', ` - - - - - `, {cx:1250,cy:535,ampX:8,ampY:4,duration:9000,phase:.2,minActivity:.25,opacity:.58})}`; - } - - if (theme === 'spacestations') { - const clipOpen = ``; - const clipClose = ``; - return `${defs}${clipOpen} - ${obsFlightMarkup('st-arrival', obsSceneShip('shuttle','#e0f2fe','#38bdf8'), { - x0:270,y0:520,x1:500,y1:455,x2:920,y2:380,x3:1195,y3:415,duration:36000,delay:0,mode:'arrival',scale0:.16,scale1:.62,minActivity:.05,opacity:.75 - })} - ${obsFlightMarkup('st-depart', obsSceneShip('tug','#bae6fd','#f97316'), { - x0:1180,y0:485,x1:1030,y1:430,x2:650,y2:310,x3:260,y3:255,duration:30000,delay:8400,mode:'depart',scale0:.58,scale1:.16,minActivity:.2,opacity:.68 - })} - ${obsFlightMarkup('st-freighter', obsSceneShip('freighter','#cbd5e1','#0ea5e9'), { - x0:185,y0:255,x1:530,y1:210,x2:920,y2:230,x3:1420,y3:300,duration:52000,delay:3800,mode:'cruise',scale0:.26,scale1:.42,minActivity:.4,opacity:.55 - })} - ${obsOrbitMarkup('st-hold', obsSceneShip('shuttle','#7dd3fc','#fdba74'), { - cx:800,cy:410,rx:290,ry:145,duration:43000,phase:.18,scale0:.18,scale1:.32,minActivity:.62,opacity:.5 - })} - ${obsFlightMarkup('st-nearpass', obsSceneShip('freighter','#f1f5f9','#38bdf8'), { - x0:170,y0:650,x1:470,y1:565,x2:950,y2:535,x3:1450,y3:600,duration:42000,delay:14500,mode:'cruise',scale0:.42,scale1:.78,minActivity:.78,opacity:.48 - })} - ${clipClose}`; - } - - if (theme === 'comedy') { - return `${defs} - ${obsFlightMarkup('com-tour', ` - - - - - `, {x0:260,y0:600,x1:520,y1:255,x2:1030,y2:670,x3:1350,y3:330,duration:36000,delay:1200,mode:'cruise',scale0:.32,scale1:.62,minActivity:.08,opacity:.65})} - ${obsOrbitMarkup('com-orbit', `?`, - {cx:800,cy:445,rx:330,ry:160,duration:33000,phase:.35,scale0:.65,scale1:1.15,minActivity:.45,opacity:.58})} - ${obsFloatMarkup('com-cube', ``, - {cx:1120,cy:570,ampX:90,ampY:70,duration:18000,phase:.7,minActivity:.7,opacity:.52})}`; - } - - return defs; - } - - function registerObservationSceneEntities() { - observationSceneEntities = []; - if (!observationSimLayer) return; - - observationSimLayer.querySelectorAll('[data-obs-kind]').forEach(el => { - const d = el.dataset; - const sceneNow = performance.now(); - const common = { - el, - kind: d.obsKind, - minActivity: Number(d.minactivity || 0), - baseOpacity: Number(d.opacity || .7), - phase: Number(d.phase || 0), - startedAt: sceneNow, - inactiveUntil: sceneNow - }; - - if (d.obsKind === 'flight') { - const firstStart = sceneNow + Number(d.delay || 0); - observationSceneEntities.push({ - ...common, - startedAt: firstStart, - inactiveUntil: firstStart, - p0:{x:Number(d.x0),y:Number(d.y0)}, - p1:{x:Number(d.x1),y:Number(d.y1)}, - p2:{x:Number(d.x2),y:Number(d.y2)}, - p3:{x:Number(d.x3),y:Number(d.y3)}, - duration:Number(d.duration || 30000), - mode:d.mode || 'cruise', - scale0:Number(d.scale0 || 1), - scale1:Number(d.scale1 || 1), - cycle:0 - }); - } else if (d.obsKind === 'orbit') { - observationSceneEntities.push({ - ...common, - cx:Number(d.cx),cy:Number(d.cy), - rx:Number(d.rx),ry:Number(d.ry), - duration:Number(d.duration || 30000), - scale0:Number(d.scale0 || 1), - scale1:Number(d.scale1 || 1) - }); - } else if (d.obsKind === 'float') { - observationSceneEntities.push({ - ...common, - cx:Number(d.cx),cy:Number(d.cy), - ampX:Number(d.ampx || 0),ampY:Number(d.ampy || 0), - duration:Number(d.duration || 20000) - }); - } - }); - } - - function observationSceneResetFlight(entity, now) { - entity.cycle += 1; - const restBase = 5500 - observationActivity * 3500; - entity.inactiveUntil = now + obsRand(restBase * .35, restBase * 1.35); - entity.startedAt = entity.inactiveUntil; - } - - function updateObservationSceneEntity(entity, now, speedScale) { - const visibleByActivity = observationActivity + .001 >= entity.minActivity; - if (!visibleByActivity) { - entity.el.style.opacity = '0'; - return; - } - - if (entity.kind === 'flight') { - if (now < entity.inactiveUntil) { - entity.el.style.opacity = '0'; - return; - } - - const elapsed = (now - entity.startedAt) * speedScale; - let raw = elapsed / entity.duration; - - if (raw >= 1) { - observationSceneResetFlight(entity, now); - entity.el.style.opacity = '0'; - return; - } - - raw = obsClamp(raw, 0, 1); - let t = raw; - if (entity.mode === 'arrival') t = obsEaseOutCubic(raw); - else if (entity.mode === 'depart') t = obsEaseInCubic(raw); - else t = obsEaseSmooth(raw); - - const p = obsBezierPoint(t, entity.p0, entity.p1, entity.p2, entity.p3); - const v = obsBezierDerivative(t, entity.p0, entity.p1, entity.p2, entity.p3); - const angle = Math.atan2(v.y, v.x) * 180 / Math.PI; - const scale = entity.scale0 + (entity.scale1 - entity.scale0) * t; - - const edgeFade = Math.min(1, raw / .08, (1-raw) / .08); - const opacity = entity.baseOpacity * obsClamp(edgeFade,0,1); - entity.el.style.opacity = String(opacity); - entity.el.setAttribute('transform', `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${angle.toFixed(2)}) scale(${scale.toFixed(3)})`); - return; - } - - if (entity.kind === 'orbit') { - const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) % 1; - const a = cycle * Math.PI * 2; - const x = entity.cx + Math.cos(a) * entity.rx; - const y = entity.cy + Math.sin(a) * entity.ry; - const depth = (Math.sin(a) + 1) / 2; - const scale = entity.scale0 + (entity.scale1 - entity.scale0) * depth; - entity.el.style.opacity = String(entity.baseOpacity * (.55 + depth * .45)); - entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(3)})`); - return; - } - - if (entity.kind === 'float') { - const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) * Math.PI * 2; - const x = entity.cx + Math.sin(cycle) * entity.ampX; - const y = entity.cy + Math.cos(cycle * .73) * entity.ampY; - entity.el.style.opacity = String(entity.baseOpacity); - entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)})`); - } - } - - function observationSceneFrame(now) { - observationSceneRaf = null; - if (!observationActive || !observationSimLayer) return; - - const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - const baseSpeed = reduceMotion ? .08 : (.32 + observationActivity * 1.18); - const boost = now < observationSceneBoostUntil ? .38 : 0; - const speedScale = baseSpeed + boost; - - for (const entity of observationSceneEntities) { - updateObservationSceneEntity(entity, now, speedScale); - } - - updateObservationSplineMorphs(now, speedScale); - - observationSceneLast = now; - observationSceneRaf = requestAnimationFrame(observationSceneFrame); - } - - function startObservationScene() { - stopObservationScene(); - if (!observationSimLayer) return; - observationSimLayer.innerHTML = ``; - registerObservationSceneEntities(); - observationSceneLast = performance.now(); - observationSceneRaf = requestAnimationFrame(observationSceneFrame); - } - - function stopObservationScene() { - if (observationSceneRaf) { - cancelAnimationFrame(observationSceneRaf); - observationSceneRaf = null; - } - observationSceneEntities = []; - if (observationSimLayer) observationSimLayer.innerHTML = ''; - } - - function pulseObservationScene() { - if (!observationActive) return; - observationSceneBoostUntil = performance.now() + 1200 + observationActivity * 900; - - const eligible = observationSceneEntities.filter(e => observationActivity + .001 >= e.minActivity); - if (!eligible.length) return; - const chosen = obsPick(eligible); - - // Telemetry can wake a waiting scene object early without assigning the - // sound a literal fictional meaning. - if (chosen.kind === 'flight' && performance.now() < chosen.inactiveUntil && Math.random() < .55) { - const wakeNow = performance.now(); - chosen.inactiveUntil = wakeNow; - chosen.startedAt = wakeNow; - } - } - - - function transientSvg(body) { - return ``; - } - - function spawnObservationTransient(body, lifetime = 4200) { - if (!observationActive || !observationEventLayer) return; - const node = document.createElement('div'); - node.className = 'obs-transient'; - node.dataset.serial = String(++observationTransientSerial); - node.style.setProperty('--obs-life', `${lifetime}ms`); - node.innerHTML = transientSvg(body); - observationEventLayer.appendChild(node); - - const maxTransient = 5 + Math.round(observationActivity * 8); - while (observationEventLayer.children.length > maxTransient) { - observationEventLayer.firstElementChild.remove(); - } - - window.setTimeout(() => node.remove(), lifetime + 300); - } - - function obsTextCard(x, y, title, line, color = 'var(--primary-accent)', width = 290) { - return ` - - - - - ${title} - ${line} - - `; - } - - function obsExhibitX(width = 290) { - const leftMargin = 240; - const rightMargin = 240; - return obsInt(leftMargin, 1600 - rightMargin - width); - } - - function obsExhibitY(height = 82) { - const topMargin = 220; - const bottomMargin = 190; - return obsInt(topMargin, 900 - bottomMargin - height); - } - - function obsExhibitCardPosition(width = 290, height = 82) { - return { x: obsExhibitX(width), y: obsExhibitY(height) }; - } - - function obsContact(x, y, color = 'var(--primary-accent)', label = 'CONTACT') { - const size = obsInt(26, 54); - return ` - - - - - - ${label} - - `; - } - - function obsExpandingRing(x, y, color = 'var(--primary-accent)', radius = 70) { - return ` - - - - `; - } - - function generateStarfleetActivity(power) { - const effect = obsPick(['contact','vector','data','ring','diagnostic','streak']); - if (effect === 'contact') { - const x = obsInt(420,1180), y = obsInt(230,690); - return spawnObservationTransient( - obsContact(x,y,'var(--primary-accent)',`CONTACT ${obsInt(2,99).toString().padStart(2,'0')}`) + - ``, - 4300 - ); - } - if (effect === 'vector') { - const y1=obsInt(260,680), y2=obsInt(220,690); - return spawnObservationTransient(` - - - `, 5200); - } - if (effect === 'data') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x, pos.y, - obsPick(['SENSOR UPDATE','NAV SOLUTION','SYSTEM QUERY','PASSIVE ARRAY']), - obsPick([ - `BEARING ${obsInt(0,359).toString().padStart(3,'0')}.${obsInt(0,9)}°`, - `RANGE ${obsInt(2,980)}.${obsInt(0,9)} Mm`, - `SUBSPACE VAR ${obsRand(.01,.99).toFixed(3)}`, - `VECTOR ${obsInt(100,999)}-${obsInt(10,99)}` - ]) - ), 4200); - } - if (effect === 'diagnostic') { - const bars = Array.from({length: obsInt(5,9)}, (_,i) => - ``).join(''); - return spawnObservationTransient(`${bars}`, 3500); - } - if (effect === 'streak') { - const lines = Array.from({length: obsInt(8,16)}, () => { - const y=obsInt(160,760), x=obsInt(150,1300), len=obsInt(70,240); - return ``; - }).join(''); - return spawnObservationTransient(`${lines}`, 3000); - } - return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(250,680),'var(--primary-accent)',obsInt(45,90)), 3200); - } - - function generateWhoniverseActivity(power) { - const effect = obsPick(['echo','glyphs','coordinate','warp','rings']); - if (effect === 'echo') { - const x=obsInt(430,1170), y=obsInt(270,650); - return spawnObservationTransient(` - ${[0,1,2].map(i=>` - - `).join('')} - `, 4700); - } - if (effect === 'glyphs') { - const glyphs = Array.from({length: obsInt(4,9)}, (_,i) => - `${obsPick(['◉','◎','⌁','∆','∴','∞','⊙','⊛'])}` - ).join(''); - return spawnObservationTransient(glyphs, 6500); - } - if (effect === 'coordinate') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x, pos.y, 'TEMPORAL COORDINATE', - `${obsInt(0,99)}:${obsInt(0,99)}:${obsInt(0,99)} ∴ ${obsInt(1,13)}.${obsInt(0,9)}`, '#00e5ff' - ), 4300); - } - if (effect === 'warp') { - return spawnObservationTransient(` - - - - - - `, 4400); - } - return spawnObservationTransient(obsExpandingRing(800,450,obsPick(['#00e5ff','#d4af37']),obsInt(60,120)), 3500); - } - - function generateIndustrialActivity(power) { - // v13co: every industrial transient is clipped to ONE feed's picture area. - // In v12 these drew in raw screen space, so steam puffs and signal bars - // spilled across bezels and the console furniture. - const feeds = [ - {ix:290, iy:259, id:'CAM 01', where:'CARGO BAY 2'}, - {ix:825, iy:259, id:'CAM 02', where:'REACTOR ACCESS'}, - {ix:290, iy:483, id:'CAM 03', where:'AIRLOCK C'}, - {ix:825, iy:483, id:'CAM 04', where:'MACHINE CORRIDOR'} - ]; - const f = obsPick(feeds); - const W = 485, H = 189; - const clipId = `indFx-${Math.random().toString(36).slice(2, 9)}`; - const inFeed = (body) => ` - - - - - - - - - ${body}`; - - const canCycle = (f.id === 'CAM 01' || f.id === 'CAM 03'); - const effect = obsPick(['signal', 'dropout', 'vapour', 'motion', 'gain', 'warning'] - .concat(canCycle ? ['door'] : [])); - - // Digital break-up: displaced scan blocks plus a recalibration caption. - if (effect === 'signal') { - const blocks = Array.from({length: obsInt(9, 15)}, () => { - const by = obsInt(0, H - 14); - return ``; - }).join(''); - return spawnObservationTransient(inFeed(` - - ${blocks} - - SIGNAL RECALIBRATION - `), 3600); - } - - // Total feed loss, then reacquire. - if (effect === 'dropout') { - return spawnObservationTransient(inFeed(` - - - NO SIGNAL - ${f.id} // REACQUIRING - - `), 2600); - } - - // A vent release inside the room the camera is actually looking at. - if (effect === 'vapour') { - const x = obsInt(90, W - 90), y = H - obsInt(16, 40); - const puffs = Array.from({length: obsInt(6, 11)}, (_, i) => { - const dur = obsRand(2.8, 5.2).toFixed(1); - return ``; - }).join(''); - return spawnObservationTransient(inFeed(` - ${puffs} - - `), 6200); - } - - // Motion detection bracket that tracks across the picture. - if (effect === 'motion') { - const bx = obsInt(40, W - 190), by = obsInt(46, H - 130); - return spawnObservationTransient(inFeed(` - - - - - - - - MOTION // ${f.id} - `), 4600); - } - - // Pressure-door cycle seen head on. - if (effect === 'door') { - const mid = W / 2, top = 58, bot = H - 52; - return spawnObservationTransient(inFeed(` - - - - - - - - - - - HATCH CYCLE // ${f.where} - `), 4800); - } - - // Auto-iris hunting after a lighting change. - if (effect === 'gain') { - return spawnObservationTransient(inFeed(` - - AGC // IRIS ADJUST - `), 2400); - } - - // Plant readout card — stays on the console rail, not over a feed. - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x, pos.y, - obsPick(['PRESSURE TRANSIENT', 'COOLANT LOOP', 'MAINTENANCE BUS', 'LOCAL POWER', 'GANTRY INTERLOCK']), - obsPick([ - `P-${obsInt(1, 9)} ${obsInt(30, 98)}%`, - `FLOW ${obsInt(120, 980)} L/M`, - `BUS ${obsHex(obsInt(100, 999))} NOMINAL`, - `VALVE ${obsInt(1, 44)} CYCLING`, - `HOIST ${obsInt(1, 4)} LOAD ${obsRand(.4, 4.2).toFixed(1)} T` - ]), - '#ffaa00' - ), 4500); - } - - function generateBioshipActivity(power) { - const effect = obsPick(['neural','spores','ripple','tendril','organ','metric']); - if (effect === 'neural') { - const y1=obsInt(260,650), y2=obsInt(260,650); - return spawnObservationTransient(` - - `, 3300); - } - if (effect === 'spores') { - const spores=Array.from({length:obsInt(10,24)},()=>` - `).join(''); - return spawnObservationTransient(spores, 7000); - } - if (effect === 'tendril') { - const sx=obsPick([180,1420]), sy=obsInt(300,660); - return spawnObservationTransient(` - - `, 5200); - } - if (effect === 'organ') { - return spawnObservationTransient(` - - - - - - - `, 5000); - } - if (effect === 'metric') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x, pos.y, obsPick(['NEURAL RESPONSE','METABOLIC FIELD','SYMBIOTIC VARIANCE','BIOELECTRIC FLUX']), - `${obsRand(0.1,9.9).toFixed(2)} // ${obsInt(20,99)}%`, '#10b981' - ), 4300); - } - return spawnObservationTransient(obsExpandingRing(800,470,obsPick(['#10b981','#8b5cf6','#a7f3d0']),obsInt(55,110)), 3700); - } - - function generateRetroActivity(power) { - const effect = obsPick(['blip','scope','counter','vector','bloom','reel']); - if (effect === 'blip') { - return spawnObservationTransient( - obsContact(obsInt(470,1130),obsInt(260,660),'#86efac',`OBJ ${obsInt(1,99)}`), 4300 - ); - } - if (effect === 'scope') { - const y=obsInt(560,720); - return spawnObservationTransient(` - - `, 4500); - } - if (effect === 'counter') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x, pos.y, obsPick(['ASTROGATOR','VECTOR MEMORY','TAPE INDEX','OBJECT FILE']), - `${obsInt(0,9999).toString().padStart(4,'0')} / ${obsInt(0,9999).toString().padStart(4,'0')}`,'#22c55e' - ),4300); - } - if (effect === 'reel') { - return spawnObservationTransient(` - - - - `, 4800); - } - if (effect === 'bloom') { - return spawnObservationTransient(obsExpandingRing(obsInt(480,1120),obsInt(270,650),'#86efac',obsInt(35,75)),3000); - } - return spawnObservationTransient(` - - `, 5200); - } - - function generateMilitaryActivity(power) { - const effect = obsPick(['contact','intercept','formation','sector','status','sweep']); - if (effect === 'contact') { - const pos = obsExhibitCardPosition(320, 82); - return spawnObservationTransient( - obsContact(obsInt(430,1170),obsInt(240,680),'#eab308',obsPick(['UNKNOWN','TRACK','CONTACT','RETURN'])) + - obsTextCard(pos.x,pos.y,'TACTICAL UPDATE',`TRACK ${obsHex(obsInt(1,4095))} // ${obsInt(2,98)}%`,'#eab308',320), - 4600 - ); - } - if (effect === 'intercept') { - const x=obsInt(450,1150),y=obsInt(250,650); - return spawnObservationTransient(` - - ${obsContact(x,y,'#ef4444','VECTOR')} - `, 4700); - } - if (effect === 'formation') { - const cx=obsInt(600,1000), cy=obsInt(340,600); - return spawnObservationTransient(` - - - - - - - - `, 5200); - } - if (effect === 'sector') { - return spawnObservationTransient(` - - `, 3800); - } - if (effect === 'status') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x,pos.y,obsPick(['TACTICAL ARRAY','TRACK CORRELATION','FORMATION UPDATE','IFF QUERY']), - obsPick([`SECTOR ${obsInt(1,12)} / CLEAR`,`RETURN ${obsHex(obsInt(1,4095))}`,`VECTOR ${obsInt(0,359)}.${obsInt(0,9)}°`,`CONF ${obsInt(34,99)}%`]),'#eab308' - ),4200); - } - return spawnObservationTransient(obsExpandingRing(800,470,'#eab308',obsInt(65,130)),3500); - } - - function generateDeepSpaceActivity(power) { - const effect = obsPick(['anomaly','comet','lens','spectral','planet','signal']); - if (effect === 'anomaly') { - const x=obsInt(430,1180), y=obsInt(230,650); - return spawnObservationTransient(` - - - - - - - `, 6500); - } - if (effect === 'comet') { - return spawnObservationTransient(` - - - - `, 5200); - } - if (effect === 'lens') { - return spawnObservationTransient(` - - - - `, 6800); - } - if (effect === 'planet') { - const y=obsInt(260,620), r=obsInt(45,110); - return spawnObservationTransient(` - - - - - - `, 10000); - } - if (effect === 'spectral') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x,pos.y,obsPick(['SPECTRAL EVENT','GRAVITIC VARIANCE','DEEP FIELD RETURN','OPTICAL TRANSIENT']), - `${obsRand(.001,9.999).toFixed(3)} / ${obsInt(1,999)} ly`,'#818cf8' - ),4800); - } - return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(240,660),'#38bdf8',obsInt(45,95)),4000); - } - - function generateOutlawActivity(power) { - const effect = obsPick(['glitch','route','contact','signal','gauge','rear']); - if (effect === 'glitch') { - const bars=Array.from({length:obsInt(8,18)},()=>` - `).join(''); - return spawnObservationTransient(`${bars}`,3000); - } - if (effect === 'route') { - return spawnObservationTransient(` - - ROUTE RECALC... - `,5600); - } - if (effect === 'contact' || effect === 'rear') { - return spawnObservationTransient(obsContact( - obsInt(430,1170),obsInt(240,670),obsPick(['#ec4899','#f59e0b']),'UNKNOWN' - ),4200); - } - if (effect === 'signal') { - const pos = obsExhibitCardPosition(290, 82); - return spawnObservationTransient(obsTextCard( - pos.x,pos.y,obsPick(['TRANSPONDER HIT','SIGNAL QUALITY','NAV PATCH','RADIO BURST']), - `${obsInt(9,99)}% // ${obsHex(obsInt(1,4095))}`,'#ec4899' - ),4300); - } - return spawnObservationTransient(` - - - - `,4500); - } - - function generateStationActivity(power) { - const effect = obsPick(['dock','depart','guidance','traffic','beacon','queue']); - if (effect === 'dock') { - const y = obsInt(250, 520); - return spawnObservationTransient(` - - - - DOCK VECTOR ${obsInt(1,18)} - `, 6200); - } - if (effect === 'depart') { - const y = obsInt(235, 540); - return spawnObservationTransient(` - - - - - - - - - `, 7000); - } - if (effect === 'guidance') { - const x = obsInt(470, 1100), y = obsInt(230, 570); - return spawnObservationTransient(` - - - - - `, 4600); - } - if (effect === 'beacon') { - const x = obsPick([248, 1352]); - const y = obsPick([168, 648]); - return spawnObservationTransient(` - - - - `, 3600); - } - if (effect === 'queue') { - const pos = obsExhibitCardPosition(310, 82); - return spawnObservationTransient(obsTextCard( - pos.x, pos.y, - obsPick(['TRAFFIC CONTROL','DOCK STATUS','PORT AUTHORITY','BERTH SCHEDULER']), - obsPick([ - `QUEUE ${obsInt(2,18)} // CLEARANCE`, - `BAY ${obsInt(1,24)} // ARRIVAL WINDOW`, - `HOLD ARC ${obsRand(0.8,9.9).toFixed(1)} // ROUTING`, - `${obsInt(2,84)} OBJECTS // PORT FLOW` - ]), - '#38bdf8', 310 - ), 4600); - } - const x = obsInt(330, 1270), y = obsInt(205, 610); - return spawnObservationTransient(` - - TRAFFIC FLOW ${obsInt(1,9)} - `, 5200); - } - - function generateComedyActivity(power) { - const effect = obsPick(['route','oddity','planet','status','contact','geometry']); - if (effect === 'route') { - return spawnObservationTransient(` - - `,6000); - } - if (effect === 'oddity') { - const pos = obsExhibitCardPosition(330, 82); - return spawnObservationTransient(obsTextCard( - pos.x,pos.y, - obsPick(['NAVIGATION UPDATE','ENVIRONMENTAL NOTE','SERVICE MESSAGE','SENSOR CONCLUSION']), - obsPick([ - `ROUTE CONFIDENCE ${obsRand(96,103).toFixed(1)}%`, - `TEA PROBABILITY ${obsInt(12,99)}%`, - `MOSTLY NOMINAL`, - `OBJECT: PROBABLY HARMLESS`, - `PANIC INDEX ${obsInt(0,3)} / 10`, - `SCENIC DETOUR +${obsInt(2,48)} MIN` - ]),'#06b6d4',330 - ),5000); - } - if (effect === 'planet') { - return spawnObservationTransient(` - - - - - - `,9200); - } - if (effect === 'geometry') { - const sides=obsPick([3,4,5,6]); - const r=obsInt(55,110), cx=obsInt(500,1100),cy=obsInt(300,620); - const pts=Array.from({length:sides},(_,i)=>{ - const a=-Math.PI/2+i*Math.PI*2/sides; - return `${(cx+Math.cos(a)*r).toFixed(1)},${(cy+Math.sin(a)*r).toFixed(1)}`; - }).join(' '); - return spawnObservationTransient(``,5200); - } - if (effect === 'contact') { - return spawnObservationTransient(obsContact(obsInt(450,1150),obsInt(250,650),'#67e8f9',obsPick(['FRIENDLY?','THING','OBJECT','PROBABLY FINE'])),4300); - } - return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500); - } - - function triggerObservationActivity(source = 'ambient') { - if (!observationActive || observationActivity <= 0.001) return; - - // Telemetry is a synchronization pulse, but the slider controls whether - // that pulse becomes visible. At maximum every telemetry pulse produces - // a visible response; at low levels many pass quietly. - if (source === 'telemetry') { - const responseChance = 0.18 + observationActivity * 0.82; - if (Math.random() > responseChance) return; - } - - let count = 1; - if (source === 'telemetry' && observationActivity > .55 && Math.random() < observationActivity) count++; - if (observationActivity > .82 && Math.random() < (observationActivity - .7)) count++; - - const generator = { - starfleet: generateStarfleetActivity, - whoniverse: generateWhoniverseActivity, - industrial: generateIndustrialActivity, - bioships: generateBioshipActivity, - retrofuture: generateRetroActivity, - military: generateMilitaryActivity, - deepspace: generateDeepSpaceActivity, - outlaw: generateOutlawActivity, - spacestations: generateStationActivity, - comedy: generateComedyActivity - }[activeUniverseId] || generateStarfleetActivity; - - for (let i = 0; i < count; i++) { - window.setTimeout(() => generator(observationActivity), i * obsInt(120,380)); - } - } - - function scheduleObservationAmbientActivity() { - if (observationAmbientTimer) { - clearTimeout(observationAmbientTimer); - observationAmbientTimer = null; - } - if (!observationActive || observationActivity <= .01) return; - - // Quiet = occasional subtle life. Active = a busy generative display. - const slow = 12500; - const fast = 1500; - const base = slow - (slow - fast) * Math.pow(observationActivity, .82); - const jitter = base * obsRand(.25,.75); - - observationAmbientTimer = window.setTimeout(() => { - triggerObservationActivity('ambient'); - scheduleObservationAmbientActivity(); - }, base + jitter); - } - - function setObservationActivity(raw) { - observationActivity = obsClamp(Number(raw) / 100, 0, 1); - window.observationActivity = observationActivity; - observationOverlay.style.setProperty('--obs-activity', String(observationActivity)); - const pct = Math.round(observationActivity * 100); - valObservationActivity.textContent = `${pct}%`; - if (observationActive) { - observationStatus.innerHTML = - `OBSERVATION ACTIVE
PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}
VISUAL ACTIVITY: ${pct}%`; - scheduleObservationAmbientActivity(); - } - } - - function resetObservationReturnFade() { - if (!observationReturn) return; - - observationReturn.style.transition = 'none'; - observationReturn.classList.remove('observation-return-fade'); - void observationReturn.offsetWidth; - observationReturn.style.transition = ''; - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (observationActive) { - observationReturn.classList.add('observation-return-fade'); - } - }); - }); - } - - function resetObservationHudFade() { - if (!observationChrome) return; - - // Remove transition momentarily so each OBSERVATION session starts fully visible. - observationChrome.style.transition = 'none'; - observationChrome.classList.remove('observation-hud-fade'); - void observationChrome.offsetWidth; - observationChrome.style.transition = ''; - - if (!toggleObservationHud.checked) { - // Start the thirty-second fade on the next frame. - requestAnimationFrame(() => { - requestAnimationFrame(() => { - if (observationActive && !toggleObservationHud.checked) { - observationChrome.classList.add('observation-hud-fade'); - } - }); - }); - } - } - - function updateObservationHudMode() { - const hold = toggleObservationHud.checked; - valObservationHud.textContent = hold ? 'ON' : 'FADE'; - - if (!observationActive || !observationChrome) return; - - if (hold) { - observationChrome.style.transition = 'none'; - observationChrome.classList.remove('observation-hud-fade'); - void observationChrome.offsetWidth; - observationChrome.style.transition = ''; - } else { - resetObservationHudFade(); - } - } - - toggleObservationHud.addEventListener('change', updateObservationHudMode); - updateObservationHudMode(); - - sliderObservationActivity.addEventListener('input', () => { - setObservationActivity(sliderObservationActivity.value); - if (observationActive) scheduleObservationInstrumentTick(); - }); - - window.addEventListener('scifi-telemetry-activity', (event) => { - if (!observationActive) return; - triggerObservationActivity('telemetry'); - nudgeObservationInstruments(event.detail ? event.detail.density : null); - pulseObservationScene(); - pulseObservationSplineMorphs(); - }); - - setObservationActivity(sliderObservationActivity.value); - - - // ========================================================================= - // LIVE OBSERVATION INSTRUMENTATION - // The fixed OBSERVATION chrome is intentionally outside this system. - // Only fantasy elements inside the main SVG/event layers participate. - // ========================================================================= - - let observationInstrumentStates = []; - let observationInstrumentTimer = null; - let observationTransientObserver = null; - - const observationTextVocabulary = { - starfleet: ['NOMINAL','PASSIVE','SCANNING','CORRELATING','TRACKING','VERIFIED','STABLE'], - whoniverse: ['VORTEX LOCK','CONVERGING','DRIFTING','RECURSIVE','RESOLVING','TEMPORAL HOLD'], - industrial: ['SECURE FEED','MONITORING','SIGNAL HOLD','CYCLING','LOCAL CHECK','STABLE'], - bioships: ['QUIESCENT','RESPONDING','ADAPTING','RESONANT','SYNCHRONIZED','PULSING'], - retrofuture: ['READY','READ','PROCESS','HOLD','TRACK','STABLE'], - military: ['PASSIVE','TRACKING','CORRELATING','VERIFIED','WATCH','CLEAR'], - deepspace: ['DEEP FIELD','OBSERVING','SPECTRAL HOLD','PASSIVE','RESOLVING','STABLE'], - outlaw: ['MANUAL','INTERMITTENT','PATCHED','SEARCHING','HOLDING','DIRTY LOCK'], - spacestations: ['PASSIVE WATCH','TRAFFIC HOLD','DOCKING','ROUTING','OBSERVING','CLEAR'], - comedy: ['PROBABLY FINE','MOSTLY NOMINAL','STILL FINE','SCENIC','TEA ADJACENT','UNNECESSARILY PRECISE'] - }; - - const observationPlainTextVariants = { - comedy: { - 'TEA SOURCE DETECTED': ['TEA SOURCE DETECTED','TEA SOURCE PROBABLE','TEA SOURCE LOST','BEVERAGE VECTOR ACQUIRED'], - 'NO IMMEDIATE CAUSE FOR PANIC': ['NO IMMEDIATE CAUSE FOR PANIC','STILL NO CAUSE FOR PANIC','PANIC NOT CURRENTLY REQUIRED','SITUATION MOSTLY NORMAL'], - 'SCENIC NAVIGATION': ['SCENIC NAVIGATION','SCENIC RECALCULATION','ROUTE APPRECIATION','NAVIGATION, BUT NICER'] - } - }; - - function getObservationTelemetryDensity() { - if (typeof telemetry !== 'undefined' && telemetry && telemetry.params) { - return obsClamp(Number(telemetry.params.density) || 0, 0, 1); - } - const slider = document.getElementById('slider-telemetry-density'); - return slider ? obsClamp(Number(slider.value) || 0, 0, 1) : 0.5; - } - - function classifyObservationNumber(source, raw, index) { - const upper = source.toUpperCase(); - const decimals = raw.includes('.') ? raw.split('.')[1].length : 0; - const unsignedRaw = raw.replace(/^[-+]/, ''); - const integerPart = unsignedRaw.split('.')[0]; - const leadingZero = integerPart.length > 1 && integerPart.startsWith('0'); - const resolution = Math.pow(10, -decimals); - const base = Number(raw); - - let kind = 'generic'; - let min = -Infinity; - let max = Infinity; - let circular = false; - let macroScale = Math.max(resolution * 2, Math.abs(base || 1) * 0.025); - - const after = source.slice(index + raw.length, index + raw.length + 4); - const before = source.slice(Math.max(0, index - 24), index).toUpperCase(); - - if (after.includes('%') || upper.includes('QUALITY') || upper.includes('CONFIDENCE') || upper.includes('EFFICIENCY')) { - kind = 'percent'; - min = 0; - max = upper.includes('CONFIDENCE') ? 103 : 100; - macroScale = 8; - } else if (upper.includes('NAV VECTOR') || upper.includes('BEARING') || upper.includes('HEADING') || upper.includes('VECTOR ')) { - kind = 'heading'; - min = 0; - max = 360; - circular = true; - macroScale = 18; - } else if (upper.includes('CAM ') || upper.includes('CONTACT ') || upper.includes('TRACK ') || - upper.includes('BAY ') || upper.includes('SECTOR ') || upper.includes('OBJECT ') || - before.endsWith('CAM ') || before.endsWith('TRACK ')) { - kind = 'identifier'; - min = 0; - max = leadingZero ? Math.pow(10, integerPart.length) - 1 : Math.max(99, base + 24); - macroScale = Math.max(1, Math.min(4, 1 + base * .02)); - } else if (upper.includes('RANGE') || upper.includes('DIST') || upper.includes(' LY') || upper.includes(' MM')) { - kind = 'range'; - min = 0; - max = Math.max(base * 3, base + 1000); - macroScale = Math.max(resolution * 3, Math.abs(base || 1) * .18); - } else if (upper.includes('TIME') || upper.includes(':')) { - kind = 'clockish'; - min = 0; - max = decimals ? 99.9 : 99; - macroScale = decimals ? 2.5 : 5; - } else if (base >= 0 && base <= 100) { - min = 0; - max = 100; - macroScale = Math.max(resolution * 3, 4); - } - - return { - raw, - base, - current: base, - target: base, - decimals, - integerWidth: integerPart.length, - leadingZero, - resolution, - kind, - min, - max, - circular, - macroScale, - flutterOffset: 0, - nextFlutterAt: performance.now() + obsRand(350, 1400), - correctionKick: 0, - correctionUntil: 0 - }; - } - - function getStatusMutationRule(source) { - const text = source.trim(); - const upper = text.toUpperCase(); - - // Camera location names are identities, not status fields. - if (/^CAM\s+\d+\s*\/\//i.test(text)) return null; - - if (text.includes('//')) { - const splitAt = text.lastIndexOf('//'); - const prefix = text.slice(0, splitAt + 2).trimEnd(); - const suffix = text.slice(splitAt + 2).trim(); - - // These are display/status phrases and can safely wander. - if (/(PASSIVE|NOMINAL|ACTIVE|OBSERVATION|WATCH|FEED|SENSORS|SECURE|FIELD|ARRAY|CONTROL)/i.test(suffix) || - /(SENSOR|VECTOR|TACTICAL|OPTICAL|BIOLOGICAL|TRAFFIC|LIVE)/i.test(prefix)) { - return { type: 'suffix', prefix, original: suffix }; - } - } - - // A few colon-delimited fantasy statuses. - const colonMatch = text.match(/^(ROUTE|TRANSPONDER)\s*:\s*(.+)$/i); - if (colonMatch) { - return { type: 'colon', prefix: colonMatch[1].toUpperCase(), original: colonMatch[2].trim() }; - } - - const plain = observationPlainTextVariants[activeUniverseId]; - if (plain && plain[upper]) { - return { type: 'plain', original: upper, variants: plain[upper] }; - } - - return null; - } - - function instrumentObservationTextNode(node) { - if (!node || node.nodeType !== 1 || node.dataset.obsLiveInstrumented === '1') return; - - const source = (node.textContent || '').trim(); - if (!source) return; - - const numericMatches = [...source.matchAll(/[-+]?\d+(?:\.\d+)?/g)]; - const numbers = numericMatches.map(m => classifyObservationNumber(source, m[0], m.index)); - - const state = { - node, - source, - numbers, - numberMatches: numericMatches.map(m => ({ raw: m[0], index: m.index })), - statusRule: getStatusMutationRule(source), - statusOverride: null, - statusUntil: 0, - nextTextGlitchAt: performance.now() + obsRand(2500, 9000), - glitchUntil: 0, - glitchSeed: 0 - }; - - node.dataset.obsLiveInstrumented = '1'; - node.classList.add('obs-live-instrument'); - observationInstrumentStates.push(state); - } - - function instrumentObservationTree(root) { - if (!root || !root.querySelectorAll) return; - if (root.matches && root.matches('text')) instrumentObservationTextNode(root); - root.querySelectorAll('text').forEach(instrumentObservationTextNode); - } - - function initializeObservationLiveInstrumentation() { - stopObservationLiveInstrumentation(false); - observationInstrumentStates = []; - - instrumentObservationTree(observationStage); - instrumentObservationTree(observationEventLayer); - - observationTransientObserver = new MutationObserver((mutations) => { - for (const mutation of mutations) { - mutation.addedNodes.forEach(node => { - if (node.nodeType === 1) instrumentObservationTree(node); - }); - } - }); - observationTransientObserver.observe(observationEventLayer, { childList: true, subtree: true }); - - scheduleObservationInstrumentTick(); - } - - function stopObservationLiveInstrumentation(clearStates = true) { - if (observationInstrumentTimer) { - clearTimeout(observationInstrumentTimer); - observationInstrumentTimer = null; - } - if (observationTransientObserver) { - observationTransientObserver.disconnect(); - observationTransientObserver = null; - } - if (clearStates) observationInstrumentStates = []; - } - - function formatObservationNumber(value, token) { - let v = value; - - if (token.circular) { - const span = token.max - token.min; - v = ((v - token.min) % span + span) % span + token.min; - } else { - if (Number.isFinite(token.min)) v = Math.max(token.min, v); - if (Number.isFinite(token.max)) v = Math.min(token.max, v); - } - - // Identity-like fields stay integer but can still "flutter" by one count. - if (token.kind === 'identifier') v = Math.round(v); - - let result = token.decimals > 0 ? v.toFixed(token.decimals) : String(Math.round(v)); - - if (token.leadingZero) { - const sign = result.startsWith('-') ? '-' : ''; - const unsigned = result.replace(/^-/, ''); - const parts = unsigned.split('.'); - parts[0] = parts[0].padStart(token.integerWidth, '0'); - result = sign + parts.join('.'); - } - return result; - } - - function shortestCircularDelta(current, target, min, max) { - const span = max - min; - let delta = target - current; - if (delta > span / 2) delta -= span; - if (delta < -span / 2) delta += span; - return delta; - } - - function rebuildObservationText(state, now) { - if (!state.node || !state.node.isConnected) return; - - let cursor = 0; - let built = ''; - - state.numberMatches.forEach((match, i) => { - const token = state.numbers[i]; - built += state.source.slice(cursor, match.index); - - let displayValue = token.current + token.flutterOffset; - - if (token.correctionUntil > now) { - const remaining = (token.correctionUntil - now) / 1400; - displayValue += token.correctionKick * Math.max(0, Math.min(1, remaining)); - } - - built += formatObservationNumber(displayValue, token); - cursor = match.index + match.raw.length; - }); - - built += state.source.slice(cursor); - - if (state.statusOverride && state.statusUntil > now && state.statusRule) { - const rule = state.statusRule; - if (rule.type === 'suffix') { - const splitAt = built.lastIndexOf('//'); - if (splitAt >= 0) built = `${built.slice(0, splitAt + 2)} ${state.statusOverride}`; - } else if (rule.type === 'colon') { - const colonAt = built.indexOf(':'); - if (colonAt >= 0) built = `${built.slice(0, colonAt + 1)} ${state.statusOverride}`; - } else if (rule.type === 'plain') { - built = state.statusOverride; - } - } else if (state.statusOverride && state.statusUntil <= now) { - state.statusOverride = null; - } - - // Very brief display corruption. This is visual noise only and always recovers. - if (state.glitchUntil > now && built.length > 3) { - const chars = [...built]; - const candidates = []; - for (let i = 1; i < chars.length - 1; i++) { - if (/[A-Z0-9]/i.test(chars[i])) candidates.push(i); - } - if (candidates.length) { - const idx = candidates[state.glitchSeed % candidates.length]; - chars[idx] = obsPick(['_','?','·','Ξ','0','1']); - built = chars.join(''); - state.node.classList.add('obs-live-glitch'); - } - } else { - state.node.classList.remove('obs-live-glitch'); - } - - state.node.textContent = built; - } - - function updateObservationNumberToken(token, now) { - const activity = observationActivity; - - let delta = token.circular - ? shortestCircularDelta(token.current, token.target, token.min, token.max) - : token.target - token.current; - - // Activity controls how quickly the displayed instrument tracks its target. - const trackingFraction = 0.07 + activity * 0.31; - if (Math.abs(delta) > token.resolution * .15) { - token.current += delta * trackingFraction; - } else { - token.current = token.target; - } - - // Micro flutter: the least-significant digit hunts around the real value. - if (now >= token.nextFlutterAt) { - const flutterChance = 0.34 + activity * 0.56; - if (Math.random() < flutterChance) { - const direction = obsPick([-1, 1]); - const steps = Math.random() < (.18 + activity * .2) ? 2 : 1; - const identifierDamping = token.kind === 'identifier' ? .55 : 1; - token.flutterOffset = direction * token.resolution * steps * identifierDamping; - } else { - token.flutterOffset = 0; - } - - const slow = 1850; - const fast = 240; - token.nextFlutterAt = now + (slow - (slow-fast) * activity) * obsRand(.65, 1.35); - } else if (Math.random() < .16 + activity * .18) { - token.flutterOffset *= .45; - if (Math.abs(token.flutterOffset) < token.resolution * .15) token.flutterOffset = 0; - } - - if (token.circular) { - const span = token.max - token.min; - token.current = ((token.current - token.min) % span + span) % span + token.min; - } else { - if (Number.isFinite(token.min)) token.current = Math.max(token.min, token.current); - if (Number.isFinite(token.max)) token.current = Math.min(token.max, token.current); - } - } - - function maybeGlitchObservationText(state, now) { - if (now < state.nextTextGlitchAt) return; - - const activity = observationActivity; - const density = getObservationTelemetryDensity(); - const chance = .06 + activity * .18 + density * .05; - - if (Math.random() < chance) { - state.glitchSeed = obsInt(0, 100000); - state.glitchUntil = now + obsRand(90, 260 + activity * 180); - } - - const slow = 17000; - const fast = 2800; - state.nextTextGlitchAt = now + (slow - (slow-fast) * activity) * obsRand(.6, 1.4); - } - - function observationInstrumentTick() { - observationInstrumentTimer = null; - if (!observationActive) return; - - const now = performance.now(); - observationInstrumentStates = observationInstrumentStates.filter(state => state.node && state.node.isConnected); - - for (const state of observationInstrumentStates) { - state.numbers.forEach(token => updateObservationNumberToken(token, now)); - maybeGlitchObservationText(state, now); - rebuildObservationText(state, now); - } - - scheduleObservationInstrumentTick(); - } - - function scheduleObservationInstrumentTick() { - if (!observationActive) return; - if (observationInstrumentTimer) clearTimeout(observationInstrumentTimer); - - // Observation level directly controls the "instrument refresh rate". - // At quiet settings values settle visibly and slowly; at high settings - // they look like fast live telemetry. - const slowMs = 900; - const fastMs = 105; - const delay = slowMs - (slowMs - fastMs) * Math.pow(observationActivity, .88); - observationInstrumentTimer = setTimeout(observationInstrumentTick, delay); - } - - function chooseObservationTarget(token, density) { - const amplitudeFloor = token.resolution; - const amplitude = Math.max( - amplitudeFloor, - token.macroScale * (.06 + Math.pow(density, 1.12)) - ); - - let delta; - if (token.kind === 'identifier') { - const maxStep = Math.max(1, Math.round(1 + density * 3)); - delta = obsPick([-1,1]) * obsInt(1, maxStep); - } else { - delta = obsRand(-amplitude, amplitude); - } - - let target = token.target + delta; - - if (token.circular) { - const span = token.max - token.min; - target = ((target - token.min) % span + span) % span + token.min; - } else { - if (Number.isFinite(token.min)) target = Math.max(token.min, target); - if (Number.isFinite(token.max)) target = Math.min(token.max, target); - } - - token.target = target; - - // Sometimes a display control-loop overshoots and corrects itself. - if (Math.random() < .28 + observationActivity * .24) { - const sign = Math.random() < .5 ? -1 : 1; - const kickSteps = token.kind === 'identifier' ? 1 : obsRand(.6, 2.2 + observationActivity * 1.5); - token.correctionKick = sign * token.resolution * kickSteps; - token.correctionUntil = performance.now() + obsRand(550, 1450); - } - } - - function chooseObservationStatusOverride(state, density) { - if (!state.statusRule) return; - - let variants; - const rule = state.statusRule; - - if (rule.type === 'plain') { - variants = rule.variants || []; - } else { - variants = observationTextVocabulary[activeUniverseId] || observationTextVocabulary.starfleet; - } - - if (!variants.length) return; - - const original = rule.original.toUpperCase(); - const choices = variants.filter(v => v !== original); - if (!choices.length) return; - - state.statusOverride = obsPick(choices); - - // Higher observation activity means state changes read faster and clear sooner. - const holdSlow = 6200; - const holdFast = 1700; - state.statusUntil = performance.now() + - (holdSlow - (holdSlow-holdFast) * observationActivity) * obsRand(.7, 1.35); - } - - function nudgeObservationInstruments(densityOverride = null) { - if (!observationActive) return; - - const density = densityOverride == null - ? getObservationTelemetryDensity() - : obsClamp(Number(densityOverride) || 0, 0, 1); - - const liveStates = observationInstrumentStates.filter(s => s.node && s.node.isConnected); - const numericStates = liveStates.filter(s => s.numbers.length); - const statusStates = liveStates.filter(s => s.statusRule); - - // Density controls how much of the imaginary system is disturbed by a chirp. - const desiredNumeric = Math.min( - numericStates.length, - Math.max(1, Math.round(1 + density * (2 + numericStates.length * .22))) - ); - - const shuffledNumbers = [...numericStates].sort(() => Math.random() - .5); - for (let i = 0; i < desiredNumeric; i++) { - const state = shuffledNumbers[i]; - state.numbers.forEach(token => { - // A dense chirp can perturb several values in a compound readout. - if (Math.random() < .55 + density * .4) chooseObservationTarget(token, density); - }); - } - - // Text states are less frequent than numeric movement. They read as system - // transitions rather than a constantly changing label soup. - if (statusStates.length && Math.random() < .28 + density * .5) { - const desiredText = density > .72 && Math.random() < density ? 2 : 1; - const shuffledText = [...statusStates].sort(() => Math.random() - .5); - for (let i = 0; i < Math.min(desiredText, shuffledText.length); i++) { - chooseObservationStatusOverride(shuffledText[i], density); - } - } - } - - - function seededUnit(seed) { - let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; - return x - Math.floor(x); - } - - function makeStars(count = 90, width = 1600, height = 900, tint = '#dbeafe', seedOffset = 0) { - let stars = ''; - for (let i = 0; i < count; i++) { - const x = Math.floor(seededUnit(i + 1 + seedOffset) * width); - const y = Math.floor(seededUnit((i + 1 + seedOffset) * 7.31) * height); - const r = (0.55 + seededUnit((i + seedOffset + 4) * 3.17) * 1.65).toFixed(2); - const o = (0.25 + seededUnit((i + seedOffset + 9) * 5.19) * 0.72).toFixed(2); - stars += ``; - } - return stars; - } - - function svgFrame(inner, defs = '') { - return ` - - - - - - - - - - - - - - - - - ${defs} - - ${inner} - `; - } - - function renderObservationStarfleet() { - const starsA = makeStars(115, 1750, 900, '#dbeafe', 2); - const starsB = makeStars(55, 1750, 900, '#93c5fd', 220); - return svgFrame(` - - ${starsA} - ${starsB} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LONG RANGE SENSOR ARRAY // PASSIVE - NAV VECTOR 034.8 // NOMINAL - `); - } - - function renderObservationWhoniverse() { - return svgFrame(` - - - - - - - - - - - - - - - - - - - - - - - - - ⌁ 37.4 ∴ 9 - ∆ 04:11:∞ - - - TEMPORAL DISPLACEMENT // VORTEX OBSERVATION - - `); - } - - function renderObservationIndustrial() { - // The industrial viewport frame (getViewportFrameSvg) masks everything - // outside x 140..1460, y 160..740, so the whole surveillance console is - // laid out inside that safe area. v12 ran the feeds to x 110..1490 and had - // its edges eaten by the bulkhead. - const SAFE_X0 = 156, SAFE_X1 = 1444; - - // Feeds are authored at 650x270 and drawn through a uniform scale, so the - // scenes below can keep one readable coordinate space. - const CW = 650, CH = 270, S = 0.78; - const IX = 14, IY = 14, IW = 622, IH = 242; - const CAM_X = [279, 814], CAM_Y = [248, 472]; - - // Identity text (camera names, equipment designations, container IDs) is - // opted OUT of the live-instrumentation number drift by pre-stamping the - // flag it checks. Only telemetry should wander; "CAM 02" must not quietly - // become "CAM 01", which is exactly what v12 did to every label it drew. - const ID = 'data-obs-live-instrumented="1"'; - - // ---------------------------------------------------------------- parts - const crate = (x, y, w, h, body, edge, id, idColor) => { - let ribs = ''; - for (let i = 9; i < w - 5; i += 9) ribs += `M${i} 4V${h - 4}`; - return ` - - - - - - - - - - ${id} - `; - }; - - // Vent vapour authored as in-scene geometry so the camera clip contains it. - // v12 floated these on the sim layer, which is why steam drifted over the - // bezels and read as detached from any feed. - const vapour = (x, y, count, seed, scale = 1) => { - let out = ''; - for (let i = 0; i < count; i++) { - const dur = (4.5 + seededUnit(seed + i) * 3.6).toFixed(1); - const delay = (i * (dur / count)).toFixed(1); - const vx = Math.round(-24 + seededUnit(seed + i * 3.1) * 48); - const r = ((9 + seededUnit(seed + i * 5.7) * 13) * scale).toFixed(1); - out += ``; - } - return out; - }; - - const gauge = (x, y, r, label, needleColor, delay) => { - let ticks = ''; - for (let a = -120; a <= 120; a += 30) { - const rad = (a - 90) * Math.PI / 180; - ticks += `M${(Math.cos(rad) * (r - 3)).toFixed(1)} ${(Math.sin(rad) * (r - 3)).toFixed(1)}` + - `L${(Math.cos(rad) * (r - 8)).toFixed(1)} ${(Math.sin(rad) * (r - 8)).toFixed(1)}`; - } - return ` - - - - - - - - - ${label} - `; - }; - - // Per-feed video artifacts. This replaces v12's single screen-wide sweep - // bar: each feed rolls, tears and grains on its own clock, inside its own - // clip, so the artifacts read as video rather than as a screen overlay. - const feedFx = (i) => ` - - - - - - - - - - - - - - `; - - const cam = (i, label, code, status, scene) => ` - - - - - - - - - - ${scene} - - ${feedFx(i)} - - - - ${label} - ${code} - - ${status} - LIVE // SECURE FEED - - - - - SELECTED - - `; - - // ------------------------------------------------- procedural geometry - let bayChev = ''; - for (let i = 0; i < 660; i += 16) bayChev += `M${i} 222L${i + 8} 212H${i + 16}L${i + 8} 222Z`; - let bayDeck = ''; - for (let i = 20; i < 640; i += 44) bayDeck += `M${i} 214V256`; - - let coreSeg = ''; - for (let a = 0; a < 360; a += 45) { - const rad = a * Math.PI / 180; - coreSeg += `M${(Math.cos(rad) * 62).toFixed(1)} ${(Math.sin(rad) * 62).toFixed(1)}` + - `L${(Math.cos(rad) * 86).toFixed(1)} ${(Math.sin(rad) * 86).toFixed(1)}`; - } - let rods = ''; - for (let i = 0; i < 7; i++) { - const rx = -60 + i * 20; - rods += ` - - - `; - } - const trefoil = [0, 120, 240].map(a => - ``).join(''); - let plantGrate = ''; - for (let i = 20; i < 640; i += 40) plantGrate += `M${i} 210V256`; - - let dogs = ''; - for (let a = 0; a < 360; a += 45) { - const rad = a * Math.PI / 180; - dogs += ` - - - `; - } - let hazRing = ''; - for (let a = 0; a < 360; a += 15) { - if ((a / 15) % 2) continue; - const r0 = 78, r1 = 90, a0 = a * Math.PI / 180, a1 = (a + 15) * Math.PI / 180; - hazRing += `M${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}` + - `L${(Math.cos(a0) * r1).toFixed(1)} ${(Math.sin(a0) * r1).toFixed(1)}` + - `A${r1} ${r1} 0 0 1 ${(Math.cos(a1) * r1).toFixed(1)} ${(Math.sin(a1) * r1).toFixed(1)}` + - `L${(Math.cos(a1) * r0).toFixed(1)} ${(Math.sin(a1) * r0).toFixed(1)}` + - `A${r0} ${r0} 0 0 0 ${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}Z`; - } - const lamp = (y, txt, col, i) => ` - - - - - - - ${txt} - `; - const suits = [0, 1, 2].map(i => ` - - - - - - - `).join(''); - let lockFloor = ''; - for (let i = 20; i < 640; i += 40) lockFloor += `M${i} 214V256`; - - const vpx = 325, vpy = 128; - let corrFrames = '', corrFloor = '', corrLamps = ''; - for (let i = 0; i < 7; i++) { - const s = Math.pow(0.76, i); - const x0 = vpx + (24 - vpx) * s, x1 = vpx + (626 - vpx) * s; - const y0 = vpy + (22 - vpy) * s, y1 = vpy + (248 - vpy) * s; - corrFrames += ``; - corrFloor += ``; - } - for (let x = 24; x <= 626; x += 60) { - corrFloor += ``; - } - for (let i = 1; i < 5; i++) { - const s = Math.pow(0.76, i); - const y = vpy + (34 - vpy) * s, w = 62 * s; - corrLamps += ` - - - `; - } - const fanBlades = [0, 72, 144, 216, 288].map(a => - ``).join(''); - const drip = (x, y, d) => - ` - `; - - // ------------------------------------------------------- CAM 01 :: BAY - // A working cargo hold: a gantry crane on an overhead rail running a real - // pick-and-place cycle over stacked containers. v12 had a wheeled cart - // sliding back and forth, which read as a car and made no sense on a ship. - const sceneCargo = ` - - - - - - - - - - - - - - - BAY 2 // AFT - - - - - - - - - - - - - ${crate(90, 174, 88, 40, '#5d4427', '#8a6a42', 'CB-4471', '#e8c88a')} - - ${crate(90, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} - - ${crate(182, 174, 88, 40, '#4f3a22', '#7d6039', 'HAZ-3/09', '#f0b64f')} - - - - ${crate(380, 174, 88, 40, '#5a4426', '#886946', 'CB-3310', '#e8c88a')} - - ${crate(380, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} - - ${crate(472, 174, 88, 40, '#453120', '#6f5535', 'ORE-C', '#d9b070')} - - - - - - - - - - - - - - - ${crate(-44, 6, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ${vapour(250, 250, 6, 12.4, .8)} - - - - - - GANTRY 4 // AUTO CYCLE - LOAD 2.4 T - `; - - // --------------------------------------------------- CAM 02 :: REACTOR - const sceneReactor = ` - - - - - - - - - ${rods} - - - - - - - - - - - - - - - - - - - - - - - - - - - - ${gauge(424, 74, 26, 'TEMP', '#f59e0b', 0)} - ${gauge(490, 74, 26, 'PRESS', '#38bdf8', 1.4)} - ${gauge(556, 74, 26, 'FLUX', '#22c55e', 2.8)} - - - - ${trefoil} - - - - ${vapour(392, 206, 5, 31.7, .85)} - - - - - - - - - - FUSION PLANT 1 - OUTPUT 68% - `; - - // --------------------------------------------------- CAM 03 :: AIRLOCK - const sceneAirlock = ` - - - - - - - ${suits} - - - - - - - - ${dogs} - - - - - - - - - - - - - - - - - - - - - - - - - CYCLE STATE - ${lamp(20, 'VACUUM', '#38bdf8', 0)} - ${lamp(44, 'EQUALIZE', '#f59e0b', 1)} - ${lamp(68, 'PRESSURE', '#22c55e', 2)} - - - ${gauge(556, 172, 28, 'CHAMBER kPa', '#38bdf8', 1.1)} - ${vapour(300, 212, 5, 57.3, .7)} - - - - - - AIRLOCK C - OUTER DOOR SEALED - `; - - // -------------------------------------------------- CAM 04 :: CORRIDOR - const sceneCorridor = ` - - - - ${corrFloor} - - - - - - - - - - - ${corrFrames} - ${corrLamps} - - - - - - - ${fanBlades} - - - - - - - - - - - - - - - - - - - - - - - ${drip(196, 96, 1.4)} - ${drip(438, 82, 3.1)} - ${drip(268, 66, 5.2)} - ${vapour(520, 236, 5, 78.1, .75)} - - - FRAME 22 TO 34 - EXTRACTOR 3 ONLINE - `; - - // ------------------------------------------------- console side rails - let rails = ''; - for (let i = 0; i < 8; i++) { - const y = 252 + i * 54; - const fill = 22 + ((i * 41) % 62); - rails += ` - - - `; - } - - const defs = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - `; - - return svgFrame(` - - - - ${cam(0, 'CAM 01 // CARGO BAY 2', 'C-01 4K/IR', 'GANTRY 4 AUTO // 2 CONTACTS', sceneCargo)} - ${cam(1, 'CAM 02 // REACTOR ACCESS', 'C-02 4K/RAD', 'PLANT 1 NOMINAL // LOOP A', sceneReactor)} - ${cam(2, 'CAM 03 // AIRLOCK C', 'C-03 4K/LL', 'OUTER DOOR SEALED // NO EVA', sceneAirlock)} - ${cam(3, 'CAM 04 // MACHINE CORRIDOR', 'C-04 4K/IR', 'FRAME 22-34 // EXTRACTOR ON', sceneCorridor)} - - - - - CSV KESTREL - HEAVY HAULER - REG CB-4471 // DECK 4 SURVEILLANCE RING - - - SHIP TIME - 04:17:33 - RECORDER - ARMED - - - - 4 OF 4 FEEDS LOCKED - BANDWIDTH 62% // ARCHIVE 71% FULL - - - - - ${rails} - - - - - - BUS A - BUS B - - - - - - MANIFEST 4471-C // ORE CONCENTRATE + SEALED HAZ-3 - - HULL 12% - BAY 101.3 KPA - RCTR 68% - O2 20.9% - GRAV 0.94 G - - NOMINAL - - `, defs); - } - - function renderObservationBioships() { - return svgFrame(` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - BIOLOGICAL SYSTEMS // PASSIVE NEURAL OBSERVATION - - `); - } - - function renderObservationRetro() { - const stars = makeStars(95, 1600, 900, '#86efac', 720); - return svgFrame(` - - ${stars} - - - - - - - - - - - - - - - - - - - - - - - - - - ASTROGATION ARRAY - VECTOR MEMORY: LOCKED - SCAN 360° - ANALOG BEAM: ACTIVE - - `); - } - - function renderObservationMilitary() { - const stars = makeStars(110, 1650, 900, '#fef3c7', 960); - return svgFrame(` - - ${stars} - - - - - - - - - - - - - - - - - - - - - - - - - - - CONTACT 03 // TRACKING - TACTICAL WATCH // PASSIVE - WEAPONS SAFE // SENSORS ACTIVE - - - - - - - - `); - } - - function renderObservationDeepSpace() { - const starsA = makeStars(155, 1800, 900, '#eef2ff', 1220); - const starsB = makeStars(65, 1800, 900, '#818cf8', 1430); - // Planets, nebula clouds, dust lanes, derelicts and anomalies now live in the canvas - // engine's procedural, session-seeded Deep Space field (see ObservationEngine's - // regenerateDeepSpaceField / renderDeepSpaceField). This legacy SVG layer is kept - // intentionally sparse -- just a comet accent and the HUD label -- so it never paints a - // fixed duplicate planet/nebula on top of that varying scene. - return svgFrame(` - - ${starsA} - ${starsB} - - - - - - LONG RANGE OPTICAL // DEEP FIELD OBSERVATION - - `); - } - - function renderObservationOutlaw() { - const stars = makeStars(115, 1700, 900, '#fbcfe8', 1710); - return svgFrame(` - - ${stars} - - - - - - - - - REAR FEED // 02 - - - - - - ROUTE: MANUAL - TRANSPONDER: INTERMITTENT - SIGNAL QUALITY: 62% - - - `); - } - - function renderObservationStations() { - const starsNear = makeStars(105, 1160, 540, '#e0f2fe', 1990); - const starsFar = makeStars(55, 1160, 540, '#7dd3fc', 2310); - const telemetryModule = (x, y, w, h, title, line1, line2 = '') => ` - - - - ${title} - ${line1} - ${line2 ? `${line2}` : ''} - `; - - return svgFrame(` - - - - - - - - - - - - - - - - - - - ${starsNear} - ${starsFar} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ${telemetryModule(105, 170, 300, 96, 'OBSERVATION DECK', 'RING RATE 01.8 // STABLE', 'STARFIELD DRIFT 004.2')} - ${telemetryModule(1195, 170, 300, 96, 'TRAFFIC CONTROL', 'DOCK VECTOR 118.4 // ROUTING', 'QUEUE 03 // CLEARANCE')} - ${telemetryModule(105, 590, 320, 96, 'PORT STATUS', 'BAY 12 // ARRIVAL WINDOW', 'HOLDING ARC 02.7')} - ${telemetryModule(1175, 590, 320, 96, 'TRANSIT SPINE', 'LOCAL FLOW 07 // NOMINAL', 'HABITAT SPIN 00.9')} - - - - - - - - - STATION OBSERVATION // ROTATIONAL TRAFFIC WINDOW - - `); - } - - function renderObservationComedy() { - const stars = makeStars(120, 1700, 900, '#cffafe', 2260); - return svgFrame(` - - ${stars} - - - - - - - - - SCENIC NAVIGATION - ROUTE CONFIDENCE: 99.7% PROBABLY FINE - TEA SOURCE DETECTED - NO IMMEDIATE CAUSE FOR PANIC - - - - - - - `); - } - - function getObservationSvg(universeId) { - switch (universeId) { - case 'starfleet': return renderObservationStarfleet(); - case 'whoniverse': return renderObservationWhoniverse(); - case 'industrial': return renderObservationIndustrial(); - case 'bioships': return renderObservationBioships(); - case 'retrofuture': return renderObservationRetro(); - case 'military': return renderObservationMilitary(); - case 'deepspace': return renderObservationDeepSpace(); - case 'outlaw': return renderObservationOutlaw(); - case 'spacestations': return renderObservationStations(); - case 'comedy': return renderObservationComedy(); - default: return renderObservationStarfleet(); - } - } - - function refreshObservation() { - const universe = UniverseRegistry[activeUniverseId]; - const preset = universe && universe.presets ? universe.presets[activePresetId] : null; - if (!universe || !preset) return; - - observationUniverse.textContent = `${universe.shortCode} // OBSERVATION`; - observationProfile.textContent = preset.name.toUpperCase(); - observationStatus.innerHTML = - `OBSERVATION ACTIVE
PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}
VISUAL ACTIVITY: ${Math.round(observationActivity * 100)}%`; - - observationOverlay.style.setProperty('--obs-activity', String(observationActivity)); - observationStage.innerHTML = getObservationSvg(activeUniverseId); - initializeObservationSplineMorphs(); - startObservationScene(); - initializeObservationLiveInstrumentation(); - - if (observationEngine) { - observationEngine.start(activeUniverseId); - } - } - - function enterObservation() { - if (observationActive) return; - observationActive = true; - observationOverlay.classList.add('active'); - observationOverlay.setAttribute('aria-hidden', 'false'); - refreshObservation(); - resetObservationHudFade(); - resetObservationReturnFade(); - scheduleObservationAmbientActivity(); - - // Give the display an immediate but restrained sign of life. - if (observationActivity > .08) { - window.setTimeout(() => triggerObservationActivity('ambient'), 700); - } - } - - function exitObservation() { - if (!observationActive) return; - observationActive = false; - - if (observationEngine) { - observationEngine.stop(); - } - - if (observationAmbientTimer) { - clearTimeout(observationAmbientTimer); - observationAmbientTimer = null; - } - - stopObservationLiveInstrumentation(); - stopObservationSplineMorphs(); - stopObservationScene(); - - if (observationChrome) { - observationChrome.style.transition = 'none'; - observationChrome.classList.remove('observation-hud-fade'); - void observationChrome.offsetWidth; - observationChrome.style.transition = ''; - } - - if (observationReturn) { - observationReturn.style.transition = 'none'; - observationReturn.classList.remove('observation-return-fade'); - void observationReturn.offsetWidth; - observationReturn.style.transition = ''; - } - - observationOverlay.classList.remove('active'); - observationOverlay.setAttribute('aria-hidden', 'true'); - observationStage.innerHTML = ''; - observationEventLayer.innerHTML = ''; - } - - window.exitObservation = exitObservation; - window.refreshObservation = refreshObservation; - window.enterObservation = enterObservation; - window.selectPreset = selectPreset; - window.setUniverse = setUniverse; - - btnObservation.addEventListener('click', (e) => { - e.stopPropagation(); - enterObservation(); - }); - - // Click background to exit, but ignore clicks on the control dock or its buttons - observationOverlay.addEventListener('click', (e) => { - if (e.target.closest('#observation-control-dock')) return; - e.preventDefault(); - e.stopPropagation(); - exitObservation(); - }); - - const observationReturnPill = document.getElementById('observation-return-pill'); - if (observationReturnPill) { - observationReturnPill.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - exitObservation(); - }); - } - - // 10. Keyboard Shortcuts - window.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; - - if (e.code === 'Space') { - e.preventDefault(); - togglePlay(); - } else if (e.code === 'KeyM') { - toggleMute(); - } else if (e.code === 'KeyW') { - if (observationActive && observationEngine) { - observationEngine.toggleWarpFlight(); - return; - } - const primaryActionBtn = universeEventsContainer.children[0]; - if (primaryActionBtn) primaryActionBtn.click(); - } else if (e.code === 'KeyF') { - if (observationActive && observationEngine) { - observationEngine.toggleViewportFrame(); - return; - } - } else if (e.code === 'KeyP') { - if (observationActive && observationEngine) { - observationEngine.togglePillars(); - return; - } - } else if (e.code === 'KeyR') { - if (observationActive && observationEngine) { - observationEngine.toggleAlert(); - return; - } - const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0]; - if (firstAlertBtn) firstAlertBtn.click(); - } else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') { - e.preventDefault(); - setMasterVolume(audioManager.getMasterVolume() + 0.05); - } else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') { - e.preventDefault(); - setMasterVolume(audioManager.getMasterVolume() - 0.05); - } else if (e.code >= 'Digit1' && e.code <= 'Digit9') { - const num = parseInt(e.code.replace('Digit', '')); - setMasterVolume(num * 0.1); - } else if (e.code === 'Digit0') { - setMasterVolume(1.0); - } else if (e.code === 'Escape') { - if (observationActive) { - exitObservation(); - return; - } - alerts.stopAlert(); - whoniverseAudio.stopCloisterBell(); - resetAlertButtons(); - } - }); - - // 11. Initial Setup - initUniverseDropdown(); - setUniverse('starfleet'); - updateMasterVolumeUI(0.75, false); - visualizer.init('spectrum-canvas', 'warp-core-canvas'); -}); - +/** + * Main Application Controller - 10 Sci-Fi Universes Architecture + * Coordinates Starfleet, Whoniverse, Industrial, Bioships, Retro Future, Military, + * Deep Space, Outlaw, Space Stations, and Comedy universes. + */ + +document.addEventListener('DOMContentLoaded', () => { + // 1. Initialize Audio Subsystems + const audioManager = new AudioManager(); + const hullDrone = new HullDroneSynth(audioManager); + const warpCore = new WarpCoreSynth(audioManager); + const lifeSupport = new LifeSupportSynth(audioManager); + const telemetry = new TelemetrySynth(audioManager); + const alerts = new AlertSynth(audioManager); + const whoniverseAudio = new WhoniverseAudioSynth(audioManager); + const expandedAudio = new ExpandedSciFiAudioSynth(audioManager); + const visualizer = new StarshipVisualizer(audioManager, warpCore); + const observationEngine = new ObservationEngine(audioManager, warpCore, alerts, hullDrone, lifeSupport); + window.observationEngine = observationEngine; + + let isPlaying = false; + let activeUniverseId = 'starfleet'; + let activePresetId = 'tng-bridge'; + window.activeUniverseId = activeUniverseId; + window.activePresetId = activePresetId; + + // 2. DOM Elements + const headerUniverseBtn = document.getElementById('header-universe-btn'); + const headerUniverseLabel = document.getElementById('header-universe-label'); + const universeDropdown = document.getElementById('universe-dropdown'); + const headerShipName = document.getElementById('header-ship-name'); + const headerMatrixLabel = document.getElementById('header-matrix-label'); + + const sidebarTitle = document.getElementById('sidebar-title'); + const presetContainer = document.getElementById('preset-container'); + const currentPresetTitle = document.getElementById('current-preset-title'); + const currentPresetDesc = document.getElementById('current-preset-desc'); + const statAudioStatus = document.getElementById('stat-audio-status'); + const statCoreFreq = document.getElementById('stat-core-freq'); + + const btnPlay = document.getElementById('btn-master-play'); + const playIcon = document.getElementById('play-icon'); + const playText = document.getElementById('play-text'); + + // Master Volume Console Elements + const sliderMasterVol = document.getElementById('slider-master-vol'); + const valMasterVol = document.getElementById('val-master-vol'); + const valMasterDb = document.getElementById('val-master-db'); + const btnMasterMute = document.getElementById('btn-master-mute'); + const btnHeaderMute = document.getElementById('btn-header-mute'); + const headerVolVal = document.getElementById('header-vol-val'); + const btnVolDown = document.getElementById('btn-vol-down'); + const btnVolUp = document.getElementById('btn-vol-up'); + const volMeterLeds = document.getElementById('vol-meter-leds'); + const volStepButtons = document.querySelectorAll('.btn-vol-step'); + + // Channel Sliders + const sliderHullVol = document.getElementById('slider-hull-vol'); + const valHullVol = document.getElementById('val-hull-vol'); + const sliderHullFreq = document.getElementById('slider-hull-freq'); + const valHullFreq = document.getElementById('val-hull-freq'); + const sliderHullCutoff = document.getElementById('slider-hull-cutoff'); + const valHullCutoff = document.getElementById('val-hull-cutoff'); + + const sliderWarpVol = document.getElementById('slider-warp-vol'); + const valWarpVol = document.getElementById('val-warp-vol'); + const sliderWarpBpm = document.getElementById('slider-warp-bpm'); + const valWarpBpm = document.getElementById('val-warp-bpm'); + const sliderWarpCarrier = document.getElementById('slider-warp-carrier'); + const valWarpCarrier = document.getElementById('val-warp-carrier'); + + const sliderAirVol = document.getElementById('slider-air-vol'); + const valAirVol = document.getElementById('val-air-vol'); + const sliderAirCutoff = document.getElementById('slider-air-cutoff'); + const valAirCutoff = document.getElementById('val-air-cutoff'); + + const sliderTelemetryVol = document.getElementById('slider-telemetry-vol'); + const valTelemetryVol = document.getElementById('val-telemetry-vol'); + const sliderTelemetryDensity = document.getElementById('slider-telemetry-density'); + const valTelemetryDensity = document.getElementById('val-telemetry-density'); + + // Visualizer & Dynamic Containers + const coreVisualizerTitle = document.getElementById('core-visualizer-title'); + const coreVisualizerLabel = document.getElementById('core-visualizer-label'); + const soundboardTitle = document.getElementById('soundboard-title'); + const universeEventsContainer = document.getElementById('universe-events-container'); + const soundboardContainer = document.getElementById('soundboard-container'); + + // Sleep Timer + const timerDisplay = document.getElementById('timer-display'); + const timerButtons = document.querySelectorAll('.btn-timer'); + + // 3. Universe Management & Switching + function initUniverseDropdown() { + universeDropdown.innerHTML = ''; + Object.values(UniverseRegistry).forEach(u => { + const btn = document.createElement('button'); + btn.className = `universe-item ${u.id === activeUniverseId ? 'active' : ''}`; + btn.innerHTML = `${u.icon} ${u.name}`; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + setUniverse(u.id); + }); + universeDropdown.appendChild(btn); + }); + + headerUniverseBtn.addEventListener('click', (e) => { + e.stopPropagation(); + universeDropdown.classList.toggle('show'); + }); + + document.addEventListener('click', () => { + universeDropdown.classList.remove('show'); + }); + } + + function setUniverse(universeId) { + const universe = UniverseRegistry[universeId]; + if (!universe) return; + + activeUniverseId = universeId; + universeDropdown.classList.remove('show'); + + // Update Dropdown UI active class + document.querySelectorAll('.universe-item').forEach(el => { + el.classList.toggle('active', el.textContent.includes(universe.name)); + }); + + // Update Header and Theme Class + headerUniverseLabel.textContent = universe.shortCode; + document.body.className = universe.themeClass; + headerMatrixLabel.textContent = universe.headerTitle; + + // Custom titles per universe + const titlesMap = { + 'starfleet': { + sidebar: 'STARSHIP PRESETS', + visTitle: 'MATTER / ANTIMATTER CORE', + visLabel: 'INTERMIX CHAMBER // ACTIVE', + soundboard: 'LCARS KEYPAD' + }, + 'whoniverse': { + sidebar: 'TARDIS CONSOLES', + visTitle: 'CENTRAL TIME ROTOR COLUMN', + visLabel: 'VORTEX ROTOR // OPERATIONAL', + soundboard: 'TARDIS CONSOLE CONTROLS' + }, + 'industrial': { + sidebar: 'INDUSTRIAL FREIGHTERS', + visTitle: 'HEAVY FUSION REACTOR', + visLabel: 'CONTAINMENT CORE // ACTIVE', + soundboard: 'INDUSTRIAL INSTRUMENTATION' + }, + 'bioships': { + sidebar: 'SENTIENT LEVIATHANS', + visTitle: 'ORGANIC BIO-HEART SAC', + visLabel: 'VASCULAR NEXUS // PULSING', + soundboard: 'NEURAL LINK SENSORS' + }, + 'retrofuture': { + sidebar: 'GOLDEN AGE EXPLORATION', + visTitle: 'ASTROGATOR OSCILLOSCOPE', + visLabel: 'ANALOG BEAM // ROTATING', + soundboard: 'RETRO CONSOLE SWITCHES' + }, + 'military': { + sidebar: 'BATTLEFLEET WARSHIPS', + visTitle: 'DRADIS TACTICAL COMBAT CORE', + visLabel: 'CONDITION ONE // ARMED', + soundboard: 'CIC TACTICAL KEYPAD' + }, + 'deepspace': { + sidebar: 'DEEP EXPEDITION ARKS', + visTitle: 'GRAVITY SINGULARITY CORE', + visLabel: 'EVENT HORIZON // LOCKED', + soundboard: 'VOID ACOUSTIC CONSOLE' + }, + 'outlaw': { + sidebar: 'FRONTIER & SALVAGE SHIPS', + visTitle: 'MODIFIED SUB-LIGHT REACTOR', + visLabel: 'KINETIC THRUSTERS // PRIMED', + soundboard: 'OUTLAW COCKPIT CONTROLS' + }, + 'spacestations': { + sidebar: 'ORBITAL STATIONS & DOCKS', + visTitle: 'CENTRIFUGAL ROTATION CORE', + visLabel: 'STATION ROTOR // DOCKED', + soundboard: 'STATION OPS CONSOLE' + }, + 'comedy': { + sidebar: 'WHIMSICAL & ADVENTURE FLEET', + visTitle: 'IMPROBABILITY CORE', + visLabel: 'REALITY FIELD // SHIFTING', + soundboard: 'CHEERFUL CONSOLE KEYPAD' + } + }; + + const conf = titlesMap[universeId] || titlesMap['starfleet']; + sidebarTitle.textContent = conf.sidebar; + coreVisualizerTitle.textContent = conf.visTitle; + coreVisualizerLabel.textContent = conf.visLabel; + soundboardTitle.textContent = conf.soundboard; + + // Configure Visualizer mode + visualizer.setMode(universe.visualizer || 'warp-core'); + + // Render Event Buttons & Soundboard + renderUniverseEvents(universe); + renderUniverseSoundboard(universe); + + // Render Presets + renderUniversePresets(universe); + + // Select Default Preset + const firstPresetId = Object.keys(universe.presets)[0]; + if (firstPresetId) { + selectPreset(firstPresetId); + } + + if (observationActive && typeof refreshObservation === 'function') { + refreshObservation(); + } + } + + function renderUniverseEvents(universe) { + universeEventsContainer.innerHTML = ''; + universe.events.forEach(evt => { + const btn = document.createElement('button'); + btn.className = 'btn-lcars-pill'; + btn.id = evt.id; + btn.textContent = evt.label; + + btn.addEventListener('click', async () => { + await audioManager.resume(); + handleUniverseEvent(evt.type, btn); + }); + + universeEventsContainer.appendChild(btn); + }); + } + + function renderUniverseSoundboard(universe) { + soundboardContainer.innerHTML = ''; + universe.soundboard.forEach(key => { + const btn = document.createElement('button'); + btn.className = 'btn-chirp'; + btn.id = key.id; + btn.textContent = key.label; + if (key.span) { + btn.style.gridColumn = `span ${key.span}`; + } + + btn.addEventListener('click', async () => { + await audioManager.resume(); + handleSoundboardTrigger(key.id); + }); + + soundboardContainer.appendChild(btn); + }); + } + + function renderUniversePresets(universe) { + presetContainer.innerHTML = ''; + Object.values(universe.presets).forEach(preset => { + const btn = document.createElement('button'); + btn.className = `preset-btn ${preset.id === activePresetId ? 'active' : ''}`; + btn.setAttribute('data-preset-id', preset.id); + + btn.innerHTML = ` + ${preset.name} + ${(preset.era || 'SHIP').toUpperCase()} // ${preset.warp.pulseShape.toUpperCase()} CORE + `; + + btn.addEventListener('click', () => { + selectPreset(preset.id); + }); + + presetContainer.appendChild(btn); + }); + } + + // 4. Select & Apply Preset + function selectPreset(presetId) { + const universe = UniverseRegistry[activeUniverseId]; + if (!universe) return; + + const preset = universe.presets[presetId]; + if (!preset) return; + + activePresetId = presetId; + window.activePresetId = presetId; + + document.querySelectorAll('.preset-btn').forEach(b => { + b.classList.toggle('active', b.getAttribute('data-preset-id') === presetId); + }); + + headerShipName.textContent = preset.name.toUpperCase(); + currentPresetTitle.textContent = preset.name.toUpperCase(); + currentPresetDesc.textContent = preset.description; + statCoreFreq.textContent = `${preset.warp.carrierFreq.toFixed(1)} Hz`; + + hullDrone.applyPreset(preset.hull); + warpCore.applyPreset(preset.warp); + lifeSupport.applyPreset(preset.lifeSupport); + telemetry.applyPreset(preset.telemetry); + + syncSlidersToSynthParams(); + + if (isPlaying) { + if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeDematSwitch(); + else if (activeUniverseId === 'industrial') expandedAudio.synthesizeDockingClamp(); + else telemetry.synthesizeLCARSSingleChirp(); + } + + if (observationActive && typeof refreshObservation === 'function') { + refreshObservation(); + } + } + + function syncSlidersToSynthParams() { + sliderHullVol.value = hullDrone.params.volume; + valHullVol.textContent = `${Math.round(hullDrone.params.volume * 100)}%`; + sliderHullFreq.value = hullDrone.params.baseFreq; + valHullFreq.textContent = `${hullDrone.params.baseFreq} Hz`; + sliderHullCutoff.value = hullDrone.params.filterCutoff; + valHullCutoff.textContent = `${hullDrone.params.filterCutoff} Hz`; + + sliderWarpVol.value = warpCore.params.volume; + valWarpVol.textContent = `${Math.round(warpCore.params.volume * 100)}%`; + sliderWarpBpm.value = warpCore.params.bpm; + valWarpBpm.textContent = `${warpCore.params.bpm} BPM`; + sliderWarpCarrier.value = warpCore.params.carrierFreq; + valWarpCarrier.textContent = `${warpCore.params.carrierFreq} Hz`; + + sliderAirVol.value = lifeSupport.params.volume; + valAirVol.textContent = `${Math.round(lifeSupport.params.volume * 100)}%`; + sliderAirCutoff.value = lifeSupport.params.lowpassFreq; + valAirCutoff.textContent = `${lifeSupport.params.lowpassFreq} Hz`; + + sliderTelemetryVol.value = telemetry.params.volume; + valTelemetryVol.textContent = `${Math.round(telemetry.params.volume * 100)}%`; + sliderTelemetryDensity.value = telemetry.params.density; + valTelemetryDensity.textContent = `${Math.round(telemetry.params.density * 100)}%`; + } + + // 5. Play / Pause Master Toggle + async function togglePlay() { + if (!isPlaying) { + await audioManager.resume(); + hullDrone.start(); + warpCore.start(); + lifeSupport.start(); + telemetry.start(); + + isPlaying = true; + btnPlay.classList.add('playing'); + playIcon.textContent = '■'; + playText.textContent = 'DISENGAGE'; + statAudioStatus.textContent = 'ONLINE'; + statAudioStatus.style.color = '#00ff88'; + + if (activeUniverseId === 'whoniverse') whoniverseAudio.synthesizeTelepathicChime(); + else if (activeUniverseId === 'bioships') expandedAudio.synthesizeStarburst(); + else telemetry.synthesizeLCARSDoubleChirp(); + } else { + hullDrone.stop(); + warpCore.stop(); + lifeSupport.stop(); + telemetry.stop(); + alerts.stopAlert(); + whoniverseAudio.stopCloisterBell(); + resetAlertButtons(); + + isPlaying = false; + btnPlay.classList.remove('playing'); + playIcon.textContent = '▶'; + playText.textContent = 'ENGAGE AMBIENCE'; + statAudioStatus.textContent = 'STANDBY'; + statAudioStatus.style.color = '#ffcc00'; + } + } + + btnPlay.addEventListener('click', togglePlay); + + // 6. Master Volume Control System + function updateMasterVolumeUI(val, isMuted = false) { + sliderMasterVol.value = val; + const pct = Math.round(val * 100); + valMasterVol.textContent = isMuted ? 'MUTED' : `${pct}%`; + headerVolVal.textContent = isMuted ? 'MUTE' : `${pct}%`; + + if (isMuted || val <= 0.001) { + valMasterDb.textContent = '-∞ dB'; + } else { + const db = 20 * Math.log10(val); + valMasterDb.textContent = `${db >= 0 ? '+' : ''}${db.toFixed(1)} dB`; + } + + btnMasterMute.classList.toggle('muted', isMuted); + btnHeaderMute.classList.toggle('muted', isMuted); + btnMasterMute.textContent = isMuted ? 'UNMUTE' : 'MUTE'; + btnHeaderMute.textContent = isMuted ? 'MUTED' : 'MUTE'; + + if (volMeterLeds) { + const pips = volMeterLeds.querySelectorAll('.led-pip'); + const activeCount = isMuted ? 0 : Math.round(val * pips.length); + pips.forEach((pip, idx) => { + pip.classList.toggle('active', idx < activeCount); + }); + } + + volStepButtons.forEach(btn => { + const stepVal = parseFloat(btn.getAttribute('data-vol')); + btn.classList.toggle('active', !isMuted && Math.abs(stepVal - val) < 0.04); + }); + } + + function setMasterVolume(val) { + const clamped = Math.max(0, Math.min(1, val)); + if (audioManager.isMuted) audioManager.setMute(false); + audioManager.setMasterVolume(clamped); + updateMasterVolumeUI(clamped, false); + } + + function toggleMute() { + const isMuted = audioManager.toggleMute(); + updateMasterVolumeUI(audioManager.getMasterVolume(), isMuted); + } + + sliderMasterVol.addEventListener('input', (e) => setMasterVolume(parseFloat(e.target.value))); + btnMasterMute.addEventListener('click', toggleMute); + btnHeaderMute.addEventListener('click', toggleMute); + btnVolDown.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() - 0.05)); + btnVolUp.addEventListener('click', () => setMasterVolume(audioManager.getMasterVolume() + 0.05)); + + volStepButtons.forEach(btn => { + btn.addEventListener('click', () => { + const stepVal = parseFloat(btn.getAttribute('data-vol')); + setMasterVolume(stepVal); + }); + }); + + // 7. Channel Synthesizer Sliders + sliderHullVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + hullDrone.setVolume(val); + valHullVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderHullFreq.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + hullDrone.setBaseFreq(val); + valHullFreq.textContent = `${val} Hz`; + }); + sliderHullCutoff.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + hullDrone.setFilterCutoff(val); + valHullCutoff.textContent = `${val} Hz`; + }); + + sliderWarpVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + warpCore.setVolume(val); + valWarpVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderWarpBpm.addEventListener('input', (e) => { + const val = parseInt(e.target.value); + warpCore.setBpm(val); + valWarpBpm.textContent = `${val} BPM`; + }); + sliderWarpCarrier.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + warpCore.setCarrierFreq(val); + valWarpCarrier.textContent = `${val} Hz`; + statCoreFreq.textContent = `${val.toFixed(1)} Hz`; + }); + + sliderAirVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + lifeSupport.setVolume(val); + valAirVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderAirCutoff.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + lifeSupport.setFilterCutoff(val); + valAirCutoff.textContent = `${val} Hz`; + }); + + sliderTelemetryVol.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + telemetry.setVolume(val); + valTelemetryVol.textContent = `${Math.round(val * 100)}%`; + }); + sliderTelemetryDensity.addEventListener('input', (e) => { + const val = parseFloat(e.target.value); + telemetry.setDensity(val); + valTelemetryDensity.textContent = `${Math.round(val * 100)}%`; + }); + + // 8. Universe Event Handlers + function resetAlertButtons() { + document.querySelectorAll('.btn-lcars-pill').forEach(b => b.classList.remove('active-alert')); + } + + function handleUniverseEvent(type, btnElement) { + switch (type) { + case 'warp': + case 'quantum': + alerts.synthesizeWarpJump(); + visualizer.spawnWarpPulses(); + break; + case 'alert-red': + case 'action-stations': + case 'scramble': + if (alerts.activeAlert === 'red') { + alerts.stopAlert(); + resetAlertButtons(); + } else { + resetAlertButtons(); + btnElement.classList.add('active-alert'); + alerts.triggerRedAlert('tng'); + } + break; + case 'alert-yellow': + if (alerts.activeAlert === 'yellow') { + alerts.stopAlert(); + resetAlertButtons(); + } else { + resetAlertButtons(); + btnElement.classList.add('active-alert'); + alerts.triggerYellowAlert(); + } + break; + case 'demat': + whoniverseAudio.synthesizeDematCycle(4); + visualizer.spawnWarpPulses(); + break; + case 'vortex': + whoniverseAudio.synthesizeDematCycle(1); + visualizer.spawnWarpPulses(); + break; + case 'cloister': + if (whoniverseAudio.activeCloister) { + whoniverseAudio.stopCloisterBell(); + resetAlertButtons(); + } else { + resetAlertButtons(); + btnElement.classList.add('active-alert'); + whoniverseAudio.triggerCloisterBell(); + } + break; + case 'epstein': + case 'subdrive': + expandedAudio.synthesizeEpsteinBurn(); + visualizer.spawnWarpPulses(); + break; + case 'purge': + case 'decompress': + case 'docking': + expandedAudio.synthesizeDockingClamp(); + break; + case 'vent': + whoniverseAudio.synthesizeFastReturn(); + break; + case 'starburst': + case 'biodefense': + expandedAudio.synthesizeStarburst(); + visualizer.spawnWarpPulses(); + break; + case 'neural': + whoniverseAudio.synthesizeTelepathicChime(); + break; + case 'centrifuge': + case 'astrogator': + expandedAudio.synthesizeRetroAstrogator(); + break; + case 'hal': + expandedAudio.synthesizeHalChime(); + break; + case 'ftl-jump': + case 'flak': + expandedAudio.synthesizeFtlJump(); + visualizer.spawnWarpPulses(); + break; + case 'singularity': + case 'gravity': + case 'rotation': + expandedAudio.synthesizeSingularityEngage(); + visualizer.spawnWarpPulses(); + break; + case 'solar': + alerts.synthesizeWarpJump(); + break; + case 'afterburner': + expandedAudio.synthesizeAfterburner(); + visualizer.spawnWarpPulses(); + break; + case 'ludicrous': + expandedAudio.synthesizeLudicrousSpeed(); + visualizer.spawnWarpPulses(); + break; + case 'improbability': + expandedAudio.synthesizeImprobabilityFlip(); + visualizer.spawnWarpPulses(); + break; + default: + telemetry.synthesizeLCARSSingleChirp(); + break; + } + } + + function handleSoundboardTrigger(id) { + switch (id) { + case 'btn-chirp-single': + case 'btn-switch-pop': + telemetry.synthesizeLCARSSingleChirp(); + break; + case 'btn-chirp-double': + telemetry.synthesizeLCARSDoubleChirp(); + break; + case 'btn-chirp-ack': + telemetry.synthesizeLCARSSequence(); + break; + case 'btn-chirp-sweep': + telemetry.synthesizeSensorSweep(); + break; + case 'btn-chirp-door': + telemetry.synthesizeDoorChime(); + break; + case 'btn-sonic': + whoniverseAudio.synthesizeSonicScrewdriver(); + break; + case 'btn-fast-return': + whoniverseAudio.synthesizeFastReturn(); + break; + case 'btn-demat-switch': + case 'btn-relay-click': + whoniverseAudio.synthesizeDematSwitch(); + break; + case 'btn-telepathic': + case 'btn-telepathic-chime': + whoniverseAudio.synthesizeTelepathicChime(); + break; + case 'btn-scanner': + whoniverseAudio.synthesizeDematSwitch(); + telemetry.synthesizeSensorSweep(); + break; + case 'btn-geiger': + expandedAudio.synthesizeGeigerBurst(); + break; + case 'btn-dock-clamp': + case 'btn-pneumatic-door': + case 'btn-tape-clunk': + expandedAudio.synthesizeDockingClamp(); + break; + case 'btn-air-handler': + expandedAudio.synthesizeAirHandlerThud(); + break; + case 'btn-dradis-ping': + expandedAudio.synthesizeDradisPing(); + break; + case 'btn-hal-chime': + expandedAudio.synthesizeHalChime(); + break; + case 'btn-cheerful-door': + expandedAudio.synthesizeCheerfulDoor(); + break; + case 'btn-beryllium-pulse': + case 'btn-tea-machine': + expandedAudio.synthesizeImprobabilityFlip(); + break; + case 'btn-tram-gong': + whoniverseAudio.synthesizeCloisterStrike(); + break; + default: + telemetry.synthesizeLCARSSingleChirp(); + break; + } + } + + // 9. Sleep Timer + timerButtons.forEach(btn => { + btn.addEventListener('click', () => { + const minutes = parseInt(btn.getAttribute('data-minutes')); + if (minutes === 0) { + audioManager.stopSleepTimer(); + timerDisplay.textContent = 'TIMER INACTIVE (CONTINUOUS)'; + } else { + audioManager.startSleepTimer( + minutes, + (secondsLeft) => { + const m = Math.floor(secondsLeft / 60); + const s = secondsLeft % 60; + timerDisplay.textContent = `SLEEP TIMER: ${m}:${s < 10 ? '0' : ''}${s}`; + }, + () => { + timerDisplay.textContent = 'SLEEP TIMER COMPLETED'; + if (isPlaying) togglePlay(); + } + ); + } + }); + }); + + + // ========================================================================= + // OBSERVATION MODE + // One passive full-screen view with theme-specific inline SVG displays. + // Deliberately exits only on an actual click/tap (or Escape). + // ========================================================================= + const btnObservation = document.getElementById('btn-observation'); + const observationOverlay = document.getElementById('observation-overlay'); + const observationStage = document.getElementById('observation-stage'); + const observationSimLayer = document.getElementById('observation-sim-layer'); + const observationUniverse = document.getElementById('observation-universe'); + const observationProfile = document.getElementById('observation-profile'); + const observationStatus = document.getElementById('observation-status'); + const observationEventLayer = document.getElementById('observation-event-layer'); + const sliderObservationActivity = document.getElementById('slider-observation-activity'); + const valObservationActivity = document.getElementById('val-observation-activity'); + const toggleObservationHud = document.getElementById('toggle-observation-hud'); + const valObservationHud = document.getElementById('val-observation-hud'); + const observationChrome = observationOverlay.querySelector('.observation-chrome'); + const observationReturn = observationOverlay.querySelector('.observation-return'); + + let observationActive = false; + let observationActivity = 0.60; + window.observationActivity = observationActivity; + let observationAmbientTimer = null; + let observationTransientSerial = 0; + + function obsClamp(v, lo, hi) { + return Math.max(lo, Math.min(hi, v)); + } + + function obsRand(min, max) { + return min + Math.random() * (max - min); + } + + function obsInt(min, max) { + return Math.floor(obsRand(min, max + 1)); + } + + function obsPick(items) { + return items[Math.floor(Math.random() * items.length)]; + } + + function obsHex(n, width = 3) { + return Math.floor(n).toString(16).toUpperCase().padStart(width, '0'); + } + + + // ========================================================================= + // PERSISTENT OBSERVATION SCENE ENGINE + // Original visual worlds, not franchise reproductions. Persistent entities + // use simple stateful motion rather than one-shot CSS decorations. + // ========================================================================= + let observationSceneRaf = null; + let observationSceneLast = 0; + let observationSceneEntities = []; + let observationSceneBoostUntil = 0; + let observationSplineMorphs = []; + + function obsEaseSmooth(t) { + return t * t * (3 - 2 * t); + } + + function obsEaseInCubic(t) { + return t * t * t; + } + + function obsEaseOutCubic(t) { + return 1 - Math.pow(1 - t, 3); + } + + function obsBezierPoint(t, p0, p1, p2, p3) { + const u = 1 - t; + const tt = t * t; + const uu = u * u; + const uuu = uu * u; + const ttt = tt * t; + return { + x: uuu*p0.x + 3*uu*t*p1.x + 3*u*tt*p2.x + ttt*p3.x, + y: uuu*p0.y + 3*uu*t*p1.y + 3*u*tt*p2.y + ttt*p3.y + }; + } + + function obsBezierDerivative(t, p0, p1, p2, p3) { + const u = 1 - t; + return { + x: 3*u*u*(p1.x-p0.x) + 6*u*t*(p2.x-p1.x) + 3*t*t*(p3.x-p2.x), + y: 3*u*u*(p1.y-p0.y) + 6*u*t*(p2.y-p1.y) + 3*t*t*(p3.y-p2.y) + }; + } + + + // ========================================================================= + // LONG-LIVED SPLINE MORPH ENGINE + // Applies only to persistent pathwork in the main observation stage. + // The goal is subtle recalculation/breathing, not chaotic wriggling. + // ========================================================================= + + function obsThemeSplineScale(theme) { + return ({ + starfleet: 0.60, + whoniverse: 1.05, + industrial: 0.48, + bioships: 1.28, + retrofuture: 0.72, + military: 0.44, + deepspace: 0.34, + outlaw: 0.86, + spacestations: 0.50, + comedy: 0.90 + })[theme] || 0.65; + } + + function obsTokenizePath(d) { + return (d.match(/[A-Za-z]|[-+]?(?:\d*\.\d+|\d+)(?:e[-+]?\d+)?/g) || []); + } + + function obsParsePathSegments(d) { + const tokens = obsTokenizePath(d); + const segments = []; + let i = 0; + while (i < tokens.length) { + const cmd = tokens[i++]; + if (!/^[A-Za-z]$/.test(cmd)) break; + const upper = cmd.toUpperCase(); + + let count = 0; + if (upper === 'M' || upper === 'L' || upper === 'T') count = 2; + else if (upper === 'Q' || upper === 'S') count = 4; + else if (upper === 'C') count = 6; + else if (upper === 'H' || upper === 'V') count = 1; + else if (upper === 'Z') count = 0; + else count = 0; + + if (upper == 'Z') { + segments.push({ cmd, values: [] }); + continue; + } + + const values = []; + while (i < tokens.length && !/^[A-Za-z]$/.test(tokens[i])) { + values.push(Number(tokens[i++])); + } + if (!values.length) { + segments.push({ cmd, values: [] }); + continue; + } + + if (count == 0) { + segments.push({ cmd, values }); + continue; + } + + // Split repeated coordinate groups into separate segments. For repeated M, + // SVG treats the subsequent groups as implicit L commands. + for (let start = 0, segIdx = 0; start < values.length; start += count, segIdx++) { + const slice = values.slice(start, start + count); + if (slice.length < count) break; + const segCmd = (upper === 'M' && segIdx > 0) ? (cmd === upper ? 'L' : 'l') : cmd; + segments.push({ cmd: segCmd, values: slice }); + } + } + return segments; + } + + function obsBuildPathFromSegments(segments) { + return segments.map(seg => { + if (!seg.values || !seg.values.length) return seg.cmd; + const nums = seg.values.map(v => { + const n = Math.abs(v) < 1e-6 ? 0 : v; + return Number(n.toFixed(2)).toString(); + }).join(' '); + return `${seg.cmd}${nums}`; + }).join(' '); + } + + function obsCreateSplineMorphState(pathEl) { + if (!pathEl) return null; + const d = pathEl.getAttribute('d'); + if (!d || !/[CQST]/i.test(d)) return null; + + const segments = obsParsePathSegments(d); + if (!segments.length) return null; + + const bbox = pathEl.getBBox(); + const width = Math.max(80, bbox.width || 80); + const height = Math.max(80, bbox.height || 80); + const routeLike = pathEl.classList.contains('obs-route-wander'); + const themeScale = obsThemeSplineScale(activeUniverseId); + const kindScale = routeLike ? 1.0 : 0.7; + + let drawable = segments + .map((seg, idx) => ({ idx, upper: seg.cmd.toUpperCase(), count: seg.values.length })) + .filter(item => ['Q', 'T', 'S', 'C'].includes(item.upper)); + const lastDrawableIdx = drawable.length ? drawable[drawable.length - 1].idx : -1; + + const ampFractions = []; + segments.forEach((seg, segIdx) => { + const upper = seg.cmd.toUpperCase(); + let frac = []; + + if (upper === 'M' || upper === 'L') { + frac = new Array(seg.values.length).fill(0); + } else if (upper === 'C') { + frac = [1, 1, 1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18]; + } else if (upper === 'S') { + frac = [1, 1, segIdx === lastDrawableIdx ? 0 : 0.18, segIdx === lastDrawableIdx ? 0 : 0.18]; + } else if (upper === 'Q') { + frac = [0.92, 0.92, segIdx === lastDrawableIdx ? 0 : 0.15, segIdx === lastDrawableIdx ? 0 : 0.15]; + } else if (upper === 'T') { + frac = [segIdx === lastDrawableIdx ? 0 : 0.14, segIdx === lastDrawableIdx ? 0 : 0.14]; + } else { + frac = new Array(seg.values.length).fill(0); + } + ampFractions.push(frac); + }); + + const offsets = segments.map(seg => new Array(seg.values.length).fill(0)); + const targets = segments.map(seg => new Array(seg.values.length).fill(0)); + + function amplitudeFor(segIndex, valueIndex) { + const seg = segments[segIndex]; + const frac = (ampFractions[segIndex] && ampFractions[segIndex][valueIndex]) || 0; + if (frac <= 0) return 0; + const upper = seg.cmd.toUpperCase(); + const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y'); + const span = axis === 'x' ? width : height; + const baseFrac = routeLike ? 0.038 : 0.026; + return span * baseFrac * frac * kindScale * themeScale; + } + + return { + el: pathEl, + baseD: d, + segments, + ampFractions, + offsets, + targets, + nextRetargetAt: performance.now() + obsRand(2200, 7800), + lastRetargetAt: performance.now(), + width, + height, + routeLike, + targetMultiplier: 1 + }; + } + + function initializeObservationSplineMorphs() { + observationSplineMorphs = []; + if (!observationStage) return; + + const paths = observationStage.querySelectorAll('path.obs-dashflow, path.obs-route-wander'); + paths.forEach(pathEl => { + const state = obsCreateSplineMorphState(pathEl); + if (state) observationSplineMorphs.push(state); + }); + } + + function stopObservationSplineMorphs() { + observationSplineMorphs.forEach(state => { + if (state.el && state.el.isConnected) state.el.setAttribute('d', state.baseD); + }); + observationSplineMorphs = []; + } + + function retargetObservationSpline(state, now, forceScale = 1) { + const activity = observationActivity; + state.targetMultiplier = forceScale; + state.lastRetargetAt = now; + + state.segments.forEach((seg, segIndex) => { + seg.values.forEach((_, valueIndex) => { + const amp = (function() { + const frac = (state.ampFractions[segIndex] && state.ampFractions[segIndex][valueIndex]) || 0; + if (frac <= 0) return 0; + const upper = seg.cmd.toUpperCase(); + const axis = upper === 'H' ? 'x' : upper === 'V' ? 'y' : (valueIndex % 2 === 0 ? 'x' : 'y'); + const span = axis === 'x' ? state.width : state.height; + const baseFrac = state.routeLike ? 0.038 : 0.026; + return span * baseFrac * frac * (state.routeLike ? 1.0 : 0.7) * obsThemeSplineScale(activeUniverseId) * forceScale; + })(); + + if (amp <= 0) { + state.targets[segIndex][valueIndex] = 0; + return; + } + + const bias = state.routeLike ? 1.0 : 0.75; + state.targets[segIndex][valueIndex] = obsRand(-amp * bias, amp * bias); + }); + }); + + const slow = state.routeLike ? 32000 : 42000; + const fast = state.routeLike ? 8500 : 14000; + const next = slow - (slow - fast) * Math.pow(activity, 0.82); + state.nextRetargetAt = now + next * obsRand(0.70, 1.35); + } + + function updateObservationSplineMorphs(now, speedScale) { + if (!observationSplineMorphs.length) return; + + const themeScale = obsThemeSplineScale(activeUniverseId); + observationSplineMorphs = observationSplineMorphs.filter(state => state.el && state.el.isConnected); + + observationSplineMorphs.forEach(state => { + if (now >= state.nextRetargetAt) { + retargetObservationSpline(state, now, 1); + } + + const follow = (0.012 + observationActivity * 0.030 + Math.max(0, speedScale - 0.4) * 0.006) * themeScale; + + state.segments.forEach((seg, segIndex) => { + seg.values.forEach((baseValue, valueIndex) => { + const current = state.offsets[segIndex][valueIndex]; + const target = state.targets[segIndex][valueIndex]; + state.offsets[segIndex][valueIndex] = current + (target - current) * follow; + }); + }); + + const morphed = state.segments.map((seg, segIndex) => ({ + cmd: seg.cmd, + values: seg.values.map((baseValue, valueIndex) => baseValue + state.offsets[segIndex][valueIndex]) + })); + + state.el.setAttribute('d', obsBuildPathFromSegments(morphed)); + }); + } + + function pulseObservationSplineMorphs() { + if (!observationActive || !observationSplineMorphs.length) return; + + const candidates = observationSplineMorphs.filter(state => state.el && state.el.isConnected); + if (!candidates.length) return; + + const count = observationActivity > 0.72 && Math.random() < observationActivity ? 2 : 1; + const shuffled = [...candidates].sort(() => Math.random() - 0.5); + + for (let i = 0; i < Math.min(count, shuffled.length); i++) { + retargetObservationSpline(shuffled[i], performance.now(), 1.22 + observationActivity * 0.38); + } + } + + + function obsSceneShip(kind='scout', color='#dbeafe', accent='#38bdf8') { + if (kind === 'freighter') { + return ` + + + + + + + `; + } + if (kind === 'shuttle') { + return ` + + + + + `; + } + if (kind === 'tug') { + return ` + + + + + `; + } + return ` + + + + + `; + } + + function obsSceneDefs() { + return ` + + + + + + + + + `; + } + + function obsFlightMarkup(id, shipMarkup, cfg) { + return ` + + ${shipMarkup} + `; + } + + function obsOrbitMarkup(id, body, cfg) { + return ` + + ${body} + `; + } + + function obsFloatMarkup(id, body, cfg) { + return ` + + ${body} + `; + } + + function buildObservationSceneMarkup(theme) { + const defs = obsSceneDefs(); + + if (theme === 'starfleet') { + return `${defs} + + ${obsFlightMarkup('sf-flight-1', obsSceneShip('scout','#e0f2fe','#38bdf8'), { + x0:410,y0:640,x1:520,y1:520,x2:880,y2:245,x3:1110,y3:330,duration:26000,delay:900,mode:'arrival',scale0:.35,scale1:.75,minActivity:.15 + })} + ${obsFlightMarkup('sf-flight-2', obsSceneShip('shuttle','#fde68a','#f97316'), { + x0:1190,y0:600,x1:1030,y1:500,x2:680,y2:420,x3:510,y3:260,duration:31000,delay:4600,mode:'cruise',scale0:.28,scale1:.55,minActivity:.35 + })} + ${obsOrbitMarkup('sf-orbit-1', ``, { + cx:800,cy:455,rx:245,ry:155,duration:24000,phase:.2,scale0:.65,scale1:1.05,minActivity:.05 + })} + ${obsOrbitMarkup('sf-orbit-2', ``, { + cx:800,cy:455,rx:165,ry:245,duration:39000,phase:.62,scale0:.5,scale1:.9,minActivity:.5 + })} + `; + } + + if (theme === 'whoniverse') { + const glyph = (char,color,size) => `${char}`; + return `${defs} + ${obsOrbitMarkup('who-glyph-1', glyph('◎','#00e5ff',38), {cx:800,cy:455,rx:335,ry:160,duration:23000,phase:.1,scale0:.55,scale1:1.2,minActivity:.05})} + ${obsOrbitMarkup('who-glyph-2', glyph('∆','#d4af37',30), {cx:800,cy:455,rx:255,ry:280,duration:31000,phase:.42,scale0:.5,scale1:1,minActivity:.2})} + ${obsOrbitMarkup('who-glyph-3', glyph('∞','#00e5ff',28), {cx:800,cy:455,rx:420,ry:110,duration:39000,phase:.73,scale0:.4,scale1:1.15,minActivity:.45})} + ${obsFloatMarkup('who-fragment-1', ``, + {cx:480,cy:520,ampX:95,ampY:75,duration:17000,phase:.35,minActivity:.25})} + ${obsFloatMarkup('who-fragment-2', ``, + {cx:1110,cy:330,ampX:70,ampY:110,duration:21000,phase:.8,minActivity:.55})}`; + } + + if (theme === 'industrial') { + // v13co: the sim layer sits ABOVE the stage (z-index 2 vs 1), so anything + // placed here draws over the camera console. Every entity is therefore + // parented to a clip matching one feed's picture area. The v12 entities + // (a wheeled cart, a crane hook, a fan and two steam blobs) were free + // floating in screen space, which is what made the smoke look detached + // and put a rolling "cart" across the cargo bay. They are replaced by + // motion that belongs to a specific feed. + const feedClip = (id, x, y) => + ``; + return `${defs} + + ${feedClip('indSimCam1', 290, 259)} + ${feedClip('indSimCam2', 825, 259)} + ${feedClip('indSimCam3', 290, 483)} + ${feedClip('indSimCam4', 825, 483)} + + + ${obsFloatMarkup('ind-bay-drone', ` + + + + + `, {cx:532,cy:420,ampX:186,ampY:11,duration:31000,phase:.15,minActivity:.2,opacity:.66})} + + + ${obsFloatMarkup('ind-ember-1', ``, + {cx:1035,cy:352,ampX:38,ampY:52,duration:13000,phase:.2,minActivity:.25,opacity:.6})} + ${obsFloatMarkup('ind-ember-2', ``, + {cx:1092,cy:320,ampX:46,ampY:44,duration:17000,phase:.62,minActivity:.45,opacity:.5})} + ${obsFloatMarkup('ind-ember-3', ``, + {cx:1160,cy:372,ampX:30,ampY:40,duration:21000,phase:.85,minActivity:.6,opacity:.42})} + + + ${obsFloatMarkup('ind-lock-tell', ` + `, + {cx:532,cy:640,ampX:0,ampY:13,duration:23000,phase:.3,minActivity:.5,opacity:.4})} + + + ${obsFloatMarkup('ind-corridor-lamp', ` + `, + {cx:1067,cy:600,ampX:118,ampY:26,duration:27000,phase:.4,minActivity:.3,opacity:.55})} + `; + } + + if (theme === 'bioships') { + const particle = (color,r=5) => ``; + return `${defs} + ${obsFlightMarkup('bio-flow-1', particle('#a7f3d0',5), {x0:300,y0:520,x1:520,y1:220,x2:760,y2:680,x3:1110,y3:350,duration:15000,delay:0,mode:'cruise',scale0:.7,scale1:1,minActivity:.05,opacity:.7})} + ${obsFlightMarkup('bio-flow-2', particle('#10b981',4), {x0:1240,y0:610,x1:970,y1:430,x2:880,y2:720,x3:480,y3:390,duration:18000,delay:1600,mode:'cruise',scale0:.5,scale1:.9,minActivity:.15,opacity:.65})} + ${obsFlightMarkup('bio-flow-3', particle('#8b5cf6',6), {x0:340,y0:290,x1:620,y1:570,x2:920,y2:180,x3:1300,y3:510,duration:22000,delay:4200,mode:'cruise',scale0:.55,scale1:1.05,minActivity:.35,opacity:.58})} + ${obsOrbitMarkup('bio-node-1', ``, {cx:800,cy:470,rx:210,ry:125,duration:19000,phase:.3,scale0:.65,scale1:1.2,minActivity:.3})} + ${obsFloatMarkup('bio-membrane', ``, + {cx:800,cy:470,ampX:26,ampY:18,duration:13000,phase:.1,minActivity:.55,opacity:.6})}`; + } + + if (theme === 'retrofuture') { + const vectorShip = ``; + return `${defs} + ${obsFlightMarkup('retro-v1', vectorShip, {x0:350,y0:610,x1:510,y1:430,x2:950,y2:330,x3:1210,y3:500,duration:25000,delay:0,mode:'cruise',scale0:.5,scale1:.8,minActivity:.1,opacity:.65})} + ${obsFlightMarkup('retro-v2', vectorShip, {x0:1180,y0:260,x1:1010,y1:570,x2:610,y2:610,x3:430,y3:330,duration:33000,delay:4300,mode:'cruise',scale0:.35,scale1:.65,minActivity:.45,opacity:.52})} + ${obsOrbitMarkup('retro-blip', ``, {cx:800,cy:460,rx:235,ry:235,duration:30000,phase:.15,scale0:.55,scale1:.95,minActivity:.05})} + ${obsFloatMarkup('retro-liss', ``, + {cx:800,cy:460,ampX:45,ampY:25,duration:14000,phase:.42,minActivity:.7,opacity:.5})}`; + } + + if (theme === 'military') { + const tri = ``; + return `${defs} + ${obsFlightMarkup('mil-form', ` + + ${tri} + ${tri} + ${tri} + ${tri} + `, {x0:390,y0:620,x1:550,y1:410,x2:900,y2:290,x3:1160,y3:390,duration:28000,delay:800,mode:'cruise',scale0:.4,scale1:.7,minActivity:.15,opacity:.65})} + ${obsFlightMarkup('mil-hostile', ``, + {x0:1190,y0:650,x1:1020,y1:520,x2:660,y2:430,x3:470,y3:240,duration:22000,delay:6200,mode:'arrival',scale0:.35,scale1:.6,minActivity:.45,opacity:.58})} + ${obsOrbitMarkup('mil-cap', ``, + {cx:800,cy:470,rx:300,ry:185,duration:41000,phase:.5,scale0:.45,scale1:.75,minActivity:.65,opacity:.52})}`; + } + + if (theme === 'deepspace') { + return `${defs} + ${obsFlightMarkup('deep-ship', obsSceneShip('shuttle','#e0e7ff','#818cf8'), { + x0:230,y0:570,x1:520,y1:500,x2:900,y2:350,x3:1370,y3:300,duration:52000,delay:0,mode:'cruise',scale0:.12,scale1:.28,minActivity:.2,opacity:.6 + })} + ${obsFlightMarkup('deep-comet', ``, { + x0:180,y0:700,x1:480,y1:520,x2:930,y2:230,x3:1490,y3:150,duration:36000,delay:7500,mode:'cruise',scale0:.55,scale1:.9,minActivity:.5,opacity:.52 + })} + ${obsFloatMarkup('deep-planet', ` + + + + + `, {cx:1200,cy:590,ampX:80,ampY:24,duration:95000,phase:.22,minActivity:.05,opacity:.55})}`; + } + + if (theme === 'outlaw') { + return `${defs} + ${obsFlightMarkup('out-runner', ` + + + + + `, {x0:300,y0:610,x1:480,y1:430,x2:940,y2:300,x3:1290,y3:410,duration:31000,delay:1400,mode:'cruise',scale0:.35,scale1:.75,minActivity:.1,opacity:.62})} + ${obsFlightMarkup('out-pursuer', obsSceneShip('scout','#f9a8d4','#8b5cf6'), { + x0:1280,y0:590,x1:1070,y1:520,x2:730,y2:520,x3:420,y3:310,duration:27000,delay:7900,mode:'arrival',scale0:.25,scale1:.52,minActivity:.5,opacity:.5 + })} + ${obsFloatMarkup('out-needle', ` + + + + + `, {cx:1250,cy:535,ampX:8,ampY:4,duration:9000,phase:.2,minActivity:.25,opacity:.58})}`; + } + + if (theme === 'spacestations') { + const clipOpen = ``; + const clipClose = ``; + return `${defs}${clipOpen} + ${obsFlightMarkup('st-arrival', obsSceneShip('shuttle','#e0f2fe','#38bdf8'), { + x0:270,y0:520,x1:500,y1:455,x2:920,y2:380,x3:1195,y3:415,duration:36000,delay:0,mode:'arrival',scale0:.16,scale1:.62,minActivity:.05,opacity:.75 + })} + ${obsFlightMarkup('st-depart', obsSceneShip('tug','#bae6fd','#f97316'), { + x0:1180,y0:485,x1:1030,y1:430,x2:650,y2:310,x3:260,y3:255,duration:30000,delay:8400,mode:'depart',scale0:.58,scale1:.16,minActivity:.2,opacity:.68 + })} + ${obsFlightMarkup('st-freighter', obsSceneShip('freighter','#cbd5e1','#0ea5e9'), { + x0:185,y0:255,x1:530,y1:210,x2:920,y2:230,x3:1420,y3:300,duration:52000,delay:3800,mode:'cruise',scale0:.26,scale1:.42,minActivity:.4,opacity:.55 + })} + ${obsOrbitMarkup('st-hold', obsSceneShip('shuttle','#7dd3fc','#fdba74'), { + cx:800,cy:410,rx:290,ry:145,duration:43000,phase:.18,scale0:.18,scale1:.32,minActivity:.62,opacity:.5 + })} + ${obsFlightMarkup('st-nearpass', obsSceneShip('freighter','#f1f5f9','#38bdf8'), { + x0:170,y0:650,x1:470,y1:565,x2:950,y2:535,x3:1450,y3:600,duration:42000,delay:14500,mode:'cruise',scale0:.42,scale1:.78,minActivity:.78,opacity:.48 + })} + ${clipClose}`; + } + + if (theme === 'comedy') { + return `${defs} + ${obsFlightMarkup('com-tour', ` + + + + + `, {x0:260,y0:600,x1:520,y1:255,x2:1030,y2:670,x3:1350,y3:330,duration:36000,delay:1200,mode:'cruise',scale0:.32,scale1:.62,minActivity:.08,opacity:.65})} + ${obsOrbitMarkup('com-orbit', `?`, + {cx:800,cy:445,rx:330,ry:160,duration:33000,phase:.35,scale0:.65,scale1:1.15,minActivity:.45,opacity:.58})} + ${obsFloatMarkup('com-cube', ``, + {cx:1120,cy:570,ampX:90,ampY:70,duration:18000,phase:.7,minActivity:.7,opacity:.52})}`; + } + + return defs; + } + + function registerObservationSceneEntities() { + observationSceneEntities = []; + if (!observationSimLayer) return; + + observationSimLayer.querySelectorAll('[data-obs-kind]').forEach(el => { + const d = el.dataset; + const sceneNow = performance.now(); + const common = { + el, + kind: d.obsKind, + minActivity: Number(d.minactivity || 0), + baseOpacity: Number(d.opacity || .7), + phase: Number(d.phase || 0), + startedAt: sceneNow, + inactiveUntil: sceneNow + }; + + if (d.obsKind === 'flight') { + const firstStart = sceneNow + Number(d.delay || 0); + observationSceneEntities.push({ + ...common, + startedAt: firstStart, + inactiveUntil: firstStart, + p0:{x:Number(d.x0),y:Number(d.y0)}, + p1:{x:Number(d.x1),y:Number(d.y1)}, + p2:{x:Number(d.x2),y:Number(d.y2)}, + p3:{x:Number(d.x3),y:Number(d.y3)}, + duration:Number(d.duration || 30000), + mode:d.mode || 'cruise', + scale0:Number(d.scale0 || 1), + scale1:Number(d.scale1 || 1), + cycle:0 + }); + } else if (d.obsKind === 'orbit') { + observationSceneEntities.push({ + ...common, + cx:Number(d.cx),cy:Number(d.cy), + rx:Number(d.rx),ry:Number(d.ry), + duration:Number(d.duration || 30000), + scale0:Number(d.scale0 || 1), + scale1:Number(d.scale1 || 1) + }); + } else if (d.obsKind === 'float') { + observationSceneEntities.push({ + ...common, + cx:Number(d.cx),cy:Number(d.cy), + ampX:Number(d.ampx || 0),ampY:Number(d.ampy || 0), + duration:Number(d.duration || 20000) + }); + } + }); + } + + function observationSceneResetFlight(entity, now) { + entity.cycle += 1; + const restBase = 5500 - observationActivity * 3500; + entity.inactiveUntil = now + obsRand(restBase * .35, restBase * 1.35); + entity.startedAt = entity.inactiveUntil; + } + + function updateObservationSceneEntity(entity, now, speedScale) { + const visibleByActivity = observationActivity + .001 >= entity.minActivity; + if (!visibleByActivity) { + entity.el.style.opacity = '0'; + return; + } + + if (entity.kind === 'flight') { + if (now < entity.inactiveUntil) { + entity.el.style.opacity = '0'; + return; + } + + const elapsed = (now - entity.startedAt) * speedScale; + let raw = elapsed / entity.duration; + + if (raw >= 1) { + observationSceneResetFlight(entity, now); + entity.el.style.opacity = '0'; + return; + } + + raw = obsClamp(raw, 0, 1); + let t = raw; + if (entity.mode === 'arrival') t = obsEaseOutCubic(raw); + else if (entity.mode === 'depart') t = obsEaseInCubic(raw); + else t = obsEaseSmooth(raw); + + const p = obsBezierPoint(t, entity.p0, entity.p1, entity.p2, entity.p3); + const v = obsBezierDerivative(t, entity.p0, entity.p1, entity.p2, entity.p3); + const angle = Math.atan2(v.y, v.x) * 180 / Math.PI; + const scale = entity.scale0 + (entity.scale1 - entity.scale0) * t; + + const edgeFade = Math.min(1, raw / .08, (1-raw) / .08); + const opacity = entity.baseOpacity * obsClamp(edgeFade,0,1); + entity.el.style.opacity = String(opacity); + entity.el.setAttribute('transform', `translate(${p.x.toFixed(2)} ${p.y.toFixed(2)}) rotate(${angle.toFixed(2)}) scale(${scale.toFixed(3)})`); + return; + } + + if (entity.kind === 'orbit') { + const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) % 1; + const a = cycle * Math.PI * 2; + const x = entity.cx + Math.cos(a) * entity.rx; + const y = entity.cy + Math.sin(a) * entity.ry; + const depth = (Math.sin(a) + 1) / 2; + const scale = entity.scale0 + (entity.scale1 - entity.scale0) * depth; + entity.el.style.opacity = String(entity.baseOpacity * (.55 + depth * .45)); + entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)}) scale(${scale.toFixed(3)})`); + return; + } + + if (entity.kind === 'float') { + const cycle = ((now - entity.startedAt) * speedScale / entity.duration + entity.phase) * Math.PI * 2; + const x = entity.cx + Math.sin(cycle) * entity.ampX; + const y = entity.cy + Math.cos(cycle * .73) * entity.ampY; + entity.el.style.opacity = String(entity.baseOpacity); + entity.el.setAttribute('transform', `translate(${x.toFixed(2)} ${y.toFixed(2)})`); + } + } + + function observationSceneFrame(now) { + observationSceneRaf = null; + if (!observationActive || !observationSimLayer) return; + + const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + const baseSpeed = reduceMotion ? .08 : (.32 + observationActivity * 1.18); + const boost = now < observationSceneBoostUntil ? .38 : 0; + const speedScale = baseSpeed + boost; + + for (const entity of observationSceneEntities) { + updateObservationSceneEntity(entity, now, speedScale); + } + + updateObservationSplineMorphs(now, speedScale); + + observationSceneLast = now; + observationSceneRaf = requestAnimationFrame(observationSceneFrame); + } + + function startObservationScene() { + stopObservationScene(); + if (!observationSimLayer) return; + observationSimLayer.innerHTML = ``; + registerObservationSceneEntities(); + observationSceneLast = performance.now(); + observationSceneRaf = requestAnimationFrame(observationSceneFrame); + } + + function stopObservationScene() { + if (observationSceneRaf) { + cancelAnimationFrame(observationSceneRaf); + observationSceneRaf = null; + } + observationSceneEntities = []; + if (observationSimLayer) observationSimLayer.innerHTML = ''; + } + + function pulseObservationScene() { + if (!observationActive) return; + observationSceneBoostUntil = performance.now() + 1200 + observationActivity * 900; + + const eligible = observationSceneEntities.filter(e => observationActivity + .001 >= e.minActivity); + if (!eligible.length) return; + const chosen = obsPick(eligible); + + // Telemetry can wake a waiting scene object early without assigning the + // sound a literal fictional meaning. + if (chosen.kind === 'flight' && performance.now() < chosen.inactiveUntil && Math.random() < .55) { + const wakeNow = performance.now(); + chosen.inactiveUntil = wakeNow; + chosen.startedAt = wakeNow; + } + } + + + function transientSvg(body) { + return ``; + } + + function spawnObservationTransient(body, lifetime = 4200) { + if (!observationActive || !observationEventLayer) return; + const node = document.createElement('div'); + node.className = 'obs-transient'; + node.dataset.serial = String(++observationTransientSerial); + node.style.setProperty('--obs-life', `${lifetime}ms`); + node.innerHTML = transientSvg(body); + observationEventLayer.appendChild(node); + + const maxTransient = 5 + Math.round(observationActivity * 8); + while (observationEventLayer.children.length > maxTransient) { + observationEventLayer.firstElementChild.remove(); + } + + window.setTimeout(() => node.remove(), lifetime + 300); + } + + function obsTextCard(x, y, title, line, color = 'var(--primary-accent)', width = 290) { + return ` + + + + + ${title} + ${line} + + `; + } + + function obsExhibitX(width = 290) { + const leftMargin = 240; + const rightMargin = 240; + return obsInt(leftMargin, 1600 - rightMargin - width); + } + + function obsExhibitY(height = 82) { + const topMargin = 220; + const bottomMargin = 190; + return obsInt(topMargin, 900 - bottomMargin - height); + } + + function obsExhibitCardPosition(width = 290, height = 82) { + return { x: obsExhibitX(width), y: obsExhibitY(height) }; + } + + function obsContact(x, y, color = 'var(--primary-accent)', label = 'CONTACT') { + const size = obsInt(26, 54); + return ` + + + + + + ${label} + + `; + } + + function obsExpandingRing(x, y, color = 'var(--primary-accent)', radius = 70) { + return ` + + + + `; + } + + function generateStarfleetActivity(power) { + const effect = obsPick(['contact','vector','data','ring','diagnostic','streak']); + if (effect === 'contact') { + const x = obsInt(420,1180), y = obsInt(230,690); + return spawnObservationTransient( + obsContact(x,y,'var(--primary-accent)',`CONTACT ${obsInt(2,99).toString().padStart(2,'0')}`) + + ``, + 4300 + ); + } + if (effect === 'vector') { + const y1=obsInt(260,680), y2=obsInt(220,690); + return spawnObservationTransient(` + + + `, 5200); + } + if (effect === 'data') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x, pos.y, + obsPick(['SENSOR UPDATE','NAV SOLUTION','SYSTEM QUERY','PASSIVE ARRAY']), + obsPick([ + `BEARING ${obsInt(0,359).toString().padStart(3,'0')}.${obsInt(0,9)}°`, + `RANGE ${obsInt(2,980)}.${obsInt(0,9)} Mm`, + `SUBSPACE VAR ${obsRand(.01,.99).toFixed(3)}`, + `VECTOR ${obsInt(100,999)}-${obsInt(10,99)}` + ]) + ), 4200); + } + if (effect === 'diagnostic') { + const bars = Array.from({length: obsInt(5,9)}, (_,i) => + ``).join(''); + return spawnObservationTransient(`${bars}`, 3500); + } + if (effect === 'streak') { + const lines = Array.from({length: obsInt(8,16)}, () => { + const y=obsInt(160,760), x=obsInt(150,1300), len=obsInt(70,240); + return ``; + }).join(''); + return spawnObservationTransient(`${lines}`, 3000); + } + return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(250,680),'var(--primary-accent)',obsInt(45,90)), 3200); + } + + function generateWhoniverseActivity(power) { + const effect = obsPick(['echo','glyphs','coordinate','warp','rings']); + if (effect === 'echo') { + const x=obsInt(430,1170), y=obsInt(270,650); + return spawnObservationTransient(` + ${[0,1,2].map(i=>` + + `).join('')} + `, 4700); + } + if (effect === 'glyphs') { + const glyphs = Array.from({length: obsInt(4,9)}, (_,i) => + `${obsPick(['◉','◎','⌁','∆','∴','∞','⊙','⊛'])}` + ).join(''); + return spawnObservationTransient(glyphs, 6500); + } + if (effect === 'coordinate') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x, pos.y, 'TEMPORAL COORDINATE', + `${obsInt(0,99)}:${obsInt(0,99)}:${obsInt(0,99)} ∴ ${obsInt(1,13)}.${obsInt(0,9)}`, '#00e5ff' + ), 4300); + } + if (effect === 'warp') { + return spawnObservationTransient(` + + + + + + `, 4400); + } + return spawnObservationTransient(obsExpandingRing(800,450,obsPick(['#00e5ff','#d4af37']),obsInt(60,120)), 3500); + } + + function generateIndustrialActivity(power) { + // v13co: every industrial transient is clipped to ONE feed's picture area. + // In v12 these drew in raw screen space, so steam puffs and signal bars + // spilled across bezels and the console furniture. + const feeds = [ + {ix:290, iy:259, id:'CAM 01', where:'CARGO BAY 2'}, + {ix:825, iy:259, id:'CAM 02', where:'REACTOR ACCESS'}, + {ix:290, iy:483, id:'CAM 03', where:'AIRLOCK C'}, + {ix:825, iy:483, id:'CAM 04', where:'MACHINE CORRIDOR'} + ]; + const f = obsPick(feeds); + const W = 485, H = 189; + const clipId = `indFx-${Math.random().toString(36).slice(2, 9)}`; + const inFeed = (body) => ` + + + + + + + + + ${body}`; + + const canCycle = (f.id === 'CAM 01' || f.id === 'CAM 03'); + const effect = obsPick(['signal', 'dropout', 'vapour', 'motion', 'gain', 'warning'] + .concat(canCycle ? ['door'] : [])); + + // Digital break-up: displaced scan blocks plus a recalibration caption. + if (effect === 'signal') { + const blocks = Array.from({length: obsInt(9, 15)}, () => { + const by = obsInt(0, H - 14); + return ``; + }).join(''); + return spawnObservationTransient(inFeed(` + + ${blocks} + + SIGNAL RECALIBRATION + `), 3600); + } + + // Total feed loss, then reacquire. + if (effect === 'dropout') { + return spawnObservationTransient(inFeed(` + + + NO SIGNAL + ${f.id} // REACQUIRING + + `), 2600); + } + + // A vent release inside the room the camera is actually looking at. + if (effect === 'vapour') { + const x = obsInt(90, W - 90), y = H - obsInt(16, 40); + const puffs = Array.from({length: obsInt(6, 11)}, (_, i) => { + const dur = obsRand(2.8, 5.2).toFixed(1); + return ``; + }).join(''); + return spawnObservationTransient(inFeed(` + ${puffs} + + `), 6200); + } + + // Motion detection bracket that tracks across the picture. + if (effect === 'motion') { + const bx = obsInt(40, W - 190), by = obsInt(46, H - 130); + return spawnObservationTransient(inFeed(` + + + + + + + + MOTION // ${f.id} + `), 4600); + } + + // Pressure-door cycle seen head on. + if (effect === 'door') { + const mid = W / 2, top = 58, bot = H - 52; + return spawnObservationTransient(inFeed(` + + + + + + + + + + + HATCH CYCLE // ${f.where} + `), 4800); + } + + // Auto-iris hunting after a lighting change. + if (effect === 'gain') { + return spawnObservationTransient(inFeed(` + + AGC // IRIS ADJUST + `), 2400); + } + + // Plant readout card — stays on the console rail, not over a feed. + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x, pos.y, + obsPick(['PRESSURE TRANSIENT', 'COOLANT LOOP', 'MAINTENANCE BUS', 'LOCAL POWER', 'GANTRY INTERLOCK']), + obsPick([ + `P-${obsInt(1, 9)} ${obsInt(30, 98)}%`, + `FLOW ${obsInt(120, 980)} L/M`, + `BUS ${obsHex(obsInt(100, 999))} NOMINAL`, + `VALVE ${obsInt(1, 44)} CYCLING`, + `HOIST ${obsInt(1, 4)} LOAD ${obsRand(.4, 4.2).toFixed(1)} T` + ]), + '#ffaa00' + ), 4500); + } + + function generateBioshipActivity(power) { + const effect = obsPick(['neural','spores','ripple','tendril','organ','metric']); + if (effect === 'neural') { + const y1=obsInt(260,650), y2=obsInt(260,650); + return spawnObservationTransient(` + + `, 3300); + } + if (effect === 'spores') { + const spores=Array.from({length:obsInt(10,24)},()=>` + `).join(''); + return spawnObservationTransient(spores, 7000); + } + if (effect === 'tendril') { + const sx=obsPick([180,1420]), sy=obsInt(300,660); + return spawnObservationTransient(` + + `, 5200); + } + if (effect === 'organ') { + return spawnObservationTransient(` + + + + + + + `, 5000); + } + if (effect === 'metric') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x, pos.y, obsPick(['NEURAL RESPONSE','METABOLIC FIELD','SYMBIOTIC VARIANCE','BIOELECTRIC FLUX']), + `${obsRand(0.1,9.9).toFixed(2)} // ${obsInt(20,99)}%`, '#10b981' + ), 4300); + } + return spawnObservationTransient(obsExpandingRing(800,470,obsPick(['#10b981','#8b5cf6','#a7f3d0']),obsInt(55,110)), 3700); + } + + function generateRetroActivity(power) { + const effect = obsPick(['blip','scope','counter','vector','bloom','reel']); + if (effect === 'blip') { + return spawnObservationTransient( + obsContact(obsInt(470,1130),obsInt(260,660),'#86efac',`OBJ ${obsInt(1,99)}`), 4300 + ); + } + if (effect === 'scope') { + const y=obsInt(560,720); + return spawnObservationTransient(` + + `, 4500); + } + if (effect === 'counter') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x, pos.y, obsPick(['ASTROGATOR','VECTOR MEMORY','TAPE INDEX','OBJECT FILE']), + `${obsInt(0,9999).toString().padStart(4,'0')} / ${obsInt(0,9999).toString().padStart(4,'0')}`,'#22c55e' + ),4300); + } + if (effect === 'reel') { + return spawnObservationTransient(` + + + + `, 4800); + } + if (effect === 'bloom') { + return spawnObservationTransient(obsExpandingRing(obsInt(480,1120),obsInt(270,650),'#86efac',obsInt(35,75)),3000); + } + return spawnObservationTransient(` + + `, 5200); + } + + function generateMilitaryActivity(power) { + const effect = obsPick(['contact','intercept','formation','sector','status','sweep']); + if (effect === 'contact') { + const pos = obsExhibitCardPosition(320, 82); + return spawnObservationTransient( + obsContact(obsInt(430,1170),obsInt(240,680),'#eab308',obsPick(['UNKNOWN','TRACK','CONTACT','RETURN'])) + + obsTextCard(pos.x,pos.y,'TACTICAL UPDATE',`TRACK ${obsHex(obsInt(1,4095))} // ${obsInt(2,98)}%`,'#eab308',320), + 4600 + ); + } + if (effect === 'intercept') { + const x=obsInt(450,1150),y=obsInt(250,650); + return spawnObservationTransient(` + + ${obsContact(x,y,'#ef4444','VECTOR')} + `, 4700); + } + if (effect === 'formation') { + const cx=obsInt(600,1000), cy=obsInt(340,600); + return spawnObservationTransient(` + + + + + + + + `, 5200); + } + if (effect === 'sector') { + return spawnObservationTransient(` + + `, 3800); + } + if (effect === 'status') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x,pos.y,obsPick(['TACTICAL ARRAY','TRACK CORRELATION','FORMATION UPDATE','IFF QUERY']), + obsPick([`SECTOR ${obsInt(1,12)} / CLEAR`,`RETURN ${obsHex(obsInt(1,4095))}`,`VECTOR ${obsInt(0,359)}.${obsInt(0,9)}°`,`CONF ${obsInt(34,99)}%`]),'#eab308' + ),4200); + } + return spawnObservationTransient(obsExpandingRing(800,470,'#eab308',obsInt(65,130)),3500); + } + + function generateDeepSpaceActivity(power) { + const effect = obsPick(['anomaly','comet','lens','spectral','planet','signal']); + if (effect === 'anomaly') { + const x=obsInt(430,1180), y=obsInt(230,650); + return spawnObservationTransient(` + + + + + + + `, 6500); + } + if (effect === 'comet') { + return spawnObservationTransient(` + + + + `, 5200); + } + if (effect === 'lens') { + return spawnObservationTransient(` + + + + `, 6800); + } + if (effect === 'planet') { + const y=obsInt(260,620), r=obsInt(45,110); + return spawnObservationTransient(` + + + + + + `, 10000); + } + if (effect === 'spectral') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x,pos.y,obsPick(['SPECTRAL EVENT','GRAVITIC VARIANCE','DEEP FIELD RETURN','OPTICAL TRANSIENT']), + `${obsRand(.001,9.999).toFixed(3)} / ${obsInt(1,999)} ly`,'#818cf8' + ),4800); + } + return spawnObservationTransient(obsExpandingRing(obsInt(420,1180),obsInt(240,660),'#38bdf8',obsInt(45,95)),4000); + } + + function generateOutlawActivity(power) { + const effect = obsPick(['glitch','route','contact','signal','gauge','rear']); + if (effect === 'glitch') { + const bars=Array.from({length:obsInt(8,18)},()=>` + `).join(''); + return spawnObservationTransient(`${bars}`,3000); + } + if (effect === 'route') { + return spawnObservationTransient(` + + ROUTE RECALC... + `,5600); + } + if (effect === 'contact' || effect === 'rear') { + return spawnObservationTransient(obsContact( + obsInt(430,1170),obsInt(240,670),obsPick(['#ec4899','#f59e0b']),'UNKNOWN' + ),4200); + } + if (effect === 'signal') { + const pos = obsExhibitCardPosition(290, 82); + return spawnObservationTransient(obsTextCard( + pos.x,pos.y,obsPick(['TRANSPONDER HIT','SIGNAL QUALITY','NAV PATCH','RADIO BURST']), + `${obsInt(9,99)}% // ${obsHex(obsInt(1,4095))}`,'#ec4899' + ),4300); + } + return spawnObservationTransient(` + + + + `,4500); + } + + function generateStationActivity(power) { + const effect = obsPick(['dock','depart','guidance','traffic','beacon','queue']); + if (effect === 'dock') { + const y = obsInt(250, 520); + return spawnObservationTransient(` + + + + DOCK VECTOR ${obsInt(1,18)} + `, 6200); + } + if (effect === 'depart') { + const y = obsInt(235, 540); + return spawnObservationTransient(` + + + + + + + + + `, 7000); + } + if (effect === 'guidance') { + const x = obsInt(470, 1100), y = obsInt(230, 570); + return spawnObservationTransient(` + + + + + `, 4600); + } + if (effect === 'beacon') { + const x = obsPick([248, 1352]); + const y = obsPick([168, 648]); + return spawnObservationTransient(` + + + + `, 3600); + } + if (effect === 'queue') { + const pos = obsExhibitCardPosition(310, 82); + return spawnObservationTransient(obsTextCard( + pos.x, pos.y, + obsPick(['TRAFFIC CONTROL','DOCK STATUS','PORT AUTHORITY','BERTH SCHEDULER']), + obsPick([ + `QUEUE ${obsInt(2,18)} // CLEARANCE`, + `BAY ${obsInt(1,24)} // ARRIVAL WINDOW`, + `HOLD ARC ${obsRand(0.8,9.9).toFixed(1)} // ROUTING`, + `${obsInt(2,84)} OBJECTS // PORT FLOW` + ]), + '#38bdf8', 310 + ), 4600); + } + const x = obsInt(330, 1270), y = obsInt(205, 610); + return spawnObservationTransient(` + + TRAFFIC FLOW ${obsInt(1,9)} + `, 5200); + } + + function generateComedyActivity(power) { + const effect = obsPick(['route','oddity','planet','status','contact','geometry']); + if (effect === 'route') { + return spawnObservationTransient(` + + `,6000); + } + if (effect === 'oddity') { + const pos = obsExhibitCardPosition(330, 82); + return spawnObservationTransient(obsTextCard( + pos.x,pos.y, + obsPick(['NAVIGATION UPDATE','ENVIRONMENTAL NOTE','SERVICE MESSAGE','SENSOR CONCLUSION']), + obsPick([ + `ROUTE CONFIDENCE ${obsRand(96,103).toFixed(1)}%`, + `TEA PROBABILITY ${obsInt(12,99)}%`, + `MOSTLY NOMINAL`, + `OBJECT: PROBABLY HARMLESS`, + `PANIC INDEX ${obsInt(0,3)} / 10`, + `SCENIC DETOUR +${obsInt(2,48)} MIN` + ]),'#06b6d4',330 + ),5000); + } + if (effect === 'planet') { + return spawnObservationTransient(` + + + + + + `,9200); + } + if (effect === 'geometry') { + const sides=obsPick([3,4,5,6]); + const r=obsInt(55,110), cx=obsInt(500,1100),cy=obsInt(300,620); + const pts=Array.from({length:sides},(_,i)=>{ + const a=-Math.PI/2+i*Math.PI*2/sides; + return `${(cx+Math.cos(a)*r).toFixed(1)},${(cy+Math.sin(a)*r).toFixed(1)}`; + }).join(' '); + return spawnObservationTransient(``,5200); + } + if (effect === 'contact') { + return spawnObservationTransient(obsContact(obsInt(450,1150),obsInt(250,650),'#67e8f9',obsPick(['FRIENDLY?','THING','OBJECT','PROBABLY FINE'])),4300); + } + return spawnObservationTransient(obsExpandingRing(obsInt(450,1150),obsInt(250,650),obsPick(['#06b6d4','#f43f5e','#fbbf24']),obsInt(45,100)),3500); + } + + function triggerObservationActivity(source = 'ambient') { + if (!observationActive || observationActivity <= 0.001) return; + + // Telemetry is a synchronization pulse, but the slider controls whether + // that pulse becomes visible. At maximum every telemetry pulse produces + // a visible response; at low levels many pass quietly. + if (source === 'telemetry') { + const responseChance = 0.18 + observationActivity * 0.82; + if (Math.random() > responseChance) return; + } + + let count = 1; + if (source === 'telemetry' && observationActivity > .55 && Math.random() < observationActivity) count++; + if (observationActivity > .82 && Math.random() < (observationActivity - .7)) count++; + + const generator = { + starfleet: generateStarfleetActivity, + whoniverse: generateWhoniverseActivity, + industrial: generateIndustrialActivity, + bioships: generateBioshipActivity, + retrofuture: generateRetroActivity, + military: generateMilitaryActivity, + deepspace: generateDeepSpaceActivity, + outlaw: generateOutlawActivity, + spacestations: generateStationActivity, + comedy: generateComedyActivity + }[activeUniverseId] || generateStarfleetActivity; + + for (let i = 0; i < count; i++) { + window.setTimeout(() => generator(observationActivity), i * obsInt(120,380)); + } + } + + function scheduleObservationAmbientActivity() { + if (observationAmbientTimer) { + clearTimeout(observationAmbientTimer); + observationAmbientTimer = null; + } + if (!observationActive || observationActivity <= .01) return; + + // Quiet = occasional subtle life. Active = a busy generative display. + const slow = 12500; + const fast = 1500; + const base = slow - (slow - fast) * Math.pow(observationActivity, .82); + const jitter = base * obsRand(.25,.75); + + observationAmbientTimer = window.setTimeout(() => { + triggerObservationActivity('ambient'); + scheduleObservationAmbientActivity(); + }, base + jitter); + } + + function setObservationActivity(raw) { + observationActivity = obsClamp(Number(raw) / 100, 0, 1); + window.observationActivity = observationActivity; + observationOverlay.style.setProperty('--obs-activity', String(observationActivity)); + const pct = Math.round(observationActivity * 100); + valObservationActivity.textContent = `${pct}%`; + if (observationActive) { + observationStatus.innerHTML = + `OBSERVATION ACTIVE
PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}
VISUAL ACTIVITY: ${pct}%`; + scheduleObservationAmbientActivity(); + } + } + + function resetObservationReturnFade() { + if (!observationReturn) return; + + observationReturn.style.transition = 'none'; + observationReturn.classList.remove('observation-return-fade'); + void observationReturn.offsetWidth; + observationReturn.style.transition = ''; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (observationActive) { + observationReturn.classList.add('observation-return-fade'); + } + }); + }); + } + + function resetObservationHudFade() { + if (!observationChrome) return; + + // Remove transition momentarily so each OBSERVATION session starts fully visible. + observationChrome.style.transition = 'none'; + observationChrome.classList.remove('observation-hud-fade'); + void observationChrome.offsetWidth; + observationChrome.style.transition = ''; + + if (!toggleObservationHud.checked) { + // Start the thirty-second fade on the next frame. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (observationActive && !toggleObservationHud.checked) { + observationChrome.classList.add('observation-hud-fade'); + } + }); + }); + } + } + + function updateObservationHudMode() { + const hold = toggleObservationHud.checked; + valObservationHud.textContent = hold ? 'ON' : 'FADE'; + + if (!observationActive || !observationChrome) return; + + if (hold) { + observationChrome.style.transition = 'none'; + observationChrome.classList.remove('observation-hud-fade'); + void observationChrome.offsetWidth; + observationChrome.style.transition = ''; + } else { + resetObservationHudFade(); + } + } + + toggleObservationHud.addEventListener('change', updateObservationHudMode); + updateObservationHudMode(); + + sliderObservationActivity.addEventListener('input', () => { + setObservationActivity(sliderObservationActivity.value); + if (observationActive) scheduleObservationInstrumentTick(); + }); + + window.addEventListener('scifi-telemetry-activity', (event) => { + if (!observationActive) return; + triggerObservationActivity('telemetry'); + nudgeObservationInstruments(event.detail ? event.detail.density : null); + pulseObservationScene(); + pulseObservationSplineMorphs(); + }); + + setObservationActivity(sliderObservationActivity.value); + + + // ========================================================================= + // LIVE OBSERVATION INSTRUMENTATION + // The fixed OBSERVATION chrome is intentionally outside this system. + // Only fantasy elements inside the main SVG/event layers participate. + // ========================================================================= + + let observationInstrumentStates = []; + let observationInstrumentTimer = null; + let observationTransientObserver = null; + + const observationTextVocabulary = { + starfleet: ['NOMINAL','PASSIVE','SCANNING','CORRELATING','TRACKING','VERIFIED','STABLE'], + whoniverse: ['VORTEX LOCK','CONVERGING','DRIFTING','RECURSIVE','RESOLVING','TEMPORAL HOLD'], + industrial: ['SECURE FEED','MONITORING','SIGNAL HOLD','CYCLING','LOCAL CHECK','STABLE'], + bioships: ['QUIESCENT','RESPONDING','ADAPTING','RESONANT','SYNCHRONIZED','PULSING'], + retrofuture: ['READY','READ','PROCESS','HOLD','TRACK','STABLE'], + military: ['PASSIVE','TRACKING','CORRELATING','VERIFIED','WATCH','CLEAR'], + deepspace: ['DEEP FIELD','OBSERVING','SPECTRAL HOLD','PASSIVE','RESOLVING','STABLE'], + outlaw: ['MANUAL','INTERMITTENT','PATCHED','SEARCHING','HOLDING','DIRTY LOCK'], + spacestations: ['PASSIVE WATCH','TRAFFIC HOLD','DOCKING','ROUTING','OBSERVING','CLEAR'], + comedy: ['PROBABLY FINE','MOSTLY NOMINAL','STILL FINE','SCENIC','TEA ADJACENT','UNNECESSARILY PRECISE'] + }; + + const observationPlainTextVariants = { + comedy: { + 'TEA SOURCE DETECTED': ['TEA SOURCE DETECTED','TEA SOURCE PROBABLE','TEA SOURCE LOST','BEVERAGE VECTOR ACQUIRED'], + 'NO IMMEDIATE CAUSE FOR PANIC': ['NO IMMEDIATE CAUSE FOR PANIC','STILL NO CAUSE FOR PANIC','PANIC NOT CURRENTLY REQUIRED','SITUATION MOSTLY NORMAL'], + 'SCENIC NAVIGATION': ['SCENIC NAVIGATION','SCENIC RECALCULATION','ROUTE APPRECIATION','NAVIGATION, BUT NICER'] + } + }; + + function getObservationTelemetryDensity() { + if (typeof telemetry !== 'undefined' && telemetry && telemetry.params) { + return obsClamp(Number(telemetry.params.density) || 0, 0, 1); + } + const slider = document.getElementById('slider-telemetry-density'); + return slider ? obsClamp(Number(slider.value) || 0, 0, 1) : 0.5; + } + + function classifyObservationNumber(source, raw, index) { + const upper = source.toUpperCase(); + const decimals = raw.includes('.') ? raw.split('.')[1].length : 0; + const unsignedRaw = raw.replace(/^[-+]/, ''); + const integerPart = unsignedRaw.split('.')[0]; + const leadingZero = integerPart.length > 1 && integerPart.startsWith('0'); + const resolution = Math.pow(10, -decimals); + const base = Number(raw); + + let kind = 'generic'; + let min = -Infinity; + let max = Infinity; + let circular = false; + let macroScale = Math.max(resolution * 2, Math.abs(base || 1) * 0.025); + + const after = source.slice(index + raw.length, index + raw.length + 4); + const before = source.slice(Math.max(0, index - 24), index).toUpperCase(); + + if (after.includes('%') || upper.includes('QUALITY') || upper.includes('CONFIDENCE') || upper.includes('EFFICIENCY')) { + kind = 'percent'; + min = 0; + max = upper.includes('CONFIDENCE') ? 103 : 100; + macroScale = 8; + } else if (upper.includes('NAV VECTOR') || upper.includes('BEARING') || upper.includes('HEADING') || upper.includes('VECTOR ')) { + kind = 'heading'; + min = 0; + max = 360; + circular = true; + macroScale = 18; + } else if (upper.includes('CAM ') || upper.includes('CONTACT ') || upper.includes('TRACK ') || + upper.includes('BAY ') || upper.includes('SECTOR ') || upper.includes('OBJECT ') || + before.endsWith('CAM ') || before.endsWith('TRACK ')) { + kind = 'identifier'; + min = 0; + max = leadingZero ? Math.pow(10, integerPart.length) - 1 : Math.max(99, base + 24); + macroScale = Math.max(1, Math.min(4, 1 + base * .02)); + } else if (upper.includes('RANGE') || upper.includes('DIST') || upper.includes(' LY') || upper.includes(' MM')) { + kind = 'range'; + min = 0; + max = Math.max(base * 3, base + 1000); + macroScale = Math.max(resolution * 3, Math.abs(base || 1) * .18); + } else if (upper.includes('TIME') || upper.includes(':')) { + kind = 'clockish'; + min = 0; + max = decimals ? 99.9 : 99; + macroScale = decimals ? 2.5 : 5; + } else if (base >= 0 && base <= 100) { + min = 0; + max = 100; + macroScale = Math.max(resolution * 3, 4); + } + + return { + raw, + base, + current: base, + target: base, + decimals, + integerWidth: integerPart.length, + leadingZero, + resolution, + kind, + min, + max, + circular, + macroScale, + flutterOffset: 0, + nextFlutterAt: performance.now() + obsRand(350, 1400), + correctionKick: 0, + correctionUntil: 0 + }; + } + + function getStatusMutationRule(source) { + const text = source.trim(); + const upper = text.toUpperCase(); + + // Camera location names are identities, not status fields. + if (/^CAM\s+\d+\s*\/\//i.test(text)) return null; + + if (text.includes('//')) { + const splitAt = text.lastIndexOf('//'); + const prefix = text.slice(0, splitAt + 2).trimEnd(); + const suffix = text.slice(splitAt + 2).trim(); + + // These are display/status phrases and can safely wander. + if (/(PASSIVE|NOMINAL|ACTIVE|OBSERVATION|WATCH|FEED|SENSORS|SECURE|FIELD|ARRAY|CONTROL)/i.test(suffix) || + /(SENSOR|VECTOR|TACTICAL|OPTICAL|BIOLOGICAL|TRAFFIC|LIVE)/i.test(prefix)) { + return { type: 'suffix', prefix, original: suffix }; + } + } + + // A few colon-delimited fantasy statuses. + const colonMatch = text.match(/^(ROUTE|TRANSPONDER)\s*:\s*(.+)$/i); + if (colonMatch) { + return { type: 'colon', prefix: colonMatch[1].toUpperCase(), original: colonMatch[2].trim() }; + } + + const plain = observationPlainTextVariants[activeUniverseId]; + if (plain && plain[upper]) { + return { type: 'plain', original: upper, variants: plain[upper] }; + } + + return null; + } + + function instrumentObservationTextNode(node) { + if (!node || node.nodeType !== 1 || node.dataset.obsLiveInstrumented === '1') return; + + const source = (node.textContent || '').trim(); + if (!source) return; + + const numericMatches = [...source.matchAll(/[-+]?\d+(?:\.\d+)?/g)]; + const numbers = numericMatches.map(m => classifyObservationNumber(source, m[0], m.index)); + + const state = { + node, + source, + numbers, + numberMatches: numericMatches.map(m => ({ raw: m[0], index: m.index })), + statusRule: getStatusMutationRule(source), + statusOverride: null, + statusUntil: 0, + nextTextGlitchAt: performance.now() + obsRand(2500, 9000), + glitchUntil: 0, + glitchSeed: 0 + }; + + node.dataset.obsLiveInstrumented = '1'; + node.classList.add('obs-live-instrument'); + observationInstrumentStates.push(state); + } + + function instrumentObservationTree(root) { + if (!root || !root.querySelectorAll) return; + if (root.matches && root.matches('text')) instrumentObservationTextNode(root); + root.querySelectorAll('text').forEach(instrumentObservationTextNode); + } + + function initializeObservationLiveInstrumentation() { + stopObservationLiveInstrumentation(false); + observationInstrumentStates = []; + + instrumentObservationTree(observationStage); + instrumentObservationTree(observationEventLayer); + + observationTransientObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + mutation.addedNodes.forEach(node => { + if (node.nodeType === 1) instrumentObservationTree(node); + }); + } + }); + observationTransientObserver.observe(observationEventLayer, { childList: true, subtree: true }); + + scheduleObservationInstrumentTick(); + } + + function stopObservationLiveInstrumentation(clearStates = true) { + if (observationInstrumentTimer) { + clearTimeout(observationInstrumentTimer); + observationInstrumentTimer = null; + } + if (observationTransientObserver) { + observationTransientObserver.disconnect(); + observationTransientObserver = null; + } + if (clearStates) observationInstrumentStates = []; + } + + function formatObservationNumber(value, token) { + let v = value; + + if (token.circular) { + const span = token.max - token.min; + v = ((v - token.min) % span + span) % span + token.min; + } else { + if (Number.isFinite(token.min)) v = Math.max(token.min, v); + if (Number.isFinite(token.max)) v = Math.min(token.max, v); + } + + // Identity-like fields stay integer but can still "flutter" by one count. + if (token.kind === 'identifier') v = Math.round(v); + + let result = token.decimals > 0 ? v.toFixed(token.decimals) : String(Math.round(v)); + + if (token.leadingZero) { + const sign = result.startsWith('-') ? '-' : ''; + const unsigned = result.replace(/^-/, ''); + const parts = unsigned.split('.'); + parts[0] = parts[0].padStart(token.integerWidth, '0'); + result = sign + parts.join('.'); + } + return result; + } + + function shortestCircularDelta(current, target, min, max) { + const span = max - min; + let delta = target - current; + if (delta > span / 2) delta -= span; + if (delta < -span / 2) delta += span; + return delta; + } + + function rebuildObservationText(state, now) { + if (!state.node || !state.node.isConnected) return; + + let cursor = 0; + let built = ''; + + state.numberMatches.forEach((match, i) => { + const token = state.numbers[i]; + built += state.source.slice(cursor, match.index); + + let displayValue = token.current + token.flutterOffset; + + if (token.correctionUntil > now) { + const remaining = (token.correctionUntil - now) / 1400; + displayValue += token.correctionKick * Math.max(0, Math.min(1, remaining)); + } + + built += formatObservationNumber(displayValue, token); + cursor = match.index + match.raw.length; + }); + + built += state.source.slice(cursor); + + if (state.statusOverride && state.statusUntil > now && state.statusRule) { + const rule = state.statusRule; + if (rule.type === 'suffix') { + const splitAt = built.lastIndexOf('//'); + if (splitAt >= 0) built = `${built.slice(0, splitAt + 2)} ${state.statusOverride}`; + } else if (rule.type === 'colon') { + const colonAt = built.indexOf(':'); + if (colonAt >= 0) built = `${built.slice(0, colonAt + 1)} ${state.statusOverride}`; + } else if (rule.type === 'plain') { + built = state.statusOverride; + } + } else if (state.statusOverride && state.statusUntil <= now) { + state.statusOverride = null; + } + + // Very brief display corruption. This is visual noise only and always recovers. + if (state.glitchUntil > now && built.length > 3) { + const chars = [...built]; + const candidates = []; + for (let i = 1; i < chars.length - 1; i++) { + if (/[A-Z0-9]/i.test(chars[i])) candidates.push(i); + } + if (candidates.length) { + const idx = candidates[state.glitchSeed % candidates.length]; + chars[idx] = obsPick(['_','?','·','Ξ','0','1']); + built = chars.join(''); + state.node.classList.add('obs-live-glitch'); + } + } else { + state.node.classList.remove('obs-live-glitch'); + } + + state.node.textContent = built; + } + + function updateObservationNumberToken(token, now) { + const activity = observationActivity; + + let delta = token.circular + ? shortestCircularDelta(token.current, token.target, token.min, token.max) + : token.target - token.current; + + // Activity controls how quickly the displayed instrument tracks its target. + const trackingFraction = 0.07 + activity * 0.31; + if (Math.abs(delta) > token.resolution * .15) { + token.current += delta * trackingFraction; + } else { + token.current = token.target; + } + + // Micro flutter: the least-significant digit hunts around the real value. + if (now >= token.nextFlutterAt) { + const flutterChance = 0.34 + activity * 0.56; + if (Math.random() < flutterChance) { + const direction = obsPick([-1, 1]); + const steps = Math.random() < (.18 + activity * .2) ? 2 : 1; + const identifierDamping = token.kind === 'identifier' ? .55 : 1; + token.flutterOffset = direction * token.resolution * steps * identifierDamping; + } else { + token.flutterOffset = 0; + } + + const slow = 1850; + const fast = 240; + token.nextFlutterAt = now + (slow - (slow-fast) * activity) * obsRand(.65, 1.35); + } else if (Math.random() < .16 + activity * .18) { + token.flutterOffset *= .45; + if (Math.abs(token.flutterOffset) < token.resolution * .15) token.flutterOffset = 0; + } + + if (token.circular) { + const span = token.max - token.min; + token.current = ((token.current - token.min) % span + span) % span + token.min; + } else { + if (Number.isFinite(token.min)) token.current = Math.max(token.min, token.current); + if (Number.isFinite(token.max)) token.current = Math.min(token.max, token.current); + } + } + + function maybeGlitchObservationText(state, now) { + if (now < state.nextTextGlitchAt) return; + + const activity = observationActivity; + const density = getObservationTelemetryDensity(); + const chance = .06 + activity * .18 + density * .05; + + if (Math.random() < chance) { + state.glitchSeed = obsInt(0, 100000); + state.glitchUntil = now + obsRand(90, 260 + activity * 180); + } + + const slow = 17000; + const fast = 2800; + state.nextTextGlitchAt = now + (slow - (slow-fast) * activity) * obsRand(.6, 1.4); + } + + function observationInstrumentTick() { + observationInstrumentTimer = null; + if (!observationActive) return; + + const now = performance.now(); + observationInstrumentStates = observationInstrumentStates.filter(state => state.node && state.node.isConnected); + + for (const state of observationInstrumentStates) { + state.numbers.forEach(token => updateObservationNumberToken(token, now)); + maybeGlitchObservationText(state, now); + rebuildObservationText(state, now); + } + + scheduleObservationInstrumentTick(); + } + + function scheduleObservationInstrumentTick() { + if (!observationActive) return; + if (observationInstrumentTimer) clearTimeout(observationInstrumentTimer); + + // Observation level directly controls the "instrument refresh rate". + // At quiet settings values settle visibly and slowly; at high settings + // they look like fast live telemetry. + const slowMs = 900; + const fastMs = 105; + const delay = slowMs - (slowMs - fastMs) * Math.pow(observationActivity, .88); + observationInstrumentTimer = setTimeout(observationInstrumentTick, delay); + } + + function chooseObservationTarget(token, density) { + const amplitudeFloor = token.resolution; + const amplitude = Math.max( + amplitudeFloor, + token.macroScale * (.06 + Math.pow(density, 1.12)) + ); + + let delta; + if (token.kind === 'identifier') { + const maxStep = Math.max(1, Math.round(1 + density * 3)); + delta = obsPick([-1,1]) * obsInt(1, maxStep); + } else { + delta = obsRand(-amplitude, amplitude); + } + + let target = token.target + delta; + + if (token.circular) { + const span = token.max - token.min; + target = ((target - token.min) % span + span) % span + token.min; + } else { + if (Number.isFinite(token.min)) target = Math.max(token.min, target); + if (Number.isFinite(token.max)) target = Math.min(token.max, target); + } + + token.target = target; + + // Sometimes a display control-loop overshoots and corrects itself. + if (Math.random() < .28 + observationActivity * .24) { + const sign = Math.random() < .5 ? -1 : 1; + const kickSteps = token.kind === 'identifier' ? 1 : obsRand(.6, 2.2 + observationActivity * 1.5); + token.correctionKick = sign * token.resolution * kickSteps; + token.correctionUntil = performance.now() + obsRand(550, 1450); + } + } + + function chooseObservationStatusOverride(state, density) { + if (!state.statusRule) return; + + let variants; + const rule = state.statusRule; + + if (rule.type === 'plain') { + variants = rule.variants || []; + } else { + variants = observationTextVocabulary[activeUniverseId] || observationTextVocabulary.starfleet; + } + + if (!variants.length) return; + + const original = rule.original.toUpperCase(); + const choices = variants.filter(v => v !== original); + if (!choices.length) return; + + state.statusOverride = obsPick(choices); + + // Higher observation activity means state changes read faster and clear sooner. + const holdSlow = 6200; + const holdFast = 1700; + state.statusUntil = performance.now() + + (holdSlow - (holdSlow-holdFast) * observationActivity) * obsRand(.7, 1.35); + } + + function nudgeObservationInstruments(densityOverride = null) { + if (!observationActive) return; + + const density = densityOverride == null + ? getObservationTelemetryDensity() + : obsClamp(Number(densityOverride) || 0, 0, 1); + + const liveStates = observationInstrumentStates.filter(s => s.node && s.node.isConnected); + const numericStates = liveStates.filter(s => s.numbers.length); + const statusStates = liveStates.filter(s => s.statusRule); + + // Density controls how much of the imaginary system is disturbed by a chirp. + const desiredNumeric = Math.min( + numericStates.length, + Math.max(1, Math.round(1 + density * (2 + numericStates.length * .22))) + ); + + const shuffledNumbers = [...numericStates].sort(() => Math.random() - .5); + for (let i = 0; i < desiredNumeric; i++) { + const state = shuffledNumbers[i]; + state.numbers.forEach(token => { + // A dense chirp can perturb several values in a compound readout. + if (Math.random() < .55 + density * .4) chooseObservationTarget(token, density); + }); + } + + // Text states are less frequent than numeric movement. They read as system + // transitions rather than a constantly changing label soup. + if (statusStates.length && Math.random() < .28 + density * .5) { + const desiredText = density > .72 && Math.random() < density ? 2 : 1; + const shuffledText = [...statusStates].sort(() => Math.random() - .5); + for (let i = 0; i < Math.min(desiredText, shuffledText.length); i++) { + chooseObservationStatusOverride(shuffledText[i], density); + } + } + } + + + function seededUnit(seed) { + let x = Math.sin(seed * 12.9898 + 78.233) * 43758.5453; + return x - Math.floor(x); + } + + function makeStars(count = 90, width = 1600, height = 900, tint = '#dbeafe', seedOffset = 0) { + let stars = ''; + for (let i = 0; i < count; i++) { + const x = Math.floor(seededUnit(i + 1 + seedOffset) * width); + const y = Math.floor(seededUnit((i + 1 + seedOffset) * 7.31) * height); + const r = (0.55 + seededUnit((i + seedOffset + 4) * 3.17) * 1.65).toFixed(2); + const o = (0.25 + seededUnit((i + seedOffset + 9) * 5.19) * 0.72).toFixed(2); + stars += ``; + } + return stars; + } + + function svgFrame(inner, defs = '') { + return ` + + + + + + + + + + + + + + + + + ${defs} + + ${inner} + `; + } + + function renderObservationStarfleet() { + const starsA = makeStars(115, 1750, 900, '#dbeafe', 2); + const starsB = makeStars(55, 1750, 900, '#93c5fd', 220); + return svgFrame(` + + ${starsA} + ${starsB} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LONG RANGE SENSOR ARRAY // PASSIVE + NAV VECTOR 034.8 // NOMINAL + `); + } + + function renderObservationWhoniverse() { + return svgFrame(` + + + + + + + + + + + + + + + + + + + + + + + + + ⌁ 37.4 ∴ 9 + ∆ 04:11:∞ + + + TEMPORAL DISPLACEMENT // VORTEX OBSERVATION + + `); + } + + function renderObservationIndustrial() { + // The industrial viewport frame (getViewportFrameSvg) masks everything + // outside x 140..1460, y 160..740, so the whole surveillance console is + // laid out inside that safe area. v12 ran the feeds to x 110..1490 and had + // its edges eaten by the bulkhead. + const SAFE_X0 = 156, SAFE_X1 = 1444; + + // Feeds are authored at 650x270 and drawn through a uniform scale, so the + // scenes below can keep one readable coordinate space. + const CW = 650, CH = 270, S = 0.78; + const IX = 14, IY = 14, IW = 622, IH = 242; + const CAM_X = [279, 814], CAM_Y = [248, 472]; + + // Identity text (camera names, equipment designations, container IDs) is + // opted OUT of the live-instrumentation number drift by pre-stamping the + // flag it checks. Only telemetry should wander; "CAM 02" must not quietly + // become "CAM 01", which is exactly what v12 did to every label it drew. + const ID = 'data-obs-live-instrumented="1"'; + + // ---------------------------------------------------------------- parts + const crate = (x, y, w, h, body, edge, id, idColor) => { + let ribs = ''; + for (let i = 9; i < w - 5; i += 9) ribs += `M${i} 4V${h - 4}`; + return ` + + + + + + + + + + ${id} + `; + }; + + // Vent vapour authored as in-scene geometry so the camera clip contains it. + // v12 floated these on the sim layer, which is why steam drifted over the + // bezels and read as detached from any feed. + const vapour = (x, y, count, seed, scale = 1) => { + let out = ''; + for (let i = 0; i < count; i++) { + const dur = (4.5 + seededUnit(seed + i) * 3.6).toFixed(1); + const delay = (i * (dur / count)).toFixed(1); + const vx = Math.round(-24 + seededUnit(seed + i * 3.1) * 48); + const r = ((9 + seededUnit(seed + i * 5.7) * 13) * scale).toFixed(1); + out += ``; + } + return out; + }; + + const gauge = (x, y, r, label, needleColor, delay) => { + let ticks = ''; + for (let a = -120; a <= 120; a += 30) { + const rad = (a - 90) * Math.PI / 180; + ticks += `M${(Math.cos(rad) * (r - 3)).toFixed(1)} ${(Math.sin(rad) * (r - 3)).toFixed(1)}` + + `L${(Math.cos(rad) * (r - 8)).toFixed(1)} ${(Math.sin(rad) * (r - 8)).toFixed(1)}`; + } + return ` + + + + + + + + + ${label} + `; + }; + + // Per-feed video artifacts. This replaces v12's single screen-wide sweep + // bar: each feed rolls, tears and grains on its own clock, inside its own + // clip, so the artifacts read as video rather than as a screen overlay. + const feedFx = (i) => ` + + + + + + + + + + + + + + `; + + const cam = (i, label, code, status, scene) => ` + + + + + + + + + + ${scene} + + ${feedFx(i)} + + + + ${label} + ${code} + + ${status} + LIVE // SECURE FEED + + + + + SELECTED + + `; + + // ------------------------------------------------- procedural geometry + let bayChev = ''; + for (let i = 0; i < 660; i += 16) bayChev += `M${i} 222L${i + 8} 212H${i + 16}L${i + 8} 222Z`; + let bayDeck = ''; + for (let i = 20; i < 640; i += 44) bayDeck += `M${i} 214V256`; + + let coreSeg = ''; + for (let a = 0; a < 360; a += 45) { + const rad = a * Math.PI / 180; + coreSeg += `M${(Math.cos(rad) * 62).toFixed(1)} ${(Math.sin(rad) * 62).toFixed(1)}` + + `L${(Math.cos(rad) * 86).toFixed(1)} ${(Math.sin(rad) * 86).toFixed(1)}`; + } + let rods = ''; + for (let i = 0; i < 7; i++) { + const rx = -60 + i * 20; + rods += ` + + + `; + } + const trefoil = [0, 120, 240].map(a => + ``).join(''); + let plantGrate = ''; + for (let i = 20; i < 640; i += 40) plantGrate += `M${i} 210V256`; + + let dogs = ''; + for (let a = 0; a < 360; a += 45) { + const rad = a * Math.PI / 180; + dogs += ` + + + `; + } + let hazRing = ''; + for (let a = 0; a < 360; a += 15) { + if ((a / 15) % 2) continue; + const r0 = 78, r1 = 90, a0 = a * Math.PI / 180, a1 = (a + 15) * Math.PI / 180; + hazRing += `M${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}` + + `L${(Math.cos(a0) * r1).toFixed(1)} ${(Math.sin(a0) * r1).toFixed(1)}` + + `A${r1} ${r1} 0 0 1 ${(Math.cos(a1) * r1).toFixed(1)} ${(Math.sin(a1) * r1).toFixed(1)}` + + `L${(Math.cos(a1) * r0).toFixed(1)} ${(Math.sin(a1) * r0).toFixed(1)}` + + `A${r0} ${r0} 0 0 0 ${(Math.cos(a0) * r0).toFixed(1)} ${(Math.sin(a0) * r0).toFixed(1)}Z`; + } + const lamp = (y, txt, col, i) => ` + + + + + + + ${txt} + `; + const suits = [0, 1, 2].map(i => ` + + + + + + + `).join(''); + let lockFloor = ''; + for (let i = 20; i < 640; i += 40) lockFloor += `M${i} 214V256`; + + const vpx = 325, vpy = 128; + let corrFrames = '', corrFloor = '', corrLamps = ''; + for (let i = 0; i < 7; i++) { + const s = Math.pow(0.76, i); + const x0 = vpx + (24 - vpx) * s, x1 = vpx + (626 - vpx) * s; + const y0 = vpy + (22 - vpy) * s, y1 = vpy + (248 - vpy) * s; + corrFrames += ``; + corrFloor += ``; + } + for (let x = 24; x <= 626; x += 60) { + corrFloor += ``; + } + for (let i = 1; i < 5; i++) { + const s = Math.pow(0.76, i); + const y = vpy + (34 - vpy) * s, w = 62 * s; + corrLamps += ` + + + `; + } + const fanBlades = [0, 72, 144, 216, 288].map(a => + ``).join(''); + const drip = (x, y, d) => + ` + `; + + // ------------------------------------------------------- CAM 01 :: BAY + // A working cargo hold: a gantry crane on an overhead rail running a real + // pick-and-place cycle over stacked containers. v12 had a wheeled cart + // sliding back and forth, which read as a car and made no sense on a ship. + const sceneCargo = ` + + + + + + + + + + + + + + + BAY 2 // AFT + + + + + + + + + + + + + ${crate(90, 174, 88, 40, '#5d4427', '#8a6a42', 'CB-4471', '#e8c88a')} + + ${crate(90, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} + + ${crate(182, 174, 88, 40, '#4f3a22', '#7d6039', 'HAZ-3/09', '#f0b64f')} + + + + ${crate(380, 174, 88, 40, '#5a4426', '#886946', 'CB-3310', '#e8c88a')} + + ${crate(380, 134, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} + + ${crate(472, 174, 88, 40, '#453120', '#6f5535', 'ORE-C', '#d9b070')} + + + + + + + + + + + + + + + ${crate(-44, 6, 88, 40, '#6b4d2c', '#96754a', 'CB-4472', '#e8c88a')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${vapour(250, 250, 6, 12.4, .8)} + + + + + + GANTRY 4 // AUTO CYCLE + LOAD 2.4 T + `; + + // --------------------------------------------------- CAM 02 :: REACTOR + const sceneReactor = ` + + + + + + + + + ${rods} + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${gauge(424, 74, 26, 'TEMP', '#f59e0b', 0)} + ${gauge(490, 74, 26, 'PRESS', '#38bdf8', 1.4)} + ${gauge(556, 74, 26, 'FLUX', '#22c55e', 2.8)} + + + + ${trefoil} + + + + ${vapour(392, 206, 5, 31.7, .85)} + + + + + + + + + + FUSION PLANT 1 + OUTPUT 68% + `; + + // --------------------------------------------------- CAM 03 :: AIRLOCK + const sceneAirlock = ` + + + + + + + ${suits} + + + + + + + + ${dogs} + + + + + + + + + + + + + + + + + + + + + + + + + CYCLE STATE + ${lamp(20, 'VACUUM', '#38bdf8', 0)} + ${lamp(44, 'EQUALIZE', '#f59e0b', 1)} + ${lamp(68, 'PRESSURE', '#22c55e', 2)} + + + ${gauge(556, 172, 28, 'CHAMBER kPa', '#38bdf8', 1.1)} + ${vapour(300, 212, 5, 57.3, .7)} + + + + + + AIRLOCK C + OUTER DOOR SEALED + `; + + // -------------------------------------------------- CAM 04 :: CORRIDOR + const sceneCorridor = ` + + + + ${corrFloor} + + + + + + + + + + + ${corrFrames} + ${corrLamps} + + + + + + + ${fanBlades} + + + + + + + + + + + + + + + + + + + + + + + ${drip(196, 96, 1.4)} + ${drip(438, 82, 3.1)} + ${drip(268, 66, 5.2)} + ${vapour(520, 236, 5, 78.1, .75)} + + + FRAME 22 TO 34 + EXTRACTOR 3 ONLINE + `; + + // ------------------------------------------------- console side rails + let rails = ''; + for (let i = 0; i < 8; i++) { + const y = 252 + i * 54; + const fill = 22 + ((i * 41) % 62); + rails += ` + + + `; + } + + const defs = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; + + return svgFrame(` + + + + ${cam(0, 'CAM 01 // CARGO BAY 2', 'C-01 4K/IR', 'GANTRY 4 AUTO // 2 CONTACTS', sceneCargo)} + ${cam(1, 'CAM 02 // REACTOR ACCESS', 'C-02 4K/RAD', 'PLANT 1 NOMINAL // LOOP A', sceneReactor)} + ${cam(2, 'CAM 03 // AIRLOCK C', 'C-03 4K/LL', 'OUTER DOOR SEALED // NO EVA', sceneAirlock)} + ${cam(3, 'CAM 04 // MACHINE CORRIDOR', 'C-04 4K/IR', 'FRAME 22-34 // EXTRACTOR ON', sceneCorridor)} + + + + + CSV KESTREL - HEAVY HAULER + REG CB-4471 // DECK 4 SURVEILLANCE RING + + + SHIP TIME + 04:17:33 + RECORDER + ARMED + + + + 4 OF 4 FEEDS LOCKED + BANDWIDTH 62% // ARCHIVE 71% FULL + + + + + ${rails} + + + + + + BUS A + BUS B + + + + + + MANIFEST 4471-C // ORE CONCENTRATE + SEALED HAZ-3 + + HULL 12% + BAY 101.3 KPA + RCTR 68% + O2 20.9% + GRAV 0.94 G + + NOMINAL + + `, defs); + } + + function renderObservationBioships() { + return svgFrame(` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BIOLOGICAL SYSTEMS // PASSIVE NEURAL OBSERVATION + + `); + } + + function renderObservationRetro() { + const stars = makeStars(95, 1600, 900, '#86efac', 720); + return svgFrame(` + + ${stars} + + + + + + + + + + + + + + + + + + + + + + + + + + ASTROGATION ARRAY + VECTOR MEMORY: LOCKED + SCAN 360° + ANALOG BEAM: ACTIVE + + `); + } + + function renderObservationMilitary() { + const stars = makeStars(110, 1650, 900, '#fef3c7', 960); + return svgFrame(` + + ${stars} + + + + + + + + + + + + + + + + + + + + + + + + + + + CONTACT 03 // TRACKING + TACTICAL WATCH // PASSIVE + WEAPONS SAFE // SENSORS ACTIVE + + + + + + + + `); + } + + function renderObservationDeepSpace() { + const starsA = makeStars(155, 1800, 900, '#eef2ff', 1220); + const starsB = makeStars(65, 1800, 900, '#818cf8', 1430); + // Planets, nebula clouds, dust lanes, derelicts and anomalies now live in the canvas + // engine's procedural, session-seeded Deep Space field (see ObservationEngine's + // regenerateDeepSpaceField / renderDeepSpaceField). This legacy SVG layer is kept + // intentionally sparse -- just a comet accent and the HUD label -- so it never paints a + // fixed duplicate planet/nebula on top of that varying scene. + return svgFrame(` + + ${starsA} + ${starsB} + + + + + + LONG RANGE OPTICAL // DEEP FIELD OBSERVATION + + `); + } + + function renderObservationOutlaw() { + const stars = makeStars(115, 1700, 900, '#fbcfe8', 1710); + return svgFrame(` + + ${stars} + + + + + + + + + REAR FEED // 02 + + + + + + ROUTE: MANUAL + TRANSPONDER: INTERMITTENT + SIGNAL QUALITY: 62% + + + `); + } + + function renderObservationStations() { + const starsNear = makeStars(105, 1160, 540, '#e0f2fe', 1990); + const starsFar = makeStars(55, 1160, 540, '#7dd3fc', 2310); + const telemetryModule = (x, y, w, h, title, line1, line2 = '') => ` + + + + ${title} + ${line1} + ${line2 ? `${line2}` : ''} + `; + + return svgFrame(` + + + + + + + + + + + + + + + + + + + ${starsNear} + ${starsFar} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ${telemetryModule(105, 170, 300, 96, 'OBSERVATION DECK', 'RING RATE 01.8 // STABLE', 'STARFIELD DRIFT 004.2')} + ${telemetryModule(1195, 170, 300, 96, 'TRAFFIC CONTROL', 'DOCK VECTOR 118.4 // ROUTING', 'QUEUE 03 // CLEARANCE')} + ${telemetryModule(105, 590, 320, 96, 'PORT STATUS', 'BAY 12 // ARRIVAL WINDOW', 'HOLDING ARC 02.7')} + ${telemetryModule(1175, 590, 320, 96, 'TRANSIT SPINE', 'LOCAL FLOW 07 // NOMINAL', 'HABITAT SPIN 00.9')} + + + + + + + + + STATION OBSERVATION // ROTATIONAL TRAFFIC WINDOW + + `); + } + + function renderObservationComedy() { + const stars = makeStars(120, 1700, 900, '#cffafe', 2260); + return svgFrame(` + + ${stars} + + + + + + + + + SCENIC NAVIGATION + ROUTE CONFIDENCE: 99.7% PROBABLY FINE + TEA SOURCE DETECTED + NO IMMEDIATE CAUSE FOR PANIC + + + + + + + `); + } + + function getObservationSvg(universeId) { + switch (universeId) { + case 'starfleet': return renderObservationStarfleet(); + case 'whoniverse': return renderObservationWhoniverse(); + case 'industrial': return renderObservationIndustrial(); + case 'bioships': return renderObservationBioships(); + case 'retrofuture': return renderObservationRetro(); + case 'military': return renderObservationMilitary(); + case 'deepspace': return renderObservationDeepSpace(); + case 'outlaw': return renderObservationOutlaw(); + case 'spacestations': return renderObservationStations(); + case 'comedy': return renderObservationComedy(); + default: return renderObservationStarfleet(); + } + } + + function refreshObservation() { + const universe = UniverseRegistry[activeUniverseId]; + const preset = universe && universe.presets ? universe.presets[activePresetId] : null; + if (!universe || !preset) return; + + observationUniverse.textContent = `${universe.shortCode} // OBSERVATION`; + observationProfile.textContent = preset.name.toUpperCase(); + observationStatus.innerHTML = + `OBSERVATION ACTIVE
PROCEDURAL AUDIO LINK: ${isPlaying ? 'ONLINE' : 'STANDBY'}
VISUAL ACTIVITY: ${Math.round(observationActivity * 100)}%`; + + observationOverlay.style.setProperty('--obs-activity', String(observationActivity)); + observationStage.innerHTML = getObservationSvg(activeUniverseId); + initializeObservationSplineMorphs(); + startObservationScene(); + initializeObservationLiveInstrumentation(); + + if (observationEngine) { + observationEngine.start(activeUniverseId); + } + } + + function enterObservation() { + if (observationActive) return; + observationActive = true; + observationOverlay.classList.add('active'); + observationOverlay.setAttribute('aria-hidden', 'false'); + refreshObservation(); + resetObservationHudFade(); + resetObservationReturnFade(); + scheduleObservationAmbientActivity(); + + // Give the display an immediate but restrained sign of life. + if (observationActivity > .08) { + window.setTimeout(() => triggerObservationActivity('ambient'), 700); + } + } + + function exitObservation() { + if (!observationActive) return; + observationActive = false; + + if (observationEngine) { + observationEngine.stop(); + } + + if (observationAmbientTimer) { + clearTimeout(observationAmbientTimer); + observationAmbientTimer = null; + } + + stopObservationLiveInstrumentation(); + stopObservationSplineMorphs(); + stopObservationScene(); + + if (observationChrome) { + observationChrome.style.transition = 'none'; + observationChrome.classList.remove('observation-hud-fade'); + void observationChrome.offsetWidth; + observationChrome.style.transition = ''; + } + + if (observationReturn) { + observationReturn.style.transition = 'none'; + observationReturn.classList.remove('observation-return-fade'); + void observationReturn.offsetWidth; + observationReturn.style.transition = ''; + } + + observationOverlay.classList.remove('active'); + observationOverlay.setAttribute('aria-hidden', 'true'); + observationStage.innerHTML = ''; + observationEventLayer.innerHTML = ''; + } + + window.exitObservation = exitObservation; + window.refreshObservation = refreshObservation; + window.enterObservation = enterObservation; + window.selectPreset = selectPreset; + window.setUniverse = setUniverse; + + btnObservation.addEventListener('click', (e) => { + e.stopPropagation(); + enterObservation(); + }); + + // Click background to exit, but ignore clicks on the control dock or its buttons + observationOverlay.addEventListener('click', (e) => { + if (e.target.closest('#observation-control-dock')) return; + e.preventDefault(); + e.stopPropagation(); + exitObservation(); + }); + + const observationReturnPill = document.getElementById('observation-return-pill'); + if (observationReturnPill) { + observationReturnPill.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + exitObservation(); + }); + } + + // 10. Keyboard Shortcuts + window.addEventListener('keydown', (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return; + + if (e.code === 'Space') { + e.preventDefault(); + togglePlay(); + } else if (e.code === 'KeyM') { + toggleMute(); + } else if (e.code === 'KeyW') { + if (observationActive && observationEngine) { + observationEngine.toggleWarpFlight(); + return; + } + const primaryActionBtn = universeEventsContainer.children[0]; + if (primaryActionBtn) primaryActionBtn.click(); + } else if (e.code === 'KeyF') { + if (observationActive && observationEngine) { + observationEngine.toggleViewportFrame(); + return; + } + } else if (e.code === 'KeyP') { + if (observationActive && observationEngine) { + observationEngine.togglePillars(); + return; + } + } else if (e.code === 'KeyR') { + if (observationActive && observationEngine) { + observationEngine.toggleAlert(); + return; + } + const firstAlertBtn = universeEventsContainer.children[1] || universeEventsContainer.children[0]; + if (firstAlertBtn) firstAlertBtn.click(); + } else if (e.code === 'ArrowUp' || e.code === 'Equal' || e.code === 'NumpadAdd') { + e.preventDefault(); + setMasterVolume(audioManager.getMasterVolume() + 0.05); + } else if (e.code === 'ArrowDown' || e.code === 'Minus' || e.code === 'NumpadSubtract') { + e.preventDefault(); + setMasterVolume(audioManager.getMasterVolume() - 0.05); + } else if (e.code >= 'Digit1' && e.code <= 'Digit9') { + const num = parseInt(e.code.replace('Digit', '')); + setMasterVolume(num * 0.1); + } else if (e.code === 'Digit0') { + setMasterVolume(1.0); + } else if (e.code === 'Escape') { + if (observationActive) { + exitObservation(); + return; + } + alerts.stopAlert(); + whoniverseAudio.stopCloisterBell(); + resetAlertButtons(); + } + }); + + // 11. Initial Setup + initUniverseDropdown(); + setUniverse('starfleet'); + updateMasterVolumeUI(0.75, false); + visualizer.init('spectrum-canvas', 'warp-core-canvas'); +}); + diff --git a/js/audio.js b/js/audio.js index 1f9ee03..9328af2 100644 --- a/js/audio.js +++ b/js/audio.js @@ -1,2258 +1,2333 @@ -class AudioManager { - constructor() { - this.ctx = null; - this.isInitialized = false; - this.isPlaying = false; - this.masterGain = null; - this.compressor = null; - this.analyser = null; - - this.currentVolume = 0.75; - this.isMuted = false; - - // Sleep Timer - this.timerId = null; - this.timerRemainingSeconds = 0; - this.onTimerTick = null; - this.onTimerComplete = null; - } - - init() { - if (this.isInitialized) return; - - const AudioContextClass = window.AudioContext || window.webkitAudioContext; - this.ctx = new AudioContextClass(); - - // Master Dynamics Compressor / Limiter for studio-quality mastering & anti-clipping - this.compressor = this.ctx.createDynamicsCompressor(); - this.compressor.threshold.setValueAtTime(-12, this.ctx.currentTime); - this.compressor.knee.setValueAtTime(8, this.ctx.currentTime); - this.compressor.ratio.setValueAtTime(4, this.ctx.currentTime); - this.compressor.attack.setValueAtTime(0.003, this.ctx.currentTime); - this.compressor.release.setValueAtTime(0.25, this.ctx.currentTime); - - // Master Gain - this.masterGain = this.ctx.createGain(); - this.masterGain.gain.setValueAtTime(this.isMuted ? 0 : this.currentVolume, this.ctx.currentTime); - - // Master Analyser Node for Visualizers - this.analyser = this.ctx.createAnalyser(); - this.analyser.fftSize = 512; - this.analyser.smoothingTimeConstant = 0.82; - - // Route: Nodes -> Compressor -> MasterGain -> Analyser -> Destination - this.compressor.connect(this.masterGain); - this.masterGain.connect(this.analyser); - this.analyser.connect(this.ctx.destination); - - this.isInitialized = true; - } - - async resume() { - if (!this.isInitialized) this.init(); - if (this.ctx.state === 'suspended') { - await this.ctx.resume(); - } - } - - setMasterVolume(val, smoothTime = 0.05) { - const clamped = Math.max(0, Math.min(1, val)); - this.currentVolume = clamped; - if (this.masterGain && this.ctx) { - const target = this.isMuted ? 0 : clamped; - const now = this.ctx.currentTime; - this.masterGain.gain.cancelScheduledValues(now); - this.masterGain.gain.linearRampToValueAtTime(target, now + smoothTime); - } - } - - getMasterVolume() { - return this.currentVolume; - } - - toggleMute() { - return this.setMute(!this.isMuted); - } - - setMute(muted) { - this.isMuted = !!muted; - if (this.masterGain && this.ctx) { - const target = this.isMuted ? 0 : this.currentVolume; - const now = this.ctx.currentTime; - this.masterGain.gain.cancelScheduledValues(now); - this.masterGain.gain.linearRampToValueAtTime(target, now + 0.05); - } - return this.isMuted; - } - - // Noise Buffer Helper (White, Pink, Brown) - createNoiseBuffer(type = 'pink', durationSeconds = 5) { - if (!this.ctx) this.init(); - const sampleRate = this.ctx.sampleRate; - const bufferSize = sampleRate * durationSeconds; - const buffer = this.ctx.createBuffer(2, bufferSize, sampleRate); - const left = buffer.getChannelData(0); - const right = buffer.getChannelData(1); - - if (type === 'white') { - for (let i = 0; i < bufferSize; i++) { - left[i] = Math.random() * 2 - 1; - right[i] = Math.random() * 2 - 1; - } - } else if (type === 'pink') { - let b0L = 0, b1L = 0, b2L = 0, b3L = 0, b4L = 0, b5L = 0, b6L = 0; - let b0R = 0, b1R = 0, b2R = 0, b3R = 0, b4R = 0, b5R = 0, b6R = 0; - for (let i = 0; i < bufferSize; i++) { - const whiteL = Math.random() * 2 - 1; - b0L = 0.99886 * b0L + whiteL * 0.0555179; - b1L = 0.99332 * b1L + whiteL * 0.0750759; - b2L = 0.96900 * b2L + whiteL * 0.1538520; - b3L = 0.86650 * b3L + whiteL * 0.3104856; - b4L = 0.55000 * b4L + whiteL * 0.5329522; - b5L = -0.7616 * b5L - whiteL * 0.0168980; - left[i] = (b0L + b1L + b2L + b3L + b4L + b5L + b6L + whiteL * 0.5362) * 0.11; - b6L = whiteL * 0.115926; - - const whiteR = Math.random() * 2 - 1; - b0R = 0.99886 * b0R + whiteR * 0.0555179; - b1R = 0.99332 * b1R + whiteR * 0.0750759; - b2R = 0.96900 * b2R + whiteR * 0.1538520; - b3R = 0.86650 * b3R + whiteR * 0.3104856; - b4R = 0.55000 * b4R + whiteR * 0.5329522; - b5R = -0.7616 * b5R - whiteR * 0.0168980; - right[i] = (b0R + b1R + b2R + b3R + b4R + b5R + b6R + whiteR * 0.5362) * 0.11; - b6R = whiteR * 0.115926; - } - } else if (type === 'brown') { - let lastOutL = 0.0; - let lastOutR = 0.0; - for (let i = 0; i < bufferSize; i++) { - const whiteL = Math.random() * 2 - 1; - lastOutL = (lastOutL + 0.02 * whiteL) / 1.02; - left[i] = lastOutL * 3.5; - - const whiteR = Math.random() * 2 - 1; - lastOutR = (lastOutR + 0.02 * whiteR) / 1.02; - right[i] = lastOutR * 3.5; - } - } - - return buffer; - } - - // Sleep Timer System - startSleepTimer(minutes, onTick, onComplete) { - this.stopSleepTimer(); - this.timerRemainingSeconds = Math.round(minutes * 60); - this.onTimerTick = onTick; - this.onTimerComplete = onComplete; - - if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds); - - this.timerId = setInterval(() => { - this.timerRemainingSeconds--; - if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds); - - // Begin exponential smooth fadeout during final 30 seconds - if (this.timerRemainingSeconds <= 30 && this.timerRemainingSeconds > 0) { - const factor = this.timerRemainingSeconds / 30; - if (this.masterGain && this.ctx) { - const targetVol = this.getMasterVolume() * factor; - this.masterGain.gain.setValueAtTime(Math.max(0, targetVol), this.ctx.currentTime); - } - } - - if (this.timerRemainingSeconds <= 0) { - this.stopSleepTimer(); - if (this.onTimerComplete) this.onTimerComplete(); - } - }, 1000); - } - - stopSleepTimer() { - if (this.timerId) { - clearInterval(this.timerId); - this.timerId = null; - this.timerRemainingSeconds = 0; - } - } -} - -window.AudioManager = AudioManager; - - -/** - * Hull Drone & Environmental Sub-Bass Synthesizer - * Generates organic, continuous low-frequency starship structural vibration & room tone. - */ - -class HullDroneSynth { - constructor(audioManager) { - this.am = audioManager; - this.nodes = []; - this.gainNode = null; - this.filterNode = null; - this.subOsc1 = null; - this.subOsc2 = null; - this.noiseSource = null; - this.isMuted = false; - - // Default configuration parameters - this.params = { - volume: 0.7, - baseFreq: 50, // Fundamental frequency (e.g. 50Hz for TNG bridge) - filterCutoff: 110, // Lowpass filter cutoff - resonance: 2.5, // Filter Q / resonance peak - noiseMix: 0.45, // Brown noise texture mix - harmonicSpread: 1.02 // Slight frequency detune between sub-oscillators for phasing - }; - } - - start() { - this.stop(); - const ctx = this.am.ctx; - if (!ctx) return; - - // Channel Gain Node - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); - - // Steep Lowpass Filter (24dB/oct via 2 cascading biquads) - this.filterNode = ctx.createBiquadFilter(); - this.filterNode.type = 'lowpass'; - this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime); - this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime); - - const filterStage2 = ctx.createBiquadFilter(); - filterStage2.type = 'lowpass'; - filterStage2.frequency.setValueAtTime(this.params.filterCutoff * 1.5, ctx.currentTime); - filterStage2.Q.setValueAtTime(1.0, ctx.currentTime); - - // Sub-bass Oscillator 1 (Sine) - this.subOsc1 = ctx.createOscillator(); - this.subOsc1.type = 'sine'; - this.subOsc1.frequency.setValueAtTime(this.params.baseFreq, ctx.currentTime); - - const osc1Gain = ctx.createGain(); - osc1Gain.gain.setValueAtTime(0.5, ctx.currentTime); - this.subOsc1.connect(osc1Gain); - osc1Gain.connect(this.filterNode); - - // Sub-bass Oscillator 2 (Triangle/Sine detuned for slow, natural phase beating) - this.subOsc2 = ctx.createOscillator(); - this.subOsc2.type = 'triangle'; - this.subOsc2.frequency.setValueAtTime(this.params.baseFreq * this.params.harmonicSpread, ctx.currentTime); - - const osc2Gain = ctx.createGain(); - osc2Gain.gain.setValueAtTime(0.3, ctx.currentTime); - this.subOsc2.connect(osc2Gain); - osc2Gain.connect(this.filterNode); - - // Brown Noise Structural Rumble Layer - const brownBuffer = this.am.createNoiseBuffer('brown', 6); - this.noiseSource = ctx.createBufferSource(); - this.noiseSource.buffer = brownBuffer; - this.noiseSource.loop = true; - - const noiseGain = ctx.createGain(); - noiseGain.gain.setValueAtTime(this.params.noiseMix * 0.7, ctx.currentTime); - this.noiseSource.connect(noiseGain); - noiseGain.connect(this.filterNode); - - // Slow LFO for organic drifting movement - const lfo = ctx.createOscillator(); - lfo.type = 'sine'; - lfo.frequency.setValueAtTime(0.1, ctx.currentTime); // 10 second cycle - - const lfoGain = ctx.createGain(); - lfoGain.gain.setValueAtTime(12, ctx.currentTime); // Modulate cutoff by ±12Hz - lfo.connect(lfoGain); - lfoGain.connect(this.filterNode.frequency); - - // Connect Graph - this.filterNode.connect(filterStage2); - filterStage2.connect(this.gainNode); - this.gainNode.connect(this.am.compressor); - - // Start Sources - this.subOsc1.start(); - this.subOsc2.start(); - this.noiseSource.start(); - lfo.start(); - - this.nodes = [this.subOsc1, this.subOsc2, this.noiseSource, lfo, osc1Gain, osc2Gain, noiseGain, lfoGain, this.filterNode, filterStage2, this.gainNode]; - } - - stop() { - if (this.nodes.length > 0) { - try { - if (this.subOsc1) this.subOsc1.stop(); - if (this.subOsc2) this.subOsc2.stop(); - if (this.noiseSource) this.noiseSource.stop(); - } catch (e) { - // Ignore if already stopped - } - this.nodes.forEach(node => { - try { node.disconnect(); } catch (e) {} - }); - this.nodes = []; - } - } - - setVolume(val) { - this.params.volume = Math.max(0, Math.min(1, val)); - if (this.gainNode && this.am.ctx && !this.isMuted) { - const now = this.am.ctx.currentTime; - this.gainNode.gain.cancelScheduledValues(now); - this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); - } - } - - setBaseFreq(freq) { - this.params.baseFreq = freq; - if (this.subOsc1 && this.subOsc2 && this.am.ctx) { - const now = this.am.ctx.currentTime; - this.subOsc1.frequency.linearRampToValueAtTime(freq, now + 0.1); - this.subOsc2.frequency.linearRampToValueAtTime(freq * this.params.harmonicSpread, now + 0.1); - } - } - - setFilterCutoff(cutoff) { - this.params.filterCutoff = cutoff; - if (this.filterNode && this.am.ctx) { - const now = this.am.ctx.currentTime; - this.filterNode.frequency.linearRampToValueAtTime(cutoff, now + 0.1); - } - } - - applyPreset(config) { - if (config.volume !== undefined) this.params.volume = config.volume; - if (config.baseFreq !== undefined) this.setBaseFreq(config.baseFreq); - if (config.filterCutoff !== undefined) this.setFilterCutoff(config.filterCutoff); - if (config.resonance !== undefined) this.params.resonance = config.resonance; - if (config.noiseMix !== undefined) this.params.noiseMix = config.noiseMix; - if (config.harmonicSpread !== undefined) this.params.harmonicSpread = config.harmonicSpread; - this.setVolume(this.params.volume); - } -} - -window.HullDroneSynth = HullDroneSynth; - - -/** - * Warp Core & Reactor Pulse Synthesizer - * Generates the iconic pulsating magnetic intermix thrum of Star Trek warp cores. - */ - -class WarpCoreSynth { - constructor(audioManager) { - this.am = audioManager; - this.nodes = []; - this.gainNode = null; - this.isMuted = false; - - // Pulse parameters - this.params = { - volume: 0.8, - bpm: 48, // Pulse rate (TNG is ~46-52 BPM, Voyager is ~68-75 BPM) - carrierFreq: 58, // Fundamental carrier pitch (Hz) - modFreqRatio: 2.0, // FM modulation frequency multiplier - modIndex: 40, // FM modulation depth - filterCutoff: 180, // Lowpass filter cutoff - pulseShape: 'tng', // 'tng', 'voyager', 'tos', 'defiant', 'nx' - resonance: 3.0, - swirlMix: 0.35 // Stereo phase swirl - }; - - this.pulseInterval = null; - this.pulsePhase = 0; - this.onPulse = null; // Callback for UI visualizer pulse animation! - } - - start() { - this.stop(); - const ctx = this.am.ctx; - if (!ctx) return; - - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); - - // Filter Node - this.filterNode = ctx.createBiquadFilter(); - this.filterNode.type = 'lowpass'; - this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime); - this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime); - - // Stereo Panner for magnetic swirl - this.panner = ctx.createStereoPanner ? ctx.createStereoPanner() : null; - - // Carrier & Modulator Oscillators (FM Engine) - this.carrier = ctx.createOscillator(); - this.carrier.type = this.params.pulseShape === 'tos' ? 'sawtooth' : (this.params.pulseShape === 'nx' ? 'triangle' : 'sine'); - this.carrier.frequency.setValueAtTime(this.params.carrierFreq, ctx.currentTime); - - // Sub-harmonic oscillator for massive bottom end - this.subOsc = ctx.createOscillator(); - this.subOsc.type = 'sine'; - this.subOsc.frequency.setValueAtTime(this.params.carrierFreq * 0.5, ctx.currentTime); - - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0.6, ctx.currentTime); - this.subOsc.connect(subGain); - subGain.connect(this.filterNode); - - // Pulse Envelope Modulator Gain Node - this.pulseGain = ctx.createGain(); - this.pulseGain.gain.setValueAtTime(0.2, ctx.currentTime); - - // Connect Carrier -> PulseGain -> Filter -> Panner -> ChannelGain -> Master - this.carrier.connect(this.pulseGain); - this.pulseGain.connect(this.filterNode); - - if (this.panner) { - this.filterNode.connect(this.panner); - this.panner.connect(this.gainNode); - } else { - this.filterNode.connect(this.gainNode); - } - - this.gainNode.connect(this.am.compressor); - - this.carrier.start(); - this.subOsc.start(); - - this.nodes = [this.carrier, this.subOsc, subGain, this.pulseGain, this.filterNode, this.gainNode]; - if (this.panner) this.nodes.push(this.panner); - - // Start precision pulse scheduler - this.startPulseLoop(); - } - - startPulseLoop() { - if (this.pulseInterval) clearInterval(this.pulseInterval); - - const intervalMs = (60 / this.params.bpm) * 1000; - this.scheduleNextPulse(); - - this.pulseInterval = setInterval(() => { - this.scheduleNextPulse(); - }, intervalMs); - } - - scheduleNextPulse() { - const ctx = this.am.ctx; - if (!ctx || !this.pulseGain || !this.filterNode) return; - - const now = ctx.currentTime; - const pulseDuration = (60 / this.params.bpm); - - this.pulsePhase = (this.pulsePhase + 1) % 4; - - // Trigger visualizer callback - if (this.onPulse) { - this.onPulse(this.pulsePhase, pulseDuration); - } - - // Dynamic envelope shaping based on ship era - if (this.params.pulseShape === 'tng') { - // Iconic 4-stage Galaxy-class magnetic warp pulse - // Soft attack, deep swelling peak, secondary reverberant harmonic bloom, smooth decay - const peakTime = now + pulseDuration * 0.28; - const secondPeak = now + pulseDuration * 0.58; - - this.pulseGain.gain.cancelScheduledValues(now); - this.pulseGain.gain.setValueAtTime(0.18, now); - this.pulseGain.gain.linearRampToValueAtTime(0.95, peakTime); - this.pulseGain.gain.exponentialRampToValueAtTime(0.45, now + pulseDuration * 0.42); - this.pulseGain.gain.linearRampToValueAtTime(0.65, secondPeak); - this.pulseGain.gain.exponentialRampToValueAtTime(0.18, now + pulseDuration * 0.95); - - // Modulate filter cutoff in sync with the pulse - this.filterNode.frequency.cancelScheduledValues(now); - this.filterNode.frequency.setValueAtTime(this.params.filterCutoff * 0.7, now); - this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 1.6, peakTime); - this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 0.7, now + pulseDuration * 0.95); - - } else if (this.params.pulseShape === 'voyager') { - // Faster, sharper, higher-resonance Class 9 warp core - const peakTime = now + pulseDuration * 0.2; - this.pulseGain.gain.cancelScheduledValues(now); - this.pulseGain.gain.setValueAtTime(0.25, now); - this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime); - this.pulseGain.gain.exponentialRampToValueAtTime(0.25, now + pulseDuration * 0.85); - - this.filterNode.frequency.cancelScheduledValues(now); - this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 1.8, peakTime); - this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 0.8, now + pulseDuration * 0.85); - - } else if (this.params.pulseShape === 'tos') { - // TOS Electromechanical oscillating engine thrum - const halfTime = now + pulseDuration * 0.5; - this.pulseGain.gain.cancelScheduledValues(now); - this.pulseGain.gain.setValueAtTime(0.4, now); - this.pulseGain.gain.linearRampToValueAtTime(0.9, halfTime); - this.pulseGain.gain.linearRampToValueAtTime(0.4, now + pulseDuration); - - } else if (this.params.pulseShape === 'defiant') { - // Defiant: tight, aggressive pulse with rapid decay - const peakTime = now + pulseDuration * 0.15; - this.pulseGain.gain.cancelScheduledValues(now); - this.pulseGain.gain.setValueAtTime(0.3, now); - this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime); - this.pulseGain.gain.exponentialRampToValueAtTime(0.3, now + pulseDuration * 0.75); - - } else { - // NX / Industrial reactor chug - const peakTime = now + pulseDuration * 0.35; - this.pulseGain.gain.cancelScheduledValues(now); - this.pulseGain.gain.setValueAtTime(0.2, now); - this.pulseGain.gain.linearRampToValueAtTime(0.85, peakTime); - this.pulseGain.gain.linearRampToValueAtTime(0.2, now + pulseDuration); - } - - // Subtle stereo panning drift - if (this.panner && this.params.swirlMix > 0) { - const panTarget = Math.sin(this.pulsePhase * Math.PI * 0.5) * this.params.swirlMix; - this.panner.pan.linearRampToValueAtTime(panTarget, now + pulseDuration * 0.5); - } - } - - setBpm(bpm) { - this.params.bpm = Math.max(20, Math.min(160, bpm)); - if (this.nodes.length > 0) { - this.startPulseLoop(); - } - } - - setVolume(val) { - this.params.volume = Math.max(0, Math.min(1, val)); - if (this.gainNode && this.am.ctx && !this.isMuted) { - const now = this.am.ctx.currentTime; - this.gainNode.gain.cancelScheduledValues(now); - this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); - } - } - - setCarrierFreq(freq) { - this.params.carrierFreq = freq; - if (this.carrier && this.subOsc && this.am.ctx) { - const now = this.am.ctx.currentTime; - this.carrier.frequency.linearRampToValueAtTime(freq, now + 0.1); - this.subOsc.frequency.linearRampToValueAtTime(freq * 0.5, now + 0.1); - } - } - - stop() { - if (this.pulseInterval) { - clearInterval(this.pulseInterval); - this.pulseInterval = null; - } - if (this.nodes.length > 0) { - try { - if (this.carrier) this.carrier.stop(); - if (this.subOsc) this.subOsc.stop(); - } catch (e) {} - this.nodes.forEach(node => { - try { node.disconnect(); } catch (e) {} - }); - this.nodes = []; - } - } - - applyPreset(config) { - if (config.volume !== undefined) this.params.volume = config.volume; - if (config.bpm !== undefined) this.setBpm(config.bpm); - if (config.carrierFreq !== undefined) this.setCarrierFreq(config.carrierFreq); - if (config.filterCutoff !== undefined) this.params.filterCutoff = config.filterCutoff; - if (config.pulseShape !== undefined) this.params.pulseShape = config.pulseShape; - if (config.resonance !== undefined) this.params.resonance = config.resonance; - if (config.swirlMix !== undefined) this.params.swirlMix = config.swirlMix; - this.setVolume(this.params.volume); - } -} - -window.WarpCoreSynth = WarpCoreSynth; - - -/** - * Environmental Life Support & Airflow Synthesizer - * Generates continuous ventilation airflow, atmospheric hiss, and room acoustic damping. - */ - -class LifeSupportSynth { - constructor(audioManager) { - this.am = audioManager; - this.nodes = []; - this.gainNode = null; - this.isMuted = false; - - this.params = { - volume: 0.5, - noiseType: 'pink', // 'pink' (warm TNG), 'white' (crisp Voyager), 'brown' (heavy NX-01) - highpassFreq: 180, // Cuts extreme sub rumble to isolate air movement - lowpassFreq: 1800, // Gentle top-end rolloff - airflowModSpeed: 0.15, // Subtle breathing movement of the environmental airflow - airflowModDepth: 0.12 // Depth of airflow intensity modulation - }; - } - - start() { - this.stop(); - const ctx = this.am.ctx; - if (!ctx) return; - - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); - - // Highpass filter (cuts muddy lows) - const hpFilter = ctx.createBiquadFilter(); - hpFilter.type = 'highpass'; - hpFilter.frequency.setValueAtTime(this.params.highpassFreq, ctx.currentTime); - - // Lowpass filter (shapes the crispness vs warmth of the air) - this.lpFilter = ctx.createBiquadFilter(); - this.lpFilter.type = 'lowpass'; - this.lpFilter.frequency.setValueAtTime(this.params.lowpassFreq, ctx.currentTime); - this.lpFilter.Q.setValueAtTime(0.7, ctx.currentTime); - - // Noise buffer source - const noiseBuffer = this.am.createNoiseBuffer(this.params.noiseType, 6); - this.noiseSource = ctx.createBufferSource(); - this.noiseSource.buffer = noiseBuffer; - this.noiseSource.loop = true; - - // Slow airflow modulation LFO for organic breath - const airflowLfo = ctx.createOscillator(); - airflowLfo.type = 'sine'; - airflowLfo.frequency.setValueAtTime(this.params.airflowModSpeed, ctx.currentTime); - - const lfoGain = ctx.createGain(); - lfoGain.gain.setValueAtTime(this.params.airflowModDepth, ctx.currentTime); - - const modGain = ctx.createGain(); - modGain.gain.setValueAtTime(0.8, ctx.currentTime); - - airflowLfo.connect(lfoGain); - lfoGain.connect(modGain.gain); - - // Stereo widener using delay - const splitter = ctx.createChannelSplitter(2); - const merger = ctx.createChannelMerger(2); - const delayRight = ctx.createDelay(); - delayRight.delayTime.setValueAtTime(0.018, ctx.currentTime); // 18ms Haas effect widening - - // Graph: Noise -> ModGain -> HP -> LP -> Splitter -> (Left direct, Right delay) -> Merger -> Gain -> Compressor - this.noiseSource.connect(modGain); - modGain.connect(hpFilter); - hpFilter.connect(this.lpFilter); - this.lpFilter.connect(splitter); - - splitter.connect(merger, 0, 0); // Left channel - splitter.connect(delayRight, 1); - delayRight.connect(merger, 0, 1); // Right delayed channel - - merger.connect(this.gainNode); - this.gainNode.connect(this.am.compressor); - - this.noiseSource.start(); - airflowLfo.start(); - - this.nodes = [ - this.noiseSource, airflowLfo, lfoGain, modGain, - hpFilter, this.lpFilter, splitter, delayRight, merger, this.gainNode - ]; - } - - stop() { - if (this.nodes.length > 0) { - try { - if (this.noiseSource) this.noiseSource.stop(); - } catch (e) {} - this.nodes.forEach(node => { - try { node.disconnect(); } catch (e) {} - }); - this.nodes = []; - } - } - - setVolume(val) { - this.params.volume = Math.max(0, Math.min(1, val)); - if (this.gainNode && this.am.ctx && !this.isMuted) { - const now = this.am.ctx.currentTime; - this.gainNode.gain.cancelScheduledValues(now); - this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); - } - } - - setFilterCutoff(freq) { - this.params.lowpassFreq = freq; - if (this.lpFilter && this.am.ctx) { - const now = this.am.ctx.currentTime; - this.lpFilter.frequency.linearRampToValueAtTime(freq, now + 0.1); - } - } - - applyPreset(config) { - if (config.volume !== undefined) this.params.volume = config.volume; - if (config.noiseType !== undefined) this.params.noiseType = config.noiseType; - if (config.highpassFreq !== undefined) this.params.highpassFreq = config.highpassFreq; - if (config.lowpassFreq !== undefined) this.setFilterCutoff(config.lowpassFreq); - if (config.airflowModSpeed !== undefined) this.params.airflowModSpeed = config.airflowModSpeed; - if (config.airflowModDepth !== undefined) this.params.airflowModDepth = config.airflowModDepth; - this.setVolume(this.params.volume); - } -} - -window.LifeSupportSynth = LifeSupportSynth; - - -/** - * Procedural Starship Telemetry, LCARS Chirps, Beeps & Console Synthesizer - * 100% synthesized programmatically via Web Audio API oscillators, FM synthesis, and envelopes. - * Zero stored audio samples. - */ - -class TelemetrySynth { - constructor(audioManager) { - this.am = audioManager; - this.gainNode = null; - this.isMuted = false; - this.schedulerTimer = null; - - this.params = { - volume: 0.4, - density: 0.5, // How often background telemetry chirps occur (0 = off, 1 = busy bridge) - era: 'tng', // 'tng', 'voyager', 'tos', 'ds9', 'nx' - reverbMix: 0.25 - }; - - // Musical pitch frequencies for authentic LCARS musical intervals (major/minor pentatonic & perfect 4ths/5ths) - this.lcarsPitches = [ - 880, 987.77, 1046.50, 1174.66, 1318.51, 1396.91, 1567.98, 1760, 1975.53, 2093.00, 2349.32, 2637.02 - ]; - - // TOS Bridge oscillator warble frequencies - this.tosFrequencies = [ - 440, 554.37, 659.25, 830.61, 880, 1108.73, 1318.51, 1661.22, 2217.46 - ]; - } - - start() { - this.stop(); - const ctx = this.am.ctx; - if (!ctx) return; - - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); - this.gainNode.connect(this.am.compressor); - - this.startAutoTelemetryScheduler(); - } - - stop() { - if (this.schedulerTimer) { - clearTimeout(this.schedulerTimer); - this.schedulerTimer = null; - } - } - - setVolume(val) { - this.params.volume = Math.max(0, Math.min(1, val)); - if (this.gainNode && this.am.ctx && !this.isMuted) { - const now = this.am.ctx.currentTime; - this.gainNode.gain.cancelScheduledValues(now); - this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); - } - } - - setDensity(val) { - this.params.density = Math.max(0, Math.min(1, val)); - } - - startAutoTelemetryScheduler() { - if (this.schedulerTimer) clearTimeout(this.schedulerTimer); - if (this.params.density <= 0.01) return; - - // Calculate delay inversely proportional to density (2s to 12s) - const baseDelay = 12000 * (1.05 - this.params.density); - const jitter = Math.random() * 4000; - const nextInterval = Math.max(800, baseDelay + jitter); - - this.schedulerTimer = setTimeout(() => { - this.playRandomTelemetrySound(); - this.startAutoTelemetryScheduler(); - }, nextInterval); - } - - playRandomTelemetrySound() { - if (this.isMuted || this.params.volume <= 0.01 || !this.am.ctx) return; - - switch (this.params.era) { - case 'tos': - Math.random() > 0.4 ? this.synthesizeTOSWarble() : this.synthesizeTOSRelayClick(); - break; - case 'ds9': - Math.random() > 0.5 ? this.synthesizeCardassianSensor() : this.synthesizeLCARSSingleChirp(); - break; - case 'voyager': - Math.random() > 0.4 ? this.synthesizeLCARSDoubleChirp() : this.synthesizeSensorSweep(); - break; - case 'nx': - Math.random() > 0.5 ? this.synthesizeNXRelay() : this.synthesizeNXIndicatorBeep(); - break; - case 'tng': - default: - const r = Math.random(); - if (r < 0.45) this.synthesizeLCARSSingleChirp(); - else if (r < 0.75) this.synthesizeLCARSDoubleChirp(); - else if (r < 0.90) this.synthesizeLCARSSequence(); - else this.synthesizeSensorSweep(); - break; - } - - // OBSERVATION intentionally treats telemetry as an abstract activity pulse, - // not as a claim that a specific fictional beep means a specific thing. - window.dispatchEvent(new CustomEvent('scifi-telemetry-activity', { - detail: { - era: this.params.era, - density: this.params.density, - firedAt: performance.now() - } - })); - } - - /** - * TNG/Voyager Single LCARS Touch Tone (Soft sine with gentle attack and rapid exponential decay) - */ - synthesizeLCARSSingleChirp(pitch = null) { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const freq = pitch || this.lcarsPitches[Math.floor(Math.random() * this.lcarsPitches.length)]; - const duration = 0.09; - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(freq, now); - // Subtle downward micro-pitch glide (12Hz) for that warm capacitive touch feel - osc.frequency.exponentialRampToValueAtTime(freq * 0.98, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.35, now + 0.008); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - // Filter to eliminate any click - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime(3200, now); - - osc.connect(env); - env.connect(filter); - filter.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * TNG LCARS Double Chirp (Iconic standard confirmation tone) - */ - synthesizeLCARSDoubleChirp() { - const ctx = this.am.ctx; - if (!ctx) return; - const idx = Math.floor(Math.random() * (this.lcarsPitches.length - 2)); - const p1 = this.lcarsPitches[idx]; - const p2 = this.lcarsPitches[idx + 2]; // Minor third or fourth higher - - this.synthesizeLCARSSingleChirp(p1); - setTimeout(() => { - this.synthesizeLCARSSingleChirp(p2); - }, 65); - } - - /** - * TNG LCARS Multi-Tone Data Acknowledgment Sequence - */ - synthesizeLCARSSequence() { - const ctx = this.am.ctx; - if (!ctx) return; - const notes = [ - this.lcarsPitches[Math.floor(Math.random() * 4) + 4], - this.lcarsPitches[Math.floor(Math.random() * 4) + 6], - this.lcarsPitches[Math.floor(Math.random() * 4) + 2] - ]; - - notes.forEach((freq, i) => { - setTimeout(() => { - this.synthesizeLCARSSingleChirp(freq); - }, i * 75); - }); - } - - /** - * High-tech Sensor Sweep Tone (Voyager/TNG Long-Range Sensor telemetry) - */ - synthesizeSensorSweep() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.38; - const startFreq = 1200 + Math.random() * 800; - const endFreq = startFreq * (Math.random() > 0.5 ? 1.6 : 0.65); - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(startFreq, now); - osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.18, now + 0.05); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * TOS Original Series Bridge Electronic Computer Warble - * Two detuned square/triangle oscillators modulated by high-speed vibrato LFO - */ - synthesizeTOSWarble() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.45; - const baseFreq = this.tosFrequencies[Math.floor(Math.random() * this.tosFrequencies.length)]; - - const osc1 = ctx.createOscillator(); - osc1.type = 'triangle'; - osc1.frequency.setValueAtTime(baseFreq, now); - - const osc2 = ctx.createOscillator(); - osc2.type = 'sawtooth'; - osc2.frequency.setValueAtTime(baseFreq * 1.5, now); - - // Fast Vibrato LFO - const lfo = ctx.createOscillator(); - lfo.type = 'sine'; - lfo.frequency.setValueAtTime(14 + Math.random() * 8, now); // 14-22 Hz warble - - const lfoGain = ctx.createGain(); - lfoGain.gain.setValueAtTime(35, now); - lfo.connect(lfoGain); - lfoGain.connect(osc1.frequency); - lfoGain.connect(osc2.frequency); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.22, now + 0.04); - env.gain.setValueAtTime(0.22, now + duration * 0.7); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - // Bandpass filter to create that vintage analog 1960s telephone/relay resonance - const filter = ctx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.setValueAtTime(baseFreq * 1.2, now); - filter.Q.setValueAtTime(3.5, now); - - osc1.connect(env); - osc2.connect(env); - env.connect(filter); - filter.connect(this.gainNode); - - osc1.start(now); - osc2.start(now); - lfo.start(now); - - osc1.stop(now + duration); - osc2.stop(now + duration); - lfo.stop(now + duration); - } - - /** - * TOS Mechanical Relay Solenoid Click - */ - synthesizeTOSRelayClick() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.025; - - const osc = ctx.createOscillator(); - osc.type = 'square'; - osc.frequency.setValueAtTime(1400, now); - osc.frequency.exponentialRampToValueAtTime(300, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.3, now); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * DS9 / Cardassian Cavernous Sensor Tone (Resonant metallic ring) - */ - synthesizeCardassianSensor() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.55; - const freq = 420 + Math.random() * 200; - - const osc1 = ctx.createOscillator(); - osc1.type = 'sine'; - osc1.frequency.setValueAtTime(freq, now); - - const osc2 = ctx.createOscillator(); - osc2.type = 'sine'; - osc2.frequency.setValueAtTime(freq * 1.414, now); // Tritone metallic dissonance - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.2, now + 0.015); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - osc1.connect(env); - osc2.connect(env); - env.connect(this.gainNode); - - osc1.start(now); - osc2.start(now); - osc1.stop(now + duration); - osc2.stop(now + duration); - } - - /** - * NX-01 Industrial Hydraulic Relay Click - */ - synthesizeNXRelay() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.04; - - const osc = ctx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.setValueAtTime(750, now); - osc.frequency.exponentialRampToValueAtTime(120, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.25, now); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * NX-01 Indicator Beep (Early 22nd century industrial tone) - */ - synthesizeNXIndicatorBeep() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.08; - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(950, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.2, now + 0.005); - env.gain.setValueAtTime(0.2, now + duration * 0.8); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Iconic TNG 2-Tone Door Chime ("Come in") - */ - synthesizeDoorChime() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const f1 = 880; // A5 - const f2 = 1174.66; // D6 (Up a fourth) - - const osc1 = ctx.createOscillator(); - osc1.type = 'sine'; - osc1.frequency.setValueAtTime(f1, now); - - const env1 = ctx.createGain(); - env1.gain.setValueAtTime(0.001, now); - env1.gain.linearRampToValueAtTime(0.35, now + 0.015); - env1.gain.exponentialRampToValueAtTime(0.001, now + 0.45); - - osc1.connect(env1); - env1.connect(this.gainNode); - osc1.start(now); - osc1.stop(now + 0.45); - - // Second tone starts at 0.16s - const osc2 = ctx.createOscillator(); - osc2.type = 'sine'; - osc2.frequency.setValueAtTime(f2, now + 0.16); - - const env2 = ctx.createGain(); - env2.gain.setValueAtTime(0.001, now + 0.16); - env2.gain.linearRampToValueAtTime(0.4, now + 0.175); - env2.gain.exponentialRampToValueAtTime(0.001, now + 0.7); - - osc2.connect(env2); - env2.connect(this.gainNode); - osc2.start(now + 0.16); - osc2.stop(now + 0.7); - } - - applyPreset(config) { - if (config.volume !== undefined) this.params.volume = config.volume; - if (config.density !== undefined) this.setDensity(config.density); - if (config.era !== undefined) this.params.era = config.era; - this.setVolume(this.params.volume); - this.startAutoTelemetryScheduler(); - } -} - -window.TelemetrySynth = TelemetrySynth; - - -/** - * Procedural Starship Alert & Event Synthesizer - * 100% synthesized programmatically in Web Audio API. - * Includes TNG Red Alert (3-tone), TOS Red Alert (hooter buzzer), Movie-era descending klaxon, - * Yellow Alert chime, and dynamic Warp Drive Throttle swell. - */ - -class AlertSynth { - constructor(audioManager) { - this.am = audioManager; - this.gainNode = null; - this.activeAlert = null; // 'red', 'yellow', null - this.alertTimer = null; - this.alertType = 'tng'; // 'tng', 'tos', 'movie' - - this.params = { - volume: 0.6 - }; - } - - init() { - if (this.gainNode || !this.am.ctx) return; - const ctx = this.am.ctx; - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(this.params.volume, ctx.currentTime); - this.gainNode.connect(this.am.compressor); - } - - setVolume(val) { - this.params.volume = Math.max(0, Math.min(1, val)); - if (this.gainNode && this.am.ctx) { - const now = this.am.ctx.currentTime; - this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); - } - } - - triggerRedAlert(type = 'tng') { - this.init(); - this.stopAlert(); - this.activeAlert = 'red'; - this.alertType = type; - - const playLoop = () => { - if (this.activeAlert !== 'red') return; - let loopDuration = 1.35; - - if (this.alertType === 'tng') { - this.synthesizeTNGRedAlertCycle(); - loopDuration = 1.35; - } else if (this.alertType === 'tos') { - this.synthesizeTOSRedAlertCycle(); - loopDuration = 1.1; - } else { - this.synthesizeMovieRedAlertCycle(); - loopDuration = 1.4; - } - - this.alertTimer = setTimeout(playLoop, loopDuration * 1000); - }; - - playLoop(); - } - - triggerYellowAlert() { - this.init(); - this.stopAlert(); - this.activeAlert = 'yellow'; - - const playLoop = () => { - if (this.activeAlert !== 'yellow') return; - this.synthesizeYellowAlertCycle(); - this.alertTimer = setTimeout(playLoop, 2200); - }; - - playLoop(); - } - - stopAlert() { - this.activeAlert = null; - if (this.alertTimer) { - clearTimeout(this.alertTimer); - this.alertTimer = null; - } - } - - /** - * TNG Red Alert Klaxon (3-tone rising & cascading electronic horn with resonant envelope) - */ - synthesizeTNGRedAlertCycle() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - // Frequencies for the iconic TNG 3-tone klaxon chord: F5 (698.46Hz), Ab5 (830.61Hz), C6 (1046.50Hz) - const freqs = [698.46, 830.61, 1046.50]; - - freqs.forEach((freq, idx) => { - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(freq * 0.94, now); - // Fast upward swoop on trigger - osc.frequency.exponentialRampToValueAtTime(freq, now + 0.12); - - // Lowpass filter to give that brassy starship horn acoustic resonance - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime(1600, now); - filter.Q.setValueAtTime(4.0, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.25 / freqs.length, now + 0.08); - env.gain.setValueAtTime(0.25 / freqs.length, now + 0.45); - env.gain.exponentialRampToValueAtTime(0.0001, now + 0.85); - - osc.connect(filter); - filter.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + 0.86); - }); - } - - /** - * TOS Red Alert Buzzer / Hooter Siren (Pulsing 2-tone frequency modulation) - */ - synthesizeTOSRedAlertCycle() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.85; - - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(540, now); - osc.frequency.linearRampToValueAtTime(920, now + duration * 0.5); - osc.frequency.linearRampToValueAtTime(540, now + duration); - - const filter = ctx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.setValueAtTime(800, now); - filter.Q.setValueAtTime(2.2, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.01, now); - env.gain.linearRampToValueAtTime(0.28, now + 0.05); - env.gain.setValueAtTime(0.28, now + duration * 0.85); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(filter); - filter.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Star Trek Movie Era Refit Descending Red Alert Klaxon - */ - synthesizeMovieRedAlertCycle() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 1.05; - - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(1350, now); - osc.frequency.exponentialRampToValueAtTime(420, now + duration * 0.9); - - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime(2200, now); - filter.Q.setValueAtTime(3.5, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.01, now); - env.gain.linearRampToValueAtTime(0.3, now + 0.06); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(filter); - filter.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Yellow Alert Pulsing Warning Chime - */ - synthesizeYellowAlertCycle() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const f1 = 660; // E5 - const f2 = 880; // A5 - - [0, 0.22].forEach((offset, idx) => { - const freq = idx === 0 ? f1 : f2; - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(freq, now + offset); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now + offset); - env.gain.linearRampToValueAtTime(0.32, now + offset + 0.015); - env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now + offset); - osc.stop(now + offset + 0.52); - }); - } - - /** - * Procedural Warp Drive Acceleration Swell ("Engage!") - * Synthesizes rising plasma induction whine + deep bass detonation - */ - synthesizeWarpJump() { - this.init(); - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 2.8; - - // 1. Rising High Induction Whine - const whineOsc = ctx.createOscillator(); - whineOsc.type = 'sawtooth'; - whineOsc.frequency.setValueAtTime(80, now); - whineOsc.frequency.exponentialRampToValueAtTime(3800, now + 1.8); - whineOsc.frequency.exponentialRampToValueAtTime(14000, now + 2.5); - - const whineFilter = ctx.createBiquadFilter(); - whineFilter.type = 'bandpass'; - whineFilter.frequency.setValueAtTime(200, now); - whineFilter.frequency.exponentialRampToValueAtTime(4500, now + 1.8); - whineFilter.Q.setValueAtTime(5.0, now); - - const whineEnv = ctx.createGain(); - whineEnv.gain.setValueAtTime(0.01, now); - whineEnv.gain.linearRampToValueAtTime(0.35, now + 1.6); - whineEnv.gain.exponentialRampToValueAtTime(0.001, now + 2.7); - - whineOsc.connect(whineFilter); - whineFilter.connect(whineEnv); - whineEnv.connect(this.gainNode); - - // 2. Sub-bass Matter-Antimatter Boom - const subOsc = ctx.createOscillator(); - subOsc.type = 'sine'; - subOsc.frequency.setValueAtTime(140, now + 1.4); - subOsc.frequency.exponentialRampToValueAtTime(32, now + 2.6); - - const subEnv = ctx.createGain(); - subEnv.gain.setValueAtTime(0.001, now + 1.4); - subEnv.gain.linearRampToValueAtTime(0.7, now + 1.7); - subEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - subOsc.connect(subEnv); - subEnv.connect(this.gainNode); - - whineOsc.start(now); - whineOsc.stop(now + 2.7); - subOsc.start(now + 1.4); - subOsc.stop(now + duration); - } -} - -window.AlertSynth = AlertSynth; - - -/** - * Procedural Doctor Who & TARDIS Sound Synthesizer - * 100% synthesized programmatically via Web Audio API. - * Includes Dematerialization Wheeze-Groan, Cloister Bell, Sonic Screwdriver, and TARDIS Console Foley. - */ - -class WhoniverseAudioSynth { - constructor(audioManager) { - this.am = audioManager; - this.gainNode = null; - this.activeCloister = false; - this.cloisterTimer = null; - } - - init() { - if (this.gainNode || !this.am.ctx) return; - const ctx = this.am.ctx; - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime); - this.gainNode.connect(this.am.compressor); - } - - /** - * Procedural TARDIS Materialization / Dematerialization ("Wheeze-Groan") - * Modeled after Brian Hodgson's 1963 BBC Radiophonic technique: - * Dragging keys on piano bass strings -> reverse playback -> slow tape speed -> feedback loop. - */ - synthesizeDematCycle(cycles = 4) { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - for (let c = 0; c < cycles; c++) { - const cycleStart = ctx.currentTime + c * 1.85; - this.synthesizeSingleDematSwell(cycleStart, c, cycles); - } - } - - synthesizeSingleDematSwell(startTime, cycleIndex, totalCycles) { - const ctx = this.am.ctx; - const duration = 1.75; - - // Intensity fades slightly on later cycles - const intensity = 1.0 - (cycleIndex / totalCycles) * 0.35; - - // 1. Friction Scrape Carrier (Sawtooth through resonant highpass/bandpass with frequency glide) - const frictionOsc = ctx.createOscillator(); - frictionOsc.type = 'sawtooth'; - // Frequency glides up then groans down - frictionOsc.frequency.setValueAtTime(120, startTime); - frictionOsc.frequency.exponentialRampToValueAtTime(840, startTime + 0.65); - frictionOsc.frequency.exponentialRampToValueAtTime(95, startTime + duration); - - // Filter modeling the piano soundboard metallic scraping resonance - const frictionFilter = ctx.createBiquadFilter(); - frictionFilter.type = 'bandpass'; - frictionFilter.frequency.setValueAtTime(320, startTime); - frictionFilter.frequency.exponentialRampToValueAtTime(1450, startTime + 0.65); - frictionFilter.frequency.exponentialRampToValueAtTime(220, startTime + duration); - frictionFilter.Q.setValueAtTime(4.5, startTime); - - const frictionGain = ctx.createGain(); - frictionGain.gain.setValueAtTime(0.001, startTime); - frictionGain.gain.linearRampToValueAtTime(0.4 * intensity, startTime + 0.45); - frictionGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); - - // 2. Sub-Vortex Resonant Groan (FM synthesis for the deep cosmic groaning undertone) - const groanCarrier = ctx.createOscillator(); - groanCarrier.type = 'triangle'; - groanCarrier.frequency.setValueAtTime(55, startTime); - groanCarrier.frequency.linearRampToValueAtTime(138, startTime + 0.55); - groanCarrier.frequency.exponentialRampToValueAtTime(48, startTime + duration); - - const groanMod = ctx.createOscillator(); - groanMod.type = 'sine'; - groanMod.frequency.setValueAtTime(28, startTime); // Phasing FM modulator - groanMod.frequency.linearRampToValueAtTime(65, startTime + 0.6); - - const groanModGain = ctx.createGain(); - groanModGain.gain.setValueAtTime(45, startTime); - groanMod.connect(groanModGain); - groanModGain.connect(groanCarrier.frequency); - - const groanGain = ctx.createGain(); - groanGain.gain.setValueAtTime(0.001, startTime); - groanGain.gain.linearRampToValueAtTime(0.6 * intensity, startTime + 0.5); - groanGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); - - // 3. Phasing Flutter / Swell (Tape-flange simulation via slow LFO) - const lfo = ctx.createOscillator(); - lfo.type = 'sine'; - lfo.frequency.setValueAtTime(5.5, startTime); // 5.5 Hz flanging flutter - - const lfoDepth = ctx.createGain(); - lfoDepth.gain.setValueAtTime(0.25, startTime); - lfo.connect(lfoDepth); - lfoDepth.connect(frictionGain.gain); - - // Connect Graph - frictionOsc.connect(frictionFilter); - frictionFilter.connect(frictionGain); - frictionGain.connect(this.gainNode); - - groanCarrier.connect(groanGain); - groanGain.connect(this.gainNode); - - // Trigger Nodes - frictionOsc.start(startTime); - frictionOsc.stop(startTime + duration); - groanCarrier.start(startTime); - groanCarrier.stop(startTime + duration); - groanMod.start(startTime); - groanMod.stop(startTime + duration); - lfo.start(startTime); - lfo.stop(startTime + duration); - } - - /** - * Procedural Cloister Bell (Deep, ominous bronze cathedral bell) - */ - triggerCloisterBell() { - this.init(); - this.stopCloisterBell(); - this.activeCloister = true; - - const ringLoop = () => { - if (!this.activeCloister) return; - this.synthesizeCloisterStrike(); - this.cloisterTimer = setTimeout(ringLoop, 3200); // Canonical cloister bell repetition rate - }; - - ringLoop(); - } - - stopCloisterBell() { - this.activeCloister = false; - if (this.cloisterTimer) { - clearTimeout(this.cloisterTimer); - this.cloisterTimer = null; - } - } - - synthesizeCloisterStrike() { - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 4.2; - - // Authentic bell inharmonic partial ratios: Fundamental, Minor 3rd, 5th, Octave, Major 7th - const bellPartials = [ - { freqRatio: 1.0, gain: 0.65, decay: 4.2 }, // Fundamental ~108 Hz - { freqRatio: 1.19, gain: 0.45, decay: 3.6 }, // Minor third - { freqRatio: 1.51, gain: 0.40, decay: 3.1 }, // Fifth - { freqRatio: 2.01, gain: 0.30, decay: 2.4 }, // Octave - { freqRatio: 2.74, gain: 0.22, decay: 1.8 }, // Upper strike tone - { freqRatio: 3.42, gain: 0.15, decay: 1.2 } // High strike transient - ]; - - const basePitch = 108.0; // Deep bronze bell pitch - - bellPartials.forEach(p => { - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(basePitch * p.freqRatio, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(p.gain * 0.35, now + 0.012); // Sharp hammer impact - env.gain.exponentialRampToValueAtTime(0.0001, now + p.decay); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + p.decay + 0.05); - }); - } - - /** - * Procedural Sonic Screwdriver (High-frequency modulated sweep & vibrato warble) - */ - synthesizeSonicScrewdriver(durationSeconds = 1.2) { - this.init(); - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = durationSeconds; - - // Dual square/saw oscillators - const osc1 = ctx.createOscillator(); - osc1.type = 'square'; - osc1.frequency.setValueAtTime(2350, now); - osc1.frequency.linearRampToValueAtTime(2650, now + duration * 0.5); - osc1.frequency.linearRampToValueAtTime(2350, now + duration); - - const osc2 = ctx.createOscillator(); - osc2.type = 'sawtooth'; - osc2.frequency.setValueAtTime(2362, now); // 12Hz natural phase beat - - // Rapid Vibrato LFO - const vibrato = ctx.createOscillator(); - vibrato.type = 'sine'; - vibrato.frequency.setValueAtTime(32, now); // 32 Hz warble rate - - const vibGain = ctx.createGain(); - vibGain.gain.setValueAtTime(140, now); - vibrato.connect(vibGain); - vibGain.connect(osc1.frequency); - vibGain.connect(osc2.frequency); - - // Bandpass filter for metallic resonance - const filter = ctx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.setValueAtTime(2500, now); - filter.Q.setValueAtTime(4.0, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.28, now + 0.03); - env.gain.setValueAtTime(0.28, now + duration * 0.85); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - osc1.connect(filter); - osc2.connect(filter); - filter.connect(env); - env.connect(this.gainNode); - - osc1.start(now); - osc2.start(now); - vibrato.start(now); - osc1.stop(now + duration); - osc2.stop(now + duration); - vibrato.stop(now + duration); - } - - /** - * Fast-Return Spring Lever (Heavy spring recoil clack + resonant ring) - */ - synthesizeFastReturn() { - this.init(); - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.35; - - const osc = ctx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.setValueAtTime(620, now); - osc.frequency.exponentialRampToValueAtTime(95, now + 0.08); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.45, now); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * TARDIS Demat Switch / Relay Solenoid - */ - synthesizeDematSwitch() { - this.init(); - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 0.06; - - const osc = ctx.createOscillator(); - osc.type = 'square'; - osc.frequency.setValueAtTime(850, now); - osc.frequency.exponentialRampToValueAtTime(140, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.35, now); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Telepathic Circuit Chime (Glassy, mystical resonance) - */ - synthesizeTelepathicChime() { - this.init(); - const ctx = this.am.ctx; - if (!ctx || !this.gainNode) return; - - const now = ctx.currentTime; - const duration = 1.4; - const notes = [1046.50, 1318.51, 1567.98, 2093.00]; // C Major arpeggio shimmer - - notes.forEach((freq, idx) => { - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(freq, now + idx * 0.08); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now + idx * 0.08); - env.gain.linearRampToValueAtTime(0.18, now + idx * 0.08 + 0.02); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now + idx * 0.08); - osc.stop(now + duration + 0.05); - }); - } -} - -window.WhoniverseAudioSynth = WhoniverseAudioSynth; - - -/** - * Procedural Audio Synthesizers for Expanded Sci-Fi Universes - * Generates Epstein drives, Bio-ship neural pulses, DRADIS sonar, Singularity drives, - * Retro analog tone glides, and Ludicrous speed reality shifts via Web Audio API. - */ - -class ExpandedSciFiAudioSynth { - constructor(audioManager) { - this.am = audioManager; - this.gainNode = null; - } - - init() { - if (this.gainNode || !this.am.ctx) return; - const ctx = this.am.ctx; - this.gainNode = ctx.createGain(); - this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime); - this.gainNode.connect(this.am.compressor); - } - - /** - * Epstein Drive Fusion Torch Burn (The Expanse / Industrial Space) - * Tremendous raw fusion thrust with high-pressure magnetic plasma acceleration - */ - synthesizeEpsteinBurn() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 3.5; - - // 1. High-frequency plasma induction whine - const whineOsc = ctx.createOscillator(); - whineOsc.type = 'sawtooth'; - whineOsc.frequency.setValueAtTime(140, now); - whineOsc.frequency.exponentialRampToValueAtTime(1800, now + 1.2); - whineOsc.frequency.exponentialRampToValueAtTime(3200, now + 2.5); - - const whineFilter = ctx.createBiquadFilter(); - whineFilter.type = 'bandpass'; - whineFilter.frequency.setValueAtTime(280, now); - whineFilter.frequency.exponentialRampToValueAtTime(2600, now + 2.0); - whineFilter.Q.setValueAtTime(4.0, now); - - const whineGain = ctx.createGain(); - whineGain.gain.setValueAtTime(0.001, now); - whineGain.gain.linearRampToValueAtTime(0.35, now + 1.0); - whineGain.gain.exponentialRampToValueAtTime(0.001, now + duration); - - whineOsc.connect(whineFilter); - whineFilter.connect(whineGain); - whineGain.connect(this.gainNode); - - // 2. Colossal fusion blast roar (filtered noise) - const roarBuffer = this.am.createNoiseBuffer('brown', 4); - const roarSource = ctx.createBufferSource(); - roarSource.buffer = roarBuffer; - - const roarFilter = ctx.createBiquadFilter(); - roarFilter.type = 'lowpass'; - roarFilter.frequency.setValueAtTime(180, now); - roarFilter.frequency.linearRampToValueAtTime(550, now + 1.2); - roarFilter.frequency.exponentialRampToValueAtTime(120, now + duration); - - const roarGain = ctx.createGain(); - roarGain.gain.setValueAtTime(0.001, now); - roarGain.gain.linearRampToValueAtTime(0.7, now + 1.2); - roarGain.gain.exponentialRampToValueAtTime(0.001, now + duration); - - roarSource.connect(roarFilter); - roarFilter.connect(roarGain); - roarGain.connect(this.gainNode); - - whineOsc.start(now); - whineOsc.stop(now + duration); - roarSource.start(now); - roarSource.stop(now + duration); - } - - /** - * Bio-Ship Starburst / Neural Pulse (Farscape Moya & Bioships) - * Organic vocalized dimensional fold and vascular wave - */ - synthesizeStarburst() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 2.8; - - const osc1 = ctx.createOscillator(); - osc1.type = 'sine'; - osc1.frequency.setValueAtTime(85, now); - osc1.frequency.exponentialRampToValueAtTime(940, now + 1.4); - osc1.frequency.exponentialRampToValueAtTime(45, now + duration); - - const osc2 = ctx.createOscillator(); - osc2.type = 'triangle'; - osc2.frequency.setValueAtTime(125, now); - osc2.frequency.exponentialRampToValueAtTime(1420, now + 1.4); - osc2.frequency.exponentialRampToValueAtTime(65, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.45, now + 1.3); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc1.connect(env); - osc2.connect(env); - env.connect(this.gainNode); - - osc1.start(now); - osc2.start(now); - osc1.stop(now + duration); - osc2.stop(now + duration); - } - - /** - * Battlestar Galactica DRADIS Sonar Ping (Military Space) - * The iconic tactical combat contact echo - */ - synthesizeDradisPing() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 1.4; - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(1860, now); - osc.frequency.exponentialRampToValueAtTime(1540, now + 0.08); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.35, now + 0.01); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * FTL Jump Thunderclap (BSG / Military Space) - * Sudden vacuum displacement shockwave - */ - synthesizeFtlJump() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 2.2; - - const noiseBuffer = this.am.createNoiseBuffer('brown', 2.5); - const noise = ctx.createBufferSource(); - noise.buffer = noiseBuffer; - - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime(800, now); - filter.frequency.exponentialRampToValueAtTime(45, now + 1.8); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.8, now); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - noise.connect(filter); - filter.connect(env); - env.connect(this.gainNode); - - noise.start(now); - noise.stop(now + duration); - } - - /** - * Retro Astrogator Tone Glide (Jupiter 2 / Retro Future) - * 1960s Theremin / electronic oscillator glissando - */ - synthesizeRetroAstrogator() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 1.6; - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(440, now); - osc.frequency.linearRampToValueAtTime(1180, now + 0.6); - osc.frequency.linearRampToValueAtTime(320, now + 1.1); - osc.frequency.linearRampToValueAtTime(660, now + duration); - - const vibrato = ctx.createOscillator(); - vibrato.type = 'sine'; - vibrato.frequency.setValueAtTime(8, now); - - const vibGain = ctx.createGain(); - vibGain.gain.setValueAtTime(25, now); - vibrato.connect(vibGain); - vibGain.connect(osc.frequency); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.3, now + 0.1); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - vibrato.start(now); - osc.stop(now + duration); - vibrato.stop(now + duration); - } - - /** - * HAL 9000 Logic Confirmation Chime (Discovery One) - */ - synthesizeHalChime() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const f1 = 784; // G5 - const f2 = 523; // C5 - - [0, 0.14].forEach((offset, idx) => { - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(idx === 0 ? f1 : f2, now + offset); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now + offset); - env.gain.linearRampToValueAtTime(0.25, now + offset + 0.01); - env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now + offset); - osc.stop(now + offset + 0.52); - }); - } - - /** - * Event Horizon Gravity Singularity Pulse (Deep Space) - * Deep sub-bass dimensional warping thrum - */ - synthesizeSingularityEngage() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 3.2; - - const sub = ctx.createOscillator(); - sub.type = 'sine'; - sub.frequency.setValueAtTime(95, now); - sub.frequency.exponentialRampToValueAtTime(28, now + 2.2); - - const mod = ctx.createOscillator(); - mod.type = 'triangle'; - mod.frequency.setValueAtTime(14, now); - mod.frequency.linearRampToValueAtTime(45, now + 1.8); - - const modGain = ctx.createGain(); - modGain.gain.setValueAtTime(60, now); - mod.connect(modGain); - modGain.connect(sub.frequency); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.65, now + 1.5); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - sub.connect(env); - env.connect(this.gainNode); - - sub.start(now); - mod.start(now); - sub.stop(now + duration); - mod.stop(now + duration); - } - - /** - * Outlaw Afterburner Thruster Surge (Cowboy Bebop / Milano) - */ - synthesizeAfterburner() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 2.4; - - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(80, now); - osc.frequency.exponentialRampToValueAtTime(850, now + 0.8); - osc.frequency.linearRampToValueAtTime(620, now + duration); - - const filter = ctx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.setValueAtTime(350, now); - filter.frequency.exponentialRampToValueAtTime(1400, now + 0.8); - filter.Q.setValueAtTime(3.0, now); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.5, now + 0.6); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(filter); - filter.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Space Station Docking Clamp Latch & Airlock Purge - */ - synthesizeDockingClamp() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - - // Heavy mechanical solenoid impact - const osc = ctx.createOscillator(); - osc.type = 'square'; - osc.frequency.setValueAtTime(380, now); - osc.frequency.exponentialRampToValueAtTime(65, now + 0.12); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.5, now); - env.gain.exponentialRampToValueAtTime(0.001, now + 0.25); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + 0.25); - - // Followed by pneumatic seal hiss - setTimeout(() => { - if (!this.am.ctx) return; - const t = this.am.ctx.currentTime; - const hissBuf = this.am.createNoiseBuffer('white', 1.2); - const hiss = this.am.ctx.createBufferSource(); - hiss.buffer = hissBuf; - - const hFilter = this.am.ctx.createBiquadFilter(); - hFilter.type = 'bandpass'; - hFilter.frequency.setValueAtTime(2200, t); - hFilter.Q.setValueAtTime(2.5, t); - - const hEnv = this.am.ctx.createGain(); - hEnv.gain.setValueAtTime(0.001, t); - hEnv.gain.linearRampToValueAtTime(0.25, t + 0.05); - hEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.9); - - hiss.connect(hFilter); - hFilter.connect(hEnv); - hEnv.connect(this.gainNode); - - hiss.start(t); - hiss.stop(t + 0.9); - }, 180); - } - - /** - * Ludicrous Speed Accelerator (Spaceball One / Comedy) - */ - synthesizeLudicrousSpeed() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 3.2; - - const osc = ctx.createOscillator(); - osc.type = 'sawtooth'; - osc.frequency.setValueAtTime(60, now); - osc.frequency.exponentialRampToValueAtTime(5400, now + 2.2); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.01, now); - env.gain.linearRampToValueAtTime(0.4, now + 1.8); - env.gain.exponentialRampToValueAtTime(0.0001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Infinite Improbability Reality-Warp Flip (Heart of Gold) - */ - synthesizeImprobabilityFlip() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 1.8; - - const osc = ctx.createOscillator(); - osc.type = 'triangle'; - osc.frequency.setValueAtTime(1400, now); - osc.frequency.exponentialRampToValueAtTime(180, now + 0.7); - osc.frequency.exponentialRampToValueAtTime(2200, now + 1.3); - osc.frequency.exponentialRampToValueAtTime(440, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.35, now + 0.1); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } - - /** - * Geiger Counter Click Burst (Nostromo / Mining) - */ - synthesizeGeigerBurst() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const clicks = 8 + Math.floor(Math.random() * 8); - - for (let i = 0; i < clicks; i++) { - const clickTime = now + (i * 0.04) + (Math.random() * 0.03); - const osc = ctx.createOscillator(); - osc.type = 'square'; - osc.frequency.setValueAtTime(2800 + Math.random() * 800, clickTime); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.18, clickTime); - env.gain.exponentialRampToValueAtTime(0.001, clickTime + 0.015); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(clickTime); - osc.stop(clickTime + 0.016); - } - } - - /** - * Cheerful Door Sigh (Heart of Gold / Sirius Cybernetics Corp) - */ - synthesizeCheerfulDoor() { - this.init(); - const ctx = this.am.ctx; - if (!ctx) return; - - const now = ctx.currentTime; - const duration = 0.9; - - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(620, now); - osc.frequency.linearRampToValueAtTime(840, now + 0.35); - osc.frequency.linearRampToValueAtTime(520, now + duration); - - const env = ctx.createGain(); - env.gain.setValueAtTime(0.001, now); - env.gain.linearRampToValueAtTime(0.25, now + 0.15); - env.gain.exponentialRampToValueAtTime(0.001, now + duration); - - osc.connect(env); - env.connect(this.gainNode); - - osc.start(now); - osc.stop(now + duration); - } -} - -window.ExpandedSciFiAudioSynth = ExpandedSciFiAudioSynth; - - -/** - * Canonical Starship & Location Sound Profiles Matrix - * Each preset defines the exact parameters for Hull Drone, Warp Core, Life Support, and Telemetry. - */ - +class AudioManager { + constructor() { + this.ctx = null; + this.isInitialized = false; + this.isPlaying = false; + this.masterGain = null; + this.compressor = null; + this.analyser = null; + + this.currentVolume = 0.75; + this.isMuted = false; + + // Sleep Timer + this.timerId = null; + this.timerRemainingSeconds = 0; + this.onTimerTick = null; + this.onTimerComplete = null; + } + + init() { + if (this.isInitialized) return; + + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioContextClass(); + + // Master Dynamics Compressor / Limiter for studio-quality mastering & anti-clipping + this.compressor = this.ctx.createDynamicsCompressor(); + this.compressor.threshold.setValueAtTime(-12, this.ctx.currentTime); + this.compressor.knee.setValueAtTime(8, this.ctx.currentTime); + this.compressor.ratio.setValueAtTime(4, this.ctx.currentTime); + this.compressor.attack.setValueAtTime(0.003, this.ctx.currentTime); + this.compressor.release.setValueAtTime(0.25, this.ctx.currentTime); + + // Master Gain + this.masterGain = this.ctx.createGain(); + this.masterGain.gain.setValueAtTime(this.isMuted ? 0 : this.currentVolume, this.ctx.currentTime); + + // Master Analyser Node for Visualizers + this.analyser = this.ctx.createAnalyser(); + this.analyser.fftSize = 512; + this.analyser.smoothingTimeConstant = 0.82; + + // Route: Nodes -> Compressor -> MasterGain -> Analyser -> Destination + this.compressor.connect(this.masterGain); + this.masterGain.connect(this.analyser); + this.analyser.connect(this.ctx.destination); + + this.isInitialized = true; + } + + async resume() { + if (!this.isInitialized) this.init(); + if (this.ctx.state === 'suspended') { + await this.ctx.resume(); + } + } + + setMasterVolume(val, smoothTime = 0.05) { + const clamped = Math.max(0, Math.min(1, val)); + this.currentVolume = clamped; + if (this.masterGain && this.ctx) { + const target = this.isMuted ? 0 : clamped; + const now = this.ctx.currentTime; + this.masterGain.gain.cancelScheduledValues(now); + this.masterGain.gain.linearRampToValueAtTime(target, now + smoothTime); + } + } + + getMasterVolume() { + return this.currentVolume; + } + + toggleMute() { + return this.setMute(!this.isMuted); + } + + setMute(muted) { + this.isMuted = !!muted; + if (this.masterGain && this.ctx) { + const target = this.isMuted ? 0 : this.currentVolume; + const now = this.ctx.currentTime; + this.masterGain.gain.cancelScheduledValues(now); + this.masterGain.gain.linearRampToValueAtTime(target, now + 0.05); + } + return this.isMuted; + } + + // Noise Buffer Helper (White, Pink, Brown) + createNoiseBuffer(type = 'pink', durationSeconds = 5) { + if (!this.ctx) this.init(); + const sampleRate = this.ctx.sampleRate; + const bufferSize = sampleRate * durationSeconds; + const buffer = this.ctx.createBuffer(2, bufferSize, sampleRate); + const left = buffer.getChannelData(0); + const right = buffer.getChannelData(1); + + if (type === 'white') { + for (let i = 0; i < bufferSize; i++) { + left[i] = Math.random() * 2 - 1; + right[i] = Math.random() * 2 - 1; + } + } else if (type === 'pink') { + let b0L = 0, b1L = 0, b2L = 0, b3L = 0, b4L = 0, b5L = 0, b6L = 0; + let b0R = 0, b1R = 0, b2R = 0, b3R = 0, b4R = 0, b5R = 0, b6R = 0; + for (let i = 0; i < bufferSize; i++) { + const whiteL = Math.random() * 2 - 1; + b0L = 0.99886 * b0L + whiteL * 0.0555179; + b1L = 0.99332 * b1L + whiteL * 0.0750759; + b2L = 0.96900 * b2L + whiteL * 0.1538520; + b3L = 0.86650 * b3L + whiteL * 0.3104856; + b4L = 0.55000 * b4L + whiteL * 0.5329522; + b5L = -0.7616 * b5L - whiteL * 0.0168980; + left[i] = (b0L + b1L + b2L + b3L + b4L + b5L + b6L + whiteL * 0.5362) * 0.11; + b6L = whiteL * 0.115926; + + const whiteR = Math.random() * 2 - 1; + b0R = 0.99886 * b0R + whiteR * 0.0555179; + b1R = 0.99332 * b1R + whiteR * 0.0750759; + b2R = 0.96900 * b2R + whiteR * 0.1538520; + b3R = 0.86650 * b3R + whiteR * 0.3104856; + b4R = 0.55000 * b4R + whiteR * 0.5329522; + b5R = -0.7616 * b5R - whiteR * 0.0168980; + right[i] = (b0R + b1R + b2R + b3R + b4R + b5R + b6R + whiteR * 0.5362) * 0.11; + b6R = whiteR * 0.115926; + } + } else if (type === 'brown') { + let lastOutL = 0.0; + let lastOutR = 0.0; + for (let i = 0; i < bufferSize; i++) { + const whiteL = Math.random() * 2 - 1; + lastOutL = (lastOutL + 0.02 * whiteL) / 1.02; + left[i] = lastOutL * 3.5; + + const whiteR = Math.random() * 2 - 1; + lastOutR = (lastOutR + 0.02 * whiteR) / 1.02; + right[i] = lastOutR * 3.5; + } + } + + return buffer; + } + + // Sleep Timer System + startSleepTimer(minutes, onTick, onComplete) { + this.stopSleepTimer(); + this.timerRemainingSeconds = Math.round(minutes * 60); + this.onTimerTick = onTick; + this.onTimerComplete = onComplete; + + if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds); + + this.timerId = setInterval(() => { + this.timerRemainingSeconds--; + if (this.onTimerTick) this.onTimerTick(this.timerRemainingSeconds); + + // Begin exponential smooth fadeout during final 30 seconds + if (this.timerRemainingSeconds <= 30 && this.timerRemainingSeconds > 0) { + const factor = this.timerRemainingSeconds / 30; + if (this.masterGain && this.ctx) { + const targetVol = this.getMasterVolume() * factor; + this.masterGain.gain.setValueAtTime(Math.max(0, targetVol), this.ctx.currentTime); + } + } + + if (this.timerRemainingSeconds <= 0) { + this.stopSleepTimer(); + if (this.onTimerComplete) this.onTimerComplete(); + } + }, 1000); + } + + stopSleepTimer() { + if (this.timerId) { + clearInterval(this.timerId); + this.timerId = null; + this.timerRemainingSeconds = 0; + } + } +} + +window.AudioManager = AudioManager; + + +/** + * Hull Drone & Environmental Sub-Bass Synthesizer + * Generates organic, continuous low-frequency starship structural vibration & room tone. + */ + +class HullDroneSynth { + constructor(audioManager) { + this.am = audioManager; + this.nodes = []; + this.gainNode = null; + this.filterNode = null; + this.subOsc1 = null; + this.subOsc2 = null; + this.noiseSource = null; + this.isMuted = false; + + // Default configuration parameters + this.params = { + volume: 0.7, + baseFreq: 50, // Fundamental frequency (e.g. 50Hz for TNG bridge) + filterCutoff: 110, // Lowpass filter cutoff + resonance: 2.5, // Filter Q / resonance peak + noiseMix: 0.45, // Brown noise texture mix + harmonicSpread: 1.02 // Slight frequency detune between sub-oscillators for phasing + }; + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + // Channel Gain Node + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + + // Steep Lowpass Filter (24dB/oct via 2 cascading biquads) + this.filterNode = ctx.createBiquadFilter(); + this.filterNode.type = 'lowpass'; + this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime); + this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime); + + const filterStage2 = ctx.createBiquadFilter(); + filterStage2.type = 'lowpass'; + filterStage2.frequency.setValueAtTime(this.params.filterCutoff * 1.5, ctx.currentTime); + filterStage2.Q.setValueAtTime(1.0, ctx.currentTime); + + // Sub-bass Oscillator 1 (Sine) + this.subOsc1 = ctx.createOscillator(); + this.subOsc1.type = 'sine'; + this.subOsc1.frequency.setValueAtTime(this.params.baseFreq, ctx.currentTime); + + const osc1Gain = ctx.createGain(); + osc1Gain.gain.setValueAtTime(0.5, ctx.currentTime); + this.subOsc1.connect(osc1Gain); + osc1Gain.connect(this.filterNode); + + // Sub-bass Oscillator 2 (Triangle/Sine detuned for slow, natural phase beating) + this.subOsc2 = ctx.createOscillator(); + this.subOsc2.type = 'triangle'; + this.subOsc2.frequency.setValueAtTime(this.params.baseFreq * this.params.harmonicSpread, ctx.currentTime); + + const osc2Gain = ctx.createGain(); + osc2Gain.gain.setValueAtTime(0.3, ctx.currentTime); + this.subOsc2.connect(osc2Gain); + osc2Gain.connect(this.filterNode); + + // Brown Noise Structural Rumble Layer + const brownBuffer = this.am.createNoiseBuffer('brown', 6); + this.noiseSource = ctx.createBufferSource(); + this.noiseSource.buffer = brownBuffer; + this.noiseSource.loop = true; + + const noiseGain = ctx.createGain(); + noiseGain.gain.setValueAtTime(this.params.noiseMix * 0.7, ctx.currentTime); + this.noiseSource.connect(noiseGain); + noiseGain.connect(this.filterNode); + + // Slow LFO for organic drifting movement + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.setValueAtTime(0.1, ctx.currentTime); // 10 second cycle + + const lfoGain = ctx.createGain(); + lfoGain.gain.setValueAtTime(12, ctx.currentTime); // Modulate cutoff by ±12Hz + lfo.connect(lfoGain); + lfoGain.connect(this.filterNode.frequency); + + // Connect Graph + this.filterNode.connect(filterStage2); + filterStage2.connect(this.gainNode); + this.gainNode.connect(this.am.compressor); + + // Start Sources + this.subOsc1.start(); + this.subOsc2.start(); + this.noiseSource.start(); + lfo.start(); + + this.nodes = [this.subOsc1, this.subOsc2, this.noiseSource, lfo, osc1Gain, osc2Gain, noiseGain, lfoGain, this.filterNode, filterStage2, this.gainNode]; + } + + stop() { + if (this.nodes.length > 0) { + try { + if (this.subOsc1) this.subOsc1.stop(); + if (this.subOsc2) this.subOsc2.stop(); + if (this.noiseSource) this.noiseSource.stop(); + } catch (e) { + // Ignore if already stopped + } + this.nodes.forEach(node => { + try { node.disconnect(); } catch (e) {} + }); + this.nodes = []; + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setBaseFreq(freq) { + this.params.baseFreq = freq; + if (this.subOsc1 && this.subOsc2 && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.subOsc1.frequency.linearRampToValueAtTime(freq, now + 0.1); + this.subOsc2.frequency.linearRampToValueAtTime(freq * this.params.harmonicSpread, now + 0.1); + } + } + + setFilterCutoff(cutoff) { + this.params.filterCutoff = cutoff; + if (this.filterNode && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.filterNode.frequency.linearRampToValueAtTime(cutoff, now + 0.1); + } + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.baseFreq !== undefined) this.setBaseFreq(config.baseFreq); + if (config.filterCutoff !== undefined) this.setFilterCutoff(config.filterCutoff); + if (config.resonance !== undefined) this.params.resonance = config.resonance; + if (config.noiseMix !== undefined) this.params.noiseMix = config.noiseMix; + if (config.harmonicSpread !== undefined) this.params.harmonicSpread = config.harmonicSpread; + this.setVolume(this.params.volume); + } +} + +window.HullDroneSynth = HullDroneSynth; + + +/** + * Warp Core & Reactor Pulse Synthesizer + * Generates the iconic pulsating magnetic intermix thrum of Star Trek warp cores. + */ + +class WarpCoreSynth { + constructor(audioManager) { + this.am = audioManager; + this.nodes = []; + this.gainNode = null; + this.isMuted = false; + + // Pulse parameters + this.params = { + volume: 0.8, + bpm: 48, // Pulse rate (TNG is ~46-52 BPM, Voyager is ~68-75 BPM) + carrierFreq: 58, // Fundamental carrier pitch (Hz) + modFreqRatio: 2.0, // FM modulation frequency multiplier + modIndex: 40, // FM modulation depth + filterCutoff: 180, // Lowpass filter cutoff + pulseShape: 'tng', // 'tng', 'voyager', 'tos', 'defiant', 'nx' + resonance: 3.0, + swirlMix: 0.35 // Stereo phase swirl + }; + + this.pulseInterval = null; + this.pulsePhase = 0; + this.onPulse = null; // Callback for UI visualizer pulse animation! + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + + // Filter Node + this.filterNode = ctx.createBiquadFilter(); + this.filterNode.type = 'lowpass'; + this.filterNode.frequency.setValueAtTime(this.params.filterCutoff, ctx.currentTime); + this.filterNode.Q.setValueAtTime(this.params.resonance, ctx.currentTime); + + // Stereo Panner for magnetic swirl + this.panner = ctx.createStereoPanner ? ctx.createStereoPanner() : null; + + // Carrier & Modulator Oscillators (FM Engine) + this.carrier = ctx.createOscillator(); + this.carrier.type = this.params.pulseShape === 'tos' ? 'sawtooth' : (this.params.pulseShape === 'nx' ? 'triangle' : 'sine'); + this.carrier.frequency.setValueAtTime(this.params.carrierFreq, ctx.currentTime); + + // Sub-harmonic oscillator for massive bottom end + this.subOsc = ctx.createOscillator(); + this.subOsc.type = 'sine'; + this.subOsc.frequency.setValueAtTime(this.params.carrierFreq * 0.5, ctx.currentTime); + + const subGain = ctx.createGain(); + subGain.gain.setValueAtTime(0.6, ctx.currentTime); + this.subOsc.connect(subGain); + subGain.connect(this.filterNode); + + // Pulse Envelope Modulator Gain Node + this.pulseGain = ctx.createGain(); + this.pulseGain.gain.setValueAtTime(0.2, ctx.currentTime); + + // Connect Carrier -> PulseGain -> Filter -> Panner -> ChannelGain -> Master + this.carrier.connect(this.pulseGain); + this.pulseGain.connect(this.filterNode); + + if (this.panner) { + this.filterNode.connect(this.panner); + this.panner.connect(this.gainNode); + } else { + this.filterNode.connect(this.gainNode); + } + + this.gainNode.connect(this.am.compressor); + + this.carrier.start(); + this.subOsc.start(); + + this.nodes = [this.carrier, this.subOsc, subGain, this.pulseGain, this.filterNode, this.gainNode]; + if (this.panner) this.nodes.push(this.panner); + + // Start precision pulse scheduler + this.startPulseLoop(); + } + + startPulseLoop() { + if (this.pulseInterval) clearInterval(this.pulseInterval); + + const intervalMs = (60 / this.params.bpm) * 1000; + this.scheduleNextPulse(); + + this.pulseInterval = setInterval(() => { + this.scheduleNextPulse(); + }, intervalMs); + } + + scheduleNextPulse() { + const ctx = this.am.ctx; + if (!ctx || !this.pulseGain || !this.filterNode) return; + + const now = ctx.currentTime; + const pulseDuration = (60 / this.params.bpm); + + this.pulsePhase = (this.pulsePhase + 1) % 4; + + // Trigger visualizer callback + if (this.onPulse) { + this.onPulse(this.pulsePhase, pulseDuration); + } + + // Dynamic envelope shaping based on ship era + if (this.params.pulseShape === 'tng') { + // Iconic 4-stage Galaxy-class magnetic warp pulse + // Soft attack, deep swelling peak, secondary reverberant harmonic bloom, smooth decay + const peakTime = now + pulseDuration * 0.28; + const secondPeak = now + pulseDuration * 0.58; + + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.18, now); + this.pulseGain.gain.linearRampToValueAtTime(0.95, peakTime); + this.pulseGain.gain.exponentialRampToValueAtTime(0.45, now + pulseDuration * 0.42); + this.pulseGain.gain.linearRampToValueAtTime(0.65, secondPeak); + this.pulseGain.gain.exponentialRampToValueAtTime(0.18, now + pulseDuration * 0.95); + + // Modulate filter cutoff in sync with the pulse + this.filterNode.frequency.cancelScheduledValues(now); + this.filterNode.frequency.setValueAtTime(this.params.filterCutoff * 0.7, now); + this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 1.6, peakTime); + this.filterNode.frequency.exponentialRampToValueAtTime(this.params.filterCutoff * 0.7, now + pulseDuration * 0.95); + + } else if (this.params.pulseShape === 'voyager') { + // Faster, sharper, higher-resonance Class 9 warp core + const peakTime = now + pulseDuration * 0.2; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.25, now); + this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime); + this.pulseGain.gain.exponentialRampToValueAtTime(0.25, now + pulseDuration * 0.85); + + this.filterNode.frequency.cancelScheduledValues(now); + this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 1.8, peakTime); + this.filterNode.frequency.linearRampToValueAtTime(this.params.filterCutoff * 0.8, now + pulseDuration * 0.85); + + } else if (this.params.pulseShape === 'tos') { + // TOS Electromechanical oscillating engine thrum + const halfTime = now + pulseDuration * 0.5; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.4, now); + this.pulseGain.gain.linearRampToValueAtTime(0.9, halfTime); + this.pulseGain.gain.linearRampToValueAtTime(0.4, now + pulseDuration); + + } else if (this.params.pulseShape === 'defiant') { + // Defiant: tight, aggressive pulse with rapid decay + const peakTime = now + pulseDuration * 0.15; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.3, now); + this.pulseGain.gain.linearRampToValueAtTime(1.0, peakTime); + this.pulseGain.gain.exponentialRampToValueAtTime(0.3, now + pulseDuration * 0.75); + + } else { + // NX / Industrial reactor chug + const peakTime = now + pulseDuration * 0.35; + this.pulseGain.gain.cancelScheduledValues(now); + this.pulseGain.gain.setValueAtTime(0.2, now); + this.pulseGain.gain.linearRampToValueAtTime(0.85, peakTime); + this.pulseGain.gain.linearRampToValueAtTime(0.2, now + pulseDuration); + } + + // Subtle stereo panning drift + if (this.panner && this.params.swirlMix > 0) { + const panTarget = Math.sin(this.pulsePhase * Math.PI * 0.5) * this.params.swirlMix; + this.panner.pan.linearRampToValueAtTime(panTarget, now + pulseDuration * 0.5); + } + } + + setBpm(bpm) { + this.params.bpm = Math.max(20, Math.min(160, bpm)); + if (this.nodes.length > 0) { + this.startPulseLoop(); + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setCarrierFreq(freq) { + this.params.carrierFreq = freq; + if (this.carrier && this.subOsc && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.carrier.frequency.linearRampToValueAtTime(freq, now + 0.1); + this.subOsc.frequency.linearRampToValueAtTime(freq * 0.5, now + 0.1); + } + } + + stop() { + if (this.pulseInterval) { + clearInterval(this.pulseInterval); + this.pulseInterval = null; + } + if (this.nodes.length > 0) { + try { + if (this.carrier) this.carrier.stop(); + if (this.subOsc) this.subOsc.stop(); + } catch (e) {} + this.nodes.forEach(node => { + try { node.disconnect(); } catch (e) {} + }); + this.nodes = []; + } + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.bpm !== undefined) this.setBpm(config.bpm); + if (config.carrierFreq !== undefined) this.setCarrierFreq(config.carrierFreq); + if (config.filterCutoff !== undefined) this.params.filterCutoff = config.filterCutoff; + if (config.pulseShape !== undefined) this.params.pulseShape = config.pulseShape; + if (config.resonance !== undefined) this.params.resonance = config.resonance; + if (config.swirlMix !== undefined) this.params.swirlMix = config.swirlMix; + this.setVolume(this.params.volume); + } +} + +window.WarpCoreSynth = WarpCoreSynth; + + +/** + * Environmental Life Support & Airflow Synthesizer + * Generates continuous ventilation airflow, atmospheric hiss, and room acoustic damping. + */ + +class LifeSupportSynth { + constructor(audioManager) { + this.am = audioManager; + this.nodes = []; + this.gainNode = null; + this.isMuted = false; + + this.params = { + volume: 0.5, + noiseType: 'pink', // 'pink' (warm TNG), 'white' (crisp Voyager), 'brown' (heavy NX-01) + highpassFreq: 180, // Cuts extreme sub rumble to isolate air movement + lowpassFreq: 1800, // Gentle top-end rolloff + airflowModSpeed: 0.15, // Subtle breathing movement of the environmental airflow + airflowModDepth: 0.12 // Depth of airflow intensity modulation + }; + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + + // Highpass filter (cuts muddy lows) + const hpFilter = ctx.createBiquadFilter(); + hpFilter.type = 'highpass'; + hpFilter.frequency.setValueAtTime(this.params.highpassFreq, ctx.currentTime); + + // Lowpass filter (shapes the crispness vs warmth of the air) + this.lpFilter = ctx.createBiquadFilter(); + this.lpFilter.type = 'lowpass'; + this.lpFilter.frequency.setValueAtTime(this.params.lowpassFreq, ctx.currentTime); + this.lpFilter.Q.setValueAtTime(0.7, ctx.currentTime); + + // Noise buffer source + const noiseBuffer = this.am.createNoiseBuffer(this.params.noiseType, 6); + this.noiseSource = ctx.createBufferSource(); + this.noiseSource.buffer = noiseBuffer; + this.noiseSource.loop = true; + + // Slow airflow modulation LFO for organic breath + const airflowLfo = ctx.createOscillator(); + airflowLfo.type = 'sine'; + airflowLfo.frequency.setValueAtTime(this.params.airflowModSpeed, ctx.currentTime); + + const lfoGain = ctx.createGain(); + lfoGain.gain.setValueAtTime(this.params.airflowModDepth, ctx.currentTime); + + const modGain = ctx.createGain(); + modGain.gain.setValueAtTime(0.8, ctx.currentTime); + + airflowLfo.connect(lfoGain); + lfoGain.connect(modGain.gain); + + // Stereo widener using delay + const splitter = ctx.createChannelSplitter(2); + const merger = ctx.createChannelMerger(2); + const delayRight = ctx.createDelay(); + delayRight.delayTime.setValueAtTime(0.018, ctx.currentTime); // 18ms Haas effect widening + + // Graph: Noise -> ModGain -> HP -> LP -> Splitter -> (Left direct, Right delay) -> Merger -> Gain -> Compressor + this.noiseSource.connect(modGain); + modGain.connect(hpFilter); + hpFilter.connect(this.lpFilter); + this.lpFilter.connect(splitter); + + splitter.connect(merger, 0, 0); // Left channel + splitter.connect(delayRight, 1); + delayRight.connect(merger, 0, 1); // Right delayed channel + + merger.connect(this.gainNode); + this.gainNode.connect(this.am.compressor); + + this.noiseSource.start(); + airflowLfo.start(); + + this.nodes = [ + this.noiseSource, airflowLfo, lfoGain, modGain, + hpFilter, this.lpFilter, splitter, delayRight, merger, this.gainNode + ]; + } + + stop() { + if (this.nodes.length > 0) { + try { + if (this.noiseSource) this.noiseSource.stop(); + } catch (e) {} + this.nodes.forEach(node => { + try { node.disconnect(); } catch (e) {} + }); + this.nodes = []; + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setFilterCutoff(freq) { + this.params.lowpassFreq = freq; + if (this.lpFilter && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.lpFilter.frequency.linearRampToValueAtTime(freq, now + 0.1); + } + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.noiseType !== undefined) this.params.noiseType = config.noiseType; + if (config.highpassFreq !== undefined) this.params.highpassFreq = config.highpassFreq; + if (config.lowpassFreq !== undefined) this.setFilterCutoff(config.lowpassFreq); + if (config.airflowModSpeed !== undefined) this.params.airflowModSpeed = config.airflowModSpeed; + if (config.airflowModDepth !== undefined) this.params.airflowModDepth = config.airflowModDepth; + this.setVolume(this.params.volume); + } +} + +window.LifeSupportSynth = LifeSupportSynth; + + +/** + * Procedural Starship Telemetry, LCARS Chirps, Beeps & Console Synthesizer + * 100% synthesized programmatically via Web Audio API oscillators, FM synthesis, and envelopes. + * Zero stored audio samples. + */ + +class TelemetrySynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + this.isMuted = false; + this.schedulerTimer = null; + + this.params = { + volume: 0.4, + density: 0.5, // How often background telemetry chirps occur (0 = off, 1 = busy bridge) + era: 'tng', // 'tng', 'voyager', 'tos', 'ds9', 'nx' + reverbMix: 0.25 + }; + + // Musical pitch frequencies for authentic LCARS musical intervals (major/minor pentatonic & perfect 4ths/5ths) + this.lcarsPitches = [ + 880, 987.77, 1046.50, 1174.66, 1318.51, 1396.91, 1567.98, 1760, 1975.53, 2093.00, 2349.32, 2637.02 + ]; + + // TOS Bridge oscillator warble frequencies + this.tosFrequencies = [ + 440, 554.37, 659.25, 830.61, 880, 1108.73, 1318.51, 1661.22, 2217.46 + ]; + } + + start() { + this.stop(); + const ctx = this.am.ctx; + if (!ctx) return; + + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + + this.startAutoTelemetryScheduler(); + } + + stop() { + if (this.schedulerTimer) { + clearTimeout(this.schedulerTimer); + this.schedulerTimer = null; + } + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx && !this.isMuted) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.cancelScheduledValues(now); + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + setDensity(val) { + this.params.density = Math.max(0, Math.min(1, val)); + } + + startAutoTelemetryScheduler() { + if (this.schedulerTimer) clearTimeout(this.schedulerTimer); + if (this.params.density <= 0.01) return; + + // Calculate delay inversely proportional to density (2s to 12s) + const baseDelay = 12000 * (1.05 - this.params.density); + const jitter = Math.random() * 4000; + const nextInterval = Math.max(800, baseDelay + jitter); + + this.schedulerTimer = setTimeout(() => { + this.playRandomTelemetrySound(); + this.startAutoTelemetryScheduler(); + }, nextInterval); + } + + playRandomTelemetrySound() { + if (this.isMuted || this.params.volume <= 0.01 || !this.am.ctx) return; + + switch (this.params.era) { + case 'tos': + Math.random() > 0.4 ? this.synthesizeTOSWarble() : this.synthesizeTOSRelayClick(); + break; + case 'ds9': + Math.random() > 0.5 ? this.synthesizeCardassianSensor() : this.synthesizeLCARSSingleChirp(); + break; + case 'voyager': + Math.random() > 0.4 ? this.synthesizeLCARSDoubleChirp() : this.synthesizeSensorSweep(); + break; + case 'nx': + Math.random() > 0.5 ? this.synthesizeNXRelay() : this.synthesizeNXIndicatorBeep(); + break; + case 'tng': + default: + const r = Math.random(); + if (r < 0.45) this.synthesizeLCARSSingleChirp(); + else if (r < 0.75) this.synthesizeLCARSDoubleChirp(); + else if (r < 0.90) this.synthesizeLCARSSequence(); + else this.synthesizeSensorSweep(); + break; + } + + // OBSERVATION intentionally treats telemetry as an abstract activity pulse, + // not as a claim that a specific fictional beep means a specific thing. + window.dispatchEvent(new CustomEvent('scifi-telemetry-activity', { + detail: { + era: this.params.era, + density: this.params.density, + firedAt: performance.now() + } + })); + } + + /** + * TNG/Voyager Single LCARS Touch Tone (Soft sine with gentle attack and rapid exponential decay) + */ + synthesizeLCARSSingleChirp(pitch = null) { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const freq = pitch || this.lcarsPitches[Math.floor(Math.random() * this.lcarsPitches.length)]; + const duration = 0.09; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, now); + // Subtle downward micro-pitch glide (12Hz) for that warm capacitive touch feel + osc.frequency.exponentialRampToValueAtTime(freq * 0.98, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.35, now + 0.008); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + // Filter to eliminate any click + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(3200, now); + + osc.connect(env); + env.connect(filter); + filter.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * TNG LCARS Double Chirp (Iconic standard confirmation tone) + */ + synthesizeLCARSDoubleChirp() { + const ctx = this.am.ctx; + if (!ctx) return; + const idx = Math.floor(Math.random() * (this.lcarsPitches.length - 2)); + const p1 = this.lcarsPitches[idx]; + const p2 = this.lcarsPitches[idx + 2]; // Minor third or fourth higher + + this.synthesizeLCARSSingleChirp(p1); + setTimeout(() => { + this.synthesizeLCARSSingleChirp(p2); + }, 65); + } + + /** + * TNG LCARS Multi-Tone Data Acknowledgment Sequence + */ + synthesizeLCARSSequence() { + const ctx = this.am.ctx; + if (!ctx) return; + const notes = [ + this.lcarsPitches[Math.floor(Math.random() * 4) + 4], + this.lcarsPitches[Math.floor(Math.random() * 4) + 6], + this.lcarsPitches[Math.floor(Math.random() * 4) + 2] + ]; + + notes.forEach((freq, i) => { + setTimeout(() => { + this.synthesizeLCARSSingleChirp(freq); + }, i * 75); + }); + } + + /** + * High-tech Sensor Sweep Tone (Voyager/TNG Long-Range Sensor telemetry) + */ + synthesizeSensorSweep() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.38; + const startFreq = 1200 + Math.random() * 800; + const endFreq = startFreq * (Math.random() > 0.5 ? 1.6 : 0.65); + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(startFreq, now); + osc.frequency.exponentialRampToValueAtTime(endFreq, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.18, now + 0.05); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * TOS Original Series Bridge Electronic Computer Warble + * Two detuned square/triangle oscillators modulated by high-speed vibrato LFO + */ + synthesizeTOSWarble() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.45; + const baseFreq = this.tosFrequencies[Math.floor(Math.random() * this.tosFrequencies.length)]; + + const osc1 = ctx.createOscillator(); + osc1.type = 'triangle'; + osc1.frequency.setValueAtTime(baseFreq, now); + + const osc2 = ctx.createOscillator(); + osc2.type = 'sawtooth'; + osc2.frequency.setValueAtTime(baseFreq * 1.5, now); + + // Fast Vibrato LFO + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.setValueAtTime(14 + Math.random() * 8, now); // 14-22 Hz warble + + const lfoGain = ctx.createGain(); + lfoGain.gain.setValueAtTime(35, now); + lfo.connect(lfoGain); + lfoGain.connect(osc1.frequency); + lfoGain.connect(osc2.frequency); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.22, now + 0.04); + env.gain.setValueAtTime(0.22, now + duration * 0.7); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + // Bandpass filter to create that vintage analog 1960s telephone/relay resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(baseFreq * 1.2, now); + filter.Q.setValueAtTime(3.5, now); + + osc1.connect(env); + osc2.connect(env); + env.connect(filter); + filter.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + lfo.start(now); + + osc1.stop(now + duration); + osc2.stop(now + duration); + lfo.stop(now + duration); + } + + /** + * TOS Mechanical Relay Solenoid Click + */ + synthesizeTOSRelayClick() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.025; + + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(1400, now); + osc.frequency.exponentialRampToValueAtTime(300, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.3, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * DS9 / Cardassian Cavernous Sensor Tone (Resonant metallic ring) + */ + synthesizeCardassianSensor() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.55; + const freq = 420 + Math.random() * 200; + + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(freq, now); + + const osc2 = ctx.createOscillator(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(freq * 1.414, now); // Tritone metallic dissonance + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.2, now + 0.015); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc1.connect(env); + osc2.connect(env); + env.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + osc1.stop(now + duration); + osc2.stop(now + duration); + } + + /** + * NX-01 Industrial Hydraulic Relay Click + */ + synthesizeNXRelay() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.04; + + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(750, now); + osc.frequency.exponentialRampToValueAtTime(120, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.25, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * NX-01 Indicator Beep (Early 22nd century industrial tone) + */ + synthesizeNXIndicatorBeep() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.08; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(950, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.2, now + 0.005); + env.gain.setValueAtTime(0.2, now + duration * 0.8); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Iconic TNG 2-Tone Door Chime ("Come in") + */ + synthesizeDoorChime() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const f1 = 880; // A5 + const f2 = 1174.66; // D6 (Up a fourth) + + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(f1, now); + + const env1 = ctx.createGain(); + env1.gain.setValueAtTime(0.001, now); + env1.gain.linearRampToValueAtTime(0.35, now + 0.015); + env1.gain.exponentialRampToValueAtTime(0.001, now + 0.45); + + osc1.connect(env1); + env1.connect(this.gainNode); + osc1.start(now); + osc1.stop(now + 0.45); + + // Second tone starts at 0.16s + const osc2 = ctx.createOscillator(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(f2, now + 0.16); + + const env2 = ctx.createGain(); + env2.gain.setValueAtTime(0.001, now + 0.16); + env2.gain.linearRampToValueAtTime(0.4, now + 0.175); + env2.gain.exponentialRampToValueAtTime(0.001, now + 0.7); + + osc2.connect(env2); + env2.connect(this.gainNode); + osc2.start(now + 0.16); + osc2.stop(now + 0.7); + } + + applyPreset(config) { + if (config.volume !== undefined) this.params.volume = config.volume; + if (config.density !== undefined) this.setDensity(config.density); + if (config.era !== undefined) this.params.era = config.era; + this.setVolume(this.params.volume); + this.startAutoTelemetryScheduler(); + } +} + +window.TelemetrySynth = TelemetrySynth; + + +/** + * Procedural Starship Alert & Event Synthesizer + * 100% synthesized programmatically in Web Audio API. + * Includes TNG Red Alert (3-tone), TOS Red Alert (hooter buzzer), Movie-era descending klaxon, + * Yellow Alert chime, and dynamic Warp Drive Throttle swell. + */ + +class AlertSynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + this.activeAlert = null; // 'red', 'yellow', null + this.alertTimer = null; + this.alertType = 'tng'; // 'tng', 'tos', 'movie' + + this.params = { + volume: 0.6 + }; + } + + init() { + if (this.gainNode || !this.am.ctx) return; + const ctx = this.am.ctx; + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(this.params.volume, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + } + + setVolume(val) { + this.params.volume = Math.max(0, Math.min(1, val)); + if (this.gainNode && this.am.ctx) { + const now = this.am.ctx.currentTime; + this.gainNode.gain.linearRampToValueAtTime(this.params.volume, now + 0.05); + } + } + + triggerRedAlert(type = 'tng') { + this.init(); + this.stopAlert(); + this.activeAlert = 'red'; + this.alertType = type; + + const playLoop = () => { + if (this.activeAlert !== 'red') return; + let loopDuration = 1.35; + + if (this.alertType === 'tng') { + this.synthesizeTNGRedAlertCycle(); + loopDuration = 1.35; + } else if (this.alertType === 'tos') { + this.synthesizeTOSRedAlertCycle(); + loopDuration = 1.1; + } else { + this.synthesizeMovieRedAlertCycle(); + loopDuration = 1.4; + } + + this.alertTimer = setTimeout(playLoop, loopDuration * 1000); + }; + + playLoop(); + } + + triggerYellowAlert() { + this.init(); + this.stopAlert(); + this.activeAlert = 'yellow'; + + const playLoop = () => { + if (this.activeAlert !== 'yellow') return; + this.synthesizeYellowAlertCycle(); + this.alertTimer = setTimeout(playLoop, 2200); + }; + + playLoop(); + } + + stopAlert() { + this.activeAlert = null; + if (this.alertTimer) { + clearTimeout(this.alertTimer); + this.alertTimer = null; + } + } + + /** + * TNG Red Alert Klaxon (3-tone rising & cascading electronic horn with resonant envelope) + */ + synthesizeTNGRedAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + // Frequencies for the iconic TNG 3-tone klaxon chord: F5 (698.46Hz), Ab5 (830.61Hz), C6 (1046.50Hz) + const freqs = [698.46, 830.61, 1046.50]; + + freqs.forEach((freq, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(freq * 0.94, now); + // Fast upward swoop on trigger + osc.frequency.exponentialRampToValueAtTime(freq, now + 0.12); + + // Lowpass filter to give that brassy starship horn acoustic resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(1600, now); + filter.Q.setValueAtTime(4.0, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.25 / freqs.length, now + 0.08); + env.gain.setValueAtTime(0.25 / freqs.length, now + 0.45); + env.gain.exponentialRampToValueAtTime(0.0001, now + 0.85); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + 0.86); + }); + } + + /** + * TOS Red Alert Buzzer / Hooter Siren (Pulsing 2-tone frequency modulation) + */ + synthesizeTOSRedAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.85; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(540, now); + osc.frequency.linearRampToValueAtTime(920, now + duration * 0.5); + osc.frequency.linearRampToValueAtTime(540, now + duration); + + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(800, now); + filter.Q.setValueAtTime(2.2, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.01, now); + env.gain.linearRampToValueAtTime(0.28, now + 0.05); + env.gain.setValueAtTime(0.28, now + duration * 0.85); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Star Trek Movie Era Refit Descending Red Alert Klaxon + */ + synthesizeMovieRedAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 1.05; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(1350, now); + osc.frequency.exponentialRampToValueAtTime(420, now + duration * 0.9); + + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(2200, now); + filter.Q.setValueAtTime(3.5, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.01, now); + env.gain.linearRampToValueAtTime(0.3, now + 0.06); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Yellow Alert Pulsing Warning Chime + */ + synthesizeYellowAlertCycle() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const f1 = 660; // E5 + const f2 = 880; // A5 + + [0, 0.22].forEach((offset, idx) => { + const freq = idx === 0 ? f1 : f2; + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, now + offset); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now + offset); + env.gain.linearRampToValueAtTime(0.32, now + offset + 0.015); + env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now + offset); + osc.stop(now + offset + 0.52); + }); + } + + /** + * Procedural Warp Drive Acceleration Swell ("Engage!") + * Synthesizes rising plasma induction whine + deep bass detonation + */ + synthesizeWarpJump() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 2.8; + + // 1. Rising High Induction Whine + const whineOsc = ctx.createOscillator(); + whineOsc.type = 'sawtooth'; + whineOsc.frequency.setValueAtTime(80, now); + whineOsc.frequency.exponentialRampToValueAtTime(3800, now + 1.8); + whineOsc.frequency.exponentialRampToValueAtTime(14000, now + 2.5); + + const whineFilter = ctx.createBiquadFilter(); + whineFilter.type = 'bandpass'; + whineFilter.frequency.setValueAtTime(200, now); + whineFilter.frequency.exponentialRampToValueAtTime(4500, now + 1.8); + whineFilter.Q.setValueAtTime(5.0, now); + + const whineEnv = ctx.createGain(); + whineEnv.gain.setValueAtTime(0.01, now); + whineEnv.gain.linearRampToValueAtTime(0.35, now + 1.6); + whineEnv.gain.exponentialRampToValueAtTime(0.001, now + 2.7); + + whineOsc.connect(whineFilter); + whineFilter.connect(whineEnv); + whineEnv.connect(this.gainNode); + + // 2. Sub-bass Matter-Antimatter Boom + const subOsc = ctx.createOscillator(); + subOsc.type = 'sine'; + subOsc.frequency.setValueAtTime(140, now + 1.4); + subOsc.frequency.exponentialRampToValueAtTime(32, now + 2.6); + + const subEnv = ctx.createGain(); + subEnv.gain.setValueAtTime(0.001, now + 1.4); + subEnv.gain.linearRampToValueAtTime(0.7, now + 1.7); + subEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + subOsc.connect(subEnv); + subEnv.connect(this.gainNode); + + whineOsc.start(now); + whineOsc.stop(now + 2.7); + subOsc.start(now + 1.4); + subOsc.stop(now + duration); + } +} + +window.AlertSynth = AlertSynth; + + +/** + * Procedural Doctor Who & TARDIS Sound Synthesizer + * 100% synthesized programmatically via Web Audio API. + * Includes Dematerialization Wheeze-Groan, Cloister Bell, Sonic Screwdriver, and TARDIS Console Foley. + */ + +class WhoniverseAudioSynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + this.activeCloister = false; + this.cloisterTimer = null; + } + + init() { + if (this.gainNode || !this.am.ctx) return; + const ctx = this.am.ctx; + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + } + + /** + * Procedural TARDIS Materialization / Dematerialization ("Wheeze-Groan") + * Modeled after Brian Hodgson's 1963 BBC Radiophonic technique: + * Dragging keys on piano bass strings -> reverse playback -> slow tape speed -> feedback loop. + */ + synthesizeDematCycle(cycles = 4) { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + for (let c = 0; c < cycles; c++) { + const cycleStart = ctx.currentTime + c * 1.85; + this.synthesizeSingleDematSwell(cycleStart, c, cycles); + } + } + + synthesizeSingleDematSwell(startTime, cycleIndex, totalCycles) { + const ctx = this.am.ctx; + const duration = 1.75; + + // Intensity fades slightly on later cycles + const intensity = 1.0 - (cycleIndex / totalCycles) * 0.35; + + // 1. Friction Scrape Carrier (Sawtooth through resonant highpass/bandpass with frequency glide) + const frictionOsc = ctx.createOscillator(); + frictionOsc.type = 'sawtooth'; + // Frequency glides up then groans down + frictionOsc.frequency.setValueAtTime(120, startTime); + frictionOsc.frequency.exponentialRampToValueAtTime(840, startTime + 0.65); + frictionOsc.frequency.exponentialRampToValueAtTime(95, startTime + duration); + + // Filter modeling the piano soundboard metallic scraping resonance + const frictionFilter = ctx.createBiquadFilter(); + frictionFilter.type = 'bandpass'; + frictionFilter.frequency.setValueAtTime(320, startTime); + frictionFilter.frequency.exponentialRampToValueAtTime(1450, startTime + 0.65); + frictionFilter.frequency.exponentialRampToValueAtTime(220, startTime + duration); + frictionFilter.Q.setValueAtTime(4.5, startTime); + + const frictionGain = ctx.createGain(); + frictionGain.gain.setValueAtTime(0.001, startTime); + frictionGain.gain.linearRampToValueAtTime(0.4 * intensity, startTime + 0.45); + frictionGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); + + // 2. Sub-Vortex Resonant Groan (FM synthesis for the deep cosmic groaning undertone) + const groanCarrier = ctx.createOscillator(); + groanCarrier.type = 'triangle'; + groanCarrier.frequency.setValueAtTime(55, startTime); + groanCarrier.frequency.linearRampToValueAtTime(138, startTime + 0.55); + groanCarrier.frequency.exponentialRampToValueAtTime(48, startTime + duration); + + const groanMod = ctx.createOscillator(); + groanMod.type = 'sine'; + groanMod.frequency.setValueAtTime(28, startTime); // Phasing FM modulator + groanMod.frequency.linearRampToValueAtTime(65, startTime + 0.6); + + const groanModGain = ctx.createGain(); + groanModGain.gain.setValueAtTime(45, startTime); + groanMod.connect(groanModGain); + groanModGain.connect(groanCarrier.frequency); + + const groanGain = ctx.createGain(); + groanGain.gain.setValueAtTime(0.001, startTime); + groanGain.gain.linearRampToValueAtTime(0.6 * intensity, startTime + 0.5); + groanGain.gain.exponentialRampToValueAtTime(0.001, startTime + duration); + + // 3. Phasing Flutter / Swell (Tape-flange simulation via slow LFO) + const lfo = ctx.createOscillator(); + lfo.type = 'sine'; + lfo.frequency.setValueAtTime(5.5, startTime); // 5.5 Hz flanging flutter + + const lfoDepth = ctx.createGain(); + lfoDepth.gain.setValueAtTime(0.25, startTime); + lfo.connect(lfoDepth); + lfoDepth.connect(frictionGain.gain); + + // Connect Graph + frictionOsc.connect(frictionFilter); + frictionFilter.connect(frictionGain); + frictionGain.connect(this.gainNode); + + groanCarrier.connect(groanGain); + groanGain.connect(this.gainNode); + + // Trigger Nodes + frictionOsc.start(startTime); + frictionOsc.stop(startTime + duration); + groanCarrier.start(startTime); + groanCarrier.stop(startTime + duration); + groanMod.start(startTime); + groanMod.stop(startTime + duration); + lfo.start(startTime); + lfo.stop(startTime + duration); + } + + /** + * Procedural Cloister Bell (Deep, ominous bronze cathedral bell) + */ + triggerCloisterBell() { + this.init(); + this.stopCloisterBell(); + this.activeCloister = true; + + const ringLoop = () => { + if (!this.activeCloister) return; + this.synthesizeCloisterStrike(); + this.cloisterTimer = setTimeout(ringLoop, 3200); // Canonical cloister bell repetition rate + }; + + ringLoop(); + } + + stopCloisterBell() { + this.activeCloister = false; + if (this.cloisterTimer) { + clearTimeout(this.cloisterTimer); + this.cloisterTimer = null; + } + } + + synthesizeCloisterStrike() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 4.2; + + // Authentic bell inharmonic partial ratios: Fundamental, Minor 3rd, 5th, Octave, Major 7th + const bellPartials = [ + { freqRatio: 1.0, gain: 0.65, decay: 4.2 }, // Fundamental ~108 Hz + { freqRatio: 1.19, gain: 0.45, decay: 3.6 }, // Minor third + { freqRatio: 1.51, gain: 0.40, decay: 3.1 }, // Fifth + { freqRatio: 2.01, gain: 0.30, decay: 2.4 }, // Octave + { freqRatio: 2.74, gain: 0.22, decay: 1.8 }, // Upper strike tone + { freqRatio: 3.42, gain: 0.15, decay: 1.2 } // High strike transient + ]; + + const basePitch = 108.0; // Deep bronze bell pitch + + bellPartials.forEach(p => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(basePitch * p.freqRatio, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(p.gain * 0.35, now + 0.012); // Sharp hammer impact + env.gain.exponentialRampToValueAtTime(0.0001, now + p.decay); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + p.decay + 0.05); + }); + } + + /** + * Procedural Sonic Screwdriver (High-frequency modulated sweep & vibrato warble) + */ + synthesizeSonicScrewdriver(durationSeconds = 1.2) { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = durationSeconds; + + // Dual square/saw oscillators + const osc1 = ctx.createOscillator(); + osc1.type = 'square'; + osc1.frequency.setValueAtTime(2350, now); + osc1.frequency.linearRampToValueAtTime(2650, now + duration * 0.5); + osc1.frequency.linearRampToValueAtTime(2350, now + duration); + + const osc2 = ctx.createOscillator(); + osc2.type = 'sawtooth'; + osc2.frequency.setValueAtTime(2362, now); // 12Hz natural phase beat + + // Rapid Vibrato LFO + const vibrato = ctx.createOscillator(); + vibrato.type = 'sine'; + vibrato.frequency.setValueAtTime(32, now); // 32 Hz warble rate + + const vibGain = ctx.createGain(); + vibGain.gain.setValueAtTime(140, now); + vibrato.connect(vibGain); + vibGain.connect(osc1.frequency); + vibGain.connect(osc2.frequency); + + // Bandpass filter for metallic resonance + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(2500, now); + filter.Q.setValueAtTime(4.0, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.28, now + 0.03); + env.gain.setValueAtTime(0.28, now + duration * 0.85); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc1.connect(filter); + osc2.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + vibrato.start(now); + osc1.stop(now + duration); + osc2.stop(now + duration); + vibrato.stop(now + duration); + } + + /** + * Fast-Return Spring Lever (Heavy spring recoil clack + resonant ring) + */ + synthesizeFastReturn() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.35; + + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(620, now); + osc.frequency.exponentialRampToValueAtTime(95, now + 0.08); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.45, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * TARDIS Demat Switch / Relay Solenoid + */ + synthesizeDematSwitch() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 0.06; + + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(850, now); + osc.frequency.exponentialRampToValueAtTime(140, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.35, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Telepathic Circuit Chime (Glassy, mystical resonance) + */ + synthesizeTelepathicChime() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const duration = 1.4; + const notes = [1046.50, 1318.51, 1567.98, 2093.00]; // C Major arpeggio shimmer + + notes.forEach((freq, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(freq, now + idx * 0.08); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now + idx * 0.08); + env.gain.linearRampToValueAtTime(0.18, now + idx * 0.08 + 0.02); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now + idx * 0.08); + osc.stop(now + duration + 0.05); + }); + } +} + +window.WhoniverseAudioSynth = WhoniverseAudioSynth; + + +/** + * Procedural Audio Synthesizers for Expanded Sci-Fi Universes + * Generates Epstein drives, Bio-ship neural pulses, DRADIS sonar, Singularity drives, + * Retro analog tone glides, and Ludicrous speed reality shifts via Web Audio API. + */ + +class ExpandedSciFiAudioSynth { + constructor(audioManager) { + this.am = audioManager; + this.gainNode = null; + } + + init() { + if (this.gainNode || !this.am.ctx) return; + const ctx = this.am.ctx; + this.gainNode = ctx.createGain(); + this.gainNode.gain.setValueAtTime(0.7, ctx.currentTime); + this.gainNode.connect(this.am.compressor); + } + + /** + * Epstein Drive Fusion Torch Burn (The Expanse / Industrial Space) + * Tremendous raw fusion thrust with high-pressure magnetic plasma acceleration + */ + synthesizeEpsteinBurn() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 3.5; + + // 1. High-frequency plasma induction whine + const whineOsc = ctx.createOscillator(); + whineOsc.type = 'sawtooth'; + whineOsc.frequency.setValueAtTime(140, now); + whineOsc.frequency.exponentialRampToValueAtTime(1800, now + 1.2); + whineOsc.frequency.exponentialRampToValueAtTime(3200, now + 2.5); + + const whineFilter = ctx.createBiquadFilter(); + whineFilter.type = 'bandpass'; + whineFilter.frequency.setValueAtTime(280, now); + whineFilter.frequency.exponentialRampToValueAtTime(2600, now + 2.0); + whineFilter.Q.setValueAtTime(4.0, now); + + const whineGain = ctx.createGain(); + whineGain.gain.setValueAtTime(0.001, now); + whineGain.gain.linearRampToValueAtTime(0.35, now + 1.0); + whineGain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + whineOsc.connect(whineFilter); + whineFilter.connect(whineGain); + whineGain.connect(this.gainNode); + + // 2. Colossal fusion blast roar (filtered noise) + const roarBuffer = this.am.createNoiseBuffer('brown', 4); + const roarSource = ctx.createBufferSource(); + roarSource.buffer = roarBuffer; + + const roarFilter = ctx.createBiquadFilter(); + roarFilter.type = 'lowpass'; + roarFilter.frequency.setValueAtTime(180, now); + roarFilter.frequency.linearRampToValueAtTime(550, now + 1.2); + roarFilter.frequency.exponentialRampToValueAtTime(120, now + duration); + + const roarGain = ctx.createGain(); + roarGain.gain.setValueAtTime(0.001, now); + roarGain.gain.linearRampToValueAtTime(0.7, now + 1.2); + roarGain.gain.exponentialRampToValueAtTime(0.001, now + duration); + + roarSource.connect(roarFilter); + roarFilter.connect(roarGain); + roarGain.connect(this.gainNode); + + whineOsc.start(now); + whineOsc.stop(now + duration); + roarSource.start(now); + roarSource.stop(now + duration); + } + + /** + * Bio-Ship Starburst / Neural Pulse (Farscape Moya & Bioships) + * Organic vocalized dimensional fold and vascular wave + */ + synthesizeStarburst() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 2.8; + + const osc1 = ctx.createOscillator(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(85, now); + osc1.frequency.exponentialRampToValueAtTime(940, now + 1.4); + osc1.frequency.exponentialRampToValueAtTime(45, now + duration); + + const osc2 = ctx.createOscillator(); + osc2.type = 'triangle'; + osc2.frequency.setValueAtTime(125, now); + osc2.frequency.exponentialRampToValueAtTime(1420, now + 1.4); + osc2.frequency.exponentialRampToValueAtTime(65, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.45, now + 1.3); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc1.connect(env); + osc2.connect(env); + env.connect(this.gainNode); + + osc1.start(now); + osc2.start(now); + osc1.stop(now + duration); + osc2.stop(now + duration); + } + + /** + * Battlestar Galactica DRADIS Sonar Ping (Military Space) + * The iconic tactical combat contact echo + */ + synthesizeDradisPing() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 1.4; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(1860, now); + osc.frequency.exponentialRampToValueAtTime(1540, now + 0.08); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.35, now + 0.01); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * FTL Jump Thunderclap (BSG / Military Space) + * Sudden vacuum displacement shockwave + */ + synthesizeFtlJump() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 2.2; + + const noiseBuffer = this.am.createNoiseBuffer('brown', 2.5); + const noise = ctx.createBufferSource(); + noise.buffer = noiseBuffer; + + const filter = ctx.createBiquadFilter(); + filter.type = 'lowpass'; + filter.frequency.setValueAtTime(800, now); + filter.frequency.exponentialRampToValueAtTime(45, now + 1.8); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.8, now); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + noise.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + noise.start(now); + noise.stop(now + duration); + } + + /** + * Retro Astrogator Tone Glide (Jupiter 2 / Retro Future) + * 1960s Theremin / electronic oscillator glissando + */ + synthesizeRetroAstrogator() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 1.6; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(440, now); + osc.frequency.linearRampToValueAtTime(1180, now + 0.6); + osc.frequency.linearRampToValueAtTime(320, now + 1.1); + osc.frequency.linearRampToValueAtTime(660, now + duration); + + const vibrato = ctx.createOscillator(); + vibrato.type = 'sine'; + vibrato.frequency.setValueAtTime(8, now); + + const vibGain = ctx.createGain(); + vibGain.gain.setValueAtTime(25, now); + vibrato.connect(vibGain); + vibGain.connect(osc.frequency); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.3, now + 0.1); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + vibrato.start(now); + osc.stop(now + duration); + vibrato.stop(now + duration); + } + + /** + * HAL 9000 Logic Confirmation Chime (Discovery One) + */ + synthesizeHalChime() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const f1 = 784; // G5 + const f2 = 523; // C5 + + [0, 0.14].forEach((offset, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(idx === 0 ? f1 : f2, now + offset); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now + offset); + env.gain.linearRampToValueAtTime(0.25, now + offset + 0.01); + env.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.5); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now + offset); + osc.stop(now + offset + 0.52); + }); + } + + /** + * Event Horizon Gravity Singularity Pulse (Deep Space) + * Deep sub-bass dimensional warping thrum + */ + synthesizeSingularityEngage() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 3.2; + + const sub = ctx.createOscillator(); + sub.type = 'sine'; + sub.frequency.setValueAtTime(95, now); + sub.frequency.exponentialRampToValueAtTime(28, now + 2.2); + + const mod = ctx.createOscillator(); + mod.type = 'triangle'; + mod.frequency.setValueAtTime(14, now); + mod.frequency.linearRampToValueAtTime(45, now + 1.8); + + const modGain = ctx.createGain(); + modGain.gain.setValueAtTime(60, now); + mod.connect(modGain); + modGain.connect(sub.frequency); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.65, now + 1.5); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + sub.connect(env); + env.connect(this.gainNode); + + sub.start(now); + mod.start(now); + sub.stop(now + duration); + mod.stop(now + duration); + } + + /** + * Outlaw Afterburner Thruster Surge (Cowboy Bebop / Milano) + */ + synthesizeAfterburner() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 2.4; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(80, now); + osc.frequency.exponentialRampToValueAtTime(850, now + 0.8); + osc.frequency.linearRampToValueAtTime(620, now + duration); + + const filter = ctx.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.setValueAtTime(350, now); + filter.frequency.exponentialRampToValueAtTime(1400, now + 0.8); + filter.Q.setValueAtTime(3.0, now); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.5, now + 0.6); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(filter); + filter.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Space Station Docking Clamp Latch & Airlock Purge + */ + synthesizeDockingClamp() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + + // Heavy mechanical solenoid impact + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(380, now); + osc.frequency.exponentialRampToValueAtTime(65, now + 0.12); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.5, now); + env.gain.exponentialRampToValueAtTime(0.001, now + 0.25); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + 0.25); + + // Followed by pneumatic seal hiss + setTimeout(() => { + if (!this.am.ctx) return; + const t = this.am.ctx.currentTime; + const hissBuf = this.am.createNoiseBuffer('white', 1.2); + const hiss = this.am.ctx.createBufferSource(); + hiss.buffer = hissBuf; + + const hFilter = this.am.ctx.createBiquadFilter(); + hFilter.type = 'bandpass'; + hFilter.frequency.setValueAtTime(2200, t); + hFilter.Q.setValueAtTime(2.5, t); + + const hEnv = this.am.ctx.createGain(); + hEnv.gain.setValueAtTime(0.001, t); + hEnv.gain.linearRampToValueAtTime(0.25, t + 0.05); + hEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.9); + + hiss.connect(hFilter); + hFilter.connect(hEnv); + hEnv.connect(this.gainNode); + + hiss.start(t); + hiss.stop(t + 0.9); + }, 180); + } + + /** + * Space Station Air Handler Thud (large HVAC unit cycling on) + * Distinct from the docking clamp latch above: a dull low-frequency thump + * (no bright metallic impact) followed by a slow-building airflow whoosh + * rather than a short pneumatic hiss. A clamp is a single hard mechanical + * event; an air handler is a big soft one that keeps breathing after it. + */ + synthesizeAirHandlerThud() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + + // Dull low-frequency thump -- triangle, not square, and no bright impact + // transient, so it reads as a heavy fan housing rather than a latch. + const thump = ctx.createOscillator(); + thump.type = 'triangle'; + thump.frequency.setValueAtTime(95, now); + thump.frequency.exponentialRampToValueAtTime(38, now + 0.22); + + const thumpEnv = ctx.createGain(); + thumpEnv.gain.setValueAtTime(0.001, now); + thumpEnv.gain.linearRampToValueAtTime(0.42, now + 0.02); + thumpEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.4); + + // Sub-octave body for HVAC housing weight + const sub = ctx.createOscillator(); + sub.type = 'sine'; + sub.frequency.setValueAtTime(46, now); + + const subEnv = ctx.createGain(); + subEnv.gain.setValueAtTime(0.001, now); + subEnv.gain.linearRampToValueAtTime(0.25, now + 0.03); + subEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.45); + + thump.connect(thumpEnv); + thumpEnv.connect(this.gainNode); + sub.connect(subEnv); + subEnv.connect(this.gainNode); + + thump.start(now); + thump.stop(now + 0.4); + sub.start(now); + sub.stop(now + 0.45); + + // Slow-building airflow whoosh -- lowpass rather than the clamp's + // bandpass, and a much slower attack, so it reads as a fan spinning up + // rather than a sharp seal-release hiss. + setTimeout(() => { + if (!this.am.ctx) return; + const t = this.am.ctx.currentTime; + const airBuf = this.am.createNoiseBuffer('pink', 1.8); + const air = this.am.ctx.createBufferSource(); + air.buffer = airBuf; + + const airFilter = this.am.ctx.createBiquadFilter(); + airFilter.type = 'lowpass'; + airFilter.frequency.setValueAtTime(420, t); + airFilter.Q.setValueAtTime(0.7, t); + + const airEnv = this.am.ctx.createGain(); + airEnv.gain.setValueAtTime(0.001, t); + airEnv.gain.linearRampToValueAtTime(0.22, t + 0.35); + airEnv.gain.exponentialRampToValueAtTime(0.001, t + 1.6); + + air.connect(airFilter); + airFilter.connect(airEnv); + airEnv.connect(this.gainNode); + + air.start(t); + air.stop(t + 1.6); + }, 140); + } + + /** + * Ludicrous Speed Accelerator (Spaceball One / Comedy) + */ + synthesizeLudicrousSpeed() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 3.2; + + const osc = ctx.createOscillator(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(60, now); + osc.frequency.exponentialRampToValueAtTime(5400, now + 2.2); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.01, now); + env.gain.linearRampToValueAtTime(0.4, now + 1.8); + env.gain.exponentialRampToValueAtTime(0.0001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Infinite Improbability Reality-Warp Flip (Heart of Gold) + */ + synthesizeImprobabilityFlip() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 1.8; + + const osc = ctx.createOscillator(); + osc.type = 'triangle'; + osc.frequency.setValueAtTime(1400, now); + osc.frequency.exponentialRampToValueAtTime(180, now + 0.7); + osc.frequency.exponentialRampToValueAtTime(2200, now + 1.3); + osc.frequency.exponentialRampToValueAtTime(440, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.35, now + 0.1); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } + + /** + * Geiger Counter Click Burst (Nostromo / Mining) + */ + synthesizeGeigerBurst() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const clicks = 8 + Math.floor(Math.random() * 8); + + for (let i = 0; i < clicks; i++) { + const clickTime = now + (i * 0.04) + (Math.random() * 0.03); + const osc = ctx.createOscillator(); + osc.type = 'square'; + osc.frequency.setValueAtTime(2800 + Math.random() * 800, clickTime); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.18, clickTime); + env.gain.exponentialRampToValueAtTime(0.001, clickTime + 0.015); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(clickTime); + osc.stop(clickTime + 0.016); + } + } + + /** + * Cheerful Door Sigh (Heart of Gold / Sirius Cybernetics Corp) + */ + synthesizeCheerfulDoor() { + this.init(); + const ctx = this.am.ctx; + if (!ctx) return; + + const now = ctx.currentTime; + const duration = 0.9; + + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(620, now); + osc.frequency.linearRampToValueAtTime(840, now + 0.35); + osc.frequency.linearRampToValueAtTime(520, now + duration); + + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, now); + env.gain.linearRampToValueAtTime(0.25, now + 0.15); + env.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.connect(env); + env.connect(this.gainNode); + + osc.start(now); + osc.stop(now + duration); + } +} + +window.ExpandedSciFiAudioSynth = ExpandedSciFiAudioSynth; + + +/** + * Canonical Starship & Location Sound Profiles Matrix + * Each preset defines the exact parameters for Hull Drone, Warp Core, Life Support, and Telemetry. + */ + diff --git a/sound_reference.md b/sound_reference.md new file mode 100644 index 0000000..ea2015d --- /dev/null +++ b/sound_reference.md @@ -0,0 +1,942 @@ +# Real-World Sound Reference + +Source material for deciding what each preset **should** sound like. This is a +reference about the shows, not about this codebase — but every section ends by +naming the synth element that already covers the sound, so it can be read +straight across into `js/config.js`. + +**Nothing here proposes removing an existing element.** The current elements +catalogued in [`telemetry_elements.md`](telemetry_elements.md) are the baseline; +this document maps them to their real counterparts and identifies what is +*missing*, not what is wrong. + +Scope: Star Trek (five eras the telemetry router already knows), Doctor Who +/ The Whoniverse, the six ships grouped under this app's `bioships` universe, +and the six stations grouped under `spacestations`. Other universes are a +later pass. + +--- + +## Confidence levels + +There is no single authoritative catalog of Star Trek sound effects by ship and +room. What exists is production history (interviews, obituaries, trade press), +fan-maintained sound archives organized by category, and the shows themselves. +Every claim below carries one of these markers. **Do not promote a claim to a +higher level without a source.** + +| Level | Meaning | +| --- | --- | +| **[DOC]** | Documented in a cited production source — an interview, trade article, or credited account. | +| **[OBS]** | Directly observable by listening to the episodes or an archive file. Verifiable by anyone, but not documented as a production decision. | +| **[ATT]** | Widely attested in fan documentation and consistent across sources, but without a primary production citation. | +| **[GAP]** | Not yet established. Listed so it is visible, not filled in with a guess. | + +--- + +## Layer mapping + +Real production sound categories map onto this app's four audio layers as +follows. This is the translation table between "what the show did" and "what we +can synthesize". + +| Show category | App layer | Notes | +| --- | --- | --- | +| Room tone / set ambience | `hull` (`HullDroneSynth`) | The low continuous bed. Per-room variation lives almost entirely here. | +| Engine / warp core | `warp` (`WarpCoreSynth`) | Rhythmic pulse. Five `pulseShape` variants already exist. | +| Air handling / life support | `lifeSupport` (`LifeSupportSynth`) | Filtered noise with slow airflow modulation. | +| Computer beeps, chirps, relays, sweeps | `telemetry` (`TelemetrySynth`) | The discrete chatter layer. | +| Klaxons, alerts | `AlertSynth` | Separate layer, event-triggered. | +| Doors, turbolifts, transporters, comms | `TelemetrySynth` (manual) / soundboard | One-shots, not part of the ambient bed. | +| Phasers, torpedoes, explosions | *out of scope* | This is an ambient display, not a combat simulator. | + +Categories with no home yet — turbolift, transporter, replicator, forcefield, +holodeck, tricorder, viewscreen — are noted in the gap list at the end. + +--- + +## TOS — The Original Series + +**Production context [DOC]:** Sound effects were built by Douglas Grindstaff, +working from Paramount and Desilu library material, personal collections, and +Paramount's 1953 *War of the Worlds*. Gene Roddenberry's direction to him was to +"think like an artist and paint everything with sound," and he insisted each +visited planet carry its own distinct audio signature — built from variations of +an orchestra tuning up. The transporter effect blended musical effects with +electric generator recordings, with fades cut by hand using a razor blade on +magnetic tape at a Moviola. + +### Ambience bed + +| Element | Character | Confidence | +| --- | --- | --- | +| Bridge room tone | Constant low hum with a persistent layer of electronic computer chatter riding on top — the chatter is nearly continuous rather than occasional, which is the defining TOS trait. | **[OBS]** | +| Engine room | Heavier, more mechanical, with a pronounced oscillating thrum. | **[OBS]** | +| Quarters | Quieter, less chatter, hum dominant. | **[OBS]** | + +*Covered by:* `hull` with low `baseFreq` and low `filterCutoff`; `warp` with +`pulseShape: 'tos'`, whose comment already describes an "electromechanical +oscillating engine thrum." + +### Discrete sounds + +| Sound | Character | Existing element | Confidence | +| --- | --- | --- | --- | +| Computer warble | Wavering, unstable analog tone; reads as a machine thinking. | **TOS Computer Warble** — already a close match (detuned tri+saw under a 14–22 Hz vibrato LFO through a bandpass). | **[OBS]** | +| Relay / solenoid click | Sharp mechanical snap, no pitch. | **TOS Relay Click** | **[OBS]** | +| Bosun's whistle | Rising-falling two-tone whistle preceding shipwide announcements. | *none* — **gap** | **[ATT]** | +| Red alert klaxon | Harsh oscillating buzzer, closer to a hooter than a tone. | `AlertSynth.synthesizeTOSRedAlertCycle` | **[OBS]** | +| Door | Pneumatic swish, softer and more mechanical than TNG's. | *none* — **gap** | **[OBS]** | + +**Assessment:** TOS is the best-served era in the app already. The two telemetry +elements are era-appropriate and the density is right — TOS should run at +*higher* telemetry density than TNG, because the chatter is near-continuous +rather than punctuating. + +--- + +## TNG — The Next Generation + +### Ambience bed + +| Element | Character | Confidence | +| --- | --- | --- | +| Bridge room tone | Warm, smooth, low-frequency hum. Deliberately calmer and less busy than TOS — the 24th century sounds *settled*. | **[OBS]** | +| Main Engineering | The warp core dominates: a slow, deep, multi-stage pulse with a reverberant bloom. The single most recognizable ambience in the franchise. | **[OBS]** | +| Crew quarters | Muffled, heavily damped, warp core barely present. | **[OBS]** | +| Corridor | Between bridge and quarters; some air handling. | **[OBS]** | +| Sickbay | Quieter, with intermittent medical monitor tones. | **[OBS]** | + +*Covered by:* `pulseShape: 'tng'` already implements a four-stage envelope with +a secondary harmonic bloom, which is the correct shape. + +### Discrete sounds + +| Sound | Character | Existing element | Confidence | +| --- | --- | --- | --- | +| LCARS touch tone | Short, soft, musical sine blip. Panel interaction. | **LCARS Single Chirp** | **[OBS]** | +| LCARS confirmation | Two tones, second higher — acknowledgment. | **LCARS Double Chirp** | **[OBS]** | +| LCARS data sequence | Three or more tones in quick succession. | **LCARS Acknowledgment Sequence** | **[OBS]** | +| Door chime | Two-tone, rising fourth. The "come in" chime. | **TNG Door Chime** | **[OBS]** | +| Door open | Soft pneumatic swish. | *none* — **gap** | **[OBS]** | +| Red alert klaxon | Three-tone descending pattern, urgent but musical. | `AlertSynth.synthesizeTNGRedAlertCycle` | **[OBS]** | +| Yellow alert | Softer single chime, repeating. | `AlertSynth.synthesizeYellowAlertCycle` | **[OBS]** | +| Comm chirp | The tap-badge tone before speech. | *none* — **gap**, and a strong candidate | **[OBS]** | +| Turbolift | Rising whoosh with a settling tone. | *none* — **gap** | **[OBS]** | +| Transporter | Shimmering rise or fall over ~3 s. | *none* — **gap** | **[OBS]** | +| Replicator | Short shimmer, transporter-adjacent but briefer. | *none* — **gap** | **[OBS]** | + +**Sound team [GAP]:** TNG's supervising sound editors and the specific +attribution for the LCARS tone set are not established here. The series won +multiple sound editing Emmys and the credits are a matter of record; this +document should not name individuals until that is checked against a primary +source. + +**Assessment:** The best-covered era for telemetry. The clearest single gap is +the **comm badge chirp** — arguably the most recognizable discrete sound in all +of TNG and currently absent. + +--- + +## DS9 — Deep Space Nine + +The key structural fact: DS9 is a **Cardassian-built station** (originally Terok +Nor), not a Starfleet ship. Its native systems sound alien, and Starfleet +equipment installed aboard sounds like Starfleet — the two coexist, which is why +the era's current 50/50 split between the Cardassian Sensor and the LCARS Single +Chirp is defensible rather than accidental. **[ATT]** + +### Ambience bed + +| Element | Character | Confidence | +| --- | --- | --- | +| Ops | Deeper, more resonant and more cavernous than a Starfleet bridge; a heavier industrial bed. | **[OBS]** | +| Promenade | Open, echoing public space with crowd presence — distinct from any shipboard room tone. | **[OBS]** | +| Habitat ring / quarters | Enclosed, low, mechanical. | **[OBS]** | +| Defiant | Cramped warship: tighter, more aggressive, faster core pulse. | **[OBS]** | + +*Covered by:* `pulseShape: 'defiant'` (tight attack, rapid decay) — already the +most-used pulse shape in the config at 15 presets. + +### Discrete sounds + +| Sound | Character | Existing element | Confidence | +| --- | --- | --- | --- | +| Cardassian computer tone | Resonant, metallic, unresolved — dissonant rather than musical. | **Cardassian Sensor Tone** (sine pair a tritone apart) | **[OBS]** | +| Cardassian door | Heavy, grinding, mechanical — nothing like a Starfleet swish. | *none* — **gap**, high character value | **[OBS]** | +| Starfleet overlay | LCARS tones from installed Federation equipment. | **LCARS Single Chirp** | **[OBS]** | + +**Assessment:** The Promenade has no equivalent anywhere in the app — it is the +one Trek location whose defining quality is *crowd*, not machinery. Worth noting +as a design question rather than a synth gap. + +--- + +## VOY — Voyager + +### Ambience bed + +| Element | Character | Confidence | +| --- | --- | --- | +| Bridge | Crisper and cleaner than TNG; higher-frequency content, more air. | **[OBS]** | +| Engineering | Class-9 warp core: faster and sharper than the Galaxy-class pulse, higher resonance. | **[OBS]** | +| Astrometrics | Open, quiet, with sensor sweep activity. | **[OBS]** | + +*Covered by:* `pulseShape: 'voyager'` — faster peak (20% vs TNG's 28%), higher +filter excursion. Correct. + +### Discrete sounds + +| Sound | Character | Existing element | Confidence | +| --- | --- | --- | --- | +| LCARS tones | Same family as TNG, slightly brighter. | **LCARS Double Chirp** | **[OBS]** | +| Long-range sensor sweep | Gliding tone, up or down. | **Sensor Sweep** | **[OBS]** | +| Bio-neural gel pack | *No distinctive recurring sound established.* | — | **[GAP]** | + +**Assessment:** Well covered. The current 60/40 Double Chirp / Sensor Sweep +split is a reasonable read of the era. + +--- + +## ENT — Enterprise (NX-01) + +The design brief across all departments was a ship closer to present-day +technology than to the 24th century — the NX-01 reads as a submarine or a +research vessel rather than a starship. **[ATT]** + +### Ambience bed + +| Element | Character | Confidence | +| --- | --- | --- | +| Bridge | Mechanical, more present machinery noise, less smooth than TNG. | **[OBS]** | +| Engineering | Reactor chug rather than a magnetic pulse; heavier, more industrial. | **[OBS]** | +| Quarters | Audible ventilation; the ship is never silent. | **[OBS]** | + +*Covered by:* `pulseShape: 'nx'` — described in the code as a "reactor chug", +which is the right instinct. + +### Discrete sounds + +| Sound | Character | Existing element | Confidence | +| --- | --- | --- | --- | +| Hydraulic relay | Heavy, dull mechanical thunk. | **NX Hydraulic Relay** | **[OBS]** | +| Indicator beep | Plain utilitarian beep, no musical intent. | **NX Indicator Beep** | **[OBS]** | +| Door | Manual-feeling, mechanical, slower than later eras. | *none* — **gap** | **[OBS]** | +| Comm | Rougher and more radio-like than the TNG badge chirp. | *none* — **gap** | **[OBS]** | + +**Assessment:** Correctly characterized. The deliberate *plainness* of the NX +telemetry is the point — resist any temptation to make these more musical. + +--- + +## Star Trek existing element retention map + +Every current telemetry element and its real counterpart. All ten are +era-appropriate and none should be removed. + +| Existing element | Real counterpart | Verdict | +| --- | --- | --- | +| LCARS Single Chirp | TNG/VOY panel touch tone | Keep — accurate | +| LCARS Double Chirp | TNG/VOY confirmation tone | Keep — accurate | +| LCARS Acknowledgment Sequence | TNG multi-tone data run | Keep — accurate | +| Sensor Sweep | Long-range sensor glide | Keep — accurate | +| TOS Computer Warble | TOS computer chatter | Keep — strong match | +| TOS Relay Click | TOS mechanical relay | Keep — accurate | +| Cardassian Sensor Tone | Terok Nor native systems | Keep — strong match | +| NX Hydraulic Relay | NX-01 mechanical systems | Keep — accurate | +| NX Indicator Beep | NX-01 indicator | Keep — accurate | +| TNG Door Chime | Door annunciator | Keep — accurate; consider adding to the `tng` scheduler at low weight | + +--- + +## Star Trek gap list + +Ranked by how much each would add, given what already exists. + +| # | Sound | Era(s) | Why it matters | Difficulty | +| --- | --- | --- | --- | --- | +| 1 | **Comm badge chirp** | TNG, VOY | The most recognizable discrete sound in the franchise, and completely absent. Short, bright, two-part. | Low | +| 2 | **Cardassian door** | DS9 | Heavy grinding mechanism; would give DS9 presets character they currently lack entirely. | Medium — needs noise plus resonant filter sweep | +| 3 | **Door swish** | TOS, TNG, VOY, ENT | Four era variants from one filtered-noise primitive with different envelopes. High reuse for low cost. | Low | +| 4 | **Turbolift** | TNG, VOY | Rising whoosh and settle; adds a sense of a ship in use. | Medium | +| 5 | **Bosun's whistle** | TOS | Distinctive and very era-specific; two-tone glide. | Low | +| 6 | **Transporter** | all | Iconic, but long (~3 s) and shimmer-dense — pushes past the ambient-layer duration guideline and belongs on the soundboard. | High | +| 7 | **Medical monitor** | TNG, VOY | Slow rhythmic tone; would make a sickbay preset viable. | Low | +| 8 | **Replicator** | TNG, VOY | Brief shimmer; lower value than the above. | Medium | + +Items 1, 3, 5 and 7 are all low-difficulty and would roughly double the +discrete vocabulary. + +--- + +## Star Trek open questions + +Deliberately unresolved rather than guessed: + +- **[GAP]** TNG/DS9/VOY sound editor credits and who designed the LCARS tone + set. Check series credits and Emmy records against a primary source before + naming anyone. +- **[GAP]** Whether the LCARS tones follow a deliberate musical scheme or the + perceived intervals are incidental. The app assumes a fixed pitch set; that is + a design choice, not an established fact about the shows. +- **[GAP]** Per-room ambience is characterized above by listening, not from + production documentation. If a set-by-set breakdown exists it has not been + located. +- **[GAP]** Whether TOS telemetry density should differ from TNG by default. The + listening impression is that it should be substantially higher; no source + confirms an intended difference. + +### Where to verify + +- Memory Alpha's *Star Trek Sound Effects* page — the closest thing to a canon + index, though it resists automated fetching. +- TrekCore's audio archive, organized into 19 categories including background + ambient, computer, door, turbolift, red alert and warp, with per-series + subdivisions. Listening to these directly is the fastest route to closing the + **[OBS]** and **[GAP]** entries above. +- Episode audio itself, for per-room ambience — the only reliable source for it. + +--- + +# Doctor Who — The Whoniverse + +Everything in this app's `whoniverse` universe traces back to one source: the +**BBC Radiophonic Workshop**, and specifically sound designer **Brian Hodgson**, +who created the TARDIS's signature sounds in 1963. Unlike Star Trek's five +distinct production eras, Doctor Who's sound identity is organized around the +**TARDIS console room redesigns that accompany each Doctor** — which is exactly +how this app's own presets are already structured (`js/config.js`'s +`whoniverse` universe has one preset per console room, 1st/2nd Doctors through +14th/15th). + +**Nothing here proposes removing an existing element.** `WhoniverseAudioSynth` +already implements eight elements, several of them explicitly modeled on the +real production technique in their own code comments. This section verifies +those claims against source material and identifies what's missing — not what's +wrong. + +## Layer mapping (Whoniverse-specific) + +| Real sound | App layer | Notes | +| --- | --- | --- | +| TARDIS interior room tone | `hull` (`HullDroneSynth`) | Continuous, present in every scene set inside the TARDIS. | +| Central column / time rotor pulse | `warp` (`WarpCoreSynth`) | The rising-falling column is the TARDIS's visual "engine," so it maps to the pulse layer even though nothing is propelling anything in the Trek sense. | +| Cloister Room / ship systems | `lifeSupport` (`LifeSupportSynth`) | Background systems hum. | +| Console beeps / instrument chatter | `telemetry` (`TelemetrySynth`) | Borrowed wholesale from Star Trek eras — see [`telemetry_elements.md`](telemetry_elements.md#preset--telemetry-era-reference). | +| Materialization/dematerialization, cloister bell, sonic screwdriver, etc. | `WhoniverseAudioSynth` (manual, soundboard) | One-shots, not part of the ambient bed — same role as doors/transporters in the Trek layer mapping. | + +## The materialization sound ("wheeze-groan" / "vwoorp") + +**[DOC]** Brian Hodgson created the effect in 1963 for *An Unearthly Child* by +dragging a house key along the bass strings of a broken piano, then processing +the recording through **tape feedback** — playing a signal into a second tape +recorder that repeats it, producing a sound that seems to recede, and reversing +that same technique to make it seem to approach. He layered in "little bits of +white noise, little chiffs of beeps and things coming and going" on top. The +conceptual hook was "the rending of the fabric of time and space" — the sound +is deliberately a *tearing*, not a mechanical hum. + +**[ATT]** The sound has no single official name. Fans call it the "vwoorpy" or +the "wheezing, groaning" sound; there is no evidence the production ever gave it +a formal name. + +**[DOC]** — and worth knowing because it changes how you'd think about +*variation* — the sound is explicitly **not** how a time machine is supposed to +sound. In-universe, it results from the Doctor's TARDIS having its brakes left +on; other, properly functioning TARDISes make no such sound. This licenses this +app's cross-console-room variation as accurate to the fiction rather than an +oversight: every console room can plausibly sound slightly different, because +the "sound" was never a fixed engineering constant to begin with. + +*Covered by:* `synthesizeDematCycle` / `synthesizeSingleDematSwell`. The code +comment already cites Hodgson's technique directly and models it in three +layers — a friction-scrape carrier (sawtooth through a swept bandpass filter, +standing in for the scraped piano string), a sub-vortex FM groan (the "cosmic" +undertone), and a 5.5 Hz flutter LFO (standing in for tape flanging). This is +the single most research-grounded element in the entire codebase — the +implementation isn't just era-appropriate, it's technique-appropriate. + +**Assessment: keep as-is.** This element does not need revision; it needs +recognition as the reference implementation for how a "real sound → synthesis" +translation should look throughout this whole app. + +## The cloister bell + +**[DOC]** In-universe, the cloister bell warns the TARDIS crew of a threat +severe enough to endanger the ship itself — hull breach, vortex discontinuity, +even the heat death of the universe. It's mounted in the Cloister Room but +audible throughout the ship, and can be triggered automatically by the TARDIS's +systems or manually from the console. + +**[OBS]** Sonically it reads as a deep, resonant bronze bell — closer to a +cathedral bell than an alarm klaxon — struck at a slow, ominous, unhurried +interval that contrasts with how urgent its message is. + +**[GAP]** No production source located for how the actual bell sound was +recorded, its real strike interval, or who created it. The existing code's +comment calling 3.2 s "the canonical cloister bell repetition rate" is an +**[ATT]**-level claim at best here — it should not be read as sourced fact +without a citation, even though it isn't being challenged as *wrong*. + +*Covered by:* `triggerCloisterBell` / `synthesizeCloisterStrike`. The strike is +built from six inharmonic partials at realistic bell ratios (fundamental, minor +third, fifth, octave, and two upper strike tones) over a 108 Hz fundamental — +this is a legitimate bell-synthesis model, not a guess, regardless of whether +the specific interval is confirmed. + +**Assessment: keep as-is; the interval claim's citation is the only open +item, not the sound design.** + +## The sonic screwdriver + +**[OBS]** A high-pitched, warbling electronic buzz/whine — mechanical rather +than musical, closer to a dental drill or scanning tool than a chime. + +**[GAP]** No production source located describing how the effect was originally +created or engineered, despite searching. This is a genuine hole in the +public record, not a research shortcut — treat any claim about its origin +(a common one repeated informally is a kazoo or comb-and-paper source) as +unverified folklore unless a citation turns up. + +*Covered by:* `synthesizeSonicScrewdriver`. Dual square/sawtooth oscillators +roughly a beat-frequency apart, under a fast 32 Hz vibrato, through a resonant +bandpass — a reasonable synthesis of the *character*, independent of whether it +matches the real production method. + +**Assessment: keep as-is; character match is solid even though the historical +method is undocumented.** + +## Other discrete sounds + +| Sound | Character | Existing element | Confidence | +| --- | --- | --- | --- | +| Fast-return lever | Mechanical spring-lever clack the Doctor throws to abort a landing/departure. | **`synthesizeFastReturn`** — triangle wave falling 620→95 Hz, hard clack envelope. | **[ATT]** — the lever is a recurring visual/mechanical prop across eras; no dedicated production sound citation found, but the clack-and-recoil character is consistent with how it's portrayed. | +| Demat switch / console relay | Small mechanical toggle sound for individual console actions, distinct from the full materialization cycle. | **`synthesizeDematSwitch`** — 60 ms square-wave click. | **[OBS]** | +| Telepathic circuits | The TARDIS's translation/psychic-link system — no consistent discrete sound is established on screen; it is usually implied rather than heard. | **`synthesizeTelepathicChime`** — a four-note ascending C-major arpeggio shimmer. | **[GAP]** — this element is a reasonable *invention* for something the show doesn't consistently sonify, not a documented real sound. Flagging so it isn't mistaken for a verified effect. | +| Time rotor column | The rising/falling central column, often shown in sync with the engine sound rather than having a distinct sound of its own. | *none dedicated* — folded into the demat cycle | **[OBS]** | +| Console "typewriter" / dematerialization lever throw | Physical lever-throw clunk before a materialization cycle begins. | *none* — **gap** | **[ATT]** | + +## Whoniverse existing element retention map + +| Existing element | Real counterpart | Verdict | +| --- | --- | --- | +| Demat Cycle / Single Demat Swell | TARDIS wheeze-groan (Hodgson, 1963) | Keep — the strongest research-to-synthesis match in the app | +| Cloister Bell / Cloister Strike | Cloister bell danger signal | Keep — sound model is sound; strike-interval citation is the only gap | +| Sonic Screwdriver | Sonic screwdriver buzz | Keep — character accurate; historical method undocumented | +| Fast-Return Lever | Fast-return spring lever | Keep — plausible, unverified against a primary source | +| Demat Switch | Console relay/toggle | Keep — generic and safe regardless of citation | +| Telepathic Chime | *(no consistent on-screen sound)* | Keep as a deliberate invention — worth labeling as such in-app rather than implying it's documented | + +## Whoniverse gap list + +| # | Sound | Why it matters | Difficulty | +| --- | --- | --- | --- | +| 1 | **Lever-throw clunk** | A physical, tactile beat that currently has no discrete sound — precedes every materialization cycle on screen. Cheap complement to the existing demat cycle. | Low | +| 2 | **TARDIS door open/close** | Distinct wooden-creak-plus-latch character, unlike any Trek door sound already in the app; would give the Whoniverse universe a texture Star Trek's presets can't reuse. | Low–Medium | +| 3 | **Scanner/monitor activation** | The console room's viewscreen has a recognizable activation tone across several eras. | Medium | +| 4 | **Console "type" input** | Rapid button/lever interaction chatter distinct from Trek's LCARS chirps — currently the app reuses Trek telemetry wholesale here (see the era table in `telemetry_elements.md`). | Medium — this is really about giving Whoniverse its *own* telemetry vocabulary rather than one new element | + +Item 4 is the more consequential one long-term: right now every Whoniverse +preset borrows a Trek-era telemetry vocabulary (TOS warbles, TNG chirps, etc.) +because `telemetry.era` only has five Trek values to choose from. A dedicated +`who` era — built from lever clunks, switch throws, and the console's own +percussive character — would be the single biggest authenticity improvement +available to this universe, bigger than any individual missing one-shot. + +## Whoniverse open questions + +- **[GAP]** The cloister bell's real strike interval and who created the sound. + The existing "canonical... rate" code comment should be treated as unverified + until a source is found. +- **[GAP]** The sonic screwdriver's original production method. A commonly + repeated claim (kazoo/comb-and-paper origin) surfaced in searches but with no + citation strong enough to record here as fact. +- **[GAP]** Whether materialization sound genuinely varies by console + room/Doctor in the source material, the way this app's presets already + assume, or whether that's an app-level design choice dressed as canon. The + in-universe "brakes left on" explanation supports the *idea* of variation but + doesn't confirm the show varied the actual sound design by era. +- **[GAP]** No console-room-specific ambience breakdown was found (parallel to + the missing Star Trek per-room documentation) — characterizations of "warmer" + vs. "colder" console rooms in this app's presets are plausible extrapolations + from the visual redesigns, not sourced audio claims. + +### Where to verify (Doctor Who) + +- The BBC's own account of Brian Hodgson's technique (`doctorwho.tv`) is the + best primary source located and is what the demat-cycle comment in the code + already reflects accurately. +- *TARDIS Wiki* (`tardis.fandom.com`) and its mirror `tardis.wiki` are the + closest thing to a canon index for in-universe behavior (cloister bell, + vwoorpy) but do not cover sound production technique. +- BBC Sound Effects releases (e.g. *BBC Sound Effects No. 19: Doctor Who Sound + Effects*) are a plausible source of primary audio for closing the sonic + screwdriver and cloister bell **[GAP]** entries — not yet reviewed here. + +--- + +# Bioships + +Unlike Star Trek or Doctor Who, "Bioships" is not one franchise — it's this +app's own category grouping six living/biomechanical ships from five unrelated +sources: *Farscape* (Moya, Talyn), *Lexx* (The Lexx), *Babylon 5* (Vorlon +Cruiser), *Stargate Atlantis* (Wraith Hive Ship), and *Star Trek: Voyager* +(Species 8472). There is no shared production history to research — each ship +has its own creative team and its own answer to "what does organic technology +sound like." What follows is per-ship, not per-era. + +**Nothing here proposes removing an existing element.** As the retention map +below shows, `bioships` currently has **no bespoke synthesis of its own at +all** — every discrete "bio" sound in the soundboard is a relabeled element +borrowed from `WhoniverseAudioSynth`. That is worth knowing, not fixing by +force; see the assessment at the end. + +## Layer mapping (Bioships-specific) + +| Real concept | App layer | Notes | +| --- | --- | --- | +| Heartbeat / vascular pulse | `hull` (`HullDroneSynth`) | The organic equivalent of engine room tone — several presets describe this explicitly (Moya: "pulsating vascular fluid circulation"). | +| Propulsion (starburst, bio-molecular thrust) | `warp` (`WarpCoreSynth`) | Reused pulse shapes stand in for biological propulsion with no real acoustic reference to check against. | +| Respiration / internal atmosphere | `lifeSupport` (`LifeSupportSynth`) | Wraith hive ships' misty corridors and Lexx's "organic respiration" both map here. | +| Neural/telepathic activity | `telemetry` (`TelemetrySynth`) | Currently just borrowed Trek eras — see the config table below. | +| Discrete organic events (creaks, twitches, vocalizations) | soundboard, relabeled `WhoniverseAudioSynth` calls | No ship-specific synthesis exists; see below. | + +## Ship-by-ship + +### Moya — Farscape (Leviathan transport) + +**[DOC]** Series creator Rockne S. O'Bannon's explicit design brief was that +Moya's interior should be "suggestive of organic, but I didn't want it to give +the sense that when people walked down the passageway you'd hear a squishing +sound." This is a real, citable production constraint, and it cuts directly +against the wettest, most visceral reading of "organic ship." + +**[ATT]** Moya is a Leviathan transport vessel — a living, sentient, +bio-mechanical being, not merely a ship *shaped* like an organism. Her pilot +(Pilot) is neurally bonded to her, with nerve endings that grow together over +time; an artificially rushed bond causes "a great deal of pain" from mismatched +nerve endings. + +**[ATT]** Starburst — the Leviathans' faster-than-light escape mechanism — is +described as an energy buildup that surges through the pilot's den and around +the pilot before releasing across the hull, tearing a dimensional rift. Its +unpredictability is explicitly called biological in nature, and hasn't been +replicated by any non-Leviathan ship. + +*Covered by:* preset `bio-moya` (hull volume 0.75, low 45 Hz base, `pulseShape: +'tng'` at a slow 38 bpm — a heartbeat-like rate, not an engine rate). The slow +bpm choice is a reasonable synthesis decision even without a documented real +tempo to check it against. + +### Talyn — Farscape (Leviathan-Gunship hybrid) + +**[ATT]** Talyn is Moya's offspring, genetically and technologically altered +into a hybrid gunship — younger, more aggressive, combat-capable in a way +ordinary Leviathans are not. + +*Covered by:* preset `bio-talyn` — higher bpm (64 vs Moya's 38), `pulseShape: +'defiant'` (the tightest, most aggressive envelope in the app), higher +resonance. The faster/tenser parameter choices track the "young, aggressive +warship" framing accurately even without a specific real-world sound to verify +against. + +### The Lexx — Lexx (machine-insect hybrid superweapon) + +**[DOC]** The Lexx is explicitly a "machine–insect hybrid," created as the most +powerful weapon of destruction across two universes, capable of destroying a +planet in a single shot. Its command chair was originally covered in a +removable "skin" (a technological shell), which was written out after the +first season to reveal the organic tissue beneath directly — a production +choice to lean *further* into the organic aesthetic over time, not away from +it. + +**[GAP]** No production source located describing how the ship's ambient sound +— engine drone, organic wall texture, breathing — was actually created or +recorded, despite searching. + +*Covered by:* preset `bio-lexx` — the lowest base frequency of any bioship +preset (38 Hz), heaviest noise mix (0.6), brown noise in `lifeSupport` (the +darkest noise color available). These choices align with "massive biomechanical +insectoid digestive engine drone" from the preset's own description, but that +description is this app's own writing, not a sourced claim about the show. + +### Vorlon Cruiser — Babylon 5 (living transport) + +**[DOC]** Vorlon transports are described on more than one occasion in the show +as **"singing"** to their occupants — a directly citable, specific claim that +lines up almost exactly with this app's own preset description ("telepathic +singing crystal harmonics"). + +**[ATT]** The ships carry Vorlon bio-armor and use four sail-like organic folds +for propulsion rather than a mechanical drive, and are described as having "an +intelligence and will of their own" — a genuinely symbiotic, not merely +piloted, relationship with Ambassador Kosh. + +*Covered by:* preset `bio-vorlon` — the highest `resonance` of any bioship +preset (3.8 hull / 4.5 warp) and the widest `swirlMix` (0.5), which is a +plausible way to synthesize "singing" — resonant, harmonically rich, spatially +moving — even though no specific frequency content is documented for the real +sound. + +**Assessment:** this is the one bioship preset with the strongest direct +textual match ("singing") between the source material and the preset's own +description. It is the best candidate in this whole category for a bespoke +telemetry element, discussed below. + +### Wraith Hive Ship — Stargate Atlantis (grown organic warship) + +**[DOC]** Hive ship hulls are living, growing organic matter, built through +biotechnology capable of rapid growth — which grants automatic hull +regeneration and self-regulating internal structure, but also means the ship +can dangerously reconfigure itself when something goes wrong, with chambers +appearing or disappearing. The organic hull requires periodic rest between +hyperspace jumps to heal radiation damage, which is a real plot-relevant +constraint, not incidental color. + +**[DOC]** The ship is crewed through a neural interface that responds only to +a telepathic signature carried in Wraith DNA — full-blooded Wraith operate it +without fatigue, while those with diluted DNA tire quickly. Interior corridors +are described with **a fine mist covering the floor** throughout inhabited +sections. + +*Covered by:* preset `bio-wraith` — the lowest bpm of any bioship (32), +combined with the second-lowest base frequency (34 Hz) and heaviest noise +mix alongside `bio-lexx` (0.65). The mist detail is a strong, currently unused +cue for `lifeSupport` tuning — see the gap list. + +### Species 8472 — Star Trek: Voyager (fluidic-space bioship) + +**[DOC]** Species 8472 bioships are built from organic technology resembling +the species' own biology, originate from a parallel dimension called fluidic +space, and are explicitly impervious to conventional Starfleet and Borg +weapons — Voyager only defeats one using modified Borg nanoprobes, a +biological rather than a physical countermeasure. + +**[GAP]** No documented detail located on the ship's actual sound design; +Memory Alpha's production-side coverage of this vessel is thin. + +*Covered by:* preset `bio-species-8472` — the highest base frequency of any +bioship (68 Hz, notably brighter than the others' 34–58 Hz range) and +`pulseShape: 'defiant'`. The brighter tuning is a reasonable read of "pure +genetic thrust" and "high-frequency biological firing capacitors" from the +app's own preset description, but again, that description is house writing, not +a documented production fact. + +## The "no squishing" design constraint + +O'Bannon's stated preference for Moya deserves to generalize across the whole +category, not just his own ship: **organic does not have to mean wet.** Every +existing bioship preset in this app already avoids literal squelch/gurgle +noise-synthesis in favor of resonant tones, filtered noise beds, and pulse +envelopes — which happens to align with the one explicit real production +statement found in this research. Any future bioship element should hold that +line deliberately, not by accident. + +## Existing element / soundboard relabeling + +`bioships`' soundboard (`js/config.js`) does not call any bespoke synthesis. It +relabels five existing `WhoniverseAudioSynth` / `TelemetrySynth` elements with +organic-sounding names: + +| Soundboard label | Actual method called | Real element | +| --- | --- | --- | +| NEURAL TWITCH | `synthesizeTelepathicChime` | Doctor Who telepathic circuit chime (C-major arpeggio shimmer) | +| VASCULAR PUMP | `synthesizeFastReturn` | Doctor Who fast-return lever clack | +| CHITIN CREAK | `synthesizeDematSwitch` | Doctor Who console relay click | +| BIOPLASMIC HISS | `synthesizeSensorSweep` | Star Trek sensor sweep glide | +| SYMBIOTE VOCALIZATION | `synthesizeSonicScrewdriver` | Doctor Who sonic screwdriver buzz | + +None of these were designed with any of the six ships above in mind — they are +reused verbatim from a different universe's implementation, exactly as +`telemetry.era` reuses Trek vocabulary for every non-Trek universe (see +[`telemetry_elements.md`](telemetry_elements.md)). This is the same pattern +recurring a third time: Whoniverse borrows nothing (it's the source), Trek eras +get borrowed by everyone else's telemetry, and now bioships borrows discrete +one-shots the same way. + +## Bioships existing element retention map + +| Existing (relabeled) element | Ship it's used for | Verdict | +| --- | --- | --- | +| Neural Twitch (telepathic chime) | all bioship presets | Keep — a shimmering chime is a defensible generic stand-in for "neural event," but it was written for the TARDIS, not any of these six ships | +| Vascular Pump (fast return) | all bioship presets | Keep — mechanical clack reads as circulatory pump only by association, not by design | +| Chitin Creak (demat switch) | all bioship presets | Keep — a 60 ms square click has no organic quality at all; the label is doing all the work here | +| Bioplasmic Hiss (sensor sweep) | all bioship presets | Keep — a clean sine glide is the least "biological"-sounding element in the whole app; strongest case for replacement | +| Symbiote Vocalization (sonic screwdriver) | all bioship presets | Keep — a resonant buzz is closer to organic than the others, best of the five relabels | + +## Bioships gap list + +| # | Sound | Why it matters | Difficulty | +| --- | --- | --- | --- | +| 1 | **Bespoke bioship telemetry era** | The single biggest gap in this category, and the same shape as Whoniverse's biggest gap. All six presets currently draw `telemetry.era` from Trek's five values (see the era table in `telemetry_elements.md`) — there is no organic-sounding discrete vocabulary at all, only relabeled inorganic ones. | Medium–High | +| 2 | **"Singing" resonance element for the Vorlon Cruiser** | The strongest single documented cue in this whole category ("singing to their occupants") has no dedicated synthesis; it's currently indistinguishable from any other bioship preset at the discrete-sound level. | Medium | +| 3 | **Neural-bond swell (Moya/Talyn/Wraith)** | Three of six ships have a documented neural-interface detail (Pilot's nerve endings, Wraith telepathic DNA control) that could unify into one element used across all three, rather than reusing the TARDIS's telepathic chime. | Medium | +| 4 | **Misty-atmosphere life-support tuning (Wraith)** | A specific, documented environmental detail (mist-covered corridor floors) with no corresponding audio treatment — likely a `lifeSupport` filter/noise adjustment rather than a new element. | Low | +| 5 | **Hull self-repair / regeneration texture (Wraith)** | Documented as a distinct capability (auto-regenerating organic hull) with dramatic stakes (forced rest between jumps) and no sonic representation at all. | Medium | + +Item 1 dwarfs the rest in the same way the Whoniverse telemetry gap did: a +purpose-built organic discrete vocabulary — creaks, pulses, membrane flutters — +would do more for this universe's authenticity than any single new one-shot. + +## Bioships open questions + +- **[GAP]** No sound design production information was found for The Lexx, + Species 8472, or the Wraith hive ship specifically — all three assessments + above rest on in-universe descriptions (organic materials, behavior, + constraints), not on how anyone actually built the sound. +- **[GAP]** Whether any of these six ships has ever had its sound + professionally analyzed or cataloged the way TrekCore catalogs Star Trek — + no equivalent archive was found across five different franchises with five + different fan communities of very different sizes. +- **[GAP]** Real starburst acoustic character (Farscape) — the visual and + mechanical description is well documented; no sound-specific source was + found. +- **[ATT]** Whether "singing" for the Vorlon ships was ever given specific + acoustic character on screen (pitch, harmony, language-like quality) versus + being a narrative description characters use — the sourced material + confirms the word is used, not what it actually sounds like. + +### Where to verify (Bioships) + +- *Farscape Encyclopedia Project* (`farscape.fandom.com`) has the O'Bannon + design-brief quote and is the strongest single source found in this pass. +- Direct episode audio is the only realistic source for the Vorlon "singing" + character, the Lexx's ambient drone, and the Wraith hive ship's interior + tone — no written source located describes any of them acoustically. +- *Stargate* wikis (`stargate.fandom.com`, GateWorld) are strong on Wraith + hive ship biology and constraints but do not cover sound production. +- Memory Alpha's Species 8472 coverage is comparatively thin; a production + interview specific to that episode arc ("Scorpion") may exist but was not + located here. + +--- + +# Space Stations + +Like Bioships, "Space Stations" is this app's own grouping, not a franchise — +six stations from five unrelated sources: *Babylon 5*, *Space: 1999* +(Moonbase Alpha), *Alien: Isolation* (Sevastopol Station), *The Expanse* +(Tycho and Ceres — two presets, one franchise), and *Aliens*, 1986 (Gateway +Station). Two of the six — Sevastopol and the Belter stations — turned out to +have unusually strong, specifically-attributable production documentation; +this is the best-sourced non-Trek section in this document as a result. + +**Nothing here proposes removing an existing element.** As with Bioships, the +soundboard reuses borrowed elements rather than anything station-specific; see +the relabeling table below, which also surfaces a duplication worth knowing +about regardless of any real-world research. + +## Layer mapping (Space Stations-specific) + +| Real concept | App layer | Notes | +| --- | --- | --- | +| Hull / structural rumble, rotation | `hull` (`HullDroneSynth`) | For rotating stations this is a **centrifugal** rumble, not an engine — a structurally different real-world source than every other universe in this app. | +| Docking, tram transit, mechanical systems | `warp` (`WarpCoreSynth`) | Reused as a rhythmic-pulse layer standing in for docking clamps and tram/transit systems rather than any propulsion. | +| Air handling, decompression risk | `lifeSupport` (`LifeSupportSynth`) | The most literal, well-matched reuse of any layer in this app — stations really do run on continuous mechanical air handling, and two of the six presets (Sevastopol, Gateway) name it directly in their own descriptions. | +| PA announcements, commerce-hub chatter | `telemetry` (`TelemetrySynth`) | Currently just borrowed Trek eras — see the config table below. | +| Docking clamps, trams, PA beats, plaza ambience | soundboard, relabeled elements | No station-specific synthesis exists; see below. | + +## Station-by-station + +### Babylon 5: Core Control & Zocalo + +**[DOC]** The station is a 5-mile, self-sufficient O'Neill-cylinder-style +habitat. Unlike Gerard O'Neill's original counter-rotating-cylinder concept, +Babylon 5's cylinders rotate about the *same* axis while still maintaining zero +net angular momentum — a specific, citable engineering detail — and the +station draws power from fusion reactors rather than the solar collection +O'Neill's original design assumed. + +**[GAP]** No production sound-design source located for the station's actual +audio identity or for the Zocalo (its central commerce plaza) specifically. + +*Covered by:* preset `sta-babylon-5` — the lowest `warp` bpm outside Sevastopol +(34) standing in for slow centrifugal rotation rather than any engine rhythm, +heavy noise mix (0.6) for structural mass, `telemetry.era: 'ds9'` (a Cardassian +sensor tone for a station that has nothing to do with Cardassia — the same +Trek-borrowing pattern documented in `telemetry_elements.md`). + +### Moonbase Alpha: Main Mission Control + +**[DOC]** Moonbase Alpha is a **static lunar surface installation**, not a +rotating station — it's a four-kilometer, self-sustaining complex in the +Moon's Plato crater, kept habitable by four nuclear reactors, solar +supplementation, and **eight anti-gravity towers** rather than spin gravity. +Water comes from recycled subsurface ice; food from hydroponics and +biochemical synthesis; sections connect via pressurized travel tubes. Main +Mission itself is a large multi-level control room (relocated underground to a +smaller room in the show's second season for added protection). + +**[GAP]** No production audio-design source located. + +**Design nuance worth flagging:** Moonbase Alpha is the *one* preset in this +category that structurally has no rotation to rumble with — everything else +here spins. Its current `hull`/`warp` parameters (54 Hz base, 44 bpm) don't +distinguish it from the rotating stations in any deliberate way; there is +nothing wrong with the numbers, but the *reason* for them (reactor and +travel-tube hum, not spin) isn't represented by anything the other presets +don't also have. + +*Covered by:* preset `sta-moonbase-alpha` — `telemetry.era: 'tos'` is the one +telemetry choice in this category that actually lines up with real production +history: Space: 1999 (1975) and TOS (1966) are close contemporaries in +broadcast-era electronic sound design, so borrowing TOS's warble for a 1970s +lunar base is coincidentally more defensible than any other era-borrowing case +in this document. + +### Sevastopol Station: Habitation Deck (Alien: Isolation) + +**[DOC]** This is the best-documented station in the entire app. Audio +director **Jeff van Dyck** was given access to the original 1979 *Alien* +film's sound effects and rebuilt them with modern technology for continuity +across the franchise. The team hired **Pinewood Studios** to record bespoke +foley rather than relying on effects libraries — including stamping on +different surfaces, "including soil covered in assorted squishy vegetables," +to capture movement across the station's varied environments, from the +sterile medical bay to the alien nest. + +**[DOC]** The station's decay is conveyed through baseline ambient sound +described directly as **"creaking and sparking and shuddering,"** and — a +specific, transferable technique — that ambience is **dynamically ducked** +when the threat is close, so the player becomes more aware of the protagonist's +own breathing and footsteps instead. This is a mixing decision, not a synthesis +one, but it's directly relevant to how any real "Sevastopol" preset ought to +behave if this app ever added activity-linked ambience ducking. + +*Covered by:* preset `sta-sevastopol` — brown noise in `lifeSupport` (the +darkest available), high `hull` noise mix (0.65), `pulseShape: 'nx'` for a +mechanical rather than musical pulse. These choices land close to "creaking and +shuddering" in spirit. What's absent is any **spark** element — a short, +transient, high-frequency event distinct from the continuous creak-and-shudder +bed — and the **dynamic ducking** behavior, neither of which this app's +architecture currently supports for any universe. + +### Tycho Station: Asteroid Construction Bay & Ceres Station: Sub-Crustal Tunnels (The Expanse) + +**[DOC]** Supervising sound editor **Nelson Ferreira** (with sound designers +Nathan Robitaille, then Dave Rose) built *The Expanse*'s audio around a +deliberate **"low-tech it"** philosophy — technology 200 years from now that +still sounds like it evolved from the present, not like generic futurism — and +assigned each faction a distinct sonic signature: **Earth** is heavy and +bass-rich, **Mars** is clean and precise with tight servo sounds, and +**Belters** — the people who live on Tycho and Ceres — get **"broken, creaky +mechanisms suggesting jury-rigged technology."** + +That last detail is directly and specifically actionable: it's a citable, +named-production description of exactly what these two presets should sound +like, not an inference from watching. Both Tycho and Ceres are Belter +stations; both should sound *maintained by scavenging*, not engineered. + +*Covered by:* preset `sta-tycho` (`noiseMix: 0.55`, `pulseShape: 'defiant'`, +construction-bay framing) and `sta-ceres` (`noiseMix: 0.62`, brown noise, +subterranean framing). Both lean appropriately noisy and mechanical rather than +clean — broadly consistent with "jury-rigged" even without the app having a +dedicated "broken/creaky" telemetry vocabulary to complete the picture (see the +gap list). + +### Gateway Station (Aliens, 1986) + +**[DOC]** Gateway is a massive geosynchronous station above Quito, Ecuador, +built from advanced plastics and titanium composite in modular sections joined +by steel beams — Earth's hub for interstellar cargo, personnel transfer, the +Colonial Marine Corps, and an aerospace training school. Production designer +Peter Lamont built it from matte paintings and miniature models, reusing parts +from the Nostromo refinery model; Syd Mead and Robert Skotak contributed to its +design. In the film it's where Ripley wakes from 57 years of hypersleep, +recovers in the infirmary, and faces her inquiry — corridors, medical bays, and +an atrium with environmental screens showing Earth. + +**[GAP]** No dedicated sound-design source was located for Gateway +specifically; the film's overall industrial sound design is well documented +in general Alien-franchise coverage, but nothing ties a specific technique to +this station rather than to the Sulaco or LV-426 colony. + +*Covered by:* preset `sta-gateway` — the lowest `noiseMix` of any station +preset (0.5) and a relatively bright 46 Hz base, consistent with a station +description that emphasizes clean transfer/quarantine functions ("high-volume +environmental air handlers") over the grime of Sevastopol or the Belt. + +## Existing element / soundboard relabeling + +Same pattern as Bioships: the `spacestations` soundboard calls no +station-specific synthesis. All five buttons relabel existing elements from +two other universes' classes: + +| Soundboard label | Actual method called | Real element | +| --- | --- | --- | +| AIR HANDLER THUD | `expandedAudio.synthesizeDockingClamp` | Shared 1:1 with the button below — see note | +| DOCKING CLAMP LATCH | `expandedAudio.synthesizeDockingClamp` | Same docking-clamp synthesis, different label | +| TRAM DEPARTURE GONG | `whoniverseAudio.synthesizeCloisterStrike` | One strike of the Doctor Who cloister bell | +| COMM PA BEAT | `whoniverseAudio.synthesizeDematSwitch` | Doctor Who console relay click | +| ZOCALO PLAZA CHIME | `whoniverseAudio.synthesizeTelepathicChime` | Doctor Who telepathic circuit chime | + +**Fixed.** Unlike every other cross-universe borrowing in this document, AIR +HANDLER THUD and DOCKING CLAMP LATCH were calling the **exact same method** +and producing **the literal identical sound** under two different labels on +the same soundboard — the one true duplicate button mapping found anywhere in +the app during this documentation effort. `AIR HANDLER THUD` now calls a new +`ExpandedSciFiAudioSynth.synthesizeAirHandlerThud()`: a dull triangle-wave +thump with a sub-octave body (no bright metallic impact) followed by a +slow-building lowpass-filtered airflow whoosh — a big soft mechanical event +that keeps breathing after it, rather than the docking clamp's single hard +square-wave latch and bright pneumatic hiss. `DOCKING CLAMP LATCH` is +unchanged and still calls `synthesizeDockingClamp()`. + +## Space Stations existing element retention map + +| Existing (relabeled) element | Verdict | +| --- | --- | +| Air Handler Thud (now `synthesizeAirHandlerThud`) | **Fixed** — no longer shares a method with Docking Clamp Latch; the two are now audibly distinct | +| Docking Clamp Latch (`synthesizeDockingClamp`) | Keep — unchanged | +| Tram Departure Gong (cloister strike) | Keep — a single deep bell strike is a defensible generic "departure" cue, though it carries no tram-specific character | +| Comm PA Beat (demat switch) | Keep — a 60ms click reused as a PA beep; functionally fine, sonically generic | +| Zocalo Plaza Chime (telepathic chime) | Keep — a shimmering four-note arpeggio for a bustling commerce plaza is the largest tonal mismatch of the five: Babylon 5's Zocalo is a defensible **[DOC]** description, and this element (built for a psychic time machine circuit) has no plaza connection at all | + +## Space Stations gap list + +| # | Sound | Why it matters | Difficulty | +| --- | --- | --- | --- | +| ~~1~~ | ~~Distinct air-handler vs. docking-clamp sounds~~ | **Fixed** — `AIR HANDLER THUD` now calls its own `synthesizeAirHandlerThud()` (dull thump + slow airflow whoosh) instead of sharing `synthesizeDockingClamp()`. | Done | +| 2 | **Belter "jury-rigged" telemetry era** | The single most specific, most directly citable production detail found in this whole reference ("broken, creaky mechanisms") has no corresponding discrete-sound vocabulary — Tycho and Ceres currently borrow Voyager/NX Trek telemetry instead of anything broken or creaky. | Medium | +| 3 | **Sevastopol "spark" transient** | Van Dyck's ambience is explicitly creak **and spark** and shudder; this app's Sevastopol preset covers creak/shudder territory via noise and filtering but has no discrete spark event at all. | Low | +| 4 | **Zocalo / commerce-plaza chatter** | A crowd-and-chatter texture (paralleling the Deep Space Nine Promenade gap noted in the Star Trek section) with no equivalent anywhere in the app — public gathering spaces are structurally different from every machinery-dominated preset. | Medium–High | +| 5 | **Rotation-vs-static distinction for Moonbase Alpha** | Not a missing sound so much as a missing *reason*: nothing currently separates "spinning station rumble" from "static lunar base with anti-gravity towers" even though they are physically different phenomena in the source material. | Low (parameter tuning, not new synthesis) | + +Item 1 is close to a one-line fix and should probably happen regardless of any +broader redesign; item 2 has the same "biggest single win" shape as the +Whoniverse and Bioships telemetry gaps, and is unusually well-supported by a +named, quoted production source. + +## Space Stations open questions + +- **[GAP]** No dedicated sound-design source was found for Babylon 5, Moonbase + Alpha, or Gateway Station specifically — the Babylon 5 and Moonbase Alpha + assessments rest on engineering/production facts (rotation physics, reactor + count), not on how anyone built the audio. +- **[GAP]** Whether Alien: Isolation's per-area foley (medical bay vs. alien + nest, per van Dyck) implies the station's ambience genuinely varies by room + in-game, the way this app's single `sta-sevastopol` preset cannot — the + source confirms *foley* varied by surface, not that the *ambient bed* + itself changes by room. +- **[GAP]** Whether "low-tech it" as a philosophy has any further specifics + published for Ceres vs. Tycho individually, beyond the shared Belter + signature — the source found treats Belter technology as one category, not + two distinct stations. +- **[ATT]** Gateway Station's minimal foley identity in the film itself (a + brief early sequence) means it's the thinnest-sourced entry in this + category by nature of the material, not by research gap. + +### Where to verify (Space Stations) + +- **A Sound Effect** (`asoundeffect.com`) carries a full named-interview + breakdown of *The Expanse*'s sound team and philosophy — the strongest + single production source in this entire document. +- **PC Gamer**'s "The audio of Alien: Isolation" and **Audio Media + International**'s dedicated coverage both name Jeff van Dyck directly and + describe concrete foley technique — the second is worth reading in full for + detail not captured here. +- *AVP Central* is a strong resource for Alien-franchise station lore + (Sevastopol, Gateway) but does not cover sound production. +- No equivalent named-interview source was found for Babylon 5 or Space: 1999 + audio specifically; direct episode audio remains the only way to close those + two entries. diff --git a/telemetry_elements.md b/telemetry_elements.md new file mode 100644 index 0000000..e33eb39 --- /dev/null +++ b/telemetry_elements.md @@ -0,0 +1,343 @@ +# Telemetry Audio Elements + +Catalog of the discrete telemetry sounds the application produces. All of them +live in `TelemetrySynth` (`js/audio.js`, ~line 715) and are **synthesized at +runtime** from Web Audio oscillators, envelopes and filters — there are no +samples anywhere in this project. + +Telemetry is the "computer chatter" layer: short, non-musical-foreground blips +that sit on top of the continuous hull / warp / life-support beds. It is the +only audio layer with a **density** parameter, because it fires as discrete +events rather than running continuously. + +--- + +## Element catalog + +| Element | Method | Description | Where used | +| --- | --- | --- | --- | +| **LCARS Single Chirp** | `synthesizeLCARSSingleChirp(pitch?)` | Soft sine touch tone, ~90 ms, gentle 8 ms attack into exponential decay, with a subtle 2% downward pitch glide and a 3.2 kHz lowpass to kill the click. Accepts an optional pitch, which makes it the building block for the other LCARS elements. | TNG auto-scheduler (45%), DS9 (50%), soundboard `btn-chirp-single` / `btn-switch-pop`, preset-change confirmation while playing, **default fallback** for any unmapped soundboard or event id | +| **LCARS Double Chirp** | `synthesizeLCARSDoubleChirp()` | Two single chirps 65 ms apart, second pitch two steps up the LCARS scale (minor third / fourth). The iconic "acknowledged" tone. | TNG auto-scheduler (30%), Voyager (60%), soundboard `btn-chirp-double`, **engage/play confirmation** for non-Whoniverse, non-bioship universes | +| **LCARS Acknowledgment Sequence** | `synthesizeLCARSSequence()` | Three chirps at 75 ms spacing drawn from overlapping windows of the LCARS scale — reads as a short data-accept run rather than a single button press. | TNG auto-scheduler (15%), soundboard `btn-chirp-ack` | +| **Sensor Sweep** | `synthesizeSensorSweep()` | 380 ms sine glide from 1200–2000 Hz, ramping either up ×1.6 or down ×0.65 at random. Longer and more "scanning" than the chirps. | TNG auto-scheduler (10%), Voyager (40%), soundboard `btn-chirp-sweep`, layered under `btn-scanner` | +| **TOS Computer Warble** | `synthesizeTOSWarble()` | 450 ms of detuned triangle + sawtooth (fundamental and its fifth) under a 14–22 Hz vibrato LFO, through a Q=3.5 bandpass at 1.2× the fundamental. Vintage 1960s analog computer voice. | TOS auto-scheduler (60%) | +| **TOS Relay Click** | `synthesizeTOSRelayClick()` | 25 ms square wave falling 1400 → 300 Hz. A mechanical solenoid snap, not a tone. | TOS auto-scheduler (40%) | +| **Cardassian Sensor Tone** | `synthesizeCardassianSensor()` | 550 ms pair of sines a tritone apart (×1.414) for deliberate metallic dissonance, base 420–620 Hz. Cavernous, alien, unresolved. | DS9 auto-scheduler (50%) | +| **NX Hydraulic Relay** | `synthesizeNXRelay()` | 40 ms triangle falling 750 → 120 Hz. Heavier and duller than the TOS click — 22nd-century industrial rather than mid-century electrical. | NX auto-scheduler (50%) | +| **NX Indicator Beep** | `synthesizeNXIndicatorBeep()` | 80 ms 950 Hz sine with a flat sustain plateau and a fast tail. Plain and utilitarian; no glide, no scale membership. | NX auto-scheduler (50%) | +| **TNG Door Chime** | `synthesizeDoorChime()` | Two overlapping sines, A5 (880 Hz) then D6 (1174.66 Hz) at +160 ms, each with a long 450–700 ms decay. The "come in" chime. | Soundboard `btn-chirp-door` only — **never** fired by the auto-scheduler | + +--- + +## The auto-scheduler + +`startAutoTelemetryScheduler()` self-reschedules after every sound: + +``` +baseDelay = 12000 * (1.05 - density) // ms +interval = max(800, baseDelay + random(0..4000)) +``` + +- `density` 0 → scheduler does not arm at all (`<= 0.01` returns early). +- `density` 1 → ~0.6 s base + jitter, floored at 800 ms — a busy bridge. +- Mid values land in the 2–12 s range the parameter comment describes. +- Density comes from the active preset's `telemetry.density` and is also live + on the `slider-telemetry-density` UI control. + +### Era routing + +`playRandomTelemetrySound()` picks an element by `params.era`. Each era draws +from **exactly two or four** elements — this is the table to extend when adding +an era or a new element. + +| Era | Elements and weights | +| --- | --- | +| `tng` (also the default) | Single Chirp 45% · Double Chirp 30% · Acknowledgment Sequence 15% · Sensor Sweep 10% | +| `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| `tos` | Computer Warble 60% · Relay Click 40% | +| `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| `nx` | Hydraulic Relay 50% · Indicator Beep 50% | + +Note the era is a **telemetry** property, not a universe property: it is set +per preset in `js/config.js` (`telemetry.era`) and is independent of the +universe that governs the visuals. In practice every preset in every universe +uses one of these five Trek eras, so non-Trek themes play Trek telemetry — see +[Preset → telemetry era reference](#preset--telemetry-era-reference) for the +full per-preset mapping. + +--- + +## The activity event contract + +Every scheduled telemetry sound dispatches a window event **after** it plays: + +```js +window.dispatchEvent(new CustomEvent('scifi-telemetry-activity', { + detail: { era, density, firedAt } +})); +``` + +`js/app.js` listens for this and calls `triggerObservationActivity('telemetry')`, +which is what synchronizes observation-mode transient visuals to the audio. +The visual side deliberately treats the pulse as **abstract activity** — no +specific beep means a specific thing on screen. The observation activity slider +then gates whether a given pulse becomes visible: + +``` +responseChance = 0.18 + activity * 0.82 +``` + +Important: only `playRandomTelemetrySound()` dispatches the event. Elements +triggered directly (soundboard, play/stop, preset change) are silent as far as +the visual layer is concerned. If a new element should drive visuals, it must +either be reachable through the era router or dispatch the event itself. + +--- + +## Pitch material + +Two fixed scales are the source of all pitched telemetry. Reuse them rather +than inventing loose frequencies — they are what makes the layer sound coherent. + +- **`lcarsPitches`** — 12 entries, 880 Hz → 2637 Hz, a pentatonic-flavored set + built on perfect fourths and fifths. Used by every LCARS element. +- **`tosFrequencies`** — 9 entries, 440 Hz → 2217 Hz, wider and more angular, + matching the era's less "designed" sound. + +Unpitched elements (relay clicks, sweeps, the Cardassian tone) generate their +own frequencies and intentionally sit outside both scales. + +--- + +## Signal-chain conventions + +Every element follows the same shape. Match it when adding one: + +1. Bail out early on `!ctx || !this.gainNode`. +2. Capture `const now = ctx.currentTime` once and schedule everything relative + to it — never use `setTimeout` for sample-accurate timing (it is used only + for the deliberate multi-note spacing in the double chirp and sequence). +3. Build oscillator → per-voice envelope gain → optional filter → `this.gainNode`. +4. Envelopes start at `0.001`, `linearRampToValueAtTime` up, then + `exponentialRampToValueAtTime` down to `~0.0001`. Never ramp exponentially + to or from exactly zero. +5. Peak envelope gain stays in the **0.18–0.35** band so no element dominates + the bed. Clicks sit at the top of that range because they are so short. +6. Explicitly `stop()` every node at the end of its life. Nothing here loops. + +`this.gainNode` carries the layer volume and connects to `am.compressor`, so +individual elements should never touch master volume or the destination. + +--- + +## Related but not telemetry + +Short event sounds for non-Starfleet universes live in sibling classes and are +routed by `handleSoundboardTrigger()` in `js/app.js`. Check these before adding +a new telemetry element — the sound you want may already exist: + +- **`WhoniverseAudioSynth`** — sonic screwdriver, fast return, demat switch, + telepathic chime, cloister strike. +- **`ExpandedSciFiAudioSynth`** — DRADIS ping, HAL chime, Geiger burst, docking + clamp, improbability flip, cheerful door, and the various drive effects. +- **`AlertSynth`** — red/yellow alert cycles and the warp jump swell. Alerts are + a separate layer with its own trigger path, not telemetry. **They are not + purely user-triggered:** in Observation Mode the cinematic director's Hull + Breach sequence calls `alerts.triggerRedAlert('tng')` on its own, roughly + 6.5 s before restoring the previous state. It is in the sequence pool for the + `military`, `outlaw` and `industrial` universes and fires on a random timer + (45–75 s for the first sequence, 90–180 s between later ones). This is the + only non-telemetry sound in the app that can start without user action. + +Preset changes and play/stop pick between these three families by +`activeUniverseId` — see `js/app.js` around lines 305–365. + +--- + +## Adding or reusing an element + +Reuse an existing element when the need is **generic UI feedback** — the Single +Chirp and Double Chirp are explicitly the neutral confirm/acknowledge sounds and +are already the fallbacks for unmapped ids. Reaching for them costs nothing. + +Invent a new element when the sound must carry **era or universe identity** that +no existing element has. In that case: + +1. Add the method to `TelemetrySynth` following the signal-chain conventions. +2. Give it a doc comment naming the era and the physical thing it imitates — + every existing element has one. +3. Wire it into the `playRandomTelemetrySound()` era switch with an explicit + probability, or leave it manual-only (like the Door Chime) if it is too + characterful to fire unattended. +4. If it is manual-only, add a soundboard case in `handleSoundboardTrigger()` + and a button in `index.html`. +5. Reuse `lcarsPitches` / `tosFrequencies` if the element is pitched and belongs + to those eras. +6. Keep the duration under ~600 ms. This is an ambient layer; anything longer + reads as a foreground event and belongs in `AlertSynth` instead. + +--- + +## Preset → telemetry era reference + +`telemetry.era` is assigned **per preset**, and it is the only thing that +decides which elements the auto-scheduler fires. It is *not* derived from the +universe. All 70 presets across all 10 universes currently use one of the five +Star Trek eras, so non-Trek universes play Trek telemetry: a retrofuture profile +can chatter in TOS warbles, and a living-ship profile can ring with the +Cardassian sensor tone. If you hear a sound that seems foreign to the theme you +are on, this table is where to look it up. + +| Era | Elements fired | +| --- | --- | +| `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | + +### Starfleet Command (10 presets — ds9 ×2 · nx ×1 · tng ×3 · tos ×2 · voyager ×2) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| Enterprise-D: Main Bridge | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| Enterprise-D: Main Engineering | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| Enterprise-D: Crew Quarters | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| USS Voyager: Bridge | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| USS Voyager: Class-9 Warp Core | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| USS Defiant: Tactical Bridge | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| Deep Space 9: Ops Center | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| Enterprise NCC-1701: Bridge (TOS) | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Enterprise NCC-1701: Engineering (TOS) | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Enterprise NX-01: Command Bridge | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | + +### The Whoniverse (7 presets — ds9 ×1 · nx ×1 · tng ×2 · tos ×1 · voyager ×2) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| 1963 Classic Console (1st / 2nd Doctors) | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Victorian Secondary Console (4th Doctor) | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| The Coral Living TARDIS (9th / 10th Doctors) | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| The Copper Workshop (11th Doctor) | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| The Cold Machine (12th Doctor) | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| The Singing Crystal Console (13th Doctor) | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| The Infinite White (14th / 15th Doctors) | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | + +### Industrial (8 presets — ds9 ×1 · nx ×3 · tos ×3 · voyager ×1) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| USCSS Nostromo: Ore Refinery Bridge | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Serenity: Firefly-Class Cargo Hold | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Red Dwarf: Main Drive Corridor | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Starbug 1: Cockpit Environment | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| The Raza: Dark Matter Bridge | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| Rocinante: Combat Ops & Epstein Drive | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| Eagle Transporter: Command Module | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Valley Forge: Agro-Dome Forest Hub | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | + +### Bioships (6 presets — ds9 ×1 · nx ×2 · tng ×1 · voyager ×2) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| Moya: Leviathan Central Nexus | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| Talyn: Gunship Neural Bridge | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| The Lexx: Primary Organ Bridge | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Vorlon Cruiser: Sentient Core | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| Wraith Hive Ship: Throne Chamber | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Species 8472: Fluidic Bioship | `voyager` | Double Chirp 60% · Sensor Sweep 40% | + +### Retrofuture (8 presets — nx ×2 · tng ×2 · tos ×3 · voyager ×1) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| Jupiter 2: Upper Deck Astrogator | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| The Liberator: Zen Flight Bridge | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| USS Cygnus: Victorian Engine Hall | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| USS Palomino: Deep Research Pod | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Discovery One: Habitation Centrifuge | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| Dark Star: Bomb Bay & Quarters | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Gunstar: Tactical Combat Cockpit | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| The Searcher: 25th Century Flagship | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | + +### Military (7 presets — ds9 ×3 · nx ×1 · tng ×1 · voyager ×2) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| USS Sulaco: Conestoga Hangar Deck | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Battlestar Galactica: Combat Information Center (CIC) | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| White Star: Minbari/Vorlon Hybrid Bridge | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| EAS Agamemnon: Omega Destroyer Bridge | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| Andromeda Ascendant: Command Deck | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| Excalibur: Victory-Class Heavy Combat Core | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| Colonial Viper Mk II: Cockpit Atmosphere | `voyager` | Double Chirp 60% · Sensor Sweep 40% | + +### Deep Space (7 presets — ds9 ×2 · nx ×2 · tng ×2 · voyager ×1) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| The Avalon: Interstellar Cruise Concourse | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| The Nightflyer: Telepathic Corridor | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| USS Ascension: Generation Ship Promenade | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Ark One: Evacuation Ark Bridge | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| Icarus II: Solar Shield Core | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| Event Horizon: Gravity Singularity Core | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Lewis & Clark: Rescue Cutter Bridge | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | + +### Outlaw (6 presets — ds9 ×1 · nx ×1 · tos ×2 · voyager ×2) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| The Betty: Salvage Freighter Mess | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Bebop: Living Quarters & Hangar | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Outlaw Star: Grappler Bridge | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| Scorpio: Wanderer Salvage Vessel | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| The Marauder: Havoc Shuttle Bridge | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| The Milano: M-Ship Cockpit Lounge | `voyager` | Double Chirp 60% · Sensor Sweep 40% | + +### Space Stations (6 presets — ds9 ×1 · nx ×2 · tng ×1 · tos ×1 · voyager ×1) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| Babylon 5: Core Control & Zocalo | `ds9` | Cardassian Sensor 50% · Single Chirp 50% | +| Moonbase Alpha: Main Mission Control | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | +| Sevastopol Station: Habitation Deck | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Tycho Station: Asteroid Construction Bay | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| Ceres Station: Sub-Crustal Tunnels | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Gateway Station: Quarantine Transfer Deck | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | + +### Comedy (5 presets — nx ×1 · tng ×2 · tos ×1 · voyager ×1) + +| Preset | Era | Elements heard | +| --- | --- | --- | +| USS Orville: Command Bridge | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| NSEA Protector: Beryllium Engine Room | `voyager` | Double Chirp 60% · Sensor Sweep 40% | +| Heart of Gold: Improbability Bridge | `tng` | Single Chirp 45% · Double Chirp 30% · Ack Sequence 15% · Sensor Sweep 10% | +| HMS Camden Lock: Flight Deck & Mess | `nx` | NX Hydraulic Relay 50% · NX Indicator Beep 50% | +| Spaceball One: Mega-Warship Bridge | `tos` | TOS Computer Warble 60% · TOS Relay Click 40% | + +### Notes on the spread + +- **Starfleet** is the only universe where the era always matches the fiction. +- **Whoniverse** spans all five eras across its seven presets — the widest + spread of any universe, and the most likely place to hear something + unexpected. +- **Retrofuture** leans on `tos` (3 of 8), which is at least era-plausible for + mid-century-styled ships, but `Discovery One` and `The Searcher` are on full + TNG LCARS. +- **Bioships** has no acoustic relationship to any Trek era; `Talyn` on `ds9` + produces the Cardassian tritone tone inside a living-ship profile. +- **Comedy** and **Spaceball One** on `tos` produce the analog warble. + +Two consequences worth keeping in mind when editing: + +1. Changing a preset's `telemetry.era` silently changes which sounds it makes. + There is no UI anywhere that names the active era, so the change is audible + but not visible. +2. Any era value outside the five above falls through the `switch` in + `playRandomTelemetrySound()` to the `tng` default. A typo does not fail + loudly — it just produces LCARS chirps. + +If a universe should stop borrowing Trek telemetry, the fix is a new era +vocabulary (new elements plus a new `case` in the era router), not a change to +these preset assignments alone — the router has nothing else to offer them. diff --git a/visual_elements.md b/visual_elements.md new file mode 100644 index 0000000..35f42c5 --- /dev/null +++ b/visual_elements.md @@ -0,0 +1,322 @@ +# Observation Visual Elements + +Catalog of the animated visual elements Observation Mode produces. Everything +here is drawn procedurally at runtime — canvas 2D in `js/observation-engine.js`, +inline SVG generated in `js/app.js` and `js/observation-bezels.js`, and CSS +keyframes in `css/style.css`. There are no image assets in this project. + +--- + +## The two rendering paths + +Observation Mode composites **two independent stacks**, and which one a universe +uses is the single most important fact when deciding where a new element goes. + +| | Canvas path | SVG path | +| --- | --- | --- | +| Owner | `ObservationEngine.render()` | `getObservationSvg()` / `buildObservationSceneMarkup()` in `js/app.js` | +| DOM node | `#observation-canvas` | `#observation-stage` (art) + `#observation-sim-layer` (motion), z-index 1 and 2 | +| Motion | per-frame in a `requestAnimationFrame` loop | CSS animations + JS spline morphing | +| Used by | universes with `canvas: true` | every universe, but it is the *only* path for `canvas: false` ones | + +`OBSERVATION_CANVAS` (top of `js/observation-engine.js`) is the manifest. A +universe not declared `canvas: true` draws nothing on the canvas at all — three +separate barriers enforce this (`canvasEnabled()` in `render()`, canvas +`visibility: hidden` in `start()`, and the stage SVG's own opaque background +rect). Its observation display is entirely bespoke SVG art. + +### Layer declarations + +| Layer | starfleet | whoniverse | deepspace | spacestations | others | +| --- | :-: | :-: | :-: | :-: | :-: | +| `celestial` | ● | ● | | | | +| `starfield` | ● | | ● | | | +| `deepSpaceField` | | | ● | | | +| `constellations` | | | ● | | | +| `shootingStars` | | | ● | ● | | +| `stationPanorama` | | | | ● | | +| `traffic` | | | | ● | | +| `events` | | | ● | ● | | +| `reticles` | | | | ● | | +| `cinematics` | | | | ● | | +| canvas enabled | ● | ● | ● | ● | — | + +`industrial`, `bioships`, `retrofuture`, `military`, `outlaw` and `comedy` are +all `canvas: false` with no layers. + +**Dormant layer:** `renderNebulae()` exists and is fully implemented with a +per-universe palette, but no universe currently declares `nebulae`. It is +available for reuse without new code. + +Draw order in `render()` is fixed and should not be reshuffled: +stationPanorama → nebulae → celestial → deepSpaceField → starfield → +constellations → shootingStars → traffic/events → reticles. + +--- + +## Canvas celestial elements + +| Element | Where drawn | Description | Where used | +| --- | --- | --- | --- | +| **Time Vortex** | `renderCelestialObjects()` | 14 nested ellipses receding on a z-cycle, alternating cyan and gold strokes, counter-rotating by ring parity. Deliberately drawn on pure black with no starfield. | `whoniverse` | +| **Class-M Planet** | `renderCelestialObjects()` | Large body with a Rayleigh-scattering atmosphere halo, a radial terminator gradient, and a bright rim arc on the lit limb. | `starfleet` | +| **Orbiting Moon** | `renderCelestialObjects()` | Small grey disc on a squashed ellipse (×1.7 / ×0.6) around the Class-M planet, with an offset shadow disc. | `starfleet` | +| **Deep-Space Planet** | `regenerateDeepSpaceField()` / `renderDeepSpaceField()` | 2–4 per session from 6 distinct world palettes (gas giant, ice world, ion-storm giant, seismic rocky, biosignature rocky, frozen dormant). 50% chance of rings, 0–2 moons each, independent slow drift. | `deepspace` | +| **Nebula Cloud** | `renderDeepSpaceField()` | 3–5 soft morphing blobs, radius 220–620 px, alpha 0.035–0.09, each with its own drift, pulse and morph seed. | `deepspace` | +| **Dust Lane** | `renderDeepSpaceField()` | 4–7 thin angled streaks, 260–620 px long, for texture distinct from the soft blobs. | `deepspace` | +| **Derelict Silhouette** | `renderDeepSpaceField()` | 70% chance of 1–2 segmented megastructure hulks with a blinking beacon and a `DERELICT // NO SIGNAL` callout. | `deepspace` | +| **Anomaly** | `renderDeepSpaceField()` | 1–2 per session, kind `lensing` or `radiation`, each gated behind its own random `minActivity` threshold of 0.15–0.55. | `deepspace` | +| **Computer Callout** | `drawComputerCallout()` | Shared annotation primitive: scanning bracket, periodic ping ring, leader line and label. Attached to every planet, derelict and anomaly. | `deepspace` | +| **Generic Nebulae** | `renderNebulae()` | 4 ambient morphing blobs with a per-universe palette (`getNebulaPalette()` covers whoniverse, industrial, bioships and a default). | *dormant — no universe declares it* | + +### Station panorama elements + +The `spacestations` sky is a **360° panorama in azimuth/elevation space**, not a +forward-motion starfield: the station rotates in place, one full revolution every +240 s, with a ~22% field of view. Generated once per page load. + +| Element | Description | +| --- | --- | +| **Panorama Starfield** | 900 stars around the full circle, power-curve size distribution (mostly pinpricks), per-star twinkle speed and phase. | +| **Panorama Nebulae** | 3–5 patches, own palette, slow pulse. | +| **Panorama Planet** | 1–2 bodies, banded, 45% rings, 0–2 moons, own light angle. | +| **Distant Sun** | One, small radius, warm or cool tint. | +| **Sister Station** | 1–2 far-off structures with optional ring and blinking beacons. | +| **Asteroid Cluster** | 9–18 procedurally faceted rocks (5–8 vertices each) with individual rotation. | + +Major features are **slot-spread** around the circle rather than randomly placed, +so one notable object drifts through the window every so often instead of +everything clumping into one bearing. + +--- + +## Canvas motion elements + +| Element | Method | Description | Where used | +| --- | --- | --- | --- | +| **Parallax Starfield** | `renderStarfield()` | 3D projected star volume with per-universe profile (count, size range, color set, warp-streak flag). Starfleet gets a bespoke calm 150-star cool profile; everything else falls back to `DEFAULT_STARFIELD_PROFILE` (340 stars, 10 colors). | `starfleet`, `deepspace` | +| **Warp Streaks** | `renderStarfield()` + `render()` | In `warp` flight mode stars elongate and the frame clears to a translucent fill instead of solid, producing a motion-blur trail. Station view never smears. | starfield universes | +| **Shooting Star** | `spawnShootingStar()` / `updateShootingStars()` / `renderShootingStars()` | Occasional fast streak with a fading tail. | `deepspace`, `spacestations` | +| **Constellation Lines** | `regenerateConstellation()` / `renderConstellations()` | Faint synthetic point set with connecting lines that fade in, hold, and fade out. Independent of the main starfield. | `deepspace` | +| **Traffic Vessel** | `spawnTraffic()` / `drawShipVessel()` | A ship crossing the frame on a linear path over 12–26 s, with an engine particle trail. 12% chance each of a warp-flash entry and/or warp-jump exit. | `spacestations` (and any universe declaring `traffic`) | +| **Comet** | `spawnEvent('comet')` | 5 s drifting event with lateral velocity. | `deepspace`, `spacestations`, cinematics | +| **Warp Flash** | `spawnEvent('warp-flash')` | 1.4 s stationary flash, also used as the arrival marker for warp-entry traffic. | `deepspace`, `spacestations`, cinematics | +| **Target Reticle** | `renderTargetReticles()` | LCARS corner brackets around each traffic vessel with a leader line and text tag. | `spacestations` | + +### Traffic vessel types + +`drawShipVessel()` draws each by type. `spawnTraffic()` picks the type and label +from the active universe: + +| Type | Label pattern | Universe | +| --- | --- | --- | +| `shuttle` | `SHUTTLE // TYPE-9` | default / starfleet | +| `cruiser` | `USS GIBRALTAR // NCC-#####` | starfleet, military | +| `runabout` | `RUNABOUT YANGTZE // NCC-72452` | starfleet | +| `tardis` | `TYPE 40 TIME CAPSULE // DRIFT` | whoniverse | +| `freighter` | `HEAVY HAULER // CLASS IV` | industrial, outlaw | +| `fighterwing` | `VIPER WING // FLIGHT n` | military | +| `bioshippod` | `SPAWN POD // DRIFTING` | bioships | +| `retrosaucer` | `ATOMIC CRUISER // SAUCER CLASS` | retrofuture | + +Note that most of these universes are `canvas: false`, so their vessel type is +defined but only reachable if `traffic` is later declared for them. The drawing +code already exists. + +--- + +## SVG scene elements + +`buildObservationSceneMarkup(theme)` populates `#observation-sim-layer` with +motion entities over the bespoke stage art. Three builders cover every case: + +| Builder | Motion | Parameters | +| --- | --- | --- | +| `obsFlightMarkup` | Travel along a cubic Bézier | `x0..x3, y0..y3, duration, delay, mode` (`arrival` / `cruise` / `depart`), `scale0→scale1`, `minActivity`, `opacity` | +| `obsOrbitMarkup` | Elliptical orbit | `cx, cy, rx, ry, duration, phase, scale0→scale1`, `minActivity`, `opacity` | +| `obsFloatMarkup` | Bounded drift around a point | `cx, cy, ampX, ampY, duration, phase`, `minActivity`, `opacity` | + +Per-theme casts: + +| Theme | Entities | +| --- | --- | +| `starfleet` | 2 flights (scout arrival, shuttle cruise), 2 orbits (sensor blip, diamond marker) | +| `whoniverse` | 3 orbiting glyphs (◎ ∆ ∞), 2 floating vortex fragments | +| `industrial` | 4 camera-feed-clipped groups: bay drone, 3 embers, airlock tell-tale, corridor lamp — each parented to a clip path matching one feed's picture area | +| `bioships` | 3 flowing particles, 1 orbiting node, 1 breathing membrane | +| `retrofuture` | 2 vector-outline ships, 1 orbiting scope blip, 1 Lissajous figure | +| `military` | 1 four-ship formation, 1 hostile contact, 1 orbiting CAP marker | +| `deepspace` | 1 very slow distant ship, 1 comet, 1 large drifting ringed planet | +| `outlaw` | 1 runner, 1 pursuer, 1 floating needle gauge | +| `spacestations` | 5 clipped entities: arrival, departure, freighter, holding pattern, near pass | +| `comedy` | 1 tour ship, 1 orbiting `?`, 1 tumbling cube | + +**Clipping is a rule, not a detail.** The sim layer sits *above* the stage art +(z-index 2 vs 1), so any entity that should appear inside a viewport, camera feed +or window must be wrapped in a matching `clipPath`. The industrial theme's +comment records exactly what went wrong when entities floated free in screen +space. + +### Spline morphing + +`initializeObservationSplineMorphs()` and `updateObservationSplineMorphs()` +parse the stage SVG's path data and continuously re-target control points, so +static-looking bespoke art breathes. `obsThemeSplineScale(theme)` sets the +per-theme amplitude; `pulseObservationSplineMorphs()` kicks it on activity. + +--- + +## Transient activity effects + +`triggerObservationActivity(source)` spawns short-lived generative overlays. +Each universe has its own six-effect vocabulary: + +| Universe | Effect vocabulary | +| --- | --- | +| `starfleet` | contact, vector, data, ring, diagnostic, streak | +| `whoniverse` | echo, glyphs, coordinate, warp, rings | +| `industrial` | signal, dropout, vapour, motion, gain, warning | +| `bioships` | neural, spores, ripple, tendril, organ, metric | +| `retrofuture` | blip, scope, counter, vector, bloom, reel | +| `military` | contact, intercept, formation, sector, status, sweep | +| `deepspace` | anomaly, comet, lens, spectral, planet, signal | +| `outlaw` | glitch, route, contact, signal, gauge, rear | +| `spacestations` | dock, depart, guidance, traffic, beacon, queue | +| `comedy` | route, oddity, planet, status, contact, geometry | + +Shared primitives every generator can reuse: `obsTextCard()`, `obsContact()`, +`obsExpandingRing()`, plus `obsRand` / `obsInt` / `obsPick` / `obsHex` and the +easing helpers. + +**Two trigger sources:** + +- `'telemetry'` — driven by the `scifi-telemetry-activity` audio event. Gated by + `responseChance = 0.18 + activity * 0.82`, so at low activity many audio + pulses pass silently and at maximum every pulse gets a visible response. +- `'ambient'` — a self-rescheduling timer whose interval curves from 12.5 s at + minimum activity down to 1.5 s at maximum, plus 25–75% jitter. + +Above activity 0.55 (telemetry) and 0.82 (any source) a second and third +concurrent effect can spawn, staggered 120–380 ms apart. + +--- + +## Frame, chrome and post effects + +| Element | Where | Description | Where used | +| --- | --- | --- | --- | +| **Viewport Bezel** | `ObservationBezels.getViewportFrameSvg()` | Procedural SVG window frame, keyed by universe and — for starfleet — by the active preset's *era*, so TOS gets a hexagonal amber bridge bezel and TNG/Voyager get their own. | all except `spacestations`, which has its own native station window in the stage art | +| **Support Pillars** | inside each bezel | Optional vertical struts across the viewport. **Default off.** | bezel universes | +| **Glass Sheen** | `.observation-glass-sheen` | Static specular sheen over the viewport. | all | +| **Scanlines** | `.observation-scanlines` + `updateScanlineBreathing()` | CRT scanline overlay whose opacity breathes with audio energy (base 0.14). | all | +| **Vignette** | `.observation-vignette` | Edge darkening. | all | +| **Alert Wash** | `.observation-alert-wash.alert-red` / `.alert-yellow` | Full-screen color wash driven by `AlertSynth` state via `updateAlertState()`. | all | +| **Camera Wobble** | `cameraWobble` in `update()` | Slow sway of the projection center, so the canvas never feels locked to the frame. | canvas universes | +| **Viewport Vibration** | `updateViewportVibration()` | Short high-frequency shake driven by bass energy and warp pulses. | canvas universes | +| **Lighting Cycle** | `updateLightingCycle()` / `getLightingModifiers()` | A 25-minute ambient cycle that modulates overall brightness and tint — the slowest animation in the app. | canvas universes | +| **Waveform** | `renderWaveform()` | 30-bar HUD spectrum on its own small canvas, colored from the live `--primary-accent` CSS variable. | all | +| **Status Ticker** | `getTickerMessages()` / `updateStatusTicker()` | Scrolling HUD line, per-universe message sets. | all | +| **Cinematic Caption** | `queueCinematicAction('flash-status')` | Transient headline text driven by the cinematic director. | cinematic universes | + +### Cinematic sequences + +`updateCinematicDirector()` fires a timed multi-step sequence every 45–75 s +initially, then 90–180 s after each one completes. + +| Sequence | Steps | Universes | +| --- | --- | --- | +| **First Contact** | comet → unknown vessel + emphasis flyby → hailing frequencies → warp flash | starfleet, deepspace, spacestations | +| **Hull Breach** | warp pulse + stress caption → red alert → damage control → stabilized + alert off | military, outlaw, industrial | +| **Temporal Anomaly** | flux detected → random warp flash → vortex stabilizing | whoniverse | +| **Bio Resonance** | resonant pulse → comet → dissipating | bioships | +| **Close Flyby** | one emphasized flyby (×1.6 scale, ×0.55 duration) | **every** universe | + +Note that Hull Breach reaches into `AlertSynth` — a cinematic can change audio +state, and it restores it in its final step. + +### Reusable CSS animation classes + +Apply these to any SVG element instead of writing new keyframes: + +| Class | Effect | +| --- | --- | +| `.obs-spin` | 18 s linear rotation | +| `.obs-spin-slow` | 42 s linear rotation | +| `.obs-spin-rev` | 28 s reverse rotation | +| `.obs-pulse` | 3.8 s ease-in-out scale pulse | +| `.obs-breathe` | 5.5 s ease-in-out soft swell | +| `.obs-flicker` | 8 s stepped flicker | +| `.obs-blink` | 3.5 s stepped blink | +| `.obs-dashflow` | flowing dash offset (20/12 dasharray, 7 s) | + +All of them set `transform-box: fill-box` and a center origin where relevant. +A `prefers-reduced-motion` block collapses every animation inside +`.observation-overlay` to a single 1 ms iteration. + +--- + +## Cross-cutting rules + +**Coordinate systems — three of them, do not mix:** + +1. **SVG stage space** — a fixed `viewBox="0 0 1600 900"` with + `preserveAspectRatio` slice behavior, so it lands like a CSS `cover` + background. All stage and sim-layer coordinates are in this space. +2. **Canvas normalized space** — deep-space objects store `nx` / `ny` as + screen fractions from the center and are projected each frame, so they + survive window resizes. +3. **Panorama azimuth/elevation** — station features store `a` (0–1 of a full + revolution) and `e` (elevation), projected through `stationProjectX/Y()`. + +`getStationViewportRect()` reproduces the stage SVG's slice math so canvas +objects transit through the *actual* window opening rather than the full canvas. + +**Session-seeded vs. per-frame.** The station panorama and the deep-space field +are generated **once per page load** in the constructor and retained for the +whole session — they survive entering and leaving Observation Mode, preset +switches and window resizes. Only a page reload rolls a new sky. Everything else +(traffic, events, shooting stars, constellations, transients) is spawned and +discarded continuously. Respect this contract: regenerating a seeded field +mid-session is a visible glitch, not a refresh. + +**Placement clearance.** `placeAway()` in `regenerateDeepSpaceField()` biases new +objects away from already-placed ones, measuring in real pixels (converting +normalized offsets through canvas width/height, because a canvas is wider than +it is tall) and requiring clearance from **both** sides of a pair. The station +panorama does the equivalent with even bearing slots. Any new annotated object +must go through the same placement, or its callout will collide with another's. + +**Activity gating.** Nearly every non-essential element carries a `minActivity` +threshold and is skipped when the observation activity slider sits below it. +This is how one scene serves both "calm ambient wallpaper" and "busy bridge". +Give every new element a threshold; 0.05 for things that should almost always be +present, 0.5+ for things that should feel like a rare event. + +--- + +## Reuse or invent + +**Reuse** when the need is structural rather than thematic. The three SVG motion +builders, `drawComputerCallout()`, `obsContact()` / `obsExpandingRing()` / +`obsTextCard()`, the eight CSS animation classes, the existing eight vessel +types, and the dormant `renderNebulae()` layer all cover a wide range of needs +with no new code. Declaring an existing layer for another universe in +`OBSERVATION_CANVAS` is the cheapest possible addition — the drawing code +already runs. + +**Invent** when a universe needs identity no existing element carries. Then: + +1. Decide the path first — canvas or SVG — from the universe's + `OBSERVATION_CANVAS` entry. Putting a canvas element in a `canvas: false` + universe produces nothing at all. +2. For a canvas layer: add it to the manifest, add a `hasLayer()` guard in + `render()` at the right point in the draw order, and never assume a layer is + universal. +3. For an SVG entity: use one of the three builders, give it a `minActivity`, + and clip it if it belongs inside a framed area. +4. Seed anything expensive once and store it on the engine, following the + panorama / deep-space contract. +5. Add a `minActivity` threshold and, for annotated objects, route placement + through the clearance helper. +6. Prefer the existing CSS animation classes over new keyframes, and confirm the + result still reads correctly under `prefers-reduced-motion`.