Host STT automation¶
Advanced host-side transcription helpers. Mainstream path (Import Transcript, Transcribe Audio): Transcription. Running Compose + host whispermlx + optional WhisperX/WebUI: STT stacks. Audio merge/preprocess before STT: Audio prep.
There is no transcriptx transcript … terminal subcommand. Validate and import from the GUI, or from code as below.
whispermlx is macOS-host only. Do not docker exec into transcriptx-web to run it.
Finding whispermlx¶
which whispermlx
If not found, set WHISPERMLX in whisperx.env at the repo root to the full binary path.
Host note (macOS whispermlx): pyannote may dump a long torchcodec is not installed correctly warning (FFmpeg ABI / PyTorch mismatch while probing libtorchcodec). If VAD and transcription continue and segments appear, treat it as noise — whispermlx usually hands pyannote a preloaded waveform. Worth aligning torchcodec + PyTorch + FFmpeg only if a later job needs pyannote to decode a file path directly and fails.
Environment defaults (whisperx.env)¶
Copy docs/recipes/whisperx/whisperx.env.example to whisperx.env and configure:
Variable |
Purpose |
|---|---|
|
Path to whispermlx binary |
|
Default model (e.g. |
|
Default language |
|
Default diarization on/off |
|
Per-file timeout (0 = no limit) |
|
Required when diarization is on |
whispermlx-missing bulk script¶
Install once from the repo root (not shipped as a package entrypoint):
mkdir -p ~/.local/bin
install -m 755 scripts/whispermlx-missing.py ~/.local/bin/whispermlx-missing
# ensure ~/.local/bin is on PATH (new shell, or: export PATH="$HOME/.local/bin:$PATH")
which whispermlx-missing
If command not found, either PATH is missing ~/.local/bin or the install step was skipped. You can also run without installing:
python3 scripts/whispermlx-missing.py --dry-run …
It processes MP3s in a source folder that lack matching JSON in a transcripts output folder.
When convert_wavs is on (default), it first converts .wav files in the source folder to
16 kHz mono 64k MP3 (same settings as inbox-watch), then moves the WAV into the WAV backup folder.
WAV → MP3 + archive: On by default. Converts .wav in --source to MP3 in the same folder,
then moves the WAV to --wav-backup (TRANSCRIPTX_WAV_BACKUP_DIR / portable default).
Skips when an MP3 with the same stem already exists. Disable with --no-convert-wavs or
"convert_wavs": false in JSON.
Resume / duplicates: stems with matching JSON are skipped by default — in --transcripts (typically …/originals), in the parent library root when that folder is named originals (already-imported canonical JSON), as foo (N).json import-archive names, or as a sidecar next to the MP3. Use --force / --rerun to replace after a valid new JSON is produced. --fuzzy-json-match also treats foo-….json / foo_….json / foo.….json as already done. Writes still go only to originals/ (see STORAGE.md).
Skip likely serial parts: --skip-serial (also JSON skip_serial / WHISPERMLX_SKIP_SERIAL) does not transcribe MP3s that Tools → Auto-merge would group as split parts or voice-note runs (meeting_part2, timestamp _1/_2, WhatsApp bursts, …). Merge those files first, then transcribe the *_merged.mp3. Standalone files still run. --force does not override this; use --no-skip-serial. When TranscriptX is importable (repo checkout / installed package), detection uses the same Auto-merge profiles; otherwise a filename + common voice-note fallback. Opt-in (off by default).
Dry-run: --dry-run previews work without requiring HF_TOKEN or a working whispermlx binary.
Partial failures: failed items leave temps under transcripts/.whispermlx-missing/tmp/; no partial JSON is written to the transcripts root. --clean-failed removes those temps.
Spaces: pass paths via quoted CLI args (the Transcribe Audio generator does this) or via the JSON config file.
Local config (gitignored): copy config/whispermlx-missing.example.json to .transcriptx/whispermlx-missing.json and set your paths. For standalone use outside the repo, pass --config /path/to/config.json or set WHISPERMLX_MISSING_CONFIG.
Config merge order: portable repo defaults ← TRANSCRIPTX_* / WHISPERMLX* env ← local JSON ← CLI flags.
When the script will process
sourceandtranscriptsare each set via CLI, local JSON, orTRANSCRIPTX_*env (not portable defaults alone).A fresh clone with no local JSON and no env overrides will not auto-run batch transcription.
When it only saves or prints config
--show-config— print effective settings; never runs whispermlx.--save-configwithout meaningful paths — writes.transcriptx/whispermlx-missing.jsonand exits.Normal run with portable defaults only — prints guidance; does not process.
Transcripts path semantics
Source |
Meaning |
|---|---|
|
Transcripts base directory; script appends |
|
Exact output directory — must be |
|
Maps directly to |
WAV archive |
|
Convert WAV in source |
|
ffmpeg for WAV convert |
|
Host helpers write raw engine JSON under originals/ only. Library admission requires Import Transcript, Settings → Watcher, or optional inbox-watch --admit (admit_and_register), which writes canonical schema_version / source markers plus an import sidecar.
whisperx.env is used only for the whispermlx subprocess environment (HF_TOKEN, etc.), not for resolving config paths. Repo .env is loaded early (without overriding existing shell env) for TRANSCRIPTX_* path overrides — same pattern as Docker/native TranscriptX.
Host inbox watcher (inbox-watch)¶
Optional host-side companion (not the in-app Settings → Watcher). Watches a drop folder for new audio and/or transcripts. Streamlit never runs it. After convert/copy, STT is --transcribe (default whispermlx-missing on configs that omit the key — that default is the macOS MLX path). Admission into the library is off by default; pass --admit (or "admit_to_library": true) to run python -m transcriptx.admit_originals after convert/copy/STT.
OS honesty¶
Host OS |
Analysis GUI |
inbox-watch STT |
Notes |
|---|---|---|---|
macOS |
Compose or native |
Default |
Preferred stack; optional launchd agent below |
Linux |
Compose |
|
No whispermlx. Convert + admit work with |
Windows |
Docker Desktop / WSL2 Compose |
|
No whispermlx. No launchd/Task Scheduler sample yet. Use |
Combined stacks: STT stacks.
Install / run¶
macOS / Linux (from the repo root):
mkdir -p ~/.local/bin
install -m 755 scripts/inbox-watch.py ~/.local/bin/inbox-watch
which inbox-watch
# or without installing:
python3 scripts/inbox-watch.py --once --dry-run …
Windows (PowerShell or cmd; from the repo root):
py -3 scripts\inbox-watch.py --once --dry-run …
Requires ffmpeg.exe on PATH (or --ffmpeg) for --watch-audio. For --admit, discovery looks for .transcriptx\Scripts\python.exe and .venv\Scripts\python.exe (as well as Unix bin/python layouts). Override with:
py -3 scripts\inbox-watch.py --once --admit --admit-python .transcriptx\Scripts\python.exe …
Mode |
What it does |
Skip when |
|---|---|---|
|
Convert new inbox audio to 16 kHz mono 64k MP3 in the recordings folder |
Recordings already has that stem (any audio extension) |
|
Host STT after convert: |
|
|
Copy new JSON/SRT/VTT/txt/html into the transcripts dest |
Dest already has that stem (any transcript extension) |
|
After audio/transcript handling, admit eligible files in the transcripts dest (typically |
Already-imported stems; |
|
After admit, auto-write speaker display names (implies |
Fail-open: leaves |
|
After admit, create longitudinal profile links for matched enrolled / named profiles ( |
No new profiles from first-meeting names; does not enrol voice samples |
|
Disable that mode |
At least one mode must stay on |
--transcribe is independent of --watch-audio except that STT only runs when audio watching is on. Existing configs with no transcribe key keep whispermlx-missing.
|
Behaviour |
|---|---|
|
After convert, run |
|
Convert, copy, and optional |
|
Run |
transcribe_cmd is a JSON array of strings, or one CLI string passed through shlex.split. Placeholders {recordings}, {transcripts}, and {env_file} substitute only as a whole argv token or inside a single argv (for example -v then {recordings}:/audio). Unknown {…} tokens fail closed. {recordings} and {transcripts} are required. Empty transcribe_cmd with transcribe=command is a config error before ffmpeg runs.
WhisperX-shaped example (host Docker; not executed by Streamlit):
{
"transcribe": "command",
"transcribe_cmd": [
"docker", "run", "--rm",
"-v", "{recordings}:/audio",
"-v", "{transcripts}:/output",
"-e", "HF_TOKEN",
"ghcr.io/m-bain/whisperx:latest",
"whisperx", "/audio",
"--output_dir", "/output"
]
}
# Preview (no ffmpeg, no copy, no whispermlx)
inbox-watch --once --dry-run \
--inbox /path/to/drop \
--recordings /path/to/recordings \
--transcripts /path/to/transcripts/originals
# One scan (cron / launchd)
inbox-watch --once --inbox … --recordings … --transcripts …
# Poll until Ctrl-C. If the USB inbox path is missing, --watch keeps running
# (empty scans) and still runs configured STT + --admit on the first cycle.
inbox-watch --watch --interval 5
# Convert without whispermlx (Linux/Windows host, or BYO STT later)
inbox-watch --once --transcribe none --inbox … --recordings …
# Same, and admit new originals/ JSON into the library
inbox-watch --watch --admit
# USB → diarized transcript with named speakers (and profile links)
inbox-watch --watch --auto-name
# names only:
inbox-watch --watch --auto-name --no-auto-link
--auto-name / --auto-link pass through to python -m transcriptx.admit_originals. Standalone: python -m transcriptx.identify_speakers --path FILE --auto-name --auto-link. Settings → Speakers stores ingest defaults in config_dir/identify.json. Full operator reference (knobs, fusion, review): Auto-identify speakers.
ffmpeg (audio mode): -nostdin -y -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k -f mp3. Writes a temp .mp3.partial file then renames into recordings so whispermlx-missing never sees a half-written MP3. -f mp3 is required so ffmpeg 8+ can mux even when the temp name does not end in .mp3.
Local staging (removable inbox)¶
--inbox is still the drop folder (USB volume or any configured path). When that folder is on a removable / ejectable volume, audio is copied to a local staging directory before ffmpeg runs, so a flaky USB stick is not held open for a multi-hour convert.
Setting |
Default |
Override |
|---|---|---|
When to stage |
Auto: macOS |
|
Stage dir |
|
|
A complete staged file with the same size as the inbox original is reused on the next cycle (resume after a failed convert). A half-written .inbox-staging.{name}.partial is discarded and the copy is retried. After a successful convert, the staged copy is moved into the WAV backup folder when --backup-wav is on; otherwise it is deleted. Inbox sources are still kept unless --delete-originals / --move-processed.
Local/internal inbox folders skip staging unless you pass --stage-local.
# Force staging even when the inbox is already on local disk
inbox-watch --once --stage-local --inbox ~/Drop --recordings ~/Documents/recordings …
# Never stage (ffmpeg reads the inbox path, as before)
inbox-watch --once --no-stage-local --inbox /Volumes/USB-DISK/RECORD …
Terminal feedback¶
Host output mirrors the analysis CLI Review before run / Run summary shape (plain text; the script does not import transcriptx or Rich):
Review before cycle — inbox / recordings / transcripts paths, modes, and candidate file list
Processing —
[i/n] audio|transcript: filename, then indented convert/copy/skip lines; long encodes print elapsed time and stream ffmpegtime=/speed=on stderrTranscription (whispermlx-missing) — when audio mode ran (child process output follows)
Library admit — when
--admitran (python -m transcriptx.admit_originalsoutput follows)Run summary —
Status(completed/partial/failed/dry-run), counts, and limited bullet lists for converted / skipped / failed
Example (abbreviated):
---
Review before cycle
---
Mode: once
Inbox: /Volumes/USB-DISK/RECORD
Candidates: 1 (1 audio, 0 transcript)
Will consider:
• audio: R20260814-175320.WAV
---
---
Processing
---
[1/1] audio: R20260814-175320.WAV
Staging: R20260814-175320.WAV -> R20260814-175320.WAV (… GiB)
Staged: R20260814-175320.WAV (… GiB) in 12.3s
Converting: R20260814-175320.WAV -> R20260814-175320.mp3 (… GiB)
ffmpeg progress on stderr (time=/speed=)…
Converted: R20260814-175320.WAV -> R20260814-175320.mp3 (… MiB) in 123.4s
---
---
Run summary
---
Status: completed
Converted: 1
…
---
Long WAV→MP3 converts can take minutes with little stdout while ffmpeg prints progress on stderr — that is expected. Ctrl-C stops the cycle (Stopped.); a half-written .mp3.partial is discarded on the next failed/interrupted convert. A finished local staged copy is kept so the next cycle can skip the USB copy.
Inbox sources are kept by default. After a successful convert (audio) or copy (transcript):
Option |
Effect |
|---|---|
|
Copy (or move the staged local copy when staging ran) the audio original into the WAV backup folder ( |
|
Delete the inbox source (after backup, if backup was requested and succeeded) |
|
Relocate the inbox source instead of deleting (mutually exclusive with |
|
Overwrite an existing destination stem |
--backup-wav and --delete-originals are independent (use either or both). Deleting with no backup prints a warning. A failed WAV backup skips delete for that file.
Local config (gitignored): copy config/inbox-watch.example.json to .transcriptx/inbox-watch.json. Repo .env is loaded with setdefault (existing shell env wins). Merge order: portable repo defaults ← .env / TRANSCRIPTX_* / INBOX_WATCH_* ← local JSON ← CLI.
Key |
JSON |
Env |
|---|---|---|
Drop folder |
|
|
Recordings |
|
|
Transcripts dest |
|
|
WAV archive |
|
|
Convert audio |
|
|
Host STT after convert |
|
|
STT argv template |
|
|
Copy transcripts |
|
|
Admit to library (default off) |
|
|
Auto-name speakers after admit |
|
|
Auto-link matched profiles |
|
|
Admit interpreter |
|
|
Config path |
— |
|
Stage off removable inbox first |
|
|
Staging folder |
|
|
Also |
|
|
Library admit needs a native TranscriptX install (the JSON/--admit-python interpreter must import transcriptx). It does not enter the Docker analysis container. Set TRANSCRIPTX_TRANSCRIPTS_DIR and TRANSCRIPTX_OUTPUT_DIR to the same host folders Docker mounts so the GUI index stays in sync.
Enable admit in local JSON (and/or .env INBOX_WATCH_ADMIT=1):
"admit_to_library": true,
"admit_python": "/path/to/python3"
macOS login agent (optional): scripts/macos/inbox-watch-agent.sh plus scripts/macos/com.transcriptx.inbox-watch.plist can run --watch at login. The plist is a template (/Users/you/...); copy it to ~/Library/LaunchAgents and replace those paths. The agent script uses the repo .venv / .transcriptx interpreter when present, otherwise python3 on PATH. Admit and --transcribe are controlled by local JSON / .env, not by the plist. If the USB inbox is unplugged, --watch keeps polling empty cycles; the first cycle still runs configured STT (when --watch-audio is on) and --admit if enabled. Logs: .transcriptx/inbox-watch.launchd.log.
Do not point this inbox at the same folder as the in-app G2 watcher unless you intend both to handle new transcripts (G2 admits; inbox-watch copies). See directory_watcher.md.
Voice notes (voice-note-watch)¶
Optional host-side sibling for a short personal note (roadmap G4). Not a Streamlit page, not Settings → Watcher, and not a second inbox-watch config. A morning recording in the library inbox (on the owner machine, /Volumes/USB-DISK/RECORD) stays a library item. Do not turn library admit off to make notes fit, and do not point G2 at that same folder.
This script has no library admission and no --auto-name. It does not import transcriptx. Output is plain text, not diarized JSON under originals/. Default off: nothing starts it. Do not add it to the login agent that runs inbox-watch --watch.
Install once from the repo root:
mkdir -p ~/.local/bin
install -m 755 scripts/voice-note-watch.py ~/.local/bin/voice-note-watch
Or run without installing: python3 scripts/voice-note-watch.py --once --dry-run ….
Piece |
Rule |
|---|---|
Inbox |
Own folder. A subdirectory such as |
Audio |
Removable volume → local stage → ffmpeg 16 kHz mono 64k MP3, same settings as inbox-watch. MP3s go to |
Text |
|
Length cap |
|
Overflow |
Optional |
Triage |
|
voice-note-watch --once --dry-run \
--inbox /Volumes/USB-DISK/RECORD/braindump \
--audio-dir /path/to/voice-notes/audio \
--notes-dir /path/to/voice-notes/text \
--max-duration-seconds 180 \
--library-watch-config .transcriptx/inbox-watch.json \
--library-inbox /Volumes/USB-DISK/RECORD
Local config (gitignored): copy config/voice-note-watch.example.json to .transcriptx/voice-note-watch.json. Env prefix is VOICE_NOTE_WATCH_* only, so INBOX_WATCH_ADMIT cannot turn admission on. Merge order: portable defaults ← VOICE_NOTE_WATCH_* ← local JSON ← CLI.
Library admission of a note, if you want it later, is a separate act (Import Transcript or inbox-watch). This script will not do it.
Import a whole folder (details)¶
On Import Transcript, section Import all from folder scans an absolute local directory (Docker: mount the host folder — typically HOST_TRANSCRIPT_INBOX_DIR → /mnt/transcript-inbox; do not scan /mnt/transcripts or its subdirs) and imports only eligible files:
Supported extensions:
.json,.srt,.vtt,.txt,.html,.htm(case-insensitive).Eligible statuses: new, incomplete (repairable), needs registration. Already-imported stems, stem conflicts, size/symlink/special-file failures, and unrepairable incompletes are blocked (preview uses human labels).
Skips stems that are already in the library (canonical JSON + import sidecar). Incomplete JSON without a safe
originals/provenance is not treated as a new import.Duplicate stems in the folder (including case variants) are all marked conflict — none are imported.
Source files in the scanned folder are never deleted or modified; the app copies into
transcripts/imports/then admits them.Defaults: 100 MiB per file (
TRANSCRIPTX_FOLDER_IMPORT_MAX_FILE_BYTES) and 500 candidates (TRANSCRIPTX_FOLDER_IMPORT_MAX_CANDIDATES). Exceeding the candidate limit fails the scan closed (Import eligible stays disabled).Preview is invalidated if the path, transcripts root, limits, or admission policy change. Use Rescan; a successful folder import auto-rescans so statuses refresh.
Preview includes a read-only audio column: same-stem companions under approved recordings roots (
found: stem.mp3/none). No automatic copy — upload via section 3 or place matching audio for playback linking.
Programmatic admission with registration under one lock: transcriptx.io.admit_and_register.admit_and_register.
Python API¶
Canonical validation is required for library admission and analysis — see STORAGE.md.
Validate a document already loaded as a dict (raises ValueError if invalid):
import json
from pathlib import Path
from transcriptx.io.transcript_schema import validate_transcript_document
path = Path("path/to/transcript.json")
data = json.loads(path.read_text(encoding="utf-8"))
validate_transcript_document(data)
Import raw or legacy transcript files (e.g. WhisperX JSON, SRT, VTT) into the library:
from pathlib import Path
from transcriptx.core.utils.paths import PATHS
from transcriptx.io.managed_import_workflow import run_managed_import_workflow
result = run_managed_import_workflow(
PATHS.transcripts_originals_dir / "whisperx_output.json",
overwrite=False,
)
print(result.json_path)
print(result.sidecar_path)
print(result.archived_original_path)
The import workflow detects the format, normalizes speakers (missing or empty → SPEAKER_UNKNOWN where applicable), writes canonical schema_version/source/metadata, writes a sidecar, and archives the original source. Web flow does not overwrite existing canonical JSON by default; programmatic callers may opt into overwrite as a new import attempt.
Downstream analysis APIs (for example AnalysisRequest + run_analysis) assume transcript_path is a library transcript produced by this workflow or an equivalent loader. Then analyse from the web interface or via AnalysisRequest + run_analysis (see generated/cli.md).
Other engines (AssemblyAI, Deepgram, Google, manual edits): each segment needs start, end, speaker, and text. Use validate (above) and the import workflow so metadata, sidecar, and archive exist.
Multi-language variants¶
Import alternate-language versions of an existing transcript using a flat filename suffix in the same directory:
Base (default):
meeting.jsonFrench variant:
meeting_fr.jsonEnglish explicit variant:
meeting_en.json(optional;meeting.jsonremains the conventional default English path)
Workflow:
Import and identify speakers on the base transcript first (Speaker ID page, or segment-derived names on import).
Import the language variant via the same path (web Import Transcript or
run_managed_import_workflow).On import, speaker-map inheritance runs automatically when the base has a speaker-map sidecar and the variant does not yet.
Requirements: variant segments should use the same diarized speaker IDs as the base (SPEAKER_00, SPEAKER_01, …). See STORAGE.md for variant rules.
What is copied: display names, ignored speakers, and speaker_id_to_db_id. Each variant gets its own sidecar under metadata/speaker_maps/ (see STORAGE.md).
When inheritance is skipped: the filename is not {base}_{lang}, the base transcript is missing, the base has no speaker-map sidecar, or the variant already has its own speaker-map sidecar (re-import safe).
Fallback: if inheritance does not apply, segment original_cue.original_speaker names are used as on a normal import.