feat: add era-based camp soundscapes

This commit is contained in:
2026-07-13 05:18:32 +09:00
parent 1eedd63163
commit b88a2d6ec2
21 changed files with 1347 additions and 14 deletions

View File

@@ -14,6 +14,21 @@ License reference: https://pixabay.com/service/license-summary/
| `src/assets/audio/bgm/militia-theme.mp3` | Orchestra, Heroic, Warrior music | NR-Music | https://pixabay.com/music/adventure-short-heroic-orchestral-loop-541095/ |
| `src/assets/audio/bgm/battle-prep.mp3` | Epic orchestral, War, Orchestral music | Sonican | https://pixabay.com/music/main-title-epic-for-war-orchestral-loop-364200/ |
## Project-original Camp Soundscapes
The following files are generated deterministically by `scripts/generate-camp-audio.mjs`. They use only synthesized oscillators and seeded procedural noise; no external samples or copyrighted recordings are included.
| Project file | Role |
| --- | --- |
| `src/assets/audio/bgm/camp-rally.mp3` | Yellow Turban and anti-Dong Zhuo camp resolve theme |
| `src/assets/audio/bgm/camp-wayfarer.mp3` | Xuzhou, wandering, and Wolong camp theme |
| `src/assets/audio/bgm/camp-grand-strategy.mp3` | Red Cliffs through Yiling grand-strategy camp theme |
| `src/assets/audio/bgm/camp-frontier.mp3` | Nanzhong and Northern Expeditions frontier camp theme |
| `src/assets/audio/ambience/campfire-ambience.mp3` | Campfire bed for early and wandering camps |
| `src/assets/audio/ambience/river-night-ambience.mp3` | River-night bed for Red Cliffs, Jingzhou, and Yiling camps |
| `src/assets/audio/ambience/mountain-wind-ambience.mp3` | Mountain-wind bed for Wolong, Hanzhong, and Northern camps |
| `src/assets/audio/ambience/nanzhong-night-ambience.mp3` | Forest-night bed for Nanzhong camps |
## Sound Effects
| Project file | Pixabay title | Creator | Source |

View File

@@ -8,6 +8,7 @@
"debug": "vite --host 0.0.0.0 --debug",
"build": "tsc && vite build",
"generate:audio": "node scripts/generate-bgm.mjs",
"generate:camp-audio": "node scripts/generate-camp-audio.mjs",
"generate:sfx": "node scripts/generate-sfx.mjs",
"audit:equipment-icons": "node scripts/audit-equipment-icon-quality.mjs",
"audit:visual-assets": "node scripts/audit-visual-asset-quality.mjs",
@@ -21,6 +22,8 @@
"verify:sortie-synergy": "node scripts/verify-sortie-synergy.mjs",
"verify:camp-rewards": "node scripts/verify-camp-reward-data.mjs",
"verify:camp-skins": "node scripts/verify-camp-skin-data.mjs",
"verify:camp-soundscapes": "node scripts/verify-camp-soundscape-data.mjs",
"verify:sound-director": "node scripts/verify-sound-director.mjs",
"verify:campaign-recruits": "node scripts/verify-campaign-recruit-data.mjs",
"verify:equipment-catalog": "node scripts/verify-equipment-catalog-data.mjs",
"verify:inventory-rewards": "node scripts/verify-inventory-reward-data.mjs",

View File

@@ -0,0 +1,746 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join, resolve, sep } from 'node:path';
import { spawnSync } from 'node:child_process';
const sampleRate = 44100;
const channels = 2;
const loopTailSeconds = 2;
const bgmOutputDir = join('src', 'assets', 'audio', 'bgm');
const ambienceOutputDir = join('src', 'assets', 'audio', 'ambience');
const ffmpeg = process.env.FFMPEG_PATH?.trim() || 'ffmpeg';
const musicTracks = [
{
name: 'camp-rally',
title: 'Camp Rally',
tempo: 76,
bars: 16,
root: 50,
progression: [[0, 7, 12], [5, 12, 16], [7, 14, 19], [2, 9, 14]],
melody: [12, 14, 16, 19, 21, 19, 16, 14, 12, 14, 19, 21, 19, 16, 14, 12],
mood: 'rally',
stringGain: 0.02,
lowGain: 0.024,
pluckGain: 0.014,
leadGain: 0.018,
drumGain: 0.025,
pluckBeats: [0.12, 0.62, 1.12, 1.62, 2.12, 2.62, 3.12, 3.62],
drumBeats: [0, 2, 2.5]
},
{
name: 'camp-wayfarer',
title: 'Camp Wayfarer',
tempo: 64,
bars: 16,
root: 45,
progression: [[0, 7, 10], [-2, 5, 10], [-5, 2, 7], [0, 5, 10]],
melody: [12, 15, 17, 19, 17, 15, 12, 10, 7, 10, 12, 15, 12, 10, 7, 5],
mood: 'wayfarer',
stringGain: 0.017,
lowGain: 0.016,
pluckGain: 0.01,
leadGain: 0.014,
drumGain: 0.012,
pluckBeats: [0.18, 2.18],
drumBeats: []
},
{
name: 'camp-grand-strategy',
title: 'Camp Grand Strategy',
tempo: 70,
bars: 16,
root: 43,
progression: [[0, 7, 12], [5, 12, 17], [2, 9, 14], [-2, 5, 10]],
melody: [12, 14, 19, 21, 19, 16, 14, 12, 14, 16, 19, 23, 21, 19, 16, 14],
mood: 'grand',
stringGain: 0.022,
lowGain: 0.025,
pluckGain: 0.015,
leadGain: 0.019,
drumGain: 0.028,
pluckBeats: [0.12, 0.78, 1.45, 2.12, 2.78, 3.45],
drumBeats: [0, 2, 3.5]
},
{
name: 'camp-frontier',
title: 'Camp Frontier',
tempo: 66,
bars: 16,
root: 47,
progression: [[0, 7, 10], [-2, 5, 10], [3, 10, 15], [0, 5, 12]],
melody: [12, 15, 17, 19, 22, 19, 17, 15, 12, 10, 12, 15, 17, 15, 12, 10],
mood: 'frontier',
stringGain: 0.018,
lowGain: 0.017,
pluckGain: 0.013,
leadGain: 0.016,
drumGain: 0.02,
pluckBeats: [0.18, 0.92, 1.68, 2.42, 3.18],
drumBeats: [0, 1.5, 3]
}
];
const ambienceTracks = [
{ name: 'campfire-ambience', title: 'Campfire Ambience', duration: 24, seed: 0x31a9f27d, kind: 'campfire' },
{ name: 'river-night-ambience', title: 'River Night Ambience', duration: 32, seed: 0x6c4d813b, kind: 'river' },
{ name: 'mountain-wind-ambience', title: 'Mountain Wind Ambience', duration: 36, seed: 0x52f0a7c1, kind: 'wind' },
{ name: 'nanzhong-night-ambience', title: 'Nanzhong Night Ambience', duration: 32, seed: 0x18bd4e63, kind: 'nanzhong' }
];
assertFfmpeg();
mkdirSync(bgmOutputDir, { recursive: true });
mkdirSync(ambienceOutputDir, { recursive: true });
const temporaryRoot = resolve(tmpdir());
const temporaryDir = mkdtempSync(join(temporaryRoot, 'heros-camp-audio-'));
try {
for (const track of musicTracks) {
const render = buildMusicTrack(track);
prepareMaster(render.buffer, 68, 0.62, true);
const loopBuffer = wrapTailIntoStart(render.buffer, render.loopDuration, loopTailSeconds);
renderMp3(loopBuffer, track, bgmOutputDir, 192, -16, -1.5, 'music');
}
for (const track of ambienceTracks) {
const render = buildAmbienceTrack(track);
prepareMaster(render.buffer, 45, 0.54, false);
const loopBuffer = wrapTailIntoStart(render.buffer, render.loopDuration, loopTailSeconds);
renderMp3(loopBuffer, track, ambienceOutputDir, 128, -24, -3, 'ambience');
}
} finally {
const resolvedTemporaryDir = resolve(temporaryDir);
const safeTemporaryPrefix = `${temporaryRoot}${sep}`;
if (resolvedTemporaryDir.startsWith(safeTemporaryPrefix) && basename(resolvedTemporaryDir).startsWith('heros-camp-audio-')) {
rmSync(resolvedTemporaryDir, { recursive: true, force: true });
}
}
function buildMusicTrack(track) {
const beat = 60 / track.tempo;
const barDuration = beat * 4;
const loopDuration = barDuration * track.bars;
const renderBuffer = createBuffer(loopDuration + loopTailSeconds);
for (let barIndex = 0; barIndex < track.bars; barIndex += 1) {
const start = barIndex * barDuration;
const chord = track.progression[barIndex % track.progression.length];
const dynamics = musicDynamics(track.mood, barIndex, track.bars);
addStringBed(renderBuffer, start, barDuration + 0.9, track, chord, dynamics);
addLowPulse(renderBuffer, start, beat, track, chord, dynamics);
addPluckedFigure(renderBuffer, start, beat, track, chord, barIndex, dynamics);
addLeadNote(renderBuffer, start, beat, track, barIndex, dynamics);
addDrumPattern(renderBuffer, start, beat, track, barIndex, dynamics);
if ((track.mood === 'rally' || track.mood === 'grand') && (barIndex + 1) % 8 === 0) {
addGong(renderBuffer, start + beat * 3.35, track.mood === 'grand' ? 0.012 : 0.01);
}
if (track.mood === 'wayfarer' && (barIndex === 3 || barIndex === 11)) {
addBell(renderBuffer, start + beat * 3.1, 0.008);
}
}
return { buffer: renderBuffer, loopDuration };
}
function musicDynamics(mood, barIndex, bars) {
if (mood === 'grand') {
const rise = Math.min(1, barIndex / Math.max(1, bars - 5));
const release = barIndex >= bars - 4 ? (bars - barIndex) / 4 : 1;
return (0.84 + rise * 0.16) * Math.max(0.88, release);
}
return 0.88 + Math.sin((barIndex / bars) * Math.PI) * 0.12;
}
function addStringBed(buffer, start, duration, track, chord, dynamics) {
const notes = [
{ midi: track.root + chord[0] + 12, pan: -0.28, gain: 0.78 },
{ midi: track.root + chord[1] + 12, pan: 0.28, gain: 0.66 },
{ midi: track.root + chord[2] + 12, pan: -0.02, gain: 0.54 },
{ midi: track.root + chord[1] + 24, pan: 0.18, gain: 0.23 }
];
notes.forEach((note, index) => addTone(buffer, {
start: start + index * 0.04,
duration,
frequency: midiToFrequency(note.midi),
gain: track.stringGain * note.gain * dynamics,
pan: note.pan,
attack: track.mood === 'grand' ? 0.8 : 1.15,
release: 1.2,
wave: stringWave
}));
}
function addLowPulse(buffer, start, beat, track, chord, dynamics) {
const pulses = track.mood === 'wayfarer' ? [0] : [0, 2];
pulses.forEach((beatOffset) => addTone(buffer, {
start: start + beat * beatOffset,
duration: beat * (track.mood === 'wayfarer' ? 2.4 : 1.45),
frequency: midiToFrequency(Math.max(38, track.root + chord[0])),
gain: track.lowGain * dynamics,
pan: -0.08,
attack: 0.06,
release: 0.62,
wave: celloWave
}));
}
function addPluckedFigure(buffer, start, beat, track, chord, barIndex, dynamics) {
track.pluckBeats.forEach((beatOffset, index) => {
const melodicOffset = track.melody[(barIndex + index) % track.melody.length];
const chordLift = chord[index % chord.length] % 5;
addTone(buffer, {
start: start + beat * beatOffset,
duration: beat * 0.66,
frequency: midiToFrequency(track.root + melodicOffset + 12 + chordLift),
gain: track.pluckGain * dynamics,
pan: index % 2 === 0 ? -0.36 : 0.36,
attack: 0.008,
release: 0.2,
wave: pluckWave,
exponentialDecay: track.mood === 'wayfarer' ? 5.2 : 6.3
});
});
}
function addLeadNote(buffer, start, beat, track, barIndex, dynamics) {
const noteOffset = track.melody[barIndex % track.melody.length];
const leadStart = track.mood === 'wayfarer' ? 1.78 : track.mood === 'frontier' ? 1.35 : 1.18;
const leadDuration = track.mood === 'wayfarer' ? beat * 1.65 : beat * 1.2;
addTone(buffer, {
start: start + beat * leadStart,
duration: leadDuration,
frequency: midiToFrequency(track.root + noteOffset + 12),
gain: track.leadGain * dynamics,
pan: track.mood === 'frontier' ? -0.08 : 0.12,
attack: 0.12,
release: 0.48,
wave: reedWave
});
}
function addDrumPattern(buffer, start, beat, track, barIndex, dynamics) {
const beats = track.mood === 'wayfarer' && (barIndex === 7 || barIndex === 15)
? [0]
: track.drumBeats;
beats.forEach((beatOffset, index) => addTone(buffer, {
start: start + beat * beatOffset,
duration: beat * 0.48,
frequency: index === 0 ? 88 : track.mood === 'frontier' ? 112 : 122,
gain: track.drumGain * dynamics * (index === 0 ? 1 : 0.68),
pan: index % 2 === 0 ? -0.04 : 0.04,
attack: 0.004,
release: 0.12,
wave: drumWave,
exponentialDecay: 8.8
}));
if (track.mood === 'frontier') {
addTone(buffer, {
start,
duration: beat * 0.7,
frequency: 66,
gain: 0.026 * dynamics,
pan: 0,
attack: 0.004,
release: 0.18,
wave: drumWave,
exponentialDecay: 7.4
});
}
}
function addGong(buffer, start, gain) {
[
{ ratio: 1, amount: 0.72, pan: -0.05 },
{ ratio: 1.48, amount: 0.42, pan: 0.08 },
{ ratio: 2.07, amount: 0.24, pan: 0.2 }
].forEach(({ ratio, amount, pan }) => addTone(buffer, {
start,
duration: 2.2,
frequency: 164 * ratio,
gain: gain * amount,
pan,
attack: 0.008,
release: 0.7,
wave: gongWave,
exponentialDecay: 1.7
}));
}
function addBell(buffer, start, gain) {
[1, 2.02, 3.94].forEach((ratio, index) => addTone(buffer, {
start,
duration: 2.1,
frequency: 520 * ratio,
gain: gain / (index + 1),
pan: index % 2 === 0 ? -0.18 : 0.18,
attack: 0.004,
release: 0.6,
wave: sineWave,
exponentialDecay: 2.4
}));
}
function buildAmbienceTrack(track) {
const renderBuffer = createBuffer(track.duration + loopTailSeconds);
if (track.kind === 'campfire') {
synthesizeCampfire(renderBuffer, track);
} else if (track.kind === 'river') {
synthesizeRiverNight(renderBuffer, track);
} else if (track.kind === 'wind') {
synthesizeMountainWind(renderBuffer, track);
} else {
synthesizeNanzhongNight(renderBuffer, track);
}
return { buffer: renderBuffer, loopDuration: track.duration };
}
function synthesizeCampfire(buffer, track) {
const random = createRandom(track.seed);
let leftState = 0;
let rightState = 0;
const alpha = onePoleAlpha(2100);
for (let index = 0; index < buffer.left.length; index += 1) {
leftState += alpha * ((random() * 2 - 1) - leftState);
rightState += alpha * ((random() * 2 - 1) - rightState);
const t = index / sampleRate;
const breathe = 0.7 + Math.sin(2 * Math.PI * 0.19 * t) * 0.16 + Math.sin(2 * Math.PI * 0.43 * t) * 0.08;
buffer.left[index] += leftState * 0.035 * breathe + Math.sin(2 * Math.PI * 78 * t) * 0.003;
buffer.right[index] += rightState * 0.033 * breathe + Math.sin(2 * Math.PI * 81 * t) * 0.0025;
}
const crackleCount = Math.floor((track.duration + loopTailSeconds) * 3.1);
for (let event = 0; event < crackleCount; event += 1) {
const start = random() * (track.duration + loopTailSeconds - 0.08);
addNoiseBurst(buffer, random, start, 0.018 + random() * 0.045, 0.07 + random() * 0.1, random() * 1.4 - 0.7, 44 + random() * 34);
}
}
function synthesizeRiverNight(buffer, track) {
const random = createRandom(track.seed);
let lowLeft = 0;
let lowRight = 0;
let previousLeft = 0;
let previousRight = 0;
const lowAlpha = onePoleAlpha(2200);
const highAlpha = highPassAlpha(90);
for (let index = 0; index < buffer.left.length; index += 1) {
const t = index / sampleRate;
const inputLeft = random() * 2 - 1;
const inputRight = random() * 2 - 1;
lowLeft += lowAlpha * (inputLeft - lowLeft);
lowRight += lowAlpha * (inputRight - lowRight);
const highLeft = highAlpha * ((buffer.left[index - 1] ?? 0) + lowLeft - previousLeft);
const highRight = highAlpha * ((buffer.right[index - 1] ?? 0) + lowRight - previousRight);
previousLeft = lowLeft;
previousRight = lowRight;
const swell = 0.72 + Math.sin(2 * Math.PI * 0.07 * t) * 0.18 + Math.sin(2 * Math.PI * 0.13 * t + 1.1) * 0.09;
buffer.left[index] = highLeft * 0.055 * swell;
buffer.right[index] = highRight * 0.055 * (1.46 - swell * 0.56);
}
for (let event = 0; event < 12; event += 1) {
const start = random() * (track.duration + loopTailSeconds - 0.5);
addTone(buffer, {
start,
duration: 0.24 + random() * 0.18,
frequency: 820 + random() * 620,
gain: 0.008 + random() * 0.006,
pan: random() * 1.4 - 0.7,
attack: 0.006,
release: 0.16,
wave: dropletWave,
exponentialDecay: 7.2
});
}
}
function synthesizeMountainWind(buffer, track) {
const random = createRandom(track.seed);
let lowLeft = 0;
let lowRight = 0;
let highLeft = 0;
let highRight = 0;
let previousLowLeft = 0;
let previousLowRight = 0;
const lowAlpha = onePoleAlpha(1400);
const highAlpha = highPassAlpha(180);
for (let index = 0; index < buffer.left.length; index += 1) {
const t = index / sampleRate;
lowLeft += lowAlpha * ((random() * 2 - 1) - lowLeft);
lowRight += lowAlpha * ((random() * 2 - 1) - lowRight);
highLeft = highAlpha * (highLeft + lowLeft - previousLowLeft);
highRight = highAlpha * (highRight + lowRight - previousLowRight);
previousLowLeft = lowLeft;
previousLowRight = lowRight;
const gust = 0.56 + Math.sin(2 * Math.PI * 0.035 * t) * 0.2 + Math.sin(2 * Math.PI * 0.08 * t + 1.7) * 0.16 + Math.sin(2 * Math.PI * 0.17 * t) * 0.06;
const whistle = Math.sin(2 * Math.PI * (510 + Math.sin(2 * Math.PI * 0.041 * t) * 90) * t) * Math.max(0, gust - 0.65) * 0.004;
buffer.left[index] = highLeft * 0.05 * gust + whistle;
buffer.right[index] = highRight * 0.048 * (gust * 0.92 + 0.08) + whistle * 0.72;
}
}
function synthesizeNanzhongNight(buffer, track) {
const random = createRandom(track.seed);
let forestLeft = 0;
let forestRight = 0;
const alpha = onePoleAlpha(1250);
for (let index = 0; index < buffer.left.length; index += 1) {
const t = index / sampleRate;
forestLeft += alpha * ((random() * 2 - 1) - forestLeft);
forestRight += alpha * ((random() * 2 - 1) - forestRight);
const pulse = 0.68 + Math.sin(2 * Math.PI * 0.09 * t) * 0.16;
buffer.left[index] = forestLeft * 0.038 * pulse;
buffer.right[index] = forestRight * 0.036 * (1.24 - pulse * 0.36);
}
for (let cluster = 0; cluster < 24; cluster += 1) {
const clusterStart = random() * (track.duration + loopTailSeconds - 0.8);
const chirps = 2 + Math.floor(random() * 4);
for (let chirp = 0; chirp < chirps; chirp += 1) {
addTone(buffer, {
start: clusterStart + chirp * (0.055 + random() * 0.045),
duration: 0.045 + random() * 0.04,
frequency: 2800 + random() * 2400,
gain: 0.006 + random() * 0.005,
pan: random() * 1.6 - 0.8,
attack: 0.003,
release: 0.025,
wave: chirpWave,
exponentialDecay: 18
});
}
}
for (let frog = 0; frog < 9; frog += 1) {
addTone(buffer, {
start: random() * (track.duration + loopTailSeconds - 0.6),
duration: 0.28 + random() * 0.24,
frequency: 140 + random() * 70,
gain: 0.008,
pan: random() * 1.2 - 0.6,
attack: 0.02,
release: 0.18,
wave: frogWave,
exponentialDecay: 4.8
});
}
}
function addNoiseBurst(buffer, random, start, duration, gain, pan, decay) {
const startIndex = Math.floor(start * sampleRate);
const count = Math.floor(duration * sampleRate);
const leftGain = Math.cos((pan + 1) * Math.PI * 0.25);
const rightGain = Math.sin((pan + 1) * Math.PI * 0.25);
for (let offset = 0; offset < count; offset += 1) {
const index = startIndex + offset;
if (index >= buffer.left.length) {
break;
}
const t = offset / sampleRate;
const sample = (random() * 2 - 1) * gain * Math.exp(-t * decay);
buffer.left[index] += sample * leftGain;
buffer.right[index] += sample * rightGain;
}
}
function createBuffer(duration) {
const length = Math.ceil(duration * sampleRate);
return { left: new Float32Array(length), right: new Float32Array(length) };
}
function wrapTailIntoStart(buffer, loopDuration, tailSeconds) {
const loopLength = Math.floor(loopDuration * sampleRate);
const tailLength = Math.min(Math.floor(tailSeconds * sampleRate), buffer.left.length - loopLength, loopLength);
const output = { left: buffer.left.slice(0, loopLength), right: buffer.right.slice(0, loopLength) };
for (let index = 0; index < tailLength; index += 1) {
const progress = index / Math.max(1, tailLength - 1);
const tailGain = Math.cos(progress * Math.PI * 0.5);
const startGain = Math.sin(progress * Math.PI * 0.5);
output.left[index] = buffer.left[loopLength + index] * tailGain + output.left[index] * startGain;
output.right[index] = buffer.right[loopLength + index] * tailGain + output.right[index] * startGain;
}
return output;
}
function addTone(buffer, options) {
const startIndex = Math.max(0, Math.floor(options.start * sampleRate));
const count = Math.max(1, Math.floor(options.duration * sampleRate));
const leftGain = Math.cos((options.pan + 1) * Math.PI * 0.25);
const rightGain = Math.sin((options.pan + 1) * Math.PI * 0.25);
for (let offset = 0; offset < count; offset += 1) {
const index = startIndex + offset;
if (index >= buffer.left.length) {
break;
}
const t = offset / sampleRate;
const envelopeValue = options.exponentialDecay
? Math.min(1, t / Math.max(0.001, options.attack)) * Math.exp(-t * options.exponentialDecay)
: envelope(t, options.duration, options.attack, options.release);
const phase = 2 * Math.PI * options.frequency * t;
const sample = options.wave(phase, t) * options.gain * envelopeValue;
buffer.left[index] += sample * leftGain;
buffer.right[index] += sample * rightGain;
}
}
function envelope(t, duration, attack, release) {
const attackLevel = Math.sin(Math.min(1, t / Math.max(0.001, attack)) * Math.PI * 0.5);
const releaseLevel = Math.min(1, Math.max(0, duration - t) / Math.max(0.001, release));
return Math.min(attackLevel, releaseLevel);
}
function stringWave(phase, t) {
const bow = 1 + Math.sin(2 * Math.PI * 0.72 * t) * 0.018;
return Math.sin(phase * bow) * 0.7 + Math.sin(phase * 2) * 0.13 + Math.sin(phase * 3) * 0.045;
}
function celloWave(phase) {
return Math.sin(phase) * 0.78 + Math.sin(phase * 2) * 0.09 + Math.sin(phase * 0.5) * 0.04;
}
function reedWave(phase, t) {
const vibrato = Math.sin(2 * Math.PI * 4.5 * t) * 0.006;
return Math.sin(phase + vibrato) * 0.86 + Math.sin(phase * 2) * 0.045;
}
function pluckWave(phase) {
return Math.sin(phase) * 0.82 + Math.sin(phase * 2) * 0.12 + Math.sin(phase * 3) * 0.035;
}
function drumWave(phase, t) {
const pitchDrop = 1 - Math.min(1, t * 5.8) * 0.48;
return Math.sin(phase * pitchDrop) * 0.92 + Math.sin(phase * 2.02) * 0.06;
}
function gongWave(phase) {
return Math.sin(phase) * 0.76 + Math.sin(phase * 1.48) * 0.17 + Math.sin(phase * 2.07) * 0.09;
}
function sineWave(phase) {
return Math.sin(phase);
}
function dropletWave(phase, t) {
return Math.sin(phase * (1 + t * 0.22)) * 0.88 + Math.sin(phase * 2.2) * 0.08;
}
function chirpWave(phase, t) {
return Math.sin(phase * (1 + t * 4.8));
}
function frogWave(phase, t) {
return Math.sin(phase) * 0.72 + Math.sin(phase * 0.5) * 0.18 + Math.sin(phase * (1.5 + t * 0.4)) * 0.08;
}
function midiToFrequency(midi) {
return 440 * 2 ** ((midi - 69) / 12);
}
function onePoleAlpha(cutoff) {
return 1 - Math.exp((-2 * Math.PI * cutoff) / sampleRate);
}
function highPassAlpha(cutoff) {
const rc = 1 / (2 * Math.PI * cutoff);
const dt = 1 / sampleRate;
return rc / (rc + dt);
}
function createRandom(seed) {
let state = seed >>> 0;
return () => {
state ^= state << 13;
state ^= state >>> 17;
state ^= state << 5;
return (state >>> 0) / 0x100000000;
};
}
function prepareMaster(buffer, highPassCutoff, targetPeak, room) {
highPass(buffer.left, highPassCutoff);
highPass(buffer.right, highPassCutoff);
if (room) {
applyRoom(buffer);
}
normalize(buffer, targetPeak);
softLimit(buffer);
}
function highPass(samples, cutoff) {
const alpha = highPassAlpha(cutoff);
let previousOutput = 0;
let previousInput = samples[0] ?? 0;
for (let index = 0; index < samples.length; index += 1) {
const input = samples[index];
const output = alpha * (previousOutput + input - previousInput);
samples[index] = output;
previousOutput = output;
previousInput = input;
}
}
function applyRoom(buffer) {
const originalLeft = Float32Array.from(buffer.left);
const originalRight = Float32Array.from(buffer.right);
[
{ delay: 0.075, gain: 0.07 },
{ delay: 0.132, gain: 0.045 }
].forEach(({ delay, gain }) => {
const samples = Math.floor(sampleRate * delay);
for (let index = samples; index < buffer.left.length; index += 1) {
buffer.left[index] += originalRight[index - samples] * gain;
buffer.right[index] += originalLeft[index - samples] * gain;
}
});
}
function normalize(buffer, targetPeak) {
let peak = 0;
for (let index = 0; index < buffer.left.length; index += 1) {
peak = Math.max(peak, Math.abs(buffer.left[index]), Math.abs(buffer.right[index]));
}
if (peak <= 0) {
return;
}
const scale = targetPeak / peak;
for (let index = 0; index < buffer.left.length; index += 1) {
buffer.left[index] *= scale;
buffer.right[index] *= scale;
}
}
function softLimit(buffer) {
const divisor = Math.tanh(1.04);
for (let index = 0; index < buffer.left.length; index += 1) {
buffer.left[index] = Math.tanh(buffer.left[index] * 1.04) / divisor;
buffer.right[index] = Math.tanh(buffer.right[index] * 1.04) / divisor;
}
}
function renderMp3(buffer, track, outputDir, bitrateKbps, loudness, truePeak, kind) {
const wavPath = join(temporaryDir, `${track.name}.wav`);
const outputPath = join(outputDir, `${track.name}.mp3`);
writeFileSync(wavPath, encodeWav(buffer));
const result = spawnSync(ffmpeg, [
'-y', '-hide_banner', '-loglevel', 'error',
'-i', wavPath,
'-af', `loudnorm=I=${loudness}:LRA=6:TP=${truePeak}`,
'-c:a', 'libmp3lame',
'-b:a', `${bitrateKbps}k`,
'-ar', String(sampleRate),
'-ac', String(channels),
'-write_xing', '1',
'-metadata', `title=${track.title}`,
'-metadata', 'artist=Heros Web Procedural Ensemble',
'-metadata', 'comment=Project-original deterministic synthesis; no sampled source audio.',
outputPath
], { encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`ffmpeg failed for ${track.name}: ${result.stderr || result.stdout}`);
}
const seam = inspectEncodedLoopSeam(outputPath, track.name, kind);
console.log(
`Generated ${outputPath} (seam ${seam.boundaryJump.toFixed(4)} / ${seam.absoluteLimit.toFixed(4)}, ` +
`peak ${(seam.peakRatio * 100).toFixed(1)}%)`
);
}
function inspectEncodedLoopSeam(outputPath, trackName, kind) {
const result = spawnSync(ffmpeg, [
'-hide_banner', '-loglevel', 'error',
'-i', outputPath,
'-f', 'f32le',
'-c:a', 'pcm_f32le',
'-ar', String(sampleRate),
'-ac', String(channels),
'pipe:1'
], { maxBuffer: 96 * 1024 * 1024 });
if (result.status !== 0 || !Buffer.isBuffer(result.stdout)) {
throw new Error(`ffmpeg could not decode ${trackName} for loop seam verification: ${result.stderr?.toString() || ''}`);
}
const bytesPerFrame = channels * 4;
const frameCount = Math.floor(result.stdout.length / bytesPerFrame);
if (frameCount < 2) {
throw new Error(`${trackName} did not decode enough samples for loop seam verification.`);
}
const sample = (frame, channel) => result.stdout.readFloatLE(frame * bytesPerFrame + channel * 4);
let signalPeak = 0;
let deltaSquaredSum = 0;
let deltaCount = 0;
for (let channel = 0; channel < channels; channel += 1) {
signalPeak = Math.max(signalPeak, Math.abs(sample(0, channel)));
}
for (let frame = 1; frame < frameCount; frame += 1) {
for (let channel = 0; channel < channels; channel += 1) {
const current = sample(frame, channel);
const delta = current - sample(frame - 1, channel);
signalPeak = Math.max(signalPeak, Math.abs(current));
deltaSquaredSum += delta * delta;
deltaCount += 1;
}
}
let boundaryJump = 0;
for (let channel = 0; channel < channels; channel += 1) {
boundaryJump = Math.max(boundaryJump, Math.abs(sample(0, channel) - sample(frameCount - 1, channel)));
}
const deltaRms = Math.sqrt(deltaSquaredSum / Math.max(1, deltaCount));
const absoluteLimit = kind === 'music'
? Math.min(0.04, Math.max(0.02, deltaRms * 4))
: Math.min(0.12, Math.max(0.04, deltaRms * 4));
const peakRatioLimit = kind === 'music' ? 0.1 : 0.75;
const peakRatio = boundaryJump / Math.max(signalPeak, Number.EPSILON);
if (
!Number.isFinite(boundaryJump) ||
boundaryJump > absoluteLimit ||
peakRatio > peakRatioLimit
) {
throw new Error(
`${trackName} loop seam is too abrupt after MP3 encoding: jump ${boundaryJump.toFixed(6)}, ` +
`limit ${absoluteLimit.toFixed(6)}, peak ratio ${(peakRatio * 100).toFixed(2)}%.`
);
}
return { boundaryJump, absoluteLimit, peakRatio };
}
function encodeWav(buffer) {
const bytesPerSample = 2;
const frameCount = buffer.left.length;
const dataSize = frameCount * channels * bytesPerSample;
const wav = Buffer.alloc(44 + dataSize);
wav.write('RIFF', 0);
wav.writeUInt32LE(36 + dataSize, 4);
wav.write('WAVE', 8);
wav.write('fmt ', 12);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(channels, 22);
wav.writeUInt32LE(sampleRate, 24);
wav.writeUInt32LE(sampleRate * channels * bytesPerSample, 28);
wav.writeUInt16LE(channels * bytesPerSample, 32);
wav.writeUInt16LE(16, 34);
wav.write('data', 36);
wav.writeUInt32LE(dataSize, 40);
for (let index = 0; index < frameCount; index += 1) {
const offset = 44 + index * channels * bytesPerSample;
wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buffer.left[index])) * 32767), offset);
wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, buffer.right[index])) * 32767), offset + bytesPerSample);
}
return wav;
}
function assertFfmpeg() {
const result = spawnSync(ffmpeg, ['-version'], { encoding: 'utf8' });
if (result.status !== 0) {
throw new Error(`ffmpeg is required to generate camp audio. Set FFMPEG_PATH when it is not on PATH. ${result.stderr || ''}`);
}
}

View File

@@ -19,16 +19,17 @@ const server = await createServer({
});
try {
const { musicTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const { musicTracks, ambienceTracks, effectTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const scenarioModule = await server.ssrLoadModule('/src/game/data/scenario.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const errors = [];
validateTrackMap(errors, 'music', musicTracks, 'bgm');
validateTrackMap(errors, 'ambience', ambienceTracks, 'ambience');
validateTrackMap(errors, 'effect', effectTracks, 'sfx');
validateAudioDirectoryCoverage(errors, musicTracks, effectTracks);
validateAudioDirectoryCoverage(errors, musicTracks, ambienceTracks, effectTracks);
validateStoryBgmReferences(errors, scenarioModule, battleScenarios, musicTracks);
validateLiteralAudioCalls(errors, musicTracks, effectTracks);
validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks);
if (errors.length) {
console.error(`Audio asset data verification failed with ${errors.length} issue(s):`);
@@ -39,7 +40,8 @@ try {
process.exitCode = 1;
} else {
console.log(
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(effectTracks).length} effect tracks, ` +
`Verified ${Object.keys(musicTracks).length} music tracks, ${Object.keys(ambienceTracks).length} ambience tracks, ` +
`${Object.keys(effectTracks).length} effect tracks, ` +
`${collectStoryBgmReferences(scenarioModule, battleScenarios).length} story BGM references, and literal audio calls.`
);
}
@@ -64,8 +66,9 @@ function validateTrackMap(errors, label, tracks, expectedFolder) {
});
}
function validateAudioDirectoryCoverage(errors, musicTracks, effectTracks) {
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) => {
@@ -73,6 +76,11 @@ function validateAudioDirectoryCoverage(errors, musicTracks, effectTracks) {
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`);
@@ -89,8 +97,9 @@ function validateStoryBgmReferences(errors, scenarioModule, battleScenarios, mus
});
}
function validateLiteralAudioCalls(errors, musicTracks, effectTracks) {
function validateLiteralAudioCalls(errors, musicTracks, ambienceTracks, effectTracks) {
const musicKeys = new Set(Object.keys(musicTracks));
const ambienceKeys = new Set(Object.keys(ambienceTracks));
const effectKeys = new Set(Object.keys(effectTracks));
sourceFilesToScan.forEach((path) => {
@@ -100,6 +109,11 @@ function validateLiteralAudioCalls(errors, musicTracks, effectTracks) {
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 "${key}"`);

View File

@@ -0,0 +1,297 @@
import { createServer } from 'vite';
import { createHash } from 'node:crypto';
import { readFileSync, statSync } from 'node:fs';
import { sep } from 'node:path';
const expectedBattleArcs = [
{ skinId: 'yellow-turban', firstBattleOrdinal: 1, lastBattleOrdinal: 4 },
{ skinId: 'anti-dong', firstBattleOrdinal: 5, lastBattleOrdinal: 6 },
{ skinId: 'xuzhou', firstBattleOrdinal: 7, lastBattleOrdinal: 9 },
{ skinId: 'wandering', firstBattleOrdinal: 10, lastBattleOrdinal: 15 },
{ skinId: 'wolong', firstBattleOrdinal: 16, lastBattleOrdinal: 18 },
{ skinId: 'red-cliffs', firstBattleOrdinal: 19, lastBattleOrdinal: 22 },
{ skinId: 'jing-yi', firstBattleOrdinal: 23, lastBattleOrdinal: 34 },
{ skinId: 'hanzhong-shuhan', firstBattleOrdinal: 35, lastBattleOrdinal: 37 },
{ skinId: 'jingzhou-crisis', firstBattleOrdinal: 38, lastBattleOrdinal: 44 },
{ skinId: 'yiling-baidi', firstBattleOrdinal: 45, lastBattleOrdinal: 46 },
{ skinId: 'nanzhong', firstBattleOrdinal: 47, lastBattleOrdinal: 54 },
{ skinId: 'northern', firstBattleOrdinal: 55, lastBattleOrdinal: 66 }
];
const expectedMusicGroups = {
rally: 'camp-rally',
wayfarer: 'camp-wayfarer',
'grand-strategy': 'camp-grand-strategy',
frontier: 'camp-frontier'
};
const expectedAmbiences = {
campfire: 'campfire-ambience',
'river-night': 'river-night-ambience',
'mountain-wind': 'mountain-wind-ambience',
'nanzhong-night': 'nanzhong-night-ambience'
};
const expectedMapping = {
'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' },
'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' },
xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' },
'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' },
'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' },
northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' }
};
const expectedSpecialSteps = {
'hanzhong-king-camp': 'hanzhong-shuhan',
'shu-han-foundation-camp': 'hanzhong-shuhan',
'baidi-entrustment-camp': 'yiling-baidi',
'northern-campaign-prep-camp': 'northern',
'ending-complete': 'northern'
};
const errors = [];
const server = await createServer({
logLevel: 'error',
server: { middlewareMode: true },
appType: 'custom'
});
try {
const { campMusicGroups, campAmbienceDefinitions, campSoundscapeMapping, getCampSoundscape } =
await server.ssrLoadModule('/src/game/data/campSoundscapes.ts');
const { selectCampSkin } = await server.ssrLoadModule('/src/game/data/campSkins.ts');
const { battleScenarios } = await server.ssrLoadModule('/src/game/data/battles.ts');
const { musicTracks, ambienceTracks } = await server.ssrLoadModule('/src/game/audio/audioAssets.ts');
const expectedSkinIds = Object.keys(expectedMapping);
const musicEntries = Object.entries(campMusicGroups);
const ambienceEntries = Object.entries(campAmbienceDefinitions);
const mappingEntries = Object.entries(campSoundscapeMapping);
const battleIds = Object.keys(battleScenarios);
assertEqual(expectedSkinIds.length, 12, 'expected camp skin count');
assertEqual(mappingEntries.length, 12, 'camp soundscape mapping count');
assertEqual(musicEntries.length, 4, 'camp music group count');
assertEqual(ambienceEntries.length, 4, 'camp ambience definition count');
assertEqual(battleIds.length, 66, 'battle count used by camp soundscapes');
validateDefinitions(musicEntries, expectedMusicGroups, musicTracks, 'music group', 0.05, 0.4);
validateDefinitions(ambienceEntries, expectedAmbiences, ambienceTracks, 'ambience', 0.03, 0.3);
validateCampAudioAssets(
musicEntries.map(([, definition]) => ({ key: definition.trackKey, url: musicTracks[definition.trackKey], kind: 'music' })),
ambienceEntries.map(([, definition]) => ({ key: definition.trackKey, url: ambienceTracks[definition.trackKey], kind: 'ambience' }))
);
expectedSkinIds.forEach((skinId) => {
const actualMapping = campSoundscapeMapping[skinId];
const expected = expectedMapping[skinId];
assert(Boolean(actualMapping), `${skinId}: missing soundscape mapping`);
if (!actualMapping) {
return;
}
assertEqual(actualMapping.musicGroupId, expected.musicGroupId, `${skinId}: music group`);
assertEqual(actualMapping.ambienceId, expected.ambienceId, `${skinId}: ambience`);
assert(Boolean(campMusicGroups[actualMapping.musicGroupId]), `${skinId}: unknown music group`);
assert(Boolean(campAmbienceDefinitions[actualMapping.ambienceId]), `${skinId}: unknown ambience`);
const soundscape = getCampSoundscape(skinId);
assertEqual(soundscape.skinId, skinId, `${skinId}: resolved skin id`);
assertEqual(soundscape.music, campMusicGroups[expected.musicGroupId], `${skinId}: resolved music definition`);
assertEqual(soundscape.ambience, campAmbienceDefinitions[expected.ambienceId], `${skinId}: resolved ambience definition`);
});
mappingEntries.forEach(([skinId]) => {
assert(expectedSkinIds.includes(skinId), `unexpected camp soundscape mapping "${skinId}"`);
});
battleIds.forEach((battleId, index) => {
const battleOrdinal = index + 1;
const expectedSkinId = expectedSkinForOrdinal(battleOrdinal);
const selection = selectCampSkin({ nextBattleId: battleId });
assertEqual(selection.skin?.id, expectedSkinId, `${battleId}: selected camp skin`);
assertSelectionSoundscape(selection, `${battleId}: battle ${battleOrdinal}`, getCampSoundscape);
});
Object.entries(expectedSpecialSteps).forEach(([campaignStep, expectedSkinId]) => {
const selection = selectCampSkin({
campaignStep,
nextBattleId: battleIds[0],
currentBattleId: battleIds.at(-1)
});
assertEqual(selection.source, 'special-step', `${campaignStep}: selection source`);
assertEqual(selection.skin?.id, expectedSkinId, `${campaignStep}: selected camp skin`);
assertSelectionSoundscape(selection, `${campaignStep}: special override`, getCampSoundscape);
});
if (errors.length > 0) {
throw new Error(`Camp soundscape data verification failed:\n${errors.map((error) => `- ${error}`).join('\n')}`);
}
console.log(
`Verified ${mappingEntries.length} camp soundscape mappings, ${musicEntries.length} music groups, ` +
`${ambienceEntries.length} ambiences, ${battleIds.length} battle selections, and ` +
`${Object.keys(expectedSpecialSteps).length} special-step overrides.`
);
} finally {
await server.close();
}
function validateDefinitions(entries, expectedDefinitions, registeredTracks, context, minimumVolume, maximumVolume) {
const expectedIds = Object.keys(expectedDefinitions);
const registeredKeys = new Set(Object.keys(registeredTracks));
entries.forEach(([id, definition]) => {
assert(expectedIds.includes(id), `unexpected camp ${context} "${id}"`);
assertEqual(definition.id, id, `${context} ${id}: id`);
assertEqual(definition.trackKey, expectedDefinitions[id], `${context} ${id}: track key`);
assertNonEmptyString(definition.label, `${context} ${id}: label`);
assert(
Number.isFinite(definition.volume) &&
definition.volume >= minimumVolume &&
definition.volume <= maximumVolume,
`${context} ${id}: volume must be between ${minimumVolume} and ${maximumVolume}`
);
assert(registeredKeys.has(definition.trackKey), `${context} ${id}: unregistered track "${definition.trackKey}"`);
assertNonEmptyString(registeredTracks[definition.trackKey], `${context} ${id}: registered track URL`);
});
expectedIds.forEach((id) => {
assert(Boolean(entries.find(([actualId]) => actualId === id)), `missing camp ${context} "${id}"`);
});
assertEqual(new Set(entries.map(([, definition]) => definition.trackKey)).size, entries.length, `${context} track keys`);
}
function validateCampAudioAssets(musicAssets, ambienceAssets) {
const assets = [...musicAssets, ...ambienceAssets];
const hashes = new Set();
let totalBytes = 0;
assets.forEach((asset) => {
const path = assetUrlToPath(asset.url);
assert(Boolean(path), `${asset.kind} ${asset.key}: URL does not point at src/assets/audio`);
if (!path) {
return;
}
let bytes;
let size;
try {
bytes = readFileSync(path);
size = statSync(path).size;
} catch (error) {
errors.push(`${asset.kind} ${asset.key}: cannot read ${path}: ${error.message}`);
return;
}
totalBytes += size;
const hash = createHash('sha256').update(bytes).digest('hex');
assert(!hashes.has(hash), `${asset.kind} ${asset.key}: audio content duplicates another camp asset`);
hashes.add(hash);
const metadata = readMp3Metadata(bytes);
assert(Boolean(metadata), `${asset.kind} ${asset.key}: missing readable MPEG-1 Layer III frame`);
if (!metadata) {
return;
}
assertEqual(metadata.sampleRate, 44100, `${asset.kind} ${asset.key}: sample rate`);
assertEqual(metadata.channels, 2, `${asset.kind} ${asset.key}: channel count`);
if (asset.kind === 'music') {
assertEqual(metadata.bitrateKbps, 192, `music ${asset.key}: bitrate`);
assert(size >= 256 * 1024 && size <= 2 * 1024 * 1024, `music ${asset.key}: size must be 256 KiB to 2 MiB`);
assert(metadata.durationSeconds >= 45 && metadata.durationSeconds <= 70, `music ${asset.key}: duration must be 45 to 70 seconds`);
} else {
assertEqual(metadata.bitrateKbps, 128, `ambience ${asset.key}: bitrate`);
assert(size >= 64 * 1024 && size <= 1024 * 1024, `ambience ${asset.key}: size must be 64 KiB to 1 MiB`);
assert(metadata.durationSeconds >= 20 && metadata.durationSeconds <= 45, `ambience ${asset.key}: duration must be 20 to 45 seconds`);
}
});
assert(totalBytes <= 8 * 1024 * 1024, `camp audio assets must stay at or below 8 MiB; received ${totalBytes} bytes`);
}
function assetUrlToPath(url) {
const marker = 'src/assets/audio/';
const index = typeof url === 'string' ? url.indexOf(marker) : -1;
return index >= 0 ? url.slice(index).replaceAll('/', sep) : undefined;
}
function readMp3Metadata(bytes) {
let searchOffset = 0;
if (bytes.subarray(0, 3).toString('ascii') === 'ID3' && bytes.length >= 10) {
const tagSize = ((bytes[6] & 0x7f) << 21) | ((bytes[7] & 0x7f) << 14) | ((bytes[8] & 0x7f) << 7) | (bytes[9] & 0x7f);
searchOffset = 10 + tagSize;
}
const bitrateTable = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 0];
const sampleRateTable = [44100, 48000, 32000, 0];
const searchLimit = Math.min(bytes.length - 4, searchOffset + 65536);
for (let offset = searchOffset; offset <= searchLimit; offset += 1) {
if (bytes[offset] !== 0xff || (bytes[offset + 1] & 0xe0) !== 0xe0) {
continue;
}
const versionBits = (bytes[offset + 1] >> 3) & 0x03;
const layerBits = (bytes[offset + 1] >> 1) & 0x03;
if (versionBits !== 0x03 || layerBits !== 0x01) {
continue;
}
const bitrateKbps = bitrateTable[(bytes[offset + 2] >> 4) & 0x0f];
const sampleRate = sampleRateTable[(bytes[offset + 2] >> 2) & 0x03];
if (!bitrateKbps || !sampleRate) {
continue;
}
const channelMode = (bytes[offset + 3] >> 6) & 0x03;
return {
bitrateKbps,
sampleRate,
channels: channelMode === 0x03 ? 1 : 2,
durationSeconds: ((bytes.length - searchOffset) * 8) / (bitrateKbps * 1000)
};
}
return undefined;
}
function assertSelectionSoundscape(selection, context, getCampSoundscape) {
const skinId = selection.skin?.id;
assert(Boolean(skinId), `${context}: selection has no skin`);
if (!skinId || !expectedMapping[skinId]) {
return;
}
const expected = expectedMapping[skinId];
const soundscape = getCampSoundscape(skinId);
assertEqual(soundscape.music.id, expected.musicGroupId, `${context}: music group`);
assertEqual(soundscape.ambience.id, expected.ambienceId, `${context}: ambience`);
}
function expectedSkinForOrdinal(battleOrdinal) {
return expectedBattleArcs.find(
({ firstBattleOrdinal, lastBattleOrdinal }) =>
battleOrdinal >= firstBattleOrdinal && battleOrdinal <= lastBattleOrdinal
)?.skinId;
}
function assertNonEmptyString(value, context) {
assert(typeof value === 'string' && value.trim().length > 0, `${context} must be a non-empty string`);
}
function assertEqual(actual, expected, context) {
assert(Object.is(actual, expected), `${context}: expected ${format(expected)}, received ${format(actual)}`);
}
function assert(condition, message) {
if (!condition) {
errors.push(message);
}
}
function format(value) {
return value === undefined ? 'undefined' : JSON.stringify(value);
}

View File

@@ -2020,6 +2020,7 @@ try {
await clickLegacyUi(page, 738, 642);
await waitForCampAfterBattleResult(page);
await waitForCampSkinTransition(page, 'yellow-turban');
await waitForCampSoundscapeTransition(page, 'camp-rally', 'campfire-ambience');
await page.screenshot({ path: `${screenshotDir}/rc-first-camp.png`, fullPage: true });
await assertCanvasPainted(page, 'first camp');
@@ -2037,6 +2038,12 @@ try {
});
assert(firstCampProbe.state?.campaign?.step === 'first-camp', `Expected victory to return to first camp: ${JSON.stringify(firstCampProbe.state)}`);
assertCampSkinState(firstCampProbe.state, 'yellow-turban', 'first camp');
assertCampSoundscapeState(firstCampProbe.state, {
musicKey: 'camp-rally',
ambienceKey: 'campfire-ambience',
musicVolume: 0.22,
ambienceVolume: 0.12
}, 'first camp');
assert(
firstCampProbe.state?.campRoster?.pageCount === 1 &&
firstCampProbe.state.campRoster.totalCount === 3 &&
@@ -2075,7 +2082,10 @@ try {
firstCampProbe.state?.sortieDeploymentPreview?.map((slot) => `${slot.x},${slot.y}`),
`Expected first camp deployment preview to avoid duplicate tiles: ${JSON.stringify(firstCampProbe.state?.sortieDeploymentPreview)}`
);
assert(firstCampProbe.summary?.includes('보유 군자금'), `Expected camp summary to show held gold: ${JSON.stringify(firstCampProbe)}`);
assert(
firstCampProbe.summary?.includes('군자금') && !firstCampProbe.summary.includes('\n'),
`Expected the compact camp summary to show held gold on one line: ${JSON.stringify(firstCampProbe)}`
);
assert(firstCampProbe.texts.some((text) => text.includes('전투 보상')), `Expected camp report to show battle reward: ${JSON.stringify(firstCampProbe.texts)}`);
assert(
firstCampProbe.texts.some((text) => text.includes('명령 기록') && text.includes('재정비')),
@@ -2565,6 +2575,16 @@ try {
undefined,
{ timeout: 30000 }
);
const firstCampTabAudioState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp()?.audio);
assert(
firstCampTabAudioState?.music?.currentKey === firstCampProbe.state?.audio?.music?.currentKey &&
firstCampTabAudioState?.ambience?.currentKey === firstCampProbe.state?.audio?.ambience?.currentKey &&
firstCampTabAudioState?.music?.elementRevision === firstCampProbe.state?.audio?.music?.elementRevision &&
firstCampTabAudioState?.ambience?.elementRevision === firstCampProbe.state?.audio?.ambience?.elementRevision &&
firstCampTabAudioState?.music?.transition?.revision === firstCampProbe.state?.audio?.music?.transition?.revision &&
firstCampTabAudioState?.ambience?.transition?.revision === firstCampProbe.state?.audio?.ambience?.transition?.revision,
`Expected camp tab changes to keep the existing soundscape elements: ${JSON.stringify({ before: firstCampProbe.state?.audio, after: firstCampTabAudioState })}`
);
const firstCampUseSupplyPoint = await readCampSupplyUseControlPoint(page, '콩');
await page.mouse.click(firstCampUseSupplyPoint.x, firstCampUseSupplyPoint.y);
await page.waitForTimeout(120);
@@ -5212,9 +5232,16 @@ try {
await clickLegacyUi(page, 962, 310);
await waitForCamp(page);
await waitForCampSkinTransition(page, 'northern');
await waitForCampSoundscapeTransition(page, 'camp-frontier', 'mountain-wind-ambience');
const finalCampState = await page.evaluate(() => window.__HEROS_DEBUG__?.camp());
assert(finalCampState?.campaign?.step === 'sixty-sixth-camp', `Expected final camp save to continue: ${JSON.stringify(finalCampState)}`);
assertCampSkinState(finalCampState, 'northern', 'final camp');
assertCampSoundscapeState(finalCampState, {
musicKey: 'camp-frontier',
ambienceKey: 'mountain-wind-ambience',
musicVolume: 0.2,
ambienceVolume: 0.09
}, 'final camp');
assert(finalCampState.sortieHasBattle === false, `Expected final camp to expose no playable sortie target: ${JSON.stringify(finalCampState)}`);
assert(finalCampState.stagedSortiePrep === false, `Expected the non-battle final camp to avoid the three-stage sortie flow: ${JSON.stringify(finalCampState)}`);
assert(finalCampState.nextSortieBattleId === null, `Expected final camp to route to ending instead of a battle: ${JSON.stringify(finalCampState)}`);
@@ -7080,6 +7107,26 @@ async function waitForCampSkinTransition(page, expectedSkinId) {
}));
}
async function waitForCampSoundscapeTransition(page, expectedMusicKey, expectedAmbienceKey) {
await page.waitForFunction(
({ musicKey, ambienceKey }) => {
const audio = window.__HEROS_DEBUG__?.camp?.()?.audio;
const channels = [audio?.music, audio?.ambience];
return (
audio?.music?.currentKey === musicKey &&
audio?.ambience?.currentKey === ambienceKey &&
channels.every((channel) =>
channel?.transition?.active === false &&
channel.transition.revision > 0 &&
channel.transition.completedRevision === channel.transition.revision
)
);
},
{ musicKey: expectedMusicKey, ambienceKey: expectedAmbienceKey },
{ timeout: 30000 }
);
}
async function waitForCampVisualFrame(page, expectedPage) {
await page.waitForFunction(
(pageNumber) => window.__HEROS_DEBUG__?.camp()?.campRoster?.page === pageNumber,
@@ -8491,6 +8538,42 @@ function assertCampSkinState(state, expectedSkinId, label) {
);
}
function assertCampSoundscapeState(state, expected, label) {
const soundscape = state?.campSoundscape;
const audio = state?.audio;
assert(
soundscape?.skinId === state?.campSkin?.id &&
soundscape.musicKey === expected.musicKey &&
soundscape.ambienceKey === expected.ambienceKey &&
Math.abs(soundscape.musicVolume - expected.musicVolume) <= 0.001 &&
Math.abs(soundscape.ambienceVolume - expected.ambienceVolume) <= 0.001,
`Expected ${label} to select its era soundscape profile: ${JSON.stringify(soundscape)}`
);
for (const [channelName, expectedKey, expectedVolume, minFadeMs, maxFadeMs] of [
['music', expected.musicKey, expected.musicVolume, 700, 1100],
['ambience', expected.ambienceKey, expected.ambienceVolume, 1000, 1400]
]) {
const channel = audio?.[channelName];
assert(
channel?.currentKey === expectedKey &&
channel.pendingKey === null &&
channel.hasPendingRequest === false &&
channel.activeElementCount === 1 &&
channel.elementRevision > 0 &&
Math.abs(channel.targetVolume - expectedVolume) <= 0.001 &&
channel.transition?.active === false &&
channel.transition.revision > 0 &&
channel.transition.completedRevision === channel.transition.revision &&
channel.transition.last?.toKey === expectedKey &&
channel.transition.last.durationMs >= minFadeMs &&
channel.transition.last.durationMs <= maxFadeMs,
`Expected ${label} ${channelName} channel to finish one clean crossfade: ${JSON.stringify(channel)}`
);
}
}
function isVisibleCampSkinView(view) {
return Boolean(
view?.visible === true &&

View File

@@ -140,6 +140,18 @@ try {
});
const elementsDuringCrossfade = audioHarness.activeInstances();
assert.equal(elementsDuringCrossfade.length, 4, 'two channels should each retain one outgoing element during a crossfade');
assertClose(
audioHarness.byOriginalSrc('/audio/music-a.ogg').volume,
0.18,
'outgoing music should keep its settled volume at crossfade start'
);
assertClose(
audioHarness.byOriginalSrc('/audio/ambience-a.ogg').volume,
0.05,
'outgoing ambience should keep its settled volume at crossfade start'
);
assertClose(audioHarness.byOriginalSrc('/audio/music-b.ogg').volume, 0, 'incoming music should start silent');
assertClose(audioHarness.byOriginalSrc('/audio/ambience-b.ogg').volume, 0, 'incoming ambience should start silent');
director.setMuted(true);
assert(elementsDuringCrossfade.every((element) => element.muted), 'mute should cover current and outgoing elements');

View File

@@ -8,6 +8,7 @@ const checks = [
'scripts/verify-battle-scenario-data.mjs',
'scripts/verify-camp-reward-data.mjs',
'scripts/verify-camp-skin-data.mjs',
'scripts/verify-camp-soundscape-data.mjs',
'scripts/verify-campaign-recruit-data.mjs',
'scripts/verify-equipment-catalog-data.mjs',
'scripts/verify-inventory-reward-data.mjs',
@@ -16,7 +17,8 @@ const checks = [
'scripts/verify-visual-asset-data.mjs',
'scripts/verify-unit-sprite-asset-data.mjs',
'scripts/verify-battle-map-asset-data.mjs',
'scripts/verify-audio-asset-data.mjs'
'scripts/verify-audio-asset-data.mjs',
'scripts/verify-sound-director.mjs'
];
for (const scriptPath of checks) {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -380,9 +380,6 @@ export class SoundDirector {
private setLoopTargetVolume(channel: LoopChannel, volume: number) {
channel.targetVolume = Number.isFinite(volume) ? Math.min(1, Math.max(0, volume)) : channel.defaultVolume;
if (channel.current && !channel.transition.active) {
channel.current.volume = channel.targetVolume;
}
}
private requestLoopChannel(channel: LoopChannel, key?: string) {

View File

@@ -1,4 +1,8 @@
import battlePrepUrl from '../../assets/audio/bgm/battle-prep.mp3';
import campFrontierUrl from '../../assets/audio/bgm/camp-frontier.mp3';
import campGrandStrategyUrl from '../../assets/audio/bgm/camp-grand-strategy.mp3';
import campRallyUrl from '../../assets/audio/bgm/camp-rally.mp3';
import campWayfarerUrl from '../../assets/audio/bgm/camp-wayfarer.mp3';
import militiaThemeUrl from '../../assets/audio/bgm/militia-theme.mp3';
import oathThemeUrl from '../../assets/audio/bgm/oath-theme.mp3';
import storyDarkUrl from '../../assets/audio/bgm/story-dark.mp3';
@@ -15,13 +19,28 @@ import strategyCastUrl from '../../assets/audio/sfx/strategy-cast.mp3';
import swordSlashUrl from '../../assets/audio/sfx/sword-slash.mp3';
import uiSelectUrl from '../../assets/audio/sfx/ui-select.mp3';
import victoryFanfareUrl from '../../assets/audio/sfx/victory-fanfare.wav';
import campfireAmbienceUrl from '../../assets/audio/ambience/campfire-ambience.mp3';
import mountainWindAmbienceUrl from '../../assets/audio/ambience/mountain-wind-ambience.mp3';
import nanzhongNightAmbienceUrl from '../../assets/audio/ambience/nanzhong-night-ambience.mp3';
import riverNightAmbienceUrl from '../../assets/audio/ambience/river-night-ambience.mp3';
export const musicTracks = {
'title-theme': titleThemeUrl,
'story-dark': storyDarkUrl,
'oath-theme': oathThemeUrl,
'militia-theme': militiaThemeUrl,
'battle-prep': battlePrepUrl
'battle-prep': battlePrepUrl,
'camp-rally': campRallyUrl,
'camp-wayfarer': campWayfarerUrl,
'camp-grand-strategy': campGrandStrategyUrl,
'camp-frontier': campFrontierUrl
} as const;
export const ambienceTracks = {
'campfire-ambience': campfireAmbienceUrl,
'river-night-ambience': riverNightAmbienceUrl,
'mountain-wind-ambience': mountainWindAmbienceUrl,
'nanzhong-night-ambience': nanzhongNightAmbienceUrl
} as const;
export const effectTracks = {
@@ -40,4 +59,5 @@ export const effectTracks = {
} as const;
export type MusicTrackKey = keyof typeof musicTracks;
export type AmbienceTrackKey = keyof typeof ambienceTracks;
export type EffectTrackKey = keyof typeof effectTracks;

View File

@@ -0,0 +1,119 @@
import type { AmbienceTrackKey, MusicTrackKey } from '../audio/audioAssets';
import type { CampSkinId } from './campSkins';
export type CampMusicGroupId = 'rally' | 'wayfarer' | 'grand-strategy' | 'frontier';
export type CampAmbienceId = 'campfire' | 'river-night' | 'mountain-wind' | 'nanzhong-night';
export type CampMusicTrackKey = Extract<
MusicTrackKey,
'camp-rally' | 'camp-wayfarer' | 'camp-grand-strategy' | 'camp-frontier'
>;
export type CampAmbienceTrackKey = Extract<
AmbienceTrackKey,
'campfire-ambience' | 'river-night-ambience' | 'mountain-wind-ambience' | 'nanzhong-night-ambience'
>;
export type CampMusicGroupDefinition = {
id: CampMusicGroupId;
trackKey: CampMusicTrackKey;
label: string;
volume: number;
};
export type CampAmbienceDefinition = {
id: CampAmbienceId;
trackKey: CampAmbienceTrackKey;
label: string;
volume: number;
};
export type CampSoundscapeMapping = {
musicGroupId: CampMusicGroupId;
ambienceId: CampAmbienceId;
};
export type CampSoundscape = {
skinId: CampSkinId;
music: CampMusicGroupDefinition;
ambience: CampAmbienceDefinition;
};
export const campMusicGroups: Record<CampMusicGroupId, CampMusicGroupDefinition> = {
rally: {
id: 'rally',
trackKey: 'camp-rally',
label: '결의와 출정',
volume: 0.22
},
wayfarer: {
id: 'wayfarer',
trackKey: 'camp-wayfarer',
label: '유랑의 밤',
volume: 0.18
},
'grand-strategy': {
id: 'grand-strategy',
trackKey: 'camp-grand-strategy',
label: '대업의 계책',
volume: 0.2
},
frontier: {
id: 'frontier',
trackKey: 'camp-frontier',
label: '변경과 북벌',
volume: 0.2
}
};
export const campAmbienceDefinitions: Record<CampAmbienceId, CampAmbienceDefinition> = {
campfire: {
id: 'campfire',
trackKey: 'campfire-ambience',
label: '장작불',
volume: 0.12
},
'river-night': {
id: 'river-night',
trackKey: 'river-night-ambience',
label: '밤의 강물',
volume: 0.1
},
'mountain-wind': {
id: 'mountain-wind',
trackKey: 'mountain-wind-ambience',
label: '산바람',
volume: 0.09
},
'nanzhong-night': {
id: 'nanzhong-night',
trackKey: 'nanzhong-night-ambience',
label: '남중의 밤',
volume: 0.11
}
};
export const campSoundscapeMapping: Record<CampSkinId, CampSoundscapeMapping> = {
'yellow-turban': { musicGroupId: 'rally', ambienceId: 'campfire' },
'anti-dong': { musicGroupId: 'rally', ambienceId: 'campfire' },
xuzhou: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wandering: { musicGroupId: 'wayfarer', ambienceId: 'campfire' },
wolong: { musicGroupId: 'wayfarer', ambienceId: 'mountain-wind' },
'red-cliffs': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'jing-yi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'hanzhong-shuhan': { musicGroupId: 'grand-strategy', ambienceId: 'mountain-wind' },
'jingzhou-crisis': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
'yiling-baidi': { musicGroupId: 'grand-strategy', ambienceId: 'river-night' },
nanzhong: { musicGroupId: 'frontier', ambienceId: 'nanzhong-night' },
northern: { musicGroupId: 'frontier', ambienceId: 'mountain-wind' }
};
export function getCampSoundscape(skinId: CampSkinId): CampSoundscape {
const mapping = campSoundscapeMapping[skinId];
return {
skinId,
music: campMusicGroups[mapping.musicGroupId],
ambience: campAmbienceDefinitions[mapping.ambienceId]
};
}

View File

@@ -16,6 +16,7 @@ import { compareUnitStrategyCoverage, getUnitStrategyCoverage, getUnitStrategyNa
import { getUnitClass, terrainRules, type TerrainType, type UnitClassKey } from '../data/battleRules';
import { defaultBattleScenario, getBattleScenario, type BattleScenarioDefinition, type BattleScenarioId } from '../data/battles';
import { getSortieFlow } from '../data/campaignFlow';
import { getCampSoundscape, type CampSoundscape } from '../data/campSoundscapes';
import {
campaignPortraitKeysByUnitId,
portraitAssetEntriesForKey,
@@ -11070,6 +11071,7 @@ export class CampScene extends Phaser.Scene {
private campSkinSeal?: Phaser.GameObjects.Arc;
private campSkinBadge?: Phaser.GameObjects.Rectangle;
private campSkinSummary?: Phaser.GameObjects.Text;
private campSoundscape?: CampSoundscape;
private campSkinRenderedTextureKey?: string;
private campSkinUsedFallback = false;
private campSkinTransitionRevision = 0;
@@ -11206,6 +11208,7 @@ export class CampScene extends Phaser.Scene {
nextBattleId: sortieFlow.nextBattleId,
currentBattleId: this.currentCampBattleId()
});
this.campSoundscape = getCampSoundscape(this.campSkinSelection.skin.id);
this.campSkinBackdrop = undefined;
this.campSkinWash = undefined;
this.campSkinVignette = undefined;
@@ -11223,7 +11226,12 @@ export class CampScene extends Phaser.Scene {
this.sortieItemAssignments = this.normalizedSortieItemAssignments(this.campaign.sortieItemAssignments);
this.selectedDialogueId = this.availableCampDialogues()[0]?.id ?? campDialogues[0].id;
this.selectedVisitId = this.availableCampVisits()[0]?.id ?? campVisits[0].id;
soundDirector.playMusic('militia-theme');
soundDirector.playSoundscape({
musicKey: this.campSoundscape.music.trackKey,
ambienceKey: this.campSoundscape.ambience.trackKey,
musicVolume: this.campSoundscape.music.volume,
ambienceVolume: this.campSoundscape.ambience.volume
});
this.input.keyboard?.on('keydown-ESC', () => this.handleEscapeKey());
this.input.keyboard?.on('keydown-ENTER', (event: KeyboardEvent) => this.handleSortieEnterKey(event));
this.input.keyboard?.on('keydown', (event: KeyboardEvent) => this.handleSaveSlotKey(event));
@@ -21673,6 +21681,20 @@ export class CampScene extends Phaser.Scene {
campaignProgress: this.campaignTimelineProgress(),
campBattleId: this.currentCampBattleId(),
campTitle: this.currentCampTitle(),
campSoundscape: this.campSoundscape
? {
skinId: this.campSoundscape.skinId,
musicGroupId: this.campSoundscape.music.id,
musicKey: this.campSoundscape.music.trackKey,
musicLabel: this.campSoundscape.music.label,
musicVolume: this.campSoundscape.music.volume,
ambienceId: this.campSoundscape.ambience.id,
ambienceKey: this.campSoundscape.ambience.trackKey,
ambienceLabel: this.campSoundscape.ambience.label,
ambienceVolume: this.campSoundscape.ambience.volume
}
: null,
audio: soundDirector.getDebugState(),
campSkin: this.campSkinSelection
? {
id: this.campSkinSelection.skin.id,

View File

@@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { effectTracks, musicTracks } from './game/audio/audioAssets';
import { ambienceTracks, effectTracks, musicTracks } from './game/audio/audioAssets';
import { soundDirector } from './game/audio/SoundDirector';
import { BootScene } from './game/scenes/BootScene';
import { startLazySceneFromGame } from './game/scenes/lazyScenes';
@@ -26,6 +26,7 @@ type DebugCampScene = Phaser.Scene & {
type HerosDebugApi = {
game: Phaser.Game;
activeScenes: () => string[];
audio: () => ReturnType<typeof soundDirector.getDebugState>;
battle: () => unknown;
camp: () => unknown;
goToBattle: (battleId?: string) => Promise<void>;
@@ -57,6 +58,7 @@ const config: Phaser.Types.Core.GameConfig = {
};
soundDirector.registerMusicTracks(musicTracks);
soundDirector.registerAmbienceTracks(ambienceTracks);
soundDirector.registerEffectTracks(effectTracks);
const game = new Phaser.Game(config);
@@ -76,6 +78,7 @@ function createDebugApi(game: Phaser.Game): HerosDebugApi {
return {
game,
activeScenes: () => game.scene.getScenes(true).map((activeScene) => activeScene.scene.key),
audio: () => soundDirector.getDebugState(),
battle: () => battleScene()?.getDebugState?.() ?? { active: false, reason: 'BattleScene is not active yet.' },
camp: () => campScene()?.getDebugState?.() ?? { active: false, reason: 'CampScene is not active yet.' },
goToBattle: (battleId) => startLazySceneFromGame(game, 'BattleScene', battleId ? { battleId } : undefined),