Skip to content

Commit a7cb587

Browse files
localai-botmudler
andauthored
feat(parakeet-cpp): real segment timestamps (NeMo-faithful) (#10207)
* feat(parakeet-cpp): real segment timestamps (NeMo-faithful) Offline: replace the single synthetic whole-clip segment with multiple segments grouped exactly like NeMo's get_segment_offsets - a new segment after sentence-ending punctuation ('. ? !'), each carrying start/end and its time-window token ids. The optional model option segment_gap_threshold (NeMo's unit: encoder FRAMES, default 0=off) adds NeMo's silence-gap split, converted to seconds via the JSON frame_sec the engine now reports. Per-segment words are still gated behind timestamp_granularities=["word"]; a zero-word document falls back to a single text segment. Streaming: when libparakeet.so exposes the ABI v4 JSON entry points (probed), drive parakeet_capi_stream_feed_json / _finalize_json and accumulate the streamed per-word timestamps into per-utterance segments (EOU stays the boundary), so streaming FinalResult segments now carry start/end. Falls back to the text-only feed against an older library. Pure-Go specs cover splitWordsIntoSegments (punctuation + gap rules, NeMo elif order, fallback), transcriptResultFromDoc (multi-segment, token windows, word-granularity gate), and the streaming segmenter. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(audio): document parakeet-cpp segment timestamps + segment_gap_threshold Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(parakeet-cpp): update model-gated specs for multi-segment output The offline AudioTranscription specs asserted the old single synthetic segment (Segments HaveLen(1), Segments[0].Text == res.Text). With NeMo-faithful segmentation a multi-sentence clip now yields multiple punctuation-delimited segments, so assert the new contract instead: one-or-more time-ordered segments, each with text and (under word granularity) per-segment words whose span tracks the segment start/end. Caught by running the model-gated suite on the dgx (GB10) against the real tdt_ctc-110m + realtime_eou models. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent f7c74ad commit a7cb587

5 files changed

Lines changed: 455 additions & 38 deletions

File tree

backend/go/parakeet-cpp/goparakeetcpp.go

Lines changed: 263 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ var (
6767
// plus a trailing target_lang ("" means the model default). Present only in
6868
// newer libparakeet.so; nil falls back to CppStreamBegin.
6969
CppStreamBeginLang func(ctx uintptr, targetLang string) uintptr
70+
71+
// Streaming JSON variants (ABI v4): feed/finalize returning a malloc'd char*
72+
// JSON document {text,eou,frame_sec,words} (uintptr, freed via CppFreeString)
73+
// so streaming segments can carry per-word timestamps. Present only in newer
74+
// libparakeet.so; nil falls back to the text-only CppStreamFeed/Finalize path.
75+
CppStreamFeedJSON func(s uintptr, pcm []float32, nSamples int32) uintptr
76+
CppStreamFinalizeJSON func(s uintptr) uintptr
7077
)
7178

7279
// streamChunkSamples is how much 16 kHz mono PCM we hand to stream_feed per
@@ -84,9 +91,26 @@ const streamChunkSamples = 16000
8491
//
8592
// "start"/"end"/"t" are seconds; "conf" is confidence in (0,1].
8693
type transcriptJSON struct {
87-
Text string `json:"text"`
88-
Words []transcriptWord `json:"words"`
89-
Tokens []transcriptToken `json:"tokens"`
94+
Text string `json:"text"`
95+
FrameSec float64 `json:"frame_sec"`
96+
Words []transcriptWord `json:"words"`
97+
Tokens []transcriptToken `json:"tokens"`
98+
}
99+
100+
// streamFeedJSON mirrors the document returned by
101+
// parakeet_capi_stream_feed_json / parakeet_capi_stream_finalize_json (ABI v4):
102+
//
103+
// {"text":"...","eou":0,"frame_sec":0.080000,
104+
// "words":[{"w":"...","start":0.480,"end":0.640,"conf":0.9100}, ...]}
105+
//
106+
// "text" is the newly-finalized text since the last call; "eou" is 1 when an
107+
// <EOU>/<EOB> fired this feed; "words" are the words finalized this call with
108+
// absolute (stream-relative) start/end seconds.
109+
type streamFeedJSON struct {
110+
Text string `json:"text"`
111+
Eou int `json:"eou"`
112+
FrameSec float64 `json:"frame_sec"`
113+
Words []transcriptWord `json:"words"`
90114
}
91115

92116
type transcriptWord struct {
@@ -115,6 +139,10 @@ type ParakeetCpp struct {
115139
engineMu sync.Mutex // sole guard of the one C engine (dispatcher + streaming)
116140
bat *batcher
117141
batStop chan struct{}
142+
// segmentGapFrames is NeMo's segment_gap_threshold in ENCODER FRAMES (model
143+
// YAML option, default 0=off). When >0 it adds NeMo's silence-gap split on
144+
// top of the punctuation split; converted to seconds via the JSON frame_sec.
145+
segmentGapFrames int
118146
}
119147

120148
// Load is the LocalAI gRPC entry point for LoadModel: it calls
@@ -144,6 +172,11 @@ func (p *ParakeetCpp) Load(opts *pb.ModelOptions) error {
144172
if maxWaitMs < 0 {
145173
maxWaitMs = 0
146174
}
175+
176+
// NeMo's segment_gap_threshold (encoder frames, default 0=off). Off by
177+
// default matches NeMo's default (punctuation-only segments); when set it
178+
// additionally splits segments on inter-word silence (see transcriptResultFromDoc).
179+
p.segmentGapFrames = optInt(opts, "segment_gap_threshold", 0)
147180
if CppTranscribePcmBatchJSON != nil {
148181
p.batStop = make(chan struct{})
149182
p.bat = newBatcher(maxSize, time.Duration(maxWaitMs)*time.Millisecond, p.runBatch)
@@ -283,7 +316,7 @@ func (p *ParakeetCpp) AudioTranscription(ctx context.Context, opts *pb.Transcrip
283316
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
284317
return pb.TranscriptResult{}, fmt.Errorf("parakeet-cpp: decode transcript json: %w", err)
285318
}
286-
return transcriptResultFromDoc(doc, opts), nil
319+
return transcriptResultFromDoc(doc, opts, p.segmentGapFrames), nil
287320
}
288321

289322
// Batched path: decode to PCM, submit to the batcher, wait for this request's
@@ -312,34 +345,169 @@ func (p *ParakeetCpp) AudioTranscription(ctx context.Context, opts *pb.Transcrip
312345
if err := json.Unmarshal([]byte(res.json), &doc); err != nil {
313346
return pb.TranscriptResult{}, fmt.Errorf("parakeet-cpp: decode transcript json: %w", err)
314347
}
315-
return transcriptResultFromDoc(doc, opts), nil
348+
return transcriptResultFromDoc(doc, opts, p.segmentGapFrames), nil
316349
}
317350

351+
// segmentSeparators is NeMo's default segment_seperators (sentence-ending
352+
// punctuation). Splitting on these matches NeMo's default segment timestamps.
353+
var segmentSeparators = []rune{'.', '?', '!'}
354+
318355
// transcriptResultFromDoc maps a decoded transcriptJSON to a TranscriptResult,
319-
// synthesising a single whole-clip segment and attaching word timings only when
320-
// the caller requested word granularity. Shared by the batched and direct paths.
321-
func transcriptResultFromDoc(doc transcriptJSON, opts *pb.TranscriptRequest) pb.TranscriptResult {
356+
// grouping words into NeMo-faithful segments (see splitWordsIntoSegments). The
357+
// optional gapFrames (NeMo's segment_gap_threshold, in encoder FRAMES; 0=off)
358+
// additionally splits on inter-word silence; it is converted to a seconds gap
359+
// with the document's frame_sec. Per-segment word timings are attached only when
360+
// the caller requested word granularity; token ids populate each segment's
361+
// Tokens by time-window membership. Shared by the batched and direct paths.
362+
func transcriptResultFromDoc(doc transcriptJSON, opts *pb.TranscriptRequest, gapFrames int) pb.TranscriptResult {
322363
text := strings.TrimSpace(doc.Text)
323-
words := make([]*pb.TranscriptWord, 0, len(doc.Words))
324-
for _, w := range doc.Words {
325-
words = append(words, &pb.TranscriptWord{Start: secondsToNanos(w.Start), End: secondsToNanos(w.End), Text: w.W})
364+
365+
// Frame-unit gap threshold -> seconds (NeMo segment_gap_threshold). 0 = off.
366+
gapSeconds := 0.0
367+
if gapFrames > 0 {
368+
if doc.FrameSec > 0 {
369+
gapSeconds = float64(gapFrames) * doc.FrameSec
370+
} else {
371+
xlog.Warn("parakeet-cpp: segment_gap_threshold set but libparakeet.so " +
372+
"did not report frame_sec; falling back to punctuation-only segments")
373+
}
374+
}
375+
376+
groups := splitWordsIntoSegments(doc.Words, segmentSeparators, gapSeconds)
377+
if len(groups) == 0 {
378+
// No words (edge case): single whole-clip text segment.
379+
return pb.TranscriptResult{
380+
Text: text,
381+
Segments: []*pb.TranscriptSegment{{Id: 0, Text: text}},
382+
}
383+
}
384+
385+
wantWords := wordsRequested(opts.TimestampGranularities)
386+
segments := make([]*pb.TranscriptSegment, 0, len(groups))
387+
for id, group := range groups {
388+
parts := make([]string, len(group))
389+
for i, gw := range group {
390+
parts[i] = gw.W
391+
}
392+
seg := &pb.TranscriptSegment{
393+
Id: int32(id),
394+
Start: secondsToNanos(group[0].Start),
395+
End: secondsToNanos(group[len(group)-1].End),
396+
Text: strings.TrimSpace(strings.Join(parts, " ")),
397+
Tokens: tokensInWindow(doc.Tokens, group[0].Start, group[len(group)-1].End),
398+
}
399+
if wantWords {
400+
ws := make([]*pb.TranscriptWord, len(group))
401+
for i, gw := range group {
402+
ws[i] = &pb.TranscriptWord{Start: secondsToNanos(gw.Start), End: secondsToNanos(gw.End), Text: gw.W}
403+
}
404+
seg.Words = ws
405+
}
406+
segments = append(segments, seg)
407+
}
408+
return pb.TranscriptResult{Text: text, Segments: segments}
409+
}
410+
411+
// splitWordsIntoSegments groups words into segments exactly as NeMo's
412+
// get_segment_offsets does (nemo/collections/asr/parts/utils/timestamp_utils.py).
413+
// Walking the words, it closes a segment when (1) the gap rule is enabled
414+
// (gapSeconds > 0) and the segment already has words and the gap from the
415+
// previous word's end to this word's start is >= gapSeconds - the current word
416+
// then STARTS a new segment - or, checked only when the gap rule did not apply
417+
// (NeMo's elif), (2) the word ends with (or is) a separator, which closes the
418+
// segment INCLUDING that word. Trailing words flush into a final segment.
419+
// gapSeconds <= 0 disables the gap rule, matching NeMo's default
420+
// segment_gap_threshold=None (punctuation-only segments).
421+
func splitWordsIntoSegments(words []transcriptWord, separators []rune, gapSeconds float64) [][]transcriptWord {
422+
var segments [][]transcriptWord
423+
var cur []transcriptWord
424+
for i, word := range words {
425+
gapActive := gapSeconds > 0 && len(cur) > 0
426+
if gapActive && (word.Start-words[i-1].End) >= gapSeconds {
427+
segments = append(segments, cur)
428+
cur = []transcriptWord{word}
429+
continue
430+
}
431+
if !gapActive && endsWithSeparator(word.W, separators) {
432+
cur = append(cur, word)
433+
segments = append(segments, cur)
434+
cur = nil
435+
continue
436+
}
437+
cur = append(cur, word)
326438
}
327-
tokens := make([]int32, 0, len(doc.Tokens))
328-
for _, t := range doc.Tokens {
329-
tokens = append(tokens, t.ID)
439+
if len(cur) > 0 {
440+
segments = append(segments, cur)
441+
}
442+
return segments
443+
}
444+
445+
// endsWithSeparator reports whether w's last rune is in separators (matching
446+
// NeMo's `word[-1] in delims or word in delims`).
447+
func endsWithSeparator(w string, separators []rune) bool {
448+
r := []rune(strings.TrimSpace(w))
449+
if len(r) == 0 {
450+
return false
451+
}
452+
last := r[len(r)-1]
453+
for _, s := range separators {
454+
if last == s {
455+
return true
456+
}
457+
}
458+
return false
459+
}
460+
461+
// tokensInWindow returns the ids of tokens whose timestamp t falls in
462+
// [start, end] (inclusive), assigning each token to the segment that spans its
463+
// time. The last segment's end is the last word end, so the final token is
464+
// included.
465+
func tokensInWindow(tokens []transcriptToken, start, end float64) []int32 {
466+
var ids []int32
467+
for _, t := range tokens {
468+
if t.T >= start && t.T <= end {
469+
ids = append(ids, t.ID)
470+
}
330471
}
331-
var segStart, segEnd int64
332-
if len(words) > 0 {
333-
segStart = words[0].Start
334-
segEnd = words[len(words)-1].End
472+
return ids
473+
}
474+
475+
// streamSegmenter accumulates streaming words into per-utterance segments. EOU
476+
// is the model's own utterance boundary; each closed segment takes its start/end
477+
// from its first/last accumulated word.
478+
type streamSegmenter struct {
479+
segs []*pb.TranscriptSegment
480+
cur []transcriptWord
481+
nextID int32
482+
}
483+
484+
func (s *streamSegmenter) add(doc streamFeedJSON) {
485+
s.cur = append(s.cur, doc.Words...)
486+
if doc.Eou != 0 {
487+
s.flush()
335488
}
336-
seg := &pb.TranscriptSegment{Id: 0, Start: segStart, End: segEnd, Text: text, Tokens: tokens}
337-
if wordsRequested(opts.TimestampGranularities) {
338-
seg.Words = words
489+
}
490+
491+
func (s *streamSegmenter) flush() {
492+
if len(s.cur) == 0 {
493+
return
339494
}
340-
return pb.TranscriptResult{Text: text, Segments: []*pb.TranscriptSegment{seg}}
495+
parts := make([]string, len(s.cur))
496+
for i, w := range s.cur {
497+
parts[i] = w.W
498+
}
499+
s.segs = append(s.segs, &pb.TranscriptSegment{
500+
Id: s.nextID,
501+
Start: secondsToNanos(s.cur[0].Start),
502+
End: secondsToNanos(s.cur[len(s.cur)-1].End),
503+
Text: strings.TrimSpace(strings.Join(parts, " ")),
504+
})
505+
s.nextID++
506+
s.cur = nil
341507
}
342508

509+
func (s *streamSegmenter) segments() []*pb.TranscriptSegment { return s.segs }
510+
343511
// wordsRequested reports whether the caller asked for word-level timestamps.
344512
// The OpenAI transcription API gates word timings behind
345513
// timestamp_granularities[] containing "word" and defaults to segment-level
@@ -419,6 +587,14 @@ func (p *ParakeetCpp) AudioTranscriptionStream(ctx context.Context, opts *pb.Tra
419587
return err
420588
}
421589

590+
// ABI v4: when the streaming JSON entry points are present, drive them so the
591+
// per-utterance segments carry per-word start/end timestamps. Falls through to
592+
// the text-only loop below against an older libparakeet.so. Runs under the
593+
// engineMu already held above.
594+
if CppStreamFeedJSON != nil {
595+
return p.streamJSON(ctx, stream, data, duration, results)
596+
}
597+
422598
var (
423599
full strings.Builder
424600
segText strings.Builder
@@ -495,6 +671,71 @@ func (p *ParakeetCpp) AudioTranscriptionStream(ctx context.Context, opts *pb.Tra
495671
return nil
496672
}
497673

674+
// streamJSON drives the ABI v4 streaming JSON entry points: each feed/finalize
675+
// returns a {text,eou,frame_sec,words} document. The newly-finalized text is
676+
// emitted as a delta (unchanged streaming contract) while words are accumulated
677+
// into per-utterance segments (closed on EOU) so the closing FinalResult carries
678+
// timestamped segments. Runs under engineMu (already held by the caller).
679+
func (p *ParakeetCpp) streamJSON(ctx context.Context, stream uintptr, data []float32,
680+
duration float32, results chan *pb.TranscriptStreamResponse) error {
681+
var (
682+
full strings.Builder
683+
seg streamSegmenter
684+
)
685+
// consume frees the malloc'd char* (a 0 return is an error), parses the JSON,
686+
// emits the delta, and routes words through the segmenter.
687+
consume := func(ret uintptr) error {
688+
if ret == 0 {
689+
msg := CppLastError(p.ctxPtr)
690+
if msg == "" {
691+
msg = "unknown error"
692+
}
693+
return fmt.Errorf("parakeet-cpp: stream feed/finalize failed: %s", msg)
694+
}
695+
raw := goStringFromCPtr(ret)
696+
CppFreeString(ret)
697+
var doc streamFeedJSON
698+
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
699+
return fmt.Errorf("parakeet-cpp: decode stream json: %w", err)
700+
}
701+
if doc.Text != "" {
702+
full.WriteString(doc.Text)
703+
results <- &pb.TranscriptStreamResponse{Delta: doc.Text}
704+
}
705+
seg.add(doc)
706+
return nil
707+
}
708+
709+
for off := 0; off < len(data); off += streamChunkSamples {
710+
if err := ctx.Err(); err != nil {
711+
return status.Error(codes.Canceled, "transcription cancelled")
712+
}
713+
end := min(off+streamChunkSamples, len(data))
714+
chunk := data[off:end]
715+
if err := consume(CppStreamFeedJSON(stream, chunk, int32(len(chunk)))); err != nil {
716+
return err
717+
}
718+
}
719+
if err := consume(CppStreamFinalizeJSON(stream)); err != nil {
720+
return err
721+
}
722+
seg.flush() // close any trailing utterance that never saw an EOU
723+
724+
text := strings.TrimSpace(full.String())
725+
segments := seg.segments()
726+
if len(segments) == 0 && text != "" {
727+
segments = append(segments, &pb.TranscriptSegment{Id: 0, Text: text})
728+
}
729+
results <- &pb.TranscriptStreamResponse{
730+
FinalResult: &pb.TranscriptResult{
731+
Text: text,
732+
Segments: segments,
733+
Duration: duration,
734+
},
735+
}
736+
return nil
737+
}
738+
498739
// decodeWavMono16k converts any input audio to 16 kHz mono PCM and returns the
499740
// float samples plus the clip duration in seconds. Mirrors the whisper
500741
// backend: utils.AudioToWav (ffmpeg) normalises rate/channels, go-audio

0 commit comments

Comments
 (0)