import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, sep } from 'node:path'; import { spawnSync } from 'node:child_process'; import { createServer } from 'vite'; const audioRoot = join('src', 'assets', 'audio'); const sourceFilesToScan = [ 'src/game/audio/SoundDirector.ts', 'src/game/scenes/BattleScene.ts', 'src/game/scenes/CampScene.ts', 'src/game/scenes/EndingScene.ts', 'src/game/scenes/StoryScene.ts', 'src/game/scenes/TitleScene.ts' ]; const requiredBattlefieldAmbienceKeys = ['battle-rain', 'battle-fire']; const requiredTacticalCueKeys = [ 'ally-turn', 'enemy-turn', 'operation-alert', 'objective-success', 'objective-failure' ]; const requiredNarrativeCueKeys = ['story-page-turn']; const requiredSemanticFeedbackCueKeys = ['recovery-cue', 'reward-reveal', 'burn-tick']; const movementPoolSourceRecords = { 'movement-foot-earth': movementSourceRecord('footstep', 'earth', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), 'movement-foot-stone': movementSourceRecord('footstep', 'stone', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), 'movement-foot-wood': movementSourceRecord('footstep', 'wood', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), 'movement-foot-wet': movementSourceRecord('footstep', 'wet', 'footstep-walk.mp3', 'vgraham1 (Freesound)', 'https://pixabay.com/sound-effects/film-special-effects-walking-through-grass-80308/'), 'movement-hoof-earth': movementSourceRecord('hoof', 'earth', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'), 'movement-hoof-stone': movementSourceRecord('hoof', 'stone', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'), 'movement-hoof-wood': movementSourceRecord('hoof', 'wood', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/'), 'movement-hoof-wet': movementSourceRecord('hoof', 'wet', 'horse-gallop.mp3', 'DRAGON-STUDIO', 'https://pixabay.com/sound-effects/nature-horse-gallop-sfx-339736/') }; const compensatedPoolKeys = ['melee-swing', 'melee-impact', 'arrow-impact']; const ffmpegCommand = process.env.FFMPEG_PATH?.trim() || 'ffmpeg'; const ffprobeCommand = process.env.FFPROBE_PATH?.trim() || 'ffprobe'; let audioInspectionToolStatus; const requiredBattleSceneAudioMethods = [ 'playSoundscape', 'playAllyTurnCue', 'playEnemyTurnCue', 'playOperationAlert', 'playObjectiveAchieved', 'playObjectiveFailed' ]; const battlefieldSourceRecords = [ { projectFile: 'src/assets/audio/ambience/battle-rain.mp3', source: 'https://pixabay.com/sound-effects/nature-gentle-midday-rain-499668/' }, { projectFile: 'src/assets/audio/ambience/battle-fire.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-fire-sounds-405444/' }, ...['ally-turn', 'enemy-turn', 'operation-alert'].map((key) => ({ projectFile: `src/assets/audio/sfx/${key}.mp3`, source: 'https://pixabay.com/sound-effects/musical-ancient-war-drums-463214/' })), { projectFile: 'src/assets/audio/sfx/objective-success.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-level-complete-143022/' }, { projectFile: 'src/assets/audio/sfx/objective-failure.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-failure-1-89170/' } ]; const narrativeSourceRecord = { projectFile: 'src/assets/audio/sfx/story-page-turn.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-flipping-book-page-499646/', requiredDocumentation: [ '2026-07-22', 'Flipping Book Page', 'DRAGON-STUDIO', '48 kHz stereo MP3', '1.0-second', 'silence-trimmed', 'fade-in/out', 'high-pass and low-pass filtered' ] }; const semanticFeedbackSourceRecords = [ { projectFile: 'src/assets/audio/sfx/recovery-cue.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-holy-healing-spell-533279/', requiredDocumentation: ['Holy Healing Spell', 'Coghezzi', 'approximately 1.9 seconds'] }, { projectFile: 'src/assets/audio/sfx/reward-reveal.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-reward-chime-144757/', requiredDocumentation: ['Reward Chime', 'Universfield', '1.4 seconds'] }, { projectFile: 'src/assets/audio/sfx/burn-tick.mp3', source: 'https://pixabay.com/sound-effects/film-special-effects-electronic-element-burn-spark-1-248606/', requiredDocumentation: ['Electronic element burn spark 1', 'FxProSound', '0.7 seconds'] } ]; const server = await createServer({ logLevel: 'error', server: { middlewareMode: true }, appType: 'custom' }); try { const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts'); const { musicTracks, ambienceTracks, effectTracks, effectTrackGainCompensation = {}, movementEffectPoolsBySurface = {} } = audioAssets; const effectPools = audioAssets.effectPools ?? {}; const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts'); const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts'); const errors = []; validateTrackMap(errors, 'music', musicTracks, 'bgm'); validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience'); validateTrackMap(errors, 'effect', effectTracks, 'sfx'); validateRequiredTrackKeys(errors, 'battlefield ambience', ambienceTracks, requiredBattlefieldAmbienceKeys); validateRequiredTrackKeys(errors, 'tactical cue', effectTracks, requiredTacticalCueKeys); validateRequiredTrackKeys(errors, 'narrative cue', effectTracks, requiredNarrativeCueKeys); validateRequiredTrackKeys(errors, 'semantic feedback cue', effectTracks, requiredSemanticFeedbackCueKeys); validateBattlefieldSourceDocumentation(errors); validateNarrativeSourceDocumentation(errors); validateSemanticFeedbackSourceDocumentation(errors); validateMovementSourceDocumentation(errors); validateBattleSceneAudioIntegration(errors); validateEffectPools(errors, effectPools, effectTracks); validateMovementEffectPools( errors, effectPools, effectTracks, movementEffectPoolsBySurface ); validateEffectPoolMixBalance(errors, effectPools, effectTracks, effectTrackGainCompensation); validateEffectPoolRegistration(errors, effectPools); validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks); validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks); validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools); if (errors.length) { console.error(`Audio asset data verification failed with ${errors.length} issue(s):`); errors.slice(0, 100).forEach((error) => console.error(`- ${error}`)); if (errors.length > 100) { console.error(`- ...and ${errors.length - 100} more`); } process.exitCode = 1; } else { console.log( `Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` + `${Object.keys(effectTracks).length} effect tracks, ${Object.keys(effectPools).length} effect pools, ` + `${Object.keys(movementPoolSourceRecords).length} terrain movement pools, balanced randomized effects, ` + `${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.` ); } } finally { await server.close(); } function validateEffectPools(errors, effectPools, effectTracks) { const effectKeys = new Set(Object.keys(effectTracks)); Object.entries(effectPools).forEach(([poolKey, trackKeys]) => { assertNonEmptyString(errors, poolKey, 'effect pool key'); if (!Array.isArray(trackKeys) || trackKeys.length === 0) { errors.push(`effect pool "${poolKey}" must contain at least one track key`); return; } const seenTrackKeys = new Set(); trackKeys.forEach((trackKey, index) => { assertNonEmptyString(errors, trackKey, `effect pool "${poolKey}" track ${index}`); if (!effectKeys.has(trackKey)) { errors.push(`effect pool "${poolKey}" references unknown effect track "${trackKey}"`); } if (seenTrackKeys.has(trackKey)) { errors.push(`effect pool "${poolKey}" repeats effect track "${trackKey}"`); } seenTrackKeys.add(trackKey); }); }); } function validateMovementEffectPools(errors, effectPools, effectTracks, movementEffectPoolsBySurface) { const expectedSurfaceEntries = { earth: { foot: 'movement-foot-earth', mounted: 'movement-hoof-earth' }, stone: { foot: 'movement-foot-stone', mounted: 'movement-hoof-stone' }, wood: { foot: 'movement-foot-wood', mounted: 'movement-hoof-wood' }, wet: { foot: 'movement-foot-wet', mounted: 'movement-hoof-wet' } }; if (JSON.stringify(movementEffectPoolsBySurface) !== JSON.stringify(expectedSurfaceEntries)) { errors.push('movementEffectPoolsBySurface must map earth/stone/wood/wet to their foot and mounted pools'); } if (!audioInspectionToolsAvailable(errors)) { return; } Object.entries(movementPoolSourceRecords).forEach(([poolKey, record]) => { const trackKeys = effectPools[poolKey]; if (JSON.stringify(trackKeys) !== JSON.stringify(record.trackKeys)) { errors.push(`${poolKey} must contain exactly ${record.trackKeys.join(', ')}`); return; } const meanVolumes = []; trackKeys.forEach((trackKey) => { const url = effectTracks[trackKey]; const path = url ? assetUrlToPath(url) : undefined; if (!path) { errors.push(`${poolKey} references movement track "${trackKey}" without a readable project path`); return; } const probe = probeAudio(path, errors, trackKey); const volume = measureAudioVolume(path, errors, trackKey); if (!probe || !volume) { return; } if (probe.duration < 0.3 || probe.duration > 0.52) { errors.push(`${trackKey} must remain a 0.30-0.52 second one-shot; received ${probe.duration.toFixed(3)} seconds`); } if (probe.sampleRate !== 48_000 || probe.channels !== 2) { errors.push(`${trackKey} must be 48 kHz stereo; received ${probe.sampleRate} Hz/${probe.channels} channels`); } if (!probe.artist.includes(record.creator)) { errors.push(`${trackKey} metadata must retain original creator "${record.creator}"`); } if (!probe.comment.includes(record.parentFile) || !probe.comment.includes(record.source)) { errors.push(`${trackKey} metadata must retain parent file and Pixabay source lineage`); } if (volume.meanDb < -28 || volume.meanDb > -20) { errors.push(`${trackKey} mean level ${volume.meanDb.toFixed(1)} dB is outside the quiet movement mix`); } if (volume.peakDb > -2 || volume.peakDb < -15) { errors.push(`${trackKey} peak ${volume.peakDb.toFixed(1)} dB is outside the -15..-2 dB safety window`); } meanVolumes.push(volume.meanDb); }); if (meanVolumes.length === record.trackKeys.length) { const spread = Math.max(...meanVolumes) - Math.min(...meanVolumes); if (spread > 5) { errors.push(`${poolKey} one-shot mean-level spread ${spread.toFixed(1)} dB exceeds 5 dB`); } } }); } function validateEffectPoolMixBalance(errors, effectPools, effectTracks, gainCompensation) { Object.entries(gainCompensation).forEach(([trackKey, gain]) => { if (!(trackKey in effectTracks)) { errors.push(`effect gain compensation references unknown track "${trackKey}"`); } if (!Number.isFinite(gain) || gain < 0.5 || gain > 2) { errors.push(`effect gain compensation for "${trackKey}" must stay within 0.5..2`); } }); if (!audioInspectionToolsAvailable(errors)) { return; } compensatedPoolKeys.forEach((poolKey) => { const adjustedLoudness = []; (effectPools[poolKey] ?? []).forEach((trackKey) => { const gain = gainCompensation[trackKey]; if (!Number.isFinite(gain)) { errors.push(`${poolKey}/${trackKey} needs explicit playback gain compensation`); return; } const path = assetUrlToPath(effectTracks[trackKey] ?? ''); if (!path) { return; } const loudness = measureIntegratedLoudness(path, errors, trackKey); if (loudness !== undefined) { adjustedLoudness.push(loudness + 20 * Math.log10(gain)); } }); if (adjustedLoudness.length === (effectPools[poolKey]?.length ?? 0)) { const spread = Math.max(...adjustedLoudness) - Math.min(...adjustedLoudness); if (spread > 0.8) { errors.push(`${poolKey} compensated integrated-loudness spread ${spread.toFixed(2)} LU exceeds 0.8 LU`); } } }); } function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) { requiredKeys.forEach((key) => { if (!(key in tracks)) { errors.push(`required ${label} track "${key}" is not registered`); } }); } function validateBattlefieldSourceDocumentation(errors) { const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); if (!docs.includes('2026-07-21')) { errors.push('docs/audio-sources.md must record the 2026-07-21 battlefield audio download date'); } if (!docs.includes('https://pixabay.com/service/license-summary/')) { errors.push('docs/audio-sources.md must link the Pixabay Content License summary'); } battlefieldSourceRecords.forEach(({ projectFile, source }) => { if (!docs.includes(projectFile)) { errors.push(`docs/audio-sources.md is missing battlefield audio file "${projectFile}"`); } if (!docs.includes(source)) { errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`); } }); } function validateNarrativeSourceDocumentation(errors) { const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); if (!docs.includes(narrativeSourceRecord.projectFile)) { errors.push(`docs/audio-sources.md is missing narrative audio file "${narrativeSourceRecord.projectFile}"`); } if (!docs.includes(narrativeSourceRecord.source)) { errors.push(`docs/audio-sources.md is missing Pixabay source "${narrativeSourceRecord.source}"`); } narrativeSourceRecord.requiredDocumentation.forEach((value) => { if (!docs.includes(value)) { errors.push(`docs/audio-sources.md is missing narrative audio detail "${value}"`); } }); } function validateSemanticFeedbackSourceDocumentation(errors) { const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); const sectionHeading = '## Semantic Feedback Cues'; const sectionStart = docs.indexOf(sectionHeading); if (sectionStart < 0) { errors.push('docs/audio-sources.md must include the Semantic Feedback Cues section'); return; } const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length); const semanticDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart); if (!semanticDocs.includes('2026-07-22')) { errors.push('docs/audio-sources.md must record the 2026-07-22 semantic feedback download date'); } ['48 kHz stereo MP3', 'silence-trimmed', 'fade-in/out', 'high-pass and low-pass filtered'].forEach((detail) => { if (!semanticDocs.includes(detail)) { errors.push(`docs/audio-sources.md is missing semantic feedback processing detail "${detail}"`); } }); semanticFeedbackSourceRecords.forEach(({ projectFile, source, requiredDocumentation }) => { if (!semanticDocs.includes(projectFile)) { errors.push(`docs/audio-sources.md is missing semantic feedback file "${projectFile}"`); } if (!semanticDocs.includes(source)) { errors.push(`docs/audio-sources.md is missing Pixabay source "${source}"`); } requiredDocumentation.forEach((detail) => { if (!semanticDocs.includes(detail)) { errors.push(`docs/audio-sources.md is missing semantic feedback detail "${detail}"`); } }); }); } function validateMovementSourceDocumentation(errors) { const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8'); const sectionHeading = '## Derived Terrain Movement One-shots'; const sectionStart = docs.indexOf(sectionHeading); if (sectionStart < 0) { errors.push('docs/audio-sources.md must include the Derived Terrain Movement One-shots section'); return; } const nextSectionStart = docs.indexOf('\n## ', sectionStart + sectionHeading.length); const movementDocs = docs.slice(sectionStart, nextSectionStart < 0 ? docs.length : nextSectionStart); [ '2026-07-23', '48 kHz stereo MP3', '8 ms attack', '55 ms release', 'terrain-specific EQ', 'per-track gain compensation' ].forEach((detail) => { if (!movementDocs.includes(detail)) { errors.push(`docs/audio-sources.md is missing movement processing detail "${detail}"`); } }); Object.values(movementPoolSourceRecords).forEach((record) => { [record.projectPattern, record.parentProjectFile, record.creator, record.source].forEach((detail) => { if (!movementDocs.includes(detail)) { errors.push(`docs/audio-sources.md is missing movement source detail "${detail}"`); } }); }); } function validateBattleSceneAudioIntegration(errors) { const path = join('src', 'game', 'scenes', 'BattleScene.ts'); const source = readFileSync(path, 'utf8'); requiredBattleSceneAudioMethods.forEach((method) => { const pattern = new RegExp(`\\bsoundDirector\\.${method}\\s*\\(`); if (!pattern.test(source)) { errors.push(`${path} must call soundDirector.${method}()`); } }); } function validateEffectPoolRegistration(errors, effectPools) { if (Object.keys(effectPools).length === 0) { return; } const mainSource = readFileSync(join('src', 'main.ts'), 'utf8'); if (!/\bregisterEffectPools\s*\(\s*effectPools\s*\)/.test(mainSource)) { errors.push('src/main.ts must register exported effectPools with SoundDirector'); } } function validateTrackMap(errors, label, tracks, expectedFolder) { Object.entries(tracks).forEach(([key, url]) => { assertNonEmptyString(errors, key, `${label} track key`); assertNonEmptyString(errors, url, `${label} track "${key}" URL`); const path = assetUrlToPath(url); if (!path) { errors.push(`${label} track "${key}" URL "${url}" does not point at src/assets/audio`); return; } if (!path.includes(`${sep}${expectedFolder}${sep}`)) { errors.push(`${label} track "${key}" should live under ${expectedFolder}, got "${path}"`); } validateAudioFile(errors, `${label} track "${key}"`, path); }); } function validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks) { const registeredMusicPaths = new Set(Object.values(musicTracks).map(assetUrlToPath).filter(Boolean)); const registeredAmbiencePaths = new Set(Object.values(ambienceTracks).map(assetUrlToPath).filter(Boolean)); const registeredEffectPaths = new Set(Object.values(effectTracks).map(assetUrlToPath).filter(Boolean)); listAudioFiles(join(audioRoot, 'bgm')).forEach((path) => { if (!registeredMusicPaths.has(path)) { errors.push(`BGM file "${path}" exists but is not registered in musicTracks`); } }); listAudioFiles(join(audioRoot, 'ambience')).forEach((path) => { if (!registeredAmbiencePaths.has(path)) { errors.push(`Ambience file "${path}" exists but is not registered in ambienceTracks`); } }); listAudioFiles(join(audioRoot, 'sfx')).forEach((path) => { if (!registeredEffectPaths.has(path)) { errors.push(`SFX file "${path}" exists but is not registered in effectTracks`); } }); } function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks) { const musicKeys = new Set(Object.keys(musicTracks)); collectStoryBgmReferences(scenarioModule, battleScenarios).forEach(({ source, pageId, bgm }) => { if (!musicKeys.has(bgm)) { errors.push(`${source}/${pageId}: unknown story BGM "${bgm}"`); } }); } function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks, effectPools) { const musicKeys = new Set(Object.keys(musicTracks)); const ambienceKeys = new Set(Object.keys(ambienceTracks)); const effectKeys = new Set([...Object.keys(effectTracks), ...Object.keys(effectPools)]); sourceFilesToScan.forEach((path) => { const source = readFileSync(path, 'utf8'); collectLiteralCallKeys(source, 'playMusic').forEach(({ key, index }) => { if (!musicKeys.has(key)) { errors.push(`${path}:${lineForIndex(source, index)}: playMusic references unknown track "${key}"`); } }); collectLiteralCallKeys(source, 'playAmbience').forEach(({ key, index }) => { if (!ambienceKeys.has(key)) { errors.push(`${path}:${lineForIndex(source, index)}: playAmbience references unknown track "${key}"`); } }); collectLiteralCallKeys(source, 'playEffect').forEach(({ key, index }) => { if (!effectKeys.has(key)) { errors.push(`${path}:${lineForIndex(source, index)}: playEffect references unknown track or pool "${key}"`); } }); }); } function collectStoryBgmReferences(scenarioModule, battleScenarios) { const refs = []; const seenArrays = new WeakSet(); Object.entries(scenarioModule) .filter(([, value]) => isStoryPageList(value)) .sort(([left], [right]) => left.localeCompare(right)) .forEach(([name, pages]) => { collectPageBgmRefs(refs, name, pages); seenArrays.add(pages); }); Object.entries(battleScenarios) .sort(([left], [right]) => left.localeCompare(right)) .forEach(([battleId, scenario]) => { if (!isStoryPageList(scenario.victoryPages) || seenArrays.has(scenario.victoryPages)) { return; } collectPageBgmRefs(refs, `${battleId}.victoryPages`, scenario.victoryPages); seenArrays.add(scenario.victoryPages); }); return refs; } function collectPageBgmRefs(refs, source, pages) { pages.forEach((page) => { if (page.bgm) { refs.push({ source, pageId: page.id, bgm: page.bgm }); } }); } function isStoryPageList(value) { return Array.isArray(value) && value.length > 0 && value.every(isStoryPage); } function isStoryPage(page) { return Boolean(page && typeof page === 'object' && typeof page.id === 'string' && typeof page.text === 'string'); } function collectLiteralCallKeys(source, callName) { const keys = []; const pattern = new RegExp(`\\.${callName}\\s*\\(`, 'g'); for (const match of source.matchAll(pattern)) { const callIndex = match.index ?? 0; const argumentStart = callIndex + match[0].length; const argumentEnd = firstArgumentEnd(source, argumentStart); const argumentSource = source.slice(argumentStart, argumentEnd); const ternaryIndex = firstTernaryIndex(argumentSource); const stringPattern = /(['"])([^'"\\]*(?:\\.[^'"\\]*)*)\1/g; for (const stringMatch of argumentSource.matchAll(stringPattern)) { if (ternaryIndex >= 0 && (stringMatch.index ?? 0) < ternaryIndex) { continue; } if (stringMatch[2].includes('\\')) { continue; } keys.push({ key: stringMatch[2], index: argumentStart + (stringMatch.index ?? 0) }); } } return keys; } function firstTernaryIndex(source) { let quote = ''; for (let index = 0; index < source.length; index += 1) { const character = source[index]; if (quote) { if (character === '\\') { index += 1; } else if (character === quote) { quote = ''; } continue; } if (character === "'" || character === '"' || character === '`') { quote = character; continue; } if (character === '?' && source[index - 1] !== '?' && source[index + 1] !== '?' && source[index + 1] !== '.') { return index; } } return -1; } function firstArgumentEnd(source, startIndex) { let nestedDepth = 0; let quote = ''; for (let index = startIndex; index < source.length; index += 1) { const character = source[index]; if (quote) { if (character === '\\') { index += 1; } else if (character === quote) { quote = ''; } continue; } if (character === "'" || character === '"' || character === '`') { quote = character; continue; } if (character === '(' || character === '[' || character === '{') { nestedDepth += 1; continue; } if (character === ')' || character === ']' || character === '}') { if (nestedDepth === 0) { return index; } nestedDepth -= 1; continue; } if (character === ',' && nestedDepth === 0) { return index; } } return source.length; } function movementSourceRecord(prefix, surface, parentFile, creator, source) { return { trackKeys: [`${prefix}-${surface}-1`, `${prefix}-${surface}-2`], projectPattern: `src/assets/audio/sfx/${prefix}-${surface}-{1,2}.mp3`, parentFile, parentProjectFile: `src/assets/audio/sfx/${parentFile}`, creator, source }; } function audioInspectionToolsAvailable(errors) { if (!audioInspectionToolStatus) { const ffmpeg = spawnSync(ffmpegCommand, ['-version'], { encoding: 'utf8', windowsHide: true }); const ffprobe = spawnSync(ffprobeCommand, ['-version'], { encoding: 'utf8', windowsHide: true }); audioInspectionToolStatus = { available: ffmpeg.status === 0 && ffprobe.status === 0, reported: false, detail: [ffmpeg.error?.message, ffprobe.error?.message].filter(Boolean).join('; ') }; } if (!audioInspectionToolStatus.available && !audioInspectionToolStatus.reported) { errors.push( `ffmpeg and ffprobe are required for movement and pool loudness verification` + (audioInspectionToolStatus.detail ? `: ${audioInspectionToolStatus.detail}` : '') ); audioInspectionToolStatus.reported = true; } return audioInspectionToolStatus.available; } function probeAudio(path, errors, context) { const result = spawnSync( ffprobeCommand, [ '-v', 'error', '-select_streams', 'a:0', '-show_entries', 'format=duration:stream=sample_rate,channels:format_tags=artist,comment', '-of', 'json', path ], { encoding: 'utf8', maxBuffer: 1024 * 1024, windowsHide: true } ); if (result.status !== 0) { errors.push(`${context}: ffprobe failed: ${(result.stderr || result.error?.message || '').trim()}`); return undefined; } try { const parsed = JSON.parse(result.stdout); const stream = parsed.streams?.[0] ?? {}; const tags = parsed.format?.tags ?? {}; return { duration: Number(parsed.format?.duration), sampleRate: Number(stream.sample_rate), channels: Number(stream.channels), artist: String(tags.artist ?? ''), comment: String(tags.comment ?? '') }; } catch (error) { errors.push(`${context}: unreadable ffprobe JSON: ${error.message}`); return undefined; } } function measureAudioVolume(path, errors, context) { const result = spawnSync( ffmpegCommand, ['-hide_banner', '-nostats', '-i', path, '-af', 'volumedetect', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true } ); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; const meanMatch = [...output.matchAll(/mean_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/g)].at(-1); const peakMatch = [...output.matchAll(/max_volume:\s*(-?\d+(?:\.\d+)?)\s*dB/g)].at(-1); if (result.status !== 0 || !meanMatch || !peakMatch) { errors.push(`${context}: ffmpeg could not measure mean/peak volume`); return undefined; } return { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) }; } function measureIntegratedLoudness(path, errors, context) { const result = spawnSync( ffmpegCommand, ['-hide_banner', '-nostats', '-i', path, '-filter_complex', 'ebur128=peak=true', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024, windowsHide: true } ); const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; const matches = [...output.matchAll(/\bI:\s*(-?\d+(?:\.\d+)?)\s*LUFS/g)]; const loudness = Number(matches.at(-1)?.[1]); if (result.status !== 0 || !Number.isFinite(loudness) || loudness <= -70) { errors.push(`${context}: ffmpeg could not measure integrated loudness`); return undefined; } return loudness; } function assetUrlToPath(url) { const marker = 'src/assets/audio/'; const index = url.indexOf(marker); if (index < 0) { return undefined; } return url.slice(index).replaceAll('/', sep); } function listAudioFiles(path) { return readdirSync(path, { withFileTypes: true }) .flatMap((entry) => { const childPath = join(path, entry.name); return entry.isDirectory() ? listAudioFiles(childPath) : [childPath]; }) .filter((filePath) => /\.(mp3|wav)$/i.test(filePath)) .sort(); } function validateAudioFile(errors, context, path) { let stats; let bytes; try { stats = statSync(path); bytes = readFileSync(path); } catch (error) { errors.push(`${context} cannot be read at "${path}": ${error.message}`); return; } if (stats.size < 1024) { errors.push(`${context} at "${path}" is too small to be a useful audio file`); } if (path.toLowerCase().endsWith('.wav')) { if (bytes.subarray(0, 4).toString('ascii') !== 'RIFF' || bytes.subarray(8, 12).toString('ascii') !== 'WAVE') { errors.push(`${context} at "${path}" does not have a WAV header`); return; } const metadata = readWavMetadata(bytes); if (!metadata) { errors.push(`${context} at "${path}" is missing readable WAV fmt/data chunks`); return; } if (metadata.audioFormat !== 1) { errors.push(`${context} at "${path}" should be PCM WAV audio, got format ${metadata.audioFormat}`); } if (metadata.channels < 1 || metadata.channels > 2) { errors.push(`${context} at "${path}" should be mono or stereo WAV audio, got ${metadata.channels} channels`); } if (metadata.sampleRate < 22050) { errors.push(`${context} at "${path}" should use at least 22050 Hz WAV audio, got ${metadata.sampleRate} Hz`); } if (![16, 24, 32].includes(metadata.bitsPerSample)) { errors.push(`${context} at "${path}" should use 16/24/32-bit WAV audio, got ${metadata.bitsPerSample}-bit`); } if (metadata.dataSize <= 0) { errors.push(`${context} at "${path}" does not contain WAV sample data`); } return; } if (path.toLowerCase().endsWith('.mp3')) { const hasId3 = bytes.subarray(0, 3).toString('ascii') === 'ID3'; const hasFrameSync = bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0; if (!hasId3 && !hasFrameSync) { errors.push(`${context} at "${path}" does not look like an MP3 file`); } return; } errors.push(`${context} at "${path}" uses an unsupported audio extension`); } function readWavMetadata(bytes) { let offset = 12; let format; let dataSize = 0; while (offset + 8 <= bytes.length) { const chunkType = bytes.subarray(offset, offset + 4).toString('ascii'); const chunkSize = bytes.readUInt32LE(offset + 4); const dataOffset = offset + 8; if (dataOffset + chunkSize > bytes.length) { return undefined; } if (chunkType === 'fmt ' && chunkSize >= 16) { format = { audioFormat: bytes.readUInt16LE(dataOffset), channels: bytes.readUInt16LE(dataOffset + 2), sampleRate: bytes.readUInt32LE(dataOffset + 4), bitsPerSample: bytes.readUInt16LE(dataOffset + 14) }; } else if (chunkType === 'data') { dataSize += chunkSize; } offset = dataOffset + chunkSize + (chunkSize % 2); } if (!format || dataSize <= 0) { return undefined; } return { ...format, dataSize }; } function lineForIndex(source, index) { return source.slice(0, index).split(/\r?\n/).length; } function assertNonEmptyString(errors, value, context) { if (typeof value !== 'string' || value.trim().length === 0) { errors.push(`${context} must be a non-empty string`); } }