UNPKG

xfyun-sdk

Version:

科大讯飞语音识别 SDK,支持浏览器中实时语音听写功能

1,698 lines (1,689 loc) 88.2 kB
'use strict'; var CryptoJS = require('crypto-js'); var React = require('react'); function _interopNamespaceDefault(e) { var n = Object.create(null); if (e) { Object.keys(e).forEach(function (k) { if (k !== 'default') { var d = Object.getOwnPropertyDescriptor(e, k); Object.defineProperty(n, k, d.get ? d : { enumerable: true, get: function () { return e[k]; } }); } }); } n.default = e; return Object.freeze(n); } var CryptoJS__namespace = /*#__PURE__*/_interopNamespaceDefault(CryptoJS); /** * 判断是否在浏览器环境 */ function isBrowser() { return typeof window !== 'undefined' && typeof window.btoa === 'function'; } /** * 将字符串转换为 Base64 (浏览器/Node.js 兼容) * @remarks * 此函数处理 Unicode 字符,确保在不同环境下都能正确编码 */ function toBase64(str, encoding = 'utf-8') { if (isBrowser()) { // btoa 不能直接处理 Unicode 字符串,需要先转换 const encoded = encoding === 'utf-8' ? window.btoa(window.unescape(encodeURIComponent(str))) : window.atob(str); return encoded; } // Node.js 环境 return Buffer.from(str, encoding).toString('base64'); } /** * 生成科大讯飞API请求URL * @param apiKey 接口密钥 * @param apiSecret 接口密钥对应的secret * @param host 请求的服务器地址 * @param path API 路径,默认 /v2/iat * @returns 带有签名的完整URL */ function generateAuthUrl(apiKey, apiSecret, host = 'iat-api.xfyun.cn', path = '/v2/iat') { const url = 'wss://' + host + path; const date = new Date().toUTCString(); const algorithm = 'hmac-sha256'; // 生成签名 const signatureOrigin = `host: ${host}\ndate: ${date}\nGET ${path} HTTP/1.1`; const signatureSha = CryptoJS__namespace.HmacSHA256(signatureOrigin, apiSecret); const signature = CryptoJS__namespace.enc.Base64.stringify(signatureSha); // 生成授权字符串 const authorizationOrigin = `api_key="${apiKey}", algorithm="${algorithm}", headers="host date request-line", signature="${signature}"`; const authorization = isBrowser() ? window.btoa(window.unescape(encodeURIComponent(authorizationOrigin))) : Buffer.from(authorizationOrigin).toString('base64'); // 拼接请求URL,确保使用encodeURIComponent进行更安全的编码 return `${url}?authorization=${encodeURIComponent(authorization)}&date=${encodeURIComponent(date)}&host=${encodeURIComponent(host)}`; } /** * 检测浏览器是否支持 MediaRecorder API * @returns 支持的 MIME 类型字符串 */ function detectSupportedMimeType() { // Check browser environment if (typeof MediaRecorder === 'undefined') { return 'audio/webm'; // Fallback for Node.js and other environments } // Check if MediaRecorder.isTypeSupported is available if (typeof MediaRecorder.isTypeSupported !== 'function') { return 'audio/webm'; // Fallback when method not available } const mimeTypes = [ 'audio/webm', 'audio/webm;codecs=opus', 'audio/ogg;codecs=opus', ]; for (const type of mimeTypes) { if (MediaRecorder.isTypeSupported(type)) { return type; } } // Fallback return 'audio/webm'; } /** * 创建 AudioContext(兼容 webkit 前缀) * @param sampleRate 可选采样率 * @returns AudioContext 实例 * * @warning ⚠️ 重要资源管理提示 * * AudioContext 是重要的浏览器资源,调用方必须在不再使用时显式调用 `close()` 方法: * * ```typescript * const audioContext = createAudioContext(16000); * * // ... 使用 audioContext ... * * // 使用完毕后务必调用 * audioContext.close(); * * // 最佳实践:在组件销毁或清理时调用 * function cleanup() { * if (audioContext) { * audioContext.close(); * audioContext = null; * } * } * ``` * * 未能正确关闭 AudioContext 可能导致: * - 浏览器音频设备无法释放 * - 内存泄漏 * - 其他音频应用无法使用音频设备 * * 在 xfyun-sdk 中,所有使用 createAudioContext 的类都在 `destroy()` 方法中正确调用了 `close()`。 */ function createAudioContext(sampleRate) { const AudioContextClass = window.webkitAudioContext || window.AudioContext; return sampleRate ? new AudioContextClass({ sampleRate }) : new AudioContextClass(); } /** * 计算音频音量 * @param array 音频数据 * @returns 音量值 */ function calculateVolume(array) { let sum = 0; for (let i = 0; i < array.length; i++) { sum += array[i] * array[i]; } return Math.sqrt(sum / array.length) * 100; } /** * Convert ArrayBuffer to Base64 string (handles large buffers) * @param buffer - ArrayBuffer data * @returns Base64 string */ function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); if (isBrowser()) { // Use chunked approach to avoid stack overflow with large audio data let binary = ''; const chunkSize = 8192; for (let i = 0; i < bytes.length; i += chunkSize) { const chunk = bytes.slice(i, Math.min(i + chunkSize, bytes.length)); binary += String.fromCharCode.apply(null, Array.from(chunk)); } return window.btoa(binary); } // Node.js environment return Buffer.from(bytes).toString('base64'); } /** * 解析科大讯飞返回的结果 * @param result 科大讯飞返回的识别结果 * @param logger 可选的日志记录器,若不提供则使用 console.error */ function parseXfyunResult(result, logger) { if (!result || typeof result !== 'object') { return ''; } const resultObj = result; if (!Array.isArray(resultObj.ws)) { return ''; } try { return resultObj.ws.map((ws) => { if (!Array.isArray(ws.cw)) { return ''; } return ws.cw.map((cw) => cw.w || '').join(''); }).join(''); } catch (error) { if (logger) { logger.error('解析讯飞结果失败:', error, '原始数据:', result); } else { console.error('[XfyunASR] 解析讯飞结果失败:', error, '原始数据:', result); } return ''; } } /** * 日志级别枚举 */ exports.LogLevel = void 0; (function (LogLevel) { LogLevel[LogLevel["DEBUG"] = 0] = "DEBUG"; LogLevel[LogLevel["INFO"] = 1] = "INFO"; LogLevel[LogLevel["WARN"] = 2] = "WARN"; LogLevel[LogLevel["ERROR"] = 3] = "ERROR"; })(exports.LogLevel || (exports.LogLevel = {})); /** * 统一的日志工具类 * * 提供 debug、info、warn、error 四个级别的日志方法, * 根据设置的日志级别过滤输出。 */ class Logger { /** * 创建日志器实例 * @param prefix 日志前缀,默认 '[XfyunASR]' */ constructor(prefix = '[XfyunASR]') { this.level = exports.LogLevel.INFO; this.prefix = prefix; } /** * 设置日志级别 * @param level 日志级别 ('debug' | 'info' | 'warn' | 'error') */ setLevel(level) { switch (level) { case 'debug': this.level = exports.LogLevel.DEBUG; break; case 'info': this.level = exports.LogLevel.INFO; break; case 'warn': this.level = exports.LogLevel.WARN; break; case 'error': this.level = exports.LogLevel.ERROR; break; } } /** * 输出 DEBUG 级别日志 * @param args 日志内容 */ debug(...args) { if (this.level <= exports.LogLevel.DEBUG) { console.debug(this.prefix, ...args); } } /** * 输出 INFO 级别日志 * @param args 日志内容 */ info(...args) { if (this.level <= exports.LogLevel.INFO) { console.info(this.prefix, ...args); } } /** * 输出 WARN 级别日志 * @param args 日志内容 */ warn(...args) { if (this.level <= exports.LogLevel.WARN) { console.warn(this.prefix, ...args); } } /** * 输出 ERROR 级别日志 * @param args 日志内容 */ error(...args) { if (this.level <= exports.LogLevel.ERROR) { console.error(this.prefix, ...args); } } } /** * WebSocket 客户端基类 * @description 提取所有基于 WebSocket 的讯飞 API 客户端的通用逻辑 * * 包含功能: * - WebSocket 连接管理(创建、发送、关闭) * - 定时器管理(连接超时、关闭延迟) * - 状态管理(状态转换验证) * - 错误处理模式 * * @example * ```typescript * abstract class MyClient extends BaseWebSocketClient<MyState> { * protected readonly STATE_TRANSITIONS: Record<MyState, MyState[]> = { ... }; * * protected parseMessage(data: string | ArrayBuffer): void { * // 子类实现消息解析 * } * * protected getModulePrefix(): string { * return '[MyClient]'; * } * } * ``` */ /** * WebSocket 就绪状态映射 */ const WS_STATE_MAP = { [WebSocket.CONNECTING]: 'CONNECTING', [WebSocket.OPEN]: 'OPEN', [WebSocket.CLOSING]: 'CLOSING', [WebSocket.CLOSED]: 'CLOSED', }; /** * WebSocket 客户端基类 * * 提供通用的 WebSocket 连接管理、状态管理和错误处理功能。 * 子类需要实现特定的消息解析和业务逻辑。 */ class BaseWebSocketClient { /** * 创建 WebSocket 客户端实例 * @param options 配置选项 * @param handlers 事件处理程序 */ constructor(options, handlers = {}) { // ========== WebSocket 相关 ========== this.websocket = null; this.websocketCloseTimer = null; this.connectingTimer = null; // ========== 状态管理 ========== this.state = 'idle'; // ========== 销毁状态 ========== this.destroyed = false; // 验证必要参数 if (!options.appId || !options.apiKey || !options.apiSecret) { throw new Error('缺少必要参数: appId, apiKey, apiSecret 不能为空'); } this.options = options; this.handlers = handlers; // 初始化日志器 const prefix = this.getModulePrefix(); this.logger = new Logger(prefix); this.logger.setLevel(options.logLevel || 'info'); this.logger.info(`${prefix} 实例创建`, options); } // ========== WebSocket 管理 ========== /** * 确保 WebSocket 已初始化 * @throws 如果 WebSocket 未初始化则抛出错误 */ ensureWebSocket() { if (!this.websocket) { this.logger.error('WebSocket 未初始化'); throw new Error('WebSocket 未初始化,请先调用 start() 方法'); } return this.websocket; } /** * 安全地发送 WebSocket 消息 * @param data 要发送的数据 * @returns 发送是否成功 */ safeSend(data) { try { const ws = this.ensureWebSocket(); if (ws.readyState === WebSocket.OPEN) { ws.send(data); this.logger.debug('WebSocket 发送数据成功'); return true; } else { this.logger.warn(`WebSocket 未就绪,当前状态: ${WS_STATE_MAP[ws.readyState]}`); return false; } } catch (error) { this.logger.error('WebSocket 发送数据失败:', error); return false; } } /** * 安全地关闭 WebSocket 连接 */ safeCloseWebSocket() { if (this.websocket) { if (this.websocket.readyState === WebSocket.OPEN || this.websocket.readyState === WebSocket.CONNECTING) { this.websocket.close(1000, '正常关闭'); } this.websocket = null; this.logger.debug('WebSocket 已安全关闭'); } } /** * 初始化 WebSocket 连接 */ initWebSocket() { try { this.setState('connecting'); const url = this.generateAuthUrl(); this.logger.info('正在连接 WebSocket'); this.websocket = new WebSocket(url); // 设置 WebSocket 事件处理器 this.setupWebSocketHandlers(); // Connecting 超时兜底 this.setupConnectingTimeout(); } catch (error) { this.logger.error('初始化 WebSocket 失败:', error); this.handleError({ code: this.getErrorCodePrefix() + 3, message: '初始化 WebSocket 失败', data: error }); } } /** * 设置 WebSocket 所有事件处理器 */ setupWebSocketHandlers() { if (!this.websocket) return; this.websocket.onopen = () => this.handleWebSocketOpen(); this.websocket.onmessage = (event) => this.handleWebSocketMessage(event); this.websocket.onerror = (error) => this.handleWebSocketError(error); this.websocket.onclose = (event) => this.handleWebSocketClose(event); } /** * 处理 WebSocket 连接打开事件 */ handleWebSocketOpen() { this.clearConnectingTimer(); this.logger.info('WebSocket 连接成功'); this.setState('connected'); this.onConnected(); } /** * 连接成功后的回调 - 子类可重写 */ onConnected() { // 默认空实现,子类根据需要重写 } /** * 处理 WebSocket 消息事件 */ handleWebSocketMessage(event) { try { this.logger.debug('收到 WebSocket 消息'); this.parseMessage(event.data); } catch (error) { this.logger.error('解析 WebSocket 消息失败:', error); this.handleError({ code: this.getErrorCodePrefix() + 5, message: '解析消息失败', data: error }); } } /** * 处理 WebSocket 错误事件 */ handleWebSocketError(error) { this.clearConnectingTimer(); this.logger.error('WebSocket 连接错误:', error); this.handleError({ code: this.getErrorCodePrefix() + 2, message: 'WebSocket 连接错误', data: error }); } /** * 处理 WebSocket 关闭事件 */ handleWebSocketClose(event) { this.logger.info('WebSocket 连接关闭:', event.code, event.reason); this.websocket = null; // 子类可以在这里添加关闭后的处理逻辑 this.onWebSocketClosed(event); } /** * WebSocket 关闭后的回调 - 子类可重写 */ onWebSocketClosed(_event) { // 默认空实现 } /** * 设置 Connecting 超时检测 */ setupConnectingTimeout() { if (typeof window === 'undefined') return; this.connectingTimer = window.setTimeout(() => { if (this.state === 'connecting' && !this.destroyed) { this.logger.warn('WebSocket connecting 超时,强制关闭'); this.safeCloseWebSocket(); this.handleError({ code: this.getErrorCodePrefix() + 5, message: 'WebSocket 连接超时', }); } }, this.constructor.CONNECTING_TIMEOUT_MS); } // ========== 定时器管理 ========== /** * 清除 WebSocket 关闭定时器 */ clearWebSocketCloseTimer() { if (this.websocketCloseTimer && typeof window !== 'undefined') { window.clearTimeout(this.websocketCloseTimer); this.websocketCloseTimer = null; } } /** * 清除连接超时定时器 */ clearConnectingTimer() { if (this.connectingTimer && typeof window !== 'undefined') { window.clearTimeout(this.connectingTimer); this.connectingTimer = null; } } /** * 安排 WebSocket 延迟关闭 * @param delay 延迟时间(毫秒) */ scheduleWebSocketClose(delay = 1000) { this.clearWebSocketCloseTimer(); if (typeof window !== 'undefined') { this.websocketCloseTimer = window.setTimeout(() => { this.safeCloseWebSocket(); this.websocketCloseTimer = null; }, delay); } } // ========== 状态管理 ========== /** * 获取当前状态 */ getState() { return this.state; } /** * 设置状态(带转换验证) * @param newState 新状态 */ setState(newState) { // 检查状态转换是否合法 const validTransitions = this.STATE_TRANSITIONS[this.state] || []; if (!validTransitions.includes(newState)) { this.logger.warn(`⚠️ 非法状态转换: ${this.state} -> ${newState}`, `合法转换: [${validTransitions.join(', ')}]`); } const oldState = this.state; this.state = newState; if (this.handlers.onStateChange) { this.handlers.onStateChange(newState); } this.logger.debug(`状态变更: ${oldState} -> ${newState}`); } // ========== 错误处理 ========== /** * 处理错误 * @param error 错误信息 */ handleError(error) { // 清除所有定时器 this.clearWebSocketCloseTimer(); this.clearConnectingTimer(); this.setState('error'); if (this.handlers.onError) { this.handlers.onError(error); } // 通知停止(让调用方知道操作已结束) if (this.handlers.onStop) { this.handlers.onStop(); } this.logger.error(`${this.getModulePrefix()} 错误:`, error); } // ========== 生命周期 ========== /** * 销毁实例,释放所有资源 */ destroy() { this.destroyed = true; this.clearWebSocketCloseTimer(); this.clearConnectingTimer(); this.safeCloseWebSocket(); this.setState('stopped'); this.logger.info(`${this.getModulePrefix()} 实例已销毁`); } /** * 检查实例是否已销毁 */ isDestroyed() { return this.destroyed; } /** * 设置事件处理程序 * @param newHandlers 新的事件处理程序 */ setHandlers(newHandlers) { if (!newHandlers || typeof newHandlers !== 'object') { throw new TypeError('handlers 必须是有效的对象'); } this.handlers = { ...this.handlers, ...newHandlers }; } } // 连接超时兜底(部分浏览器 WebSocket 失败不触发 onerror) BaseWebSocketClient.CONNECTING_TIMEOUT_MS = 10000; /** * 科大讯飞语音识别模块 * @description 基于 WebSocket 的实时语音识别,支持麦克风录音、文件识别、重连机制 */ // 默认配置 const DEFAULT_OPTIONS$2 = { language: 'zh_cn', domain: 'iat', accent: 'mandarin', vadEos: 3000, maxAudioSize: 1024 * 1024, autoStart: false, audioFormat: 'audio/L16;rate=16000', reconnectAttempts: 3, reconnectInterval: 3000, enableReconnect: false, logLevel: 'info', }; /** * 科大讯飞语音识别类 * * 继承 BaseWebSocketClient,复用 WebSocket 连接管理、状态管理、错误处理等通用逻辑。 * 专注于语音识别特有的功能:麦克风管理、音频录制、重连机制等。 * * @example * ```typescript * const recognizer = new XfyunASR({ * appId: 'your-app-id', * apiKey: 'your-api-key', * apiSecret: 'your-api-secret' * }, { * onResult: (text) => console.log('识别结果:', text), * onError: (err) => console.error('错误:', err) * }); * * await recognizer.start(); * recognizer.record(); * await recognizer.stop(); * ``` */ class XfyunASR extends BaseWebSocketClient { /** * 创建语音识别实例 * @param options 配置选项 * @param handlers 事件处理程序 */ constructor(options, handlers = {}) { super({ ...DEFAULT_OPTIONS$2, ...options }, handlers); // ========== 音频相关 ========== this.recorder = null; this.audioContext = null; this.audioSource = null; this.analyser = null; this.audioDataQueue = []; this.totalAudioBytes = 0; this.recognitionResult = ''; this.volumeTimer = null; this.microphoneStream = null; // 业务参数缓存(避免每帧都 new 对象) this.cachedBusinessParams = null; // ========== 重连相关 ========== this.reconnectCount = 0; this.reconnectTimer = null; this.isReconnecting = false; // ========== 状态管理 ========== this.STATE_TRANSITIONS = { 'idle': ['connecting'], 'connecting': ['connected', 'stopped', 'error'], 'connected': ['recording', 'stopped', 'error'], 'recording': ['stopped', 'error'], 'stopped': ['idle', 'connecting'], 'error': ['idle', 'connecting'] }; // 如果设置为自动开始,则初始化后立即开始 if (this.options.autoStart) ; } // ========== 实现 BaseWebSocketClient 抽象方法 ========== getModulePrefix() { return '[XfyunASR]'; } getErrorCodePrefix() { return 10000; } generateAuthUrl() { return generateAuthUrl(this.options.apiKey, this.options.apiSecret); } onConnected() { // 连接成功后开始录音 this.startRecording(); } onWebSocketClosed(_event) { // WebSocket 关闭后,如果不是主动停止,尝试重连 if (this.state !== 'stopped' && this.state !== 'error' && !this.destroyed) { this.handleReconnect(); } } parseMessage(data) { if (typeof data !== 'string') return; const message = JSON.parse(data); // 处理错误响应 if (message.code !== 0) { this.handleError({ code: message.code, message: message.message || '识别错误' }); this.handleReconnect(); return; } // 处理识别结果 this.processRecognitionResult(message); } // ========== 公共方法 ========== /** * 设置事件处理程序 */ setHandlers(handlers) { // 验证回调函数类型 const validHandlers = ['onStart', 'onStop', 'onRecognitionResult', 'onProcess', 'onError', 'onStateChange']; for (const key of validHandlers) { if (handlers[key] && typeof handlers[key] !== 'function') { throw new TypeError(`${key} 必须是函数`); } } super.setHandlers(handlers); } /** * 开始语音识别 */ async start() { if (this.destroyed) { this.logger.error('实例已销毁,无法启动'); return; } try { // 检查浏览器兼容性 if (!navigator.mediaDevices || !window.WebSocket) { this.handleError({ code: 10001, message: '浏览器不支持语音识别功能,请使用现代浏览器' }); return; } if (this.state === 'connecting' || this.state === 'connected' || this.state === 'recording') { this.logger.warn('语音识别已在进行中,忽略此次启动请求'); return; } // 重置状态 this.setState('connecting'); this.recognitionResult = ''; this.audioDataQueue = []; this.totalAudioBytes = 0; this.reconnectCount = 0; this.cachedBusinessParams = null; // 请求麦克风权限 await this.initMicrophone(); // 创建 WebSocket 连接 this.initWebSocket(); // 触发开始事件 if (this.handlers.onStart) { this.handlers.onStart(); } } catch (error) { // initWebSocket 失败,释放 initMicrophone 已申请的全部资源 this.releaseMicrophone(); if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } this.analyser = null; this.audioSource = null; this.recorder = null; this.handleError({ code: 10003, message: '启动语音识别失败', data: error }); } } /** * 停止语音识别 */ stop() { if (this.state === 'idle' || this.state === 'stopped') { return; } this.clearReconnectTimer(); this.isReconnecting = false; try { this.setState('stopped'); // 停止录音 if (this.recorder && this.recorder.state !== 'inactive') { this.recorder.stop(); } // 停止音量检测 this.stopVolumeDetection(); // 发送结束帧 this.sendEndFrame(); // 延迟关闭 WebSocket this.scheduleWebSocketClose(1000); // 关闭音频流 this.releaseMicrophone(); // 关闭音频上下文 if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } // 触发停止事件 if (this.handlers.onStop) { this.handlers.onStop(); } } catch (error) { this.handleError({ code: 10004, message: '停止语音识别失败', data: error }); } } /** * 销毁实例,释放所有资源 */ destroy() { this.destroyed = true; this.clearReconnectTimer(); // 清除连接超时定时器 this.clearConnectingTimer(); // 立即关闭 websocket this.clearWebSocketCloseTimer(); this.safeCloseWebSocket(); // 停止 recorder 并清空引用 if (this.recorder && this.recorder.state !== 'inactive') { this.recorder.stop(); } this.recorder = null; this.stopVolumeDetection(); this.releaseMicrophone(); if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } this.setState('stopped'); this.logger.info('XfyunASR 实例已销毁'); } /** * 获取当前识别结果 */ getResult() { return this.recognitionResult; } /** * 清除识别结果 */ clearResult() { this.recognitionResult = ''; } /** * 是否正在录音中 */ isRecording() { return this.state === 'recording'; } // ========== 私有方法 ========== /** * 初始化麦克风 */ async initMicrophone() { try { // 获取麦克风权限 this.microphoneStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, sampleRate: 16000 }, video: false }); this.logger.info('成功获取麦克风权限'); // 创建音频上下文 this.audioContext = createAudioContext(); // 创建分析器节点 this.analyser = this.audioContext.createAnalyser(); this.analyser.fftSize = 2048; // 连接音频源 this.audioSource = this.audioContext.createMediaStreamSource(this.microphoneStream); this.audioSource.connect(this.analyser); // 检测支持的音频格式 const mimeType = detectSupportedMimeType(); if (!mimeType) { throw new Error('浏览器不支持任何可用的音频编码格式'); } this.logger.info('使用音频格式:', mimeType); // 创建音频录制器 this.recorder = new MediaRecorder(this.microphoneStream, { mimeType, audioBitsPerSecond: 16000 }); // 处理录音数据 this.recorder.ondataavailable = (event) => { if (event.data.size > 0) { const reader = new FileReader(); reader.onload = () => { if (this.state === 'recording' && !this.destroyed && reader.result instanceof ArrayBuffer) { try { const base64Audio = arrayBufferToBase64(reader.result); this.audioDataQueue.push(base64Audio); this.sendAudioData(); } catch (error) { this.logger.error('处理音频数据失败:', error); } } }; reader.onerror = (error) => { this.logger.error('读取音频数据失败:', error); }; reader.readAsArrayBuffer(event.data); } }; // 录音出错处理 this.recorder.onerror = (error) => { this.logger.error('录音出错:', error); this.handleError({ code: 10009, message: '录音出错', data: error }); }; this.logger.info('麦克风和录音器初始化完成'); } catch (error) { // 清理部分初始化的资源 this.releaseMicrophone(); if (this.audioContext) { this.audioContext.close(); this.audioContext = null; } this.logger.error('初始化麦克风失败:', error); throw error; } } /** * 开始录音 */ startRecording() { if (!this.recorder || this.recorder.state === 'recording') return; // 开始录音(每 500ms 触发一次 dataavailable) this.recorder.start(500); // 开始音量检测 this.startVolumeDetection(); this.setState('recording'); this.logger.info('开始录音'); } /** * 释放麦克风资源 */ releaseMicrophone() { // 先断开音频节点连接,再停止轨道 if (this.audioSource) { try { this.audioSource.disconnect(); } catch { } this.audioSource = null; } if (this.analyser) { try { this.analyser.disconnect(); } catch { } this.analyser = null; } if (this.microphoneStream) { this.microphoneStream.getTracks().forEach(track => track.stop()); this.microphoneStream = null; } } /** * 停止音量检测 */ stopVolumeDetection() { if (this.volumeTimer) { window.clearInterval(this.volumeTimer); this.volumeTimer = null; } } /** * 构建公共业务参数(缓存避免重复创建) */ buildBusinessParams() { if (this.cachedBusinessParams) { return this.cachedBusinessParams; } const business = { language: this.options.language, domain: this.options.domain, accent: this.options.accent, vad_eos: this.options.vadEos, dwa: 'wpgs', pd: 'speech', ptt: 0, rlang: 'zh-cn', vinfo: 1, nunum: 1, speex_size: 70, nbest: 1, wbest: 5, }; // 设置标点符号选项 if (typeof this.options.punctuation !== 'undefined') { if (typeof this.options.punctuation === 'boolean') { business.punctuation = this.options.punctuation ? 'on' : 'off'; } else { business.punctuation = this.options.punctuation; } } // 如果有热词,添加到请求中 if (this.options.hotWords && this.options.hotWords.length > 0) { business.hotwords = this.options.hotWords.join(','); } this.cachedBusinessParams = business; return business; } /** * 处理识别结果数据 */ processRecognitionResult(message) { if (!message.data || !message.data.result) return; const text = parseXfyunResult(message.data.result, this.logger); const isEnd = message.data.result.ls; this.logger.debug('解析识别结果:', text, '是否最终结果:', isEnd); if (text) { this.recognitionResult += text; if (this.handlers.onRecognitionResult) { this.handlers.onRecognitionResult(text, isEnd); } } // 如果是最终结果,重置重连计数 if (isEnd) { this.reconnectCount = 0; } } /** * 发送开始帧 */ sendStartFrame() { if (this.destroyed) { this.logger.warn('实例已销毁,无法发送开始帧'); return; } try { const frame = { common: { app_id: this.options.appId }, business: this.buildBusinessParams(), data: { status: 0, format: this.options.audioFormat || 'audio/L16;rate=16000', encoding: 'raw', audio: '' } }; this.logger.debug('发送开始帧'); if (!this.safeSend(JSON.stringify(frame))) { throw new Error('WebSocket 发送失败'); } } catch (error) { this.logger.error('发送开始帧失败:', error); this.handleError({ code: 10008, message: '发送开始帧失败', data: error }); } } /** * 发送音频数据 */ sendAudioData() { if (this.state !== 'recording' || this.destroyed) { return; } while (this.audioDataQueue.length > 0) { const audioData = this.audioDataQueue.shift(); if (!audioData) continue; const maxSize = this.options.maxAudioSize || 1024 * 1024; if (this.totalAudioBytes + audioData.length > maxSize) { this.logger.warn('音频数据超过大小限制,停止发送'); this.audioDataQueue = []; break; } try { // 后续帧只发 common + data,不带 business 减少冗余数据传输 const frame = { common: { app_id: this.options.appId }, data: { status: 1, format: this.options.audioFormat || 'audio/L16;rate=16000', encoding: 'raw', audio: audioData } }; if (!this.safeSend(JSON.stringify(frame))) { this.audioDataQueue.unshift(audioData); this.handleError({ code: 10007, message: '发送音频数据失败: WebSocket 未就绪或发送失败' }); break; } this.totalAudioBytes += audioData.length; this.logger.debug('发送音频数据帧, 大小:', audioData.length); } catch (error) { this.logger.error('发送音频数据失败:', error); this.audioDataQueue.unshift(audioData); this.handleError({ code: 10007, message: '发送音频数据失败', data: error }); } } } /** * 发送结束帧 */ sendEndFrame() { const frame = { common: { app_id: this.options.appId }, business: this.buildBusinessParams(), data: { status: 2, format: this.options.audioFormat || 'audio/L16;rate=16000', encoding: 'raw', audio: '' } }; this.logger.debug('发送结束帧'); if (!this.safeSend(JSON.stringify(frame))) { this.logger.warn('发送结束帧失败,WebSocket 未就绪'); } } /** * 开始音量检测 */ startVolumeDetection() { if (!this.analyser) return; const bufferLength = this.analyser.frequencyBinCount; const dataArray = new Float32Array(bufferLength); this.volumeTimer = window.setInterval(() => { if (this.analyser && this.state === 'recording' && !this.destroyed) { this.analyser.getFloatTimeDomainData(dataArray); const volume = calculateVolume(dataArray); if (this.handlers.onProcess) { this.handlers.onProcess(volume); } } }, 100); } /** * 清除重连定时器 */ clearReconnectTimer() { if (this.reconnectTimer) { window.clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.clearConnectingTimer(); } /** * 处理重连逻辑 */ handleReconnect() { if (this.destroyed) return; if (!this.options.enableReconnect) return; if (this.isReconnecting) return; const maxAttempts = this.options.reconnectAttempts || 3; const baseInterval = this.options.reconnectInterval || 3000; if (this.reconnectCount >= maxAttempts) { this.logger.warn('已达到最大重连次数,重连停止'); this.setState('error'); return; } this.isReconnecting = true; this.reconnectCount++; // 指数退避: interval * 2^(attempt-1),上限 30s const interval = Math.min(baseInterval * Math.pow(2, this.reconnectCount - 1), 30000); this.logger.info(`正在尝试第 ${this.reconnectCount} 次重连,间隔 ${interval}ms...`); this.reconnectTimer = window.setTimeout(() => { this.isReconnecting = false; if (this.state === 'error' || this.state === 'idle' || this.state === 'connecting') { this.start(); } }, interval); } } /** * 科大讯飞 TTS 语音合成模块 * @description 基于 WebSocket 的流式语音合成,支持多种音色、语速调节、多种音频格式 */ // Default options const DEFAULT_OPTIONS$1 = { voice_name: 'xiaoyan', speed: 50, pitch: 50, volume: 50, accent: 'accent=mandarin', audioFormat: 'mp3', sampleRate: 16000, enableCache: true, logLevel: 'info', }; // 音频格式映射 const AUDIO_FORMAT_MAP = { mp3: 'audio/mpeg', wav: 'audio/wav', pcm: 'audio/pcm', }; // 采样率映射 const SAMPLE_RATE_MAP = { 8000: '8000', 16000: '16000', 24000: '24000', 48000: '48000', }; /** * 科大讯飞语音合成类 * * 继承 BaseWebSocketClient,复用 WebSocket 连接管理、状态管理、错误处理等通用逻辑。 * 专注于语音合成特有的功能:文本转语音、音频流处理、缓存管理等。 * * @example * ```typescript * const synthesizer = new XfyunTTS({ * appId: 'your-app-id', * apiKey: 'your-api-key', * apiSecret: 'your-api-secret' * }, { * onAudioData: (buffer) => console.log('音频数据:', buffer), * onEnd: () => console.log('合成完成') * }); * * await synthesizer.speak('你好,这是语音合成测试'); * await synthesizer.stop(); * ``` */ class XfyunTTS extends BaseWebSocketClient { /** * 创建 TTS 合成器实例 * @param options 配置选项 * @param handlers 事件处理程序 */ constructor(options, handlers = {}) { super({ ...DEFAULT_OPTIONS$1, ...options }, handlers); // ========== 音频相关 ========== this.audioChunks = []; this.currentText = ''; this.textIndex = 0; // ========== 状态管理 ========== this.STATE_TRANSITIONS = { 'idle': ['connecting'], 'connecting': ['connected', 'stopped', 'error'], 'connected': ['synthesizing', 'stopped', 'error'], 'synthesizing': ['stopped', 'error'], 'stopped': ['idle', 'connecting'], 'error': ['idle', 'connecting'] }; } // ========== 实现 BaseWebSocketClient 抽象方法 ========== getModulePrefix() { return '[XfyunTTS]'; } getErrorCodePrefix() { return 20000; } generateAuthUrl() { return generateAuthUrl(this.options.apiKey, this.options.apiSecret, 'tts-api.xfyun.cn', '/v2/tts'); } parseMessage(data) { if (typeof data === 'string') { try { const message = JSON.parse(data); if (message.code !== 0) { this.handleError({ code: message.code, message: message.message || '合成错误' }); return; } // 进度更新 if (message.data && message.data.current_index !== undefined) { const current = message.data.current_index; const total = this.currentText.length; if (this.handlers.onProgress) { this.handlers.onProgress(current, total); } } } catch (error) { this.logger.error('解析 TTS 消息失败:', error); } } else { // 音频数据 if (this.state === 'synthesizing' || this.state === 'connected') { this.audioChunks.push(data); if (this.handlers.onAudioData) { this.handlers.onAudioData(data); } } } } // ========== 公共方法 ========== /** * 开始语音合成 * @param text 要合成的文本 */ start(text) { if (this.destroyed) { this.logger.error('实例已销毁,无法启动'); return; } if (!text || text.trim().length === 0) { this.handleError({ code: 20001, message: '合成文本不能为空' }); return; } if (this.state === 'synthesizing' || this.state === 'connecting') { this.logger.warn('合成正在进行中,忽略此次请求'); return; } this.currentText = text; this.textIndex = 0; this.audioChunks = []; this.initWebSocket(); } /** * 停止合成 */ stop() { if (this.state === 'idle' || this.state === 'stopped') { return; } this.clearWebSocketCloseTimer(); this.setState('stopped'); this.safeCloseWebSocket(); if (this.handlers.onStop) { this.handlers.onStop(); } this.logger.info('TTS 合成已停止'); } /** * 获取累积的音频数据 */ getAudioData() { if (this.audioChunks.length === 0) { return null; } const totalLength = this.audioChunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); const result = new ArrayBuffer(totalLength); const resultArray = new Uint8Array(result); let offset = 0; for (const chunk of this.audioChunks) { resultArray.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; } return result; } /** * Export audio blob * @returns Audio Blob object, or null if no audio data */ exportAudio() { const mimeType = this.getMimeType(); const audioData = this.getAudioData(); if (!audioData) { return null; } return new Blob([audioData], { type: mimeType }); } /** * Download audio file * @param filename - Name of the file to download */ downloadAudio(filename = 'synthesis') { // 参数验证 if (!filename || typeof filename !== 'string') { throw new TypeError('filename 必须是字符串'); } if (filename.trim().length === 0) { throw new Error('filename 不能为空'); } if (filename.includes('/') || filename.includes('\\') || filename.includes('\x00')) { throw new Error('filename 包含非法字符'); } // Check browser environment if (typeof document === 'undefined' || typeof URL === 'undefined') { this.logger.warn('downloadAudio is only available in browser environment'); return; } const blob = this.exportAudio(); if (!blob) { this.logger.warn('No audio data to download'); return; } const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename + this.getFileExtension(); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } /** * Get file extension based on audio format */ getFileExtension() { const format = this.options.audioFormat || 'mp3'; return '.' + format; } /** * 获取 MIME 类型 */ getMimeType() { const format = this.options.audioFormat || 'mp3'; return AUDIO_FORMAT_MAP[format] || 'audio/mpeg'; } // ========== 私有方法 ========== /** * 发送开始帧 */ sendStartFrame() { if (this.destroyed) { this.logger.warn('实例已销毁,无法发送开始帧'); return; } try { const format = this.options.audioFormat || 'mp3'; const sampleRate = this.options.sampleRate || 16000; const frame = { common: { app_id: this.options.appId }, business: { aue: format === 'pcm' ? 'raw' : 'lame', auf: SAMPLE_RATE_MAP[sampleRate] || '16000', voice_name: this.options.voice_name || 'xiaoyan', speed: this.options.speed ?? 50, pitch: this.options.pitch ?? 50, volume: this.options.volume ?? 50, tte: 'UTF8', reg: 0, bg: 0, }, data: { status: 0, text: toBase64(this.currentText, 'utf-8'), }, }; this.logger.debug('发送 TTS 开始帧'); if (!this.safeSend(JSON.stringify(frame))) { throw new Error('WebSocket 发送失败'); } this.setState('synthesizing'); } catch (error) { this.logger.error('发送 TTS 开始帧失败:', error); this.handleError({ code: 20004, message: '发送开始帧失败', data: error }); } } } /** * 科大讯飞翻译模块 * @description 支持语音翻译(边说边译)和文本翻译 */ // 默认配置 const DEFAULT_OPTIONS = { type: 'asr', from: 'cn', to: 'en', domain: 'iner', autoStart: false, vadEos: 5000, sampleRate: 16000, logLevel: 'info', }; // 语言代码映射 const LANGUAGE_CODE_MAP = { cn: 'cn', en: 'en', ja: 'ja', ko: 'ko', fr: 'fr', es: 'es', it: 'it', de: 'de', pt: 'pt', vi: 'vi', id: 'id', ms: 'ms', ru: 'ru', ar: 'ar', hi: 'hi', th: 'th', }; /** * 科大讯飞翻译类 * * 继承 BaseWebSocketClient,复用 WebSocket 连接管理、状态管理、错误处理等通用逻辑。 * 专注于语音/文本翻译特有的功能:多语言支持、实时翻译结果、翻译模式切换等。 * * @example * ```typescript * const translator = new XfyunTranslator({ * appId: 'your-app-id', * apiKey: 'your-api-key', * apiSecret: 'your-api-secret', * type: 'asr', * from: 'cn', * to: 'en' * }, { * onResult: (result) => console.log('翻译结果:', result), * onError: (err) => console.error('错误:', err) * }); * * await translator.start(); * translator.record(); * await translator.stop(); * ``` */ class XfyunTranslator extends BaseWebSocketClient { /** * 创建翻译器实例 * @param options 配置选项 * @param handlers 事件处理程序 */ constructor(options, handlers = {}) { super({ ...DEFAULT_OPTIONS, ...options }, handlers); // ========== 音频相关(语音翻译模式)========== this.microphoneStream = null; this.audioContext = null; this.recorder = null; this.audioDataQueue = []; // ========== 状态管理 ========== this.STATE_TRANSITIONS = { 'idle': ['connecting'], 'connecting': ['connected', 'stopped', 'error'], 'connected': ['translating', 'stopped', 'error'], 'translating': ['stopped', 'error'], 'stopped': ['idle', 'connecting'], 'error': ['idle', 'connecting'] }; } // ========== 实现 BaseWebSocketClient 抽象方法 ========== getModulePrefix() { return '[XfyunTranslator]'; } getErrorCodePrefix() { return 30000; } generateAuthUrl() { // 根据类型选择不同的路径 const path = this.options.type === 'text' ? '/v2/translate' : '/v2/itr'; return generateAuthUrl(this.options.apiKey, this.options.apiSecret, 'itr-api.xfyun.cn', path); } onConnected() { // 连接成功后,根据类型执行不同逻辑 if (this.options.type === 'asr') { this.initRecorder(); this.sendStartFrame(); } } parseMessage(data) { if (typeof data !== 'string') return; try { const message = JSON.parse(data); if (message.code !== 0) { this.handleError({ code: message.code, message: message.message || '翻译错误' }); return; } if (message.data) { // 文本翻译模式:result 直接包含 source/target,没有 status 字段 // 语音翻译模式:data 包含 status 字段,status === 2 表示最终结果 const isTextTranslation = this.options.type === 'text'; const status = message.data.status; // status 在 data 层级,不在 result 层级 const result = { sourceLanguage: (this.options.from || 'cn'), targetLanguage: (this.options.to || 'en'), sourceText: message.data.result?.source || '', targetText: message.data.result?.target || '', isFinal: isTextTranslation || status === 2, // 文本翻译直接是最终结果 confidence: message.data.result?.confidence, }; if (this.handlers.onResult) { this.handlers.onResult(result); } if (result.isFinal) { this.setState('stopped'); if (this.handlers.onEnd) { this.handlers.onEnd(); } } } } catch (error) { this.logger.error('解析翻译消息失败:', error); } } // ========== 公共方法 ========== /** * 设置事件处理程序 */ setHandlers(handlers) { const validHandlers = ['onStart', 'onEnd', 'onStop', 'onResult', 'onError', 'onStateChange']; for (const key of validHandlers) { if (handlers[key] && typeof handlers[key] !== 'function') { throw new TypeError(`${key} 必须是函数`); } } super.setHandlers(handlers); } /** * 开始翻译 * @param text 文本翻译的文本(语音翻译模式可省略) */ async start(text) { if (this.destroyed) { this.logger.error('实例已销毁,无法启动'); return; } if (this.state === 'translating' || this.state === 'connecting') { this.logger.warn('翻译正在进行中,忽略此次请求'); return; } const type = this.options.type || 'asr'; if (type === 'text') { // 文本翻译模式 if (!text || text.trim().length === 0) { this.handleError({ code: 30001, message: '翻译文本不能为空' }); return; } await this.startTextTranslation(text); } else { // 语音翻译模式 await this.startSpeechTranslation(); } } /** * 停止翻译 */ stop() { if (this.state === 'idle' || this.state === 'stopped') { return; } this.clearWebSocketCloseTimer(); // 清理所有录音资源 this.cleanupRecordingResources(); this.setState('stopped'); // 处理 WebSocket 关闭 if (this.websocket) { if (this.options.type === 'asr') { this.sendTranslationEndFrame(); } this.scheduleWebSocketClose(500); } if (this.handlers.onStop) { this.handlers.onStop(); } this.logger.info('翻译已停止'); } /** * 销毁实例 */ destroy() { this.destroyed = true; this.clearWebSocketCloseTimer(); this.clearConnectingTimer(); this.cleanupRecordingResources(); this.safeCloseWebSocket(); this.audioDataQueue = []; this.setState('stopped'); this.logger.info('XfyunTranslator 实例已销毁'); } // ========== 私有方法 ========== /** * 清理所有录音相关资源 */ cleanupRecordingResources() { if (this.recorde