gabriel / muse public
test_music_midi_merge.py python
590 lines 23.9 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Tests for muse/plugins/midi/midi_merge.py — 21-dimension MIDI merge."""
2
3 import io
4
5 import mido
6 import pytest
7
8 pytestmark = pytest.mark.midi
9
10 from muse.core.attributes import AttributeRule
11 from muse.plugins.midi.midi_merge import (
12 INTERNAL_DIMS,
13 DIM_ALIAS,
14 DimensionSlice,
15 MidiDimensions,
16 NON_INDEPENDENT_DIMS,
17 _classify_event,
18 _hash_events,
19 dimension_conflict_detail,
20 extract_dimensions,
21 merge_midi_dimensions,
22 )
23
24
25 # ---------------------------------------------------------------------------
26 # MIDI builder helpers
27 # ---------------------------------------------------------------------------
28
29
30 def _make_midi(
31 *,
32 notes: list[tuple[int, int, int]] | None = None,
33 pitchwheel: list[tuple[int, int]] | None = None,
34 control_change: list[tuple[int, int, int]] | None = None,
35 channel_pressure: list[tuple[int, int]] | None = None,
36 poly_aftertouch: list[tuple[int, int, int]] | None = None,
37 program_change: list[tuple[int, int]] | None = None,
38 tempo: int = 500_000,
39 ticks_per_beat: int = 480,
40 ) -> bytes:
41 """Build a minimal type-0 MIDI file in memory.
42
43 Args:
44 notes: List of (abs_tick, note, velocity) note-on events.
45 pitchwheel: List of (abs_tick, pitch) pitchwheel events.
46 control_change: List of (abs_tick, control, value) CC events.
47 channel_pressure: List of (abs_tick, pressure) channel pressure events.
48 poly_aftertouch: List of (abs_tick, note, pressure) poly-pressure events.
49 program_change: List of (abs_tick, program) program-change events.
50 tempo: Microseconds per beat (default 120 BPM).
51 ticks_per_beat: MIDI resolution.
52 """
53 mid = mido.MidiFile(type=0, ticks_per_beat=ticks_per_beat)
54 track = mido.MidiTrack()
55
56 events: list[tuple[int, mido.Message]] = []
57 events.append((0, mido.MetaMessage("set_tempo", tempo=tempo, time=0)))
58
59 for abs_tick, note, vel in notes or []:
60 events.append((abs_tick, mido.Message("note_on", note=note, velocity=vel, time=0)))
61 events.append((abs_tick + 120, mido.Message("note_off", note=note, velocity=0, time=0)))
62
63 for abs_tick, pitch in pitchwheel or []:
64 events.append((abs_tick, mido.Message("pitchwheel", pitch=pitch, time=0)))
65
66 for abs_tick, ctrl, val in control_change or []:
67 events.append((abs_tick, mido.Message("control_change", control=ctrl, value=val, time=0)))
68
69 for abs_tick, pressure in channel_pressure or []:
70 events.append((abs_tick, mido.Message("aftertouch", value=pressure, time=0)))
71
72 for abs_tick, note, pressure in poly_aftertouch or []:
73 events.append((abs_tick, mido.Message("polytouch", note=note, value=pressure, time=0)))
74
75 for abs_tick, program in program_change or []:
76 events.append((abs_tick, mido.Message("program_change", program=program, time=0)))
77
78 events.sort(key=lambda x: (x[0], x[1].type))
79 prev = 0
80 for abs_tick, msg in events:
81 delta = abs_tick - prev
82 track.append(msg.copy(time=delta))
83 prev = abs_tick
84
85 track.append(mido.MetaMessage("end_of_track", time=0))
86 mid.tracks.append(track)
87
88 buf = io.BytesIO()
89 mid.save(file=buf)
90 return buf.getvalue()
91
92
93 def _midi_bytes_to_notes(midi_bytes: bytes) -> set[int]:
94 mid = mido.MidiFile(file=io.BytesIO(midi_bytes))
95 notes: set[int] = set()
96 for track in mid.tracks:
97 for msg in track:
98 if msg.type == "note_on" and msg.velocity > 0:
99 notes.add(msg.note)
100 return notes
101
102
103 def _midi_bytes_to_pitchwheels(midi_bytes: bytes) -> list[int]:
104 mid = mido.MidiFile(file=io.BytesIO(midi_bytes))
105 values: list[int] = []
106 for track in mid.tracks:
107 for msg in track:
108 if msg.type == "pitchwheel":
109 values.append(msg.pitch)
110 return values
111
112
113 def _midi_bytes_to_ccs(midi_bytes: bytes) -> list[tuple[int, int]]:
114 mid = mido.MidiFile(file=io.BytesIO(midi_bytes))
115 ccs: list[tuple[int, int]] = []
116 for track in mid.tracks:
117 for msg in track:
118 if msg.type == "control_change":
119 ccs.append((msg.control, msg.value))
120 return ccs
121
122
123 # ---------------------------------------------------------------------------
124 # INTERNAL_DIMS — verify all 21 dimensions declared
125 # ---------------------------------------------------------------------------
126
127
128 class TestInternalDims:
129 _EXPECTED_21 = [
130 "notes", "pitch_bend", "channel_pressure", "poly_pressure",
131 "cc_modulation", "cc_volume", "cc_pan", "cc_expression",
132 "cc_sustain", "cc_portamento", "cc_sostenuto", "cc_soft_pedal",
133 "cc_reverb", "cc_chorus", "cc_other",
134 "program_change", "tempo_map", "time_signatures",
135 "key_signatures", "markers", "track_structure",
136 ]
137
138 def test_exactly_21_dims(self) -> None:
139 assert len(INTERNAL_DIMS) == 21
140
141 def test_all_expected_names_present(self) -> None:
142 assert set(INTERNAL_DIMS) == set(self._EXPECTED_21)
143
144 def test_non_independent_dims(self) -> None:
145 assert NON_INDEPENDENT_DIMS == frozenset({"tempo_map", "time_signatures", "track_structure"})
146
147 def test_no_old_coarse_names_in_dims(self) -> None:
148 """Old coarse names (melodic, rhythmic, harmonic, dynamic, structural) must be gone."""
149 old_names = {"melodic", "rhythmic", "harmonic", "dynamic", "structural"}
150 assert old_names.isdisjoint(set(INTERNAL_DIMS))
151
152 def test_no_old_coarse_aliases_in_dim_alias(self) -> None:
153 """Old aliases removed from DIM_ALIAS — no backward-compat shims."""
154 old_aliases = {"melodic", "rhythmic", "harmonic", "dynamic", "structural"}
155 assert old_aliases.isdisjoint(set(DIM_ALIAS))
156
157
158 # ---------------------------------------------------------------------------
159 # _classify_event — fine-grained 21-dimension routing
160 # ---------------------------------------------------------------------------
161
162
163 class TestClassifyEvent:
164 # Note events → notes
165 def test_note_on(self) -> None:
166 assert _classify_event(mido.Message("note_on", note=60)) == "notes"
167
168 def test_note_off(self) -> None:
169 assert _classify_event(mido.Message("note_off", note=60)) == "notes"
170
171 # Pitch bend → pitch_bend
172 def test_pitchwheel(self) -> None:
173 assert _classify_event(mido.Message("pitchwheel", pitch=100)) == "pitch_bend"
174
175 # Channel pressure → channel_pressure
176 def test_channel_aftertouch(self) -> None:
177 assert _classify_event(mido.Message("aftertouch", value=64)) == "channel_pressure"
178
179 # Polyphonic aftertouch → poly_pressure
180 def test_poly_aftertouch(self) -> None:
181 assert _classify_event(mido.Message("polytouch", note=60, value=64)) == "poly_pressure"
182
183 # Named CC controllers
184 def test_cc_1_modulation(self) -> None:
185 assert _classify_event(mido.Message("control_change", control=1, value=64)) == "cc_modulation"
186
187 def test_cc_7_volume(self) -> None:
188 assert _classify_event(mido.Message("control_change", control=7, value=100)) == "cc_volume"
189
190 def test_cc_10_pan(self) -> None:
191 assert _classify_event(mido.Message("control_change", control=10, value=64)) == "cc_pan"
192
193 def test_cc_11_expression(self) -> None:
194 assert _classify_event(mido.Message("control_change", control=11, value=100)) == "cc_expression"
195
196 def test_cc_64_sustain(self) -> None:
197 assert _classify_event(mido.Message("control_change", control=64, value=127)) == "cc_sustain"
198
199 def test_cc_65_portamento(self) -> None:
200 assert _classify_event(mido.Message("control_change", control=65, value=0)) == "cc_portamento"
201
202 def test_cc_66_sostenuto(self) -> None:
203 assert _classify_event(mido.Message("control_change", control=66, value=127)) == "cc_sostenuto"
204
205 def test_cc_67_soft_pedal(self) -> None:
206 assert _classify_event(mido.Message("control_change", control=67, value=64)) == "cc_soft_pedal"
207
208 def test_cc_91_reverb(self) -> None:
209 assert _classify_event(mido.Message("control_change", control=91, value=40)) == "cc_reverb"
210
211 def test_cc_93_chorus(self) -> None:
212 assert _classify_event(mido.Message("control_change", control=93, value=20)) == "cc_chorus"
213
214 def test_cc_other_unlisted(self) -> None:
215 # CC 2 is not individually named → cc_other
216 assert _classify_event(mido.Message("control_change", control=2, value=50)) == "cc_other"
217
218 def test_cc_3_other(self) -> None:
219 assert _classify_event(mido.Message("control_change", control=3, value=50)) == "cc_other"
220
221 # Program change
222 def test_program_change(self) -> None:
223 assert _classify_event(mido.Message("program_change", program=40)) == "program_change"
224
225 # Tempo / time-sig → non-independent
226 def test_set_tempo(self) -> None:
227 assert _classify_event(mido.MetaMessage("set_tempo", tempo=500_000)) == "tempo_map"
228
229 def test_time_signature(self) -> None:
230 msg = mido.MetaMessage(
231 "time_signature", numerator=4, denominator=4,
232 clocks_per_click=24, notated_32nd_notes_per_beat=8,
233 )
234 assert _classify_event(msg) == "time_signatures"
235
236 # Key signature
237 def test_key_signature(self) -> None:
238 assert _classify_event(mido.MetaMessage("key_signature", key="C")) == "key_signatures"
239
240 # Markers
241 def test_marker(self) -> None:
242 assert _classify_event(mido.MetaMessage("marker", text="verse")) == "markers"
243
244 def test_text(self) -> None:
245 assert _classify_event(mido.MetaMessage("text", text="hello")) == "markers"
246
247 # Track structure
248 def test_track_name(self) -> None:
249 assert _classify_event(mido.MetaMessage("track_name", name="Piano")) == "track_structure"
250
251 def test_end_of_track_returns_none(self) -> None:
252 # end_of_track is reconstructed during MIDI assembly, not stored in any dim
253 assert _classify_event(mido.MetaMessage("end_of_track")) is None
254
255
256 # ---------------------------------------------------------------------------
257 # extract_dimensions
258 # ---------------------------------------------------------------------------
259
260
261 class TestExtractDimensions:
262 def test_empty_midi_has_all_21_dims(self) -> None:
263 midi = _make_midi()
264 dims = extract_dimensions(midi)
265 assert set(dims.slices.keys()) == set(INTERNAL_DIMS)
266
267 def test_notes_in_notes_bucket(self) -> None:
268 midi = _make_midi(notes=[(0, 60, 80), (480, 64, 80)])
269 dims = extract_dimensions(midi)
270 note_on = [msg for _, msg in dims.slices["notes"].events if msg.type == "note_on"]
271 assert len(note_on) == 2
272
273 def test_pitchwheel_in_pitch_bend(self) -> None:
274 midi = _make_midi(pitchwheel=[(100, 500), (200, -500)])
275 dims = extract_dimensions(midi)
276 assert len(dims.slices["pitch_bend"].events) == 2
277
278 def test_cc_volume_bucket(self) -> None:
279 midi = _make_midi(control_change=[(0, 7, 100)])
280 dims = extract_dimensions(midi)
281 assert len(dims.slices["cc_volume"].events) == 1
282
283 def test_cc_sustain_bucket(self) -> None:
284 midi = _make_midi(control_change=[(0, 64, 127)])
285 dims = extract_dimensions(midi)
286 assert len(dims.slices["cc_sustain"].events) == 1
287
288 def test_cc_modulation_bucket(self) -> None:
289 midi = _make_midi(control_change=[(0, 1, 90)])
290 dims = extract_dimensions(midi)
291 assert len(dims.slices["cc_modulation"].events) == 1
292
293 def test_cc_other_bucket(self) -> None:
294 midi = _make_midi(control_change=[(0, 2, 50)])
295 dims = extract_dimensions(midi)
296 assert len(dims.slices["cc_other"].events) == 1
297
298 def test_tempo_in_tempo_map(self) -> None:
299 midi = _make_midi(tempo=600_000)
300 dims = extract_dimensions(midi)
301 types = {msg.type for _, msg in dims.slices["tempo_map"].events}
302 assert "set_tempo" in types
303
304 def test_content_hash_is_deterministic(self) -> None:
305 midi = _make_midi(notes=[(0, 60, 80)])
306 d1 = extract_dimensions(midi)
307 d2 = extract_dimensions(midi)
308 assert d1.slices["notes"].content_hash == d2.slices["notes"].content_hash
309
310 def test_different_notes_give_different_hash(self) -> None:
311 da = extract_dimensions(_make_midi(notes=[(0, 60, 80)]))
312 db = extract_dimensions(_make_midi(notes=[(0, 62, 80)]))
313 assert da.slices["notes"].content_hash != db.slices["notes"].content_hash
314
315 def test_different_dimensions_independent_hashes(self) -> None:
316 """Changing notes must not affect pitch_bend hash."""
317 base = _make_midi(pitchwheel=[(0, 200)])
318 with_notes = _make_midi(notes=[(0, 60, 80)], pitchwheel=[(0, 200)])
319 da = extract_dimensions(base)
320 db = extract_dimensions(with_notes)
321 assert da.slices["pitch_bend"].content_hash == db.slices["pitch_bend"].content_hash
322 assert da.slices["notes"].content_hash != db.slices["notes"].content_hash
323
324 def test_ticks_per_beat_preserved(self) -> None:
325 midi = _make_midi(ticks_per_beat=960)
326 assert extract_dimensions(midi).ticks_per_beat == 960
327
328 def test_invalid_bytes_raises(self) -> None:
329 with pytest.raises(ValueError, match="Failed to parse"):
330 extract_dimensions(b"not a midi file")
331
332 def test_get_by_fine_alias(self) -> None:
333 midi = _make_midi(pitchwheel=[(0, 100)])
334 dims = extract_dimensions(midi)
335 assert dims.get("pitch_bend").name == "pitch_bend"
336 assert dims.get("sustain").name == "cc_sustain"
337 assert dims.get("volume").name == "cc_volume"
338
339 def test_get_unknown_alias_raises(self) -> None:
340 midi = _make_midi()
341 dims = extract_dimensions(midi)
342 with pytest.raises(KeyError):
343 dims.get("melodic") # old alias — removed
344
345
346 # ---------------------------------------------------------------------------
347 # dimension_conflict_detail
348 # ---------------------------------------------------------------------------
349
350
351 class TestDimensionConflictDetail:
352 def _dims_from(
353 self,
354 notes: list[tuple[int, int, int]] | None = None,
355 pitchwheel: list[tuple[int, int]] | None = None,
356 control_change: list[tuple[int, int, int]] | None = None,
357 tempo: int = 500_000,
358 ) -> MidiDimensions:
359 return extract_dimensions(_make_midi(
360 notes=notes, pitchwheel=pitchwheel,
361 control_change=control_change, tempo=tempo,
362 ))
363
364 def test_unchanged_when_all_same(self) -> None:
365 base = self._dims_from(notes=[(0, 60, 80)])
366 detail = dimension_conflict_detail(base, base, base)
367 assert all(v == "unchanged" for v in detail.values())
368
369 def test_notes_left_only(self) -> None:
370 base = self._dims_from()
371 left = self._dims_from(notes=[(0, 60, 80)])
372 detail = dimension_conflict_detail(base, left, base)
373 assert detail["notes"] == "left_only"
374 assert detail["pitch_bend"] == "unchanged"
375
376 def test_pitch_bend_right_only(self) -> None:
377 base = self._dims_from()
378 right = self._dims_from(pitchwheel=[(0, 100)])
379 detail = dimension_conflict_detail(base, base, right)
380 assert detail["pitch_bend"] == "right_only"
381
382 def test_both_sides_change_notes(self) -> None:
383 base = self._dims_from()
384 left = self._dims_from(notes=[(0, 60, 80)])
385 right = self._dims_from(notes=[(0, 64, 80)])
386 detail = dimension_conflict_detail(base, left, right)
387 assert detail["notes"] == "both"
388
389 def test_independent_changes_in_separate_dims(self) -> None:
390 base = self._dims_from()
391 left = self._dims_from(notes=[(0, 60, 80)])
392 right = self._dims_from(pitchwheel=[(0, 200)])
393 detail = dimension_conflict_detail(base, left, right)
394 assert detail["notes"] == "left_only"
395 assert detail["pitch_bend"] == "right_only"
396 assert detail["cc_volume"] == "unchanged"
397
398 def test_cc_volume_vs_cc_sustain_independent(self) -> None:
399 """Two different CC dims changed independently."""
400 base = self._dims_from()
401 left = self._dims_from(control_change=[(0, 7, 100)]) # cc_volume
402 right = self._dims_from(control_change=[(0, 64, 127)]) # cc_sustain
403 detail = dimension_conflict_detail(base, left, right)
404 assert detail["cc_volume"] == "left_only"
405 assert detail["cc_sustain"] == "right_only"
406
407
408 # ---------------------------------------------------------------------------
409 # merge_midi_dimensions
410 # ---------------------------------------------------------------------------
411
412
413 class TestMergeMidiDimensions:
414 def _midi(
415 self,
416 notes: list[tuple[int, int, int]] | None = None,
417 pitchwheel: list[tuple[int, int]] | None = None,
418 control_change: list[tuple[int, int, int]] | None = None,
419 tempo: int = 500_000,
420 ticks_per_beat: int = 480,
421 ) -> bytes:
422 return _make_midi(
423 notes=notes, pitchwheel=pitchwheel, control_change=control_change,
424 tempo=tempo, ticks_per_beat=ticks_per_beat,
425 )
426
427 def _rules(self, *rules: tuple[str, str, str]) -> list[AttributeRule]:
428 return [AttributeRule(p, d, s, i + 1) for i, (p, d, s) in enumerate(rules)]
429
430 # --- Clean auto-merge: independent dimensions ---------------------------
431
432 def test_independent_notes_and_pitch_bend(self) -> None:
433 """Left changed notes, right changed pitch_bend → clean auto-merge."""
434 base = self._midi()
435 left = self._midi(notes=[(0, 60, 80)])
436 right = self._midi(pitchwheel=[(0, 500)])
437 result = merge_midi_dimensions(base, left, right, [], "song.mid")
438 assert result is not None
439 merged, _ = result
440 assert _midi_bytes_to_notes(merged) == {60}
441 assert _midi_bytes_to_pitchwheels(merged) == [500]
442
443 def test_independent_two_cc_dims(self) -> None:
444 """Left changed cc_volume, right changed cc_sustain → clean auto-merge."""
445 base = self._midi()
446 left = self._midi(control_change=[(0, 7, 100)]) # cc_volume
447 right = self._midi(control_change=[(0, 64, 127)]) # cc_sustain
448 result = merge_midi_dimensions(base, left, right, [], "song.mid")
449 assert result is not None
450 merged, _ = result
451 ccs = dict(_midi_bytes_to_ccs(merged))
452 assert ccs.get(7) == 100
453 assert ccs.get(64) == 127
454
455 def test_one_side_only_changed_notes(self) -> None:
456 base = self._midi()
457 left = self._midi(notes=[(0, 64, 80)])
458 result = merge_midi_dimensions(base, left, self._midi(), [], "song.mid")
459 assert result is not None
460 merged, _ = result
461 assert _midi_bytes_to_notes(merged) == {64}
462
463 def test_unchanged_both_sides_preserved(self) -> None:
464 base = self._midi(notes=[(0, 60, 80)])
465 result = merge_midi_dimensions(base, base, base, [], "song.mid")
466 assert result is not None
467 merged, _ = result
468 assert _midi_bytes_to_notes(merged) == {60}
469
470 # --- Strategy override via AttributeRule --------------------------------
471
472 def test_notes_conflict_resolved_by_ours_rule(self) -> None:
473 base = self._midi()
474 left = self._midi(notes=[(0, 60, 80)])
475 right = self._midi(notes=[(0, 64, 80)])
476 rules = self._rules(("*", "notes", "ours"))
477 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
478 assert result is not None
479 merged, report = result
480 assert _midi_bytes_to_notes(merged) == {60}
481 assert "ours" in report["notes"]
482
483 def test_notes_conflict_resolved_by_theirs_rule(self) -> None:
484 base = self._midi()
485 left = self._midi(notes=[(0, 60, 80)])
486 right = self._midi(notes=[(0, 64, 80)])
487 rules = self._rules(("*", "notes", "theirs"))
488 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
489 assert result is not None
490 merged, _ = result
491 assert _midi_bytes_to_notes(merged) == {64}
492
493 def test_pitch_bend_conflict_resolved_by_theirs(self) -> None:
494 base = self._midi()
495 left = self._midi(pitchwheel=[(0, 200)])
496 right = self._midi(pitchwheel=[(0, -200)])
497 rules = self._rules(("*", "pitch_bend", "theirs"))
498 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
499 assert result is not None
500 merged, _ = result
501 assert _midi_bytes_to_pitchwheels(merged) == [-200]
502
503 def test_wildcard_dim_rule_resolves_all(self) -> None:
504 base = self._midi()
505 left = self._midi(notes=[(0, 60, 80)], pitchwheel=[(0, 200)])
506 right = self._midi(notes=[(0, 64, 80)], pitchwheel=[(0, -200)])
507 rules = self._rules(("*", "*", "ours"))
508 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
509 assert result is not None
510 merged, _ = result
511 assert _midi_bytes_to_notes(merged) == {60}
512 assert 200 in _midi_bytes_to_pitchwheels(merged)
513
514 def test_notes_conflict_no_rule_returns_none(self) -> None:
515 """Both sides changed notes, no matching rule → conflict → None."""
516 base = self._midi()
517 left = self._midi(notes=[(0, 60, 80)])
518 right = self._midi(notes=[(0, 64, 80)])
519 assert merge_midi_dimensions(base, left, right, [], "song.mid") is None
520
521 def test_manual_strategy_returns_none(self) -> None:
522 base = self._midi()
523 left = self._midi(notes=[(0, 60, 80)])
524 right = self._midi(notes=[(0, 64, 80)])
525 rules = self._rules(("*", "notes", "manual"))
526 assert merge_midi_dimensions(base, left, right, rules, "song.mid") is None
527
528 # --- Report content -----------------------------------------------------
529
530 def test_report_shows_left_right_labels(self) -> None:
531 base = self._midi()
532 left = self._midi(notes=[(0, 60, 80)])
533 right = self._midi(pitchwheel=[(0, 100)])
534 result = merge_midi_dimensions(base, left, right, [], "song.mid")
535 assert result is not None
536 _, report = result
537 assert report["notes"] == "left"
538 assert report["pitch_bend"] == "right"
539
540 # --- Output is valid MIDI -----------------------------------------------
541
542 def test_merged_bytes_parseable(self) -> None:
543 base = self._midi()
544 left = self._midi(notes=[(0, 60, 80)])
545 right = self._midi(pitchwheel=[(0, 100)])
546 result = merge_midi_dimensions(base, left, right, [], "song.mid")
547 assert result is not None
548 merged, _ = result
549 parsed = mido.MidiFile(file=io.BytesIO(merged))
550 assert parsed.ticks_per_beat == 480
551
552 def test_merged_bytes_preserve_ticks_per_beat(self) -> None:
553 base = _make_midi(ticks_per_beat=960)
554 left = _make_midi(notes=[(0, 60, 80)], ticks_per_beat=960)
555 right = _make_midi(pitchwheel=[(0, 100)], ticks_per_beat=960)
556 result = merge_midi_dimensions(base, left, right, [], "song.mid")
557 assert result is not None
558 merged, _ = result
559 assert mido.MidiFile(file=io.BytesIO(merged)).ticks_per_beat == 960
560
561 # --- Path-pattern matching in rules ------------------------------------
562
563 def test_path_specific_rule_respected(self) -> None:
564 base = self._midi()
565 left = self._midi(pitchwheel=[(0, 200)])
566 right = self._midi(pitchwheel=[(0, -200)])
567 rules = self._rules(("keys/*", "pitch_bend", "theirs"))
568
569 result_keys = merge_midi_dimensions(base, left, right, rules, "keys/piano.mid")
570 assert result_keys is not None
571 merged_keys, _ = result_keys
572 assert _midi_bytes_to_pitchwheels(merged_keys) == [-200]
573
574 result_other = merge_midi_dimensions(base, left, right, rules, "other/bass.mid")
575 assert result_other is None # rule doesn't match this path
576
577 def test_multi_rule_priority_order(self) -> None:
578 """Lower-priority rule does not override higher-priority one."""
579 base = self._midi()
580 left = self._midi(control_change=[(0, 7, 100)]) # cc_volume
581 right = self._midi(control_change=[(0, 7, 50)])
582 rules = self._rules(
583 ("*", "cc_volume", "ours"), # priority 1
584 ("*", "cc_volume", "theirs"), # priority 2 — should be ignored
585 )
586 result = merge_midi_dimensions(base, left, right, rules, "song.mid")
587 assert result is not None
588 merged, _ = result
589 ccs = dict(_midi_bytes_to_ccs(merged))
590 assert ccs.get(7) == 100 # ours = left = 100
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 22 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 28 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago