Skip to content

Add --force-program flag to pin decoded notes onto one instrument - #178

Closed
mrkkucharski wants to merge 51 commits into
magenta:mainfrom
mrkkucharski:feature/force-program-transcribe
Closed

Add --force-program flag to pin decoded notes onto one instrument#178
mrkkucharski wants to merge 51 commits into
magenta:mainfrom
mrkkucharski:feature/force-program-transcribe

Conversation

@mrkkucharski

Copy link
Copy Markdown

Summary

  • NoteDecodingState gets a force_program field: when set, it pins current_program from init and decode_note_event ignores program tokens instead of updating state from them (still consumed, so shift/timing stays in sync). Propagated into the lookback shadow state too.
  • Transcriber(force_program=...) builds an encoding spec that pins this on every fresh decoding state; range-validated to [0, 127] before any checkpoint is touched. TranscriptionResult now carries force_program alongside the other run-config fields it already tracks.
  • CLI: --force-program [0-127], threaded through and echoed in the human-readable summary output.

Useful for single-instrument input where the model's own program-change predictions are unreliable/irrelevant — as a side effect it also merges note fragments that would otherwise split across differing (wrong) program guesses across a note's onset/offset, since active notes are keyed by (pitch, current_program, current_rhythm).

Test plan

  • note_sequences_test.py: new decode-level test showing a note whose onset/offset carry mismatched program tokens closes correctly and lands on the forced program.
  • transcription_test.py: force_program range validation raises before checkpoint load; _build_encoding_spec unit-tested via a bare namespace stand-in for self.
  • cli_test.py: flag parsing, range rejection, and pass-through into Transcriber kwargs.
  • Full suite: 153 passed, 20 skipped.

🤖 Generated with Claude Code

mrkkucharski and others added 30 commits August 2, 2026 21:10
download_model()'s destination path stripped the GCS object prefix
(checkpoints/mt3/) and wrote straight into output_dir, but then unconditionally
checked for output_dir/checkpoint_0 and raised RuntimeError -- the bucket's own
objects have no checkpoint_0 prefix, so that check could never pass. Write
each file under checkpoint_dir instead, so the on-disk layout actually matches
every documented --checkpoint .../checkpoint_0 invocation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds the `rhythm` event type to the MT3 codec (vocabularies.py), threads it
through note_sequences.py's encode/decode state machine, and solves the
carrier problem -- NoteSequence.Note has no field for a role flag -- by
grouping assign_instruments by (program, rhythm) and writing the canonical
name into instrument_infos, which note_seq's own MIDI writer already keys
track names on.

Registers guitar_pilot_notes_ties against the current data/pilot corpus via
a new converter (build_guitar_pilot_tfrecord.py) that reads MIDI directly
with note_seq's standard reader and cross-checks it against the manifest.
Makes metrics.py's F1 scoring (program, rhythm)-aware, consistent per
granularity (flat collapses rhythm, midi_class/full retain it). Adds 6
round-trip tests covering every property in the Phase 0 plan.

Runs both Phase 0 gate checks: the official 46M-param checkpoint restores
into the extended codec with no vocab shape error and completes one real
forward/backward/save/resume step (guitar_pilot_finetune_local.gin). The
tiny-overfit check (guitar_pilot_gate_local.gin) climbed to F1=0.5 over
50k steps, short of the plan's 0.95 target -- a bounded exposure-bias gap,
not a broken pipeline.

Adds pyfluidsynth as a dependency: MT3's eval metrics hard-require it for
audio summaries and fail outright without it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
modal_train.py + guitar_pilot_finetune_modal.gin: runs the pretrained,
full-size guitar_pilot fine-tune on a CUDA GPU against volumes mounted
at /workspace/{data,model,runs}, with train_steps as a per-invocation
relative step count (t5x.train's relative_steps overrides TRAIN_STEPS
unconditionally whenever set, so it can't be hardcoded).

model_download.rebuild_checkpoint_0_view: rebuilds the legacy checkpoint_0
symlink view from a model volume's flat files after mounting, since a
volume upload doesn't reliably carry relative symlinks across.

Transcriber._restore: switched off from_checkpoint_or_scratch, which only
reads the non-Orbax legacy layout, to create_checkpoint_manager_and_restore
with use_orbax=True -- the same path t5x.train's own resume uses, and the
only one that can read a checkpoint this fork's own training produces
(Orbax-native/OCDBT), not just the officially released one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…le's checkpoint

model_download.download_model now pulls the pinned checkpoint_1002000 step
from mrkkucharski/mt3-guitar-pilot (public) at a fixed revision, via
huggingface_hub.snapshot_download, and materializes it locally as
checkpoint_0 -- the same on-disk contract every documented
`--checkpoint .../checkpoint_0` invocation already expects. Replaces the
GCS-based official-multitrack-v1 downloader entirely (no more Google
dependency); rebuild_checkpoint_0_view is untouched since Modal training
still needs it for the legacy pretrained layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
validate/convert/train/resume already existed as single commands
(reaper2mt3 check, build_guitar_pilot_tfrecord, modal run modal_train.py --
which is both train and resume, since it always resumes from whatever
checkpoint sits in the model volume). evaluate and export did not: every
fine-tune leg so far scored a checkpoint by transcribing and eyeballing note
counts, and exported a reviewable REAPER project by running mt3-transcribe
then midi2reaper build by hand.

evaluate_checkpoint.py (mt3-evaluate) transcribes a dataset split with the
real Transcriber and scores it against ground-truth corpus MIDI using the
same (program, rhythm)-aware onset+offset F1 run_phase0_gate_eval.py already
uses for the Phase 0 tiny-model gate.

export_transcription.py (mt3-export) fuses mt3-transcribe and
midi2reaper build into one command, bridging the two tools' separate
uv-managed environments via subprocess (mirroring modal_train.py's own
approach to t5x.train). Guards a real footgun: midi2reaper build exits 0
even when it writes nothing (REJECT or EXISTS-without---force), so this
verifies the .RPP actually landed rather than trusting the exit code.

Both registered as console scripts in pyproject.toml. Documented all six
Phase 1 entry points together for the first time in README.md, plus the two
new commands in mt3/README.md, and fixed a stale doc line describing the
split-reassignment bug the prior commit's splits.json fix already closed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
train.gin hardcodes eval_period=5000, and t5x.train requires the checkpoint
period, eval period, and GC period (0 here) to all be multiples of each
other. Any save_period that does not divide 5000 -- 2000, for instance --
therefore fails fast with "Checkpoint period (N), eval period (5000), and GC
period (0) must all be multiples of each other." Passing eval_period through
alongside the save period keeps the two trivially compatible for any value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds gin/context_4s.gin, a thin overlay rebinding TASK_FEATURE_LENGTHS to
{'inputs': 512, 'targets': 1024}, to be loaded after an existing
guitar_pilot config. Kept as an overlay rather than an edit to the finetune
gin so a 2 s-vs-4 s ablation differs in exactly one causal factor; the 2 s
path is byte-identical.

The window is expressed only in frames (16 kHz / hop 128 = 125 fps, so 512
frames = 4.096 s), and both chunking paths in tasks.py -- select_random_chunk
for training and split_tokens_to_inputs_length for eval -- read
sequence_length['inputs'], so the rebinding reaches both with no other edits.

checkpoint_0 restores without positional surgery: t5x's T5Config has no
sequence-length-dependent field and network.py uses RelativePositionBiases
(a [num_buckets, num_heads] table read at the live input length), not
absolute position embeddings. The behavioural caveat is separate --
max_distance=128 saturates well inside a 512-frame window, so the weights
load but the model has not learned to use the extra context.

targets is deliberately left at 1024 rather than guessed upward. tasks.py
ends the training pipeline with handle_too_long(skip=skip_too_long) and
skip_too_long defaults to False, so an undersized targets RAISES rather than
dropping the example -- a dead paid GPU job. measure_target_lengths.py sizes
it beforehand by running the real pipeline with a deliberately high probe
targets and histogramming the true lengths.

Transcriber and mt3-transcribe gain input_length/--input-length so a 4 s
checkpoint can actually be run at inference; defaults are unchanged, and all
existing call sites pass checkpoint_path positionally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
measure_target_lengths.py on guitar_pilot_notes_ties_vb1_train shows targets
must grow with the window. At 512 frames one pass produced a maximum of 1533
tokens and overflowed the 1024 budget on 2 of 500 windows (0.4%); at 256
frames the same corpus peaks at 608-911 and fits.

That 0.4% is not a quality tax, it is a crash. tasks.py ends the training
pipeline with handle_too_long(skip=skip_too_long), skip_too_long defaults to
False, and that branch raises rather than filtering -- so the overflow lands
as a dead job at an unpredictable step on a paid GPU.

2048 clears the highest observed length by ~33%. It is explicitly not a proven
bound: select_random_chunk draws different chunks each epoch, and two
full-epoch passes returned maxima of 1249 and 1533, so no single pass bounds
the tail. skip_too_long=True was rejected as the alternative because it trades
the crash for silent bias -- the dropped windows are the densest passages.

Note the cost, since it compounds with the window change: seqio pads every
feature to its task_feature_length for XLA's static shapes, so the decoder
computes at the full 2048 every step against a median content length of 187.
1024 -> 2048 quadruples decoder self-attention on top of the ~4x encoder
attention from 256 -> 512.

Also corrects the task name in the docs to the registered
guitar_pilot_notes_ties_vb1_train.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Restore and forward pass at 512/2048 verified by running them:
Transcriber(checkpoint_0, input_length=512, target_length=2048) restores with
no shape error and transcribes end to end, and the seqio task pipeline
materialises a full 1040-window epoch at 512. The checkpoint-compatibility
claim is now empirical rather than read off the source.

A local training step could not be tested. python -m t5x.train dies in
prepare_train_iter with "Can't find an output tensor for the output node:
identity_RetVal [Op:MakeIterator]" -- but the identical command WITHOUT
context_4s.gin fails in exactly the same place, so it is the known
Apple-Silicon TensorFlow input-pipeline fault and not the window change. The
task pipeline itself is fine; only t5x's clu iterator wrapper breaks. The
first real 512 step has to happen on Modal.

Warm inference timing over 30 s of audio, M4 CPU: 21.5 s at 256 (1.40x
realtime, 1097 notes) versus 20.3 s at 512 (1.48x realtime, 460 notes).
Whole-file throughput is roughly flat because halving the window count offsets
the higher per-window cost -- but the 4 s figure flatters itself, since the
un-adapted checkpoint predicts 460 notes instead of 1097 and the
autoregressive decoder therefore runs far fewer steps. Re-measure after
adaptation. Training gets no such reprieve: targets pad to the full 2048 every
step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… dir

_t5x_train_command gains context_4s, which appends gin/context_4s.gin after
guitar_pilot_finetune_modal.gin (gin is last-write-wins, so the overlay only
overrides 256/1024 from that position) and simultaneously redirects MODEL_DIR
to guitar_pilot_finetune_64ex_it2_4s.

Redirecting the directory is not a convenience. The 2 s MODEL_DIR is baked
into the finetune gin, so without this a 4 s run would drop checkpoints into
the 2 s run's directory and interleave two incompatible window lengths in one
step sequence. Tying the redirect to the same flag that changes the window
makes that collision unreachable rather than merely documented; --model-dir
overrides the destination but cannot restore the 2 s default while
--context-4s is set.

The name follows the 2 s run's tag with a _4s suffix so the pair reads as one
experiment. Both legs restore from checkpoint_0, so _4s denotes the window,
not a continuation of it2.

Verified: with context_4s the overlay is last in the gin chain and MODEL_DIR
points at the _4s tag; without it the argv is byte-identical to before, with
no MODEL_DIR binding at all.

Preflight on an A10G passed: restore in 3.3 s, compile in 59 s, one optimizer
step and a checkpoint save at 512/2048 with no OOM at BATCH_SIZE=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Head-crop overlap (MT3_HEADCROP_OVERLAP_PLAN.md, Phase 1): a strided
drop-in replacement for t5.data.preprocessors.split_tokens. At
hop_tokens == window_tokens it is exactly equivalent to the existing
non-overlapping split. At hop_tokens < window_tokens, windows overlap
and the trailing (window - hop) frames of each window become lookahead
context for the encoder; the existing max_decode_time cropping in
metrics_utils.decode_and_combine_predictions already discards that
context from the decoded output, so no downstream decode-path change
is needed for this half of the plan.

Window count is ceil(n / hop_tokens), independent of window_tokens --
verified against tf.signal.frame's own pad_end=True frame count, which
depends only on frame_step. Each window is built via tf.signal.frame
and then stripped back to its true per-window content length (mirrors
t5.split_tokens' handling of its final, possibly-short segment) so a
downstream feature converter pads it identically to today's baseline.

Verified via ad hoc script (not checked into the repo) against 100+
(n, window, hop) configurations: exact coverage tiling of [0, n) with
no gaps or overlaps, correct per-window content and orig_lengths, and
byte-for-byte equivalence to a manual non-overlapping reference at
hop == window. Existing test suite (71 passed, 4 skipped) is
unaffected.
Head-crop overlap (MT3_HEADCROP_OVERLAP_PLAN.md, Phase 2). Transcriber
gains a lookahead_frames constructor argument (default 0, reproducing
the baseline exactly); it derives hop_frames = input_length -
lookahead_frames and swaps the old t5.data.preprocessors
.split_tokens_to_inputs_length call in _dataset() for the new
preprocessors.split_tokens_strided from Phase 1, reading the window
size from self.sequence_length['inputs'] rather than a constant so it
stays correct at --input-length 512 on the 4 s branch.

transcribe() keeps its existing NoteSequence-only return type --
evaluate_checkpoint.py and run_phase0_gate_eval.py call it directly and
must not break. Diagnostics (est_invalid_events, est_dropped_events)
and resolved geometry (window_frames, hop_frames, lookahead_seconds,
cost_multiplier) are instead threaded through a new
_transcribe_with_diagnostics() helper into TranscriptionResult, whose
new fields default to values that leave export_transcription_test.py's
_FakeTranscriber construction unaffected.

cli.py adds --lookahead-frames and a mutually exclusive
--lookahead-seconds convenience flag (converted at the fixed 125
frames/s the spectrogram front end uses).

Verified end-to-end against the real checkpoint at
model/mt3/checkpoint_0 on a 6.5 s clip:
- lookahead_frames=0 reproduces the pre-change code path exactly --
  output MIDI files are byte-identical (same MD5) between this branch
  and the original t5.split_tokens-based implementation (stashed and
  re-run for comparison).
- lookahead_frames=128 (50% overlap at window 256) runs end-to-end and
  reports the expected geometry (hop_frames=128, cost_multiplier=2.0,
  lookahead_seconds=1.024).
- --lookahead-seconds 1.024 produces an identical result to
  --lookahead-frames 128.
- lookahead_frames >= input_length raises ValueError from both the
  direct constructor call and through cli.main().
Full test suite: 86 passed, 4 skipped (unchanged from before this
commit).
Formalizes the ad hoc verification from the Phase 1/2 commits into a
checked-in suite: exact equivalence to a non-overlapping reference at
hop==window (the property Transcriber(lookahead_frames=0) depends on),
coverage tiling with no gaps or overlaps across 18 (n, window, hop)
geometries including window > n (every window short, not just the
last) and hop not dividing window evenly, per-window content/length
correctness, passthrough replication, and the two error paths.

7 passed, 1 skipped (tf.test.TestCase's own harness test).
…strided

Wires lookahead_frames through Transcriber and the CLI for head-crop
overlap inference. Smoke-tested against the it2/40k checkpoint; overlap
did not show a clear quality improvement in informal checks, but the
capability is now available behind lookahead_frames for future use
and a proper F1-based ablation.
…description

Introduces the value object the lookback feature's later plumbing will
build on. lookback_frames=0 reproduces today's lookahead-only geometry
exactly (the compatibility anchor for the whole feature). Transcriber
accepts a lookback_frames keyword but raises NotImplementedError for
non-zero values until the segmentation and decode paths are wired up.

Part of the lookback feature (issue #1 of #1-#8).
Lookback: introduce a validated (lookback | keep | lookahead) window geometry
Adds _windowed_input_dataset(): left-pads audio by geometry.lookback_frames
before framing so window 0 gets real silence rather than reused audio,
keeps input_times in original-audio time throughout (the pad occupies
negative timestamps rather than shifting the whole timeline), and drops
the surplus trailing windows that left-padding would otherwise add (windows
whose kept region starts at or past the end of the real audio).

lookback_frames=0 is byte-identical to the pre-existing plain
split_tokens_strided call -- the compatibility anchor this whole feature
leans on. Transcriber._dataset now delegates to this helper; Transcriber
itself still rejects non-zero lookback_frames until the decode side is
wired up.

Also broadens split_tokens_strided's docstring, which described only the
trailing-overlap (lookahead) case; it now says overlap can lead, trail, or
split across both ends, since the function itself has no opinion on that,
only its callers do.

Part of the lookback feature (issue #2 of #1-#8).
Adds min_time alongside the existing max_time, so a segment can crop both
ends of its decoded output: events before min_time are suppressed (still
run through decode_event_fn so sticky run-length state like
program/velocity/rhythm carries over correctly, but withheld from
state-mutating note effects via a state.suppress flag decode_events sets
per event) rather than skipped outright, and events land in the half-open
[min_time, max_time) interval rather than double-committing on a shared
boundary.

Also fixes a latent bug this surfaced: `if max_time and cur_time >
max_time` treats max_time=0.0 as "no bound" due to Python truthiness,
silently keeping events that should have been dropped. Now
`max_time is not None`.

decode_events now returns a 3-tuple (invalid, dropped, suppressed) instead
of 2; updates every direct caller (metrics_utils.py's
decode_and_combine_predictions, and the note_sequences_test.py /
note_sequences_rhythm_test.py call sites) to match. No caller passes
min_time yet, so suppressed_events is always 0 today and behavior is
unchanged end to end -- decode_and_combine_predictions doesn't expose the
new counter externally yet either.

Part of the lookback feature (issue #3 of #1-#8).
…type hint

decode_events counted an event as both invalid_events and suppressed_events
when it fell before min_time and also raised ValueError from
decode_event_fn -- suppressed_events now only increments after a
successful decode, since an event that was never validly decoded isn't
meaningfully "withheld by suppression".

Also updates decode_tokens_fn's type annotation and docstring in
metrics_utils.decode_and_combine_predictions, which still said
Tuple[int, int] after decode_events grew a third return value.
Transcriber.__init__'s NotImplementedError guard comment said _dataset()
doesn't honor a left-shifted window, but this PR makes it do exactly
that via _windowed_input_dataset(). The real remaining gap is on the
decode side (metrics_utils.decode_and_combine_predictions crops using
only start_time, with no per-segment kept-region start yet).
The if/elif pair differed only in >= vs > and their max_time is not None
guard, duplicated in a way a future edit could update one branch without
the other. Minor cleanup from PR review; no behavior change (both
branches were already individually correct and tested).
Lookback: shift encoder windows left and pad the start of the audio
Lookback: add a min_time head crop to decode_events
decode_note_event now checks state.suppress (set per-event by
run_length_encoding.decode_events when min_time is given): a suppressed
pitch/drum event returns immediately without touching active_pitches or
note_sequence -- the previous window's kept region already committed it
(or its closure) -- and a suppressed tie terminator clears is_tie_section
and returns without ending any "untied" active notes.

This matters because the tie section describes what's sounding at the
WINDOW's first frame, lookback_frames before min_time -- not at the kept
region's start, which is what active_pitches (carried over from the
previous window's kept region ending exactly there) already reflects
correctly. Left unhandled, this causes two failure modes: every held note
gets chopped at the window boundary (the tie terminator ending it, since
it wasn't declared tied at the wrong instant), and a tie section entry
for an already-closed pitch raises "inactive pitch/program/rhythm in tie
section". Sticky state (program/velocity/rhythm) still applies while
suppressed, since decode_events routes suppressed events through
decode_event_fn -- only note-emitting effects are withheld.

Also adds a diagnostic: flush_note_decoding_state now logs a warning when
force-closing a note held open longer than 30s, since discarding the tie
section means a note's offset relies entirely on some later window
actually decoding its note-off -- if none ever does, the note silently
turns into one absurdly long note instead of visibly failing.

lookback_frames=0 (min_time always None) is untouched: state.suppress
starts False and decode_events never sets it, so every branch above takes
its pre-existing path. Confirmed by the full existing note_sequences_test,
note_sequences_rhythm_test, and metrics_utils_test suites passing
unmodified, including the tie-across-boundary regression test.

Part of the lookback feature (issue #4 of #1-#8).
Predictions may now optionally include 'min_decode_time' (this segment's
own kept-region start, for a lookback-overlap caller) and, on the last
segment only, 'max_decode_time' (an explicit cap, e.g. real audio
duration, so a lookahead tail can't emit events into padding-only silence
past the end of the file). Both are entirely optional and default to
today's behavior: max_time for segment k is still derived from segment
k+1's start_time when no min_decode_time is present, min_time defaults to
None, and the last segment stays unbounded absent an explicit
max_decode_time.

decode_and_combine_predictions now returns a 4-tuple (adding
total_suppressed_events, summed from decode_tokens_fn's third return
value) instead of 3, and validates that predictions carrying
min_decode_time agree with the start_time-based sort order it already
uses -- the crop logic assumes segment k+1's min_decode_time is where
segment k's kept region should end. event_predictions_to_ns surfaces the
new total as 'est_suppressed_events'; metrics.py's per-example score
table gains a matching 'Suppressed events' column.

inference.py and scripts/run_phase0_gate_eval.py need no changes: neither
unpacks decode_and_combine_predictions directly, only reads
event_predictions_to_ns's dict, and an added key doesn't break that.

Part of the lookback feature (issue #5 of #1-#8).
… value

next_pred.get('min_decode_time', next_pred['start_time']) only falls back
to start_time when the key is absent -- a caller that always includes
'min_decode_time' but sets it to None for a non-lookback segment left the
preceding segment's decode window unbounded, silently overlapping the
next segment's kept region.
mrkkucharski and others added 21 commits August 14, 2026 11:17
…ogic

run_length_encoding.decode_events now unconditionally assigns
state.suppress = min_time is not None and cur_time < min_time (rather than
only assigning it inside `if min_time is not None`), so a state reused
across a min_time=X call followed by a min_time=None call can no longer
carry a stale True forward and silently suppress every event in the
second call.

decode_note_onset_event (NoteOnsetEncodingSpec) gains the same suppress
guard as decode_note_event: it has no active_pitches/tie-section state to
naturally dedupe against, so without this it would unconditionally
duplicate any onset re-declared in a window's suppressed prefix.

decode_note_event's three near-identical `if state.suppress: return`
guards (pitch, drum, and part of tie) collapse into one dispatch-level
check for pitch/drum; tie keeps its own handling since it also needs to
clear is_tie_section.

Not changed, as accepted consequences of the suppressed-prefix design
(each already covered by an existing test or comment, and reversing them
would reintroduce the wrong-instant validation this feature exists to
avoid): the monotonicity check and invariant checks (inactive-pitch
note-off, already-tied, zero-velocity drum, duplicate tie-end) are
intentionally skipped for suppressed events, since validating them against
active_pitches/current_time as of the WINDOW's first frame -- not the kept
region's start -- is exactly the false-positive source this feature
exists to eliminate.
Lookback: reconcile the tie section, which describes the window start rather than the kept region
Lookback: carry per-segment crop bounds through decode_and_combine_predictions
Removes the NotImplementedError guard from #1: Transcriber now honors a
non-zero lookback_frames end to end. Each prediction gets a
'min_decode_time' (its own kept-region start, quantized onto the same
codec step grid as 'start_time' so a boundary event can't fall through the
crack between two windows' crop bounds), and the chronologically-last
prediction gets an explicit 'max_decode_time' capped at the real audio
duration, so a lookahead tail can't decode events from the padding-only
silence past the end of the file -- this applies regardless of geometry,
not just when lookback is in use.

Extracts the crop-bound math (_quantize, _min_decode_time,
_cap_last_segment_tail) as pure module-level functions, directly
unit-testable without a checkpoint, mirroring how _windowed_input_dataset
was already split out in the windowing PR.

TranscriptionResult gains est_suppressed_events, keep_frames, and
lookback_seconds, alongside the existing hop_frames/lookahead_seconds/
cost_multiplier -- an ablation run's full geometry now travels with its
output. The Transcriber docstring describes the three-part
[lookback | keep | lookahead] window instead of only the lookahead half.

lookback_frames=0 remains the compatibility anchor: min_decode_time then
equals start_time exactly, which can never suppress anything (a segment's
own cur_time is always >= its start_time by construction), so this is a
no-op end to end -- confirmed by the full existing test suite passing
unmodified alongside the new tests.

Not yet covered here: a real-checkpoint smoke test across all four
requested geometries, and byte-identical-to-main verification on a real
WAV. Both need an actual checkpoint file, which this environment doesn't
have; deferred to the end-to-end testing issue.

Part of the lookback feature (issue #6 of #1-#8).
Mirrors the existing --lookahead-frames/--lookahead-seconds flags: its own
mutually exclusive group, seconds truncated to whole frames at the fixed
125 frames/s. Both default to None -> lookback_frames=0, today's baseline.

Validates the resolved geometry via WindowGeometry before Transcriber
spends time loading a checkpoint, so an over-budget combination
(lookback + lookahead >= input-length) is a clean argparse usage error
naming all three numbers, not a stack trace from inside model
construction.

Prints the resolved window geometry alongside the note count in
non-JSON output, so a mis-specified ablation run is obvious at a glance
(e.g. "window 512f (4.10s) = 125f lookback + 250f keep + 137f lookahead,
cost 2.05x"); the JSON output already carries it via
TranscriptionResult.as_dict() from the Transcriber-wiring PR.

Extracts _resolve_transcriber_kwargs() as a pure, import-free function
(no mt3.transcription), so the CLI's own conversion logic is testable
without paying for the heavy ML imports main() otherwise delays until
after argument parsing.

Part of the lookback feature (issue #7 of #1-#8).
_min_decode_time now returns None (not start_time) when
geometry.lookback_frames is 0. Passing decode_events a non-None min_time
-- even one that can never actually suppress anything, since a segment's
own cur_time can never fall below its own start_time -- silently switches
its max_time boundary test from the legacy inclusive `cur_time > max_time`
to the half-open `cur_time >= max_time`. That drops an event landing
exactly on a window boundary, which is common rather than rare once both
sides are quantized onto the same codec step grid: a real regression in
the plain baseline path this whole feature is supposed to leave untouched.

_cap_last_segment_tail is now only applied when overlap is actually in
use (lookback_frames or lookahead_frames nonzero), gated through a new
_should_cap_last_segment_tail() helper. Capping the last segment's tail at
the real audio duration is new behavior relative to the pre-overlap
baseline (previously always unbounded) -- worth doing once overlap is in
play, but not something the 0/0 compatibility anchor should silently pick
up too.

Both are now covered by direct unit tests instead of only being
implicit in end-to-end behavior.
lookback_seconds and lookahead_seconds in transcribe_file() were computed
via two different formulas for the same kind of quantity (one through
geometry.seconds(), one a raw division). Both now go through
geometry.seconds(), so they can't silently desync if self.geometry is ever
rebuilt independently of self.lookahead_frames.

More significantly: _quantize's `t - t % step` floors any value that is a
decimal-exact multiple of the codec step down a WHOLE EXTRA STEP, because
a step like 0.01 has no exact binary floating-point representation --
`5.0 % 0.01` evaluates to ~0.00999999999999990, not 0.0. This is a
pre-existing bug (predates this whole feature; the formula is unchanged
from the original pre-lookback code, just extracted into this named
helper), latent because real start_time values derived from 125fps frame
boundaries rarely land exactly on a 100-steps/s codec boundary -- but the
end-to-end tests being written for issue #8 hit it immediately, since
their synthetic audio uses clean decimal second boundaries for
readability. Rewritten as floor(t * steps_per_second + epsilon) /
steps_per_second, which is robust to this class of float noise while the
epsilon (1e-9) is far too small to affect any genuine non-boundary value.
…ation

_cap_last_segment_tail set max_decode_time to exactly
audio_duration_seconds. Since the capped segment almost always also has
its own min_decode_time (true whenever lookback_frames > 0),
run_length_encoding.decode_events treats max_time as the EXCLUSIVE end of
a half-open [min_time, max_time) interval -- correct when max_time is the
*next* segment's own min_decode_time (that segment's min_time picks up
anything at the exact boundary instead), but there is no next segment
here to do that. A real note ending at exactly the last frame of the
file -- an entirely ordinary case, not a contrived edge case -- was
silently dropped instead of kept.

Nudges the cap to audio_duration_seconds + 1e-6: far smaller than a
single codec step (0.01s at the defaults), so it can't admit a genuinely
hallucinated event from the padding tail, which would need at least one
full step past audio_duration_seconds to be encoded at all.

Surfaced by issue #8's end-to-end tests.
lookback_e2e_test.py: for a fixed reference NoteSequence, simulates a
*perfect* multi-window transcription -- segmented exactly as
_windowed_input_dataset would per WindowGeometry, but reporting the true
events within each window's own observed range instead of running a real
model -- and decodes it back through the actual production combine path
(metrics_utils.event_predictions_to_ns, NoteEncodingWithTiesSpec, and the
Transcriber crop-bound helpers). No checkpoint needed.

Confirms, for all four requested geometries plus the 0/0 baseline: exact
round-trip (note-for-note, not just as a deduplicated set -- an exact
duplicate would otherwise hide inside set equality), no truncation or
duplication of a note held across a window boundary, correct handling of
a note starting at the very beginning of the recording (window 0's
lookback-padding edge) and one ending at the very end (the last-window
tail-cap edge), geometry invariance (all four produce byte-for-byte the
same decoded output), and that the 0/0 baseline split across several
windows matches decoding the whole recording as one unsplit window.

Building this test surfaced two more real bugs beyond the two review
already caught in the Transcriber-wiring PR (see that PR's latest
commits): _quantize's floating-point modulo bug, and
_cap_last_segment_tail's exact-boundary drop. Both are fixed there, not
here -- this file only adds coverage.

Also adds scripts/lookback_smoke_test.py: a documented, not-run-in-CI
one-liner that runs one real WAV through all four geometries against a
real checkpoint and reports note counts and decode diagnostics side by
side, for the by-ear verification the synthetic tests structurally can't
do (CLAUDE.md: F1 and listening quality can genuinely disagree).

Part of the lookback feature (issue #8 of #1-#8) -- the last of the
implementation issues (#9's ablation sweep and #10's training-geometry
investigation are out of scope here, per the earlier scoping decision).
- Rename lookback_smoke_test.py -> lookback_smoke.py: pytest.ini collects
  every *_test.py file, so the old name made this a silent zero-test
  module in any repo-wide pytest run, and would have become a real
  collection failure the moment it gained any module-level code.
- Add lookback-only and lookahead-only geometries to _TEST_GEOMETRIES: the
  module docstring claims the round-trip property holds "lookback,
  lookahead, both, or neither" but no existing case exercised exactly one
  in isolation. Surfaced a real remaining bug: for the lookback-only case
  (lookahead=0), the last window's nominal end lands exactly on the
  audio's real end, and event_start_indices' "strictly before this frame"
  convention excluded an event sitting exactly on that boundary --
  slicing now goes one frame past the window's nominal end (a real window
  gets the same slack for free via tf.signal.frame's pad_end=True).
- Correct an overclaiming comment: the held-note test's note does NOT
  cross a boundary under the baseline geometry (it fits entirely inside
  one window there); only true for the overlapping geometries.
- lookback_smoke.py: only time transcribe_file(), not Transcriber's own
  checkpoint restore (previously dominated and hid the geometry-dependent
  cost the column exists to show); an explicit None-check instead of `or`
  for --input-length so an (invalid) explicit 0 isn't silently replaced by
  the default; wrap each geometry in try/except so one incompatible
  geometry doesn't discard every row already computed; add lookback-only
  and lookahead-only rows; corrected a docstring claim that didn't match
  what the code actually computes at input_length=512.
main()'s final diagnostics print read from the CLI-local WindowGeometry
built only for pre-flight validation, instead of from `result`
(TranscriptionResult), which already carries the same fields for exactly
this purpose. The two were only numerically identical because both
happened to derive from the same kwargs via the same constructor -- if
Transcriber's own geometry resolution ever diverges from the CLI's
pre-check, the printed diagnostics would silently describe a different
geometry than what actually ran.
Lookback: wire lookback_frames through Transcriber and TranscriptionResult
Lookback: expose --lookback-frames / --lookback-seconds on the CLI
Lookback: end-to-end segmentation-invariance and baseline-equivalence tests
Lets single-instrument input skip the model's own program-change
predictions entirely: NoteDecodingState.force_program pins
current_program at init and makes decode_note_event ignore 'program'
tokens (still consumed, so shift/timing stays in sync). As a side
effect this also merges note fragments that would otherwise split
across differing (wrong) program guesses, since active notes are
keyed by (pitch, current_program, current_rhythm).

Wired through Transcriber(force_program=...) and CLI --force-program
[0-127], with range validation before any checkpoint is touched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mrkkucharski

Copy link
Copy Markdown
Author

Opened against the wrong repo by mistake (gh defaulted to upstream instead of my fork). Closing — redoing this against mrkkucharski/mt3.

@google-cla

google-cla Bot commented Aug 17, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@mrkkucharski
mrkkucharski deleted the feature/force-program-transcribe branch August 17, 2026 07:13
@mrkkucharski

Copy link
Copy Markdown
Author

this was my mistake. good you closed it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant