1010 lines
39 KiB
JavaScript
1010 lines
39 KiB
JavaScript
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 integratedLoudnessCache = new Map();
|
|
const audioVolumeCache = new Map();
|
|
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,
|
|
ambienceTrackGainCompensation = {},
|
|
ambienceTrackIntegratedLoudnessLufs = {},
|
|
musicTrackIntegratedLoudnessLufs = {},
|
|
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 { campaignPresentationTransitionPolicy, resolveCampaignPresentationSoundscape } = await server.ssrLoadModule(
|
|
'/src/game/data/campaignPresentationProfiles.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);
|
|
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);
|
|
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 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')) {
|
|
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) {
|
|
if (audioVolumeCache.has(path)) {
|
|
return audioVolumeCache.get(path);
|
|
}
|
|
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;
|
|
}
|
|
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', '-'],
|
|
{ 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;
|
|
}
|
|
integratedLoudnessCache.set(path, loudness);
|
|
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`);
|
|
}
|
|
}
|