import type { AudioContextLifecycleState, AudioGraph } from '@/audio/types'; import { createMusicSequencer } from '@/audio/music-sequencer'; import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks'; declare const __PORTFOLIO_TEST_BUILD__: boolean; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; type AudioContextFactory = () => AudioContext; export function createBrowserSynthEngine(): AudioGraph { if (TEST_BUILD && isTestFault('F10-audio-start-rejection')) { recordTestFaultEvent('audio-start-rejected'); throw new DOMException('Injected audio start rejection', 'NotAllowedError'); } const AudioContextConstructor = window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!AudioContextConstructor) throw new Error('Web Audio is unavailable'); const graph = createSynthEngine(() => new AudioContextConstructor()); if (!TEST_BUILD) return graph; let resumeCount = 0; return { ...graph, async resume() { resumeCount += 1; if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) { recordTestFaultEvent('audio-resume-rejected'); throw new DOMException('Injected audio resume rejection', 'NotAllowedError'); } if (isTestFault('F12-audio-graph-invalid') && resumeCount > 1) { recordTestFaultEvent('audio-graph-invalid'); throw new DOMException('Injected invalid audio graph', 'InvalidStateError'); } await graph.resume(); }, async suspend() { if (isTestFault('F13-audio-suspend-rejection')) { recordTestFaultEvent('audio-suspend-rejected'); throw new DOMException('Injected audio suspend rejection', 'InvalidStateError'); } await graph.suspend(); }, }; } export function createSynthEngine(createContext: AudioContextFactory): AudioGraph { const context = createContext(); const master = context.createGain(); const dynamics = context.createDynamicsCompressor(); master.gain.setValueAtTime(0, context.currentTime); dynamics.connect(master); const musicFilter = context.createBiquadFilter(); musicFilter.type = 'lowpass'; musicFilter.frequency.setValueAtTime(900, context.currentTime); musicFilter.Q.setValueAtTime(0.72, context.currentTime); const musicGain = context.createGain(); musicGain.gain.setValueAtTime(0.38, context.currentTime); musicFilter.connect(musicGain); musicGain.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.24, context.currentTime); engineFilter.connect(engineGain); engineGain.connect(dynamics); 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.045, context.currentTime); roadFilter.connect(roadGain); roadGain.connect(dynamics); 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(engineFilter); const scheduledNodes: Array = [fundamental.source, harmonic.source, noise, ignition]; for (const node of scheduledNodes) node.start(); const music = createMusicSequencer(context, musicFilter); music.start(context.currentTime); let outputConnected = false; let closed = false; return { resume: () => context.resume(), suspend: () => context.suspend(), async close() { if (closed) return; closed = true; music.stop(); for (const node of scheduledNodes) { try { node.stop(); } catch { /* A source may already have ended. */ } node.disconnect(); } if (outputConnected) { master.disconnect(context.destination); outputConnected = false; } fundamental.gain.disconnect(); harmonic.gain.disconnect(); ignitionGain.disconnect(); musicFilter.disconnect(); musicGain.disconnect(); engineFilter.disconnect(); engineGain.disconnect(); roadFilter.disconnect(); roadGain.disconnect(); dynamics.disconnect(); master.disconnect(); await context.close(); }, connectOutput() { if (closed || outputConnected) return; master.connect(context.destination); outputConnected = true; }, disconnectOutput() { if (!outputConnected) return; master.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); musicFilter.frequency.setTargetAtTime(900 + next * 2_400, time, 0.42); engineGain.gain.setTargetAtTime(0.24 + next * 0.12, time, 0.4); roadGain.gain.setTargetAtTime(0.045 + next * 0.06, time, 0.45); music.setIntensity(next, time); }, 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)); }