9 Commits
Author SHA1 Message Date
JMR-devandClaude Sonnet 5 b23995f218 refactor: rename the package from ccn-transcribe to audio-scribe
The distribution, console script and import package are now audio-scribe /
audio_scribe (src/audio_scribe). Everything named for the old project follows:

- CcnError -> AudioScribeError, and its code "ccn_error" -> "audio_scribe_error"
  (the code is never persisted, so existing job state still loads)
- CCN_LIVE -> AUDIO_SCRIBE_LIVE for the live-GPU tests
- OpenVINO kernel cache moves to <cache>/audio-scribe/ov_cache; the first run
  after upgrading recompiles kernels, and the old directory is left in place
- README, build-binary.sh, hatch/coverage config and uv.lock updated to match

Breaking: the command is now `audio-scribe`; reinstall any tool install of the
old name with `uv tool uninstall ccn-transcribe && uv tool install .`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:52:42 -05:00
JMR-devandClaude Opus 5 1114595de7 feat(jobs): record what each URL expanded to, so a resume needs no network
Resolving a URL to a job id cost a metadata request on every invocation,
including re-runs with nothing to do. index.json records what the last
expansion produced and a single-video URL is now answered from it: measured on
a finished job, 2.25s and three requests becomes 0.75s and none, and the resume
succeeds with the network blocked entirely, where it previously failed.

A playlist is deliberately still re-read. Answering one from the index would
silently ignore videos added to it since the last run, and that request is the
only way to notice them. Entries are keyed by whether --playlist was in effect,
because the same URL names a different set with and without it.

When a URL that has been expanded before can no longer be read, the recorded
jobs are used and the reason is logged rather than failing the whole URL. This
keys on the source being unreadable rather than on a particular error, because
an unreachable host surfaces as "no metadata" -- yt-dlp reports the failure and
returns nothing rather than raising. Only a previously expanded URL can reach
this, so it cannot invent work, and any job still needing a download fails on
its own merits. That NotFoundError's hint no longer claims a bad URL is the
only explanation.

Target replaces the (url, entry) tuple so a job id survives without metadata,
and store grows the atomic JSON write that save and the index now share.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:58:16 -05:00
JMR-devandClaude Opus 5 605275e37e feat(jobs): add run configuration and the per-job runner
Retention deletions are sequenced so the guarantee actually holds: the video
goes only after the FLAC is extracted, verified and committed, and the FLAC only
after every output is written and committed. Each deletion records itself both
in state.json and in events.jsonl, so a later run can tell "removed as
requested" from "gone and should not be".

Two bugs the tests caught while writing this:

- params_changed compared the requested model id against the resolved one, so
  every re-run looked like a parameter change and re-transcribed. An unpinned
  model now matches whatever ran; only an explicit --model can disagree.
- chain=DEFAULT_CHAIN as a default argument binds at import time, which made
  the backend chain impossible to override. It is resolved at call time now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 15:04:43 -05:00
JMR-devandClaude Opus 5 1ae8c2fc96 feat(stages): add audio extraction, transcription and output writing
Audio is extracted into the job's tmp/ and moved into place only after ffprobe
confirms a readable duration, so an interrupted run never leaves a
plausible-looking stub that later verifies as complete.

transcript.json is always written even when not requested: it doubles as the
segment cache, which is what lets a deleted subtitle file be re-rendered
instead of re-transcribing a two-hour recording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:51:04 -05:00
JMR-devandClaude Opus 5 2ca46074d3 feat(backends): add GPU detection, runtime fallback and the OpenVINO backend
Detection is two-phase by construction: the registry only calls probe_toolchain
once probe_hardware confirms the vendor, which is what structurally keeps torch
from being imported on an Intel-only box. A test asserts exactly that.

Hardware probing reads each render node's bound driver rather than loaded kernel
modules: /sys/module/xe exists here with zero bound devices while i915 owns the
card, so a module-presence check false-positives.

Fallback is runtime, not detection-time -- construction and the first generate()
sit in the same try, because the render-node permission failure and the OpenCL
JIT failure both surface there rather than at device enumeration. A failure
demotes the backend process-wide so a 50-job batch does not retry it 50 times,
and an explicit --device never falls back silently.

NVIDIA and AMD are interface-only: detection is real and the error names the
module to implement and the model format required. The cached model is OpenVINO
IR and cannot load on CUDA or ROCm, and CTranslate2 has no ROCm support, so
those are two separate paths rather than one parameterized one.

Model resolution is offline-first, and CACHE_DIR is anchored under XDG rather
than the working directory, which the benchmark scripts depend on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:33:21 -05:00
JMR-devandClaude Opus 5 2cfd7ad087 feat(jobs): add job identity, state, verification and resume planning
Artifacts on disk are the source of truth. state.json deliberately has no
top-level "stage" field -- persisting one is how "marked done but the file
is gone" bugs happen -- so the resume point is computed from what verifies.

The distinction that makes --no-retain safe is deleted_by_policy vs missing.
verify_* short-circuits on a policy deletion before touching the filesystem,
because probing a deliberately absent file would raise and degrade the whole
feature into "re-download everything".

A .part without its .aria2 control file is treated as unresumable: aria2
writes segments out of order, so such a file is sparse with holes rather
than a valid prefix, and resuming from its length yields a corrupt video.

Planning walks stages backwards. A policy deletion satisfies a stage that is
not re-running, but not one that is -- so --force-stage transcribe correctly
walks back to re-download. Saved segments let a deleted subtitle file be
re-rendered without re-transcribing a long recording.

state.json is written tmp -> fsync -> replace -> fsync(dir), with a test that
a failed replace leaves the previous record intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:24:24 -05:00
JMR-devandClaude Opus 5 15a28495b8 chore(tooling): enable flake8-bandit rules
Turns on ruff's S rules, which matter for code that shells out to ffmpeg,
aria2c and yt-dlp. S607 is ignored project-wide: binaries are looked up on
PATH deliberately and preflight-checked with shutil.which, so hardcoding
absolute paths would be less portable rather than safer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:17:41 -05:00
JMR-devandClaude Opus 5 157e846490 chore(tooling): keep ruff format out of Markdown, allow 10 dataclass fields
ruff format restyles Python blocks inside Markdown, which silently rewrote
the ffmpeg sample in README-intel.md. Documentation is not ours to restyle,
so exclude *.md.

Raise pylint max-attributes to 10: TranscriptResult is a data model and its
eight fields are not a design smell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:12:38 -05:00
JMR-devandClaude Opus 5 7a62688355 chore(tooling): set up uv, ruff, pyright, pylint, mypy and git hooks
Replace the ad-hoc stdlib venv with a uv-managed, locked environment and
add the quality gates:

- pre-commit: ruff (lint + format), pyright, pytest at 100% coverage
- pre-push:   pylint, mypy --strict
- commit-msg: conventional commits

pytest and pyright run with pass_filenames: false so they always see the
whole project; --cov-fail-under stays in the hook rather than addopts so
single-file TDD runs are not blocked by coverage.

requires-python is >=3.12 because numpy 2.5 does not support 3.11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 14:06:38 -05:00