diff --git a/.dockerignore b/.dockerignore
index c45e593..112352a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -29,7 +29,10 @@ prompt.txt
# Pre-built binary
transcriber
-# Docs / CI not needed in build context
+# Docs / CI / deploy config not needed in build context
DEPLOY.md
COMPATIBILITY.md
IMPROVEMENTS.md
+.github
+docker-compose*.yml
+.env.example
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1d650dd
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,46 @@
+# Copy to .env and edit. `docker compose` reads .env automatically.
+# Every value here is optional — the defaults in docker-compose.yml match these.
+#
+# Requires an NVIDIA GPU + driver >= 560.28.03. There is no CPU-only mode for
+# this image; see DEPLOY.md.
+
+# ---- Image ----
+# Pull a CI-built image instead of building on every host.
+# Pin a tag (e.g. :sha-abc1234) in production so you can roll back.
+IMAGE=ghcr.io/bcc-code/transcriber:latest
+
+# Prebuilt whisper.cpp + CUDA base, used only by `docker compose build`. Bump to
+# change the whisper.cpp or CUDA version; build it with
+# .github/workflows/whisper-base.yml. The tag encodes both versions.
+BASE_IMAGE=ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3
+
+# ---- Storage ----
+# Must already exist on the host, and must be the same path the caller puts in
+# the `path` / `output_path` fields — they are resolved inside the container.
+STORAGE_PATH=/mnt/storage
+
+# ---- Network ----
+# The API is unauthenticated. Set this to an internal interface, or firewall
+# the port, so only the workflow engine can reach it.
+BIND_ADDR=0.0.0.0
+PORT=8888
+
+# ---- Runtime ----
+# Concurrent transcription jobs. Each one runs its own whisper-cli process
+# holding a ~3 GB FP16 large-v3 model on the GPU; 2 fits comfortably in the
+# 3090's 24 GB, but raising this risks CUDA OOM (which surfaces as a failed job).
+# Each job also uses scratch disk for chunk extraction (~115 MB per hour of
+# audio), under the OS temp dir unless -scratch-dir is set.
+WORKERS=2
+CALLBACK_WORKERS=2
+DEFAULT_MODEL=whisper-cpp-large-v3
+# ISO 639-1 code to skip whisper's language auto-detection (faster and more
+# reliable for a mono-lingual corpus). Empty = auto-detect. Requests can
+# override per job.
+DEFAULT_LANGUAGE=no
+JOB_TIMEOUT=30m
+# Finished jobs retained in memory. The store is in-memory only, so a completed
+# job evicted before the caller polls it becomes a 404 — keep this comfortably
+# above the largest burst the pipeline will submit.
+MAX_TERMINAL_JOBS=200
+LOG_FORMAT=json
diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml
new file mode 100644
index 0000000..1c53a09
--- /dev/null
+++ b/.github/workflows/image.yml
@@ -0,0 +1,116 @@
+name: image
+
+# Builds the app image (Go binary + embedded SPA) on top of the prebuilt
+# whisper.cpp/CUDA base, and pushes it to GHCR so on-prem hosts `docker compose
+# pull` instead of building. Nothing here compiles CUDA — that lives in
+# whisper-base.yml — so this is a fast build.
+#
+# The base image must exist before this can run. On a fresh repo, dispatch the
+# whisper-base workflow first.
+
+on:
+ push:
+ branches: [main]
+ tags: ["v*"]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE_NAME: ${{ github.repository }}
+ # Keep in sync with docker-compose.yml / .env.example.
+ BASE_IMAGE: ghcr.io/${{ github.repository_owner }}/whisper-cuda:v1.8.6-cuda12.6.3
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ # internal/web/dist is gitignored, so //go:embed has nothing to embed in
+ # a fresh checkout and the package fails to compile. The real SPA is
+ # built inside the image; a placeholder is enough for vet/test.
+ - name: Stage embed placeholder
+ run: |
+ mkdir -p internal/web/dist
+ echo '
placeholder' > internal/web/dist/index.html
+
+ - run: go vet ./...
+ - run: go test -race ./...
+
+ image:
+ runs-on: ubuntu-latest
+ needs: test
+ env:
+ # Same-repo events (push, tag, branch PR) get a write-scoped
+ # GITHUB_TOKEN and can authenticate to GHCR. Fork PRs get a read-only
+ # token, so they build without touching the registry — but they also
+ # can't pull the private base image, so the build is skipped entirely.
+ CAN_PUSH: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ # Required even on PRs: the FROM in Dockerfile needs to pull the base
+ # image, and the PR build is the test run for the app image.
+ if: env.CAN_PUSH == 'true'
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Verify the base image exists
+ # Without this, a missing base fails deep inside buildx as
+ # "failed to solve: ...: not found" pointing at a FROM line, which
+ # doesn't hint at the actual fix.
+ if: env.CAN_PUSH == 'true'
+ run: |
+ if docker buildx imagetools inspect "${BASE_IMAGE}" >/dev/null 2>&1; then
+ echo "base image ok: ${BASE_IMAGE}"
+ else
+ echo "::error::Base image ${BASE_IMAGE} not found. It must be built and pushed once before the app image can build. Push a change to Dockerfile.whisper, or run the whisper-base workflow (available in the Actions UI only once that workflow is on the default branch)."
+ exit 1
+ fi
+
+ - id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ # sha- tags are the ones to pin in .env for a rollback-able deploy.
+ tags: |
+ type=ref,event=branch
+ type=semver,pattern={{version}}
+ type=sha,prefix=sha-
+ type=raw,value=latest,enable={{is_default_branch}}
+
+ - name: Build and push
+ if: env.CAN_PUSH == 'true'
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: |
+ BASE_IMAGE=${{ env.BASE_IMAGE }}
+ # No build cache, deliberately. Measured on the first green run: the
+ # cacheable work is pnpm install (8.9s) + generate (8.2s) + go build
+ # (19.8s) = ~37s, while `cache-to: type=gha,mode=max` spent 271s
+ # exporting — 79% of a 342s build. mode=max exports *every* stage,
+ # which here includes the ~3 GB CUDA base image's layers, so it also
+ # blows GHA's 10 GB per-repo cache limit. Paying 271s to save 37s is
+ # a bad trade; the rest of the build is registry pull/push, which a
+ # build cache doesn't help with anyway.
diff --git a/.github/workflows/whisper-base.yml b/.github/workflows/whisper-base.yml
new file mode 100644
index 0000000..becb0f5
--- /dev/null
+++ b/.github/workflows/whisper-base.yml
@@ -0,0 +1,136 @@
+name: whisper-base
+
+# Builds the whisper.cpp + CUDA base image that the app image FROMs. This is the
+# expensive build (15-30 min of nvcc), so it is heavily gated:
+#
+# - Triggers on `push` (any branch) only when Dockerfile.whisper or this file
+# changes. `push` evaluates `paths` against that push's own diff, so an
+# unrelated commit will not fire it.
+# - Deliberately NOT on `pull_request`: for PR events `paths` is evaluated
+# against the whole PR diff, so it re-fires on *every* push to a PR branch,
+# and PR runs can't publish anyway — which would leave the app image build
+# permanently unable to resolve its base.
+# - Skips the build entirely when the target tag already exists, so an
+# accidental re-trigger costs seconds instead of half an hour. Pass
+# `force: true` (or bump a version) to republish.
+#
+# The tag encodes both versions, e.g. v1.8.6-cuda12.6.3, so bumping either one
+# produces a new image; BASE_IMAGE in .env / docker-compose.yml / image.yml
+# selects it. Pin the versioned tag — nothing should depend on `latest`.
+#
+# NOTE: because the tag is shared, changing *how* the base is built without
+# changing a version will not republish unless you pass force: true. That is
+# intentional — it stops an in-progress branch from silently replacing a base
+# image the deployed app is running on.
+
+on:
+ push:
+ paths:
+ - Dockerfile.whisper
+ - .github/workflows/whisper-base.yml
+ workflow_dispatch:
+ inputs:
+ whisper_ref:
+ description: whisper.cpp git ref (tag or 40-char SHA)
+ default: v1.8.6
+ cuda_version:
+ description: CUDA version (must match a nvidia/cuda image tag)
+ default: 12.6.3
+ cuda_archs:
+ description: "SM arch: 86=RTX 3090, 89=L4/RTX 40xx, 90=H100"
+ default: "86"
+ force:
+ description: Rebuild and republish even if the tag already exists
+ type: boolean
+ default: false
+
+env:
+ REGISTRY: ghcr.io
+ IMAGE: ghcr.io/${{ github.repository_owner }}/whisper-cuda
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ env:
+ WHISPER_REF: ${{ inputs.whisper_ref || 'v1.8.6' }}
+ CUDA_VERSION: ${{ inputs.cuda_version || '12.6.3' }}
+ CUDA_ARCHS: ${{ inputs.cuda_archs || '86' }}
+ permissions:
+ contents: read
+ packages: write
+ steps:
+ - uses: actions/checkout@v5
+
+ - uses: docker/setup-buildx-action@v3
+
+ - name: Log in to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Resolve tag and check whether it already exists
+ id: check
+ run: |
+ tag="${IMAGE}:${WHISPER_REF}-cuda${CUDA_VERSION}"
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+ if docker buildx imagetools inspect "${tag}" >/dev/null 2>&1; then
+ echo "exists=true" >> "$GITHUB_OUTPUT"
+ echo "${tag} already exists" >> "$GITHUB_STEP_SUMMARY"
+ else
+ echo "exists=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Skip notice
+ if: steps.check.outputs.exists == 'true' && !inputs.force
+ run: |
+ echo "Skipping the ~20 min CUDA build: ${{ steps.check.outputs.tag }} is already published." \
+ >> "$GITHUB_STEP_SUMMARY"
+ echo "Re-run this workflow with force: true to republish." >> "$GITHUB_STEP_SUMMARY"
+
+ - name: Free up disk space
+ if: steps.check.outputs.exists != 'true' || inputs.force
+ # ubuntu-latest ships ~14 GB free on /. The CUDA devel image alone is
+ # ~8 GB extracted, plus the whisper build tree and the runtime image —
+ # it does not fit. Drop preinstalled toolchains this build never uses.
+ run: |
+ df -h /
+ sudo rm -rf \
+ /usr/local/lib/android \
+ /usr/share/dotnet \
+ /opt/ghc \
+ /usr/local/share/boost \
+ /opt/hostedtoolcache/CodeQL
+ sudo docker image prune -af
+ df -h /
+
+ - name: Build and push
+ if: steps.check.outputs.exists != 'true' || inputs.force
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: Dockerfile.whisper
+ platforms: linux/amd64
+ push: true
+ # `latest` only from the default branch: a feature branch may publish
+ # the versioned tag to bootstrap itself, but must not move `latest`.
+ tags: |
+ ${{ steps.check.outputs.tag }}
+ ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && format('{0}:latest', env.IMAGE) || '' }}
+ build-args: |
+ WHISPER_CPP_REF=${{ env.WHISPER_REF }}
+ CUDA_VERSION=${{ env.CUDA_VERSION }}
+ CUDA_ARCHS=${{ env.CUDA_ARCHS }}
+
+ - name: Summary
+ if: steps.check.outputs.exists != 'true' || inputs.force
+ run: |
+ {
+ echo "Published \`${{ steps.check.outputs.tag }}\`"
+ echo ""
+ echo "Set this as BASE_IMAGE in \`.env\`, \`docker-compose.yml\`, and \`.github/workflows/image.yml\`:"
+ echo '```'
+ echo "BASE_IMAGE=${{ steps.check.outputs.tag }}"
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index ddd2359..d04e107 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@ docker-compose.override.yml
# Default prompt file (deployment-specific vocabulary)
prompt.txt
+
+# Compiled binary from `make build`
+/transcriber
diff --git a/DEPLOY.md b/DEPLOY.md
index ad471a6..4aa3590 100644
--- a/DEPLOY.md
+++ b/DEPLOY.md
@@ -1,90 +1,183 @@
# Deploying transcriber
-Target: on-prem Linux host with NVIDIA GPUs, running Docker. The image
-bundles `whisper-cli` (whisper.cpp, built with the **CUDA** GGML backend
-plus OpenBLAS for the CPU fallback path), `ffmpeg`/`ffprobe`, and the Go
-API + embedded SPA. Everything the container needs is in the image
-except the ggml model files (downloaded from Hugging Face on first use
-into a persisted volume).
-
-## GPU access (CUDA)
-
-Install the [NVIDIA Container Toolkit][nvct] on the host. With it in
-place, `docker-compose.gpu.yml` reserves all NVIDIA devices for the
-container, and `NVIDIA_DRIVER_CAPABILITIES=compute,utility` (baked into
-the image) tells the toolkit which driver libraries to expose. To run
-on CPU only — e.g. a host without NVIDIA hardware, or local Mac dev —
-bring up `docker-compose.yml` alone and add `-whispercpp-no-gpu` to the
-service command.
-
-CUDA runtime version is pinned via `CUDA_VERSION` build arg
-(default `12.6.3`); this requires NVIDIA driver **≥ 560.28.03** on the
-host. Check with `nvidia-smi` before deploying. To target an older
-driver, lower `CUDA_VERSION` to a release whose minimum driver matches
-what's installed — see the [CUDA compatibility matrix][cuda-compat].
-
-GPU architecture is pinned via `CUDA_ARCHS` build arg (default `"86"`,
-the SM version for the on-prem RTX 3090 / Ampere). If the deployment
-GPU changes, override at build time (e.g. `--build-arg CUDA_ARCHS=89`
-for L4 / RTX 40xx, `90` for H100) and update the Dockerfile default.
+Target: on-prem Linux host with an NVIDIA GPU, running Docker. Everything
+the container needs is in the image except the ggml model files
+(downloaded from Hugging Face on first use into a persisted volume).
+
+> **An NVIDIA GPU and driver are required.** `whisper-cli` is dynamically
+> linked against `libcuda.so.1`, which the NVIDIA Container Toolkit injects
+> at container start. Without it the dynamic loader fails before `main()`,
+> so the binary cannot run at all — there is no CPU fallback in the
+> container, and `-whispercpp-no-gpu` is unreachable there. For CPU or
+> Apple-Metal hosts, run the Go binary natively against a native
+> `whisper-cli` (see README.md); that is also the recommended local
+> development path.
+
+## Two images
+
+The build is split in two, because compiling whisper.cpp with nvcc takes
+15–30 minutes and only changes when whisper.cpp or CUDA does:
+
+| Image | Built by | Contains |
+| ----------------------------------------- | ------------------------------------- | ----------------------------------------------------- |
+| `ghcr.io/bcc-code/whisper-cuda:[-cuda` | `Dockerfile.whisper`, `whisper-base.yml` | whisper.cpp (CUDA), ffmpeg/ffprobe, CUDA runtime libs |
+| `ghcr.io/bcc-code/transcriber:` | `Dockerfile`, `image.yml` | the above + Go binary with embedded SPA |
+
+Ordinary code changes rebuild only the app image, in seconds. `BASE_IMAGE`
+in `.env` / `docker-compose.yml` selects the base; the tag encodes both the
+whisper.cpp ref and the CUDA version.
+
+The base must exist before the app image can build at all — the app's
+`FROM` resolves it. `image.yml` checks for it up front and fails with an
+actionable message rather than a cryptic buildx error.
+
+**Publishing a base.** `whisper-base.yml` triggers on a push (any branch)
+that touches `Dockerfile.whisper`, and via `Actions → whisper-base → Run
+workflow` once the workflow is on the default branch — `workflow_dispatch`
+is not offered for workflows that only exist on a feature branch, so on a
+new branch the push path is how you bootstrap it.
+
+It **skips the build if the target tag already exists**, so a re-trigger
+costs seconds rather than ~20 minutes. Consequently, changing *how* the
+base is built without changing a version does not republish: pass
+`force: true`. That is deliberate — it stops an in-progress branch from
+silently replacing the base image the deployed app is running on. A
+feature branch may publish the versioned tag, but only the default branch
+moves `latest`.
+
+**Bumping whisper.cpp or CUDA:** dispatch whisper-base with the new
+ref/version, then update `BASE_IMAGE` in `.env.example`,
+`docker-compose.yml`, and `image.yml`.
+
+## Preflight
+
+Run through these before deploying — each one is a failure that otherwise
+shows up minutes later as a mysteriously failed job.
+
+1. **NVIDIA driver version.** `nvidia-smi`. The image is built on CUDA
+ 12.6, and `nvidia/cuda` images carry `NVIDIA_REQUIRE_CUDA=cuda>=12.6`
+ which the NVIDIA Container Toolkit **enforces at container start**.
+ With an older driver the container refuses to start:
+ `nvidia-container-cli: requirement error: unsatisfied condition:
+ cuda>=12.6`. Driver must be **≥ 560.28.03**. To target an older
+ driver, lower `CUDA_VERSION` to a release whose minimum driver matches
+ (see the [CUDA compatibility matrix][cuda-compat]), or set
+ `NVIDIA_DISABLE_REQUIRE=1` to bypass the check.
+2. **NVIDIA Container Toolkit installed.** [Install guide][nvct]. Verify:
+ `docker run --rm --gpus all nvidia/cuda:12.6.3-base-ubuntu24.04 nvidia-smi`.
+3. **`STORAGE_PATH` exists on the host.** Docker otherwise creates it as
+ an empty root-owned directory and every job fails with ENOENT.
+4. **Outbound HTTPS to `huggingface.co`.** Models (~3 GB) are fetched on
+ first use. If the host is firewalled, pre-seed them (below) — otherwise
+ every job fails.
[nvct]: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html
[cuda-compat]: https://docs.nvidia.com/deploy/cuda-compatibility/
-## Build & run
+## Configure
-```sh
-# CPU-only (local sanity check / dev machine without GPU):
-docker compose build
-docker compose up -d
+All configuration lives in `.env`, read automatically by `docker compose`:
-# On-prem with NVIDIA GPU:
-docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
+```sh
+cp .env.example .env
+$EDITOR .env
```
-The base `docker-compose.yml` works anywhere; `docker-compose.gpu.yml`
-overlays the NVIDIA device reservation and is **Linux-host only** —
-the `nvidia` driver runtime isn't available on macOS or Windows. On a
-Mac dev machine, run the base compose file alone (CPU fallback, under
-qemu emulation — slow but correct).
+`.env.example` documents every knob. The ones that matter most:
+`STORAGE_PATH` (must match the paths the caller sends), `CUDA_ARCHS`
+(`86` for the RTX 3090; `89` for L4/RTX 40xx, `90` for H100),
+`DEFAULT_LANGUAGE`, and `WORKERS`.
-All Dockerfile stages are pinned to `linux/amd64` because the on-prem GPU
-hosts are x86_64. On an x86_64 build host this is a no-op; on an arm64
-host (Apple Silicon dev machine) the build runs under qemu emulation,
-which is slow but produces deployment-correct binaries. To build natively
-for arm64 instead, strip the `--platform=linux/amd64` from each `FROM`.
+## Deploy
-The API is served on `:8888`. Open `http://:8888/` for the SPA or
-hit `POST /transcription/job` directly. `GET /healthz` and `GET /readyz`
-are available for liveness/readiness probes.
+Preferred — pull the CI-built image (no compiling on the host):
-## Volumes
+```sh
+docker compose pull
+docker compose up -d
+```
-| Mount | Purpose |
-| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `models:/var/cache/transcriber` | ggml whisper.cpp models. Survives container restarts — first job downloads ~3 GB. |
-| `/mnt/storage:/mnt/storage` | Audio inputs (`path`) and transcript outputs (`output_path`). Paths in API requests are read inside the container, so the host paths you reference must be visible at the same mount points. |
-| `./prompt.txt:/app/prompt.txt:ro` _(optional)_ | Default prompt file. Without it, only requests carrying their own `prompt` field get one. |
+Or build locally, on an **x86_64** host:
-## Configuration
+```sh
+docker compose build
+docker compose up -d
+```
+
+GPU access is part of `docker-compose.yml`, so the short command above is
+the production command — there is no overlay to remember. On a host with
+no NVIDIA GPU it fails loudly with "could not select device driver", which
+is the correct outcome: the image cannot run without a GPU regardless.
-Flags are set via the `command:` field in `docker-compose.yml`. The
-defaults run `whisper-cpp-large-v3` with 2 workers; for a beefier host
-something like `["-workers=4", "-callback-workers=4"]` is reasonable.
+The API is served on `:8888`. Open `http://:8888/` for the SPA or
+hit `POST /transcription/job` directly. `GET /healthz` and `GET /readyz`
+are available for probes, and the compose file wires `/healthz` into a
+container healthcheck — `docker compose ps` shows health at a glance.
+
+> **Note:** `/readyz` currently only checks that a default model is
+> registered, which is always true. It does **not** verify that
+> `whisper-cli` runs, that a GPU is visible, or that model files are
+> present. Treat a green `/readyz` as "the process is up", not "the next
+> job will succeed".
+
+### Building the images
+
+**App image** (`Dockerfile`) — cheap. It only builds the SPA and the Go
+binary, both of which run natively on the build host and cross-compile, so
+nothing is qemu-emulated even on an arm64 Mac. Measured locally: ~2 s with
+a warm cache. `.github/workflows/image.yml` builds it on every push and
+tags `latest`, `sha-`, and semver on tags. Pin a `sha-` tag in
+`.env` for a rollback-able deploy.
+
+**Base image** (`Dockerfile.whisper`) — expensive, and rarely rebuilt.
+Compiling ggml-cuda is ~15–30 min on an x86_64 runner. Two things about
+this build are easy to get wrong and are pinned deliberately:
+
+- **`BUILD_JOBS`.** nvcc needs roughly 2 GB of RAM per concurrent job for
+ ggml-cuda's template instantiations, so the build is memory-bound rather
+ than core-bound. A bare `-j` (unlimited) launched 138 concurrent nvcc
+ processes on a 4-vCPU/16 GB runner and the OOM killer took down the
+ runner agent mid-build, leaving no compiler error behind — the log simply
+ stopped. The job count is now derived from available memory and capped at
+ the core count; override with `--build-arg BUILD_JOBS=N`.
+- **The CUDA driver stub on the link line.** ggml-cuda calls driver-API
+ functions (`cuMemCreate`, `cuGetErrorString`, …) that live in
+ `libcuda.so`, not `libcudart`. With `BUILD_SHARED_LIBS=ON`,
+ `libggml-cuda.so` links fine with those undefined, and the failure
+ surfaces much later as `undefined reference to 'cuGetErrorString'` when
+ an executable links against it.
+
+Build it on an x86_64 host or via the **whisper-base** workflow. On arm64
+it also builds natively (nvidia/cuda publishes arm64 manifests), which is
+useful for validating changes to `Dockerfile.whisper` without qemu.
+
+The frontend build downloads webfonts from `fonts.gstatic.com`
+(`@nuxt/fonts`), so the *builder* needs outbound internet even though the
+resulting image is self-contained.
+
+## Scratch space
+
+Adapter intermediates — extracted chunk wavs and raw whisper JSON — go into a
+per-job scratch directory that is deleted when the job ends, including on
+timeout, cancellation, and failure. Only the final transcripts are written to
+`output_path`.
+
+Scratch defaults to the OS temp dir (`/tmp` in the container, so the container's
+writable layer). Chunking writes roughly **115 MB per hour of audio**, so with
+`WORKERS=2` on long files budget a few GB of headroom. Set `-scratch-dir` to
+move it — keep it on local disk rather than network storage, since none of it
+is worth shipping over the wire. `-keep-work-dirs` retains the directories for
+inspecting a bad transcription.
-To skip whisper's language auto-detection (faster, more reliable when the
-corpus is mono-lingual), pass an ISO 639-1 code with `-default-language`,
-e.g. `["-default-language=no"]`. Requests can still override with their
-own `language` field, or send `"auto"` to opt back into detection.
+## Volumes
-On hosts without an NVIDIA GPU (Mac dev, CPU-only Linux), pass
-`-whispercpp-no-gpu` to force whisper-cli's CPU backend (OpenBLAS-
-accelerated). Without it, the CUDA backend tries to initialize, fails
-to find a device, and the job errors. The `docker-compose.override.yml`
-in this repo already sets the flag for local dev; production NVIDIA
-hosts don't need it.
+| Mount | Purpose |
+| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `models:/var/cache/transcriber` | ggml whisper.cpp models. Survives restarts — first job downloads ~3 GB. |
+| `${STORAGE_PATH}:${STORAGE_PATH}` | Audio inputs (`path`) and transcript outputs (`output_path`). Mounted at the same path inside and out, because request paths resolve in the container. |
+| `./prompt.txt:/app/prompt.txt:ro` _(optional)_ | Default prompt file. Uncomment in `docker-compose.yml`. Without it, only requests carrying their own `prompt` get one. |
-Env vars set inside the image:
+Env vars baked into the image:
- `WHISPER_CPP_BIN=/usr/local/bin/whisper-cli`
- `XDG_CACHE_HOME=/var/cache` → models live at `/var/cache/transcriber/hf//`
@@ -92,42 +185,58 @@ Env vars set inside the image:
Both whisper.cpp adapters resolve the FP16 large-v3 weights (~3 GB
each) — the reference quality. On the RTX 3090 (24 GB VRAM) there's no
reason to trade accuracy for the Q5_0 variant; CUDA inference is
-compute-bound here, not memory-bound, so quantization wouldn't speed
-things up meaningfully either. To switch to a quantized file anyway
-(e.g. `ggml-large-v3-q5_0.bin`), pre-seed it into the volume and pin
-via `WHISPER_CPP_MODEL` / `NB_WHISPER_MODEL`.
+compute-bound here, not memory-bound. Override `WHISPER_CPP_MODEL` /
+`NB_WHISPER_MODEL` / `WHISPER_VAD_MODEL` on the service to pin a model to
+a specific file on disk instead of letting the HF cache resolve it.
-Override `WHISPER_CPP_MODEL` / `NB_WHISPER_MODEL` / `WHISPER_VAD_MODEL`
-on the service to pin a model to a specific file on disk instead of
-letting the HF cache resolve it.
+## Pre-seeding models
-## Pre-seeding models (optional)
-
-To avoid the first-request download, drop ggml files into the volume
-ahead of time:
+Worth doing even with internet access: the download currently happens
+*inside* the first job's context, so it competes with `-job-timeout`
+(default 30m) and a slow fetch makes the first job fail with
+`error: "timeout"`. With `WORKERS=2`, the second job blocks on the first
+job's download while its own deadline runs.
```sh
# Find the volume path:
docker volume inspect transcriber_models -f '{{ .Mountpoint }}'
-# Copy a pre-downloaded model into place:
+# Copy pre-downloaded models into place:
sudo mkdir -p /transcriber/hf/ggerganov/whisper.cpp
sudo cp ggml-large-v3.bin /transcriber/hf/ggerganov/whisper.cpp/
+sudo mkdir -p /transcriber/hf/ggml-org/whisper-vad
+sudo cp ggml-silero-v5.1.2.bin /transcriber/hf/ggml-org/whisper-vad/
```
## Upgrading
```sh
-git pull
-docker compose build
+git pull # picks up .env.example / compose changes
+docker compose pull # or: docker compose build
docker compose up -d
```
The API has a 10 s graceful shutdown — in-flight HTTP requests finish,
but workers receive a cancel and any running transcription jobs are
-killed. Avoid redeploying while jobs are running, or drain the queue
-first (`GET /transcription/jobs`, `DELETE /transcription/job/{id}`).
+killed. **The job store is in-memory**, so a restart also discards the
+queue and all job history; the caller's next poll returns 404. Drain
+first (`GET /transcription/jobs`, `DELETE /transcription/job/{id}`) or
+redeploy when idle.
## Logs
-`docker compose logs -f transcriber` — the API logs via slog to stderr.
+`docker compose logs -f transcriber` — the API logs via slog to stderr,
+JSON by default (`LOG_FORMAT` in `.env`).
+
+## Known rough edges
+
+Tracked in `IMPROVEMENTS.md`; these are the ones that affect operations:
+
+- **In-memory job store.** Restart or crash loses the queue and history.
+ `MAX_TERMINAL_JOBS` (default 200 here) also evicts completed jobs, so a
+ large enough burst can evict a result before the caller reads it.
+- **Output files are root-owned.** The container runs as root, so
+ transcripts land on `STORAGE_PATH` as `root:root`.
+- **No authentication.** Anything that can reach the port can submit jobs
+ with arbitrary absolute `path` / `output_path` values. Bind to an
+ internal interface (`BIND_ADDR`) and firewall it.
diff --git a/Dockerfile b/Dockerfile
index 2e9ea3d..5028ea1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,99 +1,82 @@
# syntax=docker/dockerfile:1.7
-# Target on-prem x86_64 GPU servers. On arm64 hosts (Apple Silicon) the build
-# runs under qemu emulation, which is slow but produces deployment-correct
-# binaries; on x86_64 hosts --platform is a no-op.
-
-# ---- Stage 1: build whisper.cpp (whisper-cli) with CUDA backend ----
-# Pinned to the on-prem host's GPU stack: NVIDIA only. CUDA is whisper.cpp's
-# most-optimized backend (1.5–2× over Vulkan on NVIDIA). The runtime stage
-# uses the matching nvidia/cuda runtime image so libcudart / libcublas etc.
-# are available without polluting the host. Local Mac dev still works in
-# CPU-only mode (`-whispercpp-no-gpu`) — the CUDA libs are present but
-# unused.
-ARG CUDA_VERSION=12.6.3
-FROM --platform=linux/amd64 nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04 AS whisper-build
-ARG WHISPER_CPP_REF=v1.8.6
-RUN apt-get update && apt-get install -y --no-install-recommends \
- build-essential cmake git ca-certificates pkg-config \
- libopenblas-dev \
- && rm -rf /var/lib/apt/lists/*
-WORKDIR /src
-RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov/whisper.cpp.git .
-# BLAS stays on for the CPU fallback path (`-whispercpp-no-gpu`); on CUDA
-# hosts it's unused at runtime, so the only cost is image size.
+# App image: the Go binary with the SPA embedded, on top of the prebuilt
+# whisper.cpp/CUDA base (see Dockerfile.whisper). Nothing here compiles CUDA, so
+# this builds in seconds — bump BASE_IMAGE when whisper.cpp or CUDA changes.
+#
+# The frontend and Go stages run natively on the build host ($BUILDPLATFORM) and
+# cross-compile, so building an amd64 image from an arm64 Mac never touches qemu.
#
-# CMAKE_CUDA_ARCHITECTURES must be pinned: ggml-cuda's default is `native`,
-# which queries nvidia-smi on the build host — docker build has no GPU, so
-# the build would fail. Pinned to sm_86 for the on-prem RTX 3090 (Ampere).
-# If the deployment GPU changes, update this — building for the wrong arch
-# either falls back to PTX JIT at startup (slow) or fails outright.
-ARG CUDA_ARCHS="86"
-RUN cmake -B build \
- -DCMAKE_BUILD_TYPE=Release \
- -DGGML_CUDA=ON \
- -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
- -DGGML_BLAS=ON \
- -DGGML_BLAS_VENDOR=OpenBLAS \
- -DWHISPER_BUILD_TESTS=OFF \
- -DWHISPER_BUILD_EXAMPLES=ON \
- -DBUILD_SHARED_LIBS=ON \
- && cmake --build build --config Release -j \
- && cmake --install build --prefix /opt/whisper
+# NOTE: the base image requires an NVIDIA GPU and driver at runtime — whisper-cli
+# is dynamically linked against libcuda.so.1, injected by the NVIDIA Container
+# Toolkit. There is no CPU-only mode. For local development on a machine without
+# an NVIDIA GPU, run the binary natively (`make build`) against a native
+# whisper-cli; see README.md.
-# ---- Stage 2: build frontend (Nuxt → static) ----
-FROM --platform=linux/amd64 node:22-bookworm-slim AS frontend-build
-# Pin pnpm v10: lockfile is v9.0 (created by pnpm 9), pnpm 10 reads it natively.
-# Avoids the pnpm v11 "approved builds" gate which requires interactive
-# pnpm approve-builds to allow esbuild / @parcel/watcher native postinstalls.
-RUN npm install -g pnpm@10
+ARG ARCH=amd64
+ARG BASE_IMAGE=ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3
+
+# ---- Stage 1: build frontend (Nuxt → static) ----
+# Runs on the build host's native arch: the output is platform-independent
+# static JS/CSS, so there is nothing to gain from emulating the target.
+FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS frontend-build
+# PNPM_VERSION must match frontend/package.json's "packageManager" field.
+# pnpm >= 10 self-manages to whatever that field declares, so installing a
+# different major here does not pin anything — it silently re-execs the
+# declared version mid-build. --config.manage-package-manager-versions=false
+# disables that, so the version installed here is the version that runs and
+# any drift fails loudly (lockfile mismatch) instead of quietly.
+ARG PNPM_VERSION=11.21.0
+RUN npm install -g pnpm@${PNPM_VERSION}
+ENV PNPM_FLAGS="--config.manage-package-manager-versions=false"
WORKDIR /src/frontend
-COPY frontend/package.json frontend/pnpm-lock.yaml ./
-RUN pnpm install --frozen-lockfile
+# pnpm-workspace.yaml carries settings pnpm needs AT INSTALL TIME: allowBuilds
+# (permits the esbuild / @parcel/watcher native postinstalls) and
+# minimumReleaseAge. Omit it from this layer and `pnpm install` still exits 0
+# but skips those builds (ERR_PNPM_IGNORED_BUILDS), and the Nuxt build fails
+# downstream. It must be copied alongside package.json, not with the source.
+COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./
+RUN pnpm ${PNPM_FLAGS} install --frozen-lockfile
COPY frontend/ ./
-RUN pnpm generate
+# @nuxt/fonts downloads webfonts from fonts.gstatic.com during the build, so
+# this stage needs outbound internet even though the result is self-contained.
+RUN pnpm ${PNPM_FLAGS} generate
-# ---- Stage 3: build Go binary ----
-FROM --platform=linux/amd64 golang:1.26-bookworm AS go-build
+# ---- Stage 2: build Go binary ----
+# Also native + cross-compiled: CGO_ENABLED=0 makes GOARCH a pure flag flip.
+FROM --platform=$BUILDPLATFORM golang:1.26.3-bookworm AS go-build
+ARG ARCH
+# go.mod pins a patch version (go 1.26.3). GOTOOLCHAIN=local makes a mismatch
+# with this base image fail loudly instead of silently downloading a toolchain
+# from proxy.golang.org — which breaks in network-restricted builders and makes
+# the build depend on whatever the floating image tag resolved to.
+ENV GOTOOLCHAIN=local CGO_ENABLED=0 GOOS=linux
WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
COPY internal ./internal
-# Replace the embedded dist with the freshly built SPA.
+# internal/web/dist is gitignored, so it is absent (or stale) in the build
+# context. Replace it with the freshly built SPA — //go:embed needs it to
+# exist and contain index.html.
RUN rm -rf internal/web/dist && mkdir -p internal/web/dist
COPY --from=frontend-build /src/frontend/.output/public/ ./internal/web/dist/
-RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /out/transcriber ./cmd/transcriber
-
-# ---- Stage 4: runtime ----
-# Matches the whisper-build base so glibc / libstdc++ and CUDA runtime libs
-# (libcudart, libcublas) are present without bundling the full toolkit.
-FROM --platform=linux/amd64 nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04 AS runtime
-RUN apt-get update && apt-get install -y --no-install-recommends \
- ffmpeg ca-certificates libgomp1 libstdc++6 \
- libopenblas0-pthread \
- && rm -rf /var/lib/apt/lists/*
+RUN GOARCH=${ARCH} go build -trimpath -ldflags='-s -w' -o /out/transcriber ./cmd/transcriber
-# NVIDIA Container Toolkit reads these to expose the right device files and
-# driver libraries. `compute,utility` is all CUDA needs — we dropped `graphics`
-# along with the Vulkan backend.
-ENV NVIDIA_VISIBLE_DEVICES=all \
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
+# ---- Stage 3: runtime ----
+FROM --platform=linux/${ARCH} ${BASE_IMAGE}
# Model cache: $XDG_CACHE_HOME/transcriber/hf//
ENV XDG_CACHE_HOME=/var/cache \
WHISPER_CPP_BIN=/usr/local/bin/whisper-cli
RUN mkdir -p /var/cache/transcriber/hf
-# `cmake --install` lays out whisper-cli + the libwhisper/libggml shared libs
-# it dynamically links against under one prefix. Drop it into /usr/local so the
-# binary and libs land on the default $PATH / loader path.
-COPY --from=whisper-build /opt/whisper/ /usr/local/
-RUN ldconfig
COPY --from=go-build /out/transcriber /usr/local/bin/transcriber
WORKDIR /app
EXPOSE 8888
# `prompt.txt` is optional; mount one in if you want a default prompt.
+# Flags come from docker-compose.yml (driven by .env); these defaults apply
+# when running the image directly with `docker run`.
ENTRYPOINT ["/usr/local/bin/transcriber"]
CMD ["-port=8888", "-workers=2", "-default-model=whisper-cpp-large-v3", "-log-format=json"]
diff --git a/Dockerfile.whisper b/Dockerfile.whisper
new file mode 100644
index 0000000..a34485f
--- /dev/null
+++ b/Dockerfile.whisper
@@ -0,0 +1,120 @@
+# syntax=docker/dockerfile:1.7
+
+# Base image: whisper.cpp (CUDA) + ffmpeg + CUDA runtime libs.
+#
+# Split out from the app Dockerfile because compiling whisper.cpp with nvcc
+# takes 15-30 minutes and only changes when WHISPER_CPP_REF or CUDA_VERSION
+# does — which is rarely. The app image FROMs this, so ordinary Go/frontend
+# changes rebuild in seconds instead of recompiling CUDA kernels.
+#
+# Build and publish (see .github/workflows/whisper-base.yml):
+# docker build -f Dockerfile.whisper \
+# -t ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3 .
+#
+# NOTE: this image requires an NVIDIA GPU and driver at runtime. whisper-cli is
+# dynamically linked against libcuda.so.1, which the NVIDIA Container Toolkit
+# injects; without it the loader fails before main() and the binary cannot run
+# at all. There is no CPU-only mode for this image — use a native whisper-cli
+# build for CPU/Metal hosts.
+
+ARG ARCH=amd64
+ARG CUDA_VERSION=12.6.3
+
+# ---- Stage 1: build whisper.cpp (whisper-cli) with the CUDA backend ----
+FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-devel-ubuntu24.04 AS whisper-build
+ARG WHISPER_CPP_REF=v1.8.6
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ build-essential cmake git ca-certificates pkg-config \
+ libopenblas-dev \
+ && rm -rf /var/lib/apt/lists/*
+WORKDIR /src
+# WHISPER_CPP_REF is a mutable tag — git tags can be force-moved, so the image
+# is not bit-reproducible. Pin a 40-char commit SHA when that matters.
+RUN git clone --depth 1 --branch ${WHISPER_CPP_REF} https://github.com/ggerganov/whisper.cpp.git .
+
+# BLAS stays on for CPU-side work; the only cost is image size.
+#
+# CMAKE_CUDA_ARCHITECTURES must be pinned: ggml-cuda's default is `native`,
+# which queries nvidia-smi on the build host — docker build has no GPU, so the
+# build would fail. 86 = RTX 3090 (Ampere); 89 = L4/RTX 40xx; 90 = H100.
+# Building for the wrong arch either falls back to PTX JIT at startup (slow) or
+# fails outright.
+ARG CUDA_ARCHS="86"
+
+# BUILD_JOBS bounds compile parallelism. This matters: ggml-cuda's template
+# instantiations need roughly 2 GB of RAM *per concurrent nvcc*, so the build is
+# memory-bound, not core-bound. A bare `-j` means unlimited — it launched 138
+# concurrent nvcc processes in 2.6 s on a 4-vCPU/16 GB GitHub runner and the OOM
+# killer took down the runner agent mid-build, with no compiler error to show
+# for it. Empty derives a safe count from available memory, capped at the core
+# count; override with --build-arg BUILD_JOBS=N.
+ARG BUILD_JOBS
+
+# The CUDA *driver* API stub must be on the link line. ggml-cuda calls
+# cuMemCreate / cuMemMap / cuDeviceGet / cuGetErrorString etc., which live in
+# libcuda.so (driver), not libcudart (runtime). With BUILD_SHARED_LIBS=ON,
+# libggml-cuda.so builds fine with those symbols undefined — shared libraries
+# tolerate that — and the failure only appears later when an executable links
+# against it ("undefined reference to `cuGetErrorString'"). The devel image ships
+# a stub at this arch-independent path; DT_NEEDED resolves to the real driver at
+# runtime, which the NVIDIA Container Toolkit injects.
+ARG CUDA_STUBS=/usr/local/cuda/lib64/stubs
+
+RUN cmake -B build \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DGGML_CUDA=ON \
+ -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
+ -DGGML_BLAS=ON \
+ -DGGML_BLAS_VENDOR=OpenBLAS \
+ -DWHISPER_BUILD_TESTS=OFF \
+ -DWHISPER_BUILD_EXAMPLES=ON \
+ -DBUILD_SHARED_LIBS=ON \
+ -DCMAKE_SHARED_LINKER_FLAGS="-L${CUDA_STUBS} -lcuda" \
+ -DCMAKE_EXE_LINKER_FLAGS="-L${CUDA_STUBS} -lcuda" \
+ && set -eu \
+ && cores="$(nproc)" \
+ && memjobs="$(awk '/^MemTotal:/ {printf "%d", $2/1024/1024/3}' /proc/meminfo)" \
+ && if [ "${memjobs}" -lt 1 ]; then memjobs=1; fi \
+ && jobs="${BUILD_JOBS:-${memjobs}}" \
+ && if [ "${jobs}" -gt "${cores}" ]; then jobs="${cores}"; fi \
+ && echo "whisper.cpp build: -j${jobs} (cores=${cores} mem-derived=${memjobs})" \
+ && cmake --build build --config Release --parallel "${jobs}" \
+ && cmake --install build --prefix /opt/whisper
+# Fail here rather than at first transcription if the install layout changes.
+RUN test -x /opt/whisper/bin/whisper-cli
+
+# ---- Stage 2: the published base ----
+# Matches the whisper-build base so glibc / libstdc++ and the CUDA runtime libs
+# (libcudart, libcublas) are present without bundling the full toolkit.
+#
+# NOTE: nvidia/cuda images carry NVIDIA_REQUIRE_CUDA=cuda>=, which
+# the NVIDIA Container Toolkit *enforces* at container start. With CUDA 12.6 the
+# host driver must be >= 560.28.03 or the container refuses to start with
+# "requirement error: unsatisfied condition: cuda>=12.6". Lower CUDA_VERSION or
+# set NVIDIA_DISABLE_REQUIRE=1 to override. See DEPLOY.md.
+FROM --platform=linux/${ARCH} nvidia/cuda:${CUDA_VERSION}-runtime-ubuntu24.04
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ ffmpeg ca-certificates libgomp1 libstdc++6 \
+ libopenblas0-pthread \
+ curl \
+ && rm -rf /var/lib/apt/lists/*
+
+# NVIDIA Container Toolkit reads these to expose the right device files and
+# driver libraries. `compute,utility` is all CUDA needs — `compute` is what
+# gets libcuda.so.1 injected, which whisper-cli requires to start.
+ENV NVIDIA_VISIBLE_DEVICES=all \
+ NVIDIA_DRIVER_CAPABILITIES=compute,utility
+
+# `cmake --install` lays out whisper-cli plus the libwhisper/libggml shared libs
+# it dynamically links against under one prefix. Drop it into /usr/local so the
+# binary and libs land on the default $PATH / loader path.
+COPY --from=whisper-build /opt/whisper/ /usr/local/
+RUN ldconfig
+
+# Sanity-check the layout. whisper-cli itself can't be executed here (no driver
+# in the build container), so verify the loader can resolve everything *except*
+# libcuda.so.1, which the toolkit injects at runtime.
+RUN set -eu; \
+ missing="$(ldd /usr/local/bin/whisper-cli | awk '/not found/ {print $1}' | grep -v '^libcuda\.so\.1$' || true)"; \
+ if [ -n "${missing}" ]; then echo "unresolved libraries: ${missing}"; exit 1; fi; \
+ ffmpeg -version >/dev/null && ffprobe -version >/dev/null
diff --git a/README.md b/README.md
index 6ef74d4..c646dda 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,13 @@ to extract each chunk to a 16kHz mono wav. Model files are downloaded
from Hugging Face on first use and cached on disk. The `stub` adapter
has no external dependencies.
+On macOS, `brew install whisper-cpp ffmpeg` covers all three, and brew's
+`whisper-cli` is Metal-accelerated — so a native run is both the fastest
+and the only workable local option. **Do not use Docker for local
+development:** the image requires an NVIDIA GPU (`whisper-cli` there is
+linked against `libcuda.so.1`), so it cannot start on a Mac at all. See
+DEPLOY.md.
+
## Configuration
The set of registered models lives in `cmd/transcriber/models.go` as typed
@@ -53,6 +60,8 @@ Go code. Server settings come from flags; per-machine paths from env vars.
| `-job-timeout` | `30m` | wall-clock cap per job; on expiry the worker cancels the subprocess and marks the job `FAILED` with `error: "timeout"`. Per-request `timeout_seconds` overrides this. `<= 0` disables |
| `-max-terminal-jobs` | `20` | how many finished jobs (completed/failed/canceled) to retain in memory; `<= 0` disables the cap |
| `-log-format` | `text` | `text` for human-readable output (dev), `json` for structured logs (prod). The Dockerfile sets `json` |
+| `-scratch-dir` | _(OS temp)_ | where per-job scratch dirs are created for adapter intermediates (extracted chunk wavs, raw model output). Keep on local disk — chunking writes ~115 MB per hour of audio |
+| `-keep-work-dirs` | `false` | retain per-job scratch dirs after completion, for inspecting a bad transcription. They are large; off by default |
| Env var | Default | Meaning |
| ------------------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
diff --git a/cmd/transcriber/main.go b/cmd/transcriber/main.go
index f3c8c20..ad8a305 100644
--- a/cmd/transcriber/main.go
+++ b/cmd/transcriber/main.go
@@ -30,6 +30,8 @@ func main() {
maxTerminalJobs := flag.Int("max-terminal-jobs", 20, "how many finished jobs (completed/failed/canceled) to retain in memory; <= 0 disables the cap")
jobTimeout := flag.Duration("job-timeout", 30*time.Minute, "default wall-clock timeout per job; per-request timeout_seconds overrides this; <= 0 disables")
logFormat := flag.String("log-format", "text", "log handler: text (human-readable, dev) or json (structured, prod)")
+ scratchDir := flag.String("scratch-dir", "", "directory for per-job scratch space (extracted audio chunks, raw model output); empty uses the OS temp dir. Keep this on local disk, not network storage")
+ keepWorkDirs := flag.Bool("keep-work-dirs", false, "retain per-job scratch directories after completion for debugging; they are large (~115 MB per hour of audio)")
whisperNoGPU := flag.Bool("whispercpp-no-gpu", false, "pass `-ng` to whisper-cli, forcing the CPU backend (use on hosts without a real GPU, e.g. Docker-on-Mac)")
flag.Parse()
@@ -87,7 +89,11 @@ func main() {
pool := worker.New(*workers, store, queue, registry, notifier, func(j jobs.Job) any {
return api.ToDTO(j)
- }, *jobTimeout)
+ }, worker.Config{
+ DefaultTimeout: *jobTimeout,
+ ScratchRoot: *scratchDir,
+ KeepWorkDirs: *keepWorkDirs,
+ })
pool.Start(ctx)
srv := api.NewServer(store, queue, registry, defaultPrompt, normalizedDefaultLang)
diff --git a/docker-compose.gpu.yml b/docker-compose.gpu.yml
deleted file mode 100644
index ab3cd44..0000000
--- a/docker-compose.gpu.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-# GPU access overlay. Compose this on top of docker-compose.yml on the on-prem
-# NVIDIA host. Without it, the container runs CPU-only (fine for local sanity
-# checks, not fine for production throughput). Requires the NVIDIA Container
-# Toolkit on the host.
-#
-# Usage:
-# docker compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
-
-services:
- transcriber:
- deploy:
- resources:
- reservations:
- devices:
- - driver: nvidia
- count: all
- capabilities: [gpu]
diff --git a/docker-compose.yml b/docker-compose.yml
index 5a18816..e8f7a17 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,22 +1,69 @@
+# Single compose file. Every knob comes from `.env` — copy .env.example to .env
+# and edit. See DEPLOY.md.
+#
+# docker compose pull && docker compose up -d # registry image (preferred)
+# docker compose build && docker compose up -d # build the app image locally
+#
+# This service REQUIRES an NVIDIA GPU and driver. whisper-cli is dynamically
+# linked against libcuda.so.1, which the NVIDIA Container Toolkit injects, so
+# without a GPU the binary cannot start at all — there is no CPU fallback in the
+# container. For local development on a machine without an NVIDIA GPU, run the
+# binary natively instead (`make build`); see README.md.
+name: transcriber
+
services:
transcriber:
- build: .
- image: transcriber:latest
+ image: ${IMAGE:-ghcr.io/bcc-code/transcriber:latest}
+ build:
+ context: .
+ args:
+ # Prebuilt whisper.cpp/CUDA base. Bump this to change whisper or CUDA
+ # versions; it is built separately by .github/workflows/whisper-base.yml.
+ BASE_IMAGE: ${BASE_IMAGE:-ghcr.io/bcc-code/whisper-cuda:v1.8.6-cuda12.6.3}
restart: unless-stopped
+
+ # BIND_ADDR=0.0.0.0 preserves the previous behaviour: the API has no
+ # authentication, so anything that can reach this port can read and write
+ # any path visible inside the container. Firewall it, or set BIND_ADDR to
+ # an internal interface.
ports:
- - "8888:8888"
+ - "${BIND_ADDR:-0.0.0.0}:${PORT:-8888}:${PORT:-8888}"
+
+ command:
+ - "-port=${PORT:-8888}"
+ - "-workers=${WORKERS:-2}"
+ - "-callback-workers=${CALLBACK_WORKERS:-2}"
+ - "-default-model=${DEFAULT_MODEL:-whisper-cpp-large-v3}"
+ - "-default-language=${DEFAULT_LANGUAGE:-}"
+ - "-job-timeout=${JOB_TIMEOUT:-30m}"
+ - "-max-terminal-jobs=${MAX_TERMINAL_JOBS:-200}"
+ - "-log-format=${LOG_FORMAT:-json}"
+
volumes:
- # Whisper model cache — persists ggml model downloads across restarts.
+ # ggml model cache — first job downloads ~3 GB, this keeps it.
- models:/var/cache/transcriber
- # Audio in / transcripts out. Adjust the host paths to your storage.
- - /mnt/storage:/mnt/storage
- # Optional: mount a default prompt file (vocabulary hints, etc.).
+ # Audio in / transcripts out. Job requests carry absolute paths that are
+ # resolved *inside* the container, so host and container paths must match.
+ # STORAGE_PATH must already exist on the host: Docker otherwise creates
+ # it as an empty root-owned directory and every job fails with ENOENT.
+ - ${STORAGE_PATH:-/mnt/storage}:${STORAGE_PATH:-/mnt/storage}
+ # Optional default prompt (vocabulary hints). Uncomment to use.
# - ./prompt.txt:/app/prompt.txt:ro
- # Override the default flags here if you want, e.g.:
- # command: ["-port=8888", "-workers=4", "-default-model=nb-whisper-large"]
+ healthcheck:
+ test: ["CMD", "curl", "-fsS", "http://localhost:${PORT:-8888}/healthz"]
+ interval: 30s
+ timeout: 5s
+ start_period: 15s
+ retries: 3
-# GPU access is opt-in via docker-compose.gpu.yml — see DEPLOY.md.
+ deploy:
+ resources:
+ reservations:
+ devices:
+ - driver: nvidia
+ count: all
+ capabilities: [gpu]
volumes:
models:
diff --git a/internal/transcriber/adapter.go b/internal/transcriber/adapter.go
index 95bb817..e49604f 100644
--- a/internal/transcriber/adapter.go
+++ b/internal/transcriber/adapter.go
@@ -35,7 +35,15 @@ type Word struct {
type Request struct {
InputPath string
Language string
- OutputDir string
+ // WorkDir is scratch space for intermediate files (extracted chunk wavs,
+ // raw model output). It is owned by the caller, which deletes it once the
+ // job finishes, so adapters must not treat anything written here as a
+ // deliverable.
+ //
+ // It is deliberately NOT the job's output_path: the final transcripts are
+ // written by the worker via internal/formats, so nothing an adapter leaves
+ // behind lands in the directory the API caller reads.
+ WorkDir string
// Prompt biases the decoder toward names and terms it might otherwise mishear.
// whisper.cpp truncates to ~224 tokens.
Prompt string
diff --git a/internal/transcriber/chunked/chunked.go b/internal/transcriber/chunked/chunked.go
index 85978fa..665e524 100644
--- a/internal/transcriber/chunked/chunked.go
+++ b/internal/transcriber/chunked/chunked.go
@@ -55,10 +55,12 @@ func (a *Adapter) Transcribe(ctx context.Context, req transcriber.Request, onPro
return a.Inner.Transcribe(ctx, req, onProgress)
}
- if err := os.MkdirAll(req.OutputDir, 0o755); err != nil {
+ if err := os.MkdirAll(req.WorkDir, 0o755); err != nil {
return nil, err
}
- chunksDir := filepath.Join(req.OutputDir, "chunks")
+ // Extracted chunk wavs are ~32 kB/s of audio (16 kHz mono s16le), i.e.
+ // ~115 MB per hour. They live in scratch and are deleted with it.
+ chunksDir := filepath.Join(req.WorkDir, "chunks")
if err := os.MkdirAll(chunksDir, 0o755); err != nil {
return nil, err
}
@@ -109,14 +111,14 @@ func (a *Adapter) transcribeChunk(ctx context.Context, req transcriber.Request,
if err := ExtractChunk(ctx, a.cfg.FFmpegBin, req.InputPath, wavPath, ch.Start, ch.Duration()); err != nil {
return nil, err
}
- chunkOutDir := filepath.Join(chunksDir, fmt.Sprintf("%03d", ch.Index))
- if err := os.MkdirAll(chunkOutDir, 0o755); err != nil {
+ chunkWorkDir := filepath.Join(chunksDir, fmt.Sprintf("%03d", ch.Index))
+ if err := os.MkdirAll(chunkWorkDir, 0o755); err != nil {
return nil, err
}
subReq := transcriber.Request{
InputPath: wavPath,
Language: req.Language,
- OutputDir: chunkOutDir,
+ WorkDir: chunkWorkDir,
Prompt: req.Prompt,
Options: req.Options,
}
diff --git a/internal/transcriber/chunked/chunked_test.go b/internal/transcriber/chunked/chunked_test.go
index 6685824..1549038 100644
--- a/internal/transcriber/chunked/chunked_test.go
+++ b/internal/transcriber/chunked/chunked_test.go
@@ -56,14 +56,14 @@ func TestAdapterEndToEnd(t *testing.T) {
t.Fatalf("ffmpeg generate: %v: %s", err, out)
}
- outDir := filepath.Join(tmp, "out")
+ workDir := filepath.Join(tmp, "work")
inner := &fakeInner{}
a := New(inner, Config{ChunkLengthSec: 2, OverlapSec: 0.5})
var progressLast float64
res, err := a.Transcribe(context.Background(), transcriber.Request{
InputPath: src,
- OutputDir: outDir,
+ WorkDir: workDir,
}, func(p float64) {
progressLast = p
})
@@ -119,7 +119,7 @@ func TestAdapterShortFileBypassesChunking(t *testing.T) {
a := New(inner, Config{ChunkLengthSec: 300, OverlapSec: 3})
_, err := a.Transcribe(context.Background(), transcriber.Request{
InputPath: src,
- OutputDir: filepath.Join(tmp, "out"),
+ WorkDir: filepath.Join(tmp, "work"),
}, nil)
if err != nil {
t.Fatalf("Transcribe: %v", err)
diff --git a/internal/transcriber/whispercpp/whispercpp.go b/internal/transcriber/whispercpp/whispercpp.go
index b096dd8..8da8ac4 100644
--- a/internal/transcriber/whispercpp/whispercpp.go
+++ b/internal/transcriber/whispercpp/whispercpp.go
@@ -90,10 +90,12 @@ func (a *Adapter) Transcribe(ctx context.Context, req transcriber.Request, onPro
}
modelPath = p
}
- if err := os.MkdirAll(req.OutputDir, 0o755); err != nil {
+ if err := os.MkdirAll(req.WorkDir, 0o755); err != nil {
return nil, err
}
- outPrefix := filepath.Join(req.OutputDir, "whispercpp_out")
+ // Raw whisper output is an intermediate: it goes in the scratch dir, not
+ // the caller's output_path.
+ outPrefix := filepath.Join(req.WorkDir, "whispercpp_out")
args := []string{
"-m", modelPath,
diff --git a/internal/worker/pool.go b/internal/worker/pool.go
index ac30043..245dbb7 100644
--- a/internal/worker/pool.go
+++ b/internal/worker/pool.go
@@ -6,7 +6,9 @@ import (
"errors"
"fmt"
"log/slog"
+ "os"
"path/filepath"
+ "strings"
"sync"
"time"
@@ -16,20 +18,35 @@ import (
"transcriber/internal/transcriber"
)
+// Config holds the pool's tunables.
+type Config struct {
+ // DefaultTimeout <= 0 disables the wall-clock cap; a job's own Timeout
+ // takes precedence when non-zero.
+ DefaultTimeout time.Duration
+
+ // ScratchRoot is where per-job working directories are created; empty uses
+ // os.TempDir(). Keep it on local disk — chunk extraction writes ~115 MB per
+ // hour of audio, and routing that through network storage is wasted I/O.
+ ScratchRoot string
+
+ // KeepWorkDirs retains per-job scratch directories after the job finishes,
+ // for inspecting a bad transcription. Off by default: they are large.
+ KeepWorkDirs bool
+}
+
// Pool is a fixed-size worker pool that runs jobs through the configured adapter.
type Pool struct {
- workers int
- store *jobs.Store
- queue *jobs.Queue
- registry *transcriber.Registry
- notifier *callback.Notifier
- dtoFn func(jobs.Job) any
- defaultTimeout time.Duration
- wg sync.WaitGroup
+ workers int
+ store *jobs.Store
+ queue *jobs.Queue
+ registry *transcriber.Registry
+ notifier *callback.Notifier
+ dtoFn func(jobs.Job) any
+ cfg Config
+ wg sync.WaitGroup
}
-// New builds a pool. defaultTimeout <= 0 disables the wall-clock cap; the
-// per-job Timeout takes precedence when non-zero.
+// New builds a pool.
func New(
workers int,
store *jobs.Store,
@@ -37,19 +54,19 @@ func New(
registry *transcriber.Registry,
notifier *callback.Notifier,
dtoFn func(jobs.Job) any,
- defaultTimeout time.Duration,
+ cfg Config,
) *Pool {
if workers < 1 {
workers = 1
}
return &Pool{
- workers: workers,
- store: store,
- queue: queue,
- registry: registry,
- notifier: notifier,
- dtoFn: dtoFn,
- defaultTimeout: defaultTimeout,
+ workers: workers,
+ store: store,
+ queue: queue,
+ registry: registry,
+ notifier: notifier,
+ dtoFn: dtoFn,
+ cfg: cfg,
}
}
@@ -92,9 +109,20 @@ func (p *Pool) runJob(parent context.Context, id string, log *slog.Logger) {
return
}
+ // Scratch space for the adapter's intermediates, removed when the job ends
+ // (including on timeout, cancel, and failure) so nothing accumulates in the
+ // caller's output_path.
+ workDir, cleanupWorkDir, err := p.newWorkDir(id)
+ if err != nil {
+ p.markFailed(id, fmt.Errorf("create work dir: %w", err))
+ p.fireCallback(id)
+ return
+ }
+ defer cleanupWorkDir()
+
timeout := job.Timeout
if timeout <= 0 {
- timeout = p.defaultTimeout
+ timeout = p.cfg.DefaultTimeout
}
var ctx context.Context
var cancel context.CancelFunc
@@ -124,7 +152,7 @@ func (p *Pool) runJob(parent context.Context, id string, log *slog.Logger) {
req := transcriber.Request{
InputPath: job.Path,
Language: job.Language,
- OutputDir: job.OutputPath,
+ WorkDir: workDir,
Prompt: job.Prompt,
}
@@ -175,6 +203,50 @@ func (p *Pool) runJob(parent context.Context, id string, log *slog.Logger) {
p.fireCallback(id)
}
+// newWorkDir allocates per-job scratch space and returns a cleanup func that
+// the caller must defer. The cleanup is a no-op (beyond a log line) when
+// KeepWorkDirs is set.
+func (p *Pool) newWorkDir(id string) (string, func(), error) {
+ root := p.cfg.ScratchRoot
+ if root == "" {
+ root = os.TempDir()
+ }
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ return "", nil, err
+ }
+ dir, err := os.MkdirTemp(root, "transcriber-"+safeName(id)+"-")
+ if err != nil {
+ return "", nil, err
+ }
+ if p.cfg.KeepWorkDirs {
+ return dir, func() {
+ slog.Info("retaining job work dir", "id", id, "dir", dir)
+ }, nil
+ }
+ return dir, func() {
+ if err := os.RemoveAll(dir); err != nil {
+ slog.Warn("work dir cleanup failed", "id", id, "dir", dir, "err", err)
+ }
+ }, nil
+}
+
+// safeName reduces s to characters safe in a single filename component. The ID
+// goes into the work dir name so `-keep-work-dirs` output is traceable back to
+// a job, but os.MkdirTemp rejects patterns containing a path separator and IDs
+// are only hex by current convention.
+func safeName(s string) string {
+ return strings.Map(func(r rune) rune {
+ switch {
+ case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
+ return r
+ case r == '-', r == '_', r == '.':
+ return r
+ default:
+ return '-'
+ }
+ }, s)
+}
+
func (p *Pool) pickAdapter(j jobs.Job) (transcriber.Transcriber, error) {
if j.Model != "" {
a, ok := p.registry.Get(j.Model)
diff --git a/internal/worker/pool_test.go b/internal/worker/pool_test.go
index c585946..53da058 100644
--- a/internal/worker/pool_test.go
+++ b/internal/worker/pool_test.go
@@ -1,6 +1,8 @@
package worker
import (
+ "context"
+ "errors"
"os"
"path/filepath"
"testing"
@@ -35,6 +37,123 @@ func TestPool_Timeouts(t *testing.T) {
}
}
+// scratchAdapter writes intermediates into req.WorkDir the way the real
+// whisper.cpp adapters do, so the test can assert none of it reaches the
+// caller's output_path and that the scratch dir is reclaimed.
+type scratchAdapter struct {
+ workDirs chan string
+}
+
+func (a *scratchAdapter) ID() string { return "scratch" }
+func (a *scratchAdapter) Name() string { return "Scratch" }
+
+func (a *scratchAdapter) Transcribe(ctx context.Context, req transcriber.Request, _ transcriber.ProgressFunc) (*transcriber.Result, error) {
+ if req.WorkDir == "" {
+ return nil, errors.New("WorkDir not set")
+ }
+ if err := os.MkdirAll(filepath.Join(req.WorkDir, "chunks"), 0o755); err != nil {
+ return nil, err
+ }
+ for _, name := range []string{"whispercpp_out.json", filepath.Join("chunks", "000.wav")} {
+ if err := os.WriteFile(filepath.Join(req.WorkDir, name), []byte("junk"), 0o644); err != nil {
+ return nil, err
+ }
+ }
+ a.workDirs <- req.WorkDir
+ return &transcriber.Result{
+ Transcription: &transcriber.Transcription{
+ Language: "en",
+ Text: "hello",
+ Segments: []transcriber.Segment{{ID: 0, Start: 0, End: 1, Text: "hello"}},
+ },
+ ModelUsed: "scratch",
+ }, nil
+}
+
+func TestPool_IntermediatesStayOutOfOutputPath(t *testing.T) {
+ tmp := t.TempDir()
+ outPath := filepath.Join(tmp, "out")
+
+ store := jobs.NewStore(0)
+ queue := jobs.NewQueue()
+ t.Cleanup(queue.Close)
+
+ adapter := &scratchAdapter{workDirs: make(chan string, 1)}
+ registry := transcriber.NewRegistry("scratch")
+ registry.Register(adapter)
+
+ // ScratchRoot under tmp keeps the test off the real temp dir.
+ pool := New(1, store, queue, registry, nil, func(j jobs.Job) any { return j },
+ Config{ScratchRoot: filepath.Join(tmp, "scratch")})
+ pool.Start(t.Context())
+
+ now := time.Now()
+ job := jobs.Job{
+ ID: "clean-output",
+ Path: filepath.Join(tmp, "input.wav"),
+ OutputPath: outPath,
+ Format: "json,txt",
+ Model: "scratch",
+ Status: jobs.StatusPending,
+ CreatedAt: now,
+ }
+ store.Create(job)
+ queue.Push(job.ID, 1, now)
+
+ var final jobs.Job
+ deadline := time.Now().Add(5 * time.Second)
+ for time.Now().Before(deadline) {
+ if j, ok := store.Get(job.ID); ok && isTerminal(j.Status) {
+ final = j
+ break
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ if final.Status != jobs.StatusCompleted {
+ t.Fatalf("status = %q (%s), want COMPLETED", final.Status, final.Error)
+ }
+
+ // output_path holds deliverables and nothing else.
+ entries, err := os.ReadDir(outPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, e := range entries {
+ names = append(names, e.Name())
+ }
+ want := map[string]bool{"input.wav.json": true, "input.wav.txt": true}
+ if len(names) != len(want) {
+ t.Errorf("output_path contains %v, want exactly %v", names, want)
+ }
+ for _, n := range names {
+ if !want[n] {
+ t.Errorf("intermediate %q leaked into output_path", n)
+ }
+ }
+
+ // And the scratch dir the adapter wrote into is reclaimed. Cleanup is a
+ // deferred call that runs *after* the status is published — marking the job
+ // complete deliberately isn't blocked on removing a potentially large
+ // directory — so poll rather than checking once.
+ var wd string
+ select {
+ case wd = <-adapter.workDirs:
+ default:
+ t.Fatal("adapter never reported a work dir")
+ }
+ deadline = time.Now().Add(2 * time.Second)
+ for {
+ if _, err := os.Stat(wd); os.IsNotExist(err) {
+ break
+ }
+ if time.Now().After(deadline) {
+ t.Fatalf("work dir %s still exists after job", wd)
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+}
+
func runJobAndWait(t *testing.T, defaultTimeout, jobTimeout time.Duration) jobs.Job {
t.Helper()
tmp := t.TempDir()
@@ -46,7 +165,7 @@ func runJobAndWait(t *testing.T, defaultTimeout, jobTimeout time.Duration) jobs.
registry := transcriber.NewRegistry("stub")
registry.Register(stub.New("stub", "Stub"))
- pool := New(1, store, queue, registry, nil, func(j jobs.Job) any { return j }, defaultTimeout)
+ pool := New(1, store, queue, registry, nil, func(j jobs.Job) any { return j }, Config{DefaultTimeout: defaultTimeout})
pool.Start(t.Context())
now := time.Now()
]