gabriel / muse public
test_midi_semantic.py python
382 lines 12.9 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Tests for the new MIDI semantic porcelain — analysis helpers and CLI commands.
2
3 Coverage:
4 - muse/plugins/midi/_analysis.py: all eight analysis functions
5 - CLI commands (via CliRunner): rhythm, scale, contour, density, tension,
6 cadence, motif, voice_leading, instrumentation, tempo, quantize, humanize,
7 invert, retrograde, arpeggiate, velocity_normalize, midi_compare
8 """
9
10 from __future__ import annotations
11
12 import pathlib
13
14 import pytest
15 from tests.cli_test_helper import CliRunner
16
17 pytestmark = pytest.mark.midi
18
19 cli = None # argparse migration — CliRunner ignores this arg
20 from muse.plugins.midi._analysis import (
21 analyze_contour,
22 analyze_density,
23 analyze_rhythm,
24 check_voice_leading,
25 compute_tension,
26 detect_cadences,
27 detect_scale,
28 estimate_tempo,
29 find_motifs,
30 phrase_similarity,
31 )
32 from muse.plugins.midi._query import NoteInfo
33
34 runner = CliRunner()
35
36 # ---------------------------------------------------------------------------
37 # Helpers
38 # ---------------------------------------------------------------------------
39
40 _TPB = 480 # ticks per beat
41
42
43 def _note(pitch: int, start_beat: float, dur_beats: float = 0.5, vel: int = 80, ch: int = 0) -> NoteInfo:
44 return NoteInfo(
45 pitch=pitch,
46 velocity=vel,
47 start_tick=round(start_beat * _TPB),
48 duration_ticks=round(dur_beats * _TPB),
49 channel=ch,
50 ticks_per_beat=_TPB,
51 )
52
53
54 def _make_scale_run() -> list[NoteInfo]:
55 """C major scale ascending, two octaves."""
56 pitches = [60, 62, 64, 65, 67, 69, 71, 72, 74, 76, 77, 79, 81, 83]
57 return [_note(p, i * 0.5) for i, p in enumerate(pitches)]
58
59
60 def _make_chord_sequence() -> list[NoteInfo]:
61 """Simple C–Am–F–G progression, one chord per bar (4 beats)."""
62 chords = [
63 [60, 64, 67], # bar 1: C major
64 [57, 60, 64], # bar 2: A minor
65 [53, 57, 60], # bar 3: F major
66 [55, 59, 62], # bar 4: G major
67 ]
68 notes: list[NoteInfo] = []
69 for bar_idx, pitches in enumerate(chords):
70 start = bar_idx * 4.0 # beat offset
71 for p in pitches:
72 notes.append(_note(p, start, dur_beats=3.5))
73 return notes
74
75
76 def _make_motif_track() -> list[NoteInfo]:
77 """A track where the interval pattern [+2, -1, +3] repeats three times."""
78 base_pitches = [60, 62, 61, 64]
79 notes: list[NoteInfo] = []
80 for rep in range(3):
81 offset = rep * 4.0
82 for i, p in enumerate(base_pitches):
83 notes.append(_note(p, offset + i * 0.5))
84 return notes
85
86
87 # ---------------------------------------------------------------------------
88 # _analysis.py unit tests
89 # ---------------------------------------------------------------------------
90
91
92 class TestDetectScale:
93 def test_major_scale_detected(self) -> None:
94 notes = _make_scale_run()
95 matches = detect_scale(notes)
96 assert matches, "should return at least one match"
97 tops = [m["name"] for m in matches[:3]]
98 assert "major" in tops
99
100 def test_returns_up_to_five(self) -> None:
101 notes = _make_scale_run()
102 matches = detect_scale(notes)
103 assert 1 <= len(matches) <= 5
104
105 def test_empty_notes(self) -> None:
106 assert detect_scale([]) == []
107
108 def test_confidence_bounded(self) -> None:
109 notes = _make_scale_run()
110 for m in detect_scale(notes):
111 assert 0.0 <= m["confidence"] <= 1.0
112
113 def test_single_note_still_works(self) -> None:
114 notes = [_note(60, 0.0)]
115 matches = detect_scale(notes)
116 assert len(matches) >= 1
117
118
119 class TestAnalyzeRhythm:
120 def test_on_beat_notes_high_quantization(self) -> None:
121 # Notes exactly on quarter-note grid
122 notes = [_note(60, float(i)) for i in range(8)]
123 analysis = analyze_rhythm(notes)
124 assert analysis["quantization_score"] >= 0.95
125
126 def test_empty_notes(self) -> None:
127 a = analyze_rhythm([])
128 assert a["total_notes"] == 0
129 assert a["quantization_score"] == 1.0
130
131 def test_syncopation_score_range(self) -> None:
132 notes = _make_scale_run()
133 a = analyze_rhythm(notes)
134 assert 0.0 <= a["syncopation_score"] <= 1.0
135
136 def test_swing_ratio_positive(self) -> None:
137 notes = _make_scale_run()
138 a = analyze_rhythm(notes)
139 assert a["swing_ratio"] >= 0.0
140
141 def test_dominant_subdivision_is_string(self) -> None:
142 notes = _make_scale_run()
143 a = analyze_rhythm(notes)
144 assert isinstance(a["dominant_subdivision"], str)
145
146
147 class TestAnalyzeContour:
148 def test_ascending_scale(self) -> None:
149 notes = [_note(60 + i, float(i)) for i in range(8)]
150 analysis = analyze_contour(notes)
151 assert analysis["shape"] == "ascending"
152
153 def test_descending_scale(self) -> None:
154 notes = [_note(72 - i, float(i)) for i in range(8)]
155 analysis = analyze_contour(notes)
156 assert analysis["shape"] == "descending"
157
158 def test_arch_shape(self) -> None:
159 pitches = [60, 62, 65, 67, 69, 67, 65, 62, 60]
160 notes = [_note(p, float(i)) for i, p in enumerate(pitches)]
161 analysis = analyze_contour(notes)
162 assert analysis["shape"] in ("arch", "wave")
163
164 def test_intervals_list_is_bounded(self) -> None:
165 notes = _make_scale_run()
166 analysis = analyze_contour(notes)
167 assert len(analysis["intervals"]) <= 32
168
169 def test_single_note(self) -> None:
170 notes = [_note(60, 0.0)]
171 analysis = analyze_contour(notes)
172 assert analysis["shape"] == "flat"
173 assert analysis["intervals"] == []
174
175
176 class TestAnalyzeDensity:
177 def test_bar_count_matches(self) -> None:
178 notes = _make_chord_sequence()
179 bars = analyze_density(notes)
180 assert len(bars) == 4
181
182 def test_notes_per_beat_positive(self) -> None:
183 notes = _make_chord_sequence()
184 for b in analyze_density(notes):
185 assert b["notes_per_beat"] > 0
186
187 def test_empty_notes(self) -> None:
188 assert analyze_density([]) == []
189
190
191 class TestComputeTension:
192 def test_returns_one_entry_per_bar(self) -> None:
193 notes = _make_chord_sequence()
194 bars = compute_tension(notes)
195 assert len(bars) == 4
196
197 def test_tension_in_range(self) -> None:
198 notes = _make_chord_sequence()
199 for b in compute_tension(notes):
200 assert 0.0 <= b["tension"] <= 1.0
201
202 def test_label_is_string(self) -> None:
203 notes = _make_chord_sequence()
204 for b in compute_tension(notes):
205 assert b["label"] in ("consonant", "mild", "tense")
206
207
208 class TestDetectCadences:
209 def test_short_track_no_cadences(self) -> None:
210 notes = _make_scale_run()
211 assert detect_cadences(notes) == []
212
213 def test_four_bar_chord_sequence_may_find_cadence(self) -> None:
214 notes = _make_chord_sequence()
215 cadences = detect_cadences(notes)
216 assert isinstance(cadences, list)
217
218
219 class TestFindMotifs:
220 def test_finds_repeated_pattern(self) -> None:
221 notes = _make_motif_track()
222 motifs = find_motifs(notes, min_length=3, min_occurrences=2)
223 assert len(motifs) >= 1
224
225 def test_motif_occurrences_gte_min(self) -> None:
226 notes = _make_motif_track()
227 for m in find_motifs(notes, min_occurrences=2):
228 assert m["occurrences"] >= 2
229
230 def test_too_short_track(self) -> None:
231 notes = [_note(60, 0.0), _note(62, 0.5)]
232 assert find_motifs(notes) == []
233
234 def test_interval_pattern_is_list_of_int(self) -> None:
235 notes = _make_motif_track()
236 for m in find_motifs(notes):
237 for iv in m["interval_pattern"]:
238 assert isinstance(iv, int)
239
240
241 class TestCheckVoiceLeading:
242 def test_no_parallel_motion_on_single_voice(self) -> None:
243 # A monophonic scale has no simultaneous voices, so parallel fifths/octaves
244 # cannot occur. Large leaps between bars may still be reported.
245 notes = _make_scale_run()
246 issues = check_voice_leading(notes)
247 parallel = [i for i in issues if i["issue_type"] in ("parallel_fifths", "parallel_octaves")]
248 assert parallel == []
249
250 def test_returns_list(self) -> None:
251 notes = _make_chord_sequence()
252 issues = check_voice_leading(notes)
253 assert isinstance(issues, list)
254
255 def test_issue_types_are_valid(self) -> None:
256 notes = _make_chord_sequence()
257 valid = {"parallel_fifths", "parallel_octaves", "large_leap"}
258 for issue in check_voice_leading(notes):
259 assert issue["issue_type"] in valid
260
261
262 class TestEstimateTempo:
263 def test_regular_quarter_notes_approx_120(self) -> None:
264 # Quarter notes at 480 tpb, 120 BPM ≈ one note per beat
265 notes = [_note(60, float(i)) for i in range(8)]
266 est = estimate_tempo(notes)
267 assert 60.0 <= est["estimated_bpm"] <= 300.0
268
269 def test_empty_notes(self) -> None:
270 est = estimate_tempo([])
271 assert est["estimated_bpm"] == 120.0
272 assert est["confidence"] == "none"
273
274 def test_confidence_is_valid(self) -> None:
275 notes = _make_scale_run()
276 est = estimate_tempo(notes)
277 assert est["confidence"] in ("high", "medium", "low", "none")
278
279
280 class TestPhraseSimilarity:
281 def test_identical_phrases_score_high(self) -> None:
282 notes = _make_scale_run()
283 score = phrase_similarity(notes, notes)
284 assert score >= 0.9
285
286 def test_empty_query_returns_zero(self) -> None:
287 notes = _make_scale_run()
288 assert phrase_similarity([], notes) == 0.0
289
290 def test_score_in_range(self) -> None:
291 a = _make_scale_run()
292 b = _make_chord_sequence()
293 score = phrase_similarity(a, b)
294 assert 0.0 <= score <= 1.0
295
296
297 # ---------------------------------------------------------------------------
298 # CLI command integration tests (no real .muse repo needed for help/validation)
299 # ---------------------------------------------------------------------------
300
301
302 class TestCliHelpPages:
303 """Verify all new commands are registered and have help text."""
304
305 @pytest.mark.parametrize("cmd", [
306 ["midi", "rhythm", "--help"],
307 ["midi", "scale", "--help"],
308 ["midi", "contour", "--help"],
309 ["midi", "density", "--help"],
310 ["midi", "tension", "--help"],
311 ["midi", "cadence", "--help"],
312 ["midi", "motif", "--help"],
313 ["midi", "voice-leading", "--help"],
314 ["midi", "instrumentation", "--help"],
315 ["midi", "tempo", "--help"],
316 ["midi", "compare", "--help"],
317 ["midi", "quantize", "--help"],
318 ["midi", "humanize", "--help"],
319 ["midi", "invert", "--help"],
320 ["midi", "retrograde", "--help"],
321 ["midi", "arpeggiate", "--help"],
322 ["midi", "normalize", "--help"],
323 ["midi", "shard", "--help"],
324 ["midi", "agent-map", "--help"],
325 ["midi", "find-phrase", "--help"],
326 ])
327 def test_help_exits_zero(self, cmd: list[str]) -> None:
328 result = runner.invoke(cli, cmd)
329 assert result.exit_code == 0, f"Help failed for {cmd}: {result.output}"
330
331 def test_midi_namespace_lists_all_commands(self) -> None:
332 result = runner.invoke(cli, ["midi", "--help"])
333 assert result.exit_code == 0
334 output = result.output
335 for expected in [
336 "rhythm", "scale", "contour", "density", "tension",
337 "cadence", "motif", "voice-leading", "instrumentation",
338 "tempo", "compare", "quantize", "humanize",
339 "invert", "retrograde", "arpeggiate", "normalize",
340 "shard", "agent-map", "find-phrase",
341 ]:
342 assert expected in output, f"'{expected}' not found in midi help"
343
344
345 class TestQuantizeValidation:
346 """Validate --grid and --strength option guards."""
347
348 def test_unknown_grid_exits_error(self, tmp_path: pathlib.Path) -> None:
349 result = runner.invoke(cli, ["midi", "quantize", "fake.mid", "--grid", "99th"])
350 assert result.exit_code != 0
351
352 def test_invalid_strength_exits_error(self, tmp_path: pathlib.Path) -> None:
353 result = runner.invoke(cli, ["midi", "quantize", "fake.mid", "--strength", "2.5"])
354 assert result.exit_code != 0
355
356
357 class TestArpeggiateValidation:
358 def test_unknown_rate_exits_error(self) -> None:
359 result = runner.invoke(cli, ["midi", "arpeggiate", "fake.mid", "--rate", "64th"])
360 assert result.exit_code != 0
361
362 def test_unknown_order_exits_error(self) -> None:
363 result = runner.invoke(cli, ["midi", "arpeggiate", "fake.mid", "--order", "zigzag"])
364 assert result.exit_code != 0
365
366
367 class TestNormalizeValidation:
368 def test_min_gte_max_exits_error(self) -> None:
369 result = runner.invoke(cli, ["midi", "normalize", "fake.mid", "--min", "100", "--max", "50"])
370 assert result.exit_code != 0
371
372 def test_out_of_range_min_exits_error(self) -> None:
373 result = runner.invoke(cli, ["midi", "normalize", "fake.mid", "--min", "0"])
374 assert result.exit_code != 0
375
376
377 class TestMidiShardValidation:
378 def test_mutually_exclusive_flags(self) -> None:
379 result = runner.invoke(cli, [
380 "midi", "shard", "fake.mid", "--shards", "4", "--bars-per-shard", "8"
381 ])
382 assert result.exit_code != 0
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 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago