From 7e48aad6a8eef0199756b26a5b105d6982685fc3 Mon Sep 17 00:00:00 2001 From: LucaP Date: Wed, 2 Sep 2026 15:53:43 +0300 Subject: [PATCH] feat: add the procedural synthwave soundtrack --- src/audio/music-sequencer.ts | 215 +++++++++++++++++++++++++++++ src/audio/synth-engine.ts | 48 +++++-- src/audio/types.ts | 7 + tests/unit/audio-lifecycle.test.ts | 143 +++++++++++++++---- 4 files changed, 376 insertions(+), 37 deletions(-) create mode 100644 src/audio/music-sequencer.ts diff --git a/src/audio/music-sequencer.ts b/src/audio/music-sequencer.ts new file mode 100644 index 0000000..54eb9bb --- /dev/null +++ b/src/audio/music-sequencer.ts @@ -0,0 +1,215 @@ +import type { MusicSequencer } from '@/audio/types'; + +export const MUSIC_BPM = 86; +export const MUSIC_SCHEDULER_INTERVAL_MS = 100; +export const MUSIC_LOOKAHEAD_SECONDS = 0.25; + +export const MUSIC_PROGRESSION = [ + { name: 'F minor', notes: [174.61, 207.65, 261.63], bass: [87.31, 130.81] }, + { name: 'D-flat major', notes: [138.59, 174.61, 207.65], bass: [69.3, 103.83] }, + { name: 'A-flat major', notes: [207.65, 261.63, 311.13], bass: [103.83, 155.56] }, + { name: 'E-flat major', notes: [155.56, 196, 233.08], bass: [77.78, 116.54] }, +] as const; + +const SIXTEENTH_SECONDS = 60 / MUSIC_BPM / 4; +const STEPS_PER_BAR = 16; +const LOOP_STEPS = STEPS_PER_BAR * MUSIC_PROGRESSION.length; +const MAX_STEPS_PER_SCHEDULE = LOOP_STEPS * 2; +const SILENCE = 0.0001; + +interface VoiceRecord { + source: AudioScheduledSourceNode; + nodes: AudioNode[]; +} + +export function createMusicSequencer( + context: AudioContext, + destination: AudioNode, +): MusicSequencer { + const voices = new Map(); + const clapFilter = context.createBiquadFilter(); + clapFilter.type = 'bandpass'; + clapFilter.frequency.setValueAtTime(1_650, context.currentTime); + clapFilter.Q.setValueAtTime(0.7, context.currentTime); + clapFilter.connect(destination); + const clapBuffer = createNoiseBuffer(context, 0.12); + let timer: ReturnType | null = null; + let playing = false; + let intensity = 0; + let stepCursor = 0; + let nextStepTime = 0; + + const cleanupVoice = (source: AudioScheduledSourceNode): void => { + const voice = voices.get(source); + if (!voice) return; + voices.delete(source); + source.disconnect(); + for (const node of voice.nodes) node.disconnect(); + }; + + const registerVoice = ( + source: AudioScheduledSourceNode, + nodes: AudioNode[], + atTime: number, + duration: number, + ): void => { + voices.set(source, { source, nodes }); + source.addEventListener('ended', () => cleanupVoice(source), { once: true }); + source.start(atTime); + source.stop(atTime + duration); + }; + + const scheduleTone = ( + frequency: number, + type: OscillatorType, + atTime: number, + duration: number, + peak: number, + attack: number, + output: AudioNode = destination, + ): void => { + const source = context.createOscillator(); + source.type = type; + source.frequency.setValueAtTime(frequency, atTime); + const gain = context.createGain(); + gain.gain.setValueAtTime(SILENCE, atTime); + gain.gain.exponentialRampToValueAtTime(peak, atTime + attack); + gain.gain.setValueAtTime(peak, Math.max(atTime + attack, atTime + duration * 0.72)); + gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + duration); + source.connect(gain); + gain.connect(output); + registerVoice(source, [gain], atTime, duration); + }; + + const schedulePad = (notes: readonly number[], atTime: number): void => { + const duration = SIXTEENTH_SECONDS * 15.6; + for (const [index, frequency] of notes.entries()) { + scheduleTone(frequency, index === 1 ? 'triangle' : 'sawtooth', atTime, duration, 0.012, 0.34); + } + }; + + const scheduleBass = (frequency: number, atTime: number): void => { + scheduleTone(frequency, 'triangle', atTime, SIXTEENTH_SECONDS * 6.8, 0.052, 0.035); + }; + + const scheduleKick = (atTime: number, accent = 1): void => { + const source = context.createOscillator(); + source.type = 'sine'; + source.frequency.setValueAtTime(118, atTime); + source.frequency.exponentialRampToValueAtTime(46, atTime + 0.16); + const gain = context.createGain(); + gain.gain.setValueAtTime(0.07 * accent, atTime); + gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + 0.22); + source.connect(gain); + gain.connect(destination); + registerVoice(source, [gain], atTime, 0.23); + }; + + const scheduleClap = (atTime: number): void => { + const source = context.createBufferSource(); + source.buffer = clapBuffer; + const gain = context.createGain(); + gain.gain.setValueAtTime(SILENCE, atTime); + gain.gain.linearRampToValueAtTime(0.026, atTime + 0.008); + gain.gain.exponentialRampToValueAtTime(SILENCE, atTime + 0.11); + source.connect(gain); + gain.connect(clapFilter); + registerVoice(source, [gain], atTime, 0.12); + }; + + const schedulePulse = (atTime: number, step: number): void => { + const level = 0.006 + intensity * 0.016; + const frequency = step % 8 === 2 ? 2_140 : 1_820; + scheduleTone(frequency, 'square', atTime, 0.045, level, 0.003); + }; + + const scheduleMotif = (atTime: number, frequency: number): void => { + scheduleTone(frequency, 'sine', atTime, SIXTEENTH_SECONDS * 2.7, 0.019, 0.025); + }; + + const scheduleStep = (absoluteStep: number, atTime: number): void => { + const loopStep = absoluteStep % LOOP_STEPS; + const stepInBar = loopStep % STEPS_PER_BAR; + const bar = Math.floor(loopStep / STEPS_PER_BAR); + const chord = MUSIC_PROGRESSION[bar]!; + + if (stepInBar === 0) { + schedulePad(chord.notes, atTime); + scheduleBass(chord.bass[0], atTime); + scheduleKick(atTime, bar === 0 ? 1 : 0.82); + } + if (stepInBar === 8) { + scheduleBass(chord.bass[1], atTime); + scheduleClap(atTime); + } + if (stepInBar === 10 && bar % 2 === 0) scheduleKick(atTime, 0.56); + if (stepInBar === 2 || stepInBar === 6 || stepInBar === 10 || stepInBar === 14) { + schedulePulse(atTime, stepInBar); + } + if (loopStep === 52) scheduleMotif(atTime, 466.16); + if (loopStep === 60) scheduleMotif(atTime, 523.25); + }; + + const scheduleThrough = (horizonTime: number): void => { + if (!playing || !Number.isFinite(horizonTime)) return; + if (nextStepTime < context.currentTime - SIXTEENTH_SECONDS * 2) { + const skipped = Math.floor((context.currentTime - nextStepTime) / SIXTEENTH_SECONDS); + stepCursor += skipped; + nextStepTime += skipped * SIXTEENTH_SECONDS; + } + let scheduled = 0; + while (nextStepTime <= horizonTime + Number.EPSILON && scheduled < MAX_STEPS_PER_SCHEDULE) { + scheduleStep(stepCursor, nextStepTime); + stepCursor += 1; + nextStepTime += SIXTEENTH_SECONDS; + scheduled += 1; + } + }; + + return { + start(atTime) { + if (playing) return; + playing = true; + stepCursor = 0; + nextStepTime = Math.max(context.currentTime, Number.isFinite(atTime) ? atTime : context.currentTime); + scheduleThrough(nextStepTime + MUSIC_LOOKAHEAD_SECONDS); + timer = setInterval(() => { + scheduleThrough(context.currentTime + MUSIC_LOOKAHEAD_SECONDS); + }, MUSIC_SCHEDULER_INTERVAL_MS); + }, + scheduleThrough, + setIntensity(value, _atTime) { + intensity = clamp(value); + }, + stop() { + if (!playing && timer === null) return; + playing = false; + if (timer !== null) clearInterval(timer); + timer = null; + for (const source of [...voices.keys()]) { + try { source.stop(context.currentTime); } catch { /* The voice may already have ended. */ } + cleanupVoice(source); + } + clapFilter.disconnect(); + }, + }; +} + +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 state = 0x9e3779b9; + for (let index = 0; index < samples.length; index += 1) { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + samples[index] = ((state >>> 0) / 0xffff_ffff) * 2 - 1; + } + return buffer; +} + +function clamp(value: number): number { + if (!Number.isFinite(value)) return 0; + return Math.min(1, Math.max(0, value)); +} diff --git a/src/audio/synth-engine.ts b/src/audio/synth-engine.ts index 72df00d..5ecec9b 100644 --- a/src/audio/synth-engine.ts +++ b/src/audio/synth-engine.ts @@ -1,4 +1,5 @@ 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; @@ -46,7 +47,16 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap const master = context.createGain(); const dynamics = context.createDynamicsCompressor(); master.gain.setValueAtTime(0, context.currentTime); - master.connect(dynamics); + 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'; @@ -54,9 +64,9 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap engineFilter.Q.setValueAtTime(1.25, context.currentTime); const engineGain = context.createGain(); - engineGain.gain.setValueAtTime(0.045, context.currentTime); + engineGain.gain.setValueAtTime(0.24, context.currentTime); engineFilter.connect(engineGain); - engineGain.connect(master); + engineGain.connect(dynamics); const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter); const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter); @@ -66,9 +76,9 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap roadFilter.frequency.setValueAtTime(410, context.currentTime); roadFilter.Q.setValueAtTime(0.55, context.currentTime); const roadGain = context.createGain(); - roadGain.gain.setValueAtTime(0.015, context.currentTime); + roadGain.gain.setValueAtTime(0.045, context.currentTime); roadFilter.connect(roadGain); - roadGain.connect(master); + roadGain.connect(dynamics); const noise = context.createBufferSource(); noise.buffer = createNoiseBuffer(context, 2); @@ -83,10 +93,12 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap ignitionGain.gain.exponentialRampToValueAtTime(0.07, context.currentTime + 0.12); ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15); ignition.connect(ignitionGain); - ignitionGain.connect(master); + 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; @@ -97,24 +109,36 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap 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) { - dynamics.disconnect(context.destination); + 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; - dynamics.connect(context.destination); + master.connect(context.destination); outputConnected = true; }, disconnectOutput() { if (!outputConnected) return; - dynamics.disconnect(context.destination); + master.disconnect(context.destination); outputConnected = false; }, setOutputGain(value) { @@ -129,8 +153,10 @@ export function createSynthEngine(createContext: AudioContextFactory): AudioGrap 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); + 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); diff --git a/src/audio/types.ts b/src/audio/types.ts index 41bb1be..aa743ae 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -19,6 +19,13 @@ export interface AudioGraph { onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void; } +export interface MusicSequencer { + start(atTime: number): void; + scheduleThrough(horizonTime: number): void; + setIntensity(value: number, atTime: number): void; + stop(): void; +} + export type AudioGraphFactory = () => AudioGraph; export interface AudioControllerOptions { diff --git a/tests/unit/audio-lifecycle.test.ts b/tests/unit/audio-lifecycle.test.ts index 80abd1b..df10089 100644 --- a/tests/unit/audio-lifecycle.test.ts +++ b/tests/unit/audio-lifecycle.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it, vi } from 'vitest'; import { createAudioController } from '@/audio/create-audio-controller'; +import { + createMusicSequencer, + MUSIC_BPM, + MUSIC_LOOKAHEAD_SECONDS, + MUSIC_PROGRESSION, + MUSIC_SCHEDULER_INTERVAL_MS, +} from '@/audio/music-sequencer'; import { createSynthEngine } from '@/audio/synth-engine'; import type { AudioGraph, AudioGraphFactory } from '@/audio/types'; @@ -205,31 +212,104 @@ describe('audio controller lifecycle', () => { }); describe('procedural audio graph', () => { - it('builds an original oscillator, filtered-noise, gain, and dynamics bed while output stays disconnected', async () => { + it('routes independent music, engine, and road buses through compression before the master output', async () => { + vi.useFakeTimers(); const context = new FakeAudioContext(); - const graph = createSynthEngine(() => context as unknown as AudioContext); + try { + const graph = createSynthEngine(() => context as unknown as AudioContext); + context.currentTime = 1.4; + vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS); - 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); + const [master, musicGain, engineGain, , , roadGain] = context.gains; + const [musicFilter, engineFilter, roadFilter] = context.filters; + const [compressor] = context.compressors; + expect(musicFilter?.connections.has(musicGain)).toBe(true); + expect(engineFilter?.connections.has(engineGain)).toBe(true); + expect(roadFilter?.connections.has(roadGain)).toBe(true); + expect(musicGain?.connections.has(compressor)).toBe(true); + expect(engineGain?.connections.has(compressor)).toBe(true); + expect(roadGain?.connections.has(compressor)).toBe(true); + expect(compressor?.connections.has(master)).toBe(true); + expect(context.destinationConnections).toBe(0); + expect(context.startedNodes).toBeGreaterThan(4); + expect(context.bufferSources.length).toBeGreaterThanOrEqual(2); + expect(context.filters.length).toBeGreaterThanOrEqual(4); - 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.connectOutput(); + graph.setOutputGain(0.6); + graph.setIntensity(1); + expect(context.destinationConnections).toBe(1); + expect(context.parameters.some((parameter) => parameter.targetValues.includes(930))).toBe(true); + expect(context.parameters.some((parameter) => parameter.targetValues.includes(3_300))).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); + graph.disconnectOutput(); + await graph.close(); + await graph.close(); + expect(context.destinationConnections).toBe(0); + expect(context.closeCalls).toBe(1); + expect(context.stoppedNodes).toBe(context.startedNodes); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('procedural music sequencer', () => { + it('schedules the fixed four-bar progression through a 100ms/250ms look-ahead clock', () => { + vi.useFakeTimers(); + const context = new FakeAudioContext(); + const destination = context.createGain(); + + try { + expect(MUSIC_BPM).toBe(86); + expect(MUSIC_SCHEDULER_INTERVAL_MS).toBe(100); + expect(MUSIC_LOOKAHEAD_SECONDS).toBe(0.25); + expect(MUSIC_PROGRESSION.map((chord) => chord.name)).toEqual([ + 'F minor', 'D-flat major', 'A-flat major', 'E-flat major', + ]); + + const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode); + expect(context.startedNodes).toBe(0); + sequencer.start(0); + const initialStarted = context.startedNodes; + expect(initialStarted).toBeGreaterThan(0); + expect(vi.getTimerCount()).toBe(1); + + context.currentTime = 0.1; + vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS); + expect(context.startedNodes).toBeGreaterThan(initialStarted); + + sequencer.scheduleThrough(11.2); + const frequencies = new Set(context.oscillators.map((oscillator) => Number(oscillator.frequency.value.toFixed(2)))); + for (const root of [174.61, 138.59, 207.65, 155.56]) expect(frequencies.has(root)).toBe(true); + + sequencer.stop(); + expect(vi.getTimerCount()).toBe(0); + expect(context.stoppedNodes).toBe(context.startedNodes); + } finally { + vi.useRealTimers(); + } + }); + + it('raises future pulse energy with intensity without changing the musical clock', () => { + vi.useFakeTimers(); + const context = new FakeAudioContext(); + const destination = context.createGain(); + + try { + const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode); + sequencer.start(0); + sequencer.setIntensity(1, 0); + sequencer.scheduleThrough(0.6); + + expect(context.parameters.some((parameter) => parameter.scheduledValues.includes(0.022))).toBe(true); + expect(MUSIC_BPM).toBe(86); + sequencer.stop(); + } finally { + vi.useRealTimers(); + } }); }); @@ -298,11 +378,12 @@ function deferred(): Deferred { class FakeAudioParam { value = 0; readonly targetValues: number[] = []; + readonly scheduledValues: number[] = []; - setValueAtTime(value: number): void { this.value = value; } + setValueAtTime(value: number): void { this.scheduledValues.push(value); 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; } + linearRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; } + exponentialRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; } cancelScheduledValues(): void {} } @@ -325,14 +406,24 @@ class FakeNode extends EventTarget { } class FakeScheduledNode extends FakeNode { - start(): void { this.owner.startedNodes += 1; } - stop(): void { this.owner.stoppedNodes += 1; } + private started = false; + private stopped = false; + start(): void { + if (this.started) return; + this.started = true; + this.owner.startedNodes += 1; + } + stop(): void { + if (this.stopped) return; + this.stopped = true; + this.owner.stoppedNodes += 1; + } } class FakeAudioContext extends EventTarget { readonly destination = {}; readonly sampleRate = 8_000; - readonly currentTime = 0; + currentTime = 0; readonly parameters: FakeAudioParam[] = []; readonly oscillators: Array = []; readonly bufferSources: Array = [];