discstation 0.1.32 → 0.1.34
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/arduino/DiscStation/DiscStation.ino +40 -10
- package/install-macos.sh +1 -1
- package/package.json +1 -1
- package/src/discstation.py +8 -1
- package/src/discstation_burn.py +160 -58
|
@@ -272,6 +272,13 @@ uint8_t vuLevel[VU_BARS];
|
|
|
272
272
|
bool visualizerActive = false;
|
|
273
273
|
unsigned long lastVuAt = 0;
|
|
274
274
|
unsigned long vuSuppressUntil = 0; // bars withheld (text screen shown instead) until millis() reaches this
|
|
275
|
+
bool vuSeenReal = false; // has this PLAY session ever gotten a VU: frame with a nonzero bar -
|
|
276
|
+
// a session-level fact (macOS: never; Linux: within the first frame or
|
|
277
|
+
// two), decided once and left alone. Deliberately separate from
|
|
278
|
+
// visualizerActive/lastVuAt, which track "is the stream still alive"
|
|
279
|
+
// per frame (including legitimate all-zero frames during quiet music) -
|
|
280
|
+
// conflating the two into one gate previously caused the bars/screensaver
|
|
281
|
+
// to flicker on Linux every time a frame happened to read all zero.
|
|
275
282
|
int displayRotation = 0;
|
|
276
283
|
|
|
277
284
|
// Indeterminate progress animation
|
|
@@ -642,7 +649,7 @@ bool wakeDisplay() {
|
|
|
642
649
|
case UI_IP: drawIP(); break;
|
|
643
650
|
case UI_BURN_READY: drawBurnReady(); break;
|
|
644
651
|
case UI_PLAY:
|
|
645
|
-
if (visualizerActive && (long)(millis() - lastVuAt) < VU_TIMEOUT_MS &&
|
|
652
|
+
if (vuSeenReal && visualizerActive && (long)(millis() - lastVuAt) < VU_TIMEOUT_MS &&
|
|
646
653
|
(long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
|
|
647
654
|
else drawPlay();
|
|
648
655
|
break;
|
|
@@ -724,18 +731,39 @@ void parseMessage(String msg) {
|
|
|
724
731
|
lastMsgTime = millis();
|
|
725
732
|
String rest = msg.substring(3);
|
|
726
733
|
for (int i = 0; i < VU_BARS; i++) vuLevel[i] = 0;
|
|
734
|
+
bool anyNonzero = false;
|
|
727
735
|
for (int i = 0; i < VU_BARS && rest.length() > 0; i++) {
|
|
728
736
|
int comma = rest.indexOf(',');
|
|
729
737
|
String tok = (comma < 0) ? rest : rest.substring(0, comma);
|
|
730
738
|
vuLevel[i] = (uint8_t)constrain(tok.toInt(), 0, 63);
|
|
739
|
+
if (vuLevel[i] > 0) anyNonzero = true;
|
|
731
740
|
if (comma < 0) break;
|
|
732
741
|
rest = rest.substring(comma + 1);
|
|
733
742
|
}
|
|
743
|
+
if (anyNonzero) vuSeenReal = true;
|
|
744
|
+
// lastVuAt/visualizerActive/lastInputTime update on EVERY frame, zero or
|
|
745
|
+
// not - they track "is the stream still alive", and real music routinely
|
|
746
|
+
// produces a frame where every bar reads 0 (quiet moment right after a
|
|
747
|
+
// loud transient keeps the adaptive reference elevated for a beat). Only
|
|
748
|
+
// gating those three on anyNonzero previously made ordinary quiet frames
|
|
749
|
+
// look like "host stopped sending" to the unrelated VU_TIMEOUT_MS check
|
|
750
|
+
// below, flickering bars/screensaver during normal playback.
|
|
734
751
|
visualizerActive = true;
|
|
735
752
|
lastVuAt = millis();
|
|
736
753
|
lastInputTime = millis(); // the visualizer's own continuous redraw already beats the
|
|
737
754
|
// power-bank shutoff - no need for the screensaver too
|
|
738
|
-
|
|
755
|
+
// Drawing bars (and reclaiming the screen from the disc-spinner screensaver,
|
|
756
|
+
// if it's up) is the one thing that DOES stay gated on vuSeenReal - a host
|
|
757
|
+
// that's technically capturing but getting only silence (e.g. macOS's
|
|
758
|
+
// blocked system-audio-capture - see docs/PLATFORM_SUPPORT.md) sends real
|
|
759
|
+
// VU: frames that are all zero forever. Without this gate, every one of
|
|
760
|
+
// those frames would re-draw a blank bars screen and set displayBlank =
|
|
761
|
+
// false, fighting the disc-spinner screensaver for the display every
|
|
762
|
+
// ~66ms instead of leaving it alone once chosen.
|
|
763
|
+
if (vuSeenReal && uiState == UI_PLAY && (long)(millis() - vuSuppressUntil) >= 0) {
|
|
764
|
+
displayBlank = false;
|
|
765
|
+
drawPlayVisualizer();
|
|
766
|
+
}
|
|
739
767
|
return;
|
|
740
768
|
}
|
|
741
769
|
|
|
@@ -798,6 +826,7 @@ void parseMessage(String msg) {
|
|
|
798
826
|
vuSuppressUntil = millis() + VU_ENTRY_DELAY_MS; // text screen first, bars after a beat
|
|
799
827
|
lastVuAt = 0; // fresh session - unknown yet whether the host even sends VU: at all
|
|
800
828
|
visualizerActive = false;
|
|
829
|
+
vuSeenReal = false; // unknown yet whether this session ever gets a real (nonzero) frame
|
|
801
830
|
Out.print("POT:"); // push the last-used volume so playback starts at it
|
|
802
831
|
Out.println(playVolume);
|
|
803
832
|
drawPlay();
|
|
@@ -1329,14 +1358,15 @@ void loop() {
|
|
|
1329
1358
|
// as HOME/STANDBY were - the power bank doesn't care what's on screen, only
|
|
1330
1359
|
// that the draw stays static this long.
|
|
1331
1360
|
//
|
|
1332
|
-
// In PLAY specifically, if this session has never
|
|
1333
|
-
// (
|
|
1334
|
-
// macOS today), don't make the user wait out the full
|
|
1335
|
-
// for an animation - drop into the spinning-disc
|
|
1336
|
-
// the text-hold window (vuSuppressUntil) expires.
|
|
1337
|
-
//
|
|
1338
|
-
//
|
|
1339
|
-
|
|
1361
|
+
// In PLAY specifically, if this session has never gotten a single REAL
|
|
1362
|
+
// (nonzero) VU: frame (!vuSeenReal - the host/platform doesn't support the
|
|
1363
|
+
// visualizer, e.g. macOS today), don't make the user wait out the full
|
|
1364
|
+
// generic idle timer for an animation - drop into the spinning-disc
|
|
1365
|
+
// screensaver as soon as the text-hold window (vuSuppressUntil) expires.
|
|
1366
|
+
// vuSeenReal (not lastVuAt, which now updates on every frame including
|
|
1367
|
+
// all-zero ones) is deliberately a session-level, decided-once fact, so
|
|
1368
|
+
// this can't re-trigger mid-playback and flicker against the bars.
|
|
1369
|
+
bool playSkippingToScreensaver = (uiState == UI_PLAY) && !vuSeenReal &&
|
|
1340
1370
|
(long)(millis() - vuSuppressUntil) >= 0;
|
|
1341
1371
|
if (displayOk && !displayBlank &&
|
|
1342
1372
|
(uiState == UI_HOME || uiState == UI_STANDBY || uiState == UI_PLAY) &&
|
package/install-macos.sh
CHANGED
|
@@ -50,7 +50,7 @@ if [[ -f "$ROOT_DIR/requirements-optional.txt" ]]; then
|
|
|
50
50
|
fi
|
|
51
51
|
|
|
52
52
|
DISC_DEVICE="${DISC_DEVICE:-}"
|
|
53
|
-
DISC_PORT="${DISC_PORT:-$(ls /dev/cu.usbserial-* /dev/cu.usbmodem-* 2>/dev/null | head -1)}"
|
|
53
|
+
DISC_PORT="${DISC_PORT:-$(ls /dev/cu.usbserial-* /dev/cu.usbmodem-* 2>/dev/null | head -1 || true)}"
|
|
54
54
|
|
|
55
55
|
if [[ ! -f "$CONFIG_DIR/server.crt" || ! -f "$CONFIG_DIR/server.key" ]]; then
|
|
56
56
|
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
package/package.json
CHANGED
package/src/discstation.py
CHANGED
|
@@ -128,6 +128,13 @@ class TcpSerial:
|
|
|
128
128
|
if p.isdigit():
|
|
129
129
|
port = int(p)
|
|
130
130
|
self._sock = socket.create_connection((host, port), timeout=connect_timeout)
|
|
131
|
+
# Nagle's algorithm is on by default and holds back small writes hoping
|
|
132
|
+
# to coalesce them - the visualizer sends a ~30-50 byte VU: frame ~15x/sec,
|
|
133
|
+
# exactly the pattern that gets delayed (commonly ~200ms with delayed-ACK
|
|
134
|
+
# on the other end), which read as visualizer lag over Wi-Fi even though
|
|
135
|
+
# the visualizer itself was untouched. Never an issue on the USB path,
|
|
136
|
+
# which isn't TCP at all.
|
|
137
|
+
self._sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
131
138
|
self._sock.settimeout(0.05)
|
|
132
139
|
self._buf = b""
|
|
133
140
|
self._lock = threading.Lock()
|
|
@@ -3056,7 +3063,7 @@ def burn_data_flow(ser):
|
|
|
3056
3063
|
for d in files_to_burn:
|
|
3057
3064
|
if d.is_dir():
|
|
3058
3065
|
total_bytes += sum(f.stat().st_size for f in d.rglob("*") if f.is_file())
|
|
3059
|
-
label = "
|
|
3066
|
+
label = "CD-R" if dl_info.get("is_cd") else ("DVD9" if dl_info["is_dual_layer"] else "DVD5")
|
|
3060
3067
|
usable = discstation_burn.disc_output_limit_bytes(disc_bytes)
|
|
3061
3068
|
if usable and total_bytes > usable:
|
|
3062
3069
|
size_gb = total_bytes / 1e9
|
package/src/discstation_burn.py
CHANGED
|
@@ -242,6 +242,7 @@ DVD_DEVICE = os.environ.get("DISC_DEVICE") or os.environ.get("DVD_DEVICE")
|
|
|
242
242
|
DISC_SPEED = os.environ.get("DISC_SPEED")
|
|
243
243
|
DISC_DISC_BYTES = 4_700_000_000
|
|
244
244
|
DVD_DL_BYTES = 8_500_000_000
|
|
245
|
+
CD_R_BYTES = 700_000_000
|
|
245
246
|
DISC_TARGET_BYTES = int(os.environ.get("DISC_TARGET_BYTES", "4300000000"))
|
|
246
247
|
DVD_MUX_SAFETY = float(os.environ.get("DVD_MUX_SAFETY", "0.92"))
|
|
247
248
|
AUDIO_BITRATE_K = int(os.environ.get("DVD_AUDIO_KBPS", "192"))
|
|
@@ -423,8 +424,17 @@ def disc_capacity_bytes(device):
|
|
|
423
424
|
props.get("ID_CDROM_MEDIA_DVD_R_DL") == "1" or
|
|
424
425
|
props.get("ID_CDROM_MEDIA_DVD_R_DL_SEQ") == "1"
|
|
425
426
|
)
|
|
426
|
-
|
|
427
|
-
|
|
427
|
+
# ID_CDROM_MEDIA_CD_R/CD_RW is the media actually loaded, not the drive's
|
|
428
|
+
# read/write capability (ID_CDROM_CD_R, set for any combo drive
|
|
429
|
+
# regardless of what's inserted) - checking the wrong one here is why a
|
|
430
|
+
# blank CD-R used to get treated as an unreadable DVD (capacity "unknown,
|
|
431
|
+
# assuming DVD5") instead of a ~700MB CD.
|
|
432
|
+
is_cd = (
|
|
433
|
+
props.get("ID_CDROM_MEDIA_CD_R") == "1" or
|
|
434
|
+
props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
435
|
+
)
|
|
436
|
+
expected_min = 100_000_000 if is_cd else 1_000_000_000
|
|
437
|
+
expected_max = CD_R_BYTES if is_cd else (DVD_DL_BYTES if is_dl else DISC_DISC_BYTES)
|
|
428
438
|
|
|
429
439
|
mediainfo_timeout = _env_int("DISCSTATION_PROBE_TIMEOUT_MEDIAINFO", 12)
|
|
430
440
|
best = None
|
|
@@ -465,14 +475,13 @@ def disc_capacity_bytes(device):
|
|
|
465
475
|
if best is not None:
|
|
466
476
|
return best
|
|
467
477
|
|
|
468
|
-
if props.get("ID_CDROM_MEDIA_STATE") == "blank":
|
|
469
|
-
if is_dl:
|
|
470
|
-
return DVD_DL_BYTES
|
|
471
478
|
if is_dl:
|
|
472
479
|
return DVD_DL_BYTES
|
|
473
480
|
if props.get("ID_CDROM_MEDIA_DVD_PLUS_R") == "1" or \
|
|
474
481
|
props.get("ID_CDROM_MEDIA_DVD_R") == "1":
|
|
475
482
|
return DISC_DISC_BYTES
|
|
483
|
+
if is_cd:
|
|
484
|
+
return CD_R_BYTES
|
|
476
485
|
|
|
477
486
|
# Last resort: a raw block size (works for finalized/pressed discs where
|
|
478
487
|
# dvd+rw-mediainfo reports no free blocks; 0/absent for audio CDs).
|
|
@@ -498,6 +507,10 @@ def detect_disc_type(device):
|
|
|
498
507
|
props.get("ID_CDROM_MEDIA_DVD_PLUS_R") == "1" or
|
|
499
508
|
props.get("ID_CDROM_MEDIA_DVD_R") == "1"
|
|
500
509
|
)
|
|
510
|
+
is_cd = (
|
|
511
|
+
props.get("ID_CDROM_MEDIA_CD_R") == "1" or
|
|
512
|
+
props.get("ID_CDROM_MEDIA_CD_RW") == "1"
|
|
513
|
+
)
|
|
501
514
|
is_blank = props.get("ID_CDROM_MEDIA_STATE") == "blank"
|
|
502
515
|
media_type = props.get("ID_CDROM_MEDIA", "")
|
|
503
516
|
|
|
@@ -505,11 +518,12 @@ def detect_disc_type(device):
|
|
|
505
518
|
|
|
506
519
|
capacity = disc_capacity_bytes(device)
|
|
507
520
|
if capacity is None:
|
|
508
|
-
capacity = DVD_DL_BYTES if is_dl else (DISC_DISC_BYTES if is_sl else None)
|
|
521
|
+
capacity = DVD_DL_BYTES if is_dl else (CD_R_BYTES if is_cd else (DISC_DISC_BYTES if is_sl else None))
|
|
509
522
|
|
|
510
523
|
return {
|
|
511
524
|
"is_dual_layer": is_dl,
|
|
512
525
|
"is_single_layer": is_sl,
|
|
526
|
+
"is_cd": is_cd,
|
|
513
527
|
"is_blank": is_blank,
|
|
514
528
|
"status": status,
|
|
515
529
|
"media_type": media_type,
|
|
@@ -1414,6 +1428,13 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1414
1428
|
send(ser, "PROGRESS:Starting")
|
|
1415
1429
|
send(ser, f"INFO:Label {disc_label[:13]}")
|
|
1416
1430
|
device = disc_device()
|
|
1431
|
+
# growisofs (dvd+rw-tools) only writes DVD/BD media - it refuses CD-R/RW
|
|
1432
|
+
# outright ("media is not recognized as recordable DVD"), so a blank CD-R
|
|
1433
|
+
# needs the CD-specific tool (genisoimage build + wodim write) instead.
|
|
1434
|
+
props = _udevadm_props(device)
|
|
1435
|
+
if props.get("ID_CDROM_MEDIA_CD_R") == "1" or props.get("ID_CDROM_MEDIA_CD_RW") == "1":
|
|
1436
|
+
_burn_data_cd(ser, source_paths, disc_label, speed, device)
|
|
1437
|
+
return
|
|
1417
1438
|
growisofs_cmd = [tool('growisofs'), '-dvd-compat', '-Z', device]
|
|
1418
1439
|
speed = speed or DISC_SPEED
|
|
1419
1440
|
if speed and speed.lower() != "auto":
|
|
@@ -1427,6 +1448,59 @@ def burn_data(ser, source_paths, disc_label, speed=None, is_dual_layer=False):
|
|
|
1427
1448
|
growisofs_cmd += [str(p) for p in source_paths]
|
|
1428
1449
|
_run_growisofs(ser, growisofs_cmd, source_paths[0].parent / "growisofs.log", device)
|
|
1429
1450
|
|
|
1451
|
+
def _burn_data_cd(ser, source_paths, disc_label, speed, device):
|
|
1452
|
+
"""Build an ISO9660/Joliet image with genisoimage and burn it with wodim -
|
|
1453
|
+
the CD-R/RW equivalent of the growisofs path above (growisofs can't
|
|
1454
|
+
write CD media at all)."""
|
|
1455
|
+
WORK.mkdir(parents=True, exist_ok=True)
|
|
1456
|
+
iso_path = WORK / f"data_{time.strftime('%Y%m%d_%H%M%S')}.iso"
|
|
1457
|
+
mkisofs_cmd = [tool('genisoimage'), '-R', '-J', '-joliet-long', '-V', disc_label,
|
|
1458
|
+
'-o', str(iso_path)]
|
|
1459
|
+
mkisofs_cmd += [str(p) for p in source_paths]
|
|
1460
|
+
r = subprocess.run(mkisofs_cmd, capture_output=True, text=True)
|
|
1461
|
+
if r.returncode != 0:
|
|
1462
|
+
raise RuntimeError(f"ISO build failed: {r.stderr.strip()[-200:] or 'genisoimage error'}")
|
|
1463
|
+
|
|
1464
|
+
try:
|
|
1465
|
+
discstation_host.unmount_device(device)
|
|
1466
|
+
wodim_cmd = [tool('wodim'), '-v', f'dev={device}']
|
|
1467
|
+
speed = speed or DISC_SPEED
|
|
1468
|
+
if speed and speed.lower() != "auto":
|
|
1469
|
+
wodim_cmd += [f"speed={re.sub(r'[^0-9]', '', speed) or '8'}"]
|
|
1470
|
+
wodim_cmd.append(str(iso_path))
|
|
1471
|
+
|
|
1472
|
+
proc = subprocess.Popen(wodim_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1473
|
+
out_lines = []
|
|
1474
|
+
last_prog = 0
|
|
1475
|
+
try:
|
|
1476
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1477
|
+
out_lines.append(line)
|
|
1478
|
+
m = re.search(r'(\d+)%', line)
|
|
1479
|
+
if m:
|
|
1480
|
+
now = time.time()
|
|
1481
|
+
if now - last_prog >= 0.2:
|
|
1482
|
+
send(ser, f"PROGRESS:{m.group(1)}%")
|
|
1483
|
+
last_prog = now
|
|
1484
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1485
|
+
stop_process(proc)
|
|
1486
|
+
raise
|
|
1487
|
+
rc = proc.wait()
|
|
1488
|
+
if rc != 0:
|
|
1489
|
+
for line in out_lines[-10:]:
|
|
1490
|
+
print(f"wodim: {line}")
|
|
1491
|
+
if rc == -15:
|
|
1492
|
+
safe_send(ser, "CANCELLED:Burn cancelled")
|
|
1493
|
+
raise CancelError("Cancelled")
|
|
1494
|
+
safe_send(ser, "INFO:Burn failed, check log")
|
|
1495
|
+
raise RuntimeError("Disc burn failed")
|
|
1496
|
+
finally:
|
|
1497
|
+
iso_path.unlink(missing_ok=True)
|
|
1498
|
+
|
|
1499
|
+
try:
|
|
1500
|
+
discstation_host.eject_device(device)
|
|
1501
|
+
except Exception as e:
|
|
1502
|
+
print(f"Disc eject skipped: {e}")
|
|
1503
|
+
|
|
1430
1504
|
def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
1431
1505
|
"""Convert audio files to CD-DA WAV and burn via cdrdao with CD-TEXT."""
|
|
1432
1506
|
send(ser, "STATUS:Reading tags...")
|
|
@@ -1493,30 +1567,27 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1493
1567
|
|
|
1494
1568
|
album_t = _cdt(album_title) or "Audio CD"
|
|
1495
1569
|
album_p = _cdt(album_artist) or "Unknown Artist"
|
|
1496
|
-
toc_lines = ["CD_DA"]
|
|
1497
|
-
toc_lines.append("CD_TEXT {")
|
|
1498
|
-
toc_lines.append(" LANGUAGE_MAP { 0: EN }")
|
|
1499
|
-
toc_lines.append(" LANGUAGE 0 {")
|
|
1500
|
-
toc_lines.append(f' TITLE "{album_t}"')
|
|
1501
|
-
toc_lines.append(f' PERFORMER "{album_p}"')
|
|
1502
|
-
toc_lines.append(" }")
|
|
1503
|
-
toc_lines.append("}")
|
|
1504
|
-
toc_lines.append("")
|
|
1505
|
-
for i, (artist, title) in enumerate(track_meta):
|
|
1506
|
-
wav = tmp_dir / f"track_{i + 1:02d}.wav"
|
|
1507
|
-
track_t = _cdt(title) or f"Track {i + 1:02d}"
|
|
1508
|
-
track_p = _cdt(artist) or album_p
|
|
1509
|
-
toc_lines.append("TRACK AUDIO")
|
|
1510
|
-
toc_lines.append("CD_TEXT {")
|
|
1511
|
-
toc_lines.append(" LANGUAGE 0 {")
|
|
1512
|
-
toc_lines.append(f' TITLE "{track_t}"')
|
|
1513
|
-
toc_lines.append(f' PERFORMER "{track_p}"')
|
|
1514
|
-
toc_lines.append(" }")
|
|
1515
|
-
toc_lines.append("}")
|
|
1516
|
-
toc_lines.append(f'FILE "{wav}" 0')
|
|
1517
|
-
toc_lines.append("")
|
|
1518
1570
|
toc_path = tmp_dir / "disc.toc"
|
|
1519
|
-
|
|
1571
|
+
|
|
1572
|
+
def _write_toc(include_cdtext):
|
|
1573
|
+
toc_lines = ["CD_DA"]
|
|
1574
|
+
if include_cdtext:
|
|
1575
|
+
toc_lines += ["CD_TEXT {", " LANGUAGE_MAP { 0: EN }", " LANGUAGE 0 {",
|
|
1576
|
+
f' TITLE "{album_t}"', f' PERFORMER "{album_p}"',
|
|
1577
|
+
" }", "}", ""]
|
|
1578
|
+
for i, (artist, title) in enumerate(track_meta):
|
|
1579
|
+
wav = tmp_dir / f"track_{i + 1:02d}.wav"
|
|
1580
|
+
toc_lines.append("TRACK AUDIO")
|
|
1581
|
+
if include_cdtext:
|
|
1582
|
+
track_t = _cdt(title) or f"Track {i + 1:02d}"
|
|
1583
|
+
track_p = _cdt(artist) or album_p
|
|
1584
|
+
toc_lines += ["CD_TEXT {", " LANGUAGE 0 {", f' TITLE "{track_t}"',
|
|
1585
|
+
f' PERFORMER "{track_p}"', " }", "}"]
|
|
1586
|
+
toc_lines.append(f'FILE "{wav}" 0')
|
|
1587
|
+
toc_lines.append("")
|
|
1588
|
+
toc_path.write_text("\n".join(toc_lines) + "\n")
|
|
1589
|
+
|
|
1590
|
+
_write_toc(include_cdtext=True)
|
|
1520
1591
|
print(f"CD-TEXT: album={album_t!r} performer={album_p!r}, "
|
|
1521
1592
|
f"{len(track_meta)} track titles")
|
|
1522
1593
|
send(ser, "PROGRESS:35%")
|
|
@@ -1533,9 +1604,8 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1533
1604
|
return
|
|
1534
1605
|
|
|
1535
1606
|
send(ser, "STATUS:Burning audio CD...")
|
|
1536
|
-
#
|
|
1537
|
-
#
|
|
1538
|
-
# (set it empty to let cdrdao auto-pick).
|
|
1607
|
+
# DISCSTATION_CDRDAO_DRIVER forces one specific driver (set it empty for
|
|
1608
|
+
# cdrdao to auto-pick) instead of the try-in-order fallback below.
|
|
1539
1609
|
try:
|
|
1540
1610
|
cdrdao_write_dev = discstation_host.cdrdao_device(disc_device())
|
|
1541
1611
|
except RuntimeError:
|
|
@@ -1543,39 +1613,71 @@ def burn_audio_cd(ser, audio_files, disc_label, speed=None):
|
|
|
1543
1613
|
raise RuntimeError("Audio CD burning is not supported on this Mac "
|
|
1544
1614
|
"(cdrdao cannot access the optical drive)")
|
|
1545
1615
|
raise
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1616
|
+
def _run_cdrdao(driver):
|
|
1617
|
+
cmd = [tool('cdrdao'), 'write', '--buffers', '64', '--device', cdrdao_write_dev]
|
|
1618
|
+
if driver:
|
|
1619
|
+
cmd += ['--driver', driver]
|
|
1620
|
+
speed_ = speed or DISC_SPEED
|
|
1621
|
+
if speed_ and speed_.lower() != "auto":
|
|
1622
|
+
cmd += ['--speed', speed_.rstrip('x')]
|
|
1623
|
+
cmd.append(str(toc_path)) # toc-file must come after all options
|
|
1624
|
+
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
1625
|
+
lines = []
|
|
1626
|
+
last_prog = 0
|
|
1627
|
+
try:
|
|
1628
|
+
for line in iter_proc_or_cancel(proc, ser):
|
|
1629
|
+
lines.append(line)
|
|
1630
|
+
# "Wrote 12 of 650 MB (Buffers 99% 100%)." is the real write
|
|
1631
|
+
# progress - matching on the first bare NN% instead (as this
|
|
1632
|
+
# used to) grabs the *buffer fill* percentage, which BURN-Proof
|
|
1633
|
+
# holds near 100% for virtually the whole burn, so the OLED
|
|
1634
|
+
# jumped straight from "Converting" to 100% and sat there.
|
|
1635
|
+
m = re.search(r'Wrote\s+(\d+)\s+of\s+(\d+)\s+MB', line)
|
|
1636
|
+
if m and int(m.group(2)) > 0:
|
|
1637
|
+
frac = int(m.group(1)) / int(m.group(2))
|
|
1638
|
+
pct = 35 + int(frac * 65)
|
|
1639
|
+
now = time.time()
|
|
1640
|
+
if now - last_prog >= 0.2:
|
|
1641
|
+
send(ser, f"PROGRESS:{pct}%")
|
|
1642
|
+
last_prog = now
|
|
1643
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1644
|
+
stop_process(proc)
|
|
1645
|
+
raise
|
|
1646
|
+
return proc.wait(), lines
|
|
1647
|
+
|
|
1558
1648
|
log_path = WORK / "cdrdao.log"
|
|
1559
|
-
|
|
1649
|
+
override = os.environ.get("DISCSTATION_CDRDAO_DRIVER")
|
|
1650
|
+
# generic-mmc-raw (raw P-W sub-channel writing) is what guarantees CD-TEXT,
|
|
1651
|
+
# but some drives reject raw sub-channel writing outright - confirmed live
|
|
1652
|
+
# on this drive with --simulate: it fails at the lead-in even with CD-TEXT
|
|
1653
|
+
# stripped out entirely, so it's the raw *driver* that's incompatible here,
|
|
1654
|
+
# not CD-TEXT itself. generic-mmc (cooked) writes fine on the same drive
|
|
1655
|
+
# (also confirmed with --simulate) and still carries the CD-TEXT blocks in
|
|
1656
|
+
# the TOC, so it's worth trying with labels intact before dropping them -
|
|
1657
|
+
# only the last resort actually removes CD-TEXT from the TOC.
|
|
1658
|
+
drivers_to_try = [override] if override else ["generic-mmc-raw", "generic-mmc"]
|
|
1560
1659
|
try:
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1660
|
+
rc, out_lines = -1, []
|
|
1661
|
+
for i, driver in enumerate(drivers_to_try):
|
|
1662
|
+
if i > 0:
|
|
1663
|
+
print(f"cdrdao: write failed with previous driver - retrying with --driver {driver or 'auto'}")
|
|
1664
|
+
send(ser, "STATUS:Retrying burn...")
|
|
1665
|
+
rc, lines = _run_cdrdao(driver)
|
|
1666
|
+
out_lines += ([f"--- driver {driver or 'auto'} ---"] if i else []) + lines
|
|
1667
|
+
if rc == 0 or rc == -15:
|
|
1668
|
+
break
|
|
1669
|
+
if rc != 0 and rc != -15 and not override:
|
|
1670
|
+
print("cdrdao: all driver modes failed - retrying without CD-TEXT")
|
|
1671
|
+
send(ser, "STATUS:Retrying without CD-TEXT...")
|
|
1672
|
+
_write_toc(include_cdtext=False)
|
|
1673
|
+
rc2, lines2 = _run_cdrdao(None)
|
|
1674
|
+
out_lines += ["--- retry without CD-TEXT ---"] + lines2
|
|
1675
|
+
rc = rc2
|
|
1573
1676
|
finally:
|
|
1574
1677
|
for w in tmp_dir.glob("*.wav"):
|
|
1575
1678
|
w.unlink(missing_ok=True)
|
|
1576
1679
|
toc_path.unlink(missing_ok=True)
|
|
1577
1680
|
shutil.rmtree(str(tmp_dir), ignore_errors=True)
|
|
1578
|
-
rc = proc.wait()
|
|
1579
1681
|
log_path.write_text("\n".join(out_lines) + "\n")
|
|
1580
1682
|
if rc != 0:
|
|
1581
1683
|
for line in out_lines[-10:]:
|