diff --git a/src/align/render.ts b/src/align/render.ts index af093ac..2feb12a 100644 --- a/src/align/render.ts +++ b/src/align/render.ts @@ -1,9 +1,11 @@ import * as at from "@coderline/alphatab"; import type { Audio } from "../core/types.ts"; import { renderMidiFile } from "../audio/render.ts"; +import { resample } from "../dsp/resample.ts"; import type { RefTrack } from "./smf.ts"; import type { Warp } from "./warp.ts"; +const RENDER_SR = 44100; // alphaSynth yields NaN at other rates (e.g. 22050), so always render here and resample const TPS = 1920; // division 960 at a fixed 120 bpm const PEAK = Math.pow(10, -1 / 20); const MELODIC_CHANNELS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15]; @@ -17,30 +19,36 @@ export function renderMidi( tracks: RefTrack[], warp: Warp, sf2: Uint8Array, opts: { sampleRate?: number; mono?: boolean; durationSec?: number } = {}, ): Audio { - const sampleRate = opts.sampleRate ?? 44100; + const sampleRate = opts.sampleRate ?? RENDER_SR; const midi = new at.midi.MidiFile(); midi.division = 960; midi.addEvent(new at.midi.TempoChangeEvent(0, 500000)); const seen = new Set(); let next = 0; for (const t of tracks) { + // Beyond 15 melodic tracks channels wrap and are shared; the first track's program wins on a shared channel. const ch = t.isDrums ? 9 : MELODIC_CHANNELS[next++ % MELODIC_CHANNELS.length]!; if (!seen.has(ch)) { seen.add(ch); midi.addEvent(new at.midi.ProgramChangeEvent(0, 0, ch, t.isDrums ? 0 : t.program)); } for (const n of t.notes) { + const offSec = warp.toAudio(n.offset); + if (offSec <= 0) continue; const on = Math.round(Math.max(0, warp.toAudio(n.onset)) * TPS); - const off = Math.max(on + 1, Math.round(warp.toAudio(n.offset) * TPS)); - if (warp.toAudio(n.offset) <= 0) continue; + const off = Math.max(on + 1, Math.round(offSec * TPS)); midi.addEvent(new at.midi.NoteOnEvent(0, on, ch, n.pitch, n.velocity)); midi.addEvent(new at.midi.NoteOffEvent(0, off, ch, n.pitch, 0)); } } - const frames = opts.durationSec === undefined ? undefined : Math.round(opts.durationSec * sampleRate); - const out = renderMidiFile(midi, sf2, sampleRate, { frames }); + const out = renderMidiFile(midi, sf2, RENDER_SR, { frames: opts.durationSec === undefined ? undefined : Math.round(opts.durationSec * RENDER_SR) }); let chans = out.channels; + if (sampleRate !== RENDER_SR) { + chans = chans.map((c) => resample(c, RENDER_SR, sampleRate)); + if (opts.durationSec !== undefined) { const n = Math.round(opts.durationSec * sampleRate); chans = chans.map((c) => { const o = new Float32Array(n); o.set(c.subarray(0, n)); return o; }); } + } if (opts.mono) { const [L, R] = chans as [Float32Array, Float32Array]; chans = [L.map((v, i) => (v + R[i]!) / 2)]; } + for (const c of chans) for (let i = 0; i < c.length; i++) if (!Number.isFinite(c[i]!)) throw new Error("renderMidi: non-finite samples from synth"); let peak = 0; for (const c of chans) for (let i = 0; i < c.length; i++) { const a = Math.abs(c[i]!); if (a > peak) peak = a; } if (peak > 0) { const g = PEAK / peak; for (const c of chans) for (let i = 0; i < c.length; i++) c[i]! *= g; } diff --git a/tests/align/render.test.ts b/tests/align/render.test.ts index 51be879..bc89035 100644 --- a/tests/align/render.test.ts +++ b/tests/align/render.test.ts @@ -7,7 +7,7 @@ import type { RefTrack, RefNote } from "../../src/align/smf.ts"; const SF2 = new Uint8Array(await Bun.file("node_modules/@coderline/alphatab/dist/soundfont/sonivox.sf2").arrayBuffer()); const note = (onset: number, offset: number, pitch: number): RefNote => ({ onset, offset, pitch, velocity: 100, channel: 0 }); const track = (notes: RefNote[], isDrums = false): RefTrack => ({ index: 0, name: "t", program: 25, isDrums, notes }); -const SR = 44100; // alphaSynth yields NaN at 22050 Hz +const SR = 44100; function firstAbove(x: Float32Array, thr: number): number { for (let i = 0; i < x.length; i++) if (Math.abs(x[i]!) > thr) return i; @@ -49,3 +49,29 @@ test("peaks of a known sine", () => { expect([...q.max]).toEqual([0.5, 0]); expect([...q.min]).toEqual([0.25, -0.75]); }); + +test("sampleRate 22050 is finite, at 22050 Hz, onset preserved", () => { + const a = renderMidi([track([note(1, 2, 60)])], makeWarp([{ midiSec: 0, audioSec: 0.5, kind: "manual" }]), SF2, { sampleRate: 22050, mono: true, durationSec: 4 }); + expect(a.sampleRate).toBe(22050); + expect(a.channels[0]!.length).toBe(4 * 22050); + expect(a.channels[0]!.every(Number.isFinite)).toBe(true); + const t = firstAbove(a.channels[0]!, 0.02) / 22050; + expect(t).toBeGreaterThan(1.48); + expect(t).toBeLessThan(1.52); +}); + +test("notes before audio zero: clamped when overlapping, dropped when entirely before", () => { + const w = makeWarp([{ midiSec: 1, audioSec: 0, kind: "manual" }]); // audio = midi - 1 + const dropped = renderMidi([track([note(0, 0.5, 60)]), track([note(2, 2.5, 64)])], w, SF2, { durationSec: 3 }); + const t = firstAbove(dropped.channels[0]!, 0.02) / 44100; + expect(t).toBeGreaterThan(0.98); + const clamped = renderMidi([track([note(0.5, 2, 60)])], w, SF2, { durationSec: 3 }); + expect(firstAbove(clamped.channels[0]!, 0.02) / 44100).toBeLessThan(0.05); +}); + +test("stereo with durationSec has two channels of exact length", () => { + const a = renderMidi([track([note(0, 0.5, 60)])], makeWarp([]), SF2, { durationSec: 2 }); + expect(a.channels.length).toBe(2); + expect(a.channels[0]!.length).toBe(88200); + expect(a.channels[1]!.length).toBe(88200); +});