feat: integrate authored nightdrive soundtrack
This commit is contained in:
@@ -1,3 +1,52 @@
|
|||||||
{
|
{
|
||||||
"assets": []
|
"assets": [
|
||||||
|
{
|
||||||
|
"id": "soundtrack-intro-opus",
|
||||||
|
"fileName": "nightdrive-intro.opus",
|
||||||
|
"sha256": "561c05cea71d0920ec323375644039007b91b0a4c346dc663e233ad42d6ab490",
|
||||||
|
"mediaType": "audio/ogg",
|
||||||
|
"width": null,
|
||||||
|
"height": null,
|
||||||
|
"durationMs": 56064,
|
||||||
|
"rightsHolder": "Luca Pîslaru",
|
||||||
|
"license": "All rights reserved",
|
||||||
|
"attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soundtrack-intro-mp3",
|
||||||
|
"fileName": "nightdrive-intro.mp3",
|
||||||
|
"sha256": "008794ebd7f545c32f19aab4ada6aba40d9fc3341830a66e77f21d87a41c009b",
|
||||||
|
"mediaType": "audio/mpeg",
|
||||||
|
"width": null,
|
||||||
|
"height": null,
|
||||||
|
"durationMs": 56064,
|
||||||
|
"rightsHolder": "Luca Pîslaru",
|
||||||
|
"license": "All rights reserved",
|
||||||
|
"attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soundtrack-main-opus",
|
||||||
|
"fileName": "nightdrive-main.opus",
|
||||||
|
"sha256": "81933fc8e589428ef07fb265572a79975f183848b6a23616cb289e33fe3fb88c",
|
||||||
|
"mediaType": "audio/ogg",
|
||||||
|
"width": null,
|
||||||
|
"height": null,
|
||||||
|
"durationMs": 178605,
|
||||||
|
"rightsHolder": "Luca Pîslaru",
|
||||||
|
"license": "All rights reserved",
|
||||||
|
"attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "soundtrack-main-mp3",
|
||||||
|
"fileName": "nightdrive-main.mp3",
|
||||||
|
"sha256": "796de8247c4cb8012d7c6883b534112ac09a40255a2c33cd68acf0f87c066659",
|
||||||
|
"mediaType": "audio/mpeg",
|
||||||
|
"width": null,
|
||||||
|
"height": null,
|
||||||
|
"durationMs": 178605,
|
||||||
|
"rightsHolder": "Luca Pîslaru",
|
||||||
|
"license": "All rights reserved",
|
||||||
|
"attribution": "Original AI-assisted soundtrack produced by Luca Pîslaru with ACE-Step 1.5"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -245,5 +245,15 @@
|
|||||||
"title": "Luca Pîslaru — System Administrator & Full-Stack Developer",
|
"title": "Luca Pîslaru — System Administrator & Full-Stack Developer",
|
||||||
"description": "Portfolio of Luca Pîslaru, a System Administrator & Full-Stack Developer building and operating reliable systems and software."
|
"description": "Portfolio of Luca Pîslaru, a System Administrator & Full-Stack Developer building and operating reliable systems and software."
|
||||||
},
|
},
|
||||||
|
"soundtrack": {
|
||||||
|
"intro": {
|
||||||
|
"opusAssetId": "soundtrack-intro-opus",
|
||||||
|
"mp3AssetId": "soundtrack-intro-mp3"
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"opusAssetId": "soundtrack-main-opus",
|
||||||
|
"mp3AssetId": "soundtrack-main-mp3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sceneDescription": "A restrained retro-futuristic night drive supports the reading journey while the portfolio content remains fully available as a semantic document."
|
"sceneDescription": "A restrained retro-futuristic night drive supports the reading journey while the portfolio content remains fully available as a semantic document."
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
inputs/prepared-assets/nightdrive-intro.mp3
Normal file
BIN
inputs/prepared-assets/nightdrive-intro.mp3
Normal file
Binary file not shown.
BIN
inputs/prepared-assets/nightdrive-intro.opus
Normal file
BIN
inputs/prepared-assets/nightdrive-intro.opus
Normal file
Binary file not shown.
BIN
inputs/prepared-assets/nightdrive-main.mp3
Normal file
BIN
inputs/prepared-assets/nightdrive-main.mp3
Normal file
Binary file not shown.
BIN
inputs/prepared-assets/nightdrive-main.opus
Normal file
BIN
inputs/prepared-assets/nightdrive-main.opus
Normal file
Binary file not shown.
@@ -30,7 +30,7 @@ server {
|
|||||||
gzip_min_length 1;
|
gzip_min_length 1;
|
||||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' '__JSON_LD_CSP_HASH__'; 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" always;
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' '__JSON_LD_CSP_HASH__'; 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" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always;
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { readFile, readdir, stat } from 'node:fs/promises';
|
|||||||
import { relative, resolve, sep } from 'node:path';
|
import { relative, resolve, sep } from 'node:path';
|
||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { fileTypeFromBuffer } from 'file-type';
|
import { fileTypeFromBuffer } from 'file-type';
|
||||||
import { probeVideo } from './lib/video-frames.mjs';
|
import { probeVideo, runMediaCommand } from './lib/video-frames.mjs';
|
||||||
import {
|
import {
|
||||||
classifyArtifact,
|
classifyArtifact,
|
||||||
evaluateBudgetTotals,
|
evaluateBudgetTotals,
|
||||||
evaluateMediaAssetBudget,
|
evaluateMediaAssetBudget,
|
||||||
|
normalizeDetectedMediaType,
|
||||||
resolveBudgetGateMode,
|
resolveBudgetGateMode,
|
||||||
validateRuntimeBudgetTraceReport,
|
validateRuntimeBudgetTraceReport,
|
||||||
} from './lib/bundle-budgets.mjs';
|
} from './lib/bundle-budgets.mjs';
|
||||||
@@ -34,7 +35,7 @@ const listFiles = async (directory) => {
|
|||||||
return files;
|
return files;
|
||||||
};
|
};
|
||||||
|
|
||||||
const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp4|png|webm|webp|woff2?)$/iu.test(path)
|
const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp3|mp4|opus|png|webm|webp|woff2?)$/iu.test(path)
|
||||||
? bytes.byteLength
|
? bytes.byteLength
|
||||||
: brotliCompressSync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 } }).byteLength);
|
: brotliCompressSync(bytes, { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 } }).byteLength);
|
||||||
|
|
||||||
@@ -42,6 +43,21 @@ const readJson = async (path, label) => {
|
|||||||
try { return JSON.parse(await readFile(path, 'utf8')); } catch { throw new Error(`${label} must be valid JSON.`); }
|
try { return JSON.parse(await readFile(path, 'utf8')); } catch { throw new Error(`${label} must be valid JSON.`); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const probeAudio = async (path) => {
|
||||||
|
const { stdout } = await runMediaCommand('ffprobe', [
|
||||||
|
'-v', 'error', '-show_entries', 'format=duration:stream=codec_type', '-of', 'json', path,
|
||||||
|
], 'ASSET-AUDIO-PROBE-001', 20_000);
|
||||||
|
let result;
|
||||||
|
try { result = JSON.parse(stdout); } catch { throw new Error('ASSET-AUDIO-PROBE-001'); }
|
||||||
|
const streams = Array.isArray(result.streams) ? result.streams : [];
|
||||||
|
const duration = Number(result.format?.duration);
|
||||||
|
if (streams.length !== 1 || streams[0]?.codec_type !== 'audio'
|
||||||
|
|| !Number.isFinite(duration) || duration <= 0 || duration > 300) {
|
||||||
|
throw new Error('ASSET-AUDIO-PROBE-001');
|
||||||
|
}
|
||||||
|
return { duration };
|
||||||
|
};
|
||||||
|
|
||||||
const manifest = await readJson(resolve('content/public/asset-manifest.json'), 'Public asset manifest');
|
const manifest = await readJson(resolve('content/public/asset-manifest.json'), 'Public asset manifest');
|
||||||
const content = await readJson(resolve('content/public/portfolio-content.json'), 'Portfolio content');
|
const content = await readJson(resolve('content/public/portfolio-content.json'), 'Portfolio content');
|
||||||
if (!Array.isArray(manifest?.assets)) throw new Error('Public asset manifest is invalid.');
|
if (!Array.isArray(manifest?.assets)) throw new Error('Public asset manifest is invalid.');
|
||||||
@@ -59,6 +75,12 @@ for (const group of content.toolkit ?? []) for (const technology of group.techno
|
|||||||
if (technology.assetId) assetClassById.set(technology.assetId, 'project-still');
|
if (technology.assetId) assetClassById.set(technology.assetId, 'project-still');
|
||||||
}
|
}
|
||||||
if (content.metadata?.socialPreviewAssetId) assetClassById.set(content.metadata.socialPreviewAssetId, 'project-still');
|
if (content.metadata?.socialPreviewAssetId) assetClassById.set(content.metadata.socialPreviewAssetId, 'project-still');
|
||||||
|
if (content.soundtrack) {
|
||||||
|
for (const region of [content.soundtrack.intro, content.soundtrack.main]) {
|
||||||
|
if (region?.opusAssetId) assetClassById.set(region.opusAssetId, 'soundtrack-audio');
|
||||||
|
if (region?.mp3AssetId) assetClassById.set(region.mp3AssetId, 'soundtrack-audio');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const manifestByPath = new Map(manifest.assets.map((asset) => [`assets/${asset.fileName}`, asset]));
|
const manifestByPath = new Map(manifest.assets.map((asset) => [`assets/${asset.fileName}`, asset]));
|
||||||
const records = [];
|
const records = [];
|
||||||
@@ -74,7 +96,8 @@ for (const absolute of await listFiles(root)) {
|
|||||||
if (digest !== manifestAsset.sha256) throw new Error(`Published asset hash mismatch: ${path}`);
|
if (digest !== manifestAsset.sha256) throw new Error(`Published asset hash mismatch: ${path}`);
|
||||||
const mediaType = manifestAsset.mediaType;
|
const mediaType = manifestAsset.mediaType;
|
||||||
const detected = await fileTypeFromBuffer(bytes);
|
const detected = await fileTypeFromBuffer(bytes);
|
||||||
const detectedMediaType = detected?.mime ?? (/^\s*<svg[\s>]/u.test(bytes.toString('utf8', 0, Math.min(bytes.length, 256))) ? 'image/svg+xml' : null);
|
const detectedMediaType = normalizeDetectedMediaType(detected?.mime)
|
||||||
|
?? (/^\s*<svg[\s>]/u.test(bytes.toString('utf8', 0, Math.min(bytes.length, 256))) ? 'image/svg+xml' : null);
|
||||||
if (detectedMediaType !== mediaType) throw new Error(`Manifest media type disagrees with deterministic probing: ${manifestAsset.id}`);
|
if (detectedMediaType !== mediaType) throw new Error(`Manifest media type disagrees with deterministic probing: ${manifestAsset.id}`);
|
||||||
let probe;
|
let probe;
|
||||||
if (mediaType?.startsWith('video/')) {
|
if (mediaType?.startsWith('video/')) {
|
||||||
@@ -85,6 +108,10 @@ for (const absolute of await listFiles(root)) {
|
|||||||
const image = await sharp(absolute, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata();
|
const image = await sharp(absolute, { failOn: 'error', limitInputPixels: 50_000_000 }).metadata();
|
||||||
probe = { mediaType, width: image.width, height: image.height, durationMs: null };
|
probe = { mediaType, width: image.width, height: image.height, durationMs: null };
|
||||||
bucket = assetClassById.get(manifestAsset.id) === 'chart' ? 'chart' : 'project-still';
|
bucket = assetClassById.get(manifestAsset.id) === 'chart' ? 'chart' : 'project-still';
|
||||||
|
} else if (mediaType?.startsWith('audio/')) {
|
||||||
|
const audio = await probeAudio(absolute);
|
||||||
|
probe = { mediaType, width: null, height: null, durationMs: Math.round(audio.duration * 1_000) };
|
||||||
|
bucket = 'soundtrack-audio';
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Manifest asset has unsupported media type: ${manifestAsset.id}`);
|
throw new Error(`Manifest asset has unsupported media type: ${manifestAsset.id}`);
|
||||||
}
|
}
|
||||||
@@ -92,11 +119,11 @@ for (const absolute of await listFiles(root)) {
|
|||||||
manifestAsset,
|
manifestAsset,
|
||||||
probe,
|
probe,
|
||||||
compressedSize(path, bytes),
|
compressedSize(path, bytes),
|
||||||
bucket === 'chart' ? 'chart' : 'project-still',
|
bucket === 'chart' ? 'chart' : bucket === 'soundtrack-audio' ? 'soundtrack-audio' : 'project-still',
|
||||||
).violations);
|
).violations);
|
||||||
} else {
|
} else {
|
||||||
bucket = classifyArtifact(path, source);
|
bucket = classifyArtifact(path, source);
|
||||||
if (['project-video', 'project-still', 'chart'].includes(bucket)) {
|
if (['project-video', 'project-still', 'chart', 'soundtrack-audio'].includes(bucket)) {
|
||||||
throw new Error(`Project media must be present in the public asset manifest: ${path}`);
|
throw new Error(`Project media must be present in the public asset manifest: ${path}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,6 +141,7 @@ const totals = {
|
|||||||
deferredProjectVisuals: sum('project-still') + sum('chart'),
|
deferredProjectVisuals: sum('project-still') + sum('chart'),
|
||||||
highTierExtras: sum('high-tier-extras'),
|
highTierExtras: sum('high-tier-extras'),
|
||||||
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart'),
|
deferredNonVideo: sum('first-live-scene') + sum('high-tier-extras') + sum('project-still') + sum('chart'),
|
||||||
|
deferredAudio: sum('soundtrack-audio'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const entry = records.find((record) => record.bucket === 'initial-application-js');
|
const entry = records.find((record) => record.bucket === 'initial-application-js');
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const PERFORMANCE_BUDGETS: Readonly<Record<string, number>>;
|
export const PERFORMANCE_BUDGETS: Readonly<Record<string, number>>;
|
||||||
|
export function normalizeDetectedMediaType(mediaType: string | null | undefined): string | null;
|
||||||
export function resolveBudgetGateMode(options: {
|
export function resolveBudgetGateMode(options: {
|
||||||
transferOnly: boolean;
|
transferOnly: boolean;
|
||||||
runtimeTracePath: string | null;
|
runtimeTracePath: string | null;
|
||||||
@@ -11,7 +12,7 @@ export function evaluateMediaAssetBudget(
|
|||||||
asset: Readonly<Record<string, unknown>>,
|
asset: Readonly<Record<string, unknown>>,
|
||||||
probe: Readonly<Record<string, unknown>> | null,
|
probe: Readonly<Record<string, unknown>> | null,
|
||||||
transferBytes: number,
|
transferBytes: number,
|
||||||
assetClass?: 'chart' | 'project-still',
|
assetClass?: 'chart' | 'project-still' | 'soundtrack-audio',
|
||||||
): { violations: Array<{ budget: string; actual: number; threshold: number; id?: string }> };
|
): { violations: Array<{ budget: string; actual: number; threshold: number; id?: string }> };
|
||||||
export function evaluateRuntimeBudgetEvidence(evidence: Readonly<Record<string, unknown>>): {
|
export function evaluateRuntimeBudgetEvidence(evidence: Readonly<Record<string, unknown>>): {
|
||||||
tiers: Array<{ tier: string; textureBytes: number; geometryBytes: number; renderTargetBytes: number; runtimeAllowanceBytes: number; totalBytes: number }>;
|
tiers: Array<{ tier: string; textureBytes: number; geometryBytes: number; renderTargetBytes: number; runtimeAllowanceBytes: number; totalBytes: number }>;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const PERFORMANCE_BUDGETS = Object.freeze({
|
|||||||
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
||||||
highTierExtras: 750 * 1024,
|
highTierExtras: 750 * 1024,
|
||||||
deferredNonVideo: 3.5 * 1024 * 1024,
|
deferredNonVideo: 3.5 * 1024 * 1024,
|
||||||
|
deferredAudio: 13 * 1024 * 1024,
|
||||||
imageSceneConcurrency: 2,
|
imageSceneConcurrency: 2,
|
||||||
videoConcurrency: 1,
|
videoConcurrency: 1,
|
||||||
mobileTextureDimension: 2048,
|
mobileTextureDimension: 2048,
|
||||||
@@ -20,6 +21,10 @@ export const PERFORMANCE_BUDGETS = Object.freeze({
|
|||||||
highTierGpuEstimate: 192 * 1024 * 1024,
|
highTierGpuEstimate: 192 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const normalizeDetectedMediaType = (mediaType) => (
|
||||||
|
typeof mediaType === 'string' ? mediaType.split(';', 1)[0].trim() : null
|
||||||
|
);
|
||||||
|
|
||||||
export const resolveBudgetGateMode = ({ transferOnly, runtimeTracePath }) => {
|
export const resolveBudgetGateMode = ({ transferOnly, runtimeTracePath }) => {
|
||||||
if (transferOnly && runtimeTracePath !== null) throw new Error('Transfer-only mode cannot be combined with runtime evidence.');
|
if (transferOnly && runtimeTracePath !== null) throw new Error('Transfer-only mode cannot be combined with runtime evidence.');
|
||||||
if (transferOnly) return { kind: 'transfer-only' };
|
if (transferOnly) return { kind: 'transfer-only' };
|
||||||
@@ -39,6 +44,7 @@ export const classifyArtifact = (path, source = '') => {
|
|||||||
if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video';
|
if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video';
|
||||||
if (/^assets\/project-[^/]*chart[^/]*\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'chart';
|
if (/^assets\/project-[^/]*chart[^/]*\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'chart';
|
||||||
if (/^assets\/project-[^/]+\.(?:avif|webp|png|jpe?g|svg)$/u.test(path)) return 'project-still';
|
if (/^assets\/project-[^/]+\.(?:avif|webp|png|jpe?g|svg)$/u.test(path)) return 'project-still';
|
||||||
|
if (/^assets\/nightdrive-(?:intro|main)\.(?:opus|mp3)$/u.test(path)) return 'soundtrack-audio';
|
||||||
if (/^(?:robots\.txt|sitemap\.xml|_headers\.example|_redirects\.example)$/u.test(path)) return 'release-metadata';
|
if (/^(?:robots\.txt|sitemap\.xml|_headers\.example|_redirects\.example)$/u.test(path)) return 'release-metadata';
|
||||||
throw new Error(`Artifact is unclassified: ${path}`);
|
throw new Error(`Artifact is unclassified: ${path}`);
|
||||||
};
|
};
|
||||||
@@ -51,6 +57,7 @@ const thresholdByTotal = Object.freeze({
|
|||||||
firstLiveScene: PERFORMANCE_BUDGETS.firstLiveScene,
|
firstLiveScene: PERFORMANCE_BUDGETS.firstLiveScene,
|
||||||
deferredProjectVisuals: PERFORMANCE_BUDGETS.deferredProjectVisuals,
|
deferredProjectVisuals: PERFORMANCE_BUDGETS.deferredProjectVisuals,
|
||||||
deferredNonVideo: PERFORMANCE_BUDGETS.deferredNonVideo,
|
deferredNonVideo: PERFORMANCE_BUDGETS.deferredNonVideo,
|
||||||
|
deferredAudio: PERFORMANCE_BUDGETS.deferredAudio,
|
||||||
highTierExtras: PERFORMANCE_BUDGETS.highTierExtras,
|
highTierExtras: PERFORMANCE_BUDGETS.highTierExtras,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,13 +70,34 @@ export const evaluateBudgetTotals = (totals) => ({
|
|||||||
const violation = (budget, actual, threshold, id) => ({ budget, actual, threshold, ...(id === undefined ? {} : { id }) });
|
const violation = (budget, actual, threshold, id) => ({ budget, actual, threshold, ...(id === undefined ? {} : { id }) });
|
||||||
const finitePositiveInteger = (value) => Number.isInteger(value) && value > 0;
|
const finitePositiveInteger = (value) => Number.isInteger(value) && value > 0;
|
||||||
|
|
||||||
export const evaluateMediaAssetBudget = (asset, probe, transferBytes, assetClass = undefined) => {
|
export const evaluateMediaAssetBudget = (
|
||||||
|
asset,
|
||||||
|
probe,
|
||||||
|
transferBytes,
|
||||||
|
assetClass = /** @type {'chart' | 'project-still' | 'soundtrack-audio' | undefined} */ (undefined),
|
||||||
|
) => {
|
||||||
if (probe === null || typeof probe !== 'object') throw new Error(`Media probe evidence is required for ${asset?.id ?? 'unknown asset'}.`);
|
if (probe === null || typeof probe !== 'object') throw new Error(`Media probe evidence is required for ${asset?.id ?? 'unknown asset'}.`);
|
||||||
|
if (assetClass === 'soundtrack-audio' && !asset?.mediaType?.startsWith('audio/')) {
|
||||||
|
throw new Error(`Soundtrack classification requires audio media for ${asset?.id ?? 'unknown asset'}.`);
|
||||||
|
}
|
||||||
|
if (asset?.mediaType !== probe.mediaType) {
|
||||||
|
throw new Error(`Manifest and probe evidence disagree for ${asset?.id ?? 'unknown asset'}.`);
|
||||||
|
}
|
||||||
|
if (asset.mediaType.startsWith('audio/')) {
|
||||||
|
if (asset.width !== null || asset.height !== null || probe.width !== null || probe.height !== null) {
|
||||||
|
throw new Error(`Audio dimensions must be null for ${asset.id}.`);
|
||||||
|
}
|
||||||
|
if (!finitePositiveInteger(asset.durationMs) || !finitePositiveInteger(probe.durationMs)
|
||||||
|
|| Math.abs(asset.durationMs - probe.durationMs) > 100) {
|
||||||
|
throw new Error(`Verified duration is required for ${asset.id}.`);
|
||||||
|
}
|
||||||
|
return { violations: [] };
|
||||||
|
}
|
||||||
if (!finitePositiveInteger(asset?.width) || !finitePositiveInteger(asset?.height)
|
if (!finitePositiveInteger(asset?.width) || !finitePositiveInteger(asset?.height)
|
||||||
|| !finitePositiveInteger(probe.width) || !finitePositiveInteger(probe.height)) {
|
|| !finitePositiveInteger(probe.width) || !finitePositiveInteger(probe.height)) {
|
||||||
throw new Error(`Verified dimensions are required for ${asset?.id ?? 'unknown asset'}.`);
|
throw new Error(`Verified dimensions are required for ${asset?.id ?? 'unknown asset'}.`);
|
||||||
}
|
}
|
||||||
if (asset.width !== probe.width || asset.height !== probe.height || asset.mediaType !== probe.mediaType) {
|
if (asset.width !== probe.width || asset.height !== probe.height) {
|
||||||
throw new Error(`Manifest and probe evidence disagree for ${asset.id}.`);
|
throw new Error(`Manifest and probe evidence disagree for ${asset.id}.`);
|
||||||
}
|
}
|
||||||
const violations = [];
|
const violations = [];
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ const readManifest = async (repositoryRoot) => {
|
|||||||
}
|
}
|
||||||
if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${id}`);
|
if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${id}`);
|
||||||
ids.add(id);
|
ids.add(id);
|
||||||
if (typeof fileName !== 'string' || !/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/u.test(fileName)) {
|
if (typeof fileName !== 'string' || !/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm|opus|mp3)$/u.test(fileName)) {
|
||||||
throw new Error(`Manifest filename must be a generated safe filename: ${id}`);
|
throw new Error(`Manifest filename must be a generated safe filename: ${id}`);
|
||||||
}
|
}
|
||||||
const foldedFileName = fileName.toLocaleLowerCase('en-US');
|
const foldedFileName = fileName.toLocaleLowerCase('en-US');
|
||||||
@@ -128,6 +128,18 @@ const assetReferences = (content) => {
|
|||||||
throw new Error('Portfolio content must contain metadata.');
|
throw new Error('Portfolio content must contain metadata.');
|
||||||
}
|
}
|
||||||
if (content.metadata.socialPreviewAssetId !== undefined) references.push(content.metadata.socialPreviewAssetId);
|
if (content.metadata.socialPreviewAssetId !== undefined) references.push(content.metadata.socialPreviewAssetId);
|
||||||
|
if (content.soundtrack !== undefined) {
|
||||||
|
if (typeof content.soundtrack !== 'object' || content.soundtrack === null || Array.isArray(content.soundtrack)) {
|
||||||
|
throw new Error('Portfolio soundtrack must be an object.');
|
||||||
|
}
|
||||||
|
for (const regionName of ['intro', 'main']) {
|
||||||
|
const region = content.soundtrack[regionName];
|
||||||
|
if (typeof region !== 'object' || region === null || Array.isArray(region)) {
|
||||||
|
throw new Error(`Portfolio soundtrack ${regionName} must be an object.`);
|
||||||
|
}
|
||||||
|
references.push(region.opusAssetId, region.mp3AssetId);
|
||||||
|
}
|
||||||
|
}
|
||||||
return references;
|
return references;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function verifyNoJavaScriptDocument(html) {
|
|||||||
function verifySecurityHeaders(response, expectedDigest) {
|
function verifySecurityHeaders(response, expectedDigest) {
|
||||||
requireHeader(response, 'x-portfolio-manifest-digest', expectedDigest);
|
requireHeader(response, 'x-portfolio-manifest-digest', expectedDigest);
|
||||||
const csp = requireHeader(response, 'content-security-policy', /default-src 'self'/u);
|
const csp = requireHeader(response, 'content-security-policy', /default-src 'self'/u);
|
||||||
for (const directive of ["connect-src 'none'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", "form-action 'self'", 'upgrade-insecure-requests']) {
|
for (const directive of ["connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", "form-action 'self'", 'upgrade-insecure-requests']) {
|
||||||
if (!csp.includes(directive)) throw new Error(`Content-Security-Policy lacks ${directive}.`);
|
if (!csp.includes(directive)) throw new Error(`Content-Security-Policy lacks ${directive}.`);
|
||||||
}
|
}
|
||||||
if (/unsafe-inline|unsafe-eval|\*/u.test(csp)) throw new Error('Content-Security-Policy contains an unsafe source expression.');
|
if (/unsafe-inline|unsafe-eval|\*/u.test(csp)) throw new Error('Content-Security-Policy contains an unsafe source expression.');
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ const assertMetadata = (html, inputs) => {
|
|||||||
const assertSecurityHeaders = (headers, inputs, serializedJsonLd) => {
|
const assertSecurityHeaders = (headers, inputs, serializedJsonLd) => {
|
||||||
const required = [
|
const required = [
|
||||||
"default-src 'self'", "script-src 'self'", "style-src 'self'", "img-src 'self' data:", "media-src 'self'",
|
"default-src 'self'", "script-src 'self'", "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'",
|
"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',
|
"form-action 'self'", 'upgrade-insecure-requests', 'X-Content-Type-Options: nosniff',
|
||||||
'Referrer-Policy: no-referrer', 'X-Frame-Options: DENY',
|
'Referrer-Policy: no-referrer', 'X-Frame-Options: DENY',
|
||||||
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="portfolio-shell">
|
<div class="portfolio-shell">
|
||||||
<AppShell :content="inputs.content" />
|
<AppShell :content="inputs.content" :manifest="inputs.manifest" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import type {
|
|||||||
AudioController,
|
AudioController,
|
||||||
AudioControllerOptions,
|
AudioControllerOptions,
|
||||||
AudioGraph,
|
AudioGraph,
|
||||||
|
AudioJourneyState,
|
||||||
AudioUiState,
|
AudioUiState,
|
||||||
} from '@/audio/types';
|
} from '@/audio/types';
|
||||||
|
import { DEFAULT_AUDIO_JOURNEY_STATE } from '@/audio/journey-music-state';
|
||||||
|
|
||||||
type ActualState = AudioUiState['kind'];
|
type ActualState = AudioUiState['kind'];
|
||||||
type DesiredState = 'idle' | 'running' | 'suspended' | 'closed';
|
type DesiredState = 'idle' | 'running' | 'suspended' | 'closed';
|
||||||
type ResumeIntent = 'none' | 'automatic-after-visibility' | 'user-required';
|
type ResumeIntent = 'none' | 'automatic-after-visibility' | 'user-required';
|
||||||
|
|
||||||
const DEFAULT_VOLUME = 0.4;
|
const DEFAULT_VOLUME = 0.4;
|
||||||
const INTENSITY_SMOOTHING = 0.12;
|
|
||||||
|
|
||||||
export function createAudioController(options: AudioControllerOptions): AudioController {
|
export function createAudioController(options: AudioControllerOptions): AudioController {
|
||||||
let actual: ActualState = 'idle';
|
let actual: ActualState = 'idle';
|
||||||
@@ -20,7 +21,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
let visible = options.initiallyVisible ?? true;
|
let visible = options.initiallyVisible ?? true;
|
||||||
let muted = false;
|
let muted = false;
|
||||||
let volume = clamp(options.initialVolume ?? DEFAULT_VOLUME);
|
let volume = clamp(options.initialVolume ?? DEFAULT_VOLUME);
|
||||||
let intensity = 0;
|
let journeyState = normalizeJourneyState(options.initialJourneyState ?? DEFAULT_AUDIO_JOURNEY_STATE);
|
||||||
let graph: AudioGraph | null = null;
|
let graph: AudioGraph | null = null;
|
||||||
let graphClosed = false;
|
let graphClosed = false;
|
||||||
let removeStateListener = (): void => {};
|
let removeStateListener = (): void => {};
|
||||||
@@ -63,6 +64,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
const closeGraph = async (): Promise<void> => {
|
const closeGraph = async (): Promise<void> => {
|
||||||
if (!graph || graphClosed) return;
|
if (!graph || graphClosed) return;
|
||||||
graphClosed = true;
|
graphClosed = true;
|
||||||
|
graph.cancelPending();
|
||||||
removeStateListener();
|
removeStateListener();
|
||||||
removeStateListener = (): void => {};
|
removeStateListener = (): void => {};
|
||||||
try {
|
try {
|
||||||
@@ -94,7 +96,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
if (!graph || graphClosed) return;
|
if (!graph || graphClosed) return;
|
||||||
graph.connectOutput();
|
graph.connectOutput();
|
||||||
graph.setOutputGain(muted ? 0 : volume);
|
graph.setOutputGain(muted ? 0 : volume);
|
||||||
graph.setIntensity(intensity);
|
graph.setJourneyState(journeyState);
|
||||||
resumeIntent = 'none';
|
resumeIntent = 'none';
|
||||||
setActual('running');
|
setActual('running');
|
||||||
};
|
};
|
||||||
@@ -150,12 +152,12 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// This factory call intentionally remains synchronous in the activating event stack.
|
// This factory call intentionally remains synchronous in the activating event stack.
|
||||||
graph = options.createGraph();
|
graph = options.createGraph(journeyState);
|
||||||
removeStateListener = graph.onContextStateChange((state) => {
|
removeStateListener = graph.onContextStateChange((state) => {
|
||||||
if (state === 'interrupted') void noteInterruption();
|
if (state === 'interrupted') void noteInterruption();
|
||||||
});
|
});
|
||||||
graph.setOutputGain(0);
|
graph.setOutputGain(0);
|
||||||
graph.setIntensity(intensity);
|
graph.setJourneyState(journeyState);
|
||||||
} catch {
|
} catch {
|
||||||
startOperation = enqueue(fail);
|
startOperation = enqueue(fail);
|
||||||
return startOperation;
|
return startOperation;
|
||||||
@@ -243,6 +245,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
visible = false;
|
visible = false;
|
||||||
operationGeneration += 1;
|
operationGeneration += 1;
|
||||||
silence();
|
silence();
|
||||||
|
graph?.cancelPending();
|
||||||
setActual('disposed');
|
setActual('disposed');
|
||||||
void enqueue(closeGraph);
|
void enqueue(closeGraph);
|
||||||
};
|
};
|
||||||
@@ -262,11 +265,10 @@ export function createAudioController(options: AudioControllerOptions): AudioCon
|
|||||||
graph?.setOutputGain(muted ? 0 : volume);
|
graph?.setOutputGain(muted ? 0 : volume);
|
||||||
if (actual === 'running') publish();
|
if (actual === 'running') publish();
|
||||||
},
|
},
|
||||||
setIntensity(nextIntensity) {
|
setJourneyState(nextState) {
|
||||||
if (actual === 'disposed') return;
|
if (actual === 'disposed') return;
|
||||||
const target = clamp(nextIntensity);
|
journeyState = normalizeJourneyState(nextState);
|
||||||
intensity += (target - intensity) * INTENSITY_SMOOTHING;
|
graph?.setJourneyState(journeyState);
|
||||||
graph?.setIntensity(intensity);
|
|
||||||
},
|
},
|
||||||
setVisible,
|
setVisible,
|
||||||
noteInterruption,
|
noteInterruption,
|
||||||
@@ -287,3 +289,13 @@ function clamp(value: number): number {
|
|||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
return Math.min(1, Math.max(0, value));
|
return Math.min(1, Math.max(0, value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeJourneyState(state: AudioJourneyState): AudioJourneyState {
|
||||||
|
const entryPhrase = Number.isFinite(state.entryPhrase) ? Math.trunc(state.entryPhrase) : 0;
|
||||||
|
return Object.freeze({
|
||||||
|
atStart: state.atStart === true,
|
||||||
|
bias: state.bias,
|
||||||
|
entryPhrase: Math.min(7, Math.max(0, entryPhrase)),
|
||||||
|
intensity: clamp(state.intensity),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
26
src/audio/journey-music-state.ts
Normal file
26
src/audio/journey-music-state.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { AudioJourneyState } from '@/audio/types';
|
||||||
|
|
||||||
|
const JOURNEY_MUSIC = Object.freeze({
|
||||||
|
start: { bias: 'open', entryPhrase: 0 },
|
||||||
|
profile: { bias: 'open', entryPhrase: 1 },
|
||||||
|
experience: { bias: 'shadow', entryPhrase: 2 },
|
||||||
|
infrastructure: { bias: 'shadow', entryPhrase: 3 },
|
||||||
|
'selected-builds': { bias: 'lift', entryPhrase: 4 },
|
||||||
|
'additional-work': { bias: 'lift', entryPhrase: 5 },
|
||||||
|
toolkit: { bias: 'resolve', entryPhrase: 6 },
|
||||||
|
contact: { bias: 'resolve', entryPhrase: 7 },
|
||||||
|
} as const);
|
||||||
|
|
||||||
|
export function resolveJourneyMusicState(sectionId: string, intensity = 0): AudioJourneyState {
|
||||||
|
const chapter = Object.hasOwn(JOURNEY_MUSIC, sectionId)
|
||||||
|
? JOURNEY_MUSIC[sectionId as keyof typeof JOURNEY_MUSIC]
|
||||||
|
: JOURNEY_MUSIC.start;
|
||||||
|
const finiteIntensity = Number.isFinite(intensity) ? intensity : 0;
|
||||||
|
return Object.freeze({
|
||||||
|
...chapter,
|
||||||
|
atStart: sectionId === 'start',
|
||||||
|
intensity: Math.min(1, Math.max(0, finiteIntensity)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_AUDIO_JOURNEY_STATE = resolveJourneyMusicState('start');
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
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));
|
|
||||||
}
|
|
||||||
383
src/audio/soundtrack-engine.ts
Normal file
383
src/audio/soundtrack-engine.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import {
|
||||||
|
INTRO_FIRST_PASS_END_SECONDS,
|
||||||
|
INTRO_IN_END_SECONDS,
|
||||||
|
INTRO_LOOP_END_SECONDS,
|
||||||
|
INTRO_LOOP_START_SECONDS,
|
||||||
|
INTRO_OUT_END_SECONDS,
|
||||||
|
INTRO_OUT_START_SECONDS,
|
||||||
|
INTRO_RUNTIME_END_SECONDS,
|
||||||
|
SOUNDTRACK_SAMPLE_RATE,
|
||||||
|
mainPhraseOffsetSeconds,
|
||||||
|
nextSafeBarTime,
|
||||||
|
} from '@/audio/soundtrack-transport';
|
||||||
|
import type {
|
||||||
|
AudioContextLifecycleState,
|
||||||
|
AudioGraph,
|
||||||
|
AudioJourneyState,
|
||||||
|
MusicChapterBias,
|
||||||
|
SoundtrackSources,
|
||||||
|
} from '@/audio/types';
|
||||||
|
|
||||||
|
const SOURCE_LEAD_SECONDS = 0.08;
|
||||||
|
const START_FADE_SECONDS = 0.8;
|
||||||
|
const EXIT_CROSSFADE_SECONDS = 0.25;
|
||||||
|
const EXIT_MINIMUM_LEAD_SECONDS = 0.35;
|
||||||
|
const EXIT_COALESCE_MS = 120;
|
||||||
|
const PROFILE_RAMP_SECONDS = 0.85;
|
||||||
|
|
||||||
|
interface SourceHandle {
|
||||||
|
readonly node: AudioBufferSourceNode;
|
||||||
|
readonly gain: GainNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MixProfile {
|
||||||
|
readonly gain: number;
|
||||||
|
readonly highShelfDb: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIX_PROFILES: Readonly<Record<MusicChapterBias, MixProfile>> = Object.freeze({
|
||||||
|
open: { gain: 0.985, highShelfDb: -0.35 },
|
||||||
|
shadow: { gain: 0.97, highShelfDb: -0.9 },
|
||||||
|
lift: { gain: 1, highShelfDb: 0.35 },
|
||||||
|
resolve: { gain: 0.985, highShelfDb: -0.15 },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createBrowserSoundtrackEngine(
|
||||||
|
sources: SoundtrackSources,
|
||||||
|
initialState: AudioJourneyState,
|
||||||
|
): AudioGraph {
|
||||||
|
const BrowserAudioContext = window.AudioContext;
|
||||||
|
if (BrowserAudioContext === undefined) throw new Error('Web Audio is unavailable.');
|
||||||
|
|
||||||
|
const context = new BrowserAudioContext({
|
||||||
|
latencyHint: 'playback',
|
||||||
|
sampleRate: SOUNDTRACK_SAMPLE_RATE,
|
||||||
|
});
|
||||||
|
const profileGain = context.createGain();
|
||||||
|
const tone = context.createBiquadFilter();
|
||||||
|
const outputGain = context.createGain();
|
||||||
|
const sourceNodes = new Set<AudioBufferSourceNode>();
|
||||||
|
const stateListeners = new Set<(state: AudioContextLifecycleState) => void>();
|
||||||
|
let latestState = initialState;
|
||||||
|
let connected = false;
|
||||||
|
let closed = false;
|
||||||
|
const activatedFromStart = initialState.atStart;
|
||||||
|
let transportStarted = false;
|
||||||
|
let introStarted = false;
|
||||||
|
let mainCommitted = false;
|
||||||
|
let introAnchorTime = 0;
|
||||||
|
let introProgramGain: GainNode | null = null;
|
||||||
|
let introSourceNodes: AudioBufferSourceNode[] = [];
|
||||||
|
let loadedIntroBuffer: AudioBuffer | null = null;
|
||||||
|
let mainBufferPromise: Promise<AudioBuffer> | null = null;
|
||||||
|
let loadController: AbortController | null = null;
|
||||||
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
let exitTimer: number | undefined;
|
||||||
|
|
||||||
|
tone.type = 'highshelf';
|
||||||
|
tone.frequency.value = 5_200;
|
||||||
|
tone.Q.value = 0.55;
|
||||||
|
outputGain.gain.value = 0;
|
||||||
|
profileGain.connect(tone);
|
||||||
|
tone.connect(outputGain);
|
||||||
|
|
||||||
|
const trackSource = (node: AudioBufferSourceNode): void => {
|
||||||
|
sourceNodes.add(node);
|
||||||
|
node.addEventListener('ended', () => {
|
||||||
|
sourceNodes.delete(node);
|
||||||
|
node.disconnect();
|
||||||
|
}, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeSource = (buffer: AudioBuffer): SourceHandle => {
|
||||||
|
const node = context.createBufferSource();
|
||||||
|
const gain = context.createGain();
|
||||||
|
node.buffer = buffer;
|
||||||
|
node.connect(gain);
|
||||||
|
gain.connect(profileGain);
|
||||||
|
node.addEventListener('ended', () => gain.disconnect(), { once: true });
|
||||||
|
trackSource(node);
|
||||||
|
return { node, gain };
|
||||||
|
};
|
||||||
|
|
||||||
|
const ramp = (parameter: AudioParam, value: number, duration: number): void => {
|
||||||
|
const now = context.currentTime;
|
||||||
|
holdParameter(parameter, now);
|
||||||
|
parameter.linearRampToValueAtTime(value, now + duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyMix = (state: AudioJourneyState): void => {
|
||||||
|
const profile = MIX_PROFILES[state.bias];
|
||||||
|
const intensity = clamp(state.intensity);
|
||||||
|
ramp(profileGain.gain, profile.gain + intensity * 0.012, PROFILE_RAMP_SECONDS);
|
||||||
|
ramp(tone.gain, profile.highShelfDb + intensity * 0.25, PROFILE_RAMP_SECONDS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startMain = (
|
||||||
|
buffer: AudioBuffer,
|
||||||
|
when: number,
|
||||||
|
offsetSeconds: number,
|
||||||
|
fadeSeconds: number,
|
||||||
|
): SourceHandle => {
|
||||||
|
const main = makeSource(buffer);
|
||||||
|
const offset = Math.min(Math.max(0, offsetSeconds), Math.max(0, buffer.duration - 0.05));
|
||||||
|
main.gain.gain.setValueAtTime(0, when);
|
||||||
|
main.gain.gain.linearRampToValueAtTime(1, when + fadeSeconds);
|
||||||
|
main.node.start(when, offset);
|
||||||
|
mainCommitted = true;
|
||||||
|
return main;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startIntro = (buffer: AudioBuffer, when: number): void => {
|
||||||
|
const lead = context.createBufferSource();
|
||||||
|
const loop = context.createBufferSource();
|
||||||
|
const programGain = context.createGain();
|
||||||
|
|
||||||
|
lead.buffer = buffer;
|
||||||
|
loop.buffer = buffer;
|
||||||
|
loop.loop = true;
|
||||||
|
loop.loopStart = INTRO_LOOP_START_SECONDS;
|
||||||
|
loop.loopEnd = INTRO_LOOP_END_SECONDS;
|
||||||
|
lead.connect(programGain);
|
||||||
|
loop.connect(programGain);
|
||||||
|
programGain.connect(profileGain);
|
||||||
|
programGain.gain.setValueAtTime(0, when);
|
||||||
|
programGain.gain.linearRampToValueAtTime(1, when + START_FADE_SECONDS);
|
||||||
|
|
||||||
|
trackSource(lead);
|
||||||
|
trackSource(loop);
|
||||||
|
lead.start(when, 0, INTRO_FIRST_PASS_END_SECONDS);
|
||||||
|
loop.start(when + INTRO_FIRST_PASS_END_SECONDS, INTRO_LOOP_START_SECONDS);
|
||||||
|
|
||||||
|
introProgramGain = programGain;
|
||||||
|
introSourceNodes = [lead, loop];
|
||||||
|
introAnchorTime = when;
|
||||||
|
introStarted = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleIntroExit = (introBuffer: AudioBuffer, mainBuffer: AudioBuffer): void => {
|
||||||
|
if (!introStarted || mainCommitted || introProgramGain === null || closed) return;
|
||||||
|
mainCommitted = true;
|
||||||
|
const schedulingTime = Math.max(
|
||||||
|
context.currentTime,
|
||||||
|
introAnchorTime + INTRO_IN_END_SECONDS - EXIT_MINIMUM_LEAD_SECONDS,
|
||||||
|
);
|
||||||
|
const transitionTime = nextSafeBarTime(
|
||||||
|
schedulingTime,
|
||||||
|
introAnchorTime,
|
||||||
|
EXIT_MINIMUM_LEAD_SECONDS,
|
||||||
|
);
|
||||||
|
const outro = makeSource(introBuffer);
|
||||||
|
outro.gain.gain.setValueAtTime(0, transitionTime);
|
||||||
|
outro.gain.gain.linearRampToValueAtTime(1, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||||
|
outro.node.start(
|
||||||
|
transitionTime,
|
||||||
|
INTRO_OUT_START_SECONDS,
|
||||||
|
INTRO_RUNTIME_END_SECONDS - INTRO_OUT_START_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
introProgramGain.gain.cancelScheduledValues(transitionTime);
|
||||||
|
introProgramGain.gain.setValueAtTime(1, transitionTime);
|
||||||
|
introProgramGain.gain.linearRampToValueAtTime(0, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||||
|
for (const source of introSourceNodes) {
|
||||||
|
stopSourceAt(source, transitionTime + EXIT_CROSSFADE_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainTime = transitionTime + INTRO_OUT_END_SECONDS - INTRO_OUT_START_SECONDS;
|
||||||
|
const main = startMain(mainBuffer, mainTime, 0, EXIT_CROSSFADE_SECONDS);
|
||||||
|
outro.gain.gain.cancelScheduledValues(mainTime);
|
||||||
|
outro.gain.gain.setValueAtTime(1, mainTime);
|
||||||
|
outro.gain.gain.linearRampToValueAtTime(0, mainTime + EXIT_CROSSFADE_SECONDS);
|
||||||
|
const finishedProgramGain = introProgramGain;
|
||||||
|
outro.node.addEventListener('ended', () => {
|
||||||
|
finishedProgramGain.disconnect();
|
||||||
|
if (introProgramGain === finishedProgramGain) introProgramGain = null;
|
||||||
|
introSourceNodes = [];
|
||||||
|
loadedIntroBuffer = null;
|
||||||
|
introStarted = false;
|
||||||
|
}, { once: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAndStart = async (): Promise<void> => {
|
||||||
|
loadController = new AbortController();
|
||||||
|
const signal = loadController.signal;
|
||||||
|
mainBufferPromise = loadAudioBuffer(context, sources.main, signal);
|
||||||
|
void mainBufferPromise.catch(() => undefined);
|
||||||
|
|
||||||
|
if (activatedFromStart) {
|
||||||
|
loadedIntroBuffer = await loadAudioBuffer(context, sources.intro, signal);
|
||||||
|
if (closed || signal.aborted) throw abortError();
|
||||||
|
if (latestState.atStart) {
|
||||||
|
transportStarted = true;
|
||||||
|
startIntro(loadedIntroBuffer, context.currentTime + SOURCE_LEAD_SECONDS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainBuffer = await mainBufferPromise;
|
||||||
|
if (closed || signal.aborted) throw abortError();
|
||||||
|
if (activatedFromStart && latestState.atStart && loadedIntroBuffer !== null) {
|
||||||
|
transportStarted = true;
|
||||||
|
startIntro(loadedIntroBuffer, context.currentTime + SOURCE_LEAD_SECONDS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transportStarted = true;
|
||||||
|
startMain(
|
||||||
|
mainBuffer,
|
||||||
|
context.currentTime + SOURCE_LEAD_SECONDS,
|
||||||
|
mainPhraseOffsetSeconds(latestState.entryPhrase),
|
||||||
|
START_FADE_SECONDS,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestIntroExit = (): void => {
|
||||||
|
if (!introStarted || mainCommitted || exitTimer !== undefined || closed) return;
|
||||||
|
exitTimer = window.setTimeout(() => {
|
||||||
|
exitTimer = undefined;
|
||||||
|
if (closed || latestState.atStart || mainCommitted) return;
|
||||||
|
const introBuffer = loadedIntroBuffer;
|
||||||
|
const pendingMain = mainBufferPromise;
|
||||||
|
if (introBuffer === null || pendingMain === null) return;
|
||||||
|
void pendingMain.then((mainBuffer) => {
|
||||||
|
if (!closed && !latestState.atStart && !mainCommitted) {
|
||||||
|
scheduleIntroExit(introBuffer, mainBuffer);
|
||||||
|
}
|
||||||
|
}).catch(() => undefined);
|
||||||
|
}, EXIT_COALESCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStateChange = (): void => {
|
||||||
|
const state = context.state as AudioContextLifecycleState;
|
||||||
|
for (const listener of stateListeners) listener(state);
|
||||||
|
};
|
||||||
|
context.addEventListener('statechange', onStateChange);
|
||||||
|
applyMix(initialState);
|
||||||
|
|
||||||
|
return {
|
||||||
|
async resume() {
|
||||||
|
if (closed) throw new DOMException('Audio graph is closed.', 'InvalidStateError');
|
||||||
|
await context.resume();
|
||||||
|
if (transportStarted) return;
|
||||||
|
loadPromise ??= loadAndStart();
|
||||||
|
await loadPromise;
|
||||||
|
},
|
||||||
|
async suspend() {
|
||||||
|
if (!closed && context.state !== 'closed') await context.suspend();
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
if (exitTimer !== undefined) window.clearTimeout(exitTimer);
|
||||||
|
loadController?.abort();
|
||||||
|
for (const source of sourceNodes) stopSource(source);
|
||||||
|
sourceNodes.clear();
|
||||||
|
context.removeEventListener('statechange', onStateChange);
|
||||||
|
stateListeners.clear();
|
||||||
|
profileGain.disconnect();
|
||||||
|
tone.disconnect();
|
||||||
|
outputGain.disconnect();
|
||||||
|
if (context.state !== 'closed') await context.close();
|
||||||
|
},
|
||||||
|
connectOutput() {
|
||||||
|
if (connected || closed) return;
|
||||||
|
outputGain.connect(context.destination);
|
||||||
|
connected = true;
|
||||||
|
},
|
||||||
|
disconnectOutput() {
|
||||||
|
if (!connected) return;
|
||||||
|
outputGain.disconnect();
|
||||||
|
connected = false;
|
||||||
|
},
|
||||||
|
setOutputGain(value) {
|
||||||
|
ramp(outputGain.gain, clamp(value), 0.08);
|
||||||
|
},
|
||||||
|
setJourneyState(state) {
|
||||||
|
if (closed) return;
|
||||||
|
latestState = state;
|
||||||
|
applyMix(state);
|
||||||
|
if (!state.atStart) requestIntroExit();
|
||||||
|
else if (exitTimer !== undefined) {
|
||||||
|
window.clearTimeout(exitTimer);
|
||||||
|
exitTimer = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelPending() {
|
||||||
|
loadController?.abort();
|
||||||
|
if (exitTimer !== undefined) {
|
||||||
|
window.clearTimeout(exitTimer);
|
||||||
|
exitTimer = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onContextStateChange(listener) {
|
||||||
|
stateListeners.add(listener);
|
||||||
|
return () => stateListeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudioBuffer(
|
||||||
|
context: BaseAudioContext,
|
||||||
|
variants: readonly SoundtrackSources['intro'][number][],
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<AudioBuffer> {
|
||||||
|
const ordered = orderSupportedVariants(variants);
|
||||||
|
let lastError: unknown;
|
||||||
|
for (const variant of ordered) {
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
try {
|
||||||
|
const response = await fetch(variant.url, { signal, credentials: 'same-origin' });
|
||||||
|
if (!response.ok) throw new Error(`Soundtrack request failed (${response.status}).`);
|
||||||
|
const encoded = await response.arrayBuffer();
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
return await context.decodeAudioData(encoded);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) throw abortError();
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('No supported soundtrack source loaded.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function orderSupportedVariants<T extends SoundtrackSources['intro'][number]>(
|
||||||
|
variants: readonly T[],
|
||||||
|
): readonly T[] {
|
||||||
|
const probe = document.createElement('audio');
|
||||||
|
return [...variants].sort((left, right) => (
|
||||||
|
Number(probe.canPlayType(right.mediaType) !== '') - Number(probe.canPlayType(left.mediaType) !== '')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
function holdParameter(parameter: AudioParam, atTime: number): void {
|
||||||
|
if (typeof parameter.cancelAndHoldAtTime === 'function') {
|
||||||
|
parameter.cancelAndHoldAtTime(atTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
parameter.cancelScheduledValues(atTime);
|
||||||
|
parameter.setValueAtTime(parameter.value, atTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSource(source: AudioBufferSourceNode): void {
|
||||||
|
try {
|
||||||
|
source.stop();
|
||||||
|
} catch {
|
||||||
|
// An already-ended or not-yet-started source is safe to ignore during teardown.
|
||||||
|
}
|
||||||
|
source.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopSourceAt(source: AudioBufferSourceNode, when: number): void {
|
||||||
|
try {
|
||||||
|
source.stop(when);
|
||||||
|
} catch {
|
||||||
|
// A source that already ended needs no scheduled cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function abortError(): DOMException {
|
||||||
|
return new DOMException('Soundtrack loading was cancelled.', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.min(1, Math.max(0, value));
|
||||||
|
}
|
||||||
53
src/audio/soundtrack-transport.ts
Normal file
53
src/audio/soundtrack-transport.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AudioJourneyState } from '@/audio/types';
|
||||||
|
|
||||||
|
export const SOUNDTRACK_SAMPLE_RATE = 48_000;
|
||||||
|
export const MUSIC_BPM = 86;
|
||||||
|
export const MUSIC_BAR_SECONDS = 4 * 60 / MUSIC_BPM;
|
||||||
|
export const MAIN_PHRASE_BARS = 8;
|
||||||
|
|
||||||
|
export const INTRO_IN_END_SAMPLE = 267_907;
|
||||||
|
export const INTRO_FIRST_PASS_END_SAMPLE = 1_339_535;
|
||||||
|
export const INTRO_LOOP_START_SAMPLE = INTRO_FIRST_PASS_END_SAMPLE;
|
||||||
|
export const INTRO_LOOP_END_SAMPLE = 2_411_163;
|
||||||
|
export const INTRO_OUT_START_SAMPLE = INTRO_LOOP_END_SAMPLE;
|
||||||
|
export const INTRO_OUT_END_SAMPLE = 2_679_070;
|
||||||
|
export const INTRO_RUNTIME_END_SAMPLE = 2_691_070;
|
||||||
|
|
||||||
|
export const INTRO_IN_END_SECONDS = INTRO_IN_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const INTRO_FIRST_PASS_END_SECONDS = INTRO_FIRST_PASS_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const INTRO_LOOP_START_SECONDS = INTRO_LOOP_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const INTRO_LOOP_END_SECONDS = INTRO_LOOP_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const INTRO_OUT_START_SECONDS = INTRO_OUT_START_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const INTRO_OUT_END_SECONDS = INTRO_OUT_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
export const INTRO_RUNTIME_END_SECONDS = INTRO_RUNTIME_END_SAMPLE / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
|
||||||
|
export type InitialPlayback =
|
||||||
|
| { readonly kind: 'intro' }
|
||||||
|
| { readonly kind: 'main'; readonly offsetSeconds: number };
|
||||||
|
|
||||||
|
export function resolveInitialPlayback(state: AudioJourneyState): InitialPlayback {
|
||||||
|
return state.atStart
|
||||||
|
? { kind: 'intro' }
|
||||||
|
: { kind: 'main', offsetSeconds: mainPhraseOffsetSeconds(state.entryPhrase) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mainPhraseOffsetSeconds(entryPhrase: number): number {
|
||||||
|
const finitePhrase = Number.isFinite(entryPhrase) ? Math.trunc(entryPhrase) : 0;
|
||||||
|
const phrase = Math.min(7, Math.max(0, finitePhrase));
|
||||||
|
const targetSamples = Math.round(
|
||||||
|
phrase * MAIN_PHRASE_BARS * MUSIC_BAR_SECONDS * SOUNDTRACK_SAMPLE_RATE,
|
||||||
|
);
|
||||||
|
return targetSamples / SOUNDTRACK_SAMPLE_RATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextSafeBarTime(
|
||||||
|
now: number,
|
||||||
|
anchorTime: number,
|
||||||
|
minimumLeadSeconds: number,
|
||||||
|
): number {
|
||||||
|
const earliest = Math.max(anchorTime, now + Math.max(0, minimumLeadSeconds));
|
||||||
|
const elapsedBars = (earliest - anchorTime) / MUSIC_BAR_SECONDS;
|
||||||
|
const oneSampleInBars = 1 / SOUNDTRACK_SAMPLE_RATE / MUSIC_BAR_SECONDS;
|
||||||
|
const nextBar = Math.ceil(elapsedBars - oneSampleInBars);
|
||||||
|
return anchorTime + nextBar * MUSIC_BAR_SECONDS;
|
||||||
|
}
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
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;
|
|
||||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
|
||||||
|
|
||||||
type AudioContextFactory = () => AudioContext;
|
|
||||||
|
|
||||||
export function createBrowserSynthEngine(): AudioGraph {
|
|
||||||
if (TEST_BUILD && isTestFault('F10-audio-start-rejection')) {
|
|
||||||
recordTestFaultEvent('audio-start-rejected');
|
|
||||||
throw new DOMException('Injected audio start rejection', 'NotAllowedError');
|
|
||||||
}
|
|
||||||
const AudioContextConstructor = window.AudioContext
|
|
||||||
?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
||||||
if (!AudioContextConstructor) throw new Error('Web Audio is unavailable');
|
|
||||||
const graph = createSynthEngine(() => new AudioContextConstructor());
|
|
||||||
if (!TEST_BUILD) return graph;
|
|
||||||
let resumeCount = 0;
|
|
||||||
return {
|
|
||||||
...graph,
|
|
||||||
async resume() {
|
|
||||||
resumeCount += 1;
|
|
||||||
if (isTestFault('F11-audio-resume-rejection') && resumeCount > 1) {
|
|
||||||
recordTestFaultEvent('audio-resume-rejected');
|
|
||||||
throw new DOMException('Injected audio resume rejection', 'NotAllowedError');
|
|
||||||
}
|
|
||||||
if (isTestFault('F12-audio-graph-invalid') && resumeCount > 1) {
|
|
||||||
recordTestFaultEvent('audio-graph-invalid');
|
|
||||||
throw new DOMException('Injected invalid audio graph', 'InvalidStateError');
|
|
||||||
}
|
|
||||||
await graph.resume();
|
|
||||||
},
|
|
||||||
async suspend() {
|
|
||||||
if (isTestFault('F13-audio-suspend-rejection')) {
|
|
||||||
recordTestFaultEvent('audio-suspend-rejected');
|
|
||||||
throw new DOMException('Injected audio suspend rejection', 'InvalidStateError');
|
|
||||||
}
|
|
||||||
await graph.suspend();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createSynthEngine(createContext: AudioContextFactory): AudioGraph {
|
|
||||||
const context = createContext();
|
|
||||||
const master = context.createGain();
|
|
||||||
const dynamics = context.createDynamicsCompressor();
|
|
||||||
master.gain.setValueAtTime(0, context.currentTime);
|
|
||||||
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.78, context.currentTime);
|
|
||||||
musicFilter.connect(musicGain);
|
|
||||||
musicGain.connect(dynamics);
|
|
||||||
|
|
||||||
const engineFilter = context.createBiquadFilter();
|
|
||||||
engineFilter.type = 'lowpass';
|
|
||||||
engineFilter.frequency.setValueAtTime(260, context.currentTime);
|
|
||||||
engineFilter.Q.setValueAtTime(1.25, context.currentTime);
|
|
||||||
|
|
||||||
const engineGain = context.createGain();
|
|
||||||
engineGain.gain.setValueAtTime(0.09, context.currentTime);
|
|
||||||
engineFilter.connect(engineGain);
|
|
||||||
engineGain.connect(dynamics);
|
|
||||||
|
|
||||||
const fundamental = createOscillator(context, 'sawtooth', 42, 0.16, engineFilter);
|
|
||||||
const harmonic = createOscillator(context, 'sine', 84, 0.055, engineFilter);
|
|
||||||
|
|
||||||
const roadFilter = context.createBiquadFilter();
|
|
||||||
roadFilter.type = 'bandpass';
|
|
||||||
roadFilter.frequency.setValueAtTime(410, context.currentTime);
|
|
||||||
roadFilter.Q.setValueAtTime(0.55, context.currentTime);
|
|
||||||
const roadGain = context.createGain();
|
|
||||||
roadGain.gain.setValueAtTime(0.018, context.currentTime);
|
|
||||||
roadFilter.connect(roadGain);
|
|
||||||
roadGain.connect(dynamics);
|
|
||||||
|
|
||||||
const noise = context.createBufferSource();
|
|
||||||
noise.buffer = createNoiseBuffer(context, 2);
|
|
||||||
noise.loop = true;
|
|
||||||
noise.connect(roadFilter);
|
|
||||||
|
|
||||||
const ignition = context.createOscillator();
|
|
||||||
ignition.type = 'triangle';
|
|
||||||
ignition.frequency.setValueAtTime(68, context.currentTime);
|
|
||||||
const ignitionGain = context.createGain();
|
|
||||||
ignitionGain.gain.setValueAtTime(0.0001, context.currentTime);
|
|
||||||
ignitionGain.gain.exponentialRampToValueAtTime(0.025, context.currentTime + 0.12);
|
|
||||||
ignitionGain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 1.15);
|
|
||||||
ignition.connect(ignitionGain);
|
|
||||||
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;
|
|
||||||
|
|
||||||
return {
|
|
||||||
resume: () => context.resume(),
|
|
||||||
suspend: () => context.suspend(),
|
|
||||||
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) {
|
|
||||||
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;
|
|
||||||
master.connect(context.destination);
|
|
||||||
outputConnected = true;
|
|
||||||
},
|
|
||||||
disconnectOutput() {
|
|
||||||
if (!outputConnected) return;
|
|
||||||
master.disconnect(context.destination);
|
|
||||||
outputConnected = false;
|
|
||||||
},
|
|
||||||
setOutputGain(value) {
|
|
||||||
const next = clamp(value);
|
|
||||||
master.gain.cancelScheduledValues(context.currentTime);
|
|
||||||
master.gain.setValueAtTime(next, context.currentTime);
|
|
||||||
},
|
|
||||||
setIntensity(value) {
|
|
||||||
const next = clamp(value);
|
|
||||||
const time = context.currentTime;
|
|
||||||
fundamental.source.frequency.setTargetAtTime(42 + next * 28, time, 0.32);
|
|
||||||
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);
|
|
||||||
musicFilter.frequency.setTargetAtTime(900 + next * 2_400, time, 0.42);
|
|
||||||
engineGain.gain.setTargetAtTime(0.09 + next * 0.04, time, 0.4);
|
|
||||||
roadGain.gain.setTargetAtTime(0.018 + next * 0.02, time, 0.45);
|
|
||||||
music.setIntensity(next, time);
|
|
||||||
},
|
|
||||||
onContextStateChange(listener) {
|
|
||||||
const onStateChange = (): void => listener(context.state as AudioContextLifecycleState);
|
|
||||||
context.addEventListener('statechange', onStateChange);
|
|
||||||
return () => context.removeEventListener('statechange', onStateChange);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createOscillator(
|
|
||||||
context: AudioContext,
|
|
||||||
type: OscillatorType,
|
|
||||||
frequency: number,
|
|
||||||
level: number,
|
|
||||||
destination: AudioNode,
|
|
||||||
): { source: OscillatorNode; gain: GainNode } {
|
|
||||||
const source = context.createOscillator();
|
|
||||||
source.type = type;
|
|
||||||
source.frequency.setValueAtTime(frequency, context.currentTime);
|
|
||||||
const gain = context.createGain();
|
|
||||||
gain.gain.setValueAtTime(level, context.currentTime);
|
|
||||||
source.connect(gain);
|
|
||||||
gain.connect(destination);
|
|
||||||
return { source, gain };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 previous = 0;
|
|
||||||
for (let index = 0; index < samples.length; index += 1) {
|
|
||||||
const white = Math.random() * 2 - 1;
|
|
||||||
previous = previous * 0.93 + white * 0.07;
|
|
||||||
samples[index] = previous;
|
|
||||||
}
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number): number {
|
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.min(1, Math.max(0, value));
|
|
||||||
}
|
|
||||||
@@ -8,6 +8,25 @@ export type AudioUiState =
|
|||||||
|
|
||||||
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
|
export type AudioContextLifecycleState = AudioContextState | 'interrupted';
|
||||||
|
|
||||||
|
export type MusicChapterBias = 'open' | 'shadow' | 'lift' | 'resolve';
|
||||||
|
|
||||||
|
export interface AudioJourneyState {
|
||||||
|
readonly atStart: boolean;
|
||||||
|
readonly intensity: number;
|
||||||
|
readonly bias: MusicChapterBias;
|
||||||
|
readonly entryPhrase: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioSourceVariant {
|
||||||
|
readonly url: string;
|
||||||
|
readonly mediaType: 'audio/ogg' | 'audio/mpeg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundtrackSources {
|
||||||
|
readonly intro: readonly AudioSourceVariant[];
|
||||||
|
readonly main: readonly AudioSourceVariant[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudioGraph {
|
export interface AudioGraph {
|
||||||
resume(): Promise<void>;
|
resume(): Promise<void>;
|
||||||
suspend(): Promise<void>;
|
suspend(): Promise<void>;
|
||||||
@@ -15,23 +34,18 @@ export interface AudioGraph {
|
|||||||
connectOutput(): void;
|
connectOutput(): void;
|
||||||
disconnectOutput(): void;
|
disconnectOutput(): void;
|
||||||
setOutputGain(value: number): void;
|
setOutputGain(value: number): void;
|
||||||
setIntensity(value: number): void;
|
setJourneyState(state: AudioJourneyState): void;
|
||||||
|
cancelPending(): void;
|
||||||
onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void;
|
onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MusicSequencer {
|
export type AudioGraphFactory = (initialState: AudioJourneyState) => AudioGraph;
|
||||||
start(atTime: number): void;
|
|
||||||
scheduleThrough(horizonTime: number): void;
|
|
||||||
setIntensity(value: number, atTime: number): void;
|
|
||||||
stop(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AudioGraphFactory = () => AudioGraph;
|
|
||||||
|
|
||||||
export interface AudioControllerOptions {
|
export interface AudioControllerOptions {
|
||||||
createGraph: AudioGraphFactory;
|
createGraph: AudioGraphFactory;
|
||||||
initialVolume?: number;
|
initialVolume?: number;
|
||||||
initiallyVisible?: boolean;
|
initiallyVisible?: boolean;
|
||||||
|
initialJourneyState?: AudioJourneyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AudioController {
|
export interface AudioController {
|
||||||
@@ -39,7 +53,7 @@ export interface AudioController {
|
|||||||
resumeFromGesture(): Promise<void>;
|
resumeFromGesture(): Promise<void>;
|
||||||
setMuted(muted: boolean): void;
|
setMuted(muted: boolean): void;
|
||||||
setVolume(volume: number): void;
|
setVolume(volume: number): void;
|
||||||
setIntensity(intensity: number): void;
|
setJourneyState(state: AudioJourneyState): void;
|
||||||
setVisible(visible: boolean): Promise<void>;
|
setVisible(visible: boolean): Promise<void>;
|
||||||
noteInterruption(): Promise<void>;
|
noteInterruption(): Promise<void>;
|
||||||
pageHide(persisted: boolean): Promise<void>;
|
pageHide(persisted: boolean): Promise<void>;
|
||||||
|
|||||||
@@ -17,14 +17,19 @@ import { useAudioEngine } from '@/composables/useAudioEngine';
|
|||||||
import { useJourneySections } from '@/composables/useJourneySections';
|
import { useJourneySections } from '@/composables/useJourneySections';
|
||||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||||
import type { PortfolioContent, SectionId, SectionLayout } from '@/content/types';
|
import { assetUrl } from '@/content/asset-url';
|
||||||
|
import type { PortfolioContent, PublicAssetManifest, SectionId, SectionLayout } from '@/content/types';
|
||||||
import type { SceneHostState } from '@/composables/useSceneController';
|
import type { SceneHostState } from '@/composables/useSceneController';
|
||||||
import type { MotionPolicy } from '@/journey/motion-policy';
|
import type { MotionPolicy } from '@/journey/motion-policy';
|
||||||
|
import type { SoundtrackSources } from '@/audio/types';
|
||||||
|
|
||||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||||
|
|
||||||
const props = defineProps<{ content: Readonly<PortfolioContent> }>();
|
const props = defineProps<{
|
||||||
|
content: Readonly<PortfolioContent>;
|
||||||
|
manifest: Readonly<PublicAssetManifest>;
|
||||||
|
}>();
|
||||||
|
|
||||||
const canonicalSectionIds = [
|
const canonicalSectionIds = [
|
||||||
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact',
|
||||||
@@ -59,8 +64,44 @@ const journey = useJourneySections(
|
|||||||
navigation.state,
|
navigation.state,
|
||||||
computed(() => motion.policy.value.allowSceneRequest),
|
computed(() => motion.policy.value.allowSceneRequest),
|
||||||
);
|
);
|
||||||
|
const pendingAudioDestination = shallowRef<SectionId | null>(null);
|
||||||
|
let audioNavigationEpoch = -1;
|
||||||
|
watch(
|
||||||
|
[navigation.state, () => journey.value.activeSectionId],
|
||||||
|
([navigationState, activeSectionId]) => {
|
||||||
|
if (navigationState.epoch !== audioNavigationEpoch) {
|
||||||
|
audioNavigationEpoch = navigationState.epoch;
|
||||||
|
pendingAudioDestination.value = navigationState.source === 'passive-scroll'
|
||||||
|
? null
|
||||||
|
: navigationState.destinationId;
|
||||||
|
}
|
||||||
|
if (pendingAudioDestination.value === activeSectionId) {
|
||||||
|
pendingAudioDestination.value = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'sync', immediate: true },
|
||||||
|
);
|
||||||
|
const audioSectionId = computed(() => (
|
||||||
|
pendingAudioDestination.value ?? journey.value.activeSectionId
|
||||||
|
));
|
||||||
|
const soundtrackSources = computed<SoundtrackSources | null>(() => {
|
||||||
|
const soundtrack = props.content.soundtrack;
|
||||||
|
if (soundtrack === undefined) return null;
|
||||||
|
return {
|
||||||
|
intro: [
|
||||||
|
{ url: assetUrl(props.manifest, soundtrack.intro.opusAssetId), mediaType: 'audio/ogg' },
|
||||||
|
{ url: assetUrl(props.manifest, soundtrack.intro.mp3AssetId), mediaType: 'audio/mpeg' },
|
||||||
|
],
|
||||||
|
main: [
|
||||||
|
{ url: assetUrl(props.manifest, soundtrack.main.opusAssetId), mediaType: 'audio/ogg' },
|
||||||
|
{ url: assetUrl(props.manifest, soundtrack.main.mp3AssetId), mediaType: 'audio/mpeg' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
const audio = useAudioEngine({
|
const audio = useAudioEngine({
|
||||||
|
activeSectionId: audioSectionId,
|
||||||
intensity: computed(() => journey.value.acceleration),
|
intensity: computed(() => journey.value.acceleration),
|
||||||
|
soundtrack: soundtrackSources,
|
||||||
volume: motion.volume,
|
volume: motion.volume,
|
||||||
setVolume: motion.setVolume,
|
setVolume: motion.setVolume,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
computed,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
readonly,
|
readonly,
|
||||||
@@ -7,14 +8,17 @@ import {
|
|||||||
type Ref,
|
type Ref,
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import { createAudioController } from '@/audio/create-audio-controller';
|
import { createAudioController } from '@/audio/create-audio-controller';
|
||||||
import { createBrowserSynthEngine } from '@/audio/synth-engine';
|
import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine';
|
||||||
import type { AudioController, AudioUiState } from '@/audio/types';
|
import { resolveJourneyMusicState } from '@/audio/journey-music-state';
|
||||||
|
import type { AudioController, AudioUiState, SoundtrackSources } from '@/audio/types';
|
||||||
|
|
||||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||||
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
|
||||||
|
|
||||||
export interface AudioEngineSources {
|
export interface AudioEngineSources {
|
||||||
|
activeSectionId: Readonly<Ref<string>>;
|
||||||
intensity: Readonly<Ref<number>>;
|
intensity: Readonly<Ref<number>>;
|
||||||
|
soundtrack: Readonly<Ref<SoundtrackSources | null>>;
|
||||||
volume: Readonly<Ref<number>>;
|
volume: Readonly<Ref<number>>;
|
||||||
setVolume(value: number): void;
|
setVolume(value: number): void;
|
||||||
}
|
}
|
||||||
@@ -34,20 +38,28 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding
|
|||||||
let controller: AudioController | null = null;
|
let controller: AudioController | null = null;
|
||||||
let removeSubscription = (): void => {};
|
let removeSubscription = (): void => {};
|
||||||
let removePageListeners = (): void => {};
|
let removePageListeners = (): void => {};
|
||||||
|
const journeyState = computed(() => resolveJourneyMusicState(
|
||||||
|
sources.activeSectionId.value,
|
||||||
|
sources.intensity.value,
|
||||||
|
));
|
||||||
|
|
||||||
const ensureController = (): AudioController => {
|
const ensureController = (): AudioController => {
|
||||||
if (controller) return controller;
|
if (controller) return controller;
|
||||||
controller = createAudioController({
|
controller = createAudioController({
|
||||||
createGraph: createBrowserSynthEngine,
|
createGraph: (initialState) => {
|
||||||
|
const soundtrack = sources.soundtrack.value;
|
||||||
|
if (soundtrack === null) throw new Error('Soundtrack assets are unavailable.');
|
||||||
|
return createBrowserSoundtrackEngine(soundtrack, initialState);
|
||||||
|
},
|
||||||
initialVolume: sources.volume.value,
|
initialVolume: sources.volume.value,
|
||||||
initiallyVisible: document.visibilityState !== 'hidden',
|
initiallyVisible: document.visibilityState !== 'hidden',
|
||||||
|
initialJourneyState: journeyState.value,
|
||||||
});
|
});
|
||||||
removeSubscription = controller.subscribe((next) => { state.value = next; });
|
removeSubscription = controller.subscribe((next) => { state.value = next; });
|
||||||
controller.setIntensity(sources.intensity.value);
|
|
||||||
return controller;
|
return controller;
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(sources.intensity, (value) => controller?.setIntensity(value));
|
watch(journeyState, (state) => controller?.setJourneyState(state), { flush: 'sync' });
|
||||||
watch(sources.volume, (value) => controller?.setVolume(value));
|
watch(sources.volume, (value) => controller?.setVolume(value));
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const navigationItemSchema = z.object({
|
|||||||
label: visibleString,
|
label: visibleString,
|
||||||
}).strict();
|
}).strict();
|
||||||
|
|
||||||
|
const soundtrackAssetSourcesSchema = z.object({
|
||||||
|
opusAssetId: opaqueId,
|
||||||
|
mp3AssetId: opaqueId,
|
||||||
|
}).strict();
|
||||||
|
|
||||||
const mediaPresentationBase = {
|
const mediaPresentationBase = {
|
||||||
assetId: opaqueId,
|
assetId: opaqueId,
|
||||||
alt: visibleString,
|
alt: visibleString,
|
||||||
@@ -194,6 +199,10 @@ const portfolioContentSchema = z.object({
|
|||||||
description: visibleString,
|
description: visibleString,
|
||||||
socialPreviewAssetId: opaqueId.optional(),
|
socialPreviewAssetId: opaqueId.optional(),
|
||||||
}).strict(),
|
}).strict(),
|
||||||
|
soundtrack: z.object({
|
||||||
|
intro: soundtrackAssetSourcesSchema,
|
||||||
|
main: soundtrackAssetSourcesSchema,
|
||||||
|
}).strict().optional(),
|
||||||
sceneDescription: visibleString,
|
sceneDescription: visibleString,
|
||||||
}).strict().superRefine((content, context) => {
|
}).strict().superRefine((content, context) => {
|
||||||
const navigationIds = content.navigation.map((item) => item.id);
|
const navigationIds = content.navigation.map((item) => item.id);
|
||||||
@@ -220,7 +229,11 @@ const portfolioContentSchema = z.object({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const mediaTypes = ['image/avif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp', 'video/mp4', 'video/webm'] as const;
|
const mediaTypes = [
|
||||||
|
'image/avif', 'image/jpeg', 'image/png', 'image/svg+xml', 'image/webp',
|
||||||
|
'video/mp4', 'video/webm',
|
||||||
|
'audio/ogg', 'audio/mpeg',
|
||||||
|
] as const;
|
||||||
const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = {
|
const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = {
|
||||||
'image/avif': 'avif',
|
'image/avif': 'avif',
|
||||||
'image/jpeg': 'jpg',
|
'image/jpeg': 'jpg',
|
||||||
@@ -229,10 +242,12 @@ const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = {
|
|||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'video/mp4': 'mp4',
|
'video/mp4': 'mp4',
|
||||||
'video/webm': 'webm',
|
'video/webm': 'webm',
|
||||||
|
'audio/ogg': 'opus',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
};
|
};
|
||||||
const publicAssetSchema = z.object({
|
const publicAssetSchema = z.object({
|
||||||
id: opaqueId,
|
id: opaqueId,
|
||||||
fileName: z.string().regex(/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm)$/, 'Asset filenames must be generated safe filenames.'),
|
fileName: z.string().regex(/^[a-z0-9][a-z0-9-]*\.(avif|jpg|png|svg|webp|mp4|webm|opus|mp3)$/, 'Asset filenames must be generated safe filenames.'),
|
||||||
sha256: z.string().regex(/^[a-f0-9]{64}$/, 'Asset sha256 must be lowercase hexadecimal.'),
|
sha256: z.string().regex(/^[a-f0-9]{64}$/, 'Asset sha256 must be lowercase hexadecimal.'),
|
||||||
mediaType: z.enum(mediaTypes),
|
mediaType: z.enum(mediaTypes),
|
||||||
width: z.number().int().positive().nullable(),
|
width: z.number().int().positive().nullable(),
|
||||||
@@ -246,12 +261,17 @@ const publicAssetSchema = z.object({
|
|||||||
context.addIssue({ code: 'custom', message: 'Asset filename extension must match media type.', path: ['fileName'] });
|
context.addIssue({ code: 'custom', message: 'Asset filename extension must match media type.', path: ['fileName'] });
|
||||||
}
|
}
|
||||||
const image = asset.mediaType.startsWith('image/');
|
const image = asset.mediaType.startsWith('image/');
|
||||||
|
const video = asset.mediaType.startsWith('video/');
|
||||||
|
const audio = asset.mediaType.startsWith('audio/');
|
||||||
if (image && (asset.width === null || asset.height === null || asset.durationMs !== null)) {
|
if (image && (asset.width === null || asset.height === null || asset.durationMs !== null)) {
|
||||||
context.addIssue({ code: 'custom', message: 'Image assets require dimensions and no duration.', path: ['width'] });
|
context.addIssue({ code: 'custom', message: 'Image assets require dimensions and no duration.', path: ['width'] });
|
||||||
}
|
}
|
||||||
if (!image && (asset.width === null || asset.height === null || asset.durationMs === null)) {
|
if (video && (asset.width === null || asset.height === null || asset.durationMs === null)) {
|
||||||
context.addIssue({ code: 'custom', message: 'Video assets require dimensions and duration.', path: ['durationMs'] });
|
context.addIssue({ code: 'custom', message: 'Video assets require dimensions and duration.', path: ['durationMs'] });
|
||||||
}
|
}
|
||||||
|
if (audio && (asset.width !== null || asset.height !== null || asset.durationMs === null)) {
|
||||||
|
context.addIssue({ code: 'custom', message: 'Audio assets require null dimensions and a positive duration.', path: ['durationMs'] });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const publicAssetManifestSchema = z.object({ assets: z.array(publicAssetSchema) }).strict().superRefine((manifest, context) => {
|
const publicAssetManifestSchema = z.object({ assets: z.array(publicAssetSchema) }).strict().superRefine((manifest, context) => {
|
||||||
const ids = manifest.assets.map((asset) => asset.id);
|
const ids = manifest.assets.map((asset) => asset.id);
|
||||||
@@ -296,6 +316,12 @@ export const parseProductionInputSnapshot = (input: unknown): ProductionInputs =
|
|||||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||||
technology.assetId === undefined ? [] : [technology.assetId])),
|
technology.assetId === undefined ? [] : [technology.assetId])),
|
||||||
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
...(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,
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
const seenReferences = new Set<string>();
|
const seenReferences = new Set<string>();
|
||||||
for (const assetId of referencedAssetIds) {
|
for (const assetId of referencedAssetIds) {
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ export interface MetadataContent {
|
|||||||
socialPreviewAssetId?: string;
|
socialPreviewAssetId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SoundtrackAssetSources {
|
||||||
|
opusAssetId: string;
|
||||||
|
mp3AssetId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoundtrackContent {
|
||||||
|
intro: SoundtrackAssetSources;
|
||||||
|
main: SoundtrackAssetSources;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PortfolioContent {
|
export interface PortfolioContent {
|
||||||
identity: {
|
identity: {
|
||||||
name: 'Luca Pîslaru';
|
name: 'Luca Pîslaru';
|
||||||
@@ -148,6 +158,7 @@ export interface PortfolioContent {
|
|||||||
toolkit: readonly ToolkitGroup[];
|
toolkit: readonly ToolkitGroup[];
|
||||||
contact: ContactContent;
|
contact: ContactContent;
|
||||||
metadata: MetadataContent;
|
metadata: MetadataContent;
|
||||||
|
soundtrack?: SoundtrackContent;
|
||||||
sceneDescription: string;
|
sceneDescription: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +169,9 @@ export type PublicAssetMediaType =
|
|||||||
| 'image/svg+xml'
|
| 'image/svg+xml'
|
||||||
| 'image/webp'
|
| 'image/webp'
|
||||||
| 'video/mp4'
|
| 'video/mp4'
|
||||||
| 'video/webm';
|
| 'video/webm'
|
||||||
|
| 'audio/ogg'
|
||||||
|
| 'audio/mpeg';
|
||||||
|
|
||||||
export interface PublicAsset {
|
export interface PublicAsset {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const buildContentSecurityPolicy = (jsonLdCspHash: string): string => {
|
|||||||
"img-src 'self' data:",
|
"img-src 'self' data:",
|
||||||
"media-src 'self'",
|
"media-src 'self'",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'none'",
|
"connect-src 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
"base-uri 'none'",
|
"base-uri 'none'",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ test.beforeEach(async ({ page }) => {
|
|||||||
0;
|
0;
|
||||||
(window as unknown as { __audioContexts: AudioContext[] }).__audioContexts =
|
(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;
|
if (!OriginalAudioContext) return;
|
||||||
class TrackingAudioContext extends OriginalAudioContext {
|
class TrackingAudioContext extends OriginalAudioContext {
|
||||||
constructor(options?: AudioContextOptions) {
|
constructor(options?: AudioContextOptions) {
|
||||||
@@ -18,6 +20,14 @@ test.beforeEach(async ({ page }) => {
|
|||||||
state.__audioConstructed += 1;
|
state.__audioConstructed += 1;
|
||||||
state.__audioContexts.push(this);
|
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", {
|
Object.defineProperty(window, "AudioContext", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -238,6 +248,35 @@ test("requires a direct visitor action after browser interruption without creati
|
|||||||
.toBe(1);
|
.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 ({
|
test("shows a concise non-blocking message when initial audio start is rejected", async ({
|
||||||
page,
|
page,
|
||||||
distUrl,
|
distUrl,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ async function fixtureFetch() {
|
|||||||
}
|
}
|
||||||
const base = {
|
const base = {
|
||||||
'x-portfolio-manifest-digest': digest,
|
'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',
|
'strict-transport-security': 'max-age=31536000',
|
||||||
'x-content-type-options': 'nosniff',
|
'x-content-type-options': 'nosniff',
|
||||||
'referrer-policy': 'no-referrer',
|
'referrer-policy': 'no-referrer',
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const contentTypes: Readonly<Record<string, string>> = {
|
|||||||
".js": "text/javascript; charset=utf-8",
|
".js": "text/javascript; charset=utf-8",
|
||||||
".json": "application/json; charset=utf-8",
|
".json": "application/json; charset=utf-8",
|
||||||
".map": "application/json; charset=utf-8",
|
".map": "application/json; charset=utf-8",
|
||||||
|
".mp3": "audio/mpeg",
|
||||||
|
".opus": "audio/ogg",
|
||||||
".png": "image/png",
|
".png": "image/png",
|
||||||
".svg": "image/svg+xml; charset=utf-8",
|
".svg": "image/svg+xml; charset=utf-8",
|
||||||
".txt": "text/plain; charset=utf-8",
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
|||||||
@@ -57,6 +57,36 @@ describe('approved asset publication closure', () => {
|
|||||||
await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined();
|
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 () => {
|
it('rejects a symbolic-link prepared-asset directory', async () => {
|
||||||
const root = await createScratchRepository();
|
const root = await createScratchRepository();
|
||||||
const target = join(root, 'outside');
|
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("default-src 'self'; script-src 'self' 'sha256-");
|
||||||
expect(config).toContain("img-src 'self' data:");
|
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 Strict-Transport-Security "max-age=31536000" always;');
|
||||||
expect(config).toContain(`add_header X-Portfolio-Manifest-Digest "${releaseManifestDigest}" always;`);
|
expect(config).toContain(`add_header X-Portfolio-Manifest-Digest "${releaseManifestDigest}" always;`);
|
||||||
expect(config).toContain('gzip on;');
|
expect(config).toContain('gzip on;');
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ describe('approved public portfolio projection', () => {
|
|||||||
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) =>
|
||||||
technology.assetId === undefined ? [] : [technology.assetId])),
|
technology.assetId === undefined ? [] : [technology.assetId])),
|
||||||
...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]),
|
...(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([...publishedReferences].sort()).toEqual(manifest.assets.map((asset) => asset.id).sort());
|
||||||
expect(site).toEqual({
|
expect(site).toEqual({
|
||||||
|
|||||||
@@ -57,6 +57,16 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
|||||||
await mkdir(join(directory, 'assets'));
|
await mkdir(join(directory, 'assets'));
|
||||||
const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs);
|
const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs);
|
||||||
const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site);
|
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 = {
|
const contract = {
|
||||||
schema: 'portfolio-artifact-contract/v1',
|
schema: 'portfolio-artifact-contract/v1',
|
||||||
buildId: inputs.site.buildId,
|
buildId: inputs.site.buildId,
|
||||||
@@ -65,10 +75,14 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
|||||||
modulePreloads: ['assets/index-test.js'],
|
modulePreloads: ['assets/index-test.js'],
|
||||||
styles: ['assets/index-test.css'],
|
styles: ['assets/index-test.css'],
|
||||||
viteAssets: [],
|
viteAssets: [],
|
||||||
publicAssets: [],
|
publicAssets: publicAssets.map((asset) => asset.path),
|
||||||
files: [
|
files: [
|
||||||
{ path: 'assets/index-test.css', sha256: sha256(clientStylesheet) },
|
{ path: 'assets/index-test.css', sha256: sha256(clientStylesheet) },
|
||||||
{ path: 'assets/index-test.js', sha256: sha256(clientJavaScript) },
|
{ path: 'assets/index-test.js', sha256: sha256(clientJavaScript) },
|
||||||
|
...publicAssetBytes.map((asset) => ({
|
||||||
|
path: asset.path,
|
||||||
|
sha256: createHash('sha256').update(asset.bytes).digest('hex'),
|
||||||
|
})),
|
||||||
],
|
],
|
||||||
} as const;
|
} as const;
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -78,6 +92,7 @@ const writeValidArtifacts = async (site?: SiteConfig) => {
|
|||||||
writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects),
|
writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects),
|
||||||
writeFile(join(directory, 'assets/index-test.js'), clientJavaScript),
|
writeFile(join(directory, 'assets/index-test.js'), clientJavaScript),
|
||||||
writeFile(join(directory, 'assets/index-test.css'), clientStylesheet),
|
writeFile(join(directory, 'assets/index-test.css'), clientStylesheet),
|
||||||
|
...publicAssetBytes.map((asset) => writeFile(join(directory, asset.path), asset.bytes)),
|
||||||
...(hostArtifacts.sitemap === undefined
|
...(hostArtifacts.sitemap === undefined
|
||||||
? []
|
? []
|
||||||
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
|
: [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]),
|
||||||
|
|||||||
@@ -7,11 +7,18 @@ import {
|
|||||||
evaluateRuntimeTraceBudgetEvidence,
|
evaluateRuntimeTraceBudgetEvidence,
|
||||||
validateRuntimeBudgetTraceReport,
|
validateRuntimeBudgetTraceReport,
|
||||||
calculatePeakRequestConcurrency,
|
calculatePeakRequestConcurrency,
|
||||||
|
normalizeDetectedMediaType,
|
||||||
PERFORMANCE_BUDGETS,
|
PERFORMANCE_BUDGETS,
|
||||||
resolveBudgetGateMode,
|
resolveBudgetGateMode,
|
||||||
} from '../../scripts/lib/bundle-budgets.mjs';
|
} from '../../scripts/lib/bundle-budgets.mjs';
|
||||||
|
|
||||||
describe('release budgets', () => {
|
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', () => {
|
it('fails closed unless the caller explicitly selects a runtime trace or transfer-only mode', () => {
|
||||||
expect(() => resolveBudgetGateMode({ transferOnly: false, runtimeTracePath: null }))
|
expect(() => resolveBudgetGateMode({ transferOnly: false, runtimeTracePath: null }))
|
||||||
.toThrow(/runtime trace is required/u);
|
.toThrow(/runtime trace is required/u);
|
||||||
@@ -39,6 +46,7 @@ describe('release budgets', () => {
|
|||||||
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
||||||
highTierExtras: 750 * 1024,
|
highTierExtras: 750 * 1024,
|
||||||
deferredNonVideo: 3.5 * 1024 * 1024,
|
deferredNonVideo: 3.5 * 1024 * 1024,
|
||||||
|
deferredAudio: 13 * 1024 * 1024,
|
||||||
imageSceneConcurrency: 2,
|
imageSceneConcurrency: 2,
|
||||||
videoConcurrency: 1,
|
videoConcurrency: 1,
|
||||||
mobileTextureDimension: 2048,
|
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/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/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-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/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras');
|
||||||
expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u);
|
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', () => {
|
it('derives decoded texture and GPU bytes from allocation formulas', () => {
|
||||||
const result = evaluateRuntimeBudgetEvidence({
|
const result = evaluateRuntimeBudgetEvidence({
|
||||||
schema: 'portfolio-runtime-budget-evidence/v1',
|
schema: 'portfolio-runtime-budget-evidence/v1',
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { createAudioController } from '@/audio/create-audio-controller';
|
import { createAudioController } from '@/audio/create-audio-controller';
|
||||||
import {
|
import type {
|
||||||
createMusicSequencer,
|
AudioGraph,
|
||||||
MUSIC_BPM,
|
AudioGraphFactory,
|
||||||
MUSIC_LOOKAHEAD_SECONDS,
|
AudioJourneyState,
|
||||||
MUSIC_PROGRESSION,
|
} from '@/audio/types';
|
||||||
MUSIC_SCHEDULER_INTERVAL_MS,
|
|
||||||
} from '@/audio/music-sequencer';
|
|
||||||
import { createSynthEngine } from '@/audio/synth-engine';
|
|
||||||
import type { AudioGraph, AudioGraphFactory } from '@/audio/types';
|
|
||||||
|
|
||||||
describe('audio controller lifecycle', () => {
|
describe('audio controller lifecycle', () => {
|
||||||
it('constructs no graph before activation and only one graph for rapid repeated activation', async () => {
|
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);
|
const closing = controller.pageHide(false);
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
|
expect(harness.graph.cancelPending).toHaveBeenCalledOnce();
|
||||||
resume.resolve();
|
resume.resolve();
|
||||||
await Promise.all([starting, closing]);
|
await Promise.all([starting, closing]);
|
||||||
await controller.whenSettled();
|
await controller.whenSettled();
|
||||||
@@ -181,6 +178,7 @@ describe('audio controller lifecycle', () => {
|
|||||||
expect(controller.snapshot()).toEqual({ kind: 'disposed' });
|
expect(controller.snapshot()).toEqual({ kind: 'disposed' });
|
||||||
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
|
expect(harness.graph.connectOutput).not.toHaveBeenCalled();
|
||||||
expect(harness.graph.close).toHaveBeenCalledOnce();
|
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 () => {
|
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');
|
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 harness = createHarness();
|
||||||
const controller = createAudioController({ createGraph: harness.createGraph });
|
const controller = createAudioController({ createGraph: harness.createGraph });
|
||||||
|
|
||||||
|
controller.setJourneyState({ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 2 });
|
||||||
|
expect(harness.constructed).toBe(0);
|
||||||
await controller.startFromGesture();
|
await controller.startFromGesture();
|
||||||
|
|
||||||
controller.setIntensity(2);
|
expect(harness.initialStates).toEqual([
|
||||||
controller.setIntensity(Number.NaN);
|
{ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(3, 0.12);
|
controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 });
|
||||||
expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(4, 0.1056);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('procedural audio graph', () => {
|
expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({
|
||||||
it('keeps the soundtrack clearly ahead of the ambient drive effects across intensity changes', async () => {
|
atStart: false,
|
||||||
vi.useFakeTimers();
|
bias: 'shadow',
|
||||||
const context = new FakeAudioContext();
|
entryPhrase: 2,
|
||||||
|
intensity: 0.73,
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -346,7 +229,7 @@ interface HarnessOptions {
|
|||||||
function createHarness(options: HarnessOptions = {}) {
|
function createHarness(options: HarnessOptions = {}) {
|
||||||
let rejectNextResume = options.resumeRejects === true;
|
let rejectNextResume = options.resumeRejects === true;
|
||||||
const stateListeners = new Set<(state: string) => void>();
|
const stateListeners = new Set<(state: string) => void>();
|
||||||
const graph: AudioGraph = {
|
const graph = {
|
||||||
resume: vi.fn(async () => {
|
resume: vi.fn(async () => {
|
||||||
if (options.resume) return options.resume.promise;
|
if (options.resume) return options.resume.promise;
|
||||||
if (rejectNextResume) {
|
if (rejectNextResume) {
|
||||||
@@ -362,20 +245,24 @@ function createHarness(options: HarnessOptions = {}) {
|
|||||||
connectOutput: vi.fn(),
|
connectOutput: vi.fn(),
|
||||||
disconnectOutput: vi.fn(),
|
disconnectOutput: vi.fn(),
|
||||||
setOutputGain: vi.fn(),
|
setOutputGain: vi.fn(),
|
||||||
setIntensity: vi.fn(),
|
setJourneyState: vi.fn(),
|
||||||
|
cancelPending: vi.fn(),
|
||||||
onContextStateChange: vi.fn((listener) => {
|
onContextStateChange: vi.fn((listener) => {
|
||||||
stateListeners.add(listener);
|
stateListeners.add(listener);
|
||||||
return () => stateListeners.delete(listener);
|
return () => stateListeners.delete(listener);
|
||||||
}),
|
}),
|
||||||
};
|
} satisfies AudioGraph;
|
||||||
let constructed = 0;
|
let constructed = 0;
|
||||||
const createGraph: AudioGraphFactory = () => {
|
const initialStates: AudioJourneyState[] = [];
|
||||||
|
const createGraph: AudioGraphFactory = (initialState?: AudioJourneyState) => {
|
||||||
constructed += 1;
|
constructed += 1;
|
||||||
|
if (initialState) initialStates.push(initialState);
|
||||||
return graph;
|
return graph;
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
graph,
|
graph,
|
||||||
createGraph,
|
createGraph,
|
||||||
|
initialStates,
|
||||||
get constructed() { return constructed; },
|
get constructed() { return constructed; },
|
||||||
rejectNextResume() { rejectNextResume = true; },
|
rejectNextResume() { rejectNextResume = true; },
|
||||||
emitState(state: string) { for (const listener of stateListeners) listener(state); },
|
emitState(state: string) { for (const listener of stateListeners) listener(state); },
|
||||||
@@ -397,107 +284,3 @@ function deferred<T>(): Deferred<T> {
|
|||||||
});
|
});
|
||||||
return { promise, resolve, reject };
|
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 }> }>;
|
toolkit: Array<{ technologies: Array<{ assetId?: string }> }>;
|
||||||
metadata: { socialPreviewAssetId?: string };
|
metadata: { socialPreviewAssetId?: string };
|
||||||
|
soundtrack?: {
|
||||||
|
intro: { opusAssetId: string; mp3AssetId: string };
|
||||||
|
main: { opusAssetId: string; mp3AssetId: string };
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture;
|
const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture;
|
||||||
@@ -39,6 +43,56 @@ describe('content schemas', () => {
|
|||||||
expect(parsePublicAssetManifest({ assets: [] })).toEqual({ assets: [] });
|
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', () => {
|
it('accepts the approved generic private project titles', () => {
|
||||||
for (const title of [
|
for (const title of [
|
||||||
'Private self-hosted medication tracker',
|
'Private self-hosted medication tracker',
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ describe('environment-aware crawl and response artifacts', () => {
|
|||||||
' Cache-Control: public, max-age=31536000, immutable',
|
' 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',
|
' X-Content-Type-Options: nosniff',
|
||||||
' Referrer-Policy: no-referrer',
|
' Referrer-Policy: no-referrer',
|
||||||
' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()',
|
' 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("default-src 'self'");
|
||||||
expect(artifacts.headers).toContain("script-src 'self' 'sha256-");
|
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("frame-ancestors 'none'");
|
||||||
expect(artifacts.headers).toContain('X-Frame-Options: DENY');
|
expect(artifacts.headers).toContain('X-Frame-Options: DENY');
|
||||||
expect(artifacts.headers).toContain('X-Content-Type-Options: nosniff');
|
expect(artifacts.headers).toContain('X-Content-Type-Options: nosniff');
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe('static-host security policy', () => {
|
|||||||
"img-src 'self' data:",
|
"img-src 'self' data:",
|
||||||
"media-src 'self'",
|
"media-src 'self'",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'none'",
|
"connect-src 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
"base-uri 'none'",
|
"base-uri 'none'",
|
||||||
"frame-ancestors '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