Metadata-Version: 2.5
Name: paraspeakrs
Version: 0.2.2
Summary: Speaker-aware meeting transcription: Parakeet INT8 ASR with speakrs diarization
Project-URL: Repository, https://github.com/alber70g/fast-speaker-aware-meeting-transcriber
Project-URL: Issues, https://github.com/alber70g/fast-speaker-aware-meeting-transcriber/issues
Author-email: Albert Groothedde <albertgroothedde@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: asr,cli,diarization,meetings,parakeet,speakrs,transcription
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
Requires-Python: <3.13,>=3.11
Requires-Dist: fastapi>=0.115.0
Requires-Dist: httpx>=0.27.0
Requires-Dist: numpy>=1.26.0
Requires-Dist: paraspeakrs-speakrs>=0.2.0; sys_platform == 'darwin' and platform_machine == 'arm64'
Requires-Dist: pydantic>=2.8.0
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: sherpa-onnx-core>=1.13.8
Requires-Dist: sherpa-onnx>=1.13.8
Requires-Dist: uvicorn>=0.30.0
Provides-Extra: denoise
Requires-Dist: clearvoice>=0.1.2; extra == 'denoise'
Requires-Dist: deepfilternet>=0.5.6; extra == 'denoise'
Requires-Dist: torch==2.8.0; extra == 'denoise'
Requires-Dist: torchaudio==2.8.0; extra == 'denoise'
Provides-Extra: mcp
Requires-Dist: mcp<2,>=1.2.0; extra == 'mcp'
Provides-Extra: senko
Requires-Dist: senko; extra == 'senko'
Requires-Dist: torch==2.8.0; extra == 'senko'
Requires-Dist: torchaudio==2.8.0; extra == 'senko'
Provides-Extra: tui
Requires-Dist: rich>=13.0; extra == 'tui'
Requires-Dist: textual>=0.60; extra == 'tui'
Description-Content-Type: text/markdown

# paraspeakrs

Speaker-aware transcription for long meeting recordings: **Para**keet INT8 for ASR,
**speakrs** for diarization.

```sh
uv tool install paraspeakrs          # the tool, plus the diarizer binary on macOS arm64
brew install ffmpeg                  # ffmpeg and ffprobe must be on PATH

paraspeakrs run --txt meeting.m4a    # transcribe; models download themselves on first run
paraspeakrs tui                      # the terminal UI (add --with 'paraspeakrs[tui]')
paraspeakrs mcp                      # the MCP server (add --with 'paraspeakrs[mcp]')
```

To install with the optional interfaces in one go:

```sh
uv tool install paraspeakrs --with 'paraspeakrs[tui]' --with 'paraspeakrs[mcp]'
```

The first run downloads the Parakeet ASR model (~490 MB) and the speakrs diarization
models (~315 MB) into `~/.local/share/fast-speaker-aware-meeting-transcriber`. Neither
needs an account or a token. `paraspeakrs fetch-models` pre-seeds the ASR model; see
[Running without model downloads](#running-without-model-downloads) if your network
blocks the download.

Jobs, transcripts and the speaker cache live in that same directory, not in the
working directory, so `paraspeakrs` behaves the same wherever it is launched from.

## What it does

- API plus background worker
- FFmpeg normalization to mono 16 kHz WAV for ASR; channel-preserving 16 kHz for diarization
- Speaker diarization before transcription (mono in → one pass; stereo in → split L/R, diarize each channel, merge with `L_`/`R_` speaker prefixes)
- Two diarization backends: **speakrs** (default) and Senko — see [Diarization backends](#diarization-backends)
- Per-speaker centroid embeddings come back from the diarizer directly — no separate embedding model
- Parakeet TDT v3 INT8 ASR selected for CPU, CUDA on NVIDIA GPUs, or CoreML on macOS
- ASR output is kept even when diarization has gaps

## Prerequisites

- **FFmpeg, with `ffprobe`, on `PATH`** — the only thing `uv tool install` cannot
  provide for you. Every audio path shells out to it. `brew install ffmpeg`, or static
  builds from <https://evermeet.cx/ffmpeg/>.
- Python 3.11 or 3.12, which `uv` will install and manage on its own.

The ASR model is downloaded on first use into
`~/.local/share/fast-speaker-aware-meeting-transcriber/models/`. It is a directory
containing `encoder.int8.onnx`, `decoder.int8.onnx`, `joiner.int8.onnx` and
`tokens.txt`; point `PARAKEET_SHERPA_MODEL_DIR` or `--sherpa-model-dir` at a copy you
already have to skip the download.

### The diarizer binary

The default `speakrs` backend runs as a sidecar binary. The
`paraspeakrs-speakrs` wheel ships it prebuilt for **macOS arm64**, and
`uv tool install paraspeakrs` pulls it in automatically there — no Rust toolchain and no
Homebrew OpenBLAS.

On other platforms that wheel does not exist, so `paraspeakrs` installs without it and
you need one of:

- `--diar-backend senko` (install with `--with 'paraspeakrs[senko]'`; pulls torch, ~1.2 GB)
- your own build, with `SPEAKRS_BIN` pointing at it:
  ```sh
  brew install openblas
  cd packages/speakrs-diar
  PKG_CONFIG_PATH="/opt/homebrew/opt/openblas/lib/pkgconfig" \
    cargo build --release --features coreml     # NVIDIA: --features cuda
  ```

`paraspeakrs` finds the binary next to its own interpreter first, then on `PATH`, then in
a source checkout — so a development checkout keeps working with a plain
`cargo build --release` and no environment variable.

## Running without model downloads

Both model downloads are plain HTTPS with no account and no token, but if your network
blocks them there are three ways through, in the order worth trying:

**1. Point at a mirror.** `PARAKEET_MODEL_URL` overrides where the ASR model comes from,
so an internal artifact proxy or any reachable copy of
`sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2` works:

```sh
export PARAKEET_MODEL_URL="https://your-proxy.internal/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2"
```

**2. Sideload.** On any machine with access, download
<https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8.tar.bz2>,
unpack it, copy the directory over, and point at it:

```sh
export PARAKEET_SHERPA_MODEL_DIR="/path/to/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
export PARAKEET_AUTO_DOWNLOAD=0   # optional: fail loudly instead of trying the network
```

The same applies to diarization: the speakrs sidecar fetches its models from the public
`avencera/speakrs-models` repository on first run, and `SPEAKRS_MODELS_DIR` points it at a
local copy instead.

**3. Skip the local model entirely.** The OpenAI-compatible ASR backend needs no download
at all — point it at any server exposing a compatible transcription endpoint:

```sh
export PARAKEET_ASR_BACKEND=openai
export PARAKEET_OPENAI_BASE_URL="http://your-host/v1"
```

Diarization still runs locally, so this only removes the 490 MB ASR download.

Supported ASR devices:

- `cpu`: Linux, Windows, or macOS CPU inference
- `cuda`: NVIDIA GPU inference on Linux or Windows with a CUDA-enabled `sherpa-onnx` wheel
- `coreml`: macOS inference through ONNX Runtime's CoreML execution provider

Supported Senko devices (`--senko-device` / `SENKO_DEVICE`):

- `auto` (default), `cpu`, `cuda`, `coreml`

## Diarization backends

Select with `--diar-backend` / `PARAKEET_DIAR_BACKEND`.

### `speakrs` (default)

A Rust port of the pyannote `community-1` pipeline. It finds more speakers and far
finer turns than Senko: on a 3059 s four-person recording Senko reported 3 speakers
with a single 347 s unbroken block, while speakrs reported 4 speakers with a 43 s
longest block — and ran faster (6.1 s vs 8.3 s).

speakrs is a Rust library with no Python bindings, so it runs as a sidecar binary
that must be built once:

```sh
brew install openblas                       # macOS; Linux: your distro's openblas-dev
cd packages/speakrs-diar
PKG_CONFIG_PATH="/opt/homebrew/opt/openblas/lib/pkgconfig" \
  cargo build --release --features coreml   # NVIDIA: --features cuda; otherwise omit
```

Build with the feature matching the `--speakrs-mode` you intend to use; a binary
built without `coreml` rejects `--speakrs-mode coreml` and lists what it supports.

- `--speakrs-bin` / `SPEAKRS_BIN` — defaults to `packages/speakrs-diar/target/release/speakrs-diar`
- `--speakrs-mode` / `SPEAKRS_MODE` — `cpu`, `coreml` (default on macOS), `coreml-fast`, `cuda`, `cuda-fast`
- `--speakrs-models-dir` / `SPEAKRS_MODELS_DIR` — load models from a directory instead of
  downloading them from the public `avencera/speakrs-models` repo on first use (no HF token needed)

### `senko`

The previous default. No sidecar build, but it lives behind an extra because it pulls
torch (~2–3 GB); the core install needs no ML framework at all:

```sh
uv sync --extra senko
paraspeakrs run --diar-backend senko "$AUDIO_FILE"
```

Pick it if you would rather not build the sidecar, and note the accuracy caveat above.
The root-level preprocessing experiments (`transcribe_variants.py` and friends) have
their own extra, `--extra denoise`.

### Moving this to another Mac

`uv tool install paraspeakrs` is the supported route — it carries the diarizer binary and
fetches its own models, so there is nothing to copy by hand.

For a machine that cannot reach PyPI, `scripts/make-shipping-zip.sh` still builds a
self-contained zip of the source plus the binary; see
[scripts/SHIPPING.md](scripts/SHIPPING.md) and [docs/releases.md](docs/releases.md).

### Speaker cache and embedding dimensions

The two backends emit different embeddings (Senko 192-dim CAM++, speakrs 256-dim
WeSpeaker), which are not comparable. The speaker cache therefore files
speakers under the backend that produced them, and switching backends means
re-labeling each voice once. Labels recorded before this split are read as Senko's.

## Linux

Copy the whole block, then replace the variables at the top.

```sh
# Required inputs. Replace these with your local values.
export SHERPA_MODEL_DIR="/path/to/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
export AUDIO_FILE="/path/to/audio.wav"
export AUDIO_DIR="./test-audio"

# Choose the ASR device.
# Use cpu for normal CPU inference.
# Use cuda only with an NVIDIA GPU, CUDA-enabled PyTorch, and a CUDA-enabled sherpa-onnx wheel.
export PARAKEET_DEVICE="cpu"
export DIAR_BACKEND="speakrs"
export SENKO_DEVICE="auto"

# Install/sync the project into uv's managed .venv.
uv sync --python 3.11 --group dev

# Optional CUDA note:
# If you use PARAKEET_DEVICE=cuda, install CUDA-enabled PyTorch and sherpa-onnx
# into this uv environment before running the service.

# Run the API service in the background.
mkdir -p var
paraspeakrs serve \
  --host 127.0.0.1 \
  --port 8000 \
  --device "$PARAKEET_DEVICE" \
  --diar-backend "$DIAR_BACKEND" \
  --senko-device "$SENKO_DEVICE" \
  --asr-backend sherpa \
  --sherpa-model-dir "$SHERPA_MODEL_DIR" \
  > var/service.log 2>&1 &
SERVICE_PID=$!
sleep 3

# Submit one audio file to the service.
JOB_JSON="$(curl -sS -X POST http://127.0.0.1:8000/jobs -F "file=@${AUDIO_FILE}")"
echo "$JOB_JSON"
JOB_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["job_id"])' <<< "$JOB_JSON")"

# Poll the job. Repeat this command until status is completed or failed.
# The response includes progress_step, progress_detail, and progress_percent.
curl -sS "http://127.0.0.1:8000/jobs/${JOB_ID}"

# Fetch the human-readable utterance transcript once the job is completed.
# 202 while still queued/running, 409 if the job failed.
curl -sS "http://127.0.0.1:8000/jobs/${JOB_ID}/txt"

# Stop the background service when you are done testing service mode.
kill "$SERVICE_PID"

# Run a stand-alone transcription/diarization test for one audio file.
paraspeakrs run \
  --device "$PARAKEET_DEVICE" \
  --diar-backend "$DIAR_BACKEND" \
  --senko-device "$SENKO_DEVICE" \
  --asr-backend sherpa \
  --sherpa-model-dir "$SHERPA_MODEL_DIR" \
  "$AUDIO_FILE"

# Run interactive label-snippet generation for a directory of audio files.
paraspeakrs label-dir "$AUDIO_DIR" \
  --device "$PARAKEET_DEVICE" \
  --diar-backend "$DIAR_BACKEND" \
  --senko-device "$SENKO_DEVICE" \
  --asr-backend sherpa \
  --sherpa-model-dir "$SHERPA_MODEL_DIR" \
  --label-probe-seconds 300

# Run the test suite.
uv run pytest
```

## macOS

Copy the whole block, then replace the variables at the top.

```sh
# Required inputs. Replace these with your local values.
export SHERPA_MODEL_DIR="/path/to/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
export AUDIO_FILE="/path/to/audio.wav"
export AUDIO_DIR="./test-audio"

# Choose the ASR device.
# Use coreml on Apple Silicon when sherpa-onnx includes ONNX Runtime CoreML.
# Use cpu when CoreML is unavailable or you want plain CPU inference.
export PARAKEET_DEVICE="coreml"
export DIAR_BACKEND="speakrs"
export SENKO_DEVICE="auto"

# Install/sync the project into uv's managed .venv.
uv sync --python 3.11 --group dev

# Run the API service in the background.
mkdir -p var
paraspeakrs serve \
  --host 127.0.0.1 \
  --port 8000 \
  --device "$PARAKEET_DEVICE" \
  --diar-backend "$DIAR_BACKEND" \
  --senko-device "$SENKO_DEVICE" \
  --asr-backend sherpa \
  --sherpa-model-dir "$SHERPA_MODEL_DIR" \
  > var/service.log 2>&1 &
SERVICE_PID=$!
sleep 3

# Submit one audio file to the service.
JOB_JSON="$(curl -sS -X POST http://127.0.0.1:8000/jobs -F "file=@${AUDIO_FILE}")"
echo "$JOB_JSON"
JOB_ID="$(python3 -c 'import json,sys; print(json.load(sys.stdin)["job_id"])' <<< "$JOB_JSON")"

# Poll the job. Repeat this command until status is completed or failed.
# The response includes progress_step, progress_detail, and progress_percent.
curl -sS "http://127.0.0.1:8000/jobs/${JOB_ID}"

# Fetch the human-readable utterance transcript once the job is completed.
# 202 while still queued/running, 409 if the job failed.
curl -sS "http://127.0.0.1:8000/jobs/${JOB_ID}/txt"

# Stop the background service when you are done testing service mode.
kill "$SERVICE_PID"

# Run a stand-alone transcription/diarization test for one audio file.
paraspeakrs run \
  --device "$PARAKEET_DEVICE" \
  --diar-backend "$DIAR_BACKEND" \
  --senko-device "$SENKO_DEVICE" \
  --asr-backend sherpa \
  --sherpa-model-dir "$SHERPA_MODEL_DIR" \
  "$AUDIO_FILE"

# Run interactive label-snippet generation for a directory of audio files.
paraspeakrs label-dir "$AUDIO_DIR" \
  --device "$PARAKEET_DEVICE" \
  --diar-backend "$DIAR_BACKEND" \
  --senko-device "$SENKO_DEVICE" \
  --asr-backend sherpa \
  --sherpa-model-dir "$SHERPA_MODEL_DIR" \
  --label-probe-seconds 300

# Run the test suite.
uv run pytest
```

## Windows

Run from PowerShell. Copy the whole block, then replace the variables at the top.

```powershell
# Required inputs. Replace these with your local values.
$SherpaModelDir = "C:\path\to\sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8"
$AudioFile = "C:\path\to\audio.wav"
$AudioDir = ".\test-audio"

# Choose the ASR device.
# Use cpu for normal CPU inference.
# Use cuda only with an NVIDIA GPU, CUDA-enabled PyTorch, and a CUDA-enabled sherpa-onnx wheel.
$ParakeetDevice = "cpu"
$SenkoDevice = "auto"

# Install/sync the project into uv's managed .venv.
uv sync --python 3.11 --group dev

# Optional CUDA note:
# If you use $ParakeetDevice = "cuda", install CUDA-enabled PyTorch and sherpa-onnx
# into this uv environment before running the service.

# Run the API service in the background.
New-Item -ItemType Directory -Force -Path var | Out-Null
$Service = Start-Process -FilePath "uv" -ArgumentList @(
  "paraspeakrs", "serve",
  "--host", "127.0.0.1",
  "--port", "8000",
  "--device", $ParakeetDevice,
  "--senko-device", $SenkoDevice,
  "--asr-backend", "sherpa",
  "--sherpa-model-dir", $SherpaModelDir
) -PassThru -RedirectStandardOutput "var\service.log" -RedirectStandardError "var\service.err.log"
Start-Sleep -Seconds 3

# Submit one audio file to the service.
$Job = curl.exe -sS -X POST http://127.0.0.1:8000/jobs -F "file=@$AudioFile" | ConvertFrom-Json
$Job

# Poll the job. Repeat this command until status is completed or failed.
# The response includes progress_step, progress_detail, and progress_percent.
curl.exe -sS "http://127.0.0.1:8000/jobs/$($Job.job_id)"

# Fetch the human-readable utterance transcript once the job is completed.
# 202 while still queued/running, 409 if the job failed.
curl.exe -sS "http://127.0.0.1:8000/jobs/$($Job.job_id)/txt"

# Stop the background service when you are done testing service mode.
Stop-Process -Id $Service.Id

# Run a stand-alone transcription/diarization test for one audio file.
paraspeakrs run `
  --device $ParakeetDevice `
  --senko-device $SenkoDevice `
  --asr-backend sherpa `
  --sherpa-model-dir $SherpaModelDir `
  $AudioFile

# Run interactive label-snippet generation for a directory of audio files.
paraspeakrs label-dir $AudioDir `
  --device $ParakeetDevice `
  --senko-device $SenkoDevice `
  --asr-backend sherpa `
  --sherpa-model-dir $SherpaModelDir `
  --label-probe-seconds 300

# Run the test suite.
uv run pytest
```

## MCP server

The same pipeline is exposed as a local [MCP](https://modelcontextprotocol.io)
server (stdio) so an LLM agent can drive transcription, speaker labeling, and
transcript retrieval directly. It runs the pipeline in-process — no HTTP service
required — and reads audio from local file paths.

Install the extra and run it:

```sh
uv sync --python 3.11 --extra mcp
export PARAKEET_WORKSPACE_DIR="./var"   # job artifacts + speaker cache live here
# PARAKEET_SHERPA_MODEL_DIR defaults to models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8

# stdio transport (default) — for a local client that launches the server itself.
paraspeakrs mcp

# streamable-http transport — for remote/network clients. Binds 127.0.0.1:8000 by default.
paraspeakrs mcp --transport streamable-http --host 0.0.0.0 --port 8000
```

### Transports

- `stdio` (default) — the client spawns `paraspeakrs mcp` and talks over stdin/stdout. Best for a local agent.
- `streamable-http` — the server listens on `--host`/`--port` (default `127.0.0.1:8000`); the endpoint is `http://<host>:<port>/mcp`. Use for remote clients or to share one running server.
- `sse` — legacy Server-Sent Events transport, also available via `--transport sse`.

Over HTTP the pipeline still runs in-process on the server host, and `audio_path` / `output_path` are resolved on the **server's** filesystem, not the client's.

Configuration is the same env vars as the service (`PARAKEET_DEVICE`,
`PARAKEET_DIAR_BACKEND`, `SENKO_DEVICE`, `SPEAKRS_BIN`, `SPEAKRS_MODE`,
`SPEAKRS_MODELS_DIR`, `PARAKEET_ASR_BACKEND`, `PARAKEET_SHERPA_MODEL_DIR`,
`PARAKEET_WORKSPACE_DIR`, …). Note that the default `speakrs` backend resolves
`SPEAKRS_BIN` relative to the server's working directory, so set it to an
absolute path when an MCP client launches the server. Example client config for the default **stdio**
transport (Claude Desktop / any MCP client launches the server itself):

```json
{
  "mcpServers": {
    "parakeet-diarize": {
      "command": "uv",
      "args": ["mcp"],
      "env": {
        "PARAKEET_SHERPA_MODEL_DIR": "/path/to/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
        "PARAKEET_DEVICE": "coreml",
        "PARAKEET_WORKSPACE_DIR": "/abs/path/to/var"
      }
    }
  }
}
```

For an already-running **streamable-http** server, point the client at the URL
instead of spawning a command:

```json
{
  "mcpServers": {
    "parakeet-diarize": {
      "url": "http://127.0.0.1:8000/mcp"
    }
  }
}
```

### Tools

| Tool | Description |
| --- | --- |
| `transcribe(audio_path)` | Run diarization + ASR on a local file. Returns `job_id`, duration, `num_speakers`, and the speaker roster. Speakers with `assigned_label: null` are unnamed; a `suggested_label` is the voice cache's guess and only reaches the transcript once confirmed with `label_speaker`. **Does not return the transcript text.** |
| `list_speakers(job_id)` | The job's speakers with their assigned names, suggestions and talk time. |
| `get_speaker_audio(job_id, speaker_id)` | Export a short (up to 10 s) sample clip for one speaker and return its wav path. Play it for the user to identify the speaker. |
| `label_speaker(job_id, speaker_id, name)` | Assign a name to a speaker and store its embedding so future transcriptions recognize the same voice automatically. |
| `get_transcript(job_id, output_path)` | Render the transcript and **write it to `output_path`**, returning only the path and a line count. |

### Speaker cache

Naming a speaker persists their centroid embedding in
`$PARAKEET_WORKSPACE_DIR/speaker-cache.json` under that name. On every
`transcribe` call, new speakers are matched against that cache by cosine
similarity, so once you name someone they are *suggested* in later meetings
without relabeling — confirm the suggestion and they are named.

A name keeps the embeddings it was learned from rather than only their running
average, so the members of a voice can be listened to individually and pulled
back out one at a time. Each additional recording of a voice moves its centroid
by `1/(n+1)`, so an established name is not yanked onto whoever was added last.

### Workflow

- **No labeling needed:** `transcribe` → `list_speakers` → confirm each
  `suggested_label` with `label_speaker` → `get_transcript(output_path)`.
  A suggestion that is never confirmed leaves the raw `SPEAKER_xx` ID in the
  transcript: a name in a transcript is a claim about who said something, so it
  only goes in once a person has agreed to it.
- **Labeling needed:** `transcribe` → `list_speakers` shows some
  `assigned_label: null` → for each unknown speaker: `get_speaker_audio` →
  play the clip for the user → user identifies them → `label_speaker` →
  `get_transcript`.

### Retrieving the transcript

`get_transcript` **writes the transcript to the `output_path` file you provide
and returns only that path plus a line/byte count — it never returns the
transcript body.** This is deliberate: meeting transcripts are large and would
otherwise flood the model's context window. The file contents are the same
`[HH:MM:SS] NAME: text` format described under
[Output formats](#output-formats).

**Consumer guidance — pipe transcript retrieval to a file, not into context.**
Any client/agent calling `get_transcript` (and the wav path from
`get_speaker_audio`) must read or stream the file from disk, or hand the path to
the user, rather than loading the body back through the model. Do not echo the
transcript into the conversation.

Note that `output_path` (and `audio_path` on `transcribe`) is resolved on the
**server's** filesystem. With `stdio` transport the server is local, so the path
is on your own machine. With `streamable-http` to a remote server, the file
lands on the server host — co-locate the client and server, mount a shared
volume, or fetch the file out-of-band; the transcript body is never sent over
the MCP channel.

## Terminal UI

A Textual terminal UI drives the same in-process pipeline as the MCP server — no
browser, no HTTP service. Browse for recordings, queue them up, listen to each
detected speaker, name them, and write the transcript.

```sh
uv sync --python 3.11 --extra tui
export PARAKEET_WORKSPACE_DIR="./var"   # job artifacts + speaker cache live here
# PARAKEET_SHERPA_MODEL_DIR defaults to models/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8
# Where the file browser starts the very first time; after that it reopens
# wherever you left it.
export PARAKEET_RECORDINGS_DIR="$HOME/Library/Application Support/MacParakeet/meeting-recordings"

paraspeakrs tui
```

The TUI reads configuration from the environment only — it takes no command-line
flags, so `SPEAKRS_BIN` and friends must be exported rather than passed.

### Home: browse on the left, queue on the right

```
╭─ type to filter ─────────────────────────╮╭─ queue · 1 waiting ────────────╮
│📂 2026-09                                ││ Recording               Status │
│├── 📁 archive                            ││ standup-2026-09-15.wav  done   │
│├── 📄 standup-2026-09-15.wav  ✓          ││ standup-2026-09-16.wav  running│
│├── 📄 standup-2026-09-16.wav  …          ││ weekly-2026-09-16.wav   waiting│
│└── 📄 weekly-2026-09-16.wav   …          ││                                │
╰──────────────────────────────────────────╯╰────────────────────────────────╯
```

`Enter` on a recording adds it to the queue and hands control straight back;
items are transcribed one at a time in the background while you keep browsing.
`✓` marks a recording that already has a job, `…` one that is queued. The queue
survives a restart, and a run interrupted by a crash goes back to `waiting`.

Keys in the tree: `↑`/`↓` move, `→` open a directory in place, `←` close it,
`Enter` on a directory makes it the new root, `Backspace` go up to the parent.
The directory you end up in is remembered, so the next launch starts there.

Typing any other character fuzzy-filters the directory the tree is rooted at —
`stwav` finds `standup-2026-03-04.wav` — and jumps the cursor to the best match.
The filter applies to that level only; open a subdirectory and you see all of it.
`Backspace` deletes a character while a filter is active (and only goes up to the
parent once it is empty), `Esc` clears it.

Because the tree turns every letter into filter input, the screen's own keys are
also bound to `ctrl`: `^j` jobs, `^v` voices, `^g` go to a path, `^x` drop a
queued item, `^r` refresh. The bare letters work wherever the tree is not
listening — on the queue, for instance.

### Naming speakers

```
Speaker        Name        Suggested       Conf   Talk
L_SPEAKER_00   Albert      –               –      4:12
L_SPEAKER_01   –           Michiel         0.81   3:40
R_SPEAKER_01   –           (no match)      0.41   0:35
```

The two columns are never blended. **Name** is a decision — typed or accepted by
you — and is the only thing that ever reaches a transcript. **Suggested** is the
voice cache's nearest match, offered and never applied on its own; a speaker you
leave alone appears in the transcript as their raw `SPEAKER_xx` ID.

Keys: `p` play the selected speaker's sample, `s` stop, `n` name them, `Enter`
accept the suggestion on that row, `A` accept every suggestion, `space` select a
row, `m` merge the selected rows into one person, `u` clear an assigned name,
`t` write the transcript, `Esc` back. Playback uses `afplay` on macOS, `ffplay`
otherwise.

Merging is the ordinary case for stereo: one person picked up on both channels
arrives as `L_SPEAKER_00` and `R_SPEAKER_00`, and merging names both and teaches
the cache one voice learned from both.

Typing a name that already exists, for a voice that does not sound like it, asks
first — fold the two together, or name this job only and leave the stored voice
alone. Two different people really can share a first name, and one averaged
centroid then matches neither of them.

### Voices (`^v`)

```
▾ Michiel                                    2 recordings
    standup-09-16.wav   L_SPEAKER_01   2026-09-16
    weekly-09-16.wav    R_SPEAKER_00   2026-09-17
  Albert                                     5 recordings
```

The speaker cache, opened up. Each name lists the recordings it was learned from;
`p` plays one of them, `u` unfolds it — pulling that recording back out of the
voice and leaving its speaker unnamed again — `m` folds one name into another
(for the `Michiel`/`michiel` case), and `d` forgets a voice entirely.

Unfolding is exact because the cache stores its contributing members rather than
only a running average. Voices learned before this layout still match and can
still be folded into, but have no recording to play and cannot be unfolded.

### Jobs (`^j`)

Jobs are shared on disk with the MCP server (`$PARAKEET_WORKSPACE_DIR/mcp-jobs/`),
so past jobs are listed and can be reopened. The list is empty until something is
transcribed through the TUI or MCP server — `paraspeakrs run` prints its
result and keeps no artifacts, so it contributes no jobs. `Enter` opens a job,
`d` deletes one; deleting a job keeps the voices it taught.

## Jobs and reuse

Every transcription — CLI, TUI or MCP — is stored as a job under
`$PARAKEET_WORKSPACE_DIR/mcp-jobs/<job_id>/`, holding the diarization, the
per-speaker embeddings and the result. All three front ends read the same store,
so a job started on the CLI can be labeled in the TUI.

Jobs are keyed by a SHA-256 of the source audio, so transcribing the same
recording twice returns the existing job instead of re-running ASR and
diarization. Matching is on content, so a recording that was renamed, moved or
re-copied still matches. `--force` re-transcribes anyway.

```sh
paraspeakrs run meeting.m4a          # full run, stored as a job
paraspeakrs run meeting.m4a          # returns immediately, reuses the job
paraspeakrs run --txt meeting.m4a    # formatted transcript instead of JSON
paraspeakrs run --force meeting.m4a  # ignore the existing job
```

### What a job keeps

A finished job keeps its `artifacts.json` — transcript, diarization, speaker
embeddings — and nothing else. The copy of the source recording, both normalized
renders and the per-chunk WAVs are deleted once the job is saved: they are
roughly 99% of its size and all rebuildable from the original recording, which is
never touched.

Measured on a 2-minute recording: **15 MB → 72 KB** per job.

Speaker samples are cut on demand. If a job has been pruned, the audio is rebuilt
from `source_path`, the sample is cached under `snippets/`, and the rebuilt render
is deleted again. Move or delete the original recording and sample playback for
that job reports so plainly — the transcript itself is unaffected.

### Where the workspace lives

By default `$XDG_DATA_HOME/fast-speaker-aware-meeting-transcriber`, i.e.
`~/.local/share/fast-speaker-aware-meeting-transcriber`. It is machine-global, so
jobs and learned speakers are the same no matter which directory you launch from.
`PARAKEET_WORKSPACE_DIR` overrides it and is resolved to an absolute path.

It sits under the data directory rather than a cache directory deliberately:
`speaker-cache.json` holds the named voice prints, `ui-state.json` the browser's
last directory and the processing queue, and each job holds its
transcript, and neither can be regenerated if something sweeps the cache.

CLI flags default to the environment, so `PARAKEET_*` applies to
`paraspeakrs run` exactly as it does to `paraspeakrs tui` and `paraspeakrs mcp`, and an
explicit flag still wins.

Earlier versions used `./var` relative to the working directory. To carry that
history over:

```sh
DEST=~/.local/share/fast-speaker-aware-meeting-transcriber
mkdir -p "$DEST"
mv var/mcp-jobs "$DEST"/
mv var/speaker-cache.json "$DEST"/     # the irreplaceable part
```

The TUI says so explicitly when the new workspace is empty but a `./var` with
jobs is present.

## Output formats

The service exposes two representations of the same job:

- `GET /jobs/{job_id}` — full JSON with segments, words (when available), speakers, embeddings, and warnings.
- `GET /jobs/{job_id}/txt` — plain-text utterance transcript, one line per utterance:

  ```text
  [HH:MM:SS] NAME: utterance text
  ```

  `NAME` is the name assigned to that speaker if there is one, otherwise the raw `SPEAKER_xx` / `L_SPEAKER_xx` / `R_SPEAKER_xx` ID. A voice-cache match on its own is a suggestion and does not put a name here. Lines are split on sentence enders (`.`, `?`, `!`) and on relative silence within a speaker's run (rolling 90th-percentile gap, with a 0.6 s floor; the silence history resets whenever the speaker changes). When the active ASR backend does not provide word-level timestamps, sentence-splits fall back to character-proportional timestamps within each segment.

  Response status:
  - `200 text/plain` once the job is `completed`.
  - `202` with a one-line comment while the job is `queued` or `running` (poll the same URL).
  - `409` when the job exists but `failed`.
  - `404` when the job ID is unknown.

## Stereo handling

If the input audio has two channels the pipeline splits it into two mono streams,
runs Senko on each independently, and merges the segments back into a single
result. Speaker IDs from the left channel are prefixed `L_` and from the right
channel `R_` so they never collide. Mono input takes a single Senko pass and
keeps the bare `SPEAKER_xx` IDs Senko produces.

## Senko threading on macOS

Senko's Numba/OpenMP stack segfaults on macOS unless these are set:

```sh
KMP_DUPLICATE_LIB_OK=TRUE NUMBA_THREADING_LAYER=workqueue NUMBA_NUM_THREADS=1
```

`build_pipeline` sets them (via `os.environ.setdefault`) on macOS, so the
service, CLI, MCP server and terminal UI need no extra setup. The standalone
`packages/senko-diarize-test/diarize.py` helper does not go through
`build_pipeline`, so run it with the variables set explicitly:

```sh
KMP_DUPLICATE_LIB_OK=TRUE NUMBA_THREADING_LAYER=workqueue NUMBA_NUM_THREADS=1 \
  uv run python ./packages/senko-diarize-test/diarize.py
```

## Current Scope

This is milestone 1. speakrs diarization (including stereo channel-split) and its
per-speaker centroid embeddings are wired in, with Senko kept as an optional
backend. Article-inspired multi-level preprocessing is intentionally left for
later milestones.

## Acknowledgements

This service is mostly glue. The parts that do the actual work belong to other
people:

**Diarization**

- [speakrs](https://github.com/avencera/speakrs) by Praveen Perera (Apache-2.0) —
  the default backend. A Rust implementation of the pyannote `community-1`
  pipeline: segmentation, powerset decode, overlap-add aggregation, binarization,
  embedding, PLDA and VBx clustering, with no Python in the library path.
- [pyannote.audio](https://github.com/pyannote/pyannote-audio) by Hervé Bredin and
  contributors — the pipeline speakrs ports, and the `segmentation-3.0` model it
  runs.
- [WeSpeaker](https://github.com/wenet-e2e/wespeaker) — the speaker embedding
  model behind speakrs' 256-dim centroids, which is what makes the cross-meeting
  speaker cache possible.
- [Senko](https://github.com/narcotic-sh/senko) by narcotic-sh — the previous
  default, still available via `--diar-backend senko`, using CAM++ embeddings from
  [3D-Speaker](https://github.com/modelscope/3D-Speaker).

**Speech recognition**

- [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) by the k2-fsa team
  (Apache-2.0) — the ASR runtime.
- [NVIDIA NeMo Parakeet TDT 0.6B v3](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3)
  (CC-BY-4.0) — the ASR model, covering 25 European languages including Dutch.

**Infrastructure**

- [ONNX Runtime](https://github.com/microsoft/onnxruntime) and the
  [`ort`](https://github.com/pykeio/ort) crate — inference for both stacks.
- [FFmpeg](https://ffmpeg.org/) — every audio normalization, channel split and
  chunk export.
- [OpenBLAS](https://github.com/OpenMathLib/OpenBLAS) — the linear algebra behind
  speakrs' PLDA and VBx clustering.
- [FastAPI](https://github.com/fastapi/fastapi), [Textual](https://github.com/Textualize/textual),
  and the [Model Context Protocol](https://github.com/modelcontextprotocol) SDK —
  the service, terminal UI and MCP surfaces.
- [uv](https://github.com/astral-sh/uv) — environment and dependency management.

Licences differ per project; check each one before redistributing. The Parakeet
model's CC-BY-4.0 in particular requires attribution in anything built on it.
