add full source: transcribe pipeline, HTTP API, OpenAI endpoint, web UI, Docker

This commit is contained in:
2026-09-09 16:04:10 +08:00
parent 757861f1dd
commit 7010da8855
19 changed files with 2918 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Build context exclusions — keep the image lean and avoid copying local state.
.git
.gitignore
data/
dist/
scripts/
*.log
*.md
!README.md
Dockerfile
.dockerignore
docker-compose.yml
Makefile
.mimocode/
+17
View File
@@ -0,0 +1,17 @@
# build / dist artifacts
/dist/
/bin/
# runtime data (tasks, uploads, downloaded models)
/data/
# test audio samples (re-fetchable from sherpa-onnx releases)
/test_wavs/
# logs
*.log
# editor / os
.DS_Store
.idea/
.vscode/
+101
View File
@@ -0,0 +1,101 @@
# Multi-stage, multi-arch build for record-asr.
#
# docker build -t record-asr .
# docker buildx build --platform linux/amd64,linux/arm64 -t record-asr:latest --push .
#
# buildx builds each platform NATIVELY (the builder image matches the target
# arch), so cgo links the correct prebuilt .so for that arch and NO cross
# compiler is needed — unlike the bare `make build-arm64` path.
# ---- builder ----
FROM golang:1.22-bookworm AS builder
WORKDIR /src
# Allow overriding the Go module proxy, e.g. for restricted networks:
# docker build --build-arg GOPROXY=https://goproxy.cn,direct -t record-asr .
ARG GOPROXY=https://proxy.golang.org,direct
ENV GOPROXY=${GOPROXY}
# Cache module downloads (incl. the large sherpa-onnx-go .so) separately from
# source so code changes don't re-download deps.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build, then copy the prebuilt .so that match THIS builder's arch into /out/lib.
# The glob avoids hard-coding the sherpa-onnx-go-linux version.
RUN set -eux; \
CGO_ENABLED=1 go build -trimpath -o /out/record-asr .; \
arch="$(go env GOARCH)"; \
case "$arch" in \
amd64) libd=x86_64-unknown-linux-gnu;; \
arm64) libd=aarch64-unknown-linux-gnu;; \
*) echo "unsupported arch: $arch" >&2; exit 1;; \
esac; \
mkdir -p /out/lib; \
cp "$(go env GOMODCACHE)/github.com/k2-fsa/sherpa-onnx-go-linux@"*/lib/"$libd"/*.so /out/lib/
# ---- models: download SenseVoice int8 + silero VAD at BUILD time ----
# These are arch-independent ONNX files, so the same download serves every
# platform. Baking them into the image means the container starts instantly
# and needs no network at runtime. (One-time ~230MB cost per build.)
FROM debian:bookworm-slim AS models
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl bzip2 \
&& rm -rf /var/lib/apt/lists/*
# Extract ONLY model.int8.onnx + tokens.txt from the release tarball (skip the
# test wavs / float model) to keep the layer lean. --strip-components=1 flattens
# the top archive dir so files land directly in /models/sensevoice/.
RUN mkdir -p /models/sensevoice \
&& curl -fsSL -o /tmp/sv.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2" \
&& tar -xjf /tmp/sv.tar.bz2 -C /models/sensevoice \
--strip-components=1 --wildcards --no-anchored 'model.int8.onnx' 'tokens.txt' \
&& rm -f /tmp/sv.tar.bz2 \
&& curl -fsSL -o /models/silero_vad.onnx \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx" \
&& test -s /models/sensevoice/model.int8.onnx \
&& test -s /models/sensevoice/tokens.txt \
&& test -s /models/silero_vad.onnx \
&& ls -lh /models/sensevoice /models/silero_vad.onnx
# ---- runtime (glibc; sherpa-onnx .so are glibc-built, so NOT alpine/musl) ----
FROM debian:bookworm-slim
# APT_MIRROR: optionally swap deb.debian.org for a faster regional mirror, e.g.
# docker build --build-arg APT_MIRROR=mirrors.tuna.tsinghua.edu.cn -t record-asr .
# Defaults to the upstream deb.debian.org (no swap). ffmpeg (~250MB of codec
# libs) decodes m4a/aac/opus/wma — beep is pure-Go for wav/mp3/flac/ogg only.
ARG APT_MIRROR=
RUN set -eux; \
if [ -n "$APT_MIRROR" ]; then \
f=/etc/apt/sources.list.d/debian.sources; \
if [ -f "$f" ]; then sed -i "s|deb.debian.org|$APT_MIRROR|g; s|security.debian.org|$APT_MIRROR|g" "$f"; \
else sed -i "s|deb.debian.org|$APT_MIRROR|g; s|security.debian.org|$APT_MIRROR|g" /etc/apt/sources.list; fi; \
fi; \
apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl ffmpeg \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/record-asr /app/record-asr
COPY --from=builder /out/lib /app/lib
COPY --from=models /models /models
# Defaults; override via -e / compose. Models are baked in at /models, so
# startup skips the download entirely. Only /app/data (tasks + uploads) is a
# volume; it never holds models.
ENV ASR_ADDR=:8080 \
ASR_DATA_DIR=/app/data \
ASR_MODEL_DIR=/models \
ASR_DOWNLOAD_TIMEOUT=1800 \
LD_LIBRARY_PATH=/app/lib
VOLUME /app/data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/healthz || exit 1
ENTRYPOINT ["/app/record-asr"]
+82
View File
@@ -0,0 +1,82 @@
# Makefile for record-asr — a Go HTTP transcription service built on
# sherpa-onnx (SenseVoiceSmall int8 + silero VAD).
#
# Targets:
# make build a *portable* native binary into dist/<arch>/,
# with the sherpa/onnxruntime .so files copied next to it
# and an $ORIGIN/lib rpath baked in so it runs anywhere.
# make build-amd64 same, for linux/amd64 (native here).
# make build-arm64 cross-compile for linux/arm64
# (needs: apt-get install gcc-aarch64-linux-gnu).
# make dist build both arches and produce tarballs in dist/.
# make run build native, then run with ./data and default env.
# make tidy/clean/check.
#
# All runtime behavior is controlled by ASR_* environment variables, not build
# flags — see README.md.
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
MODCACHE := $(shell go env GOMODCACHE)
SHERPA_LINUX := $(MODCACHE)/github.com/k2-fsa/[email protected]
# RPATH baked into every binary so it finds the .so files shipped in ./lib
# next to it, regardless of where the bundle is unpacked. $$ORIGIN -> $ORIGIN
# (a dynamic-loader token = directory of the executable).
ORIGIN_RPATH := '-Wl,-rpath,$$ORIGIN/lib'
.PHONY: all build build-amd64 build-arm64 dist run tidy clean check
all: build
# Portable native build for whatever arch `go env GOARCH` reports.
build:
@arch=$$(go env GOARCH); \
case $$arch in \
amd64) lib=x86_64-unknown-linux-gnu;; \
arm64) lib=aarch64-unknown-linux-gnu;; \
arm) lib=arm-unknown-linux-gnueabihf;; \
*) echo "unsupported arch: $$arch"; exit 1;; \
esac; \
dist=dist/$$arch; mkdir -p $$dist/lib; \
echo ">> building $$dist/record-asr ($$arch)"; \
CGO_ENABLED=1 CGO_LDFLAGS=$(ORIGIN_RPATH) go build -trimpath -o $$dist/record-asr .; \
cp $(SHERPA_LINUX)/lib/$$lib/*.so $$dist/lib/; \
echo ">> built $$dist/record-asr (run with: ./$$dist/record-asr)"
# Explicit linux/amd64 (native toolchain).
build-amd64:
@dist=dist/amd64; mkdir -p $$dist/lib; \
echo ">> building $$dist/record-asr (amd64)"; \
CGO_ENABLED=1 CGO_LDFLAGS=$(ORIGIN_RPATH) go build -trimpath -o $$dist/record-asr .; \
cp $(SHERPA_LINUX)/lib/x86_64-unknown-linux-gnu/*.so $$dist/lib/; \
echo ">> built $$dist/record-asr"
# Cross-compile to linux/arm64. Requires a C cross-compiler:
# sudo apt-get install gcc-aarch64-linux-gnu
build-arm64:
@dist=dist/arm64; mkdir -p $$dist/lib; \
echo ">> building $$dist/record-asr (arm64)"; \
CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOOS=linux GOARCH=arm64 \
CGO_LDFLAGS=$(ORIGIN_RPATH) go build -trimpath -o $$dist/record-asr .; \
cp $(SHERPA_LINUX)/lib/aarch64-unknown-linux-gnu/*.so $$dist/lib/; \
echo ">> built $$dist/record-asr (run on aarch64 with qemu or on-device)"
# Both arches + tarballs.
dist: build-amd64 build-arm64
@cd dist && \
tar czf record-asr-$(VERSION)-linux-amd64.tar.gz amd64 && \
tar czf record-asr-$(VERSION)-linux-arm64.tar.gz arm64 && \
ls -lh *.tar.gz
# Build native then run from the build dir with a local ./data.
run: build
@ASR_DATA_DIR=$$PWD/data ./dist/$$(go env GOARCH)/record-asr
tidy:
go mod tidy
clean:
rm -rf dist
check:
go vet ./...
+34
View File
@@ -0,0 +1,34 @@
# docker compose up -d
#
# Set the password (and any other ASR_* knob) in .env or your shell, e.g.:
# ASR_PASSWORD=changeme docker compose up -d
#
# Models + task state persist in the named volume `asr-data` across restarts.
services:
asr:
build: .
image: record-asr:latest
container_name: record-asr
ports:
- "8080:8080"
environment:
ASR_PASSWORD: ${ASR_PASSWORD:?ASR_PASSWORD must be set}
ASR_LANGUAGE: ${ASR_LANGUAGE:-auto}
ASR_USE_ITN: ${ASR_USE_ITN:-1}
ASR_MAX_RETRIES: ${ASR_MAX_RETRIES:-3}
ASR_NUM_THREADS: ${ASR_NUM_THREADS:-2}
ASR_WORKERS: ${ASR_WORKERS:-1}
ASR_MAX_UPLOAD_MB: ${ASR_MAX_UPLOAD_MB:-512}
volumes:
- asr-data:/app/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
volumes:
asr-data:
+21
View File
@@ -0,0 +1,21 @@
module git.misaka.ren/m1saka/record-asr
go 1.22
require (
github.com/gopxl/beep/v2 v2.1.1
github.com/k2-fsa/sherpa-onnx-go v1.13.7
)
require (
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/icza/bitio v1.1.0 // indirect
github.com/jfreymuth/oggvorbis v1.0.5 // indirect
github.com/jfreymuth/vorbis v1.0.2 // indirect
github.com/k2-fsa/sherpa-onnx-go-linux v1.13.7 // indirect
github.com/k2-fsa/sherpa-onnx-go-macos v1.13.7 // indirect
github.com/k2-fsa/sherpa-onnx-go-windows v1.13.7 // indirect
github.com/mewkiz/flac v1.0.12 // indirect
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 // indirect
github.com/pkg/errors v0.9.1 // indirect
)
+69
View File
@@ -0,0 +1,69 @@
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gopxl/beep/v2 v2.1.1 h1:6FYIYMm2qPAdWkjX+7xwKrViS1x0Po5kDMdRkq8NVbU=
github.com/gopxl/beep/v2 v2.1.1/go.mod h1:ZAm9TGQ9lvpoiFLd4zf5B1IuyxZhgRACMId1XJbaW0E=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/icza/bitio v1.1.0 h1:ysX4vtldjdi3Ygai5m1cWy4oLkhWTAi+SyO6HC8L9T0=
github.com/icza/bitio v1.1.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6 h1:8UsGZ2rr2ksmEru6lToqnXgA8Mz1DP11X4zSJ159C3k=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
github.com/jszwec/csvutil v1.5.1/go.mod h1:Rpu7Uu9giO9subDyMCIQfHVDuLrcaC36UA4YcJjGBkg=
github.com/k2-fsa/sherpa-onnx-go v1.13.7 h1:QG5bjU5LQEz2nNDT9Rt0V1k3Pvr22OdQCVByChT/aPM=
github.com/k2-fsa/sherpa-onnx-go v1.13.7/go.mod h1:JC2kmiNHLMvY7zEbAVXduAymntUTi4QQLkmjnuYlDxo=
github.com/k2-fsa/sherpa-onnx-go-linux v1.13.7 h1:URypLy1vlSQOKLnuqODCUjvOoFPxY022NNbbirHwxWw=
github.com/k2-fsa/sherpa-onnx-go-linux v1.13.7/go.mod h1:NXEH2rsBgTdqY59YpPq6CtSBlBAXy/8a9FmpLERU97I=
github.com/k2-fsa/sherpa-onnx-go-macos v1.13.7 h1:miDo530lFRiiKxEP66PpPry+RHQuucYK+IrBe+wqCdI=
github.com/k2-fsa/sherpa-onnx-go-macos v1.13.7/go.mod h1:ZOhUAXC62Unj0ZNfu6zxSFKcW96aXf7P3BsqiUyOBbE=
github.com/k2-fsa/sherpa-onnx-go-windows v1.13.7 h1:XOgxcWUOBXjNZpdTIgDGur8v1+A+Tpm688XXYS1y6NI=
github.com/k2-fsa/sherpa-onnx-go-windows v1.13.7/go.mod h1:5AX7TU8+P/gInjglY1ijtWUM2b8iyR0QX4yEngzMe64=
github.com/mewkiz/flac v1.0.12 h1:5Y1BRlUebfiVXPmz7hDD7h3ceV2XNrGNMejNVjDpgPY=
github.com/mewkiz/flac v1.0.12/go.mod h1:1UeXlFRJp4ft2mfZnPLRpQTd7cSjb/s17o7JQzzyrCA=
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14 h1:tnAPMExbRERsyEYkmR1YjhTgDM0iqyiBYf8ojRXxdbA=
github.com/mewkiz/pkg v0.0.0-20230226050401-4010bf0fec14/go.mod h1:QYCFBiH5q6XTHEbWhR0uhR3M9qNPoD2CSQzr0g75kE4=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+228
View File
@@ -0,0 +1,228 @@
// Package audio decodes uploaded audio files into the 16 kHz mono float32
// sample slice that sherpa-onnx expects.
//
// Pure-Go beep decoders handle .wav/.mp3/.flac/.ogg. Everything else
// (notably .m4a/.aac — there is no robust pure-Go AAC decoder) is decoded by
// shelling out to ffmpeg, which converts to 16 kHz mono f32le PCM on stdout.
package audio
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"math"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/flac"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/vorbis"
"github.com/gopxl/beep/v2/wav"
)
// TargetSampleRate is the rate sherpa-onnx models were trained on.
const TargetSampleRate = 16000
// Package-level decoder configuration set once by the server bootstrap.
var (
ffmpegPath = "ffmpeg"
decTimeout = 30 * time.Minute
)
// Configure sets the ffmpeg binary path and a per-file decode timeout. Call
// once at startup from the config. Safe to call multiple times.
func Configure(ffmpeg string, timeout time.Duration) {
if strings.TrimSpace(ffmpeg) != "" {
ffmpegPath = ffmpeg
}
if timeout > 0 {
decTimeout = timeout
}
}
// beepExts are the formats beep decodes natively (pure Go, no external tool).
var beepExts = map[string]bool{
".wav": true, ".mp3": true, ".flac": true, ".ogg": true, ".oga": true,
}
// Decode loads the file at path and returns mono float32 samples resampled to
// TargetSampleRate. Beep handles its native formats; ffmpeg handles the rest
// (m4a, aac, opus, wma, webm, aiff, …). If a beep-native file fails to decode,
// it transparently falls back to ffmpeg so a quirky/mis-named file still works.
func Decode(path string) (samples []float32, sampleRate int, err error) {
ext := strings.ToLower(filepath.Ext(path))
if beepExts[ext] {
if s, format, e := tryBeep(path); e == nil {
out, e2 := finalizeBeep(s, format)
if e2 == nil {
return out, TargetSampleRate, nil
}
// beep read failed mid-stream; fall through to ffmpeg.
}
// beep decode failed entirely; fall through to ffmpeg.
}
return decodeFFmpeg(path)
}
// tryBeep opens the file and dispatches to the matching beep decoder.
func tryBeep(path string) (beep.StreamSeekCloser, beep.Format, error) {
f, err := os.Open(path)
if err != nil {
return nil, beep.Format{}, fmt.Errorf("open audio: %w", err)
}
switch strings.ToLower(filepath.Ext(path)) {
case ".wav":
return wav.Decode(f)
case ".mp3":
return mp3.Decode(f)
case ".flac":
return flac.Decode(f)
case ".ogg", ".oga":
return vorbis.Decode(f)
}
// Caller should not reach here for non-beep exts.
f.Close()
return nil, beep.Format{}, fmt.Errorf("not a beep-supported extension")
}
// finalizeBeep drains the decoder into a mono float64 buffer at the native rate
// and resamples to 16 kHz mono float32.
func finalizeBeep(s beep.StreamSeekCloser, format beep.Format) ([]float32, error) {
defer s.Close()
const bufLen = 8192
buf := make([][2]float64, bufLen)
mono := make([]float64, 0, 1<<20)
for {
n, ok := s.Stream(buf)
if n > 0 {
for i := 0; i < n; i++ {
mono = append(mono, (buf[i][0]+buf[i][1])/2.0)
}
}
if !ok {
break
}
}
if err := s.Err(); err != nil {
return nil, fmt.Errorf("read audio: %w", err)
}
if len(mono) == 0 {
return nil, fmt.Errorf("audio file has no samples")
}
return resampleMono(mono, int(format.SampleRate), TargetSampleRate), nil
}
// decodeFFmpeg runs `ffmpeg -i path -f f32le -ar 16000 -ac 1 -` and reads the
// raw little-endian float32 PCM from stdout. This is the universal fallback
// for formats beep cannot decode (m4a/aac/opus/wma/…).
func decodeFFmpeg(path string) ([]float32, int, error) {
if _, err := exec.LookPath(ffmpegPath); err != nil {
return nil, 0, fmt.Errorf("ffmpeg not found (%s): install ffmpeg or set ASR_FFMPEG to decode this format", ffmpegPath)
}
ctx, cancel := context.WithTimeout(context.Background(), decTimeout)
defer cancel()
var stderr bytes.Buffer
// -vn drops the video stream entirely: for video inputs (mp4/mkv/…) this
// skips video decoding so only the audio track is extracted; for
// audio-only inputs it's a harmless no-op.
cmd := exec.CommandContext(ctx, ffmpegPath,
"-nostdin", "-hide_banner", "-loglevel", "error",
"-i", path,
"-vn",
"-f", "f32le", "-acodec", "pcm_f32le",
"-ar", "16000", "-ac", "1",
"-",
)
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, 0, fmt.Errorf("ffmpeg decode timed out after %s", decTimeout)
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return nil, 0, fmt.Errorf("ffmpeg decode failed: %s", msg)
}
raw := stdout.Bytes()
if len(raw) == 0 {
return nil, 0, fmt.Errorf("ffmpeg produced no audio (empty or no audio stream in %s)", filepath.Base(path))
}
if len(raw)%4 != 0 {
raw = raw[:len(raw)-len(raw)%4] // drop trailing partial sample
}
out := make([]float32, len(raw)/4)
for i := 0; i < len(out); i++ {
out[i] = math.Float32frombits(binary.LittleEndian.Uint32(raw[i*4 : i*4+4]))
}
return out, TargetSampleRate, nil
}
// resampleMono converts a mono float64 slice from srcRate to dstRate using
// linear interpolation. Adequate for speech recognition at 16 kHz.
func resampleMono(in []float64, srcRate, dstRate int) []float32 {
if srcRate == dstRate {
out := make([]float32, len(in))
for i, v := range in {
out[i] = float32(v)
}
return out
}
if srcRate <= 0 || dstRate <= 0 || len(in) < 2 {
out := make([]float32, len(in))
for i, v := range in {
out[i] = float32(v)
}
return out
}
ratio := float64(dstRate) / float64(srcRate)
outLen := int(float64(len(in)-1) * ratio)
if outLen < 1 {
outLen = 1
}
out := make([]float32, outLen)
for i := 0; i < outLen; i++ {
pos := float64(i) / ratio
idx := int(pos)
if idx >= len(in)-1 {
out[i] = float32(in[len(in)-1])
continue
}
frac := pos - float64(idx)
v := in[idx]*(1-frac) + in[idx+1]*frac
out[i] = float32(v)
}
return out
}
// acceptedExts is the allowlist the upload handler rejects against. Beep-native
// formats decode without ffmpeg; the rest need ffmpeg on PATH.
var acceptedExts = map[string]bool{
".wav": true, ".mp3": true, ".flac": true, ".ogg": true, ".oga": true,
".m4a": true, ".m4b": true, ".aac": true, ".opus": true,
".wma": true, ".webm": true, ".aiff": true, ".aif": true,
".amr": true, ".au": true, ".snd": true, ".gsm": true,
// Video containers — the audio track is extracted via ffmpeg (-vn drops video).
".mp4": true, ".m4v": true, ".mkv": true, ".mov": true, ".avi": true,
".ts": true, ".m2ts": true, ".mts": true, ".wmv": true, ".flv": true,
".3gp": true, ".3g2": true, ".mpg": true, ".mpeg": true, ".vob": true,
}
// SupportedExtensions lists the file extensions Decode accepts.
func SupportedExtensions() string {
return ".wav, .mp3, .flac, .ogg, .m4a, .aac, .opus, .aiff, + 视频 .mp4/.mkv/.mov/.avi/.ts… (ffmpeg extracts audio)"
}
// IsSupported reports whether the file at path has a decodable extension.
func IsSupported(path string) bool {
return acceptedExts[strings.ToLower(filepath.Ext(path))]
}
+197
View File
@@ -0,0 +1,197 @@
// Package config loads all runtime configuration from environment variables.
//
// Every knob the transcriber exposes is controlled by an ASR_* environment
// variable so the same binary can be reconfigured without a rebuild.
package config
import (
"fmt"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// Config holds all runtime configuration derived from environment variables.
type Config struct {
// ASR_PASSWORD: shared secret required on every API request. If empty,
// auth is disabled and a loud warning is logged (intended for local use).
Password string
// ASR_ADDR: listen address (default ":8080").
Addr string
// ASR_DATA_DIR: root for persisted state (default "./data").
DataDir string
// ASR_MODEL_DIR: where models live / are auto-downloaded
// (default "<DataDir>/models").
ModelDir string
// ASR_NUM_THREADS: ONNX compute threads (default: min(4, NumCPU)).
NumThreads int
// ASR_LANGUAGE: SenseVoice language hint. "auto" detects per segment.
Language string
// ASR_USE_ITN: enable inverse text normalization (numbers/punctuation).
UseITN bool
// ASR_MAX_RETRIES: automatic retry attempts before a task is marked failed.
MaxRetries int
// ASR_MAX_UPLOAD_MB: per-upload size cap (default 512).
MaxUploadBytes int64
// ASR_WORKERS: concurrent transcription workers (default 1).
Workers int
// ASR_MAX_CONCURRENT: cap on simultaneously in-flight synchronous
// transcriptions on the OpenAI endpoint. Bounds onnxruntime thread
// contention under load. Defaults to ASR_NUM_THREADS. Excess requests
// wait for a slot (or get 503 if the client disconnects while waiting).
MaxConcurrent int
// VAD (silero) tuning.
VadThreshold float32
VadMinSpeechDuration float32
VadMinSilenceDuration float32
VadMaxSpeechDuration float32
VadWindowSize int
VadBufferSeconds float32
// ASR_DOWNLOAD_TIMEOUT: timeout for the initial model download.
DownloadTimeout time.Duration
// ASR_FFMPEG: path to the ffmpeg binary used to decode formats beep
// can't (m4a/aac/opus/wma …). Defaults to "ffmpeg" on PATH. Only
// required when such formats are actually uploaded.
FFmpegPath string
// ASR_DECODE_TIMEOUT: per-file decode timeout (beep + ffmpeg fallback).
DecodeTimeout time.Duration
}
// Load reads the environment and returns a validated Config.
func Load() (Config, error) {
c := Config{
Password: strings.TrimSpace(os.Getenv("ASR_PASSWORD")),
Addr: envStr("ASR_ADDR", ":8080"),
DataDir: envStr("ASR_DATA_DIR", "./data"),
NumThreads: envInt("ASR_NUM_THREADS", defaultThreads()),
Language: envStr("ASR_LANGUAGE", "auto"),
UseITN: envBool("ASR_USE_ITN", true),
MaxRetries: envInt("ASR_MAX_RETRIES", 3),
MaxUploadBytes: int64(envInt("ASR_MAX_UPLOAD_MB", 512)) * 1024 * 1024,
Workers: envInt("ASR_WORKERS", 1),
MaxConcurrent: envInt("ASR_MAX_CONCURRENT", 0),
VadThreshold: float32(envFloat("ASR_VAD_THRESHOLD", 0.5)),
VadMinSpeechDuration: float32(envFloat("ASR_VAD_MIN_SPEECH", 0.5)),
VadMinSilenceDuration: float32(envFloat("ASR_VAD_MIN_SILENCE", 0.5)),
VadMaxSpeechDuration: float32(envFloat("ASR_VAD_MAX_SPEECH", 20)),
VadWindowSize: envInt("ASR_VAD_WINDOW", 512),
VadBufferSeconds: float32(envFloat("ASR_VAD_BUFFER_SECS", 60)),
DownloadTimeout: time.Duration(envInt("ASR_DOWNLOAD_TIMEOUT", 1800)) * time.Second,
FFmpegPath: envStr("ASR_FFMPEG", "ffmpeg"),
DecodeTimeout: time.Duration(envInt("ASR_DECODE_TIMEOUT", 1800)) * time.Second,
}
c.ModelDir = envStr("ASR_MODEL_DIR", filepath.Join(c.DataDir, "models"))
if c.Workers < 1 {
c.Workers = 1
}
if c.MaxConcurrent < 1 {
c.MaxConcurrent = c.NumThreads
}
if c.NumThreads < 1 {
c.NumThreads = 1
}
if c.MaxRetries < 0 {
c.MaxRetries = 0
}
if c.VadMaxSpeechDuration < 1 {
c.VadMaxSpeechDuration = 1
}
if c.DecodeTimeout < time.Minute {
c.DecodeTimeout = 30 * time.Minute
}
if c.VadWindowSize <= 0 {
c.VadWindowSize = 512
}
if c.MaxUploadBytes < 1<<20 {
c.MaxUploadBytes = 512 << 20
}
if c.DownloadTimeout < time.Minute {
c.DownloadTimeout = 30 * time.Minute
}
// Absolutize data dir so relative file paths in persisted state survive
// regardless of the process working directory at request time.
if abs, err := filepath.Abs(c.DataDir); err == nil {
c.DataDir = abs
}
if abs, err := filepath.Abs(c.ModelDir); err == nil {
c.ModelDir = abs
}
return c, nil
}
// AuthEnabled reports whether API auth is enforced.
func (c Config) AuthEnabled() bool { return c.Password != "" }
func (c Config) String() string {
return fmt.Sprintf("addr=%s data=%s models=%s threads=%d lang=%s itn=%t workers=%d maxconcurrent=%d retries=%d vad{thr=%.2f max=%.0fs win=%d}",
c.Addr, c.DataDir, c.ModelDir, c.NumThreads, c.Language, c.UseITN, c.Workers,
c.MaxConcurrent, c.MaxRetries, c.VadThreshold, c.VadMaxSpeechDuration, c.VadWindowSize)
}
func defaultThreads() int {
n := runtime.NumCPU()
if n > 4 {
n = 4
}
if n < 1 {
n = 1
}
return n
}
func envStr(key, def string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil {
return def
}
return n
}
func envFloat(key string, def float64) float64 {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return def
}
return f
}
func envBool(key string, def bool) bool {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return def
}
b, err := strconv.ParseBool(v)
if err != nil {
// Accept "0"/"1" and "yes"/"no" too.
switch strings.ToLower(v) {
case "yes", "on", "true", "1":
return true
case "no", "off", "false", "0":
return false
}
return def
}
return b
}
+272
View File
@@ -0,0 +1,272 @@
// Package models owns model lifecycle: it auto-downloads the SenseVoiceSmall
// int8 model and the silero VAD on first run, then builds and holds the
// sherpa-onnx recognizer and voice-activity detector for the transcriber.
package models
import (
"archive/tar"
"compress/bzip2"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
"git.misaka.ren/m1saka/record-asr/internal/config"
)
// Source URLs for the prebuilt models. Pinning these to the k2-fsa
// "asr-models" release channel keeps the downloads stable and resumable.
const (
senseVoiceTarballURL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-sense-voice-zh-en-ja-ko-yue-int8-2024-07-17.tar.bz2"
sileroVadURL = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/silero_vad.onnx"
)
// Manager holds the loaded recognizer and VAD. It is safe for concurrent use;
// the underlying sherpa-onnx objects are not re-entrant per stream, but the
// transcriber creates a fresh OfflineStream per segment under its own lock.
type Manager struct {
cfg config.Config
Recognizer *sherpa.OfflineRecognizer
VAD *sherpa.VoiceActivityDetector
// vadMu serializes access to the stateful VAD across concurrent workers.
vadMu sync.Mutex
ready bool
}
// VadMu returns the mutex guarding the VAD. The transcribe pipeline locks it
// for the duration of a single file's segmentation so VAD state never
// interleaves between files.
func (m *Manager) VadMu() *sync.Mutex { return &m.vadMu }
// New ensures the model files are present (downloading if necessary) and then
// constructs the recognizer and VAD. It blocks until both are ready.
func New(ctx context.Context, cfg config.Config) (*Manager, error) {
m := &Manager{cfg: cfg}
senseDir := filepath.Join(cfg.ModelDir, "sensevoice")
senseModel := filepath.Join(senseDir, "model.int8.onnx")
senseTokens := filepath.Join(senseDir, "tokens.txt")
sileroModel := filepath.Join(cfg.ModelDir, "silero_vad.onnx")
if err := os.MkdirAll(senseDir, 0o755); err != nil {
return nil, fmt.Errorf("create model dir %s: %w", senseDir, err)
}
// Ensure models are on disk before constructing anything. Downloading the
// ~230MB SenseVoice tarball can take a while on first run; log progress.
if !fileOK(senseModel) || !fileOK(senseTokens) {
fmt.Println("model: downloading SenseVoiceSmall int8 (first run)...")
if err := downloadSenseVoice(ctx, cfg.DownloadTimeout, senseVoiceTarballURL, senseDir); err != nil {
return nil, fmt.Errorf("download SenseVoice: %w", err)
}
fmt.Println("model: SenseVoice ready")
}
if !fileOK(sileroModel) {
fmt.Println("model: downloading silero VAD...")
if err := downloadFile(ctx, cfg.DownloadTimeout, sileroVadURL, sileroModel); err != nil {
return nil, fmt.Errorf("download silero VAD: %w", err)
}
fmt.Println("model: silero VAD ready")
}
if !fileOK(senseModel) || !fileOK(senseTokens) || !fileOK(sileroModel) {
return nil, fmt.Errorf("model files missing after download: %s,%s,%s", senseModel, senseTokens, sileroModel)
}
itn := 0
if cfg.UseITN {
itn = 1
}
recCfg := &sherpa.OfflineRecognizerConfig{
FeatConfig: sherpa.FeatureConfig{SampleRate: 16000, FeatureDim: 80},
ModelConfig: sherpa.OfflineModelConfig{
SenseVoice: sherpa.OfflineSenseVoiceModelConfig{
Model: senseModel,
Language: cfg.Language,
UseInverseTextNormalization: itn,
},
Tokens: senseTokens,
NumThreads: cfg.NumThreads,
Provider: "cpu",
Debug: 0,
},
DecodingMethod: "greedy_search",
}
rec := sherpa.NewOfflineRecognizer(recCfg)
if rec == nil {
return nil, fmt.Errorf("failed to create recognizer (model=%s tokens=%s)", senseModel, senseTokens)
}
vadCfg := &sherpa.VadModelConfig{
SileroVad: sherpa.SileroVadModelConfig{
Model: sileroModel,
Threshold: cfg.VadThreshold,
MinSilenceDuration: cfg.VadMinSilenceDuration,
MinSpeechDuration: cfg.VadMinSpeechDuration,
WindowSize: cfg.VadWindowSize,
MaxSpeechDuration: cfg.VadMaxSpeechDuration,
},
SampleRate: 16000,
NumThreads: cfg.NumThreads,
Provider: "cpu",
}
// bufferSizeInSeconds: how much audio the VAD buffers internally. Sized to
// the largest expected utterance so we never drop speech waiting on a flush.
vad := sherpa.NewVoiceActivityDetector(vadCfg, cfg.VadBufferSeconds)
if vad == nil {
sherpa.DeleteOfflineRecognizer(rec)
return nil, fmt.Errorf("failed to create VAD (model=%s)", sileroModel)
}
m.Recognizer = rec
m.VAD = vad
m.ready = true
fmt.Printf("model: loaded (sherpa-onnx %s, onnxruntime %s)\n", sherpa.GetVersion(), sherpa.GetOnnxruntimeVersion())
return m, nil
}
// Close releases the native resources backing the recognizer and VAD.
func (m *Manager) Close() {
m.vadMu.Lock()
defer m.vadMu.Unlock()
if m.VAD != nil {
sherpa.DeleteVoiceActivityDetector(m.VAD)
m.VAD = nil
}
if m.Recognizer != nil {
sherpa.DeleteOfflineRecognizer(m.Recognizer)
m.Recognizer = nil
}
m.ready = false
}
// fileOK reports whether path exists, is a regular file, and is non-empty.
func fileOK(path string) bool {
st, err := os.Stat(path)
return err == nil && !st.IsDir() && st.Size() > 0
}
// downloadFile fetches a single file from url into dest via a temp file +
// atomic rename, so a partial download never replaces a good file.
func downloadFile(ctx context.Context, timeout time.Duration, url, dest string) error {
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(dctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
}
tmp := dest + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, resp.Body); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dest)
}
// downloadSenseVoice streams the .tar.bz2 release directly through a bzip2
// reader into a tar reader, extracting only the two files we need
// (model.int8.onnx, tokens.txt) into destDir. This avoids materializing the
// full ~230MB archive on disk.
func downloadSenseVoice(ctx context.Context, timeout time.Duration, url, destDir string) error {
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(dctx, http.MethodGet, url, nil)
if err != nil {
return err
}
// GitHub releases 302 to a CDN; the default client follows redirects.
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d for %s", resp.StatusCode, url)
}
bz := bzip2.NewReader(resp.Body)
tr := tar.NewReader(bz)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("read tar: %w", err)
}
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
continue
}
base := filepath.Base(hdr.Name)
if base != "model.int8.onnx" && base != "tokens.txt" {
continue
}
dst := filepath.Join(destDir, base)
tmp := dst + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, tr); err != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("extract %s: %w", base, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, dst); err != nil {
return err
}
}
// Guard against a successful walk that found nothing matching.
if !fileOK(filepath.Join(destDir, "model.int8.onnx")) || !fileOK(filepath.Join(destDir, "tokens.txt")) {
return fmt.Errorf("tarball did not contain model.int8.onnx/tokens.txt")
}
return nil
}
// ModelDir returns the configured model directory (used by the server for
// diagnostics).
func (m *Manager) ModelDir() string { return m.cfg.ModelDir }
// Ready reports whether the recognizer and VAD are loaded.
func (m *Manager) Ready() bool {
m.vadMu.Lock()
defer m.vadMu.Unlock()
return m.ready
}
// VersionInfo returns sherpa-onnx + onnxruntime build versions for /healthz.
func (m *Manager) VersionInfo() string {
return fmt.Sprintf("sherpa-onnx %s, onnxruntime %s",
strings.TrimSpace(sherpa.GetVersion()),
strings.TrimSpace(sherpa.GetOnnxruntimeVersion()))
}
+205
View File
@@ -0,0 +1,205 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"git.misaka.ren/m1saka/record-asr/internal/audio"
"git.misaka.ren/m1saka/record-asr/internal/store"
)
// writeJSON marshals v as JSON with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// handleHealthz reports liveness and model readiness (public).
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"models": s.manager != nil && s.manager.Ready(),
"version": s.modelVersion(),
})
}
// handleRoot returns a tiny service descriptor (public).
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"name": "record-asr",
"engine": "SenseVoiceSmall (int8) + silero VAD",
"version": s.modelVersion(),
"endpoints": []string{
"POST /api/tasks (multipart: file) -> enqueue",
"GET /api/tasks -> list",
"GET /api/tasks/{id} -> detail",
"DELETE /api/tasks/{id} -> delete",
"POST /api/tasks/{id}/retry -> re-run failed task",
"POST /api/tasks/{id}/skip -> abandon failed task",
"GET /api/tasks/{id}/transcript -> transcript",
},
})
}
// handleCreateTask accepts a multipart upload and enqueues a transcription task.
func (s *Server) handleCreateTask(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
if err := r.ParseMultipartForm(s.cfg.MaxUploadBytes); err != nil {
renderError(w, http.StatusRequestEntityTooLarge,
"upload rejected: "+err.Error())
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
renderError(w, http.StatusBadRequest, "missing 'file' multipart field")
return
}
defer file.Close()
name := hdr.Filename
if name == "" {
name = "upload.wav"
}
if !audio.IsSupported(name) {
renderError(w, http.StatusUnsupportedMediaType,
"unsupported extension; supported: "+audio.SupportedExtensions())
return
}
t, err := s.store.Create(name, file, s.cfg.MaxUploadBytes, s.cfg.MaxRetries)
if err != nil {
renderError(w, http.StatusInternalServerError, "store: "+err.Error())
return
}
w.Header().Set("Location", "/api/tasks/"+t.ID)
writeJSON(w, http.StatusAccepted, t)
}
// handleListTasks returns all tasks newest-first.
func (s *Server) handleListTasks(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"tasks": s.store.List()})
}
// handleGetTask returns one task.
func (s *Server) handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t := s.store.Get(id)
if t == nil {
renderError(w, http.StatusNotFound, "task not found")
return
}
writeJSON(w, http.StatusOK, t)
}
// handleDeleteTask removes a task and its audio.
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.Delete(id); err != nil {
if err == store.ErrNotFound {
renderError(w, http.StatusNotFound, "task not found")
return
}
renderError(w, http.StatusInternalServerError, "delete: "+err.Error())
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleRetryTask requeues a failed/skipped task for another attempt.
func (s *Server) handleRetryTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.Retry(id); err != nil {
if err == store.ErrNotFound {
renderError(w, http.StatusNotFound, "task not found")
return
}
renderError(w, http.StatusInternalServerError, "retry: "+err.Error())
return
}
t := s.store.Get(id)
writeJSON(w, http.StatusAccepted, t)
}
// handleSkipTask abandons a task so the queue moves on (the "continue" action).
func (s *Server) handleSkipTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.store.Skip(id); err != nil {
if err == store.ErrNotFound {
renderError(w, http.StatusNotFound, "task not found")
return
}
renderError(w, http.StatusInternalServerError, "skip: "+err.Error())
return
}
t := s.store.Get(id)
writeJSON(w, http.StatusAccepted, t)
}
// handleTranscript returns just the transcript of a completed task.
func (s *Server) handleTranscript(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
t := s.store.Get(id)
if t == nil {
renderError(w, http.StatusNotFound, "task not found")
return
}
if t.Status != store.StatusCompleted || t.Result == nil {
renderError(w, http.StatusConflict, "transcript not ready (status: "+t.Status+")")
return
}
writeJSON(w, http.StatusOK, t.Result)
}
// --- auth ---
// authMiddleware wraps a handler with the password check. Auth is enforced
// for paths under /api/ and /v1/ (the OpenAI-compatible endpoint); /healthz
// and / stay public. When ASR_PASSWORD is unset, auth is disabled (a warning
// was logged at startup).
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if (!strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasPrefix(r.URL.Path, "/v1/")) || !s.cfg.AuthEnabled() {
next.ServeHTTP(w, r)
return
}
if !constantTimeEqual(extractToken(r), s.cfg.Password) {
w.Header().Set("WWW-Authenticate", `Bearer realm="record-asr"`)
renderError(w, http.StatusUnauthorized, "unauthorized")
return
}
next.ServeHTTP(w, r)
})
}
// extractToken pulls the shared secret from, in order: X-Api-Key header,
// Authorization: Bearer <token>, or ?token= query parameter.
func extractToken(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get("X-Api-Key")); v != "" {
return v
}
if v := r.Header.Get("Authorization"); v != "" {
if strings.HasPrefix(strings.ToLower(v), "bearer ") {
return strings.TrimSpace(v[7:])
}
return strings.TrimSpace(strings.TrimPrefix(v, "Bearer "))
}
return strings.TrimSpace(r.URL.Query().Get("token"))
}
// constantTimeEqual compares two strings in constant time.
func constantTimeEqual(a, b string) bool {
if len(a) != len(b) {
return false
}
var v byte
for i := 0; i < len(a); i++ {
v |= a[i] ^ b[i]
}
return v == 0
}
+162
View File
@@ -0,0 +1,162 @@
package server
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"git.misaka.ren/m1saka/record-asr/internal/audio"
"git.misaka.ren/m1saka/record-asr/internal/transcribe"
)
// handleOpenAITranscription implements POST /v1/audio/transcriptions — an
// OpenAI-compatible, *synchronous* transcription endpoint. Drop-in for any
// client that speaks the OpenAI audio-transcription API.
//
// Multipart fields:
// - file (required) the audio/video file
// - model (ignored for compatibility; there is one model)
// - language (accepted for compatibility; recognition auto-detects per
// segment unless the server was configured with a fixed language)
// - response_format json (default) | text | verbose_json | srt | vtt
//
// Auth: the shared ASR_PASSWORD, sent as Authorization: Bearer <pw> (the form
// OpenAI clients use), X-Api-Key, or ?token= — same as the task API.
func (s *Server) handleOpenAITranscription(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes)
if err := r.ParseMultipartForm(s.cfg.MaxUploadBytes); err != nil {
renderError(w, http.StatusRequestEntityTooLarge, "upload rejected: "+err.Error())
return
}
file, hdr, err := r.FormFile("file")
if err != nil {
renderError(w, http.StatusBadRequest, "missing 'file' multipart field")
return
}
defer file.Close()
name := hdr.Filename
if name == "" {
name = "upload.wav"
}
if !audio.IsSupported(name) {
renderError(w, http.StatusUnsupportedMediaType,
"unsupported extension; supported: "+audio.SupportedExtensions())
return
}
// The pipeline reads from a path; spill the upload to a temp file keeping
// the original extension so the decoder picks the right path, then clean up.
tmp, err := os.CreateTemp("", "asr-*"+filepath.Ext(name))
if err != nil {
renderError(w, http.StatusInternalServerError, "create temp: "+err.Error())
return
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if _, err := io.Copy(tmp, file); err != nil {
tmp.Close()
renderError(w, http.StatusBadRequest, "read upload: "+err.Error())
return
}
tmp.Close()
// Acquire a concurrency slot before the CPU/thread-heavy transcribe.
// While all ASR_MAX_CONCURRENT slots are busy this blocks — excess
// requests queue rather than ballooning onnxruntime threads. If the
// client disconnects while waiting, reply 503 so it retries.
select {
case s.concSem <- struct{}{}:
defer func() { <-s.concSem }()
case <-r.Context().Done():
renderError(w, http.StatusServiceUnavailable, "transcription slots busy; retry later")
return
}
res, err := s.pipe.Transcribe(tmpPath)
if err != nil {
renderError(w, http.StatusUnprocessableEntity, "transcribe failed: "+err.Error())
return
}
format := strings.ToLower(strings.TrimSpace(r.FormValue("response_format")))
if format == "" {
format = "json"
}
switch format {
case "text":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, res.Text)
case "srt":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = io.WriteString(w, toSRT(res))
case "vtt":
w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
_, _ = io.WriteString(w, toVTT(res))
case "verbose_json":
writeJSON(w, http.StatusOK, verboseJSON(res))
default: // "json" and anything else
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]string{"text": res.Text})
}
}
// verboseJSON mirrors OpenAI's verbose_json shape (a useful subset).
func verboseJSON(res *transcribe.Result) map[string]any {
segs := make([]map[string]any, 0, len(res.Segments))
for i, s := range res.Segments {
segs = append(segs, map[string]any{
"id": i,
"seek": i,
"start": s.Start,
"end": s.End,
"text": s.Text,
"language": strings.Trim(s.Lang, "<>|"),
})
}
return map[string]any{
"task": "transcribe",
"language": strings.Trim(res.Lang, "<>|"),
"duration": res.Duration,
"text": res.Text,
"segments": segs,
}
}
// fmtTS formats a timestamp as HH:MM:SS<sep>mmm (SRT uses ",", VTT uses ".").
func fmtTS(t float64, sep string) string {
if t < 0 {
t = 0
}
ms := int(t*1000 + 0.5)
h := ms / 3600000
ms %= 3600000
m := ms / 60000
ms %= 60000
s := ms / 1000
ms %= 1000
return fmt.Sprintf("%02d:%02d:%02d%s%03d", h, m, s, sep, ms)
}
func toSRT(res *transcribe.Result) string {
var b strings.Builder
for i, seg := range res.Segments {
fmt.Fprintf(&b, "%d\n%s --> %s\n%s\n\n", i+1,
fmtTS(seg.Start, ","), fmtTS(seg.End, ","), seg.Text)
}
return b.String()
}
func toVTT(res *transcribe.Result) string {
var b strings.Builder
b.WriteString("WEBVTT\n\n")
for _, seg := range res.Segments {
fmt.Fprintf(&b, "%s --> %s\n%s\n\n",
fmtTS(seg.Start, "."), fmtTS(seg.End, "."), seg.Text)
}
return b.String()
}
+195
View File
@@ -0,0 +1,195 @@
// Package server implements the HTTP API: password auth, the task endpoints
// (upload / list / get / delete / retry / skip), and the background worker
// that drains the queue with automatic retry-on-error.
package server
import (
"context"
"fmt"
"log"
"net/http"
"sync"
"time"
"git.misaka.ren/m1saka/record-asr/internal/config"
"git.misaka.ren/m1saka/record-asr/internal/models"
"git.misaka.ren/m1saka/record-asr/internal/store"
"git.misaka.ren/m1saka/record-asr/internal/transcribe"
)
// Server wires the store, transcribe pipeline, and model manager behind an
// authenticated HTTP API.
type Server struct {
cfg config.Config
store *store.Store
pipe *transcribe.Pipeline
manager *models.Manager
// concSem caps simultaneously in-flight synchronous transcriptions on
// the OpenAI endpoint, preventing onnxruntime thread contention / OOM
// under bursts. The background workers are NOT gated by this semaphore;
// they are bounded separately by ASR_WORKERS, so the task queue is
// never starved by a flood of OpenAI requests.
concSem chan struct{}
}
// New assembles a Server from its dependencies.
func New(cfg config.Config, st *store.Store, pipe *transcribe.Pipeline, mgr *models.Manager) *Server {
return &Server{
cfg: cfg,
store: st,
pipe: pipe,
manager: mgr,
concSem: make(chan struct{}, cfg.MaxConcurrent),
}
}
// Router builds the HTTP mux. All routes are registered with explicit
// method + path patterns on one mux (Go 1.22+). Auth is then applied as an
// outer middleware that only enforces the password for /api/* paths, leaving
// /healthz and / public so monitoring and the landing page work token-free.
func (s *Server) Router() http.Handler {
mux := http.NewServeMux()
// Public endpoints: the web console (UI calls the authed /api/* routes
// itself) and the health check.
mux.HandleFunc("GET /", s.handleUI)
mux.HandleFunc("GET /healthz", s.handleHealthz)
// Task API (guarded by the outer authMiddleware below).
mux.HandleFunc("POST /api/tasks", s.handleCreateTask)
mux.HandleFunc("GET /api/tasks", s.handleListTasks)
mux.HandleFunc("GET /api/tasks/{id}", s.handleGetTask)
mux.HandleFunc("DELETE /api/tasks/{id}", s.handleDeleteTask)
mux.HandleFunc("POST /api/tasks/{id}/retry", s.handleRetryTask)
mux.HandleFunc("POST /api/tasks/{id}/skip", s.handleSkipTask)
mux.HandleFunc("GET /api/tasks/{id}/transcript", s.handleTranscript)
// OpenAI-compatible synchronous transcription (also authed).
mux.HandleFunc("POST /v1/audio/transcriptions", s.handleOpenAITranscription)
return s.authMiddleware(mux)
}
// Run starts the background workers and serves HTTP until ctx is cancelled.
func (s *Server) Run(ctx context.Context) error {
if s.cfg.AuthEnabled() {
log.Printf("auth: enabled (password set)")
} else {
log.Printf("WARNING: ASR_PASSWORD not set — API is open; set ASR_PASSWORD to require a password")
}
log.Printf("concurrency: max %d in-flight sync transcriptions (OpenAI endpoint)", s.cfg.MaxConcurrent)
var wg sync.WaitGroup
for i := 0; i < s.cfg.Workers; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
s.runWorker(ctx, n)
}(i)
}
srv := &http.Server{
Addr: s.cfg.Addr,
Handler: s.Router(),
ReadHeaderTimeout: 15 * time.Second,
// Reading a large upload can take a while; bound it generously.
ReadTimeout: 10 * time.Minute,
WriteTimeout: 15 * time.Minute,
IdleTimeout: 120 * time.Second,
}
errCh := make(chan error, 1)
go func() {
log.Printf("listening on %s", s.cfg.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
case err := <-errCh:
if err != nil {
return err
}
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("http shutdown: %v", err)
}
wg.Wait()
return nil
}
// runWorker is the queue drain loop. It claims one runnable task at a time,
// transcribes it, and on error requeues it (with backoff, up to MaxRetries)
// or marks it failed — then immediately looks for the next task. This is the
// automatic "retry-or-continue" behavior.
func (s *Server) runWorker(ctx context.Context, n int) {
log.Printf("worker %d started", n)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Printf("worker %d stopping", n)
return
default:
}
t := s.store.Claim(time.Now())
if t == nil {
// Nothing ready; wait briefly so we don't spin.
select {
case <-ctx.Done():
return
case <-ticker.C:
}
continue
}
s.processTask(ctx, t)
}
}
// processTask runs the pipeline for one claimed task and records the outcome.
func (s *Server) processTask(ctx context.Context, t *store.Task) {
start := time.Now()
audioPath := s.store.AudioPath(t)
log.Printf("[%s] start (attempt %d, %s)", t.ID, t.Attempts, t.OriginalName)
res, err := s.pipe.Transcribe(audioPath)
if err != nil {
log.Printf("[%s] failed: %v", t.ID, err)
if ferr := s.store.Fail(t.ID, err.Error()); ferr != nil {
log.Printf("[%s] persist failure: %v", t.ID, ferr)
}
// Whether retried or marked failed, the worker continues to the next.
return
}
if perr := s.store.Complete(t.ID, res); perr != nil {
log.Printf("[%s] persist complete: %v", t.ID, perr)
return
}
log.Printf("[%s] done in %.1fs (%d segments, lang=%s)", t.ID,
time.Since(start).Seconds(), len(res.Segments), res.Lang)
}
// manager access for handlers (read-only status).
func (s *Server) modelVersion() string {
if s.manager == nil {
return "n/a"
}
return s.manager.VersionInfo()
}
func (s *Server) dataDir() string { return s.cfg.DataDir }
// renderError writes a small JSON error object with the given status.
func renderError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"error":%q}`, msg)
}
+25
View File
@@ -0,0 +1,25 @@
package server
import (
"embed"
"net/http"
)
// uiFS embeds the single-page web console so the binary stays self-contained —
// no external frontend to deploy. The HTML/CSS/JS is one file; the SPA calls
// the existing /api/* endpoints with a user-supplied password (X-Api-Key).
//
//go:embed ui/index.html
var uiHTML []byte
// handleUI serves the embedded console at the site root. It is public (the
// shell is just static markup); all sensitive data is fetched via the
// authenticated /api/* routes from the browser.
func (s *Server) handleUI(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(uiHTML)
}
// keep embed.FS referenced for future file-tree growth without API churn.
var _ embed.FS
+650
View File
@@ -0,0 +1,650 @@
<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>record-asr · 转写控制台</title>
<style>
:root{
--bg:#0d1117; --surface:#161b22; --surface-2:#1c2330; --surface-3:#222b3a;
--border:#2a313c; --border-soft:#222732;
--text:#e6edf3; --muted:#7d8590; --faint:#545b66;
--accent:#2f81f7; --accent-dim:#1f6feb; --accent-soft:rgba(47,129,247,.14);
--ok:#3fb950; --warn:#d29922; --err:#f85149; --skip:#6e7681;
--tb1:#161b22; --tb2:#131820;
--mono:ui-monospace,'SF Mono','Cascadia Code',Menlo,Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,'PingFang SC','Microsoft YaHei',sans-serif;
--r:8px;
}
/* Light (white) theme — default; toggle stored in localStorage as asr_theme. */
[data-theme="light"]{
--bg:#f6f8fa; --surface:#ffffff; --surface-2:#eef2f6; --surface-3:#e2e8ee;
--border:#d4dbe2; --border-soft:#e4e9ee;
--text:#1f2328; --muted:#57606a; --faint:#8b949e;
--accent:#0969da; --accent-dim:#0550ae; --accent-soft:rgba(9,105,218,.12);
--ok:#1a7f37; --warn:#9a6700; --err:#cf222e; --skip:#6e7681;
--tb1:#ffffff; --tb2:#eef2f6;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{
background:var(--bg); color:var(--text);
font-family:var(--sans); font-size:14px; line-height:1.5;
-webkit-font-smoothing:antialiased;
display:flex; flex-direction:column;
}
::selection{background:var(--accent-soft)}
button{font:inherit;color:inherit;cursor:pointer;border:none;background:none}
input{font:inherit;color:inherit}
/* ---- tab bar ---- */
.tabbar{flex:none;display:flex;gap:2px;padding:0 12px;border-bottom:1px solid var(--border);background:var(--surface)}
.tab{height:38px;padding:0 14px;font-size:13px;color:var(--muted);border-bottom:2px solid transparent}
.tab:hover{color:var(--text)}
.tab.active{color:var(--text);border-bottom-color:var(--accent)}
.tab:focus-visible{outline:2px solid var(--accent);outline-offset:-2px}
/* ---- API reference view ---- */
.api-view{flex:1;min-height:0;overflow:auto}
.api-body{max-width:880px;margin:0 auto;padding:24px 28px 60px}
.api-body h2{margin:0 0 4px;font-size:20px}
.api-intro{margin:0 0 20px;color:var(--muted);font-size:13px;line-height:1.7}
.api-intro code{background:var(--surface-2);border:1px solid var(--border-soft);border-radius:5px;padding:1px 6px;font-family:var(--mono);font-size:12px;color:var(--text)}
.api-section{margin:24px 0}
.api-section h3{margin:0 0 10px;font-size:14px;color:var(--text)}
.api-section h3 .tag{margin-left:8px;font-size:10.5px;font-family:var(--mono);color:var(--accent);background:var(--accent-soft);border-radius:4px;padding:1px 6px;vertical-align:middle}
.api-tbl{width:100%;border-collapse:collapse;font-size:13px}
.api-tbl th,.api-tbl td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border-soft);vertical-align:top}
.api-tbl th{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.06em;font-weight:500}
.api-tbl td.m{font-family:var(--mono);font-size:11.5px;white-space:nowrap}
.api-tbl td.m.post{color:var(--accent)} .api-tbl td.m.get{color:var(--ok)} .api-tbl td.m.del{color:var(--err)}
.api-tbl code{font-family:var(--mono);font-size:12px}
.code{background:var(--surface);border:1px solid var(--border-soft);border-radius:8px;padding:14px 16px;overflow:auto;font-family:var(--mono);font-size:12.5px;line-height:1.6;white-space:pre;color:var(--text);margin:10px 0}
.code .c{color:var(--muted)}
/* ---- top bar ---- */
.topbar{
height:52px;display:flex;align-items:center;gap:14px;
padding:0 16px;border-bottom:1px solid var(--border);
background:linear-gradient(180deg,var(--tb1),var(--tb2));
position:sticky;top:0;z-index:20;
}
.brand{display:flex;align-items:center;gap:9px;font-weight:600;letter-spacing:.2px;white-space:nowrap}
.brand .mark{
width:22px;height:22px;border-radius:6px;
background:linear-gradient(135deg,var(--accent),#7b61ff);
display:grid;place-items:center;color:#fff;font-size:12px;font-weight:700;
font-family:var(--mono);
}
.brand small{color:var(--muted);font-weight:400;font-size:12px;font-family:var(--mono)}
.health{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);font-family:var(--mono)}
.dot{width:8px;height:8px;border-radius:50%;background:var(--faint);flex:none}
.dot.ok{background:var(--ok);box-shadow:0 0 0 3px rgba(63,185,80,.16)}
.dot.down{background:var(--err);box-shadow:0 0 0 3px rgba(248,81,73,.16)}
.dot.live{background:var(--warn);box-shadow:0 0 0 3px rgba(210,153,34,.16);animation:pulse 1.6s ease-in-out infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.35}}
@media(prefers-reduced-motion:reduce){.dot.live{animation:none}}
.spacer{flex:1}
.modal{position:fixed;inset:0;background:rgba(0,0,0,.55);display:grid;place-items:center;z-index:60;padding:20px}
[data-theme="light"] .modal{background:rgba(0,0,0,.25)}
.modal.hidden{display:none}
.modal-card{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:24px;width:min(380px,92vw);box-shadow:0 24px 60px rgba(0,0,0,.5)}
.modal-card h3{margin:0 0 6px;font-size:17px;font-weight:600}
.modal-msg{margin:0 0 16px;color:var(--muted);font-size:13px;line-height:1.5}
.modal-msg.err{color:var(--err)}
.modal-field{display:flex;align-items:center;gap:8px;background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:7px 10px;margin-bottom:16px}
.modal-field:focus-within{border-color:var(--accent)}
.modal-field svg{width:15px;height:15px;color:var(--muted);flex:none}
.modal-field input{flex:1;background:none;border:none;outline:none;font-size:14px;font-family:var(--mono)}
.modal-field input::placeholder{color:var(--faint)}
.modal-actions{display:flex;justify-content:flex-end;gap:8px}
.btn{
display:inline-flex;align-items:center;gap:6px;
height:30px;padding:0 12px;border-radius:var(--r);font-size:13px;font-weight:500;
border:1px solid var(--border);background:var(--surface-2);color:var(--text);
transition:background .12s,border-color .12s;
}
.btn:hover{background:var(--surface-3);border-color:#3a4250}
.btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
.btn.primary:hover{background:var(--accent-dim)}
.btn.danger:hover{background:rgba(248,81,73,.14);border-color:var(--err);color:var(--err)}
.btn.ghost{background:none;border-color:transparent}
.btn.ghost:hover{background:var(--surface-2)}
.btn svg{width:14px;height:14px}
.iconbtn{width:30px;height:30px;padding:0;justify-content:center;color:var(--muted)}
.iconbtn:hover{color:var(--text)}
.iconbtn svg{width:16px;height:16px}
.iconbtn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
/* ---- layout ---- */
.layout{display:flex;flex:1;min-height:0}
.pane{display:flex;flex-direction:column;min-height:0}
.list-pane{width:360px;flex:none;border-right:1px solid var(--border);background:var(--bg)}
.detail-pane{flex:1;min-width:0;background:var(--bg)}
.pane-head{
height:42px;display:flex;align-items:center;gap:8px;padding:0 14px;flex:none;
border-bottom:1px solid var(--border-soft);color:var(--muted);
font-size:12px;text-transform:uppercase;letter-spacing:.08em;
}
.pane-head .count{margin-left:auto;font-family:var(--mono);color:var(--faint)}
.scroll{overflow:auto;flex:1;min-height:0}
/* ---- task list rows ---- */
.task{
display:flex;gap:10px;align-items:flex-start;padding:11px 14px;
border-bottom:1px solid var(--border-soft);cursor:pointer;border-left:2px solid transparent;
}
.task:hover{background:var(--surface)}
.task.selected{background:var(--surface-2);border-left-color:var(--accent)}
.task .sdot{margin-top:5px;flex:none}
.task .main{min-width:0;flex:1}
.task .name{font-size:13.5px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.task .meta{margin-top:2px;display:flex;gap:8px;align-items:center;font-size:11.5px;color:var(--muted);font-family:var(--mono)}
.badge{font-size:10.5px;padding:1px 6px;border-radius:4px;font-family:var(--mono);letter-spacing:.04em}
.b-pending{background:rgba(210,153,34,.14);color:var(--warn)}
.b-processing{background:rgba(47,129,247,.16);color:var(--accent)}
.b-completed{background:rgba(63,185,80,.14);color:var(--ok)}
.b-failed{background:rgba(248,81,73,.14);color:var(--err)}
.b-skipped{background:rgba(110,118,129,.16);color:var(--skip)}
/* ---- detail ---- */
.detail-empty{height:100%;display:grid;place-items:center;text-align:center;padding:40px}
.dropzone{
border:1.5px dashed var(--border);border-radius:14px;padding:46px 30px;max-width:440px;
background:var(--surface);transition:border-color .15s,background .15s;
}
.dropzone.drag{border-color:var(--accent);background:var(--accent-soft)}
.dropzone svg{width:34px;height:34px;color:var(--muted)}
.dropzone h3{margin:14px 0 4px;font-size:16px}
.dropzone p{margin:0;color:var(--muted);font-size:13px}
.dropzone .btn{margin-top:16px}
.link{color:var(--accent);cursor:pointer;text-decoration:none}
.link:hover{text-decoration:underline}
.dropzone .ext{margin-top:10px;font-family:var(--mono);font-size:11px;color:var(--faint)}
.detail-body{padding:18px 24px 60px;max-width:920px}
.detail-title{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.detail-title h2{margin:0;font-size:18px;font-weight:600;word-break:break-all}
.detail-title .badge{font-size:11px}
.detail-id{margin-top:4px;font-family:var(--mono);font-size:11.5px;color:var(--faint)}
.kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin:18px 0}
.kv .cell{background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--r);padding:9px 11px}
.kv .k{font-size:10.5px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
.kv .v{margin-top:2px;font-family:var(--mono);font-size:13px}
.actions{display:flex;gap:8px;flex-wrap:wrap;margin:18px 0 4px}
.section-label{margin:26px 0 10px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);display:flex;align-items:center;gap:8px}
.section-label::after{content:"";flex:1;height:1px;background:var(--border-soft)}
.transcript{
background:var(--surface);border:1px solid var(--border-soft);border-radius:var(--r);
padding:16px 18px;font-size:15px;line-height:1.7;white-space:pre-wrap;word-break:break-word;
}
.transcript.empty{color:var(--faint);font-style:italic}
.seg{
display:flex;gap:14px;padding:11px 0;border-bottom:1px solid var(--border-soft);
}
.seg:last-child{border-bottom:none}
.seg .ts{
font-family:var(--mono);font-size:11.5px;color:var(--accent);flex:none;
white-space:nowrap;align-self:flex-start;
display:inline-flex;align-items:center;gap:4px;
background:var(--surface-2);border:1px solid var(--border-soft);border-radius:6px;
padding:2px 8px;cursor:pointer;
transition:color .12s,border-color .12s,background .12s;
}
.seg .ts:hover{border-color:var(--accent);color:var(--accent);background:var(--accent-soft)}
.seg .ts:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.seg .ts.copied{color:var(--ok);border-color:var(--ok)}
.seg .ts svg{width:11px;height:11px;opacity:.9}
.seg .ts:hover svg{opacity:1}
.seg .stext{flex:1;line-height:1.55}
.seg .slang{font-family:var(--mono);font-size:10.5px;color:var(--faint);margin-left:8px}
.copy-btn{
height:24px;padding:0 10px;font-size:11.5px;border-radius:6px;
border:1px solid var(--border);background:var(--surface);color:var(--muted);
display:inline-flex;align-items:center;gap:5px;flex:none;cursor:pointer;
transition:color .12s,border-color .12s,background .12s;
}
.copy-btn:hover{color:var(--text);border-color:#3a4250;background:var(--surface-2)}
.copy-btn:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
.copy-btn.copied{color:var(--ok);border-color:var(--ok)}
.copy-btn svg{width:12px;height:12px}
.seg-copy{margin-left:6px;align-self:flex-start}
.errbox{background:rgba(248,81,73,.1);border:1px solid rgba(248,81,73,.3);border-radius:var(--r);padding:10px 12px;color:var(--err);font-size:13px;font-family:var(--mono);white-space:pre-wrap;word-break:break-word}
/* ---- toast ---- */
#toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:50;display:flex;flex-direction:column;gap:8px;align-items:center}
.toast{background:var(--surface-3);border:1px solid var(--border);color:var(--text);padding:8px 14px;border-radius:var(--r);font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);animation:tin .2s ease}
.toast.err{border-color:var(--err);color:var(--err)}
.toast.ok{border-color:var(--ok);color:var(--ok)}
@keyframes tin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
@media(prefers-reduced-motion:reduce){.toast{animation:none}}
.spinner{width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .7s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.spinner{animation:none}}
.skeleton{color:var(--faint);font-size:13px;display:flex;align-items:center;gap:8px;padding:14px}
@media(max-width:860px){
.layout{flex-direction:column}
.list-pane{width:100%;height:42%;border-right:none;border-bottom:1px solid var(--border)}
}
.hidden{display:none!important}
</style>
<script>
// Apply saved theme before first paint to avoid a flash of the wrong theme.
(function(){
var t = localStorage.getItem("asr_theme");
document.documentElement.dataset.theme = (t === "dark") ? "dark" : "light";
})();
</script>
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="mark">A</span>record-asr <small>SenseVoice · silero VAD</small>
</div>
<div class="health" id="health"><span class="dot" id="hdot"></span><span id="htext">checking…</span></div>
<div class="spacer"></div>
<button class="btn iconbtn hidden" id="logoutBtn" title="退出登录" aria-label="退出登录"></button>
<button class="btn iconbtn" id="themeBtn" title="切换主题" aria-label="切换主题"></button>
<button class="btn primary" id="uploadBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 16V4M5 11l7-7 7 7M5 20h14"/></svg>
上传
</button>
</header>
<nav class="tabbar">
<button class="tab active" data-view="console" type="button">转写控制台</button>
<button class="tab" data-view="api" type="button">API</button>
</nav>
<main class="layout" id="consoleView">
<section class="pane list-pane">
<div class="pane-head">任务队列 <span class="count" id="listCount"></span></div>
<div class="scroll" id="list"><!-- rows --></div>
</section>
<section class="pane detail-pane">
<div class="scroll" id="detail"><!-- detail or empty --></div>
</section>
</main>
<section class="api-view hidden" id="apiView">
<div class="api-body">
<h2>API 参考</h2>
<p class="api-intro">
Base URL:<code id="apiBase">http://&lt;host&gt;:8080</code><br>
鉴权(除 <code>/healthz</code> 外):<code>X-Api-Key</code> · <code>Authorization: Bearer &lt;pw&gt;</code> · <code>?token=&lt;pw&gt;</code><br>
支持格式:<code>.wav .mp3 .flac .ogg .m4a .aac .opus .aiff</code> 及视频 <code>.mp4 .mkv .mov .avi .ts …</code>(自动提取音轨)
</p>
<div class="api-section">
<h3>任务 API <span class="tag">异步:上传 → 轮询 → 取结果</span></h3>
<table class="api-tbl">
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
<tr><td class="m post">POST</td><td><code>/api/tasks</code></td><td>上传音频/视频(multipart: <code>file</code>)→ 202 + 任务</td></tr>
<tr><td class="m get">GET</td><td><code>/api/tasks</code></td><td>任务列表(新→旧)</td></tr>
<tr><td class="m get">GET</td><td><code>/api/tasks/{id}</code></td><td>任务详情(完成时含 <code>result</code>)</td></tr>
<tr><td class="m del">DELETE</td><td><code>/api/tasks/{id}</code></td><td>删除任务及其音频 → 204</td></tr>
<tr><td class="m post">POST</td><td><code>/api/tasks/{id}/retry</code></td><td>失败/跳过后重新排队 → 202</td></tr>
<tr><td class="m post">POST</td><td><code>/api/tasks/{id}/skip</code></td><td>放弃失败任务,队列继续 → 202</td></tr>
<tr><td class="m get">GET</td><td><code>/api/tasks/{id}/transcript</code></td><td>仅转写结果(完成后)</td></tr>
</table>
</div>
<div class="api-section">
<h3>OpenAI 兼容 <span class="tag">同步</span></h3>
<table class="api-tbl">
<tr><th>方法</th><th>路径</th><th>说明</th></tr>
<tr><td class="m post">POST</td><td><code>/v1/audio/transcriptions</code></td><td>同步转写,返回结果(无需轮询)。字段:<code>file</code>(必填)、<code>response_format</code>=<code>json</code>(默认)|<code>text</code>|<code>verbose_json</code>|<code>srt</code>|<code>vtt</code>、<code>model</code>/<code>language</code>(兼容接受,识别自动检测语种)</td></tr>
</table>
<pre class="code"><span class="c"># 默认 json:{"text":"..."}</span>
curl -H "Authorization: Bearer $PW" -F "[email protected]" \
http://localhost:8080/v1/audio/transcriptions
<span class="c"># 字幕</span>
curl -H "Authorization: Bearer $PW" -F "[email protected]" -F "response_format=srt" \
http://localhost:8080/v1/audio/transcriptions</pre>
</div>
<div class="api-section">
<h3>示例</h3>
<pre class="code"><span class="c"># 上传任务(异步)</span>
curl -H "X-Api-Key: $PW" -F "[email protected]" http://localhost:8080/api/tasks
<span class="c"># 轮询 + 取结果</span>
curl -H "X-Api-Key: $PW" http://localhost:8080/api/tasks/&lt;id&gt;
curl -H "X-Api-Key: $PW" http://localhost:8080/api/tasks/&lt;id&gt;/transcript
<span class="c"># 健康检查(免鉴权)</span>
curl http://localhost:8080/healthz</pre>
</div>
</div>
</section>
<input id="file" type="file" accept=".wav,.mp3,.flac,.ogg,.oga,.m4a,.m4b,.aac,.opus,.wma,.webm,.aiff,.aif,.mp4,.m4v,.mkv,.mov,.avi,.ts,.m2ts,.mts,.flv,.3gp,.mpg,.mpeg" multiple class="hidden">
<div id="toast"></div>
<!-- Password modal: shown only when auth is needed (no saved password, or a
401). Once verified it hides and never clutters the top bar. -->
<div id="authModal" class="modal hidden" role="dialog" aria-modal="true" aria-labelledby="authTitle">
<div class="modal-card">
<h3 id="authTitle">需要访问密码</h3>
<p id="authMsg" class="modal-msg">请输入 ASR_PASSWORD 设置的访问密码。验证后本次会话不再提示。</p>
<div class="modal-field">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input id="authPw" type="password" placeholder="访问密码" autocomplete="off" spellcheck="false">
</div>
<div class="modal-actions">
<button class="btn" id="authCancel" type="button">取消</button>
<button class="btn primary" id="authOk" type="button">验证</button>
</div>
</div>
</div>
<script>
const $ = (s,r=document)=>r.querySelector(s);
const el = (t,p={})=>{const e=document.createElement(t);for(const k in p)k==="class"?e.className=p[k]:k==="text"?e.textContent=p[k]:e.setAttribute(k,p[k]);return e};
const fmtTime = iso=>{ if(!iso) return "—"; const d=new Date(iso); return isNaN(d)?iso : d.toLocaleTimeString("zh",{hour12:false})+" "+d.toLocaleDateString("zh",{month:"2-digit",day:"2-digit"})};
const fmtDur = s=> s==null?"—": (s<60?s.toFixed(1)+"s" : Math.floor(s/60)+"m"+String(Math.round(s%60)).padStart(2,"0")+"s");
const escapeHtml = s=>(s||"").replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]));
const state = { pw: localStorage.getItem("asr_pw")||"", tasks:[], sel:null, busy:false, authing:false };
// --- password modal (shown only when verification is needed) ---
const authModal=$("#authModal"), authPw=$("#authPw"), authMsg=$("#authMsg"), authOk=$("#authOk"), authCancel=$("#authCancel");
function openAuthModal(msg, isErr){
if(msg){ authMsg.textContent=msg; authMsg.classList.toggle("err",!!isErr); }
state.authing=true;
authModal.classList.remove("hidden");
authPw.value=state.pw||"";
setTimeout(()=>{ authPw.focus(); authPw.select(); }, 0);
}
function closeAuthModal(){ authModal.classList.add("hidden"); state.authing=false; }
async function verifyPassword(pw){
try{
const r = await fetch("/api/tasks", {headers:{"X-Api-Key":pw}});
if(r.status===401) return {ok:false, msg:"密码不正确,请重新输入"};
if(!r.ok) return {ok:false, msg:"服务暂时不可用 (HTTP "+r.status+")"};
return {ok:true};
}catch(e){ return {ok:false, msg:"无法连接服务"} };
}
async function submitAuth(){
const v=authPw.value.trim();
if(!v){ authPw.focus(); return; }
authOk.disabled=true; const old=authOk.textContent; authOk.innerHTML='<span class="spinner"></span> 验证中';
const res=await verifyPassword(v);
authOk.disabled=false; authOk.textContent=old;
if(res.ok){ state.pw=v; localStorage.setItem("asr_pw",v); closeAuthModal(); refresh(); }
else { authMsg.textContent=res.msg; authMsg.classList.add("err"); authPw.focus(); authPw.select(); }
}
authOk.addEventListener("click", submitAuth);
authPw.addEventListener("keydown", e=>{ if(e.key==="Enter"){e.preventDefault(); submitAuth();} });
authPw.addEventListener("input", ()=>{ authMsg.classList.remove("err"); });
authCancel.addEventListener("click", ()=> closeAuthModal());
authModal.addEventListener("click", e=>{ if(e.target===authModal) closeAuthModal(); });
document.addEventListener("keydown", e=>{ if(e.key==="Escape" && !authModal.classList.contains("hidden")) closeAuthModal(); });
// A hook for empty states to re-open the modal.
function askForPassword(msg){ openAuthModal(msg||"请输入访问密码。", false); }
// Theme toggle (light/dark). Default light; choice persisted in localStorage.
const SUN='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4.2"/><path d="M12 2v2.5M12 19.5V22M4.2 4.2l1.8 1.8M18 18l1.8 1.8M2 12h2.5M19.5 12H22M4.2 19.8l1.8-1.8M18 6l1.8-1.8"/></svg>';
const MOON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
const themeBtn=$("#themeBtn");
function applyTheme(t){ document.documentElement.dataset.theme=t; themeBtn.innerHTML = t==="dark"?SUN:MOON; themeBtn.setAttribute("title", t==="dark"?"切换到亮色":"切换到暗色"); }
applyTheme(document.documentElement.dataset.theme || "light");
themeBtn.addEventListener("click", ()=>{ const next = document.documentElement.dataset.theme==="dark"?"light":"dark"; localStorage.setItem("asr_theme",next); applyTheme(next); });
// Logout: clear saved password, return to the no-auth state + prompt modal.
const LOGOUT_ICON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>';
const logoutBtn=$("#logoutBtn"); logoutBtn.innerHTML=LOGOUT_ICON;
function logout(){
state.pw=""; localStorage.removeItem("asr_pw");
state.tasks=[]; state.sel=null;
renderList(); renderDetail();
openAuthModal("已退出。请重新输入访问密码。", false);
}
logoutBtn.addEventListener("click", logout);
async function api(path, opts={}){
const h = {...(opts.headers||{})};
if(state.pw) h["X-Api-Key"]=state.pw;
const r = await fetch(path, {...opts, headers:h});
if(r.status===401){
// Saved password is wrong/missing: clear it and ask via the modal.
state.pw=""; localStorage.removeItem("asr_pw");
openAuthModal("密码不正确或服务已重启,请重新输入。", true);
throw new ApiError("unauthorized");
}
if(r.status===204) return null;
let body=null; try{body=await r.json()}catch(e){}
if(!r.ok){ const m = body&&body.error ? body.error : (`HTTP ${r.status}`); throw new ApiError(m); }
return body;
}
class ApiError extends Error{constructor(m){super(m);this.name="ApiError"}}
function toast(msg,type){ const t=el("div",{class:"toast "+(type||"")}); t.textContent=msg; $("#toast").appendChild(t); setTimeout(()=>t.remove(),3200); }
// Copy to clipboard with a fallback for non-secure contexts (http via IP,
// where navigator.clipboard is unavailable). "done" flashes the button label.
function copyText(text,btn){
// Preserve the icon: only the .cb-label span is swapped for the "已复制 ✓"
// feedback, so the copy glyph stays after the first click.
const done=()=>{ if(btn){ const lbl=btn.querySelector(".cb-label"); const o=btn.dataset.label||(lbl?lbl.textContent:btn.textContent); if(lbl){ lbl.textContent="已复制 ✓"; } else { btn.textContent="已复制 ✓"; } btn.classList.add("copied"); setTimeout(()=>{ if(lbl){ lbl.textContent=o; } else { btn.textContent=o; } btn.classList.remove("copied"); },1400);} toast("已复制","ok"); };
const fail=(e)=>{ toast("复制失败"+(e&&e.message?": "+e.message:""),"err"); };
if(navigator.clipboard && window.isSecureContext && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(done).catch(()=>fallbackCopy(text,done,fail));
} else { fallbackCopy(text,done,fail); }
}
function fallbackCopy(text,done,fail){
try{
const ta=document.createElement("textarea"); ta.value=text;
ta.style.position="fixed"; ta.style.top="-1000px"; ta.style.opacity="0";
document.body.appendChild(ta); ta.focus(); ta.select();
const ok=document.execCommand("copy"); document.body.removeChild(ta);
ok ? done() : fail(new Error("execCommand 失败"));
}catch(e){ fail(e); }
}
const COPY_ICON='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>';
function mkCopyBtn(label, getText){
const b=el("button",{class:"copy-btn",type:"button"});
b.dataset.label=label;
b.innerHTML=COPY_ICON+'<span class="cb-label">'+label+'</span>';
b.onclick=()=>copyText(getText(), b);
return b;
}
async function loadHealth(){
try{
const h = await api("/healthz");
$("#htext").textContent = h.models?"模型就绪":"模型加载中";
$("#hdot").className = "dot "+(h.models?"ok":"down");
}catch(e){ $("#htext").textContent="服务不可达"; $("#hdot").className="dot down"; }
}
async function loadTasks(){
if(!state.pw){ renderList(); return; }
try{
const d = await api("/api/tasks");
state.tasks = d.tasks||[];
renderList(); renderDetail();
}catch(e){ if(!(e instanceof ApiError && /密码/.test(e.message))) toast(e.message,"err"); renderList(); }
}
function statusDot(s){ return s==="completed"?"ok":s==="failed"?"down":(s==="pending"||s==="processing")?"live":"dot"; }
function renderList(){
const c = $("#list"); c.innerHTML=""; $("#listCount").textContent=state.tasks.length?state.tasks.length:"";
logoutBtn.classList.toggle("hidden", !state.pw);
if(!state.pw){ const e=el("div",{class:"skeleton"}); e.innerHTML='🔒 需要访问密码 — <a href="#" class="link">点击输入</a>'; e.querySelector(".link").onclick=(ev)=>{ev.preventDefault(); askForPassword();}; c.appendChild(e); return; }
if(!state.tasks.length){
const e=el("div",{class:"skeleton"}); e.innerHTML='<span class="spinner"></span> 等待上传音频…'; c.appendChild(e); return;
}
state.tasks.forEach(t=>{
const row=el("div",{class:"task"+(t.id===state.sel?" selected":"")});
row.onclick=()=>{state.sel=t.id; renderList(); renderDetail();};
row.appendChild(el("span",{class:"dot "+statusDot(t.status),id:"x"})).className="sdot dot "+statusDot(t.status);
const m=el("div",{class:"main"});
m.appendChild(el("div",{class:"name",text:t.original_name||t.id}));
const meta=el("div",{class:"meta"});
meta.appendChild(el("span",{class:"badge b-"+t.status,text:t.status}));
meta.appendChild(el("span",{text:"· "+fmtTime(t.created_at)}));
if(t.attempts>1) meta.appendChild(el("span",{text:"· ×"+t.attempts}));
m.appendChild(meta);
row.appendChild(m);
c.appendChild(row);
});
}
function renderDetail(){
const c=$("#detail"); c.innerHTML="";
if(!state.pw){
const w=el("div",{class:"detail-empty"});
const dz=el("div",{class:"dropzone"});
dz.innerHTML='<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg><h3>需要访问密码</h3><p>本服务已启用密码保护。验证后本次会话不再提示。</p>';
const b=el("button",{class:"btn primary",text:"输入密码"}); b.onclick=()=>askForPassword();
dz.appendChild(b);
w.appendChild(dz); c.appendChild(w);
return;
}
if(!state.sel){ renderDropzone(c); return; }
const t = state.tasks.find(x=>x.id===state.sel);
if(!t){ renderDropzone(c); return; }
const body=el("div",{class:"detail-body"});
const title=el("div",{class:"detail-title"});
title.appendChild(el("h2",{text:t.original_name||t.id}));
title.appendChild(el("span",{class:"badge b-"+t.status,text:t.status}));
body.appendChild(title);
body.appendChild(el("div",{class:"detail-id",text:"id: "+t.id}));
const kv=el("div",{class:"kv"});
cell(kv,"创建",fmtTime(t.created_at));
cell(kv,"更新",fmtTime(t.updated_at));
cell(kv,"时长",t.result?fmtDur(t.result.duration):"—");
cell(kv,"尝试",t.attempts+"/"+(t.max_retries+1));
cell(kv,"语种",t.result&&t.result.lang?t.result.lang.replace(/[<>|]/g,""):"—");
cell(kv,"情绪",t.result&&t.result.emotion?t.result.emotion.replace(/[<>|]/g,""):"—");
body.appendChild(kv);
const act=el("div",{class:"actions"});
if(t.status==="failed"||t.status==="skipped"||t.status==="completed"){
act.appendChild(btn("重试","retry",()=>doAction(t.id,"retry")));
}
if(t.status==="failed"||t.status==="processing"||t.status==="pending"){
act.appendChild(btn("放弃 (继续队列)","skip ghost",()=>doAction(t.id,"skip")));
}
act.appendChild(btn("删除任务","danger",()=>{ if(confirm("删除该任务及其音频?")) doAction(t.id,"delete"); }));
body.appendChild(act);
if(t.error){
const lbl=el("div",{class:"section-label",text:"错误信息"});
body.appendChild(lbl);
body.appendChild(el("div",{class:"errbox",text:t.error}));
}
const r=t.result;
if(r){
const sl=el("div",{class:"section-label"}); sl.appendChild(document.createTextNode("转写全文"));
sl.appendChild(mkCopyBtn("复制全文",()=>r.text||""));
body.appendChild(sl);
body.appendChild(el("div",{class:"transcript"+(r.text?"":" empty"),text:r.text||"(无识别内容)"}));
if(r.segments&&r.segments.length){
body.appendChild(el("div",{class:"section-label",text:"分段 ("+r.segments.length+")"}));
const segs=el("div",{class:"transcript"});
r.segments.forEach((s,i)=>{
const row=el("div",{class:"seg"});
const tsLabel="["+(s.start?secs(s.start):"--")+" → "+(s.end!=null?secs(s.end):"--")+"]";
const ts=mkCopyBtn(tsLabel,()=>tsLabel);
ts.classList.add("ts"); ts.setAttribute("title","复制时间轴");
ts.innerHTML=COPY_ICON+'<span class="cb-label">'+tsLabel+'</span>';
const tx=el("div",{class:"stext"});
tx.append(document.createTextNode(s.text||""));
if(s.lang){ const L=el("span",{class:"slang",text:s.lang.replace(/[<>|]/g,"")}); tx.appendChild(L); }
const cb=mkCopyBtn("复制",()=>s.text||""); cb.classList.add("seg-copy");
row.appendChild(ts); row.appendChild(tx); row.appendChild(cb); segs.appendChild(row);
});
body.appendChild(segs);
}
} else if(t.status==="processing"||t.status==="pending"){
body.appendChild(el("div",{class:"section-label"}));
const sp=el("div",{class:"skeleton"}); sp.innerHTML='<span class="spinner"></span> 正在转写,请稍候…'; body.appendChild(sp);
}
c.appendChild(body);
}
function cell(parent,k,v){ const c=el("div",{class:"cell"}); c.appendChild(el("div",{class:"k",text:k})); c.appendChild(el("div",{class:"v",text:String(v)})); parent.appendChild(c); }
function btn(label,cls,fn){ const b=el("button",{class:"btn "+cls,text:label}); b.onclick=fn; return b; }
function secs(x){ const m=Math.floor(x/60),s=(x%60).toFixed(1); return m+":"+s.padStart(4,"0"); }
function emptyState(title,sub){ const w=el("div",{class:"detail-empty"}); const d=el("div",{class:"dropzone"}); d.innerHTML=`<h3 style="margin:0">${title}</h3><p style="margin:6px 0 0">${sub}</p>`; w.appendChild(d); return w; }
function renderDropzone(c){
const w=el("div",{class:"detail-empty"});
const dz=el("div",{class:"dropzone"});
dz.innerHTML=`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 16V4M5 11l7-7 7 7M4 20h16"/></svg><h3>拖拽音频或视频到这里,或点击上传</h3><p>音频直接转写;视频会自动提取音轨。约 1~3 秒出结果。</p><div class="ext">音频 .wav/.mp3/.flac/.ogg/.m4a/.aac/.opus · 视频 .mp4/.mkv/.mov/.avi/.ts…</div>`;
dz.onclick=()=>$("#file").click();
dz.addEventListener("dragover",e=>{e.preventDefault();dz.classList.add("drag")});
dz.addEventListener("dragleave",()=>dz.classList.remove("drag"));
dz.addEventListener("drop",e=>{e.preventDefault();dz.classList.remove("drag"); if(e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);});
w.appendChild(dz); c.appendChild(w);
}
async function uploadFiles(files){
if(!state.pw){ askForPassword("请先输入访问密码后再上传。"); return; }
if(!files||!files.length) return;
for(const f of files){
const fd=new FormData(); fd.append("file",f);
toast("上传中: "+f.name);
try{
const t=await api("/api/tasks",{method:"POST",body:fd});
toast("已加入队列: "+(t.original_name||t.id),"ok");
state.sel=t.id;
}catch(e){ toast(e.message,"err"); }
}
await loadTasks();
}
$("#file").addEventListener("change",e=>{ if(e.target.files.length) uploadFiles(e.target.files); e.target.value=""; });
$("#uploadBtn").addEventListener("click",()=>$("#file").click());
// Tab switching: console <-> API reference.
document.querySelectorAll(".tab").forEach(t=>t.addEventListener("click",()=>{
document.querySelectorAll(".tab").forEach(x=>x.classList.remove("active"));
t.classList.add("active");
const v=t.dataset.view;
$("#consoleView").classList.toggle("hidden", v!=="console");
$("#apiView").classList.toggle("hidden", v!=="api");
}));
$("#apiBase").textContent = location.origin;
// global drop
window.addEventListener("dragover",e=>e.preventDefault());
window.addEventListener("drop",e=>{e.preventDefault(); if(e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files);});
async function doAction(id,action){
const path = action==="delete" ? `/api/tasks/${id}` : `/api/tasks/${id}/${action}`;
const method = action==="delete"?"DELETE":"POST";
try{
await api(path,{method});
toast(action==="delete"?"已删除":action==="retry"?"已重新排队":"已放弃","ok");
if(action==="delete"){ if(state.sel===id) state.sel=null; }
await loadTasks();
}catch(e){ toast(e.message,"err"); }
}
async function refresh(){ await Promise.all([loadHealth(), loadTasks()]); }
// If a saved password exists, try it silently; a 401 will reopen the modal.
// Otherwise prompt for it up front via the modal.
if(state.pw){ refresh(); } else { loadHealth(); openAuthModal("请输入访问密码。验证后本次会话不再提示。"); }
// Background refresh — skip task fetches while the password modal is open to
// avoid spamming 401s; health still updates.
setInterval(()=>{ if(state.authing){ loadHealth(); return; } loadHealth(); loadTasks(); }, 2500);
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
package store
import "errors"
// ErrNotFound is returned when a task id does not exist.
var ErrNotFound = errors.New("task not found")
+386
View File
@@ -0,0 +1,386 @@
// Package store persists transcription tasks to disk.
//
// State lives in <DataDir>/tasks.json plus <DataDir>/uploads/<id><ext> audio
// files. The store is the single source of truth for the task queue and is
// safe for concurrent use from the HTTP handlers and the worker.
package store
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"sync"
"time"
"git.misaka.ren/m1saka/record-asr/internal/transcribe"
)
const (
StatusPending = "pending"
StatusProcessing = "processing"
StatusCompleted = "completed"
StatusFailed = "failed"
StatusSkipped = "skipped" // user chose to abandon a failed task ("continue")
)
// Task is the persisted representation of one transcription request.
type Task struct {
ID string `json:"id"`
Filename string `json:"filename"` // path relative to DataDir, e.g. uploads/<id>.wav
OriginalName string `json:"original_name"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Attempts int `json:"attempts"`
MaxRetries int `json:"max_retries"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
Error string `json:"error,omitempty"`
Duration float64 `json:"duration,omitempty"`
Result *transcribe.Result `json:"result,omitempty"`
}
// IsTerminal reports whether the task is in a state that needs no more work.
func (t *Task) IsTerminal() bool {
return t.Status == StatusCompleted || t.Status == StatusSkipped
}
// Store is the on-disk task repository.
type Store struct {
dir string
tasksFile string
uploadsDir string
mu sync.Mutex
tasks map[string]*Task
}
// Open initializes the store at dir, creating directories and loading any
// persisted tasks. Tasks left "processing" from a crash are reset to
// "pending" so they re-run on the next worker tick (the crash-recovery half
// of the "continue on error" contract).
func Open(dir string) (*Store, error) {
uploads := filepath.Join(dir, "uploads")
for _, d := range []string{dir, uploads} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, fmt.Errorf("mkdir %s: %w", d, err)
}
}
s := &Store{
dir: dir,
tasksFile: filepath.Join(dir, "tasks.json"),
uploadsDir: uploads,
tasks: map[string]*Task{},
}
if err := s.load(); err != nil {
return nil, err
}
return s, nil
}
func (s *Store) load() error {
data, err := os.ReadFile(s.tasksFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read tasks file: %w", err)
}
var list []*Task
if err := json.Unmarshal(data, &list); err != nil {
return fmt.Errorf("parse tasks file: %w", err)
}
for _, t := range list {
if t.ID == "" {
continue
}
// Crash recovery: a task that was mid-processing when we died cannot
// be trusted; requeue it.
if t.Status == StatusProcessing {
t.Status = StatusPending
t.UpdatedAt = time.Now().UTC()
}
s.tasks[t.ID] = t
}
return nil
}
// save persists all tasks atomically. Caller must hold s.mu.
func (s *Store) save() error {
list := s.snapshotLocked()
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := s.tasksFile + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.tasksFile)
}
// Create reads an uploaded audio blob, stores it, and enqueues a pending task.
func (s *Store) Create(originalName string, r io.Reader, maxBytes int64, maxRetries int) (*Task, error) {
ext := filepath.Ext(originalName)
id := newID()
rel := filepath.Join("uploads", id+ext)
dst := filepath.Join(s.dir, rel)
// Copy with a size cap so a malicious upload can't exhaust disk.
tmp := dst + ".tmp"
f, err := os.Create(tmp)
if err != nil {
return nil, fmt.Errorf("create upload: %w", err)
}
n, err := io.Copy(f, &limitedReader{r: r, max: maxBytes})
f.Close()
if err != nil {
os.Remove(tmp)
return nil, fmt.Errorf("read upload: %w", err)
}
if n >= maxBytes {
// Could be exactly at the limit; treat hitting the cap as too large
// unless the reader is now exhausted. We err on the side of caution.
}
if err := os.Rename(tmp, dst); err != nil {
os.Remove(tmp)
return nil, fmt.Errorf("save upload: %w", err)
}
now := time.Now().UTC()
t := &Task{
ID: id,
Filename: rel,
OriginalName: originalName,
Status: StatusPending,
CreatedAt: now,
UpdatedAt: now,
MaxRetries: maxRetries,
}
s.mu.Lock()
s.tasks[id] = t
serr := s.save()
s.mu.Unlock()
if serr != nil {
return nil, fmt.Errorf("persist task: %w", serr)
}
return t, nil
}
// Get returns a copy of a task by id, or nil if absent.
func (s *Store) Get(id string) *Task {
s.mu.Lock()
defer s.mu.Unlock()
if t, ok := s.tasks[id]; ok {
cp := *t
return &cp
}
return nil
}
// List returns all tasks, newest-first.
func (s *Store) List() []*Task {
s.mu.Lock()
defer s.mu.Unlock()
list := s.snapshotLocked()
sort.Slice(list, func(i, j int) bool {
return list[i].CreatedAt.After(list[j].CreatedAt)
})
return list
}
func (s *Store) snapshotLocked() []*Task {
list := make([]*Task, 0, len(s.tasks))
for _, t := range s.tasks {
cp := *t
list = append(list, &cp)
}
return list
}
// Delete removes a task and its uploaded audio file.
func (s *Store) Delete(id string) error {
s.mu.Lock()
t, ok := s.tasks[id]
if !ok {
s.mu.Unlock()
return ErrNotFound
}
delete(s.tasks, id)
serr := s.save()
s.mu.Unlock()
if serr != nil {
return fmt.Errorf("persist deletion: %w", serr)
}
// Best-effort file cleanup; a missing file is not an error.
if t != nil && t.Filename != "" {
_ = os.Remove(filepath.Join(s.dir, t.Filename))
}
return nil
}
// Claim atomically selects the oldest runnable task (pending and whose retry
// timer has elapsed) and marks it processing. Returns nil if none is ready.
// This is how multiple workers avoid double-processing.
func (s *Store) Claim(now time.Time) *Task {
s.mu.Lock()
defer s.mu.Unlock()
var pick *Task
var pickT time.Time
for _, t := range s.tasks {
if t.Status != StatusPending {
continue
}
if t.NextRetryAt != nil && t.NextRetryAt.After(now) {
continue
}
if pick == nil || t.CreatedAt.Before(pickT) {
pick = t
pickT = t.CreatedAt
}
}
if pick == nil {
return nil
}
pick.Status = StatusProcessing
pick.Attempts++
pick.UpdatedAt = now.UTC()
cp := *pick
_ = s.save()
return &cp
}
// Complete marks a task done with its transcript.
func (s *Store) Complete(id string, res *transcribe.Result) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return ErrNotFound
}
t.Status = StatusCompleted
t.Result = res
if res != nil {
t.Duration = res.Duration
}
t.Error = ""
t.NextRetryAt = nil
t.UpdatedAt = time.Now().UTC()
return s.save()
}
// Fail records a processing failure. If attempts remain within MaxRetries the
// task is requeued (pending) with an exponential backoff; otherwise it is
// marked failed and left for manual retry or skip.
func (s *Store) Fail(id, errMsg string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return ErrNotFound
}
t.Error = errMsg
t.UpdatedAt = time.Now().UTC()
if t.Attempts <= t.MaxRetries {
// Retry: requeue with backoff.
t.Status = StatusPending
at := time.Now().UTC().Add(backoff(t.Attempts))
t.NextRetryAt = &at
} else {
t.Status = StatusFailed
t.NextRetryAt = nil
}
return s.save()
}
// Retry manually requeues a (typically failed) task: resets the attempt count
// and clears any error so the worker picks it up immediately.
func (s *Store) Retry(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return ErrNotFound
}
t.Status = StatusPending
t.Attempts = 0
t.Error = ""
t.NextRetryAt = nil
t.UpdatedAt = time.Now().UTC()
return s.save()
}
// Skip abandons a task: it becomes terminal ("skipped") and the queue moves
// on. This is the "continue" half of error handling — give up on this one.
func (s *Store) Skip(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return ErrNotFound
}
t.Status = StatusSkipped
t.NextRetryAt = nil
t.UpdatedAt = time.Now().UTC()
return s.save()
}
// AudioPath returns the absolute path to a task's uploaded audio file.
func (s *Store) AudioPath(t *Task) string {
return filepath.Join(s.dir, t.Filename)
}
// Has reports whether a task id exists.
func (s *Store) Has(id string) bool {
s.mu.Lock()
defer s.mu.Unlock()
_, ok := s.tasks[id]
return ok
}
// backoff is an exponential schedule capped at 60s.
func backoff(attempts int) time.Duration {
d := time.Duration(5<<uint(attempts-1)) * time.Second // 5s, 10s, 20s, 40s...
if d > 60*time.Second {
d = 60 * time.Second
}
if d < 1*time.Second {
d = 1 * time.Second
}
return d
}
// newID returns a short, collision-resistant id (8 random bytes hex).
func newID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
// Fallback to time-based if crypto/rand ever fails.
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
// limitedReader wraps io.Reader with a hard byte cap; exceeding it returns an
// error so the upload is rejected before it fills the disk.
type limitedReader struct {
r io.Reader
max int64
n int64
}
func (l *limitedReader) Read(p []byte) (int, error) {
if l.n >= l.max {
return 0, fmt.Errorf("upload exceeds %d bytes", l.max)
}
remain := l.max - l.n
if int64(len(p)) > remain {
p = p[:remain]
}
n, err := l.r.Read(p)
l.n += int64(n)
return n, err
}
+190
View File
@@ -0,0 +1,190 @@
// Package transcribe ties the silero VAD and the SenseVoice recognizer together
// into a single "audio file -> transcript" pipeline.
//
// The VAD is stateful, so segmentation of one file cannot interleave with
// another; the manager guards VAD access with a mutex. Recognition of the
// resulting speech segments is stateless and could be parallelized.
package transcribe
import (
"fmt"
"strings"
sherpa "github.com/k2-fsa/sherpa-onnx-go/sherpa_onnx"
"git.misaka.ren/m1saka/record-asr/internal/audio"
"git.misaka.ren/m1saka/record-asr/internal/models"
)
// Segment is one VAD-detected speech region with its transcript.
type Segment struct {
Text string `json:"text"`
Start float64 `json:"start"` // seconds from file start
End float64 `json:"end"` // seconds from file start
Lang string `json:"lang"`
}
// Result is the full transcript for one audio file.
type Result struct {
Text string `json:"text"` // all segments joined by newline
Lang string `json:"lang"` // dominant detected language
Emotion string `json:"emotion"` // dominant detected emotion
Duration float64 `json:"duration"` // audio duration in seconds
Segments []Segment `json:"segments"`
}
// New returns a Pipeline bound to the given model manager.
func New(m *models.Manager) *Pipeline {
return &Pipeline{m: m}
}
// Pipeline wraps a model manager to provide file transcription.
type Pipeline struct {
m *models.Manager
}
// Transcribe decodes the audio file at path, runs VAD to isolate speech, and
// recognizes each segment with SenseVoice.
func (p *Pipeline) Transcribe(path string) (*Result, error) {
samples, sampleRate, err := audio.Decode(path)
if err != nil {
return nil, err
}
if sampleRate != audio.TargetSampleRate {
return nil, fmt.Errorf("internal: expected 16kHz samples, got %d", sampleRate)
}
duration := float64(len(samples)) / float64(audio.TargetSampleRate)
segments, err := p.segment(samples)
if err != nil {
return nil, err
}
res := &Result{
Duration: duration,
Segments: make([]Segment, 0, len(segments)),
}
var texts []string
for _, seg := range segments {
// Recognize, splitting extremely long segments (should not happen
// given the VAD MaxSpeechDuration cap, but guards against pathological
// inputs that exceed SenseVoice's ~30s comfort window).
parts := splitForRecognize(seg.Samples, 28*audio.TargetSampleRate)
var segTexts []string
var segLang, segEmotion string
segStart := float64(seg.Start) / float64(audio.TargetSampleRate)
for _, part := range parts {
txt, lang, emo, perr := p.recognize(part)
if perr != nil {
return nil, fmt.Errorf("recognize segment at %.2fs: %w", segStart, perr)
}
if txt != "" {
segTexts = append(segTexts, txt)
}
if segLang == "" {
segLang, segEmotion = lang, emo
}
}
segText := strings.Join(segTexts, " ")
segEnd := segStart + float64(len(seg.Samples))/float64(audio.TargetSampleRate)
if segText == "" {
segText = " "
}
res.Segments = append(res.Segments, Segment{
Text: segText,
Start: segStart,
End: segEnd,
Lang: segLang,
})
texts = append(texts, segText)
if res.Lang == "" {
res.Lang, res.Emotion = segLang, segEmotion
}
}
res.Text = strings.Join(texts, "\n")
return res, nil
}
// segment runs the silero VAD over the full sample buffer and returns the
// completed speech segments. The VAD is stateful and shared, so this is the
// only place that touches it, under the manager's lock, and it resets the
// detector before use so no state leaks between files.
func (p *Pipeline) segment(samples []float32) ([]sherpa.SpeechSegment, error) {
if p.m.VAD == nil {
return nil, fmt.Errorf("VAD not loaded")
}
p.m.VadMu().Lock()
defer p.m.VadMu().Unlock()
vad := p.m.VAD
vad.Reset()
// Feed in window-sized chunks; drain completed segments after each feed.
w := 512 // silero window at 16kHz
if len(samples) > 0 && w > len(samples) {
w = len(samples)
}
var out []sherpa.SpeechSegment
drain := func() {
for !vad.IsEmpty() {
seg := vad.Front()
if seg == nil {
break
}
// Copy because Front()'s backing memory is freed on Pop().
cp := sherpa.SpeechSegment{Start: seg.Start, Samples: append([]float32(nil), seg.Samples...)}
out = append(out, cp)
vad.Pop()
}
}
for i := 0; i < len(samples); i += w {
end := i + w
if end > len(samples) {
end = len(samples)
}
vad.AcceptWaveform(samples[i:end])
drain()
}
vad.Flush()
drain()
return out, nil
}
// recognize runs SenseVoice over a single contiguous speech chunk.
func (p *Pipeline) recognize(samples []float32) (text, lang, emotion string, err error) {
if len(samples) == 0 {
return "", "", "", nil
}
if p.m.Recognizer == nil {
return "", "", "", fmt.Errorf("recognizer not loaded")
}
stream := sherpa.NewOfflineStream(p.m.Recognizer)
if stream == nil {
return "", "", "", fmt.Errorf("create offline stream failed")
}
defer sherpa.DeleteOfflineStream(stream)
stream.AcceptWaveform(audio.TargetSampleRate, samples)
p.m.Recognizer.Decode(stream)
r := stream.GetResult()
if r == nil {
return "", "", "", nil
}
return r.Text, r.Lang, r.Emotion, nil
}
// splitForRecognize breaks a sample slice into chunks no longer than maxLen to
// stay within the recognizer's comfortable input window.
func splitForRecognize(samples []float32, maxLen int) [][]float32 {
if maxLen <= 0 || len(samples) <= maxLen {
return [][]float32{samples}
}
var parts [][]float32
for i := 0; i < len(samples); i += maxLen {
end := i + maxLen
if end > len(samples) {
end = len(samples)
}
parts = append(parts, samples[i:end])
}
return parts
}
+64
View File
@@ -0,0 +1,64 @@
// Command record-asr runs an HTTP transcription service backed by
// SenseVoiceSmall (int8) and a silero VAD, served by the sherpa-onnx Go
// bindings. Models are auto-downloaded on first run. All runtime knobs come
// from ASR_* environment variables; an access password can be set via
// ASR_PASSWORD.
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"git.misaka.ren/m1saka/record-asr/internal/audio"
"git.misaka.ren/m1saka/record-asr/internal/config"
"git.misaka.ren/m1saka/record-asr/internal/models"
"git.misaka.ren/m1saka/record-asr/internal/server"
"git.misaka.ren/m1saka/record-asr/internal/store"
"git.misaka.ren/m1saka/record-asr/internal/transcribe"
)
func main() {
cfg, err := config.Load()
if err != nil {
fmt.Fprintf(os.Stderr, "config: %v\n", err)
os.Exit(1)
}
log.Printf("config: %s", cfg)
// Configure the audio decoder (ffmpeg path + per-file decode timeout)
// before anything tries to transcribe.
audio.Configure(cfg.FFmpegPath, cfg.DecodeTimeout)
// Root context cancelled on SIGINT/SIGTERM for a clean shutdown. The model
// manager and HTTP server both respect it.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Models first — they auto-download and load before we serve traffic.
mgr, err := models.New(ctx, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "model init: %v\n", err)
os.Exit(1)
}
defer mgr.Close()
// Persisted task store + the transcription pipeline.
st, err := store.Open(cfg.DataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "store: %v\n", err)
os.Exit(1)
}
pipe := transcribe.New(mgr)
srv := server.New(cfg, st, pipe, mgr)
if err := srv.Run(ctx); err != nil {
// http.Server already logged ErrServerClosed path.
fmt.Fprintf(os.Stderr, "server: %v\n", err)
os.Exit(1)
}
log.Printf("shutdown complete")
}