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 || ''}`); } }