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

WHISPERMLX

Path to whispermlx binary

WHISPERMLX_MODEL

Default model (e.g. large-v3)

WHISPERMLX_LANGUAGE

Default language

WHISPERMLX_DIARIZE

Default diarization on/off

WHISPERMLX_TIMEOUT_SECONDS

Per-file timeout (0 = no limit)

HF_TOKEN

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

  • source and transcripts are each set via CLI, local JSON, or TRANSCRIPTX_* 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-config without meaningful paths — writes .transcriptx/whispermlx-missing.json and exits.

  • Normal run with portable defaults only — prints guidance; does not process.

Transcripts path semantics

Source

Meaning

TRANSCRIPTX_TRANSCRIPTS_DIR env

Transcripts base directory; script appends /originals for batch output

transcripts in JSON or --transcripts CLI

Exact output directory — must be …/transcripts/originals (scripts refuse the library root that contains metadata/ / imports/)

TRANSCRIPTX_RECORDINGS_DIR env

Maps directly to source (recordings folder)

WAV archive

wav_backup

Convert WAV in source

convert_wavs (default on)

ffmpeg for WAV convert

ffmpeg

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 whispermlx-missing, or command

Preferred stack; optional launchd agent below

Linux

Compose

--transcribe none or command (WhisperX docker run / BYO)

No whispermlx. Convert + admit work with ffmpeg on PATH

Windows

Docker Desktop / WSL2 Compose

--transcribe none or command

No whispermlx. No launchd/Task Scheduler sample yet. Use py -3 or a venv with Scripts\python.exe

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

--watch-audio (default on)

Convert new inbox audio to 16 kHz mono 64k MP3 in the recordings folder

Recordings already has that stem (any audio extension)

--transcribe

Host STT after convert: whispermlx-missing (default), none, or command

--no-watch-audio skips convert and STT. none never invokes STT. With --skip-serial, whispermlx-missing also skips Auto-merge serial groups

--watch-transcripts (default on)

Copy new JSON/SRT/VTT/txt/html into the transcripts dest

Dest already has that stem (any transcript extension)

--admit (default off)

After audio/transcript handling, admit eligible files in the transcripts dest (typically originals/) into the library

Already-imported stems; foo (1).json archive names. Requires a Python that can import transcriptx (--admit-python, or .transcriptx/bin/python / .transcriptx\Scripts\python.exe)

--auto-name

After admit, auto-write speaker display names (implies --admit; also --auto-link unless --no-auto-link)

Fail-open: leaves SPEAKER_* when voice/text fusion is unsure. Needs enrolled voices for returning speakers; in-transcript names can still label first meetings

--auto-link

After admit, create longitudinal profile links for matched enrolled / named profiles (link_method: auto_identified)

No new profiles from first-meeting names; does not enrol voice samples

--no-watch-audio / --no-watch-transcripts

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.

--transcribe

Behaviour

whispermlx-missing

After convert, run whispermlx-missing once per cycle (macOS MLX only; not available on Windows/Linux)

none

Convert, copy, and optional --admit only. Default honest path on Linux/Windows

command

Run --transcribe-cmd / config transcribe_cmd once per cycle, as an argv list (shell=False). Typical Windows/Linux STT hand-off (WhisperX Docker or BYO)

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 diskutil Ejectable/Removable; Linux /media/ or /run/media/ (sysfs removable when available). Windows has no auto detection — use --stage-local for USB drops. Detection failure does not stage

--stage-local / --no-stage-local, JSON stage_local, env INBOX_WATCH_STAGE_LOCAL

Stage dir

{recordings}/.inbox-staging/ (hidden from whispermlx-missing, which only scans the recordings top level)

--stage-dir, JSON stage_dir, env INBOX_WATCH_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):

  1. Review before cycle — inbox / recordings / transcripts paths, modes, and candidate file list

  2. Processing — [i/n] audio|transcript: filename, then indented convert/copy/skip lines; long encodes print elapsed time and stream ffmpeg time= / speed= on stderr

  3. Transcription (whispermlx-missing) — when audio mode ran (child process output follows)

  4. Library admit — when --admit ran (python -m transcriptx.admit_originals output follows)

  5. 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

--backup-wav

Copy (or move the staged local copy when staging ran) the audio original into the WAV backup folder (--wav-backup, or TRANSCRIPTX_WAV_BACKUP_DIR)

--delete-originals

Delete the inbox source (after backup, if backup was requested and succeeded)

--move-processed DIR

Relocate the inbox source instead of deleting (mutually exclusive with --delete-originals)

--force

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

inbox

INBOX_WATCH_INBOX

Recordings

recordings

TRANSCRIPTX_RECORDINGS_DIR

Transcripts dest

transcripts (use …/originals)

TRANSCRIPTX_TRANSCRIPTS_DIR is the library base; the script appends /originals

WAV archive

wav_backup

TRANSCRIPTX_WAV_BACKUP_DIR

Convert audio

watch_audio

INBOX_WATCH_AUDIO

Host STT after convert

transcribe (whispermlx-missing, none, command)

INBOX_WATCH_TRANSCRIBE

STT argv template

transcribe_cmd (JSON array or split string)

INBOX_WATCH_TRANSCRIBE_CMD

Copy transcripts

watch_transcripts

INBOX_WATCH_TRANSCRIPTS

Admit to library (default off)

admit_to_library

INBOX_WATCH_ADMIT

Auto-name speakers after admit

auto_name

INBOX_WATCH_AUTO_NAME

Auto-link matched profiles

auto_link

INBOX_WATCH_AUTO_LINK

Admit interpreter

admit_python

INBOX_WATCH_ADMIT_PYTHON

Config path

—

INBOX_WATCH_CONFIG / --config

Stage off removable inbox first

stage_local (null = auto)

INBOX_WATCH_STAGE_LOCAL

Staging folder

stage_dir

INBOX_WATCH_STAGE_DIR

Also

backup_wavs, delete_originals, skip_serial

INBOX_WATCH_BACKUP_WAV, INBOX_WATCH_DELETE_ORIGINALS, INBOX_WATCH_SKIP_SERIAL

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 RECORD/braindump is allowed only when inbox-watch is not recursive. The same path as the library inbox is rejected.

Audio

Removable volume → local stage → ffmpeg 16 kHz mono 64k MP3, same settings as inbox-watch. MP3s go to audio_dir, not the library recordings folder.

Text

whispermlx -f txt. Never --diarize. If the binary does not advertise txt, the script exits rather than writing JSON.

Length cap

max_duration_seconds is required (no product default). Longer files are not transcribed and not truncated. The example value 180 is only a starting point, not the 20-minute merge gap.

Overflow

Optional library_inbox must be the inbox-watch inbox. The untouched source is copied there once and left in the voice-note inbox.

Triage

--triage (default off) writes {stem}.draft.md with todos, questions, and unverified claims. No mail, no git, no other app.

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.json

  • French variant: meeting_fr.json

  • English explicit variant: meeting_en.json (optional; meeting.json remains the conventional default English path)

Workflow:

  1. Import and identify speakers on the base transcript first (Speaker ID page, or segment-derived names on import).

  2. Import the language variant via the same path (web Import Transcript or run_managed_import_workflow).

  3. 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.