RemiFabre commited on
Commit
c93a753
·
1 Parent(s): a6ca140

Smoother init phase and dynamic instrument change (config saved to disk)

Browse files
README.md CHANGED
@@ -14,7 +14,9 @@ tags:
14
 
15
  ## Overview
16
 
17
- Theremini turns Reachy Mini into a music instrument. The robot's head roll sets the pitch, head vertical translation maps to the amplitude, and the right antenna selects the current instrument (FluidSynth GM programs). The runtime now exposes a lightweight dashboard at `http://localhost:8095/index.html` (theremin's `custom_app_url`) so you can monitor pitch, amplitude, pose ranges, and instrument slots without keeping a terminal HUD visible.
 
 
18
 
19
  ## Controls
20
 
@@ -23,3 +25,9 @@ Theremini turns Reachy Mini into a music instrument. The robot's head roll sets
23
  | Head roll | -60° … +60° | MIDI notes 48–84 |
24
  | Head Z translation | -30 mm … +5 mm | Amplitude 0–100 % |
25
  | Right antenna | -π/3 … +π/3 radians | Instrument program |
 
 
 
 
 
 
 
14
 
15
  ## Overview
16
 
17
+ Theremini turns Reachy Mini into a music instrument. The robot's head roll sets the pitch, head vertical translation maps to the amplitude, and the right antenna selects the current instrument (FluidSynth GM programs). The runtime now exposes a lightweight dashboard at `http://localhost:8095/index.html` (theremin's `custom_app_url`) so you can monitor pitch, amplitude, pose ranges, and curate the instrument rotation without keeping a terminal HUD visible.
18
+
19
+ When the app starts, Reachy performs a short guided motion (center the head, oscillate the roll, sweep the antenna) so musicians immediately hear how gestures map to sound before the motors are relaxed.
20
 
21
  ## Controls
22
 
 
25
  | Head roll | -60° … +60° | MIDI notes 48–84 |
26
  | Head Z translation | -30 mm … +5 mm | Amplitude 0–100 % |
27
  | Right antenna | -π/3 … +π/3 radians | Instrument program |
28
+
29
+ ## Dashboard controls
30
+
31
+ - The top HUD mirrors the old terminal bars for note/pitch/amplitude, but now in the browser.
32
+ - The “Program slots” grid refreshes live to show the currently active instrument order.
33
+ - The “Live instrument set” card lets you search the full FluidSynth GM bank and build up to eight presets (order defines how the antenna scrolls). Changes are saved via the same `/api/config` endpoint the web UI hits, so advanced users can script their own configs too.
Theremini/config.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "active_instruments": [
3
+ "accordion",
4
+ "acoustic_bass"
5
+ ]
6
+ }
Theremini/config.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ from pathlib import Path
6
+ from typing import List
7
+
8
+ # Complete FluidSynth GM preset names copied from simple_theremini script
9
+ ALL_INSTRUMENTS: List[str] = [
10
+ "piano_1",
11
+ "piano_2",
12
+ "piano_3",
13
+ "honky_tonk",
14
+ "e_piano_1",
15
+ "e_piano_2",
16
+ "harpsichord",
17
+ "clavinet",
18
+ "celesta",
19
+ "glockenspiel",
20
+ "music_box",
21
+ "vibraphone",
22
+ "marimba",
23
+ "xylophone",
24
+ "tubular_bells",
25
+ "dulcimer",
26
+ "organ_1",
27
+ "organ_2",
28
+ "organ_3",
29
+ "reed_organ",
30
+ "accordion",
31
+ "harmonica",
32
+ "bandoneon",
33
+ "nylon_guitar",
34
+ "steel_guitar",
35
+ "jazz_guitar",
36
+ "clean_guitar",
37
+ "muted_guitar",
38
+ "overdrive_guitar",
39
+ "distortion_guitar",
40
+ "guitar_harmonics",
41
+ "acoustic_bass",
42
+ "fingered_bass",
43
+ "picked_bass",
44
+ "fretless_bass",
45
+ "slap_bass_1",
46
+ "slap_bass_2",
47
+ "synth_bass_1",
48
+ "synth_bass_2",
49
+ "violin",
50
+ "viola",
51
+ "cello",
52
+ "contrabass",
53
+ "tremolo_strings",
54
+ "pizzicato_strings",
55
+ "harp",
56
+ "timpani",
57
+ "string_ensemble_1",
58
+ "string_ensemble_2",
59
+ "synth_strings_1",
60
+ "synth_strings_2",
61
+ "choir_aahs",
62
+ "voice_oohs",
63
+ "synth_voice",
64
+ "orchestra_hit",
65
+ "trumpet",
66
+ "trombone",
67
+ "tuba",
68
+ "muted_trumpet",
69
+ "french_horn",
70
+ "brass_section",
71
+ "synth_brass_1",
72
+ "synth_brass_2",
73
+ "soprano_sax",
74
+ "alto_sax",
75
+ "tenor_sax",
76
+ "baritone_sax",
77
+ "oboe",
78
+ "english_horn",
79
+ "bassoon",
80
+ "clarinet",
81
+ "piccolo",
82
+ "flute",
83
+ "recorder",
84
+ "pan_flute",
85
+ "blown_bottle",
86
+ "shakuhachi",
87
+ "whistle",
88
+ "ocarina",
89
+ "square_wave",
90
+ "saw_wave",
91
+ "synth_calliope",
92
+ "chiffer_lead",
93
+ "charang",
94
+ "solo_voice",
95
+ "fifth_saw",
96
+ "bass_lead",
97
+ "fantasia",
98
+ "warm_pad",
99
+ "polysynth",
100
+ "space_voice",
101
+ "bowed_glass",
102
+ "metal_pad",
103
+ "halo_pad",
104
+ "sweep_pad",
105
+ "ice_rain",
106
+ "soundtrack",
107
+ "crystal",
108
+ "atmosphere",
109
+ "brightness",
110
+ "goblin",
111
+ "echo_drops",
112
+ "star_theme",
113
+ "sitar",
114
+ "banjo",
115
+ "shamisen",
116
+ "koto",
117
+ "kalimba",
118
+ "bagpipe",
119
+ "fiddle",
120
+ "shanai",
121
+ "tinker_bell",
122
+ "agogo",
123
+ "steel_drum",
124
+ "wood_block",
125
+ "taiko_drum",
126
+ "melodic_tom",
127
+ "synth_drum",
128
+ "reverse_cymbal",
129
+ "guitar_fret_noise",
130
+ "breath_noise",
131
+ "seashore",
132
+ "bird_tweet",
133
+ "telephone_ring",
134
+ "helicopter",
135
+ "applause",
136
+ "gunshot",
137
+ "coupled_harpsichord",
138
+ ]
139
+
140
+ DEFAULT_ACTIVE = [
141
+ "choir_aahs",
142
+ "orchestra_hit",
143
+ "taiko_drum",
144
+ "trumpet",
145
+ "french_horn",
146
+ "soundtrack",
147
+ ]
148
+
149
+ MAX_ACTIVE = 8
150
+
151
+ CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
152
+
153
+ _config_lock = threading.Lock()
154
+ _active_instruments: List[str] = DEFAULT_ACTIVE.copy()
155
+
156
+
157
+ def _persist_active_instruments() -> None:
158
+ data = {"active_instruments": _active_instruments}
159
+ try:
160
+ CONFIG_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
161
+ except OSError:
162
+ # Could not persist; keep running with in-memory config.
163
+ pass
164
+
165
+
166
+ def _load_initial_config() -> None:
167
+ global _active_instruments
168
+ if not CONFIG_PATH.exists():
169
+ _persist_active_instruments()
170
+ return
171
+
172
+ try:
173
+ raw = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
174
+ except (OSError, json.JSONDecodeError):
175
+ _persist_active_instruments()
176
+ return
177
+
178
+ if isinstance(raw, dict):
179
+ items = raw.get("active_instruments")
180
+ if isinstance(items, list):
181
+ cleaned = _clean_instrument_list(items)
182
+ if cleaned:
183
+ _active_instruments = cleaned
184
+ return
185
+ _persist_active_instruments()
186
+
187
+
188
+ def _clean_instrument_list(items: List[str]) -> List[str]:
189
+ seen = set()
190
+ cleaned: List[str] = []
191
+ for name in items:
192
+ if not isinstance(name, str):
193
+ continue
194
+ low = name.strip()
195
+ if not low or low not in ALL_INSTRUMENTS or low in seen:
196
+ continue
197
+ seen.add(low)
198
+ cleaned.append(low)
199
+ if len(cleaned) >= MAX_ACTIVE:
200
+ break
201
+ return cleaned or DEFAULT_ACTIVE.copy()
202
+
203
+
204
+ def get_active_instruments() -> List[str]:
205
+ with _config_lock:
206
+ return list(_active_instruments)
207
+
208
+
209
+ def set_active_instruments(new_items: List[str]) -> List[str]:
210
+ cleaned = _clean_instrument_list(new_items)
211
+ with _config_lock:
212
+ global _active_instruments
213
+ _active_instruments = cleaned
214
+ _persist_active_instruments()
215
+ return list(_active_instruments)
216
+
217
+
218
+ def get_available_instruments() -> List[str]:
219
+ return list(ALL_INSTRUMENTS)
220
+
221
+
222
+ _load_initial_config()
223
+
224
+ __all__ = [
225
+ "get_active_instruments",
226
+ "set_active_instruments",
227
+ "get_available_instruments",
228
+ "ALL_INSTRUMENTS",
229
+ "DEFAULT_ACTIVE",
230
+ "CONFIG_PATH",
231
+ "MAX_ACTIVE",
232
+ ]
Theremini/dashboard.py CHANGED
@@ -8,6 +8,12 @@ import time
8
  from pathlib import Path
9
  from typing import Any, Dict
10
 
 
 
 
 
 
 
11
  DASHBOARD_PORT = 8095
12
 
13
  _status_lock = threading.Lock()
@@ -61,7 +67,7 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
61
  def do_OPTIONS(self):
62
  self.send_response(204)
63
  self.send_header("Access-Control-Allow-Origin", "*")
64
- self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
65
  self.send_header("Access-Control-Allow-Headers", "Content-Type")
66
  self.end_headers()
67
 
@@ -69,8 +75,43 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
69
  if self.path == "/api/status":
70
  self._send_json({"ok": True, "status": get_status()})
71
  return
 
 
 
 
 
 
 
 
 
72
  return super().do_GET()
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  class DashboardServer:
76
  def __init__(self, host: str = "0.0.0.0", port: int = DASHBOARD_PORT) -> None:
 
8
  from pathlib import Path
9
  from typing import Any, Dict
10
 
11
+ from .config import (
12
+ get_active_instruments,
13
+ get_available_instruments,
14
+ set_active_instruments,
15
+ )
16
+
17
  DASHBOARD_PORT = 8095
18
 
19
  _status_lock = threading.Lock()
 
67
  def do_OPTIONS(self):
68
  self.send_response(204)
69
  self.send_header("Access-Control-Allow-Origin", "*")
70
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
71
  self.send_header("Access-Control-Allow-Headers", "Content-Type")
72
  self.end_headers()
73
 
 
75
  if self.path == "/api/status":
76
  self._send_json({"ok": True, "status": get_status()})
77
  return
78
+ if self.path == "/api/config":
79
+ self._send_json(
80
+ {
81
+ "ok": True,
82
+ "active_instruments": get_active_instruments(),
83
+ "available_instruments": get_available_instruments(),
84
+ }
85
+ )
86
+ return
87
  return super().do_GET()
88
 
89
+ def do_POST(self):
90
+ if self.path == "/api/config":
91
+ self._handle_config_post()
92
+ return
93
+ self.send_error(404)
94
+
95
+ def _handle_config_post(self) -> None:
96
+ try:
97
+ length = int(self.headers.get("Content-Length", "0"))
98
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
99
+ except Exception:
100
+ self._send_json({"ok": False, "error": "invalid-json"}, status=400)
101
+ return
102
+
103
+ if not isinstance(payload, dict) or "active_instruments" not in payload:
104
+ self._send_json({"ok": False, "error": "invalid-payload"}, status=400)
105
+ return
106
+
107
+ incoming = payload["active_instruments"]
108
+ if not isinstance(incoming, list):
109
+ self._send_json({"ok": False, "error": "invalid-payload"}, status=400)
110
+ return
111
+
112
+ updated = set_active_instruments(incoming)
113
+ self._send_json({"ok": True, "active_instruments": updated})
114
+
115
 
116
  class DashboardServer:
117
  def __init__(self, host: str = "0.0.0.0", port: int = DASHBOARD_PORT) -> None:
Theremini/main.py CHANGED
@@ -8,9 +8,11 @@ import time
8
  from typing import Any
9
 
10
  from reachy_mini import ReachyMini, ReachyMiniApp
 
11
  from scamp import Session
12
  from scipy.spatial.transform import Rotation as R
13
 
 
14
  from .dashboard import DASHBOARD_PORT, DashboardServer, set_status
15
 
16
  # ────────────────── mapping constants ───────────────────────────────
@@ -21,15 +23,6 @@ AMP_RANGE = (0.0, 1.0) # mute…full
21
  RIGHT_ANT_RANGE = (-math.pi / 3, math.pi / 3) # raw radians → program index
22
 
23
  # ────────────────── available instruments (subset of GM bank) ──────
24
- AVAILABLE_PARTS = [
25
- "choir_aahs",
26
- "orchestra_hit",
27
- "taiko_drum",
28
- "trumpet",
29
- "french_horn",
30
- "soundtrack",
31
- ]
32
-
33
  NOTE_NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
34
 
35
 
@@ -60,19 +53,20 @@ class Theremini(ReachyMiniApp):
60
  def __init__(self) -> None:
61
  super().__init__()
62
  self._session = Session(max_threads=1024)
63
- self._parts_cache: dict[int, Any] = {}
 
64
  self._theremin = self._get_part_for_prog(0)
65
  self._note_handle: Any | None = None
66
  self._current_pitch: int | None = None
67
  self._current_prog: int | None = None
68
 
69
  def _get_part_for_prog(self, prog: int):
70
- if prog not in self._parts_cache:
 
71
  buf = io.StringIO()
72
  with contextlib.redirect_stdout(buf):
73
- name = AVAILABLE_PARTS[prog]
74
- self._parts_cache[prog] = self._session.new_part(name)
75
- return self._parts_cache[prog]
76
 
77
  def _stop_note(self) -> None:
78
  if self._note_handle:
@@ -82,58 +76,20 @@ class Theremini(ReachyMiniApp):
82
 
83
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
84
  print("Roll → Pitch\nZ → Volume\nRight antenna → Instrument\n")
85
- reachy_mini.disable_motors()
86
 
87
  dashboard_server = DashboardServer(port=DASHBOARD_PORT)
88
  dashboard_server.start()
89
  set_status(self._build_status_payload(0.0, 0.0, 0.0, 0.0, None, None, False))
90
 
 
91
  try:
92
- while not stop_event.is_set():
93
- _, antennas = reachy_mini.get_current_joint_positions()
94
- pose = reachy_mini.get_current_head_pose()
95
-
96
- trans_mm = pose[:3, 3] * 1_000
97
- roll_deg = math.degrees(R.from_matrix(pose[:3, :3]).as_euler("xyz")[0])
98
-
99
- target_pitch = int(round(_aff(roll_deg, *ROLL_DEG_RANGE, *NOTE_MIDI_RANGE)))
100
- amp = max(0.0, min(_aff(trans_mm[2], *Z_MM_RANGE, *AMP_RANGE), 1.0))
101
- prog = int(
102
- _aff(antennas[1], *RIGHT_ANT_RANGE, 0, len(AVAILABLE_PARTS) - 0.0001)
103
- )
104
-
105
- if prog != self._current_prog:
106
- self._stop_note()
107
- self._theremin = self._get_part_for_prog(prog)
108
- self._current_prog = prog
109
-
110
- if amp == 0.0:
111
- self._stop_note()
112
- else:
113
- if self._note_handle is None:
114
- self._note_handle = self._theremin.start_note(
115
- target_pitch, int(amp * 100)
116
- )
117
- self._current_pitch = target_pitch
118
- elif target_pitch != self._current_pitch:
119
- self._stop_note()
120
- self._note_handle = self._theremin.start_note(
121
- target_pitch, int(amp * 100)
122
- )
123
- self._current_pitch = target_pitch
124
- else:
125
- _send_cc(self._theremin, 11, int(amp * 127))
126
-
127
- self._publish_status(
128
- roll_deg=roll_deg,
129
- head_z=trans_mm[2],
130
- amp=amp,
131
- right_ant=antennas[1],
132
- prog=self._current_prog,
133
- pitch=target_pitch,
134
- playing=bool(self._note_handle),
135
- )
136
 
 
 
 
137
  time.sleep(0.02)
138
  finally:
139
  self._stop_note()
@@ -151,7 +107,7 @@ class Theremini(ReachyMiniApp):
151
  pitch: int | None,
152
  playing: bool,
153
  ) -> dict[str, object]:
154
- inst_name = AVAILABLE_PARTS[prog] if prog is not None else "--"
155
  note_name = midi_to_name(pitch) if pitch is not None else "--"
156
  return {
157
  "instrument_index": prog,
@@ -186,6 +142,209 @@ class Theremini(ReachyMiniApp):
186
  )
187
  set_status(payload)
188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
  if __name__ == "__main__":
191
  with ReachyMini() as mini:
 
8
  from typing import Any
9
 
10
  from reachy_mini import ReachyMini, ReachyMiniApp
11
+ from reachy_mini.utils import create_head_pose
12
  from scamp import Session
13
  from scipy.spatial.transform import Rotation as R
14
 
15
+ from .config import get_active_instruments
16
  from .dashboard import DASHBOARD_PORT, DashboardServer, set_status
17
 
18
  # ────────────────── mapping constants ───────────────────────────────
 
23
  RIGHT_ANT_RANGE = (-math.pi / 3, math.pi / 3) # raw radians → program index
24
 
25
  # ────────────────── available instruments (subset of GM bank) ──────
 
 
 
 
 
 
 
 
 
26
  NOTE_NAMES = ["C", "C♯", "D", "D♯", "E", "F", "F♯", "G", "G♯", "A", "A♯", "B"]
27
 
28
 
 
53
  def __init__(self) -> None:
54
  super().__init__()
55
  self._session = Session(max_threads=1024)
56
+ self._parts_cache: dict[str, Any] = {}
57
+ self._active_parts = get_active_instruments()
58
  self._theremin = self._get_part_for_prog(0)
59
  self._note_handle: Any | None = None
60
  self._current_pitch: int | None = None
61
  self._current_prog: int | None = None
62
 
63
  def _get_part_for_prog(self, prog: int):
64
+ name = self._active_parts[prog]
65
+ if name not in self._parts_cache:
66
  buf = io.StringIO()
67
  with contextlib.redirect_stdout(buf):
68
+ self._parts_cache[name] = self._session.new_part(name)
69
+ return self._parts_cache[name]
 
70
 
71
  def _stop_note(self) -> None:
72
  if self._note_handle:
 
76
 
77
  def run(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
78
  print("Roll → Pitch\nZ → Volume\nRight antenna → Instrument\n")
 
79
 
80
  dashboard_server = DashboardServer(port=DASHBOARD_PORT)
81
  dashboard_server.start()
82
  set_status(self._build_status_payload(0.0, 0.0, 0.0, 0.0, None, None, False))
83
 
84
+ reachy_mini.enable_motors()
85
  try:
86
+ self._perform_intro_demo(reachy_mini, stop_event)
87
+ finally:
88
+ reachy_mini.disable_motors()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
+ try:
91
+ while not stop_event.is_set():
92
+ self._tick_from_robot(reachy_mini)
93
  time.sleep(0.02)
94
  finally:
95
  self._stop_note()
 
107
  pitch: int | None,
108
  playing: bool,
109
  ) -> dict[str, object]:
110
+ inst_name = self._active_parts[prog] if prog is not None else "--"
111
  note_name = midi_to_name(pitch) if pitch is not None else "--"
112
  return {
113
  "instrument_index": prog,
 
142
  )
143
  set_status(payload)
144
 
145
+ def _ensure_active_parts_fresh(self) -> None:
146
+ latest = get_active_instruments()
147
+ if latest != self._active_parts:
148
+ previous_name = None
149
+ if self._current_prog is not None and self._current_prog < len(self._active_parts):
150
+ previous_name = self._active_parts[self._current_prog]
151
+
152
+ self._active_parts = latest
153
+
154
+ if previous_name and previous_name in self._active_parts:
155
+ self._current_prog = self._active_parts.index(previous_name)
156
+ self._theremin = self._get_part_for_prog(self._current_prog)
157
+ else:
158
+ self._current_prog = None
159
+ self._theremin = self._get_part_for_prog(0)
160
+
161
+ def _handle_sound_state(self, target_pitch: int, amp: float, prog: int) -> None:
162
+ if prog != self._current_prog:
163
+ self._stop_note()
164
+ self._theremin = self._get_part_for_prog(prog)
165
+ self._current_prog = prog
166
+
167
+ if amp == 0.0:
168
+ self._stop_note()
169
+ return
170
+
171
+ if self._note_handle is None:
172
+ self._note_handle = self._theremin.start_note(target_pitch, int(amp * 100))
173
+ self._current_pitch = target_pitch
174
+ elif target_pitch != self._current_pitch:
175
+ self._stop_note()
176
+ self._note_handle = self._theremin.start_note(target_pitch, int(amp * 100))
177
+ self._current_pitch = target_pitch
178
+ else:
179
+ _send_cc(self._theremin, 11, int(amp * 127))
180
+
181
+ def _tick_from_robot(self, reachy_mini: ReachyMini) -> None:
182
+ self._ensure_active_parts_fresh()
183
+ if not self._active_parts:
184
+ return
185
+
186
+ _, antennas = reachy_mini.get_current_joint_positions()
187
+ pose = reachy_mini.get_current_head_pose()
188
+
189
+ trans_mm = pose[:3, 3] * 1_000
190
+ roll_deg = math.degrees(R.from_matrix(pose[:3, :3]).as_euler("xyz")[0])
191
+
192
+ target_pitch = int(round(_aff(roll_deg, *ROLL_DEG_RANGE, *NOTE_MIDI_RANGE)))
193
+ amp = max(0.0, min(_aff(trans_mm[2], *Z_MM_RANGE, *AMP_RANGE), 1.0))
194
+ prog = int(
195
+ _aff(
196
+ antennas[1],
197
+ *RIGHT_ANT_RANGE,
198
+ 0,
199
+ max(0.0, len(self._active_parts) - 0.0001),
200
+ )
201
+ )
202
+ prog = max(0, min(prog, len(self._active_parts) - 1))
203
+
204
+ self._handle_sound_state(target_pitch, amp, prog)
205
+
206
+ self._publish_status(
207
+ roll_deg=roll_deg,
208
+ head_z=trans_mm[2],
209
+ amp=amp,
210
+ right_ant=antennas[1],
211
+ prog=self._current_prog,
212
+ pitch=target_pitch,
213
+ playing=bool(self._note_handle),
214
+ )
215
+
216
+ def _perform_intro_demo(self, reachy_mini: ReachyMini, stop_event: threading.Event) -> None:
217
+ if not self._active_parts:
218
+ return
219
+
220
+ reachy_mini.goto_target(head=create_head_pose(z=0.0, degrees=True), antennas=[0.0, 0.0], duration=0.8)
221
+ self._demo_pause(reachy_mini, stop_event, 0.3)
222
+
223
+ def clamp_idx(idx: int) -> int:
224
+ if not self._active_parts:
225
+ return 0
226
+ return max(0, min(idx, len(self._active_parts) - 1))
227
+
228
+ left_idx = clamp_idx(0)
229
+ right_idx = clamp_idx(1)
230
+ oscillation_idx = clamp_idx(2)
231
+
232
+ current_roll = 0.0
233
+
234
+ self._demo_select_instrument(reachy_mini, left_idx, stop_event, dwell=0.3)
235
+ current_roll = self._demo_roll_linear(
236
+ reachy_mini,
237
+ stop_event,
238
+ current_roll,
239
+ target_roll=-28.0,
240
+ duration=1.6,
241
+ )
242
+ self._demo_pause(reachy_mini, stop_event, 0.45)
243
+
244
+ self._demo_select_instrument(reachy_mini, right_idx, stop_event, dwell=0.28)
245
+ current_roll = self._demo_roll_linear(
246
+ reachy_mini,
247
+ stop_event,
248
+ current_roll,
249
+ target_roll=28.0,
250
+ duration=1.6,
251
+ )
252
+ self._demo_pause(reachy_mini, stop_event, 0.45)
253
+
254
+ self._demo_select_instrument(reachy_mini, oscillation_idx, stop_event, dwell=0.25)
255
+ current_roll = self._demo_roll_linear(
256
+ reachy_mini,
257
+ stop_event,
258
+ current_roll,
259
+ target_roll=0.0,
260
+ duration=1.0,
261
+ )
262
+ self._demo_roll_oscillation(
263
+ reachy_mini,
264
+ stop_event,
265
+ center=0.0,
266
+ amplitude=10.0,
267
+ cycles=2,
268
+ frequency=1.5,
269
+ )
270
+
271
+ reachy_mini.goto_target(
272
+ head=create_head_pose(z=-0.02, degrees=True), antennas=[0.0, 0.0], duration=1.2
273
+ )
274
+ self._demo_pause(reachy_mini, stop_event, 0.6)
275
+
276
+ def _demo_select_instrument(
277
+ self,
278
+ reachy_mini: ReachyMini,
279
+ index: int,
280
+ stop_event: threading.Event,
281
+ dwell: float,
282
+ ) -> None:
283
+ if not self._active_parts:
284
+ return
285
+ index = max(0, min(index, len(self._active_parts) - 1))
286
+ angle = self._angle_for_instrument_index(index)
287
+ reachy_mini.set_target_antenna_joint_positions([0.0, float(angle)])
288
+ self._demo_pause(reachy_mini, stop_event, dwell)
289
+
290
+ def _angle_for_instrument_index(self, index: int) -> float:
291
+ if len(self._active_parts) <= 1:
292
+ return 0.0
293
+ span = RIGHT_ANT_RANGE[1] - RIGHT_ANT_RANGE[0]
294
+ ratio = index / (len(self._active_parts) - 1)
295
+ return RIGHT_ANT_RANGE[0] + span * ratio
296
+
297
+ def _demo_pause(
298
+ self, reachy_mini: ReachyMini, stop_event: threading.Event, duration: float
299
+ ) -> None:
300
+ end = time.time() + max(0.0, duration)
301
+ while time.time() < end and not stop_event.is_set():
302
+ self._tick_from_robot(reachy_mini)
303
+ time.sleep(0.02)
304
+
305
+ def _demo_roll_linear(
306
+ self,
307
+ reachy_mini: ReachyMini,
308
+ stop_event: threading.Event,
309
+ start_roll: float,
310
+ target_roll: float,
311
+ duration: float,
312
+ ) -> float:
313
+ duration = max(0.3, duration)
314
+ start_time = time.time()
315
+ while not stop_event.is_set():
316
+ elapsed = time.time() - start_time
317
+ ratio = min(1.0, elapsed / duration)
318
+ roll = start_roll + (target_roll - start_roll) * ratio
319
+ pose = create_head_pose(z=0.0, roll=roll, degrees=True)
320
+ reachy_mini.set_target(head=pose)
321
+ self._tick_from_robot(reachy_mini)
322
+ if ratio >= 1.0:
323
+ break
324
+ time.sleep(0.02)
325
+ return target_roll
326
+
327
+ def _demo_roll_oscillation(
328
+ self,
329
+ reachy_mini: ReachyMini,
330
+ stop_event: threading.Event,
331
+ center: float,
332
+ amplitude: float,
333
+ cycles: int,
334
+ frequency: float,
335
+ ) -> None:
336
+ if frequency <= 0 or cycles <= 0:
337
+ return
338
+ total_duration = cycles / frequency
339
+ start = time.time()
340
+ while not stop_event.is_set() and time.time() - start < total_duration:
341
+ elapsed = time.time() - start
342
+ roll = center + amplitude * math.sin(2 * math.pi * frequency * elapsed)
343
+ pose = create_head_pose(z=0.0, roll=roll, degrees=True)
344
+ reachy_mini.set_target(head=pose)
345
+ self._tick_from_robot(reachy_mini)
346
+ time.sleep(0.02)
347
+
348
 
349
  if __name__ == "__main__":
350
  with ReachyMini() as mini:
Theremini/webui/app.js CHANGED
@@ -11,9 +11,12 @@ const rollValueEl = document.getElementById("rollValue");
11
  const heightValueEl = document.getElementById("heightValue");
12
  const antennaValueEl = document.getElementById("antennaValue");
13
  const playingIndicator = document.getElementById("playingIndicator");
14
- const updatedAtEl = document.getElementById("updatedAt");
15
- const latencyInfoEl = document.getElementById("latencyInfo");
16
- const updatedCard = document.getElementById("updatedCard");
 
 
 
17
 
18
  const ranges = {
19
  roll: { min: -60, max: 60, meter: rollMeterEl, display: rollValueEl, unit: "°" },
@@ -28,6 +31,12 @@ const ranges = {
28
  amplitude: { min: 0, max: 1, meter: ampMeterEl },
29
  };
30
 
 
 
 
 
 
 
31
  function clamp(value, min, max) {
32
  return Math.min(Math.max(value, min), max);
33
  }
@@ -49,20 +58,6 @@ function updatePresence(isPlaying) {
49
  playingIndicator.querySelector(".label").textContent = isPlaying ? "Playing" : "Silent";
50
  }
51
 
52
- function updateTimestamp(updatedAtSeconds) {
53
- if (!updatedAtSeconds) {
54
- updatedAtEl.textContent = "--";
55
- latencyInfoEl.textContent = "waiting for data…";
56
- updatedCard.dataset.state = "fresh";
57
- return;
58
- }
59
- const updatedDate = new Date(updatedAtSeconds * 1000);
60
- updatedAtEl.textContent = updatedDate.toLocaleTimeString([], { hour12: false });
61
- const age = (Date.now() / 1000) - updatedAtSeconds;
62
- latencyInfoEl.textContent = age < 1 ? "live < 1 s" : `lag ${age.toFixed(1)} s`;
63
- updatedCard.dataset.state = age > 2 ? "stale" : "fresh";
64
- }
65
-
66
  function updateStatus(status) {
67
  const programIndex = status.instrument_index;
68
  instrumentIndexEl.textContent = programIndex ?? "--";
@@ -87,7 +82,6 @@ function updateStatus(status) {
87
  setMeter(ranges.antenna.meter, antenna, ranges.antenna.min, ranges.antenna.max);
88
 
89
  updatePresence(Boolean(status.playing));
90
- updateTimestamp(status.updated_at);
91
  }
92
 
93
  async function pollStatus() {
@@ -101,11 +95,211 @@ async function pollStatus() {
101
  updateStatus(payload.status ?? {});
102
  }
103
  } catch (error) {
104
- updatedCard.dataset.state = "stale";
105
- latencyInfoEl.textContent = "dashboard offline";
106
  console.error("Theremini dashboard", error);
107
  }
108
  }
109
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  pollStatus();
111
  setInterval(pollStatus, 400);
 
 
 
 
 
 
 
 
 
 
11
  const heightValueEl = document.getElementById("heightValue");
12
  const antennaValueEl = document.getElementById("antennaValue");
13
  const playingIndicator = document.getElementById("playingIndicator");
14
+ const programListEl = document.getElementById("programList");
15
+ const availableListEl = document.getElementById("availableList");
16
+ const activeListEl = document.getElementById("activeList");
17
+ const searchInput = document.getElementById("instrumentSearch");
18
+ const saveConfigBtn = document.getElementById("saveConfigBtn");
19
+ const configStatusEl = document.getElementById("configStatus");
20
 
21
  const ranges = {
22
  roll: { min: -60, max: 60, meter: rollMeterEl, display: rollValueEl, unit: "°" },
 
31
  amplitude: { min: 0, max: 1, meter: ampMeterEl },
32
  };
33
 
34
+ const MAX_ACTIVE = 8;
35
+ let availableVoices = [];
36
+ let activeVoices = [];
37
+ let filteredList = [];
38
+ let configDirty = false;
39
+
40
  function clamp(value, min, max) {
41
  return Math.min(Math.max(value, min), max);
42
  }
 
58
  playingIndicator.querySelector(".label").textContent = isPlaying ? "Playing" : "Silent";
59
  }
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  function updateStatus(status) {
62
  const programIndex = status.instrument_index;
63
  instrumentIndexEl.textContent = programIndex ?? "--";
 
82
  setMeter(ranges.antenna.meter, antenna, ranges.antenna.min, ranges.antenna.max);
83
 
84
  updatePresence(Boolean(status.playing));
 
85
  }
86
 
87
  async function pollStatus() {
 
95
  updateStatus(payload.status ?? {});
96
  }
97
  } catch (error) {
 
 
98
  console.error("Theremini dashboard", error);
99
  }
100
  }
101
 
102
+ function renderProgramList() {
103
+ programListEl.innerHTML = "";
104
+ if (!activeVoices.length) {
105
+ const empty = document.createElement("li");
106
+ empty.textContent = "No voices selected";
107
+ programListEl.appendChild(empty);
108
+ return;
109
+ }
110
+ activeVoices.forEach((name, index) => {
111
+ const li = document.createElement("li");
112
+ const badge = document.createElement("span");
113
+ badge.className = "badge";
114
+ badge.textContent = index;
115
+ const label = document.createElement("span");
116
+ label.textContent = name;
117
+ li.appendChild(badge);
118
+ li.appendChild(label);
119
+ programListEl.appendChild(li);
120
+ });
121
+ }
122
+
123
+ function renderAvailableList() {
124
+ availableListEl.innerHTML = "";
125
+ const searchTerm = (searchInput.value || "").trim().toLowerCase();
126
+ const list = searchTerm
127
+ ? availableVoices.filter((name) => name.includes(searchTerm))
128
+ : availableVoices;
129
+
130
+ if (!list.length) {
131
+ const empty = document.createElement("div");
132
+ empty.className = "list-item";
133
+ empty.textContent = "No match";
134
+ availableListEl.appendChild(empty);
135
+ return;
136
+ }
137
+
138
+ list.forEach((name) => {
139
+ const item = document.createElement("div");
140
+ item.className = "list-item";
141
+
142
+ const label = document.createElement("strong");
143
+ label.textContent = name;
144
+ item.appendChild(label);
145
+
146
+ const btn = document.createElement("button");
147
+ btn.textContent = activeVoices.includes(name)
148
+ ? "Added"
149
+ : activeVoices.length >= MAX_ACTIVE
150
+ ? "Full"
151
+ : "Add";
152
+ btn.disabled = activeVoices.includes(name) || activeVoices.length >= MAX_ACTIVE;
153
+ btn.addEventListener("click", () => addVoice(name));
154
+ item.appendChild(btn);
155
+ availableListEl.appendChild(item);
156
+ });
157
+ }
158
+
159
+ function renderActiveList() {
160
+ activeListEl.innerHTML = "";
161
+ if (!activeVoices.length) {
162
+ const empty = document.createElement("div");
163
+ empty.className = "list-item";
164
+ empty.textContent = "No voices yet";
165
+ activeListEl.appendChild(empty);
166
+ return;
167
+ }
168
+
169
+ activeVoices.forEach((name, index) => {
170
+ const item = document.createElement("div");
171
+ item.className = "list-item";
172
+
173
+ const info = document.createElement("div");
174
+ info.style.display = "flex";
175
+ info.style.gap = "0.4rem";
176
+ const idx = document.createElement("strong");
177
+ idx.textContent = index;
178
+ const lbl = document.createElement("span");
179
+ lbl.textContent = name;
180
+ info.appendChild(idx);
181
+ info.appendChild(lbl);
182
+ item.appendChild(info);
183
+
184
+ const controls = document.createElement("div");
185
+ controls.style.display = "flex";
186
+ controls.style.gap = "0.3rem";
187
+
188
+ const up = document.createElement("button");
189
+ up.className = "small";
190
+ up.textContent = "↑";
191
+ up.disabled = index === 0;
192
+ up.addEventListener("click", () => moveVoice(index, -1));
193
+
194
+ const down = document.createElement("button");
195
+ down.className = "small";
196
+ down.textContent = "↓";
197
+ down.disabled = index === activeVoices.length - 1;
198
+ down.addEventListener("click", () => moveVoice(index, 1));
199
+
200
+ const remove = document.createElement("button");
201
+ remove.textContent = "Remove";
202
+ remove.addEventListener("click", () => removeVoice(index));
203
+
204
+ controls.appendChild(up);
205
+ controls.appendChild(down);
206
+ controls.appendChild(remove);
207
+ item.appendChild(controls);
208
+
209
+ activeListEl.appendChild(item);
210
+ });
211
+ }
212
+
213
+ function markDirty() {
214
+ configDirty = true;
215
+ configStatusEl.textContent = "Unsaved changes";
216
+ }
217
+
218
+ function addVoice(name) {
219
+ if (activeVoices.includes(name) || activeVoices.length >= MAX_ACTIVE) {
220
+ return;
221
+ }
222
+ activeVoices.push(name);
223
+ renderActiveList();
224
+ renderProgramList();
225
+ renderAvailableList();
226
+ markDirty();
227
+ }
228
+
229
+ function removeVoice(index) {
230
+ activeVoices.splice(index, 1);
231
+ renderActiveList();
232
+ renderProgramList();
233
+ renderAvailableList();
234
+ markDirty();
235
+ }
236
+
237
+ function moveVoice(index, delta) {
238
+ const target = index + delta;
239
+ if (target < 0 || target >= activeVoices.length) {
240
+ return;
241
+ }
242
+ const [voice] = activeVoices.splice(index, 1);
243
+ activeVoices.splice(target, 0, voice);
244
+ renderActiveList();
245
+ renderProgramList();
246
+ markDirty();
247
+ }
248
+
249
+ async function fetchConfig() {
250
+ try {
251
+ const response = await fetch("/api/config", { cache: "no-store" });
252
+ if (!response.ok) {
253
+ throw new Error(`HTTP ${response.status}`);
254
+ }
255
+ const payload = await response.json();
256
+ if (payload && payload.ok) {
257
+ availableVoices = [...(payload.available_instruments || [])].sort();
258
+ activeVoices = [...(payload.active_instruments || [])];
259
+ configDirty = false;
260
+ configStatusEl.textContent = "Synced";
261
+ renderProgramList();
262
+ renderAvailableList();
263
+ renderActiveList();
264
+ }
265
+ } catch (error) {
266
+ configStatusEl.textContent = "Unable to load config";
267
+ console.error("Theremini config", error);
268
+ }
269
+ }
270
+
271
+ async function saveConfig() {
272
+ try {
273
+ saveConfigBtn.disabled = true;
274
+ configStatusEl.textContent = "Saving...";
275
+ const response = await fetch("/api/config", {
276
+ method: "POST",
277
+ headers: { "Content-Type": "application/json" },
278
+ body: JSON.stringify({ active_instruments: activeVoices }),
279
+ });
280
+ const payload = await response.json();
281
+ if (!response.ok || !payload.ok) {
282
+ throw new Error(payload?.error || "Failed to save");
283
+ }
284
+ configDirty = false;
285
+ configStatusEl.textContent = "Saved";
286
+ renderProgramList();
287
+ } catch (error) {
288
+ configStatusEl.textContent = `Error: ${error.message}`;
289
+ console.error("Theremini save", error);
290
+ } finally {
291
+ saveConfigBtn.disabled = false;
292
+ }
293
+ }
294
+
295
  pollStatus();
296
  setInterval(pollStatus, 400);
297
+ fetchConfig();
298
+ setInterval(() => {
299
+ if (!configDirty) {
300
+ fetchConfig();
301
+ }
302
+ }, 5000);
303
+
304
+ saveConfigBtn.addEventListener("click", saveConfig);
305
+ searchInput.addEventListener("input", renderAvailableList);
Theremini/webui/index.html CHANGED
@@ -37,15 +37,10 @@
37
  <article class="card">
38
  <p class="label">Amplitude</p>
39
  <h2 id="ampPercent">0%</h2>
40
- <div class="meter" data-min="0" data-max="1" data-unit="">
41
  <div class="meter-fill" id="ampMeter"></div>
42
  </div>
43
  </article>
44
- <article class="card" id="updatedCard">
45
- <p class="label">Last update</p>
46
- <h2 id="updatedAt">--</h2>
47
- <p class="meta" id="latencyInfo">waiting for data…</p>
48
- </article>
49
  </section>
50
 
51
  <section class="grid meters">
@@ -96,17 +91,36 @@
96
  <div>
97
  <p class="label">Program slots</p>
98
  <h2>FluidSynth voices</h2>
99
- <p class="meta">Same subset as <code>simple_theremini.py</code>. Move the right antenna to scroll.</p>
100
  </div>
101
- <ul class="program-list">
102
- <li><span>0</span> choir_aahs</li>
103
- <li><span>1</span> orchestra_hit</li>
104
- <li><span>2</span> taiko_drum</li>
105
- <li><span>3</span> trumpet</li>
106
- <li><span>4</span> french_horn</li>
107
- <li><span>5</span> soundtrack</li>
108
  </ul>
109
  </section>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  </main>
111
 
112
  <script src="app.js" type="module"></script>
 
37
  <article class="card">
38
  <p class="label">Amplitude</p>
39
  <h2 id="ampPercent">0%</h2>
40
+ <div class="meter">
41
  <div class="meter-fill" id="ampMeter"></div>
42
  </div>
43
  </article>
 
 
 
 
 
44
  </section>
45
 
46
  <section class="grid meters">
 
91
  <div>
92
  <p class="label">Program slots</p>
93
  <h2>FluidSynth voices</h2>
94
+ <p class="meta">Move the right antenna to cycle through your curated list.</p>
95
  </div>
96
+ <ul class="program-list" id="programList">
 
 
 
 
 
 
97
  </ul>
98
  </section>
99
+
100
+ <section class="card config-card">
101
+ <div class="config-head">
102
+ <div>
103
+ <p class="label">Live instrument set</p>
104
+ <h2>Pick the presets Reachy cycles through</h2>
105
+ <p class="meta">Choose up to eight voices from the full GM bank. Order matters.</p>
106
+ </div>
107
+ <div class="config-actions">
108
+ <input id="instrumentSearch" type="text" placeholder="Filter presets..." />
109
+ <button id="saveConfigBtn">Save selection</button>
110
+ </div>
111
+ </div>
112
+ <div class="list-grid">
113
+ <div class="list-panel">
114
+ <p class="panel-title">Available voices</p>
115
+ <div class="list-box" id="availableList"></div>
116
+ </div>
117
+ <div class="list-panel">
118
+ <p class="panel-title">Active rotation</p>
119
+ <div class="list-box active" id="activeList"></div>
120
+ </div>
121
+ </div>
122
+ <p class="meta" id="configStatus">&nbsp;</p>
123
+ </section>
124
  </main>
125
 
126
  <script src="app.js" type="module"></script>
Theremini/webui/style.css CHANGED
@@ -204,7 +204,7 @@ body {
204
  color: rgba(241, 245, 249, 0.85);
205
  }
206
 
207
- .program-list span {
208
  width: 1.8rem;
209
  height: 1.8rem;
210
  border-radius: 0.6rem;
@@ -216,6 +216,109 @@ body {
216
  border: 1px solid rgba(129, 140, 248, 0.5);
217
  }
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  code {
220
  background: rgba(15, 23, 42, 0.7);
221
  padding: 0.1rem 0.4rem;
@@ -223,13 +326,10 @@ code {
223
  border: 1px solid rgba(148, 163, 184, 0.25);
224
  }
225
 
226
- #updatedCard[data-state="stale"] {
227
- border-color: rgba(251, 113, 133, 0.6);
228
- }
229
-
230
- #updatedCard[data-state="stale"] h2,
231
- #updatedCard[data-state="stale"] .meta {
232
- color: var(--danger);
233
  }
234
 
235
  @media (max-width: 640px) {
 
204
  color: rgba(241, 245, 249, 0.85);
205
  }
206
 
207
+ .program-list .badge {
208
  width: 1.8rem;
209
  height: 1.8rem;
210
  border-radius: 0.6rem;
 
216
  border: 1px solid rgba(129, 140, 248, 0.5);
217
  }
218
 
219
+ .config-card {
220
+ display: flex;
221
+ flex-direction: column;
222
+ gap: 1.2rem;
223
+ }
224
+
225
+ .config-head {
226
+ display: flex;
227
+ flex-wrap: wrap;
228
+ gap: 1rem;
229
+ justify-content: space-between;
230
+ align-items: center;
231
+ }
232
+
233
+ .config-actions {
234
+ display: flex;
235
+ gap: 0.75rem;
236
+ align-items: center;
237
+ }
238
+
239
+ .config-actions input {
240
+ padding: 0.5rem 0.85rem;
241
+ border-radius: 999px;
242
+ border: 1px solid rgba(148, 163, 184, 0.4);
243
+ background: rgba(2, 6, 23, 0.7);
244
+ color: var(--text);
245
+ min-width: 220px;
246
+ }
247
+
248
+ .config-actions button {
249
+ border: none;
250
+ border-radius: 999px;
251
+ padding: 0.55rem 1.4rem;
252
+ background: linear-gradient(120deg, #6366f1, #c084fc);
253
+ color: white;
254
+ font-weight: 600;
255
+ cursor: pointer;
256
+ }
257
+
258
+ .list-grid {
259
+ display: grid;
260
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
261
+ gap: 1rem;
262
+ }
263
+
264
+ .list-panel {
265
+ display: flex;
266
+ flex-direction: column;
267
+ gap: 0.75rem;
268
+ }
269
+
270
+ .panel-title {
271
+ text-transform: uppercase;
272
+ font-size: 0.75rem;
273
+ letter-spacing: 0.08em;
274
+ color: var(--muted);
275
+ }
276
+
277
+ .list-box {
278
+ border: 1px solid rgba(148, 163, 184, 0.25);
279
+ border-radius: 16px;
280
+ background: rgba(9, 9, 21, 0.65);
281
+ padding: 0.75rem;
282
+ max-height: 320px;
283
+ overflow-y: auto;
284
+ display: flex;
285
+ flex-direction: column;
286
+ gap: 0.4rem;
287
+ }
288
+
289
+ .list-box.active {
290
+ border-color: rgba(167, 139, 250, 0.6);
291
+ }
292
+
293
+ .list-item {
294
+ display: flex;
295
+ justify-content: space-between;
296
+ align-items: center;
297
+ gap: 0.75rem;
298
+ padding: 0.4rem 0.6rem;
299
+ border-radius: 10px;
300
+ background: rgba(99, 102, 241, 0.08);
301
+ }
302
+
303
+ .list-item strong {
304
+ font-size: 0.95rem;
305
+ color: var(--accent);
306
+ }
307
+
308
+ .list-item button {
309
+ border: none;
310
+ border-radius: 6px;
311
+ padding: 0.25rem 0.6rem;
312
+ font-size: 0.75rem;
313
+ background: rgba(148, 163, 184, 0.25);
314
+ color: var(--text);
315
+ cursor: pointer;
316
+ }
317
+
318
+ .list-item button.small {
319
+ padding: 0.2rem 0.45rem;
320
+ }
321
+
322
  code {
323
  background: rgba(15, 23, 42, 0.7);
324
  padding: 0.1rem 0.4rem;
 
326
  border: 1px solid rgba(148, 163, 184, 0.25);
327
  }
328
 
329
+ input:focus,
330
+ button:focus {
331
+ outline: 2px solid rgba(99, 102, 241, 0.4);
332
+ outline-offset: 2px;
 
 
 
333
  }
334
 
335
  @media (max-width: 640px) {
index.html CHANGED
@@ -40,7 +40,7 @@
40
  <tr>
41
  <td>Right antenna</td>
42
  <td>-π/3 ⇢ +π/3 rad</td>
43
- <td>Instrument index 0 ⇢ 5</td>
44
  </tr>
45
  </tbody>
46
  </table>
@@ -48,7 +48,7 @@
48
 
49
  <section class="card">
50
  <h2>Instrument Programs</h2>
51
- <p class="caption">Theremini cycles through a curated subset of FluidSynth GM presets. The robot crossfades by reusing CC 11 just like the original script.</p>
52
  <ul class="instrument-list">
53
  <li><span class="label">0</span>choir_aahs</li>
54
  <li><span class="label">1</span>orchestra_hit</li>
 
40
  <tr>
41
  <td>Right antenna</td>
42
  <td>-π/3 ⇢ +π/3 rad</td>
43
+ <td>Instrument index 0 ⇢ (#active−1)</td>
44
  </tr>
45
  </tbody>
46
  </table>
 
48
 
49
  <section class="card">
50
  <h2>Instrument Programs</h2>
51
+ <p class="caption">Theremini cycles through a curated subset of FluidSynth GM presets. The dashboard lets you swap presets live; these are the defaults.</p>
52
  <ul class="instrument-list">
53
  <li><span class="label">0</span>choir_aahs</li>
54
  <li><span class="label">1</span>orchestra_hit</li>
pyproject.toml CHANGED
@@ -27,4 +27,4 @@ include-package-data = true
27
  where = ["Theremini"]
28
 
29
  [tool.setuptools.package-data]
30
- Theremini = ["webui/**/*"]
 
27
  where = ["Theremini"]
28
 
29
  [tool.setuptools.package-data]
30
+ Theremini = ["webui/**/*", "config.json"]