diff --git a/cmd/mavsttd/main.go b/cmd/mavsttd/main.go index 5faabc3..94d9f25 100644 --- a/cmd/mavsttd/main.go +++ b/cmd/mavsttd/main.go @@ -47,6 +47,8 @@ func main() { func run(args []string) error { sock := flag.String("socket", defaultSocket("stt.sock"), "unix socket path") 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) 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 if *model != "" { - w, err := newWhisperHandler(*model) + w, err := newWhisperHandler(*model, *minMs, *silenceRMS) if err != nil { return fmt.Errorf("whisper: %w", err) } diff --git a/cmd/mavsttd/silence_test.go b/cmd/mavsttd/silence_test.go new file mode 100644 index 0000000..6f20b08 --- /dev/null +++ b/cmd/mavsttd/silence_test.go @@ -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) + } + }) + } +} diff --git a/cmd/mavsttd/whisper_handler.go b/cmd/mavsttd/whisper_handler.go index d23fda8..b8cdf93 100644 --- a/cmd/mavsttd/whisper_handler.go +++ b/cmd/mavsttd/whisper_handler.go @@ -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