From 6fce20a22893c90b9a14717be4cd6fa79bdb1853 Mon Sep 17 00:00:00 2001 From: LucaP Date: Fri, 4 Sep 2026 15:50:37 +0300 Subject: [PATCH] feat: restore restrained drive texture --- src/audio/drive-texture.ts | 151 ++++++++++++++++ tests/helpers/fake-audio-context.ts | 267 ++++++++++++++++++++++++++++ tests/unit/drive-texture.test.ts | 55 ++++++ 3 files changed, 473 insertions(+) create mode 100644 src/audio/drive-texture.ts create mode 100644 tests/helpers/fake-audio-context.ts create mode 100644 tests/unit/drive-texture.test.ts diff --git a/src/audio/drive-texture.ts b/src/audio/drive-texture.ts new file mode 100644 index 0000000..7c70140 --- /dev/null +++ b/src/audio/drive-texture.ts @@ -0,0 +1,151 @@ +import type { DriveEnergySample } from '@/audio/types'; + +const NOISE_SECONDS = 2; +const NOISE_SEED = 0x4e494748; +const ATTACK_SECONDS = 0.22; +const RELEASE_SECONDS = 0.9; +const CRUISE_ENGINE_GAIN = 0.055; +const MAX_ENGINE_GAIN = 0.12; +const CRUISE_ROAD_GAIN = 0.006; +const MAX_ROAD_GAIN = 0.024; + +export interface DriveTexture { + setEnergy(sample: DriveEnergySample): void; + dispose(): void; +} + +export type DriveTextureFactory = ( + context: AudioContext, + destination: AudioNode, +) => DriveTexture; + +function createNoiseBuffer(context: BaseAudioContext, seed: number): AudioBuffer { + const length = Math.max(1, Math.round(context.sampleRate * NOISE_SECONDS)); + const buffer = context.createBuffer(2, length, context.sampleRate); + let state = seed >>> 0; + for (let channel = 0; channel < 2; channel += 1) { + const samples = buffer.getChannelData(channel); + let previous = 0; + for (let index = 0; index < samples.length; index += 1) { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + const white = state / 0xffff_ffff * 2 - 1; + previous = previous * 0.9 + white * 0.1; + samples[index] = previous; + } + } + return buffer; +} + +function createOscillator( + context: AudioContext, + type: OscillatorType, + frequency: number, + level: number, + destination: AudioNode, +): { readonly source: OscillatorNode; readonly gain: GainNode } { + const source = context.createOscillator(); + const gain = context.createGain(); + source.type = type; + source.frequency.setValueAtTime(frequency, context.currentTime); + gain.gain.setValueAtTime(level, context.currentTime); + source.connect(gain); + gain.connect(destination); + return { source, gain }; +} + +function holdParameter(parameter: AudioParam, atTime: number): void { + if (typeof parameter.cancelAndHoldAtTime === 'function') { + parameter.cancelAndHoldAtTime(atTime); + return; + } + parameter.cancelScheduledValues(atTime); + parameter.setValueAtTime(parameter.value, atTime); +} + +export const createDriveTexture: DriveTextureFactory = (context, destination) => { + const engineFilter = context.createBiquadFilter(); + engineFilter.type = 'lowpass'; + engineFilter.frequency.value = 240; + engineFilter.Q.value = 1.1; + const engineGain = context.createGain(); + engineGain.gain.value = CRUISE_ENGINE_GAIN; + engineFilter.connect(engineGain); + engineGain.connect(destination); + + const fundamental = createOscillator(context, 'sawtooth', 42, 0.1, engineFilter); + const harmonic = createOscillator(context, 'sine', 84, 0.035, engineFilter); + const ignition = createOscillator(context, 'triangle', 120, 0.0001, engineFilter); + ignition.gain.gain.setValueAtTime(0.0001, context.currentTime); + ignition.gain.gain.linearRampToValueAtTime(0.008, context.currentTime + 0.12); + ignition.gain.gain.linearRampToValueAtTime(0.0001, context.currentTime + 1.15); + + const roadSource = context.createBufferSource(); + roadSource.buffer = createNoiseBuffer(context, NOISE_SEED); + roadSource.loop = true; + const roadHighpass = context.createBiquadFilter(); + roadHighpass.type = 'highpass'; + roadHighpass.frequency.value = 180; + const roadLowpass = context.createBiquadFilter(); + roadLowpass.type = 'lowpass'; + roadLowpass.frequency.value = 1_600; + const roadGain = context.createGain(); + roadGain.gain.value = CRUISE_ROAD_GAIN; + roadSource.connect(roadHighpass); + roadHighpass.connect(roadLowpass); + roadLowpass.connect(roadGain); + roadGain.connect(destination); + + const sources: readonly AudioScheduledSourceNode[] = [ + fundamental.source, + harmonic.source, + ignition.source, + roadSource, + ]; + const ownedNodes: readonly AudioNode[] = [ + ...sources, + fundamental.gain, + harmonic.gain, + ignition.gain, + engineFilter, + engineGain, + roadHighpass, + roadLowpass, + roadGain, + ]; + for (const source of sources) source.start(); + + let currentEnergy = 0; + let disposed = false; + const automate = (parameter: AudioParam, value: number, duration: number): void => { + const now = context.currentTime; + holdParameter(parameter, now); + parameter.linearRampToValueAtTime(value, now + duration); + }; + + return { + setEnergy(sample) { + if (disposed) return; + const next = Math.min(1, Math.max(0, sample.energy)); + const duration = next > currentEnergy ? ATTACK_SECONDS : RELEASE_SECONDS; + automate(fundamental.source.frequency, 42 + next * 20, duration); + automate(harmonic.source.frequency, 84 + next * 40, duration); + automate(engineFilter.frequency, 240 + next * 460, duration); + automate(roadLowpass.frequency, 1_600 + next * 1_800, duration); + automate(engineGain.gain, CRUISE_ENGINE_GAIN + next * (MAX_ENGINE_GAIN - CRUISE_ENGINE_GAIN), duration); + automate(roadGain.gain, CRUISE_ROAD_GAIN + next * (MAX_ROAD_GAIN - CRUISE_ROAD_GAIN), duration); + currentEnergy = next; + }, + dispose() { + if (disposed) return; + disposed = true; + for (const source of sources) { + try { + source.stop(); + } catch { + // A source that has already ended needs no further teardown. + } + } + for (const node of ownedNodes) node.disconnect(); + }, + }; +}; diff --git a/tests/helpers/fake-audio-context.ts b/tests/helpers/fake-audio-context.ts new file mode 100644 index 0000000..f10992c --- /dev/null +++ b/tests/helpers/fake-audio-context.ts @@ -0,0 +1,267 @@ +export type AudioParamEvent = + | { readonly kind: 'set'; readonly value: number; readonly at: number } + | { readonly kind: 'linear'; readonly value: number; readonly at: number } + | { readonly kind: 'exponential'; readonly value: number; readonly at: number } + | { readonly kind: 'target'; readonly value: number; readonly at: number; readonly constant: number } + | { readonly kind: 'cancel'; readonly at: number } + | { readonly kind: 'hold'; readonly at: number }; + +export interface FakeAudioParamRecord { + readonly param: AudioParam; + readonly events: AudioParamEvent[]; +} + +export interface FakeSourceRecord { + readonly kind: 'buffer' | 'oscillator'; + readonly node: AudioScheduledSourceNode; + readonly starts: Array<{ at: number; offset: number; duration: number | null }>; + readonly stops: number[]; + disconnectCount: number; + emitEnded(): void; +} + +export interface FakeAudioHarness { + readonly context: AudioContext; + readonly params: FakeAudioParamRecord[]; + readonly sources: FakeSourceRecord[]; + readonly gainNodes: GainNode[]; + readonly buffers: AudioBuffer[]; + readonly destination: AudioNode; + setCurrentTime(seconds: number): void; + decodedBuffer(durationSeconds: number, channels?: number): AudioBuffer; + emitContextState(state: AudioContextState | 'interrupted'): void; +} + +const createParam = ( + records: FakeAudioParamRecord[], + initial = 0, +): AudioParam => { + const events: AudioParamEvent[] = []; + const parameter = { + value: initial, + defaultValue: initial, + minValue: Number.NEGATIVE_INFINITY, + maxValue: Number.POSITIVE_INFINITY, + automationRate: 'a-rate' as const, + setValueAtTime(this: { value: number }, value: number, at: number) { + this.value = value; + events.push({ kind: 'set', value, at }); + return this as unknown as AudioParam; + }, + linearRampToValueAtTime(this: { value: number }, value: number, at: number) { + this.value = value; + events.push({ kind: 'linear', value, at }); + return this as unknown as AudioParam; + }, + exponentialRampToValueAtTime(this: { value: number }, value: number, at: number) { + this.value = value; + events.push({ kind: 'exponential', value, at }); + return this as unknown as AudioParam; + }, + setTargetAtTime(this: { value: number }, value: number, at: number, constant: number) { + this.value = value; + events.push({ kind: 'target', value, at, constant }); + return this as unknown as AudioParam; + }, + cancelScheduledValues(at: number) { + events.push({ kind: 'cancel', at }); + return this as unknown as AudioParam; + }, + cancelAndHoldAtTime(at: number) { + events.push({ kind: 'hold', at }); + return this as unknown as AudioParam; + }, + setValueCurveAtTime() { + throw new Error('Unexpected value-curve scheduling in focused audio tests.'); + }, + } as unknown as AudioParam; + records.push({ param: parameter, events }); + return parameter; +}; + +interface DisconnectableNode extends AudioNode { + disconnectCount: number; +} + +function createNode(): DisconnectableNode { + const node = { + disconnectCount: 0, + connect(destination: AudioNode | AudioParam) { + return destination; + }, + disconnect(this: { disconnectCount: number }) { + this.disconnectCount += 1; + }, + } as unknown as DisconnectableNode; + return node; +} + +export function createFakeAudioHarness(): FakeAudioHarness { + const params: FakeAudioParamRecord[] = []; + const sources: FakeSourceRecord[] = []; + const gainNodes: GainNode[] = []; + const buffers: AudioBuffer[] = []; + const stateListeners = new Set<() => void>(); + const sampleRate = 48_000; + let currentTime = 0; + let state: AudioContextState | 'interrupted' = 'suspended'; + + const decodedBuffer = (durationSeconds: number, channels = 2): AudioBuffer => { + const length = Math.max(1, Math.round(durationSeconds * sampleRate)); + const data = Array.from({ length: channels }, () => new Float32Array(length)); + const buffer = { + sampleRate, + length, + duration: length / sampleRate, + numberOfChannels: channels, + getChannelData(channel: number) { + const samples = data[channel]; + if (samples === undefined) throw new RangeError(`Missing channel ${channel}.`); + return samples; + }, + } as unknown as AudioBuffer; + buffers.push(buffer); + return buffer; + }; + + const destination = createNode(); + + const createScheduledSource = (kind: FakeSourceRecord['kind']): AudioScheduledSourceNode => { + const node = createNode() as AudioScheduledSourceNode & DisconnectableNode; + const starts: FakeSourceRecord['starts'] = []; + const stops: number[] = []; + const endedListeners = new Set<() => void>(); + const record: FakeSourceRecord = { + kind, + node, + starts, + stops, + get disconnectCount() { + return node.disconnectCount; + }, + set disconnectCount(value: number) { + node.disconnectCount = value; + }, + emitEnded() { + for (const listener of endedListeners) listener(); + endedListeners.clear(); + }, + }; + Object.assign(node, { + start(at = 0, offset = 0, duration?: number) { + starts.push({ at, offset, duration: duration ?? null }); + }, + stop(at = 0) { + stops.push(at); + }, + addEventListener(type: string, listener: () => void, options?: AddEventListenerOptions) { + if (type === 'ended') endedListeners.add(listener); + if (options?.once !== true) return; + }, + removeEventListener(type: string, listener: () => void) { + if (type === 'ended') endedListeners.delete(listener); + }, + }); + sources.push(record); + return node; + }; + + const context = { + get currentTime() { + return currentTime; + }, + get state() { + return state; + }, + sampleRate, + destination, + createGain() { + const node = Object.assign(createNode(), { gain: createParam(params, 1) }) as GainNode; + gainNodes.push(node); + return node; + }, + createBiquadFilter() { + return Object.assign(createNode(), { + type: 'lowpass' as BiquadFilterType, + frequency: createParam(params), + detune: createParam(params), + Q: createParam(params), + gain: createParam(params), + }) as unknown as BiquadFilterNode; + }, + createStereoPanner() { + return Object.assign(createNode(), { pan: createParam(params) }) as StereoPannerNode; + }, + createBuffer(numberOfChannels: number, length: number, bufferSampleRate: number) { + const data = Array.from( + { length: numberOfChannels }, + () => new Float32Array(length), + ); + const buffer = { + sampleRate: bufferSampleRate, + length, + duration: length / bufferSampleRate, + numberOfChannels, + getChannelData(channel: number) { + const samples = data[channel]; + if (samples === undefined) throw new RangeError(`Missing channel ${channel}.`); + return samples; + }, + } as unknown as AudioBuffer; + buffers.push(buffer); + return buffer; + }, + createBufferSource() { + return Object.assign(createScheduledSource('buffer'), { + buffer: null as AudioBuffer | null, + loop: false, + loopStart: 0, + loopEnd: 0, + playbackRate: createParam(params, 1), + detune: createParam(params), + }) as AudioBufferSourceNode; + }, + createOscillator() { + return Object.assign(createScheduledSource('oscillator'), { + type: 'sine' as OscillatorType, + frequency: createParam(params, 440), + detune: createParam(params), + }) as OscillatorNode; + }, + async decodeAudioData() { + return decodedBuffer(1); + }, + async resume() { + state = 'running'; + }, + async suspend() { + state = 'suspended'; + }, + async close() { + state = 'closed'; + }, + addEventListener(type: string, listener: () => void) { + if (type === 'statechange') stateListeners.add(listener); + }, + removeEventListener(type: string, listener: () => void) { + if (type === 'statechange') stateListeners.delete(listener); + }, + } as unknown as AudioContext; + + return { + context, + params, + sources, + gainNodes, + buffers, + destination, + setCurrentTime(seconds) { + currentTime = seconds; + }, + decodedBuffer, + emitContextState(nextState) { + state = nextState; + for (const listener of stateListeners) listener(); + }, + }; +} diff --git a/tests/unit/drive-texture.test.ts b/tests/unit/drive-texture.test.ts new file mode 100644 index 0000000..cb63346 --- /dev/null +++ b/tests/unit/drive-texture.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { createDriveTexture } from '@/audio/drive-texture'; +import { createFakeAudioHarness } from '../helpers/fake-audio-context'; + +describe('drive texture', () => { + it('preallocates one texture and uses exact attack and release ramps', () => { + const harness = createFakeAudioHarness(); + const texture = createDriveTexture(harness.context, harness.destination); + const sourceCount = harness.sources.length; + const eventCount = () => harness.params.flatMap((record) => record.events).length; + + harness.setCurrentTime(2); + const beforeAttack = eventCount(); + texture.setEnergy({ energy: 1, source: 'physical' }); + expect(harness.sources).toHaveLength(sourceCount); + expect(harness.params.flatMap((record) => record.events).slice(beforeAttack)) + .toContainEqual(expect.objectContaining({ kind: 'linear', at: 2.22 })); + + harness.setCurrentTime(3); + const beforeRelease = eventCount(); + texture.setEnergy({ energy: 0, source: 'idle' }); + expect(harness.sources).toHaveLength(sourceCount); + expect(harness.params.flatMap((record) => record.events).slice(beforeRelease)) + .toContainEqual(expect.objectContaining({ kind: 'linear', at: 3.9 })); + }); + + it('stops and disconnects every scheduled source exactly once', () => { + const harness = createFakeAudioHarness(); + const texture = createDriveTexture(harness.context, harness.destination); + texture.dispose(); + texture.dispose(); + + for (const source of harness.sources) { + expect(source.stops).toHaveLength(1); + expect(source.disconnectCount).toBe(1); + } + }); + + it('creates deterministic decorrelated stereo road noise', () => { + const first = createFakeAudioHarness(); + const second = createFakeAudioHarness(); + createDriveTexture(first.context, first.destination); + createDriveTexture(second.context, second.destination); + + const firstNoise = first.buffers[0]; + const secondNoise = second.buffers[0]; + expect(firstNoise).toBeDefined(); + expect(secondNoise).toBeDefined(); + if (firstNoise === undefined || secondNoise === undefined) return; + + expect([...firstNoise.getChannelData(0)]).toEqual([...secondNoise.getChannelData(0)]); + expect([...firstNoise.getChannelData(1)]).toEqual([...secondNoise.getChannelData(1)]); + expect([...firstNoise.getChannelData(0)]).not.toEqual([...firstNoise.getChannelData(1)]); + }); +});