Files
LittleWhiteBox/modules/tts/tts.js

1335 lines
44 KiB
JavaScript
Raw Normal View History

2026-01-17 16:34:39 +08:00
// ============ 导入 ============
import { event_types } from "../../../../../../script.js";
import { extension_settings, getContext } from "../../../../../extensions.js";
import { EXT_ID, extensionFolderPath } from "../../core/constants.js";
import { createModuleEvents } from "../../core/event-manager.js";
import { TtsStorage } from "../../core/server-storage.js";
import { extractSpeakText, parseTtsSegments, DEFAULT_SKIP_TAGS, normalizeEmotion, splitTtsSegmentsForFree } from "./tts-text.js";
import { TtsPlayer } from "./tts-player.js";
import { synthesizeV3, FREE_DEFAULT_VOICE } from "./tts-api.js";
2026-01-18 01:48:30 +08:00
import {
ensureTtsPanel,
updateTtsPanel,
removeAllTtsPanels,
initTtsPanelStyles,
setPanelConfigHandlers,
updateAutoSpeakAll,
updateSpeedAll,
updateVoiceAll
} from "./tts-panel.js";
2026-01-17 16:34:39 +08:00
import { getCacheEntry, setCacheEntry, getCacheStats, clearExpiredCache, clearAllCache, pruneCache } from './tts-cache.js';
import { speakMessageFree, clearAllFreeQueues, clearFreeQueueForMessage } from './tts-free-provider.js';
import {
speakMessageAuth,
speakSegmentAuth,
inferResourceIdBySpeaker,
buildV3Headers,
speedToV3SpeechRate
} from './tts-auth-provider.js';
import { postToIframe, isTrustedIframeEvent } from "../../core/iframe-messaging.js";
// ============ 常量 ============
const MODULE_ID = 'tts';
const OVERLAY_ID = 'xiaobaix-tts-overlay';
const HTML_PATH = `${extensionFolderPath}/modules/tts/tts-overlay.html`;
const TTS_DIRECTIVE_REGEX = /\[tts:([^\]]*)\]/gi;
const FREE_VOICE_KEYS = new Set([
'female_1', 'female_2', 'female_3', 'female_4', 'female_5', 'female_6', 'female_7',
'male_1', 'male_2', 'male_3', 'male_4'
]);
// ============ NovelDraw 兼容 ============
let ndImageObserver = null;
let ndRenderPending = new Set();
let ndRenderTimer = null;
function scheduleNdRerender(mesText) {
ndRenderPending.add(mesText);
if (ndRenderTimer) return;
ndRenderTimer = setTimeout(() => {
ndRenderTimer = null;
const pending = Array.from(ndRenderPending);
ndRenderPending.clear();
if (!isModuleEnabled()) return;
for (const el of pending) {
if (!el.isConnected) continue;
TTS_DIRECTIVE_REGEX.lastIndex = 0;
// Tests existing message HTML only.
// eslint-disable-next-line no-unsanitized/property
if (TTS_DIRECTIVE_REGEX.test(el.innerHTML)) {
enhanceTtsDirectives(el);
}
}
}, 50);
}
function setupNovelDrawObserver() {
if (ndImageObserver) return;
const chatEl = document.getElementById('chat');
if (!chatEl) return;
ndImageObserver = new MutationObserver((mutations) => {
if (!isModuleEnabled()) return;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const isNdImg = node.classList?.contains('xb-nd-img');
const hasNdImg = isNdImg || node.querySelector?.('.xb-nd-img');
if (!hasNdImg) continue;
const mesText = node.closest('.mes_text');
if (mesText) {
scheduleNdRerender(mesText);
}
}
}
});
ndImageObserver.observe(chatEl, { childList: true, subtree: true });
}
function cleanupNovelDrawObserver() {
ndImageObserver?.disconnect();
ndImageObserver = null;
if (ndRenderTimer) {
clearTimeout(ndRenderTimer);
ndRenderTimer = null;
}
ndRenderPending.clear();
}
// ============ 状态 ============
let player = null;
let moduleInitialized = false;
let overlay = null;
let config = null;
const messageStateMap = new Map();
const cacheCounters = { hits: 0, misses: 0 };
const events = createModuleEvents(MODULE_ID);
// ============ 指令块懒加载 ============
let directiveObserver = null;
const processedDirectives = new WeakSet();
function setupDirectiveObserver() {
if (directiveObserver) return;
directiveObserver = new IntersectionObserver((entries) => {
if (!isModuleEnabled()) return;
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const mesText = entry.target;
if (processedDirectives.has(mesText)) {
directiveObserver.unobserve(mesText);
continue;
}
TTS_DIRECTIVE_REGEX.lastIndex = 0;
// Tests existing message HTML only.
// eslint-disable-next-line no-unsanitized/property
if (TTS_DIRECTIVE_REGEX.test(mesText.innerHTML)) {
enhanceTtsDirectives(mesText);
}
processedDirectives.add(mesText);
directiveObserver.unobserve(mesText);
}
}, { rootMargin: '300px' });
}
function observeDirective(mesText) {
if (!mesText || processedDirectives.has(mesText)) return;
setupDirectiveObserver();
// 已在视口附近,立即处理
const rect = mesText.getBoundingClientRect();
if (rect.top < window.innerHeight + 300 && rect.bottom > -300) {
TTS_DIRECTIVE_REGEX.lastIndex = 0;
// Tests existing message HTML only.
// eslint-disable-next-line no-unsanitized/property
if (TTS_DIRECTIVE_REGEX.test(mesText.innerHTML)) {
enhanceTtsDirectives(mesText);
}
processedDirectives.add(mesText);
return;
}
// 不在视口,加入观察队列
directiveObserver.observe(mesText);
}
function cleanupDirectiveObserver() {
directiveObserver?.disconnect();
directiveObserver = null;
}
// ============ 模块状态检查 ============
function isModuleEnabled() {
if (!moduleInitialized) return false;
try {
const settings = extension_settings[EXT_ID];
if (!settings?.enabled) return false;
if (!settings?.tts?.enabled) return false;
return true;
} catch {
return false;
}
}
// ============ 工具函数 ============
function hashString(input) {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
}
return (hash >>> 0).toString(16);
}
function generateBatchId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function normalizeSpeed(value) {
const num = Number.isFinite(value) ? value : 1.0;
if (num >= 0.5 && num <= 2.0) return num;
return Math.min(2.0, Math.max(0.5, 1 + num / 100));
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ============ 音色来源判断 ============
function getVoiceSource(value) {
if (!value) return 'free';
if (FREE_VOICE_KEYS.has(value)) return 'free';
return 'auth';
}
function isAuthConfigured() {
return !!(config?.volc?.appId && config?.volc?.accessKey);
}
function resolveSpeakerWithSource(speakerName, mySpeakers, defaultSpeaker) {
const list = Array.isArray(mySpeakers) ? mySpeakers : [];
// ★ 调试日志
if (speakerName) {
console.log('[TTS Debug] resolveSpeaker:', {
查找的名称: speakerName,
mySpeakers: list.map(s => ({ name: s.name, value: s.value, source: s.source })),
默认音色: defaultSpeaker
});
}
if (!speakerName) {
const defaultItem = list.find(s => s.value === defaultSpeaker);
return {
value: defaultSpeaker,
source: defaultItem?.source || getVoiceSource(defaultSpeaker)
};
}
const byName = list.find(s => s.name === speakerName);
console.log('[TTS Debug] byName 查找结果:', byName); // ★ 调试
if (byName?.value) {
return {
value: byName.value,
source: byName.source || getVoiceSource(byName.value)
};
}
const byValue = list.find(s => s.value === speakerName);
console.log('[TTS Debug] byValue 查找结果:', byValue); // ★ 调试
if (byValue?.value) {
return {
value: byValue.value,
source: byValue.source || getVoiceSource(byValue.value)
};
}
if (FREE_VOICE_KEYS.has(speakerName)) {
return { value: speakerName, source: 'free' };
}
// ★ 回退到默认,这是问题发生的地方
console.warn('[TTS Debug] 未找到匹配音色,回退到默认:', defaultSpeaker);
const defaultItem = list.find(s => s.value === defaultSpeaker);
return {
value: defaultSpeaker,
source: defaultItem?.source || getVoiceSource(defaultSpeaker)
};
}
// ============ 缓存管理 ============
function buildCacheKey(params) {
const payload = {
providerMode: params.providerMode || 'auth',
text: params.text || '',
speaker: params.speaker || '',
resourceId: params.resourceId || '',
format: params.format || 'mp3',
sampleRate: params.sampleRate || 24000,
speechRate: params.speechRate || 0,
loudnessRate: params.loudnessRate || 0,
emotion: params.emotion || '',
emotionScale: params.emotionScale || 0,
explicitLanguage: params.explicitLanguage || '',
disableMarkdownFilter: params.disableMarkdownFilter !== false,
disableEmojiFilter: params.disableEmojiFilter === true,
enableLanguageDetector: params.enableLanguageDetector === true,
model: params.model || '',
maxLengthToFilterParenthesis: params.maxLengthToFilterParenthesis ?? null,
postProcessPitch: params.postProcessPitch ?? 0,
contextTexts: Array.isArray(params.contextTexts) ? params.contextTexts : [],
freeSpeed: params.freeSpeed ?? null,
};
return `tts:${hashString(JSON.stringify(payload))}`;
}
async function getCacheStatsSafe() {
try {
const stats = await getCacheStats();
return { ...stats, hits: cacheCounters.hits, misses: cacheCounters.misses };
} catch {
return { count: 0, sizeMB: '0', totalBytes: 0, hits: cacheCounters.hits, misses: cacheCounters.misses };
}
}
async function tryLoadLocalCache(params) {
if (!config.volc.localCacheEnabled) return null;
const key = buildCacheKey(params);
try {
const entry = await getCacheEntry(key);
if (entry?.blob) {
const cutoff = Date.now() - (config.volc.cacheDays || 7) * 24 * 60 * 60 * 1000;
if (entry.createdAt && entry.createdAt < cutoff) {
await clearExpiredCache(config.volc.cacheDays || 7);
cacheCounters.misses += 1;
return null;
}
cacheCounters.hits += 1;
return { key, entry };
}
cacheCounters.misses += 1;
return null;
} catch {
cacheCounters.misses += 1;
return null;
}
}
async function storeLocalCache(key, blob, meta) {
if (!config.volc.localCacheEnabled) return;
try {
await setCacheEntry(key, blob, meta);
await pruneCache({
maxEntries: config.volc.cacheMaxEntries,
maxBytes: config.volc.cacheMaxMB * 1024 * 1024,
});
} catch {}
}
// ============ 消息状态管理 ============
function ensureMessageState(messageId) {
if (!messageStateMap.has(messageId)) {
messageStateMap.set(messageId, {
messageId,
status: 'idle',
text: '',
textLength: 0,
cached: false,
usage: null,
duration: 0,
progress: 0,
error: '',
audioBlob: null,
cacheKey: '',
updatedAt: 0,
currentSegment: 0,
totalSegments: 0,
});
}
return messageStateMap.get(messageId);
}
function getMessageElement(messageId) {
return document.querySelector(`.mes[mesid="${messageId}"]`);
}
function getMessageData(messageId) {
const context = getContext();
return (context.chat || [])[messageId] || null;
}
function getSpeakTextFromMessage(message) {
if (!message || typeof message.mes !== 'string') return '';
return extractSpeakText(message.mes, {
skipRanges: config.skipRanges,
readRanges: config.readRanges,
readRangesEnabled: config.readRangesEnabled,
});
}
// ============ 队列管理 ============
function clearMessageFromQueue(messageId) {
clearFreeQueueForMessage(messageId);
if (!player) return;
const prefix = `msg-${messageId}-`;
player.queue = player.queue.filter(item => !item.id?.startsWith(prefix));
if (player.currentItem?.messageId === messageId) {
player._stopCurrent(true);
player.currentItem = null;
player.isPlaying = false;
player._playNext();
}
}
// ============ 状态保护更新器 ============
function createProtectedStateUpdater(messageId) {
return (updates) => {
const st = ensureMessageState(messageId);
// 如果播放器正在播放/暂停,保护这个状态不被队列状态覆盖
const isPlayerActive = st.status === 'playing' || st.status === 'paused';
const isQueueStatus = updates.status === 'sending' ||
updates.status === 'queued' ||
updates.status === 'cached';
if (isPlayerActive && isQueueStatus) {
// 只更新进度相关字段,不覆盖播放状态
const rest = { ...updates };
delete rest.status;
Object.assign(st, rest);
} else {
Object.assign(st, updates);
}
updateTtsPanel(messageId, st);
};
}
// ============ 混合模式辅助 ============
function expandMixedSegments(resolvedSegments) {
const expanded = [];
for (const seg of resolvedSegments) {
if (seg.resolvedSource === 'free' && seg.text && seg.text.length > 200) {
const splitSegs = splitTtsSegmentsForFree([{
text: seg.text,
emotion: seg.emotion || '',
context: seg.context || '',
speaker: seg.speaker || '',
}]);
for (const splitSeg of splitSegs) {
expanded.push({
...splitSeg,
resolvedSpeaker: seg.resolvedSpeaker,
resolvedSource: 'free',
});
}
} else {
expanded.push(seg);
}
}
return expanded;
}
async function speakSingleFreeSegment(messageId, segment, segmentIndex, batchId) {
const state = ensureMessageState(messageId);
state.status = 'sending';
state.currentSegment = segmentIndex + 1;
state.text = segment.text;
state.textLength = segment.text.length;
state.updatedAt = Date.now();
updateTtsPanel(messageId, state);
const freeSpeed = normalizeSpeed(config?.volc?.speechRate);
const voiceKey = segment.resolvedSpeaker || FREE_DEFAULT_VOICE;
const emotion = normalizeEmotion(segment.emotion);
const cacheParams = {
providerMode: 'free',
text: segment.text,
speaker: voiceKey,
freeSpeed,
emotion: emotion || '',
};
const cacheHit = await tryLoadLocalCache(cacheParams);
if (cacheHit?.entry?.blob) {
state.cached = true;
state.status = 'cached';
updateTtsPanel(messageId, state);
player.enqueue({
id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`,
messageId,
segmentIndex,
batchId,
audioBlob: cacheHit.entry.blob,
text: segment.text,
});
return;
}
try {
const { synthesizeFreeV1 } = await import('./tts-api.js');
const { audioBase64 } = await synthesizeFreeV1({
text: segment.text,
voiceKey,
speed: freeSpeed,
emotion: emotion || null,
});
const byteString = atob(audioBase64);
const bytes = new Uint8Array(byteString.length);
for (let j = 0; j < byteString.length; j++) {
bytes[j] = byteString.charCodeAt(j);
}
const audioBlob = new Blob([bytes], { type: 'audio/mpeg' });
const cacheKey = buildCacheKey(cacheParams);
storeLocalCache(cacheKey, audioBlob, {
text: segment.text.slice(0, 200),
textLength: segment.text.length,
speaker: voiceKey,
resourceId: 'free',
}).catch(() => {});
state.status = 'queued';
updateTtsPanel(messageId, state);
player.enqueue({
id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`,
messageId,
segmentIndex,
batchId,
audioBlob,
text: segment.text,
});
} catch (err) {
state.status = 'error';
state.error = err?.message || '合成失败';
updateTtsPanel(messageId, state);
}
}
// ============ 主播放入口 ============
async function handleMessagePlayClick(messageId) {
if (!isModuleEnabled()) return;
const state = ensureMessageState(messageId);
if (state.status === 'sending' || state.status === 'queued') {
clearMessageFromQueue(messageId);
state.status = 'idle';
state.currentSegment = 0;
state.totalSegments = 0;
updateTtsPanel(messageId, state);
return;
}
if (player?.currentItem?.messageId === messageId && player?.currentAudio) {
if (player.currentAudio.paused) {
player.currentAudio.play().catch(() => {});
} else {
player.currentAudio.pause();
}
return;
}
await speakMessage(messageId, { mode: 'manual' });
}
async function speakMessage(messageId, { mode = 'manual' } = {}) {
if (!isModuleEnabled()) return;
const message = getMessageData(messageId);
if (!message || message.is_user) return;
const messageEl = getMessageElement(messageId);
if (!messageEl) return;
ensureTtsPanel(messageEl, messageId, handleMessagePlayClick);
const speakText = getSpeakTextFromMessage(message);
if (!speakText.trim()) {
const state = ensureMessageState(messageId);
state.status = 'idle';
state.text = '';
state.currentSegment = 0;
state.totalSegments = 0;
updateTtsPanel(messageId, state);
return;
}
const mySpeakers = config.volc?.mySpeakers || [];
const defaultSpeaker = config.volc.defaultSpeaker || FREE_DEFAULT_VOICE;
const defaultResolved = resolveSpeakerWithSource('', mySpeakers, defaultSpeaker);
let segments = parseTtsSegments(speakText);
if (!segments.length) {
const state = ensureMessageState(messageId);
state.status = 'idle';
state.currentSegment = 0;
state.totalSegments = 0;
updateTtsPanel(messageId, state);
return;
}
const resolvedSegments = segments.map(seg => {
const resolved = seg.speaker
? resolveSpeakerWithSource(seg.speaker, mySpeakers, defaultSpeaker)
: defaultResolved;
return {
...seg,
resolvedSpeaker: resolved.value,
resolvedSource: resolved.source
};
});
const needsAuth = resolvedSegments.some(s => s.resolvedSource === 'auth');
if (needsAuth && !isAuthConfigured()) {
toastr?.warning?.('部分音色需要配置鉴权 API将仅播放免费音色');
const freeOnly = resolvedSegments.filter(s => s.resolvedSource === 'free');
if (!freeOnly.length) {
const state = ensureMessageState(messageId);
state.status = 'error';
state.error = '所有音色均需要鉴权';
updateTtsPanel(messageId, state);
return;
}
resolvedSegments.length = 0;
resolvedSegments.push(...freeOnly);
}
const batchId = generateBatchId();
if (mode === 'manual') clearMessageFromQueue(messageId);
const hasFree = resolvedSegments.some(s => s.resolvedSource === 'free');
const hasAuth = resolvedSegments.some(s => s.resolvedSource === 'auth');
const isMixed = hasFree && hasAuth;
const state = ensureMessageState(messageId);
if (isMixed) {
const expandedSegments = expandMixedSegments(resolvedSegments);
state.totalSegments = expandedSegments.length;
state.currentSegment = 0;
state.status = 'sending';
updateTtsPanel(messageId, state);
const ctx = {
config,
player,
tryLoadLocalCache,
storeLocalCache,
buildCacheKey,
updateState: (updates) => {
Object.assign(state, updates);
updateTtsPanel(messageId, state);
},
};
for (let i = 0; i < expandedSegments.length; i++) {
if (!isModuleEnabled()) return;
const seg = expandedSegments[i];
state.currentSegment = i + 1;
updateTtsPanel(messageId, state);
if (seg.resolvedSource === 'free') {
await speakSingleFreeSegment(messageId, seg, i, batchId);
} else {
await speakSegmentAuth(messageId, seg, i, batchId, {
isFirst: i === 0,
...ctx
});
}
}
return;
}
state.totalSegments = resolvedSegments.length;
state.currentSegment = 0;
state.status = 'sending';
updateTtsPanel(messageId, state);
if (hasFree) {
await speakMessageFree({
messageId,
segments: resolvedSegments,
defaultSpeaker,
mySpeakers,
player,
config,
tryLoadLocalCache,
storeLocalCache,
buildCacheKey,
updateState: createProtectedStateUpdater(messageId),
clearMessageFromQueue,
mode,
});
return;
}
if (hasAuth) {
await speakMessageAuth({
messageId,
segments: resolvedSegments,
batchId,
config,
player,
tryLoadLocalCache,
storeLocalCache,
buildCacheKey,
updateState: (updates) => {
const st = ensureMessageState(messageId);
Object.assign(st, updates);
updateTtsPanel(messageId, st);
},
isModuleEnabled,
});
}
}
// ============ 指令块增强 ============
function parseDirectiveParams(raw) {
const result = { speaker: '', emotion: '', context: '' };
if (!raw) return result;
const parts = String(raw).split(';').map(s => s.trim()).filter(Boolean);
for (const part of parts) {
const idx = part.indexOf('=');
if (idx === -1) continue;
const key = part.slice(0, idx).trim().toLowerCase();
let val = part.slice(idx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key === 'speaker') result.speaker = val;
if (key === 'emotion') result.emotion = val;
if (key === 'context') result.context = val;
}
return result;
}
function buildTtsTagHtml(parsed, rawParams) {
const parts = [];
if (parsed.speaker) parts.push(parsed.speaker);
if (parsed.emotion) parts.push(parsed.emotion);
if (parsed.context) {
const ctx = parsed.context.length > 10
? parsed.context.slice(0, 10) + '…'
: parsed.context;
parts.push(`"${ctx}"`);
}
const hasParams = parts.length > 0;
const title = rawParams ? escapeHtml(rawParams.replace(/;/g, '; ')) : '';
let html = `<span class="xb-tts-tag" data-has-params="${hasParams}"${title ? ` title="${title}"` : ''}>`;
html += `<span class="xb-tts-tag-icon">♪</span>`;
if (hasParams) {
const textParts = parts.map(p => `<span>${escapeHtml(p)}</span>`);
html += textParts.join('<span class="xb-tts-tag-dot"> · </span>');
}
html += `</span>`;
return html;
}
function enhanceTtsDirectives(container) {
if (!container) return;
// Rewrites already-rendered message HTML; no new HTML source is introduced here.
// eslint-disable-next-line no-unsanitized/property
const html = container.innerHTML;
TTS_DIRECTIVE_REGEX.lastIndex = 0;
if (!TTS_DIRECTIVE_REGEX.test(html)) return;
TTS_DIRECTIVE_REGEX.lastIndex = 0;
const enhanced = html.replace(TTS_DIRECTIVE_REGEX, (match, params) => {
const parsed = parseDirectiveParams(params);
return buildTtsTagHtml(parsed, params);
});
if (enhanced !== html) {
// Replaces existing message HTML with enhanced tokens only.
// eslint-disable-next-line no-unsanitized/property
container.innerHTML = enhanced;
}
}
function enhanceAllTtsDirectives() {
if (!isModuleEnabled()) return;
document.querySelectorAll('#chat .mes .mes_text').forEach(mesText => {
observeDirective(mesText);
});
}
function handleDirectiveEnhance(data) {
if (!isModuleEnabled()) return;
setTimeout(() => {
if (!isModuleEnabled()) return;
const messageId = typeof data === 'object'
? (data.messageId ?? data.id ?? data.index ?? data.mesId)
: data;
if (!Number.isFinite(messageId)) {
enhanceAllTtsDirectives();
return;
}
const mesText = document.querySelector(`#chat .mes[mesid="${messageId}"] .mes_text`);
if (mesText) {
processedDirectives.delete(mesText);
observeDirective(mesText);
}
}, 100);
}
function onGenerationEnd() {
if (!isModuleEnabled()) return;
setTimeout(enhanceAllTtsDirectives, 150);
}
// ============ 消息渲染处理 ============
function renderExistingMessageUIs() {
if (!isModuleEnabled()) return;
const context = getContext();
const chat = context.chat || [];
chat.forEach((message, messageId) => {
if (!message || message.is_user) return;
const messageEl = getMessageElement(messageId);
if (!messageEl) return;
ensureTtsPanel(messageEl, messageId, handleMessagePlayClick);
const mesText = messageEl.querySelector('.mes_text');
if (mesText) observeDirective(mesText);
const state = ensureMessageState(messageId);
state.text = '';
state.textLength = 0;
updateTtsPanel(messageId, state);
});
}
async function onCharacterMessageRendered(data) {
if (!isModuleEnabled()) return;
try {
const context = getContext();
const chat = context.chat;
const messageId = data.messageId ?? (chat.length - 1);
const message = chat[messageId];
if (!message || message.is_user) return;
const messageEl = getMessageElement(messageId);
if (!messageEl) return;
ensureTtsPanel(messageEl, messageId, handleMessagePlayClick);
const mesText = messageEl.querySelector('.mes_text');
if (mesText) {
enhanceTtsDirectives(mesText);
processedDirectives.add(mesText);
}
updateTtsPanel(messageId, ensureMessageState(messageId));
if (!config?.autoSpeak) return;
if (!isModuleEnabled()) return;
await speakMessage(messageId, { mode: 'auto' });
} catch {}
}
function onChatChanged() {
clearAllFreeQueues();
if (player) player.clear();
messageStateMap.clear();
removeAllTtsPanels();
setTimeout(() => {
renderExistingMessageUIs();
}, 100);
}
// ============ 配置管理 ============
async function loadConfig() {
config = await TtsStorage.load();
config.volc = config.volc || {};
if (Array.isArray(config.volc.mySpeakers)) {
config.volc.mySpeakers = config.volc.mySpeakers.map(s => ({
...s,
source: s.source || getVoiceSource(s.value)
}));
}
config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false;
config.volc.disableEmojiFilter = config.volc.disableEmojiFilter === true;
config.volc.enableLanguageDetector = config.volc.enableLanguageDetector === true;
config.volc.explicitLanguage = typeof config.volc.explicitLanguage === 'string' ? config.volc.explicitLanguage : '';
config.volc.speechRate = normalizeSpeed(Number.isFinite(config.volc.speechRate) ? config.volc.speechRate : 1.0);
config.volc.maxLengthToFilterParenthesis = Number.isFinite(config.volc.maxLengthToFilterParenthesis) ? config.volc.maxLengthToFilterParenthesis : 100;
config.volc.postProcessPitch = Number.isFinite(config.volc.postProcessPitch) ? config.volc.postProcessPitch : 0;
config.volc.emotionScale = Math.min(5, Math.max(1, Number.isFinite(config.volc.emotionScale) ? config.volc.emotionScale : 5));
config.volc.serverCacheEnabled = config.volc.serverCacheEnabled === true;
config.volc.localCacheEnabled = true;
config.volc.cacheDays = Math.max(1, Number.isFinite(config.volc.cacheDays) ? config.volc.cacheDays : 7);
config.volc.cacheMaxEntries = Math.max(10, Number.isFinite(config.volc.cacheMaxEntries) ? config.volc.cacheMaxEntries : 200);
config.volc.cacheMaxMB = Math.max(10, Number.isFinite(config.volc.cacheMaxMB) ? config.volc.cacheMaxMB : 200);
config.volc.usageReturn = config.volc.usageReturn === true;
config.autoSpeak = config.autoSpeak !== false;
config.skipTags = config.skipTags || [...DEFAULT_SKIP_TAGS];
config.skipCodeBlocks = config.skipCodeBlocks !== false;
config.skipRanges = Array.isArray(config.skipRanges) ? config.skipRanges : [];
config.readRanges = Array.isArray(config.readRanges) ? config.readRanges : [];
config.readRangesEnabled = config.readRangesEnabled === true;
return config;
}
async function saveConfig(updates) {
Object.assign(config, updates);
await TtsStorage.set('volc', config.volc);
await TtsStorage.set('autoSpeak', config.autoSpeak);
await TtsStorage.set('skipRanges', config.skipRanges || []);
await TtsStorage.set('readRanges', config.readRanges || []);
await TtsStorage.set('readRangesEnabled', config.readRangesEnabled === true);
await TtsStorage.set('skipTags', config.skipTags);
await TtsStorage.set('skipCodeBlocks', config.skipCodeBlocks);
try {
return await TtsStorage.saveNow({ silent: false });
} catch {
return false;
}
}
// ============ 设置面板 ============
function openSettings() {
if (document.getElementById(OVERLAY_ID)) return;
overlay = document.createElement('div');
overlay.id = OVERLAY_ID;
// 使用动态高度而非100vh
const updateOverlayHeight = () => {
if (overlay && overlay.style.display !== 'none') {
overlay.style.height = `${window.innerHeight}px`;
}
};
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: ${window.innerHeight}px;
background: rgba(0,0,0,0.5);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
`;
const iframe = document.createElement('iframe');
iframe.src = HTML_PATH;
iframe.style.cssText = `
width: min(1300px, 96vw);
height: min(1050px, 94vh);
max-height: calc(100% - 24px);
border: none;
border-radius: 12px;
background: #1a1a1a;
`;
overlay.appendChild(iframe);
document.body.appendChild(overlay);
// 监听视口变化
window.addEventListener('resize', updateOverlayHeight);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateOverlayHeight);
}
// 存储清理函数
overlay._cleanup = () => {
window.removeEventListener('resize', updateOverlayHeight);
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateOverlayHeight);
}
};
// Guarded by isTrustedIframeEvent (origin + source).
// eslint-disable-next-line no-restricted-syntax
window.addEventListener('message', handleIframeMessage);
}
function closeSettings() {
window.removeEventListener('message', handleIframeMessage);
const overlayEl = document.getElementById(OVERLAY_ID);
if (overlayEl) {
overlayEl._cleanup?.();
overlayEl.remove();
}
overlay = null;
}
async function handleIframeMessage(ev) {
const iframe = overlay?.querySelector('iframe');
if (!isTrustedIframeEvent(ev, iframe)) return;
if (!ev.data?.type?.startsWith('xb-tts:')) return;
const { type, payload } = ev.data;
switch (type) {
case 'xb-tts:ready': {
const cacheStats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:config', payload: { ...config, cacheStats } });
break;
}
case 'xb-tts:close':
closeSettings();
break;
case 'xb-tts:save-config': {
const ok = await saveConfig(payload);
if (ok) {
const cacheStats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } });
2026-01-18 01:48:30 +08:00
updateAutoSpeakAll();
updateSpeedAll();
updateVoiceAll();
2026-01-17 16:34:39 +08:00
} else {
postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } });
}
break;
}
case 'xb-tts:toast':
if (payload.type === 'error') toastr.error(payload.message);
else if (payload.type === 'success') toastr.success(payload.message);
else toastr.info(payload.message);
break;
case 'xb-tts:test-speak':
await handleTestSpeak(payload, iframe);
break;
case 'xb-tts:clear-queue':
player.clear();
break;
case 'xb-tts:cache-refresh': {
const stats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats });
break;
}
case 'xb-tts:cache-clear-expired': {
const removed = await clearExpiredCache(config.volc.cacheDays || 7);
const stats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats });
postToIframe(iframe, { type: 'xb-tts:toast', payload: { type: 'success', message: `已清理 ${removed}` } });
break;
}
case 'xb-tts:cache-clear-all': {
await clearAllCache();
const stats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:cache-stats', payload: stats });
postToIframe(iframe, { type: 'xb-tts:toast', payload: { type: 'success', message: '已清空全部' } });
break;
}
}
}
async function handleTestSpeak(payload, iframe) {
try {
const { text, speaker, source, resourceId } = payload;
const testText = text || '你好,这是一段测试语音。';
if (source === 'free') {
const { synthesizeFreeV1 } = await import('./tts-api.js');
const { audioBase64 } = await synthesizeFreeV1({
text: testText,
voiceKey: speaker || FREE_DEFAULT_VOICE,
speed: normalizeSpeed(config.volc?.speechRate),
});
const byteString = atob(audioBase64);
const bytes = new Uint8Array(byteString.length);
for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j);
const audioBlob = new Blob([bytes], { type: 'audio/mpeg' });
player.enqueue({ id: 'test-' + Date.now(), audioBlob });
postToIframe(iframe, { type: 'xb-tts:test-done' });
} else {
if (!isAuthConfigured()) {
postToIframe(iframe, {
type: 'xb-tts:test-error',
payload: '请先配置 AppID 和 Access Token'
});
return;
}
const rid = resourceId || inferResourceIdBySpeaker(speaker);
const result = await synthesizeV3({
appId: config.volc.appId,
accessKey: config.volc.accessKey,
resourceId: rid,
speaker: speaker || config.volc.defaultSpeaker,
text: testText,
speechRate: speedToV3SpeechRate(config.volc.speechRate),
emotionScale: config.volc.emotionScale,
}, buildV3Headers(rid, config));
player.enqueue({ id: 'test-' + Date.now(), audioBlob: result.audioBlob });
postToIframe(iframe, { type: 'xb-tts:test-done' });
}
} catch (err) {
postToIframe(iframe, {
type: 'xb-tts:test-error',
payload: err.message
});
}
}
// ============ 初始化与清理 ============
export async function initTts() {
if (moduleInitialized) return;
await loadConfig();
player = new TtsPlayer();
initTtsPanelStyles();
moduleInitialized = true;
setPanelConfigHandlers({
getConfig: () => config,
saveConfig: saveConfig,
openSettings: openSettings,
clearQueue: (messageId) => {
clearMessageFromQueue(messageId);
clearFreeQueueForMessage(messageId);
const state = ensureMessageState(messageId);
state.status = 'idle';
state.currentSegment = 0;
state.totalSegments = 0;
state.error = '';
updateTtsPanel(messageId, state);
},
});
player.onStateChange = (state, item, info) => {
if (!isModuleEnabled()) return;
const messageId = item?.messageId;
if (typeof messageId !== 'number' || messageId < 0) return;
const msgState = ensureMessageState(messageId);
switch (state) {
case 'metadata':
msgState.duration = info?.duration || msgState.duration || 0;
break;
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
case 'progress':
msgState.progress = info?.currentTime || 0;
msgState.duration = info?.duration || msgState.duration || 0;
break;
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
case 'playing':
msgState.status = 'playing';
if (typeof item?.segmentIndex === 'number') {
msgState.currentSegment = item.segmentIndex + 1;
}
break;
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
case 'paused':
msgState.status = 'paused';
break;
2026-01-18 01:48:30 +08:00
case 'ended': {
// 检查是否是最后一个段落
const segIdx = typeof item?.segmentIndex === 'number' ? item.segmentIndex : -1;
const total = msgState.totalSegments || 1;
// 判断是否为最后一个段落
// segIdx 是 0-basedtotal 是总数
// 如果 segIdx >= total - 1说明是最后一个
const isLastSegment = total <= 1 || segIdx >= total - 1;
if (isLastSegment) {
// 真正播放完成
msgState.status = 'ended';
msgState.progress = msgState.duration;
} else {
// 还有后续段落
// 检查队列中是否有该消息的待播放项
const prefix = `msg-${messageId}-`;
const hasQueued = player.queue.some(q => q.id?.startsWith(prefix));
if (hasQueued) {
// 后续段落已在队列中,等待播放
msgState.status = 'queued';
} else {
// 后续段落还在请求中
msgState.status = 'sending';
}
}
2026-01-17 16:34:39 +08:00
break;
2026-01-18 01:48:30 +08:00
}
2026-01-17 16:34:39 +08:00
case 'blocked':
msgState.status = 'blocked';
break;
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
case 'error':
msgState.status = 'error';
break;
2026-01-18 01:48:30 +08:00
2026-01-17 16:34:39 +08:00
case 'enqueued':
2026-01-18 01:48:30 +08:00
// 只在非播放/暂停状态时更新
2026-01-17 16:34:39 +08:00
if (msgState.status !== 'playing' && msgState.status !== 'paused') {
msgState.status = 'queued';
}
break;
2026-01-18 01:48:30 +08:00
case 'idle':
case 'cleared':
// 播放器空闲,但可能还有段落在请求
// 不主动改变状态,让请求完成后的逻辑处理
break;
2026-01-17 16:34:39 +08:00
}
updateTtsPanel(messageId, msgState);
};
events.on(event_types.CHARACTER_MESSAGE_RENDERED, onCharacterMessageRendered);
events.on(event_types.CHAT_CHANGED, onChatChanged);
events.on(event_types.MESSAGE_EDITED, handleDirectiveEnhance);
events.on(event_types.MESSAGE_UPDATED, handleDirectiveEnhance);
events.on(event_types.MESSAGE_SWIPED, handleDirectiveEnhance);
events.on(event_types.GENERATION_STOPPED, onGenerationEnd);
events.on(event_types.GENERATION_ENDED, onGenerationEnd);
renderExistingMessageUIs();
setupNovelDrawObserver();
window.registerModuleCleanup?.('tts', cleanupTts);
window.xiaobaixTts = {
openSettings,
closeSettings,
player,
speak: async (text, options = {}) => {
if (!isModuleEnabled()) return;
const mySpeakers = config.volc?.mySpeakers || [];
const resolved = options.speaker
? resolveSpeakerWithSource(options.speaker, mySpeakers, config.volc.defaultSpeaker)
: { value: config.volc.defaultSpeaker, source: getVoiceSource(config.volc.defaultSpeaker) };
if (resolved.source === 'free') {
const { synthesizeFreeV1 } = await import('./tts-api.js');
const { audioBase64 } = await synthesizeFreeV1({
text,
voiceKey: resolved.value,
speed: normalizeSpeed(config.volc?.speechRate),
emotion: options.emotion || null,
});
const byteString = atob(audioBase64);
const bytes = new Uint8Array(byteString.length);
for (let j = 0; j < byteString.length; j++) bytes[j] = byteString.charCodeAt(j);
const audioBlob = new Blob([bytes], { type: 'audio/mpeg' });
player.enqueue({ id: 'manual-' + Date.now(), audioBlob, text });
} else {
if (!isAuthConfigured()) {
toastr?.error?.('请先配置鉴权 API');
return;
}
const resourceId = options.resourceId || inferResourceIdBySpeaker(resolved.value);
const result = await synthesizeV3({
appId: config.volc.appId,
accessKey: config.volc.accessKey,
resourceId,
speaker: resolved.value,
text,
speechRate: speedToV3SpeechRate(config.volc.speechRate),
...options,
}, buildV3Headers(resourceId, config));
player.enqueue({ id: 'manual-' + Date.now(), audioBlob: result.audioBlob, text });
}
},
};
}
export function cleanupTts() {
moduleInitialized = false;
events.cleanup();
clearAllFreeQueues();
cleanupNovelDrawObserver();
cleanupDirectiveObserver();
if (player) {
player.clear();
player.onStateChange = null;
player = null;
}
closeSettings();
removeAllTtsPanels();
try {
import('./tts-panel.js').then(m => m.clearPanelConfigHandlers?.());
} catch {}
messageStateMap.clear();
cacheCounters.hits = 0;
cacheCounters.misses = 0;
delete window.xiaobaixTts;
}