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;
|
||||
}
|
||||
Reference in New Issue
Block a user