From ea3d0281dd0ba7c1c5e62e8519273c8b4c1cd3fe Mon Sep 17 00:00:00 2001 From: Ed Powell Date: Wed, 29 Jul 2026 10:38:53 -0500 Subject: [PATCH 1/4] Add ld-find-start pre-roll detector --- README.md | 20 ++ cmake_modules/LdDecodeTests.cmake | 1 + ld-find-start | 8 + lddecode/start_finder.py | 569 ++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_start_finder.py | 237 +++++++++++++ 6 files changed, 836 insertions(+) create mode 100755 ld-find-start create mode 100644 lddecode/start_finder.py create mode 100644 tests/test_start_finder.py diff --git a/README.md b/README.md index 8b7507322..1021243fa 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,26 @@ For installation instructions after building, see **[INSTALL.md](INSTALL.md)** w > > Please see [Decode-Orc](https://github.com/simoninns/decode-orc) for details of how to obtain and install the Decode-Orc tools +## Finding the start of a capture + +Use `ld-find-start` when a capture begins while the player is paused or is +seeking back to the disc start. It scans RF and Philips CAV/CLV codes without +creating TBC, audio, JSON, or database output. A confirmed result is emitted +as the capture-relative file-frame argument accepted by `ld-decode --start`: + +```sh +ld-decode input.ldf output $(ld-find-start input.ldf) +``` + +The finder requires a sustained, normal-speed CAV or CLV address run, then +replays the preceding clean field sequence to retain player lead-in/pre-roll +while excluding the preceding paused or seeking material. It searches five +minutes by default; use `--max-search 0` to search to EOF. The pre-roll replay +window defaults to five seconds and can be adjusted with `--pre-roll-search`. +For a capture with stable video but no usable VBI address, it prints a guarded +candidate only on stderr and exits with status 2. Inspect that candidate and +pass its `--start` value explicitly if appropriate. + # Want to get involved? The documentation includes details of the ld-decode community's Discord / IRC Bridge and the now legacy Facebook group. diff --git a/cmake_modules/LdDecodeTests.cmake b/cmake_modules/LdDecodeTests.cmake index 31773939b..3bc4560c6 100644 --- a/cmake_modules/LdDecodeTests.cmake +++ b/cmake_modules/LdDecodeTests.cmake @@ -7,6 +7,7 @@ set(SCRIPTS_DIR ${CMAKE_SOURCE_DIR}/scripts) set(TESTDATA_DIR ${CMAKE_SOURCE_DIR}/testdata) +file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/testout) # Test that ld-decode can decode NTSC files and produce TBC output add_test( diff --git a/ld-find-start b/ld-find-start new file mode 100755 index 000000000..534100b79 --- /dev/null +++ b/ld-find-start @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +import sys + +from lddecode.start_finder import main + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/lddecode/start_finder.py b/lddecode/start_finder.py new file mode 100644 index 000000000..0b7d0f948 --- /dev/null +++ b/lddecode/start_finder.py @@ -0,0 +1,569 @@ +"""Find the clean pre-roll before programme playback in an LD RF capture. + +The normal decoder's ``--start`` argument is a capture-relative file-frame +number. This module finds that number without writing any decode output. It +uses the existing field and Philips-code decoders, so it deliberately avoids +trying to infer programme material from RF amplitude alone. +""" + +from __future__ import print_function + +import argparse +import sys +import warnings +from collections import namedtuple + +from lddecode.core import LDdecode +from lddecode.utils import make_loader, parse_frequency + + +RF_SAMPLE_RATE = 40000000 +DEFAULT_MAX_SEARCH_SECONDS = 300.0 +DEFAULT_FALLBACK_SECONDS = 10.0 +DEFAULT_PRE_ROLL_SEARCH_SECONDS = 5.0 +# A five-frame run can occur while a player is still settling or seeking. One +# second of clean, normal-speed addresses is long enough to reject that case. +REQUIRED_VBI_FRAMES = 30 + +FrameObservation = namedtuple( + "FrameObservation", "file_frame readloc address disk_type" +) +"""A complete decoded video frame and its capture-relative location.""" + + +StartResult = namedtuple( + "StartResult", + "file_frame readloc confidence disk_type addresses searched_seconds", +) +"""Result from :func:`find_start`; confidence is ``vbi`` or ``fallback``.""" + + +def file_frame_from_readloc(readloc, bytes_per_field): + """Convert a field read location to the number consumed by ``--start``.""" + + if bytes_per_field <= 0: + raise ValueError("bytes_per_field must be positive") + return int(readloc) // (int(bytes_per_field) * 2) + + +class VbiRunDetector: + """Recognise contiguous, normal-speed CAV or CLV address runs.""" + + def __init__(self, required_frames=REQUIRED_VBI_FRAMES): + if required_frames < 1: + raise ValueError("required_frames must be positive") + self.required_frames = required_frames + self.run = [] + + def reset(self): + self.run = [] + + def observe(self, observation): + """Add an observation and return the first run member when qualified.""" + + if observation is None or observation.address is None: + self.reset() + return None + + if self.run: + previous = self.run[-1] + contiguous = ( + observation.disk_type == previous.disk_type + and observation.address == previous.address + 1 + ) + if not contiguous: + self.reset() + + self.run.append(observation) + if len(self.run) >= self.required_frames: + return self.run[0] + return None + + +class _StableVideoTracker: + """Track a continuous valid-field run for guarded VBI-less fallback.""" + + def __init__(self): + self.start_readloc = None + self.last_readloc = None + + def reset(self): + self.start_readloc = None + self.last_readloc = None + + def observe(self, field, bytes_per_field): + readloc = int(field.readloc) + discontinuity = ( + self.last_readloc is None + or readloc <= self.last_readloc + or readloc - self.last_readloc > int(bytes_per_field) * 2 + ) + if discontinuity: + self.start_readloc = readloc + self.last_readloc = readloc + return self.start_readloc + + +class _PreRollTracker: + """Find the earliest clean field sequence immediately before content.""" + + def __init__(self, bytes_per_field, field_phases): + self.bytes_per_field = int(bytes_per_field) + self.field_phases = int(field_phases) + self.start_field = None + self.previous_field = None + + def reset(self): + self.start_field = None + self.previous_field = None + + def _is_continuous(self, field): + previous = self.previous_field + if previous is None: + return False + + distance = int(field.readloc) - int(previous.readloc) + if distance < (self.bytes_per_field // 2): + return False + if distance > (self.bytes_per_field + (self.bytes_per_field // 2)): + return False + + previous_phase = getattr(previous, "fieldPhaseID", None) + current_phase = getattr(field, "fieldPhaseID", None) + if previous_phase is None or current_phase is None: + return bool(previous.isFirstField) != bool(field.isFirstField) + + expected_phase = 1 if previous_phase == self.field_phases else previous_phase + 1 + return current_phase == expected_phase + + def observe(self, field): + if not self._is_continuous(field): + self.start_field = field if field.isFirstField else None + elif self.start_field is None and field.isFirstField: + self.start_field = field + self.previous_field = field + + +class _FinderLogger: + """Suppress expected bad-RF diagnostics unless verbose output is wanted.""" + + def __init__(self, report, verbose): + self.report = report + self.verbose = verbose + + def _log(self, message, *args): + if not self.verbose or self.report is None: + return + if args: + message = message % args + self.report("decoder: " + str(message)) + + debug = _log + info = _log + warning = _log + error = _log + + def status(self, message): + self._log(message) + + +def _make_decoder(filename, system, inputfreq, report, verbose): + loader = make_loader(filename, inputfreq) + return LDdecode( + filename, + None, + loader, + _FinderLogger(report, verbose), + analog_audio=0, + digital_audio=False, + system=system, + doDOD=False, + threads=0, + ) + + +class StartFinder: + """Run the lightweight field/VBI scan used by the command-line tool.""" + + def __init__( + self, + decoder_factory, + max_search_seconds=DEFAULT_MAX_SEARCH_SECONDS, + fallback_seconds=DEFAULT_FALLBACK_SECONDS, + required_frames=REQUIRED_VBI_FRAMES, + sample_rate=RF_SAMPLE_RATE, + pre_roll_search_seconds=DEFAULT_PRE_ROLL_SEARCH_SECONDS, + report=None, + ): + if max_search_seconds < 0: + raise ValueError("max_search_seconds must not be negative") + if fallback_seconds < 0: + raise ValueError("fallback_seconds must not be negative") + if pre_roll_search_seconds < 0: + raise ValueError("pre_roll_search_seconds must not be negative") + if sample_rate <= 0: + raise ValueError("sample_rate must be positive") + + self.decoder_factory = decoder_factory + self.max_search_seconds = max_search_seconds + self.fallback_seconds = fallback_seconds + self.sample_rate = sample_rate + self.pre_roll_search_seconds = pre_roll_search_seconds + self.report = report + self.detector = VbiRunDetector(required_frames) + + def _report_progress(self, position, last_bucket): + seconds = int(position // self.sample_rate) + bucket = seconds // 10 + if self.report is not None and seconds and bucket > last_bucket: + self.report("scanned {0:02d}:{1:02d}".format(seconds // 60, seconds % 60)) + return bucket + + def _find_pre_roll_start(self, stable_observation, bytes_per_field, field_phases): + """Replay the preceding clean field run and return its first frame.""" + + if not self.pre_roll_search_seconds: + return stable_observation + + lookback_samples = int(self.pre_roll_search_seconds * self.sample_rate) + position = max(0, stable_observation.readloc - lookback_samples) + tracker = _PreRollTracker(bytes_per_field, field_phases) + previous_field = None + decoder = None + + try: + decoder = self.decoder_factory() + while position <= stable_observation.readloc: + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + field, offset = decoder.decodefield(position, 0, previous_field) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as error: + if self.report is not None: + self.report( + "pre-roll decode failure at sample {0}: {1}; " + "skipping one second".format(position, error) + ) + decoder.close() + decoder = self.decoder_factory() + tracker.reset() + previous_field = None + position += self.sample_rate + continue + + if field is None or offset is None: + break + + try: + offset = int(offset) + except (TypeError, ValueError): + offset = 0 + if offset <= 0: + offset = self.sample_rate + + if not field.valid: + tracker.reset() + previous_field = None + elif int(field.readloc) <= stable_observation.readloc: + tracker.observe(field) + previous_field = field + + if int(getattr(field, "readloc", position)) >= stable_observation.readloc: + break + position += offset + finally: + if decoder is not None: + decoder.close() + + if tracker.start_field is None: + return stable_observation + + return FrameObservation( + file_frame_from_readloc(tracker.start_field.readloc, bytes_per_field), + int(tracker.start_field.readloc), + stable_observation.address, + stable_observation.disk_type, + ) + + def search(self): + """Return a :class:`StartResult`, or ``None`` when no start is found.""" + + max_samples = None + if self.max_search_seconds: + max_samples = int(self.max_search_seconds * self.sample_rate) + fallback_samples = int(self.fallback_seconds * self.sample_rate) + + decoder = None + position = 0 + previous_field = None + first_field = None + stable_video = _StableVideoTracker() + last_progress_bucket = -1 + + try: + decoder = self.decoder_factory() + while max_samples is None or position <= max_samples: + last_progress_bucket = self._report_progress( + position, last_progress_bucket + ) + try: + # Bad tracking windows can make the RF calculations emit NumPy + # RuntimeWarnings. They are expected probe failures, not a + # diagnostic for the caller. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + field, offset = decoder.decodefield( + position, 0, previous_field + ) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as error: + if self.report is not None: + self.report( + "decode failure at sample {0}: {1}; skipping one second".format( + position, error + ) + ) + decoder.close() + decoder = None + decoder = self.decoder_factory() + position += self.sample_rate + previous_field = None + first_field = None + stable_video.reset() + self.detector.reset() + continue + + if field is None or offset is None: + return None + + try: + offset = int(offset) + except (TypeError, ValueError): + offset = 0 + if offset <= 0: + offset = self.sample_rate + next_position = position + offset + + if not field.valid: + previous_field = None + first_field = None + stable_video.reset() + self.detector.reset() + # Before content locks, testing every nominal field makes a + # tracking-back capture unnecessarily slow. A one-second + # stride still exercises a different field phase on each + # probe and is the same recovery interval used for errors. + position += self.sample_rate + continue + + stable_start = stable_video.observe(field, decoder.bytes_per_field) + + if field.isFirstField: + if first_field is not None: + self.detector.reset() + first_field = field + elif first_field is None: + self.detector.reset() + else: + address = decoder.decodeFrameNumber(first_field, field) + disk_type = "CLV" if decoder.isCLV else "CAV" + observation = FrameObservation( + file_frame_from_readloc( + first_field.readloc, decoder.bytes_per_field + ), + int(first_field.readloc), + None if address is None else int(address), + disk_type, + ) + qualified = self.detector.observe(observation) + first_field = None + if qualified is not None: + pre_roll = self._find_pre_roll_start( + qualified, + decoder.bytes_per_field, + decoder.rf.SysParams["fieldPhases"], + ) + return StartResult( + pre_roll.file_frame, + pre_roll.readloc, + "vbi", + qualified.disk_type, + tuple(item.address for item in self.detector.run), + float(position) / self.sample_rate, + ) + + if ( + fallback_samples + and int(field.readloc) - stable_start >= fallback_samples + ): + return StartResult( + file_frame_from_readloc( + stable_start, decoder.bytes_per_field + ), + stable_start, + "fallback", + None, + tuple(), + float(position) / self.sample_rate, + ) + + previous_field = field + position = next_position + finally: + if decoder is not None: + decoder.close() + + return None + + +def find_start( + filename, + system="NTSC", + inputfreq=None, + max_search_seconds=DEFAULT_MAX_SEARCH_SECONDS, + fallback_seconds=DEFAULT_FALLBACK_SECONDS, + required_frames=REQUIRED_VBI_FRAMES, + pre_roll_search_seconds=DEFAULT_PRE_ROLL_SEARCH_SECONDS, + report=None, + verbose=False, +): + """Find clean pre-roll before programme playback without writing output.""" + + def decoder_factory(): + return _make_decoder(filename, system, inputfreq, report, verbose) + + finder = StartFinder( + decoder_factory, + max_search_seconds=max_search_seconds, + fallback_seconds=fallback_seconds, + required_frames=required_frames, + pre_roll_search_seconds=pre_roll_search_seconds, + report=report, + ) + return finder.search() + + +def _nonnegative_float(value): + parsed = float(value) + if parsed < 0: + raise argparse.ArgumentTypeError("must be non-negative") + return parsed + + +def _build_parser(): + from lddecode import __version__ + + parser = argparse.ArgumentParser( + description=( + "Find clean pre-roll before programme playback in a LaserDisc RF capture. " + "A confirmed result is written as an ld-decode --start argument." + ) + ) + parser.add_argument("infile", metavar="infile", help="source RF capture") + parser.add_argument( + "--max-search", + metavar="seconds", + type=_nonnegative_float, + default=DEFAULT_MAX_SEARCH_SECONDS, + help="maximum source seconds to search; 0 searches to EOF (default: 300)", + ) + parser.add_argument( + "--pre-roll-search", + metavar="seconds", + type=_nonnegative_float, + default=DEFAULT_PRE_ROLL_SEARCH_SECONDS, + help="seconds to replay before content to retain clean pre-roll (default: 5)", + ) + parser.add_argument( + "--PAL", "-p", "--pal", dest="pal", action="store_true", help="source is PAL" + ) + parser.add_argument( + "--NTSC", "-n", "--ntsc", dest="ntsc", action="store_true", help="source is NTSC" + ) + parser.add_argument( + "--NTSCJ", "-j", dest="ntscj", action="store_true", help="source is NTSC-J" + ) + parser.add_argument( + "-f", + "--frequency", + dest="inputfreq", + metavar="FREQ", + type=parse_frequency, + default=None, + help="RF sampling frequency in the source (default: 40MHz)", + ) + parser.add_argument( + "--verbose", action="store_true", help="include decoder diagnostics on stderr" + ) + parser.add_argument("--version", action="version", version=__version__) + return parser + + +def main(args=None): + """Command-line entry point. Return a shell-friendly result status.""" + + parser = _build_parser() + parsed = parser.parse_args(args) + if parsed.pal and (parsed.ntsc or parsed.ntscj): + parser.error("can only be PAL or NTSC") + if parsed.ntsc and parsed.ntscj: + parser.error("can only be NTSC or NTSC-J") + + system = "PAL" if parsed.pal else "NTSC" + + def report(message): + print("ld-find-start: {0}".format(message), file=sys.stderr) + + try: + result = find_start( + parsed.infile, + system=system, + inputfreq=parsed.inputfreq, + max_search_seconds=parsed.max_search, + pre_roll_search_seconds=parsed.pre_roll_search, + report=report, + verbose=parsed.verbose, + ) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as error: + print("ld-find-start: ERROR: {0}".format(error), file=sys.stderr) + return 1 + + if result is None: + print( + "ld-find-start: no qualifying programme start found within the search limit", + file=sys.stderr, + ) + return 1 + + argument = "--start {0}".format(result.file_frame) + if result.confidence == "vbi": + print(argument) + print( + "ld-find-start: found {0} run at file frame {1} " + "(sample {2}, searched {3:.2f}s)".format( + result.disk_type, + result.file_frame, + result.readloc, + result.searched_seconds, + ), + file=sys.stderr, + ) + return 0 + + print( + "ld-find-start: WARNING: no advancing CAV/CLV VBI run; " + "guarded stable-video candidate is {0} (sample {1})".format( + argument, result.readloc + ), + file=sys.stderr, + ) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 89deff43c..eba5a249a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,7 @@ Issues = "https://github.com/happycube/ld-decode/issues" [project.scripts] ld-decode = "lddecode.main:main" ld-ldf-reader-py = "lddecode.ldf_reader:main" +ld-find-start = "lddecode.start_finder:main" [tool.setuptools] packages = ["lddecode"] diff --git a/tests/test_start_finder.py b/tests/test_start_finder.py new file mode 100644 index 000000000..076173332 --- /dev/null +++ b/tests/test_start_finder.py @@ -0,0 +1,237 @@ +from types import SimpleNamespace + +import numpy as np + +from lddecode import core +from lddecode.start_finder import ( + FrameObservation, + StartFinder, + StartResult, + VbiRunDetector, + file_frame_from_readloc, + main, +) + + +def observation(address, file_frame=0, readloc=0, disk_type="CAV"): + return FrameObservation(file_frame, readloc, address, disk_type) + + +def test_file_frame_conversion_matches_start_seek_units(): + bytes_per_field = 674029 + readloc = (bytes_per_field * 2 * 1234) + bytes_per_field + assert file_frame_from_readloc(readloc, bytes_per_field) == 1234 + + +def test_vbi_run_requires_contiguous_addresses_of_one_disk_type(): + detector = VbiRunDetector(required_frames=5) + assert detector.observe(observation(100)) is None + assert detector.observe(observation(101)) is None + assert detector.observe(observation(103)) is None + assert detector.observe(observation(104)) is None + assert detector.observe(observation(105)) is None + assert detector.observe(observation(106)) is None + result = detector.observe(observation(107)) + assert result.address == 103 + + detector.reset() + for address in (200, 201, 202, 203): + assert detector.observe(observation(address, disk_type="CLV")) is None + assert detector.observe(observation(204, disk_type="CAV")) is None + + +def test_vbi_run_rejects_paused_reverse_and_fast_forward_addresses(): + detector = VbiRunDetector(required_frames=3) + assert detector.observe(observation(10)) is None + assert detector.observe(observation(10)) is None + assert detector.observe(observation(11)) is None + assert detector.observe(observation(9)) is None + assert detector.observe(observation(10)) is None + assert detector.observe(observation(20)) is None + assert detector.observe(observation(21)) is None + assert detector.observe(observation(22)).address == 20 + + +def test_default_vbi_run_requires_a_sustained_second_of_addresses(): + detector = VbiRunDetector() + for address in range(29): + assert detector.observe(observation(address)) is None + assert detector.observe(observation(29)).address == 0 + + +class FakeField: + def __init__( + self, + readloc, + is_first_field, + address=None, + disk_type="CAV", + valid=True, + field_phase_id=None, + ): + self.readloc = readloc + self.isFirstField = is_first_field + self.address = address + self.disk_type = disk_type + self.valid = valid + self.fieldPhaseID = field_phase_id + + +class FakeDecoder: + def __init__(self, events, bytes_per_field=100): + self.events = list(events) + self.bytes_per_field = bytes_per_field + self.isCLV = False + self.closed = False + self.rf = SimpleNamespace(SysParams={"fieldPhases": 4}) + + def decodefield(self, _position, _mtf, _previous): + event = self.events.pop(0) + if isinstance(event, BaseException): + raise event + if event is None: + return None, None + return event, self.bytes_per_field + + def decodeFrameNumber(self, _first, second): + self.isCLV = second.disk_type == "CLV" + return second.address + + def close(self): + self.closed = True + + +def five_frames(start_file_frame=7, sample_base=0, disk_type="CAV"): + fields = [] + for index in range(5): + readloc = sample_base + ((start_file_frame + index) * 200) + fields.append(FakeField(readloc, True, disk_type=disk_type)) + fields.append(FakeField(readloc + 100, False, 100 + index, disk_type)) + return fields + + +def test_start_finder_returns_first_cav_frame_of_vbi_run(): + decoder = FakeDecoder(five_frames()) + result = StartFinder( + lambda: decoder, sample_rate=100, required_frames=5, pre_roll_search_seconds=0 + ).search() + assert result.confidence == "vbi" + assert result.file_frame == 7 + assert result.readloc == 1400 + assert result.disk_type == "CAV" + assert result.addresses == (100, 101, 102, 103, 104) + assert decoder.closed + + +def test_start_finder_recreates_decoder_after_decode_failure(): + first = FakeDecoder([RuntimeError("bad RF")]) + second = FakeDecoder(five_frames(start_file_frame=9)) + decoders = iter([first, second]) + result = StartFinder( + lambda: next(decoders), sample_rate=100, required_frames=5, pre_roll_search_seconds=0 + ).search() + assert result.confidence == "vbi" + assert result.file_frame == 9 + assert first.closed + assert second.closed + + +def test_start_finder_returns_guarded_fallback_for_stable_video_without_vbi(): + fields = [ + FakeField(index * 100, index % 2 == 0, None) + for index in range(11) + ] + decoder = FakeDecoder(fields, bytes_per_field=100) + result = StartFinder( + lambda: decoder, sample_rate=100, fallback_seconds=10 + ).search() + assert result.confidence == "fallback" + assert result.file_frame == 0 + assert result.readloc == 0 + + +def test_start_finder_returns_first_clv_frame_of_the_stable_run(): + decoder = FakeDecoder(five_frames(start_file_frame=30, disk_type="CLV")) + result = StartFinder( + lambda: decoder, sample_rate=100, required_frames=5, pre_roll_search_seconds=0 + ).search() + assert result.confidence == "vbi" + assert result.disk_type == "CLV" + assert result.file_frame == 30 + assert result.readloc == 6000 + + +def test_start_finder_keeps_clean_pre_roll_after_last_phase_break(): + forward = FakeDecoder(five_frames(start_file_frame=20)) + replay = FakeDecoder( + [ + FakeField(3500, True, field_phase_id=1), + FakeField(3600, False, field_phase_id=2), + # The player re-locks at a new first field. Keep this field and + # the following clean pre-roll rather than returning the later + # VBI-confirmed programme frame at sample 4000. + FakeField(3700, True, field_phase_id=1), + FakeField(3800, False, field_phase_id=2), + FakeField(3900, True, field_phase_id=3), + FakeField(4000, False, field_phase_id=4), + ] + ) + decoders = iter([forward, replay]) + + result = StartFinder( + lambda: next(decoders), + sample_rate=100, + required_frames=5, + pre_roll_search_seconds=5, + ).search() + + assert result.confidence == "vbi" + assert result.file_frame == 18 + assert result.readloc == 3700 + assert forward.closed + assert replay.closed + + +def test_main_writes_only_confirmed_start_argument_to_stdout(monkeypatch, capsys): + result = StartResult(42, 8400, "vbi", "CAV", (1, 2, 3, 4, 5), 3.0) + monkeypatch.setattr("lddecode.start_finder.find_start", lambda *_args, **_kwargs: result) + assert main(["capture.ldf"]) == 0 + captured = capsys.readouterr() + assert captured.out == "--start 42\n" + assert "found CAV run" in captured.err + + +def test_main_keeps_guarded_fallback_off_stdout(monkeypatch, capsys): + result = StartResult(42, 8400, "fallback", None, tuple(), 10.0) + monkeypatch.setattr("lddecode.start_finder.find_start", lambda *_args, **_kwargs: result) + assert main(["capture.ldf"]) == 2 + captured = capsys.readouterr() + assert captured.out == "" + assert "--start 42" in captured.err + + +def test_main_returns_one_without_a_candidate(monkeypatch, capsys): + monkeypatch.setattr("lddecode.start_finder.find_start", lambda *_args, **_kwargs: None) + assert main(["capture.ldf"]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "no qualifying programme start" in captured.err + + +def test_getpulses_recalibrates_only_once_when_no_sync_exists(monkeypatch): + field = object.__new__(core.Field) + field.rf = SimpleNamespace( + DecoderParams={"vsync_ire": -40, "ire0": 0}, + iretohz=lambda value: value, + ) + field.data = {"video": {"demod_05": np.array([1.0, 2.0, 3.0])}} + field.fields_written = 0 + calls = [] + + def no_pulses(*_args): + calls.append(True) + return [] + + monkeypatch.setattr(core, "findpulses", no_pulses) + assert field.getpulses() == [] + assert len(calls) == 2 From ac3fe044cd98c1bece74670799d38cec156396c4 Mon Sep 17 00:00:00 2001 From: Ed Powell Date: Wed, 29 Jul 2026 12:53:42 -0500 Subject: [PATCH 2/4] Remove overzealous AI documentation --- README.md | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/README.md b/README.md index 1021243fa..8b7507322 100644 --- a/README.md +++ b/README.md @@ -56,26 +56,6 @@ For installation instructions after building, see **[INSTALL.md](INSTALL.md)** w > > Please see [Decode-Orc](https://github.com/simoninns/decode-orc) for details of how to obtain and install the Decode-Orc tools -## Finding the start of a capture - -Use `ld-find-start` when a capture begins while the player is paused or is -seeking back to the disc start. It scans RF and Philips CAV/CLV codes without -creating TBC, audio, JSON, or database output. A confirmed result is emitted -as the capture-relative file-frame argument accepted by `ld-decode --start`: - -```sh -ld-decode input.ldf output $(ld-find-start input.ldf) -``` - -The finder requires a sustained, normal-speed CAV or CLV address run, then -replays the preceding clean field sequence to retain player lead-in/pre-roll -while excluding the preceding paused or seeking material. It searches five -minutes by default; use `--max-search 0` to search to EOF. The pre-roll replay -window defaults to five seconds and can be adjusted with `--pre-roll-search`. -For a capture with stable video but no usable VBI address, it prints a guarded -candidate only on stderr and exits with status 2. Inspect that candidate and -pass its `--start` value explicitly if appropriate. - # Want to get involved? The documentation includes details of the ld-decode community's Discord / IRC Bridge and the now legacy Facebook group. From 31ecb2a2cd8b08c4f5515350699591ebf6aa1e59 Mon Sep 17 00:00:00 2001 From: Ed Powell Date: Wed, 29 Jul 2026 13:13:22 -0500 Subject: [PATCH 3/4] Align ld-find-start with project style --- cmake_modules/LdDecodeTests.cmake | 1 - ld-find-start | 1 - lddecode/start_finder.py | 1 - tests/test_start_finder.py | 22 ---------------------- 4 files changed, 25 deletions(-) diff --git a/cmake_modules/LdDecodeTests.cmake b/cmake_modules/LdDecodeTests.cmake index 3bc4560c6..31773939b 100644 --- a/cmake_modules/LdDecodeTests.cmake +++ b/cmake_modules/LdDecodeTests.cmake @@ -7,7 +7,6 @@ set(SCRIPTS_DIR ${CMAKE_SOURCE_DIR}/scripts) set(TESTDATA_DIR ${CMAKE_SOURCE_DIR}/testdata) -file(MAKE_DIRECTORY ${CMAKE_BINARY_DIR}/testout) # Test that ld-decode can decode NTSC files and produce TBC output add_test( diff --git a/ld-find-start b/ld-find-start index 534100b79..ba38c6141 100755 --- a/ld-find-start +++ b/ld-find-start @@ -3,6 +3,5 @@ import sys from lddecode.start_finder import main - if __name__ == "__main__": sys.exit(main(sys.argv[1:])) diff --git a/lddecode/start_finder.py b/lddecode/start_finder.py index 0b7d0f948..bc10f6287 100644 --- a/lddecode/start_finder.py +++ b/lddecode/start_finder.py @@ -16,7 +16,6 @@ from lddecode.core import LDdecode from lddecode.utils import make_loader, parse_frequency - RF_SAMPLE_RATE = 40000000 DEFAULT_MAX_SEARCH_SECONDS = 300.0 DEFAULT_FALLBACK_SECONDS = 10.0 diff --git a/tests/test_start_finder.py b/tests/test_start_finder.py index 076173332..58f930136 100644 --- a/tests/test_start_finder.py +++ b/tests/test_start_finder.py @@ -1,8 +1,5 @@ from types import SimpleNamespace -import numpy as np - -from lddecode import core from lddecode.start_finder import ( FrameObservation, StartFinder, @@ -216,22 +213,3 @@ def test_main_returns_one_without_a_candidate(monkeypatch, capsys): captured = capsys.readouterr() assert captured.out == "" assert "no qualifying programme start" in captured.err - - -def test_getpulses_recalibrates_only_once_when_no_sync_exists(monkeypatch): - field = object.__new__(core.Field) - field.rf = SimpleNamespace( - DecoderParams={"vsync_ire": -40, "ire0": 0}, - iretohz=lambda value: value, - ) - field.data = {"video": {"demod_05": np.array([1.0, 2.0, 3.0])}} - field.fields_written = 0 - calls = [] - - def no_pulses(*_args): - calls.append(True) - return [] - - monkeypatch.setattr(core, "findpulses", no_pulses) - assert field.getpulses() == [] - assert len(calls) == 2 From e3c48f2e1a987bba2d92a853cfc3540fbe95bc2a Mon Sep 17 00:00:00 2001 From: Ed Powell Date: Thu, 30 Jul 2026 07:04:39 -0500 Subject: [PATCH 4/4] Speed ld-find-start preamble scan --- lddecode/core.py | 159 +++++++++++++++++++++++++++++++++++++ lddecode/start_finder.py | 22 +++++ tests/test_start_finder.py | 37 +++++++++ 3 files changed, 218 insertions(+) diff --git a/lddecode/core.py b/lddecode/core.py index 127b34995..6f28df24e 100644 --- a/lddecode/core.py +++ b/lddecode/core.py @@ -751,6 +751,45 @@ def demodblock(self, data=None, mtf_level=0, fftdata=None, cut=False): return self.demodblock_cpu(data, mtf_level, fftdata, cut) + def demodblock_sync(self, data=None, fftdata=None, cut=False): + """Demodulate only the 0.5 MHz path used for vertical-sync detection.""" + + if fftdata is not None: + indata_fft = fftdata + elif data is not None: + indata_fft = npfft.fft(data[: self.blocklen]) + else: + raise Exception("demodblock_sync called without raw or FFT data") + + if self.system == "PAL" and self.PAL_V4300D_NotchFilter: + indata_fft = indata_fft.copy() + sl = slice( + int(self.blocklen * (8.42 / self.freq)), + int(1 + (self.blocklen * (8.6 / self.freq))), + ) + sq_sl = sqsum(indata_fft[sl]) + m = np.mean(sq_sl) + (np.std(sq_sl) * 3) + + for i in np.where(sq_sl > m)[0]: + indata_fft[(i - 1 + sl.start)] = 0 + indata_fft[(i + sl.start)] = 0 + indata_fft[(i + 1 + sl.start)] = 0 + indata_fft[self.blocklen - (i + sl.start)] = 0 + indata_fft[self.blocklen - (i - 1 + sl.start)] = 0 + indata_fft[self.blocklen - (i + 1 + sl.start)] = 0 + + indata_fft_filt = indata_fft * self.Filters["RFVideo"] + + hilbert = npfft.ifft(indata_fft_filt) + demod = unwrap_hilbert(hilbert, self.freq_hz) + demod_fft = npfft.fft(np.clip(demod, 1500000, self.freq_hz * 0.75)) + sync = npfft.ifft(demod_fft * self.Filters["FVideo05"]).real + sync = np.roll(sync, -self.Filters["F05_offset"]) + + if cut: + sync = sync[self.blockcut : -self.blockcut_end] + return sync.astype(np.float32) + def demodblock_cpu(self, data=None, mtf_level=0, fftdata=None, cut=False): rv = {} @@ -1088,6 +1127,7 @@ def __init__( self.q_in = Queue() self.q_out = Queue() self.waiting = set() + self.sync_waiting = set() self.q_out_cv = threading.Condition(self.lock) self.threadpipes = [] @@ -1197,6 +1237,20 @@ def worker(self, return_on_empty=False): if output: self.q_out.put((blocknum, output)) + elif item[0] == "SYNC": + blocknum, block = item[1:] + output = {} + if "fft" not in block: + output["fft"] = npfft.fft(block["rawinput"]) + fftdata = output["fft"] + else: + fftdata = block["fft"] + output["sync"] = rf.demodblock_sync( + data=block["rawinput"], + fftdata=fftdata, + cut=True, + ) + self.q_out.put((blocknum, output)) elif item[0] == "NEWPARAMS": self.apply_newparams(item[1]) @@ -1279,6 +1333,66 @@ def doread(self, blocknums, MTF, redo=False, prefetch=False): return None if reached_end else need_blocks + def _load_raw_block(self, blocknum): + """Return a cached raw block without scheduling full demodulation.""" + + with self.lock: + if blocknum not in self.blocks: + LRUupdate(self.lru, blocknum) + self.blocks[blocknum] = {"rawinput": None} + block = self.blocks[blocknum] + if block is None: + return None + rawdata = block["rawinput"] + + if rawdata is None: + with self.loader_lock: + rawdata = self.loader( + self.infile, blocknum * self.blocksize, self.rf.blocklen + ) + with self.lock: + if rawdata is None or len(rawdata) < self.rf.blocklen: + self.blocks[blocknum] = None + return None + self.blocks[blocknum]["rawinput"] = rawdata + + return self.blocks[blocknum] + + def read_sync(self, begin, length): + """Return only the demodulated sync path for a contiguous input range.""" + + end = begin + length + blocknums = list(range(begin // self.blocksize, (end // self.blocksize) + 1)) + + for blocknum in blocknums: + if self._load_raw_block(blocknum) is None: + return None + + with self.lock: + for blocknum in blocknums: + block = self.blocks[blocknum] + if "sync" not in block and blocknum not in self.sync_waiting: + self.sync_waiting.add(blocknum) + self.q_in.put(("SYNC", blocknum, block)) + + while True: + if self.num_worker_threads == 0: + self.worker(return_on_empty=True) + + with self.q_out_cv: + if not any(blocknum in self.sync_waiting for blocknum in blocknums): + break + self.q_out_cv.wait() + + with self.lock: + sync = [self.blocks[blocknum]["sync"] for blocknum in blocknums] + + self.prune_cache() + return { + "sync": np.concatenate(sync), + "startloc": (begin // self.blocksize) * self.blocksize, + } + def flush_demod(self): """ Flush all demodulation data. This is called by the field class after calibration (i.e. MTF) is determined to be off """ blocks_toredo = [] @@ -1314,6 +1428,13 @@ def dequeue(self): if blocknum not in self.blocks: continue + if "sync" in item: + for key, value in item.items(): + self.blocks[blocknum][key] = value + self.sync_waiting.discard(blocknum) + self.q_out_cv.notify_all() + continue + if "MTF" not in item or "demod" not in item: # This shouldn't happen, but was observed by Simon on a decode logger.error( @@ -4014,6 +4135,44 @@ def writeout(self, dataset): if audio is not None and self.outfile_audio is not None: self.outfile_audio.write(audio) + def has_sync(self, start): + """Return whether a lightweight probe sees a possible field sync. + + This is a conservative preamble gate. It does not validate a field or + alter the decoder state; callers must still use :meth:`decodefield` + before treating a position as video. + """ + + readloc = max(0, int(start - self.rf.blockcut)) + readloc_block = readloc // self.blocksize + numblocks = (self.readlen // self.blocksize) + 2 + rawdecode = self.demodcache.read_sync( + readloc_block * self.blocksize, + numblocks * self.blocksize, + ) + if rawdecode is None: + return None + + probe_decode = { + "input": np.empty(0, dtype=np.int16), + "video": np.rec.array( + [rawdecode["sync"]], names=["demod_05"] + ), + } + field = self.FieldClass( + self.rf, + probe_decode, + fields_written=0, + readloc=rawdecode["startloc"], + ) + original_ire0 = self.rf.DecoderParams["ire0"] + try: + pulses = field.getpulses() + finally: + self.rf.DecoderParams["ire0"] = original_ire0 + + return bool(pulses is not None and len(pulses)) + @profile def decodefield(self, start, mtf_level, prevfield=None, initphase=False, redo=False, rv=None): """ returns field object if valid, and the offset to the next decode """ diff --git a/lddecode/start_finder.py b/lddecode/start_finder.py index bc10f6287..eaa5cfd2e 100644 --- a/lddecode/start_finder.py +++ b/lddecode/start_finder.py @@ -300,6 +300,7 @@ def search(self): first_field = None stable_video = _StableVideoTracker() last_progress_bucket = -1 + scanning_preamble = True try: decoder = self.decoder_factory() @@ -307,6 +308,25 @@ def search(self): last_progress_bucket = self._report_progress( position, last_progress_bucket ) + sync_probe = getattr(decoder, "has_sync", None) + if scanning_preamble and callable(sync_probe): + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore", RuntimeWarning) + has_sync = sync_probe(position) + except (KeyboardInterrupt, SystemExit): + raise + except Exception as error: + if self.report is not None: + self.report( + "sync probe failure at sample {0}: {1}; " + "using full decode".format(position, error) + ) + has_sync = None + + if has_sync is False: + position += self.sample_rate + continue try: # Bad tracking windows can make the RF calculations emit NumPy # RuntimeWarnings. They are expected probe failures, not a @@ -358,6 +378,8 @@ def search(self): position += self.sample_rate continue + scanning_preamble = False + stable_start = stable_video.observe(field, decoder.bytes_per_field) if field.isFirstField: diff --git a/tests/test_start_finder.py b/tests/test_start_finder.py index 58f930136..390916c66 100644 --- a/tests/test_start_finder.py +++ b/tests/test_start_finder.py @@ -1,5 +1,8 @@ from types import SimpleNamespace +import numpy as np + +from lddecode.core import RFDecode from lddecode.start_finder import ( FrameObservation, StartFinder, @@ -20,6 +23,18 @@ def test_file_frame_conversion_matches_start_seek_units(): assert file_frame_from_readloc(readloc, bytes_per_field) == 1234 +def test_sync_only_demodulation_matches_the_normal_sync_path(): + rf = RFDecode(system="NTSC", blocklen=32768) + raw = np.random.default_rng(0).integers( + -32768, 32767, size=rf.blocklen, dtype=np.int16 + ) + + full = rf.demodblock_cpu(data=raw, cut=True)["video"]["demod_05"] + sync = rf.demodblock_sync(data=raw, cut=True) + + np.testing.assert_array_equal(sync, full) + + def test_vbi_run_requires_contiguous_addresses_of_one_disk_type(): detector = VbiRunDetector(required_frames=5) assert detector.observe(observation(100)) is None @@ -98,6 +113,17 @@ def close(self): self.closed = True +class SyncProbeFakeDecoder(FakeDecoder): + def __init__(self, events, sync_results, bytes_per_field=100): + super().__init__(events, bytes_per_field) + self.sync_results = iter(sync_results) + self.sync_positions = [] + + def has_sync(self, position): + self.sync_positions.append(position) + return next(self.sync_results) + + def five_frames(start_file_frame=7, sample_base=0, disk_type="CAV"): fields = [] for index in range(5): @@ -133,6 +159,17 @@ def test_start_finder_recreates_decoder_after_decode_failure(): assert second.closed +def test_start_finder_skips_sync_free_preamble_without_full_field_decode(): + decoder = SyncProbeFakeDecoder(five_frames(), [False, True]) + result = StartFinder( + lambda: decoder, sample_rate=100, required_frames=5, pre_roll_search_seconds=0 + ).search() + + assert result.confidence == "vbi" + assert decoder.sync_positions == [0, 100] + assert decoder.closed + + def test_start_finder_returns_guarded_fallback_for_stable_video_without_vbi(): fields = [ FakeField(index * 100, index % 2 == 0, None)