mpump 1.3.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mpump/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mpump — hot-plug MIDI sequencer."""
2
+
3
+ __version__ = "1.3.10"
mpump/cli.py ADDED
@@ -0,0 +1,268 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ mpump — hot-plug MIDI sequencer.
4
+
5
+ Watches for USB MIDI devices and starts sequencing automatically.
6
+ Plug or unplug devices at any time — loops start/stop automatically.
7
+
8
+ Default: opens the terminal UI. Pass --cli for headless operation.
9
+ """
10
+
11
+ import argparse
12
+ import sys
13
+
14
+ from .devices import DEVICE_REGISTRY
15
+ from .keys import parse_key, valid_key_names, DEFAULT_KEY, DEFAULT_OCTAVE, OCTAVE_MIN, OCTAVE_MAX
16
+ from .patterns import get_pattern, list_patterns, GENRE_NAMES, GENRES
17
+ from .patterns_t8 import (
18
+ get_t8_drum_pattern, get_t8_bass_pattern,
19
+ list_t8_patterns, list_t8_bass_patterns, T8_GENRE_NAMES, T8_BASS,
20
+ )
21
+ from . import extras as _extras # noqa: F401 — injects "extras" genre
22
+ from .patterns_j6 import (
23
+ get_j6_pattern, get_j6_chord_set, list_j6_patterns, J6_GENRE_NAMES, J6_GENRES,
24
+ )
25
+ from .scanner import DeviceScanner
26
+
27
+ DEFAULT_GENRE = "techno"
28
+ DEFAULT_PATTERN = 1
29
+ DEFAULT_BPM = 120
30
+ DEFAULT_T8_GENRE = "techno"
31
+ DEFAULT_T8_PATTERN = 1
32
+ DEFAULT_T8_BASS_GENRE = "techno"
33
+ DEFAULT_T8_BASS_PATTERN = 1
34
+ DEFAULT_J6_GENRE = "techno"
35
+ DEFAULT_J6_PATTERN = 1
36
+
37
+
38
+ def parse_args() -> argparse.Namespace:
39
+ parser = argparse.ArgumentParser(
40
+ prog="mpump",
41
+ description="mpump — hot-plug MIDI sequencer (default: terminal UI, use --cli for headless)",
42
+ formatter_class=argparse.RawDescriptionHelpFormatter,
43
+ epilog=(
44
+ "S-1 genres: " + ", ".join(GENRE_NAMES) + "\n"
45
+ "T-8 genres: " + ", ".join(T8_GENRE_NAMES) + "\n"
46
+ "J-6 genres: " + ", ".join(J6_GENRE_NAMES) + "\n"
47
+ "Keys: " + ", ".join(valid_key_names()) + "\n\n"
48
+ "UI key bindings:\n"
49
+ " Tab Switch focus between S-1, T-8 and J-6 panels\n"
50
+ " ← / → Previous / next genre\n"
51
+ " ↑ / ↓ Previous / next pattern\n"
52
+ " b / B T-8 bass pattern down / up\n"
53
+ " k / K Key down / up (not J-6)\n"
54
+ " o / O Octave down / up (not J-6)\n"
55
+ " Space Pause / resume focused device\n"
56
+ " = / - BPM +5 / -5\n"
57
+ " Enter Commit browsed pattern / genre\n"
58
+ " q Quit\n\n"
59
+ "Other interfaces:\n"
60
+ " mpump-web Web UI (Mac + Python, control from any browser)\n"
61
+ " mpump/server/ Browser sequencer (standalone, Web MIDI, 50 devices)\n"
62
+ ),
63
+ )
64
+ parser.add_argument(
65
+ "--cli", action="store_true",
66
+ help="Run in headless CLI mode instead of the terminal UI",
67
+ )
68
+ parser.add_argument(
69
+ "--bpm", type=int, default=DEFAULT_BPM, metavar="BPM",
70
+ help=f"Tempo in BPM (20–300, default {DEFAULT_BPM})",
71
+ )
72
+
73
+ # S-1 options
74
+ s1 = parser.add_argument_group("S-1 (monosynth)")
75
+ s1.add_argument(
76
+ "--genre", default=DEFAULT_GENRE, choices=GENRE_NAMES, metavar="GENRE",
77
+ help=f"Pattern genre: {', '.join(GENRE_NAMES)} (default: {DEFAULT_GENRE})",
78
+ )
79
+ s1.add_argument(
80
+ "--pattern", type=int, default=DEFAULT_PATTERN, metavar="N",
81
+ help=f"Pattern 1–10 within genre (default: {DEFAULT_PATTERN})",
82
+ )
83
+ s1.add_argument(
84
+ "--key", default=DEFAULT_KEY, metavar="KEY",
85
+ help=f"Root key, e.g. A, F#, Bb (default: {DEFAULT_KEY})",
86
+ )
87
+ s1.add_argument(
88
+ "--octave", type=int, default=DEFAULT_OCTAVE, metavar="N",
89
+ help=(
90
+ f"Root octave ({OCTAVE_MIN}–{OCTAVE_MAX}, default {DEFAULT_OCTAVE}). "
91
+ "A2=45, A3=57, A1=33"
92
+ ),
93
+ )
94
+
95
+ # T-8 options
96
+ t8 = parser.add_argument_group("T-8 (drum machine + bass)")
97
+ t8.add_argument(
98
+ "--t8-genre", default=DEFAULT_T8_GENRE, choices=T8_GENRE_NAMES,
99
+ metavar="GENRE",
100
+ help=f"T-8 drum genre (default: {DEFAULT_T8_GENRE})",
101
+ )
102
+ t8.add_argument(
103
+ "--t8-pattern", type=int, default=DEFAULT_T8_PATTERN, metavar="N",
104
+ help=f"T-8 drum pattern 1–10 (default: {DEFAULT_T8_PATTERN})",
105
+ )
106
+ t8.add_argument(
107
+ "--t8-bass-genre", default=DEFAULT_T8_BASS_GENRE, choices=T8_GENRE_NAMES,
108
+ metavar="GENRE",
109
+ help=f"T-8 bass genre, independent of drums (default: same as --t8-genre)",
110
+ )
111
+ t8.add_argument(
112
+ "--t8-bass-pattern", type=int, default=DEFAULT_T8_BASS_PATTERN, metavar="N",
113
+ help=f"T-8 bass pattern 1–10, independent of drums (default: {DEFAULT_T8_BASS_PATTERN})",
114
+ )
115
+ t8.add_argument(
116
+ "--t8-key", default=DEFAULT_KEY, metavar="KEY",
117
+ help=f"Root key for T-8 bass (default: {DEFAULT_KEY})",
118
+ )
119
+ t8.add_argument(
120
+ "--t8-octave", type=int, default=DEFAULT_OCTAVE, metavar="N",
121
+ help=f"Root octave for T-8 bass ({OCTAVE_MIN}–{OCTAVE_MAX}, default {DEFAULT_OCTAVE})",
122
+ )
123
+
124
+ # J-6 options
125
+ j6 = parser.add_argument_group("J-6 (chord synthesizer)")
126
+ j6.add_argument(
127
+ "--j6-genre", default=DEFAULT_J6_GENRE, choices=J6_GENRE_NAMES, metavar="GENRE",
128
+ help=f"J-6 chord genre: {', '.join(J6_GENRE_NAMES)} (default: {DEFAULT_J6_GENRE})",
129
+ )
130
+ j6.add_argument(
131
+ "--j6-pattern", type=int, default=DEFAULT_J6_PATTERN, metavar="N",
132
+ help=f"J-6 chord pattern 1–10 (default: {DEFAULT_J6_PATTERN})",
133
+ )
134
+
135
+ # Listing
136
+ parser.add_argument("--list", action="store_true", help="List all synth patterns and exit")
137
+ parser.add_argument("--list-drums", action="store_true", help="List all drum patterns and exit")
138
+ parser.add_argument("--list-bass", action="store_true", help="List all bass patterns and exit")
139
+ parser.add_argument("--list-devices", action="store_true", help="List all 50 supported devices and exit")
140
+ # Deprecated aliases
141
+ parser.add_argument("--list-t8", action="store_true", help=argparse.SUPPRESS)
142
+ parser.add_argument("--list-t8-bass", action="store_true", help=argparse.SUPPRESS)
143
+ parser.add_argument("--list-j6", action="store_true", help="List all J-6 chord patterns and exit")
144
+
145
+ return parser.parse_args()
146
+
147
+
148
+ def _print_devices() -> None:
149
+ print("Supported devices (50):\n")
150
+ print(f" {'ID':<20} {'Label':<22} {'Mode':<12} {'Port match'}")
151
+ print(f" {'─'*20} {'─'*22} {'─'*12} {'─'*20}")
152
+ for d in DEVICE_REGISTRY:
153
+ print(f" {d.id:<20} {d.label:<22} {d.mode:<12} {d.port_match}")
154
+
155
+
156
+ def main() -> None:
157
+ args = parse_args()
158
+
159
+ if args.list_devices:
160
+ _print_devices()
161
+ return
162
+
163
+ if args.list:
164
+ print(list_patterns())
165
+ return
166
+
167
+ if args.list_drums or args.list_t8:
168
+ print(list_t8_patterns())
169
+ return
170
+
171
+ if args.list_bass or args.list_t8_bass:
172
+ print(list_t8_bass_patterns())
173
+ return
174
+
175
+ if args.list_j6:
176
+ print(list_j6_patterns())
177
+ return
178
+
179
+ if not (20 <= args.bpm <= 300):
180
+ print("Error: --bpm must be between 20 and 300", file=sys.stderr)
181
+ sys.exit(1)
182
+
183
+ if not args.cli:
184
+ from .ui import run_ui
185
+ run_ui(
186
+ bpm=args.bpm,
187
+ s1_genre=args.genre, s1_pattern=args.pattern,
188
+ s1_key=args.key, s1_octave=args.octave,
189
+ t8_genre=args.t8_genre, t8_pattern=args.t8_pattern,
190
+ t8_bass_genre=args.t8_bass_genre, t8_bass_pattern=args.t8_bass_pattern,
191
+ t8_key=args.t8_key, t8_octave=args.t8_octave,
192
+ j6_genre=args.j6_genre, j6_pattern=args.j6_pattern,
193
+ )
194
+ return
195
+
196
+ # S-1 ----------------------------------------------------------------
197
+ try:
198
+ s1_root = parse_key(args.key, args.octave)
199
+ except ValueError as e:
200
+ print(f"Error (S-1 key): {e}", file=sys.stderr)
201
+ sys.exit(1)
202
+
203
+ try:
204
+ s1_pattern = get_pattern(args.genre, args.pattern)
205
+ except ValueError as e:
206
+ print(f"Error (S-1 pattern): {e}", file=sys.stderr)
207
+ sys.exit(1)
208
+
209
+ # T-8 ----------------------------------------------------------------
210
+ try:
211
+ t8_bass_root = parse_key(args.t8_key, args.t8_octave)
212
+ except ValueError as e:
213
+ print(f"Error (T-8 key): {e}", file=sys.stderr)
214
+ sys.exit(1)
215
+
216
+ try:
217
+ t8_drum = get_t8_drum_pattern(args.t8_genre, args.t8_pattern)
218
+ except ValueError as e:
219
+ print(f"Error (T-8 pattern): {e}", file=sys.stderr)
220
+ sys.exit(1)
221
+
222
+ try:
223
+ t8_bass, t8_bass_desc = get_t8_bass_pattern(args.t8_bass_genre, args.t8_bass_pattern)
224
+ except ValueError as e:
225
+ print(f"Error (T-8 bass pattern): {e}", file=sys.stderr)
226
+ sys.exit(1)
227
+
228
+ t8_bass_name = T8_BASS[args.t8_bass_genre][args.t8_bass_pattern - 1][0]
229
+
230
+ # J-6 ----------------------------------------------------------------
231
+ try:
232
+ j6_pattern = get_j6_pattern(args.j6_genre, args.j6_pattern)
233
+ except ValueError as e:
234
+ print(f"Error (J-6 pattern): {e}", file=sys.stderr)
235
+ sys.exit(1)
236
+
237
+ j6_chord_set = get_j6_chord_set(args.j6_genre)
238
+ j6_pc = j6_chord_set - 1 # PC value is 0-indexed
239
+
240
+ # Header -------------------------------------------------------------
241
+ s1_name, s1_desc, _ = GENRES[args.genre][args.pattern - 1]
242
+ from .patterns_t8 import T8_DRUMS
243
+ t8_name, t8_desc, _ = T8_DRUMS[args.t8_genre][args.t8_pattern - 1]
244
+ j6_name, j6_desc, _ = J6_GENRES[args.j6_genre][args.j6_pattern - 1]
245
+
246
+ print(f"mpump — {args.bpm} BPM (Ctrl-C to quit)")
247
+ print(f"S-1 key={args.key}{args.octave} {args.genre} #{args.pattern}: {s1_name}")
248
+ print(f' "{s1_desc}"')
249
+ print(f"T-8 key={args.t8_key}{args.t8_octave}")
250
+ print(f" drums {args.t8_genre} #{args.t8_pattern}: {t8_name} — {t8_desc}")
251
+ print(f" bass {args.t8_bass_genre} #{args.t8_bass_pattern}: {t8_bass_name} — {t8_bass_desc}")
252
+ print(f"J-6 {args.j6_genre} #{args.j6_pattern}: {j6_name} (chord set #{j6_chord_set})")
253
+ print(f' "{j6_desc}"')
254
+ print()
255
+
256
+ scanner = DeviceScanner(
257
+ bpm=args.bpm,
258
+ device_states={
259
+ "s1": {"pattern": s1_pattern, "root": s1_root},
260
+ "t8": {"drum_pattern": t8_drum, "bass_pattern": t8_bass, "bass_root": t8_bass_root},
261
+ "j6": {"pattern": j6_pattern, "program_change": j6_pc},
262
+ },
263
+ )
264
+ scanner.run()
265
+
266
+
267
+ if __name__ == "__main__":
268
+ main()
mpump/devices.py ADDED
@@ -0,0 +1,193 @@
1
+ # Data-driven device registry for 50 USB MIDI devices.
2
+ #
3
+ # Mirrors mpump/server/src/data/devices.ts — every device that the browser
4
+ # sequencer supports is also listed here so the Python CLI/TUI/web backend
5
+ # can auto-detect and sequence it.
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class DeviceConfig:
14
+ id: str # "s1", "tr6s", etc.
15
+ label: str # "S-1", "TR-6S"
16
+ port_match: str # substring to match in MIDI port name
17
+ mode: str # "synth" | "drums" | "drums+bass"
18
+ channel: int # main MIDI channel (0-indexed)
19
+ bass_channel: int | None = None # bass channel for drums+bass
20
+ root_note: int = 45 # default root MIDI note
21
+ gate_frac: float = 0.5
22
+ drum_gate_frac: float = 0.10
23
+ bass_gate_frac: float = 0.50
24
+ base_velocity: int = 100
25
+ drum_map: dict[int, int] | None = None
26
+ has_key: bool = True
27
+ has_octave: bool = True
28
+ use_program_change: bool = False
29
+ send_clock: bool = False
30
+
31
+
32
+ def _synth(
33
+ id: str, label: str, port_match: str, **kw,
34
+ ) -> DeviceConfig:
35
+ return DeviceConfig(
36
+ id=id, label=label, port_match=port_match, mode="synth",
37
+ channel=kw.pop("channel", 0), **kw,
38
+ )
39
+
40
+
41
+ def _drums(
42
+ id: str, label: str, port_match: str, **kw,
43
+ ) -> DeviceConfig:
44
+ return DeviceConfig(
45
+ id=id, label=label, port_match=port_match, mode="drums",
46
+ channel=kw.pop("channel", 9), has_key=kw.pop("has_key", False),
47
+ has_octave=kw.pop("has_octave", False), root_note=kw.pop("root_note", 36),
48
+ **kw,
49
+ )
50
+
51
+
52
+ def _drums_bass(
53
+ id: str, label: str, port_match: str, **kw,
54
+ ) -> DeviceConfig:
55
+ return DeviceConfig(
56
+ id=id, label=label, port_match=port_match, mode="drums+bass",
57
+ channel=kw.pop("channel", 9), bass_channel=kw.pop("bass_channel", 1),
58
+ **kw,
59
+ )
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # 50-device registry (order matches devices.ts)
64
+ # ---------------------------------------------------------------------------
65
+
66
+ DEVICE_REGISTRY: list[DeviceConfig] = [
67
+ # ── Roland AIRA Compact ──────────────────────────────────────────────
68
+ _synth("s1", "S-1", "S-1", send_clock=True),
69
+ _drums_bass("t8", "T-8", "T-8", send_clock=True),
70
+ _synth("j6", "J-6", "J-6",
71
+ root_note=60, gate_frac=0.8, has_key=False, has_octave=False,
72
+ use_program_change=True, send_clock=True),
73
+
74
+ # ── Roland ────────────────────────────────────────────────────────────
75
+ _drums("tr6s", "TR-6S", "TR-6S"),
76
+ _drums("tr8s", "TR-8S", "TR-8S"),
77
+ _drums_bass("mc101", "MC-101", "MC-101", channel=0, bass_channel=1),
78
+ _drums_bass("mc707", "MC-707", "MC-707", channel=0, bass_channel=1),
79
+ _synth("sh4d", "SH-4d", "SH-4d"),
80
+ _synth("tb3", "TB-3", "TB-3"),
81
+ _synth("tb03", "TB-03", "Boutique"),
82
+ _synth("jdxi", "JD-Xi", "JD-Xi"),
83
+ _synth("ju06a", "JU-06A", "Boutique"),
84
+ _synth("se02", "SE-02", "Boutique"),
85
+ _synth("gaia2", "GAIA 2", "GAIA 2"),
86
+ _synth("sp404mk2", "SP-404MK2", "SP-404MK2",
87
+ root_note=36, gate_frac=0.3, has_key=False, has_octave=False),
88
+
89
+ # ── Korg ──────────────────────────────────────────────────────────────
90
+ _synth("minilogue_xd", "minilogue xd", "minilogue xd"),
91
+ _synth("monologue", "monologue", "monologue"),
92
+ _synth("nts1", "NTS-1", "NTS-1 digital kit"),
93
+ _drums_bass("drumlogue", "drumlogue", "drumlogue", channel=9, bass_channel=0),
94
+ _synth("minilogue", "minilogue", "minilogue"),
95
+ _synth("wavestate", "wavestate", "wavestate"),
96
+ _synth("opsix", "opsix", "opsix"),
97
+ _synth("modwave", "modwave", "modwave"),
98
+
99
+ # ── Novation ──────────────────────────────────────────────────────────
100
+ _drums_bass("circuit_tracks", "Circuit Tracks", "Circuit Tracks",
101
+ channel=9, bass_channel=0),
102
+ _drums("circuit_rhythm", "Circuit Rhythm", "Circuit Rhythm"),
103
+ _synth("bass_station_ii", "Bass Station II", "Bass Station II"),
104
+ _synth("peak", "Peak", "Peak"),
105
+
106
+ # ── Arturia ───────────────────────────────────────────────────────────
107
+ _synth("microfreak", "MicroFreak", "MicroFreak"),
108
+ _drums("drumbrute_impact", "DrumBrute Impact", "DrumBrute Impact"),
109
+
110
+ # ── Behringer ─────────────────────────────────────────────────────────
111
+ _synth("td3", "TD-3", "TD-3"),
112
+ _drums("rd6", "RD-6", "RD-6", channel=0,
113
+ drum_map={36: 36, 38: 40, 42: 42, 46: 46, 50: 39, 49: 51}),
114
+ _synth("crave", "Crave", "Crave"),
115
+ _synth("model_d", "Model D", "MODEL D"),
116
+ _synth("neutron", "Neutron", "Neutron"),
117
+ _synth("poly_d", "Poly D", "Poly D"),
118
+ _synth("k2", "K-2", "K-2"),
119
+ _synth("ms1", "MS-1", "MS-1"),
120
+ _synth("deepmind12", "DeepMind 12", "DeepMind 12"),
121
+ _synth("wasp_deluxe", "Wasp Deluxe", "Wasp Deluxe"),
122
+
123
+ # ── Elektron ──────────────────────────────────────────────────────────
124
+ _drums("syntakt", "Syntakt", "Syntakt", channel=0),
125
+ _drums("digitakt", "Digitakt", "Digitakt", channel=0,
126
+ drum_map={36: 24, 38: 25, 42: 26, 46: 27, 50: 28, 49: 29}),
127
+ _drums_bass("model_cycles", "Model:Cycles", "Model:Cycles",
128
+ channel=0, bass_channel=5),
129
+ _drums("model_samples", "Model:Samples", "Model:Samples", channel=0),
130
+ _drums("analog_rytm", "Analog Rytm MKII", "Analog Rytm MKII"),
131
+ _synth("analog_four", "Analog Four MKII", "Analog Four MKII"),
132
+
133
+ # ── Teenage Engineering ───────────────────────────────────────────────
134
+ _synth("opz", "OP-Z", "OP-Z", channel=4),
135
+ _drums_bass("ep133", "EP-133 K.O. II", "EP-133", channel=0, bass_channel=1),
136
+
137
+ # ── Sequential ────────────────────────────────────────────────────────
138
+ _synth("take5", "Take 5", "Take 5"),
139
+
140
+ # ── IK Multimedia ─────────────────────────────────────────────────────
141
+ _drums("uno_drum", "UNO Drum", "UNO Drum"),
142
+ _synth("uno_synth", "UNO Synth", "UNO Synth"),
143
+ ]
144
+
145
+ # Fast lookup by id
146
+ _REGISTRY_BY_ID: dict[str, DeviceConfig] = {d.id: d for d in DEVICE_REGISTRY}
147
+
148
+
149
+ def get_device(device_id: str) -> DeviceConfig | None:
150
+ return _REGISTRY_BY_ID.get(device_id)
151
+
152
+
153
+ def find_device(port_name: str) -> DeviceConfig | None:
154
+ """Return the first DeviceConfig whose port_match is a substring of *port_name*."""
155
+ for cfg in DEVICE_REGISTRY:
156
+ if cfg.port_match in port_name:
157
+ return cfg
158
+ return None
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Backwards-compat: old DEVICES dict shape used by ui.py
163
+ # ---------------------------------------------------------------------------
164
+
165
+ def _compat_dict() -> dict[str, dict]:
166
+ """Build the old-style DEVICES dict for code that still uses it."""
167
+ out: dict[str, dict] = {}
168
+ for cfg in DEVICE_REGISTRY:
169
+ if cfg.mode == "drums+bass":
170
+ out[cfg.label] = {
171
+ "port_match": cfg.port_match,
172
+ "drum_channel": cfg.channel,
173
+ "bass_channel": cfg.bass_channel,
174
+ "bass_root": None,
175
+ "base_velocity": cfg.base_velocity,
176
+ "drum_pattern": None,
177
+ "bass_pattern": None,
178
+ "type": "t8",
179
+ }
180
+ else:
181
+ out[cfg.label] = {
182
+ "port_match": cfg.port_match,
183
+ "channel": cfg.channel,
184
+ "root_note": cfg.root_note,
185
+ "base_velocity": cfg.base_velocity,
186
+ "note_fraction": cfg.gate_frac,
187
+ "pattern": None,
188
+ "type": "synth",
189
+ }
190
+ return out
191
+
192
+
193
+ DEVICES: dict[str, dict] = _compat_dict()
mpump/extras.py ADDED
@@ -0,0 +1,166 @@
1
+ """Persistence layer for user-created patterns stored in ~/.mpump/extras.json.
2
+
3
+ On import, loads saved patterns and injects an "extras" genre into the
4
+ shared genre dicts (GENRES, T8_DRUMS, T8_BASS, J6_GENRES) so all
5
+ interfaces (web, TUI, CLI) see them automatically.
6
+
7
+ Keys are mode-based: "synth", "drums", "bass", "chords".
8
+ Old device-specific keys ("s1", "t8_drums", "t8_bass", "j6") are
9
+ migrated automatically on load.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+ from .patterns import GENRES, GENRE_NAMES
18
+ from .patterns_j6 import J6_CHORD_SETS, J6_GENRE_NAMES, J6_GENRES
19
+ from .patterns_t8 import T8_BASS, T8_DRUMS, T8_GENRE_NAMES
20
+
21
+ EXTRAS_DIR = Path.home() / ".mpump"
22
+ EXTRAS_FILE = EXTRAS_DIR / "extras.json"
23
+
24
+ GENRE_KEY = "extras"
25
+
26
+ # Old key → new key migration map
27
+ _MIGRATE = {"s1": "synth", "t8_drums": "drums", "t8_bass": "bass", "j6": "chords"}
28
+
29
+ # ── Serialisation helpers ─────────────────────────────────────────────────
30
+
31
+ def _ser_step(s):
32
+ if s is None:
33
+ return None
34
+ return {"semi": s[0], "vel": s[1], "slide": s[2]}
35
+
36
+
37
+ def _deser_step(s):
38
+ if s is None:
39
+ return None
40
+ return (s["semi"], s["vel"], s["slide"])
41
+
42
+
43
+ def _ser_drum_step(s):
44
+ return [{"note": n, "vel": v} for n, v in s]
45
+
46
+
47
+ def _deser_drum_step(s):
48
+ return [(h["note"], h["vel"]) for h in s]
49
+
50
+
51
+ # ── Disk I/O ──────────────────────────────────────────────────────────────
52
+
53
+ def _load_raw() -> dict:
54
+ if not EXTRAS_FILE.exists():
55
+ return {}
56
+ try:
57
+ with open(EXTRAS_FILE) as f:
58
+ data = json.load(f)
59
+ except (json.JSONDecodeError, OSError):
60
+ return {}
61
+ # Migrate old keys if present
62
+ migrated = False
63
+ for old_key, new_key in _MIGRATE.items():
64
+ if old_key in data and new_key not in data:
65
+ data[new_key] = data.pop(old_key)
66
+ migrated = True
67
+ elif old_key in data and new_key in data:
68
+ # Merge old into new, then remove old
69
+ data[new_key].extend(data.pop(old_key))
70
+ migrated = True
71
+ if migrated:
72
+ _save_raw(data)
73
+ return data
74
+
75
+
76
+ def _save_raw(data: dict):
77
+ EXTRAS_DIR.mkdir(parents=True, exist_ok=True)
78
+ with open(EXTRAS_FILE, "w") as f:
79
+ json.dump(data, f, indent=2)
80
+
81
+
82
+ # ── Convert JSON → tuple format expected by genre dicts ───────────────────
83
+
84
+ def _to_melodic_tuples(patterns: list) -> list[tuple]:
85
+ return [
86
+ (p["name"], p["desc"], [_deser_step(s) for s in p["steps"]])
87
+ for p in patterns
88
+ ]
89
+
90
+
91
+ def _to_drum_tuples(patterns: list) -> list[tuple]:
92
+ return [
93
+ (p["name"], p["desc"], [_deser_drum_step(s) for s in p["steps"]])
94
+ for p in patterns
95
+ ]
96
+
97
+
98
+ # ── Inject / reload ──────────────────────────────────────────────────────
99
+
100
+ def reload():
101
+ """(Re)load extras from disk and update the shared genre dicts in place."""
102
+ data = _load_raw()
103
+
104
+ GENRES[GENRE_KEY] = _to_melodic_tuples(data.get("synth", []))
105
+ T8_DRUMS[GENRE_KEY] = _to_drum_tuples(data.get("drums", []))
106
+ T8_BASS[GENRE_KEY] = _to_melodic_tuples(data.get("bass", []))
107
+ J6_GENRES[GENRE_KEY] = _to_melodic_tuples(data.get("chords", []))
108
+
109
+ for names in (GENRE_NAMES, T8_GENRE_NAMES, J6_GENRE_NAMES):
110
+ if GENRE_KEY not in names:
111
+ names.append(GENRE_KEY)
112
+
113
+ J6_CHORD_SETS.setdefault(GENRE_KEY, 1)
114
+
115
+
116
+ # ── Public API ────────────────────────────────────────────────────────────
117
+
118
+ # Mode-based key map for public API (accepts both old and new names)
119
+ _KEY_MAP = {
120
+ "s1": "synth", "synth": "synth",
121
+ "t8": "drums", "drums": "drums",
122
+ "t8_bass": "bass", "bass": "bass",
123
+ "j6": "chords", "chords": "chords",
124
+ }
125
+
126
+ _DRUM_KEYS = {"drums", "t8"}
127
+
128
+
129
+ def save_pattern(device: str, name: str, desc: str, steps: list) -> bool:
130
+ """Persist a pattern to extras. *device* is synth|drums|bass|chords (or legacy s1|t8|t8_bass|j6)."""
131
+ key = _KEY_MAP.get(device)
132
+ if not key:
133
+ return False
134
+
135
+ data = _load_raw()
136
+
137
+ if device in _DRUM_KEYS or key == "drums":
138
+ serialized = [_ser_drum_step(s) for s in steps]
139
+ else:
140
+ serialized = [_ser_step(s) for s in steps]
141
+
142
+ data.setdefault(key, []).append({"name": name, "desc": desc, "steps": serialized})
143
+ _save_raw(data)
144
+ reload()
145
+ return True
146
+
147
+
148
+ def delete_pattern(device: str, idx: int) -> bool:
149
+ """Delete pattern at 0-based *idx* from the extras genre."""
150
+ key = _KEY_MAP.get(device)
151
+ if not key:
152
+ return False
153
+
154
+ data = _load_raw()
155
+ patterns = data.get(key, [])
156
+ if not (0 <= idx < len(patterns)):
157
+ return False
158
+
159
+ patterns.pop(idx)
160
+ _save_raw(data)
161
+ reload()
162
+ return True
163
+
164
+
165
+ # Run on first import — all interfaces that ``import extras`` get the genre.
166
+ reload()