discstation 0.1.31 → 0.1.32

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.
package/README.md CHANGED
@@ -25,8 +25,8 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
25
25
  the remote starts an open `DiscStation-XXXX` access point; join it from a
26
26
  phone, the captive portal (or `http://192.168.4.1`) lists nearby networks
27
27
  — pick yours, enter the password, done. It reconnects on every boot after
28
- that. Change networks later: **hold SELECT for 10 s** on the home screen
29
- to wipe the credentials and reopen the portal.
28
+ that. Change networks later: **hold HOME/BACK for 10 s** on the home
29
+ screen to wipe the credentials and reopen the portal.
30
30
  Power users can instead `cp arduino/<board>/secrets.h.example secrets.h`
31
31
  and set `WIFI_SSID` / `WIFI_PASS` at build time (git-ignored).
32
32
  - **Host side:** nothing to configure. With no USB cable present the host
@@ -47,7 +47,7 @@ remote can talk to the host over Wi-Fi instead of a USB cable.
47
47
  | **Burn Video DVD** | YouTube URL or local file → ffmpeg 2-pass → DVD-Video disc |
48
48
  | **Burn Data DVD** | Any files/folders → ISO/Joliet data disc (no quality loss) |
49
49
  | **Burn MPG** | Re-burn a previously converted movie.mpg |
50
- | **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) |
50
+ | **Play** | Playback via mpv (DVD-Video, Audio CD, VCD, SVCD) — the OLED remote shows a live spectrum visualizer of the actual audio (Linux only for now; see `docs/PLATFORM_SUPPORT.md`) |
51
51
  | **Rip** | Audio CD → FLAC (MusicBrainz); DVD-Video → VIDEO_TS mirror or HandBrake MKV (TMDb naming) |
52
52
 
53
53
  ## Project Structure
@@ -47,7 +47,7 @@
47
47
  #define VU_ENTRY_DELAY_MS 10000 // stay on the text status screen this long after entering PLAY
48
48
  #define VU_RESUME_DELAY_MS 7000 // ...and this long after any input while already in PLAY
49
49
 
50
- #define WIFI_RESET_HOLD_MS 10000 // hold SELECT this long on HOME to wipe Wi-Fi creds
50
+ #define WIFI_RESET_HOLD_MS 10000 // hold HOME/BACK this long on HOME to wipe Wi-Fi creds
51
51
  #define WIFI_CONNECT_TIMEOUT_MS 18000 // give a stored-creds join this long before falling to the portal
52
52
  #define WIFI_RETRY_MS 15000 // if a live link drops, force a re-join after this
53
53
 
@@ -796,6 +796,8 @@ void parseMessage(String msg) {
796
796
  line2 = "Playing disc";
797
797
  playSeekMode = false; // always start in volume mode
798
798
  vuSuppressUntil = millis() + VU_ENTRY_DELAY_MS; // text screen first, bars after a beat
799
+ lastVuAt = 0; // fresh session - unknown yet whether the host even sends VU: at all
800
+ visualizerActive = false;
799
801
  Out.print("POT:"); // push the last-used volume so playback starts at it
800
802
  Out.println(playVolume);
801
803
  drawPlay();
@@ -1326,9 +1328,19 @@ void loop() {
1326
1328
  // PLAY is included because a static "PLAYING" screen is just as low-current
1327
1329
  // as HOME/STANDBY were - the power bank doesn't care what's on screen, only
1328
1330
  // that the draw stays static this long.
1331
+ //
1332
+ // In PLAY specifically, if this session has never received a single VU:
1333
+ // (lastVuAt == 0 - the host/platform doesn't support the visualizer, e.g.
1334
+ // macOS today), don't make the user wait out the full generic idle timer
1335
+ // for an animation - drop into the spinning-disc screensaver as soon as
1336
+ // the text-hold window (vuSuppressUntil) expires. Once any VU: does
1337
+ // arrive this stops applying (lastVuAt is no longer 0) and PLAY behaves
1338
+ // exactly as before, showing bars instead.
1339
+ bool playSkippingToScreensaver = (uiState == UI_PLAY) && (lastVuAt == 0) &&
1340
+ (long)(millis() - vuSuppressUntil) >= 0;
1329
1341
  if (displayOk && !displayBlank &&
1330
1342
  (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
1331
- (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
1343
+ ((long)(millis() - lastInputTime) >= IDLE_BLANK_MS || playSkippingToScreensaver)) {
1332
1344
  displayBlank = true;
1333
1345
  saverStep = 0;
1334
1346
  lastSaverFrame = 0;
@@ -27,21 +27,49 @@ 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.
30
+ ### ESP32 remote: OLED spectrum visualizer — not currently working on macOS
31
+
32
+ **Confirmed broken, not just a setup step.** The visualizer's macOS capture
33
+ path (`ffmpeg -f avfoundation` reading the BlackHole loopback device) was
34
+ built and the BlackHole/Multi-Output-Device routing was set up and verified
35
+ correct on real hardware — but `ffmpeg`'s capture consistently returns
36
+ silence (valid-looking output, zero errors, every sample exactly `0`) even
37
+ with the native macOS Sound input level meter confirmed showing real,
38
+ moving signal on BlackHole at the same moment. That combination — real
39
+ signal present at the OS/driver level, silently zeroed once it reaches an
40
+ app's capture buffer — is macOS's system-audio-capture privacy protection
41
+ muting an untrusted capturer, not a permission checkbox that was missed:
42
+ Microphone and Screen & System Audio Recording were both granted to
43
+ `ffmpeg` with no change.
44
+
45
+ The legitimate modern API for this (CoreAudio's Process Tap,
46
+ `AudioHardwareCreateProcessTap`, macOS 14.2+) requires a properly
47
+ code-signed app bundle requesting a specific entitlement, with its own
48
+ dedicated system consent dialog — a bare ad-hoc-signed Homebrew CLI binary
49
+ like `ffmpeg` structurally cannot satisfy that, regardless of which Privacy
50
+ & Security toggles are flipped. Making the visualizer work on macOS would
51
+ mean building a small signed helper app around that API — a real, separate
52
+ project, not started.
53
+
54
+ **Everything else works normally** — audio-CD/DVD/video playback, ripping,
55
+ burning, and the web UI are all unaffected. PLAY just always shows the
56
+ normal text status screen on macOS instead of ever switching to bars.
57
+
58
+ If BlackHole is already installed from an earlier attempt at this, it's
59
+ harmless to leave in place — it just won't do anything useful for
60
+ DiscStation until/unless the above gets built.
61
+
62
+ ### Troubleshooting playback ("mpv not found" / play fails to start)
63
+
64
+ - **`install-macos.sh` installs `mpv` via Homebrew** — if playback fails to
65
+ start, confirm it actually landed: `mpv --version` in Terminal. If that
66
+ fails, either the installer never ran to completion or was interrupted;
67
+ re-run `install-macos.sh` (or `discstation-setup`) rather than installing
68
+ `mpv` in isolation, since other steps may be incomplete too.
69
+ - **Apple Music auto-opening on disc insert can hold the drive**, so
70
+ DiscStation's own `mpv` can fail or hang trying to claim it right after.
71
+ System Settings → **CDs & DVDs** (only shown with an optical drive
72
+ connected) → set "When you insert a music CD" to **Ignore**.
45
73
 
46
74
  ## Windows
47
75
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.31",
3
+ "version": "0.1.32",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -3448,7 +3448,29 @@ def start_vu_visualizer(ser):
3448
3448
  return stop_event, pause_event
3449
3449
 
3450
3450
 
3451
- def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3451
+ def _audio_cd_track_seek(delta, track_starts):
3452
+ """Seek to the start of the next/previous track using the CD TOC's own
3453
+ per-track offsets (already in seconds from track 1's start - see
3454
+ audio_track_metadata) instead of mpv's own chapter list. Works whether
3455
+ or not mpv has track chapters for this stream: macOS's piped
3456
+ cd-paranoia audio has none, since mpv just sees one continuous stream,
3457
+ not the disc itself - time-pos still works fine either way."""
3458
+ if not track_starts:
3459
+ mpv_command(["add", "chapter", delta])
3460
+ return
3461
+ track = mpv_query(["get_property", "chapter"])
3462
+ if not isinstance(track, (int, float)):
3463
+ position = mpv_query(["get_property", "time-pos"])
3464
+ track = max((i for i, start in enumerate(track_starts) if start <= position), default=0) \
3465
+ if isinstance(position, (int, float)) else 0
3466
+ target = max(0, min(len(track_starts) - 1, int(track) + delta))
3467
+ mpv_command(["seek", track_starts[target], "absolute"])
3468
+
3469
+
3470
+ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None, stdin_proc=None):
3471
+ """stdin_proc: an already-started subprocess whose stdout feeds mpv's
3472
+ stdin (e.g. macOS's cd-paranoia-into-mpv audio CD pipe) - mpv reads `-`
3473
+ as its input in cmd in that case. Stopped alongside mpv on cleanup."""
3452
3474
  try:
3453
3475
  os.unlink(MPV_SOCKET)
3454
3476
  except OSError:
@@ -3472,7 +3494,14 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3472
3494
  # writes block, wedging the whole play loop. We drive mpv over the IPC
3473
3495
  # socket, so none of that output is wanted.
3474
3496
  proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3497
+ stdin=(stdin_proc.stdout if stdin_proc else None),
3475
3498
  stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
3499
+ if stdin_proc:
3500
+ # Close our own copy of the read end now that mpv's had it duped into
3501
+ # its own stdin - otherwise we're also holding it open, so cd-paranoia
3502
+ # never gets SIGPIPE (and just hangs writing into a full pipe buffer)
3503
+ # if mpv exits first.
3504
+ stdin_proc.stdout.close()
3476
3505
  vu_stop = None
3477
3506
 
3478
3507
  try:
@@ -3540,7 +3569,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3540
3569
 
3541
3570
  elif line == "FF:BIG":
3542
3571
  if kind == "audio_cd":
3543
- mpv_command(["add", "chapter", 1])
3572
+ _audio_cd_track_seek(1, track_starts)
3544
3573
  send(ser, "PLAY_STATUS:Next track")
3545
3574
  else:
3546
3575
  mpv_command(["seek", 120])
@@ -3554,7 +3583,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3554
3583
  except ValueError:
3555
3584
  continue
3556
3585
  if kind == "audio_cd":
3557
- mpv_command(["add", "chapter", 1])
3586
+ _audio_cd_track_seek(1, track_starts)
3558
3587
  send(ser, "PLAY_STATUS:Next track")
3559
3588
  else:
3560
3589
  mpv_command(["seek", seek_sec])
@@ -3564,7 +3593,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3564
3593
 
3565
3594
  elif line == "REW:BIG":
3566
3595
  if kind == "audio_cd":
3567
- mpv_command(["add", "chapter", -1])
3596
+ _audio_cd_track_seek(-1, track_starts)
3568
3597
  send(ser, "PLAY_STATUS:Prev track")
3569
3598
  else:
3570
3599
  mpv_command(["seek", -120])
@@ -3578,7 +3607,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3578
3607
  except ValueError:
3579
3608
  continue
3580
3609
  if kind == "audio_cd":
3581
- mpv_command(["add", "chapter", -1])
3610
+ _audio_cd_track_seek(-1, track_starts)
3582
3611
  send(ser, "PLAY_STATUS:Prev track")
3583
3612
  else:
3584
3613
  mpv_command(["seek", -seek_sec])
@@ -3604,6 +3633,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3604
3633
  vu_stop.set()
3605
3634
  if proc.poll() is None:
3606
3635
  discstation_burn.stop_process(proc)
3636
+ if stdin_proc and stdin_proc.poll() is None:
3637
+ discstation_burn.stop_process(stdin_proc)
3607
3638
  try:
3608
3639
  os.unlink(MPV_SOCKET)
3609
3640
  except OSError:
@@ -3774,11 +3805,40 @@ def play_flow(ser):
3774
3805
 
3775
3806
  elif kind == "audio_cd":
3776
3807
  _, track_titles, track_starts = audio_track_metadata(device)
3777
- if discstation_host.system_name() == "windows":
3808
+ system = discstation_host.system_name()
3809
+ if system == "windows":
3778
3810
  # mpv on Windows has no libcdio - cdda:// is unavailable there
3779
3811
  # ("disabled at compile-time"). Windows Media Player's own COM
3780
3812
  # control plays it fine via Windows' native CD-audio support.
3781
3813
  _play_audio_cd_windows(ser, device, track_titles)
3814
+ elif system == "darwin":
3815
+ # Homebrew's mpv formula doesn't depend on libcdio either (no
3816
+ # build option to add it) - confirmed live: `mpv cdda://` says
3817
+ # "protocol ... disabled at compile-time" and --cdrom-device
3818
+ # isn't even a recognized option. Same shape of gap as Windows,
3819
+ # different fix: stream the disc via cd-paranoia (already used
3820
+ # for ripping) into mpv's stdin instead of mpv opening the
3821
+ # drive itself. mpv still does all actual playback + IPC
3822
+ # control (pause/volume/stop), just fed a pipe instead of the
3823
+ # disc directly.
3824
+ paranoia = None
3825
+ for name in ("cd-paranoia", "cdparanoia"):
3826
+ try:
3827
+ paranoia = discstation_burn.tool(name)
3828
+ break
3829
+ except FileNotFoundError:
3830
+ continue
3831
+ if not paranoia:
3832
+ raise RuntimeError("cd-paranoia not installed (brew install libcdio-paranoia)")
3833
+ rip_proc = subprocess.Popen(
3834
+ [paranoia, "-d", rip_device(device), "1-", "-"],
3835
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
3836
+ audio_device = discstation_host.audio_output_device()
3837
+ cmd = [mpv, "--input-ipc-server=" + MPV_SOCKET, "--force-window=no", "--idle=no", "-"]
3838
+ if audio_device:
3839
+ cmd.insert(1, "--audio-device=" + audio_device)
3840
+ print(f"Audio CD output: {audio_device}")
3841
+ _run_mpv(ser, cmd, "Playing audio CD", kind, track_titles, track_starts, stdin_proc=rip_proc)
3782
3842
  else:
3783
3843
  audio_device = discstation_host.audio_output_device()
3784
3844
  cmd = [