test(callSounds): cover join/leave sound design + AudioContext lifecycle
callSounds.ts had no tests despite 106 lines of user-facing audio logic. Adds 13 tests (mocking AudioContext) pinning: the chime/soft/retro join+leave melodies (frequencies, oscillator types, stagger), the click-avoidance gain envelope and osc->gain->destination wiring, and the defensive contracts — unknown style is a no-op that never creates a context, a throwing AudioContext constructor is swallowed, and the shared context is reused / recreated-when-closed / resumed- when-suspended. Suite is now 527 tests; refreshed the stale count in LOTUS_BUGS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
import { test, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { playCallJoinSound, playCallLeaveSound, unlockCallSounds } from './callSounds';
|
||||
|
||||
// ── Minimal Web Audio mock ──────────────────────────────────────────────────
|
||||
// callSounds.ts reaches for the global `AudioContext` lazily (inside its own
|
||||
// getAudioContext), so we can swap a fake in before each test and inspect the
|
||||
// oscillators/gains it schedules.
|
||||
|
||||
type RampCall = { value: number; time: number };
|
||||
|
||||
class FakeParam {
|
||||
value = 0;
|
||||
setValueAtTimeCalls: RampCall[] = [];
|
||||
linearRampCalls: RampCall[] = [];
|
||||
expRampCalls: RampCall[] = [];
|
||||
setValueAtTime(value: number, time: number): this {
|
||||
this.setValueAtTimeCalls.push({ value, time });
|
||||
return this;
|
||||
}
|
||||
linearRampToValueAtTime(value: number, time: number): this {
|
||||
this.linearRampCalls.push({ value, time });
|
||||
return this;
|
||||
}
|
||||
exponentialRampToValueAtTime(value: number, time: number): this {
|
||||
this.expRampCalls.push({ value, time });
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeOscillator {
|
||||
type = 'sine';
|
||||
frequency = { value: 0 };
|
||||
startCalls: number[] = [];
|
||||
stopCalls: number[] = [];
|
||||
connectedTo: unknown = null;
|
||||
connect(node: unknown): unknown {
|
||||
this.connectedTo = node;
|
||||
return node;
|
||||
}
|
||||
start(t: number): void {
|
||||
this.startCalls.push(t);
|
||||
}
|
||||
stop(t: number): void {
|
||||
this.stopCalls.push(t);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeGain {
|
||||
gain = new FakeParam();
|
||||
connectedTo: unknown = null;
|
||||
connect(node: unknown): unknown {
|
||||
this.connectedTo = node;
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioContext {
|
||||
static instances: FakeAudioContext[] = [];
|
||||
static throwOnConstruct = false;
|
||||
|
||||
state: 'running' | 'suspended' | 'closed' = 'running';
|
||||
// currentTime 0 keeps `start = now + at` exactly equal to the table offsets
|
||||
// (no float drift), so scheduling assertions stay precise.
|
||||
currentTime = 0;
|
||||
destination = { id: 'destination' };
|
||||
oscillators: FakeOscillator[] = [];
|
||||
gains: FakeGain[] = [];
|
||||
resumeCalls = 0;
|
||||
|
||||
constructor() {
|
||||
if (FakeAudioContext.throwOnConstruct) throw new Error('AudioContext unavailable');
|
||||
FakeAudioContext.instances.push(this);
|
||||
}
|
||||
createOscillator(): FakeOscillator {
|
||||
const osc = new FakeOscillator();
|
||||
this.oscillators.push(osc);
|
||||
return osc;
|
||||
}
|
||||
createGain(): FakeGain {
|
||||
const gain = new FakeGain();
|
||||
this.gains.push(gain);
|
||||
return gain;
|
||||
}
|
||||
resume(): Promise<void> {
|
||||
this.resumeCalls += 1;
|
||||
this.state = 'running';
|
||||
return Promise.resolve();
|
||||
}
|
||||
close(): Promise<void> {
|
||||
this.state = 'closed';
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
const current = (): FakeAudioContext => {
|
||||
const ctx = FakeAudioContext.instances.at(-1);
|
||||
assert.ok(ctx, 'expected an AudioContext to have been created');
|
||||
return ctx;
|
||||
};
|
||||
const freqs = (ctx: FakeAudioContext): number[] => ctx.oscillators.map((o) => o.frequency.value);
|
||||
|
||||
beforeEach(() => {
|
||||
// The module keeps a single shared AudioContext. Close any instance it may
|
||||
// still hold so the next call forces a fresh one, then reset our registry.
|
||||
FakeAudioContext.instances.forEach((i) => {
|
||||
i.state = 'closed';
|
||||
});
|
||||
FakeAudioContext.instances = [];
|
||||
FakeAudioContext.throwOnConstruct = false;
|
||||
(globalThis as unknown as { AudioContext: unknown }).AudioContext = FakeAudioContext;
|
||||
});
|
||||
|
||||
// ── Sound design (regression-pins the melodies) ─────────────────────────────
|
||||
|
||||
test('chime join plays D5 then A5 as sine notes, staggered', () => {
|
||||
playCallJoinSound('chime');
|
||||
const ctx = current();
|
||||
assert.equal(ctx.oscillators.length, 2);
|
||||
assert.deepEqual(
|
||||
ctx.oscillators.map((o) => o.type),
|
||||
['sine', 'sine'],
|
||||
);
|
||||
assert.deepEqual(freqs(ctx), [587.33, 880]);
|
||||
assert.deepEqual(
|
||||
ctx.oscillators.map((o) => o.startCalls[0]),
|
||||
[0, 0.1],
|
||||
);
|
||||
});
|
||||
|
||||
test('chime leave reverses the join melody', () => {
|
||||
playCallLeaveSound('chime');
|
||||
assert.deepEqual(freqs(current()), [880, 587.33]);
|
||||
});
|
||||
|
||||
test('soft join is a single triangle note at the right gain', () => {
|
||||
playCallJoinSound('soft');
|
||||
const ctx = current();
|
||||
assert.equal(ctx.oscillators.length, 1);
|
||||
assert.equal(ctx.oscillators[0].type, 'triangle');
|
||||
assert.equal(ctx.oscillators[0].frequency.value, 523.25);
|
||||
assert.equal(ctx.gains[0].gain.linearRampCalls[0].value, 0.18);
|
||||
});
|
||||
|
||||
test('soft leave drops to the lower triangle note', () => {
|
||||
playCallLeaveSound('soft');
|
||||
assert.deepEqual(freqs(current()), [392]);
|
||||
});
|
||||
|
||||
test('retro join is three ascending square notes', () => {
|
||||
playCallJoinSound('retro');
|
||||
const ctx = current();
|
||||
assert.deepEqual(
|
||||
ctx.oscillators.map((o) => o.type),
|
||||
['square', 'square', 'square'],
|
||||
);
|
||||
assert.deepEqual(freqs(ctx), [440, 554.37, 659.25]);
|
||||
assert.deepEqual(
|
||||
ctx.oscillators.map((o) => o.startCalls[0]),
|
||||
[0, 0.07, 0.14],
|
||||
);
|
||||
});
|
||||
|
||||
test('retro leave is three descending square notes', () => {
|
||||
playCallLeaveSound('retro');
|
||||
assert.deepEqual(freqs(current()), [659.25, 554.37, 440]);
|
||||
});
|
||||
|
||||
// ── Envelope (click-avoidance shape) ────────────────────────────────────────
|
||||
|
||||
test('each note ramps 0 -> peak -> ~0 to avoid clicks, and wires osc->gain->out', () => {
|
||||
playCallJoinSound('soft');
|
||||
const ctx = current();
|
||||
const { gain } = ctx.gains[0];
|
||||
assert.equal(gain.setValueAtTimeCalls[0].value, 0); // starts silent
|
||||
assert.equal(gain.linearRampCalls[0].value, 0.18); // attack to peak
|
||||
assert.equal(gain.expRampCalls[0].value, 0.0001); // exp decay can't hit 0
|
||||
// osc -> gain -> destination
|
||||
assert.equal(ctx.oscillators[0].connectedTo, ctx.gains[0]);
|
||||
assert.equal(ctx.gains[0].connectedTo, ctx.destination);
|
||||
assert.equal(ctx.oscillators[0].startCalls.length, 1);
|
||||
assert.equal(ctx.oscillators[0].stopCalls.length, 1);
|
||||
});
|
||||
|
||||
// ── Dispatch / defensive contracts ──────────────────────────────────────────
|
||||
|
||||
test('an unknown style is a no-op and never even creates a context', () => {
|
||||
assert.doesNotThrow(() => playCallJoinSound('bogus' as never));
|
||||
assert.doesNotThrow(() => playCallLeaveSound('bogus' as never));
|
||||
assert.equal(FakeAudioContext.instances.length, 0);
|
||||
});
|
||||
|
||||
test('does not throw when AudioContext construction fails (unsupported env)', () => {
|
||||
FakeAudioContext.throwOnConstruct = true;
|
||||
assert.doesNotThrow(() => playCallJoinSound('chime'));
|
||||
assert.equal(FakeAudioContext.instances.length, 0);
|
||||
});
|
||||
|
||||
// ── Shared-context lifecycle ────────────────────────────────────────────────
|
||||
|
||||
test('unlockCallSounds primes a single running context', () => {
|
||||
unlockCallSounds();
|
||||
assert.equal(FakeAudioContext.instances.length, 1);
|
||||
assert.equal(current().state, 'running');
|
||||
});
|
||||
|
||||
test('reuses one running context across multiple sounds', () => {
|
||||
playCallJoinSound('chime');
|
||||
playCallLeaveSound('chime');
|
||||
playCallJoinSound('soft');
|
||||
assert.equal(FakeAudioContext.instances.length, 1);
|
||||
});
|
||||
|
||||
test('recreates the context after it has been closed', () => {
|
||||
playCallJoinSound('soft');
|
||||
current().state = 'closed';
|
||||
playCallJoinSound('soft');
|
||||
assert.equal(FakeAudioContext.instances.length, 2);
|
||||
});
|
||||
|
||||
test('resumes (without recreating) a suspended context', () => {
|
||||
playCallJoinSound('soft');
|
||||
const ctx = current();
|
||||
ctx.state = 'suspended';
|
||||
playCallJoinSound('soft');
|
||||
assert.equal(FakeAudioContext.instances.length, 1);
|
||||
assert.equal(ctx.resumeCalls, 1);
|
||||
});
|
||||
Reference in New Issue
Block a user