Files
openchamber/packages/web/server/lib/tts/routes.js
T
Bohdan Triapitsyn c7f1501ad2 fix: harden custom TTS URL handling and availability checks
Validate and normalize custom TTS server URLs before proxying requests.
Gate server TTS availability by provider mode to avoid OpenAI/custom misrouting.
Keep browser and message TTS hooks aligned with the new provider-specific checks.
2026-04-12 10:38:16 +03:00

270 lines
9.9 KiB
JavaScript

import express from 'express';
import { normalizeCustomOpenAIBaseURL } from './base-url.js';
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, baseURL } = req.body || {};
const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL);
if (normalizedBaseURLResult.error) {
return res.status(400).json({ error: normalizedBaseURLResult.error });
}
const normalizedBaseURL = normalizedBaseURLResult.value;
console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey, hasBaseURL: !!baseURL });
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 - server-configured key, client-provided key, or custom server URL
const hasServerKey = ttsService.isAvailable();
const hasClientKey = apiKey && typeof apiKey === 'string' && apiKey.trim().length > 0;
const hasCustomBaseURL = typeof normalizedBaseURL === 'string' && normalizedBaseURL.length > 0;
if (!hasServerKey && !hasClientKey && !hasCustomBaseURL) {
return res.status(503).json({
error: 'TTS service not available. Please configure OpenAI in OpenCode, provide an API key, or set a custom server URL 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,
baseURL: hasCustomBaseURL ? normalizedBaseURL : undefined,
});
res.setHeader('Content-Type', result.contentType);
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Content-Length', result.buffer.length);
res.send(result.buffer);
} catch (error) {
console.error('[TTS] Error:', error);
if (!res.headersSent) {
const { model: m, voice: v, baseURL: b } = req.body || {};
res.status(500).json({
error: error instanceof Error ? error.message : 'TTS generation failed',
detail: { model: m, voice: v, hasBaseURL: !!b },
});
}
}
});
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'
});
}
});
// Server-side STT: receive raw audio, proxy to OpenAI-compatible transcription endpoint
app.post(
'/api/stt/transcribe',
express.raw({ type: (req) => (req.headers['content-type'] || '').startsWith('audio/'), limit: '20mb' }),
async (req, res) => {
try {
const { transcribeAudio } = await import('./stt.js');
const mimeType = (req.headers['content-type'] || 'audio/webm').split(',')[0].trim();
const baseURL = typeof req.headers['x-base-url'] === 'string' ? req.headers['x-base-url'].trim() : '';
const model = typeof req.headers['x-model'] === 'string' && req.headers['x-model'].trim().length > 0
? req.headers['x-model'].trim()
: 'deepdml/faster-whisper-large-v3-turbo-ct2';
const language = typeof req.headers['x-language'] === 'string' && req.headers['x-language'].trim().length > 0
? req.headers['x-language'].trim()
: undefined;
if (!req.body || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: 'Audio data is required' });
}
if (!baseURL) {
return res.status(400).json({ error: 'X-Base-URL header is required' });
}
console.log('[STT] Transcribing audio:', {
bytes: req.body.length,
mimeType,
model,
baseURL,
language,
});
const transcript = await transcribeAudio({
audioBuffer: req.body,
mimeType,
model,
baseURL,
language,
});
console.log('[STT] Transcript:', transcript?.slice(0, 120));
res.json({ transcript: transcript ?? '' });
} catch (error) {
console.error('[STT] Error:', error);
if (!res.headersSent) {
res.status(500).json({
error: error instanceof Error ? error.message : 'Transcription failed',
});
}
}
}
);
}