xfyun-sdk
Version:
科大讯飞语音识别 SDK,支持浏览器中实时语音听写功能
1,698 lines (1,689 loc) • 88.2 kB
JavaScript
'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