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)
12 KiB
record-asr
A self-contained Go HTTP transcription service built on 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) — 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 buildxproduces 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-onnxC API (v1.13.7) no longer exposes FSMN-VAD — itsVadModelConfigonly supportssilero_vadandten_vad(verified in the shippedc-api.hand 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).
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
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:
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.
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:
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):
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):
docker buildx build --platform linux/amd64,linux/arm64 \
-t record-asr:latest --push .
Models (~230 MB SenseVoice + silero VAD) live at
/modelsin 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
# 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
# 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. |
# 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
pendingtasks, transcribes them, and records the result. Tasks leftprocessingat crash time are reset topendingon next start (so they re-run — crash recovery). - Retry: on failure, the task is requeued with exponential backoff
(
5s, 10s, 20s, …, capped at60s), up toASR_MAX_RETRIESattempts; the worker continues to other tasks meanwhile. After exhausting retries the task isfailed. - Continue / retry (manual):
POST /api/tasks/{id}/skipabandons a failed task (terminalskipped);POST /api/tasks/{id}/retryrequeues 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