/**
|
* 语音合成工具类
|
* @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;
|