diff --git a/content/public/asset-manifest.json b/content/public/asset-manifest.json index 49bdd93..ae9a757 100644 --- a/content/public/asset-manifest.json +++ b/content/public/asset-manifest.json @@ -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" + } + ] } diff --git a/content/public/portfolio-content.json b/content/public/portfolio-content.json index c2addbc..1405688 100644 --- a/content/public/portfolio-content.json +++ b/content/public/portfolio-content.json @@ -245,5 +245,15 @@ "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." }, + "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." } diff --git a/inputs/prepared-assets/nightdrive-intro.mp3 b/inputs/prepared-assets/nightdrive-intro.mp3 new file mode 100644 index 0000000..f3b9646 Binary files /dev/null and b/inputs/prepared-assets/nightdrive-intro.mp3 differ diff --git a/inputs/prepared-assets/nightdrive-intro.opus b/inputs/prepared-assets/nightdrive-intro.opus new file mode 100644 index 0000000..c83e838 Binary files /dev/null and b/inputs/prepared-assets/nightdrive-intro.opus differ diff --git a/inputs/prepared-assets/nightdrive-main.mp3 b/inputs/prepared-assets/nightdrive-main.mp3 new file mode 100644 index 0000000..d2dcc83 Binary files /dev/null and b/inputs/prepared-assets/nightdrive-main.mp3 differ diff --git a/inputs/prepared-assets/nightdrive-main.opus b/inputs/prepared-assets/nightdrive-main.opus new file mode 100644 index 0000000..8547803 Binary files /dev/null and b/inputs/prepared-assets/nightdrive-main.opus differ diff --git a/nginx.portfolio.conf.example b/nginx.portfolio.conf.example index 8c57b78..894bcd8 100644 --- a/nginx.portfolio.conf.example +++ b/nginx.portfolio.conf.example @@ -30,7 +30,7 @@ server { gzip_min_length 1; 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 Referrer-Policy "no-referrer" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()" always; diff --git a/scripts/check-bundle-budgets.mjs b/scripts/check-bundle-budgets.mjs index 3c5ffb5..1a5b5e5 100644 --- a/scripts/check-bundle-budgets.mjs +++ b/scripts/check-bundle-budgets.mjs @@ -5,11 +5,12 @@ import { readFile, readdir, stat } from 'node:fs/promises'; import { relative, resolve, sep } from 'node:path'; import sharp from 'sharp'; import { fileTypeFromBuffer } from 'file-type'; -import { probeVideo } from './lib/video-frames.mjs'; +import { probeVideo, runMediaCommand } from './lib/video-frames.mjs'; import { classifyArtifact, evaluateBudgetTotals, evaluateMediaAssetBudget, + normalizeDetectedMediaType, resolveBudgetGateMode, validateRuntimeBudgetTraceReport, } from './lib/bundle-budgets.mjs'; @@ -34,7 +35,7 @@ const listFiles = async (directory) => { 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 : 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.`); } }; +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 content = await readJson(resolve('content/public/portfolio-content.json'), 'Portfolio content'); 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 (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 records = []; @@ -74,7 +96,8 @@ for (const absolute of await listFiles(root)) { if (digest !== manifestAsset.sha256) throw new Error(`Published asset hash mismatch: ${path}`); const mediaType = manifestAsset.mediaType; const detected = await fileTypeFromBuffer(bytes); - const detectedMediaType = detected?.mime ?? (/^\s*]/u.test(bytes.toString('utf8', 0, Math.min(bytes.length, 256))) ? 'image/svg+xml' : null); + const detectedMediaType = normalizeDetectedMediaType(detected?.mime) + ?? (/^\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}`); let probe; 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(); probe = { mediaType, width: image.width, height: image.height, durationMs: null }; 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 { throw new Error(`Manifest asset has unsupported media type: ${manifestAsset.id}`); } @@ -92,11 +119,11 @@ for (const absolute of await listFiles(root)) { manifestAsset, probe, compressedSize(path, bytes), - bucket === 'chart' ? 'chart' : 'project-still', + bucket === 'chart' ? 'chart' : bucket === 'soundtrack-audio' ? 'soundtrack-audio' : 'project-still', ).violations); } else { 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}`); } } @@ -114,6 +141,7 @@ const totals = { deferredProjectVisuals: sum('project-still') + sum('chart'), highTierExtras: sum('high-tier-extras'), 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'); diff --git a/scripts/lib/bundle-budgets.d.mts b/scripts/lib/bundle-budgets.d.mts index b0c62f3..d37cfc0 100644 --- a/scripts/lib/bundle-budgets.d.mts +++ b/scripts/lib/bundle-budgets.d.mts @@ -1,4 +1,5 @@ export const PERFORMANCE_BUDGETS: Readonly>; +export function normalizeDetectedMediaType(mediaType: string | null | undefined): string | null; export function resolveBudgetGateMode(options: { transferOnly: boolean; runtimeTracePath: string | null; @@ -11,7 +12,7 @@ export function evaluateMediaAssetBudget( asset: Readonly>, probe: Readonly> | null, transferBytes: number, - assetClass?: 'chart' | 'project-still', + assetClass?: 'chart' | 'project-still' | 'soundtrack-audio', ): { violations: Array<{ budget: string; actual: number; threshold: number; id?: string }> }; export function evaluateRuntimeBudgetEvidence(evidence: Readonly>): { tiers: Array<{ tier: string; textureBytes: number; geometryBytes: number; renderTargetBytes: number; runtimeAllowanceBytes: number; totalBytes: number }>; diff --git a/scripts/lib/bundle-budgets.mjs b/scripts/lib/bundle-budgets.mjs index fef1c34..662d0fa 100644 --- a/scripts/lib/bundle-budgets.mjs +++ b/scripts/lib/bundle-budgets.mjs @@ -13,6 +13,7 @@ export const PERFORMANCE_BUDGETS = Object.freeze({ deferredProjectVisuals: 2.5 * 1024 * 1024, highTierExtras: 750 * 1024, deferredNonVideo: 3.5 * 1024 * 1024, + deferredAudio: 13 * 1024 * 1024, imageSceneConcurrency: 2, videoConcurrency: 1, mobileTextureDimension: 2048, @@ -20,6 +21,10 @@ export const PERFORMANCE_BUDGETS = Object.freeze({ highTierGpuEstimate: 192 * 1024 * 1024, }); +export const normalizeDetectedMediaType = (mediaType) => ( + typeof mediaType === 'string' ? mediaType.split(';', 1)[0].trim() : null +); + export const resolveBudgetGateMode = ({ transferOnly, runtimeTracePath }) => { if (transferOnly && runtimeTracePath !== null) throw new Error('Transfer-only mode cannot be combined with runtime evidence.'); 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-[^/]*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\/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'; throw new Error(`Artifact is unclassified: ${path}`); }; @@ -51,6 +57,7 @@ const thresholdByTotal = Object.freeze({ firstLiveScene: PERFORMANCE_BUDGETS.firstLiveScene, deferredProjectVisuals: PERFORMANCE_BUDGETS.deferredProjectVisuals, deferredNonVideo: PERFORMANCE_BUDGETS.deferredNonVideo, + deferredAudio: PERFORMANCE_BUDGETS.deferredAudio, 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 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 (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) || !finitePositiveInteger(probe.width) || !finitePositiveInteger(probe.height)) { 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}.`); } const violations = []; diff --git a/scripts/materialize-empty-asset-set.mjs b/scripts/materialize-empty-asset-set.mjs index d6081b1..dd2208b 100644 --- a/scripts/materialize-empty-asset-set.mjs +++ b/scripts/materialize-empty-asset-set.mjs @@ -82,7 +82,7 @@ const readManifest = async (repositoryRoot) => { } if (ids.has(id)) throw new Error(`Duplicate manifest asset ID: ${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}`); } const foldedFileName = fileName.toLocaleLowerCase('en-US'); @@ -128,6 +128,18 @@ const assetReferences = (content) => { throw new Error('Portfolio content must contain metadata.'); } 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; }; diff --git a/scripts/smoke-production.mjs b/scripts/smoke-production.mjs index 2f61cde..909f339 100644 --- a/scripts/smoke-production.mjs +++ b/scripts/smoke-production.mjs @@ -48,7 +48,7 @@ export function verifyNoJavaScriptDocument(html) { function verifySecurityHeaders(response, expectedDigest) { requireHeader(response, 'x-portfolio-manifest-digest', expectedDigest); 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 (/unsafe-inline|unsafe-eval|\*/u.test(csp)) throw new Error('Content-Security-Policy contains an unsafe source expression.'); diff --git a/scripts/verify-artifacts.mjs b/scripts/verify-artifacts.mjs index 7a22bb6..a26dd4b 100644 --- a/scripts/verify-artifacts.mjs +++ b/scripts/verify-artifacts.mjs @@ -321,7 +321,7 @@ const assertMetadata = (html, inputs) => { const assertSecurityHeaders = (headers, inputs, serializedJsonLd) => { const required = [ "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', 'Referrer-Policy: no-referrer', 'X-Frame-Options: DENY', 'Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()', diff --git a/src/App.vue b/src/App.vue index d417eed..f2d6de6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,6 @@ defineProps<{ inputs: Readonly }>(); diff --git a/src/audio/create-audio-controller.ts b/src/audio/create-audio-controller.ts index 71ed933..9fb814a 100644 --- a/src/audio/create-audio-controller.ts +++ b/src/audio/create-audio-controller.ts @@ -2,15 +2,16 @@ import type { AudioController, AudioControllerOptions, AudioGraph, + AudioJourneyState, AudioUiState, } from '@/audio/types'; +import { DEFAULT_AUDIO_JOURNEY_STATE } from '@/audio/journey-music-state'; type ActualState = AudioUiState['kind']; type DesiredState = 'idle' | 'running' | 'suspended' | 'closed'; type ResumeIntent = 'none' | 'automatic-after-visibility' | 'user-required'; const DEFAULT_VOLUME = 0.4; -const INTENSITY_SMOOTHING = 0.12; export function createAudioController(options: AudioControllerOptions): AudioController { let actual: ActualState = 'idle'; @@ -20,7 +21,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon let visible = options.initiallyVisible ?? true; let muted = false; 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 graphClosed = false; let removeStateListener = (): void => {}; @@ -63,6 +64,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon const closeGraph = async (): Promise => { if (!graph || graphClosed) return; graphClosed = true; + graph.cancelPending(); removeStateListener(); removeStateListener = (): void => {}; try { @@ -94,7 +96,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon if (!graph || graphClosed) return; graph.connectOutput(); graph.setOutputGain(muted ? 0 : volume); - graph.setIntensity(intensity); + graph.setJourneyState(journeyState); resumeIntent = 'none'; setActual('running'); }; @@ -150,12 +152,12 @@ export function createAudioController(options: AudioControllerOptions): AudioCon try { // This factory call intentionally remains synchronous in the activating event stack. - graph = options.createGraph(); + graph = options.createGraph(journeyState); removeStateListener = graph.onContextStateChange((state) => { if (state === 'interrupted') void noteInterruption(); }); graph.setOutputGain(0); - graph.setIntensity(intensity); + graph.setJourneyState(journeyState); } catch { startOperation = enqueue(fail); return startOperation; @@ -243,6 +245,7 @@ export function createAudioController(options: AudioControllerOptions): AudioCon visible = false; operationGeneration += 1; silence(); + graph?.cancelPending(); setActual('disposed'); void enqueue(closeGraph); }; @@ -262,11 +265,10 @@ export function createAudioController(options: AudioControllerOptions): AudioCon graph?.setOutputGain(muted ? 0 : volume); if (actual === 'running') publish(); }, - setIntensity(nextIntensity) { + setJourneyState(nextState) { if (actual === 'disposed') return; - const target = clamp(nextIntensity); - intensity += (target - intensity) * INTENSITY_SMOOTHING; - graph?.setIntensity(intensity); + journeyState = normalizeJourneyState(nextState); + graph?.setJourneyState(journeyState); }, setVisible, noteInterruption, @@ -287,3 +289,13 @@ function clamp(value: number): number { if (!Number.isFinite(value)) return 0; 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), + }); +} diff --git a/src/audio/journey-music-state.ts b/src/audio/journey-music-state.ts new file mode 100644 index 0000000..95e4c94 --- /dev/null +++ b/src/audio/journey-music-state.ts @@ -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'); diff --git a/src/audio/music-sequencer.ts b/src/audio/music-sequencer.ts deleted file mode 100644 index 54eb9bb..0000000 --- a/src/audio/music-sequencer.ts +++ /dev/null @@ -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(); - 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 | 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)); -} diff --git a/src/audio/soundtrack-engine.ts b/src/audio/soundtrack-engine.ts new file mode 100644 index 0000000..289907f --- /dev/null +++ b/src/audio/soundtrack-engine.ts @@ -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> = 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(); + 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 | null = null; + let loadController: AbortController | null = null; + let loadPromise: Promise | 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 => { + 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 { + 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( + 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)); +} diff --git a/src/audio/soundtrack-transport.ts b/src/audio/soundtrack-transport.ts new file mode 100644 index 0000000..9e41a9e --- /dev/null +++ b/src/audio/soundtrack-transport.ts @@ -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; +} diff --git a/src/audio/synth-engine.ts b/src/audio/synth-engine.ts deleted file mode 100644 index 261ae6b..0000000 --- a/src/audio/synth-engine.ts +++ /dev/null @@ -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 = [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)); -} diff --git a/src/audio/types.ts b/src/audio/types.ts index aa743ae..9309c35 100644 --- a/src/audio/types.ts +++ b/src/audio/types.ts @@ -8,6 +8,25 @@ export type AudioUiState = 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 { resume(): Promise; suspend(): Promise; @@ -15,23 +34,18 @@ export interface AudioGraph { connectOutput(): void; disconnectOutput(): void; setOutputGain(value: number): void; - setIntensity(value: number): void; + setJourneyState(state: AudioJourneyState): void; + cancelPending(): void; onContextStateChange(listener: (state: AudioContextLifecycleState) => void): () => void; } -export interface MusicSequencer { - start(atTime: number): void; - scheduleThrough(horizonTime: number): void; - setIntensity(value: number, atTime: number): void; - stop(): void; -} - -export type AudioGraphFactory = () => AudioGraph; +export type AudioGraphFactory = (initialState: AudioJourneyState) => AudioGraph; export interface AudioControllerOptions { createGraph: AudioGraphFactory; initialVolume?: number; initiallyVisible?: boolean; + initialJourneyState?: AudioJourneyState; } export interface AudioController { @@ -39,7 +53,7 @@ export interface AudioController { resumeFromGesture(): Promise; setMuted(muted: boolean): void; setVolume(volume: number): void; - setIntensity(intensity: number): void; + setJourneyState(state: AudioJourneyState): void; setVisible(visible: boolean): Promise; noteInterruption(): Promise; pageHide(persisted: boolean): Promise; diff --git a/src/components/AppShell.vue b/src/components/AppShell.vue index c20595e..86dbb0c 100644 --- a/src/components/AppShell.vue +++ b/src/components/AppShell.vue @@ -17,14 +17,19 @@ import { useAudioEngine } from '@/composables/useAudioEngine'; import { useJourneySections } from '@/composables/useJourneySections'; import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation'; 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 { MotionPolicy } from '@/journey/motion-policy'; +import type { SoundtrackSources } from '@/audio/types'; declare const __PORTFOLIO_TEST_BUILD__: boolean; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; -const props = defineProps<{ content: Readonly }>(); +const props = defineProps<{ + content: Readonly; + manifest: Readonly; +}>(); const canonicalSectionIds = [ 'start', 'profile', 'experience', 'infrastructure', 'selected-builds', 'additional-work', 'toolkit', 'contact', @@ -59,8 +64,44 @@ const journey = useJourneySections( navigation.state, computed(() => motion.policy.value.allowSceneRequest), ); +const pendingAudioDestination = shallowRef(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(() => { + 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({ + activeSectionId: audioSectionId, intensity: computed(() => journey.value.acceleration), + soundtrack: soundtrackSources, volume: motion.volume, setVolume: motion.setVolume, }); diff --git a/src/composables/useAudioEngine.ts b/src/composables/useAudioEngine.ts index 37f3e5d..f8d09c4 100644 --- a/src/composables/useAudioEngine.ts +++ b/src/composables/useAudioEngine.ts @@ -1,4 +1,5 @@ import { + computed, onMounted, onUnmounted, readonly, @@ -7,14 +8,17 @@ import { type Ref, } from 'vue'; import { createAudioController } from '@/audio/create-audio-controller'; -import { createBrowserSynthEngine } from '@/audio/synth-engine'; -import type { AudioController, AudioUiState } from '@/audio/types'; +import { createBrowserSoundtrackEngine } from '@/audio/soundtrack-engine'; +import { resolveJourneyMusicState } from '@/audio/journey-music-state'; +import type { AudioController, AudioUiState, SoundtrackSources } from '@/audio/types'; declare const __PORTFOLIO_TEST_BUILD__: boolean; const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__; export interface AudioEngineSources { + activeSectionId: Readonly>; intensity: Readonly>; + soundtrack: Readonly>; volume: Readonly>; setVolume(value: number): void; } @@ -34,20 +38,28 @@ export function useAudioEngine(sources: AudioEngineSources): AudioEngineBinding let controller: AudioController | null = null; let removeSubscription = (): void => {}; let removePageListeners = (): void => {}; + const journeyState = computed(() => resolveJourneyMusicState( + sources.activeSectionId.value, + sources.intensity.value, + )); const ensureController = (): AudioController => { if (controller) return controller; 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, initiallyVisible: document.visibilityState !== 'hidden', + initialJourneyState: journeyState.value, }); removeSubscription = controller.subscribe((next) => { state.value = next; }); - controller.setIntensity(sources.intensity.value); return controller; }; - watch(sources.intensity, (value) => controller?.setIntensity(value)); + watch(journeyState, (state) => controller?.setJourneyState(state), { flush: 'sync' }); watch(sources.volume, (value) => controller?.setVolume(value)); onMounted(() => { diff --git a/src/content/schemas.ts b/src/content/schemas.ts index 47c4790..ff09b30 100644 --- a/src/content/schemas.ts +++ b/src/content/schemas.ts @@ -59,6 +59,11 @@ const navigationItemSchema = z.object({ label: visibleString, }).strict(); +const soundtrackAssetSourcesSchema = z.object({ + opusAssetId: opaqueId, + mp3AssetId: opaqueId, +}).strict(); + const mediaPresentationBase = { assetId: opaqueId, alt: visibleString, @@ -194,6 +199,10 @@ const portfolioContentSchema = z.object({ description: visibleString, socialPreviewAssetId: opaqueId.optional(), }).strict(), + soundtrack: z.object({ + intro: soundtrackAssetSourcesSchema, + main: soundtrackAssetSourcesSchema, + }).strict().optional(), sceneDescription: visibleString, }).strict().superRefine((content, context) => { 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> = { 'image/avif': 'avif', 'image/jpeg': 'jpg', @@ -229,10 +242,12 @@ const fileExtensionByMediaType: Record<(typeof mediaTypes)[number], string> = { 'image/webp': 'webp', 'video/mp4': 'mp4', 'video/webm': 'webm', + 'audio/ogg': 'opus', + 'audio/mpeg': 'mp3', }; const publicAssetSchema = z.object({ 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.'), mediaType: z.enum(mediaTypes), 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'] }); } 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)) { 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'] }); } + 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 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) => technology.assetId === undefined ? [] : [technology.assetId])), ...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]), + ...(content.soundtrack === undefined ? [] : [ + content.soundtrack.intro.opusAssetId, + content.soundtrack.intro.mp3AssetId, + content.soundtrack.main.opusAssetId, + content.soundtrack.main.mp3AssetId, + ]), ]; const seenReferences = new Set(); for (const assetId of referencedAssetIds) { diff --git a/src/content/types.ts b/src/content/types.ts index 7e63037..a83809a 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -133,6 +133,16 @@ export interface MetadataContent { socialPreviewAssetId?: string; } +export interface SoundtrackAssetSources { + opusAssetId: string; + mp3AssetId: string; +} + +export interface SoundtrackContent { + intro: SoundtrackAssetSources; + main: SoundtrackAssetSources; +} + export interface PortfolioContent { identity: { name: 'Luca Pîslaru'; @@ -148,6 +158,7 @@ export interface PortfolioContent { toolkit: readonly ToolkitGroup[]; contact: ContactContent; metadata: MetadataContent; + soundtrack?: SoundtrackContent; sceneDescription: string; } @@ -158,7 +169,9 @@ export type PublicAssetMediaType = | 'image/svg+xml' | 'image/webp' | 'video/mp4' - | 'video/webm'; + | 'video/webm' + | 'audio/ogg' + | 'audio/mpeg'; export interface PublicAsset { id: string; diff --git a/src/security/security-policy.ts b/src/security/security-policy.ts index 1887f65..3b5d1d8 100644 --- a/src/security/security-policy.ts +++ b/src/security/security-policy.ts @@ -31,7 +31,7 @@ export const buildContentSecurityPolicy = (jsonLdCspHash: string): string => { "img-src 'self' data:", "media-src 'self'", "font-src 'self'", - "connect-src 'none'", + "connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", diff --git a/tests/browser/audio-controls.spec.ts b/tests/browser/audio-controls.spec.ts index cb932da..1af3714 100644 --- a/tests/browser/audio-controls.spec.ts +++ b/tests/browser/audio-controls.spec.ts @@ -7,6 +7,8 @@ test.beforeEach(async ({ page }) => { 0; (window as unknown as { __audioContexts: AudioContext[] }).__audioContexts = []; + (window as unknown as { __audioResumeCalls: number }).__audioResumeCalls = 0; + (window as unknown as { __audioSuspendCalls: number }).__audioSuspendCalls = 0; if (!OriginalAudioContext) return; class TrackingAudioContext extends OriginalAudioContext { constructor(options?: AudioContextOptions) { @@ -18,6 +20,14 @@ test.beforeEach(async ({ page }) => { state.__audioConstructed += 1; state.__audioContexts.push(this); } + override resume(): Promise { + (window as unknown as { __audioResumeCalls: number }).__audioResumeCalls += 1; + return super.resume(); + } + override suspend(): Promise { + (window as unknown as { __audioSuspendCalls: number }).__audioSuspendCalls += 1; + return super.suspend(); + } } Object.defineProperty(window, "AudioContext", { configurable: true, @@ -238,6 +248,35 @@ test("requires a direct visitor action after browser interruption without creati .toBe(1); }); +test("automatically suspends and resumes the same graph across ordinary visibility changes", async ({ + page, + distUrl, +}) => { + await page.goto(distUrl, { waitUntil: "domcontentloaded" }); + await page.getByRole("button", { name: "Play soundtrack" }).click(); + await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible(); + + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect.poll(() => page.evaluate(() => ( + window as unknown as { __audioSuspendCalls: number } + ).__audioSuspendCalls)).toBe(1); + + await page.evaluate(() => { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + document.dispatchEvent(new Event("visibilitychange")); + }); + await expect.poll(() => page.evaluate(() => ( + window as unknown as { __audioResumeCalls: number } + ).__audioResumeCalls)).toBe(2); + expect(await page.evaluate(() => ( + window as unknown as { __audioConstructed: number } + ).__audioConstructed)).toBe(1); + await expect(page.getByRole("button", { name: "Mute sound" })).toBeVisible(); +}); + test("shows a concise non-blocking message when initial audio start is rejected", async ({ page, distUrl, diff --git a/tests/deployment/seo.spec.ts b/tests/deployment/seo.spec.ts index a6c035f..8289c10 100644 --- a/tests/deployment/seo.spec.ts +++ b/tests/deployment/seo.spec.ts @@ -32,7 +32,7 @@ async function fixtureFetch() { } const base = { 'x-portfolio-manifest-digest': digest, - 'content-security-policy': "default-src 'self'; script-src 'self' 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests", + 'content-security-policy': "default-src 'self'; script-src 'self' 'sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests", 'strict-transport-security': 'max-age=31536000', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', diff --git a/tests/helpers/serve-dist.ts b/tests/helpers/serve-dist.ts index 470e5d4..a4ad434 100644 --- a/tests/helpers/serve-dist.ts +++ b/tests/helpers/serve-dist.ts @@ -17,6 +17,8 @@ const contentTypes: Readonly> = { ".js": "text/javascript; charset=utf-8", ".json": "application/json; charset=utf-8", ".map": "application/json; charset=utf-8", + ".mp3": "audio/mpeg", + ".opus": "audio/ogg", ".png": "image/png", ".svg": "image/svg+xml; charset=utf-8", ".txt": "text/plain; charset=utf-8", diff --git a/tests/integration/materialize-empty-asset-set.test.mjs b/tests/integration/materialize-empty-asset-set.test.mjs index a525115..82b574a 100644 --- a/tests/integration/materialize-empty-asset-set.test.mjs +++ b/tests/integration/materialize-empty-asset-set.test.mjs @@ -57,6 +57,36 @@ describe('approved asset publication closure', () => { await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined(); }); + it('includes every soundtrack codec variant in the approved publication closure', async () => { + const root = await createScratchRepository(); + const assets = [ + ['soundtrack-intro-opus', 'nightdrive-intro.opus'], + ['soundtrack-intro-mp3', 'nightdrive-intro.mp3'], + ['soundtrack-main-opus', 'nightdrive-main.opus'], + ['soundtrack-main-mp3', 'nightdrive-main.mp3'], + ].map(([id, fileName], index) => { + const bytes = Buffer.from(`approved soundtrack asset ${index}`); + return { id, fileName, bytes, sha256: createHash('sha256').update(bytes).digest('hex') }; + }); + await writeFile(join(root, 'content/public/asset-manifest.json'), JSON.stringify({ + assets: assets.map(({ id, fileName, sha256 }) => ({ id, fileName, sha256 })), + })); + await writeFile(join(root, 'content/public/portfolio-content.json'), JSON.stringify({ + projects: { selected: [], additional: [] }, + toolkit: [], + metadata: {}, + soundtrack: { + intro: { opusAssetId: 'soundtrack-intro-opus', mp3AssetId: 'soundtrack-intro-mp3' }, + main: { opusAssetId: 'soundtrack-main-opus', mp3AssetId: 'soundtrack-main-mp3' }, + }, + })); + await mkdir(join(root, 'inputs/prepared-assets'), { recursive: true }); + await Promise.all(assets.map(({ fileName, bytes }) => + writeFile(join(root, 'inputs/prepared-assets', fileName), bytes))); + + await expect(verifyApprovedAssetSet(root)).resolves.toBeUndefined(); + }); + it('rejects a symbolic-link prepared-asset directory', async () => { const root = await createScratchRepository(); const target = join(root, 'outside'); diff --git a/tests/integration/nginx-config.test.ts b/tests/integration/nginx-config.test.ts index bd6a92a..cd901e0 100644 --- a/tests/integration/nginx-config.test.ts +++ b/tests/integration/nginx-config.test.ts @@ -88,7 +88,7 @@ describe('generated Nginx static-host contract', () => { expect(config).toContain("default-src 'self'; script-src 'self' 'sha256-"); expect(config).toContain("img-src 'self' data:"); - expect(config).toContain("connect-src 'none'"); + expect(config).toContain("connect-src 'self'"); expect(config).toContain('add_header Strict-Transport-Security "max-age=31536000" always;'); expect(config).toContain(`add_header X-Portfolio-Manifest-Digest "${releaseManifestDigest}" always;`); expect(config).toContain('gzip on;'); diff --git a/tests/integration/public-content.test.ts b/tests/integration/public-content.test.ts index 6c880e0..28e7ea0 100644 --- a/tests/integration/public-content.test.ts +++ b/tests/integration/public-content.test.ts @@ -42,6 +42,12 @@ describe('approved public portfolio projection', () => { ...content.toolkit.flatMap((group) => group.technologies.flatMap((technology) => technology.assetId === undefined ? [] : [technology.assetId])), ...(content.metadata.socialPreviewAssetId === undefined ? [] : [content.metadata.socialPreviewAssetId]), + ...(content.soundtrack === undefined ? [] : [ + content.soundtrack.intro.opusAssetId, + content.soundtrack.intro.mp3AssetId, + content.soundtrack.main.opusAssetId, + content.soundtrack.main.mp3AssetId, + ]), ]; expect([...publishedReferences].sort()).toEqual(manifest.assets.map((asset) => asset.id).sort()); expect(site).toEqual({ diff --git a/tests/integration/verify-artifacts.test.ts b/tests/integration/verify-artifacts.test.ts index 08ca697..a95a21f 100644 --- a/tests/integration/verify-artifacts.test.ts +++ b/tests/integration/verify-artifacts.test.ts @@ -57,6 +57,16 @@ const writeValidArtifacts = async (site?: SiteConfig) => { await mkdir(join(directory, 'assets')); const html = assemblePrerenderedHtml(template, manifest, await render('/', inputs), inputs); const hostArtifacts = createStaticHostArtifacts(createPageMetadata(inputs.content, inputs.manifest, inputs.site), inputs.site); + const publicAssets = inputs.manifest.assets + .map((asset) => ({ + path: `assets/${asset.fileName}`, + bytesPath: resolve(repositoryRoot, 'inputs/prepared-assets', asset.fileName), + })) + .sort((left, right) => left.path.localeCompare(right.path)); + const publicAssetBytes = await Promise.all(publicAssets.map(async (asset) => ({ + ...asset, + bytes: await readFile(asset.bytesPath), + }))); const contract = { schema: 'portfolio-artifact-contract/v1', buildId: inputs.site.buildId, @@ -65,10 +75,14 @@ const writeValidArtifacts = async (site?: SiteConfig) => { modulePreloads: ['assets/index-test.js'], styles: ['assets/index-test.css'], viteAssets: [], - publicAssets: [], + publicAssets: publicAssets.map((asset) => asset.path), files: [ { path: 'assets/index-test.css', sha256: sha256(clientStylesheet) }, { path: 'assets/index-test.js', sha256: sha256(clientJavaScript) }, + ...publicAssetBytes.map((asset) => ({ + path: asset.path, + sha256: createHash('sha256').update(asset.bytes).digest('hex'), + })), ], } as const; await Promise.all([ @@ -78,6 +92,7 @@ const writeValidArtifacts = async (site?: SiteConfig) => { writeFile(join(directory, '_redirects.example'), hostArtifacts.redirects), writeFile(join(directory, 'assets/index-test.js'), clientJavaScript), writeFile(join(directory, 'assets/index-test.css'), clientStylesheet), + ...publicAssetBytes.map((asset) => writeFile(join(directory, asset.path), asset.bytes)), ...(hostArtifacts.sitemap === undefined ? [] : [writeFile(join(directory, 'sitemap.xml'), hostArtifacts.sitemap)]), diff --git a/tests/performance/budgets.test.ts b/tests/performance/budgets.test.ts index 9f59ae2..176c5f9 100644 --- a/tests/performance/budgets.test.ts +++ b/tests/performance/budgets.test.ts @@ -7,11 +7,18 @@ import { evaluateRuntimeTraceBudgetEvidence, validateRuntimeBudgetTraceReport, calculatePeakRequestConcurrency, + normalizeDetectedMediaType, PERFORMANCE_BUDGETS, resolveBudgetGateMode, } from '../../scripts/lib/bundle-budgets.mjs'; describe('release budgets', () => { + it('normalizes codec-qualified Opus detection to its manifest container type', () => { + expect(normalizeDetectedMediaType('audio/ogg; codecs=opus')).toBe('audio/ogg'); + expect(normalizeDetectedMediaType('audio/mpeg')).toBe('audio/mpeg'); + expect(normalizeDetectedMediaType(null)).toBeNull(); + }); + it('fails closed unless the caller explicitly selects a runtime trace or transfer-only mode', () => { expect(() => resolveBudgetGateMode({ transferOnly: false, runtimeTracePath: null })) .toThrow(/runtime trace is required/u); @@ -39,6 +46,7 @@ describe('release budgets', () => { deferredProjectVisuals: 2.5 * 1024 * 1024, highTierExtras: 750 * 1024, deferredNonVideo: 3.5 * 1024 * 1024, + deferredAudio: 13 * 1024 * 1024, imageSceneConcurrency: 2, videoConcurrency: 1, mobileTextureDimension: 2048, @@ -53,6 +61,8 @@ describe('release budgets', () => { expect(classifyArtifact('assets/index-a.js', 'import("./environment-b.js")')).toBe('initial-application-js'); expect(classifyArtifact('assets/environment-b.js', 'three scene')).toBe('first-live-scene'); expect(classifyArtifact('assets/nightdrive-grand-tourer-a.glb', '')).toBe('first-live-scene'); + expect(classifyArtifact('assets/nightdrive-main.opus', '')).toBe('soundtrack-audio'); + expect(classifyArtifact('assets/nightdrive-intro.mp3', '')).toBe('soundtrack-audio'); expect(classifyArtifact('assets/high-tier-b.js', 'optional bloom')).toBe('high-tier-extras'); expect(() => classifyArtifact('assets/mystery.bin', '')).toThrow(/unclassified/u); }); @@ -108,6 +118,24 @@ describe('release budgets', () => { ])); }); + it('validates soundtrack duration without visual dimensions and budgets aggregate transfer', () => { + const asset = { + id: 'soundtrack-main-opus', + mediaType: 'audio/ogg', + width: null, + height: null, + durationMs: 178_605, + }; + + expect(evaluateMediaAssetBudget(asset, { ...asset }, 4 * 1024 * 1024, 'soundtrack-audio').violations).toEqual([]); + expect(() => evaluateMediaAssetBudget({ ...asset, width: 1 }, { ...asset, width: 1 }, 1_024, 'soundtrack-audio')) + .toThrow(/audio dimensions/i); + expect(() => evaluateMediaAssetBudget({ ...asset, durationMs: null }, { ...asset, durationMs: null }, 1_024, 'soundtrack-audio')) + .toThrow(/verified duration/i); + expect(evaluateBudgetTotals({ deferredAudio: PERFORMANCE_BUDGETS.deferredAudio! + 1 }).violations) + .toContainEqual(expect.objectContaining({ budget: 'deferredAudio' })); + }); + it('derives decoded texture and GPU bytes from allocation formulas', () => { const result = evaluateRuntimeBudgetEvidence({ schema: 'portfolio-runtime-budget-evidence/v1', diff --git a/tests/unit/audio-lifecycle.test.ts b/tests/unit/audio-lifecycle.test.ts index ef5b4a7..2e98593 100644 --- a/tests/unit/audio-lifecycle.test.ts +++ b/tests/unit/audio-lifecycle.test.ts @@ -1,14 +1,10 @@ import { describe, expect, it, vi } from 'vitest'; import { createAudioController } from '@/audio/create-audio-controller'; -import { - createMusicSequencer, - MUSIC_BPM, - MUSIC_LOOKAHEAD_SECONDS, - MUSIC_PROGRESSION, - MUSIC_SCHEDULER_INTERVAL_MS, -} from '@/audio/music-sequencer'; -import { createSynthEngine } from '@/audio/synth-engine'; -import type { AudioGraph, AudioGraphFactory } from '@/audio/types'; +import type { + AudioGraph, + AudioGraphFactory, + AudioJourneyState, +} from '@/audio/types'; describe('audio controller lifecycle', () => { it('constructs no graph before activation and only one graph for rapid repeated activation', async () => { @@ -174,6 +170,7 @@ describe('audio controller lifecycle', () => { const closing = controller.pageHide(false); controller.dispose(); + expect(harness.graph.cancelPending).toHaveBeenCalledOnce(); resume.resolve(); await Promise.all([starting, closing]); await controller.whenSettled(); @@ -181,6 +178,7 @@ describe('audio controller lifecycle', () => { expect(controller.snapshot()).toEqual({ kind: 'disposed' }); expect(harness.graph.connectOutput).not.toHaveBeenCalled(); expect(harness.graph.close).toHaveBeenCalledOnce(); + expect(harness.graph.cancelPending).toHaveBeenCalledTimes(2); }); it('ignores lifecycle events before activation and after failure without creating another graph', async () => { @@ -198,141 +196,26 @@ describe('audio controller lifecycle', () => { expect(controller.snapshot().kind).toBe('failed'); }); - it('clamps and slowly smooths drive intensity before forwarding it to the graph', async () => { + it('stores the latest semantic state before activation and forwards raw clamped updates', async () => { const harness = createHarness(); const controller = createAudioController({ createGraph: harness.createGraph }); + + controller.setJourneyState({ atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 2 }); + expect(harness.constructed).toBe(0); await controller.startFromGesture(); - controller.setIntensity(2); - controller.setIntensity(Number.NaN); + expect(harness.initialStates).toEqual([ + { atStart: false, bias: 'resolve', entryPhrase: 7, intensity: 1 }, + ]); - expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(3, 0.12); - expect(harness.graph.setIntensity).toHaveBeenNthCalledWith(4, 0.1056); - }); -}); + controller.setJourneyState({ atStart: false, bias: 'shadow', entryPhrase: 2, intensity: 0.73 }); -describe('procedural audio graph', () => { - it('keeps the soundtrack clearly ahead of the ambient drive effects across intensity changes', async () => { - vi.useFakeTimers(); - const context = new FakeAudioContext(); - - try { - const graph = createSynthEngine(() => context as unknown as AudioContext); - const [, musicGain, engineGain, , , roadGain, ignitionGain] = context.gains; - - expect(musicGain?.gain.value).toBe(0.78); - expect(engineGain?.gain.value).toBe(0.09); - expect(roadGain?.gain.value).toBe(0.018); - expect(ignitionGain?.gain.scheduledValues).toEqual([0.0001, 0.025, 0.0001]); - - graph.setIntensity(1); - expect(engineGain?.gain.targetValues).toContain(0.13); - expect(roadGain?.gain.targetValues).toContain(0.038); - - await graph.close(); - } finally { - vi.useRealTimers(); - } - }); - - it('routes independent music, engine, and road buses through compression before the master output', async () => { - vi.useFakeTimers(); - const context = new FakeAudioContext(); - - try { - const graph = createSynthEngine(() => context as unknown as AudioContext); - context.currentTime = 1.4; - vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS); - - const [master, musicGain, engineGain, , , roadGain] = context.gains; - const [musicFilter, engineFilter, roadFilter] = context.filters; - const [compressor] = context.compressors; - expect(musicFilter?.connections.has(musicGain)).toBe(true); - expect(engineFilter?.connections.has(engineGain)).toBe(true); - expect(roadFilter?.connections.has(roadGain)).toBe(true); - expect(musicGain?.connections.has(compressor)).toBe(true); - expect(engineGain?.connections.has(compressor)).toBe(true); - expect(roadGain?.connections.has(compressor)).toBe(true); - expect(compressor?.connections.has(master)).toBe(true); - expect(context.destinationConnections).toBe(0); - expect(context.startedNodes).toBeGreaterThan(4); - expect(context.bufferSources.length).toBeGreaterThanOrEqual(2); - expect(context.filters.length).toBeGreaterThanOrEqual(4); - - graph.connectOutput(); - graph.setOutputGain(0.6); - graph.setIntensity(1); - expect(context.destinationConnections).toBe(1); - expect(context.parameters.some((parameter) => parameter.targetValues.includes(930))).toBe(true); - expect(context.parameters.some((parameter) => parameter.targetValues.includes(3_300))).toBe(true); - - graph.disconnectOutput(); - await graph.close(); - await graph.close(); - expect(context.destinationConnections).toBe(0); - expect(context.closeCalls).toBe(1); - expect(context.stoppedNodes).toBe(context.startedNodes); - expect(vi.getTimerCount()).toBe(0); - } finally { - vi.useRealTimers(); - } - }); -}); - -describe('procedural music sequencer', () => { - it('schedules the fixed four-bar progression through a 100ms/250ms look-ahead clock', () => { - vi.useFakeTimers(); - const context = new FakeAudioContext(); - const destination = context.createGain(); - - try { - expect(MUSIC_BPM).toBe(86); - expect(MUSIC_SCHEDULER_INTERVAL_MS).toBe(100); - expect(MUSIC_LOOKAHEAD_SECONDS).toBe(0.25); - expect(MUSIC_PROGRESSION.map((chord) => chord.name)).toEqual([ - 'F minor', 'D-flat major', 'A-flat major', 'E-flat major', - ]); - - const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode); - expect(context.startedNodes).toBe(0); - sequencer.start(0); - const initialStarted = context.startedNodes; - expect(initialStarted).toBeGreaterThan(0); - expect(vi.getTimerCount()).toBe(1); - - context.currentTime = 0.1; - vi.advanceTimersByTime(MUSIC_SCHEDULER_INTERVAL_MS); - expect(context.startedNodes).toBeGreaterThan(initialStarted); - - sequencer.scheduleThrough(11.2); - const frequencies = new Set(context.oscillators.map((oscillator) => Number(oscillator.frequency.value.toFixed(2)))); - for (const root of [174.61, 138.59, 207.65, 155.56]) expect(frequencies.has(root)).toBe(true); - - sequencer.stop(); - expect(vi.getTimerCount()).toBe(0); - expect(context.stoppedNodes).toBe(context.startedNodes); - } finally { - vi.useRealTimers(); - } - }); - - it('raises future pulse energy with intensity without changing the musical clock', () => { - vi.useFakeTimers(); - const context = new FakeAudioContext(); - const destination = context.createGain(); - - try { - const sequencer = createMusicSequencer(context as unknown as AudioContext, destination as unknown as AudioNode); - sequencer.start(0); - sequencer.setIntensity(1, 0); - sequencer.scheduleThrough(0.6); - - expect(context.parameters.some((parameter) => parameter.scheduledValues.includes(0.022))).toBe(true); - expect(MUSIC_BPM).toBe(86); - sequencer.stop(); - } finally { - vi.useRealTimers(); - } + expect(harness.graph.setJourneyState).toHaveBeenLastCalledWith({ + atStart: false, + bias: 'shadow', + entryPhrase: 2, + intensity: 0.73, + }); }); }); @@ -346,7 +229,7 @@ interface HarnessOptions { function createHarness(options: HarnessOptions = {}) { let rejectNextResume = options.resumeRejects === true; const stateListeners = new Set<(state: string) => void>(); - const graph: AudioGraph = { + const graph = { resume: vi.fn(async () => { if (options.resume) return options.resume.promise; if (rejectNextResume) { @@ -362,20 +245,24 @@ function createHarness(options: HarnessOptions = {}) { connectOutput: vi.fn(), disconnectOutput: vi.fn(), setOutputGain: vi.fn(), - setIntensity: vi.fn(), + setJourneyState: vi.fn(), + cancelPending: vi.fn(), onContextStateChange: vi.fn((listener) => { stateListeners.add(listener); return () => stateListeners.delete(listener); }), - }; + } satisfies AudioGraph; let constructed = 0; - const createGraph: AudioGraphFactory = () => { + const initialStates: AudioJourneyState[] = []; + const createGraph: AudioGraphFactory = (initialState?: AudioJourneyState) => { constructed += 1; + if (initialState) initialStates.push(initialState); return graph; }; return { graph, createGraph, + initialStates, get constructed() { return constructed; }, rejectNextResume() { rejectNextResume = true; }, emitState(state: string) { for (const listener of stateListeners) listener(state); }, @@ -397,107 +284,3 @@ function deferred(): Deferred { }); 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(); - 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 = []; - readonly bufferSources: Array = []; - readonly filters: Array = []; - readonly gains: Array = []; - 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 { this.state = 'running'; } - async suspend(): Promise { this.state = 'suspended'; } - async close(): Promise { this.closeCalls += 1; this.state = 'closed'; } -} diff --git a/tests/unit/content-schemas.test.ts b/tests/unit/content-schemas.test.ts index 2362103..7388afe 100644 --- a/tests/unit/content-schemas.test.ts +++ b/tests/unit/content-schemas.test.ts @@ -24,6 +24,10 @@ type MutableContentFixture = { }; toolkit: Array<{ technologies: Array<{ assetId?: string }> }>; metadata: { socialPreviewAssetId?: string }; + soundtrack?: { + intro: { opusAssetId: string; mp3AssetId: string }; + main: { opusAssetId: string; mp3AssetId: string }; + }; }; const cloneContent = (): MutableContentFixture => structuredClone(validContent) as MutableContentFixture; @@ -39,6 +43,56 @@ describe('content schemas', () => { expect(parsePublicAssetManifest({ assets: [] })).toEqual({ assets: [] }); }); + it('accepts an optional soundtrack whose audio variants have exact manifest closure', () => { + const content = cloneContent(); + content.soundtrack = { + intro: { opusAssetId: 'soundtrack-intro-opus', mp3AssetId: 'soundtrack-intro-mp3' }, + main: { opusAssetId: 'soundtrack-main-opus', mp3AssetId: 'soundtrack-main-mp3' }, + }; + const audioMetadata = { + width: null, + height: null, + rightsHolder: 'Luca Pîslaru', + license: 'All rights reserved', + attribution: 'Original soundtrack by Luca Pîslaru', + }; + const soundtrackAssets = [ + { id: 'soundtrack-intro-opus', fileName: 'nightdrive-intro.opus', sha256: 'a'.repeat(64), mediaType: 'audio/ogg', durationMs: 56_064, ...audioMetadata }, + { id: 'soundtrack-intro-mp3', fileName: 'nightdrive-intro.mp3', sha256: 'b'.repeat(64), mediaType: 'audio/mpeg', durationMs: 56_064, ...audioMetadata }, + { id: 'soundtrack-main-opus', fileName: 'nightdrive-main.opus', sha256: 'c'.repeat(64), mediaType: 'audio/ogg', durationMs: 178_605, ...audioMetadata }, + { id: 'soundtrack-main-mp3', fileName: 'nightdrive-main.mp3', sha256: 'd'.repeat(64), mediaType: 'audio/mpeg', durationMs: 178_605, ...audioMetadata }, + ]; + const manifest = { + assets: [...(validManifest as { assets: unknown[] }).assets, ...soundtrackAssets], + }; + + expect(validateProductionInputs(content, manifest, validSite, new URL('file:///prepared/')).content.soundtrack) + .toEqual(content.soundtrack); + + content.soundtrack.main.mp3AssetId = 'missing-soundtrack'; + expect(() => validateProductionInputs(content, manifest, validSite, new URL('file:///prepared/'))) + .toThrow(/unmanifested asset reference: missing-soundtrack/i); + }); + + it('requires audio assets to have null dimensions and a positive duration', () => { + const audio = { + id: 'soundtrack-main-opus', + fileName: 'nightdrive-main.opus', + sha256: 'a'.repeat(64), + mediaType: 'audio/ogg', + width: null, + height: null, + durationMs: 178_605, + rightsHolder: 'Luca Pîslaru', + license: 'All rights reserved', + attribution: 'Original soundtrack by Luca Pîslaru', + }; + + expect(() => parsePublicAssetManifest({ assets: [audio] })).not.toThrow(); + expect(() => parsePublicAssetManifest({ assets: [{ ...audio, width: 1 }] })).toThrow(/audio assets require null dimensions/i); + expect(() => parsePublicAssetManifest({ assets: [{ ...audio, durationMs: null }] })).toThrow(/audio assets require.*duration/i); + }); + it('accepts the approved generic private project titles', () => { for (const title of [ 'Private self-hosted medication tracker', diff --git a/tests/unit/metadata.test.ts b/tests/unit/metadata.test.ts index 97b5c97..f9a1134 100644 --- a/tests/unit/metadata.test.ts +++ b/tests/unit/metadata.test.ts @@ -110,7 +110,7 @@ describe('environment-aware crawl and response artifacts', () => { ' Cache-Control: public, max-age=31536000, immutable', '', '/*', - ` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests`, + ` Content-Security-Policy: default-src 'self'; script-src 'self' '${metadata.jsonLdCspHash}'; style-src 'self'; img-src 'self' data:; media-src 'self'; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests`, ' X-Content-Type-Options: nosniff', ' Referrer-Policy: no-referrer', ' Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()', @@ -126,7 +126,7 @@ describe('environment-aware crawl and response artifacts', () => { expect(artifacts.headers).toContain("default-src 'self'"); expect(artifacts.headers).toContain("script-src 'self' 'sha256-"); - expect(artifacts.headers).toContain("connect-src 'none'"); + expect(artifacts.headers).toContain("connect-src 'self'"); expect(artifacts.headers).toContain("frame-ancestors 'none'"); expect(artifacts.headers).toContain('X-Frame-Options: DENY'); expect(artifacts.headers).toContain('X-Content-Type-Options: nosniff'); diff --git a/tests/unit/security-policy.test.ts b/tests/unit/security-policy.test.ts index 2b7c35a..e56a2fe 100644 --- a/tests/unit/security-policy.test.ts +++ b/tests/unit/security-policy.test.ts @@ -28,7 +28,7 @@ describe('static-host security policy', () => { "img-src 'self' data:", "media-src 'self'", "font-src 'self'", - "connect-src 'none'", + "connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'", diff --git a/tests/unit/soundtrack-transport.test.ts b/tests/unit/soundtrack-transport.test.ts new file mode 100644 index 0000000..04b77f9 --- /dev/null +++ b/tests/unit/soundtrack-transport.test.ts @@ -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); + }); +});