Host-side Windows / Linux compatibility (2026-09-03)

Maintainer assessment under docs/reviews/. Dated snapshot, not a contract. Where this disagrees with contracts or src/, code and contracts win. Hosted at /guide/reviews/host-side-windows-linux-compat-2026-09-03/ after make docs.

Parked (2026-09-03): do not implement Windows/Linux host-script portability before 1.0. Tracked as 1.x theme G3 in ROADMAP.md. 1.0 stays macOS-typical inbox-watch / whispermlx + Docker (WSL2 on Windows).

Static reconstruction of the host-side TranscriptX tree (processes that run outside transcriptx-web) as of 2026-09-03. No Windows or Linux host was executed. CI is ubuntu-latest only (.github/workflows/ci.yml). Where docs and code disagree, code wins.

Claimed OS policy already on file: install_verification_matrix.md (macOS supported-with-caveats, Linux supported, Windows best-effort / WSL2+Docker) and release_ops_support_1_0.md (macOS and Linux for supported cells; Windows best-effort). This review checks whether host companions match that story.


Verdict

The analysis app in Docker is a Linux container on every host OS. The host companions that feed it audio and transcripts are macOS-shaped.

Surface

Linux host

Windows host

Notes

scripts/inbox-watch.py core (poll, ffmpeg convert, copy, --admit)

Mostly portable Python

Runnable with gaps

pathlib + argv subprocess; Unix venv discovery and Unix install docs

Audio convert + STT via inbox-watch

Blocked by whispermlx

Blocked by whispermlx

watch_audio requires whispermlx-missing, which requires the whispermlx binary

Transcript-copy / --admit only

Should work

Admit venv lookup broken

--no-watch-audio --watch-transcripts; Windows lacks Scripts\python.exe candidates

scripts/whispermlx-missing.py

Not a Linux STT path

Not a Windows STT path

Apple MLX; flags are whispermlx-specific

Transcribe Audio copyable commands

WhisperX Docker snippet is the Linux path

Bash-only; will not paste into cmd/PowerShell

shlex.quote, source, mkdir -p

scripts/macos/ launchd agent

None

None

Hardcoded to one macOS user even on Darwin

./transcriptx.sh native GUI

Intended

No

bash + .transcriptx/bin/activate

In-app G2 watcher

OK native Linux; flaky in Docker bind mounts

OK native; flaky in Docker Desktop

watchdog.Observer (inotify / ReadDirectoryChangesW), not polling

Docker Compose bind mounts

Supported; UID interpolation trap

Docker Desktop + WSL2

${UID:-1000} often not in the process env

Product-honest reading: Linux users are expected to run analysis in Docker and STT via the WhisperX (or Whisper-WebUI) copyable recipes, then Import Transcript. Windows users are expected to use WSL2 + Docker and the same import path. The host inbox watcher is documented as a Mac companion (directory_watcher.md: “runs on the Mac host”). That is accurate. It is not a cross-platform host daemon.


What “outside the container” actually is

        flowchart TB
  subgraph host [Host OS process]
    Inbox[USB / drop folder]
    IW[inbox-watch.py]
    WM[whispermlx-missing.py]
    MLX[whispermlx binary]
    FFMPEG[ffmpeg]
    Admit["python -m transcriptx.admit_originals"]
    CmdGen[Copyable bash from Transcribe Audio]
    WX[WhisperX docker run]
  end
  subgraph ctr [transcriptx-web Linux container]
    GUI[Streamlit]
    G2[G2 watchdog Observer]
    Import[admit_and_register]
  end
  Inbox --> IW
  IW --> FFMPEG
  IW --> WM
  WM --> MLX
  IW --> Admit
  CmdGen --> MLX
  CmdGen --> WX
  MLX -->|originals JSON| Import
  WX -->|originals JSON| Import
  IW -->|copy transcripts| Import
  G2 -->|in-process| Import
  GUI --> CmdGen
    

In-process (inside whatever runs Streamlit): Settings → Watcher (G2). If the operator uses Compose, that is the Linux container. If they use ./transcriptx.sh, that is native host Python.

Out-of-process host scripts (never imported by Streamlit):

Path

Role

Inventory platform

scripts/inbox-watch.py

Poll inbox → ffmpeg MP3 + copy transcripts; optional admit

“macOS typical”

scripts/whispermlx-missing.py

Batch STT for MP3s missing JSON

“Apple Silicon typical”

scripts/macos/inbox-watch-agent.sh + plist

login --watch

Darwin only

src/transcriptx/services/transcription/command_gen.py

Copyable host commands (not executed)

bash / macOS / Linux Docker recipes

transcriptx.sh

Native venv + Streamlit

macOS/Linux

scripts/audio_merge.py / audio_preprocess.py

ffmpeg helpers (also callable from GUI Tools)

“any” + ffmpeg

scripts/workspace_backup.py

ZIP backup/restore

pathlib ZIP

Maintainer bash (Makefile, scripts/release/*.sh, scripts/docker-smoke-test.sh) is Unix/CI. Out of scope for an unfamiliar Windows operator; listed only where it blocks Linux/Windows product use.


A. inbox-watch.py

~1.7k lines, stdlib only, does not import transcriptx. Optional --admit subprocesses python -m transcriptx.admit_originals.

What is already portable

  • pathlib.Path, expanduser(), mkdir(parents=True), os.replace for the .mp3.partial → dest hop (same-volume atomic replace on POSIX and NT).

  • subprocess.run(list_of_args) — no shell=True.

  • Classification and stem matching use .suffix.lower() / .stem.lower() (classify_path, find_stem_match). Windows .WAV / .JSON from recorders is handled.

  • Dotfile skip (path.name.startswith(".")) hides the convert temp .inbox-watch.{stem}.mp3.partial.

  • Poll loop (--watch + time.sleep) rather than inotify. Correct choice for USB volumes that appear and disappear (wait + empty cycles when the inbox path is missing).

  • shutil.which("ffmpeg") (finds ffmpeg.exe on Windows if PATH/PATHEXT is set).

  • KeyboardInterrupt → clean stop; failed convert unlinks the partial.

  • stdout/stderr reconfigure(line_buffering=True) with OSError/ValueError swallowed (Windows consoles that refuse reconfigure).

Linux — expected behaviour

Convert + copy + admit, given ffmpeg + a native TranscriptX venv, should run. The script does not call Darwin APIs.

STT after convert will not, unless a whispermlx binary exists. watch_audio (default on) requires whispermlx-missing even when every MP3 already has JSON (find_whispermlx_missing / maybe_run_missing). There is no hook for WhisperX, faster-whisper, or a no-op. Practical Linux use today:

python3 scripts/inbox-watch.py --once --no-watch-audio --watch-transcripts \
  --inbox  --transcripts …/originals
# optional: --admit --admit-python .transcriptx/bin/python

Audio conversion without STT is also unused: converting always tries to invoke missing afterwards when watch_audio is on.

find_admit_python looks at Unix venv layouts only:

        for rel in (
            ".transcriptx/bin/python",
            ".transcriptx/bin/python3",
            ".venv/bin/python",
        ):

On Linux that matches transcriptx.sh / a normal venv. On Windows those paths never exist (Scripts\python.exe). --admit-python still works if the operator passes it explicitly.

Install docs (host-stt.md, module docstring) use install -m 755 and ~/.local/bin — fine on Linux, absent on Windows.

No systemd user unit exists. --once is cron-shaped; --watch is a long-running foreground process. The macOS agent is not a template for transcriptx-inbox-watch.service.

USB inbox paths: docs and .env.example show /Volumes/USB-DISK/RECORD. Linux equivalents are /media/$USER/… or /run/media/$USER/…. The script does not care; only examples do.

Windows — extra gaps

Issue

Why it bites

Shebang #!/usr/bin/env python3

cmd.exe does not run extensionless copies; use py -3 scripts\inbox-watch.py.

install -m 755~\.local\bin\inbox-watch

No GNU install; no shebang execution. After copy-without-.py, build_missing_cmd treats the sibling as a binary (suffix != ".py") and CreateProcess fails with WinError 193.

find_admit_python

Never finds .transcriptx\Scripts\python.exe. Error text still says .transcriptx/bin/python.

os.replace(partial, dest)

Fails with PermissionError if Defender / a player has the dest MP3 open. POSIX replace is more forgiving.

src.unlink() / shutil.move after convert

USB recorders and Explorer previews often hold the source; delete/move then fails. FAT/exFAT + AV is the usual USB inbox.

wait_until_stable uses size + st_mtime_ns

FAT mtime granularity is 2s. Size still changing saves you; preallocated files that fill in place can look “stable” early.

Hidden files

Only names starting with . are skipped. desktop.ini is ignored by extension; Thumbs.db is not an audio/transcript ext so it is scanned then ignore. Windows hidden attribute is not read.

is_same_or_under / Path.resolve().relative_to

Case-sensitive. path_canonical.canonicalise_path uses os.path.normcase on Windows; inbox-watch layout checks do not. Mixed C:\Inbox vs c:\inbox can false-negative containment.

Console encoding

Recorders with non-cp1252 names can UnicodeEncodeError on older Windows consoles unless UTF-8 is on.

No Task Scheduler / NSSM / Windows service sample

--watch is a console loop.

chmod(0o600) on saved JSON is a no-op besides the read-only bit on NT; harmless.

wait_for_directory is defined but unused; --watch inlines the same idea. Not an OS bug.

Coupling that is not OS-specific but blocks non-Mac STT

build_missing_cmd always passes --source / --transcripts to whispermlx-missing. There is no --transcribe-cmd or provider switch. Until that exists, inbox-watch audio mode is a macOS STT pipeline, not a generic convert+queue.


B. whispermlx-missing.py

Same portability profile as inbox-watch for paths/config/chmod/os.replace, plus:

  • shutil.which("whispermlx") — binary name, Apple MLX stack. WhisperMLXProvider.is_available fails closed unless sys.platform == "darwin" (whispermlx_provider.py).

  • shlex.join for dry-run command printing (POSIX quoting; cosmetic on Windows).

  • subprocess.run(..., text=True) without encoding="utf-8" — Windows text=True uses the locale encoding; whispermlx stderr with UTF-8 can throw on decode if anyone ever ran it there.

  • Live run requires a whispermlx binary (dry-run does not). Extra args (--output_dir, --diarize, -f) are whispermlx CLI, not WhisperX.

Linux/Windows operators who want bulk “skip existing JSON” need a different driver (or a wrapper that only pretends to be whispermlx). The Transcribe Audio page and host-stt.md already say macOS host / not inside the analysis container. They do not say “this script is useless on Linux”; the failure mode is a missing binary at runtime.


C. macOS login agent (not portable, not a template)

scripts/macos/inbox-watch-agent.sh and com.transcriptx.inbox-watch.plist are machine-local, not repo-portable:

  • Absolute Python: /Users/89298/.pyenv/versions/3.10.13/bin/python3

  • Absolute repo, logs, and TRANSCRIPTX_* dirs under /Users/89298/Documents/…

  • PATH: pyenv shims + Homebrew

  • Comment: waits for /Volumes/USB-DISK/RECORD

Even another Mac checkout cannot load this plist unchanged. Linux needs a systemd user unit (or cron --once). Windows needs Task Scheduler. None exist. Docs call this “optional macOS login agent” — correct, but the files are a private LaunchAgent checked into the tree.


D. Copyable host commands (command_gen.py)

Streamlit never executes these. The operator pastes them into a host terminal. Every generator emits POSIX shell:

Helper

Constructs

build_whispermlx_single / _batch_loop

set -a; source …; mkdir -p; for f in …; command -v

build_whispermlx_missing

whispermlx-missing + install -m 755 / python3 notes

build_whisperx_docker

mkdir -p, docker run \ line continuations, -v host:container

build_whisper_webui_docker

if [ ! -d ]; git clone; docker run -d

_q() is shlex.quote (single-quote POSIX). PowerShell / cmd quoting is different. Git Bash or WSL can run the snippets on a Windows box; Windows Terminal + PowerShell cannot.

Further Windows/Linux nits in the same module:

  • params.input_path.rstrip("/") then .endswith((".mp3", …)) to choose single-file vs folder loop. Backslash paths (C:\rec\a.mp3) still end with .mp3, but .MP3 does not match (no .lower()). Windows Explorer often shows uppercase extensions; the generator would emit a folder loop for a single file.

  • rstrip("/") does not strip \. WhisperX -v C:\data\audio\:/audio is a broken bash mount; Docker Desktop PowerShell wants a different spelling; Git Bash wants /c/data/audio.

  • Default WebUI clone dir $HOME/Whisper-WebUI is POSIX.

  • looks_like_container_install_path only treats /opt/venv as the Docker install. Fine.

Linux GPU STT is this WhisperX docker run recipe (docs/recipes/whisperx/), not inbox-watch. That path is the one that matches “Linux supported” for transcription, and it is already bash/Linux-shaped.


E. Native launcher transcriptx.sh

Supported inventory row: macOS/Linux. Concrete Unix assumptions:

  • #!/bin/bash, source .env, source .transcriptx/bin/activate

  • command -v python3.10 (not python / py -3.10)

  • write_install_profile$HOME/.config/transcriptx (XDG; no %APPDATA%)

  • which python, rm -rf, ANSI echo -e

Windows native GUI is python -m venv .venv + .venv\Scripts\activate as mentioned once in installation-advanced.md. There is no transcriptx.ps1 / transcriptx.bat. Compose remains the advertised Windows path.

Linux: this script is the native path. It will not help inbox-watch find whispermlx.


F. In-app G2 watcher (runs wherever Streamlit runs)

Not a host script, but it is the other “watcher”, and it does run outside Docker on a native install.

WatchObserver constructs watchdog.observers.Observer() — native backends:

OS

Backend

USB / network / Docker bind

Linux native

inotify

Often silent on NFS, SMB, some FUSE; USB ext4/exFAT usually OK

Linux in Compose watching a host drop folder

inotify inside the VM

Docker Desktop (Mac/Windows) virtiofs/osxfs frequently does not deliver inotify for host-side creates. Native Linux docker bind-mounts are more reliable.

Windows native

ReadDirectoryChangesW

USB usually OK; SMB mixed

macOS native

FSEvents

USB OK; Docker bind from host → container is the weak case

There is no PollingObserver fallback. Debounce key is str(path) (no normcase). G2 never converts audio (auto_transcribe rejected until theme H).

This is why directory_watcher.md points Mac operators at host inbox-watch for convert+STT. On Linux Docker, the same virtiofs inotify hole exists for G2 if they watch a Desktop-mounted inbox. Host-side inbox-watch polling would still be the robust drop-folder design — if STT were not whispermlx-only.


G. Shared host-adjacent utilities

File locking — src/transcriptx/core/utils/file_lock.py

Intentional NT/POSIX split: msvcrt.locking vs fcntl.flock. Used by stores when --admit or G2 runs on the host (native) or in the container.

Windows caveats: byte-range mandatory lock; unlink of a held .lock often PermissionError; SMB/USB locks are weaker; is_locked() is racy. Linux flock is advisory and matches the Darwin comment about re-entrancy. Fine for a single-user workspace; not a distributed lock.

Path identity

path_canonical.canonicalise_path does os.path.normcase on Darwin and NT. is_under_directory (import_admission.py) and inbox-watch is_same_or_under do not. Folder-import / G2 “must not watch the library” checks can disagree with Windows short/long or case variants.

path_safety.assert_safe_relpath rejects X: and \\ — correct for untrusted relative names; not used as a host-inbox sanitiser.

ffmpeg lookup — core/audio/tools.py

shutil.which first, then only /opt/homebrew/bin/ffmpeg, /usr/local/bin/ffmpeg, /usr/bin/ffmpeg. No C:\ffmpeg\bin, no WinGet links. GUI Tools (merge/preprocess) inside Docker see Linux /usr/bin/ffmpeg in the image. Host inbox-watch uses its own find_ffmpeg (PATH only) — better for Windows if ffmpeg is on PATH; worse for Homebrew-not-on-PATH Macs (inbox-watch will miss Homebrew unless PATH includes it; the launchd plist injects /opt/homebrew/bin).

Ollama URL — resolve_ollama_base_url

Rewrites host.docker.internal127.0.0.1 when not in a container (/.dockerenv). This is the right host-vs-container split for Mac and Windows Docker Desktop and a native Linux venv. Linux Compose on a real docker engine may need extra_hosts: host.docker.internal:host-gateway (not defined in docker-compose.yml); that is a Linux Docker networking issue, not inbox-watch.

Corrections memory path

Darwin → ~/Library/Application Support/transcriptx/corrections.yml; else XDG ~/.config/transcriptx/…. Windows would get a fake XDG under %USERPROFILE%\.config, not %APPDATA%. Host-side only if native Python loads that module.

Compose host glue

  • user: "${UID:-1000}:${GID:-1000}" — bash UID is not exported by default, so Compose often interpolates 1000. Accidental success when the Linux login is uid 1000. Other uids write ./data as 1000. Windows has no UID; always 1000. Docker Desktop file ownership is already fictional on NTFS/virtiofs.

  • .env.example HOST_* examples are /Users/you/.... Windows needs C:\… or WSL /mnt/c/… depending on where Compose runs.

  • NUMBA_CACHE_DIR=/tmp/numba_cache documents virtiofs EIO on Docker Desktop — Mac/Windows host FS, not Linux overlay.


H. Other host Python helpers

audio_merge.py / audio_preprocess.py / workspace_backup.py insert src/ on sys.path and import transcriptx. They are as portable as the package + ffmpeg. ZIP backup uses stdlib; path separators inside archives are typically POSIX from zipfile — restore on Windows of a Mac-made ZIP is a general zip issue, not unique here.

They are not wired to inbox-watch. A Linux operator merging serial parts still uses Tools → Auto-merge (in the container, with image ffmpeg) or a native install.


I. Findings (ranked)

Design / honesty (not defects if the Mac-only story is the contract)

  1. Audio inbox-watch is a whispermlx pipeline. Linux and Windows cannot complete convert→STT→originals with supported tools. WhisperX remains a separate copy-paste recipe.

  2. No Linux/Windows service analogue to the LaunchAgent. --watch is a terminal process; --once is cron-shaped with no unit files.

  3. Copyable commands are bash. Matches Linux and macOS; not Windows PowerShell. WSL is the undocumented escape hatch.

Defects if anyone runs these scripts on Windows (best-effort claim)

  1. find_admit_python ignores Scripts\python.exe. --admit fails unless --admit-python is set.

  2. Command generator extension check is case-sensitive and strips only /. Wrong snippet for file.MP3 / C:\dir\.

  3. Install/run docs (install, python3, ~/.local/bin, .transcriptx/bin/python) have no Windows stanza.

  4. Layout checks are case-sensitive unlike canonicalise_path.

Linux operational

  1. G2 + Docker bind-mount inotify can miss host drops (especially Docker Desktop). Polling host inbox-watch is the robust pattern — but STT is missing on Linux.

  2. Compose ${UID} often defaults to 1000. Document export UID=$(id -u) GID=$(id -g) or put them in .env.

  3. ffmpeg fallback paths in Tools are Unix-only (host native GUI on Linux is OK via /usr/bin; Windows native GUI is not).

Hygiene

  1. scripts/macos/*.plist is a personal LaunchAgent (absolute /Users/89298/...). Do not treat as a distribution artefact; it will confuse Linux/Windows readers who open scripts/macos/.

  2. CI never runs host scripts on Windows (or macOS). tests/scripts/test_inbox_watch.py is stdlib-mockable and will pass on Ubuntu while #4 stays latent.


J. What would make Linux/Windows real

Small, local, still “best-effort Windows”:

  • Discover .transcriptx/Scripts/python.exe and .venv/Scripts/python.exe; mention py -3 scripts\inbox-watch.py in host-stt.md.

  • command_gen: Path(input).suffix.lower(); strip / and \; say “Git Bash / WSL / macOS / Linux shell”.

  • Document Linux inbox-watch as transcript copy + admit only, or convert-without-STT, until a provider exists.

  • Document Compose UID/GID on Linux; keep Windows on WSL2+Docker.

Larger (theme H / G follow-on):

  • Decouple inbox-watch STT: --transcribe {whispermlx-missing,whisperx-docker,none}.

  • PollingObserver fallback for G2 on bind mounts.

  • systemd unit + a templated (not user-absolute) macOS plist; optional Windows Task Scheduler XML.

  • transcriptx.ps1 only if native Windows GUI is promoted off “best-effort”.


K. Scope limits

Not reviewed as host daemons: Sphinx make docs, release *.sh, count_sloc.py, eval/bench scripts, workspaces frontend copy-css.mjs. Those are maintainer/Unix.

Not executed: Windows 11, WSL2, Debian/Fedora, exFAT USB, Defender-on-replace, Docker Desktop inotify on a watched inbox.