feat: deepen audiovisual story immersion
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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');
|
||||
@@ -21,6 +22,20 @@ const requiredTacticalCueKeys = [
|
||||
];
|
||||
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',
|
||||
@@ -91,7 +106,13 @@ const server = await createServer({
|
||||
|
||||
try {
|
||||
const audioAssets = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
|
||||
const { musicTracks, ambienceTracks, effectTracks } = audioAssets;
|
||||
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');
|
||||
@@ -107,8 +128,16 @@ try {
|
||||
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);
|
||||
@@ -125,6 +154,7 @@ try {
|
||||
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.`
|
||||
);
|
||||
}
|
||||
@@ -156,6 +186,115 @@ function validateEffectPools(errors, effectPools, effectTracks) {
|
||||
});
|
||||
}
|
||||
|
||||
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)) {
|
||||
@@ -230,6 +369,38 @@ function validateSemanticFeedbackSourceDocumentation(errors) {
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -450,6 +621,104 @@ function firstArgumentEnd(source, startIndex) {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user