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].
8693type 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
92116type 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