133 lines
4.2 KiB
JavaScript
133 lines
4.2 KiB
JavaScript
|
|
// ════════════════════════════════════════════
|
|||
|
|
// 语音运行时 - 统一合成与互斥播放
|
|||
|
|
// ════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
import { normalizeEmotion } from '../tts/tts-text.js';
|
|||
|
|
|
|||
|
|
let currentAudio = null;
|
|||
|
|
let currentObjectUrl = null;
|
|||
|
|
let currentAbort = null;
|
|||
|
|
let currentRequestId = null;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 合成并播放语音(后播覆盖前播)
|
|||
|
|
*
|
|||
|
|
* @param {string} text - 要合成的文本
|
|||
|
|
* @param {string} [emotion] - 原始情绪字符串(自动 normalize)
|
|||
|
|
* @param {Object} [callbacks] - 状态回调
|
|||
|
|
* @param {string} [callbacks.requestId] - 请求标识,用于防时序错乱
|
|||
|
|
* @param {(state: string, info?: object) => void} [callbacks.onState] - 状态变化回调
|
|||
|
|
* state: 'loading' | 'playing' | 'ended' | 'error' | 'stopped'
|
|||
|
|
* info: { duration?, message? }
|
|||
|
|
* @returns {{ stop: () => void }} 控制句柄
|
|||
|
|
*/
|
|||
|
|
export function synthesizeAndPlay(text, emotion, callbacks) {
|
|||
|
|
const requestId = callbacks?.requestId || `vr_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|||
|
|
const onState = callbacks?.onState;
|
|||
|
|
|
|||
|
|
// 停掉前一个(回传 stopped)
|
|||
|
|
stopCurrent();
|
|||
|
|
|
|||
|
|
const abortController = new AbortController();
|
|||
|
|
currentAbort = abortController;
|
|||
|
|
currentRequestId = requestId;
|
|||
|
|
|
|||
|
|
const notify = (state, info) => {
|
|||
|
|
if (currentRequestId !== requestId && state !== 'stopped') return;
|
|||
|
|
onState?.(state, info);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
notify('loading');
|
|||
|
|
|
|||
|
|
const run = async () => {
|
|||
|
|
const synthesize = window.xiaobaixTts?.synthesize;
|
|||
|
|
if (typeof synthesize !== 'function') {
|
|||
|
|
throw new Error('请先启用 TTS 模块');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const blob = await synthesize(text, {
|
|||
|
|
emotion: normalizeEmotion(emotion || ''),
|
|||
|
|
signal: abortController.signal,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (abortController.signal.aborted) return;
|
|||
|
|
if (currentRequestId !== requestId) return;
|
|||
|
|
|
|||
|
|
const url = URL.createObjectURL(blob);
|
|||
|
|
const audio = new Audio(url);
|
|||
|
|
|
|||
|
|
// 清理旧的(理论上 stopCurrent 已清理,防御性)
|
|||
|
|
cleanup();
|
|||
|
|
|
|||
|
|
currentAudio = audio;
|
|||
|
|
currentObjectUrl = url;
|
|||
|
|
|
|||
|
|
audio.onloadedmetadata = () => {
|
|||
|
|
if (currentRequestId !== requestId) return;
|
|||
|
|
notify('playing', { duration: audio.duration || 0 });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
audio.onended = () => {
|
|||
|
|
if (currentRequestId !== requestId) return;
|
|||
|
|
notify('ended');
|
|||
|
|
cleanup();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
audio.onerror = () => {
|
|||
|
|
if (currentRequestId !== requestId) return;
|
|||
|
|
notify('error', { message: '播放失败' });
|
|||
|
|
cleanup();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
await audio.play();
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
run().catch(err => {
|
|||
|
|
if (abortController.signal.aborted) return;
|
|||
|
|
if (currentRequestId !== requestId) return;
|
|||
|
|
notify('error', { message: err?.message || '合成失败' });
|
|||
|
|
cleanup();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
stop() {
|
|||
|
|
if (currentRequestId === requestId) {
|
|||
|
|
stopCurrent();
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 停止当前语音(合成中止 + 播放停止 + 资源回收)
|
|||
|
|
*/
|
|||
|
|
export function stopCurrent() {
|
|||
|
|
if (currentAbort) {
|
|||
|
|
try { currentAbort.abort(); } catch { }
|
|||
|
|
currentAbort = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
cleanup();
|
|||
|
|
|
|||
|
|
currentRequestId = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
// 内部
|
|||
|
|
// ════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
function cleanup() {
|
|||
|
|
if (currentAudio) {
|
|||
|
|
currentAudio.onloadedmetadata = null;
|
|||
|
|
currentAudio.onended = null;
|
|||
|
|
currentAudio.onerror = null;
|
|||
|
|
try { currentAudio.pause(); } catch { }
|
|||
|
|
currentAudio = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (currentObjectUrl) {
|
|||
|
|
URL.revokeObjectURL(currentObjectUrl);
|
|||
|
|
currentObjectUrl = null;
|
|||
|
|
}
|
|||
|
|
}
|