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
+112
View File
@@ -0,0 +1,112 @@
// Package transcript defines the live transcript message format (see
// CLAUDE.md) and a Broadcaster that fans a single stream of messages out to
// any number of SSE clients — the merge stage's output contract.
//
// Ordering note: messages are broadcast in the order transcribeTrack
// produces them, not re-sorted by Timestamp. With two tracks processed
// independently, a slower track could in principle publish slightly out of
// chronological order relative to the other. Measured ASR latency
// (~0.15-0.2x realtime) is small relative to segment spacing (3-5s), so
// this hasn't been observed in practice — add a small reordering buffer
// here if it ever becomes visible, not before.
package transcript
import (
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"github.com/stephanetailland/transcriptor-ai/internal/capture"
)
// Message is one transcript segment, matching the SSE JSON format
// documented in CLAUDE.md.
type Message struct {
Track capture.Track `json:"track"`
Text string `json:"text"`
IsFinal bool `json:"is_final"`
Timestamp time.Time `json:"timestamp"`
}
// clientBuffer bounds how many undelivered messages a slow SSE client can
// accumulate before new messages are dropped for it, so one stalled client
// can't back-pressure the whole pipeline.
const clientBuffer = 32
// Broadcaster fans Published messages out to every currently-connected SSE
// client and doubles as the http.Handler for the SSE endpoint.
type Broadcaster struct {
mu sync.Mutex
clients map[chan Message]struct{}
}
func NewBroadcaster() *Broadcaster {
return &Broadcaster{clients: make(map[chan Message]struct{})}
}
// Publish sends msg to every connected client. Non-blocking: a client whose
// buffer is full misses the message rather than stalling the sender.
func (b *Broadcaster) Publish(msg Message) {
b.mu.Lock()
defer b.mu.Unlock()
for ch := range b.clients {
select {
case ch <- msg:
default:
}
}
}
func (b *Broadcaster) subscribe() chan Message {
ch := make(chan Message, clientBuffer)
b.mu.Lock()
b.clients[ch] = struct{}{}
b.mu.Unlock()
return ch
}
func (b *Broadcaster) unsubscribe(ch chan Message) {
b.mu.Lock()
delete(b.clients, ch)
b.mu.Unlock()
}
// ServeHTTP streams messages to the client as Server-Sent Events until the
// client disconnects. See CLAUDE.md for why SSE over WebSocket: the flow is
// one-directional, and this needs no client library beyond EventSource in a
// browser or a plain HTTP client in a terminal (e.g. `curl -N`).
func (b *Broadcaster) ServeHTTP(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
flusher.Flush()
ch := b.subscribe()
defer b.unsubscribe(ch)
for {
select {
case msg, ok := <-ch:
if !ok {
return
}
data, err := json.Marshal(msg)
if err != nil {
continue
}
fmt.Fprintf(w, "data: %s\n\n", data)
flusher.Flush()
case <-r.Context().Done():
return
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package transcript
import (
"fmt"
"io"
"os"
"sync"
)
// FileWriter appends each Message to a text file as a human-readable line,
// flushed to disk immediately. Unlike Broadcaster.Publish (which drops
// messages for a slow/stalled SSE client on purpose), FileWriter never
// drops — this is the durable record a crash shouldn't lose data from, so
// every Write is synchronous and fsync'd before returning.
type FileWriter struct {
mu sync.Mutex
f *os.File
}
// NewFileWriter opens path for appending, creating it if needed. Appending
// (not truncating) means restarting transcriptor-ai against the same path
// continues the same transcript file rather than discarding it.
func NewFileWriter(path string) (*FileWriter, error) {
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open transcript file: %w", err)
}
return &FileWriter{f: f}, nil
}
// Write appends one line for msg and fsyncs before returning.
func (fw *FileWriter) Write(msg Message) error {
fw.mu.Lock()
defer fw.mu.Unlock()
line := fmt.Sprintf("[%s] [%s] %s\n", msg.Timestamp.Format("15:04:05"), msg.Track, msg.Text)
if _, err := io.WriteString(fw.f, line); err != nil {
return fmt.Errorf("write transcript line: %w", err)
}
if err := fw.f.Sync(); err != nil {
return fmt.Errorf("sync transcript file: %w", err)
}
return nil
}
func (fw *FileWriter) Close() error {
fw.mu.Lock()
defer fw.mu.Unlock()
return fw.f.Close()
}