Skip to content

Commit 03f9bd8

Browse files
committed
fix(chat): split long TTS text so the relay cap cannot drop audio
The client sentence-splits on Western `.!?` only, so text that never matches — CJK punctuation, or a list with no terminal punctuation — accumulates and is flushed as one block at the end of the stream. Against the new 2000-character relay cap that block is rejected and the whole message plays no audio, a regression introduced by adding the cap. Split to cap-sized pieces at the single point that enqueues synthesis, so both the per-sentence path and the end-of-stream flush are covered. Prefers a whitespace or CJK punctuation boundary, falling back to a hard cut when a block has none. The server cap stays as the enforcement point.
1 parent 7bb2789 commit 03f9bd8

2 files changed

Lines changed: 98 additions & 2 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { MAX_TTS_TEXT_LENGTH } from '@/lib/api/contracts/media/tts-stream'
6+
import { splitForSynthesis } from '@/app/(interfaces)/chat/hooks/use-audio-streaming'
7+
8+
describe('splitForSynthesis', () => {
9+
it('leaves text within the relay cap untouched', () => {
10+
expect(splitForSynthesis('Short answer.')).toEqual(['Short answer.'])
11+
})
12+
13+
/**
14+
* The caller only sentence-splits on Western `.!?`, so CJK punctuation never
15+
* matches and the whole answer arrives as one block. Before splitting, the
16+
* relay rejected it and the message played no audio at all.
17+
*/
18+
it('splits CJK text that never matches the Western sentence split', () => {
19+
const text = '这是一个很长的回答。'.repeat(400)
20+
expect(text.length).toBeGreaterThan(MAX_TTS_TEXT_LENGTH)
21+
22+
const chunks = splitForSynthesis(text)
23+
24+
expect(chunks.length).toBeGreaterThan(1)
25+
for (const chunk of chunks) {
26+
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
27+
}
28+
})
29+
30+
it('splits a long list that has no terminal punctuation', () => {
31+
const text = Array.from({ length: 300 }, (_, i) => `- item number ${i}`).join('\n')
32+
expect(text.length).toBeGreaterThan(MAX_TTS_TEXT_LENGTH)
33+
34+
const chunks = splitForSynthesis(text)
35+
36+
for (const chunk of chunks) {
37+
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
38+
}
39+
})
40+
41+
it('preserves the spoken content across chunks', () => {
42+
const text = Array.from({ length: 500 }, (_, i) => `word${i}`).join(' ')
43+
44+
const chunks = splitForSynthesis(text)
45+
46+
expect(chunks.join(' ').replace(/\s+/g, ' ')).toBe(text)
47+
})
48+
49+
it('still caps text with no break opportunity at all', () => {
50+
const chunks = splitForSynthesis('a'.repeat(MAX_TTS_TEXT_LENGTH * 2 + 5))
51+
52+
expect(chunks.length).toBe(3)
53+
for (const chunk of chunks) {
54+
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
55+
}
56+
})
57+
})

apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,47 @@
22

33
import { type RefObject, useCallback, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5-
import { DEFAULT_TTS_MODEL_ID } from '@/lib/api/contracts/media/tts-stream'
5+
import { DEFAULT_TTS_MODEL_ID, MAX_TTS_TEXT_LENGTH } from '@/lib/api/contracts/media/tts-stream'
66

77
const logger = createLogger('UseAudioStreaming')
88

9+
/** Prefer breaking on a boundary this far into the chunk before splitting mid-word. */
10+
const MIN_SPLIT_RATIO = 0.6
11+
12+
/**
13+
* Splits text into pieces the TTS relay will accept.
14+
*
15+
* The caller sentence-splits on Western `.!?` only, so text that never matches
16+
* — CJK punctuation, or a list with no terminal punctuation — reaches this hook
17+
* as one accumulated block that can exceed the relay's per-request cap. Without
18+
* splitting, the relay rejects it and the whole message plays no audio.
19+
*/
20+
export function splitForSynthesis(text: string, max: number = MAX_TTS_TEXT_LENGTH): string[] {
21+
if (text.length <= max) return [text]
22+
23+
const chunks: string[] = []
24+
let rest = text
25+
26+
while (rest.length > max) {
27+
const window = rest.slice(0, max)
28+
const boundary = Math.max(
29+
window.lastIndexOf(' '),
30+
window.lastIndexOf('\n'),
31+
window.lastIndexOf('。'),
32+
window.lastIndexOf(','),
33+
window.lastIndexOf('、')
34+
)
35+
const cut = boundary >= max * MIN_SPLIT_RATIO ? boundary + 1 : max
36+
const piece = rest.slice(0, cut).trim()
37+
if (piece) chunks.push(piece)
38+
rest = rest.slice(cut)
39+
}
40+
41+
const tail = rest.trim()
42+
if (tail) chunks.push(tail)
43+
return chunks
44+
}
45+
946
declare global {
1047
interface Window {
1148
webkitAudioContext?: typeof AudioContext
@@ -157,7 +194,9 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
157194
abortControllerRef.current = new AbortController()
158195
}
159196

160-
audioQueueRef.current.push({ text, options })
197+
for (const piece of splitForSynthesis(text)) {
198+
audioQueueRef.current.push({ text: piece, options })
199+
}
161200
processAudioQueue()
162201
},
163202
[processAudioQueue]

0 commit comments

Comments
 (0)