Files
LittleWhiteBox/modules/fourth-wall/fw-voice-runtime.js

133 lines
4.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ════════════════════════════════════════════
// 语音运行时 - 统一合成与互斥播放
// ════════════════════════════════════════════
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;
}
}