feat(tts): add OpenAI-compatible custom server TTS provider with configurable model, pitch, and volume (#859)

Co-authored-by: Alexander Busse <alex@ableph.net>
This commit is contained in:
Alexander Busse
2026-04-12 10:33:15 +03:00
committed by GitHub
co-authored by Alexander Busse
parent 8a836d4aed
commit 055b6f6af0
9 changed files with 285 additions and 77 deletions
+2
View File
@@ -14,3 +14,5 @@ export {
summarizeText,
sanitizeForTTS,
} from './summarization.js';
export { transcribeAudio } from './stt.js';
+17 -36
View File
@@ -42,9 +42,9 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
// 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 || {};
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 || {};
console.log('[TTS] Request received:', { voice, model, speed, textLength: text?.length, hasApiKey: !!apiKey });
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' });
@@ -53,13 +53,14 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
// Dynamically import the TTS service (ESM)
const { ttsService } = await getTtsModule();
// Check availability - either server-configured or client-provided API key
// 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 = baseURL && typeof baseURL === 'string' && baseURL.trim().length > 0;
if (!hasServerKey && !hasClientKey) {
if (!hasServerKey && !hasClientKey && !hasCustomBaseURL) {
return res.status(503).json({
error: 'TTS service not available. Please configure OpenAI in OpenCode or provide an API key in settings.'
error: 'TTS service not available. Please configure OpenAI in OpenCode, provide an API key, or set a custom server URL in settings.'
});
}
@@ -87,44 +88,24 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) {
model,
speed,
instructions,
apiKey: hasClientKey ? apiKey.trim() : undefined
apiKey: hasClientKey ? apiKey.trim() : undefined,
baseURL: hasCustomBaseURL ? baseURL.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);
res.setHeader('Content-Length', result.buffer.length);
res.send(result.buffer);
} catch (error) {
console.error('[TTS] Error:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Stream error' });
} else {
res.end();
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 },
});
}
}
} 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) => {
+27 -18
View File
@@ -78,19 +78,24 @@ class TTSService {
model = 'gpt-4o-mini-tts',
speed = 1.0,
instructions,
apiKey
apiKey,
baseURL,
} = options;
// Use provided API key or fall back to configured key
// Use provided API key / baseURL or fall back to configured key
let client;
if (apiKey) {
client = new OpenAI({ apiKey });
if (baseURL || apiKey) {
const clientOpts = {};
if (apiKey) clientOpts.apiKey = apiKey;
if (!apiKey) clientOpts.apiKey = 'not-required';
if (baseURL) clientOpts.baseURL = baseURL;
client = new OpenAI(clientOpts);
} else {
client = this._getClient();
}
if (!client) {
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable, configure OpenAI in OpenCode, or provide an API key in settings.');
throw new Error('TTS service not available. Configure OpenAI in OpenCode, provide an API key, or set a custom server URL in settings.');
}
if (!text.trim()) {
@@ -98,21 +103,25 @@ class TTSService {
}
try {
console.log('[TTSService] Generating speech with voice:', voice, 'model:', model);
const response = await client.audio.speech.create({
model,
voice,
input: text,
speed,
...(instructions && { instructions }),
response_format: 'mp3',
});
// OpenAI-compatible servers (custom baseURL) may not support `instructions`
// or `response_format`, but do support `speed`. Send the safe subset.
const speechParams = baseURL
? { model, voice, input: text, speed }
: {
model,
voice,
input: text,
speed,
...(instructions && { instructions }),
response_format: 'mp3',
};
// Convert the response to a web stream
const stream = response.body;
console.log('[TTSService] Generating speech — model:', model, 'voice:', voice, 'baseURL:', baseURL ?? '(openai)');
const response = await client.audio.speech.create(speechParams);
const arrayBuffer = await response.arrayBuffer();
return {
stream,
buffer: Buffer.from(arrayBuffer),
contentType: 'audio/mpeg',
};
} catch (error) {