gabriel / muse public
mix.py python
140 lines 5.5 KB
00373ad0 feat: migrate CLI from typer to argparse (POSIX-compliant, order-independent) Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """muse mix — merge notes from two MIDI tracks into a single output track.
2
3 Reads two MIDI files, combines their note sequences, sorts by time, and
4 writes the result to an output path. Timing collisions are preserved —
5 if both tracks have notes at the same tick, both appear in the output.
6
7 This is the music-domain equivalent of ``muse mix``: a compositional
8 assembly operation that an AI agent can use to layer tracks without
9 creating a merge conflict.
10
11 Usage::
12
13 muse mix tracks/melody.mid tracks/harmony.mid --output tracks/full.mid
14 muse mix tracks/piano.mid tracks/strings.mid --output tracks/ensemble.mid
15 muse mix tracks/drums.mid tracks/bass.mid --output tracks/rhythm.mid --dry-run
16
17 Output::
18
19 ✅ Mixed tracks/melody.mid + tracks/harmony.mid → tracks/full.mid
20 melody.mid: 23 notes (C3–G5)
21 harmony.mid: 18 notes (C2–B4)
22 full.mid: 41 notes (C2–G5)
23 Run `muse status` to review, then `muse commit`
24 """
25
26 from __future__ import annotations
27
28 import argparse
29 import json
30 import logging
31 import pathlib
32 import sys
33
34 from muse.core.errors import ExitCode
35 from muse.core.repo import require_repo
36 from muse.plugins.midi._query import (
37 NoteInfo,
38 load_track_from_workdir,
39 notes_to_midi_bytes,
40 )
41 from muse.plugins.midi.midi_diff import _pitch_name
42
43 logger = logging.getLogger(__name__)
44
45
46 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
47 """Register the mix subcommand."""
48 parser = subparsers.add_parser("mix", help="Combine notes from two MIDI tracks into a single output track.", description=__doc__)
49 parser.add_argument("track_a", metavar="TRACK-A", help="First source .mid file.")
50 parser.add_argument("track_b", metavar="TRACK-B", help="Second source .mid file.")
51 parser.add_argument("--output", "-o", metavar="OUTPUT", required=True, help="Destination .mid file path (workspace-relative).")
52 parser.add_argument("--channel-a", metavar="N", type=int, default=None, help="Remap all notes from TRACK-A to this MIDI channel.")
53 parser.add_argument("--channel-b", metavar="N", type=int, default=None, help="Remap all notes from TRACK-B to this MIDI channel.")
54 parser.add_argument("--dry-run", "-n", action="store_true", help="Preview the operation without writing to disk.")
55 parser.set_defaults(func=run)
56
57
58 def run(args: argparse.Namespace) -> None:
59 """Combine notes from two MIDI tracks into a single output track.
60
61 ``muse mix`` reads two MIDI files, merges their note sequences sorted
62 by start tick, and writes the result to *--output*. Both source
63 files are preserved unchanged.
64
65 Use ``--channel-a`` / ``--channel-b`` to assign distinct MIDI channels
66 to each source so instruments can be differentiated in the output.
67
68 This is a compositional assembly command for AI agents: layer a melody
69 over a harmony, combine drums with bass, or stack multiple instrument
70 parts — all without a merge conflict. The structured delta captured
71 on commit will record every note inserted into the output track.
72 """
73 track_a: str = args.track_a
74 track_b: str = args.track_b
75 output: str = args.output
76 channel_a: int | None = args.channel_a
77 channel_b: int | None = args.channel_b
78 dry_run: bool = args.dry_run
79
80 root = require_repo()
81
82 result_a = load_track_from_workdir(root, track_a)
83 if result_a is None:
84 print(f"❌ Track '{track_a}' not found or not a valid MIDI file.", file=sys.stderr)
85 raise SystemExit(ExitCode.USER_ERROR)
86
87 result_b = load_track_from_workdir(root, track_b)
88 if result_b is None:
89 print(f"❌ Track '{track_b}' not found or not a valid MIDI file.", file=sys.stderr)
90 raise SystemExit(ExitCode.USER_ERROR)
91
92 notes_a, tpb_a = result_a
93 notes_b, tpb_b = result_b
94 tpb = max(tpb_a, tpb_b)
95
96 # Optionally remap channels.
97 def _maybe_remap(notes: list[NoteInfo], channel: int | None) -> list[NoteInfo]:
98 if channel is None:
99 return notes
100 return [
101 NoteInfo(
102 pitch=n.pitch, velocity=n.velocity,
103 start_tick=n.start_tick, duration_ticks=n.duration_ticks,
104 channel=channel, ticks_per_beat=n.ticks_per_beat,
105 )
106 for n in notes
107 ]
108
109 notes_a = _maybe_remap(notes_a, channel_a)
110 notes_b = _maybe_remap(notes_b, channel_b)
111
112 mixed = sorted(notes_a + notes_b, key=lambda n: (n.start_tick, n.pitch))
113
114 # Stats.
115 def _range_str(notes: list[NoteInfo]) -> str:
116 if not notes:
117 return "(empty)"
118 lo = min(n.pitch for n in notes)
119 hi = max(n.pitch for n in notes)
120 return f"{_pitch_name(lo)}–{_pitch_name(hi)}"
121
122 if dry_run:
123 print(f"\n[dry-run] Would mix {track_a} + {track_b} → {output}")
124 print(f" {track_a}: {len(notes_a)} notes ({_range_str(notes_a)})")
125 print(f" {track_b}: {len(notes_b)} notes ({_range_str(notes_b)})")
126 print(f" {output}: {len(mixed)} notes ({_range_str(mixed)})")
127 print(" No changes written (--dry-run).")
128 return
129
130 midi_bytes = notes_to_midi_bytes(mixed, tpb)
131
132 out_path = root / output
133 out_path.parent.mkdir(parents=True, exist_ok=True)
134 out_path.write_bytes(midi_bytes)
135
136 print(f"\n✅ Mixed {track_a} + {track_b} → {output}")
137 print(f" {track_a}: {len(notes_a)} notes ({_range_str(notes_a)})")
138 print(f" {track_b}: {len(notes_b)} notes ({_range_str(notes_b)})")
139 print(f" {output}: {len(mixed)} notes ({_range_str(mixed)})")
140 print(" Run `muse status` to review, then `muse commit`")