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
404 lines
12 KiB
TypeScript
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,
|
|
}
|
|
}
|