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
171 lines
4.5 KiB
TypeScript
171 lines
4.5 KiB
TypeScript
'use client'
|
|
|
|
import { type RefObject, useCallback, useRef, useState } from 'react'
|
|
import { createLogger } from '@sim/logger'
|
|
|
|
const logger = createLogger('UseAudioStreaming')
|
|
|
|
declare global {
|
|
interface Window {
|
|
webkitAudioContext?: typeof AudioContext
|
|
}
|
|
}
|
|
|
|
interface AudioStreamingOptions {
|
|
voiceId: string
|
|
modelId?: string
|
|
chatId: string
|
|
onAudioStart?: () => void
|
|
onAudioEnd?: () => void
|
|
onError?: (error: Error) => void
|
|
}
|
|
|
|
interface AudioQueueItem {
|
|
text: string
|
|
options: AudioStreamingOptions
|
|
}
|
|
|
|
export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext | null>) {
|
|
const [isPlayingAudio, setIsPlayingAudio] = useState(false)
|
|
const localAudioContextRef = useRef<AudioContext | null>(null)
|
|
const audioContextRef = sharedAudioContextRef || localAudioContextRef
|
|
const currentSourceRef = useRef<AudioBufferSourceNode | null>(null)
|
|
const abortControllerRef = useRef<AbortController | null>(null)
|
|
const audioQueueRef = useRef<AudioQueueItem[]>([])
|
|
const isProcessingQueueRef = useRef(false)
|
|
|
|
const getAudioContext = useCallback(() => {
|
|
if (!audioContextRef.current) {
|
|
const AudioContextConstructor = window.AudioContext || window.webkitAudioContext
|
|
if (!AudioContextConstructor) {
|
|
throw new Error('AudioContext is not supported in this browser')
|
|
}
|
|
audioContextRef.current = new AudioContextConstructor()
|
|
}
|
|
return audioContextRef.current
|
|
}, [])
|
|
|
|
const stopAudio = useCallback(() => {
|
|
abortControllerRef.current?.abort()
|
|
|
|
if (currentSourceRef.current) {
|
|
try {
|
|
currentSourceRef.current.stop()
|
|
} catch (e) {
|
|
// Already stopped
|
|
}
|
|
currentSourceRef.current = null
|
|
}
|
|
|
|
audioQueueRef.current = []
|
|
isProcessingQueueRef.current = false
|
|
|
|
setIsPlayingAudio(false)
|
|
}, [])
|
|
|
|
const processAudioQueue = useCallback(async () => {
|
|
if (isProcessingQueueRef.current || audioQueueRef.current.length === 0) {
|
|
return
|
|
}
|
|
|
|
isProcessingQueueRef.current = true
|
|
const item = audioQueueRef.current.shift()
|
|
|
|
if (!item) {
|
|
isProcessingQueueRef.current = false
|
|
return
|
|
}
|
|
|
|
const { text, options } = item
|
|
const {
|
|
voiceId,
|
|
modelId = 'eleven_flash_v2_5',
|
|
chatId,
|
|
onAudioStart,
|
|
onAudioEnd,
|
|
onError,
|
|
} = options
|
|
|
|
try {
|
|
const audioContext = getAudioContext()
|
|
|
|
if (audioContext.state === 'suspended') {
|
|
await audioContext.resume()
|
|
}
|
|
// boundary-raw-fetch: TTS proxy returns raw audio bytes consumed via response.arrayBuffer() and decoded by AudioContext.decodeAudioData
|
|
const response = await fetch('/api/proxy/tts/stream', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
voiceId,
|
|
modelId,
|
|
chatId,
|
|
}),
|
|
signal: abortControllerRef.current?.signal,
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text().catch(() => '')
|
|
throw new Error(errorText || `TTS request failed: ${response.statusText}`)
|
|
}
|
|
|
|
const arrayBuffer = await response.arrayBuffer()
|
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
|
|
|
const source = audioContext.createBufferSource()
|
|
source.buffer = audioBuffer
|
|
source.connect(audioContext.destination)
|
|
source.onended = () => {
|
|
currentSourceRef.current = null
|
|
onAudioEnd?.()
|
|
|
|
isProcessingQueueRef.current = false
|
|
|
|
if (audioQueueRef.current.length === 0) {
|
|
setIsPlayingAudio(false)
|
|
}
|
|
|
|
setTimeout(() => processAudioQueue(), 0)
|
|
}
|
|
|
|
currentSourceRef.current = source
|
|
source.start(0)
|
|
setIsPlayingAudio(true)
|
|
onAudioStart?.()
|
|
} catch (error) {
|
|
if (error instanceof Error && error.name !== 'AbortError') {
|
|
logger.error('Audio streaming error:', error)
|
|
onError?.(error)
|
|
}
|
|
|
|
isProcessingQueueRef.current = false
|
|
setTimeout(() => processAudioQueue(), 0)
|
|
}
|
|
}, [getAudioContext])
|
|
|
|
const streamTextToAudio = useCallback(
|
|
async (text: string, options: AudioStreamingOptions) => {
|
|
if (!text.trim()) {
|
|
return
|
|
}
|
|
|
|
if (!abortControllerRef.current || abortControllerRef.current.signal.aborted) {
|
|
abortControllerRef.current = new AbortController()
|
|
}
|
|
|
|
audioQueueRef.current.push({ text, options })
|
|
processAudioQueue()
|
|
},
|
|
[processAudioQueue]
|
|
)
|
|
|
|
return {
|
|
isPlayingAudio,
|
|
streamTextToAudio,
|
|
stopAudio,
|
|
}
|
|
}
|