Host STT automation

Advanced host-side transcription helpers. Mainstream path (Import Transcript, Transcribe Audio command generation): Transcription. 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.

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. 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/whispermlx-missing.

Install once from the repo root:

mkdir -p ~/.local/bin
install -m 755 scripts/inbox-watch.py ~/.local/bin/inbox-watch
which inbox-watch

Or run without installing: python3 scripts/inbox-watch.py --once --dry-run .

Mode

What it does

Skip when

--watch-audio (default on)

Convert new inbox audio to 16 kHz mono 64k MP3 in the recordings folder, then run whispermlx-missing

Recordings already has that stem (any audio extension). 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)

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

# 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 whispermlx-missing + --admit on the first cycle.
inbox-watch --watch --interval 5

# 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). 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 summaryStatus (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

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 is controlled by local JSON / .env, not by the plist. If the USB inbox is unplugged, --watch keeps polling empty cycles; the first cycle still catch-up transcribes missing MP3s and admits originals/. 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.

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.