Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)._

---

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down
41 changes: 40 additions & 1 deletion frontend/src/composables/offlineMix.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* <https://www.gnu.org/licenses/> 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.)
Expand Down Expand Up @@ -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)
})
})
37 changes: 37 additions & 0 deletions frontend/src/composables/playerConstants.ts
Original file line number Diff line number Diff line change
@@ -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
* <https://www.gnu.org/licenses/> 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<number, PlayerPart> = {
0: 'chords', 1: 'bass', 2: 'melody', 3: 'arpeggio', 4: 'pads', 5: 'counter_melody', 9: 'drums',
}
80 changes: 80 additions & 0 deletions frontend/src/composables/scheduler.test.ts
Original file line number Diff line number Diff line change
@@ -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
* <https://www.gnu.org/licenses/> 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()
})
})
53 changes: 53 additions & 0 deletions frontend/src/composables/scheduler.ts
Original file line number Diff line number Diff line change
@@ -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
* <https://www.gnu.org/licenses/> 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)
}
}
Loading