diff --git a/.env.example b/.env.example index 1d650dd..1f14377 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/omnilingual.yml b/.github/workflows/omnilingual.yml new file mode 100644 index 0000000..8ff8047 --- /dev/null +++ b/.github/workflows/omnilingual.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index d04e107..52a6ad6 100644 --- a/.gitignore +++ b/.gitignore @@ -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* diff --git a/DEPLOY.md b/DEPLOY.md index 4855dbb..8ec8603 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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. | @@ -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 diff --git a/Dockerfile b/Dockerfile index 5028ea1..6cc4f2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -68,7 +69,7 @@ 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 +RUN mkdir -p /var/cache/transcriber/hf /var/lib/transcriber COPY --from=go-build /out/transcriber /usr/local/bin/transcriber @@ -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"] diff --git a/Makefile b/Makefile index 0edc551..046fff3 100644 --- a/Makefile +++ b/Makefile @@ -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:' @@ -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 @@ -47,6 +48,9 @@ run: build test: go test -race ./... +sqlc: + sqlc generate + clean: rm -f transcriber rm -rf frontend/.nuxt frontend/.output diff --git a/README.md b/README.md index c646dda..dae961b 100644 --- a/README.md +++ b/README.md @@ -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` | @@ -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 (`/transcriber/hf//`). | +| `OMNILINGUAL_URL` | _(unset)_ | base URL of the Omnilingual ASR sidecar (`omnilingual/`). When set, the `omnilingual-7b` model is registered; unset = model hidden. | ## API @@ -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//`. diff --git a/cmd/transcriber/main.go b/cmd/transcriber/main.go index ad8a305..92da0ae 100644 --- a/cmd/transcriber/main.go +++ b/cmd/transcriber/main.go @@ -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" @@ -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)") @@ -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) @@ -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, diff --git a/cmd/transcriber/models.go b/cmd/transcriber/models.go index 8b44d4e..c152089 100644 --- a/cmd/transcriber/models.go +++ b/cmd/transcriber/models.go @@ -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" ) @@ -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") diff --git a/docker-compose.yml b/docker-compose.yml index e8f7a17..1c6ffd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 @@ -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: diff --git a/frontend/app/app.vue b/frontend/app/app.vue index 6b1a504..781214a 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,5 +1,18 @@