tts-providers.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594
  1. /**
  2. * TTS (Text-to-Speech) Provider Implementation
  3. *
  4. * Factory pattern for routing TTS requests to appropriate provider implementations.
  5. * Follows the same architecture as lib/ai/providers.ts for consistency.
  6. *
  7. * Currently Supported Providers:
  8. * - OpenAI TTS: https://platform.openai.com/docs/guides/text-to-speech
  9. * - Azure TTS: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/text-to-speech
  10. * - GLM TTS: https://docs.bigmodel.cn/cn/guide/models/sound-and-video/glm-tts
  11. * - Qwen TTS: https://bailian.console.aliyun.com/
  12. * - MiniMax TTS: https://platform.minimaxi.com/docs/api-reference/speech-t2a-http
  13. * - Doubao TTS: https://www.volcengine.com/docs/6561/1257543
  14. * - ElevenLabs TTS: https://elevenlabs.io/docs/api-reference/text-to-speech/convert
  15. * - Browser Native: Web Speech API (client-side only)
  16. *
  17. * HOW TO ADD A NEW PROVIDER:
  18. *
  19. * 1. Add provider ID to TTSProviderId in lib/audio/types.ts
  20. * Example: | 'elevenlabs-tts'
  21. *
  22. * 2. Add provider configuration to lib/audio/constants.ts
  23. * Example:
  24. * 'elevenlabs-tts': {
  25. * id: 'elevenlabs-tts',
  26. * name: 'ElevenLabs',
  27. * requiresApiKey: true,
  28. * defaultBaseUrl: 'https://api.elevenlabs.io/v1',
  29. * icon: '/logos/elevenlabs.svg',
  30. * voices: [...],
  31. * supportedFormats: ['mp3', 'pcm'],
  32. * speedRange: { min: 0.5, max: 2.0, default: 1.0 }
  33. * }
  34. *
  35. * 3. Implement provider function in this file
  36. * Pattern: async function generateXxxTTS(config, text): Promise<TTSGenerationResult>
  37. * - Validate config and build API request
  38. * - Handle API authentication (apiKey, headers)
  39. * - Convert provider-specific parameters (voice, speed, format)
  40. * - Return { audio: Uint8Array, format: string }
  41. *
  42. * Example:
  43. * async function generateElevenLabsTTS(
  44. * config: TTSModelConfig,
  45. * text: string
  46. * ): Promise<TTSGenerationResult> {
  47. * const baseUrl = config.baseUrl || TTS_PROVIDERS['elevenlabs-tts'].defaultBaseUrl;
  48. *
  49. * const response = await fetch(`${baseUrl}/text-to-speech/${config.voice}`, {
  50. * method: 'POST',
  51. * headers: {
  52. * 'xi-api-key': config.apiKey!,
  53. * 'Content-Type': 'application/json',
  54. * },
  55. * body: JSON.stringify({
  56. * text,
  57. * model_id: 'eleven_multilingual_v2',
  58. * voice_settings: {
  59. * stability: 0.5,
  60. * similarity_boost: 0.75,
  61. * }
  62. * }),
  63. * });
  64. *
  65. * if (!response.ok) {
  66. * throw new Error(`ElevenLabs TTS API error: ${response.statusText}`);
  67. * }
  68. *
  69. * const arrayBuffer = await response.arrayBuffer();
  70. * return {
  71. * audio: new Uint8Array(arrayBuffer),
  72. * format: 'mp3',
  73. * };
  74. * }
  75. *
  76. * 4. Add case to generateTTS() switch statement
  77. * case 'elevenlabs-tts':
  78. * return await generateElevenLabsTTS(config, text);
  79. *
  80. * 5. Add i18n translations in lib/i18n.ts
  81. * providerElevenLabsTTS: { zh: 'ElevenLabs TTS', en: 'ElevenLabs TTS' }
  82. *
  83. * Error Handling Patterns:
  84. * - Always validate API key if requiresApiKey is true
  85. * - Throw descriptive errors for API failures
  86. * - Include response.statusText or error messages from API
  87. * - For client-only providers (browser-native), throw error directing to client-side usage
  88. *
  89. * API Call Patterns:
  90. * - Direct API: Use fetch with appropriate headers and body format (recommended for better encoding support)
  91. * - SSML: For Azure-like providers requiring SSML markup
  92. * - URL-based: For providers returning audio URL (download in second step)
  93. */
  94. import type { TTSModelConfig } from './types';
  95. import { TTS_PROVIDERS } from './constants';
  96. /**
  97. * Result of TTS generation
  98. */
  99. export interface TTSGenerationResult {
  100. audio: Uint8Array;
  101. format: string;
  102. }
  103. /**
  104. * Thrown when a TTS provider returns a rate-limit / concurrency-quota error.
  105. * Allows downstream consumers to distinguish rate-limit errors from other TTS failures.
  106. *
  107. * TODO: The API route currently catches all errors uniformly as GENERATION_FAILED.
  108. * This class enables future retry/backoff logic without changing the throw sites.
  109. */
  110. export class TTSRateLimitError extends Error {
  111. constructor(
  112. public readonly provider: string,
  113. message: string,
  114. ) {
  115. super(message);
  116. this.name = 'TTSRateLimitError';
  117. }
  118. }
  119. /**
  120. * Generate speech using specified TTS provider
  121. */
  122. export async function generateTTS(
  123. config: TTSModelConfig,
  124. text: string,
  125. ): Promise<TTSGenerationResult> {
  126. const provider = TTS_PROVIDERS[config.providerId];
  127. if (!provider) {
  128. throw new Error(`Unknown TTS provider: ${config.providerId}`);
  129. }
  130. // Validate API key if required
  131. if (provider.requiresApiKey && !config.apiKey) {
  132. throw new Error(`API key required for TTS provider: ${config.providerId}`);
  133. }
  134. switch (config.providerId) {
  135. case 'openai-tts':
  136. return await generateOpenAITTS(config, text);
  137. case 'azure-tts':
  138. return await generateAzureTTS(config, text);
  139. case 'glm-tts':
  140. return await generateGLMTTS(config, text);
  141. case 'qwen-tts':
  142. return await generateQwenTTS(config, text);
  143. case 'minimax-tts':
  144. return await generateMiniMaxTTS(config, text);
  145. case 'doubao-tts':
  146. return await generateDoubaoTTS(config, text);
  147. case 'elevenlabs-tts':
  148. return await generateElevenLabsTTS(config, text);
  149. case 'browser-native-tts':
  150. throw new Error(
  151. 'Browser Native TTS must be handled client-side using Web Speech API. This provider cannot be used on the server.',
  152. );
  153. default:
  154. throw new Error(`Unsupported TTS provider: ${config.providerId}`);
  155. }
  156. }
  157. /**
  158. * OpenAI TTS implementation (direct API call with explicit UTF-8 encoding)
  159. */
  160. async function generateOpenAITTS(
  161. config: TTSModelConfig,
  162. text: string,
  163. ): Promise<TTSGenerationResult> {
  164. const baseUrl = config.baseUrl || TTS_PROVIDERS['openai-tts'].defaultBaseUrl;
  165. // Use gpt-4o-mini-tts for best quality and intelligent realtime applications
  166. const response = await fetch(`${baseUrl}/audio/speech`, {
  167. method: 'POST',
  168. headers: {
  169. Authorization: `Bearer ${config.apiKey}`,
  170. 'Content-Type': 'application/json; charset=utf-8',
  171. },
  172. body: JSON.stringify({
  173. model: config.modelId || 'gpt-4o-mini-tts',
  174. input: text,
  175. voice: config.voice,
  176. speed: config.speed || 1.0,
  177. }),
  178. });
  179. if (!response.ok) {
  180. const error = await response.json().catch(() => ({ error: response.statusText }));
  181. throw new Error(`OpenAI TTS API error: ${error.error?.message || response.statusText}`);
  182. }
  183. const arrayBuffer = await response.arrayBuffer();
  184. return {
  185. audio: new Uint8Array(arrayBuffer),
  186. format: 'mp3',
  187. };
  188. }
  189. /**
  190. * Azure TTS implementation (direct API call with SSML)
  191. */
  192. async function generateAzureTTS(
  193. config: TTSModelConfig,
  194. text: string,
  195. ): Promise<TTSGenerationResult> {
  196. const baseUrl = config.baseUrl || TTS_PROVIDERS['azure-tts'].defaultBaseUrl;
  197. // Build SSML
  198. const rate = config.speed ? `${((config.speed - 1) * 100).toFixed(0)}%` : '0%';
  199. const ssml = `
  200. <speak version='1.0' xml:lang='zh-CN'>
  201. <voice xml:lang='zh-CN' name='${config.voice}'>
  202. <prosody rate='${rate}'>${escapeXml(text)}</prosody>
  203. </voice>
  204. </speak>
  205. `.trim();
  206. const response = await fetch(`${baseUrl}/cognitiveservices/v1`, {
  207. method: 'POST',
  208. headers: {
  209. 'Ocp-Apim-Subscription-Key': config.apiKey!,
  210. 'Content-Type': 'application/ssml+xml; charset=utf-8',
  211. 'X-Microsoft-OutputFormat': 'audio-16khz-128kbitrate-mono-mp3',
  212. },
  213. body: ssml,
  214. });
  215. if (!response.ok) {
  216. throw new Error(`Azure TTS API error: ${response.statusText}`);
  217. }
  218. const arrayBuffer = await response.arrayBuffer();
  219. return {
  220. audio: new Uint8Array(arrayBuffer),
  221. format: 'mp3',
  222. };
  223. }
  224. /**
  225. * GLM TTS implementation (GLM API)
  226. */
  227. async function generateGLMTTS(config: TTSModelConfig, text: string): Promise<TTSGenerationResult> {
  228. const baseUrl = config.baseUrl || TTS_PROVIDERS['glm-tts'].defaultBaseUrl;
  229. const response = await fetch(`${baseUrl}/audio/speech`, {
  230. method: 'POST',
  231. headers: {
  232. Authorization: `Bearer ${config.apiKey}`,
  233. 'Content-Type': 'application/json; charset=utf-8',
  234. },
  235. body: JSON.stringify({
  236. model: config.modelId || 'glm-tts',
  237. input: text,
  238. voice: config.voice,
  239. speed: config.speed || 1.0,
  240. volume: 1.0,
  241. response_format: 'wav',
  242. }),
  243. });
  244. if (!response.ok) {
  245. const errorText = await response.text().catch(() => response.statusText);
  246. let errorMessage = `GLM TTS API error: ${errorText}`;
  247. try {
  248. const errorJson = JSON.parse(errorText);
  249. if (errorJson.error?.message) {
  250. errorMessage = `GLM TTS API error: ${errorJson.error.message} (code: ${errorJson.error.code})`;
  251. }
  252. } catch {
  253. // If not JSON, use the text as is
  254. }
  255. throw new Error(errorMessage);
  256. }
  257. const arrayBuffer = await response.arrayBuffer();
  258. return {
  259. audio: new Uint8Array(arrayBuffer),
  260. format: 'wav',
  261. };
  262. }
  263. /**
  264. * Qwen TTS implementation (DashScope API - Qwen3 TTS Flash)
  265. */
  266. async function generateQwenTTS(config: TTSModelConfig, text: string): Promise<TTSGenerationResult> {
  267. const baseUrl = config.baseUrl || TTS_PROVIDERS['qwen-tts'].defaultBaseUrl;
  268. // Calculate speed: Qwen3 uses rate parameter from -500 to 500
  269. // speed 1.0 = rate 0, speed 2.0 = rate 500, speed 0.5 = rate -250
  270. const rate = Math.round(((config.speed || 1.0) - 1.0) * 500);
  271. const response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
  272. method: 'POST',
  273. headers: {
  274. Authorization: `Bearer ${config.apiKey}`,
  275. 'Content-Type': 'application/json; charset=utf-8',
  276. },
  277. body: JSON.stringify({
  278. model: config.modelId || 'qwen3-tts-flash',
  279. input: {
  280. text,
  281. voice: config.voice,
  282. language_type: 'Chinese', // Default to Chinese, can be made configurable
  283. },
  284. parameters: {
  285. rate, // Speech rate from -500 to 500
  286. },
  287. }),
  288. });
  289. if (!response.ok) {
  290. const errorText = await response.text().catch(() => response.statusText);
  291. throw new Error(`Qwen TTS API error: ${errorText}`);
  292. }
  293. const data = await response.json();
  294. // Check for audio URL in response
  295. if (!data.output?.audio?.url) {
  296. throw new Error(`Qwen TTS error: No audio URL in response. Response: ${JSON.stringify(data)}`);
  297. }
  298. // Download audio from URL
  299. const audioUrl = data.output.audio.url;
  300. const audioResponse = await fetch(audioUrl);
  301. if (!audioResponse.ok) {
  302. throw new Error(`Failed to download audio from URL: ${audioResponse.statusText}`);
  303. }
  304. const arrayBuffer = await audioResponse.arrayBuffer();
  305. return {
  306. audio: new Uint8Array(arrayBuffer),
  307. format: 'wav', // Qwen3 TTS returns WAV format
  308. };
  309. }
  310. /**
  311. * MiniMax TTS implementation (synchronous HTTP API)
  312. */
  313. async function generateMiniMaxTTS(
  314. config: TTSModelConfig,
  315. text: string,
  316. ): Promise<TTSGenerationResult> {
  317. const baseUrl = (config.baseUrl || TTS_PROVIDERS['minimax-tts'].defaultBaseUrl || '').replace(
  318. /\/$/,
  319. '',
  320. );
  321. const response = await fetch(`${baseUrl}/v1/t2a_v2`, {
  322. method: 'POST',
  323. headers: {
  324. Authorization: `Bearer ${config.apiKey}`,
  325. 'Content-Type': 'application/json; charset=utf-8',
  326. },
  327. body: JSON.stringify({
  328. model: config.modelId || 'speech-2.8-hd',
  329. text,
  330. stream: false,
  331. output_format: 'hex',
  332. voice_setting: {
  333. voice_id: config.voice,
  334. speed: config.speed || 1.0,
  335. vol: 1,
  336. pitch: 0,
  337. },
  338. audio_setting: {
  339. sample_rate: 32000,
  340. bitrate: 128000,
  341. format: config.format || 'mp3',
  342. channel: 1,
  343. },
  344. language_boost: 'auto',
  345. }),
  346. });
  347. if (!response.ok) {
  348. const errorText = await response.text().catch(() => response.statusText);
  349. throw new Error(`MiniMax TTS API error: ${errorText}`);
  350. }
  351. const data = await response.json();
  352. const hexAudio = data?.data?.audio;
  353. if (!hexAudio || typeof hexAudio !== 'string') {
  354. throw new Error(`MiniMax TTS error: No audio returned. Response: ${JSON.stringify(data)}`);
  355. }
  356. const cleanedHex = hexAudio.trim();
  357. if (cleanedHex.length % 2 !== 0) {
  358. throw new Error('MiniMax TTS error: invalid hex audio payload length');
  359. }
  360. const audio = new Uint8Array(
  361. cleanedHex.match(/.{1,2}/g)?.map((byte: string) => parseInt(byte, 16)) || [],
  362. );
  363. return {
  364. audio,
  365. format: data?.extra_info?.audio_format || config.format || 'mp3',
  366. };
  367. }
  368. /**
  369. * ElevenLabs TTS implementation (direct API call with voice-specific endpoint)
  370. */
  371. async function generateElevenLabsTTS(
  372. config: TTSModelConfig,
  373. text: string,
  374. ): Promise<TTSGenerationResult> {
  375. const baseUrl = config.baseUrl || TTS_PROVIDERS['elevenlabs-tts'].defaultBaseUrl;
  376. const requestedFormat = config.format || 'mp3';
  377. const clampedSpeed = Math.min(1.2, Math.max(0.7, config.speed || 1.0));
  378. const outputFormatMap: Record<string, string> = {
  379. mp3: 'mp3_44100_128',
  380. opus: 'opus_48000_96',
  381. pcm: 'pcm_44100',
  382. wav: 'wav_44100',
  383. ulaw: 'ulaw_8000',
  384. alaw: 'alaw_8000',
  385. };
  386. const outputFormat = outputFormatMap[requestedFormat] || outputFormatMap.mp3;
  387. const response = await fetch(
  388. `${baseUrl}/text-to-speech/${encodeURIComponent(config.voice)}?output_format=${outputFormat}`,
  389. {
  390. method: 'POST',
  391. headers: {
  392. 'xi-api-key': config.apiKey!,
  393. 'Content-Type': 'application/json; charset=utf-8',
  394. },
  395. body: JSON.stringify({
  396. text,
  397. model_id: config.modelId || 'eleven_multilingual_v2',
  398. voice_settings: {
  399. stability: 0.5,
  400. similarity_boost: 0.75,
  401. speed: clampedSpeed,
  402. },
  403. }),
  404. },
  405. );
  406. if (!response.ok) {
  407. const errorText = await response.text().catch(() => response.statusText);
  408. throw new Error(`ElevenLabs TTS API error: ${errorText || response.statusText}`);
  409. }
  410. const arrayBuffer = await response.arrayBuffer();
  411. return {
  412. audio: new Uint8Array(arrayBuffer),
  413. format: requestedFormat,
  414. };
  415. }
  416. /**
  417. * Get current TTS configuration from settings store
  418. * Note: This function should only be called in browser context
  419. */
  420. export async function getCurrentTTSConfig(): Promise<TTSModelConfig> {
  421. if (typeof window === 'undefined') {
  422. throw new Error('getCurrentTTSConfig() can only be called in browser context');
  423. }
  424. // Lazy import to avoid circular dependency
  425. const { useSettingsStore } = await import('@/lib/store/settings');
  426. const { ttsProviderId, ttsVoice, ttsSpeed, ttsProvidersConfig } = useSettingsStore.getState();
  427. const providerConfig = ttsProvidersConfig?.[ttsProviderId];
  428. return {
  429. providerId: ttsProviderId,
  430. modelId: providerConfig?.modelId || TTS_PROVIDERS[ttsProviderId]?.defaultModelId || '',
  431. apiKey: providerConfig?.apiKey,
  432. baseUrl: providerConfig?.baseUrl,
  433. voice: ttsVoice,
  434. speed: ttsSpeed,
  435. };
  436. }
  437. // Re-export from constants for convenience
  438. export { getAllTTSProviders, getTTSProvider, getTTSVoices } from './constants';
  439. /**
  440. * Doubao TTS 2.0 implementation (Volcengine Seed-TTS 2.0)
  441. */
  442. async function generateDoubaoTTS(
  443. config: TTSModelConfig,
  444. text: string,
  445. ): Promise<TTSGenerationResult> {
  446. const colonIdx = (config.apiKey || '').indexOf(':');
  447. if (colonIdx <= 0) {
  448. throw new Error(
  449. 'Doubao TTS requires API key in format "appId:accessKey". Get both from the Volcengine console.',
  450. );
  451. }
  452. const appId = config.apiKey!.slice(0, colonIdx);
  453. const accessKey = config.apiKey!.slice(colonIdx + 1);
  454. const baseUrl = config.baseUrl || TTS_PROVIDERS['doubao-tts'].defaultBaseUrl;
  455. const speechRate = Math.round(((config.speed || 1.0) - 1.0) * 100);
  456. const response = await fetch(`${baseUrl}/unidirectional`, {
  457. method: 'POST',
  458. headers: {
  459. 'Content-Type': 'application/json',
  460. 'X-Api-App-Id': appId,
  461. 'X-Api-Access-Key': accessKey,
  462. 'X-Api-Resource-Id': 'seed-tts-2.0',
  463. },
  464. body: JSON.stringify({
  465. user: { uid: 'openmaic' },
  466. req_params: {
  467. text,
  468. speaker: config.voice,
  469. audio_params: { format: 'mp3', sample_rate: 24000, speech_rate: speechRate },
  470. },
  471. }),
  472. });
  473. if (!response.ok) {
  474. const errorText = await response.text().catch(() => response.statusText);
  475. throw new Error(`Doubao TTS API error (${response.status}): ${errorText}`);
  476. }
  477. const responseText = await response.text();
  478. const audioChunks: Uint8Array[] = [];
  479. let depth = 0;
  480. let start = -1;
  481. for (let i = 0; i < responseText.length; i++) {
  482. if (responseText[i] === '{') {
  483. if (depth === 0) start = i;
  484. depth++;
  485. } else if (responseText[i] === '}') {
  486. depth--;
  487. if (depth === 0 && start >= 0) {
  488. let chunk: { code: number; message?: string; data?: string };
  489. try {
  490. chunk = JSON.parse(responseText.slice(start, i + 1));
  491. } catch {
  492. start = -1;
  493. continue;
  494. }
  495. start = -1;
  496. if (chunk.code === 0 && chunk.data) {
  497. audioChunks.push(new Uint8Array(Buffer.from(chunk.data, 'base64')));
  498. } else if (chunk.code === 20000000) {
  499. break;
  500. } else if (chunk.code && chunk.code !== 0) {
  501. if (chunk.code === 45000000 || chunk.code === 45000292) {
  502. throw new TTSRateLimitError(
  503. 'doubao-tts',
  504. chunk.message || 'concurrency quota exceeded',
  505. );
  506. }
  507. throw new Error(`Doubao TTS error: ${chunk.message || 'unknown'} (code: ${chunk.code})`);
  508. }
  509. }
  510. }
  511. }
  512. if (audioChunks.length === 0) {
  513. throw new Error('Doubao TTS: no audio data received');
  514. }
  515. const totalLength = audioChunks.reduce((sum, c) => sum + c.length, 0);
  516. const combined = new Uint8Array(totalLength);
  517. let offset = 0;
  518. for (const chunk of audioChunks) {
  519. combined.set(chunk, offset);
  520. offset += chunk.length;
  521. }
  522. return { audio: combined, format: 'mp3' };
  523. }
  524. /**
  525. * Escape XML special characters for SSML
  526. */
  527. function escapeXml(text: string): string {
  528. return text
  529. .replace(/&/g, '&amp;')
  530. .replace(/</g, '&lt;')
  531. .replace(/>/g, '&gt;')
  532. .replace(/"/g, '&quot;')
  533. .replace(/'/g, '&apos;');
  534. }