fix: align audiovisual and narrative feedback
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user