export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { let ttsModulePromise = null; const getTtsModule = async () => { if (!ttsModulePromise) { ttsModulePromise = import('./index.js'); } return ttsModulePromise; }; app.post('/api/voice/token', async (req, res) => { console.log('[Voice] Token request received:', { contentType: req.headers['content-type'] || null, }); try { const openaiApiKey = process.env.OPENAI_API_KEY; console.log('[Voice] OpenAI API Key present:', !!openaiApiKey); if (!openaiApiKey) { return res.status(503).json({ allowed: false, error: 'OpenAI voice service not configured. Set OPENAI_API_KEY environment variable.' }); } // Return success - OpenAI TTS is available res.json({ allowed: true, provider: 'openai', message: 'OpenAI TTS is available' }); } catch (error) { console.error('[Voice] Token generation error:', error); res.status(500).json({ allowed: false, error: 'Voice service error' }); } }); // Server-side TTS endpoint - streams audio from OpenAI TTS API app.post('/api/tts/speak', async (req, res) => { try { const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey } = req.body || {}; console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey }); if (!text || typeof text !== 'string' || !text.trim()) { return res.status(400).json({ error: 'Text is required' }); } // Dynamically import the TTS service (ESM) const { ttsService } = await getTtsModule(); // Check availability - either server-configured or client-provided API key const hasServerKey = ttsService.isAvailable(); const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0; if (!hasServerKey && !hasClientKey) { return res.status(503).json({ error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.' }); } let textToSpeak = text.trim(); // Optionally summarize long text before speaking using zen API if (summarize && textToSpeak.length > threshold) { try { const { summarizeText } = await getTtsModule(); const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); if (result.summarized && result.summary) { textToSpeak = result.summary; } } catch (summarizeError) { console.error('[TTS/speak] Summarization failed:', summarizeError); // Continue with original text if summarization fails } } const result = await ttsService.generateSpeechStream({ text: textToSpeak, voice, model, speed, instructions, apiKey: hasClientKey ? apiKey.trim() : undefined }); // Set headers for audio streaming // Note: Don't set Transfer-Encoding manually - Express handles it automatically res.setHeader('Content-Type', result.contentType); res.setHeader('Cache-Control', 'no-cache'); // Collect the full audio buffer and send it // This avoids chunked encoding issues with proxies const reader = result.stream.getReader(); const chunks = []; try { while (true) { const { done, value } = await reader.read(); if (done) break; chunks.push(Buffer.from(value)); } const audioBuffer = Buffer.concat(chunks); res.setHeader('Content-Length', audioBuffer.length); res.send(audioBuffer); } catch (streamError) { console.error('[TTS] Stream error:', streamError); if (!res.headersSent) { res.status(500).json({ error: 'Stream error' }); } else { res.end(); } } } catch (error) { console.error('[TTS] Error:', error); if (!res.headersSent) { res.status(500).json({ error: error instanceof Error ? error.message : 'TTS generation failed' }); } } }); app.post('/api/tts/summarize', async (req, res) => { try { const { summarizeText } = await getTtsModule(); const { text, threshold = 200, maxLength = 500 } = req.body || {}; if (!text || typeof text !== 'string' || !text.trim()) { return res.status(400).json({ error: 'Text is required' }); } const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); return res.json(result); } catch (error) { console.error('[Summarize] Error:', error); const { sanitizeForTTS } = await getTtsModule(); const sanitized = sanitizeForTTS(req.body?.text || ''); return res.json({ summary: sanitized, summarized: false, reason: error.message }); } }); // TTS status endpoint app.get('/api/tts/status', async (_req, res) => { try { const { ttsService } = await getTtsModule(); res.json({ available: ttsService.isAvailable(), voices: [ 'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar' ] }); } catch (error) { res.status(500).json({ error: 'Failed to check TTS status' }); } }); // macOS 'say' command TTS status endpoint - returns cached capability from startup app.get('/api/tts/say/status', (_req, res) => { res.json(sayTTSCapability); }); // macOS 'say' command TTS speak endpoint app.post('/api/tts/say/speak', async (req, res) => { try { const { text, voice = 'Samantha', rate = 200 } = req.body || {}; if (!text || typeof text !== 'string' || !text.trim()) { return res.status(400).json({ error: 'Text is required' }); } // Check if we're on macOS if (process.platform !== 'darwin') { return res.status(503).json({ error: 'macOS say command not available on this platform' }); } const { exec } = await import('child_process'); const { promisify } = await import('util'); const fs = await import('fs'); const os = await import('os'); const path = await import('path'); const execAsync = promisify(exec); // Create temp file for audio output (use m4a for browser compatibility) const tempDir = os.tmpdir(); const tempFile = path.join(tempDir, `say-${Date.now()}.m4a`); // Escape text for shell - escape both single quotes and double quotes const escapedText = text.trim().replace(/'/g, "'\\''").replace(/"/g, '\\"'); // Generate audio file using 'say' command // -o outputs to file, -r sets rate (words per minute) // --data-format=aac outputs as m4a which browsers can decode const cmd = `say -v "${voice}" -r ${rate} -o "${tempFile}" --data-format=aac '${escapedText}'`; console.log('[TTS-Say] Generating speech:', { textLength: text.length, voice, rate }); await execAsync(cmd); // Read the generated audio file const audioBuffer = await fs.promises.readFile(tempFile); // Clean up temp file fs.promises.unlink(tempFile).catch(() => {}); // Send audio response res.setHeader('Content-Type', 'audio/mp4'); res.setHeader('Content-Length', audioBuffer.length); res.send(audioBuffer); } catch (error) { console.error('[TTS-Say] Error:', error); res.status(500).json({ error: error instanceof Error ? error.message : 'Say command failed' }); } }); }