feat: add opt-in procedural drive audio
This commit is contained in:
95
tests/browser/audio-controls.spec.ts
Normal file
95
tests/browser/audio-controls.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from '../helpers/serve-dist';
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const OriginalAudioContext = window.AudioContext;
|
||||
(window as unknown as { __audioConstructed: number }).__audioConstructed = 0;
|
||||
(window as unknown as { __audioContexts: AudioContext[] }).__audioContexts = [];
|
||||
if (!OriginalAudioContext) return;
|
||||
class TrackingAudioContext extends OriginalAudioContext {
|
||||
constructor(options?: AudioContextOptions) {
|
||||
super(options);
|
||||
const state = window as unknown as { __audioConstructed: number; __audioContexts: AudioContext[] };
|
||||
state.__audioConstructed += 1;
|
||||
state.__audioContexts.push(this);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, 'AudioContext', { configurable: true, value: TrackingAudioContext });
|
||||
});
|
||||
});
|
||||
|
||||
test('starts Web Audio synchronously from the visitor gesture and exposes accessible controls', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { __constructedDuringGesture?: number }).__constructedDuringGesture = -1;
|
||||
window.addEventListener('click', (event) => {
|
||||
if ((event.target as HTMLElement | null)?.textContent?.includes('START THE DRIVE')) {
|
||||
const state = window as unknown as { __audioConstructed: number; __constructedDuringGesture: number };
|
||||
state.__constructedDuringGesture = state.__audioConstructed;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'START THE DRIVE' })).toBeVisible();
|
||||
await expect(page.getByText('Enables the interactive soundtrack.')).toBeVisible();
|
||||
await expect(page.getByLabel('Sound volume')).toHaveCount(0);
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __audioConstructed: number }).__audioConstructed)).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await expect(page.getByLabel('Sound volume')).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __constructedDuringGesture: number }).__constructedDuringGesture)).toBe(1);
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __audioConstructed: number }).__audioConstructed)).toBe(1);
|
||||
|
||||
await page.getByRole('button', { name: 'Mute sound' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Unmute sound' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('persists native volume and keeps sound independent from Static motion', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
|
||||
|
||||
const volume = page.getByLabel('Sound volume');
|
||||
await volume.fill('0.65');
|
||||
await expect(volume).toHaveValue('0.65');
|
||||
await expect.poll(() => page.evaluate(() => localStorage.getItem('portfolio.audio.volume'))).toBe('0.65');
|
||||
|
||||
await page.getByLabel('Quality control').selectOption('static');
|
||||
await expect(page.getByRole('button', { name: 'Unmute sound' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await expect(volume).toBeVisible();
|
||||
});
|
||||
|
||||
test('requires a direct visitor action after browser interruption without creating another graph', async ({ page, distUrl }) => {
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const context = (window as unknown as { __audioContexts: AudioContext[] }).__audioContexts[0];
|
||||
Object.defineProperty(context, 'state', { configurable: true, value: 'interrupted' });
|
||||
context?.dispatchEvent(new Event('statechange'));
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Resume sound' })).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Resume sound' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Mute sound' })).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => (window as unknown as { __audioConstructed: number }).__audioConstructed)).toBe(1);
|
||||
});
|
||||
|
||||
test('shows a concise non-blocking message when initial audio start is rejected', async ({ page, distUrl }) => {
|
||||
await page.addInitScript(() => {
|
||||
const OriginalAudioContext = window.AudioContext;
|
||||
if (!OriginalAudioContext) return;
|
||||
class RejectingAudioContext extends OriginalAudioContext {
|
||||
override resume(): Promise<void> { return Promise.reject(new DOMException('blocked', 'NotAllowedError')); }
|
||||
}
|
||||
Object.defineProperty(window, 'AudioContext', { configurable: true, value: RejectingAudioContext });
|
||||
});
|
||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
await page.getByRole('button', { name: 'START THE DRIVE' }).click();
|
||||
|
||||
await expect(page.getByText('Audio unavailable. Continue without sound.')).toBeVisible();
|
||||
await expect(page.locator('main#portfolio-content')).toBeVisible();
|
||||
});
|
||||
389
tests/unit/audio-lifecycle.test.ts
Normal file
389
tests/unit/audio-lifecycle.test.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { createAudioController } from '@/audio/create-audio-controller';
|
||||
import { createSynthEngine } from '@/audio/synth-engine';
|
||||
import type { AudioGraph, AudioGraphFactory } from '@/audio/types';
|
||||
|
||||
describe('audio controller lifecycle', () => {
|
||||
it('constructs no graph before activation and only one graph for rapid repeated activation', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
|
||||
expect(harness.constructed).toBe(0);
|
||||
const first = controller.startFromGesture();
|
||||
const second = controller.startFromGesture();
|
||||
|
||||
expect(harness.constructed).toBe(1);
|
||||
await Promise.all([first, second]);
|
||||
expect(harness.constructed).toBe(1);
|
||||
expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 });
|
||||
expect(harness.graph.connectOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('applies clamped volume and mute changes made while starting without changing lifecycle intent', async () => {
|
||||
const resume = deferred<void>();
|
||||
const harness = createHarness({ resume });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph, initialVolume: 3 });
|
||||
|
||||
const starting = controller.startFromGesture();
|
||||
controller.setVolume(-1);
|
||||
controller.setMuted(true);
|
||||
expect(controller.snapshot()).toEqual({ kind: 'starting' });
|
||||
|
||||
resume.resolve();
|
||||
await starting;
|
||||
expect(controller.snapshot()).toEqual({ kind: 'running', muted: true, volume: 0 });
|
||||
expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0);
|
||||
});
|
||||
|
||||
it('retains one initialization when hidden then visible before starting completes', async () => {
|
||||
const resume = deferred<void>();
|
||||
const harness = createHarness({ resume });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
|
||||
const starting = controller.startFromGesture();
|
||||
controller.setVisible(false);
|
||||
controller.setVisible(true);
|
||||
resume.resolve();
|
||||
await starting;
|
||||
|
||||
expect(controller.snapshot().kind).toBe('running');
|
||||
expect(harness.constructed).toBe(1);
|
||||
expect(harness.graph.suspend).not.toHaveBeenCalled();
|
||||
expect(harness.graph.connectOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('finishes an interrupted initialization only into safely suspended user-required state', async () => {
|
||||
const resume = deferred<void>();
|
||||
const harness = createHarness({ resume });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
|
||||
const starting = controller.startFromGesture();
|
||||
controller.noteInterruption();
|
||||
resume.resolve();
|
||||
await starting;
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true });
|
||||
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
|
||||
expect(harness.graph.suspend).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('automatically suspends and resumes an intact graph across visibility and persisted BFCache', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
await controller.startFromGesture();
|
||||
|
||||
await controller.pageHide(true);
|
||||
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: false });
|
||||
expect(harness.graph.disconnectOutput).toHaveBeenCalledOnce();
|
||||
|
||||
await controller.pageShow(true);
|
||||
expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 });
|
||||
expect(harness.constructed).toBe(1);
|
||||
expect(harness.graph.resume).toHaveBeenCalledTimes(2);
|
||||
expect(harness.graph.connectOutput).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('honors a visible event that arrives before a running suspension completes', async () => {
|
||||
const suspension = deferred<void>();
|
||||
const harness = createHarness({ suspend: suspension });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
await controller.startFromGesture();
|
||||
|
||||
const hiding = controller.setVisible(false);
|
||||
const showing = controller.setVisible(true);
|
||||
suspension.resolve();
|
||||
await Promise.all([hiding, showing]);
|
||||
await controller.whenSettled();
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'running', muted: false, volume: 0.4 });
|
||||
expect(harness.graph.suspend).toHaveBeenCalledOnce();
|
||||
expect(harness.graph.resume).toHaveBeenCalledTimes(2);
|
||||
expect(harness.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it('does not convert an interruption into automatic resume on later visibility events', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
await controller.startFromGesture();
|
||||
|
||||
await controller.noteInterruption();
|
||||
await controller.setVisible(false);
|
||||
await controller.setVisible(true);
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true });
|
||||
expect(harness.graph.resume).toHaveBeenCalledOnce();
|
||||
|
||||
await controller.resumeFromGesture();
|
||||
expect(controller.snapshot().kind).toBe('running');
|
||||
expect(harness.constructed).toBe(1);
|
||||
expect(harness.graph.resume).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('enters a recoverable visible failure when the first explicit start rejects', async () => {
|
||||
const harness = createHarness({ resumeRejects: true });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
|
||||
await controller.startFromGesture();
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'failed', message: 'Audio unavailable' });
|
||||
expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0);
|
||||
expect(harness.graph.disconnectOutput).toHaveBeenCalledOnce();
|
||||
expect(harness.graph.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps an existing graph suspended and user-recoverable when automatic resume rejects', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
await controller.startFromGesture();
|
||||
await controller.setVisible(false);
|
||||
harness.rejectNextResume();
|
||||
|
||||
await controller.setVisible(true);
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'suspended', resumeRequiresGesture: true });
|
||||
expect(harness.graph.close).not.toHaveBeenCalled();
|
||||
expect(harness.constructed).toBe(1);
|
||||
});
|
||||
|
||||
it('forces silence, closes once, and fails when suspension cannot be guaranteed', async () => {
|
||||
const harness = createHarness({ suspendRejects: true });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
await controller.startFromGesture();
|
||||
|
||||
await controller.setVisible(false);
|
||||
await controller.setVisible(true);
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'failed', message: 'Audio unavailable' });
|
||||
expect(harness.graph.setOutputGain).toHaveBeenLastCalledWith(0);
|
||||
expect(harness.graph.disconnectOutput).toHaveBeenCalled();
|
||||
expect(harness.graph.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('invalidates a pending start and closes exactly once on final pagehide or repeated disposal', async () => {
|
||||
const resume = deferred<void>();
|
||||
const harness = createHarness({ resume });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
const starting = controller.startFromGesture();
|
||||
|
||||
const closing = controller.pageHide(false);
|
||||
controller.dispose();
|
||||
resume.resolve();
|
||||
await Promise.all([starting, closing]);
|
||||
await controller.whenSettled();
|
||||
|
||||
expect(controller.snapshot()).toEqual({ kind: 'disposed' });
|
||||
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
|
||||
expect(harness.graph.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('ignores lifecycle events before activation and after failure without creating another graph', async () => {
|
||||
const harness = createHarness({ resumeRejects: true });
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
|
||||
await controller.setVisible(false);
|
||||
await controller.pageShow(true);
|
||||
expect(harness.constructed).toBe(0);
|
||||
await controller.startFromGesture();
|
||||
await controller.setVisible(true);
|
||||
await controller.pageShow(true);
|
||||
|
||||
expect(harness.constructed).toBe(1);
|
||||
expect(controller.snapshot().kind).toBe('failed');
|
||||
});
|
||||
|
||||
it('clamps and slowly smooths drive intensity before forwarding it to the graph', async () => {
|
||||
const harness = createHarness();
|
||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||
await controller.startFromGesture();
|
||||
|
||||
controller.setIntensity(2);
|
||||
controller.setIntensity(Number.NaN);
|
||||
|
||||
expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(3, 0.12);
|
||||
expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(4, 0.1056);
|
||||
});
|
||||
});
|
||||
|
||||
describe('procedural audio graph', () => {
|
||||
it('builds an original oscillator, filtered-noise, gain, and dynamics bed while output stays disconnected', async () => {
|
||||
const context = new FakeAudioContext();
|
||||
|
||||
const graph = createSynthEngine(() => context as unknown as AudioContext);
|
||||
|
||||
expect(context.oscillators).toHaveLength(3);
|
||||
expect(context.bufferSources).toHaveLength(1);
|
||||
expect(context.filters).toHaveLength(2);
|
||||
expect(context.gains.length).toBeGreaterThanOrEqual(4);
|
||||
expect(context.compressors).toHaveLength(1);
|
||||
expect(context.startedNodes).toBe(4);
|
||||
expect(context.destinationConnections).toBe(0);
|
||||
|
||||
graph.connectOutput();
|
||||
graph.setOutputGain(0.6);
|
||||
graph.setIntensity(1);
|
||||
expect(context.destinationConnections).toBe(1);
|
||||
expect(context.parameters.some((parameter) => parameter.targetValues.includes(930))).toBe(true);
|
||||
|
||||
graph.disconnectOutput();
|
||||
await graph.close();
|
||||
await graph.close();
|
||||
expect(context.destinationConnections).toBe(0);
|
||||
expect(context.closeCalls).toBe(1);
|
||||
expect(context.stoppedNodes).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
interface HarnessOptions {
|
||||
resume?: Deferred<void>;
|
||||
suspend?: Deferred<void>;
|
||||
resumeRejects?: boolean;
|
||||
suspendRejects?: boolean;
|
||||
}
|
||||
|
||||
function createHarness(options: HarnessOptions = {}) {
|
||||
let rejectNextResume = options.resumeRejects === true;
|
||||
const stateListeners = new Set<(state: string) => void>();
|
||||
const graph: AudioGraph = {
|
||||
resume: vi.fn(async () => {
|
||||
if (options.resume) return options.resume.promise;
|
||||
if (rejectNextResume) {
|
||||
rejectNextResume = false;
|
||||
throw new DOMException('blocked', 'NotAllowedError');
|
||||
}
|
||||
}),
|
||||
suspend: vi.fn(async () => {
|
||||
if (options.suspend) return options.suspend.promise;
|
||||
if (options.suspendRejects) throw new Error('suspend failed');
|
||||
}),
|
||||
close: vi.fn(async () => undefined),
|
||||
connectOutput: vi.fn(),
|
||||
disconnectOutput: vi.fn(),
|
||||
setOutputGain: vi.fn(),
|
||||
setIntensity: vi.fn(),
|
||||
onContextStateChange: vi.fn((listener) => {
|
||||
stateListeners.add(listener);
|
||||
return () => stateListeners.delete(listener);
|
||||
}),
|
||||
};
|
||||
let constructed = 0;
|
||||
const createGraph: AudioGraphFactory = () => {
|
||||
constructed += 1;
|
||||
return graph;
|
||||
};
|
||||
return {
|
||||
graph,
|
||||
createGraph,
|
||||
get constructed() { return constructed; },
|
||||
rejectNextResume() { rejectNextResume = true; },
|
||||
emitState(state: string) { for (const listener of stateListeners) listener(state); },
|
||||
};
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve(value: T | PromiseLike<T>): void;
|
||||
reject(reason?: unknown): void;
|
||||
}
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((onResolve, onReject) => {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
class FakeAudioParam {
|
||||
value = 0;
|
||||
readonly targetValues: number[] = [];
|
||||
|
||||
setValueAtTime(value: number): void { this.value = value; }
|
||||
setTargetAtTime(value: number): void { this.targetValues.push(value); this.value = value; }
|
||||
linearRampToValueAtTime(value: number): void { this.value = value; }
|
||||
exponentialRampToValueAtTime(value: number): void { this.value = value; }
|
||||
cancelScheduledValues(): void {}
|
||||
}
|
||||
|
||||
class FakeNode extends EventTarget {
|
||||
readonly connections = new Set<unknown>();
|
||||
constructor(protected readonly owner: FakeAudioContext) { super(); }
|
||||
connect(target: unknown): unknown {
|
||||
this.connections.add(target);
|
||||
if (target === this.owner.destination) this.owner.destinationConnections += 1;
|
||||
return target;
|
||||
}
|
||||
disconnect(target?: unknown): void {
|
||||
if (target === undefined) {
|
||||
if (this.connections.delete(this.owner.destination)) this.owner.destinationConnections -= 1;
|
||||
this.connections.clear();
|
||||
return;
|
||||
}
|
||||
if (this.connections.delete(target) && target === this.owner.destination) this.owner.destinationConnections -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeScheduledNode extends FakeNode {
|
||||
start(): void { this.owner.startedNodes += 1; }
|
||||
stop(): void { this.owner.stoppedNodes += 1; }
|
||||
}
|
||||
|
||||
class FakeAudioContext extends EventTarget {
|
||||
readonly destination = {};
|
||||
readonly sampleRate = 8_000;
|
||||
readonly currentTime = 0;
|
||||
readonly parameters: FakeAudioParam[] = [];
|
||||
readonly oscillators: Array<FakeScheduledNode & { type: string; frequency: FakeAudioParam }> = [];
|
||||
readonly bufferSources: Array<FakeScheduledNode & { buffer: unknown; loop: boolean }> = [];
|
||||
readonly filters: Array<FakeNode & { type: string; frequency: FakeAudioParam; Q: FakeAudioParam }> = [];
|
||||
readonly gains: Array<FakeNode & { gain: FakeAudioParam }> = [];
|
||||
readonly compressors: FakeNode[] = [];
|
||||
destinationConnections = 0;
|
||||
startedNodes = 0;
|
||||
stoppedNodes = 0;
|
||||
closeCalls = 0;
|
||||
state: AudioContextState = 'suspended';
|
||||
|
||||
private parameter(value = 0): FakeAudioParam {
|
||||
const parameter = new FakeAudioParam();
|
||||
parameter.value = value;
|
||||
this.parameters.push(parameter);
|
||||
return parameter;
|
||||
}
|
||||
|
||||
createOscillator() {
|
||||
const node = Object.assign(new FakeScheduledNode(this), { type: 'sine', frequency: this.parameter(440) });
|
||||
this.oscillators.push(node);
|
||||
return node;
|
||||
}
|
||||
createBufferSource() {
|
||||
const node = Object.assign(new FakeScheduledNode(this), { buffer: null as unknown, loop: false });
|
||||
this.bufferSources.push(node);
|
||||
return node;
|
||||
}
|
||||
createBiquadFilter() {
|
||||
const node = Object.assign(new FakeNode(this), {
|
||||
type: 'lowpass', frequency: this.parameter(350), Q: this.parameter(1),
|
||||
});
|
||||
this.filters.push(node);
|
||||
return node;
|
||||
}
|
||||
createGain() {
|
||||
const node = Object.assign(new FakeNode(this), { gain: this.parameter(1) });
|
||||
this.gains.push(node);
|
||||
return node;
|
||||
}
|
||||
createDynamicsCompressor() {
|
||||
const node = new FakeNode(this);
|
||||
this.compressors.push(node);
|
||||
return node;
|
||||
}
|
||||
createBuffer(channels: number, length: number) {
|
||||
const data = Array.from({ length: channels }, () => new Float32Array(length));
|
||||
return { getChannelData: (channel: number) => data[channel] };
|
||||
}
|
||||
async resume(): Promise<void> { this.state = 'running'; }
|
||||
async suspend(): Promise<void> { this.state = 'suspended'; }
|
||||
async close(): Promise<void> { this.closeCalls += 1; this.state = 'closed'; }
|
||||
}
|
||||
Reference in New Issue
Block a user