Skip to content

Commit 5fcf2a2

Browse files
committed
feat(realtime): script-aware clause chunking + streamed-reply fixes
Opt-in pipeline.streaming.clause_chunking splits the streamed LLM reply into speakable clauses and synthesizes each as soon as it completes, lowering time-to-first-audio instead of buffering the whole message. The splitter is script-aware (rivo/uniseg, pure Go): UAX#29 sentence segmentation handles CJK 。!? with no whitespace, CJK clause punctuation (,、;:) and Thai/Lao spaces give finer cuts, and a UAX#14 line-break cap bounds an over-long punctuation-less run. Unlike the old ASCII .!?/newline segmenter (dropped in 076dcdb) it does not degrade to whole-message buffering for CJK/Thai; scripts needing a dictionary (Khmer/Burmese) stay buffered until a space or end-of-message. Clauses are synthesized synchronously in the token callback (the LLM keeps generating into the gRPC stream meanwhile), so audio still starts mid-generation. Off by default — the whole-message path is unchanged. Also fix the streamed-reply path and the Talk page: - Don't swallow streamed autoparser content as reasoning: the tokenizer-template path already delivers reasoning-free content via ChatDeltas, so prefilling the thinking start token re-tagged it as an unclosed reasoning block, leaving no spoken reply. Disable the prefill on that path; closed tag pairs are still stripped (#9985). - Generate collision-free realtime IDs (16 random bytes) instead of a constant, so per-item bookkeeping (cancel, conversation.item.retrieve) works. - Key the Talk transcript by the server item_id and upsert entries. Realtime events arrive over a WebRTC data channel — outside React's event system — so React defers the setTranscript updaters while synchronous ref writes in handler bodies run first; the old index-tracking ref rendered a duplicate assistant bubble on completion. Upserts by item_id are idempotent and order-independent. - Drop the partial assistant bubble on a cancelled response (barge-in): the server discards the interrupted item and sends response.done with status "cancelled"; mirror that in the UI so the regenerated reply isn't rendered as a second assistant message. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Assisted-by: Claude:claude-fable-5 [Claude Code]
1 parent d05d83f commit 5fcf2a2

11 files changed

Lines changed: 528 additions & 57 deletions

File tree

core/config/meta/registry.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,13 @@ func DefaultRegistry() map[string]FieldMetaOverride {
336336
Component: "toggle",
337337
Order: 68,
338338
},
339+
"pipeline.streaming.clause_chunking": {
340+
Section: "pipeline",
341+
Label: "Clause Chunking",
342+
Description: "Split the streamed reply into speakable clauses and synthesize each as soon as it completes, instead of buffering the whole message before TTS — lower time-to-first-audio. Script-aware (handles CJK 。!? and Thai/Lao spaces), so it does not whitespace-split. Requires Stream LLM; off buffers the whole message.",
343+
Component: "toggle",
344+
Order: 69,
345+
},
339346

340347
// --- Functions ---
341348
"function.grammar.parallel_calls": {

core/config/model_config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -545,6 +545,12 @@ type PipelineStreaming struct {
545545
LLM *bool `yaml:"llm,omitempty" json:"llm,omitempty"`
546546
TTS *bool `yaml:"tts,omitempty" json:"tts,omitempty"`
547547
Transcription *bool `yaml:"transcription,omitempty" json:"transcription,omitempty"`
548+
// ClauseChunking splits the streamed LLM reply into speakable clauses and
549+
// synthesizes each as soon as it completes, instead of buffering the whole
550+
// message before TTS. Script-aware (CJK/Thai), so it does not rely on
551+
// whitespace sentence boundaries. Requires LLM streaming; unset buffers the
552+
// whole message (today's default).
553+
ClauseChunking *bool `yaml:"clause_chunking,omitempty" json:"clause_chunking,omitempty"`
548554
}
549555

550556
// StreamLLM reports whether LLM tokens should be streamed for this pipeline.
@@ -558,6 +564,12 @@ func (p Pipeline) StreamTranscription() bool {
558564
return p.Streaming.Transcription != nil && *p.Streaming.Transcription
559565
}
560566

567+
// ChunkClauses reports whether the streamed reply should be split into
568+
// script-aware clauses and synthesized incrementally rather than buffered whole.
569+
func (p Pipeline) ChunkClauses() bool {
570+
return p.Streaming.ClauseChunking != nil && *p.Streaming.ClauseChunking
571+
}
572+
561573
// ThinkingDisabled reports whether the pipeline forces the LLM's thinking off.
562574
func (p Pipeline) ThinkingDisabled() bool {
563575
return p.DisableThinking != nil && *p.DisableThinking

core/config/pipeline_streaming_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ var _ = Describe("Pipeline streaming config", func() {
1616
Expect(p.StreamLLM()).To(BeFalse())
1717
Expect(p.StreamTTS()).To(BeFalse())
1818
Expect(p.StreamTranscription()).To(BeFalse())
19+
Expect(p.ChunkClauses()).To(BeFalse())
1920
Expect(p.ThinkingDisabled()).To(BeFalse())
2021
})
2122

@@ -31,12 +32,14 @@ pipeline:
3132
llm: true
3233
tts: true
3334
transcription: true
35+
clause_chunking: true
3436
disable_thinking: true
3537
`), &c)
3638
Expect(err).ToNot(HaveOccurred())
3739
Expect(c.Pipeline.StreamLLM()).To(BeTrue())
3840
Expect(c.Pipeline.StreamTTS()).To(BeTrue())
3941
Expect(c.Pipeline.StreamTranscription()).To(BeTrue())
42+
Expect(c.Pipeline.ChunkClauses()).To(BeTrue())
4043
Expect(c.Pipeline.ThinkingDisabled()).To(BeTrue())
4144
})
4245

core/http/endpoints/openai/realtime.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package openai
22

33
import (
44
"context"
5+
"crypto/rand"
56
"encoding/base64"
67
"encoding/binary"
8+
"encoding/hex"
79
"encoding/json"
810
"fmt"
911
"math"
@@ -1983,8 +1985,11 @@ func generateItemID() string {
19831985
}
19841986

19851987
func generateUniqueID() string {
1986-
// Generate a unique ID string
1987-
// For simplicity, use a counter or UUID
1988-
// Implement as needed
1989-
return "unique_id"
1988+
// 16 random bytes, hex-encoded. Must be collision-free: session, item,
1989+
// response and call IDs build on this, and the conversation tracks/removes
1990+
// items by ID (e.g. cancel() in realtime_stream.go, conversation.item.retrieve).
1991+
// A constant would make every ID alias and corrupt that bookkeeping.
1992+
var b [16]byte
1993+
_, _ = rand.Read(b[:])
1994+
return hex.EncodeToString(b[:])
19901995
}
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package openai
2+
3+
import (
4+
"strings"
5+
"unicode"
6+
"unicode/utf8"
7+
8+
"github.com/rivo/uniseg"
9+
)
10+
11+
// Default clause-chunker bounds (in runes). minRunes gates only sub-sentence
12+
// (clause-mark / Thai-space) cuts so we don't synthesize tiny choppy fragments;
13+
// full sentences always flush regardless of length. maxRunes caps an
14+
// unterminated run so a long punctuation-less span doesn't buffer unbounded.
15+
const (
16+
defaultClauseMinRunes = 12
17+
defaultClauseMaxRunes = 200
18+
)
19+
20+
// clauseChunker splits streamed LLM content into speakable clauses for
21+
// incremental TTS, in a SCRIPT-AWARE way so it works for languages without
22+
// whitespace word boundaries. It leans on UAX #29 sentence segmentation (which
23+
// natively terminates on CJK 。!? as well as Latin .!?), adds CJK clause
24+
// punctuation (,、;:) and Thai/Lao spaces as finer boundaries, and caps an
25+
// over-long unterminated run via UAX #14 line-break opportunities.
26+
//
27+
// Unlike the old ASCII .!?/newline segmenter (dropped in 076dcdbe), it does not
28+
// degrade to whole-message buffering for CJK (handled natively) or Thai/Lao
29+
// (handled via spaces, which Thai uses at clause/sentence boundaries). Scripts
30+
// that genuinely need a dictionary (Khmer/Myanmar) simply stay buffered until a
31+
// space or end-of-message — no worse than the buffered default.
32+
//
33+
// It is not safe for concurrent use; callers feed it from a single goroutine
34+
// (the LLM token callback).
35+
type clauseChunker struct {
36+
buf strings.Builder
37+
minRunes int
38+
maxRunes int
39+
}
40+
41+
func newClauseChunker(minRunes, maxRunes int) *clauseChunker {
42+
return &clauseChunker{minRunes: minRunes, maxRunes: maxRunes}
43+
}
44+
45+
// push appends streamed content and returns any clauses that are now complete —
46+
// "complete" meaning confirmed by following content, so we never speak a clause
47+
// that the next token might extend. Incomplete trailing text stays buffered.
48+
func (c *clauseChunker) push(text string) []string {
49+
c.buf.WriteString(text)
50+
return c.drain(false)
51+
}
52+
53+
// flush returns the remaining buffered clauses, treating end-of-input as a hard
54+
// boundary, and clears the buffer.
55+
func (c *clauseChunker) flush() []string {
56+
return c.drain(true)
57+
}
58+
59+
func (c *clauseChunker) drain(final bool) []string {
60+
s := c.buf.String()
61+
rest := s
62+
var out []string
63+
for rest != "" {
64+
end, ok := c.nextBoundary(rest, final)
65+
if !ok {
66+
break
67+
}
68+
if seg := strings.TrimSpace(rest[:end]); seg != "" {
69+
out = append(out, seg)
70+
}
71+
rest = rest[end:]
72+
}
73+
// Rewriting the builder reallocates and copies the whole buffer; skip it on
74+
// the common per-token call where no boundary was confirmed.
75+
if len(rest) != len(s) {
76+
c.buf.Reset()
77+
c.buf.WriteString(rest)
78+
}
79+
return out
80+
}
81+
82+
// nextBoundary returns the byte offset just past the first emittable clause in
83+
// s, or ok=false when more input is needed (final=false) and no boundary is
84+
// confirmed yet.
85+
func (c *clauseChunker) nextBoundary(s string, final bool) (int, bool) {
86+
if s == "" {
87+
return 0, false
88+
}
89+
90+
// 1) UAX #29 sentence boundary. When the first sentence is followed by more
91+
// text it is a confirmed complete sentence (handles Latin .!? with
92+
// abbreviation/decimal guards, and CJK 。!? with no whitespace).
93+
sentence, rest, _ := uniseg.FirstSentenceInString(s, -1)
94+
if rest != "" {
95+
// Optionally cut finer inside the sentence at a clause boundary.
96+
if cut, ok := c.firstClauseCut(sentence); ok {
97+
return cut, true
98+
}
99+
return len(sentence), true
100+
}
101+
102+
// 2) Unterminated tail: look for a sub-sentence clause boundary (CJK
103+
// punctuation or a Thai/Lao space) confirmed by following content.
104+
if cut, ok := c.firstClauseCut(s); ok {
105+
return cut, true
106+
}
107+
108+
// 3) Over-long punctuation-less run: force a typographically legal break so
109+
// we don't buffer unbounded (e.g. a long CJK run with no punctuation).
110+
if !final && c.maxRunes > 0 && utf8.RuneCountInString(s) > c.maxRunes {
111+
if cut, ok := lineBreakCut(s, c.maxRunes); ok {
112+
return cut, true
113+
}
114+
}
115+
116+
// 4) End of input: emit whatever remains as the final clause.
117+
if final {
118+
return len(s), true
119+
}
120+
return 0, false
121+
}
122+
123+
// firstClauseCut returns the byte offset just past the first sub-sentence clause
124+
// boundary in s — a CJK clause punctuation mark, or a space following a Thai/Lao
125+
// letter — provided the prefix is at least minRunes long and non-space content
126+
// follows. The boundary mark (and any trailing spaces) stay with the left clause.
127+
func (c *clauseChunker) firstClauseCut(s string) (int, bool) {
128+
var prev rune
129+
runes := 0
130+
for i, r := range s {
131+
boundary := isCJKClausePunct(r) || (unicode.IsSpace(r) && isThaiLao(prev))
132+
if boundary && runes+1 >= c.minRunes {
133+
end := i + utf8.RuneLen(r)
134+
for end < len(s) {
135+
nr, sz := utf8.DecodeRuneInString(s[end:])
136+
if !unicode.IsSpace(nr) {
137+
break
138+
}
139+
end += sz
140+
}
141+
if end < len(s) { // confirmed: real content follows the boundary
142+
return end, true
143+
}
144+
// Boundary sits at the end of the buffer with nothing after it yet —
145+
// wait for the next token to confirm it rather than emit early.
146+
return 0, false
147+
}
148+
prev = r
149+
runes++
150+
}
151+
return 0, false
152+
}
153+
154+
// lineBreakCut walks UAX #14 line segments and returns the byte offset of the
155+
// last legal break opportunity at or before maxRunes. Returns ok=false when the
156+
// run has no internal break opportunity (e.g. a space-less Thai run), leaving it
157+
// buffered.
158+
func lineBreakCut(s string, maxRunes int) (int, bool) {
159+
state := -1
160+
rest := s
161+
consumed := 0
162+
runes := 0
163+
for rest != "" {
164+
seg, rem, _, st := uniseg.FirstLineSegmentInString(rest, state)
165+
state = st
166+
runes += utf8.RuneCountInString(seg)
167+
consumed += len(seg)
168+
rest = rem
169+
if runes >= maxRunes {
170+
if consumed < len(s) {
171+
return consumed, true
172+
}
173+
return 0, false
174+
}
175+
}
176+
return 0, false
177+
}
178+
179+
// isCJKClausePunct reports whether r is a CJK clause-level separator worth a
180+
// soft TTS break. Sentence terminators (。!?) are intentionally excluded — UAX
181+
// #29 sentence segmentation already handles those.
182+
func isCJKClausePunct(r rune) bool {
183+
switch r {
184+
case ',', // , fullwidth comma
185+
'、', // 、 ideographic comma
186+
';', // ; fullwidth semicolon
187+
':', // : fullwidth colon
188+
'・', // ・ katakana middle dot
189+
'・': // ・ halfwidth katakana middle dot
190+
return true
191+
}
192+
return false
193+
}
194+
195+
// isThaiLao reports whether r is a Thai or Lao letter. Those scripts have no
196+
// inter-word spaces; an ASCII space inside such a run marks a clause/sentence
197+
// boundary, which is the only no-dictionary segmentation signal available.
198+
func isThaiLao(r rune) bool {
199+
return unicode.Is(unicode.Thai, r) || unicode.Is(unicode.Lao, r)
200+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package openai
2+
3+
import (
4+
"strings"
5+
"unicode/utf8"
6+
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
// clauseChunker splits streamed LLM content into speakable clauses in a
12+
// script-aware way: UAX#29 sentences (Latin .!? and CJK 。!?), CJK clause
13+
// punctuation, and Thai/Lao spaces — never whitespace-splitting CJK.
14+
var _ = Describe("clauseChunker", func() {
15+
Context("Latin sentences", func() {
16+
It("emits a sentence only once following content confirms it is complete", func() {
17+
c := newClauseChunker(12, 200)
18+
Expect(c.push("Hello world. How are you?")).To(Equal([]string{"Hello world."}))
19+
// The trailing sentence is held until flush (the next token might extend it).
20+
Expect(c.flush()).To(Equal([]string{"How are you?"}))
21+
})
22+
23+
It("assembles a sentence across many small tokens", func() {
24+
c := newClauseChunker(12, 200)
25+
var got []string
26+
for _, tok := range []string{"Hello", " world.", " How", " are", " you?"} {
27+
got = append(got, c.push(tok)...)
28+
}
29+
got = append(got, c.flush()...)
30+
Expect(got).To(Equal([]string{"Hello world.", "How are you?"}))
31+
})
32+
33+
It("does not split decimals or abbreviations (UAX#29 SB6)", func() {
34+
c := newClauseChunker(12, 200)
35+
got := c.push("Pi is 3.14 and e is 2.72. Done")
36+
Expect(got).To(Equal([]string{"Pi is 3.14 and e is 2.72."}))
37+
Expect(c.flush()).To(Equal([]string{"Done"}))
38+
})
39+
})
40+
41+
Context("CJK (no whitespace)", func() {
42+
It("splits Chinese on the ideographic full stop", func() {
43+
c := newClauseChunker(12, 200)
44+
Expect(c.push("你好世界。今天天气很好。")).To(Equal([]string{"你好世界。"}))
45+
Expect(c.flush()).To(Equal([]string{"今天天气很好。"}))
46+
})
47+
48+
It("splits Japanese on the ideographic full stop", func() {
49+
c := newClauseChunker(12, 200)
50+
Expect(c.push("こんにちは。元気ですか。")).To(Equal([]string{"こんにちは。"}))
51+
Expect(c.flush()).To(Equal([]string{"元気ですか。"}))
52+
})
53+
54+
It("splits on CJK clause punctuation for lower latency", func() {
55+
c := newClauseChunker(2, 200) // small min so short test clauses cut
56+
Expect(c.push("你好,世界。再见")).To(Equal([]string{"你好,", "世界。"}))
57+
Expect(c.flush()).To(Equal([]string{"再见"}))
58+
})
59+
})
60+
61+
Context("Thai (spaces mark clauses, not words)", func() {
62+
It("splits a Thai run on the inter-clause space", func() {
63+
c := newClauseChunker(2, 200)
64+
Expect(c.push("สวัสดีครับ กินข้าวไหม")).To(Equal([]string{"สวัสดีครับ"}))
65+
Expect(c.flush()).To(Equal([]string{"กินข้าวไหม"}))
66+
})
67+
68+
It("never shatters a space-less Thai run into characters", func() {
69+
c := newClauseChunker(2, 200)
70+
Expect(c.push("สวัสดีครับ")).To(BeEmpty()) // held, no boundary
71+
Expect(c.flush()).To(Equal([]string{"สวัสดีครับ"}))
72+
})
73+
})
74+
75+
Context("length cap (UAX#14 fallback)", func() {
76+
It("force-breaks an over-long punctuation-less CJK run at legal points", func() {
77+
c := newClauseChunker(4, 10) // maxRunes = 10
78+
run := strings.Repeat("字", 25)
79+
got := c.push(run)
80+
got = append(got, c.flush()...)
81+
total := 0
82+
for _, seg := range got {
83+
n := utf8.RuneCountInString(seg)
84+
Expect(n).To(BeNumerically("<=", 10)) // never exceeds the cap
85+
total += n
86+
}
87+
Expect(total).To(Equal(25)) // nothing dropped
88+
Expect(len(got)).To(BeNumerically(">=", 3)) // 10 + 10 + 5
89+
})
90+
})
91+
92+
Context("buffer lifecycle", func() {
93+
It("flush clears the buffer so the chunker is reusable", func() {
94+
c := newClauseChunker(12, 200)
95+
// "First one." is confirmed by the following "Second", so push drains it;
96+
// only the unterminated tail remains for flush.
97+
Expect(c.push("First one. Second")).To(Equal([]string{"First one."}))
98+
Expect(c.flush()).To(Equal([]string{"Second"}))
99+
Expect(c.flush()).To(BeEmpty())
100+
Expect(c.push("Again. More")).To(Equal([]string{"Again."}))
101+
})
102+
})
103+
})

0 commit comments

Comments
 (0)