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>
218 lines
7.0 KiB
Go
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
|
|
}
|