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

169 lines
7.4 KiB
Go

package main
import (
"unsafe"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The sceneBegin spec drives it entirely against stubbed
// CppSceneOptsDefault/CppSceneStreamBegin (the same seam live_test.go uses
// for the full live-session scene specs), so it runs without libparakeet.so.
var _ = Describe("ParakeetCpp.sceneBegin", func() {
It("forces Sound.TopK to 0 and carries p.diarLatency into the begin opts", func() {
savedOptsDefault := CppSceneOptsDefault
savedBegin := CppSceneStreamBegin
defer func() {
CppSceneOptsDefault = savedOptsDefault
CppSceneStreamBegin = savedBegin
}()
// parakeet_capi_scene_opts_default's real default is top_k = 5 (a
// sound-window score history); simulate that here so the test proves
// sceneBegin overrides it rather than merely never setting it.
CppSceneOptsDefault = func(o *cSceneOpts) {
*o = cSceneOpts{Sound: cSoundOpts{TopK: 5}}
}
var gotOpts cSceneOpts
CppSceneStreamBegin = func(asr, diar, tagger uintptr, o *cSceneOpts) uintptr {
gotOpts = *o
return 1
}
p := &ParakeetCpp{diarCtx: 42, diarLatency: diarLatencyVeryLow}
h := p.sceneBegin(nil)
Expect(h.s).ToNot(BeZero())
Expect(gotOpts.Sound.TopK).To(Equal(int32(0)),
"the live scene path never drains sound scores (see sound.go's SoundDetection, "+
"which does); a nonzero top_k leaves the C side's per-window score queue "+
"growing for the session's lifetime")
Expect(gotOpts.DiarLatency).To(Equal(diarLatencyVeryLow))
})
})
var _ = Describe("scene stream with speaker names", func() {
var (
restore func()
pool *liveCstrPool
freedRegs []uintptr
)
BeforeEach(func() {
sOpts, sBegin, sBeginSpk, sFeed, sFree := CppSceneOptsDefault, CppSceneStreamBegin, CppSceneStreamBeginSpeaker, CppSceneStreamFeedJSON, CppSceneStreamFree
sNew, sRegFree, sAdd, sDim, sStr := CppSpeakerRegistryNew, CppSpeakerRegistryFree, CppSpeakerRegistryAddEmbedding, CppSpeakerDim, CppFreeString
restore = func() {
CppSceneOptsDefault, CppSceneStreamBegin, CppSceneStreamBeginSpeaker, CppSceneStreamFeedJSON, CppSceneStreamFree = sOpts, sBegin, sBeginSpk, sFeed, sFree
CppSpeakerRegistryNew, CppSpeakerRegistryFree, CppSpeakerRegistryAddEmbedding, CppSpeakerDim, CppFreeString = sNew, sRegFree, sAdd, sDim, sStr
}
pool = &liveCstrPool{}
freedRegs = nil
CppSceneOptsDefault = func(o *cSceneOpts) { o.Size = int32(unsafe.Sizeof(*o)) }
CppSpeakerDim = func(uintptr) int32 { return 2 }
CppSpeakerRegistryNew = func() uintptr { return 9 }
CppSpeakerRegistryFree = func(r uintptr) { freedRegs = append(freedRegs, r) }
CppSpeakerRegistryAddEmbedding = func(uintptr, string, *float32, int32) int32 { return 0 }
CppFreeString = func(uintptr) {}
})
AfterEach(func() { restore() })
It("replays duplicate display names independently and translates realtime matches", func() {
vectors := map[string]float32{}
CppSpeakerRegistryAddEmbedding = func(_ uintptr, key string, emb *float32, _ int32) int32 { vectors[key] = *emb; return 0 }
CppSceneStreamBeginSpeaker = func(_, _, _, _, _ uintptr, _ *cSceneOpts) uintptr { return 55 }
CppSceneStreamFeedJSON = func(uintptr, *float32, int32, int32) uintptr {
return pool.cstr(`{"speakers":[{"speaker":0,"start":0,"end":2},{"speaker":1,"start":2,"end":4}],"names":{"0":{"name":"a","score":0.9},"1":{"name":"b","score":0.8}}}`)
}
p := &ParakeetCpp{diarCtx: 1, spkCtx: 2}
h := p.sceneBegin([]*pb.KnownVoice{{Id: "a", Name: "Ada", Embedding: []float32{1, 0}}, {Id: "b", Name: "Ada", Embedding: []float32{0, 1}}})
doc, err := p.sceneFeed(h, nil, true)
Expect(err).NotTo(HaveOccurred())
Expect(vectors).To(Equal(map[string]float32{"a": 1, "b": 0}))
out := liveSpeakersToProto(doc.Speakers, doc.Names)
Expect(out[0].Name).To(Equal("Ada"))
Expect(out[1].Name).To(Equal("Ada"))
Expect(out[0].Speaker).NotTo(Equal(out[1].Speaker))
})
It("begins a speaker scene stream with the threshold and margin when voices are given", func() {
var gotOpts cSceneOpts
var gotReg, gotSpk uintptr
CppSceneStreamBeginSpeaker = func(asr, diar, tag, spk, reg uintptr, o *cSceneOpts) uintptr {
gotOpts, gotReg, gotSpk = *o, reg, spk
return 55
}
CppSceneStreamBegin = func(asr, diar, tag uintptr, o *cSceneOpts) uintptr {
Fail("plain begin must not be used")
return 0
}
p := &ParakeetCpp{diarCtx: 1, spkCtx: 2, speakerAccept: 0.7, speakerMargin: 0.05}
h := p.sceneBegin([]*pb.KnownVoice{{Name: "Ada", Embedding: []float32{1, 0}}})
Expect(h.s).To(Equal(uintptr(55)))
Expect(h.reg).To(Equal(uintptr(9)))
Expect(gotReg).To(Equal(uintptr(9)))
Expect(gotSpk).To(Equal(uintptr(2)))
Expect(gotOpts.SpeakerAcceptThreshold).To(BeNumerically("~", 0.7, 1e-6))
Expect(gotOpts.SpeakerMargin).To(BeNumerically("~", 0.05, 1e-6))
})
It("uses the plain scene begin without voices or without a speaker model", func() {
plain := 0
CppSceneStreamBegin = func(asr, diar, tag uintptr, o *cSceneOpts) uintptr { plain++; return 56 }
CppSceneStreamBeginSpeaker = func(asr, diar, tag, spk, reg uintptr, o *cSceneOpts) uintptr {
Fail("speaker begin must not be used")
return 0
}
p := &ParakeetCpp{diarCtx: 1, spkCtx: 2}
Expect(p.sceneBegin(nil).s).To(Equal(uintptr(56)))
p2 := &ParakeetCpp{diarCtx: 1}
Expect(p2.sceneBegin([]*pb.KnownVoice{{Name: "Ada", Embedding: []float32{1, 0}}}).s).To(Equal(uintptr(56)))
Expect(plain).To(Equal(2))
})
It("frees the registry when the speaker begin fails, and degrades to no scene stream", func() {
CppSceneStreamBeginSpeaker = func(asr, diar, tag, spk, reg uintptr, o *cSceneOpts) uintptr { return 0 }
lastErr := CppLastError
defer func() { CppLastError = lastErr }()
CppLastError = func(uintptr) string { return "registry encoder family differs" }
p := &ParakeetCpp{diarCtx: 1, spkCtx: 2}
h := p.sceneBegin([]*pb.KnownVoice{{Name: "Ada", Embedding: []float32{1, 0}}})
Expect(h.s).To(Equal(uintptr(0)))
Expect(freedRegs).To(Equal([]uintptr{9}))
})
It("frees the stream before its registry", func() {
var order []string
CppSceneStreamFree = func(uintptr) { order = append(order, "stream") }
CppSpeakerRegistryFree = func(uintptr) { order = append(order, "registry") }
(&ParakeetCpp{}).sceneFree(sceneStreamHandle{s: 55, reg: 9})
Expect(order).To(Equal([]string{"stream", "registry"}))
})
It("turns the names of the feed document into live speaker segment names", func() {
segs := liveSpeakersToProto(
[]sceneSpeakerJSON{{Speaker: 0, Start: 0, End: 1}, {Speaker: 1, Start: 1, End: 2}},
map[string]speakerNameJSON{"0": {Name: "Ada", Score: 0.9}, "1": {Name: ""}})
Expect(segs[0].Name).To(Equal("Ada"))
Expect(segs[0].Speaker).To(Equal("0"))
Expect(segs[1].Name).To(BeEmpty())
Expect(liveSpeakersToProto([]sceneSpeakerJSON{{Speaker: 0}}, nil)[0].Name).To(BeEmpty())
})
It("decodes the names map of a scene feed", func() {
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n, last int32) uintptr {
return pool.cstr(`{"speakers":[{"speaker":0,"start":0.0,"end":1.0}],"sounds":[],"names":{"0":{"name":"Ada","score":0.9}}}`)
}
p := &ParakeetCpp{diarCtx: 1, spkCtx: 2}
doc, err := p.sceneFeed(sceneStreamHandle{s: 55, diar: 1, spk: 2}, []float32{0}, false)
Expect(err).ToNot(HaveOccurred())
Expect(doc.Names["0"].Name).To(Equal("Ada"))
})
It("refuses to feed a stream whose speaker model was freed", func() {
p := &ParakeetCpp{diarCtx: 1}
_, err := p.sceneFeed(sceneStreamHandle{s: 55, diar: 1, spk: 2}, []float32{0}, false)
Expect(err).To(HaveOccurred())
})
})