discstation 0.1.29 → 0.1.31
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 +5 -8
- package/arduino/{v1 → DiscStation}/DiscStation.ino +341 -155
- package/docs/PLATFORM_SUPPORT.md +16 -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 +156 -1
- package/src/discstation_burn.py +15 -2
- package/arduino/c6/DiscStation_C6.ino +0 -1185
- package/arduino/c6/secrets.h.example +0 -14
- /package/arduino/{v1 → DiscStation}/secrets.h.example +0 -0
package/docs/PLATFORM_SUPPORT.md
CHANGED
|
@@ -27,6 +27,22 @@ 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
|
|
31
|
+
|
|
32
|
+
The visualizer needs a one-time manual setup, since macOS has no built-in
|
|
33
|
+
way to tap "whatever's currently playing" the way Linux's PulseAudio does:
|
|
34
|
+
|
|
35
|
+
1. `brew install blackhole-2ch` (done automatically by `install-macos.sh`).
|
|
36
|
+
2. Open **Audio MIDI Setup** (in Applications/Utilities), click **+** →
|
|
37
|
+
**Create Multi-Output Device**, and check both **BlackHole 2ch** and your
|
|
38
|
+
real output (speakers/headphones).
|
|
39
|
+
3. In **System Settings → Sound**, set that Multi-Output Device as the
|
|
40
|
+
default output. Audio stays audible (routed to your real device) and is
|
|
41
|
+
simultaneously tapped (via BlackHole) for the visualizer's FFT.
|
|
42
|
+
|
|
43
|
+
Without this setup the visualizer silently does nothing and PLAY just shows
|
|
44
|
+
its normal text screen — nothing else is affected.
|
|
45
|
+
|
|
30
46
|
## Windows
|
|
31
47
|
|
|
32
48
|
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,6 +3304,150 @@ def _iter_proc_lines(proc, ser):
|
|
|
3299
3304
|
reader.join(timeout=1)
|
|
3300
3305
|
|
|
3301
3306
|
|
|
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
|
+
|
|
3302
3451
|
def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
3303
3452
|
try:
|
|
3304
3453
|
os.unlink(MPV_SOCKET)
|
|
@@ -3324,6 +3473,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3324
3473
|
# socket, so none of that output is wanted.
|
|
3325
3474
|
proc = subprocess.Popen(run_as_desktop_user(cmd), env=env,
|
|
3326
3475
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
3476
|
+
vu_stop = None
|
|
3327
3477
|
|
|
3328
3478
|
try:
|
|
3329
3479
|
if not wait_for_socket(MPV_SOCKET, proc):
|
|
@@ -3343,6 +3493,7 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3343
3493
|
send(ser, "PLAY_MODE:AUDIO_CD" if kind == "audio_cd" else "PLAY_MODE:DEFAULT")
|
|
3344
3494
|
send(ser, "PLAY:PLAYING")
|
|
3345
3495
|
print(f"{label}. Short press toggles pause; long press stops.")
|
|
3496
|
+
vu_stop, vu_pause = start_vu_visualizer(ser)
|
|
3346
3497
|
|
|
3347
3498
|
last_ping = time.time()
|
|
3348
3499
|
while proc.poll() is None:
|
|
@@ -3375,6 +3526,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3375
3526
|
paused = not paused
|
|
3376
3527
|
mpv_command(["set_property", "pause", paused])
|
|
3377
3528
|
mpv_command(["set_property", "speed", 1.0])
|
|
3529
|
+
if vu_pause:
|
|
3530
|
+
vu_pause.set() if paused else vu_pause.clear()
|
|
3378
3531
|
send(ser, "PLAY_STATUS:PAUSED" if paused else "PLAY_STATUS:PLAYING")
|
|
3379
3532
|
|
|
3380
3533
|
elif line in ("PLAY_STOP", "EJECT"):
|
|
@@ -3447,6 +3600,8 @@ def _run_mpv(ser, cmd, label, kind=None, track_titles=None, track_starts=None):
|
|
|
3447
3600
|
|
|
3448
3601
|
time.sleep(0.05)
|
|
3449
3602
|
finally:
|
|
3603
|
+
if vu_stop:
|
|
3604
|
+
vu_stop.set()
|
|
3450
3605
|
if proc.poll() is None:
|
|
3451
3606
|
discstation_burn.stop_process(proc)
|
|
3452
3607
|
try:
|
|
@@ -4558,7 +4713,7 @@ def main():
|
|
|
4558
4713
|
port = discstation_host.serial_port()
|
|
4559
4714
|
if port:
|
|
4560
4715
|
print(f"Using ESP32 serial port: {port}")
|
|
4561
|
-
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=
|
|
4716
|
+
ser = serial.Serial(port, discstation_burn.BAUD, timeout=1, write_timeout=2)
|
|
4562
4717
|
if discstation_host.system_name() == "linux":
|
|
4563
4718
|
ser.setDTR(False)
|
|
4564
4719
|
time.sleep(0.1)
|
package/src/discstation_burn.py
CHANGED
|
@@ -538,9 +538,23 @@ def send(ser, msg):
|
|
|
538
538
|
pass
|
|
539
539
|
if not ser:
|
|
540
540
|
return False
|
|
541
|
+
payload = (msg + '\n').encode()
|
|
541
542
|
try:
|
|
542
543
|
with SERIAL_WRITE_LOCK:
|
|
543
|
-
|
|
544
|
+
# pyserial's Serial.write() on Linux waits on select() for the fd
|
|
545
|
+
# to report writable before it calls the real write() - some
|
|
546
|
+
# USB-CDC-ACM devices (e.g. the ESP32-C6's native USB peripheral,
|
|
547
|
+
# unlike a CP2102/CH340 bridge chip) never signal that through
|
|
548
|
+
# select() even though a plain write succeeds instantly, so
|
|
549
|
+
# ser.write() times out forever on those. Write through the raw
|
|
550
|
+
# fd directly for a real serial port; other transports
|
|
551
|
+
# (TcpSerial/VirtualSerial) keep their own write().
|
|
552
|
+
fileno = getattr(ser, "fileno", None)
|
|
553
|
+
if isinstance(ser, serial.Serial) and fileno:
|
|
554
|
+
os.write(fileno(), payload)
|
|
555
|
+
else:
|
|
556
|
+
ser.write(payload)
|
|
557
|
+
return True
|
|
544
558
|
except (serial.SerialException, OSError) as e:
|
|
545
559
|
if not _serial_write_failed:
|
|
546
560
|
print(f"serial send error ('{msg[:30]}'): {e}")
|
|
@@ -549,7 +563,6 @@ def send(ser, msg):
|
|
|
549
563
|
except Exception as e:
|
|
550
564
|
print(f"serial send error ('{msg[:30]}'): {e}")
|
|
551
565
|
return False
|
|
552
|
-
return True
|
|
553
566
|
|
|
554
567
|
def safe_send(ser, msg):
|
|
555
568
|
if not ser:
|