feat(voice): add voice input/output support with multiple providers (#281)
* feat(voice): add voice input/output support with multiple providers - Add BrowserVoiceButton component for Web Speech API voice input - Add VoiceProvider context for managing voice state across the app - Add TTS (Text-to-Speech) support with browser, macOS Say, and OpenAI providers - Add message TTS buttons to read assistant messages aloud - Add VoiceSettings page in OpenChamber settings - Add server endpoints for TTS and summarization services - Include slider component for voice rate/pitch/volume controls - Add hidden session support for background voice operations - Add Caddyfile for HTTPS support (required for microphone access) * fix: Build errors fixed and removed outdated ElevenLabs test code. * refactor(voice): use zen API with gpt-5-nano for TTS summarization Replace the hidden session + OpenCode SDK approach with direct calls to the opencode.ai zen API (same pattern used for commit message and PR description generation). - Rewrite summarization-service.js to call zen/v1/responses with gpt-5-nano - Remove hidden session logic (hiddenSession.ts, sessionStore filtering) - Remove summarizeModel setting and model selector from VoiceSettings - Simplify client-side summarize.ts to no longer pass model params - Clean up callers in useMessageTTS and useBrowserVoice * fix(voice): remove false 'voice not supported' warning in settings Mobile Safari does support voice but the isSupported check was incorrectly flagging it. Remove the warning banner entirely. * feat(voice): add configurable summary length limit for TTS output Add a slider (50-2000 chars) in voice settings to control max summary length. The limit is passed through the summarize endpoint and speak endpoint to the zen API prompt, with token budget scaled accordingly. * fix(voice): add diagnostic logging and sanitize TTS fallback Add console logging throughout the summarization flow (client + server) to trace why text may not be summarized. Fix silent error swallowing in /api/tts/speak. Always apply sanitizeForTTS even when summarization is disabled so raw markdown/code is never spoken verbatim. * fix(voice): fix token budget starving model of output tokens max_output_tokens includes both reasoning and output tokens. With effort:'low', reasoning alone consumes ~128 tokens, so a budget of 100 left zero tokens for the actual summary text. Use a fixed 1000 token budget (matching commit message generation) and control output length via the prompt's character limit instruction instead. * chore(voice): remove diagnostic logging from summarization flow * fix(voice): don't request mic permission on mobile page load Remove the useEffect that pre-requested microphone permission when the BrowserVoiceButton component mounted on mobile. This caused an unwanted permission prompt immediately on page load before the user tapped the mic icon. Permission is now only requested on explicit user interaction. * fix(voice): remove unused BrowserVoiceButton binding * fix(voice): desktop mic flow + non-continuous draft mode * fix(voice): stabilize continuous loop and polish controls * feat(settings): mark voice section experimental --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
6776ac31c2
commit
1ed5316ac7
@@ -4829,7 +4829,36 @@ async function main(options = {}) {
|
||||
|
||||
console.log(`Starting OpenChamber on port ${port === 0 ? 'auto' : port}`);
|
||||
|
||||
// Check macOS Say TTS availability once at startup
|
||||
let sayTTSCapability = { available: false, voices: [], reason: 'Not checked' };
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
const { exec } = await import('child_process');
|
||||
const { promisify } = await import('util');
|
||||
const execAsync = promisify(exec);
|
||||
const { stdout } = await execAsync('say -v "?"');
|
||||
const voices = stdout.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => {
|
||||
const match = line.match(/^(.+?)\s+([a-zA-Z]{2}_[a-zA-Z]{2,3})\s+#/);
|
||||
if (match) {
|
||||
return { name: match[1].trim(), locale: match[2] };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
sayTTSCapability = { available: true, voices };
|
||||
console.log(`macOS Say TTS available with ${voices.length} voices`);
|
||||
} catch (error) {
|
||||
sayTTSCapability = { available: false, voices: [], reason: 'say command not available' };
|
||||
console.log('macOS Say TTS not available:', error.message);
|
||||
}
|
||||
} else {
|
||||
sayTTSCapability = { available: false, voices: [], reason: 'Not macOS' };
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', true);
|
||||
expressApp = app;
|
||||
server = http.createServer(app);
|
||||
|
||||
@@ -4862,7 +4891,9 @@ async function main(options = {}) {
|
||||
req.path.startsWith('/api/prompts') ||
|
||||
req.path.startsWith('/api/terminal') ||
|
||||
req.path.startsWith('/api/opencode') ||
|
||||
req.path.startsWith('/api/push')
|
||||
req.path.startsWith('/api/push') ||
|
||||
req.path.startsWith('/api/voice') ||
|
||||
req.path.startsWith('/api/tts')
|
||||
) {
|
||||
|
||||
express.json({ limit: '50mb' })(req, res, next);
|
||||
@@ -5025,6 +5056,220 @@ async function main(options = {}) {
|
||||
res.json(getSessionActivitySnapshot());
|
||||
});
|
||||
|
||||
// Voice token endpoint - returns OpenAI TTS availability status
|
||||
app.post('/api/voice/token', async (req, res) => {
|
||||
console.log('[Voice] Token request received:', { body: req.body, headers: req.headers['content-type'] });
|
||||
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 import('./lib/tts-service.js');
|
||||
|
||||
// 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 import('./lib/summarization-service.js');
|
||||
const result = await summarizeText({ text: textToSpeak, threshold, maxLength });
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Import summarization service
|
||||
const { summarizeText, sanitizeForTTS } = await import('./lib/summarization-service.js');
|
||||
|
||||
app.post('/api/tts/summarize', async (req, res) => {
|
||||
try {
|
||||
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 result = await summarizeText({ text, threshold, maxLength });
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
console.error('[Summarize] Error:', error);
|
||||
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 import('./lib/tts-service.js');
|
||||
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'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// New authoritative session status endpoints
|
||||
// Server maintains the source of truth, clients only query
|
||||
|
||||
@@ -8284,7 +8529,6 @@ async function main(options = {}) {
|
||||
|
||||
const base = typeof req.body?.base === 'string' ? req.body.base.trim() : '';
|
||||
const head = typeof req.body?.head === 'string' ? req.body.head.trim() : '';
|
||||
const context = typeof req.body?.context === 'string' ? req.body.context.trim() : '';
|
||||
if (!base || !head) {
|
||||
return res.status(400).json({ error: 'base and head are required' });
|
||||
}
|
||||
@@ -8303,6 +8547,7 @@ async function main(options = {}) {
|
||||
}
|
||||
|
||||
const diffSummaries = diffs.map(({ path, diff }) => `FILE: ${path}\n${diff}`).join('\n\n');
|
||||
const context = typeof req.body?.context === 'string' ? req.body.context.trim() : '';
|
||||
|
||||
let prompt = `You are drafting a GitHub Pull Request title + description for a squash-merge workflow.
|
||||
Respond in JSON of the shape {"title": string, "body": string} (ONLY JSON in response, no markdown fences) with these rules:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Text Summarization Service
|
||||
*
|
||||
* Uses the opencode.ai zen API with gpt-5-nano for fast, lightweight summarization.
|
||||
* Used by all TTS implementations (Browser, Say, OpenAI).
|
||||
*/
|
||||
|
||||
function buildSummarizationPrompt(maxLength) {
|
||||
return `You are a text summarizer for text-to-speech output. Create a concise, natural-sounding summary that captures the key points. Keep the summary under ${maxLength} characters.
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
1. Output ONLY the final summary - no thinking, no reasoning, no explanations
|
||||
2. Do not show your work or thought process
|
||||
3. Do not use any special characters, markdown, code, URLs, file paths, or formatting
|
||||
4. Do not include phrases like "Here's a summary" or "In summary"
|
||||
5. Just provide clean, speakable text that can be read aloud
|
||||
6. Stay within the ${maxLength} character limit
|
||||
|
||||
Your response should be ready to speak immediately.`;
|
||||
}
|
||||
|
||||
const SUMMARIZE_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Sanitize text for TTS output
|
||||
* Removes markdown, URLs, file paths, and other non-speakable content
|
||||
*/
|
||||
export function sanitizeForTTS(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
|
||||
return text
|
||||
// Remove markdown formatting
|
||||
.replace(/[*_~`#]/g, '')
|
||||
// Remove code blocks
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`[^`]*`/g, '')
|
||||
// Remove shell-like command patterns
|
||||
.replace(/^\s*[$#>]\s*/gm, '')
|
||||
// Remove common shell operators
|
||||
.replace(/[|&;<>]/g, ' ')
|
||||
// Remove backslashes (escape characters)
|
||||
.replace(/\\/g, '')
|
||||
// Remove brackets that might be interpreted specially
|
||||
.replace(/[[\]{}()]/g, '')
|
||||
// Remove quotes that might cause issues
|
||||
.replace(/["']/g, '')
|
||||
// Remove URLs
|
||||
.replace(/https?:\/\/[^\s]+/g, ' a link ')
|
||||
// Remove file paths
|
||||
.replace(/\/[\w\-./]+/g, '')
|
||||
// Collapse multiple spaces/newlines
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from zen API response
|
||||
*/
|
||||
function extractZenOutputText(data) {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const output = data.output;
|
||||
if (!Array.isArray(output)) return null;
|
||||
|
||||
const messageItem = output.find(
|
||||
(item) => item && typeof item === 'object' && item.type === 'message'
|
||||
);
|
||||
if (!messageItem) return null;
|
||||
|
||||
const content = messageItem.content;
|
||||
if (!Array.isArray(content)) return null;
|
||||
|
||||
const textItem = content.find(
|
||||
(item) => item && typeof item === 'object' && item.type === 'output_text'
|
||||
);
|
||||
|
||||
const text = typeof textItem?.text === 'string' ? textItem.text.trim() : '';
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize text using the opencode.ai zen API with gpt-5-nano
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {string} options.text - The text to summarize
|
||||
* @param {number} options.threshold - Character threshold (don't summarize if under this length)
|
||||
* @param {number} options.maxLength - Maximum character length for the summary output (50-2000)
|
||||
* @returns {Promise<{summary: string, summarized: boolean, reason?: string}>}
|
||||
*/
|
||||
export async function summarizeText({
|
||||
text,
|
||||
threshold = 200,
|
||||
maxLength = 500,
|
||||
}) {
|
||||
// Don't summarize if text is under threshold
|
||||
if (!text || text.length <= threshold) {
|
||||
return {
|
||||
summary: sanitizeForTTS(text || ''),
|
||||
summarized: false,
|
||||
reason: text ? 'Text under threshold' : 'No text provided',
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), SUMMARIZE_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const prompt = buildSummarizationPrompt(maxLength);
|
||||
|
||||
const response = await fetch('https://opencode.ai/zen/v1/responses', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-5-nano',
|
||||
input: [
|
||||
{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` },
|
||||
],
|
||||
stream: false,
|
||||
reasoning: { effort: 'low' },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.json().catch(() => ({}));
|
||||
console.error('[Summarize] zen API error:', response.status, errorBody);
|
||||
return {
|
||||
summary: sanitizeForTTS(text),
|
||||
summarized: false,
|
||||
reason: `zen API returned ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const summary = extractZenOutputText(data);
|
||||
|
||||
if (summary) {
|
||||
const sanitized = sanitizeForTTS(summary);
|
||||
return {
|
||||
summary: sanitized,
|
||||
summarized: true,
|
||||
originalLength: text.length,
|
||||
summaryLength: sanitized.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
summary: sanitizeForTTS(text),
|
||||
summarized: false,
|
||||
reason: 'No response from model',
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
console.error('[Summarize] Request timed out');
|
||||
return {
|
||||
summary: sanitizeForTTS(text),
|
||||
summarized: false,
|
||||
reason: 'Request timed out',
|
||||
};
|
||||
}
|
||||
console.error('[Summarize] Error:', error);
|
||||
return {
|
||||
summary: sanitizeForTTS(text),
|
||||
summarized: false,
|
||||
reason: error.message,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Server-side Text-to-Speech Service
|
||||
*
|
||||
* Uses OpenAI's TTS API to generate audio on the server and stream it to clients.
|
||||
* This bypasses mobile Safari's audio context restrictions.
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import { readAuthFile } from './opencode-auth.js';
|
||||
|
||||
// Voice options from OpenAI
|
||||
export const TTS_VOICES = [
|
||||
'alloy', 'ash', 'ballad', 'coral', 'echo', 'fable',
|
||||
'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar'
|
||||
];
|
||||
|
||||
function getOpenAIApiKey() {
|
||||
// First check environment variable
|
||||
const envKey = process.env.OPENAI_API_KEY;
|
||||
if (envKey) {
|
||||
return envKey;
|
||||
}
|
||||
|
||||
// Then check opencode auth file (same as usage tracker)
|
||||
try {
|
||||
const auth = readAuthFile();
|
||||
// Check for openai, codex, or chatgpt aliases
|
||||
const openaiAuth = auth.openai || auth.codex || auth.chatgpt;
|
||||
if (openaiAuth) {
|
||||
// Handle both string format (just the token) and object format
|
||||
if (typeof openaiAuth === 'string') {
|
||||
return openaiAuth;
|
||||
}
|
||||
// Try access token first (OAuth), then regular token
|
||||
if (openaiAuth.access) {
|
||||
return openaiAuth.access;
|
||||
}
|
||||
if (openaiAuth.token) {
|
||||
return openaiAuth.token;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[TTSService] Failed to read auth file:', error.message);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
class TTSService {
|
||||
constructor() {
|
||||
this._client = null;
|
||||
this._lastApiKey = null;
|
||||
}
|
||||
|
||||
_getClient() {
|
||||
const apiKey = getOpenAIApiKey();
|
||||
|
||||
// If API key changed or client doesn't exist, create new client
|
||||
if (apiKey && (!this._client || this._lastApiKey !== apiKey)) {
|
||||
this._client = new OpenAI({ apiKey });
|
||||
this._lastApiKey = apiKey;
|
||||
}
|
||||
|
||||
return this._client;
|
||||
}
|
||||
|
||||
isAvailable() {
|
||||
return this._getClient() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech and return as a stream
|
||||
*/
|
||||
async generateSpeechStream(options) {
|
||||
const {
|
||||
text,
|
||||
voice = 'coral',
|
||||
model = 'gpt-4o-mini-tts',
|
||||
speed = 1.0,
|
||||
instructions,
|
||||
apiKey
|
||||
} = options;
|
||||
|
||||
// Use provided API key or fall back to configured key
|
||||
let client;
|
||||
if (apiKey) {
|
||||
client = new OpenAI({ apiKey });
|
||||
} 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.');
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
throw new Error('Text is required for TTS');
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
// Convert the response to a web stream
|
||||
const stream = response.body;
|
||||
|
||||
return {
|
||||
stream,
|
||||
contentType: 'audio/mpeg',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[TTSService] Error generating speech:', error);
|
||||
throw new Error(`Failed to generate speech: ${error.message || 'Unknown error'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate speech and return as a buffer (for caching)
|
||||
*/
|
||||
async generateSpeechBuffer(options) {
|
||||
const client = this._getClient();
|
||||
if (!client) {
|
||||
throw new Error('OpenAI API key not configured. Set OPENAI_API_KEY environment variable or configure OpenAI in OpenCode.');
|
||||
}
|
||||
|
||||
const {
|
||||
text,
|
||||
voice = 'coral',
|
||||
model = 'gpt-4o-mini-tts',
|
||||
speed = 1.0,
|
||||
instructions
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const response = await client.audio.speech.create({
|
||||
model,
|
||||
voice,
|
||||
input: text,
|
||||
speed,
|
||||
...(instructions && { instructions }),
|
||||
response_format: 'mp3',
|
||||
});
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch (error) {
|
||||
console.error('[TTSService] Error generating speech buffer:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const ttsService = new TTSService();
|
||||
export { TTSService };
|
||||
Reference in New Issue
Block a user