diff --git a/docs/superpowers/evidence/transcribe-fw-split-2026-08-17.json b/docs/superpowers/evidence/transcribe-fw-split-2026-08-17.json new file mode 100644 index 00000000..e65c7111 --- /dev/null +++ b/docs/superpowers/evidence/transcribe-fw-split-2026-08-17.json @@ -0,0 +1,55 @@ +{ + "intent": "Split the 302-line transcribe_fw.py into a progress-reporting module and the transcription entry point, both under the 250-line cap, moving every definition verbatim.", + "scope": [ + "transcribe_fw.py 302 -> 236 (argument parsing, model loading, diarized output, main)", + "New: _transcribe_progress.py 91 (_run_progress_loop, _format_progress_line)", + "Re-pointed the module under test for the two progress functions", + "Non-goal: any behaviour change" + ], + "changes": [ + "Extracted _run_progress_loop and _format_progress_line into _transcribe_progress.py, together with the two constants only they use (_PROGRESS_THROTTLE_SEC, _SECONDS_PER_DAY) and the hhmmss import they need.", + "transcribe_fw.py imports _run_progress_loop by name and no longer imports time, which only the progress code used.", + "The progress tests now patch _transcribe_progress - including its sys.stderr and time references, which is where the loop resolves them. _resolve_device_and_compute stayed in transcribe_fw, so its test was left pointing there.", + "_transcribe_progress.py was given the executable bit to match the repo convention for a file carrying a shebang." + ], + "verification": [ + { + "command": "python3 verify_split.py HEAD .../transcribe_fw.py ", + "result": "pass", + "evidence": "IDENTICAL: all 8 top-level definitions moved verbatim. Re-run after ruff reordered the imports, and still identical." + }, + { + "command": "AST scan for names used but not bound in each module", + "result": "pass", + "evidence": "Both modules report no unbound names, confirming the two constants and the hhmmss import travelled with the code that uses them." + }, + { + "command": "pytest linux_configuration/tests/test_transcribe_fw_*.py --cov=transcribe_fw --cov=_transcribe_progress --cov-branch", + "result": "pass", + "evidence": "32 passed - the same count as before the split. _transcribe_progress.py: 43 statements, 16 branches, 100%. transcribe_fw.py: 95 statements, 16 branches, 100%." + }, + { + "command": "python3 transcribe_fw.py --help and python3 transcribe_fw.py /nonexistent.wav", + "result": "pass", + "evidence": "Help prints the full usage; the missing-dependency path logs 'faster-whisper is not installed in this environment.' and returns exit 2, the code transcribe.sh branches on. Only a real run proves the sibling imports still resolve after a split." + }, + { + "command": "pytest linux_configuration/tests/ -q", + "result": "pass", + "evidence": "275 passed, unchanged from before the split." + }, + { + "command": "bash ~/utils/scripts/check_file_length.sh --all", + "result": "pass", + "evidence": "33 files over cap, down from 34. The two modules are 236 and 91 lines." + } + ], + "risks": [ + "transcribe_fw.py at 236 lines has only 14 lines of headroom under the cap. A further split is available if it grows: _parse_args and _load_whisper_model are an obvious next seam.", + "The progress module is imported by transcribe_fw by bare name, which resolves because they are siblings on sys.path[0]. That is the existing convention for this directory, shared with _transcribe_diarize, _transcribe_model and _transcribe_output, and the real run confirms it." + ], + "rollback": [ + "git revert the commit; transcribe_fw.py returns to its 302-line form and _transcribe_progress.py disappears.", + "After rollback, confirm python3 transcribe_fw.py /nonexistent.wav still returns 2 and pytest linux_configuration/tests/ reports 275 passed." + ] +} diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_progress.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_progress.py new file mode 100755 index 00000000..44dd55f5 --- /dev/null +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/_transcribe_progress.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Live progress reporting for transcribe_fw. + +``_format_progress_line`` is pure - seconds processed plus media duration in, +a display string out - which is what makes the percentage clamp and the ETA +testable without loading a model. +""" + +from __future__ import annotations + +import logging +import sys +import time +from typing import TYPE_CHECKING, Any + +from _transcribe_output import hhmmss + +if TYPE_CHECKING: + import argparse + +logger = logging.getLogger(__name__) + +_PROGRESS_THROTTLE_SEC = 0.2 +_SECONDS_PER_DAY = 60 * 60 * 24 + + +def _run_progress_loop( + args: argparse.Namespace, + model: object, + inp: str, + total_duration: float | None, +) -> tuple[list[Any], object]: + """Transcribe with live progress output.""" + start_ts = time.time() + iter_segments, info = model.transcribe(inp, language=args.language) + collected: list[Any] = [] + processed = 0.0 + last_prt = 0.0 + tty = sys.stderr.isatty() + + for seg in iter_segments: + collected.append(seg) + if getattr(seg, "end", None) is not None: + processed = max(processed, float(seg.end)) + now = time.time() + if not args.no_progress and (tty or (now - last_prt) >= _PROGRESS_THROTTLE_SEC): + last_prt = now + line = _format_progress_line( + processed, + total_duration, + now, + start_ts, + ) + if tty: + logger.info("\r%s", line) + else: + logger.info("%s", line) + + if not args.no_progress and tty: + logger.info("") + + return collected, info + + +def _format_progress_line( + processed: float, + total_duration: float | None, + now: float, + start_ts: float, +) -> str: + """Format a progress line string.""" + if total_duration and total_duration > 0: + pct = max( + 0.0, + min( + 100.0, + (processed / total_duration) * 100.0, + ), + ) + elapsed = now - start_ts + line = ( + f"[PROGRESS] {hhmmss(processed)} / {hhmmss(total_duration)} ({pct:5.1f}%)" + ) + if processed > 0: + rate = processed / max(1e-6, elapsed) + remaining = max(0.0, total_duration - processed) + eta = remaining / max(1e-6, rate) + if eta < _SECONDS_PER_DAY: + line += f" ETA ~{hhmmss(eta)}" + return line + return f"[PROGRESS] processed {hhmmss(processed)}" diff --git a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py index 44690689..a6504b05 100755 --- a/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py +++ b/linux_configuration/scripts/single_use/misc/testsAndMisc-bash/tools/transcribe_fw.py @@ -9,7 +9,6 @@ import logging import os from pathlib import Path import sys -import time from typing import TYPE_CHECKING, Any from _transcribe_diarize import diarize_segments, get_media_duration @@ -22,16 +21,13 @@ from _transcribe_output import ( write_txt, write_txt_with_speakers, ) +from _transcribe_progress import _run_progress_loop if TYPE_CHECKING: import types logger = logging.getLogger(__name__) -# Constants -_PROGRESS_THROTTLE_SEC = 0.2 -_SECONDS_PER_DAY = 60 * 60 * 24 - def _try_import(name: str) -> types.ModuleType | None: """Attempt to import a module, returning None on failure.""" @@ -106,73 +102,6 @@ def _resolve_device_and_compute( return device, compute_type -def _run_progress_loop( - args: argparse.Namespace, - model: object, - inp: str, - total_duration: float | None, -) -> tuple[list[Any], object]: - """Transcribe with live progress output.""" - start_ts = time.time() - iter_segments, info = model.transcribe(inp, language=args.language) - collected: list[Any] = [] - processed = 0.0 - last_prt = 0.0 - tty = sys.stderr.isatty() - - for seg in iter_segments: - collected.append(seg) - if getattr(seg, "end", None) is not None: - processed = max(processed, float(seg.end)) - now = time.time() - if not args.no_progress and (tty or (now - last_prt) >= _PROGRESS_THROTTLE_SEC): - last_prt = now - line = _format_progress_line( - processed, - total_duration, - now, - start_ts, - ) - if tty: - logger.info("\r%s", line) - else: - logger.info("%s", line) - - if not args.no_progress and tty: - logger.info("") - - return collected, info - - -def _format_progress_line( - processed: float, - total_duration: float | None, - now: float, - start_ts: float, -) -> str: - """Format a progress line string.""" - if total_duration and total_duration > 0: - pct = max( - 0.0, - min( - 100.0, - (processed / total_duration) * 100.0, - ), - ) - elapsed = now - start_ts - line = ( - f"[PROGRESS] {hhmmss(processed)} / {hhmmss(total_duration)} ({pct:5.1f}%)" - ) - if processed > 0: - rate = processed / max(1e-6, elapsed) - remaining = max(0.0, total_duration - processed) - eta = remaining / max(1e-6, rate) - if eta < _SECONDS_PER_DAY: - line += f" ETA ~{hhmmss(eta)}" - return line - return f"[PROGRESS] processed {hhmmss(processed)}" - - def _write_diarized_outputs( args: argparse.Namespace, inp: str, diff --git a/linux_configuration/tests/test_transcribe_fw_progress.py b/linux_configuration/tests/test_transcribe_fw_progress.py index 6a5ecb32..bf697a90 100644 --- a/linux_configuration/tests/test_transcribe_fw_progress.py +++ b/linux_configuration/tests/test_transcribe_fw_progress.py @@ -12,6 +12,7 @@ import argparse import logging from typing import TYPE_CHECKING, Any +import _transcribe_progress as progress import pytest import transcribe_fw as fw @@ -82,7 +83,7 @@ def test_resolve_device_and_compute( # --------------------------------------------------------------------------- # def test_format_progress_line_reports_percent_and_eta() -> None: """Halfway through at 1x speed, the ETA equals the time already spent.""" - line = fw._format_progress_line( + line = progress._format_progress_line( processed=50.0, total_duration=100.0, now=50.0, start_ts=0.0 ) @@ -93,7 +94,7 @@ def test_format_progress_line_reports_percent_and_eta() -> None: def test_format_progress_line_clamps_percent_to_100() -> None: """A segment ending past the reported duration cannot show 110%.""" - line = fw._format_progress_line( + line = progress._format_progress_line( processed=110.0, total_duration=100.0, now=10.0, start_ts=0.0 ) @@ -102,7 +103,7 @@ def test_format_progress_line_clamps_percent_to_100() -> None: def test_format_progress_line_omits_an_absurd_eta() -> None: """An ETA of more than a day is suppressed rather than shown.""" - line = fw._format_progress_line( + line = progress._format_progress_line( processed=0.001, total_duration=1_000_000.0, now=10.0, start_ts=0.0 ) @@ -111,7 +112,7 @@ def test_format_progress_line_omits_an_absurd_eta() -> None: def test_format_progress_line_omits_eta_before_any_progress() -> None: """With nothing processed there is no rate to extrapolate from.""" - line = fw._format_progress_line( + line = progress._format_progress_line( processed=0.0, total_duration=100.0, now=1.0, start_ts=0.0 ) @@ -124,7 +125,7 @@ def test_format_progress_line_falls_back_without_a_duration( duration: float | None, ) -> None: """An unknown media duration degrades to reporting elapsed audio only.""" - line = fw._format_progress_line( + line = progress._format_progress_line( processed=42.0, total_duration=duration, now=10.0, start_ts=0.0 ) @@ -140,9 +141,9 @@ def test_run_progress_loop_collects_every_segment( ) -> None: """All segments are collected and the info object is passed through.""" model = _Model([_Segment(1.0), _Segment(2.0), _Segment(3.0)]) - monkeypatch.setattr(fw.sys.stderr, "isatty", lambda: False) + monkeypatch.setattr(progress.sys.stderr, "isatty", lambda: False) - collected, info = fw._run_progress_loop(_args(), model, "in.wav", 3.0) + collected, info = progress._run_progress_loop(_args(), model, "in.wav", 3.0) assert len(collected) == 3 assert info.language == "en" @@ -154,9 +155,9 @@ def test_run_progress_loop_tolerates_a_segment_without_an_end( ) -> None: """A segment missing its end time does not break progress tracking.""" model = _Model([_Segment(None), _Segment(5.0)]) - monkeypatch.setattr(fw.sys.stderr, "isatty", lambda: False) + monkeypatch.setattr(progress.sys.stderr, "isatty", lambda: False) - collected, _ = fw._run_progress_loop(_args(), model, "in.wav", 10.0) + collected, _ = progress._run_progress_loop(_args(), model, "in.wav", 10.0) assert len(collected) == 2 @@ -166,10 +167,10 @@ def test_run_progress_loop_stays_silent_with_no_progress( ) -> None: """--no-progress suppresses the progress lines entirely.""" model = _Model([_Segment(1.0), _Segment(2.0)]) - monkeypatch.setattr(fw.sys.stderr, "isatty", lambda: False) + monkeypatch.setattr(progress.sys.stderr, "isatty", lambda: False) with caplog.at_level(logging.INFO): - fw._run_progress_loop(_args(no_progress=True), model, "in.wav", 2.0) + progress._run_progress_loop(_args(no_progress=True), model, "in.wav", 2.0) assert "[PROGRESS]" not in caplog.text @@ -179,10 +180,10 @@ def test_run_progress_loop_emits_progress_on_a_tty( ) -> None: """On a terminal every segment redraws the line.""" model = _Model([_Segment(1.0), _Segment(2.0)]) - monkeypatch.setattr(fw.sys.stderr, "isatty", lambda: True) + monkeypatch.setattr(progress.sys.stderr, "isatty", lambda: True) with caplog.at_level(logging.INFO): - fw._run_progress_loop(_args(), model, "in.wav", 2.0) + progress._run_progress_loop(_args(), model, "in.wav", 2.0) assert "[PROGRESS]" in caplog.text @@ -192,12 +193,12 @@ def test_run_progress_loop_throttles_when_not_a_tty( ) -> None: """Piped output is throttled, so a long run does not flood the log.""" model = _Model([_Segment(float(i)) for i in range(10)]) - monkeypatch.setattr(fw.sys.stderr, "isatty", lambda: False) + monkeypatch.setattr(progress.sys.stderr, "isatty", lambda: False) clock = iter([100.0, *[100.0] * 40]) - monkeypatch.setattr(fw.time, "time", lambda: next(clock)) + monkeypatch.setattr(progress.time, "time", lambda: next(clock)) with caplog.at_level(logging.INFO): - fw._run_progress_loop(_args(), model, "in.wav", 10.0) + progress._run_progress_loop(_args(), model, "in.wav", 10.0) # Every timestamp is identical, so after the first line the throttle # window never elapses and no further progress lines are emitted.