Architecture¶
This page is for contributors who want to understand how the pieces fit together before changing them. It complements CONTRIBUTING.md, which covers dev setup, tests, and PR expectations.
Module map¶
Each module has one job:
| Module | Responsibility |
|---|---|
audio.py |
Records audio with ffmpeg. Nothing else — no transcription, no printing. |
pulse.py |
Enumerates and resolves PulseAudio/PipeWire sources via pactl. |
engines.py |
The transcription engines, the TranscriptionEngine protocol, and the registry that picks between them. |
transcribe.py |
Thin entry points over the engine registry (transcribe_file, transcribe_file_cues). |
formats.py |
Transcript data types (Cue, Transcript) and the txt/srt/vtt/json/tsv renderers. Pure formatting — no engine imports, no I/O. |
pipeline.py |
Combines recording and transcription into the once and live workflows. |
constants.py |
Shared defaults: model, sample rate, segment length, timeouts, etc. |
utils.py |
Small shared helpers (e.g. which()). |
__main__.py |
The CLI: argument parsing, validation, and the only module that prints or writes files. |
The dependency direction is one-way: audio.py and formats.py know nothing about each
other or about engines; pipeline.py wires recording, engines, and formats together; and
__main__.py is the only layer that talks to the user (stdout, output files, exit codes).
This keeps every layer below the CLI usable as a library — see the
Python API page.
Data flow¶
once mode¶
record_once() -> WAV file -> transcribe_file_cues() -> render_transcript() -> print / write
pipeline.transcribe_once_cues() records to a temporary WAV file, hands it to the engine
registry, and returns a Transcript (a list of Cue(start, end, text) plus the detected
language). __main__._run_once() renders that transcript in the requested --format and
both prints it and writes it to --output, if given. transcribe_once() is the plain-text
convenience wrapper used when timed cues aren't needed.
live mode¶
iter_audio_segments() -> transcribe_live() -> TranscriptSegment -> per-segment render -> print / append
audio.iter_audio_segments() is a generator: it spawns ffmpeg with the segment muxer, polls
for finalized chunks, and yields one AudioSegment(index, path, lag, dropped) per chunk. It
knows nothing about transcription.
pipeline.transcribe_live() wraps that generator, transcribing each segment on a
single-worker thread pool with a timeout, and yields a TranscriptSegment for each one —
even a failed or silent one, so the caller always sees a contiguous timeline. A segment
that times out doesn't block the next one: its worker is abandoned rather than reused.
__main__._run_live() consumes that stream, formatting and printing each segment as it
arrives. Plain text is printed and appended to the output file line by line; the timed
formats (srt/vtt/json/tsv) are streamed through a TranscriptFormatter from formats.py
so the header, cues, and footer stay consistent with a one-shot render. This loop is also
where the stop policies live — silence timeout, consecutive-failure limit, and the
--max-lag/--on-lag backpressure policy — since they're about when to stop consuming the
stream, not about recording or transcribing.
AudioSegment.lag and .dropped (surfaced on TranscriptSegment too) describe how far
behind the recording transcription has fallen and let the CLI warn or drop old audio to
catch back up, rather than growing an unbounded backlog.
The engine registry¶
Every transcription backend implements the same small protocol, defined in engines.py:
class TranscriptionEngine(Protocol):
name: str
def is_available(self) -> bool: ...
def transcribe(self, path: str, config: TranscriptionConfig) -> Transcript: ...
def unload(self) -> None: ...
ENGINES is a list of instances, in the order auto prefers them; ENGINE_NAMES derives
the --engine CLI choices from that same list. get_engine(name):
- for
"auto", returns the first engine whoseis_available()isTrue, raising aRuntimeErrornaming every engine it tried if none is installed; - for a specific name, returns that engine without checking availability, so a deliberately-chosen backend reports its own diagnostic (e.g. a missing dependency) rather than a generic "not available" message;
- for anything else, raises
ValueError.
Backends import their heavy dependencies (faster_whisper, whisper, subprocess calls to
whisper-cli) lazily, inside the methods that need them, so importing sys2txt never pays
for a backend you don't use. Engines that load a model (FasterWhisperEngine,
OpenAIWhisperEngine) extend _CachedModelEngine, which keeps one loaded model per engine
instance, keyed on whatever makes it a different model (e.g. (model, device,
compute_type)), and reloads only when that key changes.
Adding a new engine¶
- Write a class that implements the
TranscriptionEngineprotocol (or extend_CachedModelEngineif it loads a model). It should return aTranscriptwith real per-utteranceCuetimings —config.timestampsonly affects plain-text rendering, which happens later informats.py, not in the engine. - Add an instance of it to
ENGINESinengines.py, in the position you want it tried inautomode. - That's it —
ENGINE_NAMES, the--engineCLI choice, and theautofallback all derive fromENGINES.
Where to look next¶
- Python API documents the same functions (
transcribe_once,transcribe_live,iter_audio_segments, etc.) from a library-user's point of view. - CLI reference documents every flag
__main__.pyexposes. - Engines & devices covers choosing and configuring an engine at runtime, rather than implementing one.