initial commit: live meeting transcription pipeline
Go orchestrator: spawns audiotee for capture (system audio + mic, two tracks), segments with an energy-threshold VAD, transcribes each segment via qwen-asr (subprocess per segment, vendored as a pinned git submodule in third_party/qwen-asr), and serves the live transcript over SSE while also writing it durably to a text file. Includes a glossary/prompt-leakage guard (internal/asr/leak.go) that discards a segment if the model echoes the biasing prompt instead of transcribing. cmd/transcriptor-ai is the main orchestrator; cmd/transcript-tail is a minimal terminal SSE client. See CLAUDE.md for the full architecture and the reasoning behind each choice (Go over Python/Swift/Rust, SSE over WebSocket, why the VAD is a hand-rolled heuristic, etc). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user