OpenAI-compatible speech-to-text API server for IBM Granite Speech 4.1-2B, plus and NAR variants, exposing three backends POST /v1/audio/transcriptions interface. This project also provides two wrapper endpoints that automatically chunck and restitch results of the base and plus models to overcome context length limitations.
| Port (env var) | Service | Model | Notes |
|---|---|---|---|
$GRANITE_BASE_DIRECT_PORT (default 8700) | granite-base | granite-speech-4.1-2b (Q8_0 GGUF) | Chunking proxy → llama-server on $GRANITE_BASE_PROXY_PORT; splits audio > 14 s at word boundaries |
$GRANITE_BASE_PROXY_PORT (default 18700) | (internal) | — | llama-server; loopback only |
$GRANITE_PLUS_DIRECT_PORT (default 8701) | granite-plus-proxy | granite-speech-4.1-2b-plus | Chunking proxy → model on $GRANITE_PLUS_PROXY_PORT; timestamps + speaker stitching across chunks |
$GRANITE_PLUS_PROXY_PORT (default 18701) | (internal) | — | Plus model backend (PyTorch); loopback only |
$GRANITE_NAR_DIRECT_PORT (default 8702) | granite-nar | granite-speech-4.1-2b-nar | Non-autoregressive, fastest |
Both public ports ($GRANITE_BASE_DIRECT_PORT and $GRANITE_PLUS_DIRECT_PORT) are chunking proxies that handle arbitrarily long audio:
$GRANITE_BASE_DIRECT_PORT): text chunks are concatenated with a space.$GRANITE_PLUS_DIRECT_PORT): four stitching modes depending on the prompt:
[T:N] values (centiseconds mod 1000 per model design) are unwrapped into a globally-monotone timeline across all chunks.Model limitations (plus backend):
[Speaker 1]: / [Speaker 2]:) regardless of how many distinct voices are present.Measured on Apple M3 Ultra (MPS) with scripts/start_apple_dockerless.sh, 33-minute
looped speech audio (226 chunks of ≤ 14 s each).
| Backend | Mode | Speed | Words | Chunks |
|---|---|---|---|---|
Base :8700 ($GRANITE_BASE_DIRECT_PORT) | Plain ASR (punctuated) | 31.6× realtime (62.7 s) | 4 437 (134 wpm) | 226 |
Plus :8701 ($GRANITE_PLUS_DIRECT_PORT) | Plain ASR | 15.0× realtime (131.9 s) | 4 574 (139 wpm) | 226 |
Plus :8701 ($GRANITE_PLUS_DIRECT_PORT) | Word timestamps | 3.4× realtime (582.7 s) | 5 424 tags, monotone [118..197951] cs | 226 |
"Speed" = audio duration ÷ wall-clock processing time (higher is faster). Timestamps mode is slower because the model emits ~3 tokens per word instead of ~1.
cp .env.example .env # set HF_TOKEN, LLAMA_API_KEY and GRANITE_API_KEY
Option A — use pre-built images from ghcr.io (recommended):
./scripts/start_ghcr.sh
Option B — build from local source:
./scripts/start_local_docker.sh
Both scripts auto-detect an NVIDIA GPU and its maximum supported CUDA version, then pick the highest compatible image/wheel set automatically:
| Detected CUDA | Image tag / wheel set | Typical GPUs |
|---|---|---|
| None | :latest / cpu | CPU-only |
| < 12.8 | :cuda / cu126 | Pascal → Ada Lovelace (RTX 4000 and earlier) |
| 12.8 – 12.x | :cuda128 / cu128 | Blackwell (RTX 5000 series, GB200) |
| 13.0+ | :cuda130 / cu130 | Next-gen beyond Blackwell |
Both also load docker-compose.local.yml if it exists — see Local overrides below. On Mac Silicon, Docker pulls the arm64 layer of :latest automatically — no separate script needed. Note that MPS acceleration is unavailable inside Docker on Mac (Linux VM); for native MPS performance, run the servers directly (see Apple Silicon note below).
Models are downloaded from HuggingFace on first start (several GB) and cached in a named volume.
Scripts for installing granite-speech as an auto-starting system service are provided for both macOS and Linux. They handle starting the stack at boot, stopping it cleanly on shutdown, and restarting it if it crashes.
| Platform | Mechanism | Directory |
|---|---|---|
| macOS | launchd LaunchAgent | service/osx/ |
| Linux (Ubuntu / systemd) | systemd system service | service/linux-systemd/ |
| Windows | multiple options (WSL2, NSSM, Task Scheduler) | service/windows/ |
macOS — registers scripts/start_apple_dockerless.sh as a LaunchAgent that runs at
login and restarts automatically on crash:
bash service/osx/install.sh
Linux — registers the docker compose stack as a systemd system service that
starts at boot. Choose ghcr (recommended) or local for the image source:
sudo bash service/linux-systemd/install.sh --mode ghcr
See the platform README for the full command reference, log options, and uninstall instructions.
granite-gatorsrc/granite_gator.py is a drop-in client that applies the same two mitigations as the
patched server, from outside it — for hosted endpoints that do not expose
repetition_penalty:
-50 dBFS RMS, and level-normalise the rest to -20 dBFS
(peak-limited to -1 dBFS). This is what stops confabulation over near-silence.[T:N] timestamps as a side effect.# transcribe through any Granite endpoint
python src/granite_gator.py --audio lecture.wav --out lecture.asr.json \
--endpoint http://localhost:8701/v1/audio/transcriptions
# clean an already-decoded stream — no audio, no endpoint, no GPU
python src/granite_gator.py --in raw.asr.json --out clean.asr.json --report r.json
Every default is measured, and the module docstring documents why, plus how to recreate both failure modes with four public lecture videos. Read it before tuning any threshold. Background: looping-analysis.md.
It is not a replacement for the server-side fix. While the model loops it is not
transcribing, so chomping removes the insertions but cannot recover the lost speech;
re-decoding with PLUS_REPETITION_PENALTY does. Use the server fix where you control
the server, the gator where you do not, and both where you can.
All three endpoints accept multipart/form-data with a file field. Supported
formats: WAV, FLAC, OGG, MP3, MP4/AAC, and any other format handled by ffmpeg
(installed in all deployments). Audio is decoded and resampled to 16 kHz mono
before transcription.
# Basic transcription (any backend)
curl http://localhost:${GRANITE_PLUS_DIRECT_PORT:-8701}/v1/audio/transcriptions \
-H "Authorization: Bearer $GRANITE_API_KEY" \
-F file=@audio.wav
# Health check (no auth required)
curl http://localhost:${GRANITE_PLUS_DIRECT_PORT:-8701}/health
The granite-plus backend (port $GRANITE_PLUS_DIRECT_PORT, default 8701) accepts an optional prompt field to control output style.
| Mode | Prompt |
|---|---|
| Plain ASR (default) | <|audio|> can you transcribe the speech into a written format? |
| Word timestamps | <|audio|> Timestamps: Transcribe the speech. After each word, add a timestamp tag showing the end time in centiseconds, e.g. hello [T:45] world [T:82] |
| Speaker attribution | <|audio|> Speaker attribution: Transcribe and denote who is speaking by adding [Speaker 1]: and [Speaker 2]: tags before speaker turns. |
| Timestamps + speakers | <|audio|> Timestamps and Speaker attribution: Transcribe the speech with proper punctuation and capitalization. After each word, add a timestamp tag showing the end time in centiseconds, e.g. hello [T:45] world [T:82]. Denote who is speaking by adding [Speaker 1]: and [Speaker 2]: tags before speaker turns. |
| Keyword biasing | <|audio|> can you transcribe the speech into a written format? Keywords: word1, word2 |
When curling prompts that start with <|audio|>, use --form-string instead of -F (otherwise curl treats < as a file redirect and silently drops the value):
curl http://localhost:${GRANITE_PLUS_DIRECT_PORT:-8701}/v1/audio/transcriptions \
-H "Authorization: Bearer $GRANITE_API_KEY" \
-F file=@audio.wav \
--form-string "prompt=<|audio|> Timestamps: Transcribe the speech. After each word, add a timestamp tag showing the end time in centiseconds, e.g. hello [T:45] world [T:82]"
Timestamp values in raw model output wrap at 1000 centiseconds (model design); the proxy unwraps them into globally-monotone values across all chunks. The plus model does not reliably produce punctuation or capitalization regardless of prompt wording; use the base model ($GRANITE_BASE_DIRECT_PORT, default 8700) for punctuated output.
Set COMPOSE_PROFILES in .env to control which service groups start. The three profile names map directly to the three backends:
| Profile | Services started | Public port (env var) | Memory (approx.) |
|---|---|---|---|
base | llama-base + granite-base proxy | 8700 ($GRANITE_BASE_DIRECT_PORT) | ~2 GB |
plus | granite-plus + granite-plus-proxy | 8701 ($GRANITE_PLUS_DIRECT_PORT) | ~8 GB |
nar | granite-nar | 8702 ($GRANITE_NAR_DIRECT_PORT) | ~4 GB |
Default (all three):
COMPOSE_PROFILES=base,plus,nar
Base + NAR only (skip the plus model to save GPU memory):
COMPOSE_PROFILES=base,nar
NAR only:
COMPOSE_PROFILES=nar
docker compose up -d picks up COMPOSE_PROFILES from .env automatically. scripts/test_endpoints.sh and scripts/start_apple_dockerless.sh read the same variable and skip sections for inactive profiles.
| Variable | Default | Description |
|---|---|---|
COMPOSE_PROFILES | base,plus,nar | Which service groups to start — see Selecting which services to run |
GRANITE_API_KEY | (unset = no auth) | Bearer token for plus and NAR servers |
LLAMA_API_KEY | (unset = no auth) | Bearer token for the llama.cpp base server |
GRANITE_SYSTEM_PROMPT | IBM system prompt | Set to "" to disable the system prompt |
HF_HOME | /cache/huggingface | HuggingFace model cache directory |
PLUS_MAX_NEW_TOKENS | 4096 | Max output tokens per chunk for the plus model (~3700 words) |
PLUS_REPETITION_PENALTY | 1.1 | Guards against repetition-loop hallucination, where the model repeats a short cycle until the token budget is exhausted. Set 1.0 to restore the previous (unguarded) behaviour — see looping-analysis.md |
PLUS_NO_REPEAT_NGRAM | 0 (off) | Hard ban on repeated n-grams. Blunter than the penalty — it also blocks legitimate repeated phrasing — so reach for it only in stubborn cases |
PLUS_NORMALIZE_AUDIO | 1 | Normalise each clip's level before inference. Removes the second hallucination mode: confabulation over near-silence. 0 disables normalisation and the gate |
PLUS_NORMALIZE_TARGET_DBFS | -20 | Target RMS level |
PLUS_NORMALIZE_MAX_GAIN_DB | 30 | Gain cap, so digital silence is not amplified into its own dither |
PLUS_PEAK_CEILING_DBFS | -1 | Never clip. On very quiet material this is what actually binds |
PLUS_SILENCE_GATE_DBFS | -50 | Below this, return empty rather than let the model invent content. A deep backstop, not the fix — see looping-analysis.md for why the loss-optimal gate (−43 dBFS) was rejected |
PLUS_INTERNAL_URL | http://127.0.0.1:$GRANITE_PLUS_PROXY_PORT/v1/audio/transcriptions | Plus proxy → model URL (set automatically in Docker) |
PLUS_CHUNK_MAX_S | 14 | Max chunk length in seconds for plain/timestamps modes |
PLUS_SPEAKER_MAX_UNCHUNKED_S | 120 | Audio at or below this duration is sent as a single request in speaker/combined modes (avoids per-chunk speaker label drift) |
PLUS_SPEAKER_CHUNK_MAX_S | 60 | Chunk size for speaker/combined modes when audio exceeds PLUS_SPEAKER_MAX_UNCHUNKED_S (preamble mode) |
GRANITE_BASE_DIRECT_PORT | 8700 | Client-facing port for the base chunking proxy |
GRANITE_BASE_PROXY_PORT | 18700 | Internal port for llama-server (base model backend) |
GRANITE_PLUS_DIRECT_PORT | 8701 | Client-facing port for the plus chunking proxy |
GRANITE_PLUS_PROXY_PORT | 18701 | Internal port for the plus model server |
GRANITE_NAR_DIRECT_PORT | 8702 | Client-facing port for the NAR model server |
Create a docker-compose.local.yml file in the project root to customise your deployment without touching the git-tracked compose files. Both start scripts pick it up automatically if it exists; it is listed in .gitignore so it will never be committed. Common use cases for this is to expose service the direct ports, or change resource allocation settings.
Pre-built images are published to ghcr.io/angrave/granite-speech-4.1-serve on every push to main.
| Tag | Platforms | PyTorch | When to use |
|---|---|---|---|
latest | linux/amd64, linux/arm64 | 2.6.0 | CPU inference — plain x86_64 servers and Apple Silicon |
cuda | linux/amd64, linux/arm64 | 2.7.1 (amd64) / 2.5.1 (arm64) | NVIDIA CUDA 12.6 — Pascal → Ada Lovelace (RTX 4000 and earlier)† |
cuda128 | linux/amd64, linux/arm64 | 2.11.0 | NVIDIA CUDA 12.8 — Blackwell (RTX 5000 series, GB200) |
cuda130 | linux/amd64 | 2.11.0 | NVIDIA CUDA 13.0 — next-gen beyond Blackwell (amd64 only) |
Docker pulls the correct architecture automatically. The start scripts detect your GPU's CUDA version and select the right tag — no manual choice needed.
† cuda's amd64 build uses CUDA 12.6 wheels (cu126 + torch 2.7.1) rather than 12.4: torch 2.6.0's exact nvidia-cudnn-cu12==9.1.0.70 pin is absent from pytorch.org's cu124 wheel index (that build is still on PyPI proper, just not mirrored there), so a cu124 build now fails to resolve. torch 2.7.1 is the last release that still ships Pascal (sm_60/61) kernels, so this tier's GPU coverage is unchanged. arm64 stays on cu124 + torch 2.5.1, which was never affected (that release's cudnn pin only applies on platform_machine == "x86_64", and pytorch.org publishes no arm64 wheels on cu126 at all).
docker-compose.gpu.yml has examples of mapping GPU resources to Docker containers.
Then pick the image tag that matches your GPU's CUDA version and ensure nvidia-container-toolkit is installed on the host. The start_ghcr.sh script does this selection automatically.
MPS acceleration is not available inside Docker (Linux VM). For native MPS performance, use the provided script:
cp .env.example .env # fill in GRANITE_API_KEY and LLAMA_API_KEY
./scripts/start_apple_dockerless.sh
scripts/start_apple_dockerless.sh lazy-installs all dependencies on first run (Python 3.11, a venv, PyTorch arm64 + MPS, and the Python requirements), then starts all three servers. The only prerequisite it does not auto-install:
Python 3.10+ is required (the NAR model's remote code uses Python 3.10+ union-type syntax). The script auto-installs python@3.11 via Homebrew if no suitable interpreter is found.
llama.cpp (granite-base): The script checks for a suitable llama-server binary in this order:
.llama_build/ (instant)llama-server, if it supports granite_speech (instant)main branch — only if all of the above fail (~10 min, cached for subsequent runs)Server output is written to runtime/logs/base.log, runtime/logs/plus.log, and runtime/logs/nar.log. Run tail -f runtime/logs/*.log in a second terminal to monitor startup. Models are downloaded from HuggingFace on first run (several GB each); subsequent starts load from cache.
The script starts one process per active profile: base → llama-server (:$GRANITE_BASE_PROXY_PORT) + serve_base proxy (:$GRANITE_BASE_DIRECT_PORT); plus → serve_plus model (:$GRANITE_PLUS_PROXY_PORT) + serve_plus_proxy (:$GRANITE_PLUS_DIRECT_PORT); nar → serve_nar (:$GRANITE_NAR_DIRECT_PORT). Which profiles are active is read from COMPOSE_PROFILES in .env (default: all three). The plus proxy waits for the plus model to be healthy before starting. Port defaults can be overridden via the GRANITE_*_PORT variables in .env.
Press Ctrl-C to stop all servers.
# CPU (default)
docker build -t granite-speech .
# NVIDIA CUDA 12.6 — Pascal → Ada Lovelace (RTX 4000 and earlier)
# (not cu124/2.6.0: torch 2.6.0's exact nvidia-cudnn-cu12==9.1.0.70 pin
# isn't on pytorch.org's cu124 index — see Dockerfile for details)
docker build \
--build-arg PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu126 \
--build-arg PYTORCH_VERSION=2.7.1 \
-t granite-speech:cuda .
# NVIDIA CUDA 12.8 — Blackwell (RTX 5000 series, GB200)
docker build \
--build-arg PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu128 \
--build-arg PYTORCH_VERSION=2.11.0 \
-t granite-speech:cuda128 .
# NVIDIA CUDA 13.0 — next-gen beyond Blackwell
docker build \
--build-arg PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu130 \
--build-arg PYTORCH_VERSION=2.11.0 \
-t granite-speech:cuda130 .
The test.wav and other testing files used TalkBank multiconversastion (4074.mp3 4404.mp3 4941.mp3) at https://talkbank.org/ca/access/CallHome/eng.html
Linguistic Data Consortium (2008). CABank English CallHome Corpus. TalkBank. doi:10.21415/T5KP54 Canavan, A., Graff, D., & Zipperlen, G. (1997). CALLHOME American English Speech LDC97S42. Philadelphia: Linguistic Data Consortium.
63 commits
Python
50.5%
Shell
48.1%
Dockerfile
1.4%
OpenAI-compatible speech-to-text API server for IBM Granite Speech 4.1-2B, plus and NAR variants, exposing three backends POST /v1/audio/transcriptions interface. This project also provides two wrapper endpoints that automatically chunck and restitch results of the base and plus models to overcome context length limitations.
| Port (env var) | Service | Model | Notes |
|---|---|---|---|
$GRANITE_BASE_DIRECT_PORT (default 8700) | granite-base | granite-speech-4.1-2b (Q8_0 GGUF) | Chunking proxy → llama-server on $GRANITE_BASE_PROXY_PORT; splits audio > 14 s at word boundaries |
$GRANITE_BASE_PROXY_PORT (default 18700) | (internal) | — | llama-server; loopback only |
$GRANITE_PLUS_DIRECT_PORT (default 8701) | granite-plus-proxy | granite-speech-4.1-2b-plus | Chunking proxy → model on $GRANITE_PLUS_PROXY_PORT; timestamps + speaker stitching across chunks |
$GRANITE_PLUS_PROXY_PORT (default 18701) | (internal) | — | Plus model backend (PyTorch); loopback only |
$GRANITE_NAR_DIRECT_PORT (default 8702) | granite-nar | granite-speech-4.1-2b-nar | Non-autoregressive, fastest |
Both public ports ($GRANITE_BASE_DIRECT_PORT and $GRANITE_PLUS_DIRECT_PORT) are chunking proxies that handle arbitrarily long audio:
$GRANITE_BASE_DIRECT_PORT): text chunks are concatenated with a space.$GRANITE_PLUS_DIRECT_PORT): four stitching modes depending on the prompt:
[T:N] values (centiseconds mod 1000 per model design) are unwrapped into a globally-monotone timeline across all chunks.Model limitations (plus backend):
[Speaker 1]: / [Speaker 2]:) regardless of how many distinct voices are present.Measured on Apple M3 Ultra (MPS) with scripts/start_apple_dockerless.sh, 33-minute
looped speech audio (226 chunks of ≤ 14 s each).
| Backend | Mode | Speed | Words | Chunks |
|---|---|---|---|---|
Base :8700 ($GRANITE_BASE_DIRECT_PORT) | Plain ASR (punctuated) | 31.6× realtime (62.7 s) | 4 437 (134 wpm) | 226 |
Plus :8701 ($GRANITE_PLUS_DIRECT_PORT) | Plain ASR | 15.0× realtime (131.9 s) | 4 574 (139 wpm) | 226 |
Plus :8701 ($GRANITE_PLUS_DIRECT_PORT) | Word timestamps | 3.4× realtime (582.7 s) | 5 424 tags, monotone [118..197951] cs | 226 |
"Speed" = audio duration ÷ wall-clock processing time (higher is faster). Timestamps mode is slower because the model emits ~3 tokens per word instead of ~1.
cp .env.example .env # set HF_TOKEN, LLAMA_API_KEY and GRANITE_API_KEY
Option A — use pre-built images from ghcr.io (recommended):
./scripts/start_ghcr.sh
Option B — build from local source:
./scripts/start_local_docker.sh
Both scripts auto-detect an NVIDIA GPU and its maximum supported CUDA version, then pick the highest compatible image/wheel set automatically:
| Detected CUDA | Image tag / wheel set | Typical GPUs |
|---|---|---|
| None | :latest / cpu | CPU-only |
| < 12.8 | :cuda / cu126 | Pascal → Ada Lovelace (RTX 4000 and earlier) |
| 12.8 – 12.x | :cuda128 / cu128 | Blackwell (RTX 5000 series, GB200) |
| 13.0+ | :cuda130 / cu130 | Next-gen beyond Blackwell |
Both also load docker-compose.local.yml if it exists — see Local overrides below. On Mac Silicon, Docker pulls the arm64 layer of :latest automatically — no separate script needed. Note that MPS acceleration is unavailable inside Docker on Mac (Linux VM); for native MPS performance, run the servers directly (see Apple Silicon note below).
Models are downloaded from HuggingFace on first start (several GB) and cached in a named volume.
Scripts for installing granite-speech as an auto-starting system service are provided for both macOS and Linux. They handle starting the stack at boot, stopping it cleanly on shutdown, and restarting it if it crashes.
| Platform | Mechanism | Directory |
|---|---|---|
| macOS | launchd LaunchAgent | service/osx/ |
| Linux (Ubuntu / systemd) | systemd system service | service/linux-systemd/ |
| Windows | multiple options (WSL2, NSSM, Task Scheduler) | service/windows/ |
macOS — registers scripts/start_apple_dockerless.sh as a LaunchAgent that runs at
login and restarts automatically on crash:
bash service/osx/install.sh
Linux — registers the docker compose stack as a systemd system service that
starts at boot. Choose ghcr (recommended) or local for the image source:
sudo bash service/linux-systemd/install.sh --mode ghcr
See the platform README for the full command reference, log options, and uninstall instructions.
granite-gatorsrc/granite_gator.py is a drop-in client that applies the same two mitigations as the
patched server, from outside it — for hosted endpoints that do not expose
repetition_penalty:
-50 dBFS RMS, and level-normalise the rest to -20 dBFS
(peak-limited to -1 dBFS). This is what stops confabulation over near-silence.[T:N] timestamps as a side effect.# transcribe through any Granite endpoint
python src/granite_gator.py --audio lecture.wav --out lecture.asr.json \
--endpoint http://localhost:8701/v1/audio/transcriptions
# clean an already-decoded stream — no audio, no endpoint, no GPU
python src/granite_gator.py --in raw.asr.json --out clean.asr.json --report r.json
Every default is measured, and the module docstring documents why, plus how to recreate both failure modes with four public lecture videos. Read it before tuning any threshold. Background: looping-analysis.md.
It is not a replacement for the server-side fix. While the model loops it is not
transcribing, so chomping removes the insertions but cannot recover the lost speech;
re-decoding with PLUS_REPETITION_PENALTY does. Use the server fix where you control
the server, the gator where you do not, and both where you can.
All three endpoints accept multipart/form-data with a file field. Supported
formats: WAV, FLAC, OGG, MP3, MP4/AAC, and any other format handled by ffmpeg
(installed in all deployments). Audio is decoded and resampled to 16 kHz mono
before transcription.
# Basic transcription (any backend)
curl http://localhost:${GRANITE_PLUS_DIRECT_PORT:-8701}/v1/audio/transcriptions \
-H "Authorization: Bearer $GRANITE_API_KEY" \
-F file=@audio.wav
# Health check (no auth required)
curl http://localhost:${GRANITE_PLUS_DIRECT_PORT:-8701}/health
The granite-plus backend (port $GRANITE_PLUS_DIRECT_PORT, default 8701) accepts an optional prompt field to control output style.
| Mode | Prompt |
|---|---|
| Plain ASR (default) | <|audio|> can you transcribe the speech into a written format? |
| Word timestamps | <|audio|> Timestamps: Transcribe the speech. After each word, add a timestamp tag showing the end time in centiseconds, e.g. hello [T:45] world [T:82] |
| Speaker attribution | <|audio|> Speaker attribution: Transcribe and denote who is speaking by adding [Speaker 1]: and [Speaker 2]: tags before speaker turns. |
| Timestamps + speakers | <|audio|> Timestamps and Speaker attribution: Transcribe the speech with proper punctuation and capitalization. After each word, add a timestamp tag showing the end time in centiseconds, e.g. hello [T:45] world [T:82]. Denote who is speaking by adding [Speaker 1]: and [Speaker 2]: tags before speaker turns. |
| Keyword biasing | <|audio|> can you transcribe the speech into a written format? Keywords: word1, word2 |
When curling prompts that start with <|audio|>, use --form-string instead of -F (otherwise curl treats < as a file redirect and silently drops the value):
curl http://localhost:${GRANITE_PLUS_DIRECT_PORT:-8701}/v1/audio/transcriptions \
-H "Authorization: Bearer $GRANITE_API_KEY" \
-F file=@audio.wav \
--form-string "prompt=<|audio|> Timestamps: Transcribe the speech. After each word, add a timestamp tag showing the end time in centiseconds, e.g. hello [T:45] world [T:82]"
Timestamp values in raw model output wrap at 1000 centiseconds (model design); the proxy unwraps them into globally-monotone values across all chunks. The plus model does not reliably produce punctuation or capitalization regardless of prompt wording; use the base model ($GRANITE_BASE_DIRECT_PORT, default 8700) for punctuated output.
Set COMPOSE_PROFILES in .env to control which service groups start. The three profile names map directly to the three backends:
| Profile | Services started | Public port (env var) | Memory (approx.) |
|---|---|---|---|
base | llama-base + granite-base proxy | 8700 ($GRANITE_BASE_DIRECT_PORT) | ~2 GB |
plus | granite-plus + granite-plus-proxy | 8701 ($GRANITE_PLUS_DIRECT_PORT) | ~8 GB |
nar | granite-nar | 8702 ($GRANITE_NAR_DIRECT_PORT) | ~4 GB |
Default (all three):
COMPOSE_PROFILES=base,plus,nar
Base + NAR only (skip the plus model to save GPU memory):
COMPOSE_PROFILES=base,nar
NAR only:
COMPOSE_PROFILES=nar
docker compose up -d picks up COMPOSE_PROFILES from .env automatically. scripts/test_endpoints.sh and scripts/start_apple_dockerless.sh read the same variable and skip sections for inactive profiles.
| Variable | Default | Description |
|---|---|---|
COMPOSE_PROFILES | base,plus,nar | Which service groups to start — see Selecting which services to run |
GRANITE_API_KEY | (unset = no auth) | Bearer token for plus and NAR servers |
LLAMA_API_KEY | (unset = no auth) | Bearer token for the llama.cpp base server |
GRANITE_SYSTEM_PROMPT | IBM system prompt | Set to "" to disable the system prompt |
HF_HOME | /cache/huggingface | HuggingFace model cache directory |
PLUS_MAX_NEW_TOKENS | 4096 | Max output tokens per chunk for the plus model (~3700 words) |
PLUS_REPETITION_PENALTY | 1.1 | Guards against repetition-loop hallucination, where the model repeats a short cycle until the token budget is exhausted. Set 1.0 to restore the previous (unguarded) behaviour — see looping-analysis.md |
PLUS_NO_REPEAT_NGRAM | 0 (off) | Hard ban on repeated n-grams. Blunter than the penalty — it also blocks legitimate repeated phrasing — so reach for it only in stubborn cases |
PLUS_NORMALIZE_AUDIO | 1 | Normalise each clip's level before inference. Removes the second hallucination mode: confabulation over near-silence. 0 disables normalisation and the gate |
PLUS_NORMALIZE_TARGET_DBFS | -20 | Target RMS level |
PLUS_NORMALIZE_MAX_GAIN_DB | 30 | Gain cap, so digital silence is not amplified into its own dither |
PLUS_PEAK_CEILING_DBFS | -1 | Never clip. On very quiet material this is what actually binds |
PLUS_SILENCE_GATE_DBFS | -50 | Below this, return empty rather than let the model invent content. A deep backstop, not the fix — see looping-analysis.md for why the loss-optimal gate (−43 dBFS) was rejected |
PLUS_INTERNAL_URL | http://127.0.0.1:$GRANITE_PLUS_PROXY_PORT/v1/audio/transcriptions | Plus proxy → model URL (set automatically in Docker) |
PLUS_CHUNK_MAX_S | 14 | Max chunk length in seconds for plain/timestamps modes |
PLUS_SPEAKER_MAX_UNCHUNKED_S | 120 | Audio at or below this duration is sent as a single request in speaker/combined modes (avoids per-chunk speaker label drift) |
PLUS_SPEAKER_CHUNK_MAX_S | 60 | Chunk size for speaker/combined modes when audio exceeds PLUS_SPEAKER_MAX_UNCHUNKED_S (preamble mode) |
GRANITE_BASE_DIRECT_PORT | 8700 | Client-facing port for the base chunking proxy |
GRANITE_BASE_PROXY_PORT | 18700 | Internal port for llama-server (base model backend) |
GRANITE_PLUS_DIRECT_PORT | 8701 | Client-facing port for the plus chunking proxy |
GRANITE_PLUS_PROXY_PORT | 18701 | Internal port for the plus model server |
GRANITE_NAR_DIRECT_PORT | 8702 | Client-facing port for the NAR model server |
Create a docker-compose.local.yml file in the project root to customise your deployment without touching the git-tracked compose files. Both start scripts pick it up automatically if it exists; it is listed in .gitignore so it will never be committed. Common use cases for this is to expose service the direct ports, or change resource allocation settings.
Pre-built images are published to ghcr.io/angrave/granite-speech-4.1-serve on every push to main.
| Tag | Platforms | PyTorch | When to use |
|---|---|---|---|
latest | linux/amd64, linux/arm64 | 2.6.0 | CPU inference — plain x86_64 servers and Apple Silicon |
cuda | linux/amd64, linux/arm64 | 2.7.1 (amd64) / 2.5.1 (arm64) | NVIDIA CUDA 12.6 — Pascal → Ada Lovelace (RTX 4000 and earlier)† |
cuda128 | linux/amd64, linux/arm64 | 2.11.0 | NVIDIA CUDA 12.8 — Blackwell (RTX 5000 series, GB200) |
cuda130 | linux/amd64 | 2.11.0 | NVIDIA CUDA 13.0 — next-gen beyond Blackwell (amd64 only) |
Docker pulls the correct architecture automatically. The start scripts detect your GPU's CUDA version and select the right tag — no manual choice needed.
† cuda's amd64 build uses CUDA 12.6 wheels (cu126 + torch 2.7.1) rather than 12.4: torch 2.6.0's exact nvidia-cudnn-cu12==9.1.0.70 pin is absent from pytorch.org's cu124 wheel index (that build is still on PyPI proper, just not mirrored there), so a cu124 build now fails to resolve. torch 2.7.1 is the last release that still ships Pascal (sm_60/61) kernels, so this tier's GPU coverage is unchanged. arm64 stays on cu124 + torch 2.5.1, which was never affected (that release's cudnn pin only applies on platform_machine == "x86_64", and pytorch.org publishes no arm64 wheels on cu126 at all).
docker-compose.gpu.yml has examples of mapping GPU resources to Docker containers.
Then pick the image tag that matches your GPU's CUDA version and ensure nvidia-container-toolkit is installed on the host. The start_ghcr.sh script does this selection automatically.
MPS acceleration is not available inside Docker (Linux VM). For native MPS performance, use the provided script:
cp .env.example .env # fill in GRANITE_API_KEY and LLAMA_API_KEY
./scripts/start_apple_dockerless.sh
scripts/start_apple_dockerless.sh lazy-installs all dependencies on first run (Python 3.11, a venv, PyTorch arm64 + MPS, and the Python requirements), then starts all three servers. The only prerequisite it does not auto-install:
Python 3.10+ is required (the NAR model's remote code uses Python 3.10+ union-type syntax). The script auto-installs python@3.11 via Homebrew if no suitable interpreter is found.
llama.cpp (granite-base): The script checks for a suitable llama-server binary in this order:
.llama_build/ (instant)llama-server, if it supports granite_speech (instant)main branch — only if all of the above fail (~10 min, cached for subsequent runs)Server output is written to runtime/logs/base.log, runtime/logs/plus.log, and runtime/logs/nar.log. Run tail -f runtime/logs/*.log in a second terminal to monitor startup. Models are downloaded from HuggingFace on first run (several GB each); subsequent starts load from cache.
The script starts one process per active profile: base → llama-server (:$GRANITE_BASE_PROXY_PORT) + serve_base proxy (:$GRANITE_BASE_DIRECT_PORT); plus → serve_plus model (:$GRANITE_PLUS_PROXY_PORT) + serve_plus_proxy (:$GRANITE_PLUS_DIRECT_PORT); nar → serve_nar (:$GRANITE_NAR_DIRECT_PORT). Which profiles are active is read from COMPOSE_PROFILES in .env (default: all three). The plus proxy waits for the plus model to be healthy before starting. Port defaults can be overridden via the GRANITE_*_PORT variables in .env.
Press Ctrl-C to stop all servers.
# CPU (default)
docker build -t granite-speech .
# NVIDIA CUDA 12.6 — Pascal → Ada Lovelace (RTX 4000 and earlier)
# (not cu124/2.6.0: torch 2.6.0's exact nvidia-cudnn-cu12==9.1.0.70 pin
# isn't on pytorch.org's cu124 index — see Dockerfile for details)
docker build \
--build-arg PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu126 \
--build-arg PYTORCH_VERSION=2.7.1 \
-t granite-speech:cuda .
# NVIDIA CUDA 12.8 — Blackwell (RTX 5000 series, GB200)
docker build \
--build-arg PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu128 \
--build-arg PYTORCH_VERSION=2.11.0 \
-t granite-speech:cuda128 .
# NVIDIA CUDA 13.0 — next-gen beyond Blackwell
docker build \
--build-arg PYTORCH_INDEX_URL=https://download.pytorch.org/whl/cu130 \
--build-arg PYTORCH_VERSION=2.11.0 \
-t granite-speech:cuda130 .
The test.wav and other testing files used TalkBank multiconversastion (4074.mp3 4404.mp3 4941.mp3) at https://talkbank.org/ca/access/CallHome/eng.html
Linguistic Data Consortium (2008). CABank English CallHome Corpus. TalkBank. doi:10.21415/T5KP54 Canavan, A., Graff, D., & Zipperlen, G. (1997). CALLHOME American English Speech LDC97S42. Philadelphia: Linguistic Data Consortium.
63 commits
Python
50.5%
Shell
48.1%
Dockerfile
1.4%