Spaces:
Running
Running
RemiFabre
commited on
Commit
·
b9ae085
1
Parent(s):
c93a753
QoL fixes
Browse files- README.md +1 -0
- Theremini/config.json +4 -1
- Theremini/main.py +33 -5
- Theremini/webui/app.js +168 -36
- Theremini/webui/index.html +22 -13
- Theremini/webui/style.css +62 -37
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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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.
|
| 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 |
-
|
| 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:
|
| 26 |
-
max:
|
| 27 |
meter: antennaMeterEl,
|
| 28 |
display: antennaValueEl,
|
| 29 |
-
unit: "
|
| 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 |
-
|
|
|
|
| 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
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
if (!activeVoices.length) {
|
| 105 |
-
const empty = document.createElement("
|
|
|
|
| 106 |
empty.textContent = "No voices selected";
|
| 107 |
-
|
| 108 |
return;
|
| 109 |
}
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
badge.className = "badge";
|
| 114 |
-
badge.textContent = index;
|
| 115 |
const label = document.createElement("span");
|
| 116 |
-
label.
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
});
|
| 121 |
}
|
| 122 |
|
|
@@ -210,9 +240,108 @@ function renderActiveList() {
|
|
| 210 |
});
|
| 211 |
}
|
| 212 |
|
| 213 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
configDirty = true;
|
| 215 |
-
configStatusEl.textContent = "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
|
| 218 |
function addVoice(name) {
|
|
@@ -221,17 +350,17 @@ function addVoice(name) {
|
|
| 221 |
}
|
| 222 |
activeVoices.push(name);
|
| 223 |
renderActiveList();
|
| 224 |
-
|
| 225 |
renderAvailableList();
|
| 226 |
-
|
| 227 |
}
|
| 228 |
|
| 229 |
function removeVoice(index) {
|
| 230 |
activeVoices.splice(index, 1);
|
| 231 |
renderActiveList();
|
| 232 |
-
|
| 233 |
renderAvailableList();
|
| 234 |
-
|
| 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 |
-
|
| 246 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 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 ·
|
| 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
|
| 80 |
<p class="meta">selects instrument slot</p>
|
| 81 |
</div>
|
| 82 |
-
<p class="range"
|
| 83 |
</div>
|
| 84 |
-
<div class="meter" data-min="-1.047" data-max="1.047" data-unit="
|
| 85 |
<div class="meter-fill" id="antennaMeter"></div>
|
| 86 |
</div>
|
| 87 |
</article>
|
| 88 |
</section>
|
| 89 |
|
| 90 |
<section class="card program-card">
|
| 91 |
-
<div>
|
| 92 |
-
<
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 182 |
-
gap:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
align-items: center;
|
| 184 |
-
|
| 185 |
}
|
| 186 |
|
| 187 |
-
.
|
| 188 |
-
|
|
|
|
|
|
|
| 189 |
}
|
| 190 |
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
margin: 0;
|
| 195 |
-
display: grid;
|
| 196 |
-
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
| 197 |
-
gap: 0.4rem 1rem;
|
| 198 |
}
|
| 199 |
|
| 200 |
-
.
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
}
|
| 206 |
|
| 207 |
-
.
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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));
|