uniapp-text-to-speech
Version:
uniapp 文本转语音
729 lines (728 loc) • 25.7 kB
JavaScript
/**
* 语音合成工具类
* @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;