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