feat: add the procedural synthwave soundtrack

This commit is contained in:
2026-09-02 15:53:43 +03:00
parent c4f1a38a78
commit 7e48aad6a8
4 changed files with 376 additions and 37 deletions

View File

@@ -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<AudioScheduledSourceNode, VoiceRecord>();
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<typeof setInterval> | 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));
}

View File

@@ -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<AudioScheduledSourceNode> = [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);

View File

@@ -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 {