use-browser-asr.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. /**
  2. * Browser Native ASR (Speech Recognition) Hook
  3. * Uses Web Speech API for client-side speech recognition
  4. * Completely free, no API key required
  5. */
  6. import { useState, useCallback, useRef, useEffect } from 'react';
  7. import { createLogger } from '@/lib/logger';
  8. const log = createLogger('BrowserASR');
  9. // Note: Window.SpeechRecognition declaration is in components/ai-elements/prompt-input.tsx
  10. export type ASRErrorCode =
  11. | 'not-supported'
  12. | 'no-speech'
  13. | 'audio-capture'
  14. | 'not-allowed'
  15. | 'network'
  16. | 'aborted'
  17. | 'unknown';
  18. export interface UseBrowserASROptions {
  19. onTranscription?: (text: string) => void;
  20. onError?: (errorCode: ASRErrorCode) => void;
  21. language?: string;
  22. continuous?: boolean;
  23. interimResults?: boolean;
  24. }
  25. export function useBrowserASR(options: UseBrowserASROptions = {}) {
  26. const {
  27. onTranscription,
  28. onError,
  29. language = 'zh-CN',
  30. continuous = false,
  31. interimResults = false,
  32. } = options;
  33. const [isListening, setIsListening] = useState(false);
  34. const [interimTranscript, setInterimTranscript] = useState('');
  35. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Web Speech API SpeechRecognition not typed
  36. const recognitionRef = useRef<any>(null);
  37. // Use refs for callbacks to avoid stale closures in recognition event handlers
  38. const onTranscriptionRef = useRef(onTranscription);
  39. const onErrorRef = useRef(onError);
  40. useEffect(() => {
  41. onTranscriptionRef.current = onTranscription;
  42. onErrorRef.current = onError;
  43. }, [onTranscription, onError]);
  44. // SSR-safe support detection
  45. const [isSupported] = useState(
  46. () =>
  47. typeof window !== 'undefined' &&
  48. !!(window.SpeechRecognition || window.webkitSpeechRecognition),
  49. );
  50. const startListening = useCallback(() => {
  51. // Check if Speech Recognition is supported
  52. if (
  53. typeof window === 'undefined' ||
  54. (!window.SpeechRecognition && !window.webkitSpeechRecognition)
  55. ) {
  56. onErrorRef.current?.('not-supported');
  57. return;
  58. }
  59. // Create Speech Recognition instance
  60. const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
  61. const recognition = new SpeechRecognition();
  62. recognition.lang = language;
  63. recognition.continuous = continuous;
  64. recognition.interimResults = interimResults;
  65. recognition.onstart = () => {
  66. setIsListening(true);
  67. setInterimTranscript('');
  68. };
  69. recognition.onresult = (event: {
  70. resultIndex: number;
  71. results: {
  72. [index: number]: {
  73. [index: number]: { transcript: string };
  74. isFinal: boolean;
  75. };
  76. length: number;
  77. };
  78. }) => {
  79. let finalTranscript = '';
  80. let interimText = '';
  81. for (let i = event.resultIndex; i < event.results.length; i++) {
  82. const transcript = event.results[i][0].transcript;
  83. if (event.results[i].isFinal) {
  84. finalTranscript += transcript;
  85. } else {
  86. interimText += transcript;
  87. }
  88. }
  89. if (interimResults) {
  90. setInterimTranscript(interimText);
  91. }
  92. if (finalTranscript) {
  93. onTranscriptionRef.current?.(finalTranscript);
  94. setInterimTranscript('');
  95. }
  96. };
  97. recognition.onerror = (event: { error: string }) => {
  98. log.error('Speech recognition error:', event.error);
  99. const errorCodeMap: Record<string, ASRErrorCode> = {
  100. 'no-speech': 'no-speech',
  101. 'audio-capture': 'audio-capture',
  102. 'not-allowed': 'not-allowed',
  103. network: 'network',
  104. aborted: 'aborted',
  105. };
  106. onErrorRef.current?.(errorCodeMap[event.error] ?? 'unknown');
  107. setIsListening(false);
  108. setInterimTranscript('');
  109. };
  110. recognition.onend = () => {
  111. setIsListening(false);
  112. setInterimTranscript('');
  113. };
  114. recognition.start();
  115. recognitionRef.current = recognition;
  116. }, [language, continuous, interimResults]);
  117. const stopListening = useCallback(() => {
  118. if (recognitionRef.current) {
  119. recognitionRef.current.stop();
  120. recognitionRef.current = null;
  121. setIsListening(false);
  122. setInterimTranscript('');
  123. }
  124. }, []);
  125. return {
  126. isSupported,
  127. isListening,
  128. interimTranscript,
  129. startListening,
  130. stopListening,
  131. };
  132. }