From b3f8fa1b3273e074845e1daf5d5503d35dd29983 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 7 Jul 2026 22:04:32 -0500 Subject: [PATCH 1/3] Auto-detect HVSC Songlengths.md5 under assets/sids/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit playlist.songlengths_file previously had to be set explicitly, so quick playback (positional MEDIA args, no [playlist] section to set it in) always fell back to the 180s default duration even with a full HVSC unpacked locally. _load_songlengths now treats an unset (None) path as "probe assets/sids/ for a Songlengths.md5" — checking the two layouts an HVSC unpack actually produces before falling back to a full scan — and only returns None outright for an explicit empty string. Memoized like the existing per-path DB cache, since walking a full HVSC tree isn't free. --- assets/sids/README.md | 11 +++- c64cast.schema.json | 2 +- c64cast/config.py | 59 ++++++++++++++++++-- config/c64cast.example.toml | 10 +++- config/examples/scene-waveform.toml | 6 +- docs/caveats.md | 16 ++++-- docs/troubleshooting.md | 7 ++- docs/usage.md | 3 +- tests/test_config.py | 86 +++++++++++++++++++++++++++-- 9 files changed, 175 insertions(+), 25 deletions(-) diff --git a/assets/sids/README.md b/assets/sids/README.md index ca2c47d..122f5c2 100644 --- a/assets/sids/README.md +++ b/assets/sids/README.md @@ -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. diff --git a/c64cast.schema.json b/c64cast.schema.json index 31e0015..899327a 100644 --- a/c64cast.schema.json +++ b/c64cast.schema.json @@ -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", diff --git a/c64cast/config.py b/c64cast/config.py index e93d212..37aecd2 100644 --- a/c64cast/config.py +++ b/c64cast/config.py @@ -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. @@ -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] diff --git a/config/c64cast.example.toml b/config/c64cast.example.toml index 6f2538f..d81a51a 100644 --- a/config/c64cast.example.toml +++ b/config/c64cast.example.toml @@ -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 @@ -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" @@ -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 diff --git a/config/examples/scene-waveform.toml b/config/examples/scene-waveform.toml index 070e19d..e3c48f6 100644 --- a/config/examples/scene-waveform.toml +++ b/config/examples/scene-waveform.toml @@ -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]] @@ -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. --- diff --git a/docs/caveats.md b/docs/caveats.md index 6bc600e..69c8068 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -462,8 +462,8 @@ 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"` @@ -471,10 +471,14 @@ long to play a track: 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) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0e8fba3..f45976e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 = ` 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). diff --git a/docs/usage.md b/docs/usage.md index dd6e18c..75cd850 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index a96561b..f4840e8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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"): @@ -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. From 4dbadad98987f11ae8aed9a12d3a262f91c4c56f Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 7 Jul 2026 22:14:36 -0500 Subject: [PATCH 2/3] Warn when a waveform scene falls back to the default duration Only warns for the specific case worth flagging: a songlengths DB is configured/detected but has no entry for this tune, so a fabricated 180s is used instead of the tune's real length. Having no DB at all stays silent, since that's the expected common state for anyone without HVSC's SongLengths.md5 unpacked. --- c64cast/waveform.py | 17 +++++++++++++++-- tests/test_waveform.py | 26 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/c64cast/waveform.py b/c64cast/waveform.py index 52d3776..eeeedcc 100644 --- a/c64cast/waveform.py +++ b/c64cast/waveform.py @@ -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 @@ -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: @@ -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 --------------------------------------------- diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 9bf38d6..1a0e2d7 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -1184,6 +1184,29 @@ 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. @@ -1279,7 +1302,8 @@ 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) From 91e6aa234143b98b21d97322048e0550f5da1a98 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Tue, 7 Jul 2026 22:20:39 -0500 Subject: [PATCH 3/3] Apply ruff format to test_waveform.py Wraps two lines that exceeded the line-length limit; pre-commit wasn't installed locally when they were added in the prior commit. --- tests/test_waveform.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_waveform.py b/tests/test_waveform.py index 1a0e2d7..8c12e28 100644 --- a/tests/test_waveform.py +++ b/tests/test_waveform.py @@ -1203,7 +1203,9 @@ def test_db_miss_warns_and_uses_fallback_duration(self): 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) + 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)) @@ -1303,7 +1305,9 @@ def test_cycle_style_no_skip_on_db_miss(self): fake_db = MagicMock() fake_db.lookup.side_effect = [None, None] # init + cycle, both miss with self.assertLogs("c64cast.waveform", level="WARNING"): - scene = WaveformScene(api, audio=None, file=self.sid_path, song=1, songlengths_db=fake_db) + scene = WaveformScene( + api, audio=None, file=self.sid_path, song=1, songlengths_db=fake_db + ) scene.setup() try: scene.cycle_style(api)