← back to Dear Bubbe Nextjs
app/api/bubbe-voice/route.ts
193 lines
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
try {
// Get raw text and parse manually to avoid Next.js JSON parsing issues
const rawBody = await request.text()
let requestBody
try {
requestBody = JSON.parse(rawBody)
} catch (parseError) {
console.error('Failed to parse request body:', parseError, 'Raw body:', rawBody)
return NextResponse.json({ error: 'Invalid JSON in request body' }, { status: 400 })
}
const { text, mode, voiceId: customVoiceId } = requestBody
if (!text) {
return NextResponse.json({ error: 'No text provided' }, { status: 400 })
}
console.log('[BUBBE VOICE] Request received:', { textLength: text.length, mode, customVoiceId })
// Check if API key is configured
const apiKey = process.env.ELEVENLABS_API_KEY
if (!apiKey || apiKey === 'YOUR_ELEVENLABS_API_KEY_HERE') {
console.error('ElevenLabs API key not configured')
// Return silent success to prevent breaking the app when TTS isn't configured
return new NextResponse(new ArrayBuffer(0), {
status: 200,
headers: {
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-cache',
},
})
}
// Use custom voice ID if provided, otherwise select based on mode
let voiceId = customVoiceId || 'j9Ic6rh0FeC7nBIqew0q' // Default: Yenta voice
// Only override with mode-based selection if no custom voice ID provided
if (!customVoiceId) {
switch (mode) {
case 'bubbe':
voiceId = 'j9Ic6rh0FeC7nBIqew0q' // Yenta voice for Bubbe
break
case 'meshugana':
voiceId = 'j9Ic6rh0FeC7nBIqew0q' // Yenta voice but we'll adjust stability
break
case 'comedian':
voiceId = 'j9Ic6rh0FeC7nBIqew0q' // Yenta voice but different settings
break
case 'jerry':
voiceId = 'QzTKubutNn9TjrB7Xb2Q' // NY Jerry voice
break
case 'yenta':
voiceId = 'j9Ic6rh0FeC7nBIqew0q' // Yenta voice (explicit)
break
}
}
// Clean the text - remove emojis and special characters more aggressively
const cleanText = text
// Remove all emojis and symbols
.replace(/[\u{1F600}-\u{1F64F}]/gu, '') // Emoticons
.replace(/[\u{1F300}-\u{1F5FF}]/gu, '') // Misc symbols & pictographs
.replace(/[\u{1F680}-\u{1F6FF}]/gu, '') // Transport & map symbols
.replace(/[\u{1F1E0}-\u{1F1FF}]/gu, '') // Flags
.replace(/[\u{2600}-\u{26FF}]/gu, '') // Misc symbols
.replace(/[\u{2700}-\u{27BF}]/gu, '') // Dingbats
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation selectors
.replace(/[\u{1F900}-\u{1F9FF}]/gu, '') // Supplemental symbols
.replace(/[\u{1FA70}-\u{1FAFF}]/gu, '') // Symbols and pictographs extended-A
// Remove control characters and other problematic characters
.replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
// Replace multiple spaces with single space
.replace(/\s+/g, ' ')
// Normalize quotes and apostrophes
.replace(/['']/g, "'")
.replace(/[""]/g, '"')
.trim()
// Adjust voice settings based on mode
let stability = 0.5
let similarityBoost = 0.75
switch (mode) {
case 'bubbe':
stability = 0.5
similarityBoost = 0.75
break
case 'meshugana':
stability = 0.3 // More unstable for wild personality
similarityBoost = 0.8
break
case 'comedian':
stability = 0.6 // More stable for timing
similarityBoost = 0.7
break
}
console.log('[BUBBE VOICE] Calling ElevenLabs API:', {
voiceId,
textLength: cleanText.length,
stability,
similarityBoost
})
// Call ElevenLabs API
const elevenlabsResponse = await fetch(
`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`,
{
method: 'POST',
headers: {
'xi-api-key': apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: cleanText,
model_id: 'eleven_turbo_v2_5', // Updated to newer free tier model
voice_settings: {
stability,
similarity_boost: similarityBoost,
speed: 1.08, // a little faster, still natural (ElevenLabs range 0.7–1.2)
},
}),
}
)
console.log('[BUBBE VOICE] ElevenLabs response status:', elevenlabsResponse.status)
if (!elevenlabsResponse.ok) {
const error = await elevenlabsResponse.text()
console.error('[BUBBE VOICE] ElevenLabs API error:', error)
// Check if it's a quota exceeded error
if (error.includes('quota_exceeded') || error.includes('credits remaining')) {
console.log('[BUBBE VOICE] Quota exceeded - returning empty audio to prevent app break')
// Return empty audio buffer when quota is exceeded
return new NextResponse(new ArrayBuffer(0), {
status: 200,
headers: {
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-cache',
},
})
}
return NextResponse.json(
{ error: 'Failed to generate voice' },
{ status: elevenlabsResponse.status }
)
}
// Get the audio stream
const audioBuffer = await elevenlabsResponse.arrayBuffer()
console.log('[BUBBE VOICE] Audio generated successfully, size:', audioBuffer.byteLength)
// Return the audio as a response
return new NextResponse(audioBuffer, {
status: 200,
headers: {
'Content-Type': 'audio/mpeg',
'Cache-Control': 'no-cache',
},
})
} catch (error) {
console.error('TTS generation error:', error)
return NextResponse.json(
{ error: 'Failed to generate voice' },
{ status: 500 }
)
}
}
// Optional: GET endpoint to check if TTS is configured
export async function GET() {
const apiKey = process.env.ELEVENLABS_API_KEY
const isConfigured = !!(
apiKey &&
apiKey !== 'YOUR_ELEVENLABS_API_KEY_HERE'
)
return NextResponse.json({
configured: isConfigured,
message: isConfigured
? 'ElevenLabs TTS service is configured'
: 'TTS service not configured. Please add ELEVENLABS_API_KEY to .env.local'
})
}