use-audio-recorder.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323
  1. import { useState, useRef, useCallback } from 'react';
  2. import { ASR_PROVIDERS } from '@/lib/audio/constants';
  3. import { createLogger } from '@/lib/logger';
  4. const log = createLogger('AudioRecorder');
  5. // TypeScript declarations for Web Speech API
  6. declare global {
  7. interface Window {
  8. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API not typed in lib.dom
  9. SpeechRecognition: any;
  10. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API not typed in lib.dom
  11. webkitSpeechRecognition: any;
  12. }
  13. }
  14. export interface UseAudioRecorderOptions {
  15. onTranscription?: (text: string) => void;
  16. onError?: (error: string) => void;
  17. }
  18. export function useAudioRecorder(options: UseAudioRecorderOptions = {}) {
  19. const { onTranscription, onError } = options;
  20. const [isRecording, setIsRecording] = useState(false);
  21. const [isProcessing, setIsProcessing] = useState(false);
  22. const [recordingTime, setRecordingTime] = useState(0);
  23. const mediaRecorderRef = useRef<MediaRecorder | null>(null);
  24. const audioChunksRef = useRef<Blob[]>([]);
  25. const timerRef = useRef<NodeJS.Timeout | null>(null);
  26. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API not typed
  27. const speechRecognitionRef = useRef<any>(null);
  28. // Synchronous lock to prevent rapid re-entry (React state updates are async)
  29. const busyRef = useRef(false);
  30. // Send audio to server for transcription
  31. const transcribeAudio = useCallback(
  32. async (audioBlob: Blob) => {
  33. setIsProcessing(true);
  34. try {
  35. const formData = new FormData();
  36. formData.append('audio', audioBlob, 'recording.webm');
  37. // Get current ASR configuration from settings store
  38. // Note: This requires importing useSettingsStore in browser context
  39. if (typeof window !== 'undefined') {
  40. const { useSettingsStore } = await import('@/lib/store/settings');
  41. const { asrProviderId, asrLanguage, asrProvidersConfig } = useSettingsStore.getState();
  42. formData.append('providerId', asrProviderId);
  43. formData.append(
  44. 'modelId',
  45. asrProvidersConfig?.[asrProviderId]?.modelId ||
  46. ASR_PROVIDERS[asrProviderId]?.defaultModelId ||
  47. '',
  48. );
  49. formData.append('language', asrLanguage);
  50. // Append API key and base URL if configured
  51. const providerConfig = asrProvidersConfig?.[asrProviderId];
  52. if (providerConfig?.apiKey?.trim()) {
  53. formData.append('apiKey', providerConfig.apiKey);
  54. }
  55. if (providerConfig?.baseUrl?.trim()) {
  56. formData.append('baseUrl', providerConfig.baseUrl);
  57. }
  58. }
  59. const response = await fetch('/api/transcription', {
  60. method: 'POST',
  61. body: formData,
  62. });
  63. if (!response.ok) {
  64. const error = await response.json();
  65. throw new Error(error.error || 'Transcription failed');
  66. }
  67. const result = await response.json();
  68. onTranscription?.(result.text);
  69. } catch (error) {
  70. log.error('Transcription error:', error);
  71. onError?.(error instanceof Error ? error.message : '语音识别失败,请重试');
  72. } finally {
  73. setIsProcessing(false);
  74. setRecordingTime(0);
  75. }
  76. },
  77. [onTranscription, onError],
  78. );
  79. // Start recording
  80. const startRecording = useCallback(async () => {
  81. // Synchronous lock — React state is async so isRecording may be stale
  82. if (busyRef.current) return;
  83. busyRef.current = true;
  84. try {
  85. // Get current ASR configuration
  86. if (typeof window !== 'undefined') {
  87. const { useSettingsStore } = await import('@/lib/store/settings');
  88. const { asrProviderId, asrLanguage } = useSettingsStore.getState();
  89. // Use browser native ASR if configured
  90. if (asrProviderId === 'browser-native') {
  91. // Check if Speech Recognition is supported
  92. if (!window.SpeechRecognition && !window.webkitSpeechRecognition) {
  93. onError?.('您的浏览器不支持语音识别功能');
  94. return;
  95. }
  96. const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  97. const recognition = new SpeechRecognition();
  98. recognition.lang = asrLanguage || 'zh-CN';
  99. recognition.continuous = false;
  100. recognition.interimResults = false;
  101. recognition.onstart = () => {
  102. setIsRecording(true);
  103. setRecordingTime(0);
  104. // Start timer
  105. timerRef.current = setInterval(() => {
  106. setRecordingTime((prev) => prev + 1);
  107. }, 1000);
  108. };
  109. recognition.onresult = (event: {
  110. results: {
  111. [index: number]: { [index: number]: { transcript: string } };
  112. };
  113. }) => {
  114. const transcript = event.results[0][0].transcript;
  115. onTranscription?.(transcript);
  116. };
  117. recognition.onerror = (event: { error: string }) => {
  118. log.error('Speech recognition error:', event.error);
  119. let errorMessage = '语音识别失败';
  120. switch (event.error) {
  121. case 'aborted':
  122. // Non-fatal: caused by our own cancel/stop logic or rapid toggle
  123. busyRef.current = false;
  124. setIsRecording(false);
  125. setRecordingTime(0);
  126. if (timerRef.current) {
  127. clearInterval(timerRef.current);
  128. timerRef.current = null;
  129. }
  130. return;
  131. case 'no-speech':
  132. errorMessage = '未检测到语音输入';
  133. break;
  134. case 'audio-capture':
  135. errorMessage = '无法访问麦克风';
  136. break;
  137. case 'not-allowed':
  138. errorMessage = '麦克风权限被拒绝';
  139. break;
  140. case 'network':
  141. errorMessage = '网络错误';
  142. break;
  143. default:
  144. errorMessage = `语音识别错误: ${event.error}`;
  145. }
  146. onError?.(errorMessage);
  147. busyRef.current = false;
  148. setIsRecording(false);
  149. setRecordingTime(0);
  150. if (timerRef.current) {
  151. clearInterval(timerRef.current);
  152. timerRef.current = null;
  153. }
  154. };
  155. recognition.onend = () => {
  156. busyRef.current = false;
  157. setIsRecording(false);
  158. setRecordingTime(0);
  159. if (timerRef.current) {
  160. clearInterval(timerRef.current);
  161. timerRef.current = null;
  162. }
  163. };
  164. recognition.start();
  165. speechRecognitionRef.current = recognition;
  166. return;
  167. }
  168. }
  169. // Use MediaRecorder for server-side ASR
  170. // Request microphone permission
  171. const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
  172. // Create MediaRecorder
  173. const mediaRecorder = new MediaRecorder(stream, {
  174. mimeType: 'audio/webm',
  175. });
  176. mediaRecorderRef.current = mediaRecorder;
  177. audioChunksRef.current = [];
  178. mediaRecorder.ondataavailable = (event) => {
  179. if (event.data.size > 0) {
  180. audioChunksRef.current.push(event.data);
  181. }
  182. };
  183. mediaRecorder.onstop = async () => {
  184. // Stop all audio tracks
  185. stream.getTracks().forEach((track) => track.stop());
  186. // Merge audio chunks
  187. const audioBlob = new Blob(audioChunksRef.current, {
  188. type: 'audio/webm',
  189. });
  190. // Send to server for transcription
  191. await transcribeAudio(audioBlob);
  192. busyRef.current = false;
  193. };
  194. // Start recording
  195. mediaRecorder.start();
  196. setIsRecording(true);
  197. setRecordingTime(0);
  198. // Start timer
  199. timerRef.current = setInterval(() => {
  200. setRecordingTime((prev) => prev + 1);
  201. }, 1000);
  202. } catch (error) {
  203. busyRef.current = false;
  204. log.error('Failed to start recording:', error);
  205. onError?.('无法访问麦克风,请检查权限设置');
  206. }
  207. }, [onTranscription, onError, transcribeAudio]);
  208. // Stop recording
  209. const stopRecording = useCallback(() => {
  210. // Stop Speech Recognition if active
  211. if (speechRecognitionRef.current) {
  212. speechRecognitionRef.current.stop();
  213. speechRecognitionRef.current = null;
  214. busyRef.current = false;
  215. setIsRecording(false);
  216. if (timerRef.current) {
  217. clearInterval(timerRef.current);
  218. timerRef.current = null;
  219. }
  220. return;
  221. }
  222. // Stop MediaRecorder if active
  223. if (mediaRecorderRef.current && isRecording) {
  224. mediaRecorderRef.current.stop();
  225. busyRef.current = false;
  226. setIsRecording(false);
  227. if (timerRef.current) {
  228. clearInterval(timerRef.current);
  229. timerRef.current = null;
  230. }
  231. }
  232. }, [isRecording]);
  233. // Cancel recording
  234. const cancelRecording = useCallback(() => {
  235. // Cancel Speech Recognition if active
  236. if (speechRecognitionRef.current) {
  237. speechRecognitionRef.current.onresult = null; // Prevent transcription callback
  238. speechRecognitionRef.current.onerror = null; // Suppress browser abort error events
  239. speechRecognitionRef.current.stop();
  240. speechRecognitionRef.current = null;
  241. busyRef.current = false;
  242. setIsRecording(false);
  243. setRecordingTime(0);
  244. if (timerRef.current) {
  245. clearInterval(timerRef.current);
  246. timerRef.current = null;
  247. }
  248. return;
  249. }
  250. // Cancel MediaRecorder if active
  251. if (mediaRecorderRef.current && isRecording) {
  252. // Stop recording without transcription
  253. mediaRecorderRef.current.ondataavailable = null;
  254. mediaRecorderRef.current.onstop = null;
  255. mediaRecorderRef.current.stop();
  256. // Stop all audio tracks
  257. if (mediaRecorderRef.current.stream) {
  258. mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop());
  259. }
  260. busyRef.current = false;
  261. setIsRecording(false);
  262. setRecordingTime(0);
  263. if (timerRef.current) {
  264. clearInterval(timerRef.current);
  265. timerRef.current = null;
  266. }
  267. audioChunksRef.current = [];
  268. }
  269. }, [isRecording]);
  270. return {
  271. isRecording,
  272. isProcessing,
  273. recordingTime,
  274. startRecording,
  275. stopRecording,
  276. cancelRecording,
  277. };
  278. }