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 { this.resumeCalls += 1; this.state = 'running'; return Promise.resolve(); } close(): Promise { 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); });