Files
Maven/cmd/mavsttd/whisper_handler.go
T
kami d68708b5e1 stt: make the golden tests fail where they used to disappear
The file comment named four regressions caught here. Three were not.
Nothing on this path resamples, because PCMFromWAV refuses anything that
is not already 16 kHz mono s16. Nothing exercises language selection,
because the hint comes out of the manifest already correct. And a bad
model path was the one condition that made the whole test vanish behind
a skip nobody reads. The comment now claims the two things that are
real, an explicitly set MAVEN_WHISPER_MODEL that does not exist is a
failure, and a missing fixture is a failure rather than a skip.

looseWordMatch accepted a different word. Four retained runes of "воды"
is "вод", so whisper hearing "выпил водки" satisfied the ru_fact
keyword, and "dis" let display, distance and discuss all stand in for
"disk". A case ending adds a rune, not a syllable, so the hypothesis is
capped in length as well as matched on prefix.

The spoken text lived in the generator and in the manifest with nothing
tying them together. Editing one left the other describing audio that no
longer existed, and at a flat ceiling of 0.34 over a five-word reference
a one-word drift passed silently. The script reads text out of the
manifest now, and the ceilings are set just above what each case really
measures against ggml-small, with the measurement recorded beside them.

Also: the test carried its own copy of the PCM to float32 conversion, so
a regression in the daemon's copy left the silence-gate assertion green,
and the manifest was validated for keywords but not for text, where an
empty reference makes every hypothesis score a WER of 1.

Found in review of #75.
2026-08-01 14:16:02 +04:00

185 lines
5.6 KiB
Go

package main
/*
#cgo CFLAGS: -I${SRCDIR}/../../deps/include -I${SRCDIR}/../../deps/whisper.cpp/ggml/include
#cgo LDFLAGS: -L${SRCDIR}/../../deps/lib -Wl,-rpath,${SRCDIR}/../../deps/lib -lwhisper -lggml -lggml-base -lggml-cpu -lggml-vulkan -lm -lstdc++ -fopenmp
#include <whisper.h>
#include <stdlib.h>
*/
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
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, minMs int, minRMS float64) (*whisperHandler, error) {
cparams := C.whisper_context_default_params()
cPath := C.CString(modelPath)
defer C.free(unsafe.Pointer(cPath))
ctx := C.whisper_init_from_file_with_params(cPath, cparams)
if ctx == nil {
return nil, fmt.Errorf("whisper: failed to init from %s", modelPath)
}
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 ""
}
// pcmSamples converts canonical s16le little-endian PCM to the float32 range
// whisper wants. Shared with the golden tests: they used to carry their own
// copy, so a regression here (a /32767 divisor, a byte order slip) left the
// assertion that the fixtures clear the silence gate green.
func pcmSamples(b []byte) []float32 {
out := make([]float32, len(b)/2)
for i := range out {
s := int16(b[i*2]) | int16(b[i*2+1])<<8
out[i] = float32(s) / 32768.0
}
return out
}
func (h *whisperHandler) Transcribe(ctx context.Context, req worker.TranscribeReq) (worker.TranscribeResp, error) {
if err := ctx.Err(); err != nil {
return worker.TranscribeResp{}, fmt.Errorf("whisper: context done before transcribe: %w", err)
}
a := req.Audio
if len(a.Bytes) == 0 {
return worker.TranscribeResp{}, fmt.Errorf("whisper: empty audio")
}
samples := pcmSamples(a.Bytes)
// 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
params.print_timestamps = false
params.print_special = false
params.n_threads = C.int(4)
params.single_segment = true
lang := C.CString(req.Lang)
defer C.free(unsafe.Pointer(lang))
params.language = lang
params.detect_language = false
// CGo blocks the goroutine; whisper has no portable CGo-friendly abort
// callback. Run in a goroutine so the caller's context cancellation at
// least returns promptly — the CGo goroutine leaks until whisper finishes
// but the caller doesn't hang.
type result struct {
code int
}
ch := make(chan result, 1)
cSamples := (*C.float)(unsafe.Pointer(&samples[0]))
go func() {
ch <- result{code: int(C.whisper_full(h.ctx, params, cSamples, C.int(len(samples))))}
}()
select {
case r := <-ch:
if r.code != 0 {
return worker.TranscribeResp{}, fmt.Errorf("whisper: full failed: %d", r.code)
}
case <-ctx.Done():
return worker.TranscribeResp{}, fmt.Errorf("whisper: %w", ctx.Err())
}
nSegments := int(C.whisper_full_n_segments(h.ctx))
if nSegments == 0 {
return worker.TranscribeResp{Text: "", Confidence: 0}, nil
}
var text string
totalLogProb := float64(0)
totalTokens := 0
for i := 0; i < nSegments; i++ {
cSeg := C.whisper_full_get_segment_text(h.ctx, C.int(i))
if cSeg != nil {
text += C.GoString(cSeg)
}
nTokens := int(C.whisper_full_n_tokens(h.ctx, C.int(i)))
for j := 0; j < nTokens; j++ {
p := float64(C.whisper_full_get_token_p(h.ctx, C.int(i), C.int(j)))
if p > 0 {
totalLogProb += math.Log(p)
totalTokens++
}
}
}
confidence := 0.0
if totalTokens > 0 {
avgLogProb := totalLogProb / float64(totalTokens)
confidence = math.Exp(avgLogProb)
}
noSpeechProb := float64(C.whisper_full_get_segment_no_speech_prob(h.ctx, 0))
if noSpeechProb > 0.9 {
confidence = 0
}
if math.IsNaN(confidence) || math.IsInf(confidence, 0) {
confidence = 0
}
return worker.TranscribeResp{
Text: text,
Confidence: confidence,
}, nil
}
func (h *whisperHandler) Close() {
if h.ctx != nil {
C.whisper_free(h.ctx)
h.ctx = nil
}
}