feat: integrate authored nightdrive soundtrack
This commit is contained in:
@@ -7,6 +7,8 @@ test.beforeEach(async ({ page }) => {
|
||||
0;
|
||||
(window as unknown as { __audioContexts: AudioContext[] }).__audioContexts =
|
||||
[];
|
||||
(window as unknown as { __audioResumeCalls: number }).__audioResumeCalls = 0;
|
||||
(window as unknown as { __audioSuspendCalls: number }).__audioSuspendCalls = 0;
|
||||
if (!OriginalAudioContext) return;
|
||||
class TrackingAudioContext extends OriginalAudioContext {
|
||||
constructor(options?: AudioContextOptions) {
|
||||
@@ -18,6 +20,14 @@ test.beforeEach(async ({ page }) => {
|
||||
state.__audioConstructed += 1;
|
||||
state.__audioContexts.push(this);
|
||||
}
|
||||
override resume(): Promise<void> {
|
||||
(window as unknown as { __audioResumeCalls: number }).__audioResumeCalls += 1;
|
||||
return super.resume();
|
||||
}
|
||||
override suspend(): Promise<void> {
|
||||
(window as unknown as { __audioSuspendCalls: number }).__audioSuspendCalls += 1;
|
||||
return super.suspend();
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "AudioContext", {
|
||||
configurable: true,
|
||||
@@ -238,6 +248,35 @@ test("requires a direct visitor action after browser interruption without creati
|
||||
.toBe(1);
|
||||
});
|
||||
|
||||
test("automatically suspends and resumes the same graph across ordinary visibility changes", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "Play soundtrack" }).click();
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible();
|
||||
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => (
|
||||
window as unknown as { __audioSuspendCalls: number }
|
||||
).__audioSuspendCalls)).toBe(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await expect.poll(() => page.evaluate(() => (
|
||||
window as unknown as { __audioResumeCalls: number }
|
||||
).__audioResumeCalls)).toBe(2);
|
||||
expect(await page.evaluate(() => (
|
||||
window as unknown as { __audioConstructed: number }
|
||||
).__audioConstructed)).toBe(1);
|
||||
await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows a concise non-blocking message when initial audio start is rejected", async ({
|
||||
page,
|
||||
distUrl,
|
||||
|
||||
@@ -32,7 +32,7 @@ async function fixtureFetch() {
|
||||
}
|
||||
const base = {
|
||||
'x-portfolio-manifest-digest': digest,
|
||||
'content-security-policy': "default-src 'self'; script-src 'self' 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; 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' 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; 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",
|
||||
'strict-transport-security': 'max-age=31536000',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'referrer-policy': 'no-referrer',
|
||||
|
||||
@@ -17,6 +17,8 @@ const contentTypes: Readonly<Record<string, string>> = {
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mp3": "audio/mpeg",
|
||||
".opus": "audio/ogg",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
|
||||
@@ -57,6 +57,36 @@ describe('approved asset publication closure', () => {
|
||||
await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('includes every soundtrack codec variant in the approved publication closure', async () => {
|
||||
const root = await createScratchRepository();
|
||||
const assets = [
|
||||
['soundtrack-intro-opus', 'nightdrive-intro.opus'],
|
||||
['soundtrack-intro-mp3', 'nightdrive-intro.mp3'],
|
||||
['soundtrack-main-opus', 'nightdrive-main.opus'],
|
||||
['soundtrack-main-mp3', 'nightdrive-main.mp3'],
|
||||
].map(([id, fileName], index) => {
|
||||
const bytes = Buffer.from(`approved soundtrack asset ${index}`);
|
||||
return { id, fileName, bytes, sha256: createHash('sha256').update(bytes).digest('hex') };
|
||||
});
|
||||
await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({
|
||||
assets: assets.map(({ id, fileName, sha256 }) => ({ id, fileName, sha256 })),
|
||||
}));
|
||||
await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({
|
||||
projects: { selected: [], additional: [] },
|
||||
toolkit: [],
|
||||
metadata: {},
|
||||
soundtrack: {
|
||||
intro: { opusAssetId: 'soundtrack-intro-opus', mp3AssetId: 'soundtrack-intro-mp3' },
|
||||
main: { opusAssetId: 'soundtrack-main-opus', mp3AssetId: 'soundtrack-main-mp3' },
|
||||
},
|
||||
}));
|
||||
await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true });
|
||||
await Promise.all(assets.map(({ fileName, bytes }) =>
|
||||
writeFile(join(root, 'inputs/prepared-assets', fileName), bytes)));
|
||||
|
||||
await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a symbolic-link prepared-asset directory', async () => {
|
||||
const root = await createScratchRepository();
|
||||
const target = join(root, 'outside');
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('generated Nginx static-host contract', () => {
|
||||
|
||||
expect(config).toContain("default-src 'self'; script-src 'self' 'sha256-");
|
||||
expect(config).toContain("img-src 'self' data:");
|
||||
expect(config).toContain("connect-src 'none'");
|
||||
expect(config).toContain("connect-src 'self'");
|
||||
expect(config).toContain('add_header Strict-Transport-Security "max-age=31536000" always;');
|
||||
expect(config).toContain(`add_header X-Portfolio-Manifest-Digest "${releaseManifestDigest}" always;`);
|
||||
expect(config).toContain('gzip on;');
|
||||
|
||||
@@ -42,6 +42,12 @@ describe('approved public portfolio projection', () => {
|
||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||
technology.assetId === undefined ? [] : [technology.assetId])),
|
||||
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
||||
...(content.soundtrack === undefined ? [] : [
|
||||
content.soundtrack.intro.opusAssetId,
|
||||
content.soundtrack.intro.mp3AssetId,
|
||||
content.soundtrack.main.opusAssetId,
|
||||
content.soundtrack.main.mp3AssetId,
|
||||
]),
|
||||
];
|
||||
expect([...publishedReferences].sort()).toEqual(manifest.assets.map((asset) => asset.id).sort());
|
||||
expect(site).toEqual({
|
||||
|
||||
@@ -57,6 +57,16 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
||||
await mkdir(join(directory, 'assets'));
|
||||
const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs);
|
||||
const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site);
|
||||
const publicAssets = inputs.manifest.assets
|
||||
.map((asset) => ({
|
||||
path: `assets/${asset.fileName}`,
|
||||
bytesPath: resolve(repositoryRoot, 'inputs/prepared-assets', asset.fileName),
|
||||
}))
|
||||
.sort((left, right) => left.path.localeCompare(right.path));
|
||||
const publicAssetBytes = await Promise.all(publicAssets.map(async (asset) => ({
|
||||
...asset,
|
||||
bytes: await readFile(asset.bytesPath),
|
||||
})));
|
||||
const contract = {
|
||||
schema: 'portfolio-artifact-contract/v1',
|
||||
buildId: inputs.site.buildId,
|
||||
@@ -65,10 +75,14 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
||||
modulePreloads: ['assets/index-test.js'],
|
||||
styles: ['assets/index-test.css'],
|
||||
viteAssets: [],
|
||||
publicAssets: [],
|
||||
publicAssets: publicAssets.map((asset) => asset.path),
|
||||
files: [
|
||||
{ path: 'assets/index-test.css', sha256: sha256(clientStylesheet) },
|
||||
{ path: 'assets/index-test.js', sha256: sha256(clientJavaScript) },
|
||||
...publicAssetBytes.map((asset) => ({
|
||||
path: asset.path,
|
||||
sha256: createHash('sha256').update(asset.bytes).digest('hex'),
|
||||
})),
|
||||
],
|
||||
} as const;
|
||||
await Promise.all([
|
||||
@@ -78,6 +92,7 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
||||
writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects),
|
||||
writeFile(join(directory, 'assets/index-test.js'), clientJavaScript),
|
||||
writeFile(join(directory, 'assets/index-test.css'), clientStylesheet),
|
||||
...publicAssetBytes.map((asset) => writeFile(join(directory, asset.path), asset.bytes)),
|
||||
...(hostArtifacts.sitemap === undefined
|
||||
? []
|
||||
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
|
||||
|
||||
@@ -7,11 +7,18 @@ import {
|
||||
evaluateRuntimeTraceBudgetEvidence,
|
||||
validateRuntimeBudgetTraceReport,
|
||||
calculatePeakRequestConcurrency,
|
||||
normalizeDetectedMediaType,
|
||||
PERFORMANCE_BUDGETS,
|
||||
resolveBudgetGateMode,
|
||||
} from '../../scripts/lib/bundle-budgets.mjs';
|
||||
|
||||
describe('release budgets', () => {
|
||||
it('normalizes codec-qualified Opus detection to its manifest container type', () => {
|
||||
expect(normalizeDetectedMediaType('audio/ogg; codecs=opus')).toBe('audio/ogg');
|
||||
expect(normalizeDetectedMediaType('audio/mpeg')).toBe('audio/mpeg');
|
||||
expect(normalizeDetectedMediaType(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('fails closed unless the caller explicitly selects a runtime trace or transfer-only mode', () => {
|
||||
expect(() => resolveBudgetGateMode({ transferOnly: false, runtimeTracePath: null }))
|
||||
.toThrow(/runtime trace is required/u);
|
||||
@@ -39,6 +46,7 @@ describe('release budgets', () => {
|
||||
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
||||
highTierExtras: 750 * 1024,
|
||||
deferredNonVideo: 3.5 * 1024 * 1024,
|
||||
deferredAudio: 13 * 1024 * 1024,
|
||||
imageSceneConcurrency: 2,
|
||||
videoConcurrency: 1,
|
||||
mobileTextureDimension: 2048,
|
||||
@@ -53,6 +61,8 @@ describe('release budgets', () => {
|
||||
expect(classifyArtifact('assets/index-a.js', 'import("./environment-b.js")')).toBe('initial-application-js');
|
||||
expect(classifyArtifact('assets/environment-b.js', 'three scene')).toBe('first-live-scene');
|
||||
expect(classifyArtifact('assets/nightdrive-grand-tourer-a.glb', '')).toBe('first-live-scene');
|
||||
expect(classifyArtifact('assets/nightdrive-main.opus', '')).toBe('soundtrack-audio');
|
||||
expect(classifyArtifact('assets/nightdrive-intro.mp3', '')).toBe('soundtrack-audio');
|
||||
expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
|
||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
||||
});
|
||||
@@ -108,6 +118,24 @@ describe('release budgets', () => {
|
||||
]));
|
||||
});
|
||||
|
||||
it('validates soundtrack duration without visual dimensions and budgets aggregate transfer', () => {
|
||||
const asset = {
|
||||
id: 'soundtrack-main-opus',
|
||||
mediaType: 'audio/ogg',
|
||||
width: null,
|
||||
height: null,
|
||||
durationMs: 178_605,
|
||||
};
|
||||
|
||||
expect(evaluateMediaAssetBudget(asset, { ...asset }, 4 * 1024 * 1024, 'soundtrack-audio').violations).toEqual([]);
|
||||
expect(() => evaluateMediaAssetBudget({ ...asset, width: 1 }, { ...asset, width: 1 }, 1_024, 'soundtrack-audio'))
|
||||
.toThrow(/audio dimensions/i);
|
||||
expect(() => evaluateMediaAssetBudget({ ...asset, durationMs: null }, { ...asset, durationMs: null }, 1_024, 'soundtrack-audio'))
|
||||
.toThrow(/verified duration/i);
|
||||
expect(evaluateBudgetTotals({ deferredAudio: PERFORMANCE_BUDGETS.deferredAudio! + 1 }).violations)
|
||||
.toContainEqual(expect.objectContaining({ budget: 'deferredAudio' }));
|
||||
});
|
||||
|
||||
it('derives decoded texture and GPU bytes from allocation formulas', () => {
|
||||
const result = evaluateRuntimeBudgetEvidence({
|
||||
schema: 'portfolio-runtime-budget-evidence/v1',
|
||||
|
||||
@@ -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'; }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'",
|
||||
|
||||
51
tests/unit/soundtrack-transport.test.ts
Normal file
51
tests/unit/soundtrack-transport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user