Files
sttlab-tech 3525e6b8fb 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>
2026-08-09 13:18:57 +02:00

218 lines
7.0 KiB
Go

// Package vad turns a stream of raw audio chunks into bounded speech
// segments using a simple energy-threshold heuristic — no ML model. See
// transcriptor-ai/CLAUDE.md for why: no Go/Swift ML ecosystem was mature
// enough to justify the integration cost for this. Revisit only if
// segmentation quality proves to be the actual bottleneck.
package vad
import (
"encoding/binary"
"math"
"time"
"github.com/stephanetailland/transcriptor-ai/internal/capture"
)
// Segment is a bounded span of speech audio ready for ASR.
type Segment struct {
Track capture.Track
Data []byte // raw PCM, concatenation of the contributing chunks
Start time.Time
End time.Time
// IsFinal is true when silence closed the utterance, false when the
// segment was force-cut at MaxSegmentDuration and more audio for the
// same utterance follows in the next segment (which starts with
// Config.Overlap worth of audio repeated for ASR context continuity).
IsFinal bool
}
// Config controls the energy-threshold heuristic. All durations are
// expressed in wall-clock time; SampleRate converts them to byte counts.
type Config struct {
SampleRate int
// SilenceThresholdDB is the RMS level (dBFS, 0 = full scale) below which
// a chunk is considered silence. Real speech in testing sits around -40
// to -20 dBFS; captured silence sits around -90 dBFS. Needs empirical
// tuning against real meeting audio — this default is a starting point,
// not a validated value.
SilenceThresholdDB float64
// SilenceHangover is how long silence must persist after speech before
// the segment is closed as final (IsFinal: true).
SilenceHangover time.Duration
// MinSpeechDuration is the minimum contiguous voiced audio required
// before a segment starts accumulating, to filter transient noise
// (clicks, pops) rather than opening a segment on every blip.
MinSpeechDuration time.Duration
// MaxSegmentDuration force-cuts a segment even mid-speech, so a long
// monologue still produces incremental, timely output instead of one
// unbounded segment.
MaxSegmentDuration time.Duration
// Overlap is how much trailing audio is carried into the next segment
// when force-cut, for ASR context continuity across the cut. Tradeoff:
// the carried-over audio gets transcribed again, and both copies are
// published — a nonzero Overlap produces visible duplicate text at
// every force-cut boundary (confirmed in real testing: this is worse
// than the word occasionally getting cut in half at a hard boundary,
// which is why DefaultConfig uses 0). Only raise this if paired with
// dedup logic on the published text — never on its own.
Overlap time.Duration
}
// DefaultConfig returns starting-point thresholds; tune against real audio.
func DefaultConfig(sampleRate int) Config {
return Config{
SampleRate: sampleRate,
SilenceThresholdDB: -40,
SilenceHangover: 600 * time.Millisecond,
MinSpeechDuration: 200 * time.Millisecond,
MaxSegmentDuration: 4 * time.Second,
Overlap: 0,
}
}
func (c Config) bytesForDuration(d time.Duration) int {
// 16-bit mono samples: 2 bytes/sample.
samples := int(float64(c.SampleRate) * d.Seconds())
n := samples * 2
if n%2 != 0 {
n++
}
return n
}
// Detector runs the heuristic independently per track (system and mic have
// separate state) over a shared, tagged chunk stream.
type Detector struct {
cfg Config
states map[capture.Track]*trackState
}
func New(cfg Config) *Detector {
return &Detector{cfg: cfg, states: make(map[capture.Track]*trackState)}
}
type trackState struct {
inSpeech bool
buffer []byte
segStart time.Time
// candidate holds voiced audio not yet promoted to a real segment,
// while we wait to confirm MinSpeechDuration.
candidate []byte
candidateStart time.Time
silenceSince time.Time // zero value means "not currently in a silence run"
lastChunkAt time.Time // timestamp of the most recently processed chunk
}
// Run consumes chunks until the channel closes, emitting Segments on the
// returned channel. Any in-progress segment is flushed (as final, since
// capture ended) before the output channel closes.
func (d *Detector) Run(chunks <-chan capture.Chunk) <-chan Segment {
out := make(chan Segment, 16)
go func() {
defer close(out)
for chunk := range chunks {
d.process(chunk, out)
}
for track, st := range d.states {
if st.inSpeech && len(st.buffer) > 0 {
out <- Segment{Track: track, Data: st.buffer, Start: st.segStart, End: st.lastChunkAt, IsFinal: true}
}
}
}()
return out
}
func (d *Detector) process(chunk capture.Chunk, out chan<- Segment) {
st, ok := d.states[chunk.Track]
if !ok {
st = &trackState{}
d.states[chunk.Track] = st
}
speech := dbFS(chunk.Data) >= d.cfg.SilenceThresholdDB
st.lastChunkAt = chunk.Timestamp
switch {
case speech && !st.inSpeech:
// Possible start of an utterance — buffer as a candidate until
// MinSpeechDuration is confirmed, to filter transient noise.
if len(st.candidate) == 0 {
st.candidateStart = chunk.Timestamp
}
st.candidate = append(st.candidate, chunk.Data...)
if chunk.Timestamp.Sub(st.candidateStart) >= d.cfg.MinSpeechDuration {
st.inSpeech = true
st.segStart = st.candidateStart
st.buffer = st.candidate
st.candidate = nil
st.silenceSince = time.Time{}
}
case speech && st.inSpeech:
st.buffer = append(st.buffer, chunk.Data...)
st.silenceSince = time.Time{} // speech resumed, cancel any pending hangover
if chunk.Timestamp.Sub(st.segStart) >= d.cfg.MaxSegmentDuration {
out <- Segment{Track: chunk.Track, Data: st.buffer, Start: st.segStart, End: chunk.Timestamp, IsFinal: false}
st.buffer = tail(st.buffer, d.cfg.bytesForDuration(d.cfg.Overlap))
st.segStart = chunk.Timestamp.Add(-d.cfg.Overlap)
}
case !speech && st.inSpeech:
if st.silenceSince.IsZero() {
st.silenceSince = chunk.Timestamp
}
st.buffer = append(st.buffer, chunk.Data...) // keep brief trailing silence for natural cutoff
if chunk.Timestamp.Sub(st.silenceSince) >= d.cfg.SilenceHangover {
out <- Segment{Track: chunk.Track, Data: st.buffer, Start: st.segStart, End: chunk.Timestamp, IsFinal: true}
st.inSpeech = false
st.buffer = nil
st.silenceSince = time.Time{}
}
default: // !speech && !st.inSpeech
st.candidate = nil
}
}
// dbFS returns the RMS level of a 16-bit little-endian PCM buffer in dBFS
// (0 = full scale for int16). Silence (all-zero input) returns -infinity,
// which compares correctly against any real threshold.
func dbFS(data []byte) float64 {
n := len(data) / 2
if n == 0 {
return math.Inf(-1)
}
var sumSquares float64
for i := 0; i+1 < len(data); i += 2 {
s := int16(binary.LittleEndian.Uint16(data[i : i+2]))
v := float64(s)
sumSquares += v * v
}
rms := math.Sqrt(sumSquares / float64(n))
if rms == 0 {
return math.Inf(-1)
}
return 20 * math.Log10(rms/32768)
}
func tail(data []byte, n int) []byte {
if n <= 0 {
return nil
}
if n >= len(data) {
out := make([]byte, len(data))
copy(out, data)
return out
}
out := make([]byte, n)
copy(out, data[len(data)-n:])
return out
}