radio-alarm-clock-feature
#1
by
gsalmon
- opened
- README.md +1 -0
- reachy_mini_radio/alarm_manager.py +78 -0
- reachy_mini_radio/alarm_settings.json +8 -0
- reachy_mini_radio/main.py +272 -183
- reachy_mini_radio/static/index.html +19 -0
- reachy_mini_radio/static/main.js +252 -114
- reachy_mini_radio/static/style.css +67 -0
- reachy_mini_radio/webradios.json +6 -6
- uv.lock +0 -0
README.md
CHANGED
|
@@ -8,4 +8,5 @@ pinned: false
|
|
| 8 |
short_description: Listen to the radio with Reachy Mini !
|
| 9 |
tags:
|
| 10 |
- reachy_mini
|
|
|
|
| 11 |
---
|
|
|
|
| 8 |
short_description: Listen to the radio with Reachy Mini !
|
| 9 |
tags:
|
| 10 |
- reachy_mini
|
| 11 |
+
- reachy_mini_python_app
|
| 12 |
---
|
reachy_mini_radio/alarm_manager.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AlarmManager:
|
| 7 |
+
def __init__(self, config_path: Path):
|
| 8 |
+
self.config_path = config_path
|
| 9 |
+
self.settings = {
|
| 10 |
+
"enabled": False,
|
| 11 |
+
"alarm_mode": False,
|
| 12 |
+
"time": "08:00", # HH:MM 24h format
|
| 13 |
+
|
| 14 |
+
"station_url": "",
|
| 15 |
+
"station_name": "",
|
| 16 |
+
}
|
| 17 |
+
self.load_settings()
|
| 18 |
+
self._triggered_today = False
|
| 19 |
+
self._last_check_date = None
|
| 20 |
+
|
| 21 |
+
def load_settings(self):
|
| 22 |
+
if self.config_path.exists():
|
| 23 |
+
try:
|
| 24 |
+
data = json.loads(self.config_path.read_text("utf-8"))
|
| 25 |
+
self.settings.update(data)
|
| 26 |
+
except Exception as e:
|
| 27 |
+
print(f"[AlarmManager] Error loading settings: {e}")
|
| 28 |
+
|
| 29 |
+
# Force alarm to be disabled on startup
|
| 30 |
+
self.settings["enabled"] = False
|
| 31 |
+
self.settings["alarm_mode"] = False
|
| 32 |
+
|
| 33 |
+
def save_settings(self, new_settings=None):
|
| 34 |
+
if new_settings:
|
| 35 |
+
# Reset trigger flag if we are enabling the alarm or changing the time
|
| 36 |
+
if new_settings.get("enabled") is True:
|
| 37 |
+
self._triggered_today = False
|
| 38 |
+
elif "time" in new_settings and new_settings["time"] != self.settings.get("time"):
|
| 39 |
+
self._triggered_today = False
|
| 40 |
+
|
| 41 |
+
self.settings.update(new_settings)
|
| 42 |
+
|
| 43 |
+
try:
|
| 44 |
+
self.config_path.write_text(
|
| 45 |
+
data=json.dumps(self.settings, indent=2),
|
| 46 |
+
encoding="utf-8"
|
| 47 |
+
)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
print(f"[AlarmManager] Error saving settings: {e}")
|
| 50 |
+
|
| 51 |
+
return self.settings
|
| 52 |
+
|
| 53 |
+
def get_settings(self):
|
| 54 |
+
return self.settings
|
| 55 |
+
|
| 56 |
+
def check_alarm(self):
|
| 57 |
+
if not self.settings["enabled"]:
|
| 58 |
+
return False
|
| 59 |
+
|
| 60 |
+
if self._triggered_today:
|
| 61 |
+
return False
|
| 62 |
+
|
| 63 |
+
now = datetime.now()
|
| 64 |
+
current_date = now.date()
|
| 65 |
+
|
| 66 |
+
if self._last_check_date != current_date:
|
| 67 |
+
self._triggered_today = False
|
| 68 |
+
self._last_check_date = current_date
|
| 69 |
+
|
| 70 |
+
if self._triggered_today:
|
| 71 |
+
return False
|
| 72 |
+
|
| 73 |
+
current_time_str = now.strftime("%H:%M")
|
| 74 |
+
if current_time_str == self.settings["time"]:
|
| 75 |
+
self._triggered_today = True
|
| 76 |
+
return True
|
| 77 |
+
|
| 78 |
+
return False
|
reachy_mini_radio/alarm_settings.json
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"enabled": false,
|
| 3 |
+
"alarm_mode": false,
|
| 4 |
+
"time": "07:20",
|
| 5 |
+
"station_url": "https://streaming.smartradio.ch:9502/stream",
|
| 6 |
+
"station_name": "A Fine Jazz Gumbo Radio",
|
| 7 |
+
"timezone": "America/Los_Angeles"
|
| 8 |
+
}
|
reachy_mini_radio/main.py
CHANGED
|
@@ -6,14 +6,17 @@ import json
|
|
| 6 |
|
| 7 |
import sounddevice as sd
|
| 8 |
import numpy as np
|
|
|
|
| 9 |
import av
|
| 10 |
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 11 |
from reachy_mini_radio.antenna_button import AntennaButton
|
|
|
|
| 12 |
|
| 13 |
# -----------------------------
|
| 14 |
# Config
|
| 15 |
# -----------------------------
|
| 16 |
RADIOS_FILE = Path(__file__).parent / "webradios.json"
|
|
|
|
| 17 |
|
| 18 |
sr = 48000
|
| 19 |
channels = 2
|
|
@@ -79,42 +82,48 @@ class RadioDecoder:
|
|
| 79 |
self.thread.join()
|
| 80 |
|
| 81 |
def _run(self):
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
|
|
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
def get_samples(self, frames):
|
| 120 |
out = np.zeros((frames, self.channels), dtype=np.float32)
|
|
@@ -268,8 +277,7 @@ def between_angles(angle, _angles):
|
|
| 268 |
else:
|
| 269 |
return sorted_angles[0], sorted_angles[0]
|
| 270 |
|
| 271 |
-
# Insert angle into sorted list to find
|
| 272 |
-
import bisect
|
| 273 |
|
| 274 |
idx = bisect.bisect_left(sorted_angles, angle)
|
| 275 |
if idx > 0 and abs(sorted_angles[idx - 1] - angle) < 0.05:
|
|
@@ -291,110 +299,44 @@ def between_angles(angle, _angles):
|
|
| 291 |
|
| 292 |
return prev_angle, next_angle
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
class ReachyMiniRadio(ReachyMiniApp):
|
| 296 |
# If your webradio selector serves the UI at this URL:
|
| 297 |
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 298 |
|
| 299 |
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
|
|
|
|
|
|
|
|
|
| 300 |
# Shared radio set
|
| 301 |
-
radioset = RadioSet()
|
| 302 |
-
|
| 303 |
-
@self.settings_app.get("/api/webradios")
|
| 304 |
-
async def get_webradios():
|
| 305 |
-
"""Expose the current list of stations to the settings UI."""
|
| 306 |
-
return load_stations_from_file(RADIOS_FILE)
|
| 307 |
-
|
| 308 |
-
@self.settings_app.post("/api/webradios")
|
| 309 |
-
async def save_webradios(payload: list[dict[str, str]]):
|
| 310 |
-
"""Persist the stations selected in the settings UI."""
|
| 311 |
-
cleaned = save_stations_to_file(RADIOS_FILE, payload)
|
| 312 |
-
return {"ok": True, "count": len(cleaned)}
|
| 313 |
-
|
| 314 |
self.antenna_button = AntennaButton(reachy_mini, stop_event)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
|
| 316 |
# Initial load of stations
|
| 317 |
initial_stations = load_stations_from_file(RADIOS_FILE)
|
| 318 |
-
radioset.update(initial_stations)
|
| 319 |
|
| 320 |
# Start watcher thread that reloads stations when JSON changes
|
| 321 |
watcher_thread = threading.Thread(
|
| 322 |
target=stations_watcher,
|
| 323 |
-
args=(stop_event, radioset, RADIOS_FILE),
|
| 324 |
daemon=True,
|
| 325 |
)
|
| 326 |
watcher_thread.start()
|
| 327 |
|
| 328 |
-
|
| 329 |
-
state = {"angle": 0.0}
|
| 330 |
-
# calib = {"center": None, "range": 2 * np.pi} # very rough mapping
|
| 331 |
-
|
| 332 |
-
def update_angle():
|
| 333 |
-
raw = reachy_mini.get_present_antenna_joint_positions()[1]
|
| 334 |
-
return raw % (2 * np.pi)
|
| 335 |
-
|
| 336 |
-
reachy_mini.enable_motors(["left_antenna"])
|
| 337 |
-
reachy_mini.goto_target(np.eye(4), antennas=[0, 0])
|
| 338 |
-
reachy_mini.disable_motors(["left_antenna"])
|
| 339 |
-
|
| 340 |
-
rng = np.random.default_rng()
|
| 341 |
-
|
| 342 |
-
def audio_callback(outdata, frames, time_info, status):
|
| 343 |
-
if status:
|
| 344 |
-
print(status, flush=True)
|
| 345 |
-
|
| 346 |
-
angle = state["angle"]
|
| 347 |
-
|
| 348 |
-
with radioset.lock:
|
| 349 |
-
station_angles = radioset.station_angles
|
| 350 |
-
decoders = list(radioset.decoders)
|
| 351 |
-
|
| 352 |
-
# Always produce some output (static), even with no stations
|
| 353 |
-
if station_angles.size == 0 or not decoders:
|
| 354 |
-
noise = rng.normal(0.0, noise_std, size=(frames, channels)).astype(
|
| 355 |
-
np.float32
|
| 356 |
-
)
|
| 357 |
-
np.clip(noise, -1.0, 1.0, out=noise)
|
| 358 |
-
outdata[:] = noise
|
| 359 |
-
return
|
| 360 |
-
|
| 361 |
-
# Find nearest station
|
| 362 |
-
dists = circular_distance(angle, station_angles)
|
| 363 |
-
nearest_idx = int(np.argmin(dists))
|
| 364 |
-
d_min = float(dists[nearest_idx])
|
| 365 |
-
|
| 366 |
-
# Station gain (0 outside bandwidth, 1 at station)
|
| 367 |
-
if d_min >= station_bandwidth:
|
| 368 |
-
station_gain = 0.0
|
| 369 |
-
else:
|
| 370 |
-
station_gain = 1.0 - (d_min / station_bandwidth)
|
| 371 |
-
|
| 372 |
-
# Get radio samples for nearest station only
|
| 373 |
-
decoder = decoders[nearest_idx]
|
| 374 |
-
radio = decoder.get_samples(frames)
|
| 375 |
-
|
| 376 |
-
# Noise gain with "sweet spot"
|
| 377 |
-
if d_min <= station_sweetspot:
|
| 378 |
-
# In sweet spot: no static at all
|
| 379 |
-
noise_gain = 0.0
|
| 380 |
-
elif d_min >= station_bandwidth:
|
| 381 |
-
# Completely off any station: maximum noise
|
| 382 |
-
noise_gain = max_noise_gain
|
| 383 |
-
else:
|
| 384 |
-
# Between sweet spot and bandwidth: ramp from 0 to max_noise_gain
|
| 385 |
-
t = (d_min - station_sweetspot) / (
|
| 386 |
-
station_bandwidth - station_sweetspot
|
| 387 |
-
)
|
| 388 |
-
t = max(0.0, min(1.0, t))
|
| 389 |
-
noise_gain = t * max_noise_gain
|
| 390 |
-
|
| 391 |
-
noise = rng.normal(0.0, noise_std, size=(frames, channels)).astype(
|
| 392 |
-
np.float32
|
| 393 |
-
)
|
| 394 |
-
|
| 395 |
-
mix = station_gain * radio + noise_gain * noise
|
| 396 |
-
np.clip(mix, -1.0, 1.0, out=mix)
|
| 397 |
-
outdata[:] = mix
|
| 398 |
|
| 399 |
# Start audio output
|
| 400 |
with sd.OutputStream(
|
|
@@ -402,70 +344,16 @@ class ReachyMiniRadio(ReachyMiniApp):
|
|
| 402 |
channels=channels,
|
| 403 |
dtype="float32",
|
| 404 |
blocksize=blocksize,
|
| 405 |
-
callback=
|
| 406 |
):
|
| 407 |
print(
|
| 408 |
"ReachyMiniRadio running. Move the left antenna to tune stations (hot-reload from webradios.json)."
|
| 409 |
)
|
|
|
|
| 410 |
try:
|
| 411 |
while not stop_event.is_set():
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
if triggered:
|
| 415 |
-
print(f"[AntennaButton] Triggered! Direction: {direction}")
|
| 416 |
-
# use radioset.station_angles to get the next angle to jump to depending on direction "left" or "right"
|
| 417 |
-
with radioset.lock:
|
| 418 |
-
if (
|
| 419 |
-
radioset.station_angles.size == 0
|
| 420 |
-
or radioset.station_angles.size == 1
|
| 421 |
-
):
|
| 422 |
-
continue
|
| 423 |
-
current_angle = state["angle"] # 0 to 2pi range
|
| 424 |
-
before_angle, after_angle = between_angles(
|
| 425 |
-
current_angle, radioset.station_angles.copy()
|
| 426 |
-
)
|
| 427 |
-
|
| 428 |
-
if before_angle > np.pi:
|
| 429 |
-
before_angle -= 2 * np.pi
|
| 430 |
-
if before_angle < -np.pi:
|
| 431 |
-
before_angle += 2 * np.pi
|
| 432 |
-
if after_angle > np.pi:
|
| 433 |
-
after_angle -= 2 * np.pi
|
| 434 |
-
if after_angle < -np.pi:
|
| 435 |
-
after_angle += 2 * np.pi
|
| 436 |
-
if current_angle > np.pi:
|
| 437 |
-
current_angle -= 2 * np.pi
|
| 438 |
-
if current_angle < -np.pi:
|
| 439 |
-
current_angle += 2 * np.pi
|
| 440 |
-
|
| 441 |
-
print(
|
| 442 |
-
"before:",
|
| 443 |
-
before_angle,
|
| 444 |
-
"current angle:",
|
| 445 |
-
current_angle,
|
| 446 |
-
"after:",
|
| 447 |
-
after_angle,
|
| 448 |
-
)
|
| 449 |
-
if direction == "right":
|
| 450 |
-
target_angle = after_angle
|
| 451 |
-
print("after")
|
| 452 |
-
else:
|
| 453 |
-
target_angle = before_angle
|
| 454 |
-
print("before")
|
| 455 |
-
# target_angle = target_angle % (2 * np.pi)
|
| 456 |
-
# target_angle is in -pi to +pi range
|
| 457 |
-
# if target_angle > np.pi:
|
| 458 |
-
# target_angle -= 2 * np.pi
|
| 459 |
-
print(
|
| 460 |
-
f"[AntennaButton] Jumping to angle: {target_angle:.2f} rad"
|
| 461 |
-
)
|
| 462 |
-
reachy_mini.enable_motors(["left_antenna"])
|
| 463 |
-
reachy_mini.set_target_antenna_joint_positions(
|
| 464 |
-
[0.0, float(target_angle)]
|
| 465 |
-
)
|
| 466 |
-
time.sleep(0.3) # wait for movement
|
| 467 |
-
reachy_mini.disable_motors(["left_antenna"])
|
| 468 |
-
|
| 469 |
time.sleep(0.01)
|
| 470 |
finally:
|
| 471 |
# Stop watcher
|
|
@@ -473,14 +361,215 @@ class ReachyMiniRadio(ReachyMiniApp):
|
|
| 473 |
watcher_thread.join(timeout=1.0)
|
| 474 |
|
| 475 |
# Stop decoders
|
| 476 |
-
with radioset.lock:
|
| 477 |
-
decoders = list(radioset.decoders)
|
| 478 |
for d in decoders:
|
| 479 |
d.stop()
|
| 480 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 481 |
|
| 482 |
if __name__ == "__main__":
|
| 483 |
-
# You can run the app directly from this script
|
| 484 |
with ReachyMini() as mini:
|
| 485 |
app = ReachyMiniRadio()
|
| 486 |
stop = threading.Event()
|
|
@@ -492,4 +581,4 @@ if __name__ == "__main__":
|
|
| 492 |
print("App has stopped.")
|
| 493 |
except KeyboardInterrupt:
|
| 494 |
print("Stopping the app...")
|
| 495 |
-
stop.set()
|
|
|
|
| 6 |
|
| 7 |
import sounddevice as sd
|
| 8 |
import numpy as np
|
| 9 |
+
import bisect
|
| 10 |
import av
|
| 11 |
from reachy_mini import ReachyMini, ReachyMiniApp
|
| 12 |
from reachy_mini_radio.antenna_button import AntennaButton
|
| 13 |
+
from reachy_mini_radio.alarm_manager import AlarmManager
|
| 14 |
|
| 15 |
# -----------------------------
|
| 16 |
# Config
|
| 17 |
# -----------------------------
|
| 18 |
RADIOS_FILE = Path(__file__).parent / "webradios.json"
|
| 19 |
+
ALARM_FILE = Path(__file__).parent / "alarm_settings.json"
|
| 20 |
|
| 21 |
sr = 48000
|
| 22 |
channels = 2
|
|
|
|
| 82 |
self.thread.join()
|
| 83 |
|
| 84 |
def _run(self):
|
| 85 |
+
while not self.stop_event.is_set():
|
| 86 |
+
try:
|
| 87 |
+
container = av.open(self.url, timeout=5.0)
|
| 88 |
+
audio_stream = next(s for s in container.streams if s.type == "audio")
|
| 89 |
|
| 90 |
+
resampler = av.audio.resampler.AudioResampler(
|
| 91 |
+
format="flt",
|
| 92 |
+
layout="stereo",
|
| 93 |
+
rate=self.sr,
|
| 94 |
+
)
|
| 95 |
|
| 96 |
+
for packet in container.demux(audio_stream):
|
| 97 |
+
if self.stop_event.is_set():
|
| 98 |
+
break
|
| 99 |
+
|
| 100 |
+
for frame in packet.decode():
|
| 101 |
+
for out_frame in resampler.resample(frame):
|
| 102 |
+
arr = out_frame.to_ndarray()
|
| 103 |
+
if arr.ndim == 1:
|
| 104 |
+
arr = arr[np.newaxis, :]
|
| 105 |
+
|
| 106 |
+
pcm = arr.T # (samples, channels)
|
| 107 |
+
|
| 108 |
+
i = 0
|
| 109 |
+
n = pcm.shape[0]
|
| 110 |
+
while i < n and not self.stop_event.is_set():
|
| 111 |
+
chunk = pcm[i : i + blocksize, :]
|
| 112 |
+
i += blocksize
|
| 113 |
+
try:
|
| 114 |
+
self.queue.put(chunk, timeout=0.1)
|
| 115 |
+
except queue.Full:
|
| 116 |
+
# drop if output can't keep up
|
| 117 |
+
break
|
| 118 |
+
except Exception as e:
|
| 119 |
+
if not self.stop_event.is_set():
|
| 120 |
+
print(f"[RadioDecoder] {self.name} error: {e}. Retrying in 5s...")
|
| 121 |
+
time.sleep(5.0)
|
| 122 |
+
finally:
|
| 123 |
+
# Cleanup if needed
|
| 124 |
+
pass
|
| 125 |
+
|
| 126 |
+
print(f"[RadioDecoder] {self.name} stopped")
|
| 127 |
|
| 128 |
def get_samples(self, frames):
|
| 129 |
out = np.zeros((frames, self.channels), dtype=np.float32)
|
|
|
|
| 277 |
else:
|
| 278 |
return sorted_angles[0], sorted_angles[0]
|
| 279 |
|
| 280 |
+
# Insert angle into sorted list to find positio
|
|
|
|
| 281 |
|
| 282 |
idx = bisect.bisect_left(sorted_angles, angle)
|
| 283 |
if idx > 0 and abs(sorted_angles[idx - 1] - angle) < 0.05:
|
|
|
|
| 299 |
|
| 300 |
return prev_angle, next_angle
|
| 301 |
|
| 302 |
+
def closest_equivalent_angle(target, current):
|
| 303 |
+
diff = target - current
|
| 304 |
+
# wrap diff to [-pi, pi)
|
| 305 |
+
diff = (diff + np.pi) % (2 * np.pi) - np.pi
|
| 306 |
+
return current + diff
|
| 307 |
|
| 308 |
class ReachyMiniRadio(ReachyMiniApp):
|
| 309 |
# If your webradio selector serves the UI at this URL:
|
| 310 |
custom_app_url: str | None = "http://0.0.0.0:8042"
|
| 311 |
|
| 312 |
def run(self, reachy_mini: ReachyMini, stop_event: threading.Event):
|
| 313 |
+
self.reachy_mini = reachy_mini
|
| 314 |
+
self.stop_event = stop_event
|
| 315 |
+
|
| 316 |
# Shared radio set
|
| 317 |
+
self.radioset = RadioSet()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
self.antenna_button = AntennaButton(reachy_mini, stop_event)
|
| 319 |
+
self.alarm_manager = AlarmManager(ALARM_FILE)
|
| 320 |
+
self.rng = np.random.default_rng()
|
| 321 |
+
|
| 322 |
+
# State
|
| 323 |
+
self.state = {"angle": 0.0}
|
| 324 |
+
|
| 325 |
+
self._setup_routes()
|
| 326 |
|
| 327 |
# Initial load of stations
|
| 328 |
initial_stations = load_stations_from_file(RADIOS_FILE)
|
| 329 |
+
self.radioset.update(initial_stations)
|
| 330 |
|
| 331 |
# Start watcher thread that reloads stations when JSON changes
|
| 332 |
watcher_thread = threading.Thread(
|
| 333 |
target=stations_watcher,
|
| 334 |
+
args=(stop_event, self.radioset, RADIOS_FILE),
|
| 335 |
daemon=True,
|
| 336 |
)
|
| 337 |
watcher_thread.start()
|
| 338 |
|
| 339 |
+
self._calibrate_antenna()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
|
| 341 |
# Start audio output
|
| 342 |
with sd.OutputStream(
|
|
|
|
| 344 |
channels=channels,
|
| 345 |
dtype="float32",
|
| 346 |
blocksize=blocksize,
|
| 347 |
+
callback=self._audio_callback,
|
| 348 |
):
|
| 349 |
print(
|
| 350 |
"ReachyMiniRadio running. Move the left antenna to tune stations (hot-reload from webradios.json)."
|
| 351 |
)
|
| 352 |
+
|
| 353 |
try:
|
| 354 |
while not stop_event.is_set():
|
| 355 |
+
self._handle_alarm()
|
| 356 |
+
self._handle_antenna_button()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
time.sleep(0.01)
|
| 358 |
finally:
|
| 359 |
# Stop watcher
|
|
|
|
| 361 |
watcher_thread.join(timeout=1.0)
|
| 362 |
|
| 363 |
# Stop decoders
|
| 364 |
+
with self.radioset.lock:
|
| 365 |
+
decoders = list(self.radioset.decoders)
|
| 366 |
for d in decoders:
|
| 367 |
d.stop()
|
| 368 |
|
| 369 |
+
def _setup_routes(self):
|
| 370 |
+
@self.settings_app.get("/api/webradios")
|
| 371 |
+
def get_webradios():
|
| 372 |
+
"""Expose the current list of stations to the settings UI."""
|
| 373 |
+
return load_stations_from_file(RADIOS_FILE)
|
| 374 |
+
|
| 375 |
+
@self.settings_app.post("/api/webradios")
|
| 376 |
+
def save_webradios(payload: list[dict[str, str]]):
|
| 377 |
+
"""Persist the stations selected in the settings UI."""
|
| 378 |
+
cleaned = save_stations_to_file(RADIOS_FILE, payload)
|
| 379 |
+
return {"ok": True, "count": len(cleaned)}
|
| 380 |
+
|
| 381 |
+
@self.settings_app.get("/api/alarm")
|
| 382 |
+
def get_alarm():
|
| 383 |
+
return self.alarm_manager.get_settings()
|
| 384 |
+
|
| 385 |
+
@self.settings_app.post("/api/alarm")
|
| 386 |
+
def save_alarm(payload: dict):
|
| 387 |
+
return self.alarm_manager.save_settings(payload)
|
| 388 |
+
|
| 389 |
+
def _calibrate_antenna(self):
|
| 390 |
+
self.reachy_mini.enable_motors(["left_antenna"])
|
| 391 |
+
self.reachy_mini.goto_target(np.eye(4), antennas=[0, 0])
|
| 392 |
+
self.reachy_mini.disable_motors(["left_antenna"])
|
| 393 |
+
|
| 394 |
+
def _update_angle(self):
|
| 395 |
+
raw = self.reachy_mini.get_present_antenna_joint_positions()[1]
|
| 396 |
+
return raw % (2 * np.pi)
|
| 397 |
+
|
| 398 |
+
def _audio_callback(self, outdata, frames, time_info, status):
|
| 399 |
+
if status:
|
| 400 |
+
print(status, flush=True)
|
| 401 |
+
|
| 402 |
+
settings = self.alarm_manager.get_settings()
|
| 403 |
+
if settings.get("enabled") and settings.get("alarm_mode"):
|
| 404 |
+
outdata.fill(0)
|
| 405 |
+
return
|
| 406 |
+
|
| 407 |
+
angle = self.state["angle"]
|
| 408 |
+
|
| 409 |
+
with self.radioset.lock:
|
| 410 |
+
station_angles = self.radioset.station_angles
|
| 411 |
+
decoders = list(self.radioset.decoders)
|
| 412 |
+
|
| 413 |
+
# Always produce some output (static), even with no stations
|
| 414 |
+
if station_angles.size == 0 or not decoders:
|
| 415 |
+
noise = self.rng.normal(0.0, noise_std, size=(frames, channels)).astype(
|
| 416 |
+
np.float32
|
| 417 |
+
)
|
| 418 |
+
np.clip(noise, -1.0, 1.0, out=noise)
|
| 419 |
+
outdata[:] = noise
|
| 420 |
+
return
|
| 421 |
+
|
| 422 |
+
# Find nearest station
|
| 423 |
+
dists = circular_distance(angle, station_angles)
|
| 424 |
+
nearest_idx = int(np.argmin(dists))
|
| 425 |
+
d_min = float(dists[nearest_idx])
|
| 426 |
+
|
| 427 |
+
# Station gain (0 outside bandwidth, 1 at station)
|
| 428 |
+
if d_min >= station_bandwidth:
|
| 429 |
+
station_gain = 0.0
|
| 430 |
+
else:
|
| 431 |
+
station_gain = 1.0 - (d_min / station_bandwidth)
|
| 432 |
+
|
| 433 |
+
# Get radio samples for nearest station only
|
| 434 |
+
decoder = decoders[nearest_idx]
|
| 435 |
+
radio = decoder.get_samples(frames)
|
| 436 |
+
|
| 437 |
+
# Noise gain with "sweet spot"
|
| 438 |
+
if d_min <= station_sweetspot:
|
| 439 |
+
# In sweet spot: no static at all
|
| 440 |
+
noise_gain = 0.0
|
| 441 |
+
elif d_min >= station_bandwidth:
|
| 442 |
+
# Completely off any station: maximum noise
|
| 443 |
+
noise_gain = max_noise_gain
|
| 444 |
+
else:
|
| 445 |
+
# Between sweet spot and bandwidth: ramp from 0 to max_noise_gain
|
| 446 |
+
t = (d_min - station_sweetspot) / (
|
| 447 |
+
station_bandwidth - station_sweetspot
|
| 448 |
+
)
|
| 449 |
+
t = max(0.0, min(1.0, t))
|
| 450 |
+
noise_gain = t * max_noise_gain
|
| 451 |
+
|
| 452 |
+
noise = self.rng.normal(0.0, noise_std, size=(frames, channels)).astype(
|
| 453 |
+
np.float32
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
mix = station_gain * radio + noise_gain * noise
|
| 457 |
+
np.clip(mix, -1.0, 1.0, out=mix)
|
| 458 |
+
outdata[:] = mix
|
| 459 |
+
|
| 460 |
+
def _move_antenna(self, target_angle):
|
| 461 |
+
print(f"[Tuning] Jumping to angle: {target_angle:.2f} rad")
|
| 462 |
+
self.reachy_mini.enable_motors(["left_antenna"])
|
| 463 |
+
self.reachy_mini.set_target_antenna_joint_positions(
|
| 464 |
+
[0.0, float(target_angle)]
|
| 465 |
+
)
|
| 466 |
+
time.sleep(0.3) # wait for movement
|
| 467 |
+
self.reachy_mini.disable_motors(["left_antenna"])
|
| 468 |
+
|
| 469 |
+
def _handle_alarm(self):
|
| 470 |
+
is_alarm_triggered = self.alarm_manager.check_alarm()
|
| 471 |
+
settings = self.alarm_manager.get_settings()
|
| 472 |
+
|
| 473 |
+
if settings.get("enabled") and settings.get("alarm_mode"):
|
| 474 |
+
if not self.state.get("is_sleeping", False):
|
| 475 |
+
print("[AlarmManager] Entering Alarm Mode: Going to sleep...")
|
| 476 |
+
self.reachy_mini.goto_sleep()
|
| 477 |
+
self.state["is_sleeping"] = True
|
| 478 |
+
self.state["ignore_antenna"] = True
|
| 479 |
+
|
| 480 |
+
# Check if alarm was cancelled while sleeping
|
| 481 |
+
if self.state.get("is_sleeping", False) and not settings.get("alarm_mode"):
|
| 482 |
+
print("[AlarmManager] Alarm cancelled. Waking up...")
|
| 483 |
+
self.reachy_mini.wake_up()
|
| 484 |
+
self.state["is_sleeping"] = False
|
| 485 |
+
self.state["ignore_antenna"] = False
|
| 486 |
+
|
| 487 |
+
if is_alarm_triggered:
|
| 488 |
+
print("[AlarmManager] Alarm Triggered!")
|
| 489 |
+
self.state["ignore_antenna"] = True
|
| 490 |
+
|
| 491 |
+
if self.state.get("is_sleeping", False):
|
| 492 |
+
print("[AlarmManager] Waking up...")
|
| 493 |
+
self.reachy_mini.wake_up()
|
| 494 |
+
self.state["is_sleeping"] = False
|
| 495 |
+
|
| 496 |
+
self.alarm_manager.save_settings({"alarm_mode": False})
|
| 497 |
+
settings = self.alarm_manager.get_settings()
|
| 498 |
+
target_url = settings.get("station_url")
|
| 499 |
+
|
| 500 |
+
if target_url:
|
| 501 |
+
found_angle = None
|
| 502 |
+
with self.radioset.lock:
|
| 503 |
+
for i, decoder in enumerate(self.radioset.decoders):
|
| 504 |
+
if decoder.url == target_url:
|
| 505 |
+
found_angle = self.radioset.station_angles[i]
|
| 506 |
+
break
|
| 507 |
+
|
| 508 |
+
if found_angle is not None:
|
| 509 |
+
print(f"[AlarmManager] Found target station at angle: {found_angle:.2f}")
|
| 510 |
+
|
| 511 |
+
# Use closest equivalent angle logic but execute with _move_antenna
|
| 512 |
+
current_pos = self.reachy_mini.get_present_antenna_joint_positions()[1]
|
| 513 |
+
target_angle = closest_equivalent_angle(float(found_angle), current_pos)
|
| 514 |
+
print(f"[AlarmManager] Current pos: {current_pos:.2f}, Target: {target_angle:.2f}")
|
| 515 |
+
|
| 516 |
+
self._move_antenna(target_angle)
|
| 517 |
+
else:
|
| 518 |
+
print(f"[AlarmManager] Station URL {target_url} not found in current radio set.")
|
| 519 |
+
else:
|
| 520 |
+
print("[AlarmManager] No target URL found.")
|
| 521 |
+
|
| 522 |
+
self.state["ignore_antenna"] = False
|
| 523 |
+
|
| 524 |
+
def _handle_antenna_button(self):
|
| 525 |
+
if not self.state.get("ignore_antenna", False):
|
| 526 |
+
self.state["angle"] = self._update_angle() # 0 to 2pi range
|
| 527 |
+
triggered, direction = self.antenna_button.is_triggered()
|
| 528 |
+
if triggered:
|
| 529 |
+
print(f"[AntennaButton] Triggered! Direction: {direction}")
|
| 530 |
+
# use radioset.station_angles to get the next angle to jump to depending on direction "left" or "right"
|
| 531 |
+
with self.radioset.lock:
|
| 532 |
+
if (
|
| 533 |
+
self.radioset.station_angles.size == 0
|
| 534 |
+
or self.radioset.station_angles.size == 1
|
| 535 |
+
):
|
| 536 |
+
return
|
| 537 |
+
current_angle = self.state["angle"] # 0 to 2pi range
|
| 538 |
+
before_angle, after_angle = between_angles(
|
| 539 |
+
current_angle, self.radioset.station_angles.copy()
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
if before_angle > np.pi:
|
| 543 |
+
before_angle -= 2 * np.pi
|
| 544 |
+
if before_angle < -np.pi:
|
| 545 |
+
before_angle += 2 * np.pi
|
| 546 |
+
if after_angle > np.pi:
|
| 547 |
+
after_angle -= 2 * np.pi
|
| 548 |
+
if after_angle < -np.pi:
|
| 549 |
+
after_angle += 2 * np.pi
|
| 550 |
+
if current_angle > np.pi:
|
| 551 |
+
current_angle -= 2 * np.pi
|
| 552 |
+
if current_angle < -np.pi:
|
| 553 |
+
current_angle += 2 * np.pi
|
| 554 |
+
|
| 555 |
+
print(
|
| 556 |
+
"before:",
|
| 557 |
+
before_angle,
|
| 558 |
+
"current angle:",
|
| 559 |
+
current_angle,
|
| 560 |
+
"after:",
|
| 561 |
+
after_angle,
|
| 562 |
+
)
|
| 563 |
+
if direction == "right":
|
| 564 |
+
target_angle = after_angle
|
| 565 |
+
print("after")
|
| 566 |
+
else:
|
| 567 |
+
target_angle = before_angle
|
| 568 |
+
print("before")
|
| 569 |
+
|
| 570 |
+
self._move_antenna(target_angle)
|
| 571 |
|
| 572 |
if __name__ == "__main__":
|
|
|
|
| 573 |
with ReachyMini() as mini:
|
| 574 |
app = ReachyMiniRadio()
|
| 575 |
stop = threading.Event()
|
|
|
|
| 581 |
print("App has stopped.")
|
| 582 |
except KeyboardInterrupt:
|
| 583 |
print("Stopping the app...")
|
| 584 |
+
stop.set()
|
reachy_mini_radio/static/index.html
CHANGED
|
@@ -37,6 +37,25 @@
|
|
| 37 |
<button id="saveBtn" style="margin-left: auto;">Save</button>
|
| 38 |
</div>
|
| 39 |
<div class="status" id="saveStatus"></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</div>
|
| 41 |
</div>
|
| 42 |
</div>
|
|
|
|
| 37 |
<button id="saveBtn" style="margin-left: auto;">Save</button>
|
| 38 |
</div>
|
| 39 |
<div class="status" id="saveStatus"></div>
|
| 40 |
+
|
| 41 |
+
<div class="panel-title" style="margin-top: 2rem;">Alarm Clock</div>
|
| 42 |
+
<div class="alarm-box">
|
| 43 |
+
<div class="form-row">
|
| 44 |
+
<label>Time</label>
|
| 45 |
+
<input type="time" id="alarmTime" />
|
| 46 |
+
</div>
|
| 47 |
+
|
| 48 |
+
<div class="form-row">
|
| 49 |
+
<label>Station</label>
|
| 50 |
+
<select id="alarmStation"></select>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="search-row" style="margin-top: 16px; gap: 10px;">
|
| 53 |
+
<button id="setAlarmBtn" class="primary" style="flex: 1;">Set Alarm & Sleep</button>
|
| 54 |
+
<button id="cancelAlarmBtn" class="secondary" style="flex: 1; display: none;">Cancel
|
| 55 |
+
Alarm</button>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="status" id="alarmStatus"></div>
|
| 58 |
+
</div>
|
| 59 |
</div>
|
| 60 |
</div>
|
| 61 |
</div>
|
reachy_mini_radio/static/main.js
CHANGED
|
@@ -9,6 +9,12 @@ const saveBtn = document.getElementById("saveBtn");
|
|
| 9 |
const clearBtn = document.getElementById("clearBtn");
|
| 10 |
const countBadge = document.getElementById("countBadge");
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
const currentUrl = new URL(window.location.href);
|
| 13 |
if (!currentUrl.pathname.endsWith("/")) {
|
| 14 |
currentUrl.pathname += "/";
|
|
@@ -30,141 +36,143 @@ function updateCountBadge() {
|
|
| 30 |
function renderSelected() {
|
| 31 |
selectedBox.innerHTML = "";
|
| 32 |
if (!selectedStations.length) {
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
| 36 |
}
|
| 37 |
selectedStations.forEach((st, idx) => {
|
| 38 |
-
|
| 39 |
-
|
| 40 |
|
| 41 |
-
|
| 42 |
-
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
});
|
| 67 |
updateCountBadge();
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
function renderResults(stations) {
|
| 71 |
resultsBox.innerHTML = "";
|
| 72 |
if (!stations.length) {
|
| 73 |
-
|
| 74 |
-
|
| 75 |
}
|
| 76 |
stations.forEach(st => {
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
main.appendChild(name);
|
| 99 |
-
main.appendChild(meta);
|
| 100 |
-
|
| 101 |
-
const btn = document.createElement("button");
|
| 102 |
-
btn.className = "secondary small-btn";
|
| 103 |
-
btn.textContent = "Add";
|
| 104 |
-
btn.onclick = () => {
|
| 105 |
-
selectedStations.push({ name: st.name || "(unnamed)", url });
|
| 106 |
-
// dedupe on url
|
| 107 |
-
const seen = new Set();
|
| 108 |
-
selectedStations = selectedStations.filter(s => {
|
| 109 |
-
const key = (s.url || "").toLowerCase();
|
| 110 |
-
if (!key || seen.has(key)) return false;
|
| 111 |
-
seen.add(key);
|
| 112 |
-
return true;
|
| 113 |
-
});
|
| 114 |
-
renderSelected();
|
| 115 |
-
saveStatus.textContent = "Not saved yet.";
|
| 116 |
-
saveStatus.className = "status";
|
| 117 |
-
};
|
| 118 |
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
});
|
| 123 |
}
|
| 124 |
|
| 125 |
async function doSearch() {
|
| 126 |
const q = searchInput.value.trim();
|
| 127 |
if (!q) {
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
}
|
| 133 |
searchStatus.textContent = "Searching…";
|
| 134 |
searchStatus.className = "status";
|
| 135 |
resultsBox.innerHTML = "";
|
| 136 |
|
| 137 |
try {
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
} catch (err) {
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
}
|
| 152 |
}
|
| 153 |
|
| 154 |
async function loadSelectedFromServer() {
|
| 155 |
try {
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
} catch (err) {
|
| 167 |
-
|
| 168 |
}
|
| 169 |
}
|
| 170 |
|
|
@@ -172,28 +180,28 @@ async function saveToServer() {
|
|
| 172 |
saveStatus.textContent = "Saving…";
|
| 173 |
saveStatus.className = "status";
|
| 174 |
try {
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
} catch (err) {
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
}
|
| 190 |
}
|
| 191 |
|
| 192 |
searchBtn.onclick = () => doSearch();
|
| 193 |
searchInput.addEventListener("keydown", e => {
|
| 194 |
if (e.key === "Enter") {
|
| 195 |
-
|
| 196 |
-
|
| 197 |
}
|
| 198 |
});
|
| 199 |
|
|
@@ -205,5 +213,135 @@ clearBtn.onclick = () => {
|
|
| 205 |
saveStatus.className = "status";
|
| 206 |
};
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
// Initial load
|
| 209 |
-
loadSelectedFromServer()
|
|
|
|
|
|
|
|
|
| 9 |
const clearBtn = document.getElementById("clearBtn");
|
| 10 |
const countBadge = document.getElementById("countBadge");
|
| 11 |
|
| 12 |
+
const alarmTime = document.getElementById("alarmTime");
|
| 13 |
+
const alarmStation = document.getElementById("alarmStation");
|
| 14 |
+
const setAlarmBtn = document.getElementById("setAlarmBtn");
|
| 15 |
+
const cancelAlarmBtn = document.getElementById("cancelAlarmBtn");
|
| 16 |
+
const alarmStatus = document.getElementById("alarmStatus");
|
| 17 |
+
|
| 18 |
const currentUrl = new URL(window.location.href);
|
| 19 |
if (!currentUrl.pathname.endsWith("/")) {
|
| 20 |
currentUrl.pathname += "/";
|
|
|
|
| 36 |
function renderSelected() {
|
| 37 |
selectedBox.innerHTML = "";
|
| 38 |
if (!selectedStations.length) {
|
| 39 |
+
selectedBox.textContent = "No stations selected yet.";
|
| 40 |
+
updateCountBadge();
|
| 41 |
+
if (typeof updateAlarmStationDropdown === "function") updateAlarmStationDropdown();
|
| 42 |
+
return;
|
| 43 |
}
|
| 44 |
selectedStations.forEach((st, idx) => {
|
| 45 |
+
const item = document.createElement("div");
|
| 46 |
+
item.className = "item";
|
| 47 |
|
| 48 |
+
const main = document.createElement("div");
|
| 49 |
+
main.className = "item-main";
|
| 50 |
|
| 51 |
+
const name = document.createElement("div");
|
| 52 |
+
name.className = "item-name";
|
| 53 |
+
name.textContent = st.name || "(unnamed station)";
|
| 54 |
|
| 55 |
+
const meta = document.createElement("div");
|
| 56 |
+
meta.className = "item-meta";
|
| 57 |
+
meta.textContent = st.url;
|
| 58 |
|
| 59 |
+
main.appendChild(name);
|
| 60 |
+
main.appendChild(meta);
|
| 61 |
|
| 62 |
+
const btn = document.createElement("button");
|
| 63 |
+
btn.className = "secondary small-btn";
|
| 64 |
+
btn.textContent = "Remove";
|
| 65 |
+
btn.onclick = () => {
|
| 66 |
+
selectedStations.splice(idx, 1);
|
| 67 |
+
renderSelected();
|
| 68 |
+
};
|
| 69 |
|
| 70 |
+
item.appendChild(main);
|
| 71 |
+
item.appendChild(btn);
|
| 72 |
+
selectedBox.appendChild(item);
|
| 73 |
});
|
| 74 |
updateCountBadge();
|
| 75 |
+
if (typeof updateAlarmStationDropdown === "function") updateAlarmStationDropdown();
|
| 76 |
}
|
| 77 |
|
| 78 |
function renderResults(stations) {
|
| 79 |
resultsBox.innerHTML = "";
|
| 80 |
if (!stations.length) {
|
| 81 |
+
resultsBox.textContent = "No results.";
|
| 82 |
+
return;
|
| 83 |
}
|
| 84 |
stations.forEach(st => {
|
| 85 |
+
const url = st.url_resolved || st.url;
|
| 86 |
+
if (!url) return;
|
| 87 |
+
|
| 88 |
+
const item = document.createElement("div");
|
| 89 |
+
item.className = "item";
|
| 90 |
+
|
| 91 |
+
const main = document.createElement("div");
|
| 92 |
+
main.className = "item-main";
|
| 93 |
+
|
| 94 |
+
const name = document.createElement("div");
|
| 95 |
+
name.className = "item-name";
|
| 96 |
+
name.textContent = st.name || "(unnamed)";
|
| 97 |
+
|
| 98 |
+
const meta = document.createElement("div");
|
| 99 |
+
meta.className = "item-meta";
|
| 100 |
+
const parts = [];
|
| 101 |
+
if (st.country) parts.push(st.country);
|
| 102 |
+
if (st.codec) parts.push(st.codec.toUpperCase());
|
| 103 |
+
if (st.bitrate) parts.push(st.bitrate + " kbps");
|
| 104 |
+
meta.textContent = parts.join(" · ") || url;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
+
main.appendChild(name);
|
| 107 |
+
main.appendChild(meta);
|
| 108 |
+
|
| 109 |
+
const btn = document.createElement("button");
|
| 110 |
+
btn.className = "secondary small-btn";
|
| 111 |
+
btn.textContent = "Add";
|
| 112 |
+
btn.onclick = () => {
|
| 113 |
+
selectedStations.push({ name: st.name || "(unnamed)", url });
|
| 114 |
+
// dedupe on url
|
| 115 |
+
const seen = new Set();
|
| 116 |
+
selectedStations = selectedStations.filter(s => {
|
| 117 |
+
const key = (s.url || "").toLowerCase();
|
| 118 |
+
if (!key || seen.has(key)) return false;
|
| 119 |
+
seen.add(key);
|
| 120 |
+
return true;
|
| 121 |
+
});
|
| 122 |
+
renderSelected();
|
| 123 |
+
saveStatus.textContent = "Not saved yet.";
|
| 124 |
+
saveStatus.className = "status";
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
item.appendChild(main);
|
| 128 |
+
item.appendChild(btn);
|
| 129 |
+
resultsBox.appendChild(item);
|
| 130 |
});
|
| 131 |
}
|
| 132 |
|
| 133 |
async function doSearch() {
|
| 134 |
const q = searchInput.value.trim();
|
| 135 |
if (!q) {
|
| 136 |
+
searchStatus.textContent = "Type something to search.";
|
| 137 |
+
searchStatus.className = "status err";
|
| 138 |
+
resultsBox.innerHTML = "";
|
| 139 |
+
return;
|
| 140 |
}
|
| 141 |
searchStatus.textContent = "Searching…";
|
| 142 |
searchStatus.className = "status";
|
| 143 |
resultsBox.innerHTML = "";
|
| 144 |
|
| 145 |
try {
|
| 146 |
+
const url = "https://de1.api.radio-browser.info/json/stations/search?name=" +
|
| 147 |
+
encodeURIComponent(q) +
|
| 148 |
+
"&limit=25&hidebroken=true";
|
| 149 |
+
const res = await fetch(url);
|
| 150 |
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
| 151 |
+
const data = await res.json();
|
| 152 |
+
renderResults(data);
|
| 153 |
+
searchStatus.textContent = "Found " + data.length + " stations.";
|
| 154 |
+
searchStatus.className = "status ok";
|
| 155 |
} catch (err) {
|
| 156 |
+
console.error(err);
|
| 157 |
+
searchStatus.textContent = "Search failed.";
|
| 158 |
+
searchStatus.className = "status err";
|
| 159 |
}
|
| 160 |
}
|
| 161 |
|
| 162 |
async function loadSelectedFromServer() {
|
| 163 |
try {
|
| 164 |
+
const res = await fetch(buildApiUrl("api/webradios"));
|
| 165 |
+
if (!res.ok) return;
|
| 166 |
+
const data = await res.json();
|
| 167 |
+
if (Array.isArray(data)) {
|
| 168 |
+
selectedStations = data.map(s => ({
|
| 169 |
+
name: s.name || "",
|
| 170 |
+
url: s.url || ""
|
| 171 |
+
}));
|
| 172 |
+
renderSelected();
|
| 173 |
+
}
|
| 174 |
} catch (err) {
|
| 175 |
+
console.error(err);
|
| 176 |
}
|
| 177 |
}
|
| 178 |
|
|
|
|
| 180 |
saveStatus.textContent = "Saving…";
|
| 181 |
saveStatus.className = "status";
|
| 182 |
try {
|
| 183 |
+
const res = await fetch(buildApiUrl("api/webradios"), {
|
| 184 |
+
method: "POST",
|
| 185 |
+
headers: { "Content-Type": "application/json" },
|
| 186 |
+
body: JSON.stringify(selectedStations)
|
| 187 |
+
});
|
| 188 |
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
| 189 |
+
const data = await res.json();
|
| 190 |
+
if (!data.ok) throw new Error("Server error");
|
| 191 |
+
saveStatus.textContent = "Saved (" + data.count + " stations).";
|
| 192 |
+
saveStatus.className = "status ok";
|
| 193 |
} catch (err) {
|
| 194 |
+
console.error(err);
|
| 195 |
+
saveStatus.textContent = "Save failed.";
|
| 196 |
+
saveStatus.className = "status err";
|
| 197 |
}
|
| 198 |
}
|
| 199 |
|
| 200 |
searchBtn.onclick = () => doSearch();
|
| 201 |
searchInput.addEventListener("keydown", e => {
|
| 202 |
if (e.key === "Enter") {
|
| 203 |
+
e.preventDefault();
|
| 204 |
+
doSearch();
|
| 205 |
}
|
| 206 |
});
|
| 207 |
|
|
|
|
| 213 |
saveStatus.className = "status";
|
| 214 |
};
|
| 215 |
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
function updateAlarmStationDropdown() {
|
| 219 |
+
// Save current selection
|
| 220 |
+
const currentVal = alarmStation.value;
|
| 221 |
+
|
| 222 |
+
alarmStation.innerHTML = "";
|
| 223 |
+
const defaultOpt = document.createElement("option");
|
| 224 |
+
defaultOpt.value = "";
|
| 225 |
+
defaultOpt.textContent = "-- Select Station --";
|
| 226 |
+
alarmStation.appendChild(defaultOpt);
|
| 227 |
+
|
| 228 |
+
selectedStations.forEach(st => {
|
| 229 |
+
const opt = document.createElement("option");
|
| 230 |
+
opt.value = st.url;
|
| 231 |
+
opt.textContent = st.name || st.url;
|
| 232 |
+
alarmStation.appendChild(opt);
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// Restore selection if possible
|
| 236 |
+
if (currentVal) {
|
| 237 |
+
alarmStation.value = currentVal;
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
function updateAlarmUI(enabled) {
|
| 244 |
+
if (enabled) {
|
| 245 |
+
setAlarmBtn.style.display = "none";
|
| 246 |
+
cancelAlarmBtn.style.display = "inline-block";
|
| 247 |
+
alarmStatus.textContent = "Alarm is set. Reachy is sleeping.";
|
| 248 |
+
alarmStatus.className = "status ok";
|
| 249 |
+
// Disable inputs
|
| 250 |
+
alarmTime.disabled = true;
|
| 251 |
+
|
| 252 |
+
alarmStation.disabled = true;
|
| 253 |
+
} else {
|
| 254 |
+
setAlarmBtn.style.display = "inline-block";
|
| 255 |
+
cancelAlarmBtn.style.display = "none";
|
| 256 |
+
alarmStatus.textContent = "Alarm not set.";
|
| 257 |
+
alarmStatus.className = "status";
|
| 258 |
+
// Enable inputs
|
| 259 |
+
alarmTime.disabled = false;
|
| 260 |
+
|
| 261 |
+
alarmStation.disabled = false;
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
async function loadAlarmSettings() {
|
| 266 |
+
try {
|
| 267 |
+
const res = await fetch(buildApiUrl("api/alarm"));
|
| 268 |
+
if (!res.ok) return;
|
| 269 |
+
const data = await res.json();
|
| 270 |
+
|
| 271 |
+
if (data.time) alarmTime.value = data.time;
|
| 272 |
+
|
| 273 |
+
if (data.station_url) alarmStation.value = data.station_url;
|
| 274 |
+
|
| 275 |
+
updateAlarmUI(data.enabled);
|
| 276 |
+
|
| 277 |
+
} catch (err) {
|
| 278 |
+
console.error("Failed to load alarm settings", err);
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
async function setAlarm() {
|
| 283 |
+
if (!alarmTime.value || !alarmStation.value) {
|
| 284 |
+
alarmStatus.textContent = "Please select time and station.";
|
| 285 |
+
alarmStatus.className = "status err";
|
| 286 |
+
return;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
alarmStatus.textContent = "Setting alarm...";
|
| 290 |
+
alarmStatus.className = "status";
|
| 291 |
+
|
| 292 |
+
const settings = {
|
| 293 |
+
enabled: true,
|
| 294 |
+
alarm_mode: true, // Always enable sleep mode when setting alarm this way
|
| 295 |
+
time: alarmTime.value,
|
| 296 |
+
|
| 297 |
+
station_url: alarmStation.value,
|
| 298 |
+
station_name: alarmStation.options[alarmStation.selectedIndex]?.textContent || ""
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
try {
|
| 302 |
+
const res = await fetch(buildApiUrl("api/alarm"), {
|
| 303 |
+
method: "POST",
|
| 304 |
+
headers: { "Content-Type": "application/json" },
|
| 305 |
+
body: JSON.stringify(settings)
|
| 306 |
+
});
|
| 307 |
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
| 308 |
+
|
| 309 |
+
updateAlarmUI(true);
|
| 310 |
+
} catch (err) {
|
| 311 |
+
console.error(err);
|
| 312 |
+
alarmStatus.textContent = "Failed to set alarm.";
|
| 313 |
+
alarmStatus.className = "status err";
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
async function cancelAlarm() {
|
| 318 |
+
alarmStatus.textContent = "Cancelling...";
|
| 319 |
+
|
| 320 |
+
const settings = {
|
| 321 |
+
enabled: false,
|
| 322 |
+
alarm_mode: false
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
try {
|
| 326 |
+
const res = await fetch(buildApiUrl("api/alarm"), {
|
| 327 |
+
method: "POST",
|
| 328 |
+
headers: { "Content-Type": "application/json" },
|
| 329 |
+
body: JSON.stringify(settings)
|
| 330 |
+
});
|
| 331 |
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
| 332 |
+
|
| 333 |
+
updateAlarmUI(false);
|
| 334 |
+
} catch (err) {
|
| 335 |
+
console.error(err);
|
| 336 |
+
alarmStatus.textContent = "Failed to cancel alarm.";
|
| 337 |
+
alarmStatus.className = "status err";
|
| 338 |
+
}
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
setAlarmBtn.onclick = setAlarm;
|
| 342 |
+
cancelAlarmBtn.onclick = cancelAlarm;
|
| 343 |
+
|
| 344 |
// Initial load
|
| 345 |
+
loadSelectedFromServer().then(() => {
|
| 346 |
+
loadAlarmSettings();
|
| 347 |
+
});
|
reachy_mini_radio/static/style.css
CHANGED
|
@@ -138,6 +138,63 @@ button:active {
|
|
| 138 |
border-radius: 999px;
|
| 139 |
}
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
.status {
|
| 142 |
margin-top: 4px;
|
| 143 |
font-size: 12px;
|
|
@@ -164,4 +221,14 @@ button:active {
|
|
| 164 |
.badge {
|
| 165 |
font-size: 11px;
|
| 166 |
color: #9ca3c7;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
}
|
|
|
|
| 138 |
border-radius: 999px;
|
| 139 |
}
|
| 140 |
|
| 141 |
+
@media (max-width: 768px) {
|
| 142 |
+
.hero {
|
| 143 |
+
padding: 2rem 1rem;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.hero h1 {
|
| 147 |
+
font-size: 2rem;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
.container {
|
| 151 |
+
padding: 0 1rem;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.app-details,
|
| 155 |
+
.download-card {
|
| 156 |
+
padding: 2rem;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.features-grid {
|
| 160 |
+
grid-template-columns: 1fr;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.download-options {
|
| 164 |
+
grid-template-columns: 1fr;
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.alarm-box {
|
| 169 |
+
background: #090d1c;
|
| 170 |
+
padding: 1rem;
|
| 171 |
+
border-radius: 8px;
|
| 172 |
+
border: 1px solid #272f4a;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.form-row {
|
| 176 |
+
display: flex;
|
| 177 |
+
align-items: center;
|
| 178 |
+
justify-content: space-between;
|
| 179 |
+
margin-bottom: 0.75rem;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
.form-row label {
|
| 183 |
+
font-weight: 500;
|
| 184 |
+
color: #475569;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.form-row input[type="time"],
|
| 188 |
+
.form-row select {
|
| 189 |
+
background: #080c1a;
|
| 190 |
+
color: #f5f5f8;
|
| 191 |
+
border: 1px solid #272f4a;
|
| 192 |
+
border-radius: 8px;
|
| 193 |
+
padding: 8px 10px;
|
| 194 |
+
outline: none;
|
| 195 |
+
font-size: 0.9rem;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
.status {
|
| 199 |
margin-top: 4px;
|
| 200 |
font-size: 12px;
|
|
|
|
| 221 |
.badge {
|
| 222 |
font-size: 11px;
|
| 223 |
color: #9ca3c7;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
input:disabled,
|
| 227 |
+
select:disabled {
|
| 228 |
+
opacity: 1 !important;
|
| 229 |
+
color: #ffffff !important;
|
| 230 |
+
-webkit-text-fill-color: #ffffff !important;
|
| 231 |
+
background: #262c45 !important;
|
| 232 |
+
border-color: #475569 !important;
|
| 233 |
+
cursor: not-allowed;
|
| 234 |
}
|
reachy_mini_radio/webradios.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
| 1 |
[
|
| 2 |
{
|
| 3 |
-
"name": "
|
| 4 |
-
"url": "
|
| 5 |
},
|
| 6 |
{
|
| 7 |
-
"name": "
|
| 8 |
-
"url": "
|
| 9 |
},
|
| 10 |
{
|
| 11 |
-
"name": "
|
| 12 |
-
"url": "
|
| 13 |
}
|
| 14 |
]
|
|
|
|
| 1 |
[
|
| 2 |
{
|
| 3 |
+
"name": "A Fine Jazz Gumbo Radio",
|
| 4 |
+
"url": "https://streaming.smartradio.ch:9502/stream"
|
| 5 |
},
|
| 6 |
{
|
| 7 |
+
"name": "100.5 The Fox",
|
| 8 |
+
"url": "https://cloud-proxy-hls.revma.ihrhls.com/zc2938/hls.m3u8?rj-org=n0bb-e2&rj-ttl=5&rj-tok=AAABmuU8gY4AmS-J09808GxN2g"
|
| 9 |
},
|
| 10 |
{
|
| 11 |
+
"name": "92.5 The Breeze",
|
| 12 |
+
"url": "https://cloud.revma.ihrhls.com/zc4366?rj-org=n2eb-e2&rj-ttl=5&rj-tok=AAABmuV3vVIAOF5UGGE1WgcYeA"
|
| 13 |
}
|
| 14 |
]
|
uv.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|