Skip to content

Add the Harmony Lane: a per-deck modulation timeline that clocks the deck by segmenting generation into per-step chunks #52

Description

@brxs

Blocked by issue #46 and ADR-0023 + ADR-0026 — the MIDI note/drum foundation and lane-driven chunk segmentation (ADR-0023 merged via PR #45; ADR-0026 in PR #53); see the end.

Problem

A live deck today is a free-running stream: worker.py generates fixed 1 s chunks (FRAMES_PER_CHUNK=25, CHUNK_SECONDS=1.0backend/lsdj/engine.py:15-16) and engine.generate_chunk() calls self._system.generate(style=…, frames=FRAMES_PER_CHUNK, state=…) with style only (backend/lsdj/engine.py:146-156). There is no way to author how the music moves over time — to say "play I–V–vi–IV, change chord every two bars, modulate up a tone for the bridge, drop the drums for the breakdown." The note/drum conditioning axes that would express this are being wired by issue #46 / ADR-0023 (Proposed in PR #45), but ADR-0023 deliberately stops at the channel and leaves the scheduler out of scope: "A step-sequencer/harmony-lane feature would push on [chunk size] with a smaller chunk or a per-chunk note schedule; out of scope here." This issue is that feature.

The load-bearing constraint is grounded in the model: MRT2 holds notes/style/drums conditioning constant for an entire generate() callsystem.py builds the conditioning block once, then loops frames. So a chord change cannot happen mid-chunk; it requires a new chunk with the new conditioning set. The Harmony Lane therefore is not a synthesis feature — it is an authoring + scheduling feature: it segments generation into per-step chunks and sets the chord (and key offset, and drum state) for each step's chunk. The lane controls harmonic rhythm (when the chord turns), not the model's emergent beat (ADR-0004: tempo is not a parameter; ADR-0010: the beat is a read-out, not a driver).

Proposed approach

A per-deck modulation timeline: three parallel lanes over one shared, looping step grid, authored in a new ModulationTimeline panel in DeckColumn and persisted in DeckSettings (extend frontend/src/persistence.ts, component-local useState + updateDeckSettings, no deckState/socket change — same pattern as style targets).

The three lanes (v1):

  1. HARMONY — the only lane that emits notes. Each step holds a chord (absolute, e.g. Cmaj7) or a scale-degree (ii7, key-relative). At the step boundary the symbol is realized into MRT2's 128-pitch multihot (0 off, 1 sustain, 2 onset, 3 model-decides — the ADR-0023 alphabet) and sent via set_notes. An empty/rest step sends all-masked (notes=None) so the model is harmonically free. Default note mode is sustain (chord-follow) so the model phrases the change against its own groove rather than stabbing a hard onset every step; onset is an opt-in "restate" accent on the step's first chunk.
  2. KEY/TRANSPOSE — emits nothing of its own; a per-step semitone offset (and key center for degree resolution) that composes over HARMONY before realization: resolve degree in key → add transpose → voice. Degree-authored progressions transpose as a block; absolute chords shift chromatically.
  3. DRUMS/ENERGY — drives the single drums flag (per ADR-0023 it is a suppress/permit gate, not a density continuum). v1 is honestly a 2–3 state lane (masked / suppress / drive), labelled so it does not over-promise an "energy fader."

Chord → multihot — a pure module in frontend/src/deck/ next to padWeights.ts (vitest-tested, no model dependency): symbol → pitch classes → compose KEY/TRANSPOSE (mod-12 addition) → voice sparsely into a fixed register window (~MIDI 48–72, 3–5 sounding pitches; dense multihots over-constrain the model) → 128-int array. Octave-fold out-of-range pitches rather than clamp.

Lane-as-its-own-clock (not measured-grid): when a lane is armed it becomes the deck's chunk clock. The frontend resolves a forward schedule of {frames, notes:[128]|null, drums} steps ahead of the worker's horizon and pushes it as a new set_schedule control message; the worker pops one entry per loop, calls generate(frames=…, notes=…, drums=…), and advances pace_seconds by that step's actual seconds. We reject slaving the chord clock to getLiveBeat(): that estimate is 1 s-gated, two-miss-droppable, null-during-load, and would create a feedback loop (the harmony we inject changes the audio the beat tracker reads). The lane's tempo is authored intent, not a measurement. Step duration in beats is converted using the lane's own counted tempo; beat detection stays a passive playhead read-out (beat.ts is hop-aligned at 512 samples and chunk-size-agnostic, so variable chunks are safe — frontend/src/audio/beat.ts:121-140).

Required engine/worker changes (the real integration cost):

  • engine.generate_chunk() gains frames=, notes=, drums= params instead of hardcoding FRAMES_PER_CHUNK (engine.py:146-156); render_clip is a separate path and stays untouched.
  • worker.py gains a set_schedule/clear_schedule branch and a small ring of upcoming steps. Pacing must increment pace_seconds by the actual step duration, not the constant CHUNK_SECONDS (worker.py:199), and pre-fetch the next step's frame count before computing the drain-timeout (worker.py:56-61) to dodge the chicken-and-egg the fixed-chunk math creates. An empty schedule reverts to the free-running 1 s behaviour. chunk_index accounting that derives time must read cumulative frames, not iteration count.

The wire carries resolved conditioning, not chords. The frontend owns all music theory and the scheduler; the worker receives {frames, notes:[128], drums} and is a dumb applicator. This keeps ADR-0002 intact (only engine.py imports magenta_rt), keeps the chord mapper unit-testable, and matches ADR-0023's "the transport stays dumb."

Three authoring modes, one shared step-array data model:

  • Presets — shipped + user-saved named progressions stored as key-agnostic scale degrees (ii–V–I, I–V–vi–IV, 12-bar, modal vamps) plus drum-energy patterns; loaded like style presets (App.tsx).
  • Step editor — per-step cells reusing Select/Knob/Slider/Button/Panel, on a horizontal grid with a frame-driven playhead. Editable at runtime; edits re-push the schedule from the current step. Because of the 3 s TARGET_AHEAD cushion, edits land 1–3 steps later — shown as greyed "pending" steps, mirroring the existing effective_from_chunk honesty.
  • Generative auto-fill — a pure-frontend, on-device functional-harmony generator (weighted grammar/Markov over diatonic function, seeded by key + a tension/adventurousness knob), writing scale-degree cells the editor then exposes for edit. It is a starting point in the same data model, never a black-box runtime path. A learned/LLM harmony model is explicitly OUT of v1 (it would drag in the ai-feature-spec eval/guardrail bar for no v1 benefit). This is the heaviest of the three modes — fine to land last.

Acceptance criteria

  • A new per-deck ModulationTimeline panel renders three lanes (HARMONY, KEY/TRANSPOSE, DRUMS/ENERGY) over one looping step grid; lane state persists per-deck in DeckSettings and survives reload and model switch.
  • Arming a lane segments generation: step boundaries become chunk boundaries with variable frame counts, conditioning set per step. Audio stays continuous across the boundary (state threaded through unchanged; only notes/drums/style change — the spike-mrt2 prompt-change-on-boundary result).
  • Disarming / empty schedule reverts the deck to free-running 1 s chunks with no regression to the existing path or to render_clip.
  • HARMONY realizes a chord/degree to a sparse 128-multihot; KEY/TRANSPOSE composes as a semitone shift before voicing (degree progressions transpose as a block, absolute chords shift chromatically); rest steps mask all pitches. The chord→multihot module has unit tests that run without the model.
  • Validate harmony steering early (the load-bearing risk — see non-goals): the first build step is a thin HARMONY→set_notes path, listened to before any UI polish — does a sustained chord multihot audibly and correctly steer harmony, and at what minimum frames-per-chord does it stop tracking. Findings tune the voicing/register and the min-step floor.
  • DRUMS/ENERGY is labelled and behaves as suppress/permit (not a continuous energy fader), honest to the single drums flag.
  • All three authoring modes (presets, step editor, auto-fill) write the same step array; auto-fill output is always editable, never applied blind, and stays in key by construction.
  • Live edits show a visible "pending" state until they clear the ~3 s schedule horizon (mirrors effective_from_chunk).
  • A tunable minimum step size is enforced (≈0.5 s on mrt2_small, validated per model against the RTF margin) with a warning below it, so short steps don't starve the stream (RTF < 1×) or exhaust the latency cushion.
  • Beat detection and PCM transport are unaffected by variable chunk sizes (regression check against beat.ts hop alignment).
  • Lane has a defined fallback when the frontend beat clock is null/stale (free-run on wall-clock step interval) and resets on stream discontinuity (play/prime/stop/model-switch/worker-crash), matching ADR-0009/0010/0023.
  • All new panel strings go through i18n keys (rules/localisation.md); new frontend modules match house style (single quotes, no semicolons) and type-check via npx tsc -p tsconfig.app.json --noEmit.
  • Honesty: no UI copy promises "chord change on the downbeat." The playhead is frame-driven, step durations are continuous (not beat-quantized cells), and copy frames the feature as "You conduct chord changes and energy; the model plays the beat."

Scope & non-goals

In scope (v1): the three lanes; the three authoring modes; lane-driven per-step chunk segmentation; the frontend chord→multihot mapper and scheduler; the engine/worker variable-frame + set_schedule changes; single-deck lanes.

Non-goals / explicit caveats:

  • The lane clocks harmonic rhythm, NOT the model's beat. Because MRT2's tempo is emergent and unsteerable (ADR-0004: injected pulse trains don't move it), a chord change lands on-time by the lane's clock but floats against the model's actual groove — sometimes on the downbeat, sometimes not. "Quantize chord changes to the model's downbeat" is impossible in v1 and must not be promised. Mitigation is designed in: non-quantized continuous durations, frame-driven playhead, sustain-by-default so the model phrases the change.
  • notes conditioning efficacy is unmeasured, and we are proceeding without a spike gate (accepted risk). set_notes has never reached generate(), and ADR-0004 is a cautionary precedent where timing conditioning steered weakly. The risk is managed by building the HARMONY→set_notes path first and listening (see acceptance criteria) rather than a formal spike, so the unknown surfaces on day one rather than after the UI is built. If harmony steering proves too weak, DRUMS/ENERGY still stands on the documented drums flag.
  • Style-morph lane is OUT of v1.
  • No learned/LLM auto-fill in v1 (rule/grammar generator only).
  • No inter-deck harmonic auto-mix (key-following the other deck) — that is issue Harmonic auto-mix: steer the generative deck to the playing deck's key #51 / ADR-0024 (key detection, Proposed in PR ADRs: MIDI note/drum conditioning, and beat+key audio analysis in the Rust shell #45); v1 lanes are single-deck.
  • notes has no intra-chunk timing — this is a harmonic prior / harmonic-rhythm lane, not a piano-roll or melodic step sequencer.

Dependencies & ADR

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions