feat: add era-based camp soundscapes
This commit is contained in:
746
scripts/generate-camp-audio.mjs
Normal file
746
scripts/generate-camp-audio.mjs
Normal 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 || ''}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user