Transcribe
./transcribe too_long.webm > transcript.txt
Speaker detection and matching across the recording happen automatically.
You do not need to know the number of speakers or choose a model.
Use --srt for subtitles or --no-diarization for text without speaker labels.
Requires Bash 5.1 or newer, FFmpeg, curl, jq, Python 3, and standard coreutils. Keep
chunks.py, subtitles.py, transcript.py, and diarization.py beside the script.
No local model or additional Python package is required.
Set OPENROUTER_API_KEY for transcription and PYANNOTE_API_KEY for speaker
diarization. Existing environment values are respected. When OPENROUTER_API_KEY
is unset, the script reads its key with the 1Password CLI (op) using
OPENROUTER_API_KEY_REF, which defaults to
op://API Keys/openrouter-mai-transcribe-2/credential. For Pyannote, either set
PYANNOTE_API_KEY directly or set PYANNOTE_API_KEY_REF to your own 1Password
reference. There is no default Pyannote reference. op is required only when
resolving a reference. --no-diarization needs only the OpenRouter key.
Accepts m4a, mp4, aac, flac, opus, mp3, ogg, wav, and other formats your
FFmpeg supports. Uses the first audio track, converts it to mono 16 kHz MP3
at 64 kbps (requires FFmpeg's libmp3lame encoder),
and sends it to OpenRouter's microsoft/mai-transcribe-2 model. Temporary
files are removed on exit. The transcript goes to stdout; progress and errors
go to stderr. Output is emitted only after all requests and conversion succeed.
Speaker diarization is enabled by default. MAI supplies the transcription
text and word timestamps through OpenRouter, with its own diarization disabled.
The script separately uploads the whole recording as lossless mono 16 kHz
FLAC to Pyannote Precision-2. Pyannote detects
the speakers across the recording and returns a timeline; the script assigns
those speaker labels to MAI's words without rewriting their text, timestamps,
or order. --no-diarization skips Pyannote and removes any unsolicited speaker
labels in the transcription response. It works with plain text and --srt.
Text output prints speaker IDs above each paragraph:
[Speaker 0]
How did you get started?
[Speaker 1]
It was a long time ago.
Text wraps at 80 columns (except an unusually long unbroken word), with blank
lines at speaker changes and pauses of at least two seconds. Long turns split
at sentence endings after about 400 characters, or at 800 characters even
without punctuation. The formatter preserves the words and their order.
Unlabelled passages use [Speaker unknown] when other words have labels; a
response without any labels is printed without speaker markers. A warning on
stderr reports missing or incomplete labels when diarization was requested.
Speaker labels use audio evidence; they are not names inferred from text.
SRT uses labels to break cues at speaker changes but does not print the IDs.
Long recordings are automatically split into MAI uploads of at most
30 minutes in either mode, including overlap and MP3 padding. Use
--chunk-minutes N (an integer from 1 to 30) to override the limit. At 64 kbps,
a 30-minute upload is about 14.4 MB of MP3 or 19.2 MB of base64.
These are local defaults, not guaranteed provider limits:
OpenRouter documents a 60-second processing timeout,
so reduce the chunk size if requests still time out.
For each boundary, the script looks for a pause of at least 0.3 seconds in the last 30 seconds before the limit, using a -35 dBFS peak threshold. It joins in the middle of the last suitable pause, or at the duration boundary if there is no pause. Each upload includes three seconds beyond the join, so neighbouring uploads share six seconds of audio even during continuous speech. The duration cap includes this overlap; the next chunk starts before the previous one ends.
With speaker output enabled, and for all chunked requests, MAI is asked for
word timestamps, including for plain text. Short requests with
--no-diarization retain the provider's existing text line breaks and add
wrapping to long lines.
The script matches words and their times in the overlap, switches transcripts
at a shared word, and keeps the repeated passage once. It prefers a matching
phrase to distinguish intentional repetitions. If no words match, it uses
timestamp ownership and warns when speech near the join needs review. Overlap
protects words at the audio cuts, but recognition errors or inaccurate model
timestamps can still require corrections. Missing or invalid word timestamps
are an error. Plain text for a chunked recording is assembled from the words.
SRT timestamps are shifted back to the original recording's timeline before
cue generation, with continuous cue numbering. Text uses [Speaker 0],
[Speaker 1], etc., without exposing upload boundaries to the reader. Speaker
numbering follows first appearance in the transcript. One Pyannote job supplies
the identities for the entire recording, including speakers absent from a MAI
chunk overlap. MAI's request-local speaker IDs are not used.
If you already know the count, --speakers N (1 to 64) supplies it to Pyannote
as an optional constraint. For example:
./transcribe --speakers 2 too_long.webm > transcript.txt
--speaker-model MODEL accepts precision-2 (the default) or community-1.
Both run through the Pyannote API; community-1 does not load a local model.
Neither flag is needed for the normal command. Both require diarization and
cannot be combined with --no-diarization.
The script requests Pyannote's exclusive diarization timeline, where at most one speaker is active at a time, and aligns it with MAI word timestamps. A word goes to the unique speaker whose turns cover the largest share of its duration, provided that share is at least half. If no turn overlaps the word, the nearest turn is usable only if the entire word is within 0.2 seconds of it. Ambiguous or tied assignments remain unknown and produce warnings. Malformed timelines, failed jobs, or a completely empty timeline for nonempty speech fail the command without partial stdout. The two models may disagree about timing, and even a known speaker count does not guarantee correct assignments. Review brief interjections, crosstalk, and changes in recording conditions such as host-read advertisements. Local tests verify the integration and validation rules; actual speaker accuracy requires checking the output against the recording.
Pyannote accepts a recording up to 24 hours and a FLAC upload up to 1 GiB, according to its API limits. The script checks these bounds and uses bounded polling for its job. The whole-recording job adds processing time and Pyannote charges. Uploaded audio is automatically deleted within 48 hours, and job results after 24 hours, under Pyannote's retention policy. Temporary local audio is removed on exit.
Audio is first decoded to temporary mono 16 kHz PCM (about 115 MB per hour),
so all cuts share the same sample clock even when container duration metadata
is missing. Up to eight chunks are encoded and transcribed concurrently
by default. Use --jobs N (an integer from 1 to 64) to change the limit;
--jobs 1 runs sequentially. Each worker uses separate temporary files and
releases its MP3 and request payload when finished, so staged upload data is
bounded by the number of workers. Lower --jobs to reduce simultaneous CPU,
memory, disk, or provider demand.
Responses may finish in any order; merging and speaker numbering always follow the recording's original order. A failed worker stops further scheduling and cancels the remaining workers. Interruptions also stop workers and their child processes before temporary files are removed. Requests already accepted by the provider may still be processed and billed. Transcription and job submission are not automatically retried; transient polling failures retry the same job. There is no automatic resume, and rerunning sends earlier chunks again. No transcript is emitted unless every worker, the enabled Pyannote job, and the final conversion succeed.
Use --diagnostics DIR to save intermediate JSON for reviewing an individual
run:
./transcribe --diagnostics review-run too_long.webm > transcript.txt
The script creates a new private directory; it does not reuse an existing one.
It saves chunks.json (the upload plan), transcript.json (the assembled MAI
transcript before speaker assignment), diarization.json (the successful
Pyannote job ID, status, and speaker timelines), and labeled.json (the transcript after speaker
assignment), as each stage completes. With --no-diarization, only the chunk
plan and unlabelled transcript are saved. Diagnostics contain transcription
content and may be incomplete after a failed run. They remain available for
review, including if the final text or subtitle conversion fails. They contain no raw audio,
credentials, or signed upload URLs. Keep diagnostics and generated transcripts
out of commits.
Re-render saved results without another provider request:
python3 -B transcript.py review-run/labeled.json > transcript.txt
python3 -B subtitles.py review-run/labeled.json > transcript.srt
--srt requests word timestamps using OpenRouter's verbose_json response.
Words are grouped into cues of at most two lines (42 characters per line,
except an unusually long unbroken word) and six seconds. Cues also break at
sentence endings, pauses of at least 0.7 seconds, and speaker changes when
speaker labels are available. Timing comes from the words, rounded to
milliseconds. If word start times move backwards, they are clamped to the
preceding word's start without reordering the transcript; end times are extended
to the adjusted start only when needed. Subtitle output goes to stdout; missing
or invalid word timestamps produce an error. Without --srt, output remains
plain text.
One input file per invocation. To run the local regression tests:
python3 -B -m unittest -v