Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,17 @@ JOB_TIMEOUT=30m
# above the largest burst the pipeline will submit.
MAX_TERMINAL_JOBS=200
LOG_FORMAT=json

# ---- Omnilingual ASR (optional) ----
# Meta Omnilingual ASR 7B runs as a separate container. Enable with
# docker compose --profile omnilingual up -d
# and set OMNILINGUAL_URL so the transcriber registers the `omnilingual-7b`
# model. Leave it empty to keep the model hidden.
#
# VRAM: the LLM 7B model holds ~17 GiB on the same GPU as whisper (~3 GB per
# worker). On a 24 GB RTX 3090 set WORKERS=1 while this is on, or switch to
# OMNI_MODEL_CARD=omniASR_CTC_7B_v2 (~15 GiB, much faster, ignores language).
# OMNILINGUAL_URL=http://omnilingual:8000
# OMNILINGUAL_IMAGE=ghcr.io/bcc-code/omnilingual-asr:latest
# OMNI_MODEL_CARD=omniASR_LLM_7B_v2
# OMNI_BATCH_SIZE=4
84 changes: 84 additions & 0 deletions .github/workflows/omnilingual.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: omnilingual

# Builds the Omnilingual ASR sidecar image (omnilingual/) and pushes it to GHCR
# so hosts can `docker compose --profile omnilingual pull`. Only runs when the
# sidecar changes. Model weights are not baked in — they download into the
# omnilingual-cache volume on first start.

on:
push:
branches: [main]
paths:
- omnilingual/**
- .github/workflows/omnilingual.yml
pull_request:
branches: [main]
paths:
- omnilingual/**
- .github/workflows/omnilingual.yml
workflow_dispatch:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository_owner }}/omnilingual-asr

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
# Only the dependency-free region planner; the server needs a GPU + 7B model.
- run: uv run --no-project --with pytest pytest -q test_regions.py
working-directory: omnilingual

image:
runs-on: ubuntu-latest
needs: test
env:
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

- name: Free up disk space
# torch + CUDA wheels are several GB installed; ubuntu-latest has ~14 GB free.
run: |
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 /

- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
if: env.CAN_PUSH == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: omnilingual
platforms: linux/amd64
push: ${{ github.event_name != 'pull_request' && env.CAN_PUSH == 'true' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,11 @@ prompt.txt

# Compiled binary from `make build`
/transcriber

# Python (omnilingual sidecar)
.venv
__pycache__
.pytest_cache

# Local SQLite database (+ WAL/SHM sidecars)
transcriber.db*
31 changes: 31 additions & 0 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ inspecting a bad transcription.
| Mount | Purpose |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `models:/var/cache/transcriber` | ggml whisper.cpp models. Survives restarts — first job downloads ~3 GB. |
| `data:/var/lib/transcriber` | SQLite database (`transcriber.db`) — per-model prompts edited on the UI's Models page. |
| `${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. |

Expand All @@ -251,6 +252,36 @@ 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.

## Omnilingual ASR (optional)

`omnilingual-7b` (Meta Omnilingual ASR 7B) runs in its own container, built
from `omnilingual/`. It is a Python/PyTorch service and is off by default.

```sh
# .env
OMNILINGUAL_URL=http://omnilingual:8000
WORKERS=1 # see VRAM below

docker compose --profile omnilingual up -d
docker compose logs -f omnilingual # wait for "model ready"
```

- **VRAM.** The LLM 7B checkpoint holds ~17 GiB, on the same GPU as
whisper (~3 GB per worker). On a 24 GB RTX 3090, `WORKERS=2` plus the
sidecar overshoots and jobs fail with CUDA OOM. Use `WORKERS=1`, or
`OMNI_MODEL_CARD=omniASR_CTC_7B_v2` (~15 GiB, much faster, ignores
language).
- **Driver.** The image uses PyPI torch 2.8.0, which bundles a CUDA 12.8
runtime. That needs a newer host driver than the whisper image's CUDA 12.6
(>= 560.28.03). If the sidecar fails at CUDA init, check `nvidia-smi`
against NVIDIA's CUDA 12.8 driver table.
- **First start** downloads the multi-GB checkpoint into the
`omnilingual-cache` volume. The healthcheck allows 30 minutes for it.
Jobs submitted earlier fail fast with `503 model not loaded`.
- **Output.** There are no word timestamps, so subtitles get one cue per
speech region (at most 30 s). There is no prompt support, and the text is
likely lowercase with little punctuation. See `omnilingual/README.md`.

## Pre-seeding models

Worth doing even with internet access: the download currently happens
Expand Down
7 changes: 4 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ ARG ARCH
# 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 go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
# internal/web/dist is gitignored, so it is absent (or stale) in the build
Expand All @@ -68,7 +69,7 @@ FROM --platform=linux/${ARCH} ${BASE_IMAGE}
# Model cache: $XDG_CACHE_HOME/transcriber/hf/<repo>/<file>
ENV XDG_CACHE_HOME=/var/cache \
WHISPER_CPP_BIN=/usr/local/bin/whisper-cli
RUN mkdir -p /var/cache/transcriber/hf
RUN mkdir -p /var/cache/transcriber/hf /var/lib/transcriber

COPY --from=go-build /out/transcriber /usr/local/bin/transcriber

Expand All @@ -79,4 +80,4 @@ EXPOSE 8888
# 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"]
CMD ["-port=8888", "-workers=2", "-default-model=whisper-cpp-large-v3", "-db=/var/lib/transcriber/transcriber.db", "-log-format=json"]
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: help dev dev-api dev-frontend frontend build run test clean
.PHONY: help dev dev-api dev-frontend frontend build run test sqlc clean

help:
@echo 'Targets:'
Expand All @@ -9,6 +9,7 @@ help:
@echo ' build frontend + go build → ./transcriber (single binary)'
@echo ' run build then run ./transcriber'
@echo ' test go test -race ./...'
@echo ' sqlc regenerate internal/db/dbq from internal/db/{migrations,queries}'
@echo ' clean remove build artifacts'

# Run both servers in parallel. `set -m` puts each background recipe in its
Expand Down Expand Up @@ -47,6 +48,9 @@ run: build
test:
go test -race ./...

sqlc:
sqlc generate

clean:
rm -f transcriber
rm -rf frontend/.nuxt frontend/.output
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Go code. Server settings come from flags; per-machine paths from env vars.
| `-callback-workers` | `2` | webhook delivery goroutines |
| `-default-model` | `stub` | adapter ID used when the request omits `model` |
| `-default-prompt-file` | `prompt.txt` | file whose contents are used as the prompt when the request omits one; missing file = no default. A non-empty `prompt` in the request fully overrides it |
| `-db` | `transcriber.db` | SQLite file, created and migrated (embedded goose migrations) on startup. Holds per-model prompts set on the UI's Models page |
| `-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` |
Expand All @@ -70,6 +71,7 @@ Go code. Server settings come from flags; per-machine paths from env vars.
| `NB_WHISPER_MODEL` | _(unset → fetched from HF)_ | local path override for the `nb-whisper-large` adapter. Unset = auto-download `NbAiLab/nb-whisper-large/ggml-model.bin` via `internal/hfcache`. |
| `WHISPER_VAD_MODEL` | _(unset → fetched from HF)_ | local path override for the Silero VAD model used to skip music/silence. Unset = auto-download `ggml-org/whisper-vad/ggml-silero-v5.1.2.bin`. |
| `XDG_CACHE_HOME` | `~/.cache` | base for the HF cache (`<root>/transcriber/hf/<repo>/<file>`). |
| `OMNILINGUAL_URL` | _(unset)_ | base URL of the Omnilingual ASR sidecar (`omnilingual/`). When set, the `omnilingual-7b` model is registered; unset = model hidden. |

## API

Expand Down Expand Up @@ -133,10 +135,18 @@ serialize to.

- `GET /transcription/jobs` — list all jobs
- `DELETE /transcription/job/{id}` — cancel a queued or running job
- `GET /models` — list registered adapters
- `GET /models` — list registered adapters (with aliases and per-model prompt)
- `PUT /models/{id}/prompt` — set/clear a model's default prompt. Precedence: request `prompt` > model prompt > `-default-prompt-file`
- `GET /config` — server-side defaults (currently `default_prompt`)
- `GET /healthz`, `GET /readyz`

## Database

SQLite (pure-Go `modernc.org/sqlite`, so `CGO_ENABLED=0` still works).
Migrations live in `internal/db/migrations/` and are embedded and applied by
goose on startup. Queries live in `internal/db/queries/`; after editing either,
run `make sqlc` to regenerate `internal/db/dbq/`. Jobs are still in-memory only.

## Adding a new backend

1. Implement `transcriber.Transcriber` in `internal/transcriber/<name>/`.
Expand Down
13 changes: 12 additions & 1 deletion cmd/transcriber/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ import (

"transcriber/internal/api"
"transcriber/internal/callback"
"transcriber/internal/db"
"transcriber/internal/jobs"
"transcriber/internal/modelprompts"
"transcriber/internal/transcriber"
"transcriber/internal/web"
"transcriber/internal/worker"
Expand All @@ -27,6 +29,7 @@ func main() {
defaultModel := flag.String("default-model", "stub", "model adapter ID to use when the request omits `model`")
defaultLanguage := flag.String("default-language", "", "ISO 639-1 language hint applied when the request omits `language` (empty or `auto` = let the model detect)")
defaultPromptFile := flag.String("default-prompt-file", "prompt.txt", "path to a file whose contents are used as the prompt when the request omits one (missing file = no default prompt)")
dbPath := flag.String("db", "transcriber.db", "SQLite database file (created and migrated on startup); holds per-model prompts edited from the UI")
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)")
Expand Down Expand Up @@ -63,6 +66,14 @@ func main() {
}
}

conn, err := db.Open(context.Background(), *dbPath)
if err != nil {
slog.Error("opening database", "path", *dbPath, "err", err)
os.Exit(1)
}
defer conn.Close()
modelPrompts := modelprompts.New(conn)

registry := buildRegistry(*defaultModel, *whisperNoGPU)
if _, ok := registry.Default(); !ok {
slog.Error("default model not registered", "id", *defaultModel)
Expand Down Expand Up @@ -96,7 +107,7 @@ func main() {
})
pool.Start(ctx)

srv := api.NewServer(store, queue, registry, defaultPrompt, normalizedDefaultLang)
srv := api.NewServer(store, queue, registry, modelPrompts, defaultPrompt, normalizedDefaultLang)
addr := fmt.Sprintf(":%d", *port)
httpSrv := &http.Server{
Addr: addr,
Expand Down
15 changes: 15 additions & 0 deletions cmd/transcriber/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"transcriber/internal/hfcache"
"transcriber/internal/transcriber"
"transcriber/internal/transcriber/chunked"
"transcriber/internal/transcriber/omnilingual"
"transcriber/internal/transcriber/stub"
"transcriber/internal/transcriber/whispercpp"
)
Expand Down Expand Up @@ -59,6 +60,20 @@ func buildRegistry(defaultID string, whisperNoGPU bool) *transcriber.Registry {
chunked.Config{},
))

// Meta Omnilingual ASR runs as a Python sidecar (omnilingual/). Only
// registered when its URL is configured, so /models never advertises a
// model whose backend isn't running.
if url := os.Getenv("OMNILINGUAL_URL"); url != "" {
r.Register(chunked.New(
omnilingual.New(omnilingual.Config{
ID: "omnilingual-7b",
DisplayName: "Meta Omnilingual ASR 7B",
BaseURL: url,
}),
chunked.Config{},
))
}

// Sub-large sizes upgrade to large — matches the old runner's coercion behavior.
r.Alias("openai/whisper-large-v3", "whisper-cpp-large-v3")
r.Alias("openai/whisper-large-v2", "whisper-cpp-large-v3")
Expand Down
41 changes: 41 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,18 @@ services:
- "-job-timeout=${JOB_TIMEOUT:-30m}"
- "-max-terminal-jobs=${MAX_TERMINAL_JOBS:-200}"
- "-log-format=${LOG_FORMAT:-json}"
- "-db=/var/lib/transcriber/transcriber.db"

environment:
# Set to http://omnilingual:8000 when the `omnilingual` profile is on.
# Empty = the omnilingual-7b model is not registered.
OMNILINGUAL_URL: ${OMNILINGUAL_URL:-}

volumes:
# ggml model cache — first job downloads ~3 GB, this keeps it.
- models:/var/cache/transcriber
# SQLite database (per-model prompts edited from the UI).
- data:/var/lib/transcriber
# 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
Expand All @@ -65,5 +73,38 @@ services:
count: all
capabilities: [gpu]

# Meta Omnilingual ASR sidecar (omnilingual/). Opt-in:
# docker compose --profile omnilingual up -d
# and set OMNILINGUAL_URL=http://omnilingual:8000 in .env. It holds ~17 GiB
# of VRAM on the same GPU as whisper. See DEPLOY.md before enabling it.
omnilingual:
profiles: ["omnilingual"]
image: ${OMNILINGUAL_IMAGE:-ghcr.io/bcc-code/omnilingual-asr:latest}
build:
context: ./omnilingual
restart: unless-stopped
environment:
OMNI_MODEL_CARD: ${OMNI_MODEL_CARD:-omniASR_LLM_7B_v2}
OMNI_BATCH_SIZE: ${OMNI_BATCH_SIZE:-4}
volumes:
# fairseq2 checkpoint cache — the 7B model is a multi-GB first download.
- omnilingual-cache:/cache
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/healthz"]
interval: 30s
timeout: 5s
# First start downloads and loads the 7B checkpoint.
start_period: 30m
retries: 3
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]

volumes:
models:
data:
omnilingual-cache:
Loading
Loading