chore: import upstream snapshot with attribution
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
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
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
'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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user