feat: integrate authored nightdrive soundtrack

This commit is contained in:
2026-09-04 14:39:28 +03:00
parent eb7f276b91
commit 745fa8169e
39 changed files with 1013 additions and 714 deletions

View File

@@ -1,14 +1,10 @@
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';
import type {
AudioGraph,
AudioGraphFactory,
AudioJourneyState,
} from '@/audio/types';
describe('audio controller lifecycle', () => {
it('constructs no graph before activation and only one graph for rapid repeated activation', async () => {
@@ -174,6 +170,7 @@ describe('audio controller lifecycle', () => {
const closing = controller.pageHide(false);
controller.dispose();
expect(harness.graph.cancelPending).toHaveBeenCalledOnce();
resume.resolve();
await Promise.all([starting, closing]);
await controller.whenSettled();
@@ -181,6 +178,7 @@ describe('audio controller lifecycle', () => {
expect(controller.snapshot()).toEqual({ kind: 'disposed' });
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
expect(harness.graph.close).toHaveBeenCalledOnce();
expect(harness.graph.cancelPending).toHaveBeenCalledTimes(2);
});
it('ignores lifecycle events before activation and after failure without creating another graph', async () => {
@@ -198,141 +196,26 @@ describe('audio controller lifecycle', () => {
expect(controller.snapshot().kind).toBe('failed');
});
it('clamps and slowly smooths drive intensity before forwarding it to the graph', async () => {
it('stores the latest semantic state before activation and forwards raw clamped updates', async () => {
const harness = createHarness();
const controller = createAudioController({ createGraph: harness.createGraph });
controller.setJourneyState({ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 2 });
expect(harness.constructed).toBe(0);
await controller.startFromGesture();
controller.setIntensity(2);
controller.setIntensity(Number.NaN);
expect(harness.initialStates).toEqual([
{ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 },
]);
expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(3, 0.12);
expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(4, 0.1056);
});
});
controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 });
describe('procedural audio graph', () => {
it('keeps the soundtrack clearly ahead of the ambient drive effects across intensity changes', async () => {
vi.useFakeTimers();
const context = new FakeAudioContext();
try {
const graph = createSynthEngine(() => context as unknown as AudioContext);
const [, musicGain, engineGain, , , roadGain, ignitionGain] = context.gains;
expect(musicGain?.gain.value).toBe(0.78);
expect(engineGain?.gain.value).toBe(0.09);
expect(roadGain?.gain.value).toBe(0.018);
expect(ignitionGain?.gain.scheduledValues).toEqual([0.0001, 0.025, 0.0001]);
graph.setIntensity(1);
expect(engineGain?.gain.targetValues).toContain(0.13);
expect(roadGain?.gain.targetValues).toContain(0.038);
await graph.close();
} finally {
vi.useRealTimers();
}
});
it('routes independent music, engine, and road buses through compression before the master output', async () => {
vi.useFakeTimers();
const context = new FakeAudioContext();
try {
const graph = createSynthEngine(() => context as unknown as AudioContext);
context.currentTime = 1.4;
vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS);
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);
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(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();
}
expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({
atStart: false,
bias: 'shadow',
entryPhrase: 2,
intensity: 0.73,
});
});
});
@@ -346,7 +229,7 @@ interface HarnessOptions {
function createHarness(options: HarnessOptions = {}) {
let rejectNextResume = options.resumeRejects === true;
const stateListeners = new Set<(state: string) => void>();
const graph: AudioGraph = {
const graph = {
resume: vi.fn(async () => {
if (options.resume) return options.resume.promise;
if (rejectNextResume) {
@@ -362,20 +245,24 @@ function createHarness(options: HarnessOptions = {}) {
connectOutput: vi.fn(),
disconnectOutput: vi.fn(),
setOutputGain: vi.fn(),
setIntensity: vi.fn(),
setJourneyState: vi.fn(),
cancelPending: vi.fn(),
onContextStateChange: vi.fn((listener) => {
stateListeners.add(listener);
return () => stateListeners.delete(listener);
}),
};
} satisfies AudioGraph;
let constructed = 0;
const createGraph: AudioGraphFactory = () => {
const initialStates: AudioJourneyState[] = [];
const createGraph: AudioGraphFactory = (initialState?: AudioJourneyState) => {
constructed += 1;
if (initialState) initialStates.push(initialState);
return graph;
};
return {
graph,
createGraph,
initialStates,
get constructed() { return constructed; },
rejectNextResume() { rejectNextResume = true; },
emitState(state: string) { for (const listener of stateListeners) listener(state); },
@@ -397,107 +284,3 @@ function deferred<T>(): Deferred<T> {
});
return { promise, resolve, reject };
}
class FakeAudioParam {
value = 0;
readonly targetValues: number[] = [];
readonly scheduledValues: number[] = [];
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.scheduledValues.push(value); this.value = value; }
exponentialRampToValueAtTime(value: number): void { this.scheduledValues.push(value); this.value = value; }
cancelScheduledValues(): void {}
}
class FakeNode extends EventTarget {
readonly connections = new Set<unknown>();
constructor(protected readonly owner: FakeAudioContext) { super(); }
connect(target: unknown): unknown {
this.connections.add(target);
if (target === this.owner.destination) this.owner.destinationConnections += 1;
return target;
}
disconnect(target?: unknown): void {
if (target === undefined) {
if (this.connections.delete(this.owner.destination)) this.owner.destinationConnections -= 1;
this.connections.clear();
return;
}
if (this.connections.delete(target) && target === this.owner.destination) this.owner.destinationConnections -= 1;
}
}
class FakeScheduledNode extends FakeNode {
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;
currentTime = 0;
readonly parameters: FakeAudioParam[] = [];
readonly oscillators: Array<FakeScheduledNode & { type: string; frequency: FakeAudioParam }> = [];
readonly bufferSources: Array<FakeScheduledNode & { buffer: unknown; loop: boolean }> = [];
readonly filters: Array<FakeNode & { type: string; frequency: FakeAudioParam; Q: FakeAudioParam }> = [];
readonly gains: Array<FakeNode & { gain: FakeAudioParam }> = [];
readonly compressors: FakeNode[] = [];
destinationConnections = 0;
startedNodes = 0;
stoppedNodes = 0;
closeCalls = 0;
state: AudioContextState = 'suspended';
private parameter(value = 0): FakeAudioParam {
const parameter = new FakeAudioParam();
parameter.value = value;
this.parameters.push(parameter);
return parameter;
}
createOscillator() {
const node = Object.assign(new FakeScheduledNode(this), { type: 'sine', frequency: this.parameter(440) });
this.oscillators.push(node);
return node;
}
createBufferSource() {
const node = Object.assign(new FakeScheduledNode(this), { buffer: null as unknown, loop: false });
this.bufferSources.push(node);
return node;
}
createBiquadFilter() {
const node = Object.assign(new FakeNode(this), {
type: 'lowpass', frequency: this.parameter(350), Q: this.parameter(1),
});
this.filters.push(node);
return node;
}
createGain() {
const node = Object.assign(new FakeNode(this), { gain: this.parameter(1) });
this.gains.push(node);
return node;
}
createDynamicsCompressor() {
const node = new FakeNode(this);
this.compressors.push(node);
return node;
}
createBuffer(channels: number, length: number) {
const data = Array.from({ length: channels }, () => new Float32Array(length));
return { getChannelData: (channel: number) => data[channel] };
}
async resume(): Promise<void> { this.state = 'running'; }
async suspend(): Promise<void> { this.state = 'suspended'; }
async close(): Promise<void> { this.closeCalls += 1; this.state = 'closed'; }
}

View File

@@ -24,6 +24,10 @@ type MutableContentFixture = {
};
toolkit: Array<{ technologies: Array<{ assetId?: string }> }>;
metadata: { socialPreviewAssetId?: string };
soundtrack?: {
intro: { opusAssetId: string; mp3AssetId: string };
main: { opusAssetId: string; mp3AssetId: string };
};
};
const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture;
@@ -39,6 +43,56 @@ describe('content schemas', () => {
expect(parsePublicAssetManifest({ assets: [] })).toEqual({ assets: [] });
});
it('accepts an optional soundtrack whose audio variants have exact manifest closure', () => {
const content = cloneContent();
content.soundtrack = {
intro: { opusAssetId: 'soundtrack-intro-opus', mp3AssetId: 'soundtrack-intro-mp3' },
main: { opusAssetId: 'soundtrack-main-opus', mp3AssetId: 'soundtrack-main-mp3' },
};
const audioMetadata = {
width: null,
height: null,
rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved',
attribution: 'Original soundtrack by Luca Pîslaru',
};
const soundtrackAssets = [
{ id: 'soundtrack-intro-opus', fileName: 'nightdrive-intro.opus', sha256: 'a'.repeat(64), mediaType: 'audio/ogg', durationMs: 56_064, ...audioMetadata },
{ id: 'soundtrack-intro-mp3', fileName: 'nightdrive-intro.mp3', sha256: 'b'.repeat(64), mediaType: 'audio/mpeg', durationMs: 56_064, ...audioMetadata },
{ id: 'soundtrack-main-opus', fileName: 'nightdrive-main.opus', sha256: 'c'.repeat(64), mediaType: 'audio/ogg', durationMs: 178_605, ...audioMetadata },
{ id: 'soundtrack-main-mp3', fileName: 'nightdrive-main.mp3', sha256: 'd'.repeat(64), mediaType: 'audio/mpeg', durationMs: 178_605, ...audioMetadata },
];
const manifest = {
assets: [...(validManifest as { assets: unknown[] }).assets, ...soundtrackAssets],
};
expect(validateProductionInputs(content, manifest, validSite, new URL('file:///prepared/')).content.soundtrack)
.toEqual(content.soundtrack);
content.soundtrack.main.mp3AssetId = 'missing-soundtrack';
expect(() => validateProductionInputs(content, manifest, validSite, new URL('file:///prepared/')))
.toThrow(/unmanifested asset reference: missing-soundtrack/i);
});
it('requires audio assets to have null dimensions and a positive duration', () => {
const audio = {
id: 'soundtrack-main-opus',
fileName: 'nightdrive-main.opus',
sha256: 'a'.repeat(64),
mediaType: 'audio/ogg',
width: null,
height: null,
durationMs: 178_605,
rightsHolder: 'Luca Pîslaru',
license: 'All rights reserved',
attribution: 'Original soundtrack by Luca Pîslaru',
};
expect(() => parsePublicAssetManifest({ assets: [audio] })).not.toThrow();
expect(() => parsePublicAssetManifest({ assets: [{ ...audio, width: 1 }] })).toThrow(/audio assets require null dimensions/i);
expect(() => parsePublicAssetManifest({ assets: [{ ...audio, durationMs: null }] })).toThrow(/audio assets require.*duration/i);
});
it('accepts the approved generic private project titles', () => {
for (const title of [
'Private self-hosted medication tracker',

View File

@@ -110,7 +110,7 @@ describe('environment-aware crawl and response artifacts', () => {
' Cache-Control: public, max-age=31536000, immutable',
'',
'/*',
` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests`,
` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests`,
' X-Content-Type-Options: nosniff',
' Referrer-Policy: no-referrer',
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
@@ -126,7 +126,7 @@ describe('environment-aware crawl and response artifacts', () => {
expect(artifacts.headers).toContain("default-src 'self'");
expect(artifacts.headers).toContain("script-src 'self' 'sha256-");
expect(artifacts.headers).toContain("connect-src 'none'");
expect(artifacts.headers).toContain("connect-src 'self'");
expect(artifacts.headers).toContain("frame-ancestors 'none'");
expect(artifacts.headers).toContain('X-Frame-Options: DENY');
expect(artifacts.headers).toContain('X-Content-Type-Options: nosniff');

View File

@@ -28,7 +28,7 @@ describe('static-host security policy', () => {
"img-src 'self' data:",
"media-src 'self'",
"font-src 'self'",
"connect-src 'none'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
MAIN_PHRASE_BARS,
MUSIC_BAR_SECONDS,
INTRO_IN_END_SECONDS,
SOUNDTRACK_SAMPLE_RATE,
mainPhraseOffsetSeconds,
nextSafeBarTime,
resolveInitialPlayback,
} from '@/audio/soundtrack-transport';
describe('soundtrack transport decisions', () => {
it('starts at the intro only on Start and otherwise chooses the matching main phrase', () => {
expect(resolveInitialPlayback({
atStart: true,
bias: 'open',
entryPhrase: 0,
intensity: 0,
})).toEqual({ kind: 'intro' });
expect(resolveInitialPlayback({
atStart: false,
bias: 'shadow',
entryPhrase: 3,
intensity: 0.5,
})).toEqual({ kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(3) });
expect(mainPhraseOffsetSeconds(Number.NaN)).toBe(0);
const finalPhraseSample = Math.round(
7 * MAIN_PHRASE_BARS * MUSIC_BAR_SECONDS * SOUNDTRACK_SAMPLE_RATE,
);
expect(mainPhraseOffsetSeconds(99)).toBe(finalPhraseSample / SOUNDTRACK_SAMPLE_RATE);
});
it('coalesces an exit onto the next bar with enough time for a clean crossfade', () => {
const anchorTime = 1;
const now = anchorTime + MUSIC_BAR_SECONDS * 2 + 0.1;
const transitionTime = nextSafeBarTime(now, anchorTime, 0.25);
expect(transitionTime).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 3, 8);
expect(transitionTime).toBeGreaterThanOrEqual(now + 0.25);
expect(nextSafeBarTime(anchorTime, anchorTime, 0)).toBe(anchorTime);
const earliestIntroExit = nextSafeBarTime(
anchorTime + INTRO_IN_END_SECONDS - 0.35,
anchorTime,
0.35,
);
expect(earliestIntroExit).toBeCloseTo(anchorTime + MUSIC_BAR_SECONDS * 2, 8);
});
});