feat: restore restrained drive texture
This commit is contained in:
151
src/audio/drive-texture.ts
Normal file
151
src/audio/drive-texture.ts
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
};
|
||||
267
tests/helpers/fake-audio-context.ts
Normal file
267
tests/helpers/fake-audio-context.ts
Normal file
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
55
tests/unit/drive-texture.test.ts
Normal file
55
tests/unit/drive-texture.test.ts
Normal file
@@ -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)]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user