fix: align audiovisual and narrative feedback

This commit is contained in:
2026-07-23 23:20:50 +09:00
parent 8cf0886d7d
commit e06b15ce7a
27 changed files with 1368 additions and 142 deletions

View File

@@ -36,6 +36,8 @@ 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 integratedLoudnessCache = new Map();
const audioVolumeCache = new Map();
const requiredBattleSceneAudioMethods = [
'playSoundscape',
'playAllyTurnCue',
@@ -109,6 +111,9 @@ try {
const {
musicTracks,
ambienceTracks,
ambienceTrackGainCompensation = {},
ambienceTrackIntegratedLoudnessLufs = {},
musicTrackIntegratedLoudnessLufs = {},
effectTracks,
effectTrackGainCompensation = {},
movementEffectPoolsBySurface = {}
@@ -116,6 +121,9 @@ try {
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 { campaignPresentationTransitionPolicy, resolveCampaignPresentationSoundscape } = await server.ssrLoadModule(
'/src/game/data/campaignPresentationProfiles.ts'
);
const errors = [];
validateTrackMap(errors, 'music', musicTracks, 'bgm');
@@ -138,6 +146,21 @@ try {
movementEffectPoolsBySurface
);
validateEffectPoolMixBalance(errors, effectPools, effectTracks, effectTrackGainCompensation);
validateAmbienceLoopGain(errors, ambienceTracks, ambienceTrackGainCompensation);
validateLoopLoudnessCalibration(errors, {
musicTracks,
ambienceTracks,
musicTrackIntegratedLoudnessLufs,
ambienceTrackIntegratedLoudnessLufs
});
validateCampaignBattleMix(errors, {
battleScenarios,
musicTracks,
ambienceTracks,
ambienceTrackGainCompensation,
campaignPresentationTransitionPolicy,
resolveCampaignPresentationSoundscape
});
validateEffectPoolRegistration(errors, effectPools);
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
@@ -303,6 +326,145 @@ function validateRequiredTrackKeys(errors, label, tracks, requiredKeys) {
});
}
function validateAmbienceLoopGain(errors, ambienceTracks, gainCompensation) {
Object.keys(ambienceTracks).forEach((trackKey) => {
const gain = gainCompensation[trackKey];
if (!Number.isFinite(gain) || gain < 0.5 || gain > 2) {
errors.push(`ambience loop gain for "${trackKey}" must stay within 0.5..2`);
}
});
Object.keys(gainCompensation).forEach((trackKey) => {
if (!(trackKey in ambienceTracks)) {
errors.push(`ambience loop gain references unknown track "${trackKey}"`);
}
});
if (gainCompensation['battle-fire'] > 0.75) {
errors.push('battle-fire loop gain must retain enough headroom for its high-crest crackle peaks');
}
if (gainCompensation['mountain-wind-ambience'] > 0.9) {
errors.push('mountain-wind loop gain must retain enough headroom for exposed snow-wind gusts');
}
}
function validateLoopLoudnessCalibration(
errors,
{ musicTracks, ambienceTracks, musicTrackIntegratedLoudnessLufs, ambienceTrackIntegratedLoudnessLufs }
) {
if (!audioInspectionToolsAvailable(errors)) {
return;
}
[
['music', musicTracks, musicTrackIntegratedLoudnessLufs],
['ambience', ambienceTracks, ambienceTrackIntegratedLoudnessLufs]
].forEach(([category, tracks, calibration]) => {
Object.entries(tracks).forEach(([trackKey, url]) => {
const expectedLoudness = calibration[trackKey];
if (!Number.isFinite(expectedLoudness)) {
errors.push(`${category} track "${trackKey}" needs encoded integrated-loudness calibration`);
return;
}
const path = assetUrlToPath(url);
const measuredLoudness = path
? measureIntegratedLoudness(path, errors, `${category}/${trackKey}`)
: undefined;
if (measuredLoudness !== undefined && Math.abs(measuredLoudness - expectedLoudness) > 0.15) {
errors.push(
`${category} track "${trackKey}" loudness calibration drifted: ` +
`stored ${expectedLoudness.toFixed(2)}, measured ${measuredLoudness.toFixed(2)} LUFS`
);
}
});
});
}
function validateCampaignBattleMix(
errors,
{
battleScenarios,
musicTracks,
ambienceTracks,
ambienceTrackGainCompensation,
campaignPresentationTransitionPolicy,
resolveCampaignPresentationSoundscape
}
) {
if (!audioInspectionToolsAvailable(errors)) {
return;
}
const uniquePairs = new Set();
Object.keys(battleScenarios).forEach((battleId) => {
const plan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' });
if (!plan?.ambienceKey || !Number.isFinite(plan.ambienceVolume) || !Number.isFinite(plan.musicVolume)) {
errors.push(`${battleId} must resolve a complete calibrated battle soundscape`);
return;
}
const musicPath = assetUrlToPath(musicTracks[plan.musicKey] ?? '');
const ambiencePath = assetUrlToPath(ambienceTracks[plan.ambienceKey] ?? '');
if (!musicPath || !ambiencePath) {
errors.push(`${battleId} calibrated soundscape references an unreadable loop asset`);
return;
}
const musicLoudness = measureIntegratedLoudness(musicPath, errors, `${battleId}/${plan.musicKey}`);
const ambienceLoudness = measureIntegratedLoudness(ambiencePath, errors, `${battleId}/${plan.ambienceKey}`);
if (musicLoudness === undefined || ambienceLoudness === undefined) {
return;
}
const ambienceGain = ambienceTrackGainCompensation[plan.ambienceKey] ?? 1;
uniquePairs.add(`${plan.musicKey}|${plan.ambienceKey}|${plan.musicVolume}|${plan.ambienceVolume}|${ambienceGain}`);
const effectiveMusicLoudness = musicLoudness + linearGainDb(plan.musicVolume);
const effectiveAmbienceLoudness = ambienceLoudness + linearGainDb(plan.ambienceVolume * ambienceGain);
const relativeLoudness = effectiveAmbienceLoudness - effectiveMusicLoudness;
if (
relativeLoudness < campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMinimumLu ||
relativeLoudness > campaignPresentationTransitionPolicy.battleAmbienceRelativeLoudnessMaximumLu
) {
errors.push(
`${battleId} ambience must sit 14..18 LU below music; received ${relativeLoudness.toFixed(2)} LU ` +
`(${plan.ambienceKey} at ${plan.ambienceVolume} x ${ambienceGain})`
);
}
});
if (uniquePairs.size < 10) {
errors.push(`campaign battle mix should exercise at least 10 unique loop combinations; received ${uniquePairs.size}`);
}
[
'twenty-second-battle-red-cliffs-fire',
'forty-sixth-battle-yiling-fire',
'sixty-sixth-battle-wuzhang-final'
].forEach((battleId) => {
const plan = resolveCampaignPresentationSoundscape({ battleId, stage: 'battle' });
if (!plan?.ambienceKey || !Number.isFinite(plan.ambienceVolume) || !Number.isFinite(plan.musicVolume)) {
errors.push(`${battleId} must resolve a complete peak-calibrated battle soundscape`);
return;
}
const musicPath = assetUrlToPath(musicTracks[plan.musicKey] ?? '');
const ambiencePath = assetUrlToPath(ambienceTracks[plan.ambienceKey] ?? '');
if (!musicPath || !ambiencePath) {
return;
}
const musicVolume = measureAudioVolume(musicPath, errors, `${battleId}/${plan.musicKey}`);
const ambienceVolume = measureAudioVolume(ambiencePath, errors, `${battleId}/${plan.ambienceKey}`);
if (!musicVolume || !ambienceVolume) {
return;
}
const ambienceGain = ambienceTrackGainCompensation[plan.ambienceKey] ?? 1;
const effectiveMusicPeak = musicVolume.peakDb + linearGainDb(plan.musicVolume);
const effectiveAmbiencePeak = ambienceVolume.peakDb + linearGainDb(plan.ambienceVolume * ambienceGain);
if (effectiveAmbiencePeak > effectiveMusicPeak - 1.5) {
errors.push(
`${battleId} ambience peak must remain at least 1.5 dB below music; received ` +
`${effectiveAmbiencePeak.toFixed(2)} dB vs ${effectiveMusicPeak.toFixed(2)} dB`
);
}
});
}
function linearGainDb(gain) {
return 20 * Math.log10(Math.max(0.0001, gain));
}
function validateBattlefieldSourceDocumentation(errors) {
const docs = readFileSync(join('docs', 'audio-sources.md'), 'utf8');
if (!docs.includes('2026-07-21')) {
@@ -688,6 +850,9 @@ function probeAudio(path, errors, context) {
}
function measureAudioVolume(path, errors, context) {
if (audioVolumeCache.has(path)) {
return audioVolumeCache.get(path);
}
const result = spawnSync(
ffmpegCommand,
['-hide_banner', '-nostats', '-i', path, '-af', 'volumedetect', '-f', 'null', '-'],
@@ -700,10 +865,15 @@ function measureAudioVolume(path, errors, context) {
errors.push(`${context}: ffmpeg could not measure mean/peak volume`);
return undefined;
}
return { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) };
const measurement = { meanDb: Number(meanMatch[1]), peakDb: Number(peakMatch[1]) };
audioVolumeCache.set(path, measurement);
return measurement;
}
function measureIntegratedLoudness(path, errors, context) {
if (integratedLoudnessCache.has(path)) {
return integratedLoudnessCache.get(path);
}
const result = spawnSync(
ffmpegCommand,
['-hide_banner', '-nostats', '-i', path, '-filter_complex', 'ebur128=peak=true', '-f', 'null', '-'],
@@ -716,6 +886,7 @@ function measureIntegratedLoudness(path, errors, context) {
errors.push(`${context}: ffmpeg could not measure integrated loudness`);
return undefined;
}
integratedLoudnessCache.set(path, loudness);
return loudness;
}