docs: update README with VAD, new threshold option, and silence handling
Release / Tests before release (push) Successful in 1m29s
Release / Build and publish release (push) Successful in 3m5s
Tests / Go and GTK tests (push) Successful in 1m39s

Update README to clarify VAD mode behavior: provisional caption only after Silero detects speech, Parakeet idle during silence. Document new --vad-threshold option to control Silero speech confidence. Add troubleshooting tip for music/noise false detections.
This commit is contained in:
2026-07-17 10:35:59 +03:00
parent b0487be39e
commit 55fbd357b0
8 changed files with 226 additions and 41 deletions
+130 -18
View File
@@ -14,13 +14,17 @@ type fakeSegment struct {
}
type fakeTranscriber struct {
text string
decodeTexts []string
segments []fakeSegment
flushed bool
text string
decodeTexts []string
segments []fakeSegment
flushed bool
speechActive bool
decodeSizes []int
acceptedVAD int
}
func (f *fakeTranscriber) Decode([]float32) string {
func (f *fakeTranscriber) Decode(samples []float32) string {
f.decodeSizes = append(f.decodeSizes, len(samples))
if len(f.decodeTexts) == 0 {
return f.text
}
@@ -28,8 +32,9 @@ func (f *fakeTranscriber) Decode([]float32) string {
f.decodeTexts = f.decodeTexts[1:]
return text
}
func (f *fakeTranscriber) AcceptVAD([]float32) {}
func (f *fakeTranscriber) FlushVAD() { f.flushed = true }
func (f *fakeTranscriber) AcceptVAD(samples []float32) { f.acceptedVAD += len(samples) }
func (f *fakeTranscriber) SpeechActive() bool { return f.speechActive }
func (f *fakeTranscriber) FlushVAD() { f.flushed = true }
func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
if len(f.segments) == 0 {
return 0, nil, false
@@ -42,12 +47,14 @@ func (f *fakeTranscriber) NextSpeechSegment() (int, []float32, bool) {
func TestFixedModeEmitsFinalCaption(t *testing.T) {
transcriber := &fakeTranscriber{text: "hello"}
var events []Event
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
processor := New(config.Settings{
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Kind != Final || events[0].Text != "hello" {
@@ -61,12 +68,14 @@ func TestFixedModeEmitsFinalCaption(t *testing.T) {
func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
transcriber := &fakeTranscriber{decodeTexts: []string{"first", "partial"}}
var events []Event
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(event Event) error {
processor := New(config.Settings{
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate+audio.SampleRate/2)); err != nil {
if err := processor.Accept(constantSamples(audio.SampleRate+audio.SampleRate/2, 0.5)); err != nil {
t.Fatal(err)
}
if err := processor.Flush(); err != nil {
@@ -81,7 +90,7 @@ func TestFixedModeFlushesFinalPartialChunkWithExactTimestamps(t *testing.T) {
}
func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
transcriber := &fakeTranscriber{text: "speech"}
transcriber := &fakeTranscriber{text: "speech", speechActive: true}
var events []Event
processor := New(config.Settings{
Mode: config.ModeVAD,
@@ -92,14 +101,12 @@ func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
return nil
})
loud := make([]float32, audio.SampleRate)
for i := range loud {
loud[i] = 0.5
}
loud := constantSamples(audio.SampleRate, 0.5)
if err := processor.Accept(loud); err != nil {
t.Fatal(err)
}
transcriber.segments = []fakeSegment{{start: 0, samples: loud}}
transcriber.speechActive = false
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
t.Fatal(err)
}
@@ -112,11 +119,13 @@ func TestVADModeEmitsProvisionalThenFinal(t *testing.T) {
func TestEmptyFinalEmitsHide(t *testing.T) {
transcriber := &fakeTranscriber{text: " \n\t "}
var event Event
processor := New(config.Settings{Mode: config.ModeFixed, ChunkDuration: time.Second}, transcriber, func(got Event) error {
processor := New(config.Settings{
Mode: config.ModeFixed, ChunkDuration: time.Second, PreviewThresholdDB: -45,
}, transcriber, func(got Event) error {
event = got
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
t.Fatal(err)
}
if event.Kind != Hide {
@@ -124,6 +133,101 @@ func TestEmptyFinalEmitsHide(t *testing.T) {
}
}
func TestFixedModeSilenceStaysOutOfRecognizer(t *testing.T) {
transcriber := &fakeTranscriber{text: "hallucinated caption"}
var events []Event
processor := New(config.Settings{
Mode: config.ModeFixed,
ChunkDuration: time.Second,
PreviewThresholdDB: -45,
}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
if err := processor.Accept(make([]float32, audio.SampleRate)); err != nil {
t.Fatal(err)
}
if len(transcriber.decodeSizes) != 0 {
t.Fatalf("silent audio reached recognizer: decode sizes = %v", transcriber.decodeSizes)
}
if len(events) != 1 || events[0].Kind != Hide {
t.Fatalf("silent chunk events = %#v, want one Hide", events)
}
}
func TestVADModeDoesNotPreviewLoudNonSpeech(t *testing.T) {
transcriber := &fakeTranscriber{text: "music hallucination"}
var events []Event
processor := New(config.Settings{
Mode: config.ModeVAD,
ChunkDuration: time.Second,
PreviewThresholdDB: -45,
}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
loudMusic := constantSamples(audio.SampleRate, 0.5)
if err := processor.Accept(loudMusic); err != nil {
t.Fatal(err)
}
if transcriber.acceptedVAD != len(loudMusic) {
t.Fatalf("VAD received %d samples, want %d", transcriber.acceptedVAD, len(loudMusic))
}
if len(transcriber.decodeSizes) != 0 || len(events) != 0 {
t.Fatalf("non-speech reached recognizer: decodes=%v events=%#v", transcriber.decodeSizes, events)
}
}
func TestVADModeClearsRejectedShortSpeechPreview(t *testing.T) {
transcriber := &fakeTranscriber{text: "draft", speechActive: true}
var events []Event
processor := New(config.Settings{
Mode: config.ModeVAD,
ChunkDuration: time.Second,
PreviewThresholdDB: -45,
}, transcriber, func(event Event) error {
events = append(events, event)
return nil
})
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
t.Fatal(err)
}
transcriber.speechActive = false
if err := processor.Accept(make([]float32, audio.SamplesPerPacket())); err != nil {
t.Fatal(err)
}
if len(events) != 2 || events[0].Kind != Provisional || events[1].Kind != Hide {
t.Fatalf("events = %#v, want Provisional then Hide", events)
}
}
func TestVADPreviewRecognitionWindowIsBounded(t *testing.T) {
transcriber := &fakeTranscriber{text: "draft", speechActive: true}
processor := New(config.Settings{
Mode: config.ModeVAD,
ChunkDuration: time.Second,
PreviewThresholdDB: -45,
}, transcriber, func(Event) error { return nil })
for range 15 {
if err := processor.Accept(constantSamples(audio.SampleRate, 0.5)); err != nil {
t.Fatal(err)
}
}
maxSamples := audio.DurationSamples(maxPreviewDuration)
for _, size := range transcriber.decodeSizes {
if size > maxSamples {
t.Fatalf("preview decode used %d samples, limit is %d", size, maxSamples)
}
}
if len(transcriber.decodeSizes) != 15 {
t.Fatalf("preview decode count = %d, want 15", len(transcriber.decodeSizes))
}
}
func TestFlushFlushesVAD(t *testing.T) {
transcriber := &fakeTranscriber{}
processor := New(config.Settings{Mode: config.ModeVAD, ChunkDuration: time.Second}, transcriber, func(Event) error { return nil })
@@ -134,3 +238,11 @@ func TestFlushFlushesVAD(t *testing.T) {
t.Fatal("VAD was not flushed")
}
}
func constantSamples(count int, value float32) []float32 {
samples := make([]float32, count)
for i := range samples {
samples[i] = value
}
return samples
}