Spaces:
Running
Running
RemiFabre
commited on
Commit
·
c93a753
1
Parent(s):
a6ca140
Smoother init phase and dynamic instrument change (config saved to disk)
Browse files- README.md +9 -1
- Theremini/config.json +6 -0
- Theremini/config.py +232 -0
- Theremini/dashboard.py +42 -1
- Theremini/main.py +219 -60
- Theremini/webui/app.js +214 -20
- Theremini/webui/index.html +28 -14
- Theremini/webui/style.css +108 -8
- index.html +2 -2
- pyproject.toml +1 -1
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
|
|
|
|
|
|
|
| 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[
|
|
|
|
| 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 |
-
|
|
|
|
| 71 |
buf = io.StringIO()
|
| 72 |
with contextlib.redirect_stdout(buf):
|
| 73 |
-
name =
|
| 74 |
-
|
| 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 |
-
|
| 93 |
-
|
| 94 |
-
|
| 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 =
|
| 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
|
| 15 |
-
const
|
| 16 |
-
const
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 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">
|
| 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"> </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
|
| 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 |
-
|
| 227 |
-
|
| 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 ⇢
|
| 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
|
| 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"]
|