HTTP API endpoint and web frontend for Cohere ASR with FP32, FP16 & split INT8/FP16 quantization for limited VRAM.
Python
0
13 commits
updated Sep 21, 2026
An HTTP API for transcribing audio files with the locally cached Cohere Transcribe models from HuggingFace:
| Model | Repo | Quantization | Languages |
|---|---|---|---|
cohere-transcribe-03-2026 | CohereLabs/cohere-transcribe-03-2026 | — (FP16) | en, fr, de, es, it, pt, nl, pl, el, ar, ja, zh, vi, ko |
cohere-transcribe-arabic-07-2026 | CohereLabs/cohere-transcribe-arabic-07-2026 | — (FP16) | ar, en (incl. Arabic–English code-switching) |
cohere-transcribe-03-2026-int8enc | CohereLabs/cohere-transcribe-03-2026 | INT8 encoder / FP16 decoder | same as base |
cohere-transcribe-arabic-07-2026-int8enc | CohereLabs/cohere-transcribe-arabic-07-2026 | INT8 encoder / FP16 decoder | same as base |
ivrit-whisper-large-v3-turbo-ct2 | ivrit-ai/whisper-large-v3-turbo-ct2 | INT8 via ctranslate2 | he (tuned), en, other Whisper languages |
The Cohere entries are 2B-parameter Conformer encoder-decoder models, served
via transformers>=5.4.0 (AutoProcessor + CohereAsrForConditionalGeneration).
The ivrit entry is a Whisper large-v3-turbo fine-tune for Hebrew in CTranslate2
format, served via a second backend, faster-whisper. Weights are loaded from
the local HuggingFace cache (~/.cache/huggingface/hub/) — no downloads
required (and enforced: ASR_LOCAL_FILES_ONLY=1 by default, so a cache miss
fails loudly instead of fetching mid-request).
Security posture. The API itself implements no authentication. Access is
gated in front of it, by a host firewall plus Cloudflare Zero Trust login;
there is no unauthenticated public path to it. That is an accepted risk for
this deployment, which is why the daemon still binds 0.0.0.0 by default. If
you deploy it any other way, put an authenticating proxy in front of it, or
start it with --host 127.0.0.1.
POST /v1/audio/transcriptionsTranscribes an uploaded audio file (OpenAI-style multipart form). Long-form audio (>35 s) is chunked and reassembled automatically.
| Form field | Type | Default | Description |
|---|---|---|---|
file | file | required | Audio file (wav, mp3, …; resampled to 16 kHz) |
model | string | server default (see ASR_DEFAULT_MODEL) | Model id from the table above |
language | string | en (base) / ar (Arabic model) | Language code; validated against the model's supported languages |
punctuation | bool | true | false gives lower-cased output without punctuation |
max_new_tokens | int | 256 | Generation cap |
background | bool | false | true → return 202 + job_id immediately instead of waiting |
cleanup | bool | false | true → LLM post-process the transcript (see LLM post-cleanup) |
translate_lang | string | — | with cleanup=true: translate the cleaned transcript into this language |
translate_modifier | string | — | optional modifier combined as "{modifier} {lang}", e.g. Levantine + Arabic (≤ 40 chars) |
Response:
{
"text": " If not, there will be a big crisis between you and the European Parliament.",
"model": "cohere-transcribe-03-2026",
"language": "en",
"duration_s": 5.44
}
Errors: 400 for unknown model, unsupported language, or undecodable audio; 500 for inference failures.
Backend notes: punctuation and max_new_tokens only apply to the Cohere
models — the faster-whisper backend (ivrit-whisper-large-v3-turbo-ct2)
ignores them (Whisper always punctuates, and long-audio windowing is handled
internally instead of the ASR_CHUNK_BATCH mini-batch loop).
Long recordings and proxy timeouts (Cloudflare's 100 s): synchronous
requests only work for audio short enough to finish within the proxy limit.
For anything longer, submit with background=true — the request returns in
under a second — and poll the job endpoint:
# 1. Submit (returns immediately)
curl -X POST https://asr.ninefive.cc/v1/audio/transcriptions \
-H "Cookie: CF_Authorization=$CF_JWT" \
-F "file=@long_recording.mp3" -F "background=true" -F "language=ar"
# → {"job_id": "070c7b86…", "status": "queued"}
# 2. Poll until "done" (each poll is a fast request, no proxy timeout)
curl -H "Cookie: CF_Authorization=$CF_JWT" \
https://asr.ninefive.cc/v1/audio/transcriptions/jobs/070c7b86…
# → {"status": "queued"|"running"|"done"|"failed", "result": {...}|"error": "..."}
Verified through Cloudflare with a 24-minute mp3 (42 chunks): submit 0.8 s,
job completes in the background, VRAM freed afterwards. Long-form generation
runs in mini-batches of ASR_CHUNK_BATCH chunks (default 4) so activation
memory stays bounded regardless of recording length.
Jobs are kept in process memory (lost on restart); one worker thread processes them in FIFO order, serialized with synchronous requests.
GET /v1/audio/transcriptions/jobs/{job_id}Returns {"job_id", "status", ...} — result when done, error when failed.
GET /v1/modelsLists registered models, default languages, and whether each is loaded.
GET /healthReturns {"status": "ok"}.
# English (default model)
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F "[email protected]" \
-F "language=en"
# Arabic model
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F "[email protected]" \
-F "model=cohere-transcribe-arabic-07-2026" \
-F "language=ar"
# No punctuation
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F "[email protected]" -F "language=en" -F "punctuation=false"
Interactive API docs (FastAPI/Swagger): http://localhost:8000/docs
/)A user-facing page is served at / (API docs stay at /docs):
localStorage, follows prefers-color-scheme by default)-int8enc quantized variants preselected/recommended;
language picker scoped to the chosen model family with full names
(en - English, ar+en - Arabic / English (mixed, code-switched), …)background=true and polls, so even long recordings never hit
proxy timeouts; the transcript appears in a formatted card (RTL-aware) with
a Download .txt buttonPass cleanup=true (form field) to post-process the transcript with a local
ollama LLM (gemma4:e2b at http://10.200.100.5:11434, override with
OLLAMA_HOST / OLLAMA_MODEL). The prompts in
llm-post-processing-system-prompt.txt / llm-post-processing-user-prompt.txt
make it add punctuation/capitalization, format numbers/dates, and
conservatively remove ASR artifacts while preserving every spoken word
(including fillers), for English, Arabic, and code-switched text.
keep_alive: 0) — verified via /api/ps → empty and
VRAM back at baseline.cleanup object: cleaned_text, changes[] (type,
original, replacement, reason, confidence), warnings[]. On LLM failure the
transcription still succeeds and cleanup.error is set instead.translate_lang (e.g. French) and optionally
translate_modifier (e.g. Levantine, combined as "Levantine Arabic") —
the core cleaning rules still run first, then the cleaned text is
translated; cleaned_text holds the translation and
cleanup.translation.target echoes the target. The web UI gates this
behind a checkbox with a type-to-narrow language autocomplete (~95 major
languages) and a 40-char-capped modifier box../transcribe file.mp3 --cleanup also saves <name>.cleaned.txt;
add --translate French [--translate-modifier ...] for translation.The cached hexgrad/Kokoro-82M
model is integrated for TTS, standalone or combined with transcription. It
runs on CPU (fast at 82M params; the GPU stays free for ASR).
POST /v1/audio/speech — form fields text, voice (default af_heart),
speed (0.5–2.0) → audio/wav (24 kHz).GET /v1/tts/voices — all 54 cached voices with gender/language labels.speak_voice=<id> (and optional speak_speed) to
/v1/audio/transcriptions — the result gains tts.audio_wav_b64, spoken
from the cleaned/translated text when cleanup ran, else the raw transcript../transcribe file.mp3 --speak [voice] saves <name>.<voice>.wav.Language coverage: Kokoro has voices for English (US/GB), Spanish, French,
Hindi, Italian, Japanese, Mandarin, and Portuguese (BR) — no Arabic. Voice
id prefix picks the language; the UI groups voices accordingly. Note the
kokoro/misaki packages pin Python <3.13 and their English G2P needs spacy, so
on this host they are installed with --no-deps --ignore-requires-python and
tts.py routes all languages through misaki's espeak-based G2P (English
homograph handling is slightly weaker than the spacy path as a result).
silma-ai/silma-tts (F5-TTS
based, 150M) covers Arabic and English — filling Kokoro's Arabic gap. It
clones voices from short reference clips instead of named voices: entries in
voices/ (<id>.wav + <id>.txt transcript) appear as silma_* voices in
/v1/tts/voices and the UI picker (group "Arabic + English (voice clone)").
Currently bundled: silma_ar_1, silma_ar_2 (Arabic), silma_en_1 (English).
Add your own by dropping a clean 3–10 s reference wav + exact transcript into
voices/ (mind the model's voice-consent license terms). SILMA runs on CPU
(SILMA_TTS_DEVICE to override), auto-applies Arabic tashkeel via CATT, and
is usable exactly like a Kokoro voice — standalone /v1/audio/speech calls or
speak_voice=silma_* on transcriptions.
Host shims (see tts_silma.py): NeMo text normalizer is stubbed pass-through
(pynini won't build on py3.14 — numbers/dates aren't normalized), and
torchaudio.load is replaced with a soundfile reader (torchcodec's native
lib won't load here).
./transcribe)Wraps the whole flow — file selection, upload, background polling (immune to
proxy timeouts), printing, and saving. Zero dependencies (Python standard
library only, any Python 3.8+): copy this one file to any machine and run it,
no venv or pip install needed. A GUI file picker (zenity) is used when no
path is given and available, otherwise a terminal prompt.
./transcribe sermon.mp3 # GUI file picker if no path given (zenity)
./transcribe sermon.mp3 --language ar # saves sermon.cohere-transcribe-arabic-07-2026-int8enc.txt
./transcribe sermon.mp3 --output out.txt --no-punctuation
./transcribe sermon.mp3 --server http://127.0.0.1:8000 # local server, no auth
https://asr.ninefive.cc (override with
--server or ASR_SERVER; also ASR_MODEL, ASR_LANGUAGE).CF_Authorization JWT via
--cookie, the CF_AUTHORIZATION env var, or a ./cf.jwt file (note: the
cookie expires ~24 h after browser login). Localhost servers skip auth.background=true and polls, so even hour-long files
never hit the 100 s Cloudflare timeout. Progress dots while waiting;
Ctrl+C aborts the client without killing the server-side job.<name>.<model-id>.txt unless --output is given.The asr-server script manages a daemonized (nohup + pidfile) server in
userland — no systemd needed:
cd /home/nolan/cohere-asr
./asr-server start --device cuda --gpu 0 \
--default-model cohere-transcribe-arabic-07-2026-int8enc
./asr-server status # pid + /health + VRAM in use
./asr-server logs # follow the server log (asr-server.log)
./asr-server stop # SIGTERM, then SIGKILL if needed
./asr-server restart --device cpu --dtype float32 --port 8080
| Option | Env var | Default | Purpose |
|---|---|---|---|
--device cuda|cpu | ASR_DEVICE | cuda if available | Inference device |
--gpu N | CUDA_VISIBLE_DEVICES | all visible | GPU selection |
--dtype … | ASR_DTYPE | fp16 (GPU) / fp32 (CPU) | Model dtype |
--default-model ID | ASR_DEFAULT_MODEL | cohere-transcribe-03-2026 | Model when requests omit model — set an -int8enc id for quantized-by-default |
--chunk-batch N | ASR_CHUNK_BATCH | 4 | Long-audio chunks per generate mini-batch (lower if OOM) |
| — | ASR_WHISPER_COMPUTE | int8 | ctranslate2 compute type for the faster-whisper backend (export before start; int8_float16 needs CC ≥ 7.0 — not on the P1000) |
--host / --port | — | 0.0.0.0 / 8000 | Bind address |
| — | ASR_MAX_UPLOAD_MB | 200 | Uploads above this are rejected with 413; uploads are streamed to disk, never fully buffered |
| — | ASR_MAX_AUDIO_SECONDS | 7200 | Decoded-duration cap (413 beyond it) |
| — | ASR_MAX_QUEUED_JOBS | 32 | Background queue depth — further submits get 503 instead of queueing forever |
| — | ASR_JOB_TTL_S | 3600 | Finished/failed jobs (which hold transcripts) are dropped after this long |
| — | ASR_OLLAMA_TIMEOUT | 600 | Timeout for a single LLM cleanup call |
| — | ASR_LOCAL_FILES_ONLY | 1 | Set 0 to allow downloading model weights (off by default) |
| — | ASR_MODEL_REVISIONS | — | JSON integrity pin, e.g. {"cohere-transcribe-03-2026": "<sha>"} |
| — | ASR_MAX_WAIT_S (CLI) | 3600 | Client gives up with an error instead of polling forever |
Plain uvicorn app:app --host 0.0.0.0 --port 8000 still works for
foreground/debug runs.
Every request (or background job) loads the model it needs, transcribes, and then completely evicts the model from VRAM before finishing (verified: VRAM returns to the ~85 MiB CUDA-context baseline after every request, including error paths). This keeps the GPU free for co-located processes (e.g. ollama) and makes stale-model eviction bugs impossible — two models are never in VRAM at once. The trade-off: each request pays a model load (~10–40 s depending on page cache; INT8 variants also re-quantize). The whole load → generate → unload lifecycle runs under one lock, so requests are serialized and a model is never freed mid-generation.
The -int8enc variants reproduce the
FluidInference/cohere-transcribe-03-2026-coreml
hybrid scheme natively in PyTorch: per-output-channel symmetric INT8 encoder
weights with FP16 activations; the decoder, embeddings, LayerNorms, and conv
layers stay FP16 (precision-sensitive and small). Implemented in quant.py
(Int8Linear), applied to every nn.Linear of the encoder at load time —
no extra dependencies and no calibration data needed.
Why not serve the CoreML artifacts directly? That repo contains Apple
CoreML packages (.mlpackage/.mlmodelc), and the CoreML runtime only exists
on macOS/iOS — it cannot execute on this Linux host. The files remain in the
HF cache for reference/Mac use; the API derives the same quantization from the
original FP16 weights instead.
Measured on the Quadro P1000:
| Variant | VRAM after load | EN demo transcript |
|---|---|---|
| FP16 | ~3.9 GB | reference |
-int8enc | ~2.6 GB | identical to FP16 |
Both -int8enc variants produced transcripts identical to their FP16
counterparts on the demo files (English and Arabic). Encoder weight memory
drops ~2× overall (INT8 vs FP16 on the encoder's linear layers).
.venv/)python3 -m venv .venv
.venv/bin/pip install --upgrade pip
# Torch cu126 build — required for the Quadro P1000, see notes
# torch / torchaudio first, from the PyTorch index — see requirements-torch.txt
.venv/bin/pip install torch==2.13.0+cu126 --index-url https://download.pytorch.org/whl/cu126
.venv/bin/pip install torchaudio==2.11.0+cpu --index-url https://download.pytorch.org/whl/cpu
.venv/bin/pip install -r requirements.txt
device_map="auto" places what fits on the GPU
(observed ~3.9 GB used) and offloads any overflow to CPU RAM. CPU inference
also works (ASR_DEVICE=cpu, fp32, needs ~8 GB RAM per model).transcribe is a thin
wrapper around _transcribe that re-raises captured errors after unloading.bmm) through its native Triton kernels, which need a C compiler and the
Python dev headers at runtime — gcc and python3.14-dev are installed on
this host, so this path works. If you deploy elsewhere without them, set
TORCH_DISABLE_NATIVE_JIT=1 to fall back to standard aten kernels.-int8enc)-int8enc)arabic-07-2026-int8enc model, GPU
mini-batches of 4 vs CPU single batch, 4 files incl. a 24-min/42-chunk one):
3 of 4 byte-identical; the 4th differed in exactly one word
(الغروزني. vs الغروزنة. — an fp16-vs-fp32 rounding artifact, not chunking).
Repro scripts and transcripts in comparison/.comparison/timing_summary.txt.--chunk-batch sweet spot: 4 (the default). Benchmarked 1–32 on the
24-min file (comparison/chunk_bench_summary.txt): inference time is flat
(~316–328 s) from batch 1 through 8 — the workload is dominated by
autoregressive decoder steps, so encoder batching buys nothing on the
P1000 — while peak VRAM climbs 2.8 → 3.6 GB. Batch 4 is the fastest with
comfortable headroom (3.1 GB peak); 16+ OOMs the 4 GB card. Drop to 1–2
only if ollama needs a few hundred extra MB during a transcription.background=true returns 202 + job_id and the work happens on a single
worker thread. The queue is bounded (ASR_MAX_QUEUED_JOBS, 503 when full),
finished jobs expire after ASR_JOB_TTL_S, and DELETE /v1/audio/transcriptions/jobs/{id} drops one immediately — job results hold
the transcript, so they are not kept indefinitely.
The worker's loop body is fully guarded: an unexpected error fails that one job
and the thread keeps serving. If it ever did die, every job would sit in
queued forever, which is why /health now reports worker_alive,
queue_depth and seconds_since_last_job and returns degraded when the
queue has not drained. Clients (transcribe, the web UI) also stop waiting
after ASR_MAX_WAIT_S / 60 minutes instead of polling forever.
asr-server above 20 MiB, and contain
client IPs, so treat them as personal data.voices/ (SILMA voice-clone references) is not tracked — the files are
needed at runtime but are real voice samples, so keep them out of the
repository and ship them out of band.comparison/*.txt are real transcripts kept as benchmark fixtures; they are
a deliberate retention choice, not an oversight.ASR_DEVICE=cpu ASR_DTYPE=float32 .venv/bin/python -m pytest -q
Covers input validation (upload caps, voice allowlist, token/language bounds), job lifecycle (TTL, DELETE, backpressure), worker resilience, health reporting, and the CLI's credential rules. No model is loaded and no network call is made.
cohere-asr/
├── app.py # FastAPI service (single file)
├── static/index.html # web UI served at /
├── quant.py # INT8 encoder quantization (Int8Linear)
├── tts.py # Kokoro-82M TTS (espeak G2P routing)
├── tts_silma.py # SILMA TTS (Arabic/English voice cloning)
├── voices/ # SILMA reference clips (<id>.wav + <id>.txt) — NOT tracked
├── asr-server # daemon manager (start/stop/status/logs)
├── transcribe # CLI client (upload + poll + save)
├── tests/ # pytest suite (no model loads, no network)
├── .github/workflows/ # CI
├── comparison/ # benchmark scripts and transcripts
├── requirements.txt # Python dependencies (pinned)
├── requirements-torch.txt # torch/torchaudio pins (PyTorch index, install first)
├── .venv/ # virtualenv with the verified stack
├── asr-server.*.log # daemon logs, per port (created at runtime)
├── asr-server.*.pid # daemon pidfiles, per port (created at runtime)
└── README.md
HTTP API endpoint and web frontend for Cohere ASR with FP32, FP16 & split INT8/FP16 quantization for limited VRAM.
Python
0
13 commits
updated Sep 21, 2026
An HTTP API for transcribing audio files with the locally cached Cohere Transcribe models from HuggingFace:
| Model | Repo | Quantization | Languages |
|---|---|---|---|
cohere-transcribe-03-2026 | CohereLabs/cohere-transcribe-03-2026 | — (FP16) | en, fr, de, es, it, pt, nl, pl, el, ar, ja, zh, vi, ko |
cohere-transcribe-arabic-07-2026 | CohereLabs/cohere-transcribe-arabic-07-2026 | — (FP16) | ar, en (incl. Arabic–English code-switching) |
cohere-transcribe-03-2026-int8enc | CohereLabs/cohere-transcribe-03-2026 | INT8 encoder / FP16 decoder | same as base |
cohere-transcribe-arabic-07-2026-int8enc | CohereLabs/cohere-transcribe-arabic-07-2026 | INT8 encoder / FP16 decoder | same as base |
ivrit-whisper-large-v3-turbo-ct2 | ivrit-ai/whisper-large-v3-turbo-ct2 | INT8 via ctranslate2 | he (tuned), en, other Whisper languages |
The Cohere entries are 2B-parameter Conformer encoder-decoder models, served
via transformers>=5.4.0 (AutoProcessor + CohereAsrForConditionalGeneration).
The ivrit entry is a Whisper large-v3-turbo fine-tune for Hebrew in CTranslate2
format, served via a second backend, faster-whisper. Weights are loaded from
the local HuggingFace cache (~/.cache/huggingface/hub/) — no downloads
required (and enforced: ASR_LOCAL_FILES_ONLY=1 by default, so a cache miss
fails loudly instead of fetching mid-request).
Security posture. The API itself implements no authentication. Access is
gated in front of it, by a host firewall plus Cloudflare Zero Trust login;
there is no unauthenticated public path to it. That is an accepted risk for
this deployment, which is why the daemon still binds 0.0.0.0 by default. If
you deploy it any other way, put an authenticating proxy in front of it, or
start it with --host 127.0.0.1.
POST /v1/audio/transcriptionsTranscribes an uploaded audio file (OpenAI-style multipart form). Long-form audio (>35 s) is chunked and reassembled automatically.
| Form field | Type | Default | Description |
|---|---|---|---|
file | file | required | Audio file (wav, mp3, …; resampled to 16 kHz) |
model | string | server default (see ASR_DEFAULT_MODEL) | Model id from the table above |
language | string | en (base) / ar (Arabic model) | Language code; validated against the model's supported languages |
punctuation | bool | true | false gives lower-cased output without punctuation |
max_new_tokens | int | 256 | Generation cap |
background | bool | false | true → return 202 + job_id immediately instead of waiting |
cleanup | bool | false | true → LLM post-process the transcript (see LLM post-cleanup) |
translate_lang | string | — | with cleanup=true: translate the cleaned transcript into this language |
translate_modifier | string | — | optional modifier combined as "{modifier} {lang}", e.g. Levantine + Arabic (≤ 40 chars) |
Response:
{
"text": " If not, there will be a big crisis between you and the European Parliament.",
"model": "cohere-transcribe-03-2026",
"language": "en",
"duration_s": 5.44
}
Errors: 400 for unknown model, unsupported language, or undecodable audio; 500 for inference failures.
Backend notes: punctuation and max_new_tokens only apply to the Cohere
models — the faster-whisper backend (ivrit-whisper-large-v3-turbo-ct2)
ignores them (Whisper always punctuates, and long-audio windowing is handled
internally instead of the ASR_CHUNK_BATCH mini-batch loop).
Long recordings and proxy timeouts (Cloudflare's 100 s): synchronous
requests only work for audio short enough to finish within the proxy limit.
For anything longer, submit with background=true — the request returns in
under a second — and poll the job endpoint:
# 1. Submit (returns immediately)
curl -X POST https://asr.ninefive.cc/v1/audio/transcriptions \
-H "Cookie: CF_Authorization=$CF_JWT" \
-F "file=@long_recording.mp3" -F "background=true" -F "language=ar"
# → {"job_id": "070c7b86…", "status": "queued"}
# 2. Poll until "done" (each poll is a fast request, no proxy timeout)
curl -H "Cookie: CF_Authorization=$CF_JWT" \
https://asr.ninefive.cc/v1/audio/transcriptions/jobs/070c7b86…
# → {"status": "queued"|"running"|"done"|"failed", "result": {...}|"error": "..."}
Verified through Cloudflare with a 24-minute mp3 (42 chunks): submit 0.8 s,
job completes in the background, VRAM freed afterwards. Long-form generation
runs in mini-batches of ASR_CHUNK_BATCH chunks (default 4) so activation
memory stays bounded regardless of recording length.
Jobs are kept in process memory (lost on restart); one worker thread processes them in FIFO order, serialized with synchronous requests.
GET /v1/audio/transcriptions/jobs/{job_id}Returns {"job_id", "status", ...} — result when done, error when failed.
GET /v1/modelsLists registered models, default languages, and whether each is loaded.
GET /healthReturns {"status": "ok"}.
# English (default model)
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F "[email protected]" \
-F "language=en"
# Arabic model
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F "[email protected]" \
-F "model=cohere-transcribe-arabic-07-2026" \
-F "language=ar"
# No punctuation
curl -X POST http://localhost:8000/v1/audio/transcriptions \
-F "[email protected]" -F "language=en" -F "punctuation=false"
Interactive API docs (FastAPI/Swagger): http://localhost:8000/docs
/)A user-facing page is served at / (API docs stay at /docs):
localStorage, follows prefers-color-scheme by default)-int8enc quantized variants preselected/recommended;
language picker scoped to the chosen model family with full names
(en - English, ar+en - Arabic / English (mixed, code-switched), …)background=true and polls, so even long recordings never hit
proxy timeouts; the transcript appears in a formatted card (RTL-aware) with
a Download .txt buttonPass cleanup=true (form field) to post-process the transcript with a local
ollama LLM (gemma4:e2b at http://10.200.100.5:11434, override with
OLLAMA_HOST / OLLAMA_MODEL). The prompts in
llm-post-processing-system-prompt.txt / llm-post-processing-user-prompt.txt
make it add punctuation/capitalization, format numbers/dates, and
conservatively remove ASR artifacts while preserving every spoken word
(including fillers), for English, Arabic, and code-switched text.
keep_alive: 0) — verified via /api/ps → empty and
VRAM back at baseline.cleanup object: cleaned_text, changes[] (type,
original, replacement, reason, confidence), warnings[]. On LLM failure the
transcription still succeeds and cleanup.error is set instead.translate_lang (e.g. French) and optionally
translate_modifier (e.g. Levantine, combined as "Levantine Arabic") —
the core cleaning rules still run first, then the cleaned text is
translated; cleaned_text holds the translation and
cleanup.translation.target echoes the target. The web UI gates this
behind a checkbox with a type-to-narrow language autocomplete (~95 major
languages) and a 40-char-capped modifier box../transcribe file.mp3 --cleanup also saves <name>.cleaned.txt;
add --translate French [--translate-modifier ...] for translation.The cached hexgrad/Kokoro-82M
model is integrated for TTS, standalone or combined with transcription. It
runs on CPU (fast at 82M params; the GPU stays free for ASR).
POST /v1/audio/speech — form fields text, voice (default af_heart),
speed (0.5–2.0) → audio/wav (24 kHz).GET /v1/tts/voices — all 54 cached voices with gender/language labels.speak_voice=<id> (and optional speak_speed) to
/v1/audio/transcriptions — the result gains tts.audio_wav_b64, spoken
from the cleaned/translated text when cleanup ran, else the raw transcript../transcribe file.mp3 --speak [voice] saves <name>.<voice>.wav.Language coverage: Kokoro has voices for English (US/GB), Spanish, French,
Hindi, Italian, Japanese, Mandarin, and Portuguese (BR) — no Arabic. Voice
id prefix picks the language; the UI groups voices accordingly. Note the
kokoro/misaki packages pin Python <3.13 and their English G2P needs spacy, so
on this host they are installed with --no-deps --ignore-requires-python and
tts.py routes all languages through misaki's espeak-based G2P (English
homograph handling is slightly weaker than the spacy path as a result).
silma-ai/silma-tts (F5-TTS
based, 150M) covers Arabic and English — filling Kokoro's Arabic gap. It
clones voices from short reference clips instead of named voices: entries in
voices/ (<id>.wav + <id>.txt transcript) appear as silma_* voices in
/v1/tts/voices and the UI picker (group "Arabic + English (voice clone)").
Currently bundled: silma_ar_1, silma_ar_2 (Arabic), silma_en_1 (English).
Add your own by dropping a clean 3–10 s reference wav + exact transcript into
voices/ (mind the model's voice-consent license terms). SILMA runs on CPU
(SILMA_TTS_DEVICE to override), auto-applies Arabic tashkeel via CATT, and
is usable exactly like a Kokoro voice — standalone /v1/audio/speech calls or
speak_voice=silma_* on transcriptions.
Host shims (see tts_silma.py): NeMo text normalizer is stubbed pass-through
(pynini won't build on py3.14 — numbers/dates aren't normalized), and
torchaudio.load is replaced with a soundfile reader (torchcodec's native
lib won't load here).
./transcribe)Wraps the whole flow — file selection, upload, background polling (immune to
proxy timeouts), printing, and saving. Zero dependencies (Python standard
library only, any Python 3.8+): copy this one file to any machine and run it,
no venv or pip install needed. A GUI file picker (zenity) is used when no
path is given and available, otherwise a terminal prompt.
./transcribe sermon.mp3 # GUI file picker if no path given (zenity)
./transcribe sermon.mp3 --language ar # saves sermon.cohere-transcribe-arabic-07-2026-int8enc.txt
./transcribe sermon.mp3 --output out.txt --no-punctuation
./transcribe sermon.mp3 --server http://127.0.0.1:8000 # local server, no auth
https://asr.ninefive.cc (override with
--server or ASR_SERVER; also ASR_MODEL, ASR_LANGUAGE).CF_Authorization JWT via
--cookie, the CF_AUTHORIZATION env var, or a ./cf.jwt file (note: the
cookie expires ~24 h after browser login). Localhost servers skip auth.background=true and polls, so even hour-long files
never hit the 100 s Cloudflare timeout. Progress dots while waiting;
Ctrl+C aborts the client without killing the server-side job.<name>.<model-id>.txt unless --output is given.The asr-server script manages a daemonized (nohup + pidfile) server in
userland — no systemd needed:
cd /home/nolan/cohere-asr
./asr-server start --device cuda --gpu 0 \
--default-model cohere-transcribe-arabic-07-2026-int8enc
./asr-server status # pid + /health + VRAM in use
./asr-server logs # follow the server log (asr-server.log)
./asr-server stop # SIGTERM, then SIGKILL if needed
./asr-server restart --device cpu --dtype float32 --port 8080
| Option | Env var | Default | Purpose |
|---|---|---|---|
--device cuda|cpu | ASR_DEVICE | cuda if available | Inference device |
--gpu N | CUDA_VISIBLE_DEVICES | all visible | GPU selection |
--dtype … | ASR_DTYPE | fp16 (GPU) / fp32 (CPU) | Model dtype |
--default-model ID | ASR_DEFAULT_MODEL | cohere-transcribe-03-2026 | Model when requests omit model — set an -int8enc id for quantized-by-default |
--chunk-batch N | ASR_CHUNK_BATCH | 4 | Long-audio chunks per generate mini-batch (lower if OOM) |
| — | ASR_WHISPER_COMPUTE | int8 | ctranslate2 compute type for the faster-whisper backend (export before start; int8_float16 needs CC ≥ 7.0 — not on the P1000) |
--host / --port | — | 0.0.0.0 / 8000 | Bind address |
| — | ASR_MAX_UPLOAD_MB | 200 | Uploads above this are rejected with 413; uploads are streamed to disk, never fully buffered |
| — | ASR_MAX_AUDIO_SECONDS | 7200 | Decoded-duration cap (413 beyond it) |
| — | ASR_MAX_QUEUED_JOBS | 32 | Background queue depth — further submits get 503 instead of queueing forever |
| — | ASR_JOB_TTL_S | 3600 | Finished/failed jobs (which hold transcripts) are dropped after this long |
| — | ASR_OLLAMA_TIMEOUT | 600 | Timeout for a single LLM cleanup call |
| — | ASR_LOCAL_FILES_ONLY | 1 | Set 0 to allow downloading model weights (off by default) |
| — | ASR_MODEL_REVISIONS | — | JSON integrity pin, e.g. {"cohere-transcribe-03-2026": "<sha>"} |
| — | ASR_MAX_WAIT_S (CLI) | 3600 | Client gives up with an error instead of polling forever |
Plain uvicorn app:app --host 0.0.0.0 --port 8000 still works for
foreground/debug runs.
Every request (or background job) loads the model it needs, transcribes, and then completely evicts the model from VRAM before finishing (verified: VRAM returns to the ~85 MiB CUDA-context baseline after every request, including error paths). This keeps the GPU free for co-located processes (e.g. ollama) and makes stale-model eviction bugs impossible — two models are never in VRAM at once. The trade-off: each request pays a model load (~10–40 s depending on page cache; INT8 variants also re-quantize). The whole load → generate → unload lifecycle runs under one lock, so requests are serialized and a model is never freed mid-generation.
The -int8enc variants reproduce the
FluidInference/cohere-transcribe-03-2026-coreml
hybrid scheme natively in PyTorch: per-output-channel symmetric INT8 encoder
weights with FP16 activations; the decoder, embeddings, LayerNorms, and conv
layers stay FP16 (precision-sensitive and small). Implemented in quant.py
(Int8Linear), applied to every nn.Linear of the encoder at load time —
no extra dependencies and no calibration data needed.
Why not serve the CoreML artifacts directly? That repo contains Apple
CoreML packages (.mlpackage/.mlmodelc), and the CoreML runtime only exists
on macOS/iOS — it cannot execute on this Linux host. The files remain in the
HF cache for reference/Mac use; the API derives the same quantization from the
original FP16 weights instead.
Measured on the Quadro P1000:
| Variant | VRAM after load | EN demo transcript |
|---|---|---|
| FP16 | ~3.9 GB | reference |
-int8enc | ~2.6 GB | identical to FP16 |
Both -int8enc variants produced transcripts identical to their FP16
counterparts on the demo files (English and Arabic). Encoder weight memory
drops ~2× overall (INT8 vs FP16 on the encoder's linear layers).
.venv/)python3 -m venv .venv
.venv/bin/pip install --upgrade pip
# Torch cu126 build — required for the Quadro P1000, see notes
# torch / torchaudio first, from the PyTorch index — see requirements-torch.txt
.venv/bin/pip install torch==2.13.0+cu126 --index-url https://download.pytorch.org/whl/cu126
.venv/bin/pip install torchaudio==2.11.0+cpu --index-url https://download.pytorch.org/whl/cpu
.venv/bin/pip install -r requirements.txt
device_map="auto" places what fits on the GPU
(observed ~3.9 GB used) and offloads any overflow to CPU RAM. CPU inference
also works (ASR_DEVICE=cpu, fp32, needs ~8 GB RAM per model).transcribe is a thin
wrapper around _transcribe that re-raises captured errors after unloading.bmm) through its native Triton kernels, which need a C compiler and the
Python dev headers at runtime — gcc and python3.14-dev are installed on
this host, so this path works. If you deploy elsewhere without them, set
TORCH_DISABLE_NATIVE_JIT=1 to fall back to standard aten kernels.-int8enc)-int8enc)arabic-07-2026-int8enc model, GPU
mini-batches of 4 vs CPU single batch, 4 files incl. a 24-min/42-chunk one):
3 of 4 byte-identical; the 4th differed in exactly one word
(الغروزني. vs الغروزنة. — an fp16-vs-fp32 rounding artifact, not chunking).
Repro scripts and transcripts in comparison/.comparison/timing_summary.txt.--chunk-batch sweet spot: 4 (the default). Benchmarked 1–32 on the
24-min file (comparison/chunk_bench_summary.txt): inference time is flat
(~316–328 s) from batch 1 through 8 — the workload is dominated by
autoregressive decoder steps, so encoder batching buys nothing on the
P1000 — while peak VRAM climbs 2.8 → 3.6 GB. Batch 4 is the fastest with
comfortable headroom (3.1 GB peak); 16+ OOMs the 4 GB card. Drop to 1–2
only if ollama needs a few hundred extra MB during a transcription.background=true returns 202 + job_id and the work happens on a single
worker thread. The queue is bounded (ASR_MAX_QUEUED_JOBS, 503 when full),
finished jobs expire after ASR_JOB_TTL_S, and DELETE /v1/audio/transcriptions/jobs/{id} drops one immediately — job results hold
the transcript, so they are not kept indefinitely.
The worker's loop body is fully guarded: an unexpected error fails that one job
and the thread keeps serving. If it ever did die, every job would sit in
queued forever, which is why /health now reports worker_alive,
queue_depth and seconds_since_last_job and returns degraded when the
queue has not drained. Clients (transcribe, the web UI) also stop waiting
after ASR_MAX_WAIT_S / 60 minutes instead of polling forever.
asr-server above 20 MiB, and contain
client IPs, so treat them as personal data.voices/ (SILMA voice-clone references) is not tracked — the files are
needed at runtime but are real voice samples, so keep them out of the
repository and ship them out of band.comparison/*.txt are real transcripts kept as benchmark fixtures; they are
a deliberate retention choice, not an oversight.ASR_DEVICE=cpu ASR_DTYPE=float32 .venv/bin/python -m pytest -q
Covers input validation (upload caps, voice allowlist, token/language bounds), job lifecycle (TTL, DELETE, backpressure), worker resilience, health reporting, and the CLI's credential rules. No model is loaded and no network call is made.
cohere-asr/
├── app.py # FastAPI service (single file)
├── static/index.html # web UI served at /
├── quant.py # INT8 encoder quantization (Int8Linear)
├── tts.py # Kokoro-82M TTS (espeak G2P routing)
├── tts_silma.py # SILMA TTS (Arabic/English voice cloning)
├── voices/ # SILMA reference clips (<id>.wav + <id>.txt) — NOT tracked
├── asr-server # daemon manager (start/stop/status/logs)
├── transcribe # CLI client (upload + poll + save)
├── tests/ # pytest suite (no model loads, no network)
├── .github/workflows/ # CI
├── comparison/ # benchmark scripts and transcripts
├── requirements.txt # Python dependencies (pinned)
├── requirements-torch.txt # torch/torchaudio pins (PyTorch index, install first)
├── .venv/ # virtualenv with the verified stack
├── asr-server.*.log # daemon logs, per port (created at runtime)
├── asr-server.*.pid # daemon pidfiles, per port (created at runtime)
└── README.md