feat(align): warped MIDI rendering and peaks

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Sonnet 5.5 committed 2026-10-30 06:34:15 +01:00
1 parent ca5942956a
commit 63b32a488d
4 files changed
+149 -22

No files matched your search

+36 -22
View File
@@ -5,7 +5,7 @@ import { songToScore } from "../score/gp.ts";
const TAIL_SECONDS = 2;
/** Offline-render sink: AlphaSynth requires an ISynthOutput but exportAudio never uses it. */
function dummyOutput(sampleRate: number): at.synth.ISynthOutput {
export function dummyOutput(sampleRate: number): at.synth.ISynthOutput {
const ev = { on() {}, off() {} };
return {
sampleRate, open() {}, play() {}, destroy() {}, pause() {}, addSamples() {}, resetSamples() {}, activate() {},
@@ -18,6 +18,39 @@ function songSeconds(song: Song): number {
return (song.bars * song.timeSig[0] * (4 / song.timeSig[1]) * 60) / song.bpm;
}
/** Render a MidiFile offline. With `frames`, output is exactly that long (zero-padded or truncated); else full length. */
export function renderMidiFile(
midi: at.midi.MidiFile, sf2: Uint8Array, sampleRate: number,
opts: { trackVolume?: Map<number, number>; frames?: number } = {},
): Audio {
const synth = new at.synth.AlphaSynth(dummyOutput(sampleRate), 500);
const eo = new at.synth.AudioExportOptions();
eo.soundFonts = [sf2];
eo.sampleRate = sampleRate;
eo.useSyncPoints = false;
if (opts.trackVolume) eo.trackVolume = opts.trackVolume;
const exporter = synth.exportAudio(eo, midi, [], new Map());
const cap = opts.frames ?? Infinity;
const Ls: Float32Array[] = [], Rs: Float32Array[] = [];
let pos = 0;
while (pos < cap) {
const chunk = exporter.render(500);
if (!chunk) break;
const s = chunk.samples;
const frames = Math.min(s.length >> 1, cap - pos);
const l = new Float32Array(frames), r = new Float32Array(frames);
for (let i = 0; i < frames; i++) { l[i] = s[2 * i]!; r[i] = s[2 * i + 1]!; }
Ls.push(l); Rs.push(r);
pos += frames;
}
synth.destroy();
const n = opts.frames ?? pos;
const L = new Float32Array(n), R = new Float32Array(n);
let o = 0;
for (let k = 0; k < Ls.length; k++) { L.set(Ls[k]!, o); R.set(Rs[k]!, o); o += Ls[k]!.length; }
return { sampleRate, channels: [L, R] };
}
/**
* Render each track in isolation with alphaSynth. Isolation: `AudioExportOptions.trackVolume` is keyed by MIDI
* channel (despite the doc's "track index"), so every channel not owned by the target track is muted (volume 0).
@@ -36,27 +69,8 @@ export function renderStems(song: Song, sf2: Uint8Array, sampleRate = 44100): Ma
const trackVolume = new Map<number, number>();
for (let ch = 0; ch < 16; ch++) if (!own.has(ch)) trackVolume.set(ch, 0);
const synth = new at.synth.AlphaSynth(dummyOutput(sampleRate), 500);
const opts = new at.synth.AudioExportOptions();
opts.soundFonts = [sf2];
opts.sampleRate = sampleRate;
opts.useSyncPoints = false;
opts.trackVolume = trackVolume;
const exporter = synth.exportAudio(opts, midi, [], new Map());
const L = new Float32Array(total), R = new Float32Array(total);
let pos = 0;
for (;;) {
const chunk = exporter.render(500);
if (!chunk) break;
const s = chunk.samples;
const frames = Math.min(s.length >> 1, total - pos);
for (let i = 0; i < frames; i++) { L[pos + i] = s[2 * i]!; R[pos + i] = s[2 * i + 1]!; }
pos += frames;
if (pos >= total) break;
}
synth.destroy();
stems.set(track.name, { sampleRate, channels: [L, R] });
const audio = renderMidiFile(midi, sf2, sampleRate, { trackVolume, frames: total });
stems.set(track.name, audio);
}
return stems;
}