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 +3 -3
- package/arduino/DiscStation/DiscStation.ino +173 -27
- package/docs/PLATFORM_SUPPORT.md +44 -0
- package/install-macos.sh +6 -3
- package/install.sh +1 -1
- package/package.json +1 -1
- package/requirements-optional.txt +8 -0
- package/src/discstation.py +221 -6
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
266
|
-
// -
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
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
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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() -
|
|
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
|
-
// ---
|
|
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;
|
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -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
|
@@ -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"
|
package/src/discstation.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = [
|