mix.py
python
| 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`") |