// Package asr transcribes bounded PCM segments via qwen-asr // (https://github.com/antirez/qwen-asr), invoked as a one-shot subprocess // per segment rather than a long-lived daemon. // // This was verified empirically, not assumed: `qwen_asr --stdin --stream // --silent` does not print incremental partial text as it processes a // single stdin stream — it prints only the final transcription once done, // even in --stream mode (that flag changes its internal chunked-encoding // strategy, not what gets written to stdout). Measured latency including // process startup and model load, on the 0.6B model, was ~0.15-0.2x // realtime for a ~6s clip — comfortably fast enough to spawn fresh per VAD // segment instead of keeping a persistent process fed over a pipe. // Our own VAD segment boundaries (internal/vad) are therefore what stands // in for "live" here: each vad.Segment.IsFinal maps directly to the // SSE message's is_final field described in CLAUDE.md. package asr import ( "bytes" "context" "fmt" "os" "strconv" "strings" ) // Config points at a built qwen_asr binary and a downloaded model // directory. Neither has an established default location yet (unlike // audiotee's ~/bin convention), so both are required. type Config struct { BinaryPath string ModelDir string // Prompt biases transcription toward glossary terms, e.g. // "Preserve spelling: Kubernetes, PostgreSQL". Optional. Prompt string // Threads sets qwen_asr's -t flag. Zero means "let it pick" (all CPUs). Threads int } type Transcriber struct { cfg Config run runFunc // seam for testing without a real binary } func New(cfg Config) *Transcriber { return &Transcriber{cfg: cfg, run: runCommand} } // Transcribe runs qwen_asr once against pcm (raw s16le, 16kHz, mono — the // format both audiotee and internal/vad produce) and returns the // transcribed text, trimmed. func (t *Transcriber) Transcribe(ctx context.Context, pcm []byte) (string, error) { args := []string{"-d", t.cfg.ModelDir, "--stdin", "--stream", "--silent"} if t.cfg.Prompt != "" { args = append(args, "--prompt", t.cfg.Prompt) } if t.cfg.Threads > 0 { args = append(args, "-t", strconv.Itoa(t.cfg.Threads)) } stdout, stderr, err := t.run(ctx, t.cfg.BinaryPath, args, pcm) if err != nil { return "", fmt.Errorf("qwen_asr: %w (stderr: %s)", err, bytes.TrimSpace(stderr)) } text := strings.TrimSpace(string(stdout)) if looksLikePromptLeak(text, t.cfg.Prompt) { // Discard rather than return it as a genuine transcription — see // leak.go. Logged here (not just silently dropped) since this // package has no structured logger of its own; matches the plain // stderr diagnostics the CLI already prints elsewhere. fmt.Fprintf(os.Stderr, "asr: discarding segment, looks like a --prompt leak, not a transcription: %q\n", text) return "", nil } return text, nil }