Files
LocalAI/backend/go/parakeet-cpp/guard.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

92 lines
3.0 KiB
Go

package main
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
)
// The word filter of libparakeet (the "guard"): an opt-in pass over a finished
// decode that drops words which stand alone or sit among low-confidence words,
// as noise tends to give, and words that are only punctuation. It is off unless
// one of these model options is set:
//
// guard_min_local_conf:0.5 0 to 1; 0 = off
// guard_local_radius:5 seconds > 0; the library default is 5
// guard_drop_punct_only:true
//
// The names carry the guard_ prefix because the bare library names
// (min_local_conf, local_radius) say nothing next to vad_*, speaker_* and the
// other options of this backend, and the library's result calls the section
// "guard".
// parseGuardOptions reads the guard_* model options and returns the JSON
// options object of parakeet_capi_transcribe_path_json_with (the keys the
// library names min_local_conf, local_radius and drop_punct_only), or "" when
// none is set. A value that does not parse or is out of range fails the load,
// and so does the filter on a libparakeet.so that cannot run it.
func parseGuardOptions(opts *pb.ModelOptions) (string, error) {
obj := map[string]any{}
if raw := optString(opts, "guard_min_local_conf"); raw != "" {
v, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return "", fmt.Errorf("parakeet-cpp: option guard_min_local_conf: %q is not a number", raw)
}
if v < 0 || v > 1 {
return "", fmt.Errorf("parakeet-cpp: option guard_min_local_conf: %v is out of range, want 0 to 1", v)
}
obj["min_local_conf"] = v
}
if raw := optString(opts, "guard_local_radius"); raw != "" {
v, err := strconv.ParseFloat(raw, 64)
if err != nil || math.IsNaN(v) || math.IsInf(v, 0) {
return "", fmt.Errorf("parakeet-cpp: option guard_local_radius: %q is not a number", raw)
}
if v <= 0 {
return "", fmt.Errorf("parakeet-cpp: option guard_local_radius: %v is out of range, want seconds > 0", v)
}
obj["local_radius"] = v
}
if optString(opts, "guard_drop_punct_only") != "" {
b, err := optBool(opts, "guard_drop_punct_only", false)
if err != nil {
return "", err
}
obj["drop_punct_only"] = b
}
if len(obj) == 0 {
return "", nil
}
if CppTranscribePathJSONWith == nil {
return "", errors.New("parakeet-cpp: the guard_* options need a libparakeet.so with parakeet_capi_transcribe_path_json_with; rebuild the backend against a newer parakeet.cpp")
}
b, err := json.Marshal(obj)
if err != nil {
return "", err
}
return string(b), nil
}
// mergeJSONObjects joins two flat JSON objects ("" is an empty one). The
// segmenter entry point takes the VAD keys and the guard keys in one object.
func mergeJSONObjects(a, b string) (string, error) {
if a == "" {
return b, nil
}
if b == "" {
return a, nil
}
m := map[string]json.RawMessage{}
for _, s := range []string{a, b} {
if err := json.Unmarshal([]byte(s), &m); err != nil {
return "", err
}
}
out, err := json.Marshal(m)
return string(out), err
}