diff --git a/docs/roadmap.md b/docs/roadmap.md
index c81b569..f07a41c 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -7,10 +7,13 @@ they land; add new findings under the right phase so nothing gets lost.
`[→]` moved to another phase
_Last updated: 2026-07-28 — Phase 1 & 2 complete (Phase 2 bar the manual custom-instruments
-desktop pass). Phase 3 well underway: the "parallelize generation" item was retired as a
-phantom (generation is ~0.5s; the real wait is the WAV export render, now parallelised),
-deprecation warnings cleared, swallowed exceptions logged, and the two route god-files
-split into `services/`. Remaining Phase 3: split `useMidiPlayer.ts`, grow frontend tests._
+desktop pass). **Phase 3 substantially complete:** "parallelize generation" retired as a
+phantom (generation is ~0.5s; the real wait — the WAV export render — is now parallelised +
+auto-pauses playback), deprecation warnings cleared, swallowed exceptions logged, the two
+route god-files split into `services/`, `useMidiPlayer.ts` split 1228→610 across five focused
+modules, and frontend tests grown 12→123. Open tails: a desktop wall-time number for the WAV
+render, a playback/export smoke test for the composable split, and optional follow-on moves
+(`_do_build_song`, the `toggle` shell)._
---
@@ -184,7 +187,27 @@ split into `services/`. Remaining Phase 3: split `useMidiPlayer.ts`, grow fronte
_Remaining follow-up:_ the song-build coordinator `_do_build_song` and a few
regenerate/stem/version helpers still sit in `routes_song.py` between the endpoints; they
could move into `song_builder.py` too, but the endpoints that call them are already thin.
-- [ ] **Split `useMidiPlayer.ts`** (1018 lines) into focused composables.
+- [~] **Split `useMidiPlayer.ts`** into focused composables — **1228 → 622 lines.** Four
+ concerns pulled out: the offline WAV export → **`useOfflineRender.ts`** (`offlineRenderRaw`,
+ `sumPartBuffers`, `partHasNotes`, `limitMix`, `renderOfflineFast`, `isRendering`); the
+ shared style-classification Sets + `PLAYER_PARTS`/`PlayerPart`/`CHANNEL_PART` →
+ **`playerConstants.ts`** (playback + render read one source, no import cycle); the 7 synth
+ voice factories → **`soundfonts/synthVoices.ts`** (next to `synthDrums.ts`; they take the
+ player's `disposables` array so cleanup still disposes their nodes); and the pure
+ voice-routing decision (which instrument a part plays) + the CC10 pan map →
+ **`voiceRouting.ts`** (`resolveMelodicVoiceKind` / `panFromCC10`), so the fragile branch
+ logic is unit-testable — `getMelodicInstrument` now switches on the returned kind. The
+ pause-on-export stays a thin wrapper in `useMidiPlayer` (it owns the playback refs);
+ `useMidiPlayer` re-exports `PLAYER_PARTS`/`PlayerPart` so external importers are unaffected.
+ Pure moves — verified by `vue-tsc` + eslint + production build + 123 vitest. The scheduler's
+ per-note trigger callbacks (mute gating, the kick sidechain pump, trigger args) also came out
+ → **`scheduler.ts`** (`drumTriggerCallback` / `voiceTriggerCallback`), injected into
+ `toggle`'s `Tone.Part`s. **1228 → 610 lines.**
+ _Still to sign off:_ a live playback + WAV-export smoke test in the app (runtime audio is the
+ one thing the checks can't cover). _Remaining:_ `toggle`'s shell (async sampler load → graph
+ wiring → transport control) is now mostly I/O orchestration — its extractable logic is out
+ and tested; moving the shell itself is cosmetic and high-risk without runtime audio, so left
+ as-is.
- [x] **Log swallowed exceptions** — audited all 27 broad `except` blocks. 12 already
logged or re-raised; 6 genuine silent faults now log — `record_export_keep`
(`routes_library.py`, warning), malformed groove/genre priors (`priors.py`, warning),
@@ -194,9 +217,16 @@ split into `services/`. Remaining Phase 3: split `useMidiPlayer.ts`, grow fronte
`except` is by-design control flow (exotic modes have no MIDI key sig, documented), and
the generator inner-loop `except`s (bass/melody/quality/answer/corpus) are intentional
musical graceful-degradation, not swallowed faults — logging each would be pure noise.
-- [ ] **Grow frontend test coverage** — 12 tests vs 132 backend; the audio scheduler and
- WAV-render path (most fragile code) are untested. _(Up to 96 frontend tests now — the
- WAV-render mix path gained `offlineMix.test.ts` — but the scheduler is still untested.)_
+- [x] **Grow frontend test coverage** — 12 → **123 tests**. The two fragile areas the
+ survey flagged now have real coverage. WAV-render path: `offlineMix.test.ts`
+ (`sumPartBuffers` mixing + `partHasNotes` channel→part mapping), `wavEncoder.test.ts`
+ (`encodeWav` RIFF header, stereo interleaving, int16 clamping, mono byte-rate). Live
+ playback: `voiceRouting.test.ts` (the melodic voice-selection precedence table + CC10 pan)
+ and `scheduler.test.ts` (the `Tone.Part` trigger callbacks — mute gating, the kick sidechain
+ pump, and trigger arguments, all driven without a Tone context via injected collaborators).
+ The scheduler logic became testable by extracting the callbacks (see the split above); the
+ remaining untested surface is `toggle`'s async sampler-load / graph-wiring I/O, which is
+ integration-shaped rather than unit-testable.
- [x] **Clear deprecation warnings** — **Starlette `TestClient`**: moved the test dep from
`httpx` to `httpx2` (`requirements.txt`); TestClient prefers httpx2 and the
`StarletteDeprecationWarning` is gone, 135 backend tests still green. **Electron
diff --git a/frontend/src/composables/offlineMix.test.ts b/frontend/src/composables/offlineMix.test.ts
index 6ae8a7d..6660672 100644
--- a/frontend/src/composables/offlineMix.test.ts
+++ b/frontend/src/composables/offlineMix.test.ts
@@ -9,7 +9,8 @@
* for details.
*/
import { describe, it, expect } from 'vitest'
-import { sumPartBuffers } from './useMidiPlayer'
+import type { Midi } from '@tonejs/midi'
+import { sumPartBuffers, partHasNotes } from './useOfflineRender'
// Minimal stand-in for the parts of AudioBuffer sumPartBuffers reads. (jsdom has no
// Web Audio, and the summing logic is pure, so a fake is enough to test the maths.)
@@ -65,3 +66,41 @@ describe('sumPartBuffers', () => {
])
})
})
+
+// Minimal Midi stand-in: partHasNotes only reads tracks[].{notes.length, channel,
+// instrument.percussion}. Decides which parts get their own parallel render pass.
+function fakeMidi(tracks: Array<{ channel?: number; percussion?: boolean; notes?: number }>): Midi {
+ return {
+ tracks: tracks.map(t => ({
+ notes: Array.from({ length: t.notes ?? 1 }),
+ channel: t.channel,
+ instrument: { percussion: t.percussion ?? false },
+ })),
+ } as unknown as Midi
+}
+
+describe('partHasNotes', () => {
+ it('maps MIDI channels to parts (0=chords,1=bass,2=melody,3=arp,4=pads,5=counter)', () => {
+ const midi = fakeMidi([{ channel: 0 }, { channel: 2 }, { channel: 5 }])
+ expect(partHasNotes(midi, 'chords')).toBe(true)
+ expect(partHasNotes(midi, 'melody')).toBe(true)
+ expect(partHasNotes(midi, 'counter_melody')).toBe(true)
+ expect(partHasNotes(midi, 'bass')).toBe(false)
+ expect(partHasNotes(midi, 'arpeggio')).toBe(false)
+ })
+
+ it('treats percussion (or channel 9) as drums', () => {
+ expect(partHasNotes(fakeMidi([{ channel: 9 }]), 'drums')).toBe(true)
+ expect(partHasNotes(fakeMidi([{ channel: 3, percussion: true }]), 'drums')).toBe(true)
+ // percussion wins over the channel map — a perc track never counts as arpeggio
+ expect(partHasNotes(fakeMidi([{ channel: 3, percussion: true }]), 'arpeggio')).toBe(false)
+ })
+
+ it('ignores tracks with no notes', () => {
+ expect(partHasNotes(fakeMidi([{ channel: 2, notes: 0 }]), 'melody')).toBe(false)
+ })
+
+ it('falls back to chords for unmapped non-percussion channels', () => {
+ expect(partHasNotes(fakeMidi([{ channel: 7 }]), 'chords')).toBe(true)
+ })
+})
diff --git a/frontend/src/composables/playerConstants.ts b/frontend/src/composables/playerConstants.ts
new file mode 100644
index 0000000..a12f3e4
--- /dev/null
+++ b/frontend/src/composables/playerConstants.ts
@@ -0,0 +1,37 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+
+// Shared player constants — style-behaviour classification and the MIDI
+// channel <-> part mapping. Split out of useMidiPlayer.ts so both live playback
+// and the offline WAV render read one source (no duplication, no import cycle).
+
+// Styles where ALL parts use synthesis — drum/bass samplers are not loaded
+export const SYNTH_STYLES = new Set([
+ 'house', 'techno', 'drum_and_bass', 'synthwave', 'future_bass', 'jersey_club',
+ 'grime', 'hyperpop',
+])
+// Styles that load sampled drums/bass but use a synth lead for melodic parts
+export const MELODIC_SYNTH_STYLES = new Set(['drill', 'dark_trap', 'reggaeton', 'dancehall'])
+// Styles that use a slow-attack pad synth for melodic (sampled drums/bass still load)
+export const PAD_STYLES = new Set([
+ 'ambient', 'dark_ambient', 'epic_orchestral', 'cinematic',
+ 'trap_soul', 'cloud_rap',
+])
+// Lo-fi styles — warm, bit-crushed synth for melodic
+export const LOFI_STYLES = new Set(['lofi'])
+
+export const PLAYER_PARTS = ['drums', 'bass', 'chords', 'melody', 'arpeggio', 'pads', 'counter_melody'] as const
+export type PlayerPart = typeof PLAYER_PARTS[number]
+
+// MIDI channel → part name (see backend _PART_CHANNELS; 9 = GM percussion)
+export const CHANNEL_PART: Record = {
+ 0: 'chords', 1: 'bass', 2: 'melody', 3: 'arpeggio', 4: 'pads', 5: 'counter_melody', 9: 'drums',
+}
diff --git a/frontend/src/composables/scheduler.test.ts b/frontend/src/composables/scheduler.test.ts
new file mode 100644
index 0000000..3350be0
--- /dev/null
+++ b/frontend/src/composables/scheduler.test.ts
@@ -0,0 +1,80 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+import { describe, it, expect, vi } from 'vitest'
+import { drumTriggerCallback, voiceTriggerCallback } from './scheduler'
+
+describe('drumTriggerCallback', () => {
+ it('triggers the kit with (midi, velocity, time) when unmuted', () => {
+ const kit = { trigger: vi.fn() }
+ const duck = vi.fn()
+ const cb = drumTriggerCallback(kit, () => false, false, duck)
+ cb(1.5, { midi: 38, velocity: 0.8 })
+ expect(kit.trigger).toHaveBeenCalledWith(38, 0.8, 1.5)
+ expect(duck).not.toHaveBeenCalled()
+ })
+
+ it('does nothing when the drums part is muted', () => {
+ const kit = { trigger: vi.fn() }
+ const duck = vi.fn()
+ const cb = drumTriggerCallback(kit, () => true, true, duck)
+ cb(0, { midi: 36, velocity: 1 })
+ expect(kit.trigger).not.toHaveBeenCalled()
+ expect(duck).not.toHaveBeenCalled()
+ })
+
+ it('ducks on the kick (midi 36) only when pump is on', () => {
+ const kit = { trigger: vi.fn() }
+ const duck = vi.fn()
+ drumTriggerCallback(kit, () => false, true, duck)(2, { midi: 36, velocity: 1 })
+ expect(duck).toHaveBeenCalledWith(2)
+ })
+
+ it('does not duck on non-kick hits, even with pump on', () => {
+ const kit = { trigger: vi.fn() }
+ const duck = vi.fn()
+ drumTriggerCallback(kit, () => false, true, duck)(2, { midi: 42, velocity: 1 }) // hat
+ expect(kit.trigger).toHaveBeenCalled()
+ expect(duck).not.toHaveBeenCalled()
+ })
+
+ it('does not duck when pump is off, even on a kick', () => {
+ const kit = { trigger: vi.fn() }
+ const duck = vi.fn()
+ drumTriggerCallback(kit, () => false, false, duck)(2, { midi: 36, velocity: 1 })
+ expect(duck).not.toHaveBeenCalled()
+ })
+
+ it('re-reads mute state per call (mute mid-playback stops later hits)', () => {
+ const kit = { trigger: vi.fn() }
+ let muted = false
+ const cb = drumTriggerCallback(kit, () => muted, false, vi.fn())
+ cb(0, { midi: 38, velocity: 1 })
+ muted = true
+ cb(1, { midi: 38, velocity: 1 })
+ expect(kit.trigger).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('voiceTriggerCallback', () => {
+ it('plays the note (midi->name, duration, time, velocity) when unmuted', () => {
+ const voice = { triggerAttackRelease: vi.fn() }
+ const midiToNote = (m: number) => `N${m}`
+ const cb = voiceTriggerCallback(voice, () => false, midiToNote)
+ cb(3.25, { midi: 60, duration: 0.5, velocity: 0.7 })
+ expect(voice.triggerAttackRelease).toHaveBeenCalledWith('N60', 0.5, 3.25, 0.7)
+ })
+
+ it('does nothing when the part is muted', () => {
+ const voice = { triggerAttackRelease: vi.fn() }
+ voiceTriggerCallback(voice, () => true, m => `N${m}`)(0, { midi: 60, duration: 1, velocity: 1 })
+ expect(voice.triggerAttackRelease).not.toHaveBeenCalled()
+ })
+})
diff --git a/frontend/src/composables/scheduler.ts b/frontend/src/composables/scheduler.ts
new file mode 100644
index 0000000..0be7c36
--- /dev/null
+++ b/frontend/src/composables/scheduler.ts
@@ -0,0 +1,53 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+
+// The per-note callbacks the live-playback Tone.Part scheduler fires. Pulled out of
+// useMidiPlayer's toggle() so the fragile bits — mute gating, the drum sidechain
+// pump, and the trigger arguments — are unit-testable without a Tone context: every
+// collaborator (the kit/voice, the mute check, midi->note, the duck) is injected.
+
+export interface DrumKitLike {
+ trigger(midi: number, velocity: number, time: number): void
+}
+export interface VoiceLike {
+ triggerAttackRelease(note: string, duration: number, time: number, velocity: number): void
+}
+
+export interface DrumNote { midi: number; velocity: number }
+export interface VoiceNote { midi: number; duration: number; velocity: number }
+
+// Drum part: skip when the drums part is muted; trigger the kit; and on electronic
+// styles (`pump`) briefly duck the other buses on each kick (GM kick = midi 36).
+export function drumTriggerCallback(
+ kit: DrumKitLike,
+ isMuted: () => boolean,
+ pump: boolean,
+ duckOnKick: (time: number) => void,
+): (time: number, note: DrumNote) => void {
+ return (time, note) => {
+ if (isMuted()) return
+ kit.trigger(note.midi, note.velocity, time)
+ if (pump && note.midi === 36) duckOnKick(time)
+ }
+}
+
+// Pitched part (bass / chords / melody / arp / pads / counter): skip when muted,
+// else play the note (midi -> note name) for its duration at its time/velocity.
+export function voiceTriggerCallback(
+ voice: VoiceLike,
+ isMuted: () => boolean,
+ midiToNote: (midi: number) => string,
+): (time: number, note: VoiceNote) => void {
+ return (time, note) => {
+ if (isMuted()) return
+ voice.triggerAttackRelease(midiToNote(note.midi), note.duration, time, note.velocity)
+ }
+}
diff --git a/frontend/src/composables/useMidiPlayer.ts b/frontend/src/composables/useMidiPlayer.ts
index 46d67ec..d1eb3f0 100644
--- a/frontend/src/composables/useMidiPlayer.ts
+++ b/frontend/src/composables/useMidiPlayer.ts
@@ -12,7 +12,7 @@ import { ref } from 'vue'
import * as Tone from 'tone'
import { Midi } from '@tonejs/midi'
import { downloadUrl } from '../services/api'
-import { getPianoSampler, getMasterLimiterNode, getBassBus, getMelodicBus, getDrumBus, duckOnKick, resetBusLevels, makeMasterLimiter, softClipCurve, applyMelodicFxPreset, setMasterTrimDb } from '../soundfonts/loader'
+import { getPianoSampler, getMasterLimiterNode, getBassBus, getMelodicBus, getDrumBus, duckOnKick, resetBusLevels, applyMelodicFxPreset, setMasterTrimDb } from '../soundfonts/loader'
import { MELODIC_FX_PRESETS, MASTER_TRIM_DB, fxFamilyFor } from '../soundfonts/fxPresets'
import { drumCharacterForStyle } from '../soundfonts/drums'
import { makeSynthKit } from '../soundfonts/synthDrums'
@@ -23,7 +23,14 @@ import { LayeredSampler } from '../soundfonts/layeredSampler'
import { resolvePartInstrument } from '../soundfonts/customInstruments'
import { useCustomInstruments } from './useCustomInstruments'
import { voiceFor, setActiveStyle } from './useStyleCatalog'
-import { encodeWav } from '../utils/wavEncoder'
+import { SYNTH_STYLES, MELODIC_SYNTH_STYLES, PAD_STYLES, LOFI_STYLES, PLAYER_PARTS, type PlayerPart, CHANNEL_PART } from './playerConstants'
+import { isRendering, offlineRenderRaw } from './useOfflineRender'
+import { makeMelodyLead, makeSynthChords, makeArpPluck, makePad, makeStrings, makeSynthBass, makeLofiSynth } from '../soundfonts/synthVoices'
+import { resolveMelodicVoiceKind, panFromCC10 } from './voiceRouting'
+import { drumTriggerCallback, voiceTriggerCallback } from './scheduler'
+
+// Re-exported so existing importers keep getting these from useMidiPlayer.
+export { PLAYER_PARTS, type PlayerPart } from './playerConstants'
export interface ParsedNote {
midi: number
@@ -38,20 +45,6 @@ export interface MidiData {
duration: number
}
-// Styles where ALL parts use synthesis — drum/bass samplers are not loaded
-const SYNTH_STYLES = new Set([
- 'house', 'techno', 'drum_and_bass', 'synthwave', 'future_bass', 'jersey_club',
- 'grime', 'hyperpop',
-])
-// Styles that load sampled drums/bass but use a synth lead for melodic parts
-const MELODIC_SYNTH_STYLES = new Set(['drill', 'dark_trap', 'reggaeton', 'dancehall'])
-// Styles that use a slow-attack pad synth for melodic (sampled drums/bass still load)
-const PAD_STYLES = new Set([
- 'ambient', 'dark_ambient', 'epic_orchestral', 'cinematic',
- 'trap_soul', 'cloud_rap',
-])
-// Lo-fi styles — warm, bit-crushed synth for melodic
-const LOFI_STYLES = new Set(['lofi'])
// Global so only one track plays at a time
const currentlyPlaying = ref(null)
@@ -67,18 +60,11 @@ let cuedPlay: (() => void | Promise) | null = null
const isLoading = ref(false)
const looping = ref(false)
const isRecording = ref(false)
-const isRendering = ref(false)
// Per-part mute state, keyed by part name (matches backend _PART_CHANNELS).
-export const PLAYER_PARTS = ['drums', 'bass', 'chords', 'melody', 'arpeggio', 'pads', 'counter_melody'] as const
-export type PlayerPart = typeof PLAYER_PARTS[number]
const _allUnmuted = (): Record =>
({ drums: false, bass: false, chords: false, melody: false, arpeggio: false, pads: false, counter_melody: false })
const channelMuted = ref>(_allUnmuted())
-// MIDI channel → part name (see backend _PART_CHANNELS; 9 = GM percussion)
-const CHANNEL_PART: Record = {
- 0: 'chords', 1: 'bass', 2: 'melody', 3: 'arpeggio', 4: 'pads', 5: 'counter_melody', 9: 'drums',
-}
// Abort token — incremented on every new play; stale toggles bail out after each await
let _playToken = 0
@@ -161,194 +147,6 @@ function cleanup() {
nowPlayingLabel.value = null
}
-// Melody lead — a dedicated, in-tune, articulate voice for the melodic LINE.
-// Replaces two bad melody voices: the harsh heavily-chorused sawtooth (which
-// wavered out of tune) and the pad (0.8 s attack, so fast melody notes never
-// spoke). Fast attack so every note reads, a tamed low-pass so it's not harsh,
-// and only a whisper of chorus so it stays in tune. `soft` warms it and adds
-// space for ambient/cinematic styles.
-function makeMelodyLead(soft: boolean, output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
- // Chorus + delay now live once on the shared melodic bus (see getMelodicBus).
- const filter = new Tone.Filter({ frequency: soft ? 3000 : 3800, type: 'lowpass', rolloff: -12, Q: 0.8 }).connect(output)
- const synth = new Tone.PolySynth(Tone.Synth, {
- oscillator: soft ? { type: 'triangle' } : { type: 'sawtooth' },
- envelope: soft
- ? { attack: 0.03, decay: 0.2, sustain: 0.75, release: 0.8 }
- : { attack: 0.008, decay: 0.15, sustain: 0.65, release: 0.3 },
- volume: soft ? -9 : -10,
- }).connect(filter)
- disposables.push(filter, synth)
- return synth
-}
-
-// Synth comp: detuned/warm saw stack, slower attack, rolled-off highs.
-// Deliberately darker than makeSynthLead so CHORDS don't collide with the melody
-// timbre on electronic styles (previously both used the same sawtooth lead).
-function makeSynthChords(output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
- const lp = new Tone.Filter({ frequency: 2600, type: 'lowpass', rolloff: -12 }).connect(output)
- const synth = new Tone.PolySynth(Tone.Synth, {
- oscillator: { type: 'fatsawtooth', count: 3, spread: 22 },
- envelope: { attack: 0.06, decay: 0.25, sustain: 0.65, release: 0.6 },
- volume: -15,
- }).connect(lp)
- disposables.push(lp, synth)
- return synth
-}
-
-// Arp pluck: short, bright, decaying voice with a synced delay tail. Gives the
-// arpeggio part its own identity instead of doubling the chord/lead timbre.
-function makeArpPluck(output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
- const lp = new Tone.Filter({ frequency: 4200, type: 'lowpass', rolloff: -12 }).connect(output)
- const synth = new Tone.PolySynth(Tone.Synth, {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.004, decay: 0.18, sustain: 0.0, release: 0.25 },
- volume: -12,
- }).connect(lp)
- disposables.push(lp, synth)
- return synth
-}
-
-// Pad: slow-attack triangle + long feedback delay (ambient, cinematic, etc.)
-function makePad(output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
- const synth = new Tone.PolySynth(Tone.Synth, {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.8, decay: 0.3, sustain: 0.7, release: 2.0 },
- volume: -10,
- }).connect(output)
- disposables.push(synth)
- return synth
-}
-
-// Strings ensemble: soft detuned-saw stack for the counter-melody part —
-// articulate enough to read as a line, slow and dark enough to sit behind
-// the lead instead of competing with it.
-function makeStrings(output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
- const lp = new Tone.Filter({ frequency: 2400, type: 'lowpass', rolloff: -12 }).connect(output)
- const synth = new Tone.PolySynth(Tone.Synth, {
- oscillator: { type: 'fatsawtooth', count: 3, spread: 14 },
- envelope: { attack: 0.12, decay: 0.3, sustain: 0.8, release: 1.2 },
- volume: -14,
- }).connect(lp)
- disposables.push(lp, synth)
- return synth
-}
-
-// Synth bass: sawtooth MonoSynth with portamento — house/techno/dnb etc.
-function makeSynthBass(): Tone.MonoSynth {
- const comp = getBassBus()
- const bass = new Tone.MonoSynth({
- oscillator: { type: 'sawtooth' },
- filter: { Q: 2.5, type: 'lowpass', rolloff: -24 },
- envelope: { attack: 0.01, decay: 0.08, sustain: 0.9, release: 0.3 },
- filterEnvelope: { attack: 0.04, decay: 0.2, sustain: 0.5, release: 0.3, baseFrequency: 180, octaves: 2.6 },
- portamento: 0.035,
- volume: -3,
- }).connect(comp)
- disposables.push(bass)
- return bass
-}
-
-// Lo-fi synth: warm triangle → bitcrusher → lowpass → vibrato → compressor
-function makeLofiSynth(output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
- const vibrato = new Tone.Vibrato({ frequency: 2.5, depth: 0.04, wet: 1 }).connect(output)
- const lp = new Tone.Filter({ frequency: 5500, type: 'lowpass' }).connect(vibrato)
- const crusher = new Tone.BitCrusher({ bits: 10 }).connect(lp)
- const synth = new Tone.PolySynth(Tone.Synth, {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.04, decay: 0.2, sustain: 0.6, release: 1.2 },
- volume: -4,
- }).connect(crusher)
- disposables.push(vibrato, lp, crusher, synth)
- return synth
-}
-
-// Tone.Offline() always renders "asynchronously": internally it steps through
-// the render in 128-sample blocks and, once per second of rendered audio,
-// awaits a 1ms setTimeout so the main thread isn't blocked for the whole
-// render. An earlier version of this function skipped those yields entirely
-// (render(false)) for maximum speed — but with no note actually routed
-// through Tone's Transport any more (see the trigger-ordering comment in
-// offlineRender below), each of those per-tick steps got dramatically
-// cheaper, so the yields cost much less relative to the work they used to
-// interrupt. Keeping them (render(true)) means the tab stays responsive
-// during a render — you can keep working, start another export, or play a
-// preview — for a render time difference that's now marginal.
-//
-// This also used to swap Tone's ambient context globally for the whole
-// render via Tone.setContext(), which corrupted any other Tone.js activity
-// (playback, another render) that started before the swap was undone —
-// exactly the kind of concurrent use the yields above are meant to allow.
-// Building the OfflineContext and handing it directly to the caller instead
-// avoids that: every node the caller creates must be given { context }
-// explicitly so it lives in the offline graph rather than the ambient one.
-async function renderOfflineFast(
- callback: (context: Tone.OfflineContext) => Promise | void,
- duration: number,
- channels = 2,
-): Promise {
- const context = new Tone.OfflineContext(channels, duration, Tone.getContext().sampleRate)
- await callback(context)
- return await context.render(true)
-}
-
-// Does the parsed MIDI carry any notes for a given part? Mirrors the render's own
-// channel→voice routing (percussion / ch9 → drums; everything else via CHANNEL_PART,
-// with unknown non-perc channels falling through to chords like the scheduler does).
-// Used to decide which parts to spin up as parallel per-part renders.
-function partHasNotes(midi: Midi, part: PlayerPart): boolean {
- return midi.tracks.some(t => {
- if (t.notes.length === 0) return false
- const perc = t.instrument.percussion || (t.channel ?? 0) === 9
- const p = perc ? 'drums' : CHANNEL_PART[t.channel ?? 0] ?? 'chords'
- return p === part
- })
-}
-
-// Sum several rendered part buffers into one stereo pair. Pure (no Web Audio), so it's
-// unit-testable. The per-part offline graphs never share nodes, so summing their outputs
-// reproduces exactly the signal the single-pass graph summed at its master bus — the
-// pre-limiter mix. Exported for the mix-identity test.
-export function sumPartBuffers(
- buffers: Array>,
-): { length: number; sampleRate: number; channels: [Float32Array, Float32Array] } {
- const length = buffers.reduce((m, b) => Math.max(m, b.length), 0)
- const sampleRate = buffers[0]?.sampleRate ?? 44100
- const L = new Float32Array(length)
- const R = new Float32Array(length)
- for (const b of buffers) {
- const bl = b.getChannelData(0)
- const br = b.numberOfChannels > 1 ? b.getChannelData(1) : bl
- for (let i = 0; i < b.length; i++) { L[i] += bl[i]; R[i] += br[i] }
- }
- return { length, sampleRate, channels: [L, R] }
-}
-
-// Sum the parallel per-part renders and apply the master soft-clip limiter ONCE, in a
-// raw Web-Audio pass. The limiter is non-linear, so it must run on the summed mix, not
-// per part. A raw WaveShaperNode reproduces makeMasterLimiter exactly: Tone samples the
-// `mapping` over [-1, 1] into `length` points (curve[i] = softClipCurve(i/(len-1)*2-1))
-// and sets oversample '4x' — so the parallel path's output is bit-for-bit the single-
-// pass path's, minus float summation order (inaudible).
-async function limitMix(
- buffers: Array>,
-): Promise {
- const { length, sampleRate, channels } = sumPartBuffers(buffers)
- const ctx = new OfflineAudioContext(2, length, sampleRate)
- const mix = ctx.createBuffer(2, length, sampleRate)
- mix.copyToChannel(channels[0], 0)
- mix.copyToChannel(channels[1], 1)
- const src = ctx.createBufferSource()
- src.buffer = mix
- const ws = ctx.createWaveShaper()
- const curve = new Float32Array(4096)
- for (let i = 0; i < 4096; i++) curve[i] = softClipCurve((i / 4095) * 2 - 1)
- ws.curve = curve
- ws.oversample = '4x'
- src.connect(ws).connect(ctx.destination)
- src.start(0)
- return await ctx.startRendering()
-}
-
export function useMidiPlayer() {
async function toggle(url: string, styleId?: string, label?: string) {
if (currentlyPlaying.value === url) {
@@ -497,14 +295,11 @@ export function useMidiPlayer() {
// stay synthesized, so a two-piece kit is usable without filling every slot.
const drumKit = makeHybridKit(synthDrumKit, customKit)
let _synthBass: Tone.MonoSynth | null = null
- const getSynthBass = () => { if (!_synthBass) _synthBass = makeSynthBass(); return _synthBass }
+ const getSynthBass = () => { if (!_synthBass) _synthBass = makeSynthBass(disposables); return _synthBass }
- // Read the static pan (CC10) a track was written with and return the -1..1
- // value a Tone.Panner expects. @tonejs/midi normalises CC values to 0..1.
+ // Static pan (CC10) a track was written with, as the -1..1 a Tone.Panner wants.
function trackPan(track: typeof midi.tracks[number]): number {
- const cc = track.controlChanges?.[10]
- if (cc && cc.length) return Math.max(-1, Math.min(1, cc[cc.length - 1].value * 2 - 1))
- return 0
+ return panFromCC10(track.controlChanges?.[10])
}
// Melodic voices are resolved per part (chords / melody / arpeggio) so they
@@ -527,62 +322,36 @@ export function useMidiPlayer() {
function getMelodicInstrument(channel: number, pan: number): Tone.PolySynth | LayeredSampler {
if (voiceCache[channel]) return voiceCache[channel]
- // A user custom instrument assigned to this part overrides everything else.
const custom = customByPart[CHANNEL_PART[channel]]
- if (custom) { voiceCache[channel] = custom; return custom }
-
- // Instrument-identity resolution first: a sampled voice loaded for
- // this part wins; a wind/brass "melody_lead" voice gets the articulate
- // soft lead (matches the breath-phrased writing). Anything unresolved
- // falls through to the legacy style-family logic below.
- const _voiceSamplers: Record = { 0: chordsSamp, 2: melodySamp, 3: arpSamp }
- const _voiceIds: Record = { 0: _partVoice.chords, 2: _partVoice.melody, 3: _partVoice.arpeggio }
- if (_voiceSamplers[channel]) {
- voiceCache[channel] = _voiceSamplers[channel]!
- return voiceCache[channel]
- }
- if (channel === 2 && _voiceIds[2] === 'melody_lead') {
- voiceCache[2] = makePanned((out) => makeMelodyLead(true, out), pan)
- return voiceCache[2]
- }
-
+ // A sampled (identity) voice loaded for this part, and the resolved voice id
+ // (only chords/melody/arp carry these; other channels have none).
+ const sampler = ({ 0: chordsSamp, 2: melodySamp, 3: arpSamp } as Record)[channel] ?? null
+ const voiceId = ({ 0: _partVoice.chords, 2: _partVoice.melody, 3: _partVoice.arpeggio } as Record)[channel] ?? null
+
+ // Pure decision (see voiceRouting.ts) — construction stays here.
+ const kind = resolveMelodicVoiceKind({
+ channel, hasCustom: !!custom, hasSampler: !!sampler, voiceId,
+ isLofi, isSynth, isMelodicSynth, isPad, hasPiano: !!piano,
+ })
let inst: Tone.PolySynth | LayeredSampler
- if (channel === 4) {
- // Pads part — always the sustained pad voice, never the sampler, so
- // the layer washes behind the comp regardless of style.
- inst = makePanned(makePad, pan)
- } else if (channel === 5) {
- // Counter-melody — soft string ensemble under the lead.
- inst = makePanned(makeStrings, pan)
- } else if (channel === 3) {
- // Arpeggio — a sampled arp voice already won above (_voiceSamplers);
- // here it has none, so give it its own pluck to sparkle over the comp.
- inst = makePanned(makeArpPluck, pan)
- } else if (isLofi) {
- inst = makePanned(makeLofiSynth, pan)
- } else if (isSynth || isMelodicSynth) {
- // Chords get the warm comp stack; melody gets the dedicated lead.
- inst = channel === 0
- ? makePanned(makeSynthChords, pan)
- : makePanned((out) => makeMelodyLead(false, out), pan)
- } else if (isPad) {
- // Chords hold the slow pad; the melody needs a fast-attack lead so its
- // notes actually articulate instead of smearing under the pad envelope.
- inst = channel === 0
- ? makePanned(makePad, pan)
- : makePanned((out) => makeMelodyLead(true, out), pan)
- } else if (piano) {
- inst = piano // Salamander grand piano (shared)
- } else {
- // 'synth' mode (or piano not loaded): synth comp for chords, lead for melody.
- inst = channel === 0
- ? makePanned(makeSynthChords, pan)
- : makePanned((out) => makeMelodyLead(false, out), pan)
+ switch (kind) {
+ case 'custom': inst = custom!; break
+ case 'sampler': inst = sampler!; break
+ case 'piano': inst = piano!; break // Salamander grand (shared)
+ case 'pad': inst = makePanned((out) => makePad(disposables, out), pan); break
+ case 'strings': inst = makePanned((out) => makeStrings(disposables, out), pan); break
+ case 'arp_pluck': inst = makePanned((out) => makeArpPluck(disposables, out), pan); break
+ case 'lofi': inst = makePanned((out) => makeLofiSynth(disposables, out), pan); break
+ case 'synth_chords': inst = makePanned((out) => makeSynthChords(disposables, out), pan); break
+ case 'melody_lead_soft': inst = makePanned((out) => makeMelodyLead(true, disposables, out), pan); break
+ case 'synth_lead': inst = makePanned((out) => makeMelodyLead(false, disposables, out), pan); break
+ default: { const _never: never = kind; throw new Error(`unreachable voice kind: ${_never}`) }
}
voiceCache[channel] = inst
return inst
}
+ const midiToNote = (m: number) => Tone.Frequency(m, 'midi').toNote()
for (const track of midi.tracks) {
if (track.notes.length === 0) continue
@@ -592,13 +361,9 @@ export function useMidiPlayer() {
if (isPerc) {
// Electronic styles get a sidechain pump: each kick briefly ducks the
// melodic and bass buses so the mix breathes around the four-on-the-floor.
- const pump = isSynth
const notes = track.notes.map(n => ({ time: n.time, midi: n.midi, velocity: n.velocity }))
- const part = new Tone.Part<{ time: number; midi: number; velocity: number }>((time, note) => {
- if (channelMuted.value.drums) return
- drumKit.trigger(note.midi, note.velocity, time)
- if (pump && note.midi === 36) duckOnKick(time)
- }, notes)
+ const part = new Tone.Part<{ time: number; midi: number; velocity: number }>(
+ drumTriggerCallback(drumKit, () => channelMuted.value.drums, isSynth, duckOnKick), notes)
part.start(0)
scheduledParts.push(part)
@@ -609,13 +374,8 @@ export function useMidiPlayer() {
const notes = track.notes.map(n => ({
time: n.time, midi: n.midi, duration: n.duration, velocity: n.velocity,
}))
- const part = new Tone.Part<{ time: number; midi: number; duration: number; velocity: number }>((time, note) => {
- if (channelMuted.value.bass) return
- bassInst.triggerAttackRelease(
- Tone.Frequency(note.midi, 'midi').toNote(),
- note.duration, time, note.velocity,
- )
- }, notes)
+ const part = new Tone.Part<{ time: number; midi: number; duration: number; velocity: number }>(
+ voiceTriggerCallback(bassInst, () => channelMuted.value.bass, midiToNote), notes)
part.start(0)
scheduledParts.push(part)
@@ -627,13 +387,8 @@ export function useMidiPlayer() {
time: n.time, midi: n.midi, duration: n.duration, velocity: n.velocity,
}))
const mutePart = CHANNEL_PART[channel] ?? 'chords'
- const part = new Tone.Part<{ time: number; midi: number; duration: number; velocity: number }>((time, note) => {
- if (channelMuted.value[mutePart]) return
- instrument.triggerAttackRelease(
- Tone.Frequency(note.midi, 'midi').toNote(),
- note.duration, time, note.velocity,
- )
- }, notes)
+ const part = new Tone.Part<{ time: number; midi: number; duration: number; velocity: number }>(
+ voiceTriggerCallback(instrument, () => channelMuted.value[mutePart], midiToNote), notes)
part.start(0)
scheduledParts.push(part)
}
@@ -722,11 +477,11 @@ export function useMidiPlayer() {
}
}
- /**
- * Render to WAV using Tone.Offline (faster than real-time).
- * Uses a fresh synth-only signal path — no cached samplers needed.
- * channelFilter controls which instrument group to include (for stems).
- */
+ // Render a WAV (the parallel per-part offline render lives in useOfflineRender).
+ // Pause live playback first: the render walks the timeline on the main thread and
+ // would otherwise starve the Transport's lookahead scheduler, freezing playback
+ // mid-export. The render is offline/position-independent, so pausing doesn't change
+ // the exported audio; playback holds its spot to resume from.
async function offlineRender(
url: string,
styleId: string | undefined,
@@ -734,384 +489,11 @@ export function useMidiPlayer() {
channelFilter: 'all' | 'melodic' | PlayerPart = 'all',
onProgress?: (v: number) => void,
): Promise {
- isRendering.value = true
- onProgress?.(0.02)
- // A WAV render walks the song timeline on the MAIN thread (Tone's render clock, plus
- // the per-part sum + limiter passes), which starves the live Transport's main-thread
- // lookahead scheduler and makes playback stutter/freeze mid-export. Pause live playback
- // for the duration: the render is offline and position-independent, so pausing doesn't
- // change the exported audio, and playback holds its spot to resume from. (MIDI export
- // does no audio work, so it never had this problem.)
if (currentlyPlaying.value !== null && !isPaused.value) {
Tone.getTransport().pause()
isPaused.value = true
}
- try {
- const fetchUrl = url.startsWith('blob:') || url.startsWith('data:') ? url : downloadUrl(url)
- const buf = await fetch(fetchUrl).then(r => r.arrayBuffer())
- const midi = new Midi(buf)
- const bpm = midi.header.tempos[0]?.bpm ?? 120
-
- const isPad = styleId ? PAD_STYLES.has(styleId) : false
- const isLofi = styleId ? LOFI_STYLES.has(styleId) : false
- const isSynth = styleId ? SYNTH_STYLES.has(styleId) : false
-
- onProgress?.(0.08)
-
- const tail = isPad ? 2.5 : 1.2
- const totalDuration = durationSeconds + tail
- if (!Number.isFinite(totalDuration) || totalDuration <= 0) {
- throw new Error('Invalid render duration — the source MIDI may be empty or malformed')
- }
-
- // Which parts this render includes: 'all', the legacy 'melodic' bucket,
- // or any single part name (per-part stem export).
- const wantsPart = (p: PlayerPart): boolean =>
- channelFilter === 'all' || channelFilter === p ||
- (channelFilter === 'melodic' && p !== 'drums' && p !== 'bass')
-
- // The Web Audio API gives no incremental progress for an OfflineAudioContext
- // render, so a long song (several minutes of audio, synthesized+effected)
- // would otherwise sit at a frozen percentage for its entire render time and
- // look hung even when it's working. Nudge the bar forward on a timer, and
- // give up with a clear error instead of hanging the UI forever if the
- // render graph genuinely stalls (should not happen, but silent infinite
- // waits are worse than a surfaced error).
- let simulated = 0.08
- const progressTimer = setInterval(() => {
- simulated = Math.min(0.89, simulated + 0.01)
- onProgress?.(simulated)
- }, 400)
- const timeoutMs = Math.max(45_000, totalDuration * 4000)
- let timeoutHandle: ReturnType | null = null
- const timeout = new Promise((_, reject) => {
- timeoutHandle = setTimeout(
- () => reject(new Error('WAV render timed out — try again, or export a shorter section')),
- timeoutMs,
- )
- })
-
- // Build the synth graph for a subset of parts. `wants(p)` picks which parts this
- // pass renders; `withLimiter` puts the master soft-clip limiter inline. The
- // parallel path renders each part on its OWN OfflineAudioContext WITHOUT the
- // limiter (Chromium bounces them on separate threads) and limits the summed mix
- // once afterwards; a single-part stem render keeps the limiter inline because its
- // output already IS the final mix. See the orchestration below.
- const buildGraph = async (context: Tone.OfflineContext, wants: (p: PlayerPart) => boolean, withLimiter: boolean) => {
- const dest = context.destination
- dest.volume.value = 0
- // Plain gain, not a Tone.Compressor — a DynamicsCompressorNode renders silence on
- // Linux packaged Electron (see getMasterCompressor in loader.ts), which would make
- // WAV exports silent there too. A WaveShaper soft-clip limiter after it catches
- // peaks so the exported WAV can't clip, matching live playback's master chain.
- let busOut: Tone.ToneAudioNode = dest
- if (withLimiter) {
- const limiter = makeMasterLimiter(context)
- limiter.connect(dest)
- busOut = limiter
- }
- const comp = new Tone.Gain({ context, gain: 1 }).connect(busOut)
-
- // Only needed so Transport-relative delay-time notation ('8n', '4n', …)
- // in the effects below resolves against the song's real tempo — no note
- // is actually scheduled through the Transport (see below). This is the
- // offline context's OWN transport, not the ambient one used by live
- // playback — mutating that would have audibly retempo'd anything
- // playing concurrently while this export ran.
- context.transport.bpm.value = bpm
-
- // ── Drum kit ─────────────────────────────────────────────────────
- // Same synthesized engine as live playback, routed to the offline
- // compressor so the export matches what the user auditions.
- const drumKit = wants('drums')
- ? makeSynthKit(drumCharacterForStyle(styleId), comp, context)
- : null
-
- // ── Bass synth ───────────────────────────────────────────────────
- let bassSynth: Tone.MonoSynth | null = null
- if (wants('bass')) {
- bassSynth = new Tone.MonoSynth({
- context,
- oscillator: { type: 'sawtooth' },
- filter: { Q: 2, type: 'lowpass', rolloff: -24 },
- envelope: { attack: 0.01, decay: 0.1, sustain: 0.9, release: 0.5 },
- filterEnvelope: { attack: 0.06, decay: 0.2, sustain: 0.5, release: 0.5, baseFrequency: 200, octaves: 2.6 },
- volume: -3,
- }).connect(comp)
- }
-
- // ── Melodic synths ───────────────────────────────────────────────
- // Mirror live playback: chords / melody / arpeggio each get a distinct
- // voice, panned from the part's CC10 so the export matches the preview.
- const panForChannel = (ch: number): number => {
- const track = midi.tracks.find(t => (t.channel ?? 0) === ch)
- const cc = track?.controlChanges?.[10]
- if (cc && cc.length) return Math.max(-1, Math.min(1, cc[cc.length - 1].value * 2 - 1))
- return 0
- }
- const mkVoice = (variant: 'chords' | 'lead' | 'arp' | 'pads' | 'strings', pan: number): Tone.PolySynth => {
- const panner = new Tone.Panner({ context, pan }).connect(comp)
- if (variant === 'arp') {
- const delay = new Tone.FeedbackDelay({ context, delayTime: '8n.', feedback: 0.24, wet: 0.16 }).connect(panner)
- const lp = new Tone.Filter({ context, frequency: 4200, type: 'lowpass' }).connect(delay)
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.004, decay: 0.18, sustain: 0.0, release: 0.25 },
- volume: -11,
- },
- }).connect(lp)
- }
- if (variant === 'pads') {
- // Pads part — same sustained wash as live playback's makePad
- const delay = new Tone.FeedbackDelay({ context, delayTime: '4n', feedback: 0.4, wet: 0.3 }).connect(panner)
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.8, decay: 0.3, sustain: 0.7, release: 2.0 },
- volume: -10,
- },
- }).connect(delay)
- }
- if (variant === 'strings') {
- // Counter-melody — matches makeStrings in live playback
- const lp = new Tone.Filter({ context, frequency: 2400, type: 'lowpass' }).connect(panner)
- const chorus = new Tone.Chorus({ context, frequency: 0.8, depth: 0.35, wet: 0.3 }).connect(lp)
- chorus.start()
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'fatsawtooth', count: 3, spread: 14 },
- envelope: { attack: 0.12, decay: 0.3, sustain: 0.8, release: 1.2 },
- volume: -14,
- },
- }).connect(chorus)
- }
- // Winds/brass ("melody_lead" voices) get the articulate lead in the
- // offline render too — the writing is breath-phrased and monophonic,
- // and the generic triangle poly smears it.
- const _isWindLead = voiceFor(styleId, 'melody') === 'melody_lead'
- if (variant === 'lead' && (isPad || isSynth || _isWindLead)) {
- // Dedicated articulate melody lead (matches makeMelodyLead in live play):
- // fast attack, tamed low-pass, only a whisper of chorus so it stays in tune.
- const soft = isPad
- const delay = new Tone.FeedbackDelay({ context, delayTime: '8n.', feedback: 0.22, wet: soft ? 0.22 : 0.14 }).connect(panner)
- const chorus = new Tone.Chorus({ context, frequency: 1.8, depth: 0.12, wet: 0.14 }).connect(delay)
- chorus.start()
- const lp = new Tone.Filter({ context, frequency: soft ? 3000 : 3800, type: 'lowpass', rolloff: -12, Q: 0.8 }).connect(chorus)
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: soft ? { type: 'triangle' } : { type: 'sawtooth' },
- envelope: soft
- ? { attack: 0.03, decay: 0.2, sustain: 0.75, release: 0.8 }
- : { attack: 0.008, decay: 0.15, sustain: 0.65, release: 0.3 },
- volume: soft ? -9 : -10,
- },
- }).connect(lp)
- }
- if (isPad) {
- const delay = new Tone.FeedbackDelay({ context, delayTime: '4n', feedback: 0.38, wet: 0.28 }).connect(panner)
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.8, decay: 0.3, sustain: 0.7, release: 2.0 },
- volume: -7,
- },
- }).connect(delay)
- }
- if (isLofi) {
- const lp = new Tone.Filter({ context, frequency: 5200, type: 'lowpass' }).connect(panner)
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.04, decay: 0.2, sustain: 0.6, release: 1.2 },
- volume: -4,
- },
- }).connect(lp)
- }
- if (isSynth) {
- if (variant === 'chords') {
- const lp = new Tone.Filter({ context, frequency: 2600, type: 'lowpass' }).connect(panner)
- const chorus = new Tone.Chorus({ context, frequency: 1.4, depth: 0.5, wet: 0.35 }).connect(lp)
- chorus.start()
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'fatsawtooth', count: 3, spread: 22 },
- envelope: { attack: 0.06, decay: 0.25, sustain: 0.65, release: 0.6 },
- volume: -12,
- },
- }).connect(chorus)
- }
- const delay = new Tone.PingPongDelay({ context, delayTime: '8n', feedback: 0.18, wet: 0.1 }).connect(panner)
- const chorus = new Tone.Chorus({ context, frequency: 3, depth: 0.4, wet: 0.22 }).connect(delay)
- chorus.start()
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'sawtooth' },
- envelope: { attack: 0.01, decay: 0.12, sustain: 0.8, release: 0.4 },
- volume: -9,
- },
- }).connect(chorus)
- }
- return new Tone.PolySynth({
- context,
- voice: Tone.Synth,
- options: {
- oscillator: { type: 'triangle' },
- envelope: { attack: 0.02, decay: 0.15, sustain: 0.7, release: 0.9 },
- volume: -8,
- },
- }).connect(panner)
- }
-
- let chordsSynth: Tone.PolySynth | null = null
- let leadSynth: Tone.PolySynth | null = null
- let arpSynth: Tone.PolySynth | null = null
- let padsSynth: Tone.PolySynth | null = null
- let stringsSynth: Tone.PolySynth | null = null
- const hasTrackOn = (ch: number) =>
- midi.tracks.some(t => (t.channel ?? 0) === ch && t.notes.length > 0)
- // Only built when wanted AND the file actually carries the part — keeps
- // the offline graph light for single-stem renders.
- if (wants('chords')) chordsSynth = mkVoice('chords', panForChannel(0))
- if (wants('melody')) leadSynth = mkVoice('lead', panForChannel(2))
- if (wants('arpeggio')) arpSynth = mkVoice('arp', panForChannel(3))
- if (wants('pads') && hasTrackOn(4)) padsSynth = mkVoice('pads', panForChannel(4))
- if (wants('counter_melody') && hasTrackOn(5)) stringsSynth = mkVoice('strings', panForChannel(5))
-
- // ── Schedule MIDI events ─────────────────────────────────────────
- // Triggered directly at each note's absolute time (seconds from render
- // start — exactly what the parsed MIDI already gives us) instead of via
- // Tone.getTransport().schedule(). The Transport's tick-driven scheduler
- // is designed for live, tempo-relative playback; routing every note
- // through it here forced the offline render to walk its full timeline
- // for each of the render's ~128-sample ticks, which is the dominant
- // cost for a multi-minute song. Triggering the synths directly uses
- // native Web Audio parameter scheduling instead, which the offline
- // render can bounce in one pass — this is also the pattern Tone.js's
- // own docs use for offline rendering.
- //
- // Tone.js requires each voice's start times to arrive STRICTLY
- // increasing (the Transport used to guarantee that for us by firing
- // scheduled callbacks in time order regardless of scheduling order).
- // Triggering directly means the JS *call* order is what Tone.js sees,
- // and this loop processes one track fully before the next — so two
- // tracks sharing a voice (e.g. the single bassSynth instance) could
- // call it out of time order. Sorting fixes ordering but NOT exact ties
- // (two notes computed to the same float time — plausible for drum
- // rolls/simultaneous hits) since Tone.js rejects equal times too, so
- // after sorting each fire time is nudged forward by a sub-millisecond
- // epsilon whenever it would collide with (or trail) the previous one —
- // inaudible, but guarantees strict monotonicity for every voice.
- const triggers: { time: number; fn: (time: number) => void }[] = []
- for (const track of midi.tracks) {
- if (track.notes.length === 0) continue
- const channel = track.channel ?? 0
- const isPerc = track.instrument.percussion || channel === 9
-
- if (isPerc && drumKit) {
- for (const n of track.notes) {
- triggers.push({ time: n.time, fn: (time) => drumKit.trigger(n.midi, n.velocity, time) })
- }
- } else if (channel === 1 && bassSynth) {
- const bass = bassSynth
- for (const n of track.notes) {
- const note = Tone.Frequency(n.midi, 'midi').toNote()
- triggers.push({ time: n.time, fn: (time) => bass.triggerAttackRelease(note, n.duration, time, n.velocity) })
- }
- } else if (!isPerc && channel !== 1) {
- // channel 2 = melody (lead), 3 = arpeggio (pluck), 4 = pads,
- // 5 = counter-melody (strings), else chords. A null synth means the
- // part isn't included in this render (per-part stem filtering).
- const synth = channel === 3 ? arpSynth
- : channel === 2 ? leadSynth
- : channel === 4 ? padsSynth
- : channel === 5 ? stringsSynth
- : chordsSynth
- if (synth) {
- for (const n of track.notes) {
- const note = Tone.Frequency(n.midi, 'midi').toNote()
- triggers.push({ time: n.time, fn: (time) => synth.triggerAttackRelease(note, n.duration, time, n.velocity) })
- }
- }
- }
- }
- triggers.sort((a, b) => a.time - b.time)
- const EPSILON = 0.0005 // 0.5 ms — well below audible/perceptible timing resolution
- let lastTime = -Infinity
- for (const t of triggers) {
- const time = t.time > lastTime ? t.time : lastTime + EPSILON
- lastTime = time
- t.fn(time)
- }
- }
-
- // The individual parts this render covers: the wanted universe intersected with
- // the parts the file actually carries. Each renders on its own OfflineAudioContext
- // so Chromium can bounce them on separate threads (measured ~2.5× faster on 8
- // cores); the per-part graphs never interact, so the summed mix is the same signal
- // the single-pass graph fed its limiter. A lone part (stem export) has nothing to
- // parallelise, so it renders inline with the limiter — its output IS the mix.
- const present = PLAYER_PARTS.filter(p => wantsPart(p) && partHasNotes(midi, p))
-
- // If the timeout wins a race, the render keeps going in the background (an
- // OfflineAudioContext can't be cancelled) — the `.catch(() => {})` on each promise
- // swallows its eventual settlement so a late rejection isn't an unhandled promise.
- let finalBuffer: AudioBuffer
- try {
- if (present.length <= 1) {
- const single = renderOfflineFast(c => buildGraph(c, wantsPart, true), totalDuration, 2)
- single.catch(() => {})
- const toneBuffer = await Promise.race([single, timeout])
- const ab = toneBuffer.get()
- if (!ab) throw new Error('Offline render returned empty buffer')
- finalBuffer = ab
- } else {
- // Render every part in parallel (no limiter); progress advances as each lands.
- let done = 0
- const partRenders = present.map(p =>
- renderOfflineFast(c => buildGraph(c, q => q === p, false), totalDuration, 2)
- .then(buf => {
- done += 1
- onProgress?.(0.08 + 0.8 * (done / present.length))
- return buf
- }),
- )
- partRenders.forEach(pr => pr.catch(() => {}))
- const dry = await Promise.race([Promise.all(partRenders), timeout])
- const buffers = dry.map(b => b.get()).filter((b): b is AudioBuffer => !!b)
- if (buffers.length === 0) throw new Error('Offline render returned empty buffer')
- onProgress?.(0.9)
- // Sum the parts and apply the master limiter once, on the full mix.
- finalBuffer = await limitMix(buffers)
- }
- } finally {
- clearInterval(progressTimer)
- if (timeoutHandle !== null) clearTimeout(timeoutHandle)
- }
-
- onProgress?.(0.92)
- const blob = encodeWav(finalBuffer)
- onProgress?.(1)
- return blob
- } finally {
- isRendering.value = false
- }
+ return offlineRenderRaw(url, styleId, durationSeconds, channelFilter, onProgress)
}
function stop() {
diff --git a/frontend/src/composables/useOfflineRender.ts b/frontend/src/composables/useOfflineRender.ts
new file mode 100644
index 0000000..e3af350
--- /dev/null
+++ b/frontend/src/composables/useOfflineRender.ts
@@ -0,0 +1,492 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+
+// Offline WAV export — renders each part on its own OfflineAudioContext
+// concurrently (separate Chromium threads), sums them, and applies the master
+// limiter once. Split out of useMidiPlayer.ts; the pause-live-playback wrapper
+// stays there (it owns the playback refs). See docs/roadmap.md Phase 3.
+import { ref } from 'vue'
+import * as Tone from 'tone'
+import { Midi } from '@tonejs/midi'
+import { downloadUrl } from '../services/api'
+import { makeMasterLimiter, softClipCurve } from '../soundfonts/loader'
+import { drumCharacterForStyle } from '../soundfonts/drums'
+import { makeSynthKit } from '../soundfonts/synthDrums'
+import { voiceFor } from './useStyleCatalog'
+import { encodeWav } from '../utils/wavEncoder'
+import { PLAYER_PARTS, type PlayerPart, CHANNEL_PART, SYNTH_STYLES, PAD_STYLES, LOFI_STYLES } from './playerConstants'
+
+// True while any WAV export is rendering (drives the export progress UI).
+export const isRendering = ref(false)
+
+// Tone.Offline() always renders "asynchronously": internally it steps through
+// the render in 128-sample blocks and, once per second of rendered audio,
+// awaits a 1ms setTimeout so the main thread isn't blocked for the whole
+// render. An earlier version of this function skipped those yields entirely
+// (render(false)) for maximum speed — but with no note actually routed
+// through Tone's Transport any more (see the trigger-ordering comment in
+// offlineRender below), each of those per-tick steps got dramatically
+// cheaper, so the yields cost much less relative to the work they used to
+// interrupt. Keeping them (render(true)) means the tab stays responsive
+// during a render — you can keep working, start another export, or play a
+// preview — for a render time difference that's now marginal.
+//
+// This also used to swap Tone's ambient context globally for the whole
+// render via Tone.setContext(), which corrupted any other Tone.js activity
+// (playback, another render) that started before the swap was undone —
+// exactly the kind of concurrent use the yields above are meant to allow.
+// Building the OfflineContext and handing it directly to the caller instead
+// avoids that: every node the caller creates must be given { context }
+// explicitly so it lives in the offline graph rather than the ambient one.
+async function renderOfflineFast(
+ callback: (context: Tone.OfflineContext) => Promise | void,
+ duration: number,
+ channels = 2,
+): Promise {
+ const context = new Tone.OfflineContext(channels, duration, Tone.getContext().sampleRate)
+ await callback(context)
+ return await context.render(true)
+}
+
+// Does the parsed MIDI carry any notes for a given part? Mirrors the render's own
+// channel→voice routing (percussion / ch9 → drums; everything else via CHANNEL_PART,
+// with unknown non-perc channels falling through to chords like the scheduler does).
+// Used to decide which parts to spin up as parallel per-part renders.
+export function partHasNotes(midi: Midi, part: PlayerPart): boolean {
+ return midi.tracks.some(t => {
+ if (t.notes.length === 0) return false
+ const perc = t.instrument.percussion || (t.channel ?? 0) === 9
+ const p = perc ? 'drums' : CHANNEL_PART[t.channel ?? 0] ?? 'chords'
+ return p === part
+ })
+}
+
+// Sum several rendered part buffers into one stereo pair. Pure (no Web Audio), so it's
+// unit-testable. The per-part offline graphs never share nodes, so summing their outputs
+// reproduces exactly the signal the single-pass graph summed at its master bus — the
+// pre-limiter mix. Exported for the mix-identity test.
+export function sumPartBuffers(
+ buffers: Array>,
+): { length: number; sampleRate: number; channels: [Float32Array, Float32Array] } {
+ const length = buffers.reduce((m, b) => Math.max(m, b.length), 0)
+ const sampleRate = buffers[0]?.sampleRate ?? 44100
+ const L = new Float32Array(length)
+ const R = new Float32Array(length)
+ for (const b of buffers) {
+ const bl = b.getChannelData(0)
+ const br = b.numberOfChannels > 1 ? b.getChannelData(1) : bl
+ for (let i = 0; i < b.length; i++) { L[i] += bl[i]; R[i] += br[i] }
+ }
+ return { length, sampleRate, channels: [L, R] }
+}
+
+// Sum the parallel per-part renders and apply the master soft-clip limiter ONCE, in a
+// raw Web-Audio pass. The limiter is non-linear, so it must run on the summed mix, not
+// per part. A raw WaveShaperNode reproduces makeMasterLimiter exactly: Tone samples the
+// `mapping` over [-1, 1] into `length` points (curve[i] = softClipCurve(i/(len-1)*2-1))
+// and sets oversample '4x' — so the parallel path's output is bit-for-bit the single-
+// pass path's, minus float summation order (inaudible).
+async function limitMix(
+ buffers: Array>,
+): Promise {
+ const { length, sampleRate, channels } = sumPartBuffers(buffers)
+ const ctx = new OfflineAudioContext(2, length, sampleRate)
+ const mix = ctx.createBuffer(2, length, sampleRate)
+ mix.copyToChannel(channels[0], 0)
+ mix.copyToChannel(channels[1], 1)
+ const src = ctx.createBufferSource()
+ src.buffer = mix
+ const ws = ctx.createWaveShaper()
+ const curve = new Float32Array(4096)
+ for (let i = 0; i < 4096; i++) curve[i] = softClipCurve((i / 4095) * 2 - 1)
+ ws.curve = curve
+ ws.oversample = '4x'
+ src.connect(ws).connect(ctx.destination)
+ src.start(0)
+ return await ctx.startRendering()
+}
+
+export async function offlineRenderRaw(
+ url: string,
+ styleId: string | undefined,
+ durationSeconds: number,
+ channelFilter: 'all' | 'melodic' | PlayerPart = 'all',
+ onProgress?: (v: number) => void,
+): Promise {
+ isRendering.value = true
+ onProgress?.(0.02)
+ try {
+ const fetchUrl = url.startsWith('blob:') || url.startsWith('data:') ? url : downloadUrl(url)
+ const buf = await fetch(fetchUrl).then(r => r.arrayBuffer())
+ const midi = new Midi(buf)
+ const bpm = midi.header.tempos[0]?.bpm ?? 120
+
+ const isPad = styleId ? PAD_STYLES.has(styleId) : false
+ const isLofi = styleId ? LOFI_STYLES.has(styleId) : false
+ const isSynth = styleId ? SYNTH_STYLES.has(styleId) : false
+
+ onProgress?.(0.08)
+
+ const tail = isPad ? 2.5 : 1.2
+ const totalDuration = durationSeconds + tail
+ if (!Number.isFinite(totalDuration) || totalDuration <= 0) {
+ throw new Error('Invalid render duration — the source MIDI may be empty or malformed')
+ }
+
+ // Which parts this render includes: 'all', the legacy 'melodic' bucket,
+ // or any single part name (per-part stem export).
+ const wantsPart = (p: PlayerPart): boolean =>
+ channelFilter === 'all' || channelFilter === p ||
+ (channelFilter === 'melodic' && p !== 'drums' && p !== 'bass')
+
+ // The Web Audio API gives no incremental progress for an OfflineAudioContext
+ // render, so a long song (several minutes of audio, synthesized+effected)
+ // would otherwise sit at a frozen percentage for its entire render time and
+ // look hung even when it's working. Nudge the bar forward on a timer, and
+ // give up with a clear error instead of hanging the UI forever if the
+ // render graph genuinely stalls (should not happen, but silent infinite
+ // waits are worse than a surfaced error).
+ let simulated = 0.08
+ const progressTimer = setInterval(() => {
+ simulated = Math.min(0.89, simulated + 0.01)
+ onProgress?.(simulated)
+ }, 400)
+ const timeoutMs = Math.max(45_000, totalDuration * 4000)
+ let timeoutHandle: ReturnType | null = null
+ const timeout = new Promise((_, reject) => {
+ timeoutHandle = setTimeout(
+ () => reject(new Error('WAV render timed out — try again, or export a shorter section')),
+ timeoutMs,
+ )
+ })
+
+ // Build the synth graph for a subset of parts. `wants(p)` picks which parts this
+ // pass renders; `withLimiter` puts the master soft-clip limiter inline. The
+ // parallel path renders each part on its OWN OfflineAudioContext WITHOUT the
+ // limiter (Chromium bounces them on separate threads) and limits the summed mix
+ // once afterwards; a single-part stem render keeps the limiter inline because its
+ // output already IS the final mix. See the orchestration below.
+ const buildGraph = async (context: Tone.OfflineContext, wants: (p: PlayerPart) => boolean, withLimiter: boolean) => {
+ const dest = context.destination
+ dest.volume.value = 0
+ // Plain gain, not a Tone.Compressor — a DynamicsCompressorNode renders silence on
+ // Linux packaged Electron (see getMasterCompressor in loader.ts), which would make
+ // WAV exports silent there too. A WaveShaper soft-clip limiter after it catches
+ // peaks so the exported WAV can't clip, matching live playback's master chain.
+ let busOut: Tone.ToneAudioNode = dest
+ if (withLimiter) {
+ const limiter = makeMasterLimiter(context)
+ limiter.connect(dest)
+ busOut = limiter
+ }
+ const comp = new Tone.Gain({ context, gain: 1 }).connect(busOut)
+
+ // Only needed so Transport-relative delay-time notation ('8n', '4n', …)
+ // in the effects below resolves against the song's real tempo — no note
+ // is actually scheduled through the Transport (see below). This is the
+ // offline context's OWN transport, not the ambient one used by live
+ // playback — mutating that would have audibly retempo'd anything
+ // playing concurrently while this export ran.
+ context.transport.bpm.value = bpm
+
+ // ── Drum kit ─────────────────────────────────────────────────────
+ // Same synthesized engine as live playback, routed to the offline
+ // compressor so the export matches what the user auditions.
+ const drumKit = wants('drums')
+ ? makeSynthKit(drumCharacterForStyle(styleId), comp, context)
+ : null
+
+ // ── Bass synth ───────────────────────────────────────────────────
+ let bassSynth: Tone.MonoSynth | null = null
+ if (wants('bass')) {
+ bassSynth = new Tone.MonoSynth({
+ context,
+ oscillator: { type: 'sawtooth' },
+ filter: { Q: 2, type: 'lowpass', rolloff: -24 },
+ envelope: { attack: 0.01, decay: 0.1, sustain: 0.9, release: 0.5 },
+ filterEnvelope: { attack: 0.06, decay: 0.2, sustain: 0.5, release: 0.5, baseFrequency: 200, octaves: 2.6 },
+ volume: -3,
+ }).connect(comp)
+ }
+
+ // ── Melodic synths ───────────────────────────────────────────────
+ // Mirror live playback: chords / melody / arpeggio each get a distinct
+ // voice, panned from the part's CC10 so the export matches the preview.
+ const panForChannel = (ch: number): number => {
+ const track = midi.tracks.find(t => (t.channel ?? 0) === ch)
+ const cc = track?.controlChanges?.[10]
+ if (cc && cc.length) return Math.max(-1, Math.min(1, cc[cc.length - 1].value * 2 - 1))
+ return 0
+ }
+ const mkVoice = (variant: 'chords' | 'lead' | 'arp' | 'pads' | 'strings', pan: number): Tone.PolySynth => {
+ const panner = new Tone.Panner({ context, pan }).connect(comp)
+ if (variant === 'arp') {
+ const delay = new Tone.FeedbackDelay({ context, delayTime: '8n.', feedback: 0.24, wet: 0.16 }).connect(panner)
+ const lp = new Tone.Filter({ context, frequency: 4200, type: 'lowpass' }).connect(delay)
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.004, decay: 0.18, sustain: 0.0, release: 0.25 },
+ volume: -11,
+ },
+ }).connect(lp)
+ }
+ if (variant === 'pads') {
+ // Pads part — same sustained wash as live playback's makePad
+ const delay = new Tone.FeedbackDelay({ context, delayTime: '4n', feedback: 0.4, wet: 0.3 }).connect(panner)
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.8, decay: 0.3, sustain: 0.7, release: 2.0 },
+ volume: -10,
+ },
+ }).connect(delay)
+ }
+ if (variant === 'strings') {
+ // Counter-melody — matches makeStrings in live playback
+ const lp = new Tone.Filter({ context, frequency: 2400, type: 'lowpass' }).connect(panner)
+ const chorus = new Tone.Chorus({ context, frequency: 0.8, depth: 0.35, wet: 0.3 }).connect(lp)
+ chorus.start()
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'fatsawtooth', count: 3, spread: 14 },
+ envelope: { attack: 0.12, decay: 0.3, sustain: 0.8, release: 1.2 },
+ volume: -14,
+ },
+ }).connect(chorus)
+ }
+ // Winds/brass ("melody_lead" voices) get the articulate lead in the
+ // offline render too — the writing is breath-phrased and monophonic,
+ // and the generic triangle poly smears it.
+ const _isWindLead = voiceFor(styleId, 'melody') === 'melody_lead'
+ if (variant === 'lead' && (isPad || isSynth || _isWindLead)) {
+ // Dedicated articulate melody lead (matches makeMelodyLead in live play):
+ // fast attack, tamed low-pass, only a whisper of chorus so it stays in tune.
+ const soft = isPad
+ const delay = new Tone.FeedbackDelay({ context, delayTime: '8n.', feedback: 0.22, wet: soft ? 0.22 : 0.14 }).connect(panner)
+ const chorus = new Tone.Chorus({ context, frequency: 1.8, depth: 0.12, wet: 0.14 }).connect(delay)
+ chorus.start()
+ const lp = new Tone.Filter({ context, frequency: soft ? 3000 : 3800, type: 'lowpass', rolloff: -12, Q: 0.8 }).connect(chorus)
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: soft ? { type: 'triangle' } : { type: 'sawtooth' },
+ envelope: soft
+ ? { attack: 0.03, decay: 0.2, sustain: 0.75, release: 0.8 }
+ : { attack: 0.008, decay: 0.15, sustain: 0.65, release: 0.3 },
+ volume: soft ? -9 : -10,
+ },
+ }).connect(lp)
+ }
+ if (isPad) {
+ const delay = new Tone.FeedbackDelay({ context, delayTime: '4n', feedback: 0.38, wet: 0.28 }).connect(panner)
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.8, decay: 0.3, sustain: 0.7, release: 2.0 },
+ volume: -7,
+ },
+ }).connect(delay)
+ }
+ if (isLofi) {
+ const lp = new Tone.Filter({ context, frequency: 5200, type: 'lowpass' }).connect(panner)
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.04, decay: 0.2, sustain: 0.6, release: 1.2 },
+ volume: -4,
+ },
+ }).connect(lp)
+ }
+ if (isSynth) {
+ if (variant === 'chords') {
+ const lp = new Tone.Filter({ context, frequency: 2600, type: 'lowpass' }).connect(panner)
+ const chorus = new Tone.Chorus({ context, frequency: 1.4, depth: 0.5, wet: 0.35 }).connect(lp)
+ chorus.start()
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'fatsawtooth', count: 3, spread: 22 },
+ envelope: { attack: 0.06, decay: 0.25, sustain: 0.65, release: 0.6 },
+ volume: -12,
+ },
+ }).connect(chorus)
+ }
+ const delay = new Tone.PingPongDelay({ context, delayTime: '8n', feedback: 0.18, wet: 0.1 }).connect(panner)
+ const chorus = new Tone.Chorus({ context, frequency: 3, depth: 0.4, wet: 0.22 }).connect(delay)
+ chorus.start()
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'sawtooth' },
+ envelope: { attack: 0.01, decay: 0.12, sustain: 0.8, release: 0.4 },
+ volume: -9,
+ },
+ }).connect(chorus)
+ }
+ return new Tone.PolySynth({
+ context,
+ voice: Tone.Synth,
+ options: {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.02, decay: 0.15, sustain: 0.7, release: 0.9 },
+ volume: -8,
+ },
+ }).connect(panner)
+ }
+
+ let chordsSynth: Tone.PolySynth | null = null
+ let leadSynth: Tone.PolySynth | null = null
+ let arpSynth: Tone.PolySynth | null = null
+ let padsSynth: Tone.PolySynth | null = null
+ let stringsSynth: Tone.PolySynth | null = null
+ const hasTrackOn = (ch: number) =>
+ midi.tracks.some(t => (t.channel ?? 0) === ch && t.notes.length > 0)
+ // Only built when wanted AND the file actually carries the part — keeps
+ // the offline graph light for single-stem renders.
+ if (wants('chords')) chordsSynth = mkVoice('chords', panForChannel(0))
+ if (wants('melody')) leadSynth = mkVoice('lead', panForChannel(2))
+ if (wants('arpeggio')) arpSynth = mkVoice('arp', panForChannel(3))
+ if (wants('pads') && hasTrackOn(4)) padsSynth = mkVoice('pads', panForChannel(4))
+ if (wants('counter_melody') && hasTrackOn(5)) stringsSynth = mkVoice('strings', panForChannel(5))
+
+ // ── Schedule MIDI events ─────────────────────────────────────────
+ // Triggered directly at each note's absolute time (seconds from render
+ // start — exactly what the parsed MIDI already gives us) instead of via
+ // Tone.getTransport().schedule(). The Transport's tick-driven scheduler
+ // is designed for live, tempo-relative playback; routing every note
+ // through it here forced the offline render to walk its full timeline
+ // for each of the render's ~128-sample ticks, which is the dominant
+ // cost for a multi-minute song. Triggering the synths directly uses
+ // native Web Audio parameter scheduling instead, which the offline
+ // render can bounce in one pass — this is also the pattern Tone.js's
+ // own docs use for offline rendering.
+ //
+ // Tone.js requires each voice's start times to arrive STRICTLY
+ // increasing (the Transport used to guarantee that for us by firing
+ // scheduled callbacks in time order regardless of scheduling order).
+ // Triggering directly means the JS *call* order is what Tone.js sees,
+ // and this loop processes one track fully before the next — so two
+ // tracks sharing a voice (e.g. the single bassSynth instance) could
+ // call it out of time order. Sorting fixes ordering but NOT exact ties
+ // (two notes computed to the same float time — plausible for drum
+ // rolls/simultaneous hits) since Tone.js rejects equal times too, so
+ // after sorting each fire time is nudged forward by a sub-millisecond
+ // epsilon whenever it would collide with (or trail) the previous one —
+ // inaudible, but guarantees strict monotonicity for every voice.
+ const triggers: { time: number; fn: (time: number) => void }[] = []
+ for (const track of midi.tracks) {
+ if (track.notes.length === 0) continue
+ const channel = track.channel ?? 0
+ const isPerc = track.instrument.percussion || channel === 9
+
+ if (isPerc && drumKit) {
+ for (const n of track.notes) {
+ triggers.push({ time: n.time, fn: (time) => drumKit.trigger(n.midi, n.velocity, time) })
+ }
+ } else if (channel === 1 && bassSynth) {
+ const bass = bassSynth
+ for (const n of track.notes) {
+ const note = Tone.Frequency(n.midi, 'midi').toNote()
+ triggers.push({ time: n.time, fn: (time) => bass.triggerAttackRelease(note, n.duration, time, n.velocity) })
+ }
+ } else if (!isPerc && channel !== 1) {
+ // channel 2 = melody (lead), 3 = arpeggio (pluck), 4 = pads,
+ // 5 = counter-melody (strings), else chords. A null synth means the
+ // part isn't included in this render (per-part stem filtering).
+ const synth = channel === 3 ? arpSynth
+ : channel === 2 ? leadSynth
+ : channel === 4 ? padsSynth
+ : channel === 5 ? stringsSynth
+ : chordsSynth
+ if (synth) {
+ for (const n of track.notes) {
+ const note = Tone.Frequency(n.midi, 'midi').toNote()
+ triggers.push({ time: n.time, fn: (time) => synth.triggerAttackRelease(note, n.duration, time, n.velocity) })
+ }
+ }
+ }
+ }
+ triggers.sort((a, b) => a.time - b.time)
+ const EPSILON = 0.0005 // 0.5 ms — well below audible/perceptible timing resolution
+ let lastTime = -Infinity
+ for (const t of triggers) {
+ const time = t.time > lastTime ? t.time : lastTime + EPSILON
+ lastTime = time
+ t.fn(time)
+ }
+ }
+
+ // The individual parts this render covers: the wanted universe intersected with
+ // the parts the file actually carries. Each renders on its own OfflineAudioContext
+ // so Chromium can bounce them on separate threads (measured ~2.5× faster on 8
+ // cores); the per-part graphs never interact, so the summed mix is the same signal
+ // the single-pass graph fed its limiter. A lone part (stem export) has nothing to
+ // parallelise, so it renders inline with the limiter — its output IS the mix.
+ const present = PLAYER_PARTS.filter(p => wantsPart(p) && partHasNotes(midi, p))
+
+ // If the timeout wins a race, the render keeps going in the background (an
+ // OfflineAudioContext can't be cancelled) — the `.catch(() => {})` on each promise
+ // swallows its eventual settlement so a late rejection isn't an unhandled promise.
+ let finalBuffer: AudioBuffer
+ try {
+ if (present.length <= 1) {
+ const single = renderOfflineFast(c => buildGraph(c, wantsPart, true), totalDuration, 2)
+ single.catch(() => {})
+ const toneBuffer = await Promise.race([single, timeout])
+ const ab = toneBuffer.get()
+ if (!ab) throw new Error('Offline render returned empty buffer')
+ finalBuffer = ab
+ } else {
+ // Render every part in parallel (no limiter); progress advances as each lands.
+ let done = 0
+ const partRenders = present.map(p =>
+ renderOfflineFast(c => buildGraph(c, q => q === p, false), totalDuration, 2)
+ .then(buf => {
+ done += 1
+ onProgress?.(0.08 + 0.8 * (done / present.length))
+ return buf
+ }),
+ )
+ partRenders.forEach(pr => pr.catch(() => {}))
+ const dry = await Promise.race([Promise.all(partRenders), timeout])
+ const buffers = dry.map(b => b.get()).filter((b): b is AudioBuffer => !!b)
+ if (buffers.length === 0) throw new Error('Offline render returned empty buffer')
+ onProgress?.(0.9)
+ // Sum the parts and apply the master limiter once, on the full mix.
+ finalBuffer = await limitMix(buffers)
+ }
+ } finally {
+ clearInterval(progressTimer)
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle)
+ }
+
+ onProgress?.(0.92)
+ const blob = encodeWav(finalBuffer)
+ onProgress?.(1)
+ return blob
+ } finally {
+ isRendering.value = false
+ }
+}
diff --git a/frontend/src/composables/voiceRouting.test.ts b/frontend/src/composables/voiceRouting.test.ts
new file mode 100644
index 0000000..505b802
--- /dev/null
+++ b/frontend/src/composables/voiceRouting.test.ts
@@ -0,0 +1,77 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+import { describe, it, expect } from 'vitest'
+import { resolveMelodicVoiceKind, panFromCC10, type VoiceContext } from './voiceRouting'
+
+const base: VoiceContext = {
+ channel: 0, hasCustom: false, hasSampler: false, voiceId: null,
+ isLofi: false, isSynth: false, isMelodicSynth: false, isPad: false, hasPiano: false,
+}
+const ctx = (o: Partial) => resolveMelodicVoiceKind({ ...base, ...o })
+
+describe('resolveMelodicVoiceKind — precedence', () => {
+ it('custom instrument wins over everything', () => {
+ expect(ctx({ hasCustom: true, hasSampler: true, isSynth: true, channel: 4 })).toBe('custom')
+ })
+ it('a loaded sampler wins over identity-lead and style family', () => {
+ expect(ctx({ hasSampler: true, channel: 2, voiceId: 'melody_lead', isSynth: true })).toBe('sampler')
+ })
+ it('part-specific channels (4/5/3) beat the style family', () => {
+ expect(ctx({ channel: 4, isLofi: true })).toBe('pad') // pads part before isLofi
+ expect(ctx({ channel: 5, isSynth: true })).toBe('strings') // counter-melody before isSynth
+ expect(ctx({ channel: 3, isPad: true })).toBe('arp_pluck') // arpeggio before isPad
+ })
+})
+
+describe('resolveMelodicVoiceKind — identity + families', () => {
+ it('melody_lead identity gives the soft lead, but only on the melody channel', () => {
+ expect(ctx({ channel: 2, voiceId: 'melody_lead' })).toBe('melody_lead_soft')
+ // a melody_lead id on the arp channel is ignored — arp still gets its pluck
+ expect(ctx({ channel: 3, voiceId: 'melody_lead' })).toBe('arp_pluck')
+ })
+ it('lofi routes every melodic part to the lo-fi synth', () => {
+ expect(ctx({ channel: 0, isLofi: true })).toBe('lofi')
+ expect(ctx({ channel: 2, isLofi: true })).toBe('lofi')
+ })
+ it('synth / melodic-synth: chords get the comp, melody the lead', () => {
+ expect(ctx({ channel: 0, isSynth: true })).toBe('synth_chords')
+ expect(ctx({ channel: 2, isSynth: true })).toBe('synth_lead')
+ expect(ctx({ channel: 0, isMelodicSynth: true })).toBe('synth_chords')
+ expect(ctx({ channel: 2, isMelodicSynth: true })).toBe('synth_lead')
+ })
+ it('pad styles: chords hold the pad, melody gets a fast-attack lead', () => {
+ expect(ctx({ channel: 0, isPad: true })).toBe('pad')
+ expect(ctx({ channel: 2, isPad: true })).toBe('melody_lead_soft')
+ })
+ it('piano fallback when available, else the synth default', () => {
+ expect(ctx({ channel: 0, hasPiano: true })).toBe('piano')
+ expect(ctx({ channel: 2, hasPiano: true })).toBe('piano')
+ expect(ctx({ channel: 0 })).toBe('synth_chords') // no flags, no piano
+ expect(ctx({ channel: 2 })).toBe('synth_lead')
+ })
+})
+
+describe('panFromCC10', () => {
+ it('centres when there is no CC10', () => {
+ expect(panFromCC10(undefined)).toBe(0)
+ expect(panFromCC10([])).toBe(0)
+ })
+ it('maps the last 0..1 value into -1..1', () => {
+ expect(panFromCC10([{ value: 0.5 }])).toBe(0)
+ expect(panFromCC10([{ value: 1 }])).toBe(1)
+ expect(panFromCC10([{ value: 0 }])).toBe(-1)
+ expect(panFromCC10([{ value: 0 }, { value: 1 }])).toBe(1) // last wins
+ })
+ it('clamps out-of-range values to the rails', () => {
+ expect(panFromCC10([{ value: 5 }])).toBe(1)
+ expect(panFromCC10([{ value: -5 }])).toBe(-1)
+ })
+})
diff --git a/frontend/src/composables/voiceRouting.ts b/frontend/src/composables/voiceRouting.ts
new file mode 100644
index 0000000..2aba0ff
--- /dev/null
+++ b/frontend/src/composables/voiceRouting.ts
@@ -0,0 +1,65 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+
+// Pure voice-routing decision for live playback — which instrument a melodic part
+// plays, given its MIDI channel and the style's classification. Extracted from
+// useMidiPlayer's getMelodicInstrument so the branch logic (the fragile bit) is
+// unit-testable in isolation; the actual Tone node construction stays in the
+// composable and switches on the kind this returns.
+
+// channel: 0 = chords, 2 = melody, 3 = arpeggio, 4 = pads, 5 = counter-melody
+export interface VoiceContext {
+ channel: number
+ hasCustom: boolean // a user custom instrument is assigned to this part
+ hasSampler: boolean // a sampled (identity) voice loaded for this part
+ voiceId: string | null // resolved instrument-identity id for this part
+ isLofi: boolean
+ isSynth: boolean
+ isMelodicSynth: boolean
+ isPad: boolean
+ hasPiano: boolean // the shared Salamander piano is available
+}
+
+export type MelodicVoiceKind =
+ | 'custom' // the user's assigned instrument (returned as-is)
+ | 'sampler' // the loaded identity sampler (returned as-is)
+ | 'melody_lead_soft' // makeMelodyLead(true)
+ | 'synth_lead' // makeMelodyLead(false)
+ | 'synth_chords' // makeSynthChords
+ | 'pad' // makePad
+ | 'strings' // makeStrings
+ | 'arp_pluck' // makeArpPluck
+ | 'lofi' // makeLofiSynth
+ | 'piano' // the shared piano (returned as-is)
+
+// Mirrors getMelodicInstrument's original branch ORDER exactly — the first match
+// wins, so custom > sampler > identity-lead > part-specific voice > style family >
+// piano > synth default.
+export function resolveMelodicVoiceKind(c: VoiceContext): MelodicVoiceKind {
+ if (c.hasCustom) return 'custom'
+ if (c.hasSampler) return 'sampler'
+ if (c.channel === 2 && c.voiceId === 'melody_lead') return 'melody_lead_soft'
+ if (c.channel === 4) return 'pad' // pads part — always the sustained wash
+ if (c.channel === 5) return 'strings' // counter-melody — soft strings
+ if (c.channel === 3) return 'arp_pluck' // arpeggio — its own pluck
+ if (c.isLofi) return 'lofi'
+ if (c.isSynth || c.isMelodicSynth) return c.channel === 0 ? 'synth_chords' : 'synth_lead'
+ if (c.isPad) return c.channel === 0 ? 'pad' : 'melody_lead_soft'
+ if (c.hasPiano) return 'piano'
+ return c.channel === 0 ? 'synth_chords' : 'synth_lead'
+}
+
+// Static pan a track was written with: @tonejs/midi normalises CC10 to 0..1; the
+// last value wins, mapped to the -1..1 a Tone.Panner expects. No CC10 => centre.
+export function panFromCC10(cc: ReadonlyArray<{ value: number }> | undefined): number {
+ if (cc && cc.length) return Math.max(-1, Math.min(1, cc[cc.length - 1].value * 2 - 1))
+ return 0
+}
diff --git a/frontend/src/soundfonts/synthVoices.ts b/frontend/src/soundfonts/synthVoices.ts
new file mode 100644
index 0000000..13cd4f6
--- /dev/null
+++ b/frontend/src/soundfonts/synthVoices.ts
@@ -0,0 +1,118 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+
+// Synth voice factories for live playback — each builds a Tone voice + its FX
+// nodes and registers every created node in the caller's `disposables` array so
+// the player's cleanup() disposes them. Split out of useMidiPlayer.ts (they sit
+// next to makeSynthKit in synthDrums.ts); the sampled voices stay in loader.ts.
+import * as Tone from 'tone'
+import { getMelodicBus, getBassBus } from './loader'
+
+// Melody lead — a dedicated, in-tune, articulate voice for the melodic LINE.
+// Replaces two bad melody voices: the harsh heavily-chorused sawtooth (which
+// wavered out of tune) and the pad (0.8 s attack, so fast melody notes never
+// spoke). Fast attack so every note reads, a tamed low-pass so it's not harsh,
+// and only a whisper of chorus so it stays in tune. `soft` warms it and adds
+// space for ambient/cinematic styles.
+export function makeMelodyLead(soft: boolean, disposables: Tone.ToneAudioNode[], output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
+ // Chorus + delay now live once on the shared melodic bus (see getMelodicBus).
+ const filter = new Tone.Filter({ frequency: soft ? 3000 : 3800, type: 'lowpass', rolloff: -12, Q: 0.8 }).connect(output)
+ const synth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: soft ? { type: 'triangle' } : { type: 'sawtooth' },
+ envelope: soft
+ ? { attack: 0.03, decay: 0.2, sustain: 0.75, release: 0.8 }
+ : { attack: 0.008, decay: 0.15, sustain: 0.65, release: 0.3 },
+ volume: soft ? -9 : -10,
+ }).connect(filter)
+ disposables.push(filter, synth)
+ return synth
+}
+
+// Synth comp: detuned/warm saw stack, slower attack, rolled-off highs.
+// Deliberately darker than makeSynthLead so CHORDS don't collide with the melody
+// timbre on electronic styles (previously both used the same sawtooth lead).
+export function makeSynthChords(disposables: Tone.ToneAudioNode[], output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
+ const lp = new Tone.Filter({ frequency: 2600, type: 'lowpass', rolloff: -12 }).connect(output)
+ const synth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'fatsawtooth', count: 3, spread: 22 },
+ envelope: { attack: 0.06, decay: 0.25, sustain: 0.65, release: 0.6 },
+ volume: -15,
+ }).connect(lp)
+ disposables.push(lp, synth)
+ return synth
+}
+
+// Arp pluck: short, bright, decaying voice with a synced delay tail. Gives the
+// arpeggio part its own identity instead of doubling the chord/lead timbre.
+export function makeArpPluck(disposables: Tone.ToneAudioNode[], output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
+ const lp = new Tone.Filter({ frequency: 4200, type: 'lowpass', rolloff: -12 }).connect(output)
+ const synth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.004, decay: 0.18, sustain: 0.0, release: 0.25 },
+ volume: -12,
+ }).connect(lp)
+ disposables.push(lp, synth)
+ return synth
+}
+
+// Pad: slow-attack triangle + long feedback delay (ambient, cinematic, etc.)
+export function makePad(disposables: Tone.ToneAudioNode[], output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
+ const synth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.8, decay: 0.3, sustain: 0.7, release: 2.0 },
+ volume: -10,
+ }).connect(output)
+ disposables.push(synth)
+ return synth
+}
+
+// Strings ensemble: soft detuned-saw stack for the counter-melody part —
+// articulate enough to read as a line, slow and dark enough to sit behind
+// the lead instead of competing with it.
+export function makeStrings(disposables: Tone.ToneAudioNode[], output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
+ const lp = new Tone.Filter({ frequency: 2400, type: 'lowpass', rolloff: -12 }).connect(output)
+ const synth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'fatsawtooth', count: 3, spread: 14 },
+ envelope: { attack: 0.12, decay: 0.3, sustain: 0.8, release: 1.2 },
+ volume: -14,
+ }).connect(lp)
+ disposables.push(lp, synth)
+ return synth
+}
+
+// Synth bass: sawtooth MonoSynth with portamento — house/techno/dnb etc.
+export function makeSynthBass(disposables: Tone.ToneAudioNode[]): Tone.MonoSynth {
+ const comp = getBassBus()
+ const bass = new Tone.MonoSynth({
+ oscillator: { type: 'sawtooth' },
+ filter: { Q: 2.5, type: 'lowpass', rolloff: -24 },
+ envelope: { attack: 0.01, decay: 0.08, sustain: 0.9, release: 0.3 },
+ filterEnvelope: { attack: 0.04, decay: 0.2, sustain: 0.5, release: 0.3, baseFrequency: 180, octaves: 2.6 },
+ portamento: 0.035,
+ volume: -3,
+ }).connect(comp)
+ disposables.push(bass)
+ return bass
+}
+
+// Lo-fi synth: warm triangle → bitcrusher → lowpass → vibrato → compressor
+export function makeLofiSynth(disposables: Tone.ToneAudioNode[], output: Tone.ToneAudioNode = getMelodicBus()): Tone.PolySynth {
+ const vibrato = new Tone.Vibrato({ frequency: 2.5, depth: 0.04, wet: 1 }).connect(output)
+ const lp = new Tone.Filter({ frequency: 5500, type: 'lowpass' }).connect(vibrato)
+ const crusher = new Tone.BitCrusher({ bits: 10 }).connect(lp)
+ const synth = new Tone.PolySynth(Tone.Synth, {
+ oscillator: { type: 'triangle' },
+ envelope: { attack: 0.04, decay: 0.2, sustain: 0.6, release: 1.2 },
+ volume: -4,
+ }).connect(crusher)
+ disposables.push(vibrato, lp, crusher, synth)
+ return synth
+}
diff --git a/frontend/src/utils/wavEncoder.test.ts b/frontend/src/utils/wavEncoder.test.ts
new file mode 100644
index 0000000..93481d7
--- /dev/null
+++ b/frontend/src/utils/wavEncoder.test.ts
@@ -0,0 +1,81 @@
+/*
+ * GenreGrid — a style-based MIDI generator.
+ * Copyright (C) 2026 Tw Dover
+ *
+ * This program is free software: you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option) any later
+ * version. Distributed WITHOUT ANY WARRANTY. See the GNU General Public License
+ * for details.
+ */
+import { describe, it, expect } from 'vitest'
+import { encodeWav } from './wavEncoder'
+
+// Minimal AudioBuffer stand-in — encodeWav only reads these four members, and
+// jsdom has no real Web Audio, so a fake exercises the pure encoding maths.
+function fakeBuffer(channels: number[][], sampleRate = 44100): AudioBuffer {
+ return {
+ numberOfChannels: channels.length,
+ sampleRate,
+ length: channels[0].length,
+ getChannelData: (c: number) => Float32Array.from(channels[c]),
+ } as unknown as AudioBuffer
+}
+
+async function bytesOf(blob: Blob): Promise {
+ return new DataView(await blob.arrayBuffer())
+}
+
+function ascii(v: DataView, off: number, len: number): string {
+ let s = ''
+ for (let i = 0; i < len; i++) s += String.fromCharCode(v.getUint8(off + i))
+ return s
+}
+
+describe('encodeWav', () => {
+ it('writes a correct 44-byte PCM header for stereo 44.1k', async () => {
+ const blob = encodeWav(fakeBuffer([[0, 0, 0], [0, 0, 0]], 44100))
+ expect(blob.type).toBe('audio/wav')
+ const v = await bytesOf(blob)
+ const dataLen = 3 /*frames*/ * 2 /*ch*/ * 2 /*bytes*/
+ expect(ascii(v, 0, 4)).toBe('RIFF')
+ expect(v.getUint32(4, true)).toBe(36 + dataLen) // chunk size
+ expect(ascii(v, 8, 4)).toBe('WAVE')
+ expect(ascii(v, 12, 4)).toBe('fmt ')
+ expect(v.getUint32(16, true)).toBe(16) // fmt size
+ expect(v.getUint16(20, true)).toBe(1) // PCM
+ expect(v.getUint16(22, true)).toBe(2) // channels
+ expect(v.getUint32(24, true)).toBe(44100) // sample rate
+ expect(v.getUint32(28, true)).toBe(44100 * 2 * 2) // byte rate
+ expect(v.getUint16(32, true)).toBe(2 * 2) // block align
+ expect(v.getUint16(34, true)).toBe(16) // bits/sample
+ expect(ascii(v, 36, 4)).toBe('data')
+ expect(v.getUint32(40, true)).toBe(dataLen)
+ expect(v.byteLength).toBe(44 + dataLen)
+ })
+
+ it('interleaves stereo frames L,R,L,R and scales to int16', async () => {
+ // L = [1.0, 0], R = [-1.0, 0]. Full-scale +1 -> 32767, -1 -> -32768.
+ const v = await bytesOf(encodeWav(fakeBuffer([[1, 0], [-1, 0]])))
+ expect(v.getInt16(44, true)).toBe(32767) // frame0 L
+ expect(v.getInt16(46, true)).toBe(-32768) // frame0 R
+ expect(v.getInt16(48, true)).toBe(0) // frame1 L
+ expect(v.getInt16(50, true)).toBe(0) // frame1 R
+ })
+
+ it('clamps out-of-range samples to the int16 rails', async () => {
+ const v = await bytesOf(encodeWav(fakeBuffer([[2.5, -2.5]]))) // mono, over-range
+ expect(v.getUint16(22, true)).toBe(1) // 1 channel in header
+ expect(v.getInt16(44, true)).toBe(32767) // +2.5 clamps to +1 -> max
+ expect(v.getInt16(46, true)).toBe(-32768) // -2.5 clamps to -1 -> min
+ })
+
+ it('encodes a mono buffer with the mono byte rate / block align', async () => {
+ const v = await bytesOf(encodeWav(fakeBuffer([[0.5, -0.5, 0.25]], 22050)))
+ expect(v.getUint16(22, true)).toBe(1) // channels
+ expect(v.getUint32(24, true)).toBe(22050) // sample rate
+ expect(v.getUint32(28, true)).toBe(22050 * 1 * 2) // byte rate
+ expect(v.getUint16(32, true)).toBe(1 * 2) // block align
+ expect(v.getUint32(40, true)).toBe(3 * 1 * 2) // data length
+ })
+})