Files
simstudioai--sim/apps/sim/hooks/use-speech-to-text.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

404 lines
12 KiB
TypeScript

'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createLogger } from '@sim/logger'
import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getVoiceSettingsContract } from '@/lib/api/contracts/common'
import { speechTokenContract } from '@/lib/api/contracts/media/speech'
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
import {
CHUNK_SEND_INTERVAL_MS,
ELEVENLABS_WS_URL,
MAX_SESSION_MS,
SAMPLE_RATE,
} from '@/lib/speech/config'
const logger = createLogger('useSpeechToText')
export type PermissionState = 'prompt' | 'granted' | 'denied'
interface UseSpeechToTextProps {
onTranscript: (text: string) => void
/**
* Called on a 402 from the token endpoint, with the server's limit message and
* whether it was a per-member cap (which only an org admin can raise).
*/
onUsageLimitExceeded?: (message?: string, isMemberLimit?: boolean) => void
language?: string
/** Attributes the voice-input cost to this workspace for per-member usage. */
workspaceId?: string
}
interface UseSpeechToTextReturn {
isListening: boolean
isSupported: boolean
permissionState: PermissionState
toggleListening: () => void
resetTranscript: () => void
}
export function useSpeechToText({
onTranscript,
onUsageLimitExceeded,
language,
workspaceId,
}: UseSpeechToTextProps): UseSpeechToTextReturn {
const [isListening, setIsListening] = useState(false)
const [isSupported, setIsSupported] = useState(false)
const [permissionState, setPermissionState] = useState<PermissionState>('prompt')
const onTranscriptRef = useRef(onTranscript)
const onUsageLimitExceededRef = useRef(onUsageLimitExceeded)
const languageRef = useRef(language)
const workspaceIdRef = useRef(workspaceId)
const mountedRef = useRef(true)
const startingRef = useRef(false)
const wsRef = useRef<WebSocket | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioContextRef = useRef<AudioContext | null>(null)
const processorRef = useRef<ScriptProcessorNode | null>(null)
const pcmBufferRef = useRef<Float32Array[]>([])
const sendIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const sessionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const stopStreamingRef = useRef<() => void>(() => {})
const isFirstChunkRef = useRef(true)
const committedTextRef = useRef('')
onTranscriptRef.current = onTranscript
onUsageLimitExceededRef.current = onUsageLimitExceeded
languageRef.current = language
workspaceIdRef.current = workspaceId
useEffect(() => {
const browserOk =
typeof window !== 'undefined' &&
typeof AudioContext !== 'undefined' &&
typeof WebSocket !== 'undefined' &&
typeof navigator?.mediaDevices?.getUserMedia === 'function'
if (!browserOk) {
setIsSupported(false)
return
}
requestJson(getVoiceSettingsContract, {})
.then((data) => {
if (mountedRef.current) setIsSupported(data.sttAvailable === true)
})
.catch(() => {
if (mountedRef.current) setIsSupported(false)
})
}, [])
const flushAudioBuffer = useCallback(() => {
const ws = wsRef.current
if (!ws || ws.readyState !== WebSocket.OPEN) return
const chunks = pcmBufferRef.current
if (chunks.length === 0) return
pcmBufferRef.current = []
let totalLength = 0
for (const chunk of chunks) totalLength += chunk.length
const merged = new Float32Array(totalLength)
let offset = 0
for (const chunk of chunks) {
merged.set(chunk, offset)
offset += chunk.length
}
const pcm16 = floatTo16BitPCM(merged)
const message: Record<string, unknown> = {
message_type: 'input_audio_chunk',
audio_base_64: arrayBufferToBase64(pcm16),
sample_rate: SAMPLE_RATE,
commit: false,
}
if (isFirstChunkRef.current) {
isFirstChunkRef.current = false
if (committedTextRef.current) {
message.previous_text = committedTextRef.current
}
}
ws.send(JSON.stringify(message))
}, [])
const cleanup = useCallback(() => {
if (sessionTimerRef.current) {
clearTimeout(sessionTimerRef.current)
sessionTimerRef.current = null
}
if (sendIntervalRef.current) {
clearInterval(sendIntervalRef.current)
sendIntervalRef.current = null
}
if (processorRef.current) {
processorRef.current.disconnect()
processorRef.current = null
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(() => {})
audioContextRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
if (wsRef.current) {
if (
wsRef.current.readyState === WebSocket.OPEN ||
wsRef.current.readyState === WebSocket.CONNECTING
) {
wsRef.current.close()
}
wsRef.current = null
}
pcmBufferRef.current = []
isFirstChunkRef.current = true
}, [])
const startStreaming = useCallback(async () => {
if (startingRef.current) return false
startingRef.current = true
try {
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
try {
tokenData = await requestJson(speechTokenContract, {
body: workspaceIdRef.current ? { workspaceId: workspaceIdRef.current } : {},
})
} catch (err) {
if (isApiClientError(err) && err.status === 402) {
const isMemberLimit = (err.body as { scope?: string } | null)?.scope === 'member'
onUsageLimitExceededRef.current?.(err.message, isMemberLimit)
return false
}
throw err instanceof Error ? err : new Error('Failed to get speech token')
}
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
if (!token) throw new Error('Failed to get speech token')
if (!mountedRef.current) return false
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
channelCount: 1,
sampleRate: SAMPLE_RATE,
},
})
if (!mountedRef.current) {
stream.getTracks().forEach((track) => track.stop())
return false
}
setPermissionState('granted')
streamRef.current = stream
const params = new URLSearchParams({
token,
model_id: 'scribe_v2_realtime',
audio_format: 'pcm_16000',
commit_strategy: 'vad',
vad_silence_threshold_secs: '1.0',
})
if (languageRef.current) {
params.set('language_code', languageRef.current)
}
const ws = new WebSocket(`${ELEVENLABS_WS_URL}?${params.toString()}`)
wsRef.current = ws
committedTextRef.current = ''
isFirstChunkRef.current = true
await new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve()
ws.onerror = () => reject(new Error('WebSocket connection failed'))
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.message_type === 'partial_transcript' && msg.text) {
if (mountedRef.current) {
const full = committedTextRef.current
? `${committedTextRef.current} ${msg.text}`
: msg.text
onTranscriptRef.current(full)
}
} else if (
(msg.message_type === 'committed_transcript' ||
msg.message_type === 'committed_transcript_with_timestamps') &&
msg.text
) {
committedTextRef.current = committedTextRef.current
? `${committedTextRef.current} ${msg.text}`
: msg.text
if (mountedRef.current) {
onTranscriptRef.current(committedTextRef.current)
}
} else if (
msg.message_type === 'error' ||
msg.message_type === 'auth_error' ||
msg.message_type === 'quota_exceeded'
) {
logger.error('ElevenLabs STT error', { type: msg.message_type, error: msg.error })
}
} catch {
// Ignore non-JSON messages
}
}
ws.onclose = () => {
if (mountedRef.current) {
setIsListening(false)
}
cleanup()
}
})
if (!mountedRef.current) {
ws.close()
stream.getTracks().forEach((track) => track.stop())
return false
}
const audioContext = new AudioContext({ sampleRate: SAMPLE_RATE })
audioContextRef.current = audioContext
const source = audioContext.createMediaStreamSource(stream)
const processor = audioContext.createScriptProcessor(4096, 1, 1)
processorRef.current = processor
processor.onaudioprocess = (e) => {
const input = e.inputBuffer.getChannelData(0)
pcmBufferRef.current.push(new Float32Array(input))
}
source.connect(processor)
processor.connect(audioContext.destination)
sendIntervalRef.current = setInterval(flushAudioBuffer, CHUNK_SEND_INTERVAL_MS)
setIsListening(true)
sessionTimerRef.current = setTimeout(() => {
logger.info('Voice input session reached max duration, stopping')
stopStreamingRef.current()
}, MAX_SESSION_MS)
return true
} catch (error) {
logger.error('Failed to start speech streaming', error)
cleanup()
if (error instanceof DOMException && error.name === 'NotAllowedError') {
setPermissionState('denied')
}
return false
} finally {
startingRef.current = false
}
}, [cleanup, flushAudioBuffer])
const stopStreaming = useCallback(() => {
if (sessionTimerRef.current) {
clearTimeout(sessionTimerRef.current)
sessionTimerRef.current = null
}
flushAudioBuffer()
const ws = wsRef.current
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
message_type: 'input_audio_chunk',
audio_base_64: '',
sample_rate: SAMPLE_RATE,
commit: true,
})
)
}
if (sendIntervalRef.current) {
clearInterval(sendIntervalRef.current)
sendIntervalRef.current = null
}
if (processorRef.current) {
processorRef.current.disconnect()
processorRef.current = null
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(() => {})
audioContextRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop())
streamRef.current = null
}
const wsToClose = wsRef.current
wsRef.current = null
if (wsToClose) {
setTimeout(() => {
if (
wsToClose.readyState === WebSocket.OPEN ||
wsToClose.readyState === WebSocket.CONNECTING
) {
wsToClose.close()
}
}, 2000)
}
setIsListening(false)
}, [flushAudioBuffer])
stopStreamingRef.current = stopStreaming
const resetTranscript = useCallback(() => {
committedTextRef.current = ''
isFirstChunkRef.current = true
}, [])
const toggleListening = useCallback(() => {
if (isListening) {
stopStreaming()
} else {
startStreaming()
}
}, [isListening, startStreaming, stopStreaming])
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
cleanup()
}
}, [cleanup])
return {
isListening,
isSupported,
permissionState,
toggleListening,
resetTranscript,
}
}