feat: deepen audiovisual story immersion

This commit is contained in:
2026-07-23 10:57:07 +09:00
parent a9e401aedf
commit 8cf0886d7d
38 changed files with 2037 additions and 144 deletions

View File

@@ -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);