Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { DEFAULT_TTS_VOICE_ID } from '@/lib/api/contracts/media/tts-stream'
import { noop } from '@/lib/core/utils/request'
import {
AGENT_STREAM_PROTOCOL_HEADER,
Expand Down Expand Up @@ -49,7 +50,7 @@ interface ChatRequestPayload {
}

const DEFAULT_VOICE_SETTINGS = {
voiceId: 'cgSgspJ2msm6clMCkdW9', // Default ElevenLabs voice (Jessica) — Flash v2.5-optimized
voiceId: DEFAULT_TTS_VOICE_ID,
}

/**
Expand Down
57 changes: 57 additions & 0 deletions apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { MAX_TTS_TEXT_LENGTH } from '@/lib/api/contracts/media/tts-stream'
import { splitForSynthesis } from '@/app/(interfaces)/chat/hooks/use-audio-streaming'

describe('splitForSynthesis', () => {
it('leaves text within the relay cap untouched', () => {
expect(splitForSynthesis('Short answer.')).toEqual(['Short answer.'])
})

/**
* The caller only sentence-splits on Western `.!?`, so CJK punctuation never
* matches and the whole answer arrives as one block. Before splitting, the
* relay rejected it and the message played no audio at all.
*/
it('splits CJK text that never matches the Western sentence split', () => {
const text = '这是一个很长的回答。'.repeat(400)
expect(text.length).toBeGreaterThan(MAX_TTS_TEXT_LENGTH)

const chunks = splitForSynthesis(text)

expect(chunks.length).toBeGreaterThan(1)
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
}
})

it('splits a long list that has no terminal punctuation', () => {
const text = Array.from({ length: 300 }, (_, i) => `- item number ${i}`).join('\n')
expect(text.length).toBeGreaterThan(MAX_TTS_TEXT_LENGTH)

const chunks = splitForSynthesis(text)

for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
}
})

it('preserves the spoken content across chunks', () => {
const text = Array.from({ length: 500 }, (_, i) => `word${i}`).join(' ')

const chunks = splitForSynthesis(text)

expect(chunks.join(' ').replace(/\s+/g, ' ')).toBe(text)
})

it('still caps text with no break opportunity at all', () => {
const chunks = splitForSynthesis('a'.repeat(MAX_TTS_TEXT_LENGTH * 2 + 5))

expect(chunks.length).toBe(3)
for (const chunk of chunks) {
expect(chunk.length).toBeLessThanOrEqual(MAX_TTS_TEXT_LENGTH)
}
})
})
44 changes: 42 additions & 2 deletions apps/sim/app/(interfaces)/chat/hooks/use-audio-streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,47 @@

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

const logger = createLogger('UseAudioStreaming')

/** Prefer breaking on a boundary this far into the chunk before splitting mid-word. */
const MIN_SPLIT_RATIO = 0.6

/**
* Splits text into pieces the TTS relay will accept.
*
* The caller sentence-splits on Western `.!?` only, so text that never matches
* — CJK punctuation, or a list with no terminal punctuation — reaches this hook
* as one accumulated block that can exceed the relay's per-request cap. Without
* splitting, the relay rejects it and the whole message plays no audio.
*/
export function splitForSynthesis(text: string, max: number = MAX_TTS_TEXT_LENGTH): string[] {
if (text.length <= max) return [text]

const chunks: string[] = []
let rest = text

while (rest.length > max) {
const window = rest.slice(0, max)
const boundary = Math.max(
window.lastIndexOf(' '),
window.lastIndexOf('\n'),
window.lastIndexOf('。'),
window.lastIndexOf(','),
window.lastIndexOf('、')
)
const cut = boundary >= max * MIN_SPLIT_RATIO ? boundary + 1 : max
const piece = rest.slice(0, cut).trim()
if (piece) chunks.push(piece)
rest = rest.slice(cut)
}

const tail = rest.trim()
if (tail) chunks.push(tail)
return chunks
}

declare global {
interface Window {
webkitAudioContext?: typeof AudioContext
Expand Down Expand Up @@ -79,7 +117,7 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
const { text, options } = item
const {
voiceId,
modelId = 'eleven_flash_v2_5',
modelId = DEFAULT_TTS_MODEL_ID,
chatId,
onAudioStart,
onAudioEnd,
Expand Down Expand Up @@ -156,7 +194,9 @@ export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext
abortControllerRef.current = new AbortController()
}

audioQueueRef.current.push({ text, options })
for (const piece of splitForSynthesis(text)) {
audioQueueRef.current.push({ text: piece, options })
}
processAudioQueue()
},
[processAudioQueue]
Expand Down
Loading
Loading