Files
heros_web/scripts/generate-camp-audio.mjs

747 lines
26 KiB
JavaScript

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