RemiFabre commited on
Commit
1f737e5
Β·
1 Parent(s): 1e1b6ff

v0 of flluidsynth auto detection and guide to install

Browse files
MANIFEST.in ADDED
@@ -0,0 +1 @@
 
 
1
+ recursive-include ressources *.md
Theremini/dashboard.py CHANGED
@@ -30,6 +30,13 @@ _status: Dict[str, Any] = {
30
  "updated_at": time.time(),
31
  }
32
 
 
 
 
 
 
 
 
33
 
34
  def set_status(payload: Dict[str, Any]) -> None:
35
  payload = dict(payload)
@@ -43,6 +50,16 @@ def get_status() -> Dict[str, Any]:
43
  return dict(_status)
44
 
45
 
 
 
 
 
 
 
 
 
 
 
46
  class DashboardHandler(http.server.SimpleHTTPRequestHandler):
47
  directory = str(Path(__file__).resolve().parent / "webui")
48
 
@@ -73,7 +90,7 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
73
 
74
  def do_GET(self):
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(
@@ -175,4 +192,4 @@ class DashboardServer:
175
  self._ready.set()
176
 
177
 
178
- __all__ = ["DashboardServer", "DASHBOARD_PORT", "set_status", "get_status"]
 
30
  "updated_at": time.time(),
31
  }
32
 
33
+ _health_lock = threading.Lock()
34
+ _health: Dict[str, Any] = {
35
+ "fluidsynth_ok": True,
36
+ "fluidsynth_error": None,
37
+ "fluidsynth_guide_html": "",
38
+ }
39
+
40
 
41
  def set_status(payload: Dict[str, Any]) -> None:
42
  payload = dict(payload)
 
50
  return dict(_status)
51
 
52
 
53
+ def set_health(payload: Dict[str, Any]) -> None:
54
+ with _health_lock:
55
+ _health.update(dict(payload))
56
+
57
+
58
+ def get_health() -> Dict[str, Any]:
59
+ with _health_lock:
60
+ return dict(_health)
61
+
62
+
63
  class DashboardHandler(http.server.SimpleHTTPRequestHandler):
64
  directory = str(Path(__file__).resolve().parent / "webui")
65
 
 
90
 
91
  def do_GET(self):
92
  if self.path == "/api/status":
93
+ self._send_json({"ok": True, "status": get_status(), "health": get_health()})
94
  return
95
  if self.path == "/api/config":
96
  self._send_json(
 
192
  self._ready.set()
193
 
194
 
195
+ __all__ = ["DashboardServer", "DASHBOARD_PORT", "set_status", "get_status", "set_health", "get_health"]
Theremini/fluidsynth_check.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import html
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ try:
9
+ from markdown import markdown as _markdown
10
+ except Exception: # pragma: no cover - optional dependency guard
11
+ _markdown = None
12
+
13
+ GUIDE_PATH = Path(__file__).resolve().parent.parent / "ressources" / "fluidsynth_guide.md"
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class FluidSynthProbeResult:
18
+ ok: bool
19
+ error: str | None = None
20
+
21
+
22
+ def _render_markdown_fallback(text: str) -> str:
23
+ escaped = html.escape(text)
24
+ return f"<pre>{escaped}</pre>"
25
+
26
+
27
+ @functools.lru_cache(maxsize=1)
28
+ def get_fluidsynth_guide_html() -> str:
29
+ try:
30
+ markdown_text = GUIDE_PATH.read_text(encoding="utf-8")
31
+ except OSError as exc: # pragma: no cover - IO failure fallback
32
+ return _render_markdown_fallback(f"Unable to load guide: {exc}")
33
+
34
+ if _markdown is None:
35
+ return _render_markdown_fallback(markdown_text)
36
+
37
+ return _markdown(markdown_text, extensions=["fenced_code", "tables"])
38
+
39
+
40
+ def probe_fluidsynth() -> FluidSynthProbeResult:
41
+ """Try to exercise pyFluidSynth so we catch missing system libs early."""
42
+ try:
43
+ import fluidsynth # type: ignore
44
+ except Exception as exc:
45
+ return FluidSynthProbeResult(False, f"pyFluidSynth import failed: {exc}")
46
+
47
+ try:
48
+ synth = fluidsynth.Synth()
49
+ except Exception as exc:
50
+ return FluidSynthProbeResult(False, f"Unable to initialise FluidSynth: {exc}")
51
+
52
+ try:
53
+ # Trigger a couple of commands so we actually call into the shared library.
54
+ synth.noteon(0, 60, 30)
55
+ synth.noteoff(0, 60)
56
+ except Exception as exc:
57
+ synth.delete()
58
+ return FluidSynthProbeResult(False, f"FluidSynth test note failed: {exc}")
59
+
60
+ try:
61
+ synth.delete()
62
+ except Exception:
63
+ pass
64
+ return FluidSynthProbeResult(True, None)
65
+
66
+
67
+ __all__ = ["probe_fluidsynth", "get_fluidsynth_guide_html", "FluidSynthProbeResult"]
Theremini/main.py CHANGED
@@ -6,15 +6,22 @@ import io
6
  import math
7
  import threading
8
  import time
9
- from typing import Any
10
 
11
  from reachy_mini import ReachyMini, ReachyMiniApp
12
  from reachy_mini.utils import create_head_pose
13
- from scamp import Session
14
  from scipy.spatial.transform import Rotation as R
15
 
16
  from Theremini.config import get_active_instruments
17
- from Theremini.dashboard import DASHBOARD_PORT, DashboardServer, set_status
 
 
 
 
 
 
 
 
18
 
19
  # ────────────────── mapping constants ───────────────────────────────
20
  ROLL_DEG_RANGE = (-60, 60) # head roll span
@@ -54,10 +61,10 @@ class Theremini(ReachyMiniApp):
54
 
55
  def __init__(self, debug_mode: bool = False) -> None:
56
  super().__init__()
57
- self._session = Session(max_threads=1024)
58
  self._parts_cache: dict[str, Any] = {}
59
  self._active_parts = get_active_instruments()
60
- self._theremin = self._get_part_for_prog(0)
61
  self._note_handle: Any | None = None
62
  self._current_pitch: int | None = None
63
  self._current_prog: int | None = None
@@ -68,7 +75,37 @@ class Theremini(ReachyMiniApp):
68
  self._debug_last_motion_command: float | None = None
69
  self._debug_last_motion_log: float | None = None
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _get_part_for_prog(self, prog: int):
 
 
72
  name = self._active_parts[prog]
73
  if name not in self._parts_cache:
74
  buf = io.StringIO()
@@ -95,6 +132,12 @@ class Theremini(ReachyMiniApp):
95
  )
96
  set_status(self._build_status_payload(0.0, 0.0, 0.0, 0.0, None, None, False))
97
 
 
 
 
 
 
 
98
  reachy_mini.enable_motors()
99
  try:
100
  self._perform_intro_demo(reachy_mini, stop_event)
@@ -111,6 +154,16 @@ class Theremini(ReachyMiniApp):
111
  dashboard_server.stop()
112
  print("\nTheremin stopped.")
113
 
 
 
 
 
 
 
 
 
 
 
114
  def _build_status_payload(
115
  self,
116
  roll_deg: float,
@@ -173,6 +226,8 @@ class Theremini(ReachyMiniApp):
173
  self._theremin = self._get_part_for_prog(0)
174
 
175
  def _handle_sound_state(self, target_pitch: int, amp: float, prog: int) -> None:
 
 
176
  if prog != self._current_prog:
177
  self._stop_note()
178
  self._theremin = self._get_part_for_prog(prog)
@@ -400,6 +455,12 @@ class Theremini(ReachyMiniApp):
400
  )
401
  set_status(self._build_status_payload(0.0, 0.0, 0.0, 0.0, None, None, False))
402
 
 
 
 
 
 
 
403
  reachy_mini.enable_motors()
404
  try:
405
  while not stop_event.is_set():
 
6
  import math
7
  import threading
8
  import time
9
+ from typing import TYPE_CHECKING, Any
10
 
11
  from reachy_mini import ReachyMini, ReachyMiniApp
12
  from reachy_mini.utils import create_head_pose
 
13
  from scipy.spatial.transform import Rotation as R
14
 
15
  from Theremini.config import get_active_instruments
16
+ from Theremini.dashboard import DASHBOARD_PORT, DashboardServer, set_health, set_status
17
+ from Theremini.fluidsynth_check import (
18
+ FluidSynthProbeResult,
19
+ get_fluidsynth_guide_html,
20
+ probe_fluidsynth,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from scamp import Session
25
 
26
  # ────────────────── mapping constants ───────────────────────────────
27
  ROLL_DEG_RANGE = (-60, 60) # head roll span
 
61
 
62
  def __init__(self, debug_mode: bool = False) -> None:
63
  super().__init__()
64
+ self._session: Session | None = None
65
  self._parts_cache: dict[str, Any] = {}
66
  self._active_parts = get_active_instruments()
67
+ self._theremin: Any | None = None
68
  self._note_handle: Any | None = None
69
  self._current_pitch: int | None = None
70
  self._current_prog: int | None = None
 
75
  self._debug_last_motion_command: float | None = None
76
  self._debug_last_motion_log: float | None = None
77
 
78
+ self._fluidsynth_probe = probe_fluidsynth()
79
+ if self._fluidsynth_probe.ok:
80
+ try:
81
+ from scamp import Session as ScampSession
82
+ except Exception as exc:
83
+ self._fluidsynth_probe = FluidSynthProbeResult(False, f"SCAMP import failed: {exc}")
84
+ else:
85
+ self._session = ScampSession(max_threads=1024)
86
+ if self._active_parts:
87
+ self._theremin = self._get_part_for_prog(0)
88
+
89
+ if self._fluidsynth_probe.ok:
90
+ set_health(
91
+ {
92
+ "fluidsynth_ok": True,
93
+ "fluidsynth_error": None,
94
+ "fluidsynth_guide_html": "",
95
+ }
96
+ )
97
+ else:
98
+ set_health(
99
+ {
100
+ "fluidsynth_ok": False,
101
+ "fluidsynth_error": self._fluidsynth_probe.error,
102
+ "fluidsynth_guide_html": get_fluidsynth_guide_html(),
103
+ }
104
+ )
105
+
106
  def _get_part_for_prog(self, prog: int):
107
+ if self._session is None:
108
+ raise RuntimeError("FluidSynth is unavailable.")
109
  name = self._active_parts[prog]
110
  if name not in self._parts_cache:
111
  buf = io.StringIO()
 
132
  )
133
  set_status(self._build_status_payload(0.0, 0.0, 0.0, 0.0, None, None, False))
134
 
135
+ if not self._fluidsynth_probe.ok:
136
+ self._wait_for_fluidsynth_install(stop_event)
137
+ dashboard_server.stop()
138
+ print("\nTheremin stopped.")
139
+ return
140
+
141
  reachy_mini.enable_motors()
142
  try:
143
  self._perform_intro_demo(reachy_mini, stop_event)
 
154
  dashboard_server.stop()
155
  print("\nTheremin stopped.")
156
 
157
+ def _wait_for_fluidsynth_install(self, stop_event: threading.Event) -> None:
158
+ message = self._fluidsynth_probe.error or "FluidSynth runtime check failed."
159
+ print(
160
+ "\nFluidSynth is required but could not be initialised.\n"
161
+ f"Details: {message}\n"
162
+ "Open the Theremini dashboard for installation instructions."
163
+ )
164
+ while not stop_event.is_set():
165
+ time.sleep(0.5)
166
+
167
  def _build_status_payload(
168
  self,
169
  roll_deg: float,
 
226
  self._theremin = self._get_part_for_prog(0)
227
 
228
  def _handle_sound_state(self, target_pitch: int, amp: float, prog: int) -> None:
229
+ if self._theremin is None:
230
+ return
231
  if prog != self._current_prog:
232
  self._stop_note()
233
  self._theremin = self._get_part_for_prog(prog)
 
455
  )
456
  set_status(self._build_status_payload(0.0, 0.0, 0.0, 0.0, None, None, False))
457
 
458
+ if not self._fluidsynth_probe.ok:
459
+ self._wait_for_fluidsynth_install(stop_event)
460
+ dashboard_server.stop()
461
+ print("\nDebug Theremin loop stopped.")
462
+ return
463
+
464
  reachy_mini.enable_motors()
465
  try:
466
  while not stop_event.is_set():
Theremini/webui/app.js CHANGED
@@ -11,6 +11,9 @@ 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 availableListEl = document.getElementById("availableList");
15
  const activeListEl = document.getElementById("activeList");
16
  const searchInput = document.getElementById("instrumentSearch");
@@ -18,6 +21,7 @@ const configStatusEl = document.getElementById("configStatus");
18
  const dialSegmentsGroup = document.getElementById("dialSegments");
19
  const dialPointer = document.getElementById("dialPointer");
20
  const dialAngleEl = document.getElementById("dialAngle");
 
21
 
22
  const RIGHT_ANT_RANGE = { min: -Math.PI / 3, max: Math.PI / 3 };
23
  const DIAL_CENTER = 120;
@@ -103,6 +107,24 @@ function updateStatus(status) {
103
  updateDial(antenna, programIndex);
104
  }
105
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  async function pollStatus() {
107
  try {
108
  const response = await fetch("/api/status", { cache: "no-store" });
@@ -112,6 +134,7 @@ async function pollStatus() {
112
  const payload = await response.json();
113
  if (payload && payload.ok) {
114
  updateStatus(payload.status ?? {});
 
115
  }
116
  } catch (error) {
117
  console.error("Theremini dashboard", error);
 
11
  const heightValueEl = document.getElementById("heightValue");
12
  const antennaValueEl = document.getElementById("antennaValue");
13
  const playingIndicator = document.getElementById("playingIndicator");
14
+ const guidePanel = document.getElementById("guidePanel");
15
+ const guideContentEl = document.getElementById("guideContent");
16
+ const guideErrorEl = document.getElementById("guideError");
17
  const availableListEl = document.getElementById("availableList");
18
  const activeListEl = document.getElementById("activeList");
19
  const searchInput = document.getElementById("instrumentSearch");
 
21
  const dialSegmentsGroup = document.getElementById("dialSegments");
22
  const dialPointer = document.getElementById("dialPointer");
23
  const dialAngleEl = document.getElementById("dialAngle");
24
+ const bodyEl = document.body;
25
 
26
  const RIGHT_ANT_RANGE = { min: -Math.PI / 3, max: Math.PI / 3 };
27
  const DIAL_CENTER = 120;
 
107
  updateDial(antenna, programIndex);
108
  }
109
 
110
+ function updateHealth(health = {}) {
111
+ const healthy =
112
+ health && Object.prototype.hasOwnProperty.call(health, "fluidsynth_ok")
113
+ ? Boolean(health.fluidsynth_ok)
114
+ : true;
115
+ bodyEl.dataset.guide = healthy ? "hidden" : "visible";
116
+ if (!healthy && guidePanel && guideContentEl && guideErrorEl) {
117
+ guideErrorEl.textContent = health.fluidsynth_error || "FluidSynth runtime not detected.";
118
+ const guideHtml = typeof health.fluidsynth_guide_html === "string" ? health.fluidsynth_guide_html.trim() : "";
119
+ if (guideHtml) {
120
+ guideContentEl.innerHTML = guideHtml;
121
+ } else {
122
+ guideContentEl.innerHTML =
123
+ "<p class=\"meta\">Unable to load the installation guide. Please check the app logs.</p>";
124
+ }
125
+ }
126
+ }
127
+
128
  async function pollStatus() {
129
  try {
130
  const response = await fetch("/api/status", { cache: "no-store" });
 
134
  const payload = await response.json();
135
  if (payload && payload.ok) {
136
  updateStatus(payload.status ?? {});
137
+ updateHealth(payload.health ?? {});
138
  }
139
  } catch (error) {
140
  console.error("Theremini dashboard", error);
Theremini/webui/index.html CHANGED
@@ -8,7 +8,7 @@
8
  <link rel="stylesheet" href="style.css" />
9
  </head>
10
 
11
- <body>
12
  <main class="page">
13
  <header class="hero">
14
  <div>
@@ -22,113 +22,126 @@
22
  </div>
23
  </header>
24
 
25
- <section class="grid stats">
26
- <article class="card">
27
- <p class="label">Instrument</p>
28
- <h2 id="instrumentName">--</h2>
29
- <p class="meta">Program <span id="instrumentIndex">--</span></p>
30
- </article>
31
- <article class="card">
32
- <p class="label">Note</p>
33
- <h2 id="noteName">--</h2>
34
- <p class="meta">MIDI <span id="noteNumber">--</span></p>
35
- </article>
36
- <article class="card">
37
- <p class="label">Amplitude</p>
38
- <h2 id="ampPercent">0%</h2>
39
- <div class="meter">
40
- <div class="meter-fill" id="ampMeter"></div>
41
- </div>
42
- </article>
43
  </section>
44
 
45
- <section class="grid meters">
46
- <article class="card span-2">
47
- <div class="row">
48
- <div>
49
- <p class="label">Head roll</p>
50
- <h3 id="rollValue">0Β°</h3>
51
- <p class="meta">maps to MIDI {48 .. 84}</p>
 
 
 
 
 
 
 
 
 
 
52
  </div>
53
- <p class="range">-60Β° β‡’ +60Β°</p>
54
- </div>
55
- <div class="meter" data-min="-60" data-max="60" data-unit="Β°">
56
- <div class="meter-fill" id="rollMeter"></div>
57
- </div>
58
- </article>
59
 
60
- <article class="card span-2">
61
- <div class="row">
62
- <div>
63
- <p class="label">Head Z height</p>
64
- <h3 id="heightValue">0 mm</h3>
65
- <p class="meta">controls amplitude envelope</p>
 
 
 
66
  </div>
67
- <p class="range">-30 mm β‡’ +5 mm</p>
68
- </div>
69
- <div class="meter" data-min="-30" data-max="5" data-unit=" mm">
70
- <div class="meter-fill" id="heightMeter"></div>
71
- </div>
72
- </article>
73
 
74
- <article class="card span-2">
75
- <div class="row">
76
- <div>
77
- <p class="label">Right antenna</p>
78
- <h3 id="antennaValue">0Β°</h3>
79
- <p class="meta">selects instrument slot</p>
 
 
80
  </div>
81
- <p class="range">-60Β° β‡’ +60Β°</p>
82
- </div>
83
- <div class="meter" data-min="-1.047" data-max="1.047" data-unit="Β°">
84
- <div class="meter-fill" id="antennaMeter"></div>
85
- </div>
86
- </article>
87
- </section>
88
 
89
- <section class="card program-card">
90
- <div class="dial-wrapper">
91
- <div class="dial-headings">
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
- <div class="dial-visual">
97
- <div class="dial-canvas">
98
- <svg id="dialSvg" viewBox="0 0 240 240" role="presentation">
99
- <g id="dialSegments"></g>
100
- <line id="dialPointer" x1="120" y1="120" x2="120" y2="32" />
101
- <circle id="dialCenter" cx="120" cy="120" r="20" />
102
- </svg>
103
  </div>
104
- <p class="meta">Antenna angle: <span id="dialAngle">0Β°</span></p>
105
- </div>
106
- </div>
107
- </section>
 
108
 
109
- <section class="card config-card">
110
- <div class="config-head">
111
- <div>
112
- <p class="label">Live instrument set</p>
113
- <h2>Pick the presets Reachy cycles through</h2>
114
- <p class="meta">Choose up to eight voices from the full GM bank. Order matters.</p>
115
- </div>
116
- <div class="config-actions">
117
- <input id="instrumentSearch" type="text" placeholder="Filter presets..." />
 
 
 
 
 
 
 
 
118
  </div>
119
- </div>
120
- <div class="list-grid">
121
- <div class="list-panel">
122
- <p class="panel-title">Available voices</p>
123
- <div class="list-box" id="availableList"></div>
 
 
 
 
 
 
 
124
  </div>
125
- <div class="list-panel">
126
- <p class="panel-title">Active rotation</p>
127
- <div class="list-box active" id="activeList"></div>
 
 
 
 
 
 
128
  </div>
129
- </div>
130
- <p class="meta" id="configStatus">&nbsp;</p>
131
- </section>
132
  </main>
133
 
134
  <script src="app.js" type="module"></script>
 
8
  <link rel="stylesheet" href="style.css" />
9
  </head>
10
 
11
+ <body data-guide="hidden">
12
  <main class="page">
13
  <header class="hero">
14
  <div>
 
22
  </div>
23
  </header>
24
 
25
+ <section class="card guide-card" id="guidePanel">
26
+ <div class="guide-head">
27
+ <p class="label">Setup required</p>
28
+ <h2>Install FluidSynth to unlock Theremini</h2>
29
+ <p class="meta" id="guideError">FluidSynth runtime not detected.</p>
30
+ </div>
31
+ <div class="guide-content" id="guideContent">
32
+ <p class="meta">Loading troubleshooting guide…</p>
33
+ </div>
 
 
 
 
 
 
 
 
 
34
  </section>
35
 
36
+ <div id="dashboardContent">
37
+ <section class="grid stats">
38
+ <article class="card">
39
+ <p class="label">Instrument</p>
40
+ <h2 id="instrumentName">--</h2>
41
+ <p class="meta">Program <span id="instrumentIndex">--</span></p>
42
+ </article>
43
+ <article class="card">
44
+ <p class="label">Note</p>
45
+ <h2 id="noteName">--</h2>
46
+ <p class="meta">MIDI <span id="noteNumber">--</span></p>
47
+ </article>
48
+ <article class="card">
49
+ <p class="label">Amplitude</p>
50
+ <h2 id="ampPercent">0%</h2>
51
+ <div class="meter">
52
+ <div class="meter-fill" id="ampMeter"></div>
53
  </div>
54
+ </article>
55
+ </section>
 
 
 
 
56
 
57
+ <section class="grid meters">
58
+ <article class="card span-2">
59
+ <div class="row">
60
+ <div>
61
+ <p class="label">Head roll</p>
62
+ <h3 id="rollValue">0Β°</h3>
63
+ <p class="meta">maps to MIDI {48 .. 84}</p>
64
+ </div>
65
+ <p class="range">-60Β° β‡’ +60Β°</p>
66
  </div>
67
+ <div class="meter" data-min="-60" data-max="60" data-unit="Β°">
68
+ <div class="meter-fill" id="rollMeter"></div>
69
+ </div>
70
+ </article>
 
 
71
 
72
+ <article class="card span-2">
73
+ <div class="row">
74
+ <div>
75
+ <p class="label">Head Z height</p>
76
+ <h3 id="heightValue">0 mm</h3>
77
+ <p class="meta">controls amplitude envelope</p>
78
+ </div>
79
+ <p class="range">-30 mm β‡’ +5 mm</p>
80
  </div>
81
+ <div class="meter" data-min="-30" data-max="5" data-unit=" mm">
82
+ <div class="meter-fill" id="heightMeter"></div>
83
+ </div>
84
+ </article>
 
 
 
85
 
86
+ <article class="card span-2">
87
+ <div class="row">
88
+ <div>
89
+ <p class="label">Right antenna</p>
90
+ <h3 id="antennaValue">0Β°</h3>
91
+ <p class="meta">selects instrument slot</p>
92
+ </div>
93
+ <p class="range">-60Β° β‡’ +60Β°</p>
 
 
 
 
 
 
94
  </div>
95
+ <div class="meter" data-min="-1.047" data-max="1.047" data-unit="Β°">
96
+ <div class="meter-fill" id="antennaMeter"></div>
97
+ </div>
98
+ </article>
99
+ </section>
100
 
101
+ <section class="card program-card">
102
+ <div class="dial-wrapper">
103
+ <div class="dial-headings">
104
+ <p class="label">Program slots</p>
105
+ <h2>FluidSynth voices</h2>
106
+ <p class="meta">Move the right antenna to cycle through your curated list.</p>
107
+ </div>
108
+ <div class="dial-visual">
109
+ <div class="dial-canvas">
110
+ <svg id="dialSvg" viewBox="0 0 240 240" role="presentation">
111
+ <g id="dialSegments"></g>
112
+ <line id="dialPointer" x1="120" y1="120" x2="120" y2="32" />
113
+ <circle id="dialCenter" cx="120" cy="120" r="20" />
114
+ </svg>
115
+ </div>
116
+ <p class="meta">Antenna angle: <span id="dialAngle">0Β°</span></p>
117
+ </div>
118
  </div>
119
+ </section>
120
+
121
+ <section class="card config-card">
122
+ <div class="config-head">
123
+ <div>
124
+ <p class="label">Live instrument set</p>
125
+ <h2>Pick the presets Reachy cycles through</h2>
126
+ <p class="meta">Choose up to eight voices from the full GM bank. Order matters.</p>
127
+ </div>
128
+ <div class="config-actions">
129
+ <input id="instrumentSearch" type="text" placeholder="Filter presets..." />
130
+ </div>
131
  </div>
132
+ <div class="list-grid">
133
+ <div class="list-panel">
134
+ <p class="panel-title">Available voices</p>
135
+ <div class="list-box" id="availableList"></div>
136
+ </div>
137
+ <div class="list-panel">
138
+ <p class="panel-title">Active rotation</p>
139
+ <div class="list-box active" id="activeList"></div>
140
+ </div>
141
  </div>
142
+ <p class="meta" id="configStatus">&nbsp;</p>
143
+ </section>
144
+ </div>
145
  </main>
146
 
147
  <script src="app.js" type="module"></script>
Theremini/webui/style.css CHANGED
@@ -24,6 +24,14 @@ body {
24
  color: var(--text);
25
  }
26
 
 
 
 
 
 
 
 
 
27
  .page {
28
  max-width: 1000px;
29
  margin: 0 auto;
@@ -114,6 +122,63 @@ body {
114
  box-shadow: 0 25px 50px rgba(2, 6, 23, 0.6);
115
  }
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  .card h2,
118
  .card h3 {
119
  margin: 0.25rem 0;
 
24
  color: var(--text);
25
  }
26
 
27
+ body[data-guide="visible"] #dashboardContent {
28
+ display: none;
29
+ }
30
+
31
+ body[data-guide="hidden"] #guidePanel {
32
+ display: none;
33
+ }
34
+
35
  .page {
36
  max-width: 1000px;
37
  margin: 0 auto;
 
122
  box-shadow: 0 25px 50px rgba(2, 6, 23, 0.6);
123
  }
124
 
125
+ .guide-card {
126
+ display: flex;
127
+ flex-direction: column;
128
+ gap: 1rem;
129
+ border: 1px solid rgba(251, 113, 133, 0.45);
130
+ background: rgba(127, 29, 29, 0.25);
131
+ }
132
+
133
+ .guide-head h2 {
134
+ margin: 0.2rem 0 0;
135
+ }
136
+
137
+ .guide-head .meta {
138
+ color: rgba(248, 113, 113, 0.95);
139
+ }
140
+
141
+ .guide-content {
142
+ display: flex;
143
+ flex-direction: column;
144
+ gap: 0.75rem;
145
+ line-height: 1.5;
146
+ }
147
+
148
+ .guide-content h2,
149
+ .guide-content h3 {
150
+ margin: 1rem 0 0.2rem;
151
+ }
152
+
153
+ .guide-content hr {
154
+ border: none;
155
+ height: 1px;
156
+ background: rgba(248, 250, 252, 0.25);
157
+ margin: 1rem 0;
158
+ }
159
+
160
+ .guide-content pre {
161
+ padding: 0.75rem 1rem;
162
+ border-radius: 12px;
163
+ background: rgba(15, 23, 42, 0.85);
164
+ border: 1px solid rgba(148, 163, 184, 0.35);
165
+ overflow-x: auto;
166
+ }
167
+
168
+ .guide-content a {
169
+ color: #38bdf8;
170
+ }
171
+
172
+ .guide-content ul,
173
+ .guide-content ol {
174
+ padding-left: 1.5rem;
175
+ margin: 0.2rem 0 0.6rem;
176
+ }
177
+
178
+ .guide-content li + li {
179
+ margin-top: 0.2rem;
180
+ }
181
+
182
  .card h2,
183
  .card h3 {
184
  margin: 0.25rem 0;
pyproject.toml CHANGED
@@ -12,7 +12,8 @@ requires-python = ">=3.10"
12
  dependencies = [
13
  "reachy-mini",
14
  "scamp",
15
- "scipy"
 
16
  ]
17
  keywords = ["reachy-mini-app"]
18
 
 
12
  dependencies = [
13
  "reachy-mini",
14
  "scamp",
15
+ "scipy",
16
+ "markdown"
17
  ]
18
  keywords = ["reachy-mini-app"]
19
 
ressources/fluidsynth_guide.md ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ---
3
+
4
+ If you see this message, it means that **FluidSynth** is not installed.
5
+
6
+ This app needs **FluidSynth**, a system audio component, to be installed on your computer.
7
+
8
+ The dashboard doesn't have the permissions to install the library so you have to do it manually.
9
+
10
+ **Important:**
11
+ After installing FluidSynth, you must **uninstall and reinstall the app**.
12
+ This is the fastest and safest way to make sure everything is detected correctly.
13
+
14
+ ---
15
+
16
+ ## macOS
17
+
18
+ ### Step 1 – Install FluidSynth
19
+
20
+ 1. Open **Terminal**
21
+
22
+ * Press `⌘ + Space`
23
+ * Type **Terminal**
24
+ * Press Enter
25
+
26
+ 2. Copy and paste this command, then press Enter:
27
+
28
+ ```
29
+ brew install fluid-synth
30
+ ```
31
+
32
+ If you see an error saying `brew: command not found`, do this first:
33
+
34
+ ```
35
+ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
36
+ ```
37
+
38
+ Wait for it to finish, then run again:
39
+
40
+ ```
41
+ brew install fluid-synth
42
+ ```
43
+
44
+ ---
45
+
46
+ ### Step 2 – Reinstall the app (from the dashboard)
47
+
48
+ 1. Uninstall the app
49
+ 2. Install it again
50
+ 3. Launch the app
51
+
52
+ ---
53
+
54
+ ## Windows
55
+
56
+ ### Step 1 – Install FluidSynth
57
+
58
+ 1. Open this page in your browser:
59
+ [https://github.com/FluidSynth/fluidsynth/releases](https://github.com/FluidSynth/fluidsynth/releases)
60
+
61
+ 2. Download the latest **Windows installer (.exe)**
62
+
63
+ 3. Run the installer
64
+
65
+ * Keep all default options
66
+ * Finish the installation
67
+
68
+ ---
69
+
70
+ ### Step 2 – Reinstall the app (from the dashboard)
71
+
72
+ 1. Uninstall the app
73
+ 2. Install it again
74
+ 3. Launch the app
75
+
76
+ The app will detect FluidSynth and complete setup automatically.
77
+
78
+ Windows audio setups vary, but this works for most users.
79
+
80
+ ---
81
+
82
+ ## Linux (Ubuntu / Debian)
83
+
84
+ ### Step 1 – Install FluidSynth
85
+
86
+ 1. Open a terminal
87
+ 2. Run:
88
+
89
+ ```
90
+ sudo apt update
91
+ sudo apt install fluidsynth
92
+ ```
93
+
94
+ ---
95
+
96
+ ### Step 2 – Reinstall the app (from the dashboard)
97
+
98
+ 1. Uninstall the app
99
+ 2. Install it again
100
+ 3. Launch the app
101
+
102
+ ---
103
+
104
+ ## How to check if FluidSynth is installed (optional)
105
+
106
+ If you are curious, you can run:
107
+
108
+ ```
109
+ fluidsynth --version
110
+ ```
111
+
112
+ If you see a version number, FluidSynth is installed correctly.
113
+
114
+ ---
115
+
116
+ ## If it still does not work
117
+
118
+ If the problem persists, copy the error messages you can find and create an issue with the info.
119
+
120
+ ---
121
+
122
+