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

@@ -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<T>(): Deferred<T> {
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<FakeScheduledNode & { type: string; frequency: FakeAudioParam }> = [];
readonly bufferSources: Array<FakeScheduledNode & { buffer: unknown; loop: boolean }> = [];