Files
CartoLite-Server/web/src/audio.test.ts
T
n30nex dd53b715f6 Add Dial-up modem packet sound (0.6.4) (#23)
* Add Dial-up modem packet voice for 0.6.4

* Update Go and x/net for October security fixes

* Update in-app sound help for the 31-voice catalog
2026-10-11 15:10:29 -04:00

323 lines
13 KiB
TypeScript

import { SOUND_SCENE_IDS } from './soundScenes';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { EndpointV2, RoutePacketView } from './types';
import {
DEFAULT_SOUND_SCENE,
DEFAULT_SOUND_VOLUME,
loadSoundPreference,
RouteSonifier,
routeSoundPlan,
SOUND_STORAGE_KEY
} from './audio';
function endpoint(id: string, x: number, y: number): EndpointV2 {
return { id, label: id, lng: x, lat: y };
}
function packet(points: EndpointV2[], payloadType: RoutePacketView['payloadType'] = 'Trace'): RoutePacketView {
return {
seq: 7,
id: 'packet-7',
at: 1_700_000_000_000,
payloadType,
mode: 'route',
segments: points.slice(1).map((to, index) => ({
routeId: `route-${index}`,
from: points[index]!,
to,
})),
};
}
const projector = { project: ([x, y]: [number, number]) => ({ x, y }) };
describe('route hop sonification', () => {
it('plays one deterministic pentatonic note for every hop of a visible route', () => {
const route = packet([
endpoint('a', -20, 50),
endpoint('b', 40, 50),
endpoint('c', 80, 60),
endpoint('d', 140, 60),
]);
const first = routeSoundPlan(route, projector, 100, 100);
const second = routeSoundPlan(route, projector, 100, 100);
expect(first).toHaveLength(route.segments.length);
expect(second).toEqual(first);
expect(first.map((note) => note.startMS)).toEqual([...first.map((note) => note.startMS)].sort((a, b) => a - b));
expect(first.every((note) => note.frequency >= 220 && note.frequency <= 1_100)).toBe(true);
expect(first.every((note) => note.pan >= -0.75 && note.pan <= 0.75)).toBe(true);
});
it('stays silent when no part of the route is on screen', () => {
const route = packet([
endpoint('a', 180, 180),
endpoint('b', 220, 220),
endpoint('c', 260, 260),
]);
expect(routeSoundPlan(route, projector, 100, 100)).toEqual([]);
});
it('does not sound a diagonal whose bounding box only grazes the viewport', () => {
const route = packet([
endpoint('a', -20, 90),
endpoint('b', 10, 120),
]);
expect(routeSoundPlan(route, projector, 100, 100)).toEqual([]);
});
it('sounds every visible hop but skips off-screen hops in a partially visible route', () => {
const route = packet([
endpoint('a', -140, 50),
endpoint('b', -100, 50),
endpoint('c', 20, 50),
endpoint('d', 70, 50),
endpoint('e', 160, 50),
endpoint('f', 200, 50),
]);
const notes = routeSoundPlan(route, projector, 100, 100);
expect(notes).toHaveLength(3);
expect(notes[0]!.startMS).toBeGreaterThan(0);
expect(notes[1]!.startMS).toBeGreaterThan(notes[0]!.startMS);
expect(notes[2]!.startMS).toBeGreaterThan(notes[1]!.startMS);
});
it('uses endpoint-aware projection for non-geographic views', () => {
const route = packet([
endpoint('hidden-geography-a', 900, 900),
endpoint('hidden-geography-b', 950, 950),
]);
const graphProjector = {
project: () => ({ x: 900, y: 900 }),
projectEndpoint: (value: EndpointV2) => value.id.endsWith('-a')
? { x: 20, y: 50 }
: { x: 80, y: 50 },
};
expect(routeSoundPlan(route, graphProjector, 100, 100)).toHaveLength(1);
});
it('does not sonify observer-only activity because it has no public hops', () => {
expect(routeSoundPlan({
seq: 8,
id: 'observer-8',
at: 1_700_000_000_000,
payloadType: 'Advert',
mode: 'observer',
observer: endpoint('observer', 50, 50),
}, projector, 100, 100)).toEqual([]);
});
it('sounds a terrain path crossing the view once without treating its samples as extra hops', () => {
const route = packet([endpoint('a', -20, -20), endpoint('b', 120, -20)]);
expect(routeSoundPlan(route, projector, 100, 100)).toEqual([]);
const terrain = { ...projector, projectSegment: () => [{ x: -20, y: -20 }, { x: 50, y: 50 }, { x: 120, y: -20 }] };
const notes = routeSoundPlan(route, terrain, 100, 100);
expect(notes).toHaveLength(1);
expect(notes[0]!.pan).toBe(0);
expect(notes[0]!.startMS).toBe(0);
});
it('uses distinct but restrained voices for different packet families', () => {
const points = [endpoint('a', 20, 50), endpoint('b', 80, 50)];
const text = routeSoundPlan(packet(points, 'Text'), projector, 100, 100)[0]!;
const acknowledgement = routeSoundPlan(packet(points, 'ACK'), projector, 100, 100)[0]!;
expect(text.brightness).toBeLessThan(acknowledgement.brightness);
expect(text.frequency).not.toBe(acknowledgement.frequency);
});
it('keeps every visible hop deterministic in all sound voices', () => {
const route = packet([endpoint('a', 10, 50), endpoint('b', 50, 50), endpoint('c', 90, 50)]);
for (const scene of SOUND_SCENE_IDS) {
const first = routeSoundPlan(route, projector, 100, 100, scene);
expect(first).toHaveLength(route.segments.length);
expect(routeSoundPlan(route, projector, 100, 100, scene)).toEqual(first);
expect(first.every((note) => note.scene === scene)).toBe(true);
expect(first.map(({ startMS, pan }) => ({ startMS, pan }))).toEqual(
routeSoundPlan(route, projector, 100, 100).map(({ startMS, pan }) => ({ startMS, pan })),
);
}
});
it('adds deterministic Loom and village voicing without changing visible-hop counts', () => {
const route = packet([endpoint('a', 10, 50), endpoint('b', 50, 50), endpoint('c', 90, 50)]);
const standard = routeSoundPlan(route, projector, 100, 100, 'aurora', 'map');
const loom = routeSoundPlan(route, projector, 100, 100, 'aurora', 'loom');
const village = routeSoundPlan(route, projector, 100, 100, 'aurora', 'village');
expect(loom).toHaveLength(standard.length);
expect(village).toHaveLength(standard.length);
expect(loom.every((note) => note.character === 'loom')).toBe(true);
expect(village.every((note) => note.character === 'village')).toBe(true);
expect(loom.map((note) => note.frequency)).not.toEqual(standard.map((note) => note.frequency));
expect(routeSoundPlan(route, projector, 100, 100, 'aurora', 'loom')).toEqual(loom);
});
it('creates exactly one oscillator for each visible hop in every scene and experiment character', async () => {
const original = window.AudioContext;
FakeAudioContext.oscillators = 0;
Object.defineProperty(window, 'AudioContext', { configurable: true, value: FakeAudioContext });
const viewport = document.createElement('div');
Object.defineProperties(viewport, { clientWidth: { value: 100 }, clientHeight: { value: 100 } });
const sonifier = new RouteSonifier(projector as never, viewport);
const route = packet([endpoint('a', 10, 50), endpoint('b', 50, 50), endpoint('c', 90, 50)]);
try {
expect(await sonifier.setEnabled(true)).toBe(true);
for (const scene of SOUND_SCENE_IDS) {
sonifier.setScene(scene);
for (const character of ['map', 'loom', 'village'] as const) {
const before = FakeAudioContext.oscillators;
expect(sonifier.play(route, character)).toBe(route.segments.length);
expect(FakeAudioContext.oscillators - before).toBe(route.segments.length);
}
}
} finally {
sonifier.destroy();
Object.defineProperty(window, 'AudioContext', { configurable: true, value: original });
}
});
it('unlocks suspended Android audio during the enabling tap without adding a tone', async () => {
const original = window.AudioContext;
FakeAudioContext.initialState = 'suspended';
FakeAudioContext.bufferStarts = 0;
FakeAudioContext.oscillators = 0;
Object.defineProperty(window, 'AudioContext', { configurable: true, value: FakeAudioContext });
const sonifier = new RouteSonifier(projector as never, document.createElement('div'));
try {
expect(await sonifier.setEnabled(true)).toBe(true);
expect(FakeAudioContext.bufferStarts).toBe(1);
expect(FakeAudioContext.oscillators).toBe(0);
expect(sonifier.status()).toBe('on');
} finally {
sonifier.destroy();
FakeAudioContext.initialState = 'running';
Object.defineProperty(window, 'AudioContext', { configurable: true, value: original });
}
});
});
describe('sound preference and autoplay state', () => {
it.each(['aurora', 'dial-up'] as const)('previews %s without opting the listener into live sound', async (scene) => {
const original=window.AudioContext;
FakeAudioContext.initialState='running'; FakeAudioContext.oscillators=0;
Object.defineProperty(window,'AudioContext',{configurable:true,value:FakeAudioContext});
const sonifier=new RouteSonifier(projector as never,document.createElement('div'));
try {
sonifier.setScene(scene);
await sonifier.preview();
expect(FakeAudioContext.oscillators).toBe(1);
expect(sonifier.isEnabled()).toBe(false);
expect(loadSoundPreference(localStorage).enabled).toBe(false);
} finally { sonifier.destroy(); Object.defineProperty(window,'AudioContext',{configurable:true,value:original}); }
});
beforeEach(() => localStorage.clear());
it('defaults to Aurora at 80 percent and stores only enabled, volume, and scene', () => {
expect(loadSoundPreference(localStorage)).toEqual({
enabled: false, volume: DEFAULT_SOUND_VOLUME, scene: DEFAULT_SOUND_SCENE,
});
const sonifier = new RouteSonifier({} as never, document.createElement('div'));
sonifier.setVolume(0.55);
expect(JSON.parse(localStorage.getItem(SOUND_STORAGE_KEY) ?? '{}')).toEqual({
enabled: false, volume: 0.55, scene: 'aurora',
});
});
it('migrates v1 preferences to Aurora without starting audio', () => {
localStorage.setItem('cartolite:sound:v1', JSON.stringify({ enabled: true, volume: 0.62 }));
const audioContext = vi.fn();
const original = window.AudioContext;
Object.defineProperty(window, 'AudioContext', { configurable: true, value: audioContext });
try {
expect(loadSoundPreference(localStorage)).toEqual({ enabled: true, volume: 0.62, scene: 'aurora' });
expect(JSON.parse(localStorage.getItem(SOUND_STORAGE_KEY) ?? '{}')).toEqual({
enabled: true, volume: 0.62, scene: 'aurora',
});
expect(audioContext).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, 'AudioContext', { configurable: true, value: original });
}
});
it('shows Tap to Resume for remembered sound without constructing an AudioContext', () => {
localStorage.setItem(SOUND_STORAGE_KEY, JSON.stringify({ enabled: true, volume: 0.8, scene: 'wood' }));
const audioContext = vi.fn();
const original = window.AudioContext;
Object.defineProperty(window, 'AudioContext', { configurable: true, value: audioContext });
try {
const sonifier = new RouteSonifier({} as never, document.createElement('div'));
expect(sonifier.status()).toBe('resume');
expect(sonifier.getVolume()).toBe(0.8);
expect(sonifier.getScene()).toBe('wood');
expect(audioContext).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, 'AudioContext', { configurable: true, value: original });
}
});
it('clamps malformed remembered volume while preserving the requested state', () => {
localStorage.setItem(SOUND_STORAGE_KEY, JSON.stringify({ enabled: true, volume: 4, scene: 'chimes' }));
expect(loadSoundPreference(localStorage)).toEqual({ enabled: true, volume: 1, scene: 'chimes' });
});
});
const fakeParam = () => ({
value: 0,
cancelScheduledValues: vi.fn(),
exponentialRampToValueAtTime: vi.fn(),
setTargetAtTime: vi.fn(),
setValueAtTime: vi.fn(),
});
const fakeNode = <T extends object>(extra = {} as T): T & {
connect: (destination: unknown) => unknown;
disconnect: () => void;
} => Object.assign(extra, {
connect: (destination: unknown) => destination,
disconnect: vi.fn(),
});
class FakeAudioContext {
static oscillators = 0;
static bufferStarts = 0;
static initialState: AudioContextState = 'running';
currentTime = 0;
destination = fakeNode();
sampleRate = 48_000;
state: AudioContextState = FakeAudioContext.initialState;
onstatechange: (() => void) | null = null;
createGain = () => fakeNode({ gain: fakeParam() });
createDelay = () => fakeNode({ delayTime: fakeParam() });
createBiquadFilter = () => fakeNode({ type: 'lowpass', frequency: fakeParam(), Q: fakeParam() });
createStereoPanner = () => fakeNode({ pan: fakeParam() });
createDynamicsCompressor = () => fakeNode({
threshold: fakeParam(), knee: fakeParam(), ratio: fakeParam(), attack: fakeParam(), release: fakeParam(),
});
createPeriodicWave = () => ({} as PeriodicWave);
createBuffer = () => ({} as AudioBuffer);
createBufferSource = () => fakeNode({
buffer: null as AudioBuffer | null,
onended: null as (() => void) | null,
start: () => { FakeAudioContext.bufferStarts += 1; },
});
createOscillator = () => {
FakeAudioContext.oscillators += 1;
return fakeNode({
frequency: fakeParam(),
onended: null as (() => void) | null,
setPeriodicWave: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
});
};
resume = async () => {
this.state = 'running';
this.onstatechange?.();
};
close = async () => undefined;
}