feat: add opt-in procedural drive audio
This commit is contained in:
289
src/audio/create-audio-controller.ts
Normal file
289
src/audio/create-audio-controller.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import type {
|
||||||
|
AudioController,
|
||||||
|
AudioControllerOptions,
|
||||||
|
AudioGraph,
|
||||||
|
AudioUiState,
|
||||||
|
} from '@/audio/types';
|
||||||
|
|
||||||
|
type ActualState = AudioUiState['kind'];
|
||||||
|
type DesiredState = 'idle' | 'running' | 'suspended' | 'closed';
|
||||||
|
type ResumeIntent = 'none' | 'automatic-after-visibility' | 'user-required';
|
||||||
|
|
||||||
|
const DEFAULT_VOLUME = 0.4;
|
||||||
|
const INTENSITY_SMOOTHING = 0.12;
|
||||||
|
|
||||||
|
export function createAudioController(options: AudioControllerOptions): AudioController {
|
||||||
|
let actual: ActualState = 'idle';
|
||||||
|
let desired: DesiredState = 'idle';
|
||||||
|
let resumeIntent: ResumeIntent = 'none';
|
||||||
|
let explicitlyActivated = false;
|
||||||
|
let visible = options.initiallyVisible ?? true;
|
||||||
|
let muted = false;
|
||||||
|
let volume = clamp(options.initialVolume ?? DEFAULT_VOLUME);
|
||||||
|
let intensity = 0;
|
||||||
|
let graph: AudioGraph | null = null;
|
||||||
|
let graphClosed = false;
|
||||||
|
let removeStateListener = (): void => {};
|
||||||
|
let operationGeneration = 0;
|
||||||
|
let startOperation: Promise<void> | null = null;
|
||||||
|
let operationTail = Promise.resolve();
|
||||||
|
const listeners = new Set<(state: AudioUiState) => void>();
|
||||||
|
|
||||||
|
const snapshot = (): AudioUiState => {
|
||||||
|
switch (actual) {
|
||||||
|
case 'idle': return { kind: 'idle' };
|
||||||
|
case 'starting': return { kind: 'starting' };
|
||||||
|
case 'running': return { kind: 'running', muted, volume };
|
||||||
|
case 'suspended': return { kind: 'suspended', resumeRequiresGesture: resumeIntent === 'user-required' };
|
||||||
|
case 'failed': return { kind: 'failed', message: 'Audio unavailable' };
|
||||||
|
case 'disposed': return { kind: 'disposed' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const publish = (): void => {
|
||||||
|
const state = snapshot();
|
||||||
|
for (const listener of listeners) listener(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setActual = (next: ActualState): void => {
|
||||||
|
actual = next;
|
||||||
|
publish();
|
||||||
|
};
|
||||||
|
|
||||||
|
const lifecycleInvalidated = (generation: number): boolean => (
|
||||||
|
actual === 'disposed' || desired === 'closed' || generation !== operationGeneration
|
||||||
|
);
|
||||||
|
|
||||||
|
const silence = (): void => {
|
||||||
|
if (!graph) return;
|
||||||
|
graph.setOutputGain(0);
|
||||||
|
graph.disconnectOutput();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeGraph = async (): Promise<void> => {
|
||||||
|
if (!graph || graphClosed) return;
|
||||||
|
graphClosed = true;
|
||||||
|
removeStateListener();
|
||||||
|
removeStateListener = (): void => {};
|
||||||
|
try {
|
||||||
|
await graph.close();
|
||||||
|
} catch {
|
||||||
|
// Closing is best effort after synchronous silence and disconnection.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fail = async (): Promise<void> => {
|
||||||
|
if (actual === 'disposed') {
|
||||||
|
await closeGraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
desired = 'suspended';
|
||||||
|
resumeIntent = 'user-required';
|
||||||
|
silence();
|
||||||
|
await closeGraph();
|
||||||
|
setActual('failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const enqueue = (operation: () => Promise<void>): Promise<void> => {
|
||||||
|
const result = operationTail.then(operation, operation);
|
||||||
|
operationTail = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const attachRunning = (): void => {
|
||||||
|
if (!graph || graphClosed) return;
|
||||||
|
graph.connectOutput();
|
||||||
|
graph.setOutputGain(muted ? 0 : volume);
|
||||||
|
graph.setIntensity(intensity);
|
||||||
|
resumeIntent = 'none';
|
||||||
|
setActual('running');
|
||||||
|
};
|
||||||
|
|
||||||
|
const settleAfterResume = async (generation: number, firstStart: boolean): Promise<void> => {
|
||||||
|
if (!graph) return;
|
||||||
|
try {
|
||||||
|
await graph.resume();
|
||||||
|
} catch {
|
||||||
|
if (firstStart) await fail();
|
||||||
|
else {
|
||||||
|
desired = 'suspended';
|
||||||
|
resumeIntent = 'user-required';
|
||||||
|
silence();
|
||||||
|
setActual('suspended');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lifecycleInvalidated(generation)) {
|
||||||
|
await closeGraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desired === 'running' && visible) {
|
||||||
|
attachRunning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
silence();
|
||||||
|
try {
|
||||||
|
await graph.suspend();
|
||||||
|
} catch {
|
||||||
|
await fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lifecycleInvalidated(generation)) {
|
||||||
|
await closeGraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActual('suspended');
|
||||||
|
};
|
||||||
|
|
||||||
|
const startFromGesture = (): Promise<void> => {
|
||||||
|
if (actual === 'disposed' || actual === 'failed') return Promise.resolve();
|
||||||
|
if (explicitlyActivated) return startOperation ?? operationTail;
|
||||||
|
|
||||||
|
explicitlyActivated = true;
|
||||||
|
desired = 'running';
|
||||||
|
resumeIntent = 'none';
|
||||||
|
setActual('starting');
|
||||||
|
const generation = ++operationGeneration;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// This factory call intentionally remains synchronous in the activating event stack.
|
||||||
|
graph = options.createGraph();
|
||||||
|
removeStateListener = graph.onContextStateChange((state) => {
|
||||||
|
if (state === 'interrupted') void noteInterruption();
|
||||||
|
});
|
||||||
|
graph.setOutputGain(0);
|
||||||
|
graph.setIntensity(intensity);
|
||||||
|
} catch {
|
||||||
|
startOperation = enqueue(fail);
|
||||||
|
return startOperation;
|
||||||
|
}
|
||||||
|
|
||||||
|
startOperation = enqueue(() => settleAfterResume(generation, true));
|
||||||
|
return startOperation;
|
||||||
|
};
|
||||||
|
|
||||||
|
const resumeFromGesture = (): Promise<void> => {
|
||||||
|
if (actual === 'idle') return startFromGesture();
|
||||||
|
if (actual !== 'suspended' || !graph || graphClosed) return Promise.resolve();
|
||||||
|
desired = 'running';
|
||||||
|
resumeIntent = 'none';
|
||||||
|
const generation = operationGeneration;
|
||||||
|
return enqueue(() => settleAfterResume(generation, false));
|
||||||
|
};
|
||||||
|
|
||||||
|
const suspend = (intent: ResumeIntent): Promise<void> => {
|
||||||
|
if (actual === 'idle' || actual === 'failed' || actual === 'disposed') return Promise.resolve();
|
||||||
|
desired = 'suspended';
|
||||||
|
resumeIntent = intent;
|
||||||
|
|
||||||
|
if (actual === 'starting') return startOperation ?? Promise.resolve();
|
||||||
|
if (actual === 'suspended') {
|
||||||
|
publish();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
silence();
|
||||||
|
const generation = operationGeneration;
|
||||||
|
return enqueue(async () => {
|
||||||
|
if (!graph || graphClosed || lifecycleInvalidated(generation)) return;
|
||||||
|
try {
|
||||||
|
await graph.suspend();
|
||||||
|
} catch {
|
||||||
|
await fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lifecycleInvalidated(generation)) {
|
||||||
|
await closeGraph();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (desired === 'running' && visible) {
|
||||||
|
await settleAfterResume(generation, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActual('suspended');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisible = (nextVisible: boolean): Promise<void> => {
|
||||||
|
visible = nextVisible;
|
||||||
|
if (actual === 'idle' || actual === 'failed' || actual === 'disposed') return Promise.resolve();
|
||||||
|
if (!visible) {
|
||||||
|
const intent = resumeIntent === 'user-required' ? 'user-required' : 'automatic-after-visibility';
|
||||||
|
return suspend(intent);
|
||||||
|
}
|
||||||
|
if (resumeIntent === 'automatic-after-visibility') {
|
||||||
|
desired = 'running';
|
||||||
|
resumeIntent = 'none';
|
||||||
|
if (actual === 'starting') return startOperation ?? Promise.resolve();
|
||||||
|
if (actual === 'running') return operationTail;
|
||||||
|
if (actual === 'suspended') {
|
||||||
|
const generation = operationGeneration;
|
||||||
|
return enqueue(() => settleAfterResume(generation, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const noteInterruption = (): Promise<void> => suspend('user-required');
|
||||||
|
|
||||||
|
const pageHide = (persisted: boolean): Promise<void> => {
|
||||||
|
if (persisted) return setVisible(false);
|
||||||
|
dispose();
|
||||||
|
return operationTail;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageShow = (_persisted: boolean): Promise<void> => setVisible(true);
|
||||||
|
|
||||||
|
const dispose = (): void => {
|
||||||
|
if (actual === 'disposed') return;
|
||||||
|
desired = 'closed';
|
||||||
|
visible = false;
|
||||||
|
operationGeneration += 1;
|
||||||
|
silence();
|
||||||
|
setActual('disposed');
|
||||||
|
void enqueue(closeGraph);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
startFromGesture,
|
||||||
|
resumeFromGesture,
|
||||||
|
setMuted(nextMuted) {
|
||||||
|
if (actual === 'disposed') return;
|
||||||
|
muted = nextMuted;
|
||||||
|
graph?.setOutputGain(muted ? 0 : volume);
|
||||||
|
if (actual === 'running') publish();
|
||||||
|
},
|
||||||
|
setVolume(nextVolume) {
|
||||||
|
if (actual === 'disposed') return;
|
||||||
|
volume = clamp(nextVolume);
|
||||||
|
graph?.setOutputGain(muted ? 0 : volume);
|
||||||
|
if (actual === 'running') publish();
|
||||||
|
},
|
||||||
|
setIntensity(nextIntensity) {
|
||||||
|
if (actual === 'disposed') return;
|
||||||
|
const target = clamp(nextIntensity);
|
||||||
|
intensity += (target - intensity) * INTENSITY_SMOOTHING;
|
||||||
|
graph?.setIntensity(intensity);
|
||||||
|
},
|
||||||
|
setVisible,
|
||||||
|
noteInterruption,
|
||||||
|
pageHide,
|
||||||
|
pageShow,
|
||||||
|
dispose,
|
||||||
|
whenSettled: () => operationTail,
|
||||||
|
snapshot,
|
||||||
|
subscribe(listener) {
|
||||||
|
listeners.add(listener);
|
||||||
|
listener(snapshot());
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
144
src/audio/synth-engine.ts
Normal file
144
src/audio/synth-engine.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types';
|
||||||
|
|
||||||
|
type AudioContextFactory = () => AudioContext;
|
||||||
|
|
||||||
|
export function createBrowserSynthEngine(): AudioGraph {
|
||||||
|
const AudioContextConstructor = window.AudioContext
|
||||||
|
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||||
|
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
|
||||||
|
return createSynthEngine(() => new AudioContextConstructor());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {
|
||||||
|
const context = createContext();
|
||||||
|
const master = context.createGain();
|
||||||
|
const dynamics = context.createDynamicsCompressor();
|
||||||
|
master.gain.setValueAtTime(0, context.currentTime);
|
||||||
|
master.connect(dynamics);
|
||||||
|
|
||||||
|
const engineFilter = context.createBiquadFilter();
|
||||||
|
engineFilter.type = 'lowpass';
|
||||||
|
engineFilter.frequency.setValueAtTime(260, context.currentTime);
|
||||||
|
engineFilter.Q.setValueAtTime(1.25, context.currentTime);
|
||||||
|
|
||||||
|
const engineGain = context.createGain();
|
||||||
|
engineGain.gain.setValueAtTime(0.045, context.currentTime);
|
||||||
|
engineFilter.connect(engineGain);
|
||||||
|
engineGain.connect(master);
|
||||||
|
|
||||||
|
const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter);
|
||||||
|
const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter);
|
||||||
|
|
||||||
|
const roadFilter = context.createBiquadFilter();
|
||||||
|
roadFilter.type = 'bandpass';
|
||||||
|
roadFilter.frequency.setValueAtTime(410, context.currentTime);
|
||||||
|
roadFilter.Q.setValueAtTime(0.55, context.currentTime);
|
||||||
|
const roadGain = context.createGain();
|
||||||
|
roadGain.gain.setValueAtTime(0.015, context.currentTime);
|
||||||
|
roadFilter.connect(roadGain);
|
||||||
|
roadGain.connect(master);
|
||||||
|
|
||||||
|
const noise = context.createBufferSource();
|
||||||
|
noise.buffer = createNoiseBuffer(context, 2);
|
||||||
|
noise.loop = true;
|
||||||
|
noise.connect(roadFilter);
|
||||||
|
|
||||||
|
const ignition = context.createOscillator();
|
||||||
|
ignition.type = 'triangle';
|
||||||
|
ignition.frequency.setValueAtTime(68, context.currentTime);
|
||||||
|
const ignitionGain = context.createGain();
|
||||||
|
ignitionGain.gain.setValueAtTime(0.0001, context.currentTime);
|
||||||
|
ignitionGain.gain.exponentialRampToValueAtTime(0.07, context.currentTime + 0.12);
|
||||||
|
ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15);
|
||||||
|
ignition.connect(ignitionGain);
|
||||||
|
ignitionGain.connect(master);
|
||||||
|
|
||||||
|
const scheduledNodes: Array<AudioScheduledSourceNode> = [fundamental.source, harmonic.source, noise, ignition];
|
||||||
|
for (const node of scheduledNodes) node.start();
|
||||||
|
|
||||||
|
let outputConnected = false;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resume: () => context.resume(),
|
||||||
|
suspend: () => context.suspend(),
|
||||||
|
async close() {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
for (const node of scheduledNodes) {
|
||||||
|
try { node.stop(); } catch { /* A source may already have ended. */ }
|
||||||
|
}
|
||||||
|
if (outputConnected) {
|
||||||
|
dynamics.disconnect(context.destination);
|
||||||
|
outputConnected = false;
|
||||||
|
}
|
||||||
|
master.disconnect();
|
||||||
|
await context.close();
|
||||||
|
},
|
||||||
|
connectOutput() {
|
||||||
|
if (closed || outputConnected) return;
|
||||||
|
dynamics.connect(context.destination);
|
||||||
|
outputConnected = true;
|
||||||
|
},
|
||||||
|
disconnectOutput() {
|
||||||
|
if (!outputConnected) return;
|
||||||
|
dynamics.disconnect(context.destination);
|
||||||
|
outputConnected = false;
|
||||||
|
},
|
||||||
|
setOutputGain(value) {
|
||||||
|
const next = clamp(value);
|
||||||
|
master.gain.cancelScheduledValues(context.currentTime);
|
||||||
|
master.gain.setValueAtTime(next, context.currentTime);
|
||||||
|
},
|
||||||
|
setIntensity(value) {
|
||||||
|
const next = clamp(value);
|
||||||
|
const time = context.currentTime;
|
||||||
|
fundamental.source.frequency.setTargetAtTime(42 + next * 28, time, 0.32);
|
||||||
|
harmonic.source.frequency.setTargetAtTime(84 + next * 56, time, 0.32);
|
||||||
|
engineFilter.frequency.setTargetAtTime(260 + next * 670, time, 0.38);
|
||||||
|
roadFilter.frequency.setTargetAtTime(410 + next * 520, time, 0.42);
|
||||||
|
engineGain.gain.setTargetAtTime(0.045 + next * 0.035, time, 0.4);
|
||||||
|
roadGain.gain.setTargetAtTime(0.015 + next * 0.03, time, 0.45);
|
||||||
|
},
|
||||||
|
onContextStateChange(listener) {
|
||||||
|
const onStateChange = (): void => listener(context.state as AudioContextLifecycleState);
|
||||||
|
context.addEventListener('statechange', onStateChange);
|
||||||
|
return () => context.removeEventListener('statechange', onStateChange);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOscillator(
|
||||||
|
context: AudioContext,
|
||||||
|
type: OscillatorType,
|
||||||
|
frequency: number,
|
||||||
|
level: number,
|
||||||
|
destination: AudioNode,
|
||||||
|
): { source: OscillatorNode; gain: GainNode } {
|
||||||
|
const source = context.createOscillator();
|
||||||
|
source.type = type;
|
||||||
|
source.frequency.setValueAtTime(frequency, context.currentTime);
|
||||||
|
const gain = context.createGain();
|
||||||
|
gain.gain.setValueAtTime(level, context.currentTime);
|
||||||
|
source.connect(gain);
|
||||||
|
gain.connect(destination);
|
||||||
|
return { source, gain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNoiseBuffer(context: AudioContext, durationSeconds: number): AudioBuffer {
|
||||||
|
const length = Math.max(1, Math.floor(context.sampleRate * durationSeconds));
|
||||||
|
const buffer = context.createBuffer(1, length, context.sampleRate);
|
||||||
|
const samples = buffer.getChannelData(0);
|
||||||
|
let previous = 0;
|
||||||
|
for (let index = 0; index < samples.length; index += 1) {
|
||||||
|
const white = Math.random() * 2 - 1;
|
||||||
|
previous = previous * 0.93 + white * 0.07;
|
||||||
|
samples[index] = previous;
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
44
src/audio/types.ts
Normal file
44
src/audio/types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export type AudioUiState =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'starting' }
|
||||||
|
| { kind: 'running'; muted: boolean; volume: number }
|
||||||
|
| { kind: 'suspended'; resumeRequiresGesture: boolean }
|
||||||
|
| { kind: 'failed'; message: string }
|
||||||
|
| { kind: 'disposed' };
|
||||||
|
|
||||||
|
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
|
||||||
|
|
||||||
|
export interface AudioGraph {
|
||||||
|
resume(): Promise<void>;
|
||||||
|
suspend(): Promise<void>;
|
||||||
|
close(): Promise<void>;
|
||||||
|
connectOutput(): void;
|
||||||
|
disconnectOutput(): void;
|
||||||
|
setOutputGain(value: number): void;
|
||||||
|
setIntensity(value: number): void;
|
||||||
|
onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AudioGraphFactory = () => AudioGraph;
|
||||||
|
|
||||||
|
export interface AudioControllerOptions {
|
||||||
|
createGraph: AudioGraphFactory;
|
||||||
|
initialVolume?: number;
|
||||||
|
initiallyVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioController {
|
||||||
|
startFromGesture(): Promise<void>;
|
||||||
|
resumeFromGesture(): Promise<void>;
|
||||||
|
setMuted(muted: boolean): void;
|
||||||
|
setVolume(volume: number): void;
|
||||||
|
setIntensity(intensity: number): void;
|
||||||
|
setVisible(visible: boolean): Promise<void>;
|
||||||
|
noteInterruption(): Promise<void>;
|
||||||
|
pageHide(persisted: boolean): Promise<void>;
|
||||||
|
pageShow(persisted: boolean): Promise<void>;
|
||||||
|
dispose(): void;
|
||||||
|
whenSettled(): Promise<void>;
|
||||||
|
snapshot(): AudioUiState;
|
||||||
|
subscribe(listener: (state: AudioUiState) => void): () => void;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import SceneCanvas from './SceneCanvas.vue';
|
|||||||
import SiteNavigation from './SiteNavigation.vue';
|
import SiteNavigation from './SiteNavigation.vue';
|
||||||
import ToolkitGrid from './ToolkitGrid.vue';
|
import ToolkitGrid from './ToolkitGrid.vue';
|
||||||
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
import { useAdaptiveQuality } from '@/composables/useAdaptiveQuality';
|
||||||
|
import { useAudioEngine } from '@/composables/useAudioEngine';
|
||||||
import { useJourneySections } from '@/composables/useJourneySections';
|
import { useJourneySections } from '@/composables/useJourneySections';
|
||||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||||
@@ -43,6 +44,11 @@ const journey = useJourneySections(
|
|||||||
navigation.state,
|
navigation.state,
|
||||||
computed(() => motion.policy.value.allowSceneRequest),
|
computed(() => motion.policy.value.allowSceneRequest),
|
||||||
);
|
);
|
||||||
|
const audio = useAudioEngine({
|
||||||
|
intensity: computed(() => journey.value.acceleration),
|
||||||
|
volume: motion.volume,
|
||||||
|
setVolume: motion.setVolume,
|
||||||
|
});
|
||||||
const sceneState = shallowRef<SceneHostState>('idle');
|
const sceneState = shallowRef<SceneHostState>('idle');
|
||||||
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
|
const pageLifecycle = shallowRef<'active' | 'frozen' | 'disposed'>('active');
|
||||||
const controlsReady = shallowRef(false);
|
const controlsReady = shallowRef(false);
|
||||||
@@ -175,9 +181,16 @@ onUnmounted(() => removePageListeners());
|
|||||||
:policy="displayedPolicy"
|
:policy="displayedPolicy"
|
||||||
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
:maximum-quality="controlsReady ? motion.maximumQuality.value : 'high'"
|
||||||
:scene-status="displayedSceneState"
|
:scene-status="displayedSceneState"
|
||||||
|
:audio-state="audio.state.value"
|
||||||
|
:audio-muted="audio.muted.value"
|
||||||
|
:audio-volume="controlsReady ? motion.volume.value : 0.4"
|
||||||
@pause="motion.setPaused(true)"
|
@pause="motion.setPaused(true)"
|
||||||
@resume="motion.setPaused(false)"
|
@resume="motion.setPaused(false)"
|
||||||
@enable="enableMotion"
|
@enable="enableMotion"
|
||||||
@quality-change="setQuality"
|
@quality-change="setQuality"
|
||||||
|
@audio-start="audio.startFromGesture"
|
||||||
|
@audio-resume="audio.resumeFromGesture"
|
||||||
|
@audio-mute-toggle="audio.toggleMuted"
|
||||||
|
@audio-volume-change="audio.setVolume"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,18 +1,30 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
import type { AudioUiState } from '@/audio/types';
|
||||||
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
policy: MotionPolicy;
|
policy: MotionPolicy;
|
||||||
maximumQuality: QualityTier;
|
maximumQuality: QualityTier;
|
||||||
sceneStatus: string;
|
sceneStatus: string;
|
||||||
}>();
|
audioState?: AudioUiState;
|
||||||
|
audioMuted?: boolean;
|
||||||
|
audioVolume?: number;
|
||||||
|
}>(), {
|
||||||
|
audioState: () => ({ kind: 'idle' }),
|
||||||
|
audioMuted: false,
|
||||||
|
audioVolume: 0.4,
|
||||||
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
pause: [];
|
pause: [];
|
||||||
resume: [];
|
resume: [];
|
||||||
enable: [];
|
enable: [];
|
||||||
qualityChange: [quality: QualityTier];
|
qualityChange: [quality: QualityTier];
|
||||||
|
audioStart: [];
|
||||||
|
audioResume: [];
|
||||||
|
audioMuteToggle: [];
|
||||||
|
audioVolumeChange: [volume: number];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const action = computed(() => {
|
const action = computed(() => {
|
||||||
@@ -45,11 +57,51 @@ const changeQuality = (event: Event): void => {
|
|||||||
emit('qualityChange', value);
|
emit('qualityChange', value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeVolume = (event: Event): void => {
|
||||||
|
emit('audioVolumeChange', Number((event.currentTarget as HTMLInputElement).value));
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="drive-controls">
|
<div class="drive-controls">
|
||||||
<button v-if="action" type="button" @click="runAction">{{ action.label }}</button>
|
<button v-if="action" type="button" @click="runAction">{{ action.label }}</button>
|
||||||
|
<div v-if="audioState.kind === 'idle'" class="drive-controls__audio-start">
|
||||||
|
<button type="button" @click="emit('audioStart')">START THE DRIVE</button>
|
||||||
|
<span>Enables the interactive soundtrack.</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="audioState.kind === 'starting'" class="drive-controls__audio-start" aria-live="polite">
|
||||||
|
<button type="button" disabled>Starting drive…</button>
|
||||||
|
<span>Preparing the interactive soundtrack.</span>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="audioState.kind === 'failed'" class="drive-controls__audio-status" role="status">
|
||||||
|
Audio unavailable. Continue without sound.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-else-if="audioState.kind === 'running' || audioState.kind === 'suspended'"
|
||||||
|
class="drive-controls__audio"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-if="audioState.kind === 'suspended' && audioState.resumeRequiresGesture"
|
||||||
|
type="button"
|
||||||
|
@click="emit('audioResume')"
|
||||||
|
>Resume sound</button>
|
||||||
|
<button type="button" @click="emit('audioMuteToggle')">
|
||||||
|
{{ audioMuted ? 'Unmute sound' : 'Mute sound' }}
|
||||||
|
</button>
|
||||||
|
<label>
|
||||||
|
<span>Sound volume</span>
|
||||||
|
<input
|
||||||
|
aria-label="Sound volume"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
:value="audioVolume"
|
||||||
|
@input="changeVolume"
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>Quality</span>
|
<span>Quality</span>
|
||||||
<select :value="maximumQuality" aria-label="Quality control" @change="changeQuality">
|
<select :value="maximumQuality" aria-label="Quality control" @change="changeQuality">
|
||||||
|
|||||||
91
src/composables/useAudioEngine.ts
Normal file
91
src/composables/useAudioEngine.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import {
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
readonly,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
type Ref,
|
||||||
|
} from 'vue';
|
||||||
|
import { createAudioController } from '@/audio/create-audio-controller';
|
||||||
|
import { createBrowserSynthEngine } from '@/audio/synth-engine';
|
||||||
|
import type { AudioController, AudioUiState } from '@/audio/types';
|
||||||
|
|
||||||
|
export interface AudioEngineSources {
|
||||||
|
intensity: Readonly<Ref<number>>;
|
||||||
|
volume: Readonly<Ref<number>>;
|
||||||
|
setVolume(value: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioEngineBinding {
|
||||||
|
state: Readonly<Ref<AudioUiState>>;
|
||||||
|
muted: Readonly<Ref<boolean>>;
|
||||||
|
startFromGesture(): void;
|
||||||
|
resumeFromGesture(): void;
|
||||||
|
toggleMuted(): void;
|
||||||
|
setVolume(value: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding {
|
||||||
|
const state = shallowRef<AudioUiState>({ kind: 'idle' });
|
||||||
|
const muted = shallowRef(false);
|
||||||
|
let controller: AudioController | null = null;
|
||||||
|
let removeSubscription = (): void => {};
|
||||||
|
let removePageListeners = (): void => {};
|
||||||
|
|
||||||
|
const ensureController = (): AudioController => {
|
||||||
|
if (controller) return controller;
|
||||||
|
controller = createAudioController({
|
||||||
|
createGraph: createBrowserSynthEngine,
|
||||||
|
initialVolume: sources.volume.value,
|
||||||
|
initiallyVisible: document.visibilityState !== 'hidden',
|
||||||
|
});
|
||||||
|
removeSubscription = controller.subscribe((next) => { state.value = next; });
|
||||||
|
controller.setIntensity(sources.intensity.value);
|
||||||
|
return controller;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(sources.intensity, (value) => controller?.setIntensity(value));
|
||||||
|
watch(sources.volume, (value) => controller?.setVolume(value));
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
ensureController();
|
||||||
|
const onVisibilityChange = (): void => {
|
||||||
|
void controller?.setVisible(document.visibilityState !== 'hidden');
|
||||||
|
};
|
||||||
|
const onPageHide = (event: PageTransitionEvent): void => { void controller?.pageHide(event.persisted); };
|
||||||
|
const onPageShow = (event: PageTransitionEvent): void => { void controller?.pageShow(event.persisted); };
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.addEventListener('pagehide', onPageHide);
|
||||||
|
window.addEventListener('pageshow', onPageShow);
|
||||||
|
removePageListeners = () => {
|
||||||
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
window.removeEventListener('pagehide', onPageHide);
|
||||||
|
window.removeEventListener('pageshow', onPageShow);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
removePageListeners();
|
||||||
|
removeSubscription();
|
||||||
|
controller?.dispose();
|
||||||
|
controller = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: readonly(state),
|
||||||
|
muted: readonly(muted),
|
||||||
|
startFromGesture() {
|
||||||
|
// Keep this call direct: the controller constructs AudioContext synchronously here.
|
||||||
|
void ensureController().startFromGesture();
|
||||||
|
},
|
||||||
|
resumeFromGesture() { void ensureController().resumeFromGesture(); },
|
||||||
|
toggleMuted() {
|
||||||
|
muted.value = !muted.value;
|
||||||
|
ensureController().setMuted(muted.value);
|
||||||
|
},
|
||||||
|
setVolume(value) {
|
||||||
|
sources.setVolume(value);
|
||||||
|
ensureController().setVolume(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -93,14 +93,33 @@
|
|||||||
font-size: var(--step--1);
|
font-size: var(--step--1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drive-controls__audio-start,
|
||||||
|
.drive-controls__audio {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drive-controls__audio-start span,
|
||||||
|
.drive-controls__audio-status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.drive-controls button,
|
.drive-controls button,
|
||||||
.drive-controls select {
|
.drive-controls select,
|
||||||
|
.drive-controls input {
|
||||||
min-block-size: 44px;
|
min-block-size: 44px;
|
||||||
border: 1px solid var(--line-strong);
|
border: 1px solid var(--line-strong);
|
||||||
background: #101925;
|
background: #101925;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drive-controls input[type='range'] {
|
||||||
|
inline-size: 7rem;
|
||||||
|
accent-color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
.drive-controls button {
|
.drive-controls button {
|
||||||
padding-inline: 0.8rem;
|
padding-inline: 0.8rem;
|
||||||
}
|
}
|
||||||
@@ -120,6 +139,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
@media (max-width: 767px) {
|
||||||
|
html {
|
||||||
|
scroll-padding-block-end: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#contact {
|
||||||
|
padding-block-end: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#contact a {
|
||||||
|
scroll-margin-block-end: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
.journey-hud {
|
.journey-hud {
|
||||||
inset: 0.5rem 0.5rem auto auto;
|
inset: 0.5rem 0.5rem auto auto;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
@@ -138,6 +169,10 @@
|
|||||||
.drive-controls__status {
|
.drive-controls__status {
|
||||||
flex-basis: 100%;
|
flex-basis: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.drive-controls__audio-start span {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
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