- Python 99%
- Nix 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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.
|
||
| reports | ||
| src/transcribe | ||
| tests | ||
| .coverage | ||
| .env.example | ||
| .envrc | ||
| .gitignore | ||
| AGENTS.md | ||
| flake.lock | ||
| flake.nix | ||
| pyproject.toml | ||
| README.md | ||
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>.srtalready exists are skipped;--forceregenerates them. Language-specific files like<name>.cs.srtnever block — the plain.srtis 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 exits1if anything failed. - the end-of-run
summary:line counts transcribed/skipped/failed files. - note:
--keep-audiowrites<video>.mp3next to videos, and a later folder scan will pick those.mp3files up — their output (<name>.srt) usually exists already, so they are skipped unless--forceis passed (which just re-transcribes the same audio). --outputonly 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:
response_format=verbose_jsonwith word+segment granularity — useswords[]when the provider serves them;- retries without granularity fields if the provider rejects them
(HTTP 400), then uses
segments[]; - 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 32kto 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-correctiondisables 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 setFACTOR = true_time / provider_timefor 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.