discstation 0.1.29 → 0.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,22 @@ Set `DISC_DEVICE` if automatic drive detection fails. The one known limitation
27
27
  is **audio-CD *burning*** — `cdrdao` is the only option and often cannot claim
28
28
  the drive on recent macOS; DiscStation reports this clearly instead of hanging.
29
29
 
30
+ ### ESP32 remote: OLED spectrum visualizer
31
+
32
+ The visualizer needs a one-time manual setup, since macOS has no built-in
33
+ way to tap "whatever's currently playing" the way Linux's PulseAudio does:
34
+
35
+ 1. `brew install blackhole-2ch` (done automatically by `install-macos.sh`).
36
+ 2. Open **Audio MIDI Setup** (in Applications/Utilities), click **+** →
37
+ **Create Multi-Output Device**, and check both **BlackHole 2ch** and your
38
+ real output (speakers/headphones).
39
+ 3. In **System Settings → Sound**, set that Multi-Output Device as the
40
+ default output. Audio stays audible (routed to your real device) and is
41
+ simultaneously tapped (via BlackHole) for the visualizer's FFT.
42
+
43
+ Without this setup the visualizer silently does nothing and PLAY just shows
44
+ its normal text screen — nothing else is affected.
45
+
30
46
  ## Windows
31
47
 
32
48
  Runs the same Python host and web UI as Linux/macOS, triggered the same way
package/install-macos.sh CHANGED
@@ -28,7 +28,7 @@ if ! command -v brew >/dev/null 2>&1; then
28
28
  fi
29
29
 
30
30
  brew install python ffmpeg cdrdao dvdauthor node yt-dlp xorriso mpv libdiscid handbrake \
31
- libdvdcss dvdbackup libcdio-paranoia
31
+ libdvdcss dvdbackup libcdio-paranoia blackhole-2ch
32
32
  mkdir -p "$APP_DIR" "$VENV_DIR" "$CONFIG_DIR"
33
33
  cp -R "$ROOT_DIR/src/." "$APP_DIR/"
34
34
  python3 -m venv "$VENV_DIR"
@@ -40,9 +40,12 @@ else
40
40
  fi
41
41
  # Optional metadata deps — best effort. python-libdiscid (a C extension) is
42
42
  # Linux-only in requirements-optional.txt; on macOS install the pure-Python
43
- # bits and let brew's libdiscid cover disc IDs via the CLI tools.
43
+ # bits and let brew's libdiscid cover disc IDs via the CLI tools. numpy is
44
+ # for the OLED spectrum visualizer (needs the blackhole-2ch brew package
45
+ # above too, plus a one-time Multi-Output Device setup - see
46
+ # docs/PLATFORM_SUPPORT.md).
44
47
  if [[ -f "$ROOT_DIR/requirements-optional.txt" ]]; then
45
- "$VENV_DIR/bin/python" -m pip install musicbrainzngs tmdbsimple \
48
+ "$VENV_DIR/bin/python" -m pip install musicbrainzngs tmdbsimple numpy \
46
49
  || printf 'Optional metadata deps skipped (host still works).\n'
47
50
  fi
48
51
 
package/install.sh CHANGED
@@ -17,7 +17,7 @@ if command -v apt-get >/dev/null 2>&1; then
17
17
  cdrdao dvdauthor ffmpeg genisoimage growisofs handbrake-cli lsdvd mpv \
18
18
  nodejs openssl python3 python3-pip python3-serial python3-mutagen \
19
19
  python3-requests python3-pyudev python3-libdiscid python3-musicbrainzngs \
20
- libdiscid0 python3-venv wodim
20
+ python3-numpy pulseaudio-utils libdiscid0 python3-venv wodim
21
21
  else
22
22
  printf 'Unsupported Linux package manager. Install the DiscStation dependencies manually.\n' >&2
23
23
  exit 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -12,3 +12,11 @@ pyudev>=0.24 ; sys_platform == "linux"
12
12
  python-libdiscid>=2.0 ; sys_platform == "linux"
13
13
  musicbrainzngs>=0.7.1
14
14
  tmdbsimple>=2.9
15
+
16
+ # OLED spectrum visualizer during playback. Linux needs `parec`/`pactl`
17
+ # (pulseaudio-utils apt package) - prefer python3-numpy via apt there, pip
18
+ # installs are blocked on a system Python. macOS needs the BlackHole
19
+ # loopback driver (brew install blackhole-2ch, see docs/PLATFORM_SUPPORT.md
20
+ # for the one-time Multi-Output Device setup); numpy installs fine via pip
21
+ # there since install-macos.sh's venv isn't externally-managed.
22
+ numpy>=1.24 ; sys_platform == "linux" or sys_platform == "darwin"
@@ -621,6 +621,11 @@ try:
621
621
  except Exception:
622
622
  discstation_meta = None
623
623
 
624
+ try:
625
+ import numpy as _np # OLED spectrum visualizer during playback (optional)
626
+ except Exception:
627
+ _np = None
628
+
624
629
 
625
630
  def _env_num(name, default, cast):
626
631
  try:
@@ -3299,6 +3304,150 @@ def _iter_proc_lines(proc, ser):
3299
3304
  reader.join(timeout=1)
3300
3305
 
3301
3306
 
3307
+ # --- OLED spectrum visualizer -----------------------------------------------
3308
+ # Taps the real audio the host is playing - PulseAudio's monitor source on
3309
+ # Linux, a BlackHole loopback device on macOS (see docs/PLATFORM_SUPPORT.md
3310
+ # for the one-time setup) - not a simulation, and streams it to the remote as
3311
+ # "VU:<16 comma-separated 0-63 levels>" at ~15fps. Needs numpy; anywhere else
3312
+ # (or without the capture source set up) this quietly no-ops and the remote
3313
+ # just shows its normal PLAY text screen.
3314
+ VU_BARS = 16 # must match the firmware's VU_BARS
3315
+ VU_RATE_HZ = 15
3316
+ VU_SAMPLE_RATE = 22050
3317
+ VU_DB_FLOOR = -40 # dB below the adaptive reference that maps to a flat bar
3318
+ VU_REF_DECAY = 0.995 # per-frame relaxation of the reference level (~a few sec to settle down)
3319
+ VU_PEAK_DECAY = 4 # bar units/frame a peak falls by when nothing louder follows
3320
+
3321
+
3322
+ def _pulse_default_monitor():
3323
+ try:
3324
+ sink = subprocess.run(["pactl", "get-default-sink"], capture_output=True,
3325
+ text=True, timeout=2).stdout.strip()
3326
+ except Exception:
3327
+ return None
3328
+ return f"{sink}.monitor" if sink else None
3329
+
3330
+
3331
+ def _darwin_blackhole_input():
3332
+ """Index of the 'BlackHole' avfoundation audio device, or None if it's not
3333
+ installed. macOS has no built-in loopback source - this requires the user
3334
+ to `brew install blackhole-2ch` and set a Multi-Output Device (BlackHole +
3335
+ real speakers) as the system's default output, so audio is both audible
3336
+ and tapped (see docs/PLATFORM_SUPPORT.md)."""
3337
+ try:
3338
+ # Device list is on stderr; ffmpeg exits non-zero here, that's normal.
3339
+ out = subprocess.run(["ffmpeg", "-f", "avfoundation", "-list_devices", "true", "-i", ""],
3340
+ capture_output=True, text=True, timeout=5).stderr
3341
+ except Exception:
3342
+ return None
3343
+ in_audio = False
3344
+ for line in out.splitlines():
3345
+ if "AVFoundation audio devices" in line:
3346
+ in_audio = True
3347
+ continue
3348
+ if in_audio:
3349
+ m = re.search(r"\[(\d+)\]\s+(.*)", line)
3350
+ if m and "blackhole" in m.group(2).lower():
3351
+ return m.group(1)
3352
+ return None
3353
+
3354
+
3355
+ def _vu_capture_cmd():
3356
+ """subprocess argv that streams raw s16le mono PCM at VU_SAMPLE_RATE on
3357
+ stdout for whatever's currently playing, or None if this OS/setup can't
3358
+ do it. One capture source per platform; the FFT/scaling pipeline below is
3359
+ the same regardless of where the bytes came from."""
3360
+ system = discstation_host.system_name()
3361
+ if system == "linux":
3362
+ monitor = _pulse_default_monitor()
3363
+ if not monitor:
3364
+ return None
3365
+ # --latency-msec=50: PulseAudio's default capture buffer is several
3366
+ # hundred ms to seconds (tuned for robust recording, not streaming) -
3367
+ # without this, parec hands us data in ~1.5-2s bursts instead of a
3368
+ # steady trickle, which starves the visualizer for longer than the
3369
+ # firmware's fallback timeout and flickers back to the text screen.
3370
+ return ["parec", "--format=s16le", f"--rate={VU_SAMPLE_RATE}", "--channels=1",
3371
+ "--latency-msec=50", "-d", monitor]
3372
+ if system == "darwin":
3373
+ idx = _darwin_blackhole_input()
3374
+ if idx is None:
3375
+ return None
3376
+ return ["ffmpeg", "-f", "avfoundation", "-i", f":{idx}",
3377
+ "-ac", "1", "-ar", str(VU_SAMPLE_RATE), "-f", "s16le",
3378
+ "-loglevel", "error", "-"]
3379
+ return None
3380
+
3381
+
3382
+ def _vu_loop(ser, stop_event, pause_event):
3383
+ cmd = _vu_capture_cmd()
3384
+ if not cmd:
3385
+ return
3386
+ chunk_samples = max(256, VU_SAMPLE_RATE // VU_RATE_HZ)
3387
+ chunk_bytes = chunk_samples * 2 # s16le, mono
3388
+ try:
3389
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
3390
+ except OSError:
3391
+ return
3392
+ try:
3393
+ window = _np.hanning(chunk_samples)
3394
+ freqs = _np.fft.rfftfreq(chunk_samples, d=1.0 / VU_SAMPLE_RATE)
3395
+ # Log-spaced band edges so bass doesn't dominate a single FFT bin and
3396
+ # treble isn't crammed into the last one - each bar gets a roughly
3397
+ # equal-feeling slice of the spectrum instead of a linear split.
3398
+ edges = _np.searchsorted(freqs, _np.geomspace(40, VU_SAMPLE_RATE / 2, VU_BARS + 1))
3399
+ ref_level = 1e-6 # adaptive "how loud is this track" reference (see VU_REF_DECAY)
3400
+ shown = [0.0] * VU_BARS # last drawn height per bar, for the peak-decay fall-off
3401
+ while not stop_event.is_set():
3402
+ raw = proc.stdout.read(chunk_bytes)
3403
+ if len(raw) < chunk_bytes:
3404
+ if proc.poll() is not None:
3405
+ break
3406
+ continue
3407
+ if pause_event.is_set():
3408
+ continue
3409
+ samples = _np.frombuffer(raw, dtype=_np.int16).astype(_np.float32) / 32768.0
3410
+ spectrum = _np.abs(_np.fft.rfft(samples * window))
3411
+ mags = []
3412
+ for i in range(VU_BARS):
3413
+ lo, hi = edges[i], max(edges[i + 1], edges[i] + 1)
3414
+ band = spectrum[lo:hi]
3415
+ mags.append(float(band.max()) if band.size else 0.0)
3416
+ # A fixed linear gain can't win: whatever multiplier shows motion at
3417
+ # quiet volume clips solid at 63 the moment the mix gets loud/dense.
3418
+ # Track a slowly-adapting reference level instead and scale in dB
3419
+ # relative to it, so "loud" always means "near this track's own
3420
+ # ceiling" rather than one guessed constant for every track/volume.
3421
+ ref_level = max(max(mags, default=0.0), ref_level * VU_REF_DECAY, 1e-6)
3422
+ levels = []
3423
+ for i, mag in enumerate(mags):
3424
+ db = 20 * float(_np.log10(mag / ref_level + 1e-9))
3425
+ target = max(0.0, min(63.0, (db - VU_DB_FLOOR) * 63.0 / -VU_DB_FLOOR))
3426
+ # Peak-with-decay: jump straight up to a new peak, fall a few
3427
+ # units/frame otherwise - the classic VU-meter look, and it's
3428
+ # what keeps a held loud note visibly settling instead of
3429
+ # looking stuck once it's louder than the decaying reference.
3430
+ shown[i] = target if target > shown[i] else max(0.0, shown[i] - VU_PEAK_DECAY)
3431
+ levels.append(int(shown[i]))
3432
+ send(ser, "VU:" + ",".join(str(v) for v in levels))
3433
+ finally:
3434
+ discstation_burn.stop_process(proc)
3435
+
3436
+
3437
+ def start_vu_visualizer(ser):
3438
+ """Best-effort: returns (stop_event, pause_event), or (None, None) if the
3439
+ visualizer can't run here (no numpy, no capture source, or a web-only
3440
+ link). Linux (PulseAudio) and macOS (BlackHole, see
3441
+ docs/PLATFORM_SUPPORT.md) only - _vu_capture_cmd() returns None anywhere
3442
+ else, or if the OS-specific capture device isn't set up."""
3443
+ if _np is None or isinstance(ser, VirtualSerial) or discstation_host.system_name() not in ("linux", "darwin"):
3444
+ return None, None
3445
+ stop_event = threading.Event()
3446
+ pause_event = threading.Event()
3447
+ threading.Thread(target=_vu_loop, args=(ser, stop_event, pause_event), daemon=True).start()
3448
+ return stop_event, pause_event
3449
+
3450
+
3302
3451
  def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3303
3452
  try:
3304
3453
  os.unlink(MPV_SOCKET)
@@ -3324,6 +3473,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3324
3473
  # socket, so none of that output is wanted.
3325
3474
  proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3326
3475
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
3476
+ vu_stop = None
3327
3477
 
3328
3478
  try:
3329
3479
  if not wait_for_socket(MPV_SOCKET, proc):
@@ -3343,6 +3493,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3343
3493
  send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
3344
3494
  send(ser, "PLAY:PLAYING")
3345
3495
  print(f"{label}. Short press toggles pause; long press stops.")
3496
+ vu_stop, vu_pause = start_vu_visualizer(ser)
3346
3497
 
3347
3498
  last_ping = time.time()
3348
3499
  while proc.poll() is None:
@@ -3375,6 +3526,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3375
3526
  paused = not paused
3376
3527
  mpv_command(["set_property", "pause", paused])
3377
3528
  mpv_command(["set_property", "speed", 1.0])
3529
+ if vu_pause:
3530
+ vu_pause.set() if paused else vu_pause.clear()
3378
3531
  send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
3379
3532
 
3380
3533
  elif line in ("PLAY_STOP", "EJECT"):
@@ -3447,6 +3600,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3447
3600
 
3448
3601
  time.sleep(0.05)
3449
3602
  finally:
3603
+ if vu_stop:
3604
+ vu_stop.set()
3450
3605
  if proc.poll() is None:
3451
3606
  discstation_burn.stop_process(proc)
3452
3607
  try:
@@ -4558,7 +4713,7 @@ def main():
4558
4713
  port = discstation_host.serial_port()
4559
4714
  if port:
4560
4715
  print(f"Using ESP32 serial port: {port}")
4561
- ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=1)
4716
+ ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=2)
4562
4717
  if discstation_host.system_name() == "linux":
4563
4718
  ser.setDTR(False)
4564
4719
  time.sleep(0.1)
@@ -538,9 +538,23 @@ def send(ser, msg):
538
538
  pass
539
539
  if not ser:
540
540
  return False
541
+ payload = (msg + '\n').encode()
541
542
  try:
542
543
  with SERIAL_WRITE_LOCK:
543
- ser.write((msg + '\n').encode())
544
+ # pyserial's Serial.write() on Linux waits on select() for the fd
545
+ # to report writable before it calls the real write() - some
546
+ # USB-CDC-ACM devices (e.g. the ESP32-C6's native USB peripheral,
547
+ # unlike a CP2102/CH340 bridge chip) never signal that through
548
+ # select() even though a plain write succeeds instantly, so
549
+ # ser.write() times out forever on those. Write through the raw
550
+ # fd directly for a real serial port; other transports
551
+ # (TcpSerial/VirtualSerial) keep their own write().
552
+ fileno = getattr(ser, "fileno", None)
553
+ if isinstance(ser, serial.Serial) and fileno:
554
+ os.write(fileno(), payload)
555
+ else:
556
+ ser.write(payload)
557
+ return True
544
558
  except (serial.SerialException, OSError) as e:
545
559
  if not _serial_write_failed:
546
560
  print(f"serial send error ('{msg[:30]}'): {e}")
@@ -549,7 +563,6 @@ def send(ser, msg):
549
563
  except Exception as e:
550
564
  print(f"serial send error ('{msg[:30]}'): {e}")
551
565
  return False
552
- return True
553
566
 
554
567
  def safe_send(ser, msg):
555
568
  if not ser: