RemiFabre commited on
Commit
b9ae085
·
1 Parent(s): c93a753
README.md CHANGED
@@ -30,4 +30,5 @@ When the app starts, Reachy performs a short guided motion (center the head, osc
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.
 
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
+ - A circular “instrument wheel” highlights each preset and the current antenna angle, so performers see exactly which slot is active.
34
  - 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 CHANGED
@@ -1,6 +1,9 @@
1
  {
2
  "active_instruments": [
3
  "accordion",
4
- "acoustic_bass"
 
 
 
5
  ]
6
  }
 
1
  {
2
  "active_instruments": [
3
  "accordion",
4
+ "acoustic_bass",
5
+ "atmosphere",
6
+ "blown_bottle",
7
+ "bird_tweet"
8
  ]
9
  }
Theremini/main.py CHANGED
@@ -231,7 +231,9 @@ class Theremini(ReachyMiniApp):
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,
@@ -241,7 +243,9 @@ class Theremini(ReachyMiniApp):
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,
@@ -251,7 +255,9 @@ class Theremini(ReachyMiniApp):
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,
@@ -269,7 +275,7 @@ class Theremini(ReachyMiniApp):
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
 
@@ -279,14 +285,36 @@ class Theremini(ReachyMiniApp):
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
 
231
 
232
  current_roll = 0.0
233
 
234
+ self._demo_select_instrument(
235
+ reachy_mini, left_idx, stop_event, dwell=0.3, move_duration=0.7
236
+ )
237
  current_roll = self._demo_roll_linear(
238
  reachy_mini,
239
  stop_event,
 
243
  )
244
  self._demo_pause(reachy_mini, stop_event, 0.45)
245
 
246
+ self._demo_select_instrument(
247
+ reachy_mini, right_idx, stop_event, dwell=0.28, move_duration=0.7
248
+ )
249
  current_roll = self._demo_roll_linear(
250
  reachy_mini,
251
  stop_event,
 
255
  )
256
  self._demo_pause(reachy_mini, stop_event, 0.45)
257
 
258
+ self._demo_select_instrument(
259
+ reachy_mini, oscillation_idx, stop_event, dwell=0.25, move_duration=0.6
260
+ )
261
  current_roll = self._demo_roll_linear(
262
  reachy_mini,
263
  stop_event,
 
275
  )
276
 
277
  reachy_mini.goto_target(
278
+ head=create_head_pose(z=-0.04, pitch=-6.0, degrees=True), antennas=[0.0, 0.0], duration=1.3
279
  )
280
  self._demo_pause(reachy_mini, stop_event, 0.6)
281
 
 
285
  index: int,
286
  stop_event: threading.Event,
287
  dwell: float,
288
+ move_duration: float,
289
  ) -> None:
290
  if not self._active_parts:
291
  return
292
  index = max(0, min(index, len(self._active_parts) - 1))
293
  angle = self._angle_for_instrument_index(index)
294
+ self._demo_move_antenna(reachy_mini, stop_event, angle, move_duration)
295
  self._demo_pause(reachy_mini, stop_event, dwell)
296
 
297
+ def _demo_move_antenna(
298
+ self,
299
+ reachy_mini: ReachyMini,
300
+ stop_event: threading.Event,
301
+ target_angle: float,
302
+ duration: float,
303
+ ) -> None:
304
+ duration = max(0.2, duration)
305
+ _, antennas = reachy_mini.get_current_joint_positions()
306
+ start_angle = antennas[1]
307
+ start_time = time.time()
308
+ while not stop_event.is_set():
309
+ elapsed = time.time() - start_time
310
+ ratio = min(1.0, elapsed / duration)
311
+ angle = start_angle + (target_angle - start_angle) * ratio
312
+ reachy_mini.set_target_antenna_joint_positions([0.0, float(angle)])
313
+ self._tick_from_robot(reachy_mini)
314
+ if ratio >= 1.0:
315
+ break
316
+ time.sleep(0.02)
317
+
318
  def _angle_for_instrument_index(self, index: int) -> float:
319
  if len(self._active_parts) <= 1:
320
  return 0.0
Theremini/webui/app.js CHANGED
@@ -11,36 +11,54 @@ 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 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: "°" },
23
  height: { min: -30, max: 5, meter: heightMeterEl, display: heightValueEl, unit: " mm" },
24
  antenna: {
25
- min: -Math.PI / 3,
26
- max: Math.PI / 3,
27
  meter: antennaMeterEl,
28
  display: antennaValueEl,
29
- unit: " rad",
30
  },
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
  }
43
 
 
 
 
 
 
 
 
 
44
  function setMeter(meterEl, value, min, max) {
45
  const ratio = (clamp(value, min, max) - min) / (max - min || 1);
46
  meterEl.style.width = `${(ratio * 100).toFixed(1)}%`;
@@ -78,10 +96,13 @@ function updateStatus(status) {
78
  setMeter(ranges.height.meter, height, ranges.height.min, ranges.height.max);
79
 
80
  const antenna = typeof status.right_antenna_rad === "number" ? status.right_antenna_rad : 0;
81
- ranges.antenna.display.textContent = formatNumber(antenna, ranges.antenna.unit);
 
82
  setMeter(ranges.antenna.meter, antenna, ranges.antenna.min, ranges.antenna.max);
83
 
84
  updatePresence(Boolean(status.playing));
 
 
85
  }
86
 
87
  async function pollStatus() {
@@ -99,24 +120,33 @@ async function pollStatus() {
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
 
@@ -210,9 +240,108 @@ function renderActiveList() {
210
  });
211
  }
212
 
213
- function markDirty() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  configDirty = true;
215
- configStatusEl.textContent = "Unsaved changes";
 
 
 
 
 
 
216
  }
217
 
218
  function addVoice(name) {
@@ -221,17 +350,17 @@ function addVoice(name) {
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) {
@@ -242,8 +371,8 @@ function moveVoice(index, delta) {
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() {
@@ -258,7 +387,7 @@ async function fetchConfig() {
258
  activeVoices = [...(payload.active_instruments || [])];
259
  configDirty = false;
260
  configStatusEl.textContent = "Synced";
261
- renderProgramList();
262
  renderAvailableList();
263
  renderActiveList();
264
  }
@@ -269,9 +398,14 @@ async function fetchConfig() {
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" },
@@ -283,17 +417,16 @@ async function saveConfig() {
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) {
@@ -301,5 +434,4 @@ setInterval(() => {
301
  }
302
  }, 5000);
303
 
304
- saveConfigBtn.addEventListener("click", saveConfig);
305
  searchInput.addEventListener("input", renderAvailableList);
 
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");
 
17
  const configStatusEl = document.getElementById("configStatus");
18
+ const dialSegmentsGroup = document.getElementById("dialSegments");
19
+ const dialPointer = document.getElementById("dialPointer");
20
+ const dialAngleEl = document.getElementById("dialAngle");
21
+ const dialLabelsEl = document.getElementById("dialLabels");
22
+
23
+ const RIGHT_ANT_RANGE = { min: -Math.PI / 3, max: Math.PI / 3 };
24
+ const DIAL_CENTER = 120;
25
+ const DIAL_RADIUS = 85;
26
+ const POINTER_RADIUS = 88;
27
+ const LABEL_RADIUS = 110;
28
 
29
  const ranges = {
30
  roll: { min: -60, max: 60, meter: rollMeterEl, display: rollValueEl, unit: "°" },
31
  height: { min: -30, max: 5, meter: heightMeterEl, display: heightValueEl, unit: " mm" },
32
  antenna: {
33
+ min: RIGHT_ANT_RANGE.min,
34
+ max: RIGHT_ANT_RANGE.max,
35
  meter: antennaMeterEl,
36
  display: antennaValueEl,
37
+ unit: "°",
38
  },
39
  amplitude: { min: 0, max: 1, meter: ampMeterEl },
40
  };
41
 
42
  const MAX_ACTIVE = 8;
43
+ const SVG_NS = "http://www.w3.org/2000/svg";
44
+ let saveTimer = null;
45
  let availableVoices = [];
46
  let activeVoices = [];
 
47
  let configDirty = false;
48
+ let lastStatus = { right_antenna_rad: 0, instrument_index: null };
49
 
50
  function clamp(value, min, max) {
51
  return Math.min(Math.max(value, min), max);
52
  }
53
 
54
+ function radToDeg(rad) {
55
+ return (rad * 180) / Math.PI;
56
+ }
57
+
58
+ function angleToDial(angleRad) {
59
+ return radToDeg(angleRad) - 90;
60
+ }
61
+
62
  function setMeter(meterEl, value, min, max) {
63
  const ratio = (clamp(value, min, max) - min) / (max - min || 1);
64
  meterEl.style.width = `${(ratio * 100).toFixed(1)}%`;
 
96
  setMeter(ranges.height.meter, height, ranges.height.min, ranges.height.max);
97
 
98
  const antenna = typeof status.right_antenna_rad === "number" ? status.right_antenna_rad : 0;
99
+ const antennaDeg = radToDeg(antenna);
100
+ ranges.antenna.display.textContent = formatNumber(antennaDeg, ranges.antenna.unit);
101
  setMeter(ranges.antenna.meter, antenna, ranges.antenna.min, ranges.antenna.max);
102
 
103
  updatePresence(Boolean(status.playing));
104
+ lastStatus = { right_antenna_rad: antenna, instrument_index: programIndex };
105
+ updateDial(antenna, programIndex);
106
  }
107
 
108
  async function pollStatus() {
 
120
  }
121
  }
122
 
123
+ function renderDialDisplay() {
124
+ dialLabelsEl.innerHTML = "";
125
+ renderDialSegments();
126
+
127
  if (!activeVoices.length) {
128
+ const empty = document.createElement("p");
129
+ empty.className = "meta";
130
  empty.textContent = "No voices selected";
131
+ dialLabelsEl.appendChild(empty);
132
  return;
133
  }
134
+
135
+ const segments = computeInstrumentSegments(activeVoices.length);
136
+ segments.forEach(({ index, start, end }) => {
 
 
137
  const label = document.createElement("span");
138
+ label.className = "dial-label";
139
+ label.textContent = activeVoices[index];
140
+ const mid = (start + end) / 2;
141
+ const point = polarToCartesian(
142
+ DIAL_CENTER,
143
+ DIAL_CENTER,
144
+ LABEL_RADIUS,
145
+ angleToDial(mid),
146
+ );
147
+ label.style.left = `${point.x}px`;
148
+ label.style.top = `${point.y}px`;
149
+ dialLabelsEl.appendChild(label);
150
  });
151
  }
152
 
 
240
  });
241
  }
242
 
243
+ function renderDialSegments() {
244
+ dialSegmentsGroup.innerHTML = "";
245
+ const total = activeVoices.length;
246
+ const center = DIAL_CENTER;
247
+ const radius = DIAL_RADIUS;
248
+
249
+ if (!total) {
250
+ const fallback = document.createElementNS(SVG_NS, "circle");
251
+ fallback.setAttribute("cx", center);
252
+ fallback.setAttribute("cy", center);
253
+ fallback.setAttribute("r", radius);
254
+ dialSegmentsGroup.appendChild(fallback);
255
+ updateDial(lastStatus.right_antenna_rad || 0, lastStatus.instrument_index);
256
+ return;
257
+ }
258
+
259
+ const segments = computeInstrumentSegments(total);
260
+ segments.forEach(({ index, start, end }) => {
261
+ const path = document.createElementNS(SVG_NS, "path");
262
+ path.setAttribute(
263
+ "d",
264
+ describeArc(center, center, radius, angleToDial(start), angleToDial(end))
265
+ );
266
+ path.dataset.index = String(index);
267
+ path.setAttribute("aria-label", activeVoices[index]);
268
+ dialSegmentsGroup.appendChild(path);
269
+ });
270
+ updateDial(lastStatus.right_antenna_rad || 0, lastStatus.instrument_index);
271
+ }
272
+
273
+ function computeInstrumentSegments(total) {
274
+ const min = RIGHT_ANT_RANGE.min;
275
+ const max = RIGHT_ANT_RANGE.max;
276
+ const span = max - min;
277
+ if (total <= 1) {
278
+ return [{ index: 0, start: min, end: max }];
279
+ }
280
+ const centers = [];
281
+ for (let i = 0; i < total; i += 1) {
282
+ centers.push(min + (span * i) / (total - 1));
283
+ }
284
+ return centers.map((center, index) => {
285
+ const prevCenter = index === 0 ? null : centers[index - 1];
286
+ const nextCenter = index === total - 1 ? null : centers[index + 1];
287
+ const start = prevCenter === null ? min : (prevCenter + center) / 2;
288
+ const end = nextCenter === null ? max : (center + nextCenter) / 2;
289
+ return { index, start, end };
290
+ });
291
+ }
292
+
293
+ function polarToCartesian(cx, cy, radius, angleInDegrees) {
294
+ const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180;
295
+ return {
296
+ x: cx + radius * Math.cos(angleInRadians),
297
+ y: cy + radius * Math.sin(angleInRadians),
298
+ };
299
+ }
300
+
301
+ function describeArc(cx, cy, radius, startAngle, endAngle) {
302
+ const start = polarToCartesian(cx, cy, radius, endAngle);
303
+ const end = polarToCartesian(cx, cy, radius, startAngle);
304
+ const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
305
+ return [
306
+ "M",
307
+ start.x,
308
+ start.y,
309
+ "A",
310
+ radius,
311
+ radius,
312
+ 0,
313
+ largeArcFlag,
314
+ 0,
315
+ end.x,
316
+ end.y,
317
+ ].join(" ");
318
+ }
319
+
320
+ function updateDial(angleRad, instrumentIndex) {
321
+ const pointerAngle = angleToDial(angleRad);
322
+ const target = polarToCartesian(DIAL_CENTER, DIAL_CENTER, POINTER_RADIUS, pointerAngle);
323
+ dialPointer.setAttribute("x2", target.x.toFixed(2));
324
+ dialPointer.setAttribute("y2", target.y.toFixed(2));
325
+ dialAngleEl.textContent = `${Math.round(radToDeg(angleRad))}°`;
326
+
327
+ dialSegmentsGroup.querySelectorAll("[data-index]").forEach((segment) => {
328
+ const segIndex = Number(segment.dataset.index);
329
+ segment.classList.toggle(
330
+ "active",
331
+ typeof instrumentIndex === "number" && segIndex === instrumentIndex,
332
+ );
333
+ });
334
+ }
335
+
336
+ function scheduleSave() {
337
  configDirty = true;
338
+ configStatusEl.textContent = "Saving...";
339
+ if (saveTimer) {
340
+ clearTimeout(saveTimer);
341
+ }
342
+ saveTimer = setTimeout(() => {
343
+ saveConfig();
344
+ }, 400);
345
  }
346
 
347
  function addVoice(name) {
 
350
  }
351
  activeVoices.push(name);
352
  renderActiveList();
353
+ renderDialDisplay();
354
  renderAvailableList();
355
+ scheduleSave();
356
  }
357
 
358
  function removeVoice(index) {
359
  activeVoices.splice(index, 1);
360
  renderActiveList();
361
+ renderDialDisplay();
362
  renderAvailableList();
363
+ scheduleSave();
364
  }
365
 
366
  function moveVoice(index, delta) {
 
371
  const [voice] = activeVoices.splice(index, 1);
372
  activeVoices.splice(target, 0, voice);
373
  renderActiveList();
374
+ renderDialDisplay();
375
+ scheduleSave();
376
  }
377
 
378
  async function fetchConfig() {
 
387
  activeVoices = [...(payload.active_instruments || [])];
388
  configDirty = false;
389
  configStatusEl.textContent = "Synced";
390
+ renderDialDisplay();
391
  renderAvailableList();
392
  renderActiveList();
393
  }
 
398
  }
399
 
400
  async function saveConfig() {
401
+ if (!configDirty) {
402
+ return;
403
+ }
404
+ if (saveTimer) {
405
+ clearTimeout(saveTimer);
406
+ saveTimer = null;
407
+ }
408
  try {
 
 
409
  const response = await fetch("/api/config", {
410
  method: "POST",
411
  headers: { "Content-Type": "application/json" },
 
417
  }
418
  configDirty = false;
419
  configStatusEl.textContent = "Saved";
420
+ renderDialDisplay();
421
  } catch (error) {
422
  configStatusEl.textContent = `Error: ${error.message}`;
423
  console.error("Theremini save", error);
 
 
424
  }
425
  }
426
 
427
+ renderDialDisplay();
428
  pollStatus();
429
+ setInterval(pollStatus, 150);
430
  fetchConfig();
431
  setInterval(() => {
432
  if (!configDirty) {
 
434
  }
435
  }, 5000);
436
 
 
437
  searchInput.addEventListener("input", renderAvailableList);
Theremini/webui/index.html CHANGED
@@ -12,10 +12,9 @@
12
  <main class="page">
13
  <header class="hero">
14
  <div>
15
- <p class="eyebrow">Reachy Mini · Theremin HUD</p>
16
  <h1>Theremini Live Status</h1>
17
- <p class="lede">Head roll drives pitch, head height controls amplitude, and the right antenna swaps instruments.
18
- This panel mirrors the old terminal HUD for a calmer, browser-friendly experience.</p>
19
  </div>
20
  <div class="presence" id="playingIndicator" data-playing="off">
21
  <span class="dot"></span>
@@ -76,25 +75,36 @@
76
  <div class="row">
77
  <div>
78
  <p class="label">Right antenna</p>
79
- <h3 id="antennaValue">0 rad</h3>
80
  <p class="meta">selects instrument slot</p>
81
  </div>
82
- <p class="range">-π/3+π/3 rad</p>
83
  </div>
84
- <div class="meter" data-min="-1.047" data-max="1.047" data-unit=" rad">
85
  <div class="meter-fill" id="antennaMeter"></div>
86
  </div>
87
  </article>
88
  </section>
89
 
90
  <section class="card program-card">
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">
@@ -106,7 +116,6 @@
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">
 
12
  <main class="page">
13
  <header class="hero">
14
  <div>
15
+ <p class="eyebrow">Reachy Mini · Theremini HUD</p>
16
  <h1>Theremini Live Status</h1>
17
+ <p class="lede">Head roll drives pitch, head height controls amplitude, and the right antenna swaps instruments.</p>
 
18
  </div>
19
  <div class="presence" id="playingIndicator" data-playing="off">
20
  <span class="dot"></span>
 
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 class="dial-labels" id="dialLabels"></div>
104
+ </div>
105
+ <p class="meta">Antenna angle: <span id="dialAngle">0°</span></p>
106
+ </div>
107
  </div>
 
 
108
  </section>
109
 
110
  <section class="card config-card">
 
116
  </div>
117
  <div class="config-actions">
118
  <input id="instrumentSearch" type="text" placeholder="Filter presets..." />
 
119
  </div>
120
  </div>
121
  <div class="list-grid">
Theremini/webui/style.css CHANGED
@@ -178,44 +178,79 @@ body {
178
 
179
  .program-card {
180
  display: flex;
181
- flex-wrap: wrap;
182
- gap: 1rem 2rem;
 
 
 
 
 
 
 
 
 
 
 
183
  align-items: center;
184
- justify-content: space-between;
185
  }
186
 
187
- .program-card h2 {
188
- margin-bottom: 0.2rem;
 
 
189
  }
190
 
191
- .program-list {
192
- list-style: none;
193
- padding: 0;
194
- margin: 0;
195
- display: grid;
196
- grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
197
- gap: 0.4rem 1rem;
198
  }
199
 
200
- .program-list li {
201
- display: flex;
202
- gap: 0.5rem;
203
- align-items: center;
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;
211
- display: inline-flex;
212
- align-items: center;
213
- justify-content: center;
214
- font-weight: 600;
215
- background: rgba(79, 70, 229, 0.25);
216
- border: 1px solid rgba(129, 140, 248, 0.5);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  }
218
 
 
219
  .config-card {
220
  display: flex;
221
  flex-direction: column;
@@ -245,16 +280,6 @@ body {
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));
 
178
 
179
  .program-card {
180
  display: flex;
181
+ flex-direction: column;
182
+ gap: 1.5rem;
183
+ }
184
+
185
+ .dial-wrapper {
186
+ display: flex;
187
+ flex-direction: column;
188
+ gap: 0.75rem;
189
+ }
190
+
191
+ .dial-visual {
192
+ display: flex;
193
+ flex-direction: column;
194
  align-items: center;
195
+ gap: 0.5rem;
196
  }
197
 
198
+ .dial-canvas {
199
+ position: relative;
200
+ width: min(320px, 80vw);
201
+ height: min(320px, 80vw);
202
  }
203
 
204
+ #dialSvg {
205
+ width: 100%;
206
+ height: 100%;
 
 
 
 
207
  }
208
 
209
+ .dial-labels {
210
+ position: absolute;
211
+ inset: 0;
212
+ pointer-events: none;
213
+ font-size: 0.8rem;
214
  }
215
 
216
+ .dial-label {
217
+ position: absolute;
218
+ transform: translate(-50%, -50%);
219
+ text-align: center;
220
+ color: rgba(248, 250, 252, 0.85);
221
+ text-shadow: 0 2px 6px rgba(2, 6, 23, 0.8);
222
+ width: 90px;
223
+ line-height: 1.1;
224
+ }
225
+
226
+ #dialSegments path,
227
+ #dialSegments circle {
228
+ fill: none;
229
+ stroke: rgba(148, 163, 184, 0.4);
230
+ stroke-width: 18;
231
+ stroke-linecap: round;
232
+ transition: stroke 0.2s ease, stroke-width 0.2s ease;
233
+ }
234
+
235
+ #dialSegments path.active {
236
+ stroke: var(--accent);
237
+ stroke-width: 22;
238
+ }
239
+
240
+ #dialPointer {
241
+ stroke: #f8fafc;
242
+ stroke-width: 3;
243
+ stroke-linecap: round;
244
+ filter: drop-shadow(0 0 4px rgba(248, 250, 252, 0.4));
245
+ }
246
+
247
+ #dialCenter {
248
+ fill: #0f172a;
249
+ stroke: rgba(148, 163, 184, 0.4);
250
+ stroke-width: 2;
251
  }
252
 
253
+
254
  .config-card {
255
  display: flex;
256
  flex-direction: column;
 
280
  min-width: 220px;
281
  }
282
 
 
 
 
 
 
 
 
 
 
 
283
  .list-grid {
284
  display: grid;
285
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));