mavsttd: silence gate — don't feed non-speech to whisper
Whisper hallucinates subtitle-credit boilerplate ("Редактор субтитров …") on
silence/room-noise, which then got stored as tap:voice facts. Gate before the
model: drop clips shorter than -min-ms (default 300) or below -silence-rms
(default 0.01 normalized RMS). Both are flags — the mic floor is hardware
specific. Returns empty transcript (same as whisper's no-segments path), so
nothing downstream changes.
gateReason is pure and unit-tested (silence/short/quiet → dropped, loud+long →
passes). ponytail: energy gate, not a real VAD; upgrade to WebRTC VAD or
whisper no_speech_prob if too blunt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -47,6 +47,8 @@ func main() {
|
|||||||
func run(args []string) error {
|
func run(args []string) error {
|
||||||
sock := flag.String("socket", defaultSocket("stt.sock"), "unix socket path")
|
sock := flag.String("socket", defaultSocket("stt.sock"), "unix socket path")
|
||||||
model := flag.String("model", "", "path to whisper ggml model file")
|
model := flag.String("model", "", "path to whisper ggml model file")
|
||||||
|
minMs := flag.Int("min-ms", 300, "drop clips shorter than this (silence gate); whisper hallucinates on short/quiet audio")
|
||||||
|
silenceRMS := flag.Float64("silence-rms", 0.01, "drop clips with normalized RMS below this (silence gate); tune to your mic floor")
|
||||||
flag.CommandLine.Parse(args)
|
flag.CommandLine.Parse(args)
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
|
||||||
@@ -54,7 +56,7 @@ func run(args []string) error {
|
|||||||
|
|
||||||
var t worker.Transcriber
|
var t worker.Transcriber
|
||||||
if *model != "" {
|
if *model != "" {
|
||||||
w, err := newWhisperHandler(*model)
|
w, err := newWhisperHandler(*model, *minMs, *silenceRMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("whisper: %w", err)
|
return fmt.Errorf("whisper: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sine builds `ms` milliseconds of a 16 kHz tone at the given amplitude, so we
|
||||||
|
// can exercise the energy gate without real audio or whisper.
|
||||||
|
func sine(ms int, amp float64) []float32 {
|
||||||
|
n := whisperSampleRate * ms / 1000
|
||||||
|
out := make([]float32, n)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = float32(amp * math.Sin(2*math.Pi*440*float64(i)/whisperSampleRate))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGateReason(t *testing.T) {
|
||||||
|
const (
|
||||||
|
minMs = 300
|
||||||
|
minRMS = 0.01
|
||||||
|
)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
samples []float32
|
||||||
|
gated bool // true ⇒ expect a non-empty reason (dropped)
|
||||||
|
}{
|
||||||
|
{"empty", nil, true},
|
||||||
|
{"too short", sine(100, 0.5), true}, // 100ms < 300ms
|
||||||
|
{"long but silent", make([]float32, whisperSampleRate), true}, // 1s of zeros
|
||||||
|
{"long but near-silent", sine(500, 0.005), true}, // rms ~0.0035 < floor
|
||||||
|
{"real speech-ish", sine(500, 0.3), false}, // loud enough, long enough
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
reason := gateReason(tc.samples, whisperSampleRate, minMs, minRMS)
|
||||||
|
if (reason != "") != tc.gated {
|
||||||
|
t.Fatalf("gateReason=%q, want gated=%v", reason, tc.gated)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,17 +11,24 @@ import "C"
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/worker"
|
"github.com/kami/maven/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// whisperSampleRate is the rate whisper.cpp requires; the pipeline resamples
|
||||||
|
// to it before sending, so it's also the rate the silence gate assumes.
|
||||||
|
const whisperSampleRate = 16000
|
||||||
|
|
||||||
type whisperHandler struct {
|
type whisperHandler struct {
|
||||||
ctx *C.struct_whisper_context
|
ctx *C.struct_whisper_context
|
||||||
|
minMs int // clips shorter than this are dropped (hallucination bait)
|
||||||
|
minRMS float64 // clips quieter than this (normalized RMS) are treated as silence
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWhisperHandler(modelPath string) (*whisperHandler, error) {
|
func newWhisperHandler(modelPath string, minMs int, minRMS float64) (*whisperHandler, error) {
|
||||||
cparams := C.whisper_context_default_params()
|
cparams := C.whisper_context_default_params()
|
||||||
cPath := C.CString(modelPath)
|
cPath := C.CString(modelPath)
|
||||||
defer C.free(unsafe.Pointer(cPath))
|
defer C.free(unsafe.Pointer(cPath))
|
||||||
@@ -30,7 +37,33 @@ func newWhisperHandler(modelPath string) (*whisperHandler, error) {
|
|||||||
if ctx == nil {
|
if ctx == nil {
|
||||||
return nil, fmt.Errorf("whisper: failed to init from %s", modelPath)
|
return nil, fmt.Errorf("whisper: failed to init from %s", modelPath)
|
||||||
}
|
}
|
||||||
return &whisperHandler{ctx: ctx}, nil
|
return &whisperHandler{ctx: ctx, minMs: minMs, minRMS: minRMS}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gateReason returns a non-empty reason when audio must NOT reach whisper:
|
||||||
|
// too short, or below the energy floor (silence / room noise). Whisper
|
||||||
|
// hallucinates subtitle-credit boilerplate ("Редактор субтитров …",
|
||||||
|
// "Субтитры сделал …") on non-speech input, so we drop it before the model
|
||||||
|
// ever sees it. Pure (no CGo) so it's unit-tested directly.
|
||||||
|
//
|
||||||
|
// ponytail: plain RMS energy + min-duration, not a real VAD. The mic floor is
|
||||||
|
// hardware-specific (both thresholds are flags on mavsttd) — upgrade to WebRTC
|
||||||
|
// VAD / whisper's no_speech_prob if energy gating proves too blunt.
|
||||||
|
func gateReason(samples []float32, rate, minMs int, minRMS float64) string {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
return "empty"
|
||||||
|
}
|
||||||
|
if ms := len(samples) * 1000 / rate; ms < minMs {
|
||||||
|
return fmt.Sprintf("too short (%dms < %dms)", ms, minMs)
|
||||||
|
}
|
||||||
|
var sum float64
|
||||||
|
for _, s := range samples {
|
||||||
|
sum += float64(s) * float64(s)
|
||||||
|
}
|
||||||
|
if rms := math.Sqrt(sum / float64(len(samples))); rms < minRMS {
|
||||||
|
return fmt.Sprintf("silence (rms %.4f < floor %.4f)", rms, minRMS)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
|
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
|
||||||
@@ -49,6 +82,12 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
|
|||||||
samples[i] = float32(s) / 32768.0
|
samples[i] = float32(s) / 32768.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Silence gate: drop non-speech before whisper hallucinates on it.
|
||||||
|
if reason := gateReason(samples, whisperSampleRate, h.minMs, h.minRMS); reason != "" {
|
||||||
|
log.Printf("mavsttd: gated audio (%s) — skipping whisper", reason)
|
||||||
|
return worker.TranscribeResp{Text: "", Confidence: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
params := C.whisper_full_default_params(C.WHISPER_SAMPLING_GREEDY)
|
params := C.whisper_full_default_params(C.WHISPER_SAMPLING_GREEDY)
|
||||||
params.print_progress = false
|
params.print_progress = false
|
||||||
params.print_realtime = false
|
params.print_realtime = false
|
||||||
|
|||||||
Reference in New Issue
Block a user