Files
root e5a2dc0a8d add chunked upload to bypass 100MB proxy body limit
Resumable /api/uploads flow (init -> append -> complete -> abort) so a
large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default
50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would
reject. The web console auto-routes files larger than one slice through the
chunked path with cross-slice progress and transient-error retry; smaller
files keep the single-shot upload.

- store: in-memory upload sessions + .part reassembly (offset writes,
  out-of-order/idempotent), reuse upload id as task id, orphan sweep on
  start, idle-session GC
- fix limitedReader rejecting bodies exactly at the cap (broke full chunks)
- fix drag-drop double-firing when a drop lands on the dropzone (its drop
  handler now stopPropagation so the window listener doesn't re-upload)
- tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
2026-09-10 15:12:15 +08:00

263 lines
12 KiB
Markdown

# record-asr
A self-contained **Go** HTTP transcription service built on
[sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx) Go bindings, running
**SenseVoiceSmall (int8)** for recognition and a VAD for segmentation.
- Runs on **linux/amd64** and **linux/arm64**.
- Models download **at build time into the Docker image** (see [Docker](#docker))
— or, for the standalone binary, **on first run**.
- All runtime knobs are **environment variables**; an **access password** can
be set with `ASR_PASSWORD`.
- Uploaded tasks are **persisted to disk** (`data/tasks.json` +
`data/uploads/`) so they survive restarts, and can be **deleted**.
- On error a task **auto-retries with exponential backoff**, up to
`ASR_MAX_RETRIES`; the worker then **continues** to the next task. You can
also manually **retry** or **skip (continue)** any task via the API.
- Ships a `Dockerfile` + `docker-compose.yml`; `docker buildx` produces a
multi-arch image with no cross-compiler needed.
- Bundles an **embedded web console** (`GET /`) — drag-drop upload, live task
queue, transcript view with per-segment timestamps, and retry/skip/delete — no
separate frontend to deploy.
> **Note on the VAD.** The original request asked for **FSMN-VAD**. The
> current `sherpa-onnx` C API (v1.13.7) **no longer exposes FSMN-VAD** — its
> `VadModelConfig` only supports `silero_vad` and `ten_vad` (verified in the
> shipped `c-api.h` and across older tags). After confirming this with you,
> this project uses **silero-vad** as the maintained replacement. The rest of
> the spec (SenseVoiceSmall int8, Go, both arches, password, task save/delete,
> retry/continue) is implemented exactly as requested.
## Build
Requires Go ≥ 1.22 and a C compiler (cgo is used by the sherpa-onnx bindings).
```sh
make build # portable native build -> dist/<arch>/{record-asr, lib/*.so}
```
The build bakes an `$ORIGIN/lib` rpath into the binary and copies the
sherpa-onnx / onnxruntime `.so` files next to it, so `dist/<arch>/` is a
**self-contained bundle** — copy the folder anywhere and run it.
### Both architectures
```sh
make build-amd64 # linux/amd64 (native toolchain)
make build-arm64 # linux/arm64 (cross-compile)
make dist # build both + tarballs in dist/
```
`build-arm64` cross-compiles from amd64 and needs a C cross-compiler:
```sh
sudo apt-get install gcc-aarch64-linux-gnu # Debian/Ubuntu
```
The arm64 `.so` files are already present in the sherpa-onnx-go-linux module,
so no extra download is needed; only the cross-compiler is required. (This
environment has no `aarch64-linux-gnu-gcc`/qemu, so only the amd64 build was
runtime-tested here; the arm64 path uses identical build tags selecting the
arm64 `.so`.)
## Docker
The multi-stage `Dockerfile` builds the binary, downloads the models **at build
time**, and bakes both into the image — so a container starts instantly and
needs no network at runtime.
```sh
docker build -t record-asr .
# Restricted-network build args (optional):
# GOPROXY Go module proxy (default proxy.golang.org)
# APT_MIRROR swap deb.debian.org for a faster mirror (e.g. mirrors.tuna.tsinghua.edu.cn)
docker build --build-arg GOPROXY=https://goproxy.cn,direct \
--build-arg APT_MIRROR=mirrors.tuna.tsinghua.edu.cn \
-t record-asr .
```
The image bundles **ffmpeg** (so `.m4a`/`.aac`/`.opus`/… work out of the box)
and the models (downloaded at build time).
Run a single container:
```sh
docker run -d -p 8080:8080 -e ASR_PASSWORD=changeme \
-v record-asr-data:/app/data record-asr
```
Or with `docker compose` (sets sensible defaults; only the password is
required):
```sh
ASR_PASSWORD=changeme docker compose up -d
```
The image is **multi-arch**. Publish both platforms at once with `buildx` —
each platform builds natively, so **no cross-compiler is required** (unlike
`make build-arm64`):
```sh
docker buildx build --platform linux/amd64,linux/arm64 \
-t record-asr:latest --push .
```
> Models (~230 MB SenseVoice + silero VAD) live at `/models` in the image.
> Only `/app/data` (tasks + uploads) is a volume; models are not on the
> volume, so they're not re-downloaded on restart. To upgrade a model, rebuild.
## Run
```sh
# Set a password (recommended); pick a data dir; launch.
ASR_PASSWORD=changeme ASR_DATA_DIR=./data ./dist/$(go env GOARCH)/record-asr
```
On first start it downloads the models into `<ASR_MODEL_DIR>/` (~230 MB
SenseVoice + ~2 MB silero VAD); subsequent starts load them from disk.
### Environment variables
| Variable | Default | Description |
|---|---|---|
| `ASR_PASSWORD` | _empty_ | Shared secret required on `/api/*`. If unset, the API is open and a warning is logged. |
| `ASR_ADDR` | `:8080` | Listen address. |
| `ASR_DATA_DIR` | `./data` | Root for `tasks.json` and `uploads/`. |
| `ASR_MODEL_DIR` | `<DataDir>/models` | Where models live / are downloaded. |
| `ASR_NUM_THREADS` | `min(4, NumCPU)` | ONNX compute threads. |
| `ASR_LANGUAGE` | `auto` | SenseVoice hint: `auto`/`zh`/`en`/`ja`/`ko`/`yue`. |
| `ASR_USE_ITN` | `1` | Inverse text normalization (numbers, punctuation). |
| `ASR_MAX_RETRIES` | `3` | Auto-retry attempts before a task is marked failed. |
| `ASR_MAX_UPLOAD_MB` | `512` | Per-upload size cap. |
| `ASR_CHUNK_SIZE_MB` | `50` | Slice size for resumable chunked uploads; kept under CDN body limits (e.g. Cloudflare's 100MB). |
| `ASR_WORKERS` | `1` | Concurrent transcription workers. |
| `ASR_VAD_THRESHOLD` | `0.5` | Silero speech probability threshold. |
| `ASR_VAD_MIN_SPEECH` | `0.5` | Min speech segment (s). |
| `ASR_VAD_MIN_SILENCE` | `0.5` | Min silence to split (s). |
| `ASR_VAD_MAX_SPEECH` | `20` | Max segment length (s); keeps SenseVoice within its window. |
| `ASR_VAD_WINDOW` | `512` | VAD window (samples @16k). |
| `ASR_VAD_BUFFER_SECS` | `60` | VAD internal buffer (s). |
| `ASR_DOWNLOAD_TIMEOUT` | `1800` | Model download timeout (s). |
| `ASR_FFMPEG` | `ffmpeg` | ffmpeg binary used to decode m4a/aac/opus/wma (beep handles wav/mp3/flac/ogg). Only needed for those formats. |
| `ASR_DECODE_TIMEOUT` | `1800` | Per-file decode timeout (beep + ffmpeg fallback) (s). |
## Web UI
Open `http://<host>:<port>/` in a browser. The page itself is public; enter the
`ASR_PASSWORD` value in the top-bar field (stored in `localStorage`, sent as
`X-Api-Key` on every API call). The console is a single embedded SPA (no
separate frontend to deploy) and covers the whole API:
- **Upload** — drag & drop or click; audio (`.wav/.mp3/.flac/.ogg/.m4a/.aac/.opus/…`) **and video** (`.mp4/.mkv/.mov/.avi/.ts/…` — the audio track is auto-extracted via ffmpeg).
- **Task list** — auto-refreshing; status dots (pending/processing/completed/
failed/skipped), attempts, timestamps.
- **Detail** — transcript (full text + per-segment timestamps), detected
language/emotion, duration, and Retry / Skip / Delete actions.
- **Copy** — one-click copy for the full transcript, each segment's text, and
each segment's timestamp (时间轴).
- **Theme** — light (default) / dark toggle in the top bar; choice persisted.
- A live health dot in the top bar reflects model readiness.
## HTTP API
Auth is required on `/api/*`: send the password as `X-Api-Key`, as
`Authorization: Bearer <password>`, or as `?token=<password>`. `/`, `/healthz`
are public.
| Method | Path | Description |
|---|---|---|
| `GET` | `/` | Web console (embedded SPA). |
| `GET` | `/healthz` | Liveness + model readiness. |
| `POST` | `/api/tasks` | Upload audio (`multipart: file=`). → `202` + task. |
| `GET` | `/api/tasks` | List tasks (newest-first). |
| `GET` | `/api/tasks/{id}` | Task detail (includes `result` when done). |
| `DELETE` | `/api/tasks/{id}` | Delete task + its audio. → `204`. |
| `POST` | `/api/tasks/{id}/retry` | Requeue a failed/skipped task. → `202`. |
| `POST` | `/api/tasks/{id}/skip` | Abandon a failed task (queue moves on). → `202`. |
| `GET` | `/api/tasks/{id}/transcript` | Just the transcript (when `completed`). |
Supported audio: `.wav`, `.mp3`, `.flac`, `.ogg` (pure-Go, no external tool)
plus `.m4a`, `.aac`, `.opus`, `.wma`, `.aiff`, … and **video** `.mp4`, `.mkv`,
`.mov`, `.avi`, `.ts`, … — all decoded via **ffmpeg** (which drops the video
stream and extracts the audio track). The Docker image ships ffmpeg; the
standalone binary needs ffmpeg on `PATH` (or `ASR_FFMPEG`).
### Examples
```sh
# Upload
curl -H "X-Api-Key: changeme" -F "[email protected]" http://localhost:8080/api/tasks
# Poll
curl -H "X-Api-Key: changeme" http://localhost:8080/api/tasks/<id>
# Transcript
curl -H "X-Api-Key: changeme" http://localhost:8080/api/tasks/<id>/transcript
```
### Chunked uploads (bypassing a 100MB proxy limit)
When the service sits behind a size-capping proxy — Cloudflare's free plan
rejects request bodies over **100MB** — a single `POST /api/tasks` upload of a
large recording never reaches the origin. The resumable chunked-upload API
splits the file into slices no bigger than `ASR_CHUNK_SIZE_MB` (default **50MB**,
well under the cap) and reassembles them server-side into one task:
| Method | Path | Description |
|---|---|---|
| `POST` | `/api/uploads` | Open a session: JSON `{filename,size}` → `{upload_id, chunk_size, total_chunks}`. |
| `POST` | `/api/uploads/{id}?index=N` | Send slice `N` (0-based, raw body ≤ chunk_size). Order is free; resending is idempotent. |
| `POST` | `/api/uploads/{id}/complete` | Once all slices arrive: merge → enqueue → `202` + task (same shape as `/api/tasks`). |
| `DELETE` | `/api/uploads/{id}` | Abort the session and drop the partial file → `204`. |
```sh
# Open a session for a 180MB file (4 slices of 50MB)
UID=$(curl -sH "X-Api-Key: $PW" -H "Content-Type: application/json" \
-d '{"filename":"big.mp4","size":188743680}' \
http://localhost:8080/api/uploads | jq -r .upload_id)
# Send each slice (split with dd/split beforehand)
for i in 0 1 2 3; do
curl -sH "X-Api-Key: $PW" --data-binary @"slice_$i.bin" \
"http://localhost:8080/api/uploads/$UID?index=$i" >/dev/null
done
# Merge → enqueue (returns the task, just like /api/tasks)
curl -H "X-Api-Key: $PW" -X POST http://localhost:8080/api/uploads/$UID/complete
```
The web console handles this automatically: files larger than one chunk go
through the chunked path (with a single progress bar across all slices);
smaller files keep the simple single-request upload. Sessions live in memory,
so an interrupted upload is retried from scratch on reload — and any orphaned
partial files are swept on the next start.
## Task lifecycle & error handling
States: `pending` → `processing` → `completed` | `failed` | `skipped`.
- A background worker claims `pending` tasks, transcribes them, and records the
result. Tasks left `processing` at crash time are reset to `pending` on next
start (so they re-run — crash recovery).
- **Retry:** on failure, the task is requeued with exponential backoff
(`5s, 10s, 20s, …`, capped at `60s`), up to `ASR_MAX_RETRIES` attempts; the
worker **continues** to other tasks meanwhile. After exhausting retries the
task is `failed`.
- **Continue / retry (manual):** `POST /api/tasks/{id}/skip` abandons a failed
task (terminal `skipped`); `POST /api/tasks/{id}/retry` requeues it
(`pending`, attempts reset).
## Project layout
```
main.go entrypoint + signal handling
internal/
config/ env-based config
models/ auto-download + load recognizer/VAD
audio/ wav/mp3/flac/ogg decode -> 16k mono float32
transcribe/ VAD segmentation + SenseVoice recognition
store/ task persistence (tasks.json + uploads/)
server/ HTTP API, auth middleware, worker loop
Makefile portable builds for amd64 + arm64
```