3525e6b8fb
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>
113 lines
3.2 KiB
Go
113 lines
3.2 KiB
Go
// 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
|
|
}
|
|
}
|
|
}
|