// 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 } } }