Files
LocalAI/backend/go/parakeet-cpp/vad.go
T
mudler-agentandEttore Di Giacinto 79a7631cc5 feat(parakeet-cpp): encoder fingerprint for speaker naming, VAD trim and word filter options, pin bump (#12491)
* chore(parakeet-cpp): bump parakeet.cpp to 2de154c

Brings in the speaker registry encoder fingerprint, the VAD segment trim
and the opt-in word filter, a fix for a per-call thread count that stayed
set on the process-wide backend after a Silero VAD pass, and bundle
components loaded from memory.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

* feat(parakeet-cpp): encoder fingerprint for speaker naming, vad_trim and guard_* options

Speaker naming. A registered voice now carries the encoder that made it:
the embedding family (voicedetect:<arch>:<name>:<dim>) and the sha256 of
the weights. The backend reports the family of the loaded speaker model in
Status, voice enrollment from speaker_profiles stores it as encoder_family
(old entries load without it), and the registry sent to parakeet.cpp is
built with parakeet_capi_speaker_registry_add_embedding_fp. The library
then refuses a registry of another encoder family and the error names both
families; another quantization of the same family only warns. A voice with
only a weights hash gets the loaded family when the hashes are equal.

Voices without a fingerprint (registered from audio: libvoicedetect cannot
report one) keep the file-name rule and are used with a warning. The
library cannot mix them with fingerprinted voices in one registry, so a
request that has any uses the old registry for all. speaker_strict:true
drops them instead. A library without the symbols behaves as before.

Transcription. vad_trim (seconds, 0 keeps the whole cuts) goes through the
VAD options JSON, so it reaches /v1/vad and the segmenter. The guard_*
options guard_min_local_conf, guard_local_radius and guard_drop_punct_only
turn on the word filter through parakeet_capi_transcribe_path_json_with,
or through the segmenter with vad:true. They are off by default, bad
values fail the load, and a library without the symbol fails it with a
clear message. The dropped word count is logged at debug level.

Assisted-by: Claude:claude-sonnet-5-5 [Claude Code]

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-05 15:57:16 +02:00

119 lines
4.0 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// vadSampleRate is the rate of VADRequest.audio: the VAD endpoint takes float32
// PCM at 16 kHz, the rate the other VAD backends (silero-vad, whisper) assume.
const vadSampleRate = 16000
// vadTuning maps a model option to the key of the library's VAD options object.
// All values are seconds except the threshold. An unset option leaves the
// default of the detector in use (the VAD head and Silero differ), so no value
// is repeated here.
var vadTuning = []struct {
option, key string
// min and max bound the value; the library rejects the rest, but failing the
// load names the option instead of failing every request.
min, max float64
minOpen bool // true when min itself is not allowed (the threshold is in (0, 1])
}{
{"vad_threshold", "threshold", 0, 1, true},
{"vad_min_pause", "min_pause", 0, math.Inf(1), true},
{"vad_min_speech", "min_speech", 0, math.Inf(1), false},
{"vad_speech_pad", "speech_pad", 0, math.Inf(1), false},
{"vad_max_segment", "max_segment", 0, math.Inf(1), true},
// vad_trim shrinks each transcription segment to its speech plus this much.
// Unset keeps the library default (0.3 s); 0 keeps the whole cuts.
{"vad_trim", "trim", 0, math.Inf(1), false},
}
// parseVADTuning reads the vad_threshold, vad_min_pause, vad_min_speech,
// vad_speech_pad, vad_max_segment and vad_trim model options and returns them as the JSON
// options object of the C-API, or "" when none is set. A value that does not
// parse or is out of range fails the load.
func parseVADTuning(opts *pb.ModelOptions) (string, error) {
obj := map[string]float64{}
for _, t := range vadTuning {
raw := optString(opts, t.option)
if raw == "" {
continue
}
v, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return "", fmt.Errorf("parakeet-cpp: option %s: %q is not a number", t.option, raw)
}
if v < t.min || (t.minOpen && v == t.min) || v > t.max {
return "", fmt.Errorf("parakeet-cpp: option %s: %v is out of range", t.option, v)
}
obj[t.key] = v
}
if len(obj) == 0 {
return "", nil
}
b, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(b), nil
}
// vadDocument is the part of the C-API VAD result the RPC needs.
type vadDocument struct {
Segments []struct {
Start float64 `json:"start"`
End float64 `json:"end"`
} `json:"segments"`
}
// VAD runs the standalone voice activity detection of libparakeet on the
// request audio and returns the speech regions in seconds. It uses the Silero
// context (the model itself or the vad_model: companion) when there is one, else
// the VAD head of the ASR model. An ASR model without a head fails with the
// library's message.
func (p *ParakeetCpp) VAD(req *pb.VADRequest) (pb.VADResponse, error) {
if CppVadPcmJSON == nil {
return pb.VADResponse{}, errors.New("parakeet-cpp: VAD needs a libparakeet.so with parakeet_capi_vad_pcm_json; rebuild the backend against a newer parakeet.cpp")
}
p.engineMu.Lock()
ctx := p.vadCtx
if ctx == 0 {
ctx = p.ctxPtr
}
if ctx == 0 {
p.engineMu.Unlock()
if err := p.notASRError(); err != nil {
return pb.VADResponse{}, err
}
return pb.VADResponse{}, errors.New("parakeet-cpp: no model loaded for VAD")
}
cstr := CppVadPcmJSON(ctx, req.GetAudio(), int32(len(req.GetAudio())), vadSampleRate, p.vadOptions)
var lastErr string
if cstr == 0 {
lastErr = CppLastError(ctx)
}
p.engineMu.Unlock()
if cstr == 0 {
return pb.VADResponse{}, fmt.Errorf("parakeet-cpp: vad failed: %s", lastErr)
}
raw := goStringFromCPtr(cstr)
CppFreeString(cstr)
var doc vadDocument
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return pb.VADResponse{}, fmt.Errorf("parakeet-cpp: decode vad json: %w", err)
}
segs := make([]*pb.VADSegment, 0, len(doc.Segments))
for _, s := range doc.Segments {
segs = append(segs, &pb.VADSegment{Start: float32(s.Start), End: float32(s.End)})
}
return pb.VADResponse{Segments: segs}, nil
}