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

329 lines
12 KiB
Go

package main
import (
"context"
"path/filepath"
"sync"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The VAD RPC and vad_model specs run against stubbed C entry points, so they
// need no libparakeet.so.
var _ = Describe("VAD RPC", func() {
var (
savedPcm func(uintptr, []float32, int32, int32, string) uintptr
savedFree func(uintptr)
savedLastError func(uintptr) string
pool *diarizeCstrPool
)
BeforeEach(func() {
savedPcm, savedFree, savedLastError = CppVadPcmJSON, CppFreeString, CppLastError
pool = &diarizeCstrPool{}
CppFreeString = func(uintptr) {}
})
AfterEach(func() {
CppVadPcmJSON, CppFreeString, CppLastError = savedPcm, savedFree, savedLastError
})
It("maps the library segments to the response in seconds", func() {
var gotCtx uintptr
var gotN, gotRate int32
var gotOpts string
CppVadPcmJSON = func(ctx uintptr, s []float32, n, rate int32, o string) uintptr {
gotCtx, gotN, gotRate, gotOpts = ctx, n, rate, o
return pool.cstr(`{"mode":"speech","duration":3.0,"frame_sec":0.032,"backend":"cpu",` +
`"segments":[{"start":0.514,"end":1.5},{"start":2.0,"end":2.75}]}`)
}
p := &ParakeetCpp{ctxPtr: 7}
res, err := p.VAD(&pb.VADRequest{Audio: make([]float32, 480)})
Expect(err).ToNot(HaveOccurred())
Expect(gotCtx).To(Equal(uintptr(7)))
Expect(gotN).To(Equal(int32(480)))
Expect(gotRate).To(Equal(int32(16000)))
Expect(gotOpts).To(BeEmpty())
Expect(res.Segments).To(HaveLen(2))
Expect(res.Segments[0].Start).To(BeNumerically("~", 0.514, 1e-6))
Expect(res.Segments[1].End).To(BeNumerically("~", 2.75, 1e-6))
})
It("returns an empty, non-nil segment list when nothing is speech", func() {
CppVadPcmJSON = func(uintptr, []float32, int32, int32, string) uintptr {
return pool.cstr(`{"segments":[]}`)
}
res, err := (&ParakeetCpp{ctxPtr: 7}).VAD(&pb.VADRequest{})
Expect(err).ToNot(HaveOccurred())
Expect(res.Segments).ToNot(BeNil())
Expect(res.Segments).To(BeEmpty())
})
It("prefers the Silero context over the ASR head", func() {
var gotCtx uintptr
CppVadPcmJSON = func(ctx uintptr, _ []float32, _, _ int32, _ string) uintptr {
gotCtx = ctx
return pool.cstr(`{"segments":[]}`)
}
_, err := (&ParakeetCpp{ctxPtr: 7, vadCtx: 9}).VAD(&pb.VADRequest{})
Expect(err).ToNot(HaveOccurred())
Expect(gotCtx).To(Equal(uintptr(9)))
})
It("works on a Silero primary that has no ASR context", func() {
CppVadPcmJSON = func(ctx uintptr, _ []float32, _, _ int32, _ string) uintptr {
Expect(ctx).To(Equal(uintptr(9)))
return pool.cstr(`{"segments":[{"start":1,"end":2}]}`)
}
res, err := (&ParakeetCpp{vadCtx: 9}).VAD(&pb.VADRequest{})
Expect(err).ToNot(HaveOccurred())
Expect(res.Segments).To(HaveLen(1))
})
It("passes the tuning options to the library", func() {
var gotOpts string
CppVadPcmJSON = func(_ uintptr, _ []float32, _, _ int32, o string) uintptr {
gotOpts = o
return pool.cstr(`{"segments":[]}`)
}
p := &ParakeetCpp{ctxPtr: 7, vadOptions: `{"threshold":0.6}`}
_, err := p.VAD(&pb.VADRequest{})
Expect(err).ToNot(HaveOccurred())
Expect(gotOpts).To(Equal(`{"threshold":0.6}`))
})
It("surfaces the library message for a model without a VAD head", func() {
CppVadPcmJSON = func(uintptr, []float32, int32, int32, string) uintptr { return 0 }
CppLastError = func(uintptr) string { return "model has no VAD head" }
_, err := (&ParakeetCpp{ctxPtr: 7}).VAD(&pb.VADRequest{Audio: []float32{0}})
Expect(err).To(MatchError("parakeet-cpp: vad failed: model has no VAD head"))
})
It("gives a clear error only when the symbol is missing and VAD is used", func() {
CppVadPcmJSON = nil
_, err := (&ParakeetCpp{ctxPtr: 7}).VAD(&pb.VADRequest{})
Expect(err).To(MatchError(ContainSubstring("parakeet_capi_vad_pcm_json")))
})
It("names the role when a diarization model is loaded", func() {
CppVadPcmJSON = func(uintptr, []float32, int32, int32, string) uintptr {
Fail("no C call expected")
return 0
}
_, err := (&ParakeetCpp{diarCtx: 3}).VAD(&pb.VADRequest{})
Expect(err).To(MatchError(ContainSubstring("diarization model")))
})
It("reports no model when nothing is loaded", func() {
CppVadPcmJSON = func(uintptr, []float32, int32, int32, string) uintptr { return 0 }
_, err := (&ParakeetCpp{}).VAD(&pb.VADRequest{})
Expect(err).To(MatchError(ContainSubstring("no model loaded")))
})
It("serializes concurrent requests on the engine", func() {
var mu sync.Mutex
active, maxActive := 0, 0
CppVadPcmJSON = func(uintptr, []float32, int32, int32, string) uintptr {
mu.Lock()
active++
if active > maxActive {
maxActive = active
}
mu.Unlock()
mu.Lock()
active--
mu.Unlock()
return pool.cstr(`{"segments":[]}`)
}
p := &ParakeetCpp{ctxPtr: 7}
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
defer GinkgoRecover()
_, err := p.VAD(&pb.VADRequest{Audio: []float32{0}})
Expect(err).ToNot(HaveOccurred())
}()
}
wg.Wait()
Expect(maxActive).To(Equal(1))
})
})
var _ = Describe("VAD tuning options", func() {
opts := func(o ...string) *pb.ModelOptions { return &pb.ModelOptions{Options: o} }
It("is empty when no option is set", func() {
s, err := parseVADTuning(opts("vad:true"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(BeEmpty())
})
It("maps each option to the library key", func() {
s, err := parseVADTuning(opts("vad_threshold:0.6", "vad_min_pause:0.3", "vad_min_speech:0.2",
"vad_speech_pad:0.05", "vad_max_segment:20"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(MatchJSON(`{"threshold":0.6,"min_pause":0.3,"min_speech":0.2,"speech_pad":0.05,"max_segment":20}`))
})
It("maps vad_trim to the trim key and allows 0, which keeps the whole cuts", func() {
s, err := parseVADTuning(opts("vad_trim:0.5"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(MatchJSON(`{"trim":0.5}`))
s, err = parseVADTuning(opts("vad_trim:0"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(MatchJSON(`{"trim":0}`))
})
It("allows a zero speech pad", func() {
s, err := parseVADTuning(opts("vad_speech_pad:0"))
Expect(err).ToNot(HaveOccurred())
Expect(s).To(MatchJSON(`{"speech_pad":0}`))
})
DescribeTable("rejects bad values at load",
func(opt, msg string) {
_, err := parseVADTuning(opts(opt))
Expect(err).To(MatchError(ContainSubstring(msg)))
},
Entry("not a number", "vad_threshold:high", "is not a number"),
Entry("threshold zero", "vad_threshold:0", "out of range"),
Entry("threshold above one", "vad_threshold:1.5", "out of range"),
Entry("negative pad", "vad_speech_pad:-1", "out of range"),
Entry("negative trim", "vad_trim:-0.1", "out of range"),
Entry("trim not a number", "vad_trim:long", "is not a number"),
Entry("NaN", "vad_min_pause:NaN", "is not a number"),
)
})
var _ = Describe("vad_model", func() {
var (
restore func()
savedWith func(ctx, vadCtx uintptr, p string, d int32, o string) uintptr
savedFree func(uintptr)
savedLastError func(uintptr) string
savedVad, savedPlain func(uintptr, string, int32) uintptr
pool *diarizeCstrPool
gotCtx, gotVad uintptr
gotOpts, gotPath string
calledWith, calledHead bool
)
BeforeEach(func() {
savedWith, savedFree, savedLastError = CppTranscribePathJSONVadWith, CppFreeString, CppLastError
savedVad, savedPlain = CppTranscribePathJSONVad, CppTranscribePathJSON
pool = &diarizeCstrPool{}
CppFreeString = func(uintptr) {}
calledWith, calledHead = false, false
CppTranscribePathJSONVadWith = func(ctx, vad uintptr, path string, _ int32, o string) uintptr {
calledWith = true
gotCtx, gotVad, gotPath, gotOpts = ctx, vad, path, o
return pool.cstr(`{"text":"hi.","frame_sec":0.08,"words":[],"tokens":[]}`)
}
CppTranscribePathJSONVad = func(uintptr, string, int32) uintptr {
calledHead = true
return pool.cstr(`{"text":"head.","frame_sec":0.08,"words":[],"tokens":[]}`)
}
})
AfterEach(func() {
if restore != nil {
restore()
restore = nil
}
CppTranscribePathJSONVadWith, CppFreeString, CppLastError = savedWith, savedFree, savedLastError
CppTranscribePathJSONVad, CppTranscribePathJSON = savedVad, savedPlain
})
load := func(f *fakeLib, o *pb.ModelOptions) (*ParakeetCpp, error) {
restore = f.install()
p := &ParakeetCpp{}
return p, p.Load(o)
}
It("loads a Silero companion resolved against the models root, and implies vad", func() {
f := newFakeLib().
withModel("asr.gguf", modelKindASR).
withModel(filepath.Join("/models", "silero.gguf"), modelKindVAD)
p, err := load(f, &pb.ModelOptions{ModelFile: "asr.gguf", ModelPath: "/models",
Options: []string{"vad_model:silero.gguf", "vad_threshold:0.4"}})
Expect(err).ToNot(HaveOccurred())
Expect(p.vad).To(BeTrue())
Expect(p.vadCtx).ToNot(BeZero())
Expect(p.ctxPtr).ToNot(BeZero())
Expect(p.vadOptions).To(MatchJSON(`{"threshold":0.4}`))
Expect(p.Free()).To(Succeed())
Expect(f.freed).To(HaveLen(2))
Expect(p.vadCtx).To(BeZero())
})
It("routes transcription through the _with entry point with the Silero context", func() {
p := &ParakeetCpp{ctxPtr: 7, vadCtx: 9, vad: true, vadOptions: `{"threshold":0.4}`}
doc, err := p.transcribePathDoc("/x/long.wav")
Expect(err).ToNot(HaveOccurred())
Expect(doc.Text).To(Equal("hi."))
Expect(calledWith).To(BeTrue())
Expect(calledHead).To(BeFalse())
Expect([]any{gotCtx, gotVad, gotPath, gotOpts}).To(Equal([]any{uintptr(7), uintptr(9), "/x/long.wav", `{"threshold":0.4}`}))
})
It("keeps vad:true on the model's own head when there is no vad_model and no tuning", func() {
p := &ParakeetCpp{ctxPtr: 7, vad: true}
_, err := p.transcribePathDoc("/x/long.wav")
Expect(err).ToNot(HaveOccurred())
Expect(calledHead).To(BeTrue())
Expect(calledWith).To(BeFalse())
})
It("passes vad_trim:0 to the segmenter, so the old whole cuts stay available", func() {
p := &ParakeetCpp{ctxPtr: 7, vad: true, vadOptions: `{"trim":0}`}
_, err := p.transcribePathDoc("/x/long.wav")
Expect(err).ToNot(HaveOccurred())
Expect(calledWith).To(BeTrue())
Expect(gotOpts).To(Equal(`{"trim":0}`))
})
It("passes tuning to the head through _with (null Silero context)", func() {
p := &ParakeetCpp{ctxPtr: 7, vad: true, vadOptions: `{"max_segment":20}`}
_, err := p.transcribePathDoc("/x/long.wav")
Expect(err).ToNot(HaveOccurred())
Expect(calledWith).To(BeTrue())
Expect(gotVad).To(BeZero())
})
It("refuses vad_model on a library without the _with entry point", func() {
CppTranscribePathJSONVadWith = nil
f := newFakeLib().withModel("asr.gguf", modelKindASR).withModel("s.gguf", modelKindVAD)
_, err := load(f, &pb.ModelOptions{ModelFile: "asr.gguf", Options: []string{"vad_model:s.gguf"}})
Expect(err).To(MatchError(ContainSubstring("parakeet_capi_transcribe_path_json_vad_with")))
})
It("rejects a vad_model that is not a Silero model", func() {
f := newFakeLib().withModel("asr.gguf", modelKindASR).withModel("other.gguf", modelKindASR)
_, err := load(f, &pb.ModelOptions{ModelFile: "asr.gguf", Options: []string{"vad_model:other.gguf"}})
Expect(err).To(MatchError(ContainSubstring("is an ASR model, expected a VAD model")))
Expect(f.freed).To(HaveLen(2))
})
It("rejects vad_model without an ASR model", func() {
f := newFakeLib().withModel("diar.gguf", modelKindDiarization).withModel("s.gguf", modelKindVAD)
_, err := load(f, &pb.ModelOptions{ModelFile: "diar.gguf", Options: []string{"vad_model:s.gguf"}})
Expect(err).To(MatchError(ContainSubstring("needs an ASR model")))
})
It("loads a Silero GGUF as the primary and refuses transcription with a clear error", func() {
f := newFakeLib().withModel("silero.gguf", modelKindVAD)
p, err := load(f, &pb.ModelOptions{ModelFile: "silero.gguf"})
Expect(err).ToNot(HaveOccurred())
Expect(p.vadCtx).ToNot(BeZero())
Expect(p.ctxPtr).To(BeZero())
Expect(p.vad).To(BeFalse())
_, err = p.AudioTranscription(context.Background(), &pb.TranscriptRequest{Dst: "x.wav"})
Expect(err).To(MatchError(ContainSubstring("Silero VAD model, not ASR")))
Expect(p.Free()).To(Succeed())
Expect(f.freed).To(HaveLen(1))
})
})