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
11 changes: 9 additions & 2 deletions assets/sids/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,15 @@ visualization. PSID-only — RSIDs are refused (see
## Sources

- **HVSC** (High Voltage SID Collection): https://hvsc.c64.org/
— Tens of thousands of curated SIDs. Drop the `C64Music/` tree anywhere
and point your config at the individual files you want to play.
— Tens of thousands of curated SIDs. Unpack the `C64Music/` tree (or just
its contents) directly into this directory — `assets/sids/C64Music/` or
`assets/sids/` itself — and c64cast auto-detects the bundled
`DOCUMENTS/Songlengths.md5`, so `waveform` scenes get each tune's real
duration with no config needed (`[playlist].songlengths_file` overrides
the auto-detected path if you unpack HVSC somewhere else; set it to
`""` to disable auto-detection). Unpacking elsewhere still works for
playback — you'd just set `songlengths_file` explicitly to get true
durations.
- **CSDb releases**: https://csdb.dk/ — Often bundle a `.sid` alongside the
demo it was written for.

Expand Down
2 changes: 1 addition & 1 deletion c64cast.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@
"string",
"null"
],
"description": "Path to an HVSC Songlengths.md5 file; gives waveform scenes their true duration when duration_s is unset."
"description": "Path to an HVSC Songlengths.md5 file; gives waveform scenes their true duration when duration_s is unset. Left unset (the default), an unpacked HVSC under assets/sids/ (either the whole C64Music/ tree or just its contents) is auto-detected. Set to an empty string to disable auto-detection."
},
"loop": {
"type": "boolean",
Expand Down
59 changes: 55 additions & 4 deletions c64cast/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,10 @@ class PlaylistCfg:
default=None,
metadata={
"help": "Path to an HVSC Songlengths.md5 file; gives waveform scenes their "
"true duration when duration_s is unset."
"true duration when duration_s is unset. Left unset (the default), an "
"unpacked HVSC under assets/sids/ (either the whole C64Music/ tree or "
"just its contents) is auto-detected. Set to an empty string to disable "
"auto-detection."
},
)
# See CLAUDE.md 'Playlist loop control' for single- vs multi-scene behavior.
Expand Down Expand Up @@ -2523,12 +2526,60 @@ def _build_display_mode(


_songlengths_cache: dict[str, LengthsDB | None] = {}
_AUTODETECT_SONGLENGTHS_ROOT = "assets/sids"


class _Unset:
pass


_UNSET: Any = _Unset()
_songlengths_autodetected: str | None | Any = _UNSET


def _autodetect_songlengths_path(root: str = _AUTODETECT_SONGLENGTHS_ROOT) -> str | None:
"""Best-effort discovery of an unpacked HVSC's SongLengths.md5 under
``assets/sids`` (see assets/sids/README.md), for when
``[playlist].songlengths_file`` is left unset. Checks the two layouts an
HVSC unpack actually produces before falling back to a full scan for a
nonstandard placement. Memoized (including the "not found" result) since
an HVSC tree is tens of thousands of files."""
global _songlengths_autodetected
if _songlengths_autodetected is not _UNSET:
return _songlengths_autodetected
found: str | None = None
for candidate in (
os.path.join(root, "C64Music", "DOCUMENTS", "Songlengths.md5"),
os.path.join(root, "DOCUMENTS", "Songlengths.md5"),
):
if os.path.isfile(candidate):
found = candidate
break
else:
if os.path.isdir(root):
matches = sorted(
os.path.join(dirpath, name)
for dirpath, _dirnames, filenames in os.walk(root)
for name in filenames
if name.lower() == "songlengths.md5"
)
found = matches[0] if matches else None
_songlengths_autodetected = found
return found


def _load_songlengths(path: str | None) -> LengthsDB | None:
"""Memoized load of the HVSC SongLengths database. Returns None if no
path is configured or the file is missing/unreadable."""
if not path:
"""Memoized load of the HVSC SongLengths database. If ``path`` is unset
(None — the field's default), auto-detects an unpacked HVSC under
``assets/sids``; an explicit empty string opts out of auto-detection.
Returns None if no path is configured/detected or the file is
missing/unreadable."""
if path is None:
path = _autodetect_songlengths_path()
if path is None:
return None
log.info("playlist.songlengths_file not set; auto-detected HVSC database at %s", path)
elif not path:
return None
if path in _songlengths_cache:
return _songlengths_cache[path]
Expand Down
17 changes: 15 additions & 2 deletions c64cast/waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ class WaveformScene(VoiceScopeRenderer, Scene):
END_SILENCE_S = 6.0
ENV_SILENCE_EPS = 1e-3

# Used by _resolve_duration_for_current_sid when neither an explicit
# duration_s nor a songlengths DB match is available.
FALLBACK_DURATION_S = 180.0

# Host-emu PLAY pre-flight. After loading a tune we run this many PLAY
# passes; if EVERY one bails at the host emulator's cycle cap (instead
# of returning normally in the usual ~1-2k cycles), the tune spins on a
Expand Down Expand Up @@ -610,7 +614,7 @@ def _rebuild_scope_for_sids(self) -> None:

def _resolve_duration_for_current_sid(self) -> float:
"""Compute the playback duration for self.sid_bytes + self.song.
Order: explicit user duration_s > songlengths DB > 180s default."""
Order: explicit user duration_s > songlengths DB > fallback default."""
if self._explicit_duration_s is not None:
return float(self._explicit_duration_s)
if self.songlengths_db is not None:
Expand All @@ -623,7 +627,16 @@ def _resolve_duration_for_current_sid(self) -> float:
looked_up,
)
return float(looked_up)
return 180.0
# A DB is available but this tune/subtune isn't in it — worth a
# warning (unlike "no DB configured at all", which is the
# expected, silent state for anyone without HVSC's SongLengths.md5).
log.warning(
"waveform: %s #%d not found in songlengths DB — using %.0fs fallback duration",
os.path.basename(self._sid_file),
self.song,
self.FALLBACK_DURATION_S,
)
return self.FALLBACK_DURATION_S

# ---- bring-up / bring-down ---------------------------------------------

Expand Down
10 changes: 7 additions & 3 deletions config/c64cast.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,9 @@ fade_duration_s = 0.4 # fade each scene up from black on entry
# songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5" # optional;
# waveform scenes use this to auto-pick a
# duration when duration_s is omitted.
# Left unset, an HVSC unpacked under
# assets/sids/ is auto-detected — set to
# "" to disable auto-detection.

[preview]
enabled = false # requires the `preview` extra
Expand Down Expand Up @@ -930,8 +933,9 @@ palette_mode = "grayscale"
# Plays a .sid file on the U64 and visualises the three voices.
#
# Drop SIDs into assets/sids/ (HVSC is the canonical archive: hvsc.c64.org).
# Without the songlengths DB you must set duration_s; with the DB it's
# looked up automatically.
# Without a songlengths DB you must set duration_s; with one, the true
# duration is looked up automatically. Unpacking HVSC under assets/sids/
# gets you the DB for free (auto-detected — see assets/sids/README.md).
#
# [[scenes]]
# type = "waveform"
Expand All @@ -945,7 +949,7 @@ palette_mode = "grayscale"
# # file = "assets/sids/Tune1.sid, assets/sids/Tune2.sid"
# file = "assets/sids/your-tune.sid"
# song = 0 # 0 = SID's default; 1+ = subtune
# duration_s = 180.0 # omit if [playlist].songlengths_file is set
# duration_s = 180.0 # omit if a songlengths DB is set or auto-detected
# color_mode = "per_voice" # per_voice | per_waveform
# voice_colors = ["cyan", "yellow", "light green"]
# # target_fps = 30.0 # default = HALF system rate (30 NTSC / 25 PAL); see docs/caveats.md
Expand Down
6 changes: 4 additions & 2 deletions config/examples/scene-waveform.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ url = "http://ultimate-64-ii.lan"

[playlist]
interleave_videos = false
# Uncomment + point at the HVSC SongLengths.md5 to auto-pick a duration:
# Auto-detected if you unpacked HVSC under assets/sids/ (whole C64Music/
# tree or just its contents) — no need to set this explicitly unless your
# SongLengths.md5 lives elsewhere:
# songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5"

[[scenes]]
Expand All @@ -30,7 +32,7 @@ type = "waveform"
# Omit the field entirely to fall back to scanning assets/sids/.
file = "assets/sids/your-tune.sid"
song = 0 # 0 = SID's default; 1+ selects a subtune
duration_s = 180.0 # only used if songlengths_file isn't set
duration_s = 180.0 # explicit value wins; comment out to use songlengths_file instead
color_mode = "per_voice" # per_voice | per_waveform
voice_colors = ["cyan", "yellow", "light green"]
# --- Visualization knobs (all compose). Uncomment to try. ---
Expand Down
16 changes: 10 additions & 6 deletions docs/caveats.md
Original file line number Diff line number Diff line change
Expand Up @@ -462,19 +462,23 @@ sustainable rate at the lower end of that range.
## `WaveformScene` duration

The U64's sidplay endpoint doesn't tell us when a tune ends, and the
SID file itself doesn't carry song-length data. Two ways to know how
long to play a track:
SID file itself doesn't carry song-length data. Ways to know how long
to play a track:

1. Set `duration_s = N` in the scene config (overrides any DB lookup).
2. Configure `[playlist] songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5"`
and leave `duration_s` at the default. The HVSC SongLengths DB is
keyed by an MD5 of the SID **data payload** (not the header) and
covers most HVSC tunes — the file ships inside a full HVSC unpack at
`C64Music/DOCUMENTS/Songlengths.md5`.

If neither applies, the waveform scene defaults to 180 s — usually wrong
for a specific tune. Pick one of the two options above for SID jukebox
setups.
3. Do nothing: if you unpacked HVSC under `assets/sids/` (either the
whole `C64Music/` tree or just its contents), `songlengths_file` is
auto-detected — see `assets/sids/README.md`. This is what quick
playback (positional `MEDIA` args, no `--config`) relies on, since it
has no `[playlist]` section to set the field in.

If none of these apply, the waveform scene defaults to 180 s — usually
wrong for a specific tune.

## `WaveformScene` defaults to half the video rate (DMA ceiling)

Expand Down
7 changes: 5 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,15 @@ it, or play a local copy of the file.

### "`waveform` scene plays for 180 s and stops, but the tune is longer"

Default duration is 180 s when no SongLengths DB is configured. Two
fixes:
Default duration is 180 s when no SongLengths DB is configured or
auto-detected. Fixes:

1. Set `duration_s = <seconds>` on the scene explicitly.
2. Configure `[playlist] songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5"`
and the loader will look up the tune's real length.
3. Unpack HVSC under `assets/sids/` — the loader auto-detects
`Songlengths.md5` there with no config needed (this is what quick
playback, which has no `[playlist]` section, relies on).

See [caveats.md → "WaveformScene duration"](caveats.md#waveformscene-duration).

Expand Down
3 changes: 2 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,7 +423,8 @@ background = "random" # starfield|petscii_bars|raster_bars|checker
videos_dir = "assets/videos"
interleave_videos = true
loop = true # false = exit after one pass
songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5" # optional, for waveform scenes
songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5" # optional, for waveform scenes;
# auto-detected under assets/sids/ if unset
```

When `interleave_videos` is true and `videos_dir` contains any video files (and
Expand Down
86 changes: 82 additions & 4 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,12 +390,36 @@ def test_no_position_available(self):

class LoadSonglengthsTest(unittest.TestCase):
def setUp(self):
# Memoization cache is module-global — clear it between tests.
# Memoization caches are module-global — clear between tests.
cfgmod._songlengths_cache.clear()
self._orig_autodetected = cfgmod._songlengths_autodetected
cfgmod._songlengths_autodetected = cfgmod._UNSET

def test_none_path_returns_none(self):
self.assertIsNone(cfgmod._load_songlengths(None))
self.assertIsNone(cfgmod._load_songlengths(""))
def tearDown(self):
cfgmod._songlengths_autodetected = self._orig_autodetected

def test_empty_string_disables_autodetect(self):
# Explicit "" opts out — unlike None, it never probes assets/sids/.
with mock.patch.object(cfgmod, "_autodetect_songlengths_path") as auto:
self.assertIsNone(cfgmod._load_songlengths(""))
auto.assert_not_called()

def test_none_path_autodetects_when_present(self):
with tempfile.NamedTemporaryFile("w", suffix=".md5", delete=False) as f:
f.write("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=1:23\n")
path = f.name
try:
with mock.patch.object(cfgmod, "_autodetect_songlengths_path", return_value=path):
with self.assertLogs("c64cast.config", level="INFO") as logs:
db = cfgmod._load_songlengths(None)
self.assertIsNotNone(db)
self.assertTrue(any("auto-detected" in m for m in logs.output))
finally:
os.unlink(path)

def test_none_path_returns_none_when_nothing_detected(self):
with mock.patch.object(cfgmod, "_autodetect_songlengths_path", return_value=None):
self.assertIsNone(cfgmod._load_songlengths(None))

def test_missing_file_warns_and_caches_none(self):
with self.assertLogs("c64cast.config", level="WARNING"):
Expand All @@ -417,6 +441,60 @@ def test_loads_and_memoizes_real_db(self):
os.unlink(path)


class AutodetectSonglengthsTest(unittest.TestCase):
def setUp(self):
self._orig_autodetected = cfgmod._songlengths_autodetected
cfgmod._songlengths_autodetected = cfgmod._UNSET

def tearDown(self):
cfgmod._songlengths_autodetected = self._orig_autodetected

def test_no_root_dir_returns_none(self):
with tempfile.TemporaryDirectory() as tmp:
missing = os.path.join(tmp, "assets", "sids")
self.assertIsNone(cfgmod._autodetect_songlengths_path(missing))

def test_finds_full_hvsc_tree_layout(self):
with tempfile.TemporaryDirectory() as tmp:
docs = os.path.join(tmp, "C64Music", "DOCUMENTS")
os.makedirs(docs)
expected = os.path.join(docs, "Songlengths.md5")
open(expected, "w").close()
self.assertEqual(cfgmod._autodetect_songlengths_path(tmp), expected)

def test_finds_contents_only_layout(self):
with tempfile.TemporaryDirectory() as tmp:
docs = os.path.join(tmp, "DOCUMENTS")
os.makedirs(docs)
expected = os.path.join(docs, "Songlengths.md5")
open(expected, "w").close()
self.assertEqual(cfgmod._autodetect_songlengths_path(tmp), expected)

def test_falls_back_to_full_scan_for_nonstandard_layout(self):
with tempfile.TemporaryDirectory() as tmp:
odd = os.path.join(tmp, "somewhere", "else")
os.makedirs(odd)
expected = os.path.join(odd, "Songlengths.md5")
open(expected, "w").close()
self.assertEqual(cfgmod._autodetect_songlengths_path(tmp), expected)

def test_no_match_returns_none(self):
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(os.path.join(tmp, "MUSICIANS"))
self.assertIsNone(cfgmod._autodetect_songlengths_path(tmp))

def test_result_is_memoized(self):
with tempfile.TemporaryDirectory() as tmp:
docs = os.path.join(tmp, "DOCUMENTS")
os.makedirs(docs)
open(os.path.join(docs, "Songlengths.md5"), "w").close()
first = cfgmod._autodetect_songlengths_path(tmp)
# A second call with a different (nonexistent) root still
# returns the memoized first result — proves it isn't re-probed.
second = cfgmod._autodetect_songlengths_path(os.path.join(tmp, "nope"))
self.assertEqual(first, second)


class MergeCLITest(unittest.TestCase):
def _make_args(self, **kw) -> argparse.Namespace:
# Every overridable option defaults to None; only set what's passed.
Expand Down
30 changes: 29 additions & 1 deletion tests/test_waveform.py
Original file line number Diff line number Diff line change
Expand Up @@ -1184,6 +1184,31 @@ def test_cycle_style_relookups_songlengths(self):
finally:
scene.teardown()

def test_no_db_uses_fallback_duration_silently(self):
# No songlengths DB at all is the expected, common state (no HVSC
# unpacked) — must NOT warn, just fall back quietly.
from c64cast.waveform import WaveformScene

api = FakeAPI()
with self.assertNoLogs("c64cast.waveform", level="WARNING"):
scene = WaveformScene(api, audio=None, file=self.sid_path, song=1)
self.assertAlmostEqual(scene.duration_s, WaveformScene.FALLBACK_DURATION_S)

def test_db_miss_warns_and_uses_fallback_duration(self):
# A DB is configured/detected but has no entry for this tune — that's
# the surprising case worth flagging, unlike having no DB at all.
from c64cast.waveform import WaveformScene

api = FakeAPI()
fake_db = MagicMock()
fake_db.lookup.return_value = None
with self.assertLogs("c64cast.waveform", level="WARNING") as cap:
scene = WaveformScene(
api, audio=None, file=self.sid_path, song=1, songlengths_db=fake_db
)
self.assertAlmostEqual(scene.duration_s, WaveformScene.FALLBACK_DURATION_S)
self.assertTrue(any("not found in songlengths DB" in m for m in cap.output))

def test_cycle_style_explicit_duration_survives_cycle(self):
# A user-set duration_s must NOT be overwritten by a re-lookup —
# explicit user intent wins, same as __init__'s precedence.
Expand Down Expand Up @@ -1279,7 +1304,10 @@ def test_cycle_style_no_skip_on_db_miss(self):
api = FakeAPI()
fake_db = MagicMock()
fake_db.lookup.side_effect = [None, None] # init + cycle, both miss
scene = WaveformScene(api, audio=None, file=self.sid_path, song=1, songlengths_db=fake_db)
with self.assertLogs("c64cast.waveform", level="WARNING"):
scene = WaveformScene(
api, audio=None, file=self.sid_path, song=1, songlengths_db=fake_db
)
scene.setup()
try:
scene.cycle_style(api)
Expand Down