Skip to content

feat(editor): adopt a MIDI's own tempo map on import (Use-vs-Keep) - #127

Merged
byrongamatos merged 5 commits into
mainfrom
feat/editor-midi-tempo-import
Jul 8, 2026
Merged

feat(editor): adopt a MIDI's own tempo map on import (Use-vs-Keep)#127
byrongamatos merged 5 commits into
mainfrom
feat/editor-midi-tempo-import

Conversation

@ChrisBeWithYou

@ChrisBeWithYou ChrisBeWithYou commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

DAW roadmap 3.2 (SONG-EDITOR-DAW-WORKSPACE-DESIGN.md §F3). Newly unblocked: core #796 (convert_midi_tempo_map) merged, which is the gate this slice waited on.

The gap

Importing a .mid as keys or drums baked note times but threw away the file's tempo map and time signatures — so every MIDI import landed with an implied 4/4 and no bars, no matter what the file said.

What this does

The keys/drums MIDI import now reads the SMF's real grid and, when it carries one, offers a Use MIDI tempo map / Keep project timing choice. The default is honest and never silent:

  • Use when the project has no bars yet (the MIDI's grid is strictly more information).
  • Keep when a timeline already exists — an audio-aligned grid is never stomped.

Applying runs through the existing TempoGridCmd (one undoable step that re-locks the loop onto the new grid); imported notes stay accurate either way. It degrades cleanly — a gridless MIDI, a GP import, or an older host without the core function simply shows no prompt. On a drum import the timing choice is chained before the unmapped-notes triage so the two dialogs never stack.

Backend (routes.py)

  • Module-level _sanitize_midi_tempo_map — pure gate + shape: returns {tempos, time_signatures, beats} only when the map has ≥1 numbered downbeat, else {}. Module-level so it's unit-testable without a real .mid or the core lib.
  • Module-level _safe_midi_tempo_map — wraps the core call; returns {} on ImportError (older host predating #796) or any extraction error, so an import never 500s over timing. track_index is forwarded (SMF type-2 independent timelines).
  • import-keys-midi / import-drums-midi compute the map in the same worker as the note conversion (both endpoints rmtree the temp dir immediately after) and return it as tempo_map.

Frontend (screen.js)

  • @pure:midi-tempo-choice block: project-grid detection (≥2 downbeats), beat-row sanitizing, the Use-vs-Keep default policy, and a summary string (e.g. 12 bars · 4/4 · 120 BPM).
  • _maybeOfferMidiTempoMap — the radio dialog + TempoGridCmd apply.
  • TempoGridCmd is now song-scoped (like the drum commands), matching the EditHistory contract comment that already lists "tempo grid" as song-scope. Without it, applying an imported grid right after a drums import — active part still fretted-in-roll — would be silently blocked by the feat(editor): per-part view switcher — any fretted part opens in the piano roll (read-first) #119 read-only-roll lock. A latent bug this surfaces and fixes.

Tests

  • tests/test_midi_tempo_import.py (8) — gate/shape purity, gridless drop, the two core-call failure floors (older core → {}, extraction raising → {}) via injection, track_index forwarding.
  • tests/midi_tempo_import.test.js (10) — grid detection, sanitizing, default policy, summary, TempoGridCmd songScope + S.beats round-trip.
  • Full JS suite green; editor pytest 221 passed / 2 skipped. Both features absent on main.

Scope

Single-track, riding the existing keys/drums pickers — the multitrack mapping-table picker + classifier is 3.3 (separate PR). Provenance (import_source) is 3.3 too. Fresh region: new module-level helpers among the _timeline_* family; the frontend block sits by the drum-import modals.

Summary by CodeRabbit

  • New Features

    • MIDI imports can now optionally adopt the source file’s tempo/time-signature grid via a prompt to use MIDI timing or keep project timing.
    • The choice is applied as a single, undoable song-scoped change, and the tempo prompt is only shown when a usable grid is detected.
    • Drum MIDI import prioritizes this prompt to prevent overlapping dialogs.
  • Bug Fixes

    • Improved safety and fallback when tempo-map data is missing, invalid, unsupported, or fails to extract.
    • Correctly applies audio offset to the adopted timing grid.
  • Tests

    • Added/expanded frontend and Python coverage for tempo-mapped MIDI import gating, formatting, and offset shifting.

DAW roadmap 3.2. Wires core convert_midi_tempo_map (feedback #796) into the
editor's existing single-track keys/drums MIDI import. Until now a .mid import
baked note times but discarded the file's tempo + time-signature grid, so
every import landed with an implied 4/4 and no bars.

Backend (routes.py):
- New module-level _sanitize_midi_tempo_map (pure gate/shape: returns the
  {tempos, time_signatures, beats} map only when it carries >=1 numbered
  downbeat, else {}) and _safe_midi_tempo_map (wraps the core call; returns {}
  on ImportError from an older host or any extraction error — an import never
  500s over timing). Both are module-level so they're unit-testable without a
  real .mid or the core lib.
- import-keys-midi and import-drums-midi now compute the tempo map in the SAME
  worker as the note conversion (both endpoints rmtree the temp dir right
  after) and return it as `tempo_map`. track_index is forwarded for SMF type-2.

Frontend (screen.js):
- @pure:midi-tempo-choice block: project-grid detection (>=2 downbeats), beat-
  row sanitizing, the Use-vs-Keep default policy (Keep when a grid exists so an
  audio-aligned timeline is never stomped; Use when it doesn't), and a summary
  string.
- _maybeOfferMidiTempoMap: a radio dialog offered after a MIDI import; Apply
  with "Use" runs S.history.exec(new TempoGridCmd(...)) — one undoable step
  that re-locks the loop. No-op (no dialog) for a gridless MIDI, a GP import,
  or an older host. On a drum import it's chained before the unmapped-notes
  triage so the two dialogs never stack.
- TempoGridCmd is now correctly song-scoped (like the drum commands), matching
  the EditHistory contract comment. Without it, applying an imported grid right
  after a drums import — active part still fretted-in-roll — would be silently
  blocked by the #119 read-only-roll lock.

Tests: tests/test_midi_tempo_import.py (gate/shape purity, the two core-call
failure floors via injection, track_index forwarding — 8) and
tests/midi_tempo_import.test.js (grid detection, sanitizing, default policy,
summary, TempoGridCmd songScope + round-trip — 10). Full JS suite green;
editor pytest 221 passed / 2 skipped. Both features absent on main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzMBtxWnLGHYkMMXtK38Bg
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@byrongamatos, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d72f3365-5eb8-4673-a5a3-598b9fbcc9b2

📥 Commits

Reviewing files that changed from the base of the PR and between f332394 and 0d12059.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • screen.js
📝 Walkthrough

Walkthrough

Adds MIDI tempo-map import support in the backend and frontend, including optional adoption of the imported timing grid, song-scoped tempo grid application, and coverage for extraction, selection, and rollback behavior.

Changes

MIDI Tempo Map Import

Layer / File(s) Summary
Backend tempo-map extraction and API responses
routes.py
Adds safe tempo-map extraction, sanitization, and offset shifting, then includes tempo_map in the keys and drums MIDI import responses.
Frontend tempo-map adoption prompt and TempoGridCmd scoping
screen.js
Adds tempo-map helper functions, the modal that offers MIDI timing adoption, import-flow sequencing for that modal, and TempoGridCmd.songScope = true.
Tests and changelog for tempo map import
tests/midi_tempo_import.test.js, tests/test_midi_tempo_import.py, CHANGELOG.md
Adds JS and Python coverage for tempo-map shaping, wrapper behavior, modal logic, command behavior, and documents the change in the changelog.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant routes.py
  participant screen.js
  participant User
  participant TempoGridCmd
  participant import endpoint
  routes.py-->>screen.js: tempo_map in import response
  screen.js->>User: offer "Use this MIDI's timing?"
  User-->>screen.js: Apply or Skip
  screen.js->>TempoGridCmd: exec on Apply
  TempoGridCmd-->>screen.js: update S.beats
  screen.js-->>import endpoint: continue import flow
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: importing and optionally adopting a MIDI file’s tempo map in the editor.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/editor-midi-tempo-import

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@routes.py`:
- Around line 410-436: The MIDI tempo map returned by _safe_midi_tempo_map is
still in raw MIDI time, while the imported notes/hits are already shifted by
audio_offset, so the grid and chart can diverge on offset imports. Update the
import flow where the result is passed into TempoGridCmd to apply audio_offset
to the tempo-map timing data first, adjusting the beat rows and any matching
tempos/time_signatures timestamps consistently. Use _safe_midi_tempo_map,
audio_offset, and TempoGridCmd as the main points to locate the change.

In `@screen.js`:
- Around line 14360-14399: The default-choice logic in
_midiTempoDefaultChoicePure is too strict for imported MIDI because it uses
_hasProjectGridPure, which requires two downbeats and therefore hides valid
single-bar tempo maps. Update the MIDI-side availability check to treat a
sanitized tempo map with at least one valid downbeat as offerable, while keeping
the stricter _hasProjectGridPure check for the existing projectBeats. Preserve
the current keep-vs-midi decision structure in _midiTempoDefaultChoicePure, but
base “nothing to offer” on a looser imported-grid predicate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7868b2a2-5d62-4565-b98c-aef9af035fc0

📥 Commits

Reviewing files that changed from the base of the PR and between 8eae01b and b3a2f57.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • routes.py
  • screen.js
  • tests/midi_tempo_import.test.js
  • tests/test_midi_tempo_import.py

Comment thread routes.py Outdated
Comment thread screen.js
byrongamatos and others added 3 commits July 8, 2026 09:03
- Shift the adopted MIDI tempo-map grid by audio_offset so bars land on the
  same absolute-time mapping as the just-imported notes (notes are placed at
  raw+audio_offset; the grid was emitted at raw times, so Use would misalign
  downbeats from the content by audio_offset).
- Match the frontend Use-vs-Keep offer gate to the backend: a single numbered
  downbeat is enough (routes.py _sanitize_midi_tempo_map / the backend already
  ships single-bar maps); the frontend's 2-downbeat project-grid threshold was
  silently dropping them.
- Regression tests (JS + Python) that fail on pre-fix code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eats

Core convert_midi_tempo_map may convey a non-4 denominator only in the
time_signatures list, leaving downbeat rows bare. The editor's canonical home
for the denominator is beat.den (frontend + _build_song_timeline read that,
not the list), so adopting such a grid would save e.g. 6/8 as 6/4. Fold the
signatures onto downbeats via the existing _apply_timeline_signatures_to_beats
inside _sanitize_midi_tempo_map (before the audio_offset shift, while raw times
still match). Regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_apply_timeline_signatures_to_beats folded a denominator only onto the exact
downbeat matching a time-signature event, but both song_timeline and core MIDI
maps emit one event per CHANGE, not per bar. A persistent 7/8 therefore
restored/saved as 7/8 on the change bar and /4 on every following bar (latent
in the song_timeline load path too, now that the MIDI import path also folds).
Apply each event forward until the next change. Regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@byrongamatos

Copy link
Copy Markdown
Collaborator

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@byrongamatos

Copy link
Copy Markdown
Collaborator

Both CodeRabbit findings were already addressed in this PR before the review landed:

  • Shift MIDI tempo map by audio_offset — fixed in _shift_midi_tempo_map() (routes.py); the adopted grid now rides the same offset the note converters apply, and both import endpoints pass audio_offset.
  • Preserve single-bar MIDI grids — fixed via _midiOffersGridPure() (screen.js); the frontend now offers any map with ≥1 numbered downbeat, matching the backend gate.

Also folded time-signature denominators forward across downbeats so a persistent non-4 denominator survives adopt/save. Regression tests added on both sides (JS + pytest). Resolving the threads.

@byrongamatos
byrongamatos merged commit 3e6a015 into main Jul 8, 2026
@byrongamatos
byrongamatos deleted the feat/editor-midi-tempo-import branch July 8, 2026 07:22
byrongamatos added a commit that referenced this pull request Jul 12, 2026
…ver spill past the ruler

Two review fixes on the derived tempo/meter marker lane:

- Denominator: a bare downbeat now reads /4 via _tempoNormalizeDenominatorPure
  — the SAME canonical read the signature inspector uses — instead of carrying
  the previous explicit den forward. The phantom carry missed the meter change
  back to /4 after an explicit-den bar and could label a chip with a den the
  inspector contradicts (real carry-forward is materialized at import by
  stamping den per downbeat, _apply_timeline_signatures_to_beats / #127).

- Chip stacking: the chained 44px heuristic grew one row per chip, so a dense
  marker run (a hand-fit/suggest-fit rubato map has a tempo chip on nearly
  every bar) painted chips diagonally down past RULER_H over the chart.
  _rulerMarkerRowsPure now lays chips into at most 3 rows by actual chip
  width and drops the overflow — the _rulerBarLabelSkipPure convention:
  decimate when dense, never spill.

Both regression tests fail on the pre-fix code (verified by revert).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants