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 `
- `;
- }
-
- 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 `
+ `;
+ }
+
+ 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`.