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:
kami
2026-07-04 00:02:44 +04:00
parent de8fd9ba90
commit 2740c2f685
3 changed files with 88 additions and 4 deletions
+42 -3
View File
@@ -11,17 +11,24 @@ import "C"
import (
"context"
"fmt"
"log"
"math"
"unsafe"
"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 {
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()
cPath := C.CString(modelPath)
defer C.free(unsafe.Pointer(cPath))
@@ -30,7 +37,33 @@ func newWhisperHandler(modelPath string) (*whisperHandler, error) {
if ctx == nil {
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) {
@@ -49,6 +82,12 @@ func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeRe
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.print_progress = false
params.print_realtime = false