snapkitty
hardware
erlang
grisp-shadow-fleet / universe /AudioSystem.ts
SNAPKITTYWEST's picture
push from SNAPKITTYWEST/grisp-shadow-fleet
b8a03fa verified
Raw History Blame Contribute Delete
8.4 kB
export type AudioCue =
| 'airlock'
| 'alarm'
| 'confirm'
| 'deny'
| 'dock'
| 'engine'
| 'footstep'
| 'mission'
| 'terminal'
| 'thruster';
type Zone = 'interior' | 'airlock' | 'eva' | 'ship' | 'planet';
/** Procedural WebAudio bed and cues. It remains silent until a user gesture unlocks audio. */
export class AudioSystem {
private context: AudioContext | null = null;
private master: GainNode | null = null;
private ambience: GainNode | null = null;
private ambienceNodes: AudioNode[] = [];
private zone: Zone = 'interior';
private enabled = true;
private volume = 0.45;
public async start(): Promise<void> {
if (!this.enabled) return;
this.ensureContext();
if (!this.context) return;
if (this.context.state === 'suspended') await this.context.resume();
this.rebuildAmbience();
}
public stop(): void {
this.clearAmbience();
if (this.context && this.context.state !== 'closed') void this.context.suspend();
}
public dispose(): void {
this.clearAmbience();
if (this.context && this.context.state !== 'closed') void this.context.close();
this.context = null;
this.master = null;
this.ambience = null;
}
public setEnabled(enabled: boolean): void {
this.enabled = enabled;
if (this.master) this.master.gain.setTargetAtTime(enabled ? this.volume : 0, this.now(), 0.04);
}
public setVolume(volume: number): void {
this.volume = Math.max(0, Math.min(1, volume));
if (this.master) this.master.gain.setTargetAtTime(this.enabled ? this.volume : 0, this.now(), 0.04);
}
public setZone(zone: Zone): void {
if (zone === this.zone) return;
this.zone = zone;
if (this.context?.state === 'running') this.rebuildAmbience();
}
public playCue(cue: AudioCue, intensity = 1): void {
if (!this.enabled || !this.context || this.context.state !== 'running' || !this.master) return;
const now = this.now();
const amount = Math.max(0.1, Math.min(1.5, intensity));
switch (cue) {
case 'footstep':
this.noiseBurst(now, 0.055, 0.07 * amount, 170, 620);
break;
case 'thruster':
case 'engine':
this.tone(now, cue === 'engine' ? 54 : 82, 0.18, 0.09 * amount, 'sawtooth', -18);
this.noiseBurst(now, 0.16, 0.035 * amount, 80, 360);
break;
case 'airlock':
this.tone(now, 92, 0.48, 0.1 * amount, 'sine', 42);
this.noiseBurst(now + 0.05, 0.35, 0.045 * amount, 110, 900);
break;
case 'alarm':
this.tone(now, 210, 0.12, 0.12 * amount, 'square', 0);
this.tone(now + 0.18, 170, 0.16, 0.1 * amount, 'square', 0);
break;
case 'dock':
this.tone(now, 68, 0.22, 0.12 * amount, 'triangle', 28);
this.tone(now + 0.2, 136, 0.32, 0.08 * amount, 'sine', 18);
break;
case 'deny':
this.tone(now, 150, 0.1, 0.09 * amount, 'square', -45);
this.tone(now + 0.1, 104, 0.15, 0.08 * amount, 'square', -30);
break;
case 'mission':
this.tone(now, 294, 0.13, 0.07 * amount, 'sine', 12);
this.tone(now + 0.11, 440, 0.24, 0.07 * amount, 'sine', 18);
break;
case 'terminal':
this.tone(now, 640, 0.045, 0.045 * amount, 'square', 0);
break;
case 'confirm':
default:
this.tone(now, 420, 0.08, 0.06 * amount, 'sine', 45);
break;
}
}
private ensureContext(): void {
if (this.context) return;
const AudioContextCtor = window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextCtor) return;
this.context = new AudioContextCtor();
this.master = this.context.createGain();
this.master.gain.value = this.enabled ? this.volume : 0;
this.master.connect(this.context.destination);
this.ambience = this.context.createGain();
this.ambience.gain.value = 0.12;
this.ambience.connect(this.master);
}
private rebuildAmbience(): void {
if (!this.context || !this.ambience) return;
this.clearAmbience(false);
const zoneConfig: Record<Zone, { frequency: number; gain: number; wobble: number; noise: number }> = {
interior: { frequency: 46, gain: 0.12, wobble: 0.08, noise: 0.015 },
airlock: { frequency: 61, gain: 0.13, wobble: 0.14, noise: 0.022 },
eva: { frequency: 32, gain: 0.035, wobble: 0.03, noise: 0.003 },
ship: { frequency: 72, gain: 0.11, wobble: 0.18, noise: 0.012 },
planet: { frequency: 38, gain: 0.07, wobble: 0.05, noise: 0.028 },
};
const config = zoneConfig[this.zone];
const oscillator = this.context.createOscillator();
const oscillatorGain = this.context.createGain();
const lfo = this.context.createOscillator();
const lfoGain = this.context.createGain();
oscillator.type = 'sine';
oscillator.frequency.value = config.frequency;
oscillatorGain.gain.value = config.gain;
lfo.frequency.value = config.wobble;
lfoGain.gain.value = config.frequency * 0.025;
lfo.connect(lfoGain).connect(oscillator.frequency);
oscillator.connect(oscillatorGain).connect(this.ambience);
const noise = this.context.createBufferSource();
noise.buffer = this.noiseBuffer(2);
noise.loop = true;
const lowPass = this.context.createBiquadFilter();
lowPass.type = 'lowpass';
lowPass.frequency.value = this.zone === 'planet' ? 1050 : 240;
const noiseGain = this.context.createGain();
noiseGain.gain.value = config.noise;
noise.connect(lowPass).connect(noiseGain).connect(this.ambience);
oscillator.start();
lfo.start();
noise.start();
this.ambienceNodes.push(oscillator, oscillatorGain, lfo, lfoGain, noise, lowPass, noiseGain);
}
private clearAmbience(disconnectBus = true): void {
for (const node of this.ambienceNodes) {
if ('stop' in node) {
try { (node as OscillatorNode | AudioBufferSourceNode).stop(); } catch { /* already stopped */ }
}
try { node.disconnect(); } catch { /* already disconnected */ }
}
this.ambienceNodes = [];
if (disconnectBus && this.ambience) this.ambience.gain.value = 0;
}
private tone(
at: number,
frequency: number,
duration: number,
gain: number,
type: OscillatorType,
glide: number,
): void {
if (!this.context || !this.master) return;
const oscillator = this.context.createOscillator();
const envelope = this.context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, at);
oscillator.frequency.exponentialRampToValueAtTime(Math.max(20, frequency + glide), at + duration);
envelope.gain.setValueAtTime(0.0001, at);
envelope.gain.exponentialRampToValueAtTime(gain, at + Math.min(0.02, duration * 0.2));
envelope.gain.exponentialRampToValueAtTime(0.0001, at + duration);
oscillator.connect(envelope).connect(this.master);
oscillator.start(at);
oscillator.stop(at + duration + 0.02);
}
private noiseBurst(at: number, duration: number, gain: number, low: number, high: number): void {
if (!this.context || !this.master) return;
const source = this.context.createBufferSource();
source.buffer = this.noiseBuffer(Math.max(0.1, duration));
const filter = this.context.createBiquadFilter();
filter.type = 'bandpass';
filter.frequency.value = (low + high) / 2;
filter.Q.value = Math.max(0.2, filter.frequency.value / Math.max(1, high - low));
const envelope = this.context.createGain();
envelope.gain.setValueAtTime(gain, at);
envelope.gain.exponentialRampToValueAtTime(0.0001, at + duration);
source.connect(filter).connect(envelope).connect(this.master);
source.start(at);
source.stop(at + duration);
}
private noiseBuffer(seconds: number): AudioBuffer {
if (!this.context) throw new Error('Audio context unavailable');
const frames = Math.ceil(this.context.sampleRate * seconds);
const buffer = this.context.createBuffer(1, frames, this.context.sampleRate);
const data = buffer.getChannelData(0);
let previous = 0;
for (let index = 0; index < frames; index += 1) {
const white = Math.random() * 2 - 1;
previous = previous * 0.985 + white * 0.015;
data[index] = previous;
}
return buffer;
}
private now(): number {
return this.context?.currentTime ?? 0;
}
}
export default AudioSystem;