Skip to content

src/client/audio.ts

246 lines · 8.0 KB · TypeScript
Raw
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)
	}
}