discstation 0.1.30 → 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
@@ -32,14 +32,22 @@
32
32
  #define DEBOUNCE_MS 50
33
33
  #define LONG_PRESS_MS 1000
34
34
  #define ENC_CLICK_GUARD_MS 200 // ignore new SW presses this soon after a rotation tick (rotation vibration can bounce SW low)
35
- #define ENC_DEBOUNCE_US 2000 // ignore encoder interrupts closer together than this (contact bounce)
36
35
  #define DONE_RESET_MS 30000
37
36
  #define STANDBY_BLANK_MS 60000
38
- #define IDLE_BLANK_MS 45000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
37
+ #define IDLE_BLANK_MS 15000 // no input this long on HOME/STANDBY -> spinning-disc screensaver
38
+ // (this power bank's no-load auto-shutoff trips at ~30s idle; keep this
39
+ // well under that so the screensaver's current draw beats it there)
39
40
  #define SAVER_FRAME_MS 90 // screensaver frame interval (~11fps)
40
41
  #define PING_TIMEOUT_MS 30000
41
42
 
42
- #define WIFI_RESET_HOLD_MS 10000 // hold SELECT this long on HOME to wipe Wi-Fi creds
43
+ #define VU_BARS 16 // must match the host's VU: band count
44
+ #define VU_TIMEOUT_MS 1200 // no VU: update this long -> host isn't sending (paused/stopped), fall back to text
45
+ // (generous on purpose: the host's capture thread can jitter under load on a Pi -
46
+ // too tight and normal jitter flickers between bars and the text screen)
47
+ #define VU_ENTRY_DELAY_MS 10000 // stay on the text status screen this long after entering PLAY
48
+ #define VU_RESUME_DELAY_MS 7000 // ...and this long after any input while already in PLAY
49
+
50
+ #define WIFI_RESET_HOLD_MS 10000 // hold HOME/BACK this long on HOME to wipe Wi-Fi creds
43
51
  #define WIFI_CONNECT_TIMEOUT_MS 18000 // give a stored-creds join this long before falling to the portal
44
52
  #define WIFI_RETRY_MS 15000 // if a live link drops, force a re-join after this
45
53
 
@@ -84,6 +92,7 @@ private:
84
92
 
85
93
  void drawSetup(); // fwd decls (defined with the other draw* below)
86
94
  void drawWifiReset();
95
+ void drawPlayVisualizer();
87
96
 
88
97
  String deviceSuffix() {
89
98
  uint8_t mac[6];
@@ -226,7 +235,6 @@ bool playStatusTemp = false;
226
235
  unsigned long encClickDownAt = 0;
227
236
  bool encClickDown = false;
228
237
  unsigned long encClickLastDebounce = 0;
229
- unsigned long lastEncRotateMs = 0; // set whenever a rotation tick is drained; guards SW against rotation noise
230
238
 
231
239
  // EJECT button - single press, no long-press timer needed
232
240
  bool ejectDown = false;
@@ -255,6 +263,15 @@ bool displayBlank = false; // true = real UI hidden, disc screensaver r
255
263
  unsigned long lastSaverFrame = 0;
256
264
  int saverStep = 0;
257
265
  bool audioPlayMode = false;
266
+
267
+ // Spectrum visualizer: host streams real playback levels as "VU:v0,v1,...".
268
+ // While they keep arriving, PLAY shows full-screen bars instead of the usual
269
+ // status text; VU_TIMEOUT_MS after they stop (paused/stopped host-side) it
270
+ // falls back to the normal drawPlay() screen.
271
+ uint8_t vuLevel[VU_BARS];
272
+ bool visualizerActive = false;
273
+ unsigned long lastVuAt = 0;
274
+ unsigned long vuSuppressUntil = 0; // bars withheld (text screen shown instead) until millis() reaches this
258
275
  int displayRotation = 0;
259
276
 
260
277
  // Indeterminate progress animation
@@ -262,21 +279,54 @@ int indeterminateCount = 0;
262
279
  unsigned long lastIndeterminateAnim = 0;
263
280
 
264
281
  // --- Rotary encoder decode ---
265
- // Interrupt only on CLK's falling edge (one detent = one interrupt, not four
266
- // - a mechanical encoder's contact bounce can otherwise fire far more often
267
- // than that and starve other tasks, including the USB-serial link); DT's
268
- // level at that instant gives direction. A short time debounce rejects
269
- // contact chatter. Interrupt-driven because the main loop's ~20ms cadence is
270
- // too slow to reliably catch a fast spin without missing ticks.
282
+ // Ben Buxton's full-step quadrature state machine (the standard robust
283
+ // rotary-encoder algorithm, e.g. the basis of the Arduino "Rotary" library).
284
+ // Reads BOTH pins on every change and only commits a tick after a complete,
285
+ // valid 4-transition sequence - contact bounce just walks between
286
+ // non-committing states instead of producing a spurious tick, so no time
287
+ // debounce is needed. (An earlier CLK-only + micros()-debounce version read
288
+ // just one pin's level at one instant, which is fragile - CLK and DT edges
289
+ // aren't perfectly synchronized on a mechanical encoder, so bounce right at
290
+ // the sample instant could misread direction or double-fire: skipped,
291
+ // doubled, and wrong-direction ticks. That simpler decode was chosen to
292
+ // lighten interrupt load for the dead XIAO ESP32-C6's fragile native-USB
293
+ // link; the current board's CP2102 bridge isn't affected by that at all.)
294
+ #define ENC_R_START 0x0
295
+ #define ENC_R_CW_FINAL 0x1
296
+ #define ENC_R_CW_BEGIN 0x2
297
+ #define ENC_R_CW_NEXT 0x3
298
+ #define ENC_R_CCW_BEGIN 0x4
299
+ #define ENC_R_CCW_FINAL 0x5
300
+ #define ENC_R_CCW_NEXT 0x6
301
+ #define ENC_DIR_CW 0x10
302
+ #define ENC_DIR_CCW 0x20
303
+
304
+ const uint8_t ENC_TTABLE[7][4] = {
305
+ {ENC_R_START, ENC_R_CW_BEGIN, ENC_R_CCW_BEGIN, ENC_R_START},
306
+ {ENC_R_CW_NEXT, ENC_R_START, ENC_R_CW_FINAL, ENC_R_START | ENC_DIR_CW},
307
+ {ENC_R_CW_NEXT, ENC_R_CW_BEGIN, ENC_R_START, ENC_R_START},
308
+ {ENC_R_CW_NEXT, ENC_R_CW_BEGIN, ENC_R_CW_FINAL, ENC_R_START},
309
+ {ENC_R_CCW_NEXT, ENC_R_START, ENC_R_CCW_BEGIN, ENC_R_START},
310
+ {ENC_R_CCW_NEXT, ENC_R_CCW_FINAL, ENC_R_START, ENC_R_START | ENC_DIR_CCW},
311
+ {ENC_R_CCW_NEXT, ENC_R_CCW_FINAL, ENC_R_CCW_BEGIN, ENC_R_START},
312
+ };
313
+
314
+ volatile uint8_t encState = ENC_R_START;
271
315
  volatile int16_t encTicks = 0; // whole detents ready for loop() to drain
272
- volatile uint32_t encLastIsrUs = 0;
316
+ volatile uint32_t encLastActivityMs = 0; // refreshed on every raw pin transition, not just
317
+ // committed ticks - guards handleSelectPress's SW
318
+ // read against bounce/crosstalk from a spin in progress
273
319
 
274
320
  void IRAM_ATTR encoderISR() {
275
- uint32_t now = micros();
276
- if (now - encLastIsrUs < ENC_DEBOUNCE_US) return;
277
- encLastIsrUs = now;
278
- if (digitalRead(ENC_CLK_PIN) != digitalRead(ENC_DT_PIN)) encTicks++;
279
- else encTicks--;
321
+ encLastActivityMs = millis();
322
+ uint8_t pinState = (digitalRead(ENC_DT_PIN) << 1) | digitalRead(ENC_CLK_PIN);
323
+ encState = ENC_TTABLE[encState & 0xF][pinState];
324
+ uint8_t dir = encState & 0x30;
325
+ // Flipped from the table's literal CW/CCW so the tick sign matches this
326
+ // encoder's physical wiring: turning the knob clockwise should increase
327
+ // (next track, volume up), not decrease.
328
+ if (dir == ENC_DIR_CW) encTicks--;
329
+ else if (dir == ENC_DIR_CCW) encTicks++;
280
330
  }
281
331
 
282
332
  void sendHomeMode() {
@@ -591,7 +641,11 @@ bool wakeDisplay() {
591
641
  case UI_STATUS: drawStatus(); break;
592
642
  case UI_IP: drawIP(); break;
593
643
  case UI_BURN_READY: drawBurnReady(); break;
594
- case UI_PLAY: drawPlay(); break;
644
+ case UI_PLAY:
645
+ if (visualizerActive && (long)(millis() - lastVuAt) < VU_TIMEOUT_MS &&
646
+ (long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
647
+ else drawPlay();
648
+ break;
595
649
  case UI_WAITING: drawWaiting(); break;
596
650
  case UI_STANDBY: drawStandby(); break;
597
651
  case UI_DISCONNECTED: drawDisconnected(); break;
@@ -600,9 +654,35 @@ bool wakeDisplay() {
600
654
  return true;
601
655
  }
602
656
 
657
+ #define VU_TOP_MARGIN 16 // px of empty headroom above the tallest possible bar
658
+
659
+ // Full-screen-width spectrum bars, no header/chrome, capped short of the top
660
+ // edge (VU_TOP_MARGIN) so a loud peak doesn't run the panel edge-to-edge.
661
+ // Falls back to the normal drawPlay() text screen once VU: updates stop
662
+ // arriving (see VU_TIMEOUT_MS in loop()).
663
+ void drawPlayVisualizer() {
664
+ uiState = UI_PLAY;
665
+ returnToHomeAt = 0;
666
+ if (!displayOk) return;
667
+
668
+ display.clearDisplay();
669
+ const int gap = 2;
670
+ const int maxH = SCREEN_HEIGHT - VU_TOP_MARGIN;
671
+ const int barW = (SCREEN_WIDTH - gap * (VU_BARS - 1)) / VU_BARS;
672
+ int x = (SCREEN_WIDTH - (barW * VU_BARS + gap * (VU_BARS - 1))) / 2;
673
+ for (int i = 0; i < VU_BARS; i++) {
674
+ int h = map(vuLevel[i], 0, 63, 0, maxH);
675
+ if (h > 0) display.fillRect(x, SCREEN_HEIGHT - h, barW, h, SSD1306_WHITE);
676
+ x += barW + gap;
677
+ }
678
+ display.display();
679
+ }
680
+
603
681
  void drawPlay() {
604
682
  uiState = UI_PLAY;
605
683
  returnToHomeAt = 0;
684
+ lastInputTime = millis(); // real status content (track change, pause/resume) gets a full
685
+ // IDLE_BLANK_MS on screen before the screensaver reclaims it
606
686
  if (!displayOk) return;
607
687
 
608
688
  display.clearDisplay();
@@ -637,6 +717,28 @@ void parseMessage(String msg) {
637
717
  return;
638
718
  }
639
719
 
720
+ if (msg.startsWith("VU:")) {
721
+ // No wakeDisplay()/RCV echo here - this arrives ~15x/sec while playing,
722
+ // wakeDisplay() would draw stale bars a frame early, and echoing it
723
+ // would spam the serial log for no reason.
724
+ lastMsgTime = millis();
725
+ String rest = msg.substring(3);
726
+ for (int i = 0; i < VU_BARS; i++) vuLevel[i] = 0;
727
+ for (int i = 0; i < VU_BARS && rest.length() > 0; i++) {
728
+ int comma = rest.indexOf(',');
729
+ String tok = (comma < 0) ? rest : rest.substring(0, comma);
730
+ vuLevel[i] = (uint8_t)constrain(tok.toInt(), 0, 63);
731
+ if (comma < 0) break;
732
+ rest = rest.substring(comma + 1);
733
+ }
734
+ visualizerActive = true;
735
+ lastVuAt = millis();
736
+ lastInputTime = millis(); // the visualizer's own continuous redraw already beats the
737
+ // power-bank shutoff - no need for the screensaver too
738
+ if (uiState == UI_PLAY && (long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
739
+ return;
740
+ }
741
+
640
742
  wakeDisplay();
641
743
  lastMsgTime = millis();
642
744
 
@@ -693,6 +795,9 @@ void parseMessage(String msg) {
693
795
  line1 = msg.substring(5);
694
796
  line2 = "Playing disc";
695
797
  playSeekMode = false; // always start in volume mode
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;
696
801
  Out.print("POT:"); // push the last-used volume so playback starts at it
697
802
  Out.println(playVolume);
698
803
  drawPlay();
@@ -803,13 +908,16 @@ void setup() {
803
908
  Serial.begin(115200);
804
909
  esp_task_wdt_add(NULL);
805
910
  Wire.begin(21, 22);
911
+ Wire.setClock(400000); // SSD1306 supports I2C fast-mode; the default 100kHz was too slow to
912
+ // push a full 128x64 frame at the visualizer's ~15fps without stutter
806
913
  pinMode(BTN_EJECT_PIN, INPUT_PULLUP);
807
914
  pinMode(BTN_HOME_PIN, INPUT_PULLUP);
808
915
  pinMode(BTN_PLAYPAUSE_PIN, INPUT_PULLUP);
809
916
  pinMode(ENC_SW_PIN, INPUT_PULLUP);
810
917
  pinMode(ENC_CLK_PIN, INPUT_PULLUP);
811
918
  pinMode(ENC_DT_PIN, INPUT_PULLUP);
812
- attachInterrupt(digitalPinToInterrupt(ENC_CLK_PIN), encoderISR, FALLING);
919
+ attachInterrupt(digitalPinToInterrupt(ENC_CLK_PIN), encoderISR, CHANGE);
920
+ attachInterrupt(digitalPinToInterrupt(ENC_DT_PIN), encoderISR, CHANGE);
813
921
 
814
922
  displayOk = display.begin(SSD1306_SWITCHCAPVCC, I2C_ADDRESS);
815
923
 
@@ -903,6 +1011,7 @@ void handleSelectPress(bool longPress) {
903
1011
  drawStandby();
904
1012
 
905
1013
  } else if (uiState == UI_PLAY) {
1014
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
906
1015
  if (longPress) {
907
1016
  Out.println("PLAY_STOP");
908
1017
  } else {
@@ -942,6 +1051,7 @@ void handleEncoderCW() {
942
1051
  displayRotation = 2;
943
1052
  drawStandby();
944
1053
  } else if (uiState == UI_PLAY) {
1054
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
945
1055
  if (!playSeekMode) {
946
1056
  playVolume = min(100, playVolume + 5);
947
1057
  Out.print("POT:");
@@ -979,6 +1089,7 @@ void handleEncoderCCW() {
979
1089
  displayRotation = 0;
980
1090
  drawStandby();
981
1091
  } else if (uiState == UI_PLAY) {
1092
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
982
1093
  if (!playSeekMode) {
983
1094
  playVolume = max(0, playVolume - 5);
984
1095
  Out.print("POT:");
@@ -1023,6 +1134,7 @@ void handlePlayPauseButton(bool longPress) {
1023
1134
  if (wakeDisplay()) { lastInputTime = millis(); return; }
1024
1135
  lastInputTime = millis();
1025
1136
  if (uiState == UI_PLAY) {
1137
+ vuSuppressUntil = millis() + VU_RESUME_DELAY_MS; // any PLAY input -> back to text for a beat
1026
1138
  Out.println(longPress ? "PLAY_STOP" : "PLAY_BUTTON");
1027
1139
  } else if (uiState == UI_HOME && !longPress) {
1028
1140
  bool hasPlay = false;
@@ -1037,6 +1149,24 @@ void handlePlayPauseButton(bool longPress) {
1037
1149
  }
1038
1150
  }
1039
1151
 
1152
+ // Drains every line currently buffered on a link in one go instead of one
1153
+ // per loop() call. VU: frames arrive fast enough (~15/sec) that drawing each
1154
+ // one in turn made the display fall further and further behind real-time
1155
+ // once a single draw took longer than the send interval - only the last VU:
1156
+ // seen in a batch is kept/rendered, so the visualizer always shows "now"
1157
+ // instead of working through a backlog. Every other message type still gets
1158
+ // parsed in order; only VU: is collapsible like this.
1159
+ void drainAndDispatch(Stream &s) {
1160
+ String pendingVu = "";
1161
+ while (s.available()) {
1162
+ String msg = s.readStringUntil('\n');
1163
+ if (msg.length() == 0) continue;
1164
+ if (msg.startsWith("VU:")) pendingVu = msg;
1165
+ else parseMessage(msg);
1166
+ }
1167
+ if (pendingVu.length() > 0) parseMessage(pendingVu);
1168
+ }
1169
+
1040
1170
  void loop() {
1041
1171
  if (!wifiInitDone && millis() > 3000) {
1042
1172
  wifiInitDone = true;
@@ -1079,15 +1209,13 @@ void loop() {
1079
1209
  Out.setClient(nullptr);
1080
1210
  }
1081
1211
  if (tcpClient && tcpClient.available()) {
1082
- String msg = tcpClient.readStringUntil('\n');
1083
- if (msg.length() > 0) parseMessage(msg);
1212
+ drainAndDispatch(tcpClient);
1084
1213
  }
1085
1214
  }
1086
1215
 
1087
1216
  esp_task_wdt_reset();
1088
1217
  if (Serial.available()) {
1089
- String msg = Serial.readStringUntil('\n');
1090
- parseMessage(msg);
1218
+ drainAndDispatch(Serial);
1091
1219
  }
1092
1220
 
1093
1221
  if (returnToHomeAt != 0 && (long)(millis() - returnToHomeAt) >= 0) {
@@ -1106,7 +1234,6 @@ void loop() {
1106
1234
  int16_t ticks = encTicks;
1107
1235
  encTicks = 0;
1108
1236
  interrupts();
1109
- if (ticks != 0) lastEncRotateMs = millis();
1110
1237
  while (ticks > 0) { handleEncoderCW(); ticks--; }
1111
1238
  while (ticks < 0) { handleEncoderCCW(); ticks++; }
1112
1239
  }
@@ -1117,7 +1244,7 @@ void loop() {
1117
1244
  {
1118
1245
  bool sw = digitalRead(ENC_SW_PIN) == LOW;
1119
1246
  if (sw && !encClickDown && millis() - encClickLastDebounce > DEBOUNCE_MS &&
1120
- millis() - lastEncRotateMs > ENC_CLICK_GUARD_MS) {
1247
+ millis() - encLastActivityMs > ENC_CLICK_GUARD_MS) {
1121
1248
  encClickDown = true;
1122
1249
  encClickDownAt = millis();
1123
1250
  }
@@ -1191,10 +1318,29 @@ void loop() {
1191
1318
  if (!displayBlank) drawStatus();
1192
1319
  }
1193
1320
 
1194
- // --- Idle disc screensaver (HOME + STANDBY) ---
1321
+ // --- Visualizer timeout: host stopped sending VU: (paused/stopped) ---
1322
+ if (visualizerActive && (long)(millis() - lastVuAt) >= VU_TIMEOUT_MS) {
1323
+ visualizerActive = false;
1324
+ if (uiState == UI_PLAY && !displayBlank) drawPlay();
1325
+ }
1326
+
1327
+ // --- Idle disc screensaver (HOME + STANDBY + PLAY) ---
1328
+ // PLAY is included because a static "PLAYING" screen is just as low-current
1329
+ // as HOME/STANDBY were - the power bank doesn't care what's on screen, only
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;
1195
1341
  if (displayOk && !displayBlank &&
1196
- (uiState == UI_HOME || uiState == UI_STANDBY) &&
1197
- (long)(millis() - lastInputTime) >= IDLE_BLANK_MS) {
1342
+ (uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
1343
+ ((long)(millis() - lastInputTime) >= IDLE_BLANK_MS || playSkippingToScreensaver)) {
1198
1344
  displayBlank = true;
1199
1345
  saverStep = 0;
1200
1346
  lastSaverFrame = 0;
@@ -27,6 +27,50 @@ 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 — 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**.
73
+
30
74
  ## Windows
31
75
 
32
76
  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.30",
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",
@@ -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,7 +3304,173 @@ def _iter_proc_lines(proc, ser):
3299
3304
  reader.join(timeout=1)
3300
3305
 
3301
3306
 
3302
- def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
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
+
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."""
3303
3474
  try:
3304
3475
  os.unlink(MPV_SOCKET)
3305
3476
  except OSError:
@@ -3323,7 +3494,15 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3323
3494
  # writes block, wedging the whole play loop. We drive mpv over the IPC
3324
3495
  # socket, so none of that output is wanted.
3325
3496
  proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
3497
+ stdin=(stdin_proc.stdout if stdin_proc else None),
3326
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()
3505
+ vu_stop = None
3327
3506
 
3328
3507
  try:
3329
3508
  if not wait_for_socket(MPV_SOCKET, proc):
@@ -3343,6 +3522,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3343
3522
  send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
3344
3523
  send(ser, "PLAY:PLAYING")
3345
3524
  print(f"{label}. Short press toggles pause; long press stops.")
3525
+ vu_stop, vu_pause = start_vu_visualizer(ser)
3346
3526
 
3347
3527
  last_ping = time.time()
3348
3528
  while proc.poll() is None:
@@ -3375,6 +3555,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3375
3555
  paused = not paused
3376
3556
  mpv_command(["set_property", "pause", paused])
3377
3557
  mpv_command(["set_property", "speed", 1.0])
3558
+ if vu_pause:
3559
+ vu_pause.set() if paused else vu_pause.clear()
3378
3560
  send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
3379
3561
 
3380
3562
  elif line in ("PLAY_STOP", "EJECT"):
@@ -3387,7 +3569,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3387
3569
 
3388
3570
  elif line == "FF:BIG":
3389
3571
  if kind == "audio_cd":
3390
- mpv_command(["add", "chapter", 1])
3572
+ _audio_cd_track_seek(1, track_starts)
3391
3573
  send(ser, "PLAY_STATUS:Next track")
3392
3574
  else:
3393
3575
  mpv_command(["seek", 120])
@@ -3401,7 +3583,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3401
3583
  except ValueError:
3402
3584
  continue
3403
3585
  if kind == "audio_cd":
3404
- mpv_command(["add", "chapter", 1])
3586
+ _audio_cd_track_seek(1, track_starts)
3405
3587
  send(ser, "PLAY_STATUS:Next track")
3406
3588
  else:
3407
3589
  mpv_command(["seek", seek_sec])
@@ -3411,7 +3593,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3411
3593
 
3412
3594
  elif line == "REW:BIG":
3413
3595
  if kind == "audio_cd":
3414
- mpv_command(["add", "chapter", -1])
3596
+ _audio_cd_track_seek(-1, track_starts)
3415
3597
  send(ser, "PLAY_STATUS:Prev track")
3416
3598
  else:
3417
3599
  mpv_command(["seek", -120])
@@ -3425,7 +3607,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3425
3607
  except ValueError:
3426
3608
  continue
3427
3609
  if kind == "audio_cd":
3428
- mpv_command(["add", "chapter", -1])
3610
+ _audio_cd_track_seek(-1, track_starts)
3429
3611
  send(ser, "PLAY_STATUS:Prev track")
3430
3612
  else:
3431
3613
  mpv_command(["seek", -seek_sec])
@@ -3447,8 +3629,12 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
3447
3629
 
3448
3630
  time.sleep(0.05)
3449
3631
  finally:
3632
+ if vu_stop:
3633
+ vu_stop.set()
3450
3634
  if proc.poll() is None:
3451
3635
  discstation_burn.stop_process(proc)
3636
+ if stdin_proc and stdin_proc.poll() is None:
3637
+ discstation_burn.stop_process(stdin_proc)
3452
3638
  try:
3453
3639
  os.unlink(MPV_SOCKET)
3454
3640
  except OSError:
@@ -3619,11 +3805,40 @@ def play_flow(ser):
3619
3805
 
3620
3806
  elif kind == "audio_cd":
3621
3807
  _, track_titles, track_starts = audio_track_metadata(device)
3622
- if discstation_host.system_name() == "windows":
3808
+ system = discstation_host.system_name()
3809
+ if system == "windows":
3623
3810
  # mpv on Windows has no libcdio - cdda:// is unavailable there
3624
3811
  # ("disabled at compile-time"). Windows Media Player's own COM
3625
3812
  # control plays it fine via Windows' native CD-audio support.
3626
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)
3627
3842
  else:
3628
3843
  audio_device = discstation_host.audio_output_device()
3629
3844
  cmd = [