TelemetrySynth.gainNode was only ever created inside start(), which is only called from the main ENGAGE/FULL STOP flow. Every telemetry-driven soundboard one-shot (LCARS Single/Double Chirp, Ack Sequence, Sensor Sweep, the TOS/NX/Cardassian era sounds, Door Chime, and the Phase 3 who/bioship/belter era generators) guarded on `!this.gainNode` with no fallback, so clicking any of them before the ambient bed had been engaged once did nothing at all: no sound, no console error, and nothing for the spectrum analyzer to react to. ExpandedSciFiAudioSynth and WhoniverseAudioSynth already self-heal this way via their own init() (called at the top of every public synth method) - that's why buttons like Comm Badge work standalone. Add the same init() to TelemetrySynth and call it from every method reachable from the soundboard, so the whole class follows the same convention. start() is left untouched: it still unconditionally rebuilds the node on every engage so a newly-selected preset's volume takes effect immediately. Verified with a small headless harness driving TelemetrySynth directly (no prior start()): all 13 affected methods now create gainNode and fire real oscillators on first call, where they previously did nothing. Confirmed against the pre-fix code that the same harness reproduces the original silent failure. Regenerated dist/SciFiAmbientDisplay_v4.html to match (tools/package.ps1 requires PowerShell, unavailable in this shell, so the inlining was replicated in Python matching its exact output convention; diff against the previous build is exactly the 31 added lines, nothing else). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01582UUCEBExzp3oK3Spz1Xx
6383 lines
216 KiB
JavaScript
6383 lines
216 KiB
JavaScript
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(rampDuration = 0) {
|
||
this.stop();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx) return;
|
||
|
||
// Channel Gain Node
|
||
this.gainNode = ctx.createGain();
|
||
const targetVol = this.isMuted ? 0 : this.params.volume;
|
||
if (rampDuration > 0) {
|
||
this.gainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||
this.gainNode.gain.linearRampToValueAtTime(targetVol, ctx.currentTime + rampDuration);
|
||
} else {
|
||
this.gainNode.gain.setValueAtTime(targetVol, 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(fadeDuration = 0) {
|
||
if (fadeDuration > 0 && this.nodes.length > 0 && this.gainNode && this.am.ctx) {
|
||
const now = this.am.ctx.currentTime;
|
||
this.gainNode.gain.cancelScheduledValues(now);
|
||
this.gainNode.gain.setValueAtTime(Math.max(0.0001, this.gainNode.gain.value), now);
|
||
this.gainNode.gain.linearRampToValueAtTime(0.0001, now + fadeDuration);
|
||
if (this.stoppingTimeout) clearTimeout(this.stoppingTimeout);
|
||
this.stoppingTimeout = setTimeout(() => {
|
||
this.stop(0);
|
||
}, fadeDuration * 1000);
|
||
return;
|
||
}
|
||
if (this.stoppingTimeout) {
|
||
clearTimeout(this.stoppingTimeout);
|
||
this.stoppingTimeout = null;
|
||
}
|
||
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 = [];
|
||
this.subOsc1 = null;
|
||
this.subOsc2 = null;
|
||
this.noiseSource = null;
|
||
this.gainNode = 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);
|
||
}
|
||
}
|
||
|
||
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(rampDuration = 0) {
|
||
this.stop();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx) return;
|
||
|
||
this.gainNode = ctx.createGain();
|
||
const targetVol = this.isMuted ? 0 : this.params.volume;
|
||
if (rampDuration > 0) {
|
||
this.gainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||
this.gainNode.gain.linearRampToValueAtTime(targetVol, ctx.currentTime + rampDuration);
|
||
} else {
|
||
this.gainNode.gain.setValueAtTime(targetVol, 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');
|
||
if (rampDuration > 0) {
|
||
this.carrier.frequency.setValueAtTime(Math.max(20, this.params.carrierFreq * 0.45), ctx.currentTime);
|
||
this.carrier.frequency.exponentialRampToValueAtTime(this.params.carrierFreq, ctx.currentTime + rampDuration);
|
||
} else {
|
||
this.carrier.frequency.setValueAtTime(this.params.carrierFreq, ctx.currentTime);
|
||
}
|
||
|
||
// Sub-harmonic oscillator for massive bottom end
|
||
this.subOsc = ctx.createOscillator();
|
||
this.subOsc.type = 'sine';
|
||
if (rampDuration > 0) {
|
||
this.subOsc.frequency.setValueAtTime(Math.max(12, this.params.carrierFreq * 0.22), ctx.currentTime);
|
||
this.subOsc.frequency.exponentialRampToValueAtTime(this.params.carrierFreq * 0.5, ctx.currentTime + rampDuration);
|
||
} else {
|
||
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(fadeDuration = 0) {
|
||
if (fadeDuration > 0 && this.nodes.length > 0 && this.gainNode && this.am.ctx) {
|
||
const now = this.am.ctx.currentTime;
|
||
this.gainNode.gain.cancelScheduledValues(now);
|
||
this.gainNode.gain.setValueAtTime(Math.max(0.0001, this.gainNode.gain.value), now);
|
||
this.gainNode.gain.linearRampToValueAtTime(0.0001, now + fadeDuration);
|
||
if (this.carrier) {
|
||
try {
|
||
this.carrier.frequency.cancelScheduledValues(now);
|
||
this.carrier.frequency.setValueAtTime(this.carrier.frequency.value, now);
|
||
this.carrier.frequency.linearRampToValueAtTime(Math.max(20, this.params.carrierFreq * 0.35), now + fadeDuration);
|
||
} catch (e) {}
|
||
}
|
||
if (this.stoppingTimeout) clearTimeout(this.stoppingTimeout);
|
||
this.stoppingTimeout = setTimeout(() => {
|
||
this.stop(0);
|
||
}, fadeDuration * 1000);
|
||
return;
|
||
}
|
||
if (this.stoppingTimeout) {
|
||
clearTimeout(this.stoppingTimeout);
|
||
this.stoppingTimeout = null;
|
||
}
|
||
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 = [];
|
||
this.carrier = null;
|
||
this.subOsc = null;
|
||
this.gainNode = null;
|
||
}
|
||
}
|
||
|
||
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(rampDuration = 0) {
|
||
this.stop();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx) return;
|
||
|
||
this.gainNode = ctx.createGain();
|
||
const targetVol = this.isMuted ? 0 : this.params.volume;
|
||
if (rampDuration > 0) {
|
||
this.gainNode.gain.setValueAtTime(0.0001, ctx.currentTime);
|
||
this.gainNode.gain.linearRampToValueAtTime(targetVol, ctx.currentTime + rampDuration);
|
||
} else {
|
||
this.gainNode.gain.setValueAtTime(targetVol, 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(fadeDuration = 0) {
|
||
if (fadeDuration > 0 && this.nodes.length > 0 && this.gainNode && this.am.ctx) {
|
||
const now = this.am.ctx.currentTime;
|
||
this.gainNode.gain.cancelScheduledValues(now);
|
||
this.gainNode.gain.setValueAtTime(Math.max(0.0001, this.gainNode.gain.value), now);
|
||
this.gainNode.gain.linearRampToValueAtTime(0.0001, now + fadeDuration);
|
||
if (this.stoppingTimeout) clearTimeout(this.stoppingTimeout);
|
||
this.stoppingTimeout = setTimeout(() => {
|
||
this.stop(0);
|
||
}, fadeDuration * 1000);
|
||
return;
|
||
}
|
||
if (this.stoppingTimeout) {
|
||
clearTimeout(this.stoppingTimeout);
|
||
this.stoppingTimeout = null;
|
||
}
|
||
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 = [];
|
||
this.noiseSource = null;
|
||
this.gainNode = 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);
|
||
}
|
||
}
|
||
|
||
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', 'who', 'bioship', 'belter'
|
||
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
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Lazily creates the telemetry gain node so soundboard one-shots (LCARS
|
||
* chirps, era-specific one-shots) work standalone even before the ambient
|
||
* bed has been engaged via start(). Mirrors the init() self-heal pattern
|
||
* used by ExpandedSciFiAudioSynth and WhoniverseAudioSynth. start() below
|
||
* still unconditionally rebuilds the node on every engage so a fresh
|
||
* preset's volume takes effect immediately; this only fills the gap
|
||
* before that has ever happened.
|
||
*/
|
||
init() {
|
||
if (this.gainNode || !this.am.ctx) return;
|
||
const ctx = this.am.ctx;
|
||
this.gainNode = ctx.createGain();
|
||
this.gainNode.gain.setValueAtTime(this.isMuted ? 0 : this.params.volume, ctx.currentTime);
|
||
this.gainNode.connect(this.am.compressor);
|
||
}
|
||
|
||
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 'who':
|
||
this.synthesizeWhoMechanicalTelemetry_();
|
||
break;
|
||
case 'bioship':
|
||
this.synthesizeBioshipOrganicTelemetry_();
|
||
break;
|
||
case 'belter':
|
||
this.synthesizeBelterJuryRiggedTelemetry_();
|
||
break;
|
||
case 'tos':
|
||
Math.random() > 0.4 ? this.synthesizeTOSWarble() : this.synthesizeTOSRelayClick();
|
||
break;
|
||
case 'ds9':
|
||
Math.random() > 0.5 ? this.synthesizeCardassianSensor() : this.synthesizeLCARSSingleChirp();
|
||
break;
|
||
case 'voyager': {
|
||
const r = Math.random();
|
||
if (r < 0.12) this.synthesizeCommBadge();
|
||
else if (r < 0.55) this.synthesizeLCARSDoubleChirp();
|
||
else this.synthesizeSensorSweep();
|
||
break;
|
||
}
|
||
case 'nx':
|
||
Math.random() > 0.5 ? this.synthesizeNXRelay() : this.synthesizeNXIndicatorBeep();
|
||
break;
|
||
case 'tng':
|
||
default: {
|
||
const r = Math.random();
|
||
if (r < 0.10) this.synthesizeCommBadge();
|
||
else if (r < 0.45) this.synthesizeLCARSSingleChirp();
|
||
else if (r < 0.72) this.synthesizeLCARSDoubleChirp();
|
||
else if (r < 0.88) 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()
|
||
}
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Star Trek Comm Badge Confirmation Chirp
|
||
*/
|
||
synthesizeCommBadge() {
|
||
this.init();
|
||
if (window.expandedAudio) {
|
||
window.expandedAudio.synthesizeCommBadge();
|
||
return;
|
||
}
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
const now = ctx.currentTime;
|
||
[784, 1396.91].forEach((freq, idx) => {
|
||
const t = now + idx * 0.038;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(0.35, t + 0.005);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + (idx === 0 ? 0.045 : 0.09));
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + (idx === 0 ? 0.05 : 0.095));
|
||
});
|
||
}
|
||
|
||
/**
|
||
* TNG/Voyager Single LCARS Touch Tone (Soft sine with gentle attack and rapid exponential decay)
|
||
*/
|
||
synthesizeLCARSSingleChirp(pitch = null) {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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() {
|
||
this.init();
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Whoniverse Mechanical Telemetry Engine (audio_gaps.md #25, Phase 3 era)
|
||
* The TARDIS console never chirps like Starfleet gear: each tick emits a
|
||
* 0.25-0.6 s cluster of mechanical foley - dual-filtered toggle snaps,
|
||
* low 60-120 Hz spring-lever clicks and damped rotary selector pings
|
||
* (no electronic beeps). All transients scheduled on absolute ctx time so
|
||
* the single scheduler timeout can never orphan nodes.
|
||
*/
|
||
synthesizeWhoMechanicalTelemetry_() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const strikeCount = 2 + Math.floor(Math.random() * 4); // 2-5 transients
|
||
let cursor = Math.random() * 0.12;
|
||
|
||
// Toggle snap: dual-filtered short noise pulse (20-45 ms body)
|
||
const toggleSnap = (t, peak) => {
|
||
const buf = this.am.createNoiseBuffer('white', 0.06);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bpLo = ctx.createBiquadFilter();
|
||
bpLo.type = 'bandpass';
|
||
bpLo.frequency.setValueAtTime(300 + Math.random() * 200, t);
|
||
bpLo.Q.setValueAtTime(6, t);
|
||
const bpHi = ctx.createBiquadFilter();
|
||
bpHi.type = 'bandpass';
|
||
bpHi.frequency.setValueAtTime(900 + Math.random() * 500, t);
|
||
bpHi.Q.setValueAtTime(4, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(peak, t + 0.004);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.02 + Math.random() * 0.025);
|
||
noise.connect(bpLo);
|
||
bpLo.connect(env);
|
||
noise.connect(bpHi);
|
||
bpHi.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.06);
|
||
};
|
||
|
||
// Spring lever snap: dull square click collapsing in pitch (60-120 Hz)
|
||
const springLever = (t, peak) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
const f0 = 60 + Math.random() * 60;
|
||
osc.frequency.setValueAtTime(f0, t);
|
||
osc.frequency.exponentialRampToValueAtTime(f0 * 0.5, t + 0.03);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(peak, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.03 + Math.random() * 0.02);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.055);
|
||
};
|
||
|
||
// Rotary selector clunk: short damped mechanical ping, never a clean beep
|
||
const rotaryClunk = (t, peak) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
const f0 = 180 + Math.random() * 260;
|
||
osc.frequency.setValueAtTime(f0, t);
|
||
osc.frequency.exponentialRampToValueAtTime(f0 * 0.7, t + 0.06);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(peak, t + 0.003);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.06 + Math.random() * 0.08);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.16);
|
||
};
|
||
|
||
for (let i = 0; i < strikeCount; i++) {
|
||
const r = Math.random();
|
||
if (r < 0.5) toggleSnap(cursor, 0.08 + Math.random() * 0.08);
|
||
else if (r < 0.8) springLever(cursor, 0.07 + Math.random() * 0.06);
|
||
else rotaryClunk(cursor, 0.09 + Math.random() * 0.07);
|
||
cursor += 0.04 + Math.random() * 0.14;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bioship Organic Telemetry Engine (audio_gaps.md #47, Phase 3 era)
|
||
* Living-ship telemetry is wet and pulse-driven, never electronic: vascular
|
||
* surges (30-90 Hz asymmetric sine thumps, sometimes lub-dub double beats),
|
||
* chitinous joint snaps, bioplasmic fluid pops and membrane flutters.
|
||
* One 0.5-1.1 s phrase of 2-4 events on absolute ctx time.
|
||
*/
|
||
synthesizeBioshipOrganicTelemetry_() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const eventCount = 2 + Math.floor(Math.random() * 3); // 2-4 events
|
||
let cursor = Math.random() * 0.2;
|
||
|
||
const fireEvent = (t) => {
|
||
const r = Math.random();
|
||
if (r < 0.35) {
|
||
// Vascular surge: asymmetric sine thump with pitch drop + lub-dub echo
|
||
const f0 = 30 + Math.random() * 60;
|
||
const dur = 0.2 + Math.random() * 0.15;
|
||
const surge = (st, peak, freq) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, st);
|
||
osc.frequency.linearRampToValueAtTime(freq * 0.82, st + dur * 0.7);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, st);
|
||
env.gain.linearRampToValueAtTime(peak, st + 0.06 + Math.random() * 0.05);
|
||
env.gain.exponentialRampToValueAtTime(0.001, st + dur);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(st);
|
||
osc.stop(st + dur + 0.05);
|
||
};
|
||
surge(t, 0.14 + Math.random() * 0.06, f0);
|
||
if (Math.random() < 0.5) {
|
||
surge(t + 0.22 + Math.random() * 0.06, 0.09, f0 * 0.9); // lub-dub
|
||
}
|
||
} else if (r < 0.6) {
|
||
// Chitin snap: very short high-Q bandpass noise tick
|
||
const buf = this.am.createNoiseBuffer('white', 0.03);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(1200 + Math.random() * 1400, t);
|
||
bp.Q.setValueAtTime(10 + Math.random() * 4, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.12 + Math.random() * 0.06, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.008 + Math.random() * 0.01);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.03);
|
||
} else if (r < 0.85) {
|
||
// Bioplasmic fluid pop: bandpass wet impulse with resonant tail
|
||
const buf = this.am.createNoiseBuffer('pink', 0.08);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(500 + Math.random() * 900, t);
|
||
bp.frequency.exponentialRampToValueAtTime(380, t + 0.05);
|
||
bp.Q.setValueAtTime(3 + Math.random() * 2, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(0.14, t + 0.008);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.05);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.09);
|
||
} else {
|
||
// Membrane flutter: pink noise AM-stepped between 300-700 Hz
|
||
const buf = this.am.createNoiseBuffer('pink', 0.2);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(300 + Math.random() * 400, t);
|
||
bp.Q.setValueAtTime(2, t);
|
||
const env = ctx.createGain();
|
||
const flutterRate = 18 + Math.random() * 12;
|
||
const flutterDur = 0.08 + Math.random() * 0.07;
|
||
env.gain.setValueAtTime(0.001, t);
|
||
let step = 0;
|
||
while (step < flutterDur) {
|
||
env.gain.linearRampToValueAtTime(0.09 + Math.random() * 0.05, t + step + (1 / flutterRate) * 0.6);
|
||
env.gain.linearRampToValueAtTime(0.001, t + step + (1 / flutterRate));
|
||
step += 1 / flutterRate;
|
||
}
|
||
env.gain.setValueAtTime(0.001, t + flutterDur + 0.02);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + flutterDur + 0.05);
|
||
}
|
||
};
|
||
|
||
for (let i = 0; i < eventCount; i++) {
|
||
fireEvent(cursor);
|
||
cursor += 0.16 + Math.random() * 0.3;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Belter Jury-Rigged Telemetry Engine (audio_gaps.md #29, Phase 3 era)
|
||
* Rattling relays, worn copper contactors and erratic voltage on Ceres /
|
||
* Tycho / Rocinante hardware: jittered square pulses with randomized
|
||
* micro-dropouts, contact-bounce double clicks, harsh metallic ticks and
|
||
* unstable clock pings. One 3-6 event volley on absolute ctx time, with an
|
||
* occasional late sub-cluster to mimic an unsteady clock.
|
||
*/
|
||
synthesizeBelterJuryRiggedTelemetry_() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
let cursor = 0.05 + Math.random() * 0.25;
|
||
|
||
const relayPulse = (t) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
const baseFreq = 60 + Math.random() * 60;
|
||
osc.frequency.setValueAtTime(baseFreq, t);
|
||
osc.frequency.linearRampToValueAtTime(baseFreq * 0.8, t + 0.06);
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(420, t);
|
||
const env = ctx.createGain();
|
||
// Erratic voltage: randomized micro-dropouts in the pulse body
|
||
env.gain.setValueAtTime(0.15, t);
|
||
env.gain.setValueAtTime(0.001, t + 0.012 + Math.random() * 0.015);
|
||
env.gain.setValueAtTime(0.12, t + 0.026 + Math.random() * 0.02);
|
||
env.gain.setValueAtTime(0.001, t + 0.05 + Math.random() * 0.025);
|
||
env.gain.setValueAtTime(0.09, t + 0.07);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
|
||
osc.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.11);
|
||
};
|
||
|
||
const contactBounce = (t) => {
|
||
const clicks = 2 + (Math.random() < 0.35 ? 1 : 0);
|
||
for (let i = 0; i < clicks; i++) {
|
||
const ct = t + i * (0.001 + Math.random() * 0.002);
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(500 + Math.random() * 500, ct);
|
||
osc.frequency.exponentialRampToValueAtTime(120, ct + 0.008);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.12, ct);
|
||
env.gain.exponentialRampToValueAtTime(0.001, ct + 0.012);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(ct);
|
||
osc.stop(ct + 0.015);
|
||
}
|
||
};
|
||
|
||
const metallicTick = (t) => {
|
||
const buf = this.am.createNoiseBuffer('white', 0.05);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(400 + Math.random() * 1400, t);
|
||
bp.Q.setValueAtTime(6, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.08, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.02);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.025);
|
||
};
|
||
|
||
const clockPing = (t) => {
|
||
// Unstable clock: dulled square collapsing hard, like a dying oscillator
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(700 + Math.random() * 400, t);
|
||
osc.frequency.exponentialRampToValueAtTime(200, t + 0.025);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.05, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.04);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.045);
|
||
};
|
||
|
||
const fireEvent = (t) => {
|
||
const r = Math.random();
|
||
if (r < 0.35) relayPulse(t);
|
||
else if (r < 0.6) contactBounce(t);
|
||
else if (r < 0.85) metallicTick(t);
|
||
else clockPing(t);
|
||
};
|
||
|
||
const eventCount = 3 + Math.floor(Math.random() * 4); // 3-6 events
|
||
for (let i = 0; i < eventCount; i++) {
|
||
fireEvent(cursor);
|
||
cursor += 0.06 + Math.random() * 0.16; // jittered clock drift
|
||
}
|
||
// Occasional second volley: the station clock skips then catches up
|
||
if (Math.random() < 0.35) {
|
||
const volleyBase = cursor + 0.1 + Math.random() * 0.4;
|
||
const volleyCount = 2 + Math.floor(Math.random() * 2);
|
||
for (let i = 0; i < volleyCount; i++) {
|
||
fireEvent(volleyBase + i * (0.04 + Math.random() * 0.1));
|
||
}
|
||
}
|
||
}
|
||
|
||
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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* TARDIS Lever-Throw Clunk (Tactile mechanical switch)
|
||
* Hard square transient click coupled to a dull highpass noise thump
|
||
*/
|
||
synthesizeLeverClunk() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Hard square transient click
|
||
const clickOsc = ctx.createOscillator();
|
||
clickOsc.type = 'square';
|
||
clickOsc.frequency.setValueAtTime(80, now);
|
||
clickOsc.frequency.exponentialRampToValueAtTime(30, now + 0.035);
|
||
|
||
const clickEnv = ctx.createGain();
|
||
clickEnv.gain.setValueAtTime(0.42, now);
|
||
clickEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
|
||
|
||
clickOsc.connect(clickEnv);
|
||
clickEnv.connect(this.gainNode);
|
||
clickOsc.start(now);
|
||
clickOsc.stop(now + 0.042);
|
||
|
||
// Dull highpass noise thump
|
||
const noiseBuf = this.am.createNoiseBuffer('pink', 0.15);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = 'highpass';
|
||
filter.frequency.setValueAtTime(320, now);
|
||
|
||
const noiseEnv = ctx.createGain();
|
||
noiseEnv.gain.setValueAtTime(0.3, now);
|
||
noiseEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.06);
|
||
|
||
noise.connect(filter);
|
||
filter.connect(noiseEnv);
|
||
noiseEnv.connect(this.gainNode);
|
||
noise.start(now);
|
||
noise.stop(now + 0.065);
|
||
}
|
||
|
||
/**
|
||
* TARDIS Police Box Exterior Door Open / Close (Wood creak + mortise latch)
|
||
* Modulated bandpass friction noise layered over a sharp metallic dual-click transient
|
||
*/
|
||
synthesizeTardisDoor() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.75;
|
||
|
||
// 1. Modulated bandpass friction noise (wood creak)
|
||
const creakBuf = this.am.createNoiseBuffer('brown', duration);
|
||
const creak = ctx.createBufferSource();
|
||
creak.buffer = creakBuf;
|
||
|
||
const creakFilter = ctx.createBiquadFilter();
|
||
creakFilter.type = 'bandpass';
|
||
creakFilter.frequency.setValueAtTime(260, now);
|
||
creakFilter.frequency.linearRampToValueAtTime(540, now + 0.35);
|
||
creakFilter.frequency.exponentialRampToValueAtTime(310, now + 0.65);
|
||
creakFilter.Q.setValueAtTime(6.0, now);
|
||
|
||
const creakEnv = ctx.createGain();
|
||
creakEnv.gain.setValueAtTime(0.001, now);
|
||
creakEnv.gain.linearRampToValueAtTime(0.38, now + 0.15);
|
||
creakEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.68);
|
||
|
||
creak.connect(creakFilter);
|
||
creakFilter.connect(creakEnv);
|
||
creakEnv.connect(this.gainNode);
|
||
creak.start(now);
|
||
creak.stop(now + 0.7);
|
||
|
||
// 2. Iron mortise latch dual-click transient
|
||
[0.54, 0.62].forEach((offset, idx) => {
|
||
const click = ctx.createOscillator();
|
||
click.type = 'triangle';
|
||
click.frequency.setValueAtTime(idx === 0 ? 1100 : 750, now + offset);
|
||
click.frequency.exponentialRampToValueAtTime(180, now + offset + 0.03);
|
||
|
||
const cEnv = ctx.createGain();
|
||
cEnv.gain.setValueAtTime(0.28, now + offset);
|
||
cEnv.gain.exponentialRampToValueAtTime(0.001, now + offset + 0.035);
|
||
|
||
click.connect(cEnv);
|
||
cEnv.connect(this.gainNode);
|
||
click.start(now + offset);
|
||
click.stop(now + offset + 0.04);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Scanner / Monitor Screen Activation (Whoniverse, audio_gaps.md #24)
|
||
* Rapid upward sine sweep (200 Hz -> 1600 Hz over 120 ms) terminating in a
|
||
* CRT flyback whine. The 15.6 kHz spec tail is compromised to ~10.6 kHz:
|
||
* near/above Nyquist on low-rate contexts and ear-fatiguing at audible levels.
|
||
*/
|
||
synthesizeScannerActivate() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// 1. Cathode-ray startup pitch sweep
|
||
const sweep = ctx.createOscillator();
|
||
sweep.type = 'sine';
|
||
sweep.frequency.setValueAtTime(200, now);
|
||
sweep.frequency.exponentialRampToValueAtTime(1600, now + 0.12);
|
||
|
||
const sweepEnv = ctx.createGain();
|
||
sweepEnv.gain.setValueAtTime(0.001, now);
|
||
sweepEnv.gain.linearRampToValueAtTime(0.32, now + 0.03);
|
||
sweepEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.16);
|
||
|
||
sweep.connect(sweepEnv);
|
||
sweepEnv.connect(this.gainNode);
|
||
sweep.start(now);
|
||
sweep.stop(now + 0.17);
|
||
|
||
// 2. CRT flyback whine partial (10.6 kHz compromise, low amplitude)
|
||
const whine = ctx.createOscillator();
|
||
whine.type = 'sine';
|
||
whine.frequency.setValueAtTime(10600, now + 0.1);
|
||
|
||
const wobble = ctx.createOscillator();
|
||
wobble.type = 'sine';
|
||
wobble.frequency.setValueAtTime(1.0, now + 0.1);
|
||
const wobbleDepth = ctx.createGain();
|
||
wobbleDepth.gain.setValueAtTime(8, now + 0.1);
|
||
|
||
wobble.connect(wobbleDepth);
|
||
wobbleDepth.connect(whine.frequency);
|
||
|
||
const whineEnv = ctx.createGain();
|
||
whineEnv.gain.setValueAtTime(0.001, now + 0.1);
|
||
whineEnv.gain.linearRampToValueAtTime(0.03, now + 0.16);
|
||
whineEnv.gain.exponentialRampToValueAtTime(0.0001, now + 0.85);
|
||
|
||
whine.connect(whineEnv);
|
||
whineEnv.connect(this.gainNode);
|
||
whine.start(now + 0.1);
|
||
whine.stop(now + 0.87);
|
||
wobble.start(now + 0.1);
|
||
wobble.stop(now + 0.87);
|
||
}
|
||
|
||
/**
|
||
* Console "Type" Input Clatter Cluster (Whoniverse, audio_gaps.md #25)
|
||
* Rhythmic mechanical clatter of toggle switches, spring-loaded buttons and
|
||
* tumbler relays: clustered wooden/plastic clicks and solenoid snaps rather
|
||
* than tonal beeps. Produces a single 0.3-0.6 s cluster of 3-7 transients.
|
||
*/
|
||
synthesizeTypeClatter() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const transientCount = 3 + Math.floor(Math.random() * 5); // 3-7 strikes
|
||
let cursor = 0;
|
||
|
||
const strike = (kind, offset, peak) => {
|
||
const t = now + offset;
|
||
if (kind === 'toggle') {
|
||
// Wooden relay toggle: short bandpass pink-noise burst with downward snap
|
||
const buf = this.am.createNoiseBuffer('pink', 0.1);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(700 + Math.random() * 700, t);
|
||
bp.frequency.exponentialRampToValueAtTime(240, t + 0.03);
|
||
bp.Q.setValueAtTime(8 + Math.random() * 4, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(peak, t + 0.005);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.045);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.05);
|
||
} else if (kind === 'button') {
|
||
// Spring-loaded plastic button: fast square click collapsing in pitch
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(900, t);
|
||
osc.frequency.exponentialRampToValueAtTime(200, t + 0.012);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(peak * 0.8, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.022);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.025);
|
||
} else {
|
||
// Heavy solenoid snap: dull low square thump
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(120, t);
|
||
osc.frequency.exponentialRampToValueAtTime(55, t + 0.025);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(peak, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.035);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.04);
|
||
}
|
||
};
|
||
|
||
for (let i = 0; i < transientCount; i++) {
|
||
const kinds = ['toggle', 'toggle', 'button', 'button', 'solenoid'];
|
||
const kind = kinds[Math.floor(Math.random() * kinds.length)];
|
||
cursor += 0.015 + Math.random() * 0.075;
|
||
strike(kind, cursor, 0.07 + Math.random() * 0.07);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stops all automated Whoniverse ambient loops. Phase 3 moved console
|
||
* "type" telemetry (audio_gaps.md #25) into TelemetrySynth era 'who'
|
||
* routing, which is stopped via telemetry.stop() / FULL STOP. Hook
|
||
* retained because js/app.js calls it at three ambience switch points.
|
||
*/
|
||
stopAmbientLoops() {
|
||
}
|
||
}
|
||
|
||
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;
|
||
this.medicalTimer = null;
|
||
this.sparkTimer = null;
|
||
this.loopTimers = {};
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* Star Trek Comm Badge Confirmation Chirp
|
||
* Iconic bright two-tone pulse in rapid sequence (784 Hz to 1397 Hz)
|
||
*/
|
||
synthesizeCommBadge() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const tone1Freq = 784; // G5
|
||
const tone2Freq = 1396.91; // F6
|
||
|
||
// Pulse 1
|
||
const osc1 = ctx.createOscillator();
|
||
osc1.type = 'sine';
|
||
osc1.frequency.setValueAtTime(tone1Freq, now);
|
||
|
||
const env1 = ctx.createGain();
|
||
env1.gain.setValueAtTime(0.001, now);
|
||
env1.gain.linearRampToValueAtTime(0.38, now + 0.005);
|
||
env1.gain.exponentialRampToValueAtTime(0.001, now + 0.045);
|
||
|
||
osc1.connect(env1);
|
||
env1.connect(this.gainNode);
|
||
osc1.start(now);
|
||
osc1.stop(now + 0.048);
|
||
|
||
// Pulse 2
|
||
const t2 = now + 0.038;
|
||
const osc2 = ctx.createOscillator();
|
||
osc2.type = 'sine';
|
||
osc2.frequency.setValueAtTime(tone2Freq, t2);
|
||
|
||
const env2 = ctx.createGain();
|
||
env2.gain.setValueAtTime(0.001, t2);
|
||
env2.gain.linearRampToValueAtTime(0.42, t2 + 0.006);
|
||
env2.gain.exponentialRampToValueAtTime(0.001, t2 + 0.095);
|
||
|
||
osc2.connect(env2);
|
||
env2.connect(this.gainNode);
|
||
osc2.start(t2);
|
||
osc2.stop(t2 + 0.1);
|
||
}
|
||
|
||
/**
|
||
* Starfleet Pneumatic Door Swish (Four Era Variants)
|
||
* Bandpass-filtered white noise burst shaped per era
|
||
*/
|
||
synthesizeDoorSwish(era = 'tng') {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
let centerFreq = 950;
|
||
let qVal = 1.8;
|
||
let duration = 0.45;
|
||
let noiseType = 'white';
|
||
|
||
switch (era) {
|
||
case 'tos':
|
||
centerFreq = 720;
|
||
qVal = 1.4;
|
||
duration = 0.38;
|
||
break;
|
||
case 'voyager':
|
||
centerFreq = 1250;
|
||
qVal = 2.4;
|
||
duration = 0.40;
|
||
break;
|
||
case 'nx':
|
||
centerFreq = 650;
|
||
qVal = 2.0;
|
||
duration = 0.55;
|
||
noiseType = 'pink';
|
||
break;
|
||
case 'tng':
|
||
default:
|
||
centerFreq = 950;
|
||
qVal = 1.8;
|
||
duration = 0.45;
|
||
break;
|
||
}
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer(noiseType, duration + 0.1);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(centerFreq * 0.8, now);
|
||
bp.frequency.linearRampToValueAtTime(centerFreq * 1.15, now + duration * 0.4);
|
||
bp.frequency.exponentialRampToValueAtTime(centerFreq * 0.7, now + duration);
|
||
bp.Q.setValueAtTime(qVal, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.45, now + 0.03);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.02);
|
||
}
|
||
|
||
/**
|
||
* Starfleet Bosun's Pipe Whistle (TOS Command Announce)
|
||
* Two-tone sine glide (1800 Hz -> 2400 Hz -> 1850 Hz) with gentle tremolo
|
||
*/
|
||
synthesizeBosunWhistle() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.85;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(1800, now);
|
||
osc.frequency.linearRampToValueAtTime(2400, now + 0.22);
|
||
osc.frequency.setValueAtTime(2400, now + 0.48);
|
||
osc.frequency.linearRampToValueAtTime(1850, now + 0.75);
|
||
|
||
const tremolo = ctx.createOscillator();
|
||
tremolo.type = 'sine';
|
||
tremolo.frequency.setValueAtTime(6.5, now);
|
||
|
||
const tremGain = ctx.createGain();
|
||
tremGain.gain.setValueAtTime(0.12, now);
|
||
tremolo.connect(tremGain);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.28, now + 0.05);
|
||
env.gain.setValueAtTime(0.28, now + 0.65);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
tremGain.connect(env.gain);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
tremolo.start(now);
|
||
osc.stop(now + duration);
|
||
tremolo.stop(now + duration);
|
||
}
|
||
|
||
/**
|
||
* Starfleet Sickbay Medical Monitor ECG Ping
|
||
* Calm, periodic rhythmic vital signs pulse (1080 Hz sine, 40 ms decay)
|
||
*/
|
||
synthesizeMedicalMonitor() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.045;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(1080, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.32, now + 0.003);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.005);
|
||
}
|
||
|
||
/**
|
||
* Sevastopol Electrical Conduit Spark Transient
|
||
* High-voltage electrical discharge arcs across damaged station bulkheads
|
||
*/
|
||
synthesizeSevastopolSpark() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const bursts = 2 + Math.floor(Math.random() * 2);
|
||
|
||
for (let i = 0; i < bursts; i++) {
|
||
const offset = i * (0.015 + Math.random() * 0.02);
|
||
const t = now + offset;
|
||
const dur = 0.008 + Math.random() * 0.008;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('white', 0.05);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const hp = ctx.createBiquadFilter();
|
||
hp.type = 'highpass';
|
||
hp.frequency.setValueAtTime(3600 + Math.random() * 800, t);
|
||
hp.Q.setValueAtTime(4.5, t);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.45, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + dur);
|
||
|
||
noise.connect(hp);
|
||
hp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(t);
|
||
noise.stop(t + dur + 0.005);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Moonbase Alpha Commlock Calling Tone (Space: 1999)
|
||
* Dual square wave pulse sequence (1100 Hz / 1500 Hz)
|
||
*/
|
||
synthesizeCommlockTone() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Beep 1: 1100 Hz, 60 ms
|
||
const osc1 = ctx.createOscillator();
|
||
osc1.type = 'square';
|
||
osc1.frequency.setValueAtTime(1100, now);
|
||
|
||
const env1 = ctx.createGain();
|
||
env1.gain.setValueAtTime(0.001, now);
|
||
env1.gain.linearRampToValueAtTime(0.24, now + 0.004);
|
||
env1.gain.setValueAtTime(0.24, now + 0.055);
|
||
env1.gain.exponentialRampToValueAtTime(0.001, now + 0.062);
|
||
|
||
osc1.connect(env1);
|
||
env1.connect(this.gainNode);
|
||
osc1.start(now);
|
||
osc1.stop(now + 0.065);
|
||
|
||
// Beep 2: 1500 Hz, 75 ms starting at +0.082s
|
||
const t2 = now + 0.082;
|
||
const osc2 = ctx.createOscillator();
|
||
osc2.type = 'square';
|
||
osc2.frequency.setValueAtTime(1500, t2);
|
||
|
||
const env2 = ctx.createGain();
|
||
env2.gain.setValueAtTime(0.001, t2);
|
||
env2.gain.linearRampToValueAtTime(0.24, t2 + 0.004);
|
||
env2.gain.setValueAtTime(0.24, t2 + 0.07);
|
||
env2.gain.exponentialRampToValueAtTime(0.001, t2 + 0.078);
|
||
|
||
osc2.connect(env2);
|
||
env2.connect(this.gainNode);
|
||
osc2.start(t2);
|
||
osc2.stop(t2 + 0.082);
|
||
}
|
||
|
||
/**
|
||
* Gateway Station Medical Vital Telemetry Pip (Aliens)
|
||
* Clinical sterile 950 Hz pure sine pip (35 ms decay)
|
||
*/
|
||
synthesizeStationMedicalPing() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.038;
|
||
|
||
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.3, now + 0.003);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.005);
|
||
}
|
||
|
||
/**
|
||
* Solar Radiation / Heat Shield Roar (Icarus II / Sunshine)
|
||
* Terrifying lowpass brown noise roar with amplitude swell and crackle impulses
|
||
*/
|
||
synthesizeSolarRoar() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 3.6;
|
||
|
||
// Lowpass brown noise body
|
||
const noiseBuf = this.am.createNoiseBuffer('brown', duration + 0.2);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(120, now);
|
||
lp.frequency.linearRampToValueAtTime(180, now + 1.5);
|
||
lp.frequency.exponentialRampToValueAtTime(90, now + duration);
|
||
lp.Q.setValueAtTime(2.2, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.72, now + 1.4);
|
||
env.gain.setValueAtTime(0.70, now + 2.2);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
noise.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.05);
|
||
|
||
// Highpass solar crackle layer
|
||
const crackleBuf = this.am.createNoiseBuffer('pink', duration);
|
||
const crackle = ctx.createBufferSource();
|
||
crackle.buffer = crackleBuf;
|
||
|
||
const hp = ctx.createBiquadFilter();
|
||
hp.type = 'highpass';
|
||
hp.frequency.setValueAtTime(3200, now);
|
||
hp.Q.setValueAtTime(4.0, now);
|
||
|
||
const crackleEnv = ctx.createGain();
|
||
crackleEnv.gain.setValueAtTime(0.001, now);
|
||
crackleEnv.gain.linearRampToValueAtTime(0.08, now + 1.2);
|
||
crackleEnv.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
crackle.connect(hp);
|
||
hp.connect(crackleEnv);
|
||
crackleEnv.connect(this.gainNode);
|
||
|
||
crackle.start(now);
|
||
crackle.stop(now + duration);
|
||
}
|
||
|
||
/**
|
||
* Cryogenic Pod Depressurization Sigh (Avalon / Ark One)
|
||
* Gas pressure relief with exponential filter decay followed by 205 Hz seal hum
|
||
*/
|
||
synthesizeCryoDepressurize() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 1.35;
|
||
|
||
// Pneumatic gas depressurization sigh
|
||
const noiseBuf = this.am.createNoiseBuffer('white', 1.4);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(1800, now);
|
||
lp.frequency.exponentialRampToValueAtTime(280, now + 1.1);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.38, now + 0.06);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + 1.15);
|
||
|
||
noise.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(now);
|
||
noise.stop(now + 1.2);
|
||
|
||
// Quiet motorized seal hum
|
||
const hum = ctx.createOscillator();
|
||
hum.type = 'sine';
|
||
hum.frequency.setValueAtTime(205, now + 0.35);
|
||
|
||
const humEnv = ctx.createGain();
|
||
humEnv.gain.setValueAtTime(0.001, now + 0.35);
|
||
humEnv.gain.linearRampToValueAtTime(0.18, now + 0.55);
|
||
humEnv.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
hum.connect(humEnv);
|
||
humEnv.connect(this.gainNode);
|
||
|
||
hum.start(now + 0.35);
|
||
hum.stop(now + duration + 0.02);
|
||
}
|
||
|
||
/**
|
||
* Mid-Century Orion Nuclear Pulse Thump (USS Ascension)
|
||
* Heavy 45 Hz lowpass square impulse with sub-bass body resonance
|
||
*/
|
||
synthesizeOrionPulseThump() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.22;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(45, now);
|
||
osc.frequency.exponentialRampToValueAtTime(24, now + 0.09);
|
||
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(140, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.65, now);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + 0.085);
|
||
|
||
osc.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + 0.09);
|
||
|
||
const sub = ctx.createOscillator();
|
||
sub.type = 'sine';
|
||
sub.frequency.setValueAtTime(36, now);
|
||
|
||
const subEnv = ctx.createGain();
|
||
subEnv.gain.setValueAtTime(0.001, now);
|
||
subEnv.gain.linearRampToValueAtTime(0.55, now + 0.01);
|
||
subEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
|
||
sub.connect(subEnv);
|
||
subEnv.connect(this.gainNode);
|
||
|
||
sub.start(now);
|
||
sub.stop(now + duration);
|
||
}
|
||
|
||
/**
|
||
* Nutri-Matic Dedicated Tea Dispenser Gurgle (Heart of Gold)
|
||
* Bandpass bubbling noise hops (500–1000 Hz) terminating in a short steam hiss
|
||
*/
|
||
synthesizeTeaDispenser() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Bubbling hops
|
||
const bubbleFreqs = [520, 840, 610, 960, 480, 730];
|
||
bubbleFreqs.forEach((freq, i) => {
|
||
const t = now + i * 0.11;
|
||
const bDur = 0.09;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('pink', 0.12);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(freq, t);
|
||
bp.Q.setValueAtTime(6.0, t);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(0.35, t + 0.015);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + bDur);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(t);
|
||
noise.stop(t + bDur + 0.01);
|
||
});
|
||
|
||
// Steam hiss
|
||
const steamTime = now + 0.65;
|
||
const steamBuf = this.am.createNoiseBuffer('white', 0.45);
|
||
const steam = ctx.createBufferSource();
|
||
steam.buffer = steamBuf;
|
||
|
||
const steamFilter = ctx.createBiquadFilter();
|
||
steamFilter.type = 'bandpass';
|
||
steamFilter.frequency.setValueAtTime(2400, steamTime);
|
||
steamFilter.Q.setValueAtTime(3.0, steamTime);
|
||
|
||
const steamEnv = ctx.createGain();
|
||
steamEnv.gain.setValueAtTime(0.001, steamTime);
|
||
steamEnv.gain.linearRampToValueAtTime(0.28, steamTime + 0.04);
|
||
steamEnv.gain.exponentialRampToValueAtTime(0.001, steamTime + 0.42);
|
||
|
||
steam.connect(steamFilter);
|
||
steamFilter.connect(steamEnv);
|
||
steamEnv.connect(this.gainNode);
|
||
|
||
steam.start(steamTime);
|
||
steam.stop(steamTime + 0.45);
|
||
}
|
||
|
||
/**
|
||
* British Electric Kettle Steam Whistle (HMS Camden Lock / Hyperdrive)
|
||
* Narrow bandpass sine sweeping 1820 Hz to 2380 Hz with steady tremolo
|
||
*/
|
||
synthesizeKettleWhistle() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 1.6;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(1820, now);
|
||
osc.frequency.exponentialRampToValueAtTime(2380, now + duration * 0.7);
|
||
osc.frequency.linearRampToValueAtTime(2320, now + duration);
|
||
|
||
const tremolo = ctx.createOscillator();
|
||
tremolo.type = 'sine';
|
||
tremolo.frequency.setValueAtTime(5.2, now);
|
||
|
||
const tremGain = ctx.createGain();
|
||
tremGain.gain.setValueAtTime(0.15, now);
|
||
tremolo.connect(tremGain);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.26, now + 0.25);
|
||
env.gain.setValueAtTime(0.26, now + 1.2);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
tremGain.connect(env.gain);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
tremolo.start(now);
|
||
osc.stop(now + duration);
|
||
tremolo.stop(now + duration);
|
||
}
|
||
|
||
/**
|
||
* Ludicrous Speed Plaid Alarm (Spaceball One)
|
||
* Two-tone alternating square wave siren (440 Hz / 880 Hz)
|
||
*/
|
||
synthesizePlaidAlarm() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const steps = 6;
|
||
const stepDur = 0.12;
|
||
|
||
for (let i = 0; i < steps; i++) {
|
||
const t = now + i * stepDur;
|
||
const freq = (i % 2 === 0) ? 440 : 880;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(freq, t);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(0.28, t + 0.006);
|
||
env.gain.setValueAtTime(0.28, t + stepDur - 0.01);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + stepDur);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(t);
|
||
osc.stop(t + stepDur + 0.005);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Dedicated Cassette Transport Clunk & Whirr (Milano / Bebop)
|
||
* Dual square transient click (120 Hz / 360 Hz) followed by 2200 Hz capstan tone with flutter
|
||
*/
|
||
synthesizeCassetteTransport() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Dual square mechanical button clunk
|
||
[120, 360].forEach((freq) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(freq, now);
|
||
osc.frequency.exponentialRampToValueAtTime(40, now + 0.028);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.35, now);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + 0.032);
|
||
});
|
||
|
||
// 12V capstan motor tone + tape flutter
|
||
const motorTime = now + 0.04;
|
||
const motorDur = 0.95;
|
||
|
||
const motor = ctx.createOscillator();
|
||
motor.type = 'sine';
|
||
motor.frequency.setValueAtTime(2200, motorTime);
|
||
|
||
const flutter = ctx.createOscillator();
|
||
flutter.type = 'sine';
|
||
flutter.frequency.setValueAtTime(8.2, motorTime);
|
||
|
||
const flutGain = ctx.createGain();
|
||
flutGain.gain.setValueAtTime(35, motorTime);
|
||
flutter.connect(flutGain);
|
||
flutGain.connect(motor.frequency);
|
||
|
||
const motorEnv = ctx.createGain();
|
||
motorEnv.gain.setValueAtTime(0.001, motorTime);
|
||
motorEnv.gain.linearRampToValueAtTime(0.12, motorTime + 0.08);
|
||
motorEnv.gain.exponentialRampToValueAtTime(0.001, motorTime + motorDur);
|
||
|
||
motor.connect(motorEnv);
|
||
motorEnv.connect(this.gainNode);
|
||
|
||
motor.start(motorTime);
|
||
flutter.start(motorTime);
|
||
motor.stop(motorTime + motorDur);
|
||
flutter.stop(motorTime + motorDur);
|
||
|
||
// Tape noise bed
|
||
const tapeBuf = this.am.createNoiseBuffer('pink', motorDur);
|
||
const tape = ctx.createBufferSource();
|
||
tape.buffer = tapeBuf;
|
||
|
||
const tapeFilter = ctx.createBiquadFilter();
|
||
tapeFilter.type = 'bandpass';
|
||
tapeFilter.frequency.setValueAtTime(3200, motorTime);
|
||
tapeFilter.Q.setValueAtTime(2.0, motorTime);
|
||
|
||
const tapeEnv = ctx.createGain();
|
||
tapeEnv.gain.setValueAtTime(0.001, motorTime);
|
||
tapeEnv.gain.linearRampToValueAtTime(0.08, motorTime + 0.05);
|
||
tapeEnv.gain.exponentialRampToValueAtTime(0.001, motorTime + motorDur);
|
||
|
||
tape.connect(tapeFilter);
|
||
tapeFilter.connect(tapeEnv);
|
||
tapeEnv.connect(this.gainNode);
|
||
|
||
tape.start(motorTime);
|
||
tape.stop(motorTime + motorDur);
|
||
}
|
||
|
||
/**
|
||
* Grappler Arm Servo Motor Whine (Outlaw Star / Bebop)
|
||
* Swept triangle wave (220 Hz -> 680 Hz) through resonant bandpass filter
|
||
*/
|
||
synthesizeGrapplerServo() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.44;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'triangle';
|
||
osc.frequency.setValueAtTime(220, now);
|
||
osc.frequency.exponentialRampToValueAtTime(680, now + duration * 0.7);
|
||
osc.frequency.linearRampToValueAtTime(540, now + duration);
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(320, now);
|
||
bp.frequency.exponentialRampToValueAtTime(820, now + duration * 0.7);
|
||
bp.Q.setValueAtTime(3.2, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.38, now + 0.04);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
osc.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.01);
|
||
}
|
||
|
||
/**
|
||
* True RF Radio Static Burst (The Betty / The Marauder)
|
||
* Bandpass-filtered pink/white noise burst (300–3200 Hz) with hard-knee square gate
|
||
*/
|
||
synthesizeRadioStatic() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.18;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('pink', 0.25);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(1400, now);
|
||
bp.Q.setValueAtTime(1.1, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.42, now);
|
||
env.gain.setValueAtTime(0.40, now + duration - 0.02);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.01);
|
||
}
|
||
|
||
/**
|
||
* Magnetic Boot ("Mag-Boot") Latch (Ceres / Tycho / The Expanse)
|
||
* Low-frequency impact thump (80 Hz click) followed by 120 Hz inductive clamp buzz
|
||
*/
|
||
synthesizeMagBootLatch() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Stage 1: Impact thump
|
||
const thump = ctx.createOscillator();
|
||
thump.type = 'triangle';
|
||
thump.frequency.setValueAtTime(80, now);
|
||
thump.frequency.exponentialRampToValueAtTime(32, now + 0.03);
|
||
|
||
const thumpEnv = ctx.createGain();
|
||
thumpEnv.gain.setValueAtTime(0.5, now);
|
||
thumpEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.035);
|
||
|
||
thump.connect(thumpEnv);
|
||
thumpEnv.connect(this.gainNode);
|
||
thump.start(now);
|
||
thump.stop(now + 0.038);
|
||
|
||
// Stage 2: 120 Hz inductive clamping buzz
|
||
const buzzTime = now + 0.025;
|
||
const buzz = ctx.createOscillator();
|
||
buzz.type = 'square';
|
||
buzz.frequency.setValueAtTime(120, buzzTime);
|
||
|
||
const buzzFilter = ctx.createBiquadFilter();
|
||
buzzFilter.type = 'lowpass';
|
||
buzzFilter.frequency.setValueAtTime(420, buzzTime);
|
||
|
||
const buzzEnv = ctx.createGain();
|
||
buzzEnv.gain.setValueAtTime(0.001, buzzTime);
|
||
buzzEnv.gain.linearRampToValueAtTime(0.32, buzzTime + 0.01);
|
||
buzzEnv.gain.exponentialRampToValueAtTime(0.001, buzzTime + 0.16);
|
||
|
||
buzz.connect(buzzFilter);
|
||
buzzFilter.connect(buzzEnv);
|
||
buzzEnv.connect(this.gainNode);
|
||
|
||
buzz.start(buzzTime);
|
||
buzz.stop(buzzTime + 0.17);
|
||
}
|
||
|
||
/**
|
||
* Background Loop: Periodic Medical Vital Signs Monitor (ECG)
|
||
*/
|
||
startMedicalMonitor(intervalSeconds = 1.1) {
|
||
this.stopMedicalMonitor();
|
||
const tick = () => {
|
||
this.synthesizeMedicalMonitor();
|
||
this.medicalTimer = setTimeout(tick, intervalSeconds * 1000);
|
||
};
|
||
tick();
|
||
}
|
||
|
||
stopMedicalMonitor() {
|
||
if (this.medicalTimer) {
|
||
clearTimeout(this.medicalTimer);
|
||
this.medicalTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Background Loop: Random Electrical Sparks on Damaged Stations
|
||
*/
|
||
startStationSparks(minIntervalMs = 4000, maxIntervalMs = 12000) {
|
||
this.stopStationSparks();
|
||
const scheduleNext = () => {
|
||
const delay = minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs);
|
||
this.sparkTimer = setTimeout(() => {
|
||
this.synthesizeSevastopolSpark();
|
||
scheduleNext();
|
||
}, delay);
|
||
};
|
||
scheduleNext();
|
||
}
|
||
|
||
stopStationSparks() {
|
||
if (this.sparkTimer) {
|
||
clearTimeout(this.sparkTimer);
|
||
this.sparkTimer = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop Registry (Phase 2)
|
||
* Generic recursive-timeout scheduler keyed by name. A start call always
|
||
* kills any previous instance of the same key first. If min === max the
|
||
* cadence is fixed; otherwise it is random within [min, max].
|
||
*/
|
||
_scheduleLoop(key, minIntervalMs, maxIntervalMs, fn) {
|
||
this._stopLoop(key);
|
||
const scheduleNext = () => {
|
||
const delay = maxIntervalMs === minIntervalMs
|
||
? minIntervalMs
|
||
: minIntervalMs + Math.random() * (maxIntervalMs - minIntervalMs);
|
||
this.loopTimers[key] = setTimeout(() => {
|
||
fn();
|
||
scheduleNext();
|
||
}, delay);
|
||
};
|
||
scheduleNext();
|
||
}
|
||
|
||
_stopLoop(key) {
|
||
if (this.loopTimers[key]) {
|
||
clearTimeout(this.loopTimers[key]);
|
||
delete this.loopTimers[key];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Cardassian Bulkhead Door Grind (Star Trek DS9, audio_gaps.md #21)
|
||
* Oppressive pneumatic bulkhead grinding open: resonant bandpass sweep
|
||
* across brown noise (120 Hz -> 650 Hz) layered with a scraping metallic
|
||
* saw undertone.
|
||
*/
|
||
synthesizeCardassianDoor() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 1.4;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('brown', duration + 0.2);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(120, now);
|
||
bp.frequency.exponentialRampToValueAtTime(650, now + 1.1);
|
||
bp.Q.setValueAtTime(3.5, now);
|
||
|
||
// Scraping metallic saw undertone through the same sweeping bandpass
|
||
const saw = ctx.createOscillator();
|
||
saw.type = 'sawtooth';
|
||
saw.frequency.setValueAtTime(110, now);
|
||
saw.frequency.exponentialRampToValueAtTime(70, now + duration);
|
||
const sawGain = ctx.createGain();
|
||
sawGain.gain.setValueAtTime(0.001, now);
|
||
sawGain.gain.linearRampToValueAtTime(0.09, now + 0.6);
|
||
sawGain.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.5, now + 0.4);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
noise.connect(bp);
|
||
saw.connect(sawGain);
|
||
sawGain.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.05);
|
||
saw.start(now);
|
||
saw.stop(now + duration + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Turbolift Pass Whoosh (Star Trek TNG/VOY, audio_gaps.md #22)
|
||
* Bandpass-filtered white noise pitch sweep (180 -> 450 Hz and back) with
|
||
* resonant boost and soft stereo panning drift past the listener.
|
||
*/
|
||
synthesizeTurboliftWhoosh() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 1.6;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('white', duration + 0.15);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(180, now);
|
||
bp.frequency.exponentialRampToValueAtTime(450, now + 0.55);
|
||
bp.frequency.exponentialRampToValueAtTime(180, now + 1.4);
|
||
bp.Q.setValueAtTime(2.2, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.42, now + 0.5);
|
||
env.gain.linearRampToValueAtTime(0.3, now + 0.8);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
let panOut;
|
||
if (ctx.createStereoPanner) {
|
||
const panner = ctx.createStereoPanner();
|
||
panner.pan.setValueAtTime(-0.45, now);
|
||
panner.pan.linearRampToValueAtTime(0.45, now + duration);
|
||
panOut = panner;
|
||
}
|
||
if (panOut) {
|
||
env.connect(panOut);
|
||
panOut.connect(this.gainNode);
|
||
} else {
|
||
env.connect(this.gainNode);
|
||
}
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.02);
|
||
}
|
||
|
||
/**
|
||
* Replicator Materialization Shimmer (Star Trek TNG/VOY, audio_gaps.md #23)
|
||
* High-frequency white-noise shimmer (2.5-8 kHz) amplitude-modulated by a
|
||
* fast 30 Hz sine LFO with a soft ramp decay.
|
||
*/
|
||
synthesizeReplicatorShimmer() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 2.0;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('white', duration + 0.15);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const masterEnv = ctx.createGain();
|
||
masterEnv.gain.setValueAtTime(0.001, now);
|
||
masterEnv.gain.linearRampToValueAtTime(0.3, now + 0.5);
|
||
masterEnv.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
masterEnv.connect(this.gainNode);
|
||
|
||
// Two parallel shimmer bands (2.5-4 kHz and 5-8 kHz)
|
||
const bands = [
|
||
{ center: 3200, q: 1.3, depth: 0.22 },
|
||
{ center: 6400, q: 1.2, depth: 0.16 }
|
||
];
|
||
bands.forEach((band) => {
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(band.center, now);
|
||
bp.Q.setValueAtTime(band.q, now);
|
||
|
||
const amEnv = ctx.createGain();
|
||
amEnv.gain.setValueAtTime(band.depth, now);
|
||
|
||
// 30 Hz shimmer AM
|
||
const lfo = ctx.createOscillator();
|
||
lfo.type = 'sine';
|
||
lfo.frequency.setValueAtTime(30, now);
|
||
const lfoDepth = ctx.createGain();
|
||
lfoDepth.gain.setValueAtTime(band.depth * 0.9, now);
|
||
lfo.connect(lfoDepth);
|
||
lfoDepth.connect(amEnv.gain);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(amEnv);
|
||
amEnv.connect(masterEnv);
|
||
|
||
lfo.start(now);
|
||
lfo.stop(now + duration + 0.05);
|
||
});
|
||
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.02);
|
||
}
|
||
|
||
/**
|
||
* Vorlon Crystal "Singing" Resonance (Bioships, audio_gaps.md #26)
|
||
* Ethereal telepathic crystal harmonics: dual detuned sines (528/532 Hz)
|
||
* through a narrow bandpass with a slow 0.2 Hz undulating tremolo.
|
||
* One 8.5 s swell phrasing.
|
||
*/
|
||
synthesizeVorlonSingingSwell() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 8.5;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(530, now);
|
||
bp.Q.setValueAtTime(22, now);
|
||
|
||
const tremolo = ctx.createGain();
|
||
tremolo.gain.setValueAtTime(0.5, now);
|
||
|
||
const lfo = ctx.createOscillator();
|
||
lfo.type = 'sine';
|
||
lfo.frequency.setValueAtTime(0.2, now);
|
||
const lfoDepth = ctx.createGain();
|
||
lfoDepth.gain.setValueAtTime(0.24, now);
|
||
lfo.connect(lfoDepth);
|
||
lfoDepth.connect(tremolo.gain);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.16, now + 3.0);
|
||
env.gain.linearRampToValueAtTime(0.13, now + 5.5);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
env.connect(this.gainNode);
|
||
|
||
[528, 532].forEach((freq, idx) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, now);
|
||
if (idx === 1) osc.detune.setValueAtTime(3, now);
|
||
osc.connect(bp);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.05);
|
||
});
|
||
|
||
bp.connect(tremolo);
|
||
tremolo.connect(env);
|
||
|
||
lfo.start(now);
|
||
lfo.stop(now + duration + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Vorlon singing resonance while the Vorlon cruiser is engaged
|
||
*/
|
||
startVorlonSong(minIntervalMs = 11000, maxIntervalMs = 18000) {
|
||
this._scheduleLoop('vorlonSong', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeVorlonSingingSwell();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Neural-Bond Swell (Bioships, audio_gaps.md #27)
|
||
* Symbiotic neural link between pilot and bioship swelling with emotion:
|
||
* deep 55 Hz triangle sweeping into a resonant vowel-formant filter
|
||
* (350-850 Hz) over 2.5 seconds.
|
||
*/
|
||
synthesizeNeuralBondSwell() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 3.4;
|
||
|
||
// Sub-bass carrier
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'triangle';
|
||
osc.frequency.setValueAtTime(55, now);
|
||
osc.frequency.linearRampToValueAtTime(52, now + duration);
|
||
|
||
// Formant sweep adding vowel-like harmonics
|
||
const formant = ctx.createBiquadFilter();
|
||
formant.type = 'bandpass';
|
||
formant.frequency.setValueAtTime(350, now);
|
||
formant.frequency.linearRampToValueAtTime(850, now + 2.5);
|
||
formant.Q.setValueAtTime(4.5, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.34, now + 0.9);
|
||
env.gain.linearRampToValueAtTime(0.42, now + 1.9);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
osc.connect(formant);
|
||
formant.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Hull Self-Repair / Regeneration Texture (Bioships, audio_gaps.md #28)
|
||
* Microscopic organic tissue knit and chitin regrowth: granular
|
||
* amplitude-modulated pink noise (8-24 Hz modulation) with wet low
|
||
* regenerative pops. One ~1.8 s regeneration burst.
|
||
*/
|
||
synthesizeHullRegenBurst() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 1.8;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('pink', duration + 0.1);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(650, now);
|
||
bp.frequency.linearRampToValueAtTime(1600, now + duration);
|
||
bp.Q.setValueAtTime(2.0, now);
|
||
|
||
const modEnv = ctx.createGain();
|
||
modEnv.gain.setValueAtTime(0.16, now);
|
||
|
||
// Rapid scabbing modulation (~13 Hz) over the wet tissue bed
|
||
const lfo = ctx.createOscillator();
|
||
lfo.type = 'square';
|
||
lfo.frequency.setValueAtTime(9 + Math.random() * 8, now);
|
||
const lfoDepth = ctx.createGain();
|
||
lfoDepth.gain.setValueAtTime(0.12, now);
|
||
lfo.connect(lfoDepth);
|
||
lfoDepth.connect(modEnv.gain);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.2, now + 0.25);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(modEnv);
|
||
modEnv.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.05);
|
||
lfo.start(now);
|
||
lfo.stop(now + duration + 0.05);
|
||
|
||
// Wet bioplasmic pops (chitin knit)
|
||
const popCount = 2 + Math.floor(Math.random() * 3);
|
||
for (let i = 0; i < popCount; i++) {
|
||
const popTime = now + 0.25 + Math.random() * (duration - 0.6);
|
||
const pop = ctx.createOscillator();
|
||
pop.type = 'sine';
|
||
pop.frequency.setValueAtTime(110 + Math.random() * 60, popTime);
|
||
pop.frequency.exponentialRampToValueAtTime(60, popTime + 0.06);
|
||
const popEnv = ctx.createGain();
|
||
popEnv.gain.setValueAtTime(0.001, popTime);
|
||
popEnv.gain.linearRampToValueAtTime(0.14, popTime + 0.008);
|
||
popEnv.gain.exponentialRampToValueAtTime(0.001, popTime + 0.07);
|
||
pop.connect(popEnv);
|
||
popEnv.connect(this.gainNode);
|
||
pop.start(popTime);
|
||
pop.stop(popTime + 0.08);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Periodic hull regeneration texture on Wraith hives
|
||
*/
|
||
startHullRegen(minIntervalMs = 6000, maxIntervalMs = 13000) {
|
||
this._scheduleLoop('hullRegen', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeHullRegenBurst();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Belter Jury-Rigged Telemetry Jitter (Space Stations, audio_gaps.md #29)
|
||
* Rattle of loose relays, worn copper contactors and erratic voltage drops:
|
||
* noisy square relay pulses with micro-dropouts, contact-bounce double
|
||
* clicks and harsh metallic ticks on a jittered clock. One ~1.1 s cluster.
|
||
*/
|
||
synthesizeBelterTelemetryJitter() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const eventCount = 3 + Math.floor(Math.random() * 5); // 3-7 events
|
||
let cursor = now + 0.05 + Math.random() * 0.2;
|
||
|
||
const relayPulse = (t) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
const baseFreq = 60 + Math.random() * 60;
|
||
osc.frequency.setValueAtTime(baseFreq, t);
|
||
osc.frequency.linearRampToValueAtTime(baseFreq * 0.8, t + 0.06);
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(420, t);
|
||
const env = ctx.createGain();
|
||
// Erratic voltage: randomized micro-dropouts in the pulse body
|
||
env.gain.setValueAtTime(0.17, t);
|
||
env.gain.setValueAtTime(0.001, t + 0.012 + Math.random() * 0.015);
|
||
env.gain.setValueAtTime(0.14, t + 0.026 + Math.random() * 0.02);
|
||
env.gain.setValueAtTime(0.001, t + 0.05 + Math.random() * 0.025);
|
||
env.gain.setValueAtTime(0.1, t + 0.07);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.1);
|
||
osc.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.11);
|
||
};
|
||
|
||
const contactBounce = (t) => {
|
||
// Worn contactor: double/triple click 1-3 ms apart
|
||
const clicks = 2 + (Math.random() < 0.35 ? 1 : 0);
|
||
for (let i = 0; i < clicks; i++) {
|
||
const ct = t + i * (0.001 + Math.random() * 0.002);
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(500 + Math.random() * 500, ct);
|
||
osc.frequency.exponentialRampToValueAtTime(120, ct + 0.008);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.14, ct);
|
||
env.gain.exponentialRampToValueAtTime(0.001, ct + 0.012);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(ct);
|
||
osc.stop(ct + 0.015);
|
||
}
|
||
};
|
||
|
||
const metallicTick = (t) => {
|
||
const buf = this.am.createNoiseBuffer('white', 0.05);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(400 + Math.random() * 1400, t);
|
||
bp.Q.setValueAtTime(6, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.09, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.02);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.025);
|
||
};
|
||
|
||
for (let i = 0; i < eventCount; i++) {
|
||
const kind = Math.random();
|
||
if (kind < 0.4) relayPulse(cursor);
|
||
else if (kind < 0.75) contactBounce(cursor);
|
||
else metallicTick(cursor);
|
||
cursor += 0.08 + Math.random() * 0.22; // jittered clock drift
|
||
}
|
||
}
|
||
|
||
/**
|
||
* True Hull Strain Moan (Deep Space, audio_gaps.md #30)
|
||
* Massive structural groan of stressed bulkheads flexing under gravitational
|
||
* shear: resonant bandpass slowly sweeping 40-220 Hz (high Q) over shaped
|
||
* brown noise with an asymmetric attack/decay. ~5.5 s.
|
||
*/
|
||
synthesizeHullStrainMoan() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 5.5;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('brown', duration + 0.3);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(48, now);
|
||
bp.frequency.exponentialRampToValueAtTime(210, now + 3.4);
|
||
bp.Q.setValueAtTime(10, now);
|
||
|
||
// Asymmetric envelope: slow menacing rise, long creaking decay
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.3, now + 0.9);
|
||
env.gain.linearRampToValueAtTime(0.22, now + 1.7);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Sporadic structural strain moans on haunted deep-space hulls
|
||
*/
|
||
startHullStrainMoans(minIntervalMs = 16000, maxIntervalMs = 40000) {
|
||
this._scheduleLoop('hullStrainMoans', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeHullStrainMoan();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Icarus I Distress Beacon (Deep Space, audio_gaps.md #31)
|
||
* Eerie hypnotic modal arpeggio echoing from the ghost ship: a 4-note
|
||
* modal sine sequence through a long feedback delay with soft lowpass
|
||
* dampening. ~7 s including delay tail.
|
||
*/
|
||
synthesizeIcarusBeacon() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const notes = [220.0, 261.63, 329.63, 440.0]; // A minor modal beacon
|
||
|
||
const delay = ctx.createDelay(1.2);
|
||
delay.delayTime.setValueAtTime(0.45, now);
|
||
const feedback = ctx.createGain();
|
||
feedback.gain.setValueAtTime(0.35, now);
|
||
const dampen = ctx.createBiquadFilter();
|
||
dampen.type = 'lowpass';
|
||
dampen.frequency.setValueAtTime(2200, now);
|
||
const wet = ctx.createGain();
|
||
wet.gain.setValueAtTime(0.5, now);
|
||
|
||
delay.connect(feedback);
|
||
feedback.connect(dampen);
|
||
dampen.connect(delay);
|
||
delay.connect(wet);
|
||
wet.connect(this.gainNode);
|
||
|
||
notes.forEach((freq, idx) => {
|
||
const t = now + idx * 0.95;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(0.17, t + 0.05);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.85);
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
env.connect(delay);
|
||
osc.start(t);
|
||
osc.stop(t + 0.9);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Periodic distress beacon arpeggio while Icarus II is engaged
|
||
*/
|
||
startIcarusBeacon(minIntervalMs = 22000, maxIntervalMs = 40000) {
|
||
this._scheduleLoop('icarusBeacon', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeIcarusBeacon();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Extradimensional Psychic Static Whisper (Deep Space, audio_gaps.md #32)
|
||
* Menacing psychoacoustic burst: multi-formant filtered white noise with
|
||
* randomized micro-envelopes evoking whispered vowels (800-2400 Hz).
|
||
*/
|
||
synthesizeVoidWhisper() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 2.2;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('white', duration + 0.1);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.12, now + 0.4);
|
||
env.gain.linearRampToValueAtTime(0.09, now + 1.3);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
env.connect(this.gainNode);
|
||
|
||
// Three parallel whispered-vowel formants with independent wobbles
|
||
const formants = [
|
||
{ center: 700, q: 7, wobbleHz: 0.8, wobbleAmt: 160 },
|
||
{ center: 1250, q: 8, wobbleHz: 1.1, wobbleAmt: 280 },
|
||
{ center: 2200, q: 10, wobbleHz: 1.6, wobbleAmt: 420 }
|
||
];
|
||
formants.forEach((formant) => {
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(formant.center, now);
|
||
bp.Q.setValueAtTime(formant.q, now);
|
||
|
||
const wobble = ctx.createOscillator();
|
||
wobble.type = 'sine';
|
||
wobble.frequency.setValueAtTime(formant.wobbleHz, now);
|
||
const wobbleDepth = ctx.createGain();
|
||
wobbleDepth.gain.setValueAtTime(formant.wobbleAmt, now);
|
||
wobble.connect(wobbleDepth);
|
||
wobbleDepth.connect(bp.frequency);
|
||
|
||
const breath = ctx.createGain();
|
||
breath.gain.setValueAtTime(0.3, now);
|
||
// Randomized micro-envelopes: syllable-like gulps of the formant
|
||
const gulpCount = 3 + Math.floor(Math.random() * 3);
|
||
for (let i = 0; i < gulpCount; i++) {
|
||
const gt = now + 0.15 + Math.random() * (duration - 0.55);
|
||
breath.gain.setValueAtTime(0.12, gt);
|
||
breath.gain.linearRampToValueAtTime(0.3 + Math.random() * 0.18, gt + 0.04);
|
||
breath.gain.linearRampToValueAtTime(0.05, gt + 0.12 + Math.random() * 0.1);
|
||
}
|
||
|
||
noise.connect(bp);
|
||
bp.connect(breath);
|
||
breath.connect(env);
|
||
|
||
wobble.start(now);
|
||
wobble.stop(now + duration + 0.05);
|
||
});
|
||
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.02);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Psychic whisper bursts on haunted deep-space hulls
|
||
*/
|
||
startVoidWhispers(minIntervalMs = 15000, maxIntervalMs = 32000) {
|
||
this._scheduleLoop('voidWhispers', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeVoidWhisper();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Beryllium Sphere Resonant Thrum (Comedy, audio_gaps.md #33)
|
||
* Deep glassy crystalline reactor hum: dual pure sines (65 + 195 Hz third
|
||
* harmonic) with slow beating detune and a subtle comb-filter ring.
|
||
* One ~10 s swell phrasing.
|
||
*/
|
||
synthesizeBerylliumThrumSwell() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 10.0;
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.16, now + 4.0);
|
||
env.gain.linearRampToValueAtTime(0.14, now + 6.5);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
env.connect(this.gainNode);
|
||
|
||
// Comb ring: short 5.1 ms reflection
|
||
const comb = ctx.createDelay(0.05);
|
||
comb.delayTime.setValueAtTime(0.0051, now);
|
||
const combFb = ctx.createGain();
|
||
combFb.gain.setValueAtTime(0.45, now);
|
||
comb.connect(combFb);
|
||
combFb.connect(comb);
|
||
const combWet = ctx.createGain();
|
||
combWet.gain.setValueAtTime(0.6, now);
|
||
comb.connect(combWet);
|
||
combWet.connect(env);
|
||
|
||
const partials = [
|
||
{ freq: 65, amp: 0.55 },
|
||
{ freq: 65.3, amp: 0.3 }, // slow 0.3 Hz beating partner
|
||
{ freq: 195, amp: 0.3 }
|
||
];
|
||
partials.forEach((partial) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(partial.freq, now);
|
||
const g = ctx.createGain();
|
||
g.gain.setValueAtTime(partial.amp, now);
|
||
osc.connect(g);
|
||
g.connect(env);
|
||
g.connect(comb);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.05);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Beryllium sphere thrum while the NSEA Protector is engaged
|
||
*/
|
||
startBerylliumThrum(minIntervalMs = 9000, maxIntervalMs = 16000) {
|
||
this._scheduleLoop('berylliumThrum', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeBerylliumThrumSwell();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Omega-13 Temporal Capacitor Whine (Comedy, audio_gaps.md #34)
|
||
* Tremendous temporal capacitor power buildup: deep 40 Hz sub-bass swelling
|
||
* exponentially to 3200 Hz over 3 seconds, culminating in a wide
|
||
* white-noise discharge pop.
|
||
*/
|
||
synthesizeOmega13Whine() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 3.1;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(40, now);
|
||
osc.frequency.exponentialRampToValueAtTime(3200, now + 3.0);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.exponentialRampToValueAtTime(0.5, now + 2.7);
|
||
env.gain.setValueAtTime(0.5, now + 2.95);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + 3.2);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.1);
|
||
|
||
// Dimensional discharge pop (wide white-noise transient)
|
||
const popTime = now + 3.0;
|
||
const popBuf = this.am.createNoiseBuffer('white', 0.3);
|
||
const pop = ctx.createBufferSource();
|
||
pop.buffer = popBuf;
|
||
const hp = ctx.createBiquadFilter();
|
||
hp.type = 'highpass';
|
||
hp.frequency.setValueAtTime(2000, popTime);
|
||
const popEnv = ctx.createGain();
|
||
popEnv.gain.setValueAtTime(0.001, popTime);
|
||
popEnv.gain.linearRampToValueAtTime(0.42, popTime + 0.008);
|
||
popEnv.gain.exponentialRampToValueAtTime(0.001, popTime + 0.16);
|
||
pop.connect(hp);
|
||
hp.connect(popEnv);
|
||
popEnv.connect(this.gainNode);
|
||
pop.start(popTime);
|
||
pop.stop(popTime + 0.18);
|
||
}
|
||
|
||
/**
|
||
* Repulsorlift Engine Drone (Outlaw, audio_gaps.md #35)
|
||
* Quintessential anti-gravity vehicle wash: dual detuned sines (68/72 Hz)
|
||
* through an asymmetric overdrive waveshaper, lowpass-filtered at 280 Hz.
|
||
* One ~10 s wash phrasing.
|
||
*/
|
||
synthesizeRepulsorliftDrone() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 10.0;
|
||
|
||
let shaperOut = null;
|
||
if (ctx.createWaveShaper) {
|
||
const shaper = ctx.createWaveShaper();
|
||
shaper.oversample = '2x';
|
||
const curve = new Float32Array(1024);
|
||
for (let i = 0; i < 1024; i++) {
|
||
const x = (i / 512) - 1; // -1..1
|
||
curve[i] = Math.tanh(2.4 * x); // asymmetric-ish saturation
|
||
}
|
||
shaper.curve = curve;
|
||
shaperOut = shaper;
|
||
}
|
||
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(280, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.2, now + 2.6);
|
||
env.gain.linearRampToValueAtTime(0.17, now + 7.2);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
|
||
if (shaperOut) shaperOut.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
[68, 72].forEach((freq) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(freq, now);
|
||
osc.connect(shaperOut || lp);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.05);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Repulsorlift wash while the Marauder is engaged
|
||
*/
|
||
startRepulsorliftDrone(intervalMs = 13000) {
|
||
this._scheduleLoop('repulsorliftDrone', intervalMs, intervalMs, () => {
|
||
this.synthesizeRepulsorliftDrone();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Converted Marine Trawler Engine Chug (Outlaw, audio_gaps.md #36)
|
||
* Rhythmic heavy diesel-like piston strokes: low-frequency square pulses
|
||
* (4-8 Hz cadence) through an 80 Hz resonant lowpass with mechanical
|
||
* piston wheeze. One cluster of 2-4 strokes.
|
||
*/
|
||
synthesizeBebopChugStroke() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const strokes = 2 + Math.floor(Math.random() * 3); // 2-4 strokes
|
||
|
||
const stroke = (t, peak) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'square';
|
||
osc.frequency.setValueAtTime(45 + Math.random() * 25, t);
|
||
osc.frequency.linearRampToValueAtTime(38, t + 0.3);
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(80, t);
|
||
lp.Q.setValueAtTime(3, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(peak, t + 0.02);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.42);
|
||
osc.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(t);
|
||
osc.stop(t + 0.45);
|
||
|
||
// Mechanical piston wheeze
|
||
const wheezeBuf = this.am.createNoiseBuffer('pink', 0.3);
|
||
const wheeze = ctx.createBufferSource();
|
||
wheeze.buffer = wheezeBuf;
|
||
const wheezeBp = ctx.createBiquadFilter();
|
||
wheezeBp.type = 'bandpass';
|
||
wheezeBp.frequency.setValueAtTime(380, t);
|
||
wheezeBp.Q.setValueAtTime(1.6, t);
|
||
const wheezeEnv = ctx.createGain();
|
||
wheezeEnv.gain.setValueAtTime(0.001, t + 0.02);
|
||
wheezeEnv.gain.linearRampToValueAtTime(0.05, t + 0.08);
|
||
wheezeEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
|
||
wheeze.connect(wheezeBp);
|
||
wheezeBp.connect(wheezeEnv);
|
||
wheezeEnv.connect(this.gainNode);
|
||
wheeze.start(t);
|
||
wheeze.stop(t + 0.32);
|
||
};
|
||
|
||
for (let i = 0; i < strokes; i++) {
|
||
stroke(now + i * (0.34 + Math.random() * 0.2), 0.3 - i * 0.04);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Rhythmic diesel chug while the Bebop is engaged
|
||
*/
|
||
startBebopChug(minIntervalMs = 2200, maxIntervalMs = 4200) {
|
||
this._scheduleLoop('bebopChug', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeBebopChugStroke();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Centrifugal Habitat Carousel Motor Groan (Military, audio_gaps.md #37)
|
||
* Deep rotational strain of a kilometer-long rotating drum: sub-audible
|
||
* 16 Hz rotational pair with bearing harmonics, modulated by a single
|
||
* ~1 RPM (0.0167 Hz) revolution LFO. One 58 s rotation pass.
|
||
*/
|
||
synthesizeCarouselGroanCycle() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 58.0;
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.55, now + 8.0);
|
||
env.gain.linearRampToValueAtTime(0.5, now + 50.0);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
env.connect(this.gainNode);
|
||
|
||
// One rotation per ~60 s: slow strain swell and release
|
||
const rotation = ctx.createOscillator();
|
||
rotation.type = 'sine';
|
||
rotation.frequency.setValueAtTime(1 / 60, now);
|
||
const rotationDepth = ctx.createGain();
|
||
rotationDepth.gain.setValueAtTime(0.38, now);
|
||
rotation.connect(rotationDepth);
|
||
rotationDepth.connect(env.gain);
|
||
|
||
const partials = [
|
||
{ freq: 16, amp: 0.55 },
|
||
{ freq: 16.04, amp: 0.32 }, // rotational beating pair
|
||
{ freq: 48, amp: 0.22 }, // structural harmonic
|
||
{ freq: 96, amp: 0.07 } // bearing hum harmonic
|
||
];
|
||
partials.forEach((partial) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(partial.freq, now);
|
||
const g = ctx.createGain();
|
||
g.gain.setValueAtTime(partial.amp, now);
|
||
osc.connect(g);
|
||
g.connect(env);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.1);
|
||
});
|
||
|
||
rotation.start(now);
|
||
rotation.stop(now + duration + 0.1);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Carousel rotation groan while the Agamemnon is engaged
|
||
* (one 58 s pass every 60 s; the 2 s gap reads as a rotation splice)
|
||
*/
|
||
startCarouselGroan(intervalMs = 60000) {
|
||
this._scheduleLoop('carouselGroan', intervalMs, intervalMs, () => {
|
||
this.synthesizeCarouselGroanCycle();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Slipstream Transition Surge (Military, audio_gaps.md #38)
|
||
* Ship tearing through an exotic slipstream: dual sweeping bandpass surges
|
||
* (200 -> 2400 Hz) with escalating resonance Q and a comb-filter shimmer
|
||
* tail. ~4.2 s.
|
||
*/
|
||
synthesizeSlipstreamSurge() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Comb shimmer tail bus
|
||
const shimmer = ctx.createDelay(0.5);
|
||
shimmer.delayTime.setValueAtTime(0.12, now);
|
||
const shimmerFb = ctx.createGain();
|
||
shimmerFb.gain.setValueAtTime(0.3, now);
|
||
const shimmerLp = ctx.createBiquadFilter();
|
||
shimmerLp.type = 'lowpass';
|
||
shimmerLp.frequency.setValueAtTime(4000, now);
|
||
shimmer.connect(shimmerFb);
|
||
shimmerFb.connect(shimmerLp);
|
||
shimmerLp.connect(shimmer);
|
||
const shimmerWet = ctx.createGain();
|
||
shimmerWet.gain.setValueAtTime(0.5, now);
|
||
shimmer.connect(shimmerWet);
|
||
shimmerWet.connect(this.gainNode);
|
||
|
||
const surge = (offset, peak) => {
|
||
const t = now + offset;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sawtooth';
|
||
osc.frequency.setValueAtTime(200, t);
|
||
osc.frequency.exponentialRampToValueAtTime(2400, t + 2.6);
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(200, t);
|
||
bp.frequency.exponentialRampToValueAtTime(2400, t + 2.6);
|
||
bp.Q.setValueAtTime(2, t);
|
||
bp.Q.linearRampToValueAtTime(10, t + 2.6);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(peak, t + 1.4);
|
||
env.gain.setValueAtTime(peak, t + 2.7);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 3.8);
|
||
|
||
osc.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
env.connect(shimmer);
|
||
osc.start(t);
|
||
osc.stop(t + 4.0);
|
||
};
|
||
|
||
surge(0, 0.22);
|
||
surge(0.3, 0.16);
|
||
}
|
||
|
||
/**
|
||
* Viper Pilot Oxygen Regulator Demand Valve (Military, audio_gaps.md #39)
|
||
* Pulsing rebreather of a Viper pilot mid-combat: a sharp 15 ms mechanical
|
||
* diaphragm click preceding pulsed highpass-filtered white noise breath
|
||
* (1.2-4.5 kHz, ~1.1 s). One breath cycle.
|
||
*/
|
||
synthesizeOxygenRegulatorCycle() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Sharp mechanical diaphragm click
|
||
const click = ctx.createOscillator();
|
||
click.type = 'triangle';
|
||
click.frequency.setValueAtTime(1800, now);
|
||
click.frequency.exponentialRampToValueAtTime(600, now + 0.015);
|
||
const clickEnv = ctx.createGain();
|
||
clickEnv.gain.setValueAtTime(0.3, now);
|
||
clickEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.02);
|
||
click.connect(clickEnv);
|
||
clickEnv.connect(this.gainNode);
|
||
click.start(now);
|
||
click.stop(now + 0.025);
|
||
|
||
// Regulated airflow breath
|
||
const noiseBuf = this.am.createNoiseBuffer('white', 1.3);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
const hp = ctx.createBiquadFilter();
|
||
hp.type = 'highpass';
|
||
hp.frequency.setValueAtTime(1200, now + 0.02);
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(4500, now + 0.02);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now + 0.02);
|
||
env.gain.linearRampToValueAtTime(0.13, now + 0.45);
|
||
env.gain.linearRampToValueAtTime(0.09, now + 0.62);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + 1.15);
|
||
noise.connect(hp);
|
||
hp.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(now + 0.02);
|
||
noise.stop(now + 1.2);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Rhythmic oxygen demand breathing while a Viper cockpit
|
||
* is engaged
|
||
*/
|
||
startOxygenRegulator(minIntervalMs = 3400, maxIntervalMs = 5200) {
|
||
this._scheduleLoop('oxygenRegulator', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeOxygenRegulatorCycle();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Condensation Pipe Drip & Expansion Tick (Industrial, audio_gaps.md #40)
|
||
* Lonely water droplet pinging inside a kilometres-long cargo hauler:
|
||
* high-Q sine pings (1400-2600 Hz) with fast exponential decay on a
|
||
* randomized clock. One drip.
|
||
*/
|
||
synthesizePipeDrip() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const decay = 0.01 + Math.random() * 0.015;
|
||
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
const freq = 1400 + Math.random() * 1200;
|
||
osc.frequency.setValueAtTime(freq, now);
|
||
osc.frequency.exponentialRampToValueAtTime(freq * 0.92, now + decay);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.1 + Math.random() * 0.2, now + 0.001);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + decay + 0.01);
|
||
|
||
osc.connect(env);
|
||
env.connect(this.gainNode);
|
||
osc.start(now);
|
||
osc.stop(now + decay + 0.03);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Sporadic condensation drips while the Nostromo is engaged
|
||
*/
|
||
startPipeDrips(minIntervalMs = 500, maxIntervalMs = 3500) {
|
||
this._scheduleLoop('pipeDrips', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizePipeDrip();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* High-Pressure Steam / Boiler Venting (Industrial, audio_gaps.md #41)
|
||
* Continuous hot-vent hiss of a steam-era mining vessel: bandpass-filtered
|
||
* white noise (800-2800 Hz) with a slow amplitude swell and random
|
||
* micro-flutter. One ~9 s vent pass.
|
||
*/
|
||
synthesizeSteamVentSwell() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 9.0;
|
||
|
||
// Looping noise bed (short buffer, sustained vent)
|
||
const noiseBuf = this.am.createNoiseBuffer('white', 4.0);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
noise.loop = true;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(1500, now);
|
||
bp.Q.setValueAtTime(0.7, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.17, now + 3.0);
|
||
env.gain.linearRampToValueAtTime(0.15, now + 5.5);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
|
||
// Boiler pressure micro-flutter (~6-9 Hz jitter)
|
||
const flutter = ctx.createOscillator();
|
||
flutter.type = 'sine';
|
||
flutter.frequency.setValueAtTime(6 + Math.random() * 3, now);
|
||
const flutterDepth = ctx.createGain();
|
||
flutterDepth.gain.setValueAtTime(0.04, now);
|
||
flutter.connect(flutterDepth);
|
||
flutterDepth.connect(env.gain);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(now);
|
||
noise.stop(now + duration + 0.1);
|
||
flutter.start(now);
|
||
flutter.stop(now + duration + 0.1);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Boiler vent passes while Serenity-class and Starbug
|
||
* presets are engaged
|
||
*/
|
||
startSteamVent(minIntervalMs = 6000, maxIntervalMs = 10000) {
|
||
this._scheduleLoop('steamVent', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeSteamVentSwell();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Crash-Couch Hydraulic Gimbal Strain (Industrial, audio_gaps.md #42)
|
||
* Pilot's couch swinging into launch position: FM triangle carrier
|
||
* (140 Hz / 35 Hz modulator) through lowpass damping that tracks the
|
||
* acceleration strain. ~1.9 s.
|
||
*/
|
||
synthesizeCrashCouchGimbal() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 1.9;
|
||
|
||
const layer = (offset, carrierFreq, modFreq) => {
|
||
const t = now + offset;
|
||
const carrier = ctx.createOscillator();
|
||
carrier.type = 'triangle';
|
||
carrier.frequency.setValueAtTime(carrierFreq, t);
|
||
|
||
const mod = ctx.createOscillator();
|
||
mod.type = 'sine';
|
||
mod.frequency.setValueAtTime(modFreq, t);
|
||
const modDepth = ctx.createGain();
|
||
modDepth.gain.setValueAtTime(carrierFreq * 4, t);
|
||
mod.connect(modDepth);
|
||
modDepth.connect(carrier.frequency);
|
||
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(300, t);
|
||
lp.frequency.linearRampToValueAtTime(900, t + 0.5);
|
||
lp.frequency.exponentialRampToValueAtTime(350, t + duration - offset);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime(0.24, t + 0.55);
|
||
env.gain.linearRampToValueAtTime(0.2, t + 1.0);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, t + duration - offset);
|
||
|
||
carrier.connect(lp);
|
||
lp.connect(env);
|
||
env.connect(this.gainNode);
|
||
carrier.start(t);
|
||
carrier.stop(t + (duration - offset) + 0.05);
|
||
mod.start(t);
|
||
mod.stop(t + (duration - offset) + 0.05);
|
||
};
|
||
|
||
layer(0, 140, 35);
|
||
layer(0.42, 152, 38);
|
||
}
|
||
|
||
/**
|
||
* HAL 9000 Breathing Loop (Retro Future, audio_gaps.md #43)
|
||
* Eerie slow respiration of a quiet, observant ship computer: rhythmic
|
||
* bandpass-filtered pink noise (450-1100 Hz) with a gentle 3.5 s
|
||
* inhalation/exhalation envelope. One breath.
|
||
*/
|
||
synthesizeHalBreathCycle() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const inhale = 1.3;
|
||
const exhale = 2.2;
|
||
|
||
const noiseBuf = this.am.createNoiseBuffer('pink', 4.0);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = noiseBuf;
|
||
noise.loop = true;
|
||
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(700, now);
|
||
bp.Q.setValueAtTime(1.2, now);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.085, now + inhale);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + inhale + exhale);
|
||
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(now);
|
||
noise.stop(now + inhale + exhale + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Continuous respiration while the Discovery One is engaged
|
||
*/
|
||
startHalBreathing(intervalMs = 3500) {
|
||
this._scheduleLoop('halBreathing', intervalMs, intervalMs, () => {
|
||
this.synthesizeHalBreathCycle();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Death Blossom Energy Surge Ramp (Retro Future, audio_gaps.md #44)
|
||
* Fearsome gunstar overdrive: cascaded sawtooth exponential ramp
|
||
* (200 Hz -> 8000 Hz over 2.5 s) with rising overdrive, cutting off
|
||
* abruptly at full bloom.
|
||
*/
|
||
synthesizeDeathBlossomSurge() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const rampDur = 2.5;
|
||
|
||
const hp = ctx.createBiquadFilter();
|
||
hp.type = 'highpass';
|
||
hp.frequency.setValueAtTime(400, now);
|
||
hp.frequency.exponentialRampToValueAtTime(4000, now + rampDur);
|
||
|
||
const hp2 = ctx.createBiquadFilter();
|
||
hp2.type = 'highpass';
|
||
hp2.frequency.setValueAtTime(200, now);
|
||
hp2.frequency.exponentialRampToValueAtTime(2000, now + rampDur);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, now);
|
||
env.gain.linearRampToValueAtTime(0.32, now + rampDur);
|
||
env.gain.setValueAtTime(0.32, now + rampDur + 0.03);
|
||
env.gain.exponentialRampToValueAtTime(0.001, now + rampDur + 0.4);
|
||
|
||
hp.connect(hp2);
|
||
hp2.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
// Cascaded overdriven saws with staggered ignition
|
||
[0, 0.15, 0.3].forEach((offset, idx) => {
|
||
const t = now + offset;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sawtooth';
|
||
osc.frequency.setValueAtTime(200, t);
|
||
osc.frequency.exponentialRampToValueAtTime(8000, t + rampDur - offset);
|
||
const g = ctx.createGain();
|
||
g.gain.setValueAtTime(0.33 - idx * 0.08, t);
|
||
osc.connect(g);
|
||
g.connect(hp);
|
||
osc.start(t);
|
||
osc.stop(t + (rampDur - offset) + 0.45);
|
||
});
|
||
|
||
// Abrupt bloom transient
|
||
const burstTime = now + rampDur;
|
||
const burstBuf = this.am.createNoiseBuffer('white', 0.25);
|
||
const burst = ctx.createBufferSource();
|
||
burst.buffer = burstBuf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(3500, burstTime);
|
||
bp.Q.setValueAtTime(1.5, burstTime);
|
||
const burstEnv = ctx.createGain();
|
||
burstEnv.gain.setValueAtTime(0.001, burstTime);
|
||
burstEnv.gain.linearRampToValueAtTime(0.3, burstTime + 0.01);
|
||
burstEnv.gain.exponentialRampToValueAtTime(0.001, burstTime + 0.22);
|
||
burst.connect(bp);
|
||
bp.connect(burstEnv);
|
||
burstEnv.connect(this.gainNode);
|
||
burst.start(burstTime);
|
||
burst.stop(burstTime + 0.25);
|
||
}
|
||
|
||
/**
|
||
* Victorian Steam Piston Chug (Retro Future, audio_gaps.md #45)
|
||
* The steam-age skeleton of the Cygnus: lowpass-filtered noise bursts
|
||
* synchronized with heavy 40 Hz triangle thumps and a metallic slapback.
|
||
* One cluster of 2-3 stroke cycles.
|
||
*/
|
||
synthesizeCygnusPistonChug() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const strokes = 2 + Math.floor(Math.random() * 2); // 2-3 strokes
|
||
|
||
const slap = ctx.createDelay(0.6);
|
||
slap.delayTime.setValueAtTime(0.35, now);
|
||
const slapFb = ctx.createGain();
|
||
slapFb.gain.setValueAtTime(0.3, now);
|
||
const slapWet = ctx.createGain();
|
||
slapWet.gain.setValueAtTime(0.4, now);
|
||
slap.connect(slapFb);
|
||
slapFb.connect(slap);
|
||
slap.connect(slapWet);
|
||
slapWet.connect(this.gainNode);
|
||
|
||
const stroke = (t, peak) => {
|
||
// Heavy piston body thump
|
||
const thump = ctx.createOscillator();
|
||
thump.type = 'triangle';
|
||
thump.frequency.setValueAtTime(40, t);
|
||
thump.frequency.linearRampToValueAtTime(32, t + 0.4);
|
||
const lp = ctx.createBiquadFilter();
|
||
lp.type = 'lowpass';
|
||
lp.frequency.setValueAtTime(120, t);
|
||
const thumpEnv = ctx.createGain();
|
||
thumpEnv.gain.setValueAtTime(0.001, t);
|
||
thumpEnv.gain.linearRampToValueAtTime(peak, t + 0.02);
|
||
thumpEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
|
||
thump.connect(lp);
|
||
lp.connect(thumpEnv);
|
||
thumpEnv.connect(this.gainNode);
|
||
thumpEnv.connect(slap);
|
||
thump.start(t);
|
||
thump.stop(t + 0.5);
|
||
|
||
// Synchronized steam exhaust puff
|
||
const puffBuf = this.am.createNoiseBuffer('white', 0.16);
|
||
const puff = ctx.createBufferSource();
|
||
puff.buffer = puffBuf;
|
||
const puffLp = ctx.createBiquadFilter();
|
||
puffLp.type = 'lowpass';
|
||
puffLp.frequency.setValueAtTime(500, t);
|
||
const puffEnv = ctx.createGain();
|
||
puffEnv.gain.setValueAtTime(0.001, t + 0.01);
|
||
puffEnv.gain.linearRampToValueAtTime(0.13, t + 0.03);
|
||
puffEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.15);
|
||
puff.connect(puffLp);
|
||
puffLp.connect(puffEnv);
|
||
puffEnv.connect(this.gainNode);
|
||
puff.start(t);
|
||
puff.stop(t + 0.17);
|
||
};
|
||
|
||
for (let i = 0; i < strokes; i++) {
|
||
stroke(now + i * (0.5 + Math.random() * 0.25), 0.26 - i * 0.03);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ambient Loop: Steam piston chug while the Cygnus is engaged
|
||
*/
|
||
startCygnusChug(minIntervalMs = 1800, maxIntervalMs = 3200) {
|
||
this._scheduleLoop('cygnusChug', minIntervalMs, maxIntervalMs, () => {
|
||
this.synthesizeCygnusPistonChug();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Transporter Dematerialization / Rematerialization Cycle (audio_gaps.md #48)
|
||
* The classic shimmering beam-up wash: an additive array of 5 detuned sines
|
||
* (330-990 Hz over a 660 Hz base) each interlocked with its own 5-11 Hz AM
|
||
* LFO, plus a high bandpass noise shimmer gated by a 30 Hz LFO, all under a
|
||
* slow rise / mid dip / release master envelope. ~3.0 s.
|
||
*/
|
||
synthesizeTransporterCycle() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 3.0;
|
||
|
||
// Tonal shimmer master envelope (dip mid-cycle reads as the demat/remat split)
|
||
const masterEnv = ctx.createGain();
|
||
masterEnv.gain.setValueAtTime(0.001, now);
|
||
masterEnv.gain.linearRampToValueAtTime(0.15, now + 0.9);
|
||
masterEnv.gain.setValueAtTime(0.1, now + 1.35);
|
||
masterEnv.gain.linearRampToValueAtTime(0.17, now + 1.95);
|
||
masterEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
masterEnv.connect(this.gainNode);
|
||
|
||
const ratios = [0.5, 0.75, 1.0, 1.25, 1.5];
|
||
ratios.forEach((ratio, idx) => {
|
||
const osc = ctx.createOscillator();
|
||
osc.type = 'sine';
|
||
osc.frequency.setValueAtTime(660 * ratio, now);
|
||
osc.detune.setValueAtTime((idx % 2 ? 1 : -1) * (1.5 + Math.random() * 4.5), now);
|
||
|
||
// Per-voice interlocking amplitude modulation (staggered 5-11 Hz LFOs)
|
||
const amBase = 0.5 + Math.random() * 0.2;
|
||
const voiceEnv = ctx.createGain();
|
||
voiceEnv.gain.setValueAtTime(amBase, now);
|
||
const lfo = ctx.createOscillator();
|
||
lfo.type = 'sine';
|
||
lfo.frequency.setValueAtTime(5 + idx * 1.5 + Math.random(), now);
|
||
const amDepth = ctx.createGain();
|
||
amDepth.gain.setValueAtTime(amBase * 0.7, now);
|
||
lfo.connect(amDepth);
|
||
amDepth.connect(voiceEnv.gain);
|
||
|
||
osc.connect(voiceEnv);
|
||
voiceEnv.connect(masterEnv);
|
||
osc.start(now);
|
||
osc.stop(now + duration + 0.1);
|
||
lfo.start(now);
|
||
lfo.stop(now + duration + 0.1);
|
||
});
|
||
|
||
// High-frequency energy shimmer (4-9 kHz) following a parallel envelope
|
||
const shimmerEnv = ctx.createGain();
|
||
shimmerEnv.gain.setValueAtTime(0.001, now);
|
||
shimmerEnv.gain.linearRampToValueAtTime(0.05, now + 0.9);
|
||
shimmerEnv.gain.setValueAtTime(0.035, now + 1.35);
|
||
shimmerEnv.gain.linearRampToValueAtTime(0.055, now + 1.95);
|
||
shimmerEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
shimmerEnv.connect(this.gainNode);
|
||
|
||
const shimmerBuf = this.am.createNoiseBuffer('white', duration + 0.2);
|
||
const shimmer = ctx.createBufferSource();
|
||
shimmer.buffer = shimmerBuf;
|
||
[4500, 7800].forEach((center) => {
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(center, now);
|
||
bp.Q.setValueAtTime(1.2, now);
|
||
const amEnv = ctx.createGain();
|
||
amEnv.gain.setValueAtTime(center === 4500 ? 0.6 : 0.4, now);
|
||
const lfo = ctx.createOscillator();
|
||
lfo.type = 'sine';
|
||
lfo.frequency.setValueAtTime(30, now);
|
||
const lfoDepth = ctx.createGain();
|
||
lfoDepth.gain.setValueAtTime(amEnv.gain.value * 0.9, now);
|
||
lfo.connect(lfoDepth);
|
||
lfoDepth.connect(amEnv.gain);
|
||
shimmer.connect(bp);
|
||
bp.connect(amEnv);
|
||
amEnv.connect(shimmerEnv);
|
||
lfo.start(now);
|
||
lfo.stop(now + duration + 0.1);
|
||
});
|
||
shimmer.start(now);
|
||
shimmer.stop(now + duration + 0.05);
|
||
}
|
||
|
||
/**
|
||
* Point-Defense Flak Barrage Burst (audio_gaps.md #49)
|
||
* Staggered concussive clusters of 4-8 noise transients 10-30 ms apart over
|
||
* a 40 Hz sub-bass punch, with a couple of high shell-crack transients per
|
||
* volley. Each hit self-releases; whole burst ~0.9-1.4 s.
|
||
*/
|
||
synthesizeFlakBarrageBurst() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 0.9 + Math.random() * 0.5;
|
||
|
||
// 40 Hz sub-bass punch at volley start
|
||
const sub = ctx.createOscillator();
|
||
sub.type = 'triangle';
|
||
sub.frequency.setValueAtTime(40, now);
|
||
sub.frequency.linearRampToValueAtTime(32, now + 0.25);
|
||
const subEnv = ctx.createGain();
|
||
subEnv.gain.setValueAtTime(0.001, now);
|
||
subEnv.gain.linearRampToValueAtTime(0.3, now + 0.008);
|
||
subEnv.gain.exponentialRampToValueAtTime(0.0001, now + 0.3);
|
||
sub.connect(subEnv);
|
||
subEnv.connect(this.gainNode);
|
||
sub.start(now);
|
||
sub.stop(now + 0.32);
|
||
|
||
const hitCount = 4 + Math.floor(Math.random() * 5); // 4-8 hits
|
||
let cursor = 0.05;
|
||
for (let i = 0; i < hitCount; i++) {
|
||
const t = now + cursor;
|
||
const alternate = i % 2 ? 0.75 : 1.0; // mono proxy for spread
|
||
const isCrack = i % 3 === 1;
|
||
const buf = this.am.createNoiseBuffer('white', 0.12);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
if (isCrack) {
|
||
bp.frequency.setValueAtTime(2500 + Math.random() * 1500, t);
|
||
bp.Q.setValueAtTime(8 + Math.random() * 4, t);
|
||
} else {
|
||
bp.frequency.setValueAtTime(300 + Math.random() * 1300, t);
|
||
bp.Q.setValueAtTime(2 + Math.random() * 3, t);
|
||
}
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.001, t);
|
||
env.gain.linearRampToValueAtTime((isCrack ? 0.1 : 0.17 + Math.random() * 0.12) * alternate, t + 0.004);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + (isCrack ? 0.015 : 0.03 + Math.random() * 0.04));
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(this.gainNode);
|
||
noise.start(t);
|
||
noise.stop(t + 0.13);
|
||
cursor += 0.01 + Math.random() * 0.02; // 10-30 ms stagger
|
||
}
|
||
|
||
// Second soft sub thump mid-volley (distant batteries)
|
||
const sub2 = ctx.createOscillator();
|
||
sub2.type = 'triangle';
|
||
sub2.frequency.setValueAtTime(38, now + 0.42);
|
||
sub2.frequency.linearRampToValueAtTime(30, now + 0.6);
|
||
const sub2Env = ctx.createGain();
|
||
sub2Env.gain.setValueAtTime(0.001, now + 0.42);
|
||
sub2Env.gain.linearRampToValueAtTime(0.16, now + 0.43);
|
||
sub2Env.gain.exponentialRampToValueAtTime(0.0001, now + 0.72);
|
||
sub2.connect(sub2Env);
|
||
sub2Env.connect(this.gainNode);
|
||
sub2.start(now + 0.42);
|
||
sub2.stop(now + 0.74);
|
||
}
|
||
|
||
/**
|
||
* Heavy Weapons Breech Locking Clank (audio_gags.md #50)
|
||
* Three-stage loading cycle: 80 ms pre-charge pneumatic hiss, 60 Hz square
|
||
* breech slam into a lowpass body, and a high-Q steel ringoff with damped
|
||
* inharmonic partials. ~1.6 s.
|
||
*/
|
||
synthesizeBreechClank() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
|
||
// Stage 1: pneumatic pre-charge hiss (80 ms, highpass 2.5 kHz)
|
||
const hissBuf = this.am.createNoiseBuffer('white', 0.14);
|
||
const hiss = ctx.createBufferSource();
|
||
hiss.buffer = hissBuf;
|
||
const hp = ctx.createBiquadFilter();
|
||
hp.type = 'highpass';
|
||
hp.frequency.setValueAtTime(2500, now);
|
||
hp.Q.setValueAtTime(0.7, now);
|
||
const hissEnv = ctx.createGain();
|
||
hissEnv.gain.setValueAtTime(0.001, now);
|
||
hissEnv.gain.linearRampToValueAtTime(0.14, now + 0.005);
|
||
hissEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.08);
|
||
hiss.connect(hp);
|
||
hp.connect(hissEnv);
|
||
hissEnv.connect(this.gainNode);
|
||
hiss.start(now);
|
||
hiss.stop(now + 0.1);
|
||
|
||
// Stage 2: breech slam - 60 Hz square impact into a lowpass body
|
||
const slam = ctx.createOscillator();
|
||
slam.type = 'square';
|
||
slam.frequency.setValueAtTime(60, now + 0.08);
|
||
slam.frequency.linearRampToValueAtTime(48, now + 0.2);
|
||
const slamLp = ctx.createBiquadFilter();
|
||
slamLp.type = 'lowpass';
|
||
slamLp.frequency.setValueAtTime(300, now + 0.08);
|
||
const slamEnv = ctx.createGain();
|
||
slamEnv.gain.setValueAtTime(0.001, now + 0.08);
|
||
slamEnv.gain.linearRampToValueAtTime(0.4, now + 0.086);
|
||
slamEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.28);
|
||
slam.connect(slamLp);
|
||
slamLp.connect(slamEnv);
|
||
slamEnv.connect(this.gainNode);
|
||
slam.start(now + 0.08);
|
||
slam.stop(now + 0.3);
|
||
|
||
// Stage 3: high-Q steel ringoff with damped inharmonic partials
|
||
const ringBuf = this.am.createNoiseBuffer('white', 0.45);
|
||
const ring = ctx.createBufferSource();
|
||
ring.buffer = ringBuf;
|
||
const ringBp = ctx.createBiquadFilter();
|
||
ringBp.type = 'bandpass';
|
||
ringBp.frequency.setValueAtTime(1800 + Math.random() * 1600, now + 0.09);
|
||
ringBp.Q.setValueAtTime(18 + Math.random() * 7, now + 0.09);
|
||
const ringEnv = ctx.createGain();
|
||
ringEnv.gain.setValueAtTime(0.001, now + 0.09);
|
||
ringEnv.gain.linearRampToValueAtTime(0.16, now + 0.095);
|
||
ringEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
|
||
ring.connect(ringBp);
|
||
ringBp.connect(ringEnv);
|
||
ringEnv.connect(this.gainNode);
|
||
ring.start(now + 0.09);
|
||
ring.stop(now + 0.52);
|
||
|
||
[240, 630, 1120].forEach((freq, idx) => {
|
||
const partial = ctx.createOscillator();
|
||
partial.type = 'sine';
|
||
partial.frequency.setValueAtTime(freq, now + 0.09);
|
||
const pEnv = ctx.createGain();
|
||
pEnv.gain.setValueAtTime(0.001, now + 0.09);
|
||
pEnv.gain.linearRampToValueAtTime([0.11, 0.07, 0.045][idx], now + 0.095);
|
||
pEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.09 + [0.9, 0.6, 0.35][idx]);
|
||
partial.connect(pEnv);
|
||
pEnv.connect(this.gainNode);
|
||
partial.start(now + 0.09);
|
||
partial.stop(now + [1.0, 0.7, 0.45][idx]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Heavy Hydraulic Docking Groan & Mechanical Clang (audio_gaps.md #52)
|
||
* A slow ~2.4 s structural stress groan: brown noise through two parallel
|
||
* resonant bandpasses sweeping 38->62 Hz and 55->85 Hz under an asymmetric
|
||
* groaning envelope, resolving into a metallic anvil transient with an
|
||
* inharmonic sine stack at the moment the clamp seats. ~4.0 s.
|
||
*/
|
||
synthesizeDockingGroan() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const groanDur = 3.9;
|
||
|
||
// Brown-noise stress bed through two sweeping resonant bandpasses
|
||
const bedBuf = this.am.createNoiseBuffer('brown', groanDur + 0.2);
|
||
const bed = ctx.createBufferSource();
|
||
bed.buffer = bedBuf;
|
||
const groanEnv = ctx.createGain();
|
||
groanEnv.gain.setValueAtTime(0.0001, now);
|
||
groanEnv.gain.linearRampToValueAtTime(0.001, now + 0.01);
|
||
groanEnv.gain.linearRampToValueAtTime(0.22, now + 1.1); // groan up
|
||
groanEnv.gain.setValueAtTime(0.1, now + 1.6); // stress dip
|
||
groanEnv.gain.linearRampToValueAtTime(0.26, now + 2.3); // groan peaks
|
||
groanEnv.gain.exponentialRampToValueAtTime(0.0001, now + groanDur);
|
||
groanEnv.connect(this.gainNode);
|
||
|
||
[38, 55].forEach((startFreq, idx) => {
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(startFreq, now);
|
||
bp.frequency.linearRampToValueAtTime(idx === 0 ? 62 : 85, now + 2.4);
|
||
bp.Q.setValueAtTime(idx === 0 ? 5.5 : 4.5, now);
|
||
bed.connect(bp);
|
||
bp.connect(groanEnv);
|
||
});
|
||
bed.start(now);
|
||
bed.stop(now + groanDur + 0.05);
|
||
|
||
// Metallic anvil clang as the groan resolves (~2.7 s in)
|
||
const clangT = now + 2.7;
|
||
const ringBuf = this.am.createNoiseBuffer('white', 0.4);
|
||
const ring = ctx.createBufferSource();
|
||
ring.buffer = ringBuf;
|
||
const ringBp = ctx.createBiquadFilter();
|
||
ringBp.type = 'bandpass';
|
||
ringBp.frequency.setValueAtTime(2000, clangT);
|
||
ringBp.Q.setValueAtTime(20, clangT);
|
||
const ringEnv = ctx.createGain();
|
||
ringEnv.gain.setValueAtTime(0.001, clangT);
|
||
ringEnv.gain.linearRampToValueAtTime(0.16, clangT + 0.004);
|
||
ringEnv.gain.exponentialRampToValueAtTime(0.001, clangT + 0.35);
|
||
ring.connect(ringBp);
|
||
ringBp.connect(ringEnv);
|
||
ringEnv.connect(this.gainNode);
|
||
ring.start(clangT);
|
||
ring.stop(clangT + 0.4);
|
||
|
||
[420, 880, 1600].forEach((freq, idx) => {
|
||
const partial = ctx.createOscillator();
|
||
partial.type = 'sine';
|
||
partial.frequency.setValueAtTime(freq, clangT);
|
||
const pEnv = ctx.createGain();
|
||
pEnv.gain.setValueAtTime(0.001, clangT);
|
||
pEnv.gain.linearRampToValueAtTime([0.08, 0.06, 0.04][idx], clangT + 0.005);
|
||
pEnv.gain.exponentialRampToValueAtTime(0.001, clangT + [0.8, 0.55, 0.3][idx]);
|
||
partial.connect(pEnv);
|
||
pEnv.connect(this.gainNode);
|
||
partial.start(clangT);
|
||
partial.stop(clangT + [0.85, 0.6, 0.35][idx]);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Dual-Reel Magnetic Tape Spooler & Pinch Roller Clank (audio_gaps.md #53)
|
||
* Solenoid pinch-roller engagement (110 Hz impulse), a fluttering pink tape
|
||
* bed (15 Hz AM on a bandpass noise loop) and a faint 3200 Hz capstan hum
|
||
* with spool-up glide, ending in an optional fast-forward whine tail.
|
||
* ~2.6-3.5 s.
|
||
*/
|
||
synthesizeTapeSpooler() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const duration = 2.6 + Math.random() * 0.9;
|
||
|
||
// Solenoid clamp impulse (110 Hz) with a tiny mechanical tick
|
||
const solenoid = ctx.createOscillator();
|
||
solenoid.type = 'square';
|
||
solenoid.frequency.setValueAtTime(110, now);
|
||
solenoid.frequency.linearRampToValueAtTime(95, now + 0.05);
|
||
const solEnv = ctx.createGain();
|
||
solEnv.gain.setValueAtTime(0.001, now);
|
||
solEnv.gain.linearRampToValueAtTime(0.22, now + 0.004);
|
||
solEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.07);
|
||
solenoid.connect(solEnv);
|
||
solEnv.connect(this.gainNode);
|
||
solenoid.start(now);
|
||
solenoid.stop(now + 0.09);
|
||
|
||
const tickBuf = this.am.createNoiseBuffer('white', 0.03);
|
||
const tick = ctx.createBufferSource();
|
||
tick.buffer = tickBuf;
|
||
const tickBp = ctx.createBiquadFilter();
|
||
tickBp.type = 'bandpass';
|
||
tickBp.frequency.setValueAtTime(1800, now + 0.02);
|
||
tickBp.Q.setValueAtTime(10, now + 0.02);
|
||
const tickEnv = ctx.createGain();
|
||
tickEnv.gain.setValueAtTime(0.08, now + 0.02);
|
||
tickEnv.gain.exponentialRampToValueAtTime(0.001, now + 0.045);
|
||
tick.connect(tickBp);
|
||
tickBp.connect(tickEnv);
|
||
tickEnv.connect(this.gainNode);
|
||
tick.start(now + 0.02);
|
||
tick.stop(now + 0.06);
|
||
|
||
// Fluttering pink-noise tape bed (15 Hz flutter AM)
|
||
const tapeBuf = this.am.createNoiseBuffer('pink', duration + 0.3);
|
||
const tape = ctx.createBufferSource();
|
||
tape.buffer = tapeBuf;
|
||
tape.loop = true;
|
||
const tapeBp = ctx.createBiquadFilter();
|
||
tapeBp.type = 'bandpass';
|
||
tapeBp.frequency.setValueAtTime(500 + Math.random() * 1000, now);
|
||
tapeBp.Q.setValueAtTime(1, now);
|
||
const tapeEnv = ctx.createGain();
|
||
tapeEnv.gain.setValueAtTime(0.0001, now);
|
||
tapeEnv.gain.linearRampToValueAtTime(0.05, now + 0.15);
|
||
tapeEnv.gain.setValueAtTime(0.03, now + duration * 0.45);
|
||
tapeEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
const flutter = ctx.createOscillator();
|
||
flutter.type = 'sine';
|
||
flutter.frequency.setValueAtTime(15, now);
|
||
const flutterDepth = ctx.createGain();
|
||
flutterDepth.gain.setValueAtTime(0.035, now);
|
||
flutter.connect(flutterDepth);
|
||
flutterDepth.connect(tapeEnv.gain);
|
||
tape.connect(tapeBp);
|
||
tapeBp.connect(tapeEnv);
|
||
tapeEnv.connect(this.gainNode);
|
||
tape.start(now);
|
||
tape.stop(now + duration + 0.05);
|
||
flutter.start(now);
|
||
flutter.stop(now + duration + 0.05);
|
||
|
||
// Capstan hum: faint 3200 Hz sine with a slow spool-up glide and wow
|
||
const capstan = ctx.createOscillator();
|
||
capstan.type = 'sine';
|
||
capstan.frequency.setValueAtTime(3136, now);
|
||
capstan.frequency.exponentialRampToValueAtTime(3264, now + 1.5);
|
||
const capstanEnv = ctx.createGain();
|
||
capstanEnv.gain.setValueAtTime(0.0001, now);
|
||
capstanEnv.gain.linearRampToValueAtTime(0.016, now + 0.3);
|
||
capstanEnv.gain.exponentialRampToValueAtTime(0.0001, now + duration);
|
||
const wow = ctx.createOscillator();
|
||
wow.type = 'sine';
|
||
wow.frequency.setValueAtTime(8, now);
|
||
const wowDepth = ctx.createGain();
|
||
wowDepth.gain.setValueAtTime(12, now);
|
||
wow.connect(wowDepth);
|
||
wowDepth.connect(capstan.frequency);
|
||
capstan.connect(capstanEnv);
|
||
capstanEnv.connect(this.gainNode);
|
||
capstan.start(now);
|
||
capstan.stop(now + duration + 0.1);
|
||
wow.start(now);
|
||
wow.stop(now + duration + 0.1);
|
||
|
||
// Occasional fast-forward whine tail
|
||
if (Math.random() < 0.3) {
|
||
const whineT = now + duration - 0.55;
|
||
const whine = ctx.createOscillator();
|
||
whine.type = 'sine';
|
||
whine.frequency.setValueAtTime(1400, whineT);
|
||
whine.frequency.exponentialRampToValueAtTime(2300, whineT + 0.5);
|
||
const whineEnv = ctx.createGain();
|
||
whineEnv.gain.setValueAtTime(0.0001, whineT);
|
||
whineEnv.gain.linearRampToValueAtTime(0.02, whineT + 0.08);
|
||
whineEnv.gain.exponentialRampToValueAtTime(0.0001, whineT + 0.5);
|
||
whine.connect(whineEnv);
|
||
whineEnv.connect(this.gainNode);
|
||
whine.start(whineT);
|
||
whine.stop(whineT + 0.55);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Dot-Matrix / Teletype Chatter Phrase (audio_gaps.md #51, one phrase unit)
|
||
* A burst of 6-16 pseudo-random high-Q ~4 kHz impact spikes with periodic
|
||
* 12 Hz stepper clicks, phrased with a soft attack/release. The continuous
|
||
* Nostromo terminal loop is startTeletypeChatter() below.
|
||
*/
|
||
synthesizeTeletypeChatterPhrase() {
|
||
this.init();
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return;
|
||
|
||
const now = ctx.currentTime;
|
||
const phraseDur = 0.6 + Math.random() * 0.8;
|
||
|
||
const masterEnv = ctx.createGain();
|
||
masterEnv.gain.setValueAtTime(0.0001, now);
|
||
masterEnv.gain.linearRampToValueAtTime(1, now + 0.03); // soft phrase in
|
||
masterEnv.gain.setValueAtTime(1, now + phraseDur - 0.06);
|
||
masterEnv.gain.exponentialRampToValueAtTime(0.0001, now + phraseDur + 0.05);
|
||
masterEnv.connect(this.gainNode);
|
||
|
||
const strikes = 6 + Math.floor(Math.random() * 11); // 6-16 spikes
|
||
let cursor = 0.02;
|
||
for (let i = 0; i < strikes; i++) {
|
||
if (cursor >= phraseDur - 0.1) break; // never schedule past the phrase out
|
||
const t = now + cursor;
|
||
const buf = this.am.createNoiseBuffer('white', 0.03);
|
||
const noise = ctx.createBufferSource();
|
||
noise.buffer = buf;
|
||
const bp = ctx.createBiquadFilter();
|
||
bp.type = 'bandpass';
|
||
bp.frequency.setValueAtTime(3400 + Math.random() * 1200, t);
|
||
bp.Q.setValueAtTime(10 + Math.random() * 6, t);
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.1 + Math.random() * 0.1, t);
|
||
env.gain.exponentialRampToValueAtTime(0.001, t + 0.012 + Math.random() * 0.01);
|
||
noise.connect(bp);
|
||
bp.connect(env);
|
||
env.connect(masterEnv);
|
||
noise.start(t);
|
||
noise.stop(t + 0.035);
|
||
|
||
// Periodic 12 Hz carriage stepper click
|
||
if (i % 3 === 2) {
|
||
const stepper = ctx.createOscillator();
|
||
stepper.type = 'square';
|
||
stepper.frequency.setValueAtTime(90, t);
|
||
stepper.frequency.linearRampToValueAtTime(70, t + 0.02);
|
||
const sEnv = ctx.createGain();
|
||
sEnv.gain.setValueAtTime(0.06, t);
|
||
sEnv.gain.exponentialRampToValueAtTime(0.001, t + 0.025);
|
||
stepper.connect(sEnv);
|
||
sEnv.connect(masterEnv);
|
||
stepper.start(t);
|
||
stepper.stop(t + 0.03);
|
||
}
|
||
cursor += 0.045 + Math.random() * 0.095; // jittered 45-140 ms strikes
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
});
|
||
}
|
||
}
|
||
|
||
window.ExpandedSciFiAudioSynth = ExpandedSciFiAudioSynth;
|
||
|
||
|
||
/**
|
||
* Layered & Staged Engine Startup and Shutdown Synthesizer
|
||
* Synthesizes chronological multi-phase acoustic scripts for 15 distinct starship archetypes.
|
||
*/
|
||
class EngineTransitionSynth {
|
||
constructor(audioManager) {
|
||
this.am = audioManager;
|
||
this.gainNode = null;
|
||
this.activeNodes = [];
|
||
this.activeTimeouts = [];
|
||
}
|
||
|
||
init() {
|
||
if (!this.am.ctx) return;
|
||
if (!this.gainNode) {
|
||
this.gainNode = this.am.ctx.createGain();
|
||
this.gainNode.gain.setValueAtTime(0.85, this.am.ctx.currentTime);
|
||
this.gainNode.connect(this.am.compressor);
|
||
}
|
||
}
|
||
|
||
stopTransitions() {
|
||
this.activeTimeouts.forEach(t => clearTimeout(t));
|
||
this.activeTimeouts = [];
|
||
this.activeNodes.forEach(node => {
|
||
try { if (node.stop) node.stop(); } catch (e) {}
|
||
try { node.disconnect(); } catch (e) {}
|
||
});
|
||
this.activeNodes = [];
|
||
}
|
||
|
||
_tone({ type = 'sine', startFreq, endFreq, startTime, duration, startVol = 0.001, peakVol = 0.4, endVol = 0.0001, filterType = null, filterFreq = 1000, filterQ = 1.0 }) {
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return null;
|
||
const osc = ctx.createOscillator();
|
||
osc.type = type;
|
||
osc.frequency.setValueAtTime(Math.max(10, startFreq), startTime);
|
||
if (endFreq && endFreq !== startFreq) {
|
||
osc.frequency.exponentialRampToValueAtTime(Math.max(10, endFreq), startTime + duration);
|
||
}
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(Math.max(0.0001, startVol), startTime);
|
||
const attack = Math.min(0.08, duration * 0.25);
|
||
env.gain.linearRampToValueAtTime(peakVol, startTime + attack);
|
||
env.gain.exponentialRampToValueAtTime(Math.max(0.0001, endVol), startTime + duration);
|
||
|
||
let lastNode = osc;
|
||
if (filterType) {
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = filterType;
|
||
filter.frequency.setValueAtTime(filterFreq, startTime);
|
||
filter.Q.setValueAtTime(filterQ, startTime);
|
||
lastNode.connect(filter);
|
||
lastNode = filter;
|
||
this.activeNodes.push(filter);
|
||
}
|
||
|
||
lastNode.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
osc.start(startTime);
|
||
osc.stop(startTime + duration);
|
||
|
||
this.activeNodes.push(osc, env);
|
||
return osc;
|
||
}
|
||
|
||
_noise({ noiseType = 'pink', filterType = 'bandpass', startFreq = 800, endFreq = 800, Q = 1.5, startTime, duration, peakVol = 0.35 }) {
|
||
const ctx = this.am.ctx;
|
||
if (!ctx || !this.gainNode) return null;
|
||
const buf = this.am.createNoiseBuffer(noiseType, Math.max(3, Math.ceil(duration + 1)));
|
||
const src = ctx.createBufferSource();
|
||
src.buffer = buf;
|
||
|
||
const filter = ctx.createBiquadFilter();
|
||
filter.type = filterType;
|
||
filter.frequency.setValueAtTime(Math.max(20, startFreq), startTime);
|
||
if (endFreq && endFreq !== startFreq) {
|
||
filter.frequency.exponentialRampToValueAtTime(Math.max(20, endFreq), startTime + duration);
|
||
}
|
||
filter.Q.setValueAtTime(Q, startTime);
|
||
|
||
const env = ctx.createGain();
|
||
env.gain.setValueAtTime(0.0001, startTime);
|
||
const attack = Math.min(0.12, duration * 0.3);
|
||
env.gain.linearRampToValueAtTime(peakVol, startTime + attack);
|
||
env.gain.exponentialRampToValueAtTime(0.0001, startTime + duration);
|
||
|
||
src.connect(filter);
|
||
filter.connect(env);
|
||
env.connect(this.gainNode);
|
||
|
||
src.start(startTime);
|
||
src.stop(startTime + duration);
|
||
|
||
this.activeNodes.push(src, filter, env);
|
||
return src;
|
||
}
|
||
|
||
_click(startTime, freq = 140, duration = 0.035, vol = 0.4, type = 'square') {
|
||
return this._tone({ type, startFreq: freq, endFreq: 25, startTime, duration, peakVol: vol, endVol: 0.0001 });
|
||
}
|
||
|
||
_sub(startTime, startFreq = 70, endFreq = 30, duration = 0.8, vol = 0.6) {
|
||
return this._tone({ type: 'sine', startFreq, endFreq, startTime, duration, peakVol: vol, endVol: 0.0001 });
|
||
}
|
||
|
||
_chime(startTime, pitches = [880, 1174, 1567], step = 0.05, duration = 0.35, vol = 0.25) {
|
||
pitches.forEach((freq, idx) => {
|
||
const t = startTime + idx * step;
|
||
this._tone({ type: 'sine', startFreq: freq, endFreq: freq * 1.01, startTime: t, duration, peakVol: vol, endVol: 0.0001 });
|
||
});
|
||
}
|
||
|
||
playStartup(profileKey = 'galaxy', duration = 2.5) {
|
||
this.init();
|
||
if (!this.am.ctx) return;
|
||
this.stopTransitions();
|
||
const now = this.am.ctx.currentTime;
|
||
const s = duration / 2.5;
|
||
|
||
switch (profileKey) {
|
||
case 'intrepid': this._startupIntrepid(now, s); break;
|
||
case 'defiant': this._startupDefiant(now, s); break;
|
||
case 'cardassian': this._startupCardassian(now, s); break;
|
||
case 'tos': this._startupTOS(now, s); break;
|
||
case 'nx': this._startupNX(now, s); break;
|
||
case 'tardis': this._startupTardis(now, s); break;
|
||
case 'industrial': this._startupIndustrial(now, s); break;
|
||
case 'bioship': this._startupBioship(now, s); break;
|
||
case 'retrofuture': this._startupRetrofuture(now, s); break;
|
||
case 'military': this._startupMilitary(now, s); break;
|
||
case 'deepspace': this._startupDeepSpace(now, s); break;
|
||
case 'outlaw': this._startupOutlaw(now, s); break;
|
||
case 'station': this._startupStation(now, s); break;
|
||
case 'comedy': this._startupComedy(now, s); break;
|
||
case 'galaxy':
|
||
default:
|
||
this._startupGalaxy(now, s);
|
||
break;
|
||
}
|
||
}
|
||
|
||
playShutdown(profileKey = 'galaxy', duration = 2.5) {
|
||
this.init();
|
||
if (!this.am.ctx) return;
|
||
this.stopTransitions();
|
||
const now = this.am.ctx.currentTime;
|
||
const s = duration / 2.5;
|
||
|
||
switch (profileKey) {
|
||
case 'intrepid': this._shutdownIntrepid(now, s); break;
|
||
case 'defiant': this._shutdownDefiant(now, s); break;
|
||
case 'cardassian': this._shutdownCardassian(now, s); break;
|
||
case 'tos': this._shutdownTOS(now, s); break;
|
||
case 'nx': this._shutdownNX(now, s); break;
|
||
case 'tardis': this._shutdownTardis(now, s); break;
|
||
case 'industrial': this._shutdownIndustrial(now, s); break;
|
||
case 'bioship': this._shutdownBioship(now, s); break;
|
||
case 'retrofuture': this._shutdownRetrofuture(now, s); break;
|
||
case 'military': this._shutdownMilitary(now, s); break;
|
||
case 'deepspace': this._shutdownDeepSpace(now, s); break;
|
||
case 'outlaw': this._shutdownOutlaw(now, s); break;
|
||
case 'station': this._shutdownStation(now, s); break;
|
||
case 'comedy': this._shutdownComedy(now, s); break;
|
||
case 'galaxy':
|
||
default:
|
||
this._shutdownGalaxy(now, s);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 1. TNG GALAXY CLASS
|
||
_startupGalaxy(now, s) {
|
||
this._click(now, 150, 0.04, 0.45);
|
||
this._click(now + 0.14 * s, 110, 0.03, 0.35);
|
||
this._tone({ type: 'sine', startFreq: 60, endFreq: 140, startTime: now + 0.05 * s, duration: 0.65 * s, peakVol: 0.35 });
|
||
this._tone({ type: 'sawtooth', startFreq: 120, endFreq: 480, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 750, filterQ: 3.5 });
|
||
this._sub(now + 0.85 * s, 65, 80, 0.95 * s, 0.55);
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 200, endFreq: 600, startTime: now + 0.9 * s, duration: 0.9 * s, peakVol: 0.25 });
|
||
this._chime(now + 1.8 * s, [784, 1046, 1318], 0.06 * s, 0.5 * s, 0.3);
|
||
this._sub(now + 1.8 * s, 80, 58, 0.65 * s, 0.45);
|
||
}
|
||
_shutdownGalaxy(now, s) {
|
||
this._click(now, 160, 0.05, 0.5);
|
||
this._sub(now, 85, 40, 0.65 * s, 0.6);
|
||
this._tone({ type: 'sawtooth', startFreq: 460, endFreq: 75, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 600, filterQ: 2.0 });
|
||
this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 650, endFreq: 180, startTime: now + 0.75 * s, duration: 1.0 * s, peakVol: 0.28 });
|
||
this._tone({ type: 'sine', startFreq: 75, endFreq: 24, startTime: now + 1.7 * s, duration: 0.75 * s, peakVol: 0.35 });
|
||
}
|
||
|
||
// 2. VOYAGER INTREPID CLASS
|
||
_startupIntrepid(now, s) {
|
||
this._click(now, 450, 0.025, 0.35);
|
||
this._click(now + 0.08 * s, 680, 0.025, 0.35);
|
||
this._tone({ type: 'triangle', startFreq: 240, endFreq: 580, startTime: now + 0.1 * s, duration: 0.6 * s, peakVol: 0.3 });
|
||
this._tone({ type: 'sawtooth', startFreq: 320, endFreq: 1650, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.35, filterType: 'bandpass', filterFreq: 1400, filterQ: 4.5 });
|
||
this._sub(now + 0.8 * s, 70, 95, 0.9 * s, 0.45);
|
||
this._noise({ noiseType: 'white', filterType: 'bandpass', startFreq: 1200, endFreq: 2400, startTime: now + 0.85 * s, duration: 0.9 * s, peakVol: 0.22 });
|
||
this._chime(now + 1.8 * s, [1174, 1567, 2093], 0.05 * s, 0.45 * s, 0.28);
|
||
this._tone({ type: 'sine', startFreq: 95, endFreq: 70, startTime: now + 1.85 * s, duration: 0.65 * s, peakVol: 0.4 });
|
||
}
|
||
_shutdownIntrepid(now, s) {
|
||
this._click(now, 520, 0.03, 0.4);
|
||
this._sub(now, 95, 50, 0.5 * s, 0.5);
|
||
this._tone({ type: 'sawtooth', startFreq: 1600, endFreq: 140, startTime: now + 0.5 * s, duration: 1.2 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 1200 });
|
||
this._noise({ noiseType: 'white', filterType: 'highpass', startFreq: 1400, endFreq: 400, startTime: now + 0.6 * s, duration: 0.8 * s, peakVol: 0.25 });
|
||
this._tone({ type: 'sine', startFreq: 140, endFreq: 30, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
|
||
}
|
||
|
||
// 3. DEFIANT ESCORT
|
||
_startupDefiant(now, s) {
|
||
this._click(now, 90, 0.06, 0.6, 'square');
|
||
this._tone({ type: 'sawtooth', startFreq: 50, endFreq: 120, startTime: now + 0.05 * s, duration: 0.6 * s, peakVol: 0.4, filterType: 'lowpass', filterFreq: 300 });
|
||
this._sub(now + 0.65 * s, 35, 95, 1.1 * s, 0.7);
|
||
this._tone({ type: 'sawtooth', startFreq: 180, endFreq: 740, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.32, filterType: 'bandpass', filterFreq: 550, filterQ: 3.0 });
|
||
this._chime(now + 1.8 * s, [660, 880], 0.08 * s, 0.4 * s, 0.35);
|
||
this._sub(now + 1.8 * s, 95, 68, 0.7 * s, 0.5);
|
||
}
|
||
_shutdownDefiant(now, s) {
|
||
this._click(now, 110, 0.05, 0.6);
|
||
this._sub(now, 90, 35, 0.6 * s, 0.65);
|
||
this._tone({ type: 'sawtooth', startFreq: 720, endFreq: 60, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.3, filterType: 'lowpass', filterFreq: 450 });
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 500, endFreq: 120, startTime: now + 0.7 * s, duration: 1.0 * s, peakVol: 0.3 });
|
||
this._tone({ type: 'sine', startFreq: 60, endFreq: 22, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.35 });
|
||
}
|
||
|
||
// 4. CARDASSIAN / DS9
|
||
_startupCardassian(now, s) {
|
||
this._click(now, 85, 0.08, 0.55);
|
||
this._tone({ type: 'triangle', startFreq: 165, endFreq: 110, startTime: now, duration: 0.7 * s, peakVol: 0.4 });
|
||
this._sub(now + 0.7 * s, 32, 68, 1.1 * s, 0.65);
|
||
this._tone({ type: 'sawtooth', startFreq: 80, endFreq: 260, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.3, filterType: 'lowpass', filterFreq: 320 });
|
||
this._noise({ noiseType: 'brown', filterType: 'lowpass', startFreq: 180, endFreq: 350, startTime: now + 0.8 * s, duration: 1.0 * s, peakVol: 0.3 });
|
||
this._chime(now + 1.8 * s, [330, 440], 0.1 * s, 0.5 * s, 0.35);
|
||
}
|
||
_shutdownCardassian(now, s) {
|
||
this._click(now, 95, 0.06, 0.5);
|
||
this._sub(now, 68, 30, 0.6 * s, 0.6);
|
||
this._tone({ type: 'sawtooth', startFreq: 250, endFreq: 40, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 220 });
|
||
this._noise({ noiseType: 'brown', filterType: 'bandpass', startFreq: 240, endFreq: 80, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.25 });
|
||
this._tone({ type: 'sine', startFreq: 40, endFreq: 20, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.3 });
|
||
}
|
||
|
||
// 5. TOS 1960s
|
||
_startupTOS(now, s) {
|
||
this._click(now, 220, 0.03, 0.45);
|
||
this._tone({ type: 'sine', startFreq: 80, endFreq: 180, startTime: now + 0.04 * s, duration: 0.6 * s, peakVol: 0.35 });
|
||
this._tone({ type: 'sine', startFreq: 220, endFreq: 980, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.32 });
|
||
this._tone({ type: 'triangle', startFreq: 225, endFreq: 990, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.2 });
|
||
this._chime(now + 1.8 * s, [880, 1108], 0.08 * s, 0.35 * s, 0.3);
|
||
}
|
||
_shutdownTOS(now, s) {
|
||
this._click(now, 260, 0.035, 0.45);
|
||
this._tone({ type: 'sine', startFreq: 950, endFreq: 90, startTime: now + 0.5 * s, duration: 1.2 * s, peakVol: 0.3 });
|
||
this._tone({ type: 'sine', startFreq: 2400, endFreq: 400, startTime: now + 0.6 * s, duration: 0.8 * s, peakVol: 0.15 });
|
||
this._tone({ type: 'sine', startFreq: 90, endFreq: 25, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
|
||
}
|
||
|
||
// 6. NX-01 PROTOTYPE
|
||
_startupNX(now, s) {
|
||
[0, 0.12, 0.24, 0.36, 0.48].forEach(dt => this._click(now + dt * s, 110, 0.04, 0.45));
|
||
this._tone({ type: 'sawtooth', startFreq: 95, endFreq: 420, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.32, filterType: 'lowpass', filterFreq: 600 });
|
||
this._sub(now + 0.8 * s, 45, 75, 1.0 * s, 0.6);
|
||
this._noise({ noiseType: 'brown', filterType: 'lowpass', startFreq: 220, endFreq: 480, startTime: now + 0.85 * s, duration: 0.95 * s, peakVol: 0.3 });
|
||
this._click(now + 1.8 * s, 180, 0.03, 0.4);
|
||
}
|
||
_shutdownNX(now, s) {
|
||
this._click(now, 95, 0.06, 0.55);
|
||
this._sub(now, 75, 35, 0.6 * s, 0.6);
|
||
[0.6, 0.85, 1.15, 1.5].forEach(dt => this._click(now + dt * s, 85, 0.04, 0.35));
|
||
this._noise({ noiseType: 'pink', filterType: 'highpass', startFreq: 900, endFreq: 300, startTime: now + 0.7 * s, duration: 1.0 * s, peakVol: 0.25 });
|
||
this._tone({ type: 'sine', startFreq: 40, endFreq: 18, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.3 });
|
||
}
|
||
|
||
// 7. TARDIS DIMENSIONAL
|
||
_startupTardis(now, s) {
|
||
this._click(now, 80, 0.06, 0.55);
|
||
this._tone({ type: 'sawtooth', startFreq: 140, endFreq: 680, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.32, filterType: 'bandpass', filterFreq: 550, filterQ: 3.0 });
|
||
this._tone({ type: 'sine', startFreq: 70, endFreq: 220, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.35 });
|
||
this._chime(now + 1.8 * s, [1046, 1318, 1567], 0.06 * s, 0.45 * s, 0.3);
|
||
}
|
||
_shutdownTardis(now, s) {
|
||
this._click(now, 90, 0.05, 0.5);
|
||
this._tone({ type: 'sawtooth', startFreq: 620, endFreq: 75, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.28, filterType: 'bandpass', filterFreq: 350, filterQ: 2.0 });
|
||
this._chime(now + 0.8 * s, [440], 0.1, 0.7 * s, 0.3);
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 350, endFreq: 80, startTime: now + 1.6 * s, duration: 0.9 * s, peakVol: 0.2 });
|
||
}
|
||
|
||
// 8. INDUSTRIAL FREIGHTER
|
||
_startupIndustrial(now, s) {
|
||
this._click(now, 75, 0.08, 0.6);
|
||
this._noise({ noiseType: 'white', filterType: 'highpass', startFreq: 1800, endFreq: 800, startTime: now + 0.05 * s, duration: 0.4 * s, peakVol: 0.35 });
|
||
this._tone({ type: 'sawtooth', startFreq: 55, endFreq: 290, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.35, filterType: 'lowpass', filterFreq: 400 });
|
||
this._sub(now + 0.7 * s, 35, 65, 1.1 * s, 0.7);
|
||
this._noise({ noiseType: 'brown', filterType: 'bandpass', startFreq: 400, endFreq: 900, startTime: now + 0.8 * s, duration: 1.0 * s, peakVol: 0.3 });
|
||
this._sub(now + 1.8 * s, 65, 40, 0.7 * s, 0.6);
|
||
}
|
||
_shutdownIndustrial(now, s) {
|
||
this._click(now, 85, 0.07, 0.65);
|
||
this._sub(now, 65, 28, 0.6 * s, 0.6);
|
||
this._noise({ noiseType: 'white', filterType: 'lowpass', startFreq: 2400, endFreq: 400, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.38 });
|
||
this._tone({ type: 'sawtooth', startFreq: 280, endFreq: 40, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 300 });
|
||
[1.7, 1.95, 2.2].forEach(dt => this._click(now + dt * s, 320, 0.02, 0.25));
|
||
}
|
||
|
||
// 9. BIOSHIP
|
||
_startupBioship(now, s) {
|
||
this._chime(now, [784, 1174, 1567], 0.04 * s, 0.3 * s, 0.25);
|
||
this._sub(now + 0.6 * s, 42, 28, 0.4 * s, 0.65);
|
||
this._sub(now + 0.95 * s, 44, 28, 0.4 * s, 0.7);
|
||
this._tone({ type: 'sine', startFreq: 260, endFreq: 540, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.32 });
|
||
this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 250, endFreq: 600, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.3 });
|
||
}
|
||
_shutdownBioship(now, s) {
|
||
this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 550, endFreq: 180, startTime: now, duration: 1.1 * s, peakVol: 0.35 });
|
||
this._sub(now + 0.5 * s, 38, 25, 0.4 * s, 0.55);
|
||
this._sub(now + 1.1 * s, 34, 22, 0.4 * s, 0.45);
|
||
this._tone({ type: 'sine', startFreq: 480, endFreq: 180, startTime: now + 0.6 * s, duration: 1.1 * s, peakVol: 0.22 });
|
||
this._tone({ type: 'sine', startFreq: 35, endFreq: 18, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
|
||
}
|
||
|
||
// 10. RETROFUTURE
|
||
_startupRetrofuture(now, s) {
|
||
[0, 0.07, 0.14, 0.21].forEach(dt => this._click(now + dt * s, 380, 0.02, 0.3));
|
||
this._tone({ type: 'triangle', startFreq: 95, endFreq: 580, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.35, filterType: 'lowpass', filterFreq: 650 });
|
||
this._tone({ type: 'sine', startFreq: 100, endFreq: 590, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.25 });
|
||
this._chime(now + 1.8 * s, [920], 0.1, 0.5 * s, 0.35);
|
||
}
|
||
_shutdownRetrofuture(now, s) {
|
||
this._click(now, 140, 0.05, 0.55);
|
||
this._tone({ type: 'triangle', startFreq: 550, endFreq: 65, startTime: now + 0.5 * s, duration: 1.2 * s, peakVol: 0.28 });
|
||
this._noise({ noiseType: 'pink', filterType: 'bandpass', startFreq: 450, endFreq: 150, startTime: now + 0.6 * s, duration: 1.1 * s, peakVol: 0.22 });
|
||
this._tone({ type: 'sine', startFreq: 3200, endFreq: 120, startTime: now + 1.7 * s, duration: 0.7 * s, peakVol: 0.2 });
|
||
}
|
||
|
||
// 11. MILITARY
|
||
_startupMilitary(now, s) {
|
||
this._click(now, 120, 0.06, 0.6);
|
||
this._tone({ type: 'square', startFreq: 60, endFreq: 120, startTime: now, duration: 0.6 * s, peakVol: 0.35, filterType: 'lowpass', filterFreq: 250 });
|
||
this._tone({ type: 'sawtooth', startFreq: 140, endFreq: 880, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.35, filterType: 'bandpass', filterFreq: 750, filterQ: 3.0 });
|
||
this._sub(now + 0.7 * s, 40, 85, 1.1 * s, 0.65);
|
||
this._chime(now + 1.8 * s, [750, 750], 0.08 * s, 0.35 * s, 0.35);
|
||
}
|
||
_shutdownMilitary(now, s) {
|
||
this._click(now, 150, 0.04, 0.5);
|
||
this._chime(now + 0.05 * s, [880], 0.1, 0.3 * s, 0.35);
|
||
this._tone({ type: 'sawtooth', startFreq: 850, endFreq: 70, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 500 });
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 600, endFreq: 140, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.28 });
|
||
this._sub(now + 1.7 * s, 70, 25, 0.8 * s, 0.4);
|
||
}
|
||
|
||
// 12. DEEP SPACE
|
||
_startupDeepSpace(now, s) {
|
||
this._noise({ noiseType: 'white', filterType: 'bandpass', startFreq: 2400, endFreq: 1200, startTime: now, duration: 0.65 * s, peakVol: 0.32 });
|
||
this._sub(now + 0.7 * s, 28, 68, 1.1 * s, 0.7);
|
||
this._tone({ type: 'sine', startFreq: 110, endFreq: 280, startTime: now + 0.75 * s, duration: 1.1 * s, peakVol: 0.3 });
|
||
this._chime(now + 1.8 * s, [440, 554], 0.1 * s, 0.5 * s, 0.25);
|
||
}
|
||
_shutdownDeepSpace(now, s) {
|
||
this._sub(now, 68, 25, 0.7 * s, 0.65);
|
||
this._tone({ type: 'sine', startFreq: 260, endFreq: 50, startTime: now + 0.6 * s, duration: 1.2 * s, peakVol: 0.25 });
|
||
this._noise({ noiseType: 'brown', filterType: 'lowpass', startFreq: 200, endFreq: 40, startTime: now + 0.7 * s, duration: 1.1 * s, peakVol: 0.3 });
|
||
this._sub(now + 1.7 * s, 35, 15, 0.8 * s, 0.25);
|
||
}
|
||
|
||
// 13. OUTLAW
|
||
_startupOutlaw(now, s) {
|
||
[0, 0.1, 0.22, 0.32, 0.44].forEach(dt => this._click(now + dt * s, 130 + Math.random() * 40, 0.035, 0.4));
|
||
this._tone({ type: 'sawtooth', startFreq: 70, endFreq: 130, startTime: now + 0.1 * s, duration: 0.5 * s, peakVol: 0.3, filterType: 'lowpass', filterFreq: 280 });
|
||
this._tone({ type: 'sawtooth', startFreq: 180, endFreq: 1150, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.35, filterType: 'bandpass', filterFreq: 900, filterQ: 4.0 });
|
||
this._sub(now + 0.7 * s, 45, 80, 1.1 * s, 0.65);
|
||
this._noise({ noiseType: 'white', filterType: 'highpass', startFreq: 2800, endFreq: 1500, startTime: now + 1.8 * s, duration: 0.3 * s, peakVol: 0.35 });
|
||
}
|
||
_shutdownOutlaw(now, s) {
|
||
this._click(now, 70, 0.08, 0.7, 'sawtooth');
|
||
this._sub(now, 90, 40, 0.4 * s, 0.7);
|
||
[0.5, 0.8, 1.15, 1.55].forEach(dt => this._click(now + dt * s, 90, 0.04, 0.35));
|
||
this._tone({ type: 'sawtooth', startFreq: 750, endFreq: 55, startTime: now + 0.5 * s, duration: 1.3 * s, peakVol: 0.25, filterType: 'lowpass', filterFreq: 350 });
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 350, endFreq: 80, startTime: now + 1.7 * s, duration: 0.8 * s, peakVol: 0.25 });
|
||
}
|
||
|
||
// 14. STATION
|
||
_startupStation(now, s) {
|
||
this._chime(now, [587, 880], 0.08 * s, 0.35 * s, 0.3);
|
||
this._click(now + 0.15 * s, 110, 0.05, 0.45);
|
||
this._sub(now + 0.6 * s, 30, 65, 1.2 * s, 0.7);
|
||
this._tone({ type: 'sawtooth', startFreq: 70, endFreq: 240, startTime: now + 0.65 * s, duration: 1.15 * s, peakVol: 0.28, filterType: 'lowpass', filterFreq: 280 });
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 200, endFreq: 500, startTime: now + 0.8 * s, duration: 1.0 * s, peakVol: 0.25 });
|
||
this._click(now + 1.8 * s, 85, 0.06, 0.5);
|
||
}
|
||
_shutdownStation(now, s) {
|
||
this._click(now, 105, 0.05, 0.5);
|
||
this._sub(now + 0.5 * s, 65, 25, 1.4 * s, 0.55);
|
||
this._tone({ type: 'sawtooth', startFreq: 220, endFreq: 45, startTime: now + 0.55 * s, duration: 1.3 * s, peakVol: 0.22, filterType: 'lowpass', filterFreq: 200 });
|
||
this._noise({ noiseType: 'pink', filterType: 'lowpass', startFreq: 280, endFreq: 90, startTime: now + 1.6 * s, duration: 0.9 * s, peakVol: 0.2 });
|
||
}
|
||
|
||
// 15. COMEDY
|
||
_startupComedy(now, s) {
|
||
this._chime(now, [330, 440, 554, 659], 0.07 * s, 0.35 * s, 0.32);
|
||
this._tone({ type: 'sawtooth', startFreq: 110, endFreq: 1400, startTime: now + 0.55 * s, duration: 1.25 * s, peakVol: 0.3, filterType: 'bandpass', filterFreq: 800, filterQ: 3.5 });
|
||
this._sub(now + 0.7 * s, 40, 75, 1.1 * s, 0.5);
|
||
this._chime(now + 1.8 * s, [1200], 0.1, 0.5 * s, 0.4);
|
||
}
|
||
_shutdownComedy(now, s) {
|
||
this._tone({ type: 'sine', startFreq: 920, endFreq: 140, startTime: now, duration: 0.85 * s, peakVol: 0.32 });
|
||
[0.7, 0.82, 0.96, 1.12].forEach(dt => this._click(now + dt * s, 420 + Math.random() * 200, 0.025, 0.3));
|
||
this._noise({ noiseType: 'white', filterType: 'bandpass', startFreq: 300, endFreq: 100, startTime: now + 1.6 * s, duration: 0.6 * s, peakVol: 0.25 });
|
||
}
|
||
}
|
||
|
||
window.EngineTransitionSynth = EngineTransitionSynth;
|
||
|
||
|