feat: add spatial audio simulator with camera-as-listener model
Implements the simulator described in README.md: a 3D room in which a
loudspeaker can be moved and aimed, heard binaurally from the camera's
position, with every acoustic term shown live so the user can read why it
sounds the way it does.
Only README.md was previously committed. An earlier AI-generated
implementation existed in the working tree but was never committed; it is
superseded rather than modified. That implementation did not work:
renderer.setSize() was never called and the stylesheet had no rule for
html, body or the canvas container, so the WebGL view rendered into a
300x150 box in the corner of an unstyled page. Its acoustics were also
wrong in ways that voided the stated goal, which is what motivated a
rewrite rather than a repair:
- AudioContext.listener orientation (forwardX/Y/Z, upX/Y/Z) was never
written, only position. Orbiting the camera therefore produced no
change in the sound field at all.
- The listener was a separate avatar mesh, not the camera, contradicting
the README. Its syncFromCamera() had zero callers.
- The reverb send was tapped after the distance, cone and occlusion
gains, making the direct-to-reverberant ratio mathematically constant.
Walking away sounded like a fader move rather than like distance.
- Doppler was derived from a single-frame velocity finite difference
smoothed with a per-frame (not per-second) lerp, so any drag produced
an octave-wide pitch chirp and the response varied with refresh rate.
Design decisions worth recording, since each has a cheaper alternative
that was rejected:
The camera IS the Web Audio listener. Position and orientation are both
published every frame. OrbitControls panning is disabled on purpose: it
moves the orbit target without moving the camera, which would silently
desync the ear from the view and break that invariant.
Doppler is emergent, not computed. A DelayNode holds distance /
speedOfSound; chasing it resamples the signal exactly as air does. This
removes velocity estimation from the codebase entirely, which is what
eliminated the drag-chirp class of bug. The reported ratio is 1 - dD/dt,
the shift a delay line actually produces. The textbook moving-source form
1/(1 - v/c) agrees only to first order and has a pole that inverts the
sign of the shift when a source closes fast.
The reverb send is tapped BEFORE distance, directivity and occlusion,
because a room's reverberant field is roughly uniform: it depends on how
much power the source radiates, not on where the listener stands. This is
what makes the critical distance audible, and it is the one thing the
simulator teaches that a fader cannot fake.
Room acoustics are derived from geometry via Sabine (T60 = 0.161V/Sa)
rather than from named presets, so the room-size sliders genuinely change
the sound. The impulse response is normalised to unit energy per channel,
not unit peak, because a convolution's output level follows total energy;
peak normalisation would make a long tail far louder than a short one and
the send gain would stop meaning anything.
Source presets are built additively from a bounded harmonic series instead
of being sampled from ideal waveforms, leaving an octave of clean headroom
so Doppler can pitch them up without folding harmonics back down as
aliasing.
User-supplied audio files are summed to mono. A stereo mix fed into the
panner would leave part of the image fixed to the listener's ears no
matter where the source moved, which is precisely the illusion this app
exists to break.
Dark theme only. The viewport is a lit 3D room; a light chrome around it
reads as a white frame on a dark photograph, and re-lighting the scene for
a light theme would need a second set of materials for no real gain.
User-visible behaviour, relative to the uncommitted prior version:
- The 3D view fills its pane and is sized on construction plus via a
ResizeObserver.
- Orbiting the view swings the sound between the ears (measured at
roughly 14 dB of L-R swing across a full orbit in an anechoic room).
- Walking away attenuates the direct path while the reverberant field
holds steady, and a HUD chip reports which of the two dominates.
- A signal-path table lists every stage between source and ear in dB on
one shared -60..0 dB scale. The stage losses sum exactly to the total;
air absorption deliberately reports no dB because it has no broadband
loss, only a cutoff.
- Audio files can be loaded by drop or picker and behave as the source.
- Three guided demos, a first-run explainer, and a keyboard model.
- Leaked agent artifacts are gone: the panel header no longer reads
"M4 OVERLAY", sections are no longer lettered "A.", "B.", and the
milestone scratch directories and docs are deleted.
Known limitations, also listed in README.md:
- Reverb is a single static impulse response per room; it does not vary
with listener position, so walking into a corner is not audible.
- No precedence effect. Real ears localise well beyond the critical
distance because the first wavefront wins; here localisation degrades
with the direct-to-reverberant ratio, so a very live room smears the
image more than it would in life.
- Occlusion tests one obstacle and models diffraction as a fixed
broadband loss rather than as a function of the obstacle's size.
- HRTF quality is whatever the browser's PannerNode provides.
- Loaded files loop with an audible seam; the crossfade used on the
generated presets would be wrong for music.
Test Plan
npm test 116 unit tests. Physics and room acoustics are pure
functions. The audio graph is tested against a mock
AudioContext that records connections, so the suite
can assert topology directly: that the reverb send
hangs off the delay rather than the direct gain, and
that listener forward/up are published and not just
position. Both were silently wrong before.
npm run test:e2e 47 checks driving real Chrome; starts its own dev
server. Covers what a mock cannot: that the view is
actually sized, that orbiting changes the ear balance,
that walking away attenuates the direct path while the
room holds steady, that each demo does what its
caption claims, that the panel follows the demos
rather than silently reverting them, and that a real
WAV decoded by the browser spatialises like any other
source while a non-audio file fails visibly.
npm run build tsc plus a production bundle, split so three.js gets
its own long-lived chunk (470 kB three, 99 kB app).
Manual check: load the page in a browser with headphones, press
"Enable audio & start", then press 3 and walk away with W/S. The Direct
row should fall while Room (wet) stays flat, and the HUD chip should flip
to room-dominant as you cross the ring drawn on the floor.
Refs: ORIGINAL_REQUEST.md
Docs: README.md
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AudioEngine, DEFAULT_SETTINGS, SpatialInput } from '../src/audio/AudioEngine';
|
||||
import { MockAudioContext, MockParam, installMockAudio } from './mockAudioContext';
|
||||
|
||||
let audio: ReturnType<typeof installMockAudio>;
|
||||
|
||||
beforeEach(() => {
|
||||
audio = installMockAudio();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
audio.restore();
|
||||
});
|
||||
|
||||
const ctxOf = () => audio.instances[0] as MockAudioContext;
|
||||
|
||||
function poseAt(x: number, z = 0, extra: Partial<SpatialInput> = {}): SpatialInput {
|
||||
return {
|
||||
sourcePos: { x: 0, y: 1.5, z: 0 },
|
||||
sourceForward: { x: 0, y: 0, z: -1 },
|
||||
listenerPos: { x, y: 1.5, z },
|
||||
listenerForward: { x: 0, y: 0, z: -1 },
|
||||
listenerUp: { x: 0, y: 1, z: 0 },
|
||||
occlusion: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** Settles the delay-line smoothing so telemetry reflects a steady state. */
|
||||
function settle(engine: AudioEngine, pose: SpatialInput, frames = 400) {
|
||||
let telemetry = engine.updateSpatial(pose, 1 / 60);
|
||||
for (let i = 0; i < frames; i++) telemetry = engine.updateSpatial(pose, 1 / 60);
|
||||
return telemetry;
|
||||
}
|
||||
|
||||
describe('graph topology', () => {
|
||||
it('wires the documented signal path', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const edges = ctxOf().edges();
|
||||
|
||||
expect(edges).toContain('gain->delay'); // sourceGain -> delay
|
||||
expect(edges).toContain('delay->biquad'); // -> air absorption
|
||||
expect(edges).toContain('biquad->biquad'); // air -> occlusion
|
||||
expect(edges).toContain('biquad->gain'); // occlusion -> directGain
|
||||
expect(edges).toContain('gain->panner');
|
||||
expect(edges).toContain('gain->convolver'); // reverbSend -> convolver
|
||||
expect(edges).toContain('convolver->gain'); // wet -> master
|
||||
expect(edges).toContain('gain->compressor'); // master -> limiter
|
||||
expect(edges).toContain('compressor->analyser');
|
||||
expect(edges).toContain('analyser->destination');
|
||||
});
|
||||
|
||||
it('taps the reverb send before distance, cone and occlusion', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const ctx = ctxOf();
|
||||
|
||||
// The wet send must hang off the delay, not off the direct gain: a room's
|
||||
// reverberant field does not get quieter because you walked away.
|
||||
const feedsConvolver = ctx.connections.find((c) => c.to.kind === 'convolver');
|
||||
const sendSource = ctx.connections.find((c) => c.to === feedsConvolver?.from);
|
||||
expect(sendSource?.from.kind).toBe('delay');
|
||||
});
|
||||
|
||||
it('neuters the panner so only our own physics sets level', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const panner = ctxOf().nodeOf<never>('panner') as unknown as Record<string, unknown>;
|
||||
expect(panner.panningModel).toBe('HRTF');
|
||||
expect(panner.rolloffFactor).toBe(0);
|
||||
expect(panner.coneOuterGain).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listener orientation', () => {
|
||||
it('publishes forward and up, not just position', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const listener = ctxOf().listener;
|
||||
|
||||
engine.updateSpatial(
|
||||
{
|
||||
...poseAt(5),
|
||||
listenerForward: { x: 1, y: 0, z: 0 },
|
||||
listenerUp: { x: 0, y: 1, z: 0 },
|
||||
},
|
||||
1 / 60
|
||||
);
|
||||
|
||||
expect(listener.forwardX.value).toBeCloseTo(1, 6);
|
||||
expect(listener.forwardZ.value).toBeCloseTo(0, 6);
|
||||
expect(listener.upY.value).toBeCloseTo(1, 6);
|
||||
expect(listener.forwardX.events.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('tracks a turning head', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const listener = ctxOf().listener;
|
||||
|
||||
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -1 } }, 1 / 60);
|
||||
expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
|
||||
|
||||
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: 1 } }, 1 / 60);
|
||||
expect(listener.forwardZ.value).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('normalises whatever it is handed', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const listener = ctxOf().listener;
|
||||
|
||||
engine.updateSpatial({ ...poseAt(5), listenerForward: { x: 0, y: 0, z: -9 } }, 1 / 60);
|
||||
expect(listener.forwardZ.value).toBeCloseTo(-1, 6);
|
||||
});
|
||||
|
||||
it('keeps the panner on the source', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const panner = ctxOf().nodeOf('panner') as unknown as Record<string, MockParam>;
|
||||
|
||||
engine.updateSpatial(
|
||||
{ ...poseAt(5), sourcePos: { x: -3, y: 2, z: 7 } },
|
||||
1 / 60
|
||||
);
|
||||
expect(panner.positionX.value).toBeCloseTo(-3, 6);
|
||||
expect(panner.positionZ.value).toBeCloseTo(7, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('gain staging', () => {
|
||||
it('drives the direct gain from distance, cone and occlusion together', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const ctx = ctxOf();
|
||||
const directGain = ctx.feederOf('gain', 'panner');
|
||||
|
||||
const telemetry = engine.updateSpatial(poseAt(4), 1 / 60);
|
||||
const expected = Math.pow(10, telemetry.directGainDb / 20);
|
||||
expect(directGain.gain.value).toBeCloseTo(expected, 5);
|
||||
});
|
||||
|
||||
it('keeps the reverb send level independent of listener distance', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const send = ctxOf().feederOf('gain', 'convolver');
|
||||
|
||||
engine.updateSpatial(poseAt(2), 1 / 60);
|
||||
const near = send.gain.value;
|
||||
engine.updateSpatial(poseAt(40), 1 / 60);
|
||||
const far = send.gain.value;
|
||||
|
||||
expect(near).toBeGreaterThan(0);
|
||||
expect(far).toBeCloseTo(near, 6);
|
||||
});
|
||||
|
||||
it('applies master volume on its own stage, not folded into the physics', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const ctx = ctxOf();
|
||||
const directGain = ctx.feederOf('gain', 'panner');
|
||||
const master = ctx.feederOf('gain', 'compressor');
|
||||
|
||||
const before = (engine.updateSpatial(poseAt(4), 1 / 60), directGain.gain.value);
|
||||
engine.setMasterVolume(0.25);
|
||||
engine.updateSpatial(poseAt(4), 1 / 60);
|
||||
|
||||
expect(master.gain.value).toBeCloseTo(0.25, 6);
|
||||
// Changing the volume must not move the acoustic gain, or the signal-path
|
||||
// readout would stop describing the physics.
|
||||
expect(directGain.gain.value).toBeCloseTo(before, 6);
|
||||
});
|
||||
|
||||
it('lowers the direct level as the listener walks away', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const near = engine.updateSpatial(poseAt(2), 1 / 60).directGainDb;
|
||||
const far = engine.updateSpatial(poseAt(20), 1 / 60).directGainDb;
|
||||
expect(far).toBeLessThan(near - 15);
|
||||
});
|
||||
|
||||
it('attenuates and muffles a blocked path', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const clear = engine.updateSpatial(poseAt(6, 0, { occlusion: 0 }), 1 / 60);
|
||||
const blocked = engine.updateSpatial(poseAt(6, 0, { occlusion: 1 }), 1 / 60);
|
||||
|
||||
expect(blocked.directGainDb).toBeLessThan(clear.directGainDb);
|
||||
expect(blocked.occlusionCutoff).toBeLessThan(clear.occlusionCutoff / 10);
|
||||
});
|
||||
|
||||
it('attenuates when the source is aimed away', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const onAxis = engine.updateSpatial(
|
||||
{ ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: -1 } },
|
||||
1 / 60
|
||||
);
|
||||
const offAxis = engine.updateSpatial(
|
||||
{ ...poseAt(0, -6), sourceForward: { x: 0, y: 0, z: 1 } },
|
||||
1 / 60
|
||||
);
|
||||
expect(offAxis.coneGainDb).toBeLessThan(onAxis.coneGainDb - 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('propagation and doppler', () => {
|
||||
it('settles the delay line on the true time of flight', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const telemetry = settle(engine, poseAt(34.3));
|
||||
// 34.3 m at 343 m/s is 100 ms.
|
||||
expect(telemetry.timeOfFlightMs).toBeCloseTo(100, 0);
|
||||
});
|
||||
|
||||
it('reports no shift once the geometry is still', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const telemetry = settle(engine, poseAt(10));
|
||||
expect(telemetry.dopplerRatio).toBeCloseTo(1, 3);
|
||||
expect(Math.abs(telemetry.dopplerCents)).toBeLessThan(2);
|
||||
});
|
||||
|
||||
it('raises pitch as the gap closes and lowers it as the gap opens', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
settle(engine, poseAt(30));
|
||||
|
||||
// Sweep the listener inwards: the delay shortens, so pitch must rise.
|
||||
let approaching = 1;
|
||||
for (let d = 30; d > 10; d -= 0.5) {
|
||||
approaching = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
|
||||
}
|
||||
expect(approaching).toBeGreaterThan(1.0005);
|
||||
|
||||
let receding = 1;
|
||||
for (let d = 10; d < 30; d += 0.5) {
|
||||
receding = engine.updateSpatial(poseAt(d), 1 / 60).dopplerRatio;
|
||||
}
|
||||
expect(receding).toBeLessThan(0.9995);
|
||||
});
|
||||
|
||||
it('stays silent about doppler when propagation is switched off', async () => {
|
||||
const engine = new AudioEngine({ propagationEnabled: false });
|
||||
await engine.start();
|
||||
settle(engine, poseAt(30));
|
||||
for (let d = 30; d > 10; d -= 0.5) engine.updateSpatial(poseAt(d), 1 / 60);
|
||||
const telemetry = engine.updateSpatial(poseAt(10), 1 / 60);
|
||||
expect(telemetry.timeOfFlightMs).toBeCloseTo(0, 3);
|
||||
expect(telemetry.dopplerRatio).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('reports the shift a delay line actually produces, 1 − dD/dt', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
settle(engine, poseAt(30));
|
||||
|
||||
// Close at a steady rate and compare the reported ratio against 1 − D'
|
||||
// reconstructed from the reported time of flight.
|
||||
let previousFlight = engine.updateSpatial(poseAt(30), 1 / 60).timeOfFlightMs;
|
||||
let checked = 0;
|
||||
for (let d = 29.5; d > 12; d -= 0.5) {
|
||||
const t = engine.updateSpatial(poseAt(d), 1 / 60);
|
||||
const delayRate = (t.timeOfFlightMs - previousFlight) / 1000 / (1 / 60);
|
||||
previousFlight = t.timeOfFlightMs;
|
||||
expect(t.dopplerRatio).toBeCloseTo(1 - delayRate, 6);
|
||||
checked++;
|
||||
}
|
||||
expect(checked).toBeGreaterThan(10);
|
||||
});
|
||||
|
||||
it('keeps the shift on the correct side of unity even when closing hard', async () => {
|
||||
// The textbook 1/(1 − v/c) form has a pole that flips sign here, reporting
|
||||
// a two-octave drop for a source rushing towards the listener.
|
||||
const engine = new AudioEngine({ speedOfSound: 80 });
|
||||
await engine.start();
|
||||
settle(engine, poseAt(40));
|
||||
for (let d = 39; d > 2; d -= 3) {
|
||||
const t = engine.updateSpatial(poseAt(d), 1 / 60);
|
||||
expect(t.dopplerRatio).toBeGreaterThanOrEqual(1);
|
||||
expect(t.closingSpeed).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('does not saturate the delay line at the slowest speed of sound', async () => {
|
||||
const engine = new AudioEngine({ speedOfSound: 80, room: { width: 60, height: 60, depth: 60 } });
|
||||
await engine.start();
|
||||
// Room diagonal is ~104 m; at 80 m/s that is 1.3 s of flight.
|
||||
const telemetry = settle(engine, poseAt(100), 2000);
|
||||
expect(telemetry.timeOfFlightMs).toBeCloseTo(1250, -1);
|
||||
});
|
||||
|
||||
it('never lets a violent jump produce an absurd pitch', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
settle(engine, poseAt(60));
|
||||
// Teleport the listener: a naive velocity estimate would chirp an octave.
|
||||
const telemetry = engine.updateSpatial(poseAt(0.5), 1 / 60);
|
||||
expect(telemetry.dopplerRatio).toBeLessThanOrEqual(4);
|
||||
expect(telemetry.dopplerRatio).toBeGreaterThanOrEqual(0.25);
|
||||
expect(Number.isFinite(telemetry.dopplerCents)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle', () => {
|
||||
it('reports live telemetry before any AudioContext exists', () => {
|
||||
const engine = new AudioEngine();
|
||||
const telemetry = engine.updateSpatial(poseAt(8), 1 / 60);
|
||||
|
||||
expect(engine.context).toBeNull();
|
||||
expect(telemetry.running).toBe(false);
|
||||
expect(telemetry.distance).toBeCloseTo(8, 6);
|
||||
expect(telemetry.distanceGainDb).toBeLessThan(0);
|
||||
expect(telemetry.criticalDistance).toBeGreaterThan(0);
|
||||
expect(audio.instances).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('resumes a suspended context on start', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
expect(ctxOf().state).toBe('running');
|
||||
expect(engine.isPlaying).toBe(true);
|
||||
});
|
||||
|
||||
it('is idempotent across repeated start and stop', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
await engine.start();
|
||||
expect(ctxOf().started).toHaveLength(1);
|
||||
|
||||
engine.stop();
|
||||
engine.stop();
|
||||
expect(engine.isPlaying).toBe(false);
|
||||
|
||||
await engine.start();
|
||||
expect(engine.isPlaying).toBe(true);
|
||||
expect(ctxOf().started).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('swaps presets without tearing down the graph', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const edgeCount = ctxOf().connections.length;
|
||||
|
||||
engine.update({ preset: 'engine' });
|
||||
engine.update({ preset: 'beacon' });
|
||||
|
||||
expect(engine.getSettings().preset).toBe('beacon');
|
||||
// A new source node reconnects; the rest of the graph must be untouched.
|
||||
expect(ctxOf().connections.length).toBeLessThanOrEqual(edgeCount + 4);
|
||||
});
|
||||
|
||||
it('rebuilds room acoustics when the geometry changes', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const before = engine.getAcoustics();
|
||||
|
||||
engine.update({ room: { width: 40, height: 16, depth: 40 }, surface: 'cathedral' });
|
||||
const after = engine.getAcoustics();
|
||||
|
||||
expect(after.t60).toBeGreaterThan(before.t60);
|
||||
expect(after.criticalDistance).toBeLessThan(before.criticalDistance);
|
||||
});
|
||||
|
||||
it('merges partial room patches instead of dropping dimensions', () => {
|
||||
const engine = new AudioEngine();
|
||||
engine.update({ room: { width: 30 } as never });
|
||||
const room = engine.getSettings().room;
|
||||
expect(room.width).toBe(30);
|
||||
expect(room.height).toBe(DEFAULT_SETTINGS.room.height);
|
||||
expect(room.depth).toBe(DEFAULT_SETTINGS.room.depth);
|
||||
});
|
||||
|
||||
it('clamps master volume', () => {
|
||||
const engine = new AudioEngine();
|
||||
engine.setMasterVolume(5);
|
||||
expect(engine.getSettings().masterVolume).toBe(1);
|
||||
engine.setMasterVolume(-2);
|
||||
expect(engine.getSettings().masterVolume).toBe(0);
|
||||
});
|
||||
|
||||
it('produces finite telemetry for degenerate geometry', () => {
|
||||
const engine = new AudioEngine();
|
||||
const coincident = engine.updateSpatial(
|
||||
{
|
||||
sourcePos: { x: 1, y: 1, z: 1 },
|
||||
sourceForward: { x: 0, y: 0, z: 0 },
|
||||
listenerPos: { x: 1, y: 1, z: 1 },
|
||||
listenerForward: { x: 0, y: 0, z: 0 },
|
||||
listenerUp: { x: 0, y: 0, z: 0 },
|
||||
occlusion: 0,
|
||||
},
|
||||
0
|
||||
);
|
||||
for (const value of Object.values(coincident)) {
|
||||
if (typeof value === 'number') expect(Number.isFinite(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry consistency', () => {
|
||||
it('sums the stage losses into the total, exactly', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const t = engine.updateSpatial(
|
||||
poseAt(0, -7, { occlusion: 0.6, sourceForward: { x: 1, y: 0, z: 0 } }),
|
||||
1 / 60
|
||||
);
|
||||
expect(t.distanceGainDb + t.coneGainDb + t.occlusionGainDb).toBeCloseTo(t.directGainDb, 6);
|
||||
});
|
||||
|
||||
it('derives the direct-to-reverb ratio from the two levels it reports', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const t = engine.updateSpatial(poseAt(9), 1 / 60);
|
||||
expect(t.directToReverbDb).toBeCloseTo(t.directGainDb - t.reverbGainDb, 6);
|
||||
});
|
||||
|
||||
it('flags room dominance at the critical distance when the listener is on-axis', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const rc = engine.getAcoustics().criticalDistance;
|
||||
// poseAt(0, -d) puts the listener dead ahead of a source facing −Z, so the
|
||||
// cone contributes no attenuation and distance alone decides.
|
||||
expect(engine.updateSpatial(poseAt(0, -rc * 0.5), 1 / 60).reverbDominant).toBe(false);
|
||||
expect(engine.updateSpatial(poseAt(0, -rc * 2), 1 / 60).reverbDominant).toBe(true);
|
||||
});
|
||||
|
||||
it('counts directivity towards room dominance, not just distance', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
const rc = engine.getAcoustics().criticalDistance;
|
||||
// Well inside the critical distance, but aimed away: the direct sound loses
|
||||
// to the reverberant field even though the source is close.
|
||||
const behind = engine.updateSpatial(poseAt(0, rc * 0.5), 1 / 60);
|
||||
expect(behind.distance).toBeLessThan(rc);
|
||||
expect(behind.reverbDominant).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fmt } from '../src/ui/format';
|
||||
|
||||
const stripThin = (s: string) => s.replace(/ /g, ' ');
|
||||
|
||||
describe('value formatting', () => {
|
||||
it('uses a real minus sign, not a hyphen', () => {
|
||||
expect(fmt.db(-12.3)).toContain('−');
|
||||
expect(fmt.db(-12.3)).not.toContain('-');
|
||||
});
|
||||
|
||||
it('never renders a negative zero', () => {
|
||||
// A Doppler ratio a hair under 1 must read "0.0 ¢", not "−0.0 ¢".
|
||||
expect(stripThin(fmt.cents(-0.0001))).toBe('0.0 ¢');
|
||||
expect(stripThin(fmt.cents(0))).toBe('0.0 ¢');
|
||||
expect(stripThin(fmt.speed(-0.004))).toBe('0.0 m/s');
|
||||
expect(stripThin(fmt.db(-0.02))).toBe('0.0 dB');
|
||||
});
|
||||
|
||||
it('signs real values', () => {
|
||||
expect(stripThin(fmt.cents(38.2))).toBe('+38.2 ¢');
|
||||
expect(stripThin(fmt.cents(-38.2))).toBe('−38.2 ¢');
|
||||
});
|
||||
|
||||
it('renders silence as −∞ rather than a huge number', () => {
|
||||
expect(stripThin(fmt.db(-120))).toBe('−∞ dB');
|
||||
expect(stripThin(fmt.db(-500))).toBe('−∞ dB');
|
||||
});
|
||||
|
||||
it('survives non-finite input', () => {
|
||||
for (const formatter of [fmt.metres, fmt.db, fmt.degrees, fmt.seconds, fmt.ms, fmt.speed]) {
|
||||
expect(formatter(Number.NaN)).toBe('—');
|
||||
expect(formatter(Number.POSITIVE_INFINITY)).toBe('—');
|
||||
}
|
||||
expect(fmt.hz(Number.NaN)).toBe('—');
|
||||
expect(fmt.cents(Number.NaN)).toBe('—');
|
||||
});
|
||||
|
||||
it('switches Hz to kHz with three significant figures', () => {
|
||||
expect(stripThin(fmt.hz(350))).toBe('350 Hz');
|
||||
expect(stripThin(fmt.hz(1700))).toBe('1.70 kHz');
|
||||
expect(stripThin(fmt.hz(22050))).toBe('22.1 kHz');
|
||||
});
|
||||
|
||||
it('keeps decimals fixed so numbers do not jitter in width', () => {
|
||||
expect(stripThin(fmt.metres(5))).toBe('5.00 m');
|
||||
expect(stripThin(fmt.metres(12.345))).toBe('12.35 m');
|
||||
expect(stripThin(fmt.ratio(1))).toBe('×1.0000');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* A minimal Web Audio stand-in: enough of the API for AudioEngine to build its
|
||||
* graph, with the connections recorded so tests can assert on the topology
|
||||
* rather than on implementation details.
|
||||
*/
|
||||
|
||||
export interface Connection {
|
||||
from: MockNode;
|
||||
to: MockNode;
|
||||
}
|
||||
|
||||
let nodeCounter = 0;
|
||||
|
||||
export class MockParam {
|
||||
value: number;
|
||||
readonly events: Array<{ type: string; value: number; time: number }> = [];
|
||||
|
||||
constructor(value = 0) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
setValueAtTime(value: number, time: number): this {
|
||||
this.value = value;
|
||||
this.events.push({ type: 'setValueAtTime', value, time });
|
||||
return this;
|
||||
}
|
||||
|
||||
setTargetAtTime(value: number, time: number): this {
|
||||
this.value = value;
|
||||
this.events.push({ type: 'setTargetAtTime', value, time });
|
||||
return this;
|
||||
}
|
||||
|
||||
linearRampToValueAtTime(value: number, time: number): this {
|
||||
this.value = value;
|
||||
this.events.push({ type: 'linearRamp', value, time });
|
||||
return this;
|
||||
}
|
||||
|
||||
cancelScheduledValues(): this {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class MockNode {
|
||||
readonly id: string;
|
||||
readonly outputs: MockNode[] = [];
|
||||
|
||||
constructor(readonly kind: string, readonly ctx: MockAudioContext) {
|
||||
this.id = `${kind}#${nodeCounter++}`;
|
||||
ctx.nodes.push(this);
|
||||
}
|
||||
|
||||
connect(target: MockNode): MockNode {
|
||||
this.outputs.push(target);
|
||||
this.ctx.connections.push({ from: this, to: target });
|
||||
return target;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.outputs.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class MockAudioParamNode extends MockNode {
|
||||
readonly gain = new MockParam(1);
|
||||
}
|
||||
|
||||
export class MockAudioContext {
|
||||
readonly connections: Connection[] = [];
|
||||
readonly nodes: MockNode[] = [];
|
||||
readonly sampleRate = 48000;
|
||||
currentTime = 0;
|
||||
state: AudioContextState = 'suspended';
|
||||
|
||||
readonly destination = new MockNode('destination', this);
|
||||
readonly listener = {
|
||||
positionX: new MockParam(),
|
||||
positionY: new MockParam(),
|
||||
positionZ: new MockParam(),
|
||||
forwardX: new MockParam(0),
|
||||
forwardY: new MockParam(0),
|
||||
forwardZ: new MockParam(-1),
|
||||
upX: new MockParam(0),
|
||||
upY: new MockParam(1),
|
||||
upZ: new MockParam(0),
|
||||
};
|
||||
|
||||
readonly started: MockNode[] = [];
|
||||
|
||||
async resume(): Promise<void> {
|
||||
this.state = 'running';
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.state = 'closed';
|
||||
}
|
||||
|
||||
createGain() {
|
||||
return new MockAudioParamNode('gain', this);
|
||||
}
|
||||
|
||||
createDelay(maxDelay = 1) {
|
||||
const node = new MockNode('delay', this) as MockNode & {
|
||||
delayTime: MockParam;
|
||||
maxDelayTime: number;
|
||||
};
|
||||
node.delayTime = new MockParam(0);
|
||||
node.maxDelayTime = maxDelay;
|
||||
return node;
|
||||
}
|
||||
|
||||
createBiquadFilter() {
|
||||
const node = new MockNode('biquad', this) as MockNode & {
|
||||
type: string;
|
||||
frequency: MockParam;
|
||||
Q: MockParam;
|
||||
};
|
||||
node.type = 'lowpass';
|
||||
node.frequency = new MockParam(350);
|
||||
node.Q = new MockParam(1);
|
||||
return node;
|
||||
}
|
||||
|
||||
createPanner() {
|
||||
const node = new MockNode('panner', this) as MockNode & Record<string, unknown>;
|
||||
node.panningModel = 'equalpower';
|
||||
node.distanceModel = 'inverse';
|
||||
node.rolloffFactor = 1;
|
||||
node.coneInnerAngle = 360;
|
||||
node.coneOuterAngle = 360;
|
||||
node.coneOuterGain = 0;
|
||||
node.positionX = new MockParam();
|
||||
node.positionY = new MockParam();
|
||||
node.positionZ = new MockParam();
|
||||
node.orientationX = new MockParam(1);
|
||||
node.orientationY = new MockParam(0);
|
||||
node.orientationZ = new MockParam(0);
|
||||
return node;
|
||||
}
|
||||
|
||||
createConvolver() {
|
||||
const node = new MockNode('convolver', this) as MockNode & {
|
||||
buffer: AudioBuffer | null;
|
||||
normalize: boolean;
|
||||
};
|
||||
node.buffer = null;
|
||||
node.normalize = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
createDynamicsCompressor() {
|
||||
const node = new MockNode('compressor', this) as MockNode & Record<string, MockParam>;
|
||||
for (const key of ['threshold', 'knee', 'ratio', 'attack', 'release']) {
|
||||
node[key] = new MockParam(0);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
createAnalyser() {
|
||||
const node = new MockNode('analyser', this) as MockNode & Record<string, unknown>;
|
||||
node.fftSize = 2048;
|
||||
node.frequencyBinCount = 1024;
|
||||
node.smoothingTimeConstant = 0.8;
|
||||
node.minDecibels = -100;
|
||||
node.maxDecibels = -30;
|
||||
node.getByteFrequencyData = (array: Uint8Array) => array.fill(0);
|
||||
node.getByteTimeDomainData = (array: Uint8Array) => array.fill(128);
|
||||
return node;
|
||||
}
|
||||
|
||||
createChannelSplitter() {
|
||||
return new MockNode('splitter', this);
|
||||
}
|
||||
|
||||
createBufferSource() {
|
||||
const ctx = this;
|
||||
const node = new MockNode('bufferSource', this) as MockNode & Record<string, unknown>;
|
||||
node.buffer = null;
|
||||
node.loop = false;
|
||||
node.playbackRate = new MockParam(1);
|
||||
node.start = () => {
|
||||
ctx.started.push(node);
|
||||
};
|
||||
node.stop = () => {};
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the browser decoder. A payload whose first byte is 0 is
|
||||
* treated as undecodable, which is how tests exercise the failure path.
|
||||
*/
|
||||
async decodeAudioData(bytes: ArrayBuffer): Promise<AudioBuffer> {
|
||||
const view = new Uint8Array(bytes);
|
||||
if (view.length === 0 || view[0] === 0) throw new Error('EncodingError');
|
||||
|
||||
// Two channels of a recognisable ramp, so downmixing can be checked.
|
||||
const length = 4096;
|
||||
const buffer = this.createBuffer(2, length, this.sampleRate);
|
||||
const left = buffer.getChannelData(0);
|
||||
const right = buffer.getChannelData(1);
|
||||
for (let i = 0; i < length; i++) {
|
||||
left[i] = Math.sin((i / length) * Math.PI * 2) * 0.8;
|
||||
right[i] = -left[i] * 0.5;
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
createBuffer(channels: number, length: number, sampleRate: number): AudioBuffer {
|
||||
const data = Array.from({ length: channels }, () => new Float32Array(length));
|
||||
return {
|
||||
numberOfChannels: channels,
|
||||
length,
|
||||
sampleRate,
|
||||
duration: length / sampleRate,
|
||||
getChannelData: (channel: number) => data[channel],
|
||||
copyFromChannel: () => {},
|
||||
copyToChannel: () => {},
|
||||
} as unknown as AudioBuffer;
|
||||
}
|
||||
|
||||
/** Every edge in the graph, as "from->to" strings. */
|
||||
edges(): string[] {
|
||||
return this.connections.map((c) => `${c.from.kind}->${c.to.kind}`);
|
||||
}
|
||||
|
||||
/** The single node of `kind` that feeds a node of `intoKind`. */
|
||||
feederOf(kind: string, intoKind: string): MockNode & { gain: MockParam } {
|
||||
const edge = this.connections.find((c) => c.from.kind === kind && c.to.kind === intoKind);
|
||||
if (!edge) throw new Error(`No ${kind} -> ${intoKind} edge in the graph`);
|
||||
return edge.from as MockNode & { gain: MockParam };
|
||||
}
|
||||
|
||||
nodeOf<T extends MockNode>(kind: string): T {
|
||||
const node = this.nodes.find((n) => n.kind === kind);
|
||||
if (!node) throw new Error(`No ${kind} node in the graph`);
|
||||
return node as T;
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs the mock as the global AudioContext and returns a reset function. */
|
||||
export function installMockAudio(): { restore: () => void; instances: MockAudioContext[] } {
|
||||
const instances: MockAudioContext[] = [];
|
||||
const previous = (globalThis as Record<string, unknown>).AudioContext;
|
||||
|
||||
(globalThis as Record<string, unknown>).AudioContext = function MockCtor() {
|
||||
const ctx = new MockAudioContext();
|
||||
instances.push(ctx);
|
||||
return ctx;
|
||||
} as unknown as typeof AudioContext;
|
||||
|
||||
return {
|
||||
instances,
|
||||
restore() {
|
||||
(globalThis as Record<string, unknown>).AudioContext = previous;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
airAbsorptionCutoff,
|
||||
coneGain,
|
||||
distanceGain,
|
||||
dopplerRatio,
|
||||
gainToDb,
|
||||
inverseDistanceGain,
|
||||
linearDistanceGain,
|
||||
occlusionResponse,
|
||||
offAxisAngle,
|
||||
ratioToCents,
|
||||
smoothingAlpha,
|
||||
} from '../src/physics';
|
||||
|
||||
const at = (x: number, y = 0, z = 0) => ({ x, y, z });
|
||||
const params = { refDistance: 1, maxDistance: 100, rolloffFactor: 1 };
|
||||
|
||||
describe('distance attenuation', () => {
|
||||
it('holds unity gain inside the reference distance', () => {
|
||||
expect(inverseDistanceGain(0, params)).toBe(1);
|
||||
expect(inverseDistanceGain(0.5, params)).toBe(1);
|
||||
expect(inverseDistanceGain(1, params)).toBe(1);
|
||||
});
|
||||
|
||||
it('loses 6 dB per doubling of distance, the inverse-distance law', () => {
|
||||
const near = gainToDb(inverseDistanceGain(4, params));
|
||||
const far = gainToDb(inverseDistanceGain(8, params));
|
||||
expect(near - far).toBeCloseTo(6.02, 1);
|
||||
});
|
||||
|
||||
it('never attenuates when rolloff is zero', () => {
|
||||
const flat = { ...params, rolloffFactor: 0 };
|
||||
expect(inverseDistanceGain(50, flat)).toBe(1);
|
||||
});
|
||||
|
||||
it('reaches silence at maxDistance under the linear model', () => {
|
||||
expect(linearDistanceGain(100, params)).toBe(0);
|
||||
expect(linearDistanceGain(1000, params)).toBe(0);
|
||||
});
|
||||
|
||||
it('degrades gracefully when maxDistance is below refDistance', () => {
|
||||
const inverted = { refDistance: 10, maxDistance: 2, rolloffFactor: 1 };
|
||||
expect(linearDistanceGain(1, inverted)).toBe(1);
|
||||
expect(linearDistanceGain(5, inverted)).toBe(0);
|
||||
});
|
||||
|
||||
it('stays within [0, 1] for every model across a wide sweep', () => {
|
||||
for (const model of ['inverse', 'exponential', 'linear'] as const) {
|
||||
for (const d of [0, 0.001, 1, 7, 99, 1e4]) {
|
||||
for (const rolloff of [0, 1, 5]) {
|
||||
const gain = distanceGain(d, model, { ...params, rolloffFactor: rolloff });
|
||||
expect(gain).toBeGreaterThanOrEqual(0);
|
||||
expect(gain).toBeLessThanOrEqual(1);
|
||||
expect(Number.isFinite(gain)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('decreases monotonically with distance', () => {
|
||||
for (const model of ['inverse', 'exponential', 'linear'] as const) {
|
||||
let previous = Infinity;
|
||||
for (let d = 0; d <= 60; d += 2) {
|
||||
const gain = distanceGain(d, model, params);
|
||||
expect(gain).toBeLessThanOrEqual(previous + 1e-12);
|
||||
previous = gain;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('air absorption', () => {
|
||||
it('is transparent at zero strength or zero distance', () => {
|
||||
expect(airAbsorptionCutoff(50, 0)).toBe(22050);
|
||||
expect(airAbsorptionCutoff(0, 4)).toBe(22050);
|
||||
});
|
||||
|
||||
it('rolls the cutoff down as distance grows', () => {
|
||||
const near = airAbsorptionCutoff(5, 1);
|
||||
const far = airAbsorptionCutoff(80, 1);
|
||||
expect(far).toBeLessThan(near);
|
||||
expect(far).toBeGreaterThan(200);
|
||||
});
|
||||
|
||||
it('is barely audible at realistic strength across a room', () => {
|
||||
// Real air costs very little treble over 20 m; the default must reflect that.
|
||||
expect(airAbsorptionCutoff(20, 1)).toBeGreaterThan(15000);
|
||||
});
|
||||
|
||||
it('never falls below the floor even at extreme strength', () => {
|
||||
expect(airAbsorptionCutoff(1000, 8)).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('directivity cone', () => {
|
||||
const cone = { innerAngle: 60, outerAngle: 180, outerGain: 0.1 };
|
||||
const source = at(0, 0, 0);
|
||||
const forward = at(0, 0, -1);
|
||||
|
||||
it('is at full level dead ahead', () => {
|
||||
expect(coneGain(source, forward, at(0, 0, -5), cone)).toBe(1);
|
||||
});
|
||||
|
||||
it('is at full level anywhere inside the inner cone', () => {
|
||||
// 25° off-axis, inside the 30° inner half-angle.
|
||||
const listener = at(Math.sin(0.436) * 5, 0, -Math.cos(0.436) * 5);
|
||||
expect(coneGain(source, forward, listener, cone)).toBe(1);
|
||||
});
|
||||
|
||||
it('falls to the floor gain directly behind the source', () => {
|
||||
expect(coneGain(source, forward, at(0, 0, 5), cone)).toBeCloseTo(0.1, 6);
|
||||
});
|
||||
|
||||
it('interpolates across the transition band', () => {
|
||||
// 60° off-axis: past the 30° inner half-angle, short of the 90° outer one.
|
||||
const listener = at(Math.sin(Math.PI / 3) * 5, 0, -Math.cos(Math.PI / 3) * 5);
|
||||
const gain = coneGain(source, forward, listener, cone);
|
||||
expect(gain).toBeGreaterThan(0.1);
|
||||
expect(gain).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('reaches the floor exactly at the outer half-angle', () => {
|
||||
expect(coneGain(source, forward, at(5, 0, 0), cone)).toBeCloseTo(0.1, 6);
|
||||
});
|
||||
|
||||
it('falls monotonically as the listener swings off-axis', () => {
|
||||
let previous = Infinity;
|
||||
for (let deg = 0; deg <= 180; deg += 10) {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const listener = at(Math.sin(rad) * 5, 0, -Math.cos(rad) * 5);
|
||||
const gain = coneGain(source, forward, listener, cone);
|
||||
expect(gain).toBeLessThanOrEqual(previous + 1e-9);
|
||||
previous = gain;
|
||||
}
|
||||
});
|
||||
|
||||
it('is omnidirectional at 360 degrees', () => {
|
||||
const omni = { innerAngle: 360, outerAngle: 360, outerGain: 0 };
|
||||
for (const listener of [at(5), at(-5), at(0, 5), at(0, 0, 5)]) {
|
||||
expect(coneGain(source, forward, listener, omni)).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats a coincident listener as on-axis rather than dividing by zero', () => {
|
||||
expect(coneGain(source, forward, source, cone)).toBe(1);
|
||||
});
|
||||
|
||||
it('survives a degenerate forward vector', () => {
|
||||
const gain = coneGain(source, at(0, 0, 0), at(0, 0, -5), cone);
|
||||
expect(Number.isFinite(gain)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the off-axis angle in degrees', () => {
|
||||
expect(offAxisAngle(source, forward, at(0, 0, -5))).toBeCloseTo(0, 6);
|
||||
expect(offAxisAngle(source, forward, at(5, 0, 0))).toBeCloseTo(90, 6);
|
||||
expect(offAxisAngle(source, forward, at(0, 0, 5))).toBeCloseTo(180, 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('doppler', () => {
|
||||
const still = at(0, 0, 0);
|
||||
|
||||
it('is unity when nothing moves', () => {
|
||||
expect(dopplerRatio(at(0), still, at(10), still)).toBe(1);
|
||||
});
|
||||
|
||||
it('raises pitch when the source approaches', () => {
|
||||
// Source at origin, listener at +10 x, source moving towards it.
|
||||
expect(dopplerRatio(at(0), at(30), at(10), still)).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('lowers pitch when the source recedes', () => {
|
||||
expect(dopplerRatio(at(0), at(-30), at(10), still)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('lowers pitch when the listener flees', () => {
|
||||
expect(dopplerRatio(at(0), still, at(10), at(30))).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it('matches the textbook ratio for an approaching source', () => {
|
||||
// f'/f = c / (c - v) = 343 / (343 - 34.3) = 1.1111…
|
||||
expect(dopplerRatio(at(0), at(34.3), at(10), still, 343)).toBeCloseTo(1.1111, 3);
|
||||
});
|
||||
|
||||
it('ignores motion perpendicular to the line of sight', () => {
|
||||
expect(dopplerRatio(at(0), at(0, 0, 40), at(10), still)).toBeCloseTo(1, 6);
|
||||
});
|
||||
|
||||
it('stays finite and bounded at and beyond the speed of sound', () => {
|
||||
for (const v of [343, 400, 5000]) {
|
||||
const ratio = dopplerRatio(at(0), at(v), at(10), still, 343);
|
||||
expect(Number.isFinite(ratio)).toBe(true);
|
||||
expect(ratio).toBeLessThanOrEqual(4);
|
||||
expect(ratio).toBeGreaterThanOrEqual(0.25);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a sane medium when given a nonsense speed of sound', () => {
|
||||
expect(dopplerRatio(at(0), still, at(10), still, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns unity for a coincident source and listener', () => {
|
||||
expect(dopplerRatio(at(5), at(10), at(5), still)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('occlusion', () => {
|
||||
it('is transparent with a clear line of sight', () => {
|
||||
const clear = occlusionResponse(0);
|
||||
expect(clear.gain).toBe(1);
|
||||
expect(clear.cutoff).toBeCloseTo(22050, 0);
|
||||
});
|
||||
|
||||
it('muffles but does not silence a fully blocked path', () => {
|
||||
const blocked = occlusionResponse(1);
|
||||
expect(blocked.cutoff).toBeCloseTo(350, 0);
|
||||
expect(blocked.gain).toBeGreaterThan(0);
|
||||
expect(blocked.gain).toBeLessThan(0.3);
|
||||
});
|
||||
|
||||
it('sweeps the cutoff monotonically and geometrically', () => {
|
||||
let previous = Infinity;
|
||||
for (let t = 0; t <= 1; t += 0.1) {
|
||||
const { cutoff } = occlusionResponse(t);
|
||||
expect(cutoff).toBeLessThan(previous);
|
||||
previous = cutoff;
|
||||
}
|
||||
// Geometric interpolation puts the halfway point at the geometric mean,
|
||||
// which is what keeps the sweep sounding even.
|
||||
expect(occlusionResponse(0.5).cutoff).toBeCloseTo(Math.sqrt(350 * 22050), 0);
|
||||
});
|
||||
|
||||
it('clamps out-of-range input', () => {
|
||||
expect(occlusionResponse(-3).gain).toBe(1);
|
||||
expect(occlusionResponse(9).gain).toBeCloseTo(occlusionResponse(1).gain, 12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unit helpers', () => {
|
||||
it('converts gain to decibels', () => {
|
||||
expect(gainToDb(1)).toBeCloseTo(0, 9);
|
||||
expect(gainToDb(0.5)).toBeCloseTo(-6.02, 2);
|
||||
expect(gainToDb(0)).toBe(-120);
|
||||
});
|
||||
|
||||
it('converts frequency ratios to cents', () => {
|
||||
expect(ratioToCents(1)).toBeCloseTo(0, 9);
|
||||
expect(ratioToCents(2)).toBeCloseTo(1200, 6);
|
||||
expect(ratioToCents(0.5)).toBeCloseTo(-1200, 6);
|
||||
});
|
||||
|
||||
it('produces frame-rate independent smoothing', () => {
|
||||
// Two 8 ms steps must land where one 16 ms step lands.
|
||||
const single = smoothingAlpha(0.016, 0.1);
|
||||
const a = smoothingAlpha(0.008, 0.1);
|
||||
const twice = 1 - (1 - a) * (1 - a);
|
||||
expect(twice).toBeCloseTo(single, 9);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PRESETS, createPresetBuffer } from '../src/audio/presets';
|
||||
import { MockAudioContext } from './mockAudioContext';
|
||||
|
||||
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
|
||||
|
||||
describe('sound presets', () => {
|
||||
it('offers a labelled, explained option for every id', () => {
|
||||
for (const preset of PRESETS) {
|
||||
expect(preset.label.length).toBeGreaterThan(0);
|
||||
expect(preset.hint.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(new Set(PRESETS.map((p) => p.id)).size).toBe(PRESETS.length);
|
||||
});
|
||||
|
||||
it('renders every preset as finite, audible, unclipped audio', () => {
|
||||
for (const preset of PRESETS) {
|
||||
const buffer = createPresetBuffer(ctx, preset.id);
|
||||
expect(buffer.length).toBeGreaterThan(1000);
|
||||
|
||||
const data = buffer.getChannelData(0);
|
||||
let peak = 0;
|
||||
let energy = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
expect(Number.isFinite(data[i])).toBe(true);
|
||||
peak = Math.max(peak, Math.abs(data[i]));
|
||||
energy += data[i] * data[i];
|
||||
}
|
||||
expect(peak).toBeGreaterThan(0.05);
|
||||
expect(peak).toBeLessThanOrEqual(1);
|
||||
expect(energy).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a known preset for an unrecognised id', () => {
|
||||
expect(createPresetBuffer(ctx, 'nonsense' as never).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('loops without a discontinuity', () => {
|
||||
for (const preset of PRESETS) {
|
||||
const data = createPresetBuffer(ctx, preset.id).getChannelData(0);
|
||||
// Measure against the 99.9th percentile of the internal sample-to-sample
|
||||
// steps, never the maximum. A bug that injects one discontinuity would
|
||||
// raise the maximum and so raise the bar it is being measured against —
|
||||
// it would manufacture its own alibi.
|
||||
const steps = new Float64Array(data.length - 1);
|
||||
for (let i = 1; i < data.length; i++) steps[i - 1] = Math.abs(data[i] - data[i - 1]);
|
||||
steps.sort();
|
||||
const typical = steps[Math.floor(steps.length * 0.999)];
|
||||
|
||||
const seam = Math.abs(data[data.length - 1] - data[0]);
|
||||
expect(seam).toBeLessThanOrEqual(typical * 2 + 0.02);
|
||||
}
|
||||
});
|
||||
|
||||
it('introduces no discontinuity of its own at the crossfade boundary', () => {
|
||||
// The wrap-around crossfade must not swap a seam at index 0 for a seam at
|
||||
// the end of the fade region.
|
||||
for (const preset of ['pink', 'engine'] as const) {
|
||||
const data = createPresetBuffer(ctx, preset).getChannelData(0);
|
||||
const steps = new Float64Array(data.length - 1);
|
||||
for (let i = 1; i < data.length; i++) steps[i - 1] = Math.abs(data[i] - data[i - 1]);
|
||||
const sorted = Float64Array.from(steps).sort();
|
||||
const typical = sorted[Math.floor(sorted.length * 0.999)];
|
||||
let worst = 0;
|
||||
for (const step of steps) worst = Math.max(worst, step);
|
||||
expect(worst).toBeLessThanOrEqual(typical * 3);
|
||||
}
|
||||
});
|
||||
|
||||
it('band-limits the harmonic waveforms so Doppler cannot alias them', () => {
|
||||
// A naive sawtooth carries energy right up to Nyquist; pitching it up folds
|
||||
// the top harmonics back down as inharmonic noise. Building the wave from a
|
||||
// bounded harmonic series leaves an octave of clean headroom instead.
|
||||
const rate = 48000;
|
||||
const fundamental = 165;
|
||||
const data = createPresetBuffer(ctx, 'sawtooth').getChannelData(0);
|
||||
|
||||
const inBand = magnitudeAt(data, fundamental * 20, rate);
|
||||
const nearLimit = magnitudeAt(data, fundamental * 70, rate);
|
||||
const aboveLimit = magnitudeAt(data, fundamental * 90, rate);
|
||||
|
||||
expect(inBand).toBeGreaterThan(0);
|
||||
// Harmonics run out below rate/4, leaving room for a 2x Doppler shift.
|
||||
expect(nearLimit).toBeGreaterThan(aboveLimit * 20);
|
||||
expect(aboveLimit).toBeLessThan(inBand * 0.02);
|
||||
});
|
||||
});
|
||||
|
||||
/** Single-bin DFT magnitude, for probing one frequency without a full FFT. */
|
||||
function magnitudeAt(data: Float32Array, frequency: number, sampleRate: number): number {
|
||||
const omega = (2 * Math.PI * frequency) / sampleRate;
|
||||
let real = 0;
|
||||
let imaginary = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
real += data[i] * Math.cos(omega * i);
|
||||
imaginary += data[i] * Math.sin(omega * i);
|
||||
}
|
||||
return Math.hypot(real, imaginary) / data.length;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SURFACES,
|
||||
computeAcoustics,
|
||||
generateImpulseResponse,
|
||||
getSurface,
|
||||
reverberantGain,
|
||||
} from '../src/audio/reverb';
|
||||
import { MockAudioContext } from './mockAudioContext';
|
||||
|
||||
const room = { width: 20, height: 8, depth: 20 };
|
||||
|
||||
describe('room acoustics', () => {
|
||||
it('matches Sabine for a worked example', () => {
|
||||
// V = 3200 m³, S = 1440 m², α = 0.22 → T60 = 0.161·3200 / (1440·0.22)
|
||||
const { t60, volume, surfaceArea } = computeAcoustics(room, 0.22);
|
||||
expect(volume).toBe(3200);
|
||||
expect(surfaceArea).toBe(1440);
|
||||
expect(t60).toBeCloseTo((0.161 * 3200) / (1440 * 0.22), 4);
|
||||
});
|
||||
|
||||
it('rings longer in a bigger room', () => {
|
||||
const small = computeAcoustics({ width: 6, height: 3, depth: 6 }, 0.22);
|
||||
const large = computeAcoustics({ width: 40, height: 15, depth: 40 }, 0.22);
|
||||
expect(large.t60).toBeGreaterThan(small.t60);
|
||||
});
|
||||
|
||||
it('rings longer on harder surfaces', () => {
|
||||
const soft = computeAcoustics(room, 0.9);
|
||||
const hard = computeAcoustics(room, 0.035);
|
||||
expect(hard.t60).toBeGreaterThan(soft.t60);
|
||||
});
|
||||
|
||||
it('clamps T60 to a usable range for absurd geometry', () => {
|
||||
const huge = computeAcoustics({ width: 500, height: 200, depth: 500 }, 0.01);
|
||||
expect(huge.t60).toBeLessThanOrEqual(8);
|
||||
expect(huge.t60).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('puts the critical distance closer in a live room than a dead one', () => {
|
||||
const dead = computeAcoustics(room, 0.9);
|
||||
const live = computeAcoustics(room, 0.035);
|
||||
expect(live.criticalDistance).toBeLessThan(dead.criticalDistance);
|
||||
});
|
||||
|
||||
it('produces a physically sensible critical distance for a normal room', () => {
|
||||
// A furnished 20x8x20 room should cross over a couple of metres out.
|
||||
const { criticalDistance } = computeAcoustics(room, 0.22);
|
||||
expect(criticalDistance).toBeGreaterThan(1);
|
||||
expect(criticalDistance).toBeLessThan(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reverberant field level', () => {
|
||||
it('balances wet against dry exactly at the critical distance', () => {
|
||||
const acoustics = computeAcoustics(room, 0.22);
|
||||
// Dry follows 1/r with refDistance 1, so at r = rc it equals 1/rc.
|
||||
expect(reverberantGain(acoustics, 1)).toBeCloseTo(1 / acoustics.criticalDistance, 6);
|
||||
});
|
||||
|
||||
it('is louder in a more reflective room', () => {
|
||||
const dead = reverberantGain(computeAcoustics(room, 0.9));
|
||||
const live = reverberantGain(computeAcoustics(room, 0.035));
|
||||
expect(live).toBeGreaterThan(dead);
|
||||
});
|
||||
|
||||
it('stays bounded', () => {
|
||||
for (const surface of SURFACES) {
|
||||
const gain = reverberantGain(computeAcoustics(room, surface.absorption));
|
||||
expect(gain).toBeGreaterThanOrEqual(0);
|
||||
expect(gain).toBeLessThanOrEqual(1.4);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('impulse response', () => {
|
||||
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
|
||||
|
||||
it('is stereo, finite and non-silent', () => {
|
||||
const ir = generateImpulseResponse(ctx, room, 0.22);
|
||||
expect(ir.numberOfChannels).toBe(2);
|
||||
expect(ir.length).toBeGreaterThan(1000);
|
||||
|
||||
const left = ir.getChannelData(0);
|
||||
let energy = 0;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
expect(Number.isFinite(left[i])).toBe(true);
|
||||
expect(Math.abs(left[i])).toBeLessThanOrEqual(1);
|
||||
energy += left[i] * left[i];
|
||||
}
|
||||
expect(energy).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('decays, rather than sustaining or growing', () => {
|
||||
const ir = generateImpulseResponse(ctx, room, 0.22);
|
||||
const data = ir.getChannelData(0);
|
||||
const rms = (from: number, to: number) => {
|
||||
let sum = 0;
|
||||
for (let i = from; i < to; i++) sum += data[i] * data[i];
|
||||
return Math.sqrt(sum / (to - from));
|
||||
};
|
||||
const head = rms(0, Math.floor(data.length * 0.1));
|
||||
const tail = rms(Math.floor(data.length * 0.85), data.length);
|
||||
expect(tail).toBeLessThan(head * 0.2);
|
||||
});
|
||||
|
||||
it('gets longer on harder surfaces', () => {
|
||||
const soft = generateImpulseResponse(ctx, room, 0.9);
|
||||
const hard = generateImpulseResponse(ctx, room, 0.035);
|
||||
expect(hard.length).toBeGreaterThan(soft.length);
|
||||
});
|
||||
|
||||
it('places early reflections at the geometric arrival times', () => {
|
||||
const ir = generateImpulseResponse(ctx, room, 0.09, 343);
|
||||
const data = ir.getChannelData(0);
|
||||
// A 20 m wall path arrives at 20/343 s.
|
||||
const expected = Math.floor((20 / 343) * ir.sampleRate);
|
||||
const neighbourhood = Math.max(
|
||||
...Array.from({ length: 5 }, (_, i) => Math.abs(data[expected - 2 + i]))
|
||||
);
|
||||
const background = Math.abs(data[expected + 2000]);
|
||||
expect(neighbourhood).toBeGreaterThan(background);
|
||||
});
|
||||
});
|
||||
|
||||
describe('surfaces', () => {
|
||||
it('exposes an absorption coefficient for every preset', () => {
|
||||
for (const surface of SURFACES) {
|
||||
expect(surface.absorption).toBeGreaterThan(0);
|
||||
expect(surface.absorption).toBeLessThan(1);
|
||||
expect(surface.hint.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to a normal room for an unknown id', () => {
|
||||
expect(getSurface('nonsense' as never).id).toBe('living');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AudioEngine } from '../src/audio/AudioEngine';
|
||||
import {
|
||||
AudioFileError,
|
||||
MAX_FILE_BYTES,
|
||||
decodeAudioFile,
|
||||
downmixToMono,
|
||||
formatDuration,
|
||||
} from '../src/audio/userAudio';
|
||||
import { MockAudioContext, installMockAudio } from './mockAudioContext';
|
||||
|
||||
const ctx = new MockAudioContext() as unknown as BaseAudioContext;
|
||||
|
||||
/** A File whose bytes are controllable; first byte 0 means "undecodable". */
|
||||
function fakeFile(name: string, bytes: number[], size?: number): File {
|
||||
const data = new Uint8Array(bytes);
|
||||
return {
|
||||
name,
|
||||
size: size ?? data.length,
|
||||
arrayBuffer: async () => data.buffer,
|
||||
} as unknown as File;
|
||||
}
|
||||
|
||||
describe('downmix to mono', () => {
|
||||
it('averages the channels', () => {
|
||||
const stereo = ctx.createBuffer(2, 4, 48000);
|
||||
stereo.getChannelData(0).set([1, 0, 0.5, -1]);
|
||||
stereo.getChannelData(1).set([0, 1, 0.5, -1]);
|
||||
|
||||
const mono = downmixToMono(ctx, stereo);
|
||||
expect(mono.numberOfChannels).toBe(1);
|
||||
// Averages to [0.5, 0.5, 0.5, -1], then the −1 peak is pulled to 0.98.
|
||||
const data = mono.getChannelData(0);
|
||||
expect(data[0]).toBeCloseTo(0.49, 5);
|
||||
expect(data[1]).toBeCloseTo(0.49, 5);
|
||||
expect(data[2]).toBeCloseTo(0.49, 5);
|
||||
expect(data[3]).toBeCloseTo(-0.98, 5);
|
||||
});
|
||||
|
||||
it('leaves a mono buffer untouched', () => {
|
||||
const source = ctx.createBuffer(1, 4, 48000);
|
||||
expect(downmixToMono(ctx, source)).toBe(source);
|
||||
});
|
||||
|
||||
it('pulls the peak back under unity', () => {
|
||||
const stereo = ctx.createBuffer(2, 2, 48000);
|
||||
stereo.getChannelData(0).set([1, 1]);
|
||||
stereo.getChannelData(1).set([1, 1]);
|
||||
const mono = downmixToMono(ctx, stereo);
|
||||
// Float32 rounding can land a hair above the target, hence the epsilon.
|
||||
for (const sample of mono.getChannelData(0)) expect(Math.abs(sample)).toBeLessThan(0.981);
|
||||
});
|
||||
|
||||
it('preserves length and sample rate', () => {
|
||||
const stereo = ctx.createBuffer(2, 777, 44100);
|
||||
const mono = downmixToMono(ctx, stereo);
|
||||
expect(mono.length).toBe(777);
|
||||
expect(mono.sampleRate).toBe(44100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decoding a file', () => {
|
||||
it('returns a mono buffer for good audio', async () => {
|
||||
const buffer = await decodeAudioFile(ctx, fakeFile('song.mp3', [1, 2, 3, 4]));
|
||||
expect(buffer.numberOfChannels).toBe(1);
|
||||
expect(buffer.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects an empty file with a readable message', async () => {
|
||||
await expect(decodeAudioFile(ctx, fakeFile('empty.mp3', []))).rejects.toBeInstanceOf(
|
||||
AudioFileError
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an absurdly large file before reading it', async () => {
|
||||
const huge = fakeFile('huge.wav', [1], MAX_FILE_BYTES + 1);
|
||||
await expect(decodeAudioFile(ctx, huge)).rejects.toThrow(/MB/);
|
||||
});
|
||||
|
||||
it('turns a decoder failure into advice, not a DOMException', async () => {
|
||||
const bad = fakeFile('notes.txt', [0, 0, 0]);
|
||||
await expect(decodeAudioFile(ctx, bad)).rejects.toThrow(/MP3, WAV, FLAC/);
|
||||
await expect(decodeAudioFile(ctx, bad)).rejects.toBeInstanceOf(AudioFileError);
|
||||
});
|
||||
|
||||
it('names the offending file in the error', async () => {
|
||||
await expect(decodeAudioFile(ctx, fakeFile('holiday.docx', [0]))).rejects.toThrow(
|
||||
/holiday\.docx/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duration formatting', () => {
|
||||
it('renders minutes and seconds', () => {
|
||||
expect(formatDuration(0)).toBe('0:00');
|
||||
expect(formatDuration(9)).toBe('0:09');
|
||||
expect(formatDuration(227)).toBe('3:47');
|
||||
expect(formatDuration(3600)).toBe('60:00');
|
||||
});
|
||||
|
||||
it('survives nonsense', () => {
|
||||
expect(formatDuration(Number.NaN)).toBe('—');
|
||||
expect(formatDuration(-5)).toBe('—');
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine integration', () => {
|
||||
let audio: ReturnType<typeof installMockAudio>;
|
||||
|
||||
beforeEach(() => {
|
||||
audio = installMockAudio();
|
||||
});
|
||||
afterEach(() => {
|
||||
audio.restore();
|
||||
});
|
||||
|
||||
it('loads a file, selects it, and reports it', async () => {
|
||||
const engine = new AudioEngine();
|
||||
const info = await engine.loadUserAudio(fakeFile('track.flac', [1, 2, 3]));
|
||||
|
||||
expect(info.name).toBe('track.flac');
|
||||
expect(info.duration).toBeGreaterThan(0);
|
||||
expect(engine.getSettings().preset).toBe('file');
|
||||
expect(engine.getUserAudio()?.name).toBe('track.flac');
|
||||
});
|
||||
|
||||
it('creates the AudioContext on demand so a picker click can decode', async () => {
|
||||
const engine = new AudioEngine();
|
||||
expect(engine.context).toBeNull();
|
||||
await engine.loadUserAudio(fakeFile('track.wav', [1]));
|
||||
expect(engine.context).not.toBeNull();
|
||||
});
|
||||
|
||||
it('plays the loaded buffer rather than a generated preset', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.loadUserAudio(fakeFile('track.wav', [1]));
|
||||
await engine.start();
|
||||
|
||||
const ctxInstance = audio.instances[0];
|
||||
const started = ctxInstance.started.at(-1) as unknown as { buffer: AudioBuffer };
|
||||
expect(started.buffer).toBe(
|
||||
(engine as unknown as { userBuffer: AudioBuffer }).userBuffer
|
||||
);
|
||||
expect(started.buffer.numberOfChannels).toBe(1);
|
||||
});
|
||||
|
||||
it('swaps to a second file while the first is playing', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.loadUserAudio(fakeFile('one.wav', [1]));
|
||||
await engine.start();
|
||||
const startsAfterFirst = audio.instances[0].started.length;
|
||||
|
||||
await engine.loadUserAudio(fakeFile('two.wav', [2]));
|
||||
|
||||
expect(engine.getUserAudio()?.name).toBe('two.wav');
|
||||
// A second load must actually restart the source, not silently no-op
|
||||
// because the selected source id was already 'file'.
|
||||
expect(audio.instances[0].started.length).toBeGreaterThan(startsAfterFirst);
|
||||
});
|
||||
|
||||
it('leaves the source alone when a bad file is offered', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.start();
|
||||
await expect(engine.loadUserAudio(fakeFile('bad.txt', [0]))).rejects.toBeInstanceOf(
|
||||
AudioFileError
|
||||
);
|
||||
expect(engine.getSettings().preset).toBe('sawtooth');
|
||||
expect(engine.getUserAudio()).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a request to select the file source when nothing is loaded', () => {
|
||||
const engine = new AudioEngine();
|
||||
engine.update({ preset: 'file' });
|
||||
expect(engine.getSettings().preset).toBe('sawtooth');
|
||||
});
|
||||
|
||||
it('can switch back to a generated preset after loading a file', async () => {
|
||||
const engine = new AudioEngine();
|
||||
await engine.loadUserAudio(fakeFile('track.wav', [1]));
|
||||
engine.update({ preset: 'pink' });
|
||||
expect(engine.getSettings().preset).toBe('pink');
|
||||
// The file stays loaded, so the dropdown entry remains valid.
|
||||
expect(engine.getUserAudio()?.name).toBe('track.wav');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user