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:
sttlab-tech
2026-08-09 13:18:57 +02:00
commit 3525e6b8fb
16 changed files with 1542 additions and 0 deletions
+77
View File
@@ -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
}
+61
View File
@@ -0,0 +1,61 @@
package asr
import (
"strings"
"unicode"
)
// looksLikePromptLeak reports whether text appears to be the model echoing
// the --prompt biasing content back verbatim instead of genuinely
// transcribing the segment's audio — a known failure mode of
// prompt-conditioned generation, confirmed via real testing against
// qwen_asr (see transcriptor-ui/CLAUDE.md, "Glossary prompt-leakage bug"):
// a whole segment came back as literally the glossary terms, twice, on two
// separate 5-minute test runs, even after switching to the tool's
// recommended "Preserve spelling: ..." prompt framing (which reduces but
// does not eliminate the risk).
//
// Heuristic: if most of text's words also appear among prompt's words, and
// there are enough of them to rule out a legitimate short mention of one
// or two glossary terms in real speech, treat it as a leak. Both observed
// real leaks were near-total reproductions of the prompt's term list, so a
// strict threshold (80% word overlap, at least 4 words) catches them
// without plausibly false-flagging a normal sentence that happens to
// mention "Claude" or "Mistral" once — normal speech has enough other
// words to keep the overlap ratio well below that.
func looksLikePromptLeak(text, prompt string) bool {
if prompt == "" || text == "" {
return false
}
textWords := normalizeWords(text)
if len(textWords) < 4 {
return false
}
promptWords := wordSet(normalizeWords(prompt))
matched := 0
for _, w := range textWords {
if promptWords[w] {
matched++
}
}
return float64(matched)/float64(len(textWords)) >= 0.8
}
// normalizeWords lowercases and splits on anything that isn't a letter or
// digit — unicode.IsLetter (not an ASCII-only check) so accented
// characters (French: é, è, ï, ç, ...) stay part of their word instead of
// being split into fragments.
func normalizeWords(s string) []string {
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
func wordSet(words []string) map[string]bool {
set := make(map[string]bool, len(words))
for _, w := range words {
set[w] = true
}
return set
}
+28
View File
@@ -0,0 +1,28 @@
package asr
import (
"bytes"
"context"
"os/exec"
"syscall"
)
// runFunc executes binary with args, feeding stdin, and returns
// stdout/stderr. It's a seam so tests can substitute a fake process instead
// of requiring the real qwen_asr binary and a multi-GB model on disk.
type runFunc func(ctx context.Context, binary string, args []string, stdin []byte) (stdout, stderr []byte, err error)
func runCommand(ctx context.Context, binary string, args []string, stdin []byte) ([]byte, []byte, error) {
cmd := exec.CommandContext(ctx, binary, args...)
cmd.Stdin = bytes.NewReader(stdin)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
// Run qwen_asr in its own process group so the terminal's Ctrl+C
// (SIGINT to the whole foreground process group) doesn't kill an
// in-flight transcription directly. It's still cancelable through ctx,
// same as before — this only stops the shell from also signaling it.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
err := cmd.Run()
return stdout.Bytes(), stderr.Bytes(), err
}