asr-providers.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. /**
  2. * ASR (Automatic Speech Recognition) Provider Implementation
  3. *
  4. * Factory pattern for routing ASR requests to appropriate provider implementations.
  5. * Follows the same architecture as lib/ai/providers.ts for consistency.
  6. *
  7. * Currently Supported Providers:
  8. * - OpenAI Whisper: https://platform.openai.com/docs/guides/speech-to-text
  9. * - Browser Native: Web Speech API (https://developer.mozilla.org/en-US/docs/Web/API/Web_Speech_API)
  10. * - Qwen ASR: https://bailian.console.aliyun.com/
  11. *
  12. * HOW TO ADD A NEW PROVIDER:
  13. *
  14. * 1. Add provider ID to ASRProviderId in lib/audio/types.ts
  15. * Example: | 'assemblyai-asr'
  16. *
  17. * 2. Add provider configuration to lib/audio/constants.ts
  18. * Example:
  19. * 'assemblyai-asr': {
  20. * id: 'assemblyai-asr',
  21. * name: 'AssemblyAI',
  22. * requiresApiKey: true,
  23. * defaultBaseUrl: 'https://api.assemblyai.com/v2',
  24. * icon: '/assemblyai.svg',
  25. * supportedLanguages: ['en', 'es', 'fr', 'de', 'auto'],
  26. * supportedFormats: ['mp3', 'wav', 'flac', 'm4a']
  27. * }
  28. *
  29. * 3. Implement provider function in this file
  30. * Pattern: async function transcribeXxxASR(config, audioBuffer): Promise<ASRTranscriptionResult>
  31. * - Handle Buffer/Blob conversion (see helper patterns below)
  32. * - Build API request with audio data (FormData or base64)
  33. * - Handle API authentication (apiKey, headers)
  34. * - Convert language codes if needed
  35. * - Return { text: string }
  36. *
  37. * Example:
  38. * async function transcribeAssemblyAIASR(
  39. * config: ASRModelConfig,
  40. * audioBuffer: Buffer | Blob
  41. * ): Promise<ASRTranscriptionResult> {
  42. * const baseUrl = config.baseUrl || ASR_PROVIDERS['assemblyai-asr'].defaultBaseUrl;
  43. *
  44. * // Step 1: Upload audio file
  45. * let blob: Blob;
  46. * if (audioBuffer instanceof Buffer) {
  47. * blob = new Blob([audioBuffer.buffer.slice(
  48. * audioBuffer.byteOffset,
  49. * audioBuffer.byteOffset + audioBuffer.byteLength
  50. * ) as ArrayBuffer], { type: 'audio/webm' });
  51. * } else {
  52. * blob = audioBuffer;
  53. * }
  54. *
  55. * const uploadResponse = await fetch(`${baseUrl}/upload`, {
  56. * method: 'POST',
  57. * headers: {
  58. * 'authorization': config.apiKey!,
  59. * },
  60. * body: blob,
  61. * });
  62. *
  63. * if (!uploadResponse.ok) {
  64. * throw new Error(`AssemblyAI upload error: ${uploadResponse.statusText}`);
  65. * }
  66. *
  67. * const { upload_url } = await uploadResponse.json();
  68. *
  69. * // Step 2: Request transcription
  70. * const transcriptResponse = await fetch(`${baseUrl}/transcript`, {
  71. * method: 'POST',
  72. * headers: {
  73. * 'authorization': config.apiKey!,
  74. * 'Content-Type': 'application/json',
  75. * },
  76. * body: JSON.stringify({
  77. * audio_url: upload_url,
  78. * language_code: config.language === 'auto' ? undefined : config.language,
  79. * }),
  80. * });
  81. *
  82. * const { id } = await transcriptResponse.json();
  83. *
  84. * // Step 3: Poll for completion
  85. * while (true) {
  86. * const statusResponse = await fetch(`${baseUrl}/transcript/${id}`, {
  87. * headers: { 'authorization': config.apiKey! },
  88. * });
  89. * const result = await statusResponse.json();
  90. *
  91. * if (result.status === 'completed') {
  92. * return { text: result.text || '' };
  93. * } else if (result.status === 'error') {
  94. * throw new Error(`AssemblyAI error: ${result.error}`);
  95. * }
  96. *
  97. * await new Promise(resolve => setTimeout(resolve, 1000));
  98. * }
  99. * }
  100. *
  101. * 4. Add case to transcribeAudio() switch statement
  102. * case 'assemblyai-asr':
  103. * return await transcribeAssemblyAIASR(config, audioBuffer);
  104. *
  105. * 5. Add i18n translations in lib/i18n.ts
  106. * providerAssemblyAIASR: { zh: 'AssemblyAI 语音识别', en: 'AssemblyAI ASR' }
  107. *
  108. * Buffer/Blob Conversion Patterns:
  109. *
  110. * Pattern 1: Buffer to Blob (for FormData)
  111. * const blob = new Blob([
  112. * audioBuffer.buffer.slice(audioBuffer.byteOffset, audioBuffer.byteOffset + audioBuffer.byteLength) as ArrayBuffer
  113. * ], { type: 'audio/webm' });
  114. *
  115. * Pattern 2: Buffer to base64 (for JSON API)
  116. * let base64Audio: string;
  117. * if (audioBuffer instanceof Buffer) {
  118. * base64Audio = audioBuffer.toString('base64');
  119. * } else {
  120. * const arrayBuffer = await audioBuffer.arrayBuffer();
  121. * base64Audio = Buffer.from(arrayBuffer).toString('base64');
  122. * }
  123. *
  124. * Pattern 3: Buffer/Blob to File (for Vercel AI SDK)
  125. * let audioFile: File;
  126. * if (audioBuffer instanceof Buffer) {
  127. * const arrayBuffer = audioBuffer.buffer.slice(...) as ArrayBuffer;
  128. * const blob = new Blob([arrayBuffer], { type: 'audio/webm' });
  129. * audioFile = new File([blob], 'audio.webm', { type: 'audio/webm' });
  130. * } else {
  131. * audioFile = new File([audioBuffer], 'audio.webm', { type: 'audio/webm' });
  132. * }
  133. *
  134. * Error Handling Patterns:
  135. * - Always validate API key if requiresApiKey is true
  136. * - Throw descriptive errors for API failures
  137. * - Include response.statusText or error messages from API
  138. * - For client-only providers (browser-native), throw error directing to client-side usage
  139. * - Handle polling/async APIs with proper timeout and error checking
  140. *
  141. * API Call Patterns:
  142. * - Vercel AI SDK: Use createOpenAI + transcribe (OpenAI, compatible providers)
  143. * - FormData: For providers expecting multipart/form-data (most providers)
  144. * - Base64: For providers expecting JSON with base64 audio (Qwen, DashScope)
  145. * - Upload + Poll: For async providers (AssemblyAI, Deepgram batch)
  146. */
  147. import { createOpenAI } from '@ai-sdk/openai';
  148. import { experimental_transcribe as transcribe } from 'ai';
  149. import type { ASRModelConfig } from './types';
  150. import { ASR_PROVIDERS } from './constants';
  151. /**
  152. * Result of ASR transcription
  153. */
  154. export interface ASRTranscriptionResult {
  155. text: string;
  156. }
  157. /**
  158. * Transcribe audio using specified ASR provider
  159. */
  160. export async function transcribeAudio(
  161. config: ASRModelConfig,
  162. audioBuffer: Buffer | Blob,
  163. ): Promise<ASRTranscriptionResult> {
  164. const provider = ASR_PROVIDERS[config.providerId];
  165. if (!provider) {
  166. throw new Error(`Unknown ASR provider: ${config.providerId}`);
  167. }
  168. // Validate API key if required
  169. if (provider.requiresApiKey && !config.apiKey) {
  170. throw new Error(`API key required for ASR provider: ${config.providerId}`);
  171. }
  172. switch (config.providerId) {
  173. case 'openai-whisper':
  174. return await transcribeOpenAIWhisper(config, audioBuffer);
  175. case 'browser-native':
  176. throw new Error('Browser Native ASR must be handled client-side using useBrowserASR hook');
  177. case 'qwen-asr':
  178. return await transcribeQwenASR(config, audioBuffer);
  179. default:
  180. throw new Error(`Unsupported ASR provider: ${config.providerId}`);
  181. }
  182. }
  183. /**
  184. * OpenAI Whisper implementation (using Vercel AI SDK)
  185. */
  186. async function transcribeOpenAIWhisper(
  187. config: ASRModelConfig,
  188. audioBuffer: Buffer | Blob,
  189. ): Promise<ASRTranscriptionResult> {
  190. const openai = createOpenAI({
  191. apiKey: config.apiKey!,
  192. baseURL: config.baseUrl || ASR_PROVIDERS['openai-whisper'].defaultBaseUrl,
  193. });
  194. // Convert to Buffer or Uint8Array (which is required by the AI SDK)
  195. let audioData: Buffer | Uint8Array;
  196. if (audioBuffer instanceof Buffer) {
  197. audioData = audioBuffer;
  198. } else if (audioBuffer instanceof Blob) {
  199. const arrayBuffer = await audioBuffer.arrayBuffer();
  200. audioData = new Uint8Array(arrayBuffer);
  201. } else {
  202. throw new Error('Invalid audio buffer type');
  203. }
  204. try {
  205. const result = await transcribe({
  206. model: openai.transcription(config.modelId || 'gpt-4o-mini-transcribe'),
  207. audio: audioData,
  208. providerOptions: {
  209. openai: {
  210. language: config.language === 'auto' ? undefined : config.language,
  211. },
  212. },
  213. });
  214. return { text: result.text || '' };
  215. } catch (error: unknown) {
  216. // Short/silent audio may cause the SDK to throw — treat as empty transcription
  217. const errMsg = error instanceof Error ? error.message : '';
  218. if (errMsg.includes('empty') || errMsg.includes('too short')) {
  219. return { text: '' };
  220. }
  221. throw error;
  222. }
  223. }
  224. /**
  225. * Qwen ASR implementation (DashScope API - Qwen3 ASR Flash)
  226. */
  227. async function transcribeQwenASR(
  228. config: ASRModelConfig,
  229. audioBuffer: Buffer | Blob,
  230. ): Promise<ASRTranscriptionResult> {
  231. const baseUrl = config.baseUrl || ASR_PROVIDERS['qwen-asr'].defaultBaseUrl;
  232. // Convert audio to base64
  233. let base64Audio: string;
  234. if (audioBuffer instanceof Buffer) {
  235. base64Audio = audioBuffer.toString('base64');
  236. } else if (audioBuffer instanceof Blob) {
  237. const arrayBuffer = await audioBuffer.arrayBuffer();
  238. base64Audio = Buffer.from(arrayBuffer).toString('base64');
  239. } else {
  240. throw new Error('Invalid audio buffer type');
  241. }
  242. // Build request body
  243. const requestBody: Record<string, unknown> = {
  244. model: config.modelId || 'qwen3-asr-flash',
  245. input: {
  246. messages: [
  247. {
  248. role: 'user',
  249. content: [
  250. {
  251. audio: `data:audio/wav;base64,${base64Audio}`,
  252. },
  253. ],
  254. },
  255. ],
  256. },
  257. };
  258. // Add language parameter in asr_options if specified (optional - improves accuracy for known languages)
  259. // If language is uncertain or mixed, don't specify (auto-detect)
  260. if (config.language && config.language !== 'auto') {
  261. requestBody.parameters = {
  262. asr_options: {
  263. language: config.language,
  264. },
  265. };
  266. }
  267. const response = await fetch(`${baseUrl}/services/aigc/multimodal-generation/generation`, {
  268. method: 'POST',
  269. headers: {
  270. Authorization: `Bearer ${config.apiKey}`,
  271. 'Content-Type': 'application/json; charset=utf-8',
  272. 'X-DashScope-Audio-Format': 'wav',
  273. },
  274. body: JSON.stringify(requestBody),
  275. });
  276. if (!response.ok) {
  277. const errorText = await response.text().catch(() => response.statusText);
  278. // "The audio is empty" — treat as no speech detected
  279. if (errorText.includes('audio is empty') || errorText.includes('InvalidParameter')) {
  280. return { text: '' };
  281. }
  282. throw new Error(`Qwen ASR API error: ${errorText}`);
  283. }
  284. const data = await response.json();
  285. // Check for transcription result in response
  286. // Qwen3 ASR returns OpenAI-compatible format:
  287. // { output: { choices: [{ message: { content: [{ text: "transcribed text" }] } }] } }
  288. if (
  289. !data.output?.choices ||
  290. !Array.isArray(data.output.choices) ||
  291. data.output.choices.length === 0
  292. ) {
  293. throw new Error(`Qwen ASR error: No choices in response. Response: ${JSON.stringify(data)}`);
  294. }
  295. const firstChoice = data.output.choices[0];
  296. const messageContent = firstChoice?.message?.content;
  297. if (!Array.isArray(messageContent) || messageContent.length === 0) {
  298. // Empty content typically means audio was too short or contained no speech
  299. return { text: '' };
  300. }
  301. // Extract text from first content item
  302. const transcribedText = messageContent[0]?.text || '';
  303. return { text: transcribedText };
  304. }
  305. /**
  306. * Get current ASR configuration from settings store
  307. * Note: This function should only be called in browser context
  308. */
  309. export async function getCurrentASRConfig(): Promise<ASRModelConfig> {
  310. if (typeof window === 'undefined') {
  311. throw new Error('getCurrentASRConfig() can only be called in browser context');
  312. }
  313. // Lazy import to avoid circular dependency
  314. const { useSettingsStore } = await import('@/lib/store/settings');
  315. const { asrProviderId, asrLanguage, asrProvidersConfig } = useSettingsStore.getState();
  316. const providerConfig = asrProvidersConfig?.[asrProviderId];
  317. return {
  318. providerId: asrProviderId,
  319. modelId: providerConfig?.modelId || ASR_PROVIDERS[asrProviderId]?.defaultModelId || '',
  320. apiKey: providerConfig?.apiKey,
  321. baseUrl: providerConfig?.baseUrl,
  322. language: asrLanguage,
  323. };
  324. }
  325. // Re-export from constants for convenience
  326. export { getAllASRProviders, getASRProvider, getASRSupportedLanguages } from './constants';