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.
@@ -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
- if (uiState == UI_PLAY && (long)(millis() - vuSuppressUntil) >= 0) drawPlayVisualizer();
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 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) &&
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discstation",
3
- "version": "0.1.32",
3
+ "version": "0.1.33",
4
4
  "description": "DiscStation optical-media appliance host — one cross-OS installer",
5
5
  "bin": {
6
6
  "discstation-setup": "scripts/setup.mjs",
@@ -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 = "DVD5" if not dl_info["is_dual_layer"] else "DVD9"
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
@@ -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
- expected_min = 1_000_000_000
427
- expected_max = DVD_DL_BYTES if is_dl else DISC_DISC_BYTES
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...")