/**
|
* 语音合成工具类
|
* @class SpeechSynthesisUtil
|
* @description 用于处理文字转语音的工具类,包含音频播放、合成等功能
|
*/
|
|
// 事件类型枚举
|
export enum EventType {
|
STATE_CHANGE = 'stateChange',
|
SYNTHESIS_START = 'synthesisStart',
|
SYNTHESIS_END = 'synthesisEnd',
|
AUDIO_PLAY = 'audioPlay',
|
AUDIO_END = 'audioEnd',
|
ERROR = 'error',
|
PROGRESS = 'progress',
|
PAUSE = 'pause',
|
RESUME = 'resume'
|
}
|
|
// 状态接口
|
interface State {
|
isInterrupted: boolean;
|
isStartPlayQueue: boolean;
|
isPlaying: boolean;
|
isEnded: boolean;
|
isError: boolean;
|
isPaused: boolean;
|
isSynthesizing: boolean;
|
needsUserInteraction: boolean;
|
}
|
|
// 构造函数选项接口
|
interface SpeechSynthesisOptions {
|
API_KEY: string;
|
GroupId: string;
|
MAX_QUEUE_LENGTH?: number;
|
modelConfig?: any;
|
}
|
|
// 音频文本队列项接口
|
interface AudioTextQueueItem {
|
text: string;
|
audio: string;
|
}
|
|
// API响应接口
|
interface ApiResponse {
|
data: {
|
data: {
|
audio: string;
|
};
|
};
|
}
|
|
class SpeechSynthesisUtil {
|
private API_KEY: string;
|
private GroupId: string;
|
private modelConfig: any;
|
private audioContext: UniApp.InnerAudioContext | null = null;
|
private audioQueue: string[] = [];
|
private audioTextQueue: AudioTextQueueItem[] = [];
|
private state: State;
|
private fileManager: UniApp.FileSystemManager | null = null;
|
private pendingAudioUrl: string | null = null;
|
private eventListeners: Map<EventType, Set<Function>>;
|
private timer: { start: number; end: number };
|
private modelContent: string = '';
|
private MAX_QUEUE_LENGTH: number;
|
private totalAudioCount: number = 0; // 记录总的音频数量
|
|
constructor(options: SpeechSynthesisOptions) {
|
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 - 需要转换的文本
|
*/
|
public async textToSpeech(text: string): Promise<void> {
|
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) {
|
await new Promise<void>((resolve) => {
|
const checkSynthesis = setInterval(() => {
|
if (!this.state.isSynthesizing) {
|
clearInterval(checkSynthesis);
|
resolve();
|
}
|
}, 100);
|
});
|
}
|
|
this.setState({ isSynthesizing: true, isError: false });
|
|
try {
|
const response = await this.requestSynthesis(text);
|
await this.processAudioResponse(response, text);
|
await 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
|
*/
|
private async requestSynthesis(text: string): Promise<ApiResponse> {
|
try {
|
return new Promise((resolve, reject) => {
|
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: {
|
// 默认配置
|
model: "speech-01-turbo-240228",
|
text,
|
stream: false,
|
voice_setting: {
|
voice_id: 'female-yujie',
|
speed: 1.2,
|
vol: 1.0,
|
...(this.modelConfig?.voice_setting || {}) // 合并用户配置的voice_setting
|
},
|
audio_setting: {
|
audio_sample_rate: 32000,
|
bitrate: 128000,
|
format: "mp3",
|
channel: 2,
|
...(this.modelConfig?.audio_setting || {}) // 合并用户配置的audio_setting
|
},
|
...(this.modelConfig || {}) // 合并其他用户配置
|
},
|
success: (res: ApiResponse) => {
|
if (!res.data?.data?.audio) {
|
reject(new Error('API 响应格式错误: ' + JSON.stringify(res.data)));
|
return;
|
}
|
resolve(res);
|
},
|
fail: (error: any) => {
|
reject(new Error('请求失败: ' + JSON.stringify(error)));
|
}
|
});
|
});
|
} catch (error) {
|
console.error('语音合成请求失败:', error);
|
throw error;
|
}
|
}
|
|
/**
|
* 处理音频响应数据
|
* @param response - 响应数据
|
* @param text - 对应的文本内容
|
* @private
|
*/
|
private async processAudioResponse(response: ApiResponse, text: string): Promise<void> {
|
if (!response?.data?.data?.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: string;
|
if (this.fileManager) {
|
tempFilePath = `${uni.env.USER_DATA_PATH}/audio-${Date.now()}.mp3`;
|
await 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
|
*/
|
private writeAudioFile(filePath: string, buffer: ArrayBuffer): Promise<void> {
|
if (!this.fileManager) {
|
return Promise.resolve();
|
}
|
|
return new Promise((resolve, reject) => {
|
this.fileManager!.writeFile({
|
filePath,
|
data: buffer,
|
encoding: 'binary',
|
success: () => resolve(),
|
fail: reject
|
});
|
});
|
}
|
|
/**
|
* 开始播放音频队列
|
* @private
|
*/
|
private async playAudioQueue(): Promise<void> {
|
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 {
|
await 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
|
*/
|
private async safePlay(): Promise<void> {
|
return new Promise((resolve, reject) => {
|
try {
|
this.audioContext!.play();
|
resolve();
|
} catch (error) {
|
console.error('播放失败:', error);
|
reject(error);
|
}
|
});
|
}
|
|
/**
|
* 手动触发播放
|
*/
|
public async manualPlay(): Promise<void> {
|
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 {
|
await this.safePlay();
|
this.pendingAudioUrl = null;
|
} catch (error) {
|
console.error('手动播放失败:', error);
|
this.handleError(error);
|
}
|
}
|
}
|
|
/**
|
* 绑定音频事件
|
* @private
|
*/
|
private bindAudioEvents(): void {
|
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
|
*/
|
private handleAudioPlay(): void {
|
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
|
*/
|
private handleAudioEnd(): void {
|
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
|
*/
|
private handleAudioError(res: any): void {
|
console.error('音频播放错误:', res);
|
this.state.isError = true;
|
this.destroyAudioContext();
|
}
|
|
/**
|
* 销毁音频上下文
|
* @private
|
*/
|
private destroyAudioContext(): void {
|
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
|
*/
|
private setState(newState: Partial<State>): void {
|
const oldState = { ...this.state };
|
this.state = { ...this.state, ...newState };
|
|
this.emit(EventType.STATE_CHANGE, {
|
oldState,
|
newState: this.getState(),
|
changes: Object.keys(newState)
|
});
|
}
|
|
/**
|
* 获取当前状态
|
*/
|
public getState(): State {
|
return { ...this.state };
|
}
|
|
/**
|
* 重置所有状态
|
*/
|
public reset(): void {
|
Object.keys(this.state).forEach(key => {
|
(this.state as any)[key] = false;
|
});
|
this.audioQueue = [];
|
this.audioTextQueue = [];
|
this.totalAudioCount = 0; // 重置总数计数
|
this.destroyAudioContext();
|
this.setState({ isEnded: false });
|
}
|
|
/**
|
* 添加事件监听
|
*/
|
public on(event: EventType, callback: Function): void {
|
if (!this.eventListeners.has(event)) {
|
this.eventListeners.set(event, new Set());
|
}
|
this.eventListeners.get(event)!.add(callback);
|
}
|
|
/**
|
* 移除事件监听
|
*/
|
public off(event: EventType, callback: Function): void {
|
const listeners = this.eventListeners.get(event);
|
if (listeners) {
|
listeners.delete(callback);
|
}
|
}
|
|
/**
|
* 触发事件
|
* @private
|
*/
|
private emit(event: EventType, data: any): void {
|
const listeners = this.eventListeners.get(event);
|
if (listeners) {
|
listeners.forEach(callback => callback(data));
|
}
|
}
|
|
/**
|
* 开始计时
|
* @private
|
*/
|
private startTimer(): void {
|
this.timer.start = performance.now();
|
}
|
|
/**
|
* 结束计时并返回时(毫秒)
|
* @private
|
*/
|
private endTimer(): number {
|
this.timer.end = performance.now();
|
return this.timer.end - this.timer.start;
|
}
|
|
/**
|
* 批量处理文本
|
*/
|
public async processText(text: string): Promise<void> {
|
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<string>();
|
|
// 处理每个分段
|
for (const segment of segments) {
|
// 检查是否已经处理过
|
if (processedTexts.has(segment) ||
|
this.audioTextQueue.some(item => item.text === segment)) {
|
continue;
|
}
|
|
// 添加到已处理集合
|
processedTexts.add(segment);
|
|
// 合成语音
|
await this.textToSpeech(segment);
|
}
|
}
|
|
/**
|
* 处理文本分段
|
* @private
|
*/
|
private processContent(inputText: string): string[] {
|
// 定义标点符号及其优先级
|
const punctuationGroups = [
|
['。', '!', '?'], // 高优先级
|
[';', ':'], // 中优先级
|
[',', '、'] // 低优先级
|
];
|
|
// 最大分段长度(字符数)
|
const MAX_SEGMENT_LENGTH = 100;
|
|
let currentText = inputText;
|
const segments: string[] = [];
|
|
// 如果文本很短,直接作为一个分段
|
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;
|
}
|
|
/**
|
* 强制处理剩余文本
|
*/
|
public async flushRemainingText(): Promise<void> {
|
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)) {
|
await this.textToSpeech(segment);
|
}
|
}
|
}
|
}
|
|
/**
|
* 重置文本处理状态
|
*/
|
public resetTextProcessor(): void {
|
this.modelContent = '';
|
}
|
|
/**
|
* 暂停播放
|
*/
|
public pause(): void {
|
if (this.audioContext && this.state.isPlaying) {
|
this.audioContext.pause();
|
this.setState({
|
isPaused: true,
|
isPlaying: false
|
});
|
this.emit(EventType.PAUSE, {});
|
}
|
}
|
|
/**
|
* 恢复播放
|
*/
|
public resume(): void {
|
if (this.audioContext && this.state.isPaused) {
|
this.audioContext.play();
|
this.setState({
|
isPaused: false,
|
isPlaying: true
|
});
|
this.emit(EventType.RESUME, {});
|
}
|
}
|
|
/**
|
* 切换播放/暂停状态
|
*/
|
public togglePlay(): void {
|
if (this.state.isPaused) {
|
this.resume();
|
} else if (this.state.isPlaying) {
|
this.pause();
|
}
|
}
|
|
/**
|
* 如果需要,开始播放音频
|
* @private
|
*/
|
private async startPlayingIfNeeded(): Promise<void> {
|
if (!this.state.isStartPlayQueue) {
|
await this.playAudioQueue();
|
}
|
}
|
|
/**
|
* 处理错误
|
* @private
|
*/
|
private handleError(error: any): void {
|
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;
|
}
|
|
// 添加进度计算辅助方法
|
private calculateProgress(): number {
|
if (this.totalAudioCount === 0) return 0;
|
const playedCount = this.totalAudioCount - this.audioQueue.length;
|
return Math.round((playedCount / this.totalAudioCount) * 100);
|
}
|
|
/**
|
* 判断是否是合适的分段点
|
* @private
|
*/
|
private isGoodSplitPoint(text: string, index: number): boolean {
|
// 检查前后字符,避免切断词语
|
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: string) => /[a-zA-Z]/.test(char);
|
if (isPartOfEnglishWord(prevChar) && isPartOfEnglishWord(nextChar)) {
|
return false;
|
}
|
|
// 检查是否会切断数字
|
const isPartOfNumber = (char: string) => /[0-9]/.test(char);
|
if (isPartOfNumber(prevChar) && isPartOfNumber(nextChar)) {
|
return false;
|
}
|
|
// 其他情况,可以分段
|
return true;
|
}
|
}
|
|
export default SpeechSynthesisUtil;
|