Implement Phase 3 audio gaps: continuous beds (zocalo chatter, teletype chatter, heterodyne drone)

This commit is contained in:
Claude
2026-09-04 09:43:35 -07:00
parent 9d1bc7b743
commit 4ff47b3884
3 changed files with 217 additions and 1 deletions
+12
View File
@@ -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();
+202
View File
@@ -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);
});
+3 -1
View File
@@ -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: {