squishbox 1.0.0__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.
squishbox/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """SquishBox Raspberry Pi interface
2
+
3
+ This module provides classes and functions for creating python applications
4
+ for the `SquishBox <https://www.geekfunklabs.com/products/squishbox>`_ ,
5
+ a Raspberry Pi add-on that provides an LCD, pushbutton rotary encoder,
6
+ sound card, and MIDI input/output.
7
+
8
+ Requires:
9
+ - gpiod
10
+ - yaml
11
+ """
12
+
13
+ from importlib.metadata import version, PackageNotFoundError
14
+
15
+ from .config import CONFIG
16
+ from .squishbox import SquishBox
17
+
18
+
19
+ __all__ = ["SquishBox", "CONFIG"]
20
+
21
+ try:
22
+ __version__ = version("squishbox")
23
+ except PackageNotFoundError:
24
+ __version__ = "0.0.0-dev"
25
+
File without changes
@@ -0,0 +1,488 @@
1
+ #!/usr/bin/env python3
2
+ """A SquishBox wrapper for amsynth"""
3
+
4
+ from math import log
5
+ import os
6
+ from pathlib import Path
7
+ from subprocess import Popen, PIPE
8
+ import sys
9
+ from threading import Thread
10
+
11
+ import alsa_midi
12
+
13
+ import squishbox
14
+ from squishbox.config import load_config, save_state
15
+ from squishbox.midi import midi_ports, midi_connect
16
+
17
+
18
+ COLS = squishbox.CONFIG["lcd_cols"]
19
+ ROWS = squishbox.CONFIG["lcd_rows"]
20
+ MENU_TIME = squishbox.CONFIG["menu_timeout"]
21
+
22
+ AMSPORT = "amsynth:0(MIDI IN)"
23
+ PARS = {}
24
+ # name cc type default pmin pmax base offset
25
+ for name, cc, typ, default, *s in [r.split() for r in """\
26
+ amp_attack 12 pow 0 0 2.5 3 0.0005
27
+ amp_decay 13 pow 0 0 2.5 3 0.0005
28
+ amp_sustain 14 lin 1 0 1 1 0
29
+ amp_release 15 pow 0 0 2.5 3 0.0005
30
+ osc1_waveform 16 stp 2 0 4 1 0
31
+ filter_attack 17 pow 0 0 2.5 3 0.0005
32
+ filter_decay 18 pow 0 0 2.5 3 0.0005
33
+ filter_sustain 19 lin 1 0 1 1 0
34
+ filter_release 20 pow 0 0 2.5 3 0.0005
35
+ filter_resonance 21 lin 0 0 0.97 1 0
36
+ filter_env_amount 22 lin 0 -16 16 1 0
37
+ filter_cutoff 23 exp 1.5 -0.5 1.5 16 0
38
+ osc2_detune 24 exp 0 -1 1 1.25 0
39
+ osc2_waveform 25 stp 2 0 4 1 0
40
+ master_vol 26 pow 0.67 0 1 2 0
41
+ lfo_freq 27 pow 0 0 7.5 2 0
42
+ lfo_waveform 28 stp 0 0 6 1 0
43
+ osc2_range 29 stp 0 -3 4 2 0
44
+ osc_mix 30 lin 0 -1 1 1 0
45
+ freq_mod_amount 44 pow 0 0 1.26 3 -1
46
+ filter_mod_amount 45 lin -1 -1 1 1 0
47
+ amp_mod_amount 46 lin -1 -1 1 1 0
48
+ osc_mix_mode 47 lin 0 0 1 1 0
49
+ osc1_pulsewidth 48 lin 1 0 1 1 0
50
+ osc2_pulsewidth 49 lin 1 0 1 1 0
51
+ reverb_roomsize 50 lin 0 0 1 1 0
52
+ reverb_damp 51 lin 0 0 1 1 0
53
+ reverb_wet 52 lin 0 0 1 1 0
54
+ reverb_width 53 lin 1 0 1 1 0
55
+ distortion_crunch 54 lin 0 0 0.9 1 0
56
+ osc2_sync 55 stp 0 0 1 1 0
57
+ portamento_time 56 lin 0 0 1 1 0
58
+ keyboard_mode 57 stp 0 0 2 1 0
59
+ osc2_pitch 58 stp 0 -12 12 1 0
60
+ filter_type 59 stp 0 0 4 1 0
61
+ filter_slope 60 stp 1 0 1 1 0
62
+ freq_mod_osc 61 stp 0 0 2 1 0
63
+ filter_kbd_track 62 lin 1 0 1 1 0
64
+ filter_vel_sens 63 lin 1 0 1 1 0
65
+ amp_vel_sens 84 lin 1 0 1 1 0
66
+ portamento_mode 85 stp 0 0 1 1 0
67
+ """.splitlines()]:
68
+ PARS[name] = {
69
+ "cc": int(cc), "default": float(default), "vals": [], "display": []
70
+ }
71
+ pmin, pmax, base, offset = map(float, s)
72
+ for i in range(128):
73
+ val = pmin + (pmax - pmin) * i / 127
74
+ if typ == "stp":
75
+ val = round(val)
76
+ PARS[name]["display"].append(val)
77
+ elif typ == "lin":
78
+ PARS[name]["display"].append(base * val + offset)
79
+ elif typ == "exp":
80
+ PARS[name]["display"].append(base ** val + offset)
81
+ elif typ == "pow":
82
+ PARS[name]["display"].append(val ** base + offset)
83
+ PARS[name]["vals"].append(val)
84
+ # make displayed values pretty
85
+ for name in """\
86
+ osc_mix_mode
87
+ osc1_pulsewidth
88
+ osc2_pulsewidth
89
+ amp_sustain
90
+ filter_resonance
91
+ filter_cutoff
92
+ filter_sustain
93
+ freq_mod_amount
94
+ filter_mod_amount
95
+ amp_mod_amount
96
+ reverb_roomsize
97
+ reverb_damp
98
+ reverb_wet
99
+ reverb_width
100
+ distortion_crunch
101
+ filter_kbd_track
102
+ filter_vel_sens
103
+ amp_vel_sens""".split():
104
+ PARS[name]["display"] = [
105
+ f"{i / 1.27:.0f}%" for i in range(128)
106
+ ]
107
+ for name, *display in [r.split() for r in """\
108
+ osc1_waveform sine square triangle noise noise+SH
109
+ osc2_waveform sine square triangle noise noise+SH
110
+ lfo_waveform sine square triangle noise noise+SH saw+ saw-
111
+ osc2_sync off on
112
+ keyboard_mode poly mono legato
113
+ filter_type lowpass highpass bandpass notch bypass
114
+ filter_slope 12dB/oct 24dB/oct
115
+ freq_mod_osc osc1+2 osc1 osc2
116
+ portamento_mode always legato
117
+ """.splitlines()]:
118
+ PARS[name]["display"] = [
119
+ display[round((len(display) - 1) * i / 127)]
120
+ for i in range(128)
121
+ ]
122
+ for name in """\
123
+ amp_attack
124
+ amp_decay
125
+ amp_release
126
+ filter_attack
127
+ filter_decay
128
+ filter_release
129
+ portamento_time""".split():
130
+ PARS[name]["display"] = [
131
+ f"{v * 1000:.0f} ms" if v < 1.0 else f"{v:.1f} s"
132
+ for v in PARS[name]["display"]
133
+ ]
134
+ PARS["lfo_freq"]["display"] = [
135
+ f"{v:.1f} Hz" for v in PARS["lfo_freq"]["display"]
136
+ ]
137
+ PARS["osc2_detune"]["display"] = [
138
+ f"{1200 * log(v, 2):.1f} cents"
139
+ for v in PARS["osc2_detune"]["display"]
140
+ ]
141
+ PARS["osc2_pitch"]["display"] = [
142
+ f"{v:+} semitones" for v in PARS["osc2_pitch"]["display"]
143
+ ]
144
+ PARS["osc2_range"]["display"] = [
145
+ f"{v:+} octaves" for v in PARS["osc2_range"]["display"]
146
+ ]
147
+ PARS["master_vol"]["display"] = [
148
+ f"{20 * log(v, 10):+.1f} dB" if v else "-inf dB"
149
+ for v in PARS["master_vol"]["display"]
150
+ ]
151
+ PARS["filter_env_amount"]["display"] = [
152
+ f"{v / 16 * 100:.0f}%"
153
+ for v in PARS["filter_env_amount"]["display"]
154
+ ]
155
+ PARS["osc_mix"]["display"] = [
156
+ f"1:{(127 - i) / 1.27:3.0f}% 2:{i / 1.27:3.0f}%"
157
+ for i in range(128)
158
+ ]
159
+
160
+
161
+ def read_bankfile(path):
162
+ presets = {}
163
+ for line in path.read_text().splitlines():
164
+ if line.startswith("<preset> <name>"):
165
+ presetname = line.split(maxsplit=2)[-1]
166
+ presets[presetname] = {
167
+ name: min(
168
+ range(128),
169
+ key=lambda i: abs(
170
+ PARS[name]["vals"][i] - PARS[name]["default"]
171
+ )
172
+ ) for name in PARS
173
+ }
174
+ elif line.startswith("<parameter>"):
175
+ name, val = line.split()[1:3]
176
+ presets[presetname][name] = min(
177
+ range(128),
178
+ key=lambda i: abs(PARS[name]["vals"][i] - float(val))
179
+ )
180
+ return presets
181
+
182
+
183
+ def write_bankfile(path, presets):
184
+ lines = ["amSynth"]
185
+ for presetname, preset in presets.items():
186
+ lines.append(f"<preset> <name> {presetname}")
187
+ for name, val in preset.items():
188
+ lines.append(
189
+ f"<parameter> {name} {PARS[name]['vals'][val]}"
190
+ )
191
+ lines.append("EOF")
192
+ path.write_text("\n".join(lines))
193
+
194
+
195
+ def setup_amsynth():
196
+ """Shadow config to amsynth configs
197
+ """
198
+ amscfg = Path("~/.config/amsynth/config").expanduser()
199
+ amscfg.write_text(
200
+ "\n".join(
201
+ [f"{k} {v}" for k, v in CONFIG.items() if k in (
202
+ "midi_channel",
203
+ "sample_rate",
204
+ "polyphony",
205
+ "pitch_bend_range",
206
+ "audio_driver",
207
+ "alsa_audio_device",
208
+ )
209
+ ]
210
+ )
211
+ )
212
+ amsctrl = Path("~/.config/amsynth/controllers").expanduser()
213
+ controllers = ["null"] * 128
214
+ for name in PARS:
215
+ controllers[PARS[name]["cc"]] = name
216
+ amsctrl.write_text("\n".join(controllers))
217
+
218
+
219
+ def start_wrapper():
220
+ """Create a client for observing/routing MIDI messages
221
+ and insert it between amSynth and anything connected to it
222
+ """
223
+ conns = squishbox.CONFIG.setdefault("midi_connections", [])
224
+ for i, (src, dest) in enumerate((c.split(">") for c in conns)):
225
+ if dest == AMSPORT:
226
+ conns[i] = f"{src}>_amsynth_wrapper:0(in)"
227
+ conns.append(f"_amsynth_wrapper:1(out)>{AMSPORT}")
228
+ client = alsa_midi.SequencerClient("_amsynth_wrapper")
229
+ inport = client.create_port(
230
+ "in",
231
+ caps=alsa_midi.WRITE_PORT,
232
+ type=alsa_midi.PortType.MIDI_GENERIC,
233
+ )
234
+ outport = client.create_port(
235
+ "out",
236
+ caps=alsa_midi.READ_PORT,
237
+ type=alsa_midi.PortType.MIDI_GENERIC,
238
+ )
239
+ return client, inport, outport
240
+
241
+
242
+ def remove_wrapper(client):
243
+ client.close()
244
+ conns = squishbox.CONFIG["midi_connections"]
245
+ for i, (src, dest) in enumerate((c.split(">") for c in conns)):
246
+ if dest == "_amsynth_wrapper:0(in)":
247
+ conns[i] = f"{src}>{AMSPORT}"
248
+ conns.remove(f"_amsynth_wrapper:1(out)>{AMSPORT}")
249
+ if squishbox.CONFIG.get("midi_connections") == []:
250
+ del squishbox.CONFIG["midi_connections"]
251
+ midi_connect()
252
+
253
+
254
+ def process_events():
255
+ while True:
256
+ try:
257
+ evt = wrapper.event_input(timeout=0.1)
258
+ except TypeError: # occurs if client is closed
259
+ break
260
+ if evt == None:
261
+ continue
262
+ if midi_learn_callback != None:
263
+ if (
264
+ isinstance(evt, alsa_midi.ControlChangeEvent) and
265
+ CONFIG["midi_channel"] in (evt.channel + 1, 0)
266
+ ):
267
+ midi_learn_callback(evt.param)
268
+ elif (
269
+ isinstance(evt, alsa_midi.ControlChangeEvent) and
270
+ CONFIG["midi_channel"] in (evt.channel + 1, 0)
271
+ ):
272
+ if evt.param in CONFIG.get("controllers", {}):
273
+ name = CONFIG["controllers"][evt.param]
274
+ send_param(name, evt.value)
275
+ if display_callback != None:
276
+ display_callback((name, evt.value))
277
+ else:
278
+ wrapper.event_output(evt, dest=amsynth_port)
279
+ wrapper.drain_output()
280
+
281
+
282
+ def send_param(name, i):
283
+ curvals[name] = i
284
+ evt = alsa_midi.ControlChangeEvent(
285
+ channel=(CONFIG["midi_channel"] or 1) - 1,
286
+ param=PARS[name]["cc"],
287
+ value=i,
288
+ )
289
+ wrapper.event_output(evt, dest=amsynth_port)
290
+ wrapper.drain_output()
291
+
292
+
293
+ def set_preset(presetname):
294
+ for name, i in presets[presetname].items():
295
+ curvals[name] = i
296
+ send_param(name, i)
297
+
298
+
299
+ def refresh_display():
300
+ sb.lcd.write(pname.ljust(COLS), row=0)
301
+ sb.lcd.write(f"patch {pno + 1}/{len(presets)}".rjust(COLS), row=1)
302
+
303
+
304
+ # start squishbox
305
+ sb = squishbox.SquishBox()
306
+ sb.lcd.clear()
307
+
308
+ CONFIG = load_config("amsynthbox.yaml")
309
+ if not CONFIG["banks_path"].exists():
310
+ CONFIG["banks_path"].mkdir(parents=True, exist_ok=True)
311
+ Path(CONFIG["banks_path"] / "presets").symlink_to(
312
+ "/usr/share/amsynth/banks"
313
+ )
314
+ Path("/usr/share/amsynth/banks/amsynth_factory.bank").copy(
315
+ CONFIG["banks_path"] / "amsynth_factory.bank"
316
+ )
317
+
318
+ # start amsynth
319
+ setup_amsynth()
320
+ try:
321
+ amsynthx = Popen(
322
+ ["stdbuf", "-oL", "amsynth", "-x"],
323
+ stdout=PIPE, stderr=PIPE,
324
+ text=True, bufsize=1
325
+ )
326
+ except FileNotFoundError as e:
327
+ sb.display_error(e, "install with 'apt install amsynth'")
328
+ sys.exit()
329
+ for line in amsynthx.stdout:
330
+ if "headless mode" in line:
331
+ break
332
+ amsynth_port = midi_ports()[AMSPORT]
333
+ wrapper, wrapper_in, wrapper_out = start_wrapper()
334
+ midithread = Thread(target=process_events, daemon=True)
335
+ display_callback = sb.add_action
336
+ midi_learn_callback = None
337
+ midithread.start()
338
+
339
+ presets = read_bankfile(
340
+ CONFIG["banks_path"] / CONFIG["currentbank_path"]
341
+ )
342
+ curvals = {name: 0 for name in PARS}
343
+ pno = 0
344
+ set_preset(pname := list(presets)[pno])
345
+
346
+ last = 0
347
+ lastpar = 0
348
+ refresh_display()
349
+ while True:
350
+ match sb.get_action():
351
+ case "inc":
352
+ pno = (pno + 1) % len(presets)
353
+ set_preset(pname := list(presets)[pno])
354
+ refresh_display()
355
+ case "dec":
356
+ pno = (pno - 1) % len(presets)
357
+ set_preset(pname := list(presets)[pno])
358
+ refresh_display()
359
+ case "back":
360
+ if sb.menu_exit() == "shell":
361
+ amsynthx.terminate()
362
+ break
363
+ case name, val:
364
+ sb.lcd.write(
365
+ name[:COLS].ljust(COLS),
366
+ row=0, timeout=MENU_TIME
367
+ )
368
+ sb.lcd.write(
369
+ PARS[name]["display"][val].rjust(COLS),
370
+ row=1, timeout=MENU_TIME
371
+ )
372
+ case "select":
373
+ display_callback = None
374
+ i, choice = sb.menu_choose([
375
+ "Parameters",
376
+ "MIDI Learn..",
377
+ "Save Preset",
378
+ "Delete Preset",
379
+ "Load Bank",
380
+ "Save Bank",
381
+ "System Menu..",
382
+ ], row=1, i=last)
383
+ last = i if choice != None else last
384
+ if choice == "Parameters":
385
+ while True:
386
+ i, par = sb.menu_choose(
387
+ [PARS[name]["display"][curvals[name]] for name in PARS],
388
+ row=1, i=lastpar, timeout=0,
389
+ func = lambda i: sb.lcd.write(
390
+ list(PARS)[i].ljust(COLS), row=0
391
+ )
392
+ )
393
+ if par == None:
394
+ break
395
+ lastpar = i
396
+ name = list(PARS)[i]
397
+ sb.lcd.write(f"{name[-15:]}:".ljust(COLS), row=0)
398
+ opts = list(dict.fromkeys(PARS[name]["display"]))
399
+ res = sb.menu_choose(
400
+ opts, row=1, wrap=False, timeout=0,
401
+ i=opts.index(PARS[name]["display"][curvals[name]]),
402
+ func=lambda i: send_param(
403
+ name, PARS[name]["display"].index(opts[i])
404
+ )
405
+ )
406
+ if res[1] == None:
407
+ break
408
+ elif choice == "MIDI Learn..":
409
+ ctrls = {v: k for k, v in CONFIG.get("controllers", {}).items()}
410
+ ccs = [str(ctrls.get(par, "not mapped")) for par in PARS]
411
+ i, par = sb.menu_choose(
412
+ list(PARS), row=0, i=lastpar, align="left",
413
+ func=lambda i: sb.lcd.write(
414
+ ccs[i].rjust(COLS), row=1
415
+ )
416
+ )
417
+ if par != None:
418
+ lastpar=i
419
+ midi_learn_callback = sb.add_action
420
+ i, cc = sb.menu_choose(range(128), row=1, i=ctrls.get(par, 0))
421
+ CONFIG.setdefault("controllers", {})
422
+ if isinstance(cc, int):
423
+ CONFIG["controllers"][cc] = par
424
+ elif i > -1:
425
+ CONFIG["controllers"].pop(cc, None)
426
+ if CONFIG["controllers"] == {}:
427
+ del CONFIG["controllers"]
428
+ save_state(CONFIG_PATH, CONFIG)
429
+ midi_learn_callback = None
430
+ elif choice == "Save Preset":
431
+ sb.lcd.write("Save preset as:".ljust(COLS), row=0)
432
+ newname = sb.menu_entertext(pname).strip()
433
+ if sb.menu_confirm(newname):
434
+ pname = newname
435
+ presets[pname] = curvals.copy()
436
+ pno = list(presets).index(pname)
437
+ elif choice == "Delete Preset":
438
+ sb.lcd.write("Delete preset:".ljust(COLS), row=0)
439
+ if sb.menu_confirm(pname):
440
+ del presets[pname]
441
+ pno = min(pno, len(presets) - 1)
442
+ set_preset(pname := list(presets)[pno])
443
+ elif choice == "Load Bank":
444
+ f = sb.menu_choosefile(
445
+ topdir=CONFIG["banks_path"],
446
+ start=CONFIG["currentbank_path"],
447
+ )
448
+ if f.is_file():
449
+ try:
450
+ presets = read_bankfile(f)
451
+ except Exception as e:
452
+ sb.display_error(e, "bank load error")
453
+ else:
454
+ CONFIG["currentbank_path"] = f
455
+ save_state(CONFIG_PATH, CONFIG)
456
+ pno = 0
457
+ set_preset(pname := list(presets)[pno])
458
+ elif choice == "Save Bank":
459
+ f = sb.menu_choosefile(
460
+ topdir=CONFIG["banks_path"],
461
+ start=CONFIG["currentbank_path"]
462
+ )
463
+ name = sb.menu_entertext(
464
+ f.name if f.is_file() else "", charset=sb.lcd.fnchars()
465
+ ).strip()
466
+ if name and sb.menu_confirm(name):
467
+ sb.lcd.write(name.ljust(COLS), row=0)
468
+ try:
469
+ write_bankfile(f.parent / name, presets)
470
+ except Exception as e:
471
+ sb.display_error(e, "bank save error")
472
+ else:
473
+ CONFIG["currentbank_path"] = f.parent / name
474
+ save_state(CONFIG_PATH, CONFIG)
475
+ sb.lcd.write("bank saved".ljust(COLS), row=1)
476
+ sb.get_action(timeout=MENU_TIME)
477
+ elif choice == "System Menu..":
478
+ remove_wrapper(wrapper)
479
+ midithread.join()
480
+ if sb.menu_systemsettings() == "shell":
481
+ amsynthx.terminate()
482
+ break
483
+ wrapper, wrapper_in, wrapper_out = start_wrapper()
484
+ midithread = Thread(target=process_events, daemon=True)
485
+ midithread.start()
486
+ display_callback = sb.add_action
487
+ refresh_display()
488
+