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:
committed by
GitHub
co-authored by
Alexander Busse
parent
8a836d4aed
commit
055b6f6af0
@@ -14,3 +14,5 @@ export {
|
||||
summarizeText,
|
||||
sanitizeForTTS,
|
||||
} from './summarization.js';
|
||||
|
||||
export { transcribeAudio } from './stt.js';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user