Files

133 lines
4.2 KiB
JavaScript
Raw Permalink Normal View History

// ════════════════════════════════════════════
// 语音运行时 - 统一合成与互斥播放
// ════════════════════════════════════════════
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;
}
}