mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 19:29:33 +00:00
feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid (#796)
* feat(midi): convert_midi_tempo_map — extract tempos, time signatures, beat grid
The keys/drums MIDI converters always built a tempo-aware tick->seconds
map internally (to bake note times) and then discarded it — and never
read time_signature meta at all — so every MIDI import landed with no
bars, no measures, and an implied 4/4 no matter what the file said.
New lib/midi_import.py helper convert_midi_tempo_map(midi_path,
track_index) extracts the grid a .mid actually carries:
- tempos: {time, bpm} per tempo event (deduped per tick, 120 default)
- time_signatures: {time, ts: [num, den]} — the song-timeline shape
- beats: one row per beat on the editor grid shape — numbered downbeats
with a den hint, measure:-1 interior beats; the beat unit follows the
active signature (6/8 = six eighth-note rows per bar)
Event scope mirrors _build_tick_to_seconds: SMF type 0/1 merge meta
from all tracks, type 2 reads ONLY the chosen track (independent
timelines — callers must never share one grid across type-2 tracks).
Mid-bar signature events (ill-formed but seen in the wild) apply at the
next bar boundary. All times compute from absolute ticks through the
cumulative tempo table and round once at emit — rounding error never
accumulates with song length. A bar-count safety valve guards malformed
files. Consumer: the editor's multitrack MIDI import (tempo-seed
dialog, feedBack-plugin-editor roadmap Phase 3).
Tests: tests/test_midi_tempo_map.py — 10 cases driving the REAL
function against real .mid files built with mido (no stubs): default
grid, tempo bends, 500-bar rounding-drift check, 4/4->3/4 and 6/8
signatures, mid-bar signature deferral, duplicate-tick last-wins,
type-2 meta isolation from a bogus sibling track, empty files, grid
coverage bounds. Full MIDI-adjacent suite green: 55 passed
(test_midi_tempo_map + test_midi_import + test_midi_import_drums +
test_gp2midi) under the project venv.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JEoFeTPSnz4NpwwCG52hnu
* fix(midi): make convert_midi_tempo_map robust — real division guard, symmetric tempo default, single-pass meta
- Push the ticks-per-beat fallback into _build_tick_to_seconds (the single
place ticks route through), guarding on `> 0` so a division==0 (malformed)
or negative SMPTE-division header no longer raises ZeroDivisionError or
walks the beat grid into negative times. Mirror the guard at the
convert_midi_tempo_map beat_ticks site. The local `or 480` was cosmetic
before — the closure still divided by the raw division.
- Seed tempos_out with a 120 BPM row at time 0 when the first set_tempo
lands after tick 0, symmetric with the (0, 4, 4) time-signature default,
so the sidecar matches the grid the head of the song actually used.
- Collapse the duplicated meta_source/note_source lists into one
source_tracks walked in a single pass (meta collection + end_tick).
- Fix a weak assert in test_mid_bar_signature_applies_at_the_next_boundary
(operator-precedence `(A and B) or C`) to assert den == 4 outright.
- Add tests: non-positive division (0 + negative SMPTE), first tempo after
start seeds 120@0, explicit SMF type-0 file, and the _TEMPO_MAP_MAX_BARS
safety valve.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: ChrisBeWithYou <chris@rifflarr.local>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
ChrisBeWithYou
byrongamatos
parent
010edc239b
commit
1bccb8a9e8
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **`lib/midi_import.py`: `convert_midi_tempo_map` — MIDI imports can finally carry
|
||||||
|
their bars.** The keys/drums note converters always computed a tempo-aware
|
||||||
|
tick→seconds map internally (to bake note times to absolute seconds) and then threw
|
||||||
|
it away — and never read `time_signature` meta at all — so every MIDI import landed
|
||||||
|
with no measures and an implied 4/4 regardless of what the file said. The new helper
|
||||||
|
extracts the whole grid: `tempos` (`{time, bpm}`), `time_signatures` (`{time,
|
||||||
|
ts:[num,den]}`, the song-timeline sidecar shape), and a full `beats` grid on the
|
||||||
|
editor's row shape (numbered downbeats with a `den` hint, `-1` interior beats,
|
||||||
|
eighth-note rows in 6/8 etc.). Event scope mirrors the existing tick map — SMF
|
||||||
|
type 0/1 merge meta across tracks, type 2 reads only the chosen track (independent
|
||||||
|
timelines must never share a grid); mid-bar signature events apply at the next bar
|
||||||
|
boundary; times are computed from absolute ticks through the cumulative tempo table
|
||||||
|
and rounded once at emit, so rounding error never accumulates with song length.
|
||||||
|
Consumed by the editor's upcoming multitrack MIDI import (tempo-seed dialog). Tests:
|
||||||
|
`tests/test_midi_tempo_map.py`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
|
- **Tuner: opening the player screen no longer throws `NotFoundError` and aborts the player render (feedBack#800).** `injectPlayerButton()` anchored the injected Tuner button with `controls.querySelector('button:last-child')`, which — unlike a `:scope`-scoped query — can match a **nested** button that is not a direct child of `#player-controls`. `controls.insertBefore(btn, nestedButton)` then throws `NotFoundError` (the reference node must be a direct child), and because the injection runs from the tuner's `screen:changed` → player handler, the throw propagated out of the player-screen transition and stalled its render (surfaced by a headless render of a notation arrangement; the v3 path was already safe via the plugin-control slot, only the classic path had the bad anchor). The anchor is now `:scope > button:last-of-type` (a direct child only) with a `parentNode === controls` guard before `insertBefore`, falling back to `appendChild`. `plugins/tuner` → 1.3.4. Tests: `tests/plugins/tuner/js/inject_player_button.test.js` (nested-last-button repro, direct-child insert, no-button append, idempotency, v3 slot path).
|
||||||
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
- **Auto-sync: DTW step constraint — riff-based songs no longer produce garbage sync points.** `librosa.sequence.dtw`'s default step pattern allows unbounded horizontal/vertical path runs, and on music with long self-similar chroma stretches (riff-driven stoner/doom, drone sections) the flat cost surface let the warping path collapse — minutes of score mapped onto a single audio frame, so the per-bar warp imported charts wildly out of sync while reporting success (observed on a real 138 BPM tab: effective displayed tempo 159 BPM, three sync points sharing one audio timestamp). `_dtw_align` now uses the standard music-sync slope-constrained step pattern (`[[1,1],[1,2],[2,1]]`, local tempo ratio bounded to 0.5x–2x), which makes the degenerate path impossible, with a fallback to unconstrained steps when the global length ratio makes the constrained pattern infeasible (e.g. a tab aligned against a full-concert video). Validated on the failing song: coarse points track the recording 1:1, refined downbeats land on onset peaks at 3.3x background energy.
|
||||||
|
|||||||
+143
-1
@@ -352,7 +352,14 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
|||||||
- type 1: parallel tracks share the timeline; merge tempo events.
|
- type 1: parallel tracks share the timeline; merge tempo events.
|
||||||
- type 2: independent timelines; tempo only from the chosen track.
|
- type 2: independent timelines; tempo only from the chosen track.
|
||||||
"""
|
"""
|
||||||
ticks_per_beat = midi.ticks_per_beat
|
# A metrical header carries positive ticks-per-beat. mido reads the SMF
|
||||||
|
# division as a signed short, so an SMPTE-division file surfaces as a
|
||||||
|
# negative value and a malformed header as 0 — both make the two division
|
||||||
|
# sites below divide by a non-positive number (ZeroDivisionError, or
|
||||||
|
# negative seconds that send the bar walk off the rails). Fall back to the
|
||||||
|
# SMF default here, the single place every caller routes ticks through, so
|
||||||
|
# each caller's own fallback is real rather than cosmetic.
|
||||||
|
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||||
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
raw_events: list[tuple[int, int]] = [(0, 500000)] # default 120 BPM
|
||||||
midi_type = getattr(midi, "type", 1)
|
midi_type = getattr(midi, "type", 1)
|
||||||
tempo_source = (
|
tempo_source = (
|
||||||
@@ -393,6 +400,141 @@ def _build_tick_to_seconds(midi: mido.MidiFile, track_index: int) -> Callable[[i
|
|||||||
return tick_to_seconds
|
return tick_to_seconds
|
||||||
|
|
||||||
|
|
||||||
|
# Safety valve for the bar walk below: a malformed SMF (absurd tempo + long
|
||||||
|
# trailing meta) could otherwise imply millions of bars. Real charts sit
|
||||||
|
# orders of magnitude below this.
|
||||||
|
_TEMPO_MAP_MAX_BARS = 20000
|
||||||
|
|
||||||
|
|
||||||
|
def convert_midi_tempo_map(midi_path: str, track_index: int = 0) -> dict:
|
||||||
|
"""Extract the song-timeline grid a `.mid` file carries: tempos, time
|
||||||
|
signatures, and a full beat grid — the data the note converters here
|
||||||
|
always computed internally (to bake note times) and then threw away,
|
||||||
|
which left every MIDI import with no bars, no measures, and an implied
|
||||||
|
4/4 no matter what the file said.
|
||||||
|
|
||||||
|
Returns ``{"tempos": [...], "time_signatures": [...], "beats": [...]}``:
|
||||||
|
|
||||||
|
- ``tempos``: ``{time, bpm}`` per tempo event (deduped per tick).
|
||||||
|
- ``time_signatures``: ``{time, ts: [num, den]}`` per signature event —
|
||||||
|
the song-timeline sidecar shape (feedpak-spec §7.4).
|
||||||
|
- ``beats``: one row per beat on the editor grid shape — downbeats carry
|
||||||
|
a running ``measure`` (1, 2, 3, …) plus a ``den`` hint (the signature
|
||||||
|
denominator), interior beats carry ``measure: -1``. The beat unit
|
||||||
|
follows the active signature (6/8 ⇒ six eighth-note rows per bar).
|
||||||
|
|
||||||
|
Event scope mirrors ``_build_tick_to_seconds``: SMF type 0/1 merge meta
|
||||||
|
from all tracks (shared timeline); type 2 reads ONLY ``track_index``
|
||||||
|
(independent timelines — callers must never share one grid across
|
||||||
|
type-2 tracks). Signature changes apply at the NEXT bar boundary when a
|
||||||
|
file places one mid-bar (ill-formed but seen in the wild). All times
|
||||||
|
are computed from absolute ticks through the cumulative tempo table and
|
||||||
|
rounded once at emit — rounding error never accumulates with song
|
||||||
|
length. An SMF with no note events yields empty ``beats``.
|
||||||
|
"""
|
||||||
|
midi = mido.MidiFile(midi_path)
|
||||||
|
# Positive for metrical files; 0 (malformed) or negative (SMPTE division,
|
||||||
|
# read as a signed short) otherwise — fall back so beat_ticks below stays
|
||||||
|
# sane, mirroring the guard inside _build_tick_to_seconds.
|
||||||
|
ticks_per_beat = midi.ticks_per_beat if midi.ticks_per_beat > 0 else 480
|
||||||
|
midi_type = getattr(midi, "type", 1)
|
||||||
|
# Same scope both converters use: type 2 reads only the chosen track
|
||||||
|
# (independent timelines); type 0/1 merge all tracks (shared timeline).
|
||||||
|
source_tracks = (
|
||||||
|
[midi.tracks[track_index]] if midi_type == 2 else midi.tracks
|
||||||
|
)
|
||||||
|
tick_to_seconds = _build_tick_to_seconds(midi, track_index)
|
||||||
|
|
||||||
|
# ── collect meta + the end of musical content in one pass ────────────
|
||||||
|
sig_events: list[tuple[int, int, int]] = []
|
||||||
|
tempo_events: list[tuple[int, int]] = []
|
||||||
|
end_tick = 0
|
||||||
|
for tr in source_tracks:
|
||||||
|
abs_tick = 0
|
||||||
|
for msg in tr:
|
||||||
|
abs_tick += msg.time
|
||||||
|
if msg.type == "time_signature":
|
||||||
|
num = int(getattr(msg, "numerator", 4) or 4)
|
||||||
|
den = int(getattr(msg, "denominator", 4) or 4)
|
||||||
|
if num > 0 and den > 0:
|
||||||
|
sig_events.append((abs_tick, num, den))
|
||||||
|
elif msg.type == "set_tempo":
|
||||||
|
tempo_events.append((abs_tick, int(msg.tempo)))
|
||||||
|
elif msg.type in ("note_on", "note_off"):
|
||||||
|
end_tick = max(end_tick, abs_tick)
|
||||||
|
|
||||||
|
# Dedupe at equal ticks (last wins), matching the tempo-table rule.
|
||||||
|
sig_events.sort(key=lambda e: e[0])
|
||||||
|
sigs: list[tuple[int, int, int]] = []
|
||||||
|
for ev in sig_events:
|
||||||
|
if sigs and sigs[-1][0] == ev[0]:
|
||||||
|
sigs[-1] = ev
|
||||||
|
else:
|
||||||
|
sigs.append(ev)
|
||||||
|
if not sigs or sigs[0][0] > 0:
|
||||||
|
sigs.insert(0, (0, 4, 4))
|
||||||
|
|
||||||
|
tempo_events.sort(key=lambda e: e[0])
|
||||||
|
seen_tempo_ticks: dict[int, int] = {}
|
||||||
|
for ev_tick, ev_tempo in tempo_events:
|
||||||
|
seen_tempo_ticks[ev_tick] = ev_tempo
|
||||||
|
sorted_tempo_ticks = sorted(seen_tempo_ticks)
|
||||||
|
tempos_out: list[dict] = []
|
||||||
|
# Seed the MIDI default (120 BPM) at time 0 when the first tempo event
|
||||||
|
# lands after the start (or there are none). The beat grid already runs
|
||||||
|
# at 120 for the head of the song, so the sidecar must say so too —
|
||||||
|
# symmetric with the (0, 4, 4) default seeded into the signatures above.
|
||||||
|
if not sorted_tempo_ticks or sorted_tempo_ticks[0] > 0:
|
||||||
|
tempos_out.append({"time": 0.0, "bpm": 120.0})
|
||||||
|
for ev_tick in sorted_tempo_ticks:
|
||||||
|
tempos_out.append({
|
||||||
|
"time": round(tick_to_seconds(ev_tick), 3),
|
||||||
|
"bpm": round(60_000_000.0 / seen_tempo_ticks[ev_tick], 3),
|
||||||
|
})
|
||||||
|
|
||||||
|
time_signatures_out = [
|
||||||
|
{"time": round(tick_to_seconds(t), 3), "ts": [num, den]}
|
||||||
|
for t, num, den in sigs
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── walk bars from tick 0 to the end of the notes ────────────────────
|
||||||
|
beats: list[dict] = []
|
||||||
|
if end_tick > 0:
|
||||||
|
cur_tick = 0.0
|
||||||
|
measure = 1
|
||||||
|
sig_idx = 0
|
||||||
|
while cur_tick < end_tick and measure <= _TEMPO_MAP_MAX_BARS:
|
||||||
|
# Active signature: the latest event at or before this bar's
|
||||||
|
# start. Mid-bar events wait for the next boundary by
|
||||||
|
# construction (we only re-read between bars).
|
||||||
|
while (sig_idx + 1 < len(sigs)
|
||||||
|
and sigs[sig_idx + 1][0] <= cur_tick + 1e-6):
|
||||||
|
sig_idx += 1
|
||||||
|
_, num, den = sigs[sig_idx]
|
||||||
|
beat_ticks = ticks_per_beat * 4.0 / den
|
||||||
|
beats.append({
|
||||||
|
"time": round(tick_to_seconds(int(round(cur_tick))), 3),
|
||||||
|
"measure": measure,
|
||||||
|
"den": den,
|
||||||
|
})
|
||||||
|
for k in range(1, num):
|
||||||
|
sub_tick = cur_tick + k * beat_ticks
|
||||||
|
if sub_tick >= end_tick:
|
||||||
|
break
|
||||||
|
beats.append({
|
||||||
|
"time": round(tick_to_seconds(int(round(sub_tick))), 3),
|
||||||
|
"measure": -1,
|
||||||
|
})
|
||||||
|
cur_tick += num * beat_ticks
|
||||||
|
measure += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tempos": tempos_out,
|
||||||
|
"time_signatures": time_signatures_out,
|
||||||
|
"beats": beats,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
# ── Drum track listing (channel-9 only) ──────────────────────────────────────
|
||||||
|
|
||||||
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
# Velocity below this is treated as a ghost note. GM doesn't have an explicit
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""Tests for lib/midi_import.py — convert_midi_tempo_map.
|
||||||
|
|
||||||
|
The note converters always computed a tempo-aware tick→seconds map internally
|
||||||
|
(to bake note times) and then threw it away — and never read time_signature
|
||||||
|
meta at all — so every MIDI import landed with no bars, no measures, and an
|
||||||
|
implied 4/4 regardless of the file. convert_midi_tempo_map extracts the grid:
|
||||||
|
tempos, time signatures (song-timeline shape), and a full beat grid on the
|
||||||
|
editor's row shape (numbered downbeats with a `den` hint, `-1` sub-beats).
|
||||||
|
|
||||||
|
Every test drives the REAL function against a real .mid built in-memory with
|
||||||
|
mido and saved to tmp_path — no stubs, adversarial inputs included (type-2
|
||||||
|
scoping, mid-bar signatures, duplicate meta ticks, empty files, long files
|
||||||
|
for rounding drift).
|
||||||
|
|
||||||
|
Run: pytest tests/test_midi_tempo_map.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import mido
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from midi_import import _TEMPO_MAP_MAX_BARS, convert_midi_tempo_map
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _save(mid: mido.MidiFile, tmp_path, name: str = "t.mid") -> str:
|
||||||
|
p = tmp_path / name
|
||||||
|
mid.save(str(p))
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _note_pair(track, pitch=60, at=0, dur=240):
|
||||||
|
track.append(mido.Message("note_on", note=pitch, velocity=90, time=at))
|
||||||
|
track.append(mido.Message("note_off", note=pitch, velocity=0, time=dur))
|
||||||
|
|
||||||
|
|
||||||
|
def _downbeats(result):
|
||||||
|
return [b for b in result["beats"] if b["measure"] > 0]
|
||||||
|
|
||||||
|
|
||||||
|
def _subbeats(result):
|
||||||
|
return [b for b in result["beats"] if b["measure"] == -1]
|
||||||
|
|
||||||
|
|
||||||
|
# ── the plain case ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_default_grid_is_120_bpm_four_four(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||||
|
_note_pair(tr, at=0, dur=480 * 8) # two 4/4 bars of content
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||||
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert [d["measure"] for d in dbs] == [1, 2]
|
||||||
|
assert [d["time"] for d in dbs] == [0.0, 2.0] # 4 beats at 0.5 s
|
||||||
|
assert all(d["den"] == 4 for d in dbs)
|
||||||
|
# 3 interior beats per full bar at 0.5 s spacing.
|
||||||
|
assert [b["time"] for b in _subbeats(res)][:3] == [0.5, 1.0, 1.5]
|
||||||
|
|
||||||
|
|
||||||
|
# ── tempo handling ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_tempo_change_bends_the_grid(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||||
|
meta.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
|
||||||
|
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
|
||||||
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||||
|
_note_pair(notes, at=0, dur=480 * 8)
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert [t["bpm"] for t in res["tempos"]] == [120.0, 240.0]
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
# Bar 1 spans 2.0 s at 120; bar 2 starts at 2.0 and its beats halve.
|
||||||
|
assert dbs[0]["time"] == 0.0 and dbs[1]["time"] == 2.0
|
||||||
|
bar2_subs = [b["time"] for b in _subbeats(res) if b["time"] > 2.0]
|
||||||
|
assert bar2_subs[:3] == [2.25, 2.5, 2.75]
|
||||||
|
|
||||||
|
def test_rounding_does_not_accumulate_over_a_long_file(tmp_path):
|
||||||
|
# 500 bars at 120 BPM: beat times must stay exactly on the 0.5 s lattice
|
||||||
|
# (absolute-tick computation — never beat N derived from beat N-1).
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||||
|
_note_pair(tr, at=0, dur=480 * 4 * 500)
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert len(dbs) == 500
|
||||||
|
assert dbs[-1]["time"] == pytest.approx((500 - 1) * 2.0, abs=0.0005)
|
||||||
|
assert dbs[250]["time"] == pytest.approx(250 * 2.0, abs=0.0005)
|
||||||
|
|
||||||
|
|
||||||
|
# ── time signatures (the previously-unread meta) ─────────────────────────────
|
||||||
|
|
||||||
|
def test_time_signature_changes_shape_the_bars(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||||
|
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
|
||||||
|
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 4))
|
||||||
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||||
|
_note_pair(notes, at=0, dur=480 * 10) # 4/4 bar + two 3/4 bars
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert [s["ts"] for s in res["time_signatures"]] == [[4, 4], [3, 4]]
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert [d["time"] for d in dbs] == [0.0, 2.0, 3.5] # 3/4 bars are 1.5 s
|
||||||
|
# Bar 2 has exactly two interior beats.
|
||||||
|
bar2 = [b for b in res["beats"] if 2.0 < b["time"] < 3.5]
|
||||||
|
assert [b["measure"] for b in bar2] == [-1, -1]
|
||||||
|
|
||||||
|
def test_six_eight_uses_eighth_note_rows(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||||
|
meta.append(mido.MetaMessage("time_signature", numerator=6, denominator=8, time=0))
|
||||||
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||||
|
_note_pair(notes, at=0, dur=480 * 3) # one full 6/8 bar
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert dbs[0]["den"] == 8
|
||||||
|
bar1 = [b["time"] for b in res["beats"] if b["time"] < 1.5]
|
||||||
|
# Six eighth-note rows at 120 BPM (quarter = 0.5 s ⇒ eighth = 0.25 s).
|
||||||
|
assert bar1 == [0.0, 0.25, 0.5, 0.75, 1.0, 1.25]
|
||||||
|
|
||||||
|
def test_mid_bar_signature_applies_at_the_next_boundary(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||||
|
# Ill-formed: 3/4 lands halfway through bar 1.
|
||||||
|
meta.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=480 * 2))
|
||||||
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||||
|
_note_pair(notes, at=0, dur=480 * 8)
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
# Bar 1 stays 4/4 (2.0 s); bar 2 onward is 3/4.
|
||||||
|
assert dbs[0]["time"] == 0.0 and dbs[0]["den"] == 4
|
||||||
|
# Bar 2 is the 3/4 bar, but its denominator is still 4 (3 quarter notes).
|
||||||
|
assert dbs[1]["time"] == 2.0 and dbs[1]["den"] == 4
|
||||||
|
assert dbs[2]["time"] - dbs[1]["time"] == pytest.approx(1.5, abs=0.002)
|
||||||
|
|
||||||
|
def test_duplicate_signature_ticks_last_wins(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||||
|
meta.append(mido.MetaMessage("time_signature", numerator=4, denominator=4, time=0))
|
||||||
|
meta.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
|
||||||
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||||
|
_note_pair(notes, at=0, dur=480 * 4)
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert res["time_signatures"][-1]["ts"] == [7, 8]
|
||||||
|
assert _downbeats(res)[0]["den"] == 8
|
||||||
|
|
||||||
|
|
||||||
|
# ── SMF type scoping (adversarial) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_type2_reads_meta_from_the_chosen_track_only(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480, type=2)
|
||||||
|
bogus = mido.MidiTrack(); mid.tracks.append(bogus)
|
||||||
|
bogus.append(mido.MetaMessage("set_tempo", tempo=100000, time=0)) # 600 BPM
|
||||||
|
bogus.append(mido.MetaMessage("time_signature", numerator=7, denominator=8, time=0))
|
||||||
|
_note_pair(bogus, at=0, dur=480)
|
||||||
|
real = mido.MidiTrack(); mid.tracks.append(real)
|
||||||
|
real.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120 BPM
|
||||||
|
_note_pair(real, at=0, dur=480 * 4)
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path), track_index=1)
|
||||||
|
# The bogus track's 600 BPM / 7-8 never leak into track 1's grid.
|
||||||
|
assert [t["bpm"] for t in res["tempos"]] == [120.0]
|
||||||
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||||
|
assert _downbeats(res)[0]["den"] == 4
|
||||||
|
|
||||||
|
|
||||||
|
# ── degenerate inputs ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_empty_file_yields_empty_beats_but_valid_shape(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
mid.tracks.append(mido.MidiTrack())
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert res["beats"] == []
|
||||||
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||||
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||||
|
|
||||||
|
def test_grid_covers_all_notes_and_stops_after_them(tmp_path):
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||||
|
_note_pair(tr, at=480 * 5, dur=480) # note inside bar 2 only
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert dbs[0]["time"] == 0.0, "grid starts at zero (SMF convention)"
|
||||||
|
assert dbs[-1]["measure"] == 2
|
||||||
|
assert all(b["time"] <= 3.0 + 1e-9 for b in res["beats"]), \
|
||||||
|
"no beats past the end of musical content"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("division", [0, -1, -25600])
|
||||||
|
def test_non_positive_division_header_does_not_crash(tmp_path, division):
|
||||||
|
# A malformed header reloads with ticks_per_beat == 0; a true SMPTE-division
|
||||||
|
# file reloads negative (mido reads the division as a signed short). Either
|
||||||
|
# way the tick→seconds closure would divide by a non-positive number —
|
||||||
|
# raising ZeroDivisionError (0) or walking off into negative times
|
||||||
|
# (negative) — without the header fallback. The grid must still come out on
|
||||||
|
# a sane, bounded 4/4 / 120-BPM default.
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=division)
|
||||||
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||||
|
_note_pair(tr, at=0, dur=480 * 8)
|
||||||
|
assert mido.MidiFile(_save(mid, tmp_path)).ticks_per_beat == division # precondition
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||||
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [4, 4]}]
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert [d["measure"] for d in dbs] == [1, 2]
|
||||||
|
assert all(isinstance(b["time"], float) and b["time"] >= 0.0
|
||||||
|
for b in res["beats"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_tempo_after_start_seeds_default_120_at_zero(tmp_path):
|
||||||
|
# First (and only) set_tempo lands at bar 2. The head of the song already
|
||||||
|
# played at the MIDI default of 120 BPM, so the tempos sidecar must open
|
||||||
|
# with a 120-BPM row at time 0 — symmetric with the 4/4 signature default.
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
meta = mido.MidiTrack(); mid.tracks.append(meta)
|
||||||
|
meta.append(mido.MetaMessage("set_tempo", tempo=250000, time=480 * 4)) # 240 at bar 2
|
||||||
|
notes = mido.MidiTrack(); mid.tracks.append(notes)
|
||||||
|
_note_pair(notes, at=0, dur=480 * 8)
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert res["tempos"][0] == {"time": 0.0, "bpm": 120.0}
|
||||||
|
assert res["tempos"][1] == {"time": 2.0, "bpm": 240.0}
|
||||||
|
# The seeded default actually matches the grid the head of the song used.
|
||||||
|
assert _downbeats(res)[0]["time"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_type0_single_track_carries_tempo_timesig_and_notes(tmp_path):
|
||||||
|
# Explicit SMF format 0: one track holds tempo + signature + notes.
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480, type=0)
|
||||||
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||||
|
tr.append(mido.MetaMessage("set_tempo", tempo=500000, time=0)) # 120
|
||||||
|
tr.append(mido.MetaMessage("time_signature", numerator=3, denominator=4, time=0))
|
||||||
|
_note_pair(tr, at=0, dur=480 * 6) # two 3/4 bars
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
assert mido.MidiFile(_save(mid, tmp_path)).type == 0 # precondition
|
||||||
|
assert res["tempos"] == [{"time": 0.0, "bpm": 120.0}]
|
||||||
|
assert res["time_signatures"] == [{"time": 0.0, "ts": [3, 4]}]
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert [d["measure"] for d in dbs] == [1, 2]
|
||||||
|
assert [d["time"] for d in dbs] == [0.0, 1.5] # 3/4 bar = 1.5 s at 120
|
||||||
|
assert all(d["den"] == 4 for d in dbs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_bars_safety_valve_caps_the_walk(tmp_path):
|
||||||
|
# A note one bar past the cap must not blow the walk past its ceiling.
|
||||||
|
mid = mido.MidiFile(ticks_per_beat=480)
|
||||||
|
tr = mido.MidiTrack(); mid.tracks.append(tr)
|
||||||
|
_note_pair(tr, at=0, dur=480 * 4 * (_TEMPO_MAP_MAX_BARS + 1))
|
||||||
|
res = convert_midi_tempo_map(_save(mid, tmp_path))
|
||||||
|
dbs = _downbeats(res)
|
||||||
|
assert len(dbs) == _TEMPO_MAP_MAX_BARS
|
||||||
|
assert dbs[-1]["measure"] == _TEMPO_MAP_MAX_BARS
|
||||||
Reference in New Issue
Block a user