← 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'
  })
}