← Public packages
@kentcdodds/roadster
Unofficial fan game: drive a stylized thruster roadster - launch 0-60, boost, hover, flip and hop over traffic.
src/client/audio.ts
246 lines · 8.0 KB · TypeScripttype 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)
}
}