No description
  • Python 99%
  • Nix 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
opencode 5ce3a04ef3 feat: directory batch mode and restructured progress output
Batch mode (transcribe PATH now accepts a directory):

- flat (non-recursive) scan for mp4/webm/mkv/mov/m4v/avi/mpg/mpeg/mp3,
  sorted; a single file passed directly is accepted regardless of
  extension.
- skip files whose plain <name>.srt exists (--force to regenerate);
  language-specific files like <name>.cs.srt never block and are never
  touched.
- per-file failures — including mid-file chunk failures — are logged as
  'ERROR <file>: <reason> — file skipped' and the run continues; the
  summary line counts transcribed/skipped/failed and the exit code is
  1 when anything failed.
- --output is rejected for directory input; empty folders exit 0.
- --keep-audio no longer overwrites an audio-format input (song.mp3):
  the re-encoded extraction would have replaced the original.

Progress output restructure (per user feedback — same information,
readable shape):

- provider base URL + model logged once at the start;
- one header per file ('name (i/n)'), chunk lines indented below it,
  one line per chunk: order, span, upload size, audio-vs-extent ratio
  and applied correction factor;
- timestamp source moved from per-chunk noise to the per-file done:
  line (chunk count, corrections, source, cue count, output path);
  drift-correction internals now return a _DriftOutcome instead of
  logging;
- chunk planning clamps the minimum chunk length below small
  --chunk-duration targets (a 45s target no longer crashes
  plan_chunks' min<target validation) and planning ValueErrors are
  per-file errors instead of tracebacks.

Tests: discovery (flat/sorted/extension filter/subdir exclusion),
skip/force semantics incl. the language-specific case, per-file
failure continuation, summary and exit codes, -o rejection, empty
folder, log structure, keep-audio input guard; existing log-wording
assertions updated. 167 passed, 95% coverage.
2026-09-27 18:09:23 +00:00
reports docs: add pipeline validation report 2026-09-25 19:14:10 +00:00
src/transcribe feat: directory batch mode and restructured progress output 2026-09-27 18:09:23 +00:00
tests feat: directory batch mode and restructured progress output 2026-09-27 18:09:23 +00:00
.coverage test: add suite covering segment, srt, config, api fallbacks, cli 2026-09-25 19:12:21 +00:00
.env.example chore: scaffold nix flake project 2026-09-25 18:38:32 +00:00
.envrc chore: scaffold nix flake project 2026-09-25 18:38:32 +00:00
.gitignore chore: scaffold nix flake project 2026-09-25 18:38:32 +00:00
AGENTS.md chore: scaffold nix flake project 2026-09-25 18:38:32 +00:00
flake.lock chore: bump nixpkgs to nixos-26.05 2026-09-25 18:39:34 +00:00
flake.nix test: add suite covering segment, srt, config, api fallbacks, cli 2026-09-25 19:12:21 +00:00
pyproject.toml test: add suite covering segment, srt, config, api fallbacks, cli 2026-09-25 19:12:21 +00:00
README.md feat: directory batch mode and restructured progress output 2026-09-27 18:09:23 +00:00

transcribe

CLI tool that turns a video file into timestamped SRT subtitles:

video → compact mono 16 kHz audio (ffmpeg; MP3 or AAC/M4A) →
        silence-aligned chunks → OpenAI-compatible Whisper endpoint
        (.env config) → per-chunk drift correction + offset stitching →
        locally segmented SRT

Accepts a single file or a directory (flat scan for mp4 webm mkv mov m4v avi mpg mpeg mp3); see Batch mode below.

Subtitle cues are built locally from the returned timestamps, so cue rules (max words per cue, max cue duration, punctuation closing, …) are fully configurable and independent of provider features. If the provider returns no word-level timestamps (common for whisper-large-v3 style endpoints that report words: null), word times are interpolated across provider segments and the same cue rules apply.

Installation / environment

The project is a nix flake; everything (Python, dependencies, ffmpeg, linters) comes from the pinned flake.lock:

direnv allow          # after copying .env.example to .env (see below)
# or, without direnv:
nix develop -c transcribe --help

Configuration

Copy .env.example to .env and fill in your provider. Precedence: built-in defaults < .env < CLI flags (real environment variables beat .env file contents).

Required

Variable Meaning
TRANSCRIBE_BASE_URL API root without trailing slash, e.g. https://api.example.com/v1 — the client appends /audio/transcriptions
TRANSCRIBE_MODEL model name, e.g. whisper-large-v3
TRANSCRIBE_API_KEY bearer token — never logged, .env is gitignored

Optional (defaults shown)

Variable Default Meaning
TRANSCRIBE_MAX_UPLOAD_MB 25 refuse uploads above this size (25 MiB ≈ 70 min of video at 48k mono)
TRANSCRIBE_MAX_WORDS_PER_CUE 10 hard cap on words per cue
TRANSCRIBE_MAX_CUE_DURATION_S 5.0 hard cap on cue duration (seconds)
TRANSCRIBE_SPLIT_ON_PUNCTUATION true close cue after sentence-final punctuation (. ! ?)
TRANSCRIBE_MIN_GAP_S 0.6 inter-word silence starting a new cue (word mode only)
TRANSCRIBE_MAX_CHARS_PER_CUE 84 hard cap on characters per cue

Usage

transcribe video.mp4                          # writes video.srt
transcribe video.mp4 -o out.srt               # explicit output path
transcribe ~/videos/                          # batch: every video in the folder
transcribe ~/videos/ --force                  # batch, regenerate existing .srt
transcribe video.mp4 --max-words 8 --max-duration 4
transcribe video.mp4 --no-punct-split         # keep sentences in one cue
transcribe video.mp4 --keep-audio              # also keeps the audio track
transcribe video.mp4 --audio-bitrate 32k      # smaller upload for long videos
transcribe video.mp4 --audio-format m4a       # drift-proof audio format
transcribe video.mp4 --no-drift-correction    # keep provider timestamps as-is
transcribe video.mp4 --drift-scale 0.982      # manual drift correction
transcribe video.mp4 --chunk-duration 300     # smaller chunks, tighter anchoring
transcribe video.mp4 --no-chunking            # single whole-file request

Progress and diagnostics go to stderr (prefixed #), the output path of each file to stdout — safe to script around. Exit codes: 0 success (or all files skipped), 1 pipeline error / any file failed in batch mode, 2 invalid invocation.

Batch mode

Passing a directory scans it flat (non-recursive) for known video/audio extensions (mp4 webm mkv mov m4v avi mpg mpeg mp3) and transcribes each file to <name>.srt beside it:

  • files whose plain <name>.srt already exists are skipped; --force regenerates them. Language-specific files like <name>.cs.srt never block — the plain .srt is still generated (and language-specific files are never touched).
  • a file that fails (corrupt input, API error, a failing chunk) is logged as ERROR ... and skipped; the run continues with the rest and exits 1 if anything failed.
  • the end-of-run summary: line counts transcribed/skipped/failed files.
  • note: --keep-audio writes <video>.mp3 next to videos, and a later folder scan will pick those .mp3 files up — their output (<name>.srt) usually exists already, so they are skipped unless --force is passed (which just re-transcribes the same audio).
  • --output only works with a single input file.

Progress output is structured as file headers with indented chunk lines (one per chunk) and a per-file done: summary:

# provider: https://api.example.com/v1 (model: whisper-large-v3)
# lecture.mp4 (1/5)
#   extracting audio (mono 16 kHz MP3 @ 48k)
#   13 chunks planned (target 480s)
#   chunk  1/13 [   12.0s.. 489.7s] 2.7 MiB | audio 477.8s extent 488.3s (x1.022) corrected x0.9785
#   chunk  2/13 [  490.2s.. 971.3s] 2.8 MiB | audio 481.0s extent 491.2s (x1.021) corrected x0.9792
#   ...
#   done: 13 chunks, 12 drift-corrected, provider segments, 1824 cues -> lecture.srt
# skipped: intro.mp4 (srt exists)
# ...
# summary: 5 file(s) — 3 transcribed, 1 skipped, 1 failed

How timestamps are obtained

The client negotiates with the provider automatically:

  1. response_format=verbose_json with word+segment granularity — uses words[] when the provider serves them;
  2. retries without granularity fields if the provider rejects them (HTTP 400), then uses segments[];
  3. falls back to response_format=srt — the provider's own SRT is passed through unchanged; local cue settings do not apply in this mode (a warning is printed).

The stderr line # timestamp source: … reports which tier was used.

In the segment path, each segment's [start, end] interval is distributed across its words proportionally to character positions (char-weighted linear interpolation). The first token of a segment starts exactly at the segment start and the last token ends exactly at the segment end; in-between times are estimates.

Chunked transcription

Providers that transcribe in fixed 30 s windows (processed in parallel and stitched) have been observed to distort the timeline non-uniformly: silence is condensed away (a 10 s silent intro simply disappears, subtitles start at 00:00), sparse windows are re-tiled wall-to-wall, and the distortion accumulates over long files. A single global correction cannot fix this because the error differs in every window — dense files drift late, sparse files drift early.

By default (--chunking, target --chunk-duration 480), the audio is therefore split at detected silences into chunks that each start at a speech onset and end at a following silence:

  • each chunk is transcribed as its own request, and its timestamps are shifted by the chunk's true start before merging — error cannot accumulate across the file, and a silent intro is represented correctly (the first chunk starts where speech starts);
  • chunks are speech-dense, so the provider's window re-tiling has no silence to distort;
  • the drift correction (below) is applied per chunk, anchored to each chunk's own duration;
  • chunks are small (480 s ≈ 2.9 MiB at 48 k), so long videos no longer need --audio-bitrate 32k to fit upload limits.

--no-chunking uploads the whole file as one request (with the whole-file drift correction instead). Files no longer than the chunk target are a single chunk — with leading/trailing silence trimmed.

Residual limitation: pauses inside a chunk can still be slightly distorted by the provider; the error is bounded to the chunk instead of accumulating. If a provider returns SRT (fallback tier 3) for a chunk, chunked mode fails with a pointer to --no-chunking, since provider-rendered SRT cannot be offset-stitched.

Subtitle drift / drift correction

Some providers stitch per-window transcription results onto a timeline that runs longer than the real audio — timestamps start in sync and fall progressively behind playback (observed: +1.8% on a 12-minute file, i.e. the last cue ~14 s late; the provider's own duration field was correct, so no audio format works around it).

After every run the CLI compares the extracted audio's true duration (ffprobe) against the time extent of the provider's timestamps. Timestamps beyond the audio end are physically impossible, so when the extent exceeds the real duration the CLI rescales all timestamps by real_duration / provider_extent (anchored at 0) and logs it:

# audio duration 760.4s, provider time extent 774.0s (ratio 1.018)
# drift correction: provider timestamps ran 1.018x the audio duration — rescaling by 0.9824

Notes and overrides:

  • In chunked mode (default) the check runs per chunk, anchored to each chunk's own duration — the correction is tight and bounded.
  • The correction never fires when the extent falls short of the audio duration — that is trailing silence (end-credit music), not drift.
  • --no-drift-correction disables it; the detected ratio is still logged with a warning.
  • --drift-scale FACTOR (e.g. 0.982) rescales manually, bypassing detection — use it when a video ends with non-speech so the automatic anchor (audio end) undercorrects. To calibrate: transcribe the file with --keep-audio, find a distinctive last spoken phrase, measure where it actually occurs in the audio, and set FACTOR = true_time / provider_time for that phrase.
  • Provider-generated SRT (fallback tier 3) cannot be corrected locally; a note is logged when drift is detected in that mode.

Development

nix develop                    # enter the environment
nix develop -c pytest --cov=transcribe   # test suite (no network)
nix develop -c statix check    # nix lint, then deadnix, then alejandra

Project layout: pipeline stages are separate modules (src/transcribe/{audio,api,segment,srt}.py) so a future LLM-based context-correction stage can be inserted cleanly. segment.py is pure (no I/O) and the main unit-test target. See AGENTS.md for the full project rules.