From 2da4e1d9538b1eb9282073b693714eab12861b0c Mon Sep 17 00:00:00 2001 From: TwDover Date: Tue, 28 Jul 2026 17:49:33 -0400 Subject: [PATCH] Split useMidiPlayer.ts into focused modules + grow frontend tests --- docs/roadmap.md | 46 +- frontend/src/composables/offlineMix.test.ts | 41 +- frontend/src/composables/playerConstants.ts | 37 + frontend/src/composables/scheduler.test.ts | 80 ++ frontend/src/composables/scheduler.ts | 53 ++ frontend/src/composables/useMidiPlayer.ts | 712 ++---------------- frontend/src/composables/useOfflineRender.ts | 492 ++++++++++++ frontend/src/composables/voiceRouting.test.ts | 77 ++ frontend/src/composables/voiceRouting.ts | 65 ++ frontend/src/soundfonts/synthVoices.ts | 118 +++ frontend/src/utils/wavEncoder.test.ts | 81 ++ 11 files changed, 1128 insertions(+), 674 deletions(-) create mode 100644 frontend/src/composables/playerConstants.ts create mode 100644 frontend/src/composables/scheduler.test.ts create mode 100644 frontend/src/composables/scheduler.ts create mode 100644 frontend/src/composables/useOfflineRender.ts create mode 100644 frontend/src/composables/voiceRouting.test.ts create mode 100644 frontend/src/composables/voiceRouting.ts create mode 100644 frontend/src/soundfonts/synthVoices.ts create mode 100644 frontend/src/utils/wavEncoder.test.ts 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 + }) +})