747 lines
26 KiB
JavaScript
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 || ''}`);
|
|
}
|
|
}
|