Files
LocalAI/backend/go/parakeet-cpp/live_test.go
T
localai-org-maint-botandEttore Di Giacinto 2ae6cae70d feat(parakeet-cpp): name speakers from the shared voice registry (#12382)
* feat(voice): list registered voices and record which encoder made them

The voice registry could register, identify and forget but not list, and
it did not remember which speaker encoder produced an embedding. Add
Metadata.Model and Registry.List, answered from the index the store
registry already keeps for Forget. Needed so a backend can be given the
registered voices that match its own speaker encoder.

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

* feat(voice): store the encoder model with a registered voice

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

* feat(voice): pick the registered voices that match a speaker model

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

* feat(proto): carry known voices and speaker names on diarize and live messages

Assisted-by: Claude:claude-haiku-4-5 [Claude Code]

* feat(diarization): name speakers from the voice registry

When a diarization model has a speaker_model option, the endpoint sends
the registered voices made by that encoder to the backend. The backend's
name and name_score come back as extra fields next to the normalized
SPEAKER_NN speaker, and the speakers summary carries the first name seen
for each speaker. RTTM output and results without names are unchanged.

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

* feat(live): pass registered voices to a live session and surface speaker names

Live sessions now send the registered voices that match the model's
speaker_model to the backend, and each speaker segment carries the name
the backend matched. The realtime segment event gains an optional
speaker_name field.

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

* feat(parakeet-cpp): load a speaker model and build per-request voice registries

Adds the speaker bindings (ABI v9 and v10, probed separately), the
speaker_model, speaker_threshold and speaker_margin options, and a
per-request registry builder over the known voices.

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

* feat(parakeet-cpp): name the speakers in Diarize from the known voices

Diarize builds a per-request speaker registry from the known voices when a
speaker model is loaded, calls the named C functions, and puts each slot's
registered name and score on the segments. The registry is freed on every
path. A library without ABI 10 reports Unimplemented instead of dropping
the names.

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

* feat(parakeet-cpp): name speakers in the live scene stream

The live scene stream now begins with a known-voice registry when a
speaker model is loaded and the live config carries voices, and each
closed speaker segment takes its slot's current name from the feed's
names map. A segment that closes before its slot is identified has an
empty name. The registry is freed after the stream, on every path.

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

* feat(gallery): speaker naming entries and docs for parakeet-cpp

Add three gallery entries that load the WeSpeaker ResNet34 speaker model
next to the diarization or realtime scene models, and document speaker
names in the voice recognition, diarization, audio to text and realtime
pages.

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

* fix(parakeet-cpp): skip an unusable registered voice instead of failing the request

A registered voice with the wrong embedding size, or one the C side
refused, failed the whole diarization request, so one legacy voice broke
the model for every user. Skip such voices with a warning that does not
carry the voice name, and take the plain path when none is left.

Also map an exact 0 speaker threshold or margin to a tiny positive value,
since the C side reads 0 as "use the default", and fix a stale comment
about which contexts Free() walks.

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

* fix(diarization): warn once per model about voices from another encoder; document the privacy limit

The different-encoder warning fired on every request. Log it once per
feature and speaker model, then at debug level. Document that the global
voice registry lets any caller of a speaker_model model learn matching
names, and that skipped wrong-sized voices are logged.

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

* chore(parakeet-cpp): bump parakeet.cpp to 8c8cec0 (C-API v10) and check speaker naming against the real library

The pin moves from 623a968 to 8c8cec0, which brings in everything merged
in parakeet.cpp since: the voice identification change (C-API v9, #78) and
raw-embedding enroll plus diarize-only speaker naming (C-API v10, #79).

New real-library specs (gated on PARAKEET_BACKEND_TEST_SPEAKER_MODEL,
_DIAR_MODEL, _WAV and, for the live path, _STREAM_MODEL) name the two
speakers of two_speakers.wav from a committed pair of WeSpeaker embeddings,
with the voices passed in reversed order. They also check that the float32
threshold reaches C through purego. The shared test loader now registers
the v9/v10 and scene symbols as main.go does.

The rebase onto origin/master had no conflicts.

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

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-10-01 08:25:52 +02:00

862 lines
30 KiB
Go

package main
import (
"sync"
"time"
"unsafe"
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// The live-RPC specs drive AudioTranscriptionLive entirely against stubbed
// Cpp* package vars (the same seam batcher_test.go uses), so they run
// without libparakeet.so.
// liveCstrPool hands out NUL-terminated C-style strings backed by Go memory
// and keeps them alive for the duration of a spec (goStringFromCPtr reads
// through the raw pointer; Go's GC must not collect the backing array while
// a stub's return value is in flight).
type liveCstrPool struct {
mu sync.Mutex
bufs [][]byte
}
func (p *liveCstrPool) cstr(s string) uintptr {
p.mu.Lock()
defer p.mu.Unlock()
b := append([]byte(s), 0)
p.bufs = append(p.bufs, b)
return uintptr(unsafe.Pointer(&b[0]))
}
// liveStubs swaps every C entry point the live path touches and returns a
// restore func for AfterEach.
func liveStubs() (restore func()) {
savedBegin, savedBeginLang := CppStreamBegin, CppStreamBeginLang
savedFeed, savedFeedJSON := CppStreamFeed, CppStreamFeedJSON
savedFinalize, savedFinalizeJSON := CppStreamFinalize, CppStreamFinalizeJSON
savedFree, savedLastError := CppStreamFree, CppLastError
savedFreeString := CppFreeString
savedSceneOptsDefault := CppSceneOptsDefault
savedSceneBegin := CppSceneStreamBegin
savedSceneFeedJSON := CppSceneStreamFeedJSON
savedSceneLastError := CppSceneStreamLastError
savedSceneFree := CppSceneStreamFree
savedSceneBeginSpk := CppSceneStreamBeginSpeaker
savedRegNew, savedRegFree := CppSpeakerRegistryNew, CppSpeakerRegistryFree
savedRegAdd, savedSpkDim := CppSpeakerRegistryAddEmbedding, CppSpeakerDim
return func() {
CppSceneStreamBeginSpeaker = savedSceneBeginSpk
CppSpeakerRegistryNew, CppSpeakerRegistryFree = savedRegNew, savedRegFree
CppSpeakerRegistryAddEmbedding, CppSpeakerDim = savedRegAdd, savedSpkDim
CppStreamBegin, CppStreamBeginLang = savedBegin, savedBeginLang
CppStreamFeed, CppStreamFeedJSON = savedFeed, savedFeedJSON
CppStreamFinalize, CppStreamFinalizeJSON = savedFinalize, savedFinalizeJSON
CppStreamFree, CppLastError = savedFree, savedLastError
CppFreeString = savedFreeString
CppSceneOptsDefault = savedSceneOptsDefault
CppSceneStreamBegin = savedSceneBegin
CppSceneStreamFeedJSON = savedSceneFeedJSON
CppSceneStreamLastError = savedSceneLastError
CppSceneStreamFree = savedSceneFree
}
}
// liveSceneStubs wires a minimal scene stream stub set onto p (a diarization
// and/or sound companion context so sceneWanted() is true) and returns the
// call-count trackers the specs assert on. feedJSON is called once per scene
// feed (including the is_last flush) with the stream handle the C side would
// have received (so a reset spec can tell a pre-reset feed from a post-reset
// one) and must return the canned document for that call.
func liveSceneStubs(feedJSON func(calls int, s uintptr, isLast int32) uintptr) (begun, freed *int) {
begun, freed = new(int), new(int)
CppSceneOptsDefault = func(o *cSceneOpts) { *o = cSceneOpts{} }
CppSceneStreamBegin = func(asr, diar, tagger uintptr, o *cSceneOpts) uintptr {
*begun++
return uintptr(100 + *begun)
}
calls := 0
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr {
calls++
return feedJSON(calls, s, isLast)
}
CppSceneStreamLastError = func(s uintptr) string { return "scene stub error" }
CppSceneStreamFree = func(s uintptr) { *freed++ }
return begun, freed
}
// runLive starts the RPC on its own goroutine and returns the request
// channel plus a collector for everything the backend emitted. GinkgoRecover
// turns an Expect/Fail failure inside a stub called from this goroutine into
// a normal spec failure instead of a panic that would crash the whole test
// binary (Ginkgo's failure handling is goroutine-local).
func runLive(p *ParakeetCpp) (chan *pb.TranscriptLiveRequest, chan *pb.TranscriptLiveResponse, chan error) {
in := make(chan *pb.TranscriptLiveRequest)
out := make(chan *pb.TranscriptLiveResponse, 32)
errCh := make(chan error, 1)
go func() {
defer GinkgoRecover()
errCh <- p.AudioTranscriptionLive(in, out)
}()
return in, out, errCh
}
func liveConfig(lang string) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Config{Config: &pb.TranscriptLiveConfig{Language: lang}},
}
}
func liveAudio(pcm []float32) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Audio{Audio: &pb.TranscriptLiveAudio{Pcm: pcm}},
}
}
func collectLive(out chan *pb.TranscriptLiveResponse) []*pb.TranscriptLiveResponse {
var got []*pb.TranscriptLiveResponse
for r := range out {
got = append(got, r)
}
return got
}
var _ = Describe("AudioTranscriptionLive (stubbed C API)", func() {
var (
pool *liveCstrPool
restore func()
p *ParakeetCpp
)
BeforeEach(func() {
pool = &liveCstrPool{}
restore = liveStubs()
p = &ParakeetCpp{ctxPtr: 1}
CppStreamBeginLang = nil
CppStreamBegin = func(ctx uintptr) uintptr { return 7 }
CppStreamFree = func(s uintptr) {}
CppFreeString = func(s uintptr) {}
CppLastError = func(ctx uintptr) string { return "stub error" }
CppStreamFeed = nil
CppStreamFeedJSON = nil
CppStreamFinalize = nil
CppStreamFinalizeJSON = nil
})
AfterEach(func() { restore() })
It("names the loaded role instead of a generic model-not-loaded error for a sound primary", func() {
// The ctxPtr==0 check returns before AudioTranscriptionLive ever reads
// from `in`, so nothing may be sent on it (unbuffered: a send would
// block forever waiting for a read that never happens).
p2 := &ParakeetCpp{tagCtx: 1}
in, out, errCh := runLive(p2)
close(in)
err := <-errCh
Expect(err).To(MatchError(ContainSubstring("sound model")))
Expect(collectLive(out)).To(BeEmpty())
})
It("rejects a stream whose first message is not a config", func() {
in, out, errCh := runLive(p)
in <- liveAudio([]float32{0.1})
close(in)
err := <-errCh
Expect(status.Code(err)).To(Equal(codes.InvalidArgument))
Expect(collectLive(out)).To(BeEmpty())
})
It("rejects a non-16k sample rate", func() {
in, _, errCh := runLive(p)
in <- &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Config{Config: &pb.TranscriptLiveConfig{SampleRate: 8000}},
}
close(in)
Expect(status.Code(<-errCh)).To(Equal(codes.InvalidArgument))
})
It("returns the typed Unimplemented signal for non-streaming models, before any ack", func() {
CppStreamBegin = func(ctx uintptr) uintptr { return 0 }
in, out, errCh := runLive(p)
in <- liveConfig("")
close(in)
err := <-errCh
Expect(grpcerrors.IsLiveTranscriptionUnsupported(err)).To(BeTrue())
Expect(collectLive(out)).To(BeEmpty())
})
It("streams deltas, eou flags and words on the JSON path and finalizes on close", func() {
var freed []uintptr
CppStreamFree = func(s uintptr) { freed = append(freed, s) }
feeds := 0
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
feeds++
switch feeds {
case 1:
return pool.cstr(`{"text":"hello ","eou":0,"frame_sec":0.08,` +
`"words":[{"w":"hello","start":0.1,"end":0.4,"conf":0.9}]}`)
default:
return pool.cstr(`{"text":"world","eou":1,"frame_sec":0.08,` +
`"words":[{"w":"world","start":0.5,"end":0.8,"conf":0.9}]}`)
}
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("en")
in <- liveAudio(make([]float32, 100))
in <- liveAudio(make([]float32, 200))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4)) // ready, two deltas, final
Expect(got[0].Ready).To(BeTrue())
Expect(got[1].Delta).To(Equal("hello "))
Expect(got[1].Eou).To(BeFalse())
Expect(got[1].Words).To(HaveLen(1))
Expect(got[1].Words[0].Text).To(Equal("hello"))
Expect(got[2].Delta).To(Equal("world"))
Expect(got[2].Eou).To(BeTrue())
final := got[3].FinalResult
Expect(final).NotTo(BeNil())
Expect(final.Text).To(Equal("hello world"))
// The live FinalResult carries only Text. Per-utterance segments,
// duration and the terminal eou flag are an offline-path concern (see
// boundary.go / AudioTranscriptionStream); the realtime core reads the
// streamed per-feed tokens above plus this Text.
Expect(final.Eou).To(BeFalse())
Expect(final.Segments).To(BeEmpty())
Expect(final.Duration).To(BeZero())
Expect(freed).To(Equal([]uintptr{7}))
})
It("falls back to the text feed (eou out-param) when the JSON entry points are absent", func() {
feeds := 0
CppStreamFeed = func(s uintptr, pcm []float32, n int32, eouOut unsafe.Pointer) uintptr {
feeds++
if feeds == 2 {
*(*int32)(eouOut) = 1
return pool.cstr("done")
}
return pool.cstr("first ")
}
CppStreamFinalize = func(s uintptr) uintptr { return pool.cstr("") }
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4))
Expect(got[1].Delta).To(Equal("first "))
Expect(got[1].Eou).To(BeFalse())
Expect(got[2].Delta).To(Equal("done"))
Expect(got[2].Eou).To(BeTrue())
Expect(got[3].FinalResult.Text).To(Equal("first done"))
})
It("forwards <EOB> as eob — a backchannel, never an eou (ABI v5 JSON)", func() {
feeds := 0
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
feeds++
if feeds == 1 {
return pool.cstr(`{"text":"uh-huh","eou":0,"eob":1,"frame_sec":0.08,` +
`"words":[{"w":"uh-huh","start":0.1,"end":0.3,"conf":0.9}]}`)
}
return pool.cstr(`{"text":"the turn","eou":1,"eob":0,"frame_sec":0.08,` +
`"words":[{"w":"the","start":0.5,"end":0.6,"conf":0.9},{"w":"turn","start":0.6,"end":0.8,"conf":0.9}]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"eob":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4))
Expect(got[1].Eob).To(BeTrue())
Expect(got[1].Eou).To(BeFalse(), "a backchannel must not masquerade as a turn boundary")
Expect(got[2].Eou).To(BeTrue())
})
It("maps the v5 eou_out bitmask on the text path (bit0 <EOU>, bit1 <EOB>)", func() {
feeds := 0
CppStreamFeed = func(s uintptr, pcm []float32, n int32, eouOut unsafe.Pointer) uintptr {
feeds++
if feeds == 1 {
*(*int32)(eouOut) = 2 // <EOB> only
return pool.cstr("uh-huh")
}
*(*int32)(eouOut) = 1 // <EOU>
return pool.cstr(" done")
}
CppStreamFinalize = func(s uintptr) uintptr { return pool.cstr("") }
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4))
Expect(got[1].Eob).To(BeTrue())
Expect(got[1].Eou).To(BeFalse())
Expect(got[2].Eou).To(BeTrue())
Expect(got[2].Eob).To(BeFalse())
})
It("accumulates trailing text after an EOU into the final transcript", func() {
feeds := 0
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
feeds++
if feeds == 1 {
return pool.cstr(`{"text":"turn one","eou":1,"frame_sec":0.08,"words":[]}`)
}
return pool.cstr(`{"text":" and more","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
final := got[len(got)-1].FinalResult
Expect(final.Text).To(Equal("turn one and more"))
})
It("resets the decode session on a mid-stream config", func() {
var begun, freed int
CppStreamBegin = func(ctx uintptr) uintptr { begun++; return uintptr(10 + begun) }
CppStreamFree = func(s uintptr) { freed++ }
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"x","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveConfig("") // reset
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
final := got[len(got)-1].FinalResult
Expect(final.Text).To(Equal("x"), "pre-reset transcript dropped")
Expect(begun).To(Equal(2))
Expect(freed).To(Equal(2), "old session freed on reset, new one on unwind")
})
It("does not hold engineMu between feeds (unary work interleaves with a live session)", func() {
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
// The session is open and idle between feeds: the engine lock must be
// acquirable, which is what lets batched unary transcription proceed
// mid-session. Under stream-lifetime locking this probe would block
// until the stream ended and the Eventually would time out.
locked := make(chan struct{})
go func() {
p.engineMu.Lock()
p.engineMu.Unlock() //nolint:staticcheck // probe: acquire-release proves availability
close(locked)
}()
Eventually(locked, time.Second).Should(BeClosed())
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
collectLive(out)
})
It("errors out and reads last_error under the lock when a feed fails", func() {
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr { return 0 }
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
err := <-errCh
Expect(err).To(MatchError(ContainSubstring("stub error")))
got := collectLive(out)
Expect(got).To(HaveLen(1)) // just the ready ack
close(in)
})
It("makes no scene C call and behaves unchanged when no companion is loaded", func() {
// p has ctxPtr only (no diarCtx/tagCtx): sceneWanted() must be false,
// and none of the scene entry points may be touched.
CppSceneOptsDefault = func(o *cSceneOpts) { Fail("scene_opts_default called with no companions loaded") }
CppSceneStreamBegin = func(asr, diar, tagger uintptr, o *cSceneOpts) uintptr {
Fail("scene_stream_begin called with no companions loaded")
return 0
}
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr {
Fail("scene_stream_feed_json called with no companions loaded")
return 0
}
CppSceneStreamFree = func(s uintptr) { Fail("scene_stream_free called with no companions loaded") }
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"hi","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(3)) // ready, delta, final
Expect(got[1].Speakers).To(BeEmpty())
Expect(got[1].Sounds).To(BeEmpty())
})
})
var _ = Describe("AudioTranscriptionLive scene events (stubbed C API)", func() {
var (
pool *liveCstrPool
restore func()
p *ParakeetCpp
)
BeforeEach(func() {
pool = &liveCstrPool{}
restore = liveStubs()
p = &ParakeetCpp{ctxPtr: 1, diarCtx: 2}
CppStreamBeginLang = nil
CppStreamBegin = func(ctx uintptr) uintptr { return 7 }
CppStreamFree = func(s uintptr) {}
CppFreeString = func(s uintptr) {}
CppLastError = func(ctx uintptr) string { return "stub error" }
CppStreamFeed = nil
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalize = nil
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
})
AfterEach(func() { restore() })
It("emits a closed speaker segment as its own response", func() {
liveSceneStubs(func(calls int, s uintptr, isLast int32) uintptr {
if calls == 1 {
return pool.cstr(`{"speakers":[{"speaker":0,"start":0.1,"end":0.6}],"sounds":[]}`)
}
return pool.cstr(`{"speakers":[],"sounds":[]}`)
})
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(3)) // ready, speaker-only response, final
Expect(got[1].Delta).To(BeEmpty())
Expect(got[1].Speakers).To(HaveLen(1))
Expect(got[1].Speakers[0].Speaker).To(Equal("0"))
Expect(got[1].Speakers[0].Start).To(Equal(int64(0.1 * 1e9)))
Expect(got[1].Speakers[0].End).To(Equal(int64(0.6 * 1e9)))
})
It("sends the ASR delta and a scene sound event as two responses, ASR first", func() {
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"hello ","eou":0,"frame_sec":0.08,` +
`"words":[{"w":"hello","start":0.1,"end":0.4,"conf":0.9}]}`)
}
liveSceneStubs(func(calls int, s uintptr, isLast int32) uintptr {
if calls == 1 {
return pool.cstr(`{"speakers":[],"sounds":[{"index":99,` +
`"label":"Chicken, rooster","start":24.0,"end":30.0,"peak":0.86}]}`)
}
return pool.cstr(`{"speakers":[],"sounds":[]}`)
})
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(4)) // ready, ASR delta, sound-only, final
Expect(got[1].Delta).To(Equal("hello "))
Expect(got[1].Sounds).To(BeEmpty(), "the ASR response must not wait on the scene feed")
Expect(got[2].Delta).To(BeEmpty())
Expect(got[2].Sounds).To(HaveLen(1))
Expect(got[2].Sounds[0].Label).To(Equal("Chicken, rooster"))
Expect(got[2].Sounds[0].Index).To(Equal(int32(99)))
Expect(got[2].Sounds[0].Peak).To(BeNumerically("~", 0.86, 1e-6))
Expect(got[2].Sounds[0].Start).To(Equal(int64(24.0 * 1e9)))
Expect(got[2].Sounds[0].End).To(Equal(int64(30.0 * 1e9)))
})
It("flushes the scene stream is_last before the final result, then frees it", func() {
begun, freed := liveSceneStubs(func(calls int, s uintptr, isLast int32) uintptr {
if calls == 2 {
Expect(isLast).To(Equal(int32(1)))
return pool.cstr(`{"speakers":[{"speaker":1,"start":1.0,"end":2.0}],"sounds":[]}`)
}
Expect(isLast).To(Equal(int32(0)))
return pool.cstr(`{"speakers":[],"sounds":[]}`)
})
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(3)) // ready, speaker from the is_last flush, final
Expect(got[1].Speakers).To(HaveLen(1))
Expect(got[1].Speakers[0].Speaker).To(Equal("1"))
Expect(got[2].FinalResult).NotTo(BeNil())
Expect(*begun).To(Equal(1))
Expect(*freed).To(Equal(1))
})
It("frees and begins the scene stream again on a mid-stream config reset", func() {
streamBegun := 0
CppStreamBegin = func(ctx uintptr) uintptr { streamBegun++; return uintptr(10 + streamBegun) }
var seenStreams []uintptr
begun, freed := liveSceneStubs(func(calls int, s uintptr, isLast int32) uintptr {
seenStreams = append(seenStreams, s)
return pool.cstr(`{"speakers":[],"sounds":[]}`)
})
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
in <- liveConfig("") // reset
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
collectLive(out)
Expect(*begun).To(Equal(2), "scene stream begun again on reset")
Expect(*freed).To(Equal(2), "old scene stream freed on reset, new one on unwind")
// One scene feed per audio message (pre-reset, post-reset) plus the
// close is_last flush, which runs on the post-reset stream.
Expect(seenStreams).To(HaveLen(3))
Expect(seenStreams[0]).NotTo(Equal(seenStreams[1]), "post-reset audio must go to the new scene stream handle")
Expect(seenStreams[2]).To(Equal(seenStreams[1]), "the close flush uses the post-reset stream too")
})
It("returns without a C call when Free() ran between begin and a scene feed", func() {
feedCalls := 0
CppSceneOptsDefault = func(o *cSceneOpts) { *o = cSceneOpts{} }
CppSceneStreamBegin = func(asr, diar, tagger uintptr, o *cSceneOpts) uintptr { return 999 }
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr {
feedCalls++
return pool.cstr(`{"speakers":[],"sounds":[]}`)
}
h := p.sceneBegin(nil)
Expect(h.s).NotTo(BeZero())
// Simulate a Free() racing in between the begin and the next feed: it
// zeroes the companion context under engineMu, exactly as the real
// Free() does.
p.diarCtx = 0
_, err := p.sceneFeed(h, make([]float32, 10), false)
Expect(grpcerrors.IsModelNotLoaded(err)).To(BeTrue())
Expect(feedCalls).To(Equal(0), "no C call once the scene stream's contexts were freed")
})
It("degrades to ASR-only after a mid-session scene feed failure: freed once, no more scene events, ASR keeps working", func() {
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"hi ","eou":0,"frame_sec":0.08,` +
`"words":[{"w":"hi","start":0.1,"end":0.3,"conf":0.9}]}`)
}
sceneFeedCalls := 0
begun, freed := liveSceneStubs(func(calls int, s uintptr, isLast int32) uintptr {
sceneFeedCalls++
return 0 // fails every call; only the first should ever be reached
})
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10)) // scene feed fails here: warn, free, zero the handle
in <- liveAudio(make([]float32, 10)) // ASR-only: no scene C call at all
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
// ready, ASR delta (msg 1), ASR delta (msg 2), final: no scene-only
// response ever appears, before or after the failure.
Expect(got).To(HaveLen(4))
Expect(got[0].Ready).To(BeTrue())
Expect(got[1].Delta).To(Equal("hi "))
Expect(got[2].Delta).To(Equal("hi "))
for _, r := range got {
Expect(r.Speakers).To(BeEmpty(), "no speaker events once the scene stream has failed")
Expect(r.Sounds).To(BeEmpty(), "no sound events once the scene stream has failed")
}
Expect(got[3].FinalResult).NotTo(BeNil())
Expect(got[3].FinalResult.Text).To(Equal("hi hi"))
Expect(*begun).To(Equal(1))
Expect(sceneFeedCalls).To(Equal(1), "the second audio message must not retry the broken scene stream")
Expect(*freed).To(Equal(1), "the broken scene stream is freed exactly once, not again at RPC unwind")
})
It("continues without scene events when scene begin fails", func() {
CppSceneOptsDefault = func(o *cSceneOpts) { *o = cSceneOpts{} }
CppSceneStreamBegin = func(asr, diar, tagger uintptr, o *cSceneOpts) uintptr { return 0 }
sceneFeedCalled := false
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr {
sceneFeedCalled = true
return 0
}
CppSceneStreamFree = func(s uintptr) {}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(got).To(HaveLen(2)) // ready, final only: no scene events, no ASR delta this stub sends
Expect(sceneFeedCalled).To(BeFalse(), "no feed call once begin failed")
})
})
var _ = Describe("AudioTranscriptionLive named speakers (stubbed C API)", func() {
var (
pool *liveCstrPool
restore func()
p *ParakeetCpp
regs []uintptr
plain int
spkBeg int
gotReg uintptr
order []string
)
liveVoicesConfig := func(voices ...*pb.KnownVoice) *pb.TranscriptLiveRequest {
return &pb.TranscriptLiveRequest{
Payload: &pb.TranscriptLiveRequest_Config{Config: &pb.TranscriptLiveConfig{KnownVoices: voices}},
}
}
ada := &pb.KnownVoice{Name: "Ada", Embedding: []float32{1, 0}}
BeforeEach(func() {
pool = &liveCstrPool{}
restore = liveStubs()
p = &ParakeetCpp{ctxPtr: 1, diarCtx: 2, spkCtx: 3, speakerAccept: 0.7, speakerMargin: 0.05}
regs, plain, spkBeg, gotReg, order = nil, 0, 0, 0, nil
CppStreamBeginLang = nil
CppStreamBegin = func(ctx uintptr) uintptr { return 7 }
CppStreamFree = func(s uintptr) {}
CppFreeString = func(s uintptr) {}
CppLastError = func(ctx uintptr) string { return "stub error" }
CppStreamFeed = nil
CppStreamFeedJSON = func(s uintptr, pcm []float32, n int32) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppStreamFinalize = nil
CppStreamFinalizeJSON = func(s uintptr) uintptr {
return pool.cstr(`{"text":"","eou":0,"frame_sec":0.08,"words":[]}`)
}
CppSceneOptsDefault = func(o *cSceneOpts) { *o = cSceneOpts{} }
CppSceneStreamBegin = func(asr, diar, tagger uintptr, o *cSceneOpts) uintptr { plain++; return 100 }
CppSceneStreamBeginSpeaker = func(asr, diar, tag, spk, reg uintptr, o *cSceneOpts) uintptr {
spkBeg++
gotReg = reg
return 200
}
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr {
return pool.cstr(`{"speakers":[{"speaker":0,"start":0.1,"end":0.6}],"sounds":[],` +
`"names":{"0":{"name":"Ada","score":0.9}}}`)
}
CppSceneStreamLastError = func(s uintptr) string { return "" }
CppSceneStreamFree = func(s uintptr) { order = append(order, "stream") }
CppSpeakerDim = func(uintptr) int32 { return 2 }
CppSpeakerRegistryNew = func() uintptr { return 9 }
CppSpeakerRegistryAddEmbedding = func(uintptr, string, *float32, int32) int32 { return 0 }
CppSpeakerRegistryFree = func(r uintptr) { regs = append(regs, r); order = append(order, "registry") }
})
AfterEach(func() { restore() })
It("begins a speaker scene stream and emits the slot name on the closed segment", func() {
in, out, errCh := runLive(p)
in <- liveVoicesConfig(ada)
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(spkBeg).To(Equal(1))
Expect(plain).To(Equal(0))
Expect(gotReg).To(Equal(uintptr(9)))
var named *pb.LiveSpeakerSegment
for _, r := range got {
if len(r.Speakers) > 0 {
named = r.Speakers[0]
}
}
Expect(named).NotTo(BeNil())
Expect(named.Speaker).To(Equal("0"))
Expect(named.Name).To(Equal("Ada"))
Expect(regs).To(Equal([]uintptr{9}), "the registry is freed once when the session ends")
Expect(order).To(Equal([]string{"stream", "registry"}))
})
It("uses the plain scene begin and emits empty names without known voices", func() {
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr {
return pool.cstr(`{"speakers":[{"speaker":0,"start":0.1,"end":0.6}],"sounds":[]}`)
}
in, out, errCh := runLive(p)
in <- liveConfig("")
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
got := collectLive(out)
Expect(plain).To(Equal(1))
Expect(spkBeg).To(Equal(0))
Expect(regs).To(BeEmpty())
found := false
for _, r := range got {
for _, s := range r.Speakers {
found = true
Expect(s.Name).To(BeEmpty())
}
}
Expect(found).To(BeTrue())
})
It("names the restarted scene stream after a config reset and frees every registry once", func() {
in, out, errCh := runLive(p)
in <- liveVoicesConfig(ada)
in <- liveAudio(make([]float32, 10))
in <- liveVoicesConfig(ada) // reset
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
collectLive(out)
Expect(spkBeg).To(Equal(2))
Expect(regs).To(HaveLen(2), "one registry per scene stream, each freed exactly once")
})
It("frees the registry once when a feed failure disables the scene stream", func() {
CppSceneStreamFeedJSON = func(s uintptr, pcm *float32, n int32, isLast int32) uintptr { return 0 }
in, out, errCh := runLive(p)
in <- liveVoicesConfig(ada)
in <- liveAudio(make([]float32, 10))
in <- liveAudio(make([]float32, 10))
close(in)
Expect(<-errCh).NotTo(HaveOccurred())
collectLive(out)
Expect(regs).To(Equal([]uintptr{9}))
})
})
var _ = Describe("stripEouMarker", func() {
It("strips a trailing <EOU> and reports it", func() {
text, eou := stripEouMarker("it is certainly very like the old portrait<EOU>")
Expect(text).To(Equal("it is certainly very like the old portrait"))
Expect(eou).To(BeTrue())
})
It("strips a trailing <EOB> WITHOUT reporting an utterance end", func() {
// A decode ending on a backchannel must not confirm the
// retranscribe gate — the user was acknowledging, not yielding.
text, eou := stripEouMarker("uh-huh<EOB>")
Expect(text).To(Equal("uh-huh"))
Expect(eou).To(BeFalse())
})
It("leaves marker-free text alone", func() {
text, eou := stripEouMarker("plain transcript")
Expect(text).To(Equal("plain transcript"))
Expect(eou).To(BeFalse())
})
It("does not strip a marker in the middle of the text", func() {
text, eou := stripEouMarker("a<EOU>b")
Expect(text).To(Equal("a<EOU>b"))
Expect(eou).To(BeFalse())
})
})
var _ = Describe("transcriptResultFromDoc EOU handling", func() {
It("strips the offline marker from text and sets the result flag", func() {
doc := transcriptJSON{Text: "the old portrait<EOU>"}
res := transcriptResultFromDoc(doc, &pb.TranscriptRequest{}, 0)
Expect(res.Text).To(Equal("the old portrait"))
Expect(res.Eou).To(BeTrue())
Expect(res.Segments).To(HaveLen(1))
Expect(res.Segments[0].Text).To(Equal("the old portrait"))
})
It("reports eou=false for marker-free decodes", func() {
doc := transcriptJSON{Text: "no marker here"}
res := transcriptResultFromDoc(doc, &pb.TranscriptRequest{}, 0)
Expect(res.Text).To(Equal("no marker here"))
Expect(res.Eou).To(BeFalse())
})
})