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

146 lines
6.2 KiB
Go

package main
import (
"context"
"path/filepath"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("guard_* model options (word filter)", func() {
var (
savedPlain func(uintptr, string, int32) uintptr
savedWithOpts func(uintptr, string, int32, string) uintptr
savedVadWith func(ctx, vadCtx uintptr, p string, d int32, o string) uintptr
savedFree func(uintptr)
pool *diarizeCstrPool
gotOpts string
usedWith, usedVadWith bool
)
opts := func(o ...string) *pb.ModelOptions { return &pb.ModelOptions{Options: o} }
BeforeEach(func() {
savedPlain, savedWithOpts, savedVadWith, savedFree = CppTranscribePathJSON, CppTranscribePathJSONWith, CppTranscribePathJSONVadWith, CppFreeString
pool = &diarizeCstrPool{}
usedWith, usedVadWith, gotOpts = false, false, ""
CppFreeString = func(uintptr) {}
CppTranscribePathJSON = func(uintptr, string, int32) uintptr {
Fail("the plain entry point must not be used when the filter is on")
return 0
}
CppTranscribePathJSONWith = func(_ uintptr, _ string, _ int32, o string) uintptr {
usedWith, gotOpts = true, o
return pool.cstr(`{"text":"hello.","frame_sec":0.08,"words":[{"w":"hello.","start":0.1,"end":0.4,"conf":0.9}],"tokens":[],"guard":{"dropped_words":2}}`)
}
CppTranscribePathJSONVadWith = func(_, _ uintptr, _ string, _ int32, o string) uintptr {
usedVadWith, gotOpts = true, o
return pool.cstr(`{"text":"hello.","frame_sec":0.08,"words":[],"tokens":[],"guard":{"dropped_words":0}}`)
}
})
AfterEach(func() {
CppTranscribePathJSON, CppTranscribePathJSONWith, CppTranscribePathJSONVadWith, CppFreeString = savedPlain, savedWithOpts, savedVadWith, savedFree
})
It("is off when no option is set, and then needs no library symbol", func() {
CppTranscribePathJSONWith = nil
s, err := parseGuardOptions(opts("vad:true"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(BeEmpty())
})
It("maps the options to the library keys", func() {
s, err := parseGuardOptions(opts("guard_min_local_conf:0.5", "guard_local_radius:3", "guard_drop_punct_only:true"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(MatchJSON(`{"min_local_conf":0.5,"local_radius":3,"drop_punct_only":true}`))
})
It("keeps an explicit 0 (off) and false as given", func() {
s, err := parseGuardOptions(opts("guard_min_local_conf:0", "guard_drop_punct_only:false"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(MatchJSON(`{"min_local_conf":0,"drop_punct_only":false}`))
})
DescribeTable("rejects bad values at load",
func(opt, msg string) {
_, err := parseGuardOptions(opts(opt))
Expect(err).To(MatchError(ContainSubstring(msg)))
},
Entry("confidence not a number", "guard_min_local_conf:high", "is not a number"),
Entry("confidence above one", "guard_min_local_conf:1.5", "out of range"),
Entry("negative confidence", "guard_min_local_conf:-0.1", "out of range"),
Entry("radius zero", "guard_local_radius:0", "out of range"),
Entry("radius NaN", "guard_local_radius:NaN", "is not a number"),
Entry("bool typo", "guard_drop_punct_only:ture", "is not a boolean"),
)
It("fails the load, naming the symbol, on a library without the filter", func() {
CppTranscribePathJSONWith = nil
_, err := parseGuardOptions(opts("guard_min_local_conf:0.5"))
Expect(err).To(MatchError(ContainSubstring("parakeet_capi_transcribe_path_json_with")))
})
It("fails Load for guard_* with vad:true when the segmenter entry point is missing", func() {
CppTranscribePathJSONVadWith = nil
f := newFakeLib().withModel("asr.gguf", modelKindASR)
restore := f.install()
defer restore()
savedVad := CppTranscribePathJSONVad
CppTranscribePathJSONVad = func(uintptr, string, int32) uintptr { return 0 }
defer func() { CppTranscribePathJSONVad = savedVad }()
err := (&ParakeetCpp{}).Load(&pb.ModelOptions{ModelFile: "asr.gguf", Options: []string{"vad:true", "guard_min_local_conf:0.5"}})
Expect(err).To(MatchError(ContainSubstring("parakeet_capi_transcribe_path_json_vad_with")))
})
It("fails Load on a bad value before any model is loaded", func() {
f := newFakeLib().withModel("asr.gguf", modelKindASR)
restore := f.install()
defer restore()
err := (&ParakeetCpp{}).Load(&pb.ModelOptions{ModelFile: "asr.gguf", Options: []string{"guard_min_local_conf:2"}})
Expect(err).To(MatchError(ContainSubstring("guard_min_local_conf")))
Expect(f.loadedPaths).To(BeEmpty())
})
It("routes offline transcription through the filter entry point, past the batcher", func() {
wav := filepath.Join(GinkgoT().TempDir(), "a.wav")
writeMono16kWav(wav, 16000)
// A non-nil batcher: without the filter the request would go to it.
p := &ParakeetCpp{ctxPtr: 7, bat: &batcher{}, guardOptions: `{"min_local_conf":0.5}`}
res, err := p.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: wav})
Expect(err).ToNot(HaveOccurred())
Expect(usedWith).To(BeTrue())
Expect(gotOpts).To(Equal(`{"min_local_conf":0.5}`))
Expect(res.Text).To(Equal("hello."))
})
It("sends the filter keys together with the VAD keys to the segmenter when vad is on", func() {
p := &ParakeetCpp{ctxPtr: 7, vad: true, vadOptions: `{"trim":0}`, guardOptions: `{"min_local_conf":0.5}`}
_, err := p.transcribePathDoc("/x/long.wav")
Expect(err).ToNot(HaveOccurred())
Expect(usedVadWith).To(BeTrue())
Expect(usedWith).To(BeFalse())
Expect(gotOpts).To(MatchJSON(`{"trim":0,"min_local_conf":0.5}`))
})
It("does not reach the filter entry point when the filter is off", func() {
CppTranscribePathJSON = func(uintptr, string, int32) uintptr {
return pool.cstr(`{"text":"plain.","frame_sec":0.08,"words":[],"tokens":[]}`)
}
p := &ParakeetCpp{ctxPtr: 7}
doc, err := p.transcribePathDoc("/x/a.wav")
Expect(err).ToNot(HaveOccurred())
Expect(doc.Text).To(Equal("plain."))
Expect(usedWith).To(BeFalse())
Expect(doc.Guard).To(BeNil())
})
It("reads the dropped word count of the document", func() {
p := &ParakeetCpp{ctxPtr: 7, guardOptions: `{"drop_punct_only":true}`}
doc, err := p.transcribePathDoc("/x/a.wav")
Expect(err).ToNot(HaveOccurred())
Expect(doc.Guard).ToNot(BeNil())
Expect(doc.Guard.DroppedWords).To(Equal(2))
})
})