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
@@ -715,7 +715,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
if (isTTSPlaying) {
return 'Stop speaking';
}
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : 'Say';
const providerLabel = voiceProvider === 'browser' ? 'Browser' : voiceProvider === 'openai' ? 'OpenAI' : voiceProvider === 'openai-compatible' ? 'Custom' : 'Say';
return `Read aloud (${providerLabel} voice)`;
}, [isTTSPlaying, voiceProvider]);
@@ -18,7 +18,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { browserVoiceService } from '@/lib/voice/browserVoiceService';
import { audioStreamService } from '@/lib/voice/audioStreamService';
import { cn } from '@/lib/utils';
const LANGUAGE_OPTIONS = [
{ value: 'en-US', label: 'English' },
{ value: 'es-ES', label: 'Español' },
@@ -71,6 +70,12 @@ export const VoiceSettings: React.FC = () => {
const setOpenaiVoice = useConfigStore((state) => state.setOpenaiVoice);
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
const setOpenaiApiKey = useConfigStore((state) => state.setOpenaiApiKey);
const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl);
const setOpenaiCompatibleUrl = useConfigStore((state) => state.setOpenaiCompatibleUrl);
const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice);
const setOpenaiCompatibleVoice = useConfigStore((state) => state.setOpenaiCompatibleVoice);
const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel);
const setOpenaiCompatibleTtsModel = useConfigStore((state) => state.setOpenaiCompatibleTtsModel);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
// STT settings
const sttProvider = useConfigStore((state) => state.sttProvider);
@@ -106,6 +111,9 @@ export const VoiceSettings: React.FC = () => {
const [isOpenAIPreviewPlaying, setIsOpenAIPreviewPlaying] = useState(false);
const [openaiPreviewAudio, setOpenaiPreviewAudio] = useState<HTMLAudioElement | null>(null);
const [isCompatiblePreviewPlaying, setIsCompatiblePreviewPlaying] = useState(false);
const [compatiblePreviewAudio, setCompatiblePreviewAudio] = useState<HTMLAudioElement | null>(null);
const [browserVoices, setBrowserVoices] = useState<SpeechSynthesisVoice[]>([]);
const [isBrowserPreviewPlaying, setIsBrowserPreviewPlaying] = useState(false);
@@ -182,7 +190,7 @@ export const VoiceSettings: React.FC = () => {
}, [isBrowserPreviewPlaying]);
useEffect(() => {
if (!voiceModeEnabled || voiceProvider !== 'openai') {
if (!voiceModeEnabled || (voiceProvider !== 'openai' && voiceProvider !== 'openai-compatible')) {
setIsOpenAIAvailable(openaiApiKey.trim().length > 0);
return;
}
@@ -339,6 +347,67 @@ export const VoiceSettings: React.FC = () => {
};
}, [openaiPreviewAudio]);
const previewCompatibleVoice = useCallback(async () => {
if (compatiblePreviewAudio) {
compatiblePreviewAudio.pause();
compatiblePreviewAudio.currentTime = 0;
setCompatiblePreviewAudio(null);
setIsCompatiblePreviewPlaying(false);
return;
}
if (!openaiCompatibleUrl.trim()) return;
setIsCompatiblePreviewPlaying(true);
try {
const response = await fetch('/api/tts/speak', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `Hello! This is a preview of the custom TTS server.`,
voice: openaiCompatibleVoice,
model: openaiCompatibleTtsModel || undefined,
speed: speechRate,
baseURL: openaiCompatibleUrl,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => {
URL.revokeObjectURL(url);
setCompatiblePreviewAudio(null);
setIsCompatiblePreviewPlaying(false);
};
audio.onerror = () => {
URL.revokeObjectURL(url);
setCompatiblePreviewAudio(null);
setIsCompatiblePreviewPlaying(false);
};
setCompatiblePreviewAudio(audio);
await audio.play();
} catch {
setIsCompatiblePreviewPlaying(false);
}
}, [openaiCompatibleUrl, openaiCompatibleVoice, openaiCompatibleTtsModel, speechRate, compatiblePreviewAudio]);
useEffect(() => {
return () => {
if (compatiblePreviewAudio) {
compatiblePreviewAudio.pause();
}
};
}, [compatiblePreviewAudio]);
const sliderClass = "flex-1 min-w-0 h-1.5 bg-[var(--interactive-border)] rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[var(--primary-base)] [&::-moz-range-thumb]:border-0 disabled:opacity-50";
return (
@@ -380,6 +449,7 @@ export const VoiceSettings: React.FC = () => {
<ul className="space-y-1">
<li><strong>Browser:</strong> Free, offline, limited mobile support.</li>
<li><strong>OpenAI:</strong> High quality, mobile ready, needs API key.</li>
<li><strong>Custom:</strong> OpenAI-compatible server (e.g. Kokoro).</li>
<li><strong>Say:</strong> macOS native. Fast, free, offline.</li>
</ul>
</TooltipContent>
@@ -412,6 +482,19 @@ export const VoiceSettings: React.FC = () => {
>
OpenAI
</Button>
<Button
variant="outline"
size="xs"
onClick={() => setVoiceProvider('openai-compatible')}
className={cn(
'!font-normal',
voiceProvider === 'openai-compatible'
? 'border-[var(--primary-base)] text-[var(--primary-base)] bg-[var(--primary-base)]/10 hover:text-[var(--primary-base)]'
: 'text-foreground'
)}
>
Custom
</Button>
{isSayAvailable && (
<Button
variant="outline"
@@ -462,6 +545,70 @@ export const VoiceSettings: React.FC = () => {
</div>
)}
{/* OpenAI-compatible custom server */}
{voiceProvider === 'openai-compatible' && (
<div className="py-1.5 space-y-2">
<div>
<span className={cn("typography-ui-label text-foreground", !openaiCompatibleUrl.trim() && "text-[var(--status-error)]")}>
Server URL
</span>
<span className="typography-meta ml-2 text-muted-foreground">
Base URL of the OpenAI-compatible TTS server
</span>
<div className="relative mt-1.5 max-w-xs">
<input
type="text"
value={openaiCompatibleUrl}
onChange={(e) => setOpenaiCompatibleUrl(e.target.value)}
placeholder="http://localhost:8880/v1"
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/>
{openaiCompatibleUrl && (
<button
type="button"
onClick={() => setOpenaiCompatibleUrl('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">Model</span>
<div className="relative mt-1.5 max-w-xs">
<input
type="text"
value={openaiCompatibleTtsModel}
onChange={(e) => setOpenaiCompatibleTtsModel(e.target.value)}
placeholder="speaches-ai/Kokoro-82M-v1.0-ONNX"
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/>
</div>
</div>
<div>
<span className="typography-ui-label text-foreground">Voice</span>
<span className="typography-meta ml-2 text-muted-foreground">
Voice identifier supported by the server
</span>
<div className="flex items-center gap-2 mt-1.5">
<div className="relative max-w-xs flex-1">
<input
type="text"
value={openaiCompatibleVoice}
onChange={(e) => setOpenaiCompatibleVoice(e.target.value)}
placeholder="af_sky"
className="w-full h-7 rounded-lg border border-input bg-transparent px-2 typography-ui-label text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary/50 focus:border-primary/70"
/>
</div>
<Button size="xs" variant="ghost" onClick={previewCompatibleVoice} title="Preview" disabled={!openaiCompatibleUrl.trim()}>
{isCompatiblePreviewPlaying ? <RiStopLine className="w-3.5 h-3.5" /> : <RiPlayLine className="w-3.5 h-3.5" />}
</Button>
</div>
</div>
</div>
)}
{/* Voice Selection */}
<div className="flex items-center gap-8 py-1.5">
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">Voice</span>
@@ -484,6 +631,10 @@ export const VoiceSettings: React.FC = () => {
</>
)}
{voiceProvider === 'openai-compatible' && (
<span className="typography-meta text-muted-foreground">Configured above</span>
)}
{voiceProvider === 'say' && isSayAvailable && sayVoices.length > 0 && (
<>
<Select value={sayVoice} onValueChange={setSayVoice}>
+16 -9
View File
@@ -62,7 +62,7 @@ export interface UseBrowserVoiceReturn {
/** Whether the device is mobile */
isMobile: boolean;
/** Current voice provider */
voiceProvider: 'browser' | 'openai' | 'say';
voiceProvider: 'browser' | 'openai' | 'openai-compatible' | 'say';
}
// Storage key for persisting language preference
@@ -144,10 +144,11 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
const openaiVoice = useConfigStore((state) => state.openaiVoice);
const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice);
const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl);
const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel);
const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation);
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
const shouldCheckOpenAIAvailability = voiceModeEnabled && voiceProvider === 'openai';
const shouldCheckOpenAIAvailability = voiceModeEnabled && (voiceProvider === 'openai' || voiceProvider === 'openai-compatible');
const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say';
// STT provider config
@@ -462,12 +463,19 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
}
};
// Use server TTS when OpenAI provider is selected and available
if (voiceProvider === 'openai' && isServerTTSAvailable) {
console.log('[useBrowserVoice] Using OpenAI server TTS with voice:', openaiVoice);
// Use server TTS when OpenAI (or OpenAI-compatible) provider is selected and available
if ((voiceProvider === 'openai' || voiceProvider === 'openai-compatible') && isServerTTSAvailable) {
const ttsVoice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice;
const ttsBaseURL = voiceProvider === 'openai-compatible' ? openaiCompatibleUrl : undefined;
const ttsModel = voiceProvider === 'openai-compatible' ? openaiCompatibleTtsModel : undefined;
console.log('[useBrowserVoice] Using server TTS with voice:', ttsVoice, 'provider:', voiceProvider);
await speakServerTTS(textToSpeak, {
voice: openaiVoice,
voice: ttsVoice,
model: ttsModel,
speed: speechRate,
pitch: speechPitch,
volume: speechVolume,
baseURL: ttsBaseURL,
onStart: () => console.log('[useBrowserVoice] Server TTS started'),
onEnd: () => {
console.log('[useBrowserVoice] Server TTS ended');
@@ -475,8 +483,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
},
onError: (errorMsg) => {
console.error('[useBrowserVoice] Server TTS error:', errorMsg);
// Show error to user when OpenAI voice fails
setError(`OpenAI voice failed: ${errorMsg}. Please check your OpenAI API key or switch to Browser voice.`);
setError(`Voice TTS failed: ${errorMsg}. Please check your settings or switch to Browser voice.`);
setStatus('error');
restartListening();
}
@@ -573,7 +580,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn {
setStatus('error');
processingMessageRef.current = false;
}
}, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isMobile, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, summarizeVoiceConversation, summarizeCharacterThreshold, conversationMode, sttProvider]);
}, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isMobile, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, summarizeVoiceConversation, summarizeCharacterThreshold, conversationMode, sttProvider]);
// Handle speech recognition result
const handleSpeechResult = useCallback(async (text: string, isFinal: boolean) => {
+18 -3
View File
@@ -31,11 +31,15 @@ export function useMessageTTS(): UseMessageTTSReturn {
const sayVoice = useConfigStore((state) => state.sayVoice);
const browserVoice = useConfigStore((state) => state.browserVoice);
const openaiVoice = useConfigStore((state) => state.openaiVoice);
const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice);
const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl);
const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel);
const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS);
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
const shouldCheckOpenAIAvailability = showMessageTTSButtons && voiceProvider === 'openai';
const isServerProvider = voiceProvider === 'openai' || voiceProvider === 'openai-compatible';
const shouldCheckOpenAIAvailability = showMessageTTSButtons && isServerProvider;
const shouldCheckSayAvailability = showMessageTTSButtons && voiceProvider === 'say';
const { speak: speakServerTTS, stop: stopServerTTS, isAvailable: isServerTTSAvailable } = useServerTTS({
@@ -72,11 +76,18 @@ export function useMessageTTS(): UseMessageTTSReturn {
textToSpeak = sanitizeForTTS(text);
}
if (voiceProvider === 'openai' && isServerTTSAvailable) {
if (isServerProvider && isServerTTSAvailable) {
const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice;
const baseURL = voiceProvider === 'openai-compatible' ? openaiCompatibleUrl : undefined;
const model = voiceProvider === 'openai-compatible' ? openaiCompatibleTtsModel : undefined;
await speakServerTTS(textToSpeak, {
voice: openaiVoice,
voice,
model,
speed: speechRate,
pitch: speechPitch,
volume: speechVolume,
summarize: false, // We already summarized client-side
baseURL,
onEnd: () => setIsPlaying(false),
onError: () => setIsPlaying(false),
});
@@ -110,12 +121,16 @@ export function useMessageTTS(): UseMessageTTSReturn {
}
}, [
voiceProvider,
isServerProvider,
speechRate,
speechPitch,
speechVolume,
sayVoice,
browserVoice,
openaiVoice,
openaiCompatibleVoice,
openaiCompatibleUrl,
openaiCompatibleTtsModel,
summarizeMessageTTS,
summarizeCharacterThreshold,
isServerTTSAvailable,
+29 -3
View File
@@ -85,8 +85,14 @@ export interface UseServerTTSReturn {
export interface SpeakOptions {
/** Voice to use (defaults to coral) */
voice?: string;
/** Model to use (defaults to gpt-4o-mini-tts) */
model?: string;
/** Speech speed (0.25 to 4.0, defaults to 1.0) */
speed?: number;
/** Speech pitch shift (0.5 to 2.0, mapped to cents; 1.0 = no shift) */
pitch?: number;
/** Playback volume (0 to 1, defaults to 1.0) */
volume?: number;
/** Optional instructions for the voice */
instructions?: string;
/** Summarize long text before speaking (defaults to true) */
@@ -97,6 +103,8 @@ export interface SpeakOptions {
modelId?: string;
/** Character threshold for summarization (defaults to 200) */
threshold?: number;
/** Custom base URL for OpenAI-compatible server */
baseURL?: string;
/** Callback when playback starts */
onStart?: () => void;
/** Callback when playback ends */
@@ -130,6 +138,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold);
const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength);
const openaiApiKey = useConfigStore((state) => state.openaiApiKey);
const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl);
const settingsZenModel = useConfigStore((state) => state.settingsZenModel);
// Check if server TTS is available
@@ -140,7 +149,8 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
}
const hasClientKey = Boolean(openaiApiKey && openaiApiKey.trim().length > 0);
if (hasClientKey) {
const hasCustomUrl = Boolean(openaiCompatibleUrl && openaiCompatibleUrl.trim().length > 0);
if (hasClientKey || hasCustomUrl) {
setIsAvailable(true);
return true;
}
@@ -153,7 +163,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
setIsAvailable(false);
return false;
}
}, [enabled, openaiApiKey]);
}, [enabled, openaiApiKey, openaiCompatibleUrl]);
// Check availability on mount and when API key changes
useEffect(() => {
@@ -250,6 +260,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
body: JSON.stringify({
text: text.trim(),
voice,
model: options?.model || undefined,
speed: options?.speed || 0.9,
instructions: options?.instructions,
summarize: options?.summarize ?? true, // Summarize by default for voice output
@@ -262,6 +273,8 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
maxLength: summarizeMaxLength ?? 500,
// Send API key from settings if available
apiKey: openaiApiKey || undefined,
// Send custom base URL for OpenAI-compatible servers
baseURL: options?.baseURL || undefined,
...(settingsZenModel ? { zenModel: settingsZenModel } : {}),
}),
signal: abortControllerRef.current.signal,
@@ -283,7 +296,20 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
// Create source node
const source = ctx.createBufferSource();
source.buffer = audioBuffer;
source.connect(ctx.destination);
// Apply pitch shift via detune (cents): 1200 cents = 1 octave
const pitch = options?.pitch ?? 1.0;
if (pitch !== 1.0) {
source.detune.value = (pitch - 1.0) * 1200;
}
// Apply volume via GainNode
const volume = options?.volume ?? 1.0;
const gainNode = ctx.createGain();
gainNode.gain.value = volume;
source.connect(gainNode);
gainNode.connect(ctx.destination);
audioSourceRef.current = source;
// Set up event handlers
+22 -5
View File
@@ -466,9 +466,9 @@ interface ConfigStore {
settingsAutoCreateWorktree: boolean;
settingsGitmojiEnabled: boolean;
settingsZenModel: string | undefined;
// Voice provider preference ('browser', 'openai', or 'say' for macOS)
voiceProvider: 'browser' | 'openai' | 'say';
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => void;
// Voice provider preference ('browser', 'openai', 'openai-compatible', or 'say' for macOS)
voiceProvider: 'browser' | 'openai' | 'openai-compatible' | 'say';
setVoiceProvider: (provider: 'browser' | 'openai' | 'openai-compatible' | 'say') => void;
// TTS settings
speechRate: number;
speechPitch: number;
@@ -479,6 +479,7 @@ interface ConfigStore {
openaiApiKey: string;
openaiCompatibleUrl: string;
openaiCompatibleVoice: string;
openaiCompatibleTtsModel: string;
// STT (speech-to-text) settings
sttProvider: 'browser' | 'server';
sttServerUrl: string;
@@ -502,6 +503,7 @@ interface ConfigStore {
setOpenaiApiKey: (apiKey: string) => void;
setOpenaiCompatibleUrl: (url: string) => void;
setOpenaiCompatibleVoice: (voice: string) => void;
setOpenaiCompatibleTtsModel: (model: string) => void;
setSttProvider: (provider: 'browser' | 'server') => void;
setSttServerUrl: (url: string) => void;
setSttModel: (model: string) => void;
@@ -585,7 +587,7 @@ export const useConfigStore = create<ConfigStore>()(
voiceProvider: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('voiceProvider');
if (saved === 'openai' || saved === 'browser' || saved === 'say') return saved;
if (saved === 'openai' || saved === 'browser' || saved === 'say' || saved === 'openai-compatible') return saved;
}
return 'browser';
})(),
@@ -668,6 +670,14 @@ export const useConfigStore = create<ConfigStore>()(
}
return 'af_sky';
})(),
// OpenAI-compatible custom server TTS model
openaiCompatibleTtsModel: (() => {
if (typeof window !== 'undefined') {
const saved = localStorage.getItem('openaiCompatibleTtsModel');
if (saved && saved !== 'speaches-ai/Kokoro-82M-v1.0-ONNX') return saved;
}
return 'kokoro';
})(),
// STT provider: 'browser' (Web Speech API) or 'server' (OpenAI-compat)
sttProvider: (() => {
if (typeof window !== 'undefined') {
@@ -1685,7 +1695,7 @@ export const useConfigStore = create<ConfigStore>()(
});
},
setVoiceProvider: (provider: 'browser' | 'openai' | 'say') => {
setVoiceProvider: (provider: 'browser' | 'openai' | 'openai-compatible' | 'say') => {
set({ voiceProvider: provider });
if (typeof window !== 'undefined') {
localStorage.setItem('voiceProvider', provider);
@@ -1758,6 +1768,13 @@ export const useConfigStore = create<ConfigStore>()(
}
},
setOpenaiCompatibleTtsModel: (model: string) => {
set({ openaiCompatibleTtsModel: model });
if (typeof window !== 'undefined') {
localStorage.setItem('openaiCompatibleTtsModel', model);
}
},
setSttProvider: (provider: 'browser' | 'server') => {
set({ sttProvider: provider });
if (typeof window !== 'undefined') {
+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) {