discstation 0.1.32 → 0.1.33
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/package.json +1 -1
- package/src/discstation.py +8 -1
- package/src/discstation_burn.py +80 -6
|
@@ -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/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...")
|