Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ scripts/c64cast.sh -u tr:// clip.mp4 tune.sid # TeensyROM+ over auto-de
scripts/c64cast.sh -u u64://192.168.2.64 'https://youtu.be/...' # needs the `yt` extra
```

**Audio is on by default** (`AudioCfg.enabled` defaults True); `--no-audio` mutes. Flag groups (`-h` shows them grouped): `connection`, `quick playback`, `video input`, `audio`, `vision input`, `playlist`, `introspection`, `debug`.
**Audio is on by default** (`AudioCfg.enabled` defaults True); `--no-audio` mutes. On the U64, video audio defaults to the high-fidelity **Ultimate Audio FPGA PCM sampler** (`[audio].backend = "auto"` → sampler when available; see [sampler.py](c64cast/sampler.py)); `backend = "dac"` forces the lo-fi 4-bit `$D418` DAC (the only path on TeensyROM, and the path for mic/webcam audio everywhere). Flag groups (`-h` shows them grouped): `connection`, `quick playback`, `video input`, `audio`, `vision input`, `playlist`, `introspection`, `debug`.
Notable: `--config`, `-v` / `-vv` (info / debug logging), `--log-file PATH` (mirror logs to disk for headless runs). Terminal logging uses `rich.logging.RichHandler` (colored + timestamped) when the `logging` extra is installed; falls back to plain stdlib `StreamHandler` otherwise.

The DMA password (if the U64 has one set) is supplied via `C64CAST_DMA_PASSWORD` env var or `[ultimate64] dma_password` in the config — **no CLI flag**, so secrets don't leak into shell history or `ps` output. The env var takes precedence when both are set.
Expand Down Expand Up @@ -69,6 +69,9 @@ c64cast/
│ delta uploads; REST for read_memory, reset, run_prg
│ (BASIC clear loop + SID-player SYS stub), probe
├── audio.py AudioStreamer: NMI + SID DAC + ring buffer + sample tap
├── sampler.py UltimateAudioSampler: U64 "Ultimate Audio" FPGA PCM
│ sampler ($DF20) — hi-fi video audio from a streaming
│ REU ring, zero SID/$D418/NMI/CPU (off the C64 bus)
├── video.py WebcamSource (shared cv2 camera broker) + AVFileSource (PyAV)
├── vision.py VisionController: webcam hand-gestures → pause/skip/cycle
│ (MediaPipe HandLandmarker; sibling to keyboard.py)
Expand Down Expand Up @@ -195,6 +198,18 @@ Sample encoding can optionally apply **TPDF dither** (±1 LSB triangular, contro

**`position_seconds()`** is the audio-master clock: `(pushed - queued) / sample_rate`. The C64-side ring buffer adds ~1 s of constant latency past this, harmless for relative sync.

### `sampler.py` — UltimateAudioSampler (U64 "Ultimate Audio" FPGA PCM)

The U64 firmware exposes a 7-channel **FPGA PCM sampler** at `$DF20-$DFFF` ("Ultimate Audio", Gideon's register API v0.2). It plays 8/16-bit PCM up to 48 kHz **straight out of REU SDRAM with zero SID / `$D418` / NMI / CPU / turbo involvement** — so it's immune to the bus-halt / badline problems the 4-bit DAC fights, and is **vastly higher fidelity**. It's the **default video-audio backend on the U64** ([audio].backend = "auto"); the 4-bit `$D418` DAC stays for TeensyROM (no sampler) and as an opt-in lo-fi path. Mic/webcam audio always uses the DAC.

`sampler.py` has two halves. **Pure register helpers** (unit-testable): `divider_for_rate(rate)` = `round(6_250_000 / rate)`, `control_byte(...)` (gate b0 / repeat b1 / irq b2 / mode b4-5 [`00`=8-bit, `01`=16-bit LE]), `pack_pcm(int16, bits)` (signed 8-bit, or int16-LE), `channel_register_writes(...)` (the big-endian register byte layout: start `$01000000`+REU offset, length, rate divider, repeat A/B as **byte positions in the sample**), `program_channel`/`gate_off`. **`UltimateAudioSampler`** is the scene-facing object mirroring the slice of `AudioStreamer` scenes call (`sample_rate`, `position_seconds`, `push_samples`, `get_recent_samples`, `stop`, plus no-op `set_pre_emphasis`/`mark_eof`, `is_sampler=True`).

**Streaming REU ring**: program channel 0 as an A↔B loop over `[ring_base, ring_base+ring_size)` (base `$200000` — above the mic ring `$110000`, below video staging `$E00000`, so it coexists with REU-staged bitmap video; default 1 MiB). `start()` prefills the ring with NEUTRAL (silence) + a prebuffer of real PCM, then gates the loop on and records `gate_time`. A writer thread REUWRITEs decoded PCM **ahead of a wall-clock-computed read head** (`read = (monotonic - gate_time) * actual_rate * bps`, mod ring_size) — wrapping at the boundary, NEUTRAL-padding only past a low watermark (`_lead_panic`, a genuine producer underrun — not whenever the queue is briefly empty). **No servo/governor/NMI**: the FPGA sample clock is crystal-exact, so the read head is *computed*, never read back, and the loop is open-loop + drift-free. `sample_rate` is set to the FPGA's exact `REF/divider` (a <0.5% constant pitch offset from nominal, so A/V is drift-free; `AVFileSource` resamples to it). `position_seconds()` = `clamp(monotonic - gate_time, 0, total)` — same contract as the REU-pump branch, so `VideoScene._clock_s` works unchanged. HW-de-risked on .64 (gapless / correct pitch / no drift over 3 min) before integration.

`[audio].backend` ("auto"|"dac"|"sampler") is resolved per video scene in `config.build_scene` via `resolve_audio_backend(setting, *, supports_sampler, sampler_available)` (mirrors `resolve_use_reu_staged`): "auto" → sampler iff both true, else dac; explicit "sampler" warns + falls back to dac when unavailable. The sampler is constructed as the scene's audio object (a `VideoScene` drives it polymorphically — `setup()` branches on `isinstance(audio, UltimateAudioSampler)`). `sampler_sample_rate` (default 44100) + `sampler_bits` (default 16) are validated by `config.validate_sampler_cfg`. **fps:** because the sampler is off the C64 bus, sampler-audio bitmap video does **not** get the 4-bit DAC's 20 fps cap (`_frame_push_default_fps` is fed `has_digitized_audio=False`); it defaults to the muted half-rate (30/25) pending an HW fps A/B that may raise it.

**Provisioning** (`doctor.provision_sampler`/`restore_sampler`, gated on `profile.supports_sampler` + not `--skip-probe` + `_wants_sampler`): enables `Map Ultimate Audio $DF20-DFFF` if disabled and unmutes `Vol Sampler L`/`R` (to `" 0 dB"`) if OFF — live + volatile, restored at teardown (composite-keyed `SystemStack.sampler_restore`). Because the ring lives in REU SDRAM, `_wants_sampler` also pulls the REU into `_wants_reu`, so `provision_reu` enables the REU (16 MB) for a sampler run — which makes `"auto"` video resolve to the tear-free REU bank-swap path (the sampler installs no `$0314` IRQ, so REU-staged video and the sampler coexist with no IRQ contention). `doctor.sampler_is_available(api)` (map enabled + a channel audible) feeds `cli._resolve_sampler_available`, and `_probe_sampler_status` reports the state in `--doctor`.

### `video.py` — WebcamSource (shared broker) + AVFileSource (PyAV)

**`WebcamSource`** is an always-on shared camera broker. A single `cv2.VideoCapture` is single-consumer (every `.read()` consumes the next device frame; concurrent reads from two threads aren't safe), so one background grab thread owns the capture, continuously reads the newest frame, and `read()` hands out an independent **copy** of the latest frame. That lets the webcam scene (when active) and the always-on vision controller (`vision.py`) share **one** physical camera with no contention — and keeps the live-webcam path low-latency (always the freshest frame, stale ones overwritten). `WebcamScene._read_frame()` is unchanged — it still calls `source.read()`. The camera is opened once per stack in `cli.py` when `needs_webcam or cfg.vision.enabled`, stored on `SystemStack.source`, released at teardown.
Expand Down
22 changes: 21 additions & 1 deletion c64cast.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,29 @@
},
"sample_rate": {
"type": "integer",
"description": "Audio sample rate in Hz fed to the SID DAC. Default 10500 lifts the Nyquist to ~5.25 kHz so fricatives/sibilants survive (8000 lost them); HW-verified safe on NTSC + PAL with no NMI handler overrun. NTSC can go to ~11025; keep PAL <= ~10500. Rates that overrun the handler are rejected at load (see c64.nmi_rate_safety).",
"description": "Audio sample rate in Hz fed to the SID DAC. Default 10500 lifts the Nyquist to ~5.25 kHz so fricatives/sibilants survive (8000 lost them); HW-verified safe on NTSC + PAL with no NMI handler overrun. NTSC can go to ~11025; keep PAL <= ~10500. Rates that overrun the handler are rejected at load (see c64.nmi_rate_safety). Sampler-backend playback uses [audio].sampler_sample_rate instead.",
"default": 10500
},
"backend": {
"type": "string",
"description": "Video-audio backend: 'auto' (sampler on a capable U64, else DAC), 'dac' (4-bit $D418 NMI DAC, all backends, lo-fi), or 'sampler' (U64 'Ultimate Audio' FPGA PCM, high fidelity, off the C64 bus).",
"enum": [
"auto",
"dac",
"sampler"
],
"default": "auto"
},
"sampler_sample_rate": {
"type": "integer",
"description": "Sample rate (Hz) for the Ultimate Audio sampler backend. 1000..48000; default 44100 (CD quality). The FPGA plays at the nearest divider of its 6.25 MHz reference (a <0.5% constant pitch offset, drift-free).",
"default": 44100
},
"sampler_bits": {
"type": "integer",
"description": "PCM bit depth for the Ultimate Audio sampler backend: 8 (signed) or 16 (signed little-endian). Default 16.",
"default": 16
},
"mic_sensitivity": {
"type": "number",
"description": "Microphone input gain multiplier.",
Expand Down
3 changes: 3 additions & 0 deletions c64cast/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class HardwareProfile:
supports_run_prg: bool = True # launch a PRG (clear-loop, SID player)
supports_run_crt: bool = True # launch a CRT (cartridge)
supports_reu: bool = True # REU writes (use_reu_pump / use_reu_staged)
supports_sampler: bool = False # "Ultimate Audio" FPGA PCM sampler ($DF20)
reu_bus_clean: bool = False # REU writes don't perturb the C64 bus/SID
writes_are_acked: bool = False # each write returns an ack (=> flush ~free)
kernal_irq_intact: bool = True # the kernal IRQ chain runs at bring-up
Expand Down Expand Up @@ -140,6 +141,7 @@ class HardwareProfile:
supports_run_prg=True,
supports_run_crt=True,
supports_reu=True,
supports_sampler=True, # "Ultimate Audio" FPGA PCM sampler (gated by probe)
reu_bus_clean=True, # U64 REUWRITE is an ARM-side memcpy; no bus halt
writes_are_acked=False, # socket DMAWRITE is fire-and-forget
kernal_irq_intact=True,
Expand Down Expand Up @@ -169,6 +171,7 @@ class HardwareProfile:
supports_run_prg=True, # PostFile + LaunchFile
supports_run_crt=True, # RemoteLaunch handles CRT launch
supports_reu=False, # no REUWRITE opcode
supports_sampler=False, # no FPGA PCM sampler (Ultimate-only feature)
reu_bus_clean=False,
writes_are_acked=True, # every write returns Ack/Fail -> flush ~free
kernal_irq_intact=True,
Expand Down
69 changes: 68 additions & 1 deletion c64cast/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,54 @@ def _resolve_reu_available(cfg: cfgmod.Config, api: C64Backend) -> bool:
return False


def _resolve_sampler_available(cfg: cfgmod.Config, api: C64Backend) -> bool:
"""Decide whether the U64 "Ultimate Audio" FPGA PCM sampler should back
video-scene audio for this system, by asking the U64 whether it's exposed +
routed (mirrors `_resolve_reu_available`).

Returns False (→ the 4-bit DAC) unless [audio].backend is auto/sampler AND
the backend has the sampler (supports_sampler) AND a probe is allowed AND
the firmware reports it available. `provision_sampler` runs BEFORE this in
build_stack, so a box this run just enabled reads available. Any uncertainty
(forced dac, --skip-probe, no-sampler backend, failed query, mapped-off)
degrades to the DAC so audio is never silently silent."""
if cfg.audio.backend == "dac":
return False # forced DAC ignores the probe entirely
if not api.profile.supports_sampler:
return False # backend (e.g. TeensyROM) has no FPGA sampler
if cfg.debug.skip_probe:
log.info(
"[audio].backend = %s, but --skip-probe is set — using the 4-bit "
"DAC for video audio (sampler undetected).",
cfg.audio.backend,
)
return False
from . import doctor

avail = doctor.sampler_is_available(api)
if avail:
log.info(
"[audio].backend = %s: Ultimate Audio sampler available — "
"high-fidelity video audio (FPGA PCM, off the C64 bus).",
cfg.audio.backend,
)
return True
if avail is None:
log.warning(
"[audio].backend = %s: could not read the Ultimate Audio sampler "
"state — using the 4-bit DAC for video audio.",
cfg.audio.backend,
)
else:
log.info(
"[audio].backend = %s: Ultimate Audio sampler not available "
"(map disabled / mixer muted / firmware lacks it) — using the "
"4-bit DAC for video audio.",
cfg.audio.backend,
)
return False


def _coerce_reu_for_backend(cfg: cfgmod.Config, api: C64Backend) -> None:
"""Disable the REU-staged audio/video opt-ins when the backend has no REU.

Expand Down Expand Up @@ -627,6 +675,10 @@ def build_stack(
from . import doctor as _doctor

reu_restore = _doctor.provision_reu(api, cfg)
# Auto-enable the Ultimate Audio sampler (map $DF20 + unmute Sampler mixer,
# live + volatile) when a video scene will use it. Runs BEFORE
# _resolve_sampler_available so the probe sees it on; restored at teardown.
sampler_restore = _doctor.provision_sampler(api, cfg)

audio = (
AudioStreamer(
Expand All @@ -647,8 +699,15 @@ def build_stack(
)

reu_available = _resolve_reu_available(cfg, api)
sampler_available = _resolve_sampler_available(cfg, api)
playlist_scenes = cfgmod.scenes_from_config(
cfg, api, audio, source, is_ensemble=is_ensemble, reu_available=reu_available
cfg,
api,
audio,
source,
is_ensemble=is_ensemble,
reu_available=reu_available,
sampler_available=sampler_available,
)

# The system video rate (60 NTSC / 50 PAL) is resolved into the
Expand Down Expand Up @@ -781,6 +840,8 @@ def build_stack(
vision_controller=vision_controller,
reu_available=reu_available,
reu_restore=reu_restore,
sampler_available=sampler_available,
sampler_restore=sampler_restore,
framebuffer=framebuffer,
preview_window=preview_window,
recorder=recorder,
Expand All @@ -806,6 +867,8 @@ def teardown_stack(stack: SystemStack) -> None:
# Restore any REU config we auto-provisioned, while the REST session is
# still open (no-op when nothing was changed; volatile regardless).
("REU restore", lambda: _doctor.restore_reu(stack.api, stack.reu_restore)),
# Same for the Ultimate Audio sampler map/mixer auto-provisioning.
("sampler restore", lambda: _doctor.restore_sampler(stack.api, stack.sampler_restore)),
("U64 reset", stack.api.reset),
("API close", stack.api.close),
("camera release", lambda: stack.source.release() if stack.source else None),
Expand Down Expand Up @@ -1030,6 +1093,7 @@ def main(argv=None) -> int:
# target system (broken/pitch-dropped audio) before the playlist runs.
try:
cfgmod.validate_nmi_sample_rate(cfg)
cfgmod.validate_sampler_cfg(cfg)
except cfgmod.ConfigError as e:
log.error("%s", e)
return 5
Expand Down Expand Up @@ -1099,6 +1163,7 @@ def main(argv=None) -> int:
_st.source,
is_ensemble=True,
reu_available=_st.reu_available,
sampler_available=_st.sampler_available,
)
)

Expand Down Expand Up @@ -1132,6 +1197,7 @@ def _on_sighup(_signum, _frame):
st.source,
is_ensemble=loaded.is_ensemble,
reu_available=st.reu_available,
sampler_available=st.sampler_available,
)
new_factory = interstitial_factory(st.api, new_cfg.interstitial)
st.playlist.request_reload(new_scenes, new_factory)
Expand Down Expand Up @@ -1165,6 +1231,7 @@ def _on_sighup(_signum, _frame):
st.source,
is_ensemble=loaded.is_ensemble,
reu_available=st.reu_available,
sampler_available=st.sampler_available,
)
)
for st, p in zip(stacks, loaded.paths, strict=True)
Expand Down
Loading