-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdevice_profiles.py
More file actions
610 lines (503 loc) · 22.9 KB
/
device_profiles.py
File metadata and controls
610 lines (503 loc) · 22.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
"""
Device abstraction layer for Compa.
Defines DeviceProfile (dataclass describing any USB music device)
and DeviceManager (auto-detect + registry). Built-in profiles
ship for Roland P-6, Roland SP-404 MK2, and a generic USB audio
fallback so the app works with any class-compliant interface.
"""
import logging
from dataclasses import dataclass, field
log = logging.getLogger(__name__)
# ── Data classes ─────────────────────────────────────────────────────────
@dataclass
class MidiCC:
"""Single MIDI CC parameter descriptor."""
cc: int
name: str
min_val: int = 0
max_val: int = 127
default: int = 64
@dataclass
class DeviceProfile:
"""Everything Compa needs to know about a connected device."""
# ── Identity ─────────────────────────────────────────────────────
name: str # "Roland P-6", "Roland SP-404 MK2"
short_name: str # "P-6", "SP-404"
usb_vendor: int # 0x0582 for Roland
usb_products: list[int] = field(default_factory=list) # [0x02fe] etc.
# ── Audio ────────────────────────────────────────────────────────
audio_hint: str = "" # sounddevice search string
audio_in_channels: int = 2
audio_out_channels: int = 2
supported_sample_rates: list[int] = field(
default_factory=lambda: [44100])
# ── MIDI ─────────────────────────────────────────────────────────
midi_hint: str = "" # Port-name search string
midi_channels: dict[str, int] = field(default_factory=dict)
midi_note_range: tuple[int, int] = (0, 127)
# CC maps organised by category { "granular": [MidiCC, ...], ... }
cc_map: dict[str, list[MidiCC]] = field(default_factory=dict)
# ── Patterns / program change ────────────────────────────────────
pattern_count: int = 0
pattern_pc_channel: int = 15 # MIDI channel for program change
# ── Transport ────────────────────────────────────────────────────
sends_clock: bool = True
receives_clock: bool = True
transport_works: bool = False # can we send start/stop?
# ── USB storage ──────────────────────────────────────────────────
mount_path: str = "" # "/media/pi/P-6"
storage_vendor_product: str = ""
# ── Feature flags (which Compa screens are useful) ───────────────
has_granular: bool = False
has_effects: bool = False
has_dj_mode: bool = False
has_looper: bool = False
has_sequencer: bool = True
# ── File formats ─────────────────────────────────────────────────
sample_format: str = "wav"
kit_format: str = "" # "xpm" for Akai, "" for none
# ── Built-in profile constructors ────────────────────────────────────────
def _make_p6_profile() -> DeviceProfile:
"""Roland AIRA Compact P-6."""
return DeviceProfile(
name="Roland P-6",
short_name="P-6",
usb_vendor=0x0582,
usb_products=[0x02FE],
# Audio
audio_hint="P-6",
audio_in_channels=2,
audio_out_channels=2,
supported_sample_rates=[44100],
# MIDI
midi_hint="P-6",
midi_channels={
"granular": 3, # ch4
"sampler": 10, # ch11
"auto": 14, # ch15
"program": 15, # ch16
},
midi_note_range=(48, 95),
# CC map — mirrors P6_CC_MAP from engine/p6_midi.py
cc_map={
"granular": [
MidiCC(3, "Grain Rev Prob", 0, 127, 0),
MidiCC(13, "Detune", 0, 127, 0),
MidiCC(15, "Grain Shape", 0, 127, 0),
MidiCC(16, "Grain Time KF", 0, 127, 64),
MidiCC(18, "Fine Tune", 0, 127, 64),
MidiCC(19, "Head Position", 0, 127, 0),
MidiCC(20, "Head Speed", 0, 127, 64),
MidiCC(21, "Grains", 0, 127, 0),
MidiCC(23, "Grain Size", 0, 127, 64),
MidiCC(25, "Spread", 0, 127, 0),
MidiCC(68, "Grain Jitter", 0, 127, 0),
MidiCC(76, "Coarse Tune", 0, 127, 64),
MidiCC(79, "Start Mode", 0, 127, 0),
MidiCC(88, "Sample Select", 0, 127, 0),
],
"filter": [
MidiCC(74, "Cutoff", 0, 127, 127),
MidiCC(71, "Resonance", 0, 127, 0),
MidiCC(12, "Filter Type", 0, 127, 0),
MidiCC(24, "Env Depth", 0, 127, 64),
MidiCC(26, "Cutoff KF", 0, 127, 64),
MidiCC(78, "Vel Sens", 0, 127, 64),
],
"envelope": [
MidiCC(73, "Attack", 0, 127, 0),
MidiCC(75, "Decay", 0, 127, 64),
MidiCC(30, "Sustain", 0, 127, 127),
MidiCC(72, "Release", 0, 127, 32),
MidiCC(28, "Amp Switch", 0, 127, 0),
MidiCC(29, "Env Mode", 0, 127, 0),
MidiCC(77, "Time KF", 0, 127, 64),
],
"mixer": [
MidiCC(7, "Level", 0, 127, 100),
MidiCC(10, "Pan", 0, 127, 64),
MidiCC(9, "Auto Pan", 0, 127, 0),
MidiCC(14, "Level Jitter", 0, 127, 0),
MidiCC(84, "Output Bus", 0, 127, 0),
MidiCC(85, "Send Delay", 0, 127, 0),
MidiCC(86, "Send Reverb", 0, 127, 0),
],
"fx": [
MidiCC(90, "Delay Time", 0, 127, 64),
MidiCC(92, "Delay Level", 0, 127, 0),
MidiCC(89, "Reverb Time", 0, 127, 64),
MidiCC(91, "Reverb Level", 0, 127, 0),
MidiCC(17, "Lo-fi Intensity", 0, 127, 0),
MidiCC(87, "Lo-fi Switch", 0, 127, 0),
],
},
# Patterns
pattern_count=64,
pattern_pc_channel=15, # ch16
# Transport
sends_clock=True,
receives_clock=True,
transport_works=False,
# Storage
mount_path="/media/pi/P-6",
# Features
has_granular=True,
has_effects=False,
has_dj_mode=False,
has_looper=False,
has_sequencer=True,
sample_format="wav",
)
def _sp404_bus_fx_ccs(bus_name: str) -> list:
"""Common FX CCs for an SP-404 bus (same CCs, different channel per bus)."""
return [
MidiCC(19, f"{bus_name} FX On/Off", 0, 127, 0),
MidiCC(83, f"{bus_name} FX Select", 0, 127, 0),
MidiCC(16, f"{bus_name} Ctrl 1", 0, 127, 64),
MidiCC(17, f"{bus_name} Ctrl 2", 0, 127, 64),
MidiCC(18, f"{bus_name} Ctrl 3", 0, 127, 64),
MidiCC(80, f"{bus_name} Ctrl 4", 0, 127, 64),
MidiCC(81, f"{bus_name} Ctrl 5", 0, 127, 64),
MidiCC(82, f"{bus_name} Ctrl 6", 0, 127, 64),
]
def _make_sp404mk2_profile() -> DeviceProfile:
"""Roland SP-404 MK2 — full MIDI implementation from Gemini deep research.
Effect buses live on separate MIDI channels:
Ch1=Bus1, Ch2=Bus2, Ch3=Bus3, Ch4=Bus4, Ch5=Input FX
Same CC numbers per bus, different channels.
Looper on Ch1, DJ mode on Ch1+Ch2, Chromatic on Ch16, Vocoder on Ch11.
"""
return DeviceProfile(
name="Roland SP-404 MK2",
short_name="SP-404MKII",
usb_vendor=0x0582,
usb_products=[0x02E7, 0x0281],
# Audio
audio_hint="SP-404",
audio_in_channels=2,
audio_out_channels=4,
supported_sample_rates=[44100, 48000],
# MIDI
midi_hint="SP-404",
midi_channels={
"bus1": 0, # Ch1 — Bus 1 FX + Looper
"bus2": 1, # Ch2 — Bus 2 FX
"bus3": 2, # Ch3 — Bus 3 FX
"bus4": 3, # Ch4 — Bus 4 FX
"input_fx": 4, # Ch5 — Input FX
"vocoder": 10, # Ch11 — Vocoder pitch
"chromatic": 15, # Ch16 — Chromatic mode
},
midi_note_range=(35, 51), # Mode A pads; chromatic on ch16 (36-60)
# CC map — organized by function, each bus on its own channel
cc_map={
"bus1_fx": _sp404_bus_fx_ccs("B1"),
"bus2_fx": _sp404_bus_fx_ccs("B2"),
"bus3_fx": _sp404_bus_fx_ccs("B3"),
"bus4_fx": _sp404_bus_fx_ccs("B4"),
"input_fx": _sp404_bus_fx_ccs("IN"),
"looper": [
MidiCC(88, "Rec/Stop", 0, 127, 0), # 127=start, 0=stop
MidiCC(89, "Overdub", 0, 127, 0),
MidiCC(87, "Delete", 0, 127, 0),
MidiCC(85, "Stop Playback", 0, 127, 0),
MidiCC(90, "BPM/Play Rate", 0, 127, 64),
MidiCC(86, "Reset Tempo", 0, 127, 0),
MidiCC(91, "Undo/Redo", 0, 127, 0), # 127=undo, 0=redo
],
"dj_mode": [
MidiCC(7, "Volume", 0, 127, 100),
MidiCC(8, "Crossfade", 0, 127, 64),
MidiCC(20, "Play/Pause", 0, 127, 0), # 127=play, 0=pause
MidiCC(22, "Sync", 0, 127, 0),
MidiCC(23, "Cue", 0, 127, 0),
MidiCC(24, "Bend+", 0, 127, 0), # nudge forward
MidiCC(25, "Bend-", 0, 127, 0), # nudge backward
],
},
# Patterns
pattern_count=16,
pattern_pc_channel=0, # PC on bank's channel; Ch1 for Bank A
# Transport
sends_clock=True,
receives_clock=True,
transport_works=True,
# Storage
mount_path="/media/pi/SP-404MKII",
# Features
has_granular=False,
has_effects=True,
has_dj_mode=True,
has_looper=True,
has_sequencer=True,
sample_format="wav",
)
def _make_force_profile() -> DeviceProfile:
"""Akai Force — standalone production hub.
USB-B connection provides class-compliant audio + 3 MIDI ports:
- Public: general MIDI I/O
- Private: internal system messages
- MIDI Port: traditional MIDI thru
Audio: 2in/4out at 44.1kHz.
Force runs Linux internally — most control is via its own screen,
but MIDI notes, CCs, clock sync, and program changes all work.
"""
return DeviceProfile(
name="Akai Force",
short_name="Force",
usb_vendor=0x09e8,
usb_products=[0x0040, 0x1040, 0x5040],
# Audio
audio_hint="Force",
audio_in_channels=2,
audio_out_channels=4,
supported_sample_rates=[44100],
# MIDI — use Public port for general I/O
midi_hint="Akai Pro Force",
midi_channels={
"pads": 0, # Ch1 — pad triggers
"keys": 1, # Ch2 — keyboard/chromatic
"program": 0, # Ch1 — program change
},
midi_note_range=(36, 99), # 4x4 pads + chromatic range
# CC map — Force has knobs/faders on its own screen
# but responds to standard CCs for external control
cc_map={
"mixer": [
MidiCC(7, "Volume", 0, 127, 100),
MidiCC(10, "Pan", 0, 127, 64),
MidiCC(11, "Expression", 0, 127, 127),
],
"transport": [
MidiCC(64, "Sustain", 0, 127, 0),
MidiCC(1, "Mod Wheel", 0, 127, 0),
],
},
# Patterns — Force uses clips/scenes, not numbered patterns
pattern_count=0,
pattern_pc_channel=0,
# Transport
sends_clock=True,
receives_clock=True,
transport_works=True,
# Storage — Force's internal drive via USB
mount_path="", # Force SD card shows up as separate USB device
# Features
has_granular=False,
has_effects=False,
has_dj_mode=False,
has_looper=False,
has_sequencer=True,
sample_format="wav",
kit_format="xpm", # Akai .Drum.xpm format
)
def _make_apple_ios_profile() -> DeviceProfile:
"""Apple iPad / iPhone / iPod via USB-C or Lightning.
Matches by Apple vendor ID (0x05AC) only — Apple uses many product IDs
across iOS device generations, so empty usb_products signals "any product
from this vendor" (see DeviceManager.detect()).
iOS exposes itself as a USB Audio Class + USB MIDI peer when plugged in
(USB-C iPads/iPhones natively; Lightning needs the Camera Connection Kit
+ powered hub). Acts as a peer for routing to apps like AUM, Koala,
Drambo, Loopy Pro. Tempo sync also available via Ableton Link over WiFi
(separate path, no USB needed).
"""
return DeviceProfile(
name="Apple iOS",
short_name="iOS",
usb_vendor=0x05AC,
usb_products=[], # empty = match any product from this vendor
audio_hint="iPad", # ALSA device name contains "iPad"/"iPhone"/etc.
audio_in_channels=2,
audio_out_channels=2,
supported_sample_rates=[44100, 48000],
midi_hint="iPad",
midi_channels={},
midi_note_range=(0, 127),
cc_map={},
pattern_count=0,
pattern_pc_channel=0,
sends_clock=True,
receives_clock=True,
transport_works=True,
mount_path="", # iOS doesn't expose USB Mass Storage by default
has_granular=False,
has_effects=False,
has_dj_mode=False,
has_looper=False,
has_sequencer=False,
sample_format="wav",
)
def _make_generic_usb_audio_profile() -> DeviceProfile:
"""Fallback for any USB audio interface with no MIDI features."""
return DeviceProfile(
name="USB Audio Device",
short_name="USB",
usb_vendor=0,
usb_products=[],
audio_hint="",
audio_in_channels=2,
audio_out_channels=2,
supported_sample_rates=[44100, 48000],
midi_hint="",
midi_channels={},
midi_note_range=(0, 127),
cc_map={},
pattern_count=0,
pattern_pc_channel=0,
sends_clock=False,
receives_clock=False,
transport_works=False,
mount_path="",
has_granular=False,
has_effects=False,
has_dj_mode=False,
has_looper=False,
has_sequencer=False,
sample_format="wav",
)
# ── Helpers ──────────────────────────────────────────────────────────────
def cc_map_to_legacy(cc_map: dict[str, list[MidiCC]]) -> dict[str, list[tuple]]:
"""Convert a DeviceProfile cc_map to the tuple format used by p6_midi.py.
Returns { category: [(cc, name, min, max, default), ...] }
so existing screens that expect the old P6_CC_MAP layout keep working.
"""
legacy: dict[str, list[tuple]] = {}
for cat, ccs in cc_map.items():
legacy[cat] = [(m.cc, m.name, m.min_val, m.max_val, m.default)
for m in ccs]
return legacy
def build_cc_lookup(cc_map: dict[str, list[MidiCC]]) -> dict[int, tuple[str, str]]:
"""Build a flat cc_number -> (category, name) lookup from a cc_map."""
lookup: dict[int, tuple[str, str]] = {}
for cat, ccs in cc_map.items():
for m in ccs:
lookup[m.cc] = (cat, m.name)
return lookup
# ── Device Manager ───────────────────────────────────────────────────────
class DeviceManager:
"""Registry of device profiles + multi-device USB auto-detection.
Detects ALL connected devices simultaneously and provides a focus
mechanism to select which device the UI is currently controlling.
"""
def __init__(self):
self._profiles: dict[str, DeviceProfile] = {}
self._active_device: DeviceProfile | None = None
# Multi-device state
self._connected: dict[str, DeviceProfile] = {} # short_name -> profile
self._focus_key: str | None = None
self._register_builtin_profiles()
# ── Built-in profiles ────────────────────────────────────────────
def _register_builtin_profiles(self):
self.register_profile(_make_p6_profile())
self.register_profile(_make_sp404mk2_profile())
self.register_profile(_make_force_profile())
# Apple iOS profile is intentionally NOT registered. iPadOS won't
# expose USB Audio Class to non-Apple hosts, so the card has no real
# function over USB — iPad integration goes through Ableton Link
# (over WiFi) instead. Profile code is kept for a future Compa 2
# USB-gadget-mode implementation where the Pi acts as a USB Audio
# peripheral to the iPad.
# self.register_profile(_make_apple_ios_profile())
#
# Generic fallback is NOT registered — used only when nothing matches
# ── Public API ───────────────────────────────────────────────────
def register_profile(self, profile: DeviceProfile):
"""Add (or overwrite) a device profile."""
self._profiles[profile.short_name] = profile
def detect(self) -> DeviceProfile | None:
"""Scan USB bus, match ALL registered profiles, activate first.
Populates `connected` dict with every matched device.
Falls back to generic USB audio if no known device is found but
*some* USB audio interface is present. Returns the focused
device profile (first match) or None.
"""
from engine.device_detect import scan_usb_devices, find_audio_device
usb_devices = scan_usb_devices()
log.info("USB scan found %d device(s)", len(usb_devices))
self._connected.clear()
# Try each profile against the bus — match ALL, not just first.
# Empty usb_products is a wildcard meaning "any product from this
# vendor" (used for Apple iOS where every model has a different PID).
for profile in self._profiles.values():
for dev in usb_devices:
vendor_match = dev["vendor"] == profile.usb_vendor
product_match = (not profile.usb_products
or dev["product"] in profile.usb_products)
if vendor_match and product_match:
log.info("Matched device: %s (vendor=%04x product=%04x)",
profile.name, dev["vendor"], dev["product"])
self._connected[profile.short_name] = profile
break # Don't double-match same profile
if self._connected:
# Focus on first matched device
first_key = next(iter(self._connected))
self._focus_key = first_key
self._active_device = self._connected[first_key]
if len(self._connected) > 1:
names = ", ".join(self._connected.keys())
log.info("Multi-device hub: %s (focus: %s)", names, first_key)
return self._active_device
# No known device — look for any USB audio interface
audio = find_audio_device("")
if audio is not None:
log.info("No known device matched; using generic USB audio fallback")
generic = _make_generic_usb_audio_profile()
self._connected[generic.short_name] = generic
self._focus_key = generic.short_name
self._active_device = generic
return generic
log.warning("No USB audio device detected")
return None
# ── Multi-device focus ──────────────────────────────────────────
@property
def connected(self) -> dict[str, DeviceProfile]:
"""All currently connected device profiles (keyed by short_name)."""
return dict(self._connected)
@property
def focus(self) -> DeviceProfile | None:
"""The currently focused device profile."""
if self._focus_key:
return self._connected.get(self._focus_key)
return None
@property
def focus_key(self) -> str | None:
"""Short name of the focused device (e.g. 'P-6', 'SP-404')."""
return self._focus_key
def set_focus(self, short_name: str) -> bool:
"""Switch focus to a connected device by short_name.
Returns True if focus changed, False if device not connected.
"""
if short_name not in self._connected:
log.warning("Cannot focus '%s' — not connected", short_name)
return False
if short_name == self._focus_key:
return False # Already focused
self._focus_key = short_name
self._active_device = self._connected[short_name]
log.info("Focus switched to: %s", short_name)
return True
def cycle_focus(self) -> str | None:
"""Cycle focus to the next connected device. Returns new focus key."""
if len(self._connected) < 2:
return self._focus_key
keys = list(self._connected.keys())
idx = keys.index(self._focus_key) if self._focus_key in keys else -1
next_idx = (idx + 1) % len(keys)
self.set_focus(keys[next_idx])
return self._focus_key
# ── Backward-compatible single-device API ────────────────────────
@property
def active(self) -> DeviceProfile | None:
"""Currently active (focused) device profile."""
return self._active_device
@active.setter
def active(self, profile: DeviceProfile | None):
self._active_device = profile
@property
def profiles(self) -> dict[str, DeviceProfile]:
"""All registered profiles (keyed by short_name)."""
return dict(self._profiles)
def get_profile(self, short_name: str) -> DeviceProfile | None:
"""Look up a profile by short name."""
return self._profiles.get(short_name)