feat: restore restrained drive texture

This commit is contained in:
2026-09-04 15:50:37 +03:00
parent b427d101fa
commit 6fce20a228
3 changed files with 473 additions and 0 deletions

View 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();
},
};
}