type Voice = { osc: OscillatorNode; gain: GainNode } export class Sound { ctx: AudioContext | null = null master!: GainNode sfxBus!: GainNode musicBus!: GainNode motorA!: Voice motorB!: Voice motorFilter!: BiquadFilterNode hissGain!: GainNode hissFilter!: BiquadFilterNode liftGain!: GainNode windGain!: GainNode sfxOn: boolean musicOn: boolean private nextNoteTime = 0 private step = 0 private timer: number | null = null constructor() { this.sfxOn = localStorage.getItem('roadster.sfx') !== 'off' this.musicOn = localStorage.getItem('roadster.music') === 'on' } unlock() { if (this.ctx) { if (this.ctx.state !== 'running') void this.ctx.resume().catch(() => {}) return } const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext if (!AC) return const ctx = new AC() this.ctx = ctx // Some mobile browsers start the context suspended even inside a gesture. if (ctx.state !== 'running') void ctx.resume().catch(() => {}) // iOS/Android: play a one-sample silent buffer inside the gesture to fully unlock output. const silent = ctx.createBufferSource() silent.buffer = ctx.createBuffer(1, 1, 22050) silent.connect(ctx.destination) silent.start(0) this.master = ctx.createGain() this.master.gain.value = 0.8 this.master.connect(ctx.destination) this.sfxBus = ctx.createGain() this.sfxBus.gain.value = this.sfxOn ? 1 : 0 this.sfxBus.connect(this.master) this.musicBus = ctx.createGain() this.musicBus.gain.value = this.musicOn ? 0.32 : 0 this.musicBus.connect(this.master) this.motorFilter = ctx.createBiquadFilter() this.motorFilter.type = 'lowpass' this.motorFilter.frequency.value = 900 this.motorFilter.connect(this.sfxBus) this.motorA = this.voice('sawtooth', 80, this.motorFilter) this.motorB = this.voice('triangle', 160, this.motorFilter) const noise = this.noiseBuffer(2) const hissSrc = ctx.createBufferSource() hissSrc.buffer = noise hissSrc.loop = true this.hissFilter = ctx.createBiquadFilter() this.hissFilter.type = 'bandpass' this.hissFilter.frequency.value = 1800 this.hissFilter.Q.value = 0.7 this.hissGain = ctx.createGain() this.hissGain.gain.value = 0 hissSrc.connect(this.hissFilter).connect(this.hissGain).connect(this.sfxBus) const liftSrc = ctx.createBufferSource() liftSrc.buffer = noise liftSrc.loop = true liftSrc.playbackRate.value = 0.7 const liftFilter = ctx.createBiquadFilter() liftFilter.type = 'lowpass' liftFilter.frequency.value = 700 this.liftGain = ctx.createGain() this.liftGain.gain.value = 0 liftSrc.connect(liftFilter).connect(this.liftGain).connect(this.sfxBus) const windSrc = ctx.createBufferSource() windSrc.buffer = noise windSrc.loop = true const windFilter = ctx.createBiquadFilter() windFilter.type = 'highpass' windFilter.frequency.value = 400 this.windGain = ctx.createGain() this.windGain.gain.value = 0 windSrc.connect(windFilter).connect(this.windGain).connect(this.sfxBus) hissSrc.start() liftSrc.start() windSrc.start() this.startMusic() } private voice(type: OscillatorType, freq: number, dest: AudioNode): Voice { const ctx = this.ctx! const osc = ctx.createOscillator() osc.type = type osc.frequency.value = freq const gain = ctx.createGain() gain.gain.value = 0 osc.connect(gain).connect(dest) osc.start() return { osc, gain } } private noiseBuffer(seconds: number) { const ctx = this.ctx! const buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * seconds), ctx.sampleRate) const d = buf.getChannelData(0) for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1 return buf } update(speed: number, throttle: number, thrust: number, lift: number, airborne: boolean) { const ctx = this.ctx if (!ctx) return const t = ctx.currentTime const s = Math.min(Math.abs(speed) / 110, 1.4) const base = 55 + s * 340 this.motorA.osc.frequency.setTargetAtTime(base, t, 0.05) this.motorB.osc.frequency.setTargetAtTime(base * 2.01, t, 0.05) const load = 0.03 + Math.abs(throttle) * 0.06 + s * 0.03 this.motorA.gain.gain.setTargetAtTime(airborne ? load * 0.4 : load, t, 0.08) this.motorB.gain.gain.setTargetAtTime((airborne ? load * 0.4 : load) * 0.6, t, 0.08) this.motorFilter.frequency.setTargetAtTime(500 + s * 2200, t, 0.1) this.hissGain.gain.setTargetAtTime(thrust * 0.5, t, 0.03) this.hissFilter.frequency.setTargetAtTime(1400 + thrust * 1600, t, 0.05) this.liftGain.gain.setTargetAtTime(lift * 0.7, t, 0.04) this.windGain.gain.setTargetAtTime(Math.min(s * s * 0.12, 0.15), t, 0.2) } blip(freq: number, dur = 0.15, type: OscillatorType = 'square', vol = 0.18) { const ctx = this.ctx if (!ctx) return const t = ctx.currentTime const o = ctx.createOscillator() o.type = type o.frequency.value = freq const g = ctx.createGain() g.gain.setValueAtTime(vol, t) g.gain.exponentialRampToValueAtTime(0.0001, t + dur) o.connect(g).connect(this.sfxBus) o.start(t) o.stop(t + dur + 0.02) } chime() { const notes = [523.25, 659.25, 783.99, 1046.5] notes.forEach((f, i) => setTimeout(() => this.blip(f, 0.25, 'triangle', 0.16), i * 85)) } crash() { const ctx = this.ctx if (!ctx) return const t = ctx.currentTime const src = ctx.createBufferSource() src.buffer = this.noiseBuffer(1) const f = ctx.createBiquadFilter() f.type = 'lowpass' f.frequency.setValueAtTime(1800, t) f.frequency.exponentialRampToValueAtTime(80, t + 0.9) const g = ctx.createGain() g.gain.setValueAtTime(0.7, t) g.gain.exponentialRampToValueAtTime(0.001, t + 0.9) src.connect(f).connect(g).connect(this.sfxBus) src.start(t) } thump(strength: number) { this.blip(70 + strength * 20, 0.18, 'sine', Math.min(0.1 + strength * 0.05, 0.35)) } setSfx(on: boolean) { this.sfxOn = on localStorage.setItem('roadster.sfx', on ? 'on' : 'off') if (this.ctx) this.sfxBus.gain.setTargetAtTime(on ? 1 : 0, this.ctx.currentTime, 0.05) } setMusic(on: boolean) { this.musicOn = on localStorage.setItem('roadster.music', on ? 'on' : 'off') if (this.ctx) this.musicBus.gain.setTargetAtTime(on ? 0.32 : 0, this.ctx.currentTime, 0.1) } private startMusic() { const ctx = this.ctx! this.nextNoteTime = ctx.currentTime + 0.1 const bass = [41.2, 41.2, 49, 36.71] const arp = [0, 7, 12, 15, 12, 7, 3, 7] const stepDur = 60 / 118 / 4 const schedule = () => { while (this.nextNoteTime < ctx.currentTime + 0.2) { const bar = Math.floor(this.step / 16) % bass.length const root = bass[bar]! const s = this.step % 16 if (s % 4 === 0) this.note(root, this.nextNoteTime, stepDur * 3, 'sawtooth', 0.22, 300) if (s % 2 === 0) { const semis = arp[(this.step / 2) % arp.length]! this.note(root * 4 * Math.pow(2, semis / 12), this.nextNoteTime, stepDur * 1.5, 'square', 0.06, 2400) } if (s === 4 || s === 12) this.hat(this.nextNoteTime, 0.09) if (s % 2 === 1) this.hat(this.nextNoteTime, 0.025) this.nextNoteTime += stepDur this.step++ } } this.timer = window.setInterval(schedule, 50) } private note(freq: number, at: number, dur: number, type: OscillatorType, vol: number, cutoff: number) { const ctx = this.ctx! if (!this.musicOn) return const o = ctx.createOscillator() o.type = type o.frequency.value = freq const f = ctx.createBiquadFilter() f.type = 'lowpass' f.frequency.value = cutoff const g = ctx.createGain() g.gain.setValueAtTime(0, at) g.gain.linearRampToValueAtTime(vol, at + 0.01) g.gain.exponentialRampToValueAtTime(0.0001, at + dur) o.connect(f).connect(g).connect(this.musicBus) o.start(at) o.stop(at + dur + 0.05) } private hat(at: number, vol: number) { const ctx = this.ctx! if (!this.musicOn) return const src = ctx.createBufferSource() src.buffer = this.noiseBuffer(0.05) const f = ctx.createBiquadFilter() f.type = 'highpass' f.frequency.value = 7000 const g = ctx.createGain() g.gain.setValueAtTime(vol, at) g.gain.exponentialRampToValueAtTime(0.0001, at + 0.05) src.connect(f).connect(g).connect(this.musicBus) src.start(at) } }