UNPKG

uniapp-text-to-speech

Version:

uniapp 文本转语音

729 lines (728 loc) 25.7 kB
/** * 语音合成工具类 * @class SpeechSynthesisUtil * @description 用于处理文字转语音的工具类,包含音频播放、合成等功能 */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; // 事件类型枚举 export var EventType; (function (EventType) { EventType["STATE_CHANGE"] = "stateChange"; EventType["SYNTHESIS_START"] = "synthesisStart"; EventType["SYNTHESIS_END"] = "synthesisEnd"; EventType["AUDIO_PLAY"] = "audioPlay"; EventType["AUDIO_END"] = "audioEnd"; EventType["ERROR"] = "error"; EventType["PROGRESS"] = "progress"; EventType["PAUSE"] = "pause"; EventType["RESUME"] = "resume"; })(EventType || (EventType = {})); class SpeechSynthesisUtil { constructor(options) { this.audioContext = null; this.audioQueue = []; this.audioTextQueue = []; this.fileManager = null; this.pendingAudioUrl = null; this.modelContent = ''; this.totalAudioCount = 0; // 记录总的音频数量 this.API_KEY = options.API_KEY; this.GroupId = options.GroupId; this.MAX_QUEUE_LENGTH = options.MAX_QUEUE_LENGTH || 3; this.modelConfig = options.modelConfig || {}; this.state = { isInterrupted: false, isStartPlayQueue: false, isPlaying: false, isEnded: false, isError: false, isPaused: false, isSynthesizing: false, needsUserInteraction: false }; try { if (typeof uni !== 'undefined' && uni.getFileSystemManager) { this.fileManager = uni.getFileSystemManager(); } } catch (error) { console.warn('文件系统管理器初始化失败,将使用临时文件存储'); } this.eventListeners = new Map(); this.timer = { start: 0, end: 0 }; // 绑定方法 this.handleAudioPlay = this.handleAudioPlay.bind(this); this.handleAudioEnd = this.handleAudioEnd.bind(this); this.handleAudioError = this.handleAudioError.bind(this); } /** * 文字转语音方法 * @param text - 需要转换的文本 */ textToSpeech(text) { return __awaiter(this, void 0, void 0, function* () { if (!text) return; // 检查是否已经在队列中 if (this.audioTextQueue.some(item => item.text === text)) { return; } this.emit(EventType.SYNTHESIS_START, { text }); if (this.audioQueue.length === 0) { this.startTimer(); this.totalAudioCount = 0; } // 等待之前的合成完成 if (this.state.isSynthesizing) { yield new Promise((resolve) => { const checkSynthesis = setInterval(() => { if (!this.state.isSynthesizing) { clearInterval(checkSynthesis); resolve(); } }, 100); }); } this.setState({ isSynthesizing: true, isError: false }); try { const response = yield this.requestSynthesis(text); yield this.processAudioResponse(response, text); yield this.startPlayingIfNeeded(); this.emit(EventType.SYNTHESIS_END, { text }); } catch (error) { this.emit(EventType.ERROR, { error, text }); throw error; } finally { this.setState({ isSynthesizing: false }); } }); } /** * 请求语音合成 * @param text - 需要合成的文本 * @private */ requestSynthesis(text) { return __awaiter(this, void 0, void 0, function* () { try { return new Promise((resolve, reject) => { var _a, _b; uni.request({ url: `https://api.minimax.chat/v1/t2a_v2?GroupId=${this.GroupId}`, method: 'POST', header: { "Authorization": `Bearer ${this.API_KEY}`, "Content-Type": "application/json;charset=utf-8" }, data: Object.assign({ // 默认配置 model: "speech-01-turbo-240228", text, stream: false, voice_setting: Object.assign({ voice_id: 'female-yujie', speed: 1.2, vol: 1.0 }, (((_a = this.modelConfig) === null || _a === void 0 ? void 0 : _a.voice_setting) || {}) // 合并用户配置的voice_setting ), audio_setting: Object.assign({ audio_sample_rate: 32000, bitrate: 128000, format: "mp3", channel: 2 }, (((_b = this.modelConfig) === null || _b === void 0 ? void 0 : _b.audio_setting) || {}) // 合并用户配置的audio_setting ) }, (this.modelConfig || {}) // 合并其他用户配置 ), success: (res) => { var _a, _b; if (!((_b = (_a = res.data) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.audio)) { reject(new Error('API 响应格式错误: ' + JSON.stringify(res.data))); return; } resolve(res); }, fail: (error) => { reject(new Error('请求失败: ' + JSON.stringify(error))); } }); }); } catch (error) { console.error('语音合成请求失败:', error); throw error; } }); } /** * 处理音频响应数据 * @param response - 响应数据 * @param text - 对应的文本内容 * @private */ processAudioResponse(response, text) { return __awaiter(this, void 0, void 0, function* () { var _a, _b; if (!((_b = (_a = response === null || response === void 0 ? void 0 : response.data) === null || _a === void 0 ? void 0 : _a.data) === null || _b === void 0 ? void 0 : _b.audio)) { throw new Error('无效的音频数据应答'); } try { // 检查是否已经存在相同的文本 if (this.audioTextQueue.some(item => item.text === text)) { return; } const audio = response.data.data.audio; if (typeof audio !== 'string' || audio.length === 0) { throw new Error('无效的音频数据格式'); } const binaryArray = new Uint8Array(audio.match(/.{1,2}/g).map(byte => parseInt(byte, 16))); let tempFilePath; if (this.fileManager) { tempFilePath = `${uni.env.USER_DATA_PATH}/audio-${Date.now()}.mp3`; yield this.writeAudioFile(tempFilePath, binaryArray.buffer); } else { const blob = new Blob([binaryArray], { type: 'audio/mp3' }); tempFilePath = URL.createObjectURL(blob); } this.audioQueue.push(tempFilePath); this.audioTextQueue.push({ text, audio: tempFilePath }); this.totalAudioCount++; // 增加总数计数 } catch (error) { console.error('处理音频数据失败:', error); throw new Error(`音频数据处理失败: ${error}`); } }); } /** * 写入音频文件 * @param filePath - 文件路径 * @param buffer - 音频数据 * @private */ writeAudioFile(filePath, buffer) { if (!this.fileManager) { return Promise.resolve(); } return new Promise((resolve, reject) => { this.fileManager.writeFile({ filePath, data: buffer, encoding: 'binary', success: () => resolve(), fail: reject }); }); } /** * 开始播放音频队列 * @private */ playAudioQueue() { return __awaiter(this, void 0, void 0, function* () { if (this.audioQueue.length === 0) { this.setState({ isStartPlayQueue: false, isEnded: true, isPlaying: false }); return; } if (this.state.isPlaying) return; // 销毁旧的音频上下文(如果存在) this.destroyAudioContext(); // 创建新的音频上下文 this.audioContext = uni.createInnerAudioContext(); this.bindAudioEvents(); this.setState({ isStartPlayQueue: true, isPlaying: true, isEnded: false }); const currentAudio = this.audioQueue[0]; const currentItem = this.audioTextQueue[0]; this.audioContext.src = currentAudio; try { yield this.safePlay(); } catch (error) { console.warn('播放失败:', error); this.audioQueue.shift(); this.audioTextQueue.shift(); this.setState({ isPlaying: false, needsUserInteraction: true }); this.pendingAudioUrl = currentAudio; this.destroyAudioContext(); } }); } /** * 安全播放方法 * @private */ safePlay() { return __awaiter(this, void 0, void 0, function* () { return new Promise((resolve, reject) => { try { this.audioContext.play(); resolve(); } catch (error) { console.error('播放失败:', error); reject(error); } }); }); } /** * 手动触发播放 */ manualPlay() { return __awaiter(this, void 0, void 0, function* () { if (this.state.needsUserInteraction && this.pendingAudioUrl) { this.state.needsUserInteraction = false; if (!this.audioContext) { this.audioContext = uni.createInnerAudioContext(); this.bindAudioEvents(); } this.audioContext.src = this.pendingAudioUrl; try { yield this.safePlay(); this.pendingAudioUrl = null; } catch (error) { console.error('手动播放失败:', error); this.handleError(error); } } }); } /** * 绑定音频事件 * @private */ bindAudioEvents() { if (!this.audioContext) return; // 先解绑之前的事件(如果有的话) try { this.audioContext.offPlay(this.handleAudioPlay); } catch (e) { console.warn('移除播放事件监听失败:', e); } // 重新绑定事件 this.audioContext.onPlay(this.handleAudioPlay); this.audioContext.onEnded(this.handleAudioEnd); this.audioContext.onError(this.handleAudioError); } /** * 处理音频播放开始事件 * @private */ handleAudioPlay() { const currentAudio = this.audioQueue[0]; const currentItem = this.audioTextQueue[0]; // 确保只有在真正开始播放时才更新状态和触发事件 if (currentAudio && currentItem) { this.setState({ isPlaying: true, isPaused: false }); this.emit(EventType.AUDIO_PLAY, { currentAudio, currentText: currentItem.text, remainingCount: this.audioQueue.length - 1, totalCount: this.totalAudioCount, progress: this.calculateProgress() }); } } /** * 处理音频播放结束事件 * @private */ handleAudioEnd() { const finishedAudio = this.audioQueue[0]; const finishedItem = this.audioTextQueue[0]; if (!finishedAudio || !finishedItem) return; // 先设置播放状态为 false this.setState({ isPlaying: false }); // 检查是否还有待播放的音频 if (this.audioQueue.length > 1) { // 移除当前音频 this.audioQueue.shift(); this.audioTextQueue.shift(); // 发送进度事件 this.emit(EventType.PROGRESS, { progress: this.calculateProgress(), playedCount: this.totalAudioCount - this.audioQueue.length, totalCount: this.totalAudioCount, currentText: finishedItem.text, remainingCount: this.audioQueue.length - 1 }); // 确保在开始下一个播放前销毁当前上下文 this.destroyAudioContext(); // 使用 setTimeout 确保异步执行下一个播放 setTimeout(() => { this.playAudioQueue(); }, 0); } else { // 最后一个音频播放完成 this.audioQueue.shift(); this.audioTextQueue.shift(); this.setState({ isEnded: true, isStartPlayQueue: false, isPlaying: false, isPaused: false }); this.emit(EventType.AUDIO_END, { finishedAudio, finishedText: finishedItem.text, remainingCount: 0, isComplete: true, progress: 100, totalCount: this.totalAudioCount, playedCount: this.totalAudioCount }); this.destroyAudioContext(); this.resetTextProcessor(); } } /** * 处理音频错误事件 * @private */ handleAudioError(res) { console.error('音频播放错误:', res); this.state.isError = true; this.destroyAudioContext(); } /** * 销毁音频上下文 * @private */ destroyAudioContext() { if (this.audioContext) { // 在销毁前先停止播放 try { this.audioContext.stop(); } catch (e) { console.warn('停止音频播放失败:', e); } // 移除事件监听 (只使用 offPlay,因为其他的 off 方法不存在) try { this.audioContext.offPlay(this.handleAudioPlay); } catch (e) { console.warn('移除播放事件监听失败:', e); } // 销毁音频上下文 this.audioContext.destroy(); this.audioContext = null; } } /** * 设置状态 * @private */ setState(newState) { const oldState = Object.assign({}, this.state); this.state = Object.assign(Object.assign({}, this.state), newState); this.emit(EventType.STATE_CHANGE, { oldState, newState: this.getState(), changes: Object.keys(newState) }); } /** * 获取当前状态 */ getState() { return Object.assign({}, this.state); } /** * 重置所有状态 */ reset() { Object.keys(this.state).forEach(key => { this.state[key] = false; }); this.audioQueue = []; this.audioTextQueue = []; this.totalAudioCount = 0; // 重置总数计数 this.destroyAudioContext(); this.setState({ isEnded: false }); } /** * 添加事件监听 */ on(event, callback) { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, new Set()); } this.eventListeners.get(event).add(callback); } /** * 移除事件监听 */ off(event, callback) { const listeners = this.eventListeners.get(event); if (listeners) { listeners.delete(callback); } } /** * 触发事件 * @private */ emit(event, data) { const listeners = this.eventListeners.get(event); if (listeners) { listeners.forEach(callback => callback(data)); } } /** * 开始计时 * @private */ startTimer() { this.timer.start = performance.now(); } /** * 结束计时并返回时(毫秒) * @private */ endTimer() { this.timer.end = performance.now(); return this.timer.end - this.timer.start; } /** * 批量处理文本 */ processText(text) { return __awaiter(this, void 0, void 0, function* () { if (!text.trim()) return; // 累积文本到 modelContent this.modelContent += text; // 检查是否有完整的句子(以句号、感叹号、问号结尾) const endPunctuations = ['。', '!', '?']; let hasCompleteSegment = endPunctuations.some(punct => this.modelContent.includes(punct)); // 如果没有完整的句子,继续累积 if (!hasCompleteSegment) { return; } // 获取分段 const segments = this.processContent(this.modelContent); this.modelContent = ''; // 清空累积的内容 if (segments.length === 0) return; // 创建已处理文本集合 const processedTexts = new Set(); // 处理每个分段 for (const segment of segments) { // 检查是否已经处理过 if (processedTexts.has(segment) || this.audioTextQueue.some(item => item.text === segment)) { continue; } // 添加到已处理集合 processedTexts.add(segment); // 合成语音 yield this.textToSpeech(segment); } }); } /** * 处理文本分段 * @private */ processContent(inputText) { // 定义标点符号及其优先级 const punctuationGroups = [ ['。', '!', '?'], // 高优先级 [';', ':'], // 中优先级 [',', '、'] // 低优先级 ]; // 最大分段长度(字符数) const MAX_SEGMENT_LENGTH = 100; let currentText = inputText; const segments = []; // 如果文本很短,直接作为一个分段 if (currentText.length <= MAX_SEGMENT_LENGTH) { if (currentText.trim()) { segments.push(currentText.trim()); } return segments; } // 处理长文本 while (currentText.length > 0) { let segmentEnd = -1; let foundPunctuation = ''; // 按优先级查找标点符号 for (const punctuationGroup of punctuationGroups) { for (const punctuation of punctuationGroup) { const index = currentText.indexOf(punctuation); if (index !== -1 && (index <= MAX_SEGMENT_LENGTH || segmentEnd === -1)) { segmentEnd = index; foundPunctuation = punctuation; break; } } if (segmentEnd !== -1 && segmentEnd <= MAX_SEGMENT_LENGTH) break; } // 如果找到的分段点超过最大长度,或者没找到分段点 if (segmentEnd === -1 || segmentEnd > MAX_SEGMENT_LENGTH) { segmentEnd = Math.min(MAX_SEGMENT_LENGTH, currentText.length); // 向前查找合适的分段点 while (segmentEnd > MAX_SEGMENT_LENGTH * 0.8) { if (this.isGoodSplitPoint(currentText, segmentEnd)) { break; } segmentEnd--; } } // 提取分段 const segment = currentText.substring(0, segmentEnd + 1); if (segment.trim()) { segments.push(segment.trim()); } // 更新剩余文本 currentText = currentText.substring(segmentEnd + 1); } return segments; } /** * 强制处理剩余文本 */ flushRemainingText() { return __awaiter(this, void 0, void 0, function* () { if (this.modelContent.trim()) { const segments = this.processContent(this.modelContent); this.modelContent = ''; for (const segment of segments) { if (segment.trim() && !this.audioTextQueue.some(item => item.text === segment)) { yield this.textToSpeech(segment); } } } }); } /** * 重置文本处理状态 */ resetTextProcessor() { this.modelContent = ''; } /** * 暂停播放 */ pause() { if (this.audioContext && this.state.isPlaying) { this.audioContext.pause(); this.setState({ isPaused: true, isPlaying: false }); this.emit(EventType.PAUSE, {}); } } /** * 恢复播放 */ resume() { if (this.audioContext && this.state.isPaused) { this.audioContext.play(); this.setState({ isPaused: false, isPlaying: true }); this.emit(EventType.RESUME, {}); } } /** * 切换播放/暂停状态 */ togglePlay() { if (this.state.isPaused) { this.resume(); } else if (this.state.isPlaying) { this.pause(); } } /** * 如果需要,开始播放音频 * @private */ startPlayingIfNeeded() { return __awaiter(this, void 0, void 0, function* () { if (!this.state.isStartPlayQueue) { yield this.playAudioQueue(); } }); } /** * 处理错误 * @private */ handleError(error) { console.error('语音合成错误:', error); this.setState({ isError: true }); this.destroyAudioContext(); // 清理队列的所有文件 if (this.fileManager) { this.audioQueue.forEach(filePath => { try { this.fileManager.unlinkSync(filePath); } catch (e) { console.warn('清理音频文件失败:', e); } }); } this.audioQueue = []; throw error; } // 添加进度计算辅助方法 calculateProgress() { if (this.totalAudioCount === 0) return 0; const playedCount = this.totalAudioCount - this.audioQueue.length; return Math.round((playedCount / this.totalAudioCount) * 100); } /** * 判断是否是合适的分段点 * @private */ isGoodSplitPoint(text, index) { // 检查前后字符,避免切断词语 const prevChar = text[index - 1]; const nextChar = text[index]; // 定义可以作为分段点的字符 const splitChars = new Set([' ', ',', '、', '。', '!', '?', ';', ':']); // 如果当前位置是标点符号,可以分段 if (splitChars.has(prevChar) || splitChars.has(nextChar)) { return true; } // 检查是否会切断英文单词 const isPartOfEnglishWord = (char) => /[a-zA-Z]/.test(char); if (isPartOfEnglishWord(prevChar) && isPartOfEnglishWord(nextChar)) { return false; } // 检查是否会切断数字 const isPartOfNumber = (char) => /[0-9]/.test(char); if (isPartOfNumber(prevChar) && isPartOfNumber(nextChar)) { return false; } // 其他情况,可以分段 return true; } } export default SpeechSynthesisUtil;