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 +3 -0
- mpump/cli.py +268 -0
- mpump/devices.py +193 -0
- mpump/extras.py +166 -0
- mpump/frontend/dist/assets/index-Co-rd1cs.js +41 -0
- mpump/frontend/dist/assets/index-pasjjHY9.css +1 -0
- mpump/frontend/dist/icon-192.png +0 -0
- mpump/frontend/dist/icon-512.png +0 -0
- mpump/frontend/dist/index.html +18 -0
- mpump/frontend/dist/manifest.json +21 -0
- mpump/keys.py +57 -0
- mpump/patterns.py +1190 -0
- mpump/patterns_j6.py +477 -0
- mpump/patterns_t8.py +1545 -0
- mpump/scanner.py +336 -0
- mpump/sequencer.py +413 -0
- mpump/server/scripts/export_patterns.py +122 -0
- mpump/ui.py +1324 -0
- mpump/web/__init__.py +0 -0
- mpump/web/engine.py +465 -0
- mpump/web/server.py +250 -0
- mpump-1.3.10.dist-info/METADATA +407 -0
- mpump-1.3.10.dist-info/RECORD +27 -0
- mpump-1.3.10.dist-info/WHEEL +5 -0
- mpump-1.3.10.dist-info/entry_points.txt +3 -0
- mpump-1.3.10.dist-info/licenses/LICENSE +339 -0
- mpump-1.3.10.dist-info/top_level.txt +1 -0
mpump/__init__.py
ADDED
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()
|