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
183 lines
5.4 KiB
TypeScript
183 lines
5.4 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { chat } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { eq } from 'drizzle-orm'
|
|
import { type NextRequest, NextResponse } from 'next/server'
|
|
import { ttsStreamContract } from '@/lib/api/contracts/media/tts-stream'
|
|
import { parseRequest } from '@/lib/api/server'
|
|
import { env } from '@/lib/core/config/env'
|
|
import { validateAuthToken } from '@/lib/core/security/deployment'
|
|
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
|
|
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
|
|
|
const logger = createLogger('ProxyTTSStreamAPI')
|
|
|
|
/**
|
|
* Validates chat-based authentication for deployed chat voice mode
|
|
* Checks if the user has a valid chat auth cookie for the given chatId
|
|
*/
|
|
async function validateChatAuth(request: NextRequest, chatId: string): Promise<boolean> {
|
|
try {
|
|
const chatResult = await db
|
|
.select({
|
|
id: chat.id,
|
|
isActive: chat.isActive,
|
|
authType: chat.authType,
|
|
password: chat.password,
|
|
})
|
|
.from(chat)
|
|
.where(eq(chat.id, chatId))
|
|
.limit(1)
|
|
|
|
if (chatResult.length === 0 || !chatResult[0].isActive) {
|
|
logger.warn('Chat not found or inactive for TTS auth:', chatId)
|
|
return false
|
|
}
|
|
|
|
const chatData = chatResult[0]
|
|
|
|
if (chatData.authType === 'public') {
|
|
return true
|
|
}
|
|
|
|
const cookieName = `chat_auth_${chatId}`
|
|
const authCookie = request.cookies.get(cookieName)
|
|
|
|
if (
|
|
authCookie &&
|
|
validateAuthToken(authCookie.value, chatId, chatData.authType, chatData.password)
|
|
) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
} catch (error) {
|
|
logger.error('Error validating chat auth for TTS:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
export const POST = withRouteHandler(async (request: NextRequest) => {
|
|
try {
|
|
const parsed = await parseRequest(
|
|
ttsStreamContract,
|
|
request,
|
|
{},
|
|
{
|
|
invalidJsonResponse: () => new NextResponse('Invalid request body', { status: 400 }),
|
|
validationErrorResponse: (error) => {
|
|
if (error.issues.some((issue) => issue.path[0] === 'chatId')) {
|
|
return new NextResponse('chatId is required', { status: 400 })
|
|
}
|
|
return new NextResponse('Missing required parameters', { status: 400 })
|
|
},
|
|
}
|
|
)
|
|
if (!parsed.success) return parsed.response
|
|
|
|
const { text, voiceId, modelId, chatId } = parsed.data.body
|
|
|
|
const isChatAuthed = await validateChatAuth(request, chatId)
|
|
if (!isChatAuthed) {
|
|
logger.warn('Chat authentication failed for TTS, chatId:', chatId)
|
|
return new Response('Unauthorized', { status: 401 })
|
|
}
|
|
|
|
const voiceIdValidation = validateAlphanumericId(voiceId, 'voiceId', 255)
|
|
if (!voiceIdValidation.isValid) {
|
|
logger.error(`Invalid voice ID: ${voiceIdValidation.error}`)
|
|
return new Response(voiceIdValidation.error, { status: 400 })
|
|
}
|
|
|
|
const apiKey = env.ELEVENLABS_API_KEY
|
|
if (!apiKey) {
|
|
logger.error('ELEVENLABS_API_KEY not configured on server')
|
|
return new Response('ElevenLabs service not configured', { status: 503 })
|
|
}
|
|
|
|
const query = new URLSearchParams({ output_format: 'mp3_44100_128' })
|
|
const endpoint = `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}/stream?${query.toString()}`
|
|
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
Accept: 'audio/mpeg',
|
|
'Content-Type': 'application/json',
|
|
'xi-api-key': apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
text,
|
|
model_id: modelId,
|
|
voice_settings: {
|
|
stability: 0.5,
|
|
similarity_boost: 0.8,
|
|
style: 0.0,
|
|
use_speaker_boost: false,
|
|
},
|
|
apply_text_normalization: 'auto',
|
|
}),
|
|
})
|
|
|
|
if (!response.ok) {
|
|
logger.error(`Failed to generate Stream TTS: ${response.status} ${response.statusText}`)
|
|
return new Response(`Failed to generate TTS: ${response.status} ${response.statusText}`, {
|
|
status: response.status,
|
|
})
|
|
}
|
|
|
|
if (!response.body) {
|
|
logger.error('No response body received from ElevenLabs')
|
|
return new Response('No audio stream received', { status: 422 })
|
|
}
|
|
|
|
const { readable, writable } = new TransformStream({
|
|
transform(chunk, controller) {
|
|
controller.enqueue(chunk)
|
|
},
|
|
flush(controller) {
|
|
controller.terminate()
|
|
},
|
|
})
|
|
|
|
const writer = writable.getWriter()
|
|
const reader = response.body.getReader()
|
|
|
|
;(async () => {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) {
|
|
await writer.close()
|
|
break
|
|
}
|
|
writer.write(value).catch(logger.error)
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error during Stream streaming:', error)
|
|
await writer.abort(error)
|
|
}
|
|
})()
|
|
|
|
return new Response(readable, {
|
|
headers: {
|
|
'Content-Type': 'audio/mpeg',
|
|
'Transfer-Encoding': 'chunked',
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
Pragma: 'no-cache',
|
|
Expires: '0',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Access-Control-Allow-Origin': '*',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
'X-Stream-Type': 'real-time',
|
|
},
|
|
})
|
|
} catch (error) {
|
|
logger.error('Error in Stream TTS:', error)
|
|
|
|
return new Response('Internal Server Error', {
|
|
status: 500,
|
|
})
|
|
}
|
|
})
|