diff --git a/js/app.js b/js/app.js index 9215868..3fd33d9 100644 --- a/js/app.js +++ b/js/app.js @@ -301,6 +301,10 @@ document.addEventListener('DOMContentLoaded', () => { if (presetId === 'bio-vorlon') expandedAudio.startVorlonSong(); // #26 if (presetId === 'bio-wraith') expandedAudio.startHullRegen(); // #28 if (presetId === 'deep-event-horizon' || presetId === 'deep-icarus-ii') expandedAudio.startHullStrainMoans(); // #30 + // Phase 3 (audio_gaps.md #46-#54) + if (presetId === 'sta-babylon-5') expandedAudio.startZocaloChatter(); // #46 + if (presetId === 'ind-nostromo') expandedAudio.startTeletypeChatter(); // #51 + if (presetId === 'ret-jupiter-2') expandedAudio.startHeterodyneDrone(); // #54 if (presetId === 'deep-icarus-ii') expandedAudio.startIcarusBeacon(); // #31 if (presetId === 'deep-event-horizon' || presetId === 'deep-nightflyer') expandedAudio.startVoidWhispers(); // #32 if (presetId === 'com-protector') expandedAudio.startBerylliumThrum(); // #33 @@ -935,6 +939,14 @@ document.addEventListener('DOMContentLoaded', () => { case 'btn-teletype': expandedAudio.synthesizeTeletypeChatterPhrase(); break; + case 'btn-zocalo-chatter': + if (expandedAudio.zocaloChatterActive) expandedAudio.stopZocaloChatter(); + else expandedAudio.startZocaloChatter(); + break; + case 'btn-hetero-drone': + if (expandedAudio.heterodyneDroneActive) expandedAudio.stopHeterodyneDrone(); + else expandedAudio.startHeterodyneDrone(); + break; default: telemetry.synthesizeLCARSSingleChirp(); diff --git a/js/audio.js b/js/audio.js index df09d51..d39ee79 100644 --- a/js/audio.js +++ b/js/audio.js @@ -5719,9 +5719,211 @@ class ExpandedSciFiAudioSynth { } } + /** + * Ambient Loop: Continuous Nostromo terminal teletype chatter (#51) + * Dense overlapping "typed line" phrases at a 0.9-2.0 s cadence keep the + * effect continuous while the preset runs. Dies with the loop registry. + */ + startTeletypeChatter(minIntervalMs = 900, maxIntervalMs = 2000) { + this._scheduleLoop('teletypeChatter', minIntervalMs, maxIntervalMs, () => { + this.synthesizeTeletypeChatterPhrase(); + }); + } + + /** + * Continuous Heterodyne / Theremin Navigational Drone (audio_gaps.md #54) + * A persistent room-tone element for the Jupiter 2 astrogator deck: two + * detuned sines (~1040 Hz / ~1048 Hz) beating to ~8 Hz, each with a very + * slow independent wander LFO so the beat drifts like a vacuum-tube theremin. + * True node-graph bed - long 3 s attack, room-tone level, gentle 1.8 s + * release on stop. Re-start always fades the previous bed out first. + */ + startHeterodyneDrone() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + this.stopHeterodyneDrone(); + + const now = ctx.currentTime; + const level = 0.06; + const masterGain = ctx.createGain(); + masterGain.gain.setValueAtTime(0.0001, now); + masterGain.gain.linearRampToValueAtTime(level, now + 3.0); + masterGain.connect(this.gainNode); + + const sources = []; + [1040, 1048].forEach((baseFreq, idx) => { + const osc = ctx.createOscillator(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(baseFreq, now); + // Slow independent drift (0.04-0.09 Hz) keeps the beat wandering + const wander = ctx.createOscillator(); + wander.type = 'sine'; + wander.frequency.setValueAtTime(idx === 0 ? 0.04 + Math.random() * 0.02 : 0.07 + Math.random() * 0.02, now); + const wanderDepth = ctx.createGain(); + wanderDepth.gain.setValueAtTime(idx === 0 ? 4 : 3, now); + wander.connect(wanderDepth); + wanderDepth.connect(osc.frequency); + + const voiceGain = ctx.createGain(); + voiceGain.gain.setValueAtTime(0.5, now); + osc.connect(voiceGain); + voiceGain.connect(masterGain); + osc.start(now); + wander.start(now); + sources.push(osc, wander); + }); + + this.heterodyneDroneActive = true; + this.heterodyneDroneGain = masterGain; + this.heterodyneDroneSources = sources; + } + + stopHeterodyneDrone() { + this.heterodyneDroneActive = false; + const gain = this.heterodyneDroneGain; + if (!gain) return; + const ctx = this.am.ctx; + if (ctx) { + const now = ctx.currentTime; + // linearRamp continues from the computed value, so a mid-attack stop + // glides down smoothly without a click + gain.gain.linearRampToValueAtTime(0.0001, now + 1.8); + (this.heterodyneDroneSources || []).forEach((src) => { + try { src.stop(now + 2.0); } catch (e) { /* already stopped */ } + }); + } + this.heterodyneDroneGain = null; + this.heterodyneDroneSources = null; + } + + /** + * Zocalo Commerce-Plaza Chatter Bed (audio_gaps.md #46) + * A persistent crowd-murmur bed for Babylon 5: looped pink noise through + * four parallel formant bandpasses (300 Hz - 3 kHz) whose centers drift at + * 0.03-0.11 Hz, plus an accent layer of vowelish murmur blips scheduled on + * the loop registry. Re-start fades any previous bed out first. + */ + startZocaloChatter() { + this.init(); + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + this.stopZocaloChatter(); + + const now = ctx.currentTime; + const bedBuf = this.am.createNoiseBuffer('pink', 8); + const bed = ctx.createBufferSource(); + bed.buffer = bedBuf; + bed.loop = true; + + const masterGain = ctx.createGain(); + masterGain.gain.setValueAtTime(0.0001, now); + masterGain.gain.linearRampToValueAtTime(0.16, now + 2.5); + masterGain.connect(this.gainNode); + + // Formant filter bank with slow independent center drift + const sources = []; + const formants = [ + { center: 330, q: 6, weight: 0.3, driftHz: 55, driftRate: 0.03 + Math.random() * 0.02 }, + { center: 760, q: 5, weight: 0.24, driftHz: 130, driftRate: 0.05 + Math.random() * 0.03 }, + { center: 1500, q: 4, weight: 0.16, driftHz: 260, driftRate: 0.07 + Math.random() * 0.03 }, + { center: 2600, q: 3, weight: 0.08, driftHz: 480, driftRate: 0.09 + Math.random() * 0.02 } + ]; + formants.forEach((f) => { + const bp = ctx.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.setValueAtTime(f.center, now); + bp.Q.setValueAtTime(f.q, now); + const drift = ctx.createOscillator(); + drift.type = 'sine'; + drift.frequency.setValueAtTime(f.driftRate, now); + const driftDepth = ctx.createGain(); + driftDepth.gain.setValueAtTime(f.driftHz, now); + drift.connect(driftDepth); + driftDepth.connect(bp.frequency); + const bandGain = ctx.createGain(); + bandGain.gain.setValueAtTime(f.weight, now); + bed.connect(bp); + bp.connect(bandGain); + bandGain.connect(masterGain); + drift.start(now); + sources.push(drift); + }); + + bed.start(now); + sources.push(bed); + + // Crowd accents: murmur blips layered over the wash + this._scheduleLoop('zocaloAccents', 2400, 6800, () => { + this.synthesizeZocaloAccent_(); + }); + + this.zocaloChatterActive = true; + this.zocaloChatterGain = masterGain; + this.zocaloChatterSources = sources; + } + + stopZocaloChatter() { + this.zocaloChatterActive = false; + this._stopLoop('zocaloAccents'); + const gain = this.zocaloChatterGain; + if (!gain) return; + const ctx = this.am.ctx; + if (ctx) { + const now = ctx.currentTime; + gain.gain.linearRampToValueAtTime(0.0001, now + 2.0); + (this.zocaloChatterSources || []).forEach((src) => { + try { src.stop(now + 2.2); } catch (e) { /* already stopped */ } + }); + } + this.zocaloChatterGain = null; + this.zocaloChatterSources = null; + } + + /** + * One crowd "voice" blip for the zocalo bed: a short formant-filtered noise + * impulse with a slight pitch glide, sometimes doubled. Self-releasing. + */ + synthesizeZocaloAccent_() { + const ctx = this.am.ctx; + if (!ctx || !this.gainNode) return; + + const now = ctx.currentTime; + const count = Math.random() < 0.3 ? 2 : 1; + let cursor = 0; + for (let i = 0; i < count; i++) { + const t = now + cursor; + const center = 380 + Math.random() * 1500; + const glide = Math.random() < 0.4 ? (40 + Math.random() * 90) : -(30 + Math.random() * 60); + const dur = 0.1 + Math.random() * 0.15; + const buf = this.am.createNoiseBuffer('pink', dur + 0.1); + const noise = ctx.createBufferSource(); + noise.buffer = buf; + const bp = ctx.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.setValueAtTime(center, t); + bp.frequency.linearRampToValueAtTime(Math.max(200, center + glide), t + dur); + bp.Q.setValueAtTime(2.2 + Math.random() * 1.5, t); + const env = ctx.createGain(); + env.gain.setValueAtTime(0.001, t); + env.gain.linearRampToValueAtTime(0.05 + Math.random() * 0.04, t + 0.03); + env.gain.exponentialRampToValueAtTime(0.001, t + dur); + noise.connect(bp); + bp.connect(env); + env.connect(this.gainNode); + noise.start(t); + noise.stop(t + dur + 0.05); + cursor += 0.09 + Math.random() * 0.15; + } + } + stopAllLoops() { this.stopMedicalMonitor(); this.stopStationSparks(); + this.stopZocaloChatter(); + this.stopHeterodyneDrone(); Object.keys(this.loopTimers).forEach((key) => { this._stopLoop(key); }); diff --git a/js/config.js b/js/config.js index 26aa46a..bb08186 100644 --- a/js/config.js +++ b/js/config.js @@ -510,7 +510,8 @@ const UniverseRegistry = { { id: 'btn-chirp-single', label: 'ASTROGATOR BEAT' }, { id: 'btn-chirp-sweep', label: 'ANALOG GLISSANDO' }, { id: 'btn-relay-click', label: 'TAPE REEL SPOOL' }, - { id: 'btn-tape-spooler', label: 'DUAL-REEL TAPE SPOOLER' } + { id: 'btn-tape-spooler', label: 'DUAL-REEL TAPE SPOOLER' }, + { id: 'btn-hetero-drone', label: 'HETERO-DRONE TONE' } ], presets: { 'ret-jupiter-2': { @@ -903,6 +904,7 @@ const UniverseRegistry = { { id: 'btn-commlock', label: 'COMMLOCK CALLING TONE' }, { id: 'btn-sevas-spark', label: 'CONDUIT SPARK ARC' }, { id: 'btn-station-ecg', label: 'CLINICAL VITAL (ECG)' }, + { id: 'btn-zocalo-chatter', label: 'ZOCALO PLAZA CHATTER' }, { id: 'btn-belter-telemetry', label: 'BELTER JURY-RIG TELEMETRY', span: 2 } ], presets: {