Resumable /api/uploads flow (init -> append -> complete -> abort) so a large recording uploads as many small slices (<= ASR_CHUNK_SIZE_MB, default 50MB) instead of one request a size-capping proxy (Cloudflare's 100MB) would reject. The web console auto-routes files larger than one slice through the chunked path with cross-slice progress and transient-error retry; smaller files keep the single-shot upload. - store: in-memory upload sessions + .part reassembly (offset writes, out-of-order/idempotent), reuse upload id as task id, orphan sweep on start, idle-session GC - fix limitedReader rejecting bodies exactly at the cap (broke full chunks) - fix drag-drop double-firing when a drop lands on the dropzone (its drop handler now stopPropagation so the window listener doesn't re-upload) - tests: store + HTTP flow, plus a 120MB reassembly test (ASR_TEST_LARGE)
212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
// 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_CHUNK_SIZE_MB: size of each slice in a resumable chunked upload,
|
|
// kept well under size-capping proxies' body limits so a large file can
|
|
// be uploaded as many small requests instead of one that a CDN (e.g.
|
|
// Cloudflare's 100MB cap) would reject. Default 50.
|
|
ChunkSize 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,
|
|
ChunkSize: int64(envInt("ASR_CHUNK_SIZE_MB", 50)) * 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
|
|
}
|
|
// Chunk size must be > 1MB to be useful and < 100MB so each slice clears
|
|
// common CDN body caps (e.g. Cloudflare's 100MB) with room to spare.
|
|
if c.ChunkSize < 1<<20 {
|
|
c.ChunkSize = 50 << 20
|
|
}
|
|
if c.ChunkSize > 100<<20 {
|
|
c.ChunkSize = 100 << 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 chunk=%dB 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.ChunkSize, 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
|
|
}
|