Files
openmaic/OpenMAIC/lib/audio/tts-providers.ts
2026-08-16 14:58:47 +08:00

990 lines
32 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* TTS (Text-to-Speech) Provider Implementation
*
* Factory pattern for routing TTS requests to appropriate provider implementations.
* Follows the same architecture as lib/ai/providers.ts for consistency.
*
* Currently Supported Providers:
* - OpenAI TTS: https://platform.openai.com/docs/guides/text-to-speech
* - Azure TTS: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/text-to-speech
* - GLM TTS: https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-tts
* - Qwen TTS: https://bailian.console.aliyun.com/
* - MiniMax TTS: https://platform.minimaxi.com/docs/api-reference/speech-t2a-http
* - Doubao TTS: https://www.volcengine.com/docs/6561/1257543
* - ElevenLabs TTS: https://elevenlabs.io/docs/api-reference/text-to-speech/convert
* - Browser Native: Web Speech API (client-side only)
*
* HOW TO ADD A NEW PROVIDER:
*
* 1. Add provider ID to TTSProviderId in lib/audio/types.ts
* Example: | 'elevenlabs-tts'
*
* 2. Add provider configuration to lib/audio/constants.ts
* Example:
* 'elevenlabs-tts': {
* id: 'elevenlabs-tts',
* name: 'ElevenLabs',
* requiresApiKey: true,
* defaultBaseUrl: 'https://api.elevenlabs.io/v1',
* icon: '/logos/elevenlabs.svg',
* voices: [...],
* supportedFormats: ['mp3', 'pcm'],
* speedRange: { min: 0.5, max: 2.0, default: 1.0 }
* }
*
* 3. Implement provider function in this file
* Pattern: async function generateXxxTTS(config, text): Promise<TTSGenerationResult>
* - Validate config and build API request
* - Handle API authentication (apiKey, headers)
* - Convert provider-specific parameters (voice, speed, format)
* - Return { audio: Uint8Array, format: string }
*
* Example:
* async function generateElevenLabsTTS(
* config: TTSModelConfig,
* text: string
* ): Promise<TTSGenerationResult> {
* const baseUrl = config.baseUrl || TTS_PROVIDERS['elevenlabs-tts'].defaultBaseUrl;
*
* const response = await fetch(`${baseUrl}/text-to-speech/${config.voice}`, {
* method: 'POST',
* headers: {
* 'xi-api-key': config.apiKey!,
* 'Content-Type': 'application/json',
* },
* body: JSON.stringify({
* text,
* model_id: 'eleven_multilingual_v2',
* voice_settings: {
* stability: 0.5,
* similarity_boost: 0.75,
* }
* }),
* });
*
* if (!response.ok) {
* throw new Error(`ElevenLabs TTS API error: ${response.statusText}`);
* }
*
* const arrayBuffer = await response.arrayBuffer();
* return {
* audio: new Uint8Array(arrayBuffer),
* format: 'mp3',
* };
* }
*
* 4. Add case to generateTTS() switch statement
* case 'elevenlabs-tts':
* return await generateElevenLabsTTS(config, text);
*
* 5. Add i18n translations in lib/i18n.ts
* providerElevenLabsTTS: { zh: 'ElevenLabs TTS', en: 'ElevenLabs TTS' }
*
* Error Handling Patterns:
* - Always validate API key if requiresApiKey is true
* - Throw descriptive errors for API failures
* - Include response.statusText or error messages from API
* - For client-only providers (browser-native), throw error directing to client-side usage
*
* API Call Patterns:
* - Direct API: Use fetch with appropriate headers and body format (recommended for better encoding support)
* - SSML: For Azure-like providers requiring SSML markup
* - URL-based: For providers returning audio URL (download in second step)
*/
import type { TTSModelConfig } from './types';
import { isCustomTTSProvider } from './types';
import { TTS_PROVIDERS } from './constants';
import { splitConcatenatedJsonObjects } from './json-stream';
import {
VOXCPM_VLLM_MODEL_ID,
VOXCPM_AUTO_VOICE_ID,
normalizeVoxCPMBackend,
type VoxCPMProviderOptions,
} from './voxcpm';
import { fetchPinnedPublicUrl } from '@/lib/server/public-url-fetch';
const QWEN_TTS_MAX_AUDIO_BYTES = 25 * 1024 * 1024;
const QWEN_TTS_AUDIO_TIMEOUT_MS = 30_000;
/**
* Result of TTS generation
*/
export interface TTSGenerationResult {
audio: Uint8Array;
format: string;
}
/**
* Thrown when a TTS provider returns a rate-limit / concurrency-quota error.
* Allows downstream consumers to distinguish rate-limit errors from other TTS failures.
*
* TODO: The API route currently catches all errors uniformly as GENERATION_FAILED.
* This class enables future retry/backoff logic without changing the throw sites.
*/
export class TTSRateLimitError extends Error {
constructor(
public readonly provider: string,
message: string,
) {
super(message);
this.name = 'TTSRateLimitError';
}
}
/**
* Map an upstream HTTP 429 to a typed {@link TTSRateLimitError} so the API route
* can surface it as 429 instead of a generic 500. Call right after an
* `!response.ok` check, before building the provider-specific error message.
*/
export function throwIfTtsRateLimited(provider: string, status: number): void {
if (status === 429) {
throw new TTSRateLimitError(provider, `${provider} TTS rate limit exceeded (HTTP 429)`);
}
}
/**
* Generate speech using specified TTS provider
*/
export async function generateTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const provider = TTS_PROVIDERS[config.providerId as keyof typeof TTS_PROVIDERS];
// Validate API key if required (only for built-in providers with known config)
if (provider?.requiresApiKey && !config.apiKey) {
throw new Error(`API key required for TTS provider: ${config.providerId}`);
}
switch (config.providerId) {
case 'openai-tts':
return await generateOpenAITTS(config, text);
case 'azure-tts':
return await generateAzureTTS(config, text);
case 'glm-tts':
return await generateGLMTTS(config, text);
case 'qwen-tts':
return await generateQwenTTS(config, text);
case 'voxcpm-tts':
return await generateVoxCPMTTS(config, text);
case 'minimax-tts':
return await generateMiniMaxTTS(config, text);
case 'doubao-tts':
return await generateDoubaoTTS(config, text);
case 'elevenlabs-tts':
return await generateElevenLabsTTS(config, text);
case 'lemonade-tts':
return await generateLemonadeTTS(config, text);
case 'browser-native-tts':
throw new Error(
'Browser Native TTS must be handled client-side using Web Speech API. This provider cannot be used on the server.',
);
default:
if (isCustomTTSProvider(config.providerId)) {
return await generateOpenAITTS(config, text);
}
throw new Error(`Unsupported TTS provider: ${config.providerId}`);
}
}
/**
* OpenAI TTS implementation (direct API call with explicit UTF-8 encoding)
*/
async function generateOpenAITTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = config.baseUrl || TTS_PROVIDERS['openai-tts'].defaultBaseUrl;
// Use gpt-4o-mini-tts for best quality and intelligent realtime applications
const response = await fetch(`${baseUrl}/audio/speech`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
model: config.modelId || 'gpt-4o-mini-tts',
input: text,
voice: config.voice,
speed: config.speed || 1.0,
}),
});
if (!response.ok) {
throwIfTtsRateLimited('OpenAI', response.status);
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(`OpenAI TTS API error: ${error.error?.message || response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || '';
const format = getAudioResponseFormat(contentType);
return {
audio: new Uint8Array(arrayBuffer),
format,
};
}
/**
* Lemonade TTS implementation (OpenAI-compatible /v1/audio/speech).
*/
async function generateLemonadeTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = (config.baseUrl || TTS_PROVIDERS['lemonade-tts'].defaultBaseUrl || '').replace(
/\/$/,
'',
);
const modelId = config.modelId || TTS_PROVIDERS['lemonade-tts'].defaultModelId;
const voice = config.voice || 'af_heart';
const response = await fetch(`${baseUrl}/audio/speech`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
...getBackendAuthHeaders(config.apiKey),
},
body: JSON.stringify({
model: modelId,
input: text,
voice,
speed: config.speed || 1.0,
response_format: config.format || 'wav',
}),
});
if (!response.ok) {
throwIfTtsRateLimited('Lemonade', response.status);
throw new Error(`Lemonade TTS API error: ${await readTTSApiError(response)}`);
}
const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || '';
return {
audio: new Uint8Array(arrayBuffer),
format: getAudioResponseFormat(contentType),
};
}
/**
* VoxCPM2 TTS implementation.
*
* OpenMAIC keeps one internal VoxCPM request shape, then adapts it to the
* selected official backend protocol.
*/
async function generateVoxCPMTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = (config.baseUrl || TTS_PROVIDERS['voxcpm-tts'].defaultBaseUrl || '').replace(
/\/$/,
'',
);
if (!baseUrl) {
throw new Error('VoxCPM base URL is required');
}
const options = (config.providerOptions || {}) as VoxCPMProviderOptions;
const backend = normalizeVoxCPMBackend(options.backend);
const voicePrompt =
options.voicePrompt ||
(config.voice && config.voice !== 'default' && config.voice !== VOXCPM_AUTO_VOICE_ID
? config.voice
: undefined);
// A registered voice carries timbre by id, so no voice prompt is required.
const registeredVoiceId = options.registeredVoiceId?.trim() || undefined;
if (config.voice === VOXCPM_AUTO_VOICE_ID && !voicePrompt && !registeredVoiceId) {
throw new Error('VoxCPM Auto Voice requires agent context');
}
const cfgValue = options.cfgValue ?? 2.0;
const inferenceTimesteps = options.inferenceTimesteps ?? 10;
const normalize = options.normalize ?? false;
const denoise = options.denoise ?? false;
const usePromptContinuation = Boolean(options.promptText?.trim() && options.referenceAudioBase64);
const request = {
targetText: usePromptContinuation ? text : buildVoxCPMTargetText(text, voicePrompt),
rawText: text,
registeredVoiceId,
voicePrompt,
promptText: options.promptText,
cfgValue,
inferenceTimesteps,
normalize,
denoise,
referenceAudioBase64: options.referenceAudioBase64,
referenceAudioMimeType: options.referenceAudioMimeType,
referenceAudioName: options.referenceAudioName,
};
const response =
backend === 'nano-vllm'
? await postVoxCPMNanoVLLM(baseUrl, request, config.apiKey)
: backend === 'python-api'
? await postVoxCPMPythonAPI(baseUrl, request, config.apiKey)
: await postVoxCPMVLLMOmni(baseUrl, request, config);
if (!response.ok) {
throwIfTtsRateLimited('VoxCPM', response.status);
throw new Error(`VoxCPM TTS API error: ${await readTTSApiError(response)}`);
}
const arrayBuffer = await response.arrayBuffer();
const contentType = response.headers.get('content-type') || '';
const format = getAudioResponseFormat(contentType);
return {
audio: new Uint8Array(arrayBuffer),
format,
};
}
function buildVoxCPMTargetText(text: string, voicePrompt?: string): string {
const prompt = voicePrompt
?.replace(/[\p{C}]+/gu, ' ')
.replace(/[()]/gu, '')
.replace(/\s+/gu, ' ')
.trim();
return prompt ? `(${prompt})${text}` : text;
}
function getAudioResponseFormat(contentType: string): string {
if (contentType.includes('audio/wav') || contentType.includes('audio/x-wav')) return 'wav';
if (contentType.includes('audio/mpeg') || contentType.includes('audio/mp3')) return 'mp3';
if (contentType.includes('audio/flac')) return 'flac';
if (contentType.includes('audio/ogg')) return 'ogg';
if (contentType.includes('audio/webm')) return 'webm';
return 'mp3';
}
function getVoxCPMAudioFormat(mimeType?: string, fileName?: string): string {
const lowerName = fileName?.toLowerCase() || '';
if (mimeType?.includes('wav') || lowerName.endsWith('.wav')) return 'wav';
if (mimeType?.includes('mpeg') || mimeType?.includes('mp3') || lowerName.endsWith('.mp3')) {
return 'mp3';
}
if (mimeType?.includes('flac') || lowerName.endsWith('.flac')) return 'flac';
if (mimeType?.includes('ogg') || lowerName.endsWith('.ogg')) return 'ogg';
if (mimeType?.includes('webm') || lowerName.endsWith('.webm')) return 'webm';
return 'wav';
}
function getVLLMOmniSpeechUrl(baseUrl: string): string {
return baseUrl.endsWith('/v1') ? `${baseUrl}/audio/speech` : `${baseUrl}/v1/audio/speech`;
}
function getVLLMOmniModelId(config: TTSModelConfig): string {
const modelId = config.modelId?.trim();
if (!modelId || modelId === 'VoxCPM2') return VOXCPM_VLLM_MODEL_ID;
return modelId;
}
function getBackendAuthHeaders(apiKey?: string): Record<string, string> {
return apiKey?.trim() ? { Authorization: `Bearer ${apiKey.trim()}` } : {};
}
async function postVoxCPMVLLMOmni(
baseUrl: string,
params: {
targetText: string;
rawText?: string;
registeredVoiceId?: string;
promptText?: string;
referenceAudioBase64?: string;
referenceAudioMimeType?: string;
referenceAudioName?: string;
},
config: TTSModelConfig,
): Promise<Response> {
const payload: Record<string, unknown> = {
model: getVLLMOmniModelId(config),
input: params.targetText,
voice: 'default',
response_format: 'wav',
stream: false,
};
if (params.registeredVoiceId) {
// A registered voice carries timbre by id (pre-encoded latents): reference it
// directly and send the raw text — no inline voice-design prompt or ref_audio.
payload.voice = params.registeredVoiceId;
payload.input = params.rawText ?? params.targetText;
} else if (params.referenceAudioBase64) {
const referenceAudio = getVoxCPMDataAudioUrl(
params.referenceAudioBase64,
params.referenceAudioMimeType,
params.referenceAudioName,
);
payload.ref_audio = referenceAudio;
if (params.promptText?.trim()) {
payload.prompt_audio = referenceAudio;
payload.prompt_text = params.promptText.trim();
}
}
return fetch(getVLLMOmniSpeechUrl(baseUrl), {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
...getBackendAuthHeaders(config.apiKey),
},
body: JSON.stringify(payload),
});
}
function getVoxCPMDataAudioUrl(base64: string, mimeType?: string, fileName?: string): string {
const format = getVoxCPMAudioFormat(mimeType, fileName);
const mediaType =
mimeType?.trim() ||
(format === 'mp3'
? 'audio/mpeg'
: format === 'flac'
? 'audio/flac'
: format === 'ogg'
? 'audio/ogg'
: format === 'webm'
? 'audio/webm'
: 'audio/wav');
return `data:${mediaType};base64,${base64}`;
}
function base64ToBlob(base64: string, mimeType?: string): Blob {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index++) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], { type: mimeType || 'audio/wav' });
}
async function postVoxCPMPythonAPI(
baseUrl: string,
params: {
targetText: string;
promptText?: string;
cfgValue: number;
inferenceTimesteps: number;
normalize: boolean;
denoise: boolean;
referenceAudioBase64?: string;
referenceAudioMimeType?: string;
referenceAudioName?: string;
},
apiKey?: string,
): Promise<Response> {
const formData = new FormData();
formData.set('text', params.targetText);
formData.set('cfg_value', String(params.cfgValue));
formData.set('inference_timesteps', String(params.inferenceTimesteps));
formData.set('normalize', String(params.normalize));
formData.set('denoise', String(params.denoise));
if (params.referenceAudioBase64) {
const audioBlob = base64ToBlob(params.referenceAudioBase64, params.referenceAudioMimeType);
const audioName = params.referenceAudioName || 'reference.wav';
formData.set('reference_audio', audioBlob, audioName);
if (params.promptText?.trim()) {
formData.set('prompt_audio', audioBlob, audioName);
formData.set('prompt_text', params.promptText.trim());
}
}
return fetch(`${baseUrl}/tts/upload`, {
method: 'POST',
headers: getBackendAuthHeaders(apiKey),
body: formData,
});
}
async function postVoxCPMNanoVLLM(
baseUrl: string,
params: {
targetText: string;
promptText?: string;
cfgValue: number;
referenceAudioBase64?: string;
referenceAudioMimeType?: string;
referenceAudioName?: string;
},
apiKey?: string,
): Promise<Response> {
const payload: Record<string, unknown> = {
target_text: params.targetText,
cfg_value: params.cfgValue,
};
if (params.referenceAudioBase64) {
const format = getVoxCPMAudioFormat(params.referenceAudioMimeType, params.referenceAudioName);
payload.ref_audio_wav_base64 = params.referenceAudioBase64;
payload.ref_audio_wav_format = format;
if (params.promptText?.trim()) {
payload.prompt_wav_base64 = params.referenceAudioBase64;
payload.prompt_wav_format = format;
payload.prompt_text = params.promptText.trim();
}
}
return fetch(`${baseUrl}/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
...getBackendAuthHeaders(apiKey),
},
body: JSON.stringify(payload),
});
}
async function readTTSApiError(response: Response): Promise<string> {
const text = await response.text().catch(() => response.statusText);
if (!text) return response.statusText;
try {
const json = JSON.parse(text) as { detail?: unknown; error?: { message?: string } | string };
if (typeof json.detail === 'string') return json.detail;
if (typeof json.error === 'string') return json.error;
if (json.error?.message) return json.error.message;
} catch {
// Fall through to raw text.
}
return text;
}
/**
* Azure TTS implementation (direct API call with SSML)
*/
async function generateAzureTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-tts'].defaultBaseUrl;
// Build SSML
const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
const ssml = `
<speak version='1.0' xml:lang='zh-CN'>
<voice xml:lang='zh-CN' name='${config.voice}'>
<prosody rate='${rate}'>${escapeXml(text)}</prosody>
</voice>
</speak>
`.trim();
const response = await fetch(`${baseUrl}/cognitiveservices/v1`, {
method: 'POST',
headers: {
'Ocp-Apim-Subscription-Key': config.apiKey!,
'Content-Type': 'application/ssml+xml; charset=utf-8',
'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
},
body: ssml,
});
if (!response.ok) {
throwIfTtsRateLimited('Azure', response.status);
throw new Error(`Azure TTS API error: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return {
audio: new Uint8Array(arrayBuffer),
format: 'mp3',
};
}
/**
* GLM TTS implementation (GLM API)
*/
async function generateGLMTTS(config: TTSModelConfig, text: string): Promise<TTSGenerationResult> {
const baseUrl = config.baseUrl || TTS_PROVIDERS['glm-tts'].defaultBaseUrl;
const response = await fetch(`${baseUrl}/audio/speech`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
model: config.modelId || 'glm-tts',
input: text,
voice: config.voice,
speed: config.speed || 1.0,
volume: 1.0,
response_format: 'wav',
}),
});
if (!response.ok) {
throwIfTtsRateLimited('GLM', response.status);
const errorText = await response.text().catch(() => response.statusText);
let errorMessage = `GLM TTS API error: ${errorText}`;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.error?.message) {
errorMessage = `GLM TTS API error: ${errorJson.error.message} (code: ${errorJson.error.code})`;
}
} catch {
// If not JSON, use the text as is
}
throw new Error(errorMessage);
}
const arrayBuffer = await response.arrayBuffer();
return {
audio: new Uint8Array(arrayBuffer),
format: 'wav',
};
}
/**
* Qwen TTS implementation (DashScope API - Qwen3 TTS Flash)
*/
async function generateQwenTTS(config: TTSModelConfig, text: string): Promise<TTSGenerationResult> {
const baseUrl = config.baseUrl || TTS_PROVIDERS['qwen-tts'].defaultBaseUrl;
// Calculate speed: Qwen3 uses rate parameter from -500 to 500
// speed 1.0 = rate 0, speed 2.0 = rate 500, speed 0.5 = rate -250
const rate = Math.round(((config.speed || 1.0) - 1.0) * 500);
const response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
model: config.modelId || 'qwen3-tts-flash',
input: {
text,
voice: config.voice,
language_type: 'Chinese', // Default to Chinese, can be made configurable
},
parameters: {
rate, // Speech rate from -500 to 500
},
}),
});
if (!response.ok) {
throwIfTtsRateLimited('Qwen', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`Qwen TTS API error: ${errorText}`);
}
const data = await response.json();
// Check for audio URL in response
if (!data.output?.audio?.url) {
throw new Error(`Qwen TTS error: No audio URL in response. Response: ${JSON.stringify(data)}`);
}
// Download audio from URL
const audioUrl = data.output.audio.url;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), QWEN_TTS_AUDIO_TIMEOUT_MS);
let dispose: (() => Promise<void>) | undefined;
try {
const pinned = await fetchPinnedPublicUrl(audioUrl, {
signal: controller.signal,
maxResponseBytes: QWEN_TTS_MAX_AUDIO_BYTES,
bodyTimeoutMs: QWEN_TTS_AUDIO_TIMEOUT_MS,
});
const audioResponse = pinned.response;
dispose = pinned.dispose;
// The pinned helper leaves redirects manual. Treat every 3xx as a failed
// download so a provider response cannot redirect into another network.
if (!audioResponse.ok) {
throw new Error(`Failed to download audio from URL: ${audioResponse.statusText}`);
}
const arrayBuffer = await audioResponse.arrayBuffer();
return {
audio: new Uint8Array(arrayBuffer),
format: 'wav', // Qwen3 TTS returns WAV format
};
} finally {
clearTimeout(timeout);
await dispose?.();
}
}
/**
* MiniMax TTS implementation (synchronous HTTP API)
*/
async function generateMiniMaxTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = (config.baseUrl || TTS_PROVIDERS['minimax-tts'].defaultBaseUrl || '').replace(
/\/$/,
'',
);
const response = await fetch(`${baseUrl}/v1/t2a_v2`, {
method: 'POST',
headers: {
Authorization: `Bearer ${config.apiKey}`,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
model: config.modelId || 'speech-2.8-hd',
text,
stream: false,
output_format: 'hex',
voice_setting: {
voice_id: config.voice,
speed: config.speed || 1.0,
vol: 1,
pitch: 0,
},
audio_setting: {
sample_rate: 32000,
bitrate: 128000,
format: config.format || 'mp3',
channel: 1,
},
language_boost: 'auto',
}),
});
if (!response.ok) {
throwIfTtsRateLimited('MiniMax', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`MiniMax TTS API error: ${errorText}`);
}
const data = await response.json();
const hexAudio = data?.data?.audio;
if (!hexAudio || typeof hexAudio !== 'string') {
throw new Error(`MiniMax TTS error: No audio returned. Response: ${JSON.stringify(data)}`);
}
const cleanedHex = hexAudio.trim();
if (cleanedHex.length % 2 !== 0) {
throw new Error('MiniMax TTS error: invalid hex audio payload length');
}
const audio = new Uint8Array(
cleanedHex.match(/.{1,2}/g)?.map((byte: string) => parseInt(byte, 16)) || [],
);
return {
audio,
format: data?.extra_info?.audio_format || config.format || 'mp3',
};
}
/**
* ElevenLabs TTS implementation (direct API call with voice-specific endpoint)
*/
async function generateElevenLabsTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const baseUrl = config.baseUrl || TTS_PROVIDERS['elevenlabs-tts'].defaultBaseUrl;
const requestedFormat = config.format || 'mp3';
const clampedSpeed = Math.min(1.2, Math.max(0.7, config.speed || 1.0));
const outputFormatMap: Record<string, string> = {
mp3: 'mp3_44100_128',
opus: 'opus_48000_96',
pcm: 'pcm_44100',
wav: 'wav_44100',
ulaw: 'ulaw_8000',
alaw: 'alaw_8000',
};
const outputFormat = outputFormatMap[requestedFormat] || outputFormatMap.mp3;
const response = await fetch(
`${baseUrl}/text-to-speech/${encodeURIComponent(config.voice)}?output_format=${outputFormat}`,
{
method: 'POST',
headers: {
'xi-api-key': config.apiKey!,
'Content-Type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
text,
model_id: config.modelId || 'eleven_multilingual_v2',
voice_settings: {
stability: 0.5,
similarity_boost: 0.75,
speed: clampedSpeed,
},
}),
},
);
if (!response.ok) {
throwIfTtsRateLimited('ElevenLabs', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`ElevenLabs TTS API error: ${errorText || response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
return {
audio: new Uint8Array(arrayBuffer),
format: requestedFormat,
};
}
/**
* Get current TTS configuration from settings store
* Note: This function should only be called in browser context
*/
export async function getCurrentTTSConfig(): Promise<TTSModelConfig> {
if (typeof window === 'undefined') {
throw new Error('getCurrentTTSConfig() can only be called in browser context');
}
// Lazy import to avoid circular dependency
const { useSettingsStore } = await import('@/lib/store/settings');
const { ttsProviderId, ttsVoice, ttsSpeed, ttsProvidersConfig } = useSettingsStore.getState();
const providerConfig = ttsProvidersConfig?.[ttsProviderId];
return {
providerId: ttsProviderId,
modelId:
providerConfig?.modelId ||
TTS_PROVIDERS[ttsProviderId as keyof typeof TTS_PROVIDERS]?.defaultModelId ||
'',
apiKey: providerConfig?.apiKey,
baseUrl: providerConfig?.baseUrl || providerConfig?.customDefaultBaseUrl,
voice: ttsVoice,
speed: ttsSpeed,
};
}
// Re-export from constants for convenience
export { getAllTTSProviders, getTTSProvider, getTTSVoices } from './constants';
/**
* Doubao TTS 2.0 implementation (Volcengine Seed-TTS 2.0).
*
* Two auth modes, distinguished by the API key shape — Volcengine exposes
* Seed-TTS as two separate products that do NOT share credentials or endpoints
* (verified: a plan key 401s on the normal endpoint, and the plan endpoint
* rejects Bearer auth):
* - Standalone speech console: `appId:accessKey` → normal endpoint
* (.../api/v3/tts/unidirectional) with `X-Api-App-Id` + `X-Api-Access-Key`.
* - Ark Agent Plan: a single `ark-...` plan key → plan endpoint
* (.../api/plan/tts/unidirectional, carried in config.baseUrl) with
* `X-Api-Key`. Lit up via the Token Plan one-click setup.
* The endpoint and auth header are bound together, so we pick both from the key
* shape — never a normal endpoint with X-Api-Key, or vice versa.
*/
async function generateDoubaoTTS(
config: TTSModelConfig,
text: string,
): Promise<TTSGenerationResult> {
const rawKey = config.apiKey || '';
if (!rawKey) {
throw new Error(
'Doubao TTS requires an API key: an Agent Plan key, or "appId:accessKey" from the Volcengine speech console.',
);
}
const colonIdx = rawKey.indexOf(':');
// A colon means the classic appId:accessKey pair; otherwise treat the whole
// value as an Agent Plan single key (X-Api-Key auth on the /plan endpoint).
const isPlanKey = colonIdx < 0;
const appId = isPlanKey ? '' : rawKey.slice(0, colonIdx);
const accessKey = isPlanKey ? '' : rawKey.slice(colonIdx + 1);
// A colon with an empty half is a malformed pair — fail clearly rather than
// sending an empty appId/accessKey header that the API rejects opaquely.
if (!isPlanKey && (!appId || !accessKey)) {
throw new Error(
'Doubao TTS appId:accessKey is malformed — both halves are required (or use an Agent Plan key).',
);
}
const baseUrl = config.baseUrl || TTS_PROVIDERS['doubao-tts'].defaultBaseUrl;
const speechRate = Math.round(((config.speed || 1.0) - 1.0) * 100);
const authHeaders: Record<string, string> = isPlanKey
? { 'X-Api-Key': rawKey }
: { 'X-Api-App-Id': appId, 'X-Api-Access-Key': accessKey };
const response = await fetch(`${baseUrl}/unidirectional`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeaders,
'X-Api-Resource-Id': 'seed-tts-2.0',
},
body: JSON.stringify({
user: { uid: 'openmaic' },
req_params: {
text,
speaker: config.voice,
audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: speechRate },
},
}),
});
if (!response.ok) {
throwIfTtsRateLimited('Doubao', response.status);
const errorText = await response.text().catch(() => response.statusText);
throw new Error(`Doubao TTS API error (${response.status}): ${errorText}`);
}
const responseText = await response.text();
const audioChunks: Uint8Array[] = [];
// Doubao streams a run of concatenated JSON objects with no delimiter. Split
// them string-aware (see splitConcatenatedJsonObjects) — a naive `{`/`}` depth
// counter miscounts braces that appear inside a string value (e.g. an error
// `message` containing `}`), which corrupts the object boundaries.
for (const objectText of splitConcatenatedJsonObjects(responseText)) {
let chunk: { code: number; message?: string; data?: string };
try {
chunk = JSON.parse(objectText);
} catch {
continue;
}
if (chunk.code === 0 && chunk.data) {
audioChunks.push(new Uint8Array(Buffer.from(chunk.data, 'base64')));
} else if (chunk.code === 20000000) {
break;
} else if (chunk.code && chunk.code !== 0) {
if (chunk.code === 45000000 || chunk.code === 45000292) {
throw new TTSRateLimitError('doubao-tts', chunk.message || 'concurrency quota exceeded');
}
throw new Error(`Doubao TTS error: ${chunk.message || 'unknown'} (code: ${chunk.code})`);
}
}
if (audioChunks.length === 0) {
throw new Error('Doubao TTS: no audio data received');
}
const totalLength = audioChunks.reduce((sum, c) => sum + c.length, 0);
const combined = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of audioChunks) {
combined.set(chunk, offset);
offset += chunk.length;
}
return { audio: combined, format: 'mp3' };
}
/**
* Escape XML special characters for SSML
*/
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}