diff --git a/modules/tts/tts-auth-provider.js b/modules/tts/tts-auth-provider.js index 084c3a8..d7e304c 100644 --- a/modules/tts/tts-auth-provider.js +++ b/modules/tts/tts-auth-provider.js @@ -40,7 +40,10 @@ export function speedToV3SpeechRate(speed) { return Math.round((normalizeSpeed(speed) - 1) * 100); } -export function inferResourceIdBySpeaker(value) { +export function inferResourceIdBySpeaker(value, explicitResourceId = null) { + if (explicitResourceId) { + return explicitResourceId; + } const v = (value || '').trim(); const lower = v.toLowerCase(); if (lower.startsWith('icl_') || lower.startsWith('s_')) { @@ -110,7 +113,7 @@ export async function speakSegmentAuth(messageId, segment, segmentIndex, batchId } = ctx; const speaker = segment.resolvedSpeaker; - const resourceId = inferResourceIdBySpeaker(speaker); + const resourceId = segment.resolvedResourceId || inferResourceIdBySpeaker(speaker); const params = buildSynthesizeParams({ text: segment.text, speaker, resourceId }, config); const emotion = normalizeEmotion(segment.emotion); const contextTexts = resolveContextTexts(segment.context, resourceId); @@ -171,7 +174,7 @@ export async function speakSegmentAuth(messageId, segment, segmentIndex, batchId async function playWithStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx) { const { player, storeLocalCache, buildCacheKey, updateState } = ctx; const speaker = segment.resolvedSpeaker; - const resourceId = inferResourceIdBySpeaker(speaker); + const resourceId = params.resourceId; const controller = new AbortController(); const chunks = []; @@ -250,7 +253,7 @@ async function playWithStreaming(messageId, segment, segmentIndex, batchId, para async function playWithoutStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx) { const { player, storeLocalCache, buildCacheKey, updateState } = ctx; const speaker = segment.resolvedSpeaker; - const resourceId = inferResourceIdBySpeaker(speaker); + const resourceId = params.resourceId; const result = await synthesizeV3(params, headers); updateState({ audioBlob: result.audioBlob, usage: result.usage, status: 'queued' }); diff --git a/modules/tts/tts-overlay.html b/modules/tts/tts-overlay.html index ad2ea2b..332b232 100644 --- a/modules/tts/tts-overlay.html +++ b/modules/tts/tts-overlay.html @@ -1,126 +1,126 @@ - - - - - - - -TTS 语音设置 - - - - - - -
- -
- -
-
试用
-
鉴权
-
+ +.header-badge { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 12px; + font-size: 10px; + color: var(--text-muted); + transition: all 0.2s; +} +.header-badge i { + font-size: 5px; + opacity: 0.5; +} +.header-badge.active { + color: var(--text-secondary); + border-color: var(--border-light); +} +.header-badge.active i { + color: var(--accent); + opacity: 1; +} + +.header-spacer { flex: 1; min-width: 10px; } + +.header-close { + width: 36px; height: 36px; min-width: 36px; + border: 1px solid var(--border); + border-radius: 8px; + background: transparent; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + transition: all 0.2s; +} +.header-close:hover { + background: rgba(255,255,255,0.05); + color: var(--text-primary); + border-color: var(--border-light); +} + +/* ═══════════════════════════════════════════════════════════════ + Layout + ═══════════════════════════════════════════════════════════════ */ + +.app-body { + display: flex; + flex: 1; + min-height: 0; + overflow: hidden; +} + +.app-sidebar { + width: 200px; + min-width: 200px; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + padding: 16px 8px; + padding-left: calc(8px + var(--safe-area-left)); + display: flex; + flex-direction: column; + gap: 4px; + overflow-y: auto; + flex-shrink: 0; +} + +.app-main { + flex: 1; + padding: 24px; + padding-right: calc(24px + var(--safe-area-right)); + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +/* ═══════════════════════════════════════════════════════════════ + Navigation + ═══════════════════════════════════════════════════════════════ */ + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + border-radius: 8px; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; + font-size: 13px; +} +.nav-item:hover { + background: rgba(255,255,255,0.03); + color: var(--text-secondary); +} +.nav-item.active { + background: var(--accent-soft); + color: var(--accent); + font-weight: 500; +} +.nav-item i { width: 18px; text-align: center; } + +.nav-divider { + height: 1px; + background: var(--border); + margin: 8px 0; +} + +/* ═══════════════════════════════════════════════════════════════ + Views + ═══════════════════════════════════════════════════════════════ */ + +.view { + display: none; + max-width: 800px; + margin: 0 auto; + padding-bottom: 24px; +} +.view.active { + display: block; + animation: viewIn 0.2s ease; +} +@keyframes viewIn { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; } +} + +.view-header { margin-bottom: 20px; } +.view-title { + font-size: 20px; + font-weight: 600; + margin-bottom: 4px; + color: var(--text-primary); +} +.view-desc { + font-size: 13px; + color: var(--text-muted); +} + +/* ═══════════════════════════════════════════════════════════════ + Cards + ═══════════════════════════════════════════════════════════════ */ + +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + margin-bottom: 16px; +} + +.card-title { + font-size: 11px; + font-weight: 600; + margin-bottom: 16px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* ═══════════════════════════════════════════════════════════════ + Forms + ═══════════════════════════════════════════════════════════════ */ + +.form-group { margin-bottom: 16px; } +.form-group:last-child { margin-bottom: 0; } + +.form-label { + display: block; + font-size: 12px; + color: var(--text-secondary); + margin-bottom: 6px; + font-weight: 500; +} + +.form-hint { + font-size: 11px; + color: var(--text-muted); + margin-top: 4px; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 12px; +} + +.input { + width: 100%; + padding: 10px 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + transition: all 0.15s; +} +.input:focus { + outline: none; + border-color: var(--border-focus); + background: rgba(255,255,255,0.06); +} +.input::placeholder { color: var(--text-dim); } +textarea.input { + min-height: 80px; + resize: vertical; + font-family: inherit; +} +select.input { cursor: pointer; } + +.input-row { display: flex; gap: 8px; } +.input-row .input { flex: 1; min-width: 0; } + +.checkbox-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} +.checkbox-row input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); +} +.checkbox-row label { + font-size: 13px; + cursor: pointer; + color: var(--text-secondary); +} + +/* ═══════════════════════════════════════════════════════════════ + Buttons + ═══════════════════════════════════════════════════════════════ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 16px; + min-height: 40px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 13px; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} +.btn:hover { + background: var(--bg-elevated); + color: var(--text-primary); + border-color: var(--border-light); +} +.btn:active { transform: scale(0.98); } +.btn:disabled { opacity: 0.4; cursor: not-allowed; } + +.btn-primary { + background: var(--accent-soft); + border-color: rgba(140, 200, 255, 0.2); + color: var(--accent); + font-weight: 500; +} +.btn-primary:hover { + background: rgba(140, 200, 255, 0.15); + border-color: rgba(140, 200, 255, 0.3); +} + +.btn-danger { + color: var(--error); + border-color: rgba(224, 128, 128, 0.2); +} +.btn-danger:hover { + background: var(--error-soft); +} + +.btn-icon { width: 40px; padding: 0; } +.btn-group { display: flex; gap: 8px; flex-wrap: wrap; } +.btn-sm { padding: 6px 10px; min-height: 32px; font-size: 12px; } +.btn-xs { padding: 4px 8px; min-height: 28px; font-size: 11px; } + +.btn.saving { pointer-events: none; opacity: 0.7; } +.btn.save-success { + background: var(--success-soft) !important; + border-color: rgba(144, 212, 160, 0.3) !important; + color: var(--success) !important; + pointer-events: none; +} + +/* ═══════════════════════════════════════════════════════════════ + Slider + ═══════════════════════════════════════════════════════════════ */ + +.slider-row { display: flex; align-items: center; gap: 12px; } +.slider-row input[type="range"] { + flex: 1; + height: 4px; + accent-color: var(--accent); + cursor: pointer; + opacity: 0.8; +} +.slider-row input[type="range"]:hover { opacity: 1; } +.slider-row .slider-val { + min-width: 50px; + text-align: right; + font-size: 13px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +/* ═══════════════════════════════════════════════════════════════ + Rules Editor + ═══════════════════════════════════════════════════════════════ */ + +.rules-editor { + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; +} + +.rules-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border); +} +.rules-header-title { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.rules-list { max-height: 200px; overflow-y: auto; } + +.rules-empty { + padding: 20px; + text-align: center; + color: var(--text-dim); + font-size: 12px; +} + +.rule-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); +} +.rule-item:last-child { border-bottom: none; } +.rule-item:hover { background: rgba(255,255,255,0.02); } + +.rule-input { + flex: 1; + padding: 6px 10px; + min-width: 0; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-primary); + font-size: 12px; +} +.rule-input:focus { + outline: none; + border-color: var(--border-focus); +} +.rule-input::placeholder { color: var(--text-dim); } + +.rule-arrow { + color: var(--text-dim); + font-size: 11px; + flex-shrink: 0; +} + +.rule-delete { + width: 28px; + height: 28px; + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.rule-delete:hover { + background: var(--error-soft); + color: var(--error); +} + +/* ═══════════════════════════════════════════════════════════════ + Current Voice Card - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.current-voice-card { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; + transition: all 0.2s; +} +.current-voice-card:hover { + border-color: var(--border-light); +} + +.current-voice-label { + font-size: 10px; + color: var(--text-dim); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.current-voice-display { + display: flex; + align-items: center; + gap: 14px; +} + +.current-voice-icon { + width: 44px; + height: 44px; + border-radius: 50%; + background: var(--bg-elevated); + border: 1px solid var(--border); + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s; +} +.current-voice-card:hover .current-voice-icon { + color: var(--accent); + border-color: rgba(140, 200, 255, 0.2); +} + +.current-voice-info { flex: 1; } + +.current-voice-name { + font-size: 15px; + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + color: var(--text-primary); +} + +.current-voice-source { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} + +/* ═══════════════════════════════════════════════════════════════ + Source Badge - 极简黑白版 + ═══════════════════════════════════════════════════════════════ */ + +.source-badge { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 7px; + border-radius: 4px; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.02em; +} + +/* 试用 - 白色/浅色调 */ +.source-badge.trial { + background: var(--tag-free-bg); + color: var(--tag-free); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* 鉴权 - 蓝色点缀 */ +.source-badge.auth { + background: var(--tag-auth-bg); + color: var(--tag-auth); + border: 1px solid rgba(140, 200, 255, 0.15); +} + +/* ═══════════════════════════════════════════════════════════════ + Voice Tabs - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.voice-tabs { + display: flex; + gap: 2px; + margin-bottom: 16px; + background: var(--bg-tertiary); + padding: 3px; + border-radius: 10px; + border: 1px solid var(--border); +} + +.voice-tab { + flex: 1; + padding: 10px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} +.voice-tab:hover { + color: var(--text-secondary); +} +.voice-tab.active { + background: var(--bg-elevated); + color: var(--text-primary); + font-weight: 500; +} + +.voice-tab-count { + background: var(--bg-input); + padding: 2px 6px; + border-radius: 10px; + font-size: 10px; + color: var(--text-dim); +} +.voice-tab.active .voice-tab-count { + background: var(--accent-soft); + color: var(--accent); +} + +.voice-panel { display: none; } +.voice-panel.active { display: block; } + +/* ═══════════════════════════════════════════════════════════════ + Test Voice Box + ═══════════════════════════════════════════════════════════════ */ + +.test-voice-box { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; + margin-bottom: 16px; +} + +.test-voice-row { + display: flex; + gap: 8px; + align-items: center; +} +.test-voice-row .input { flex: 1; } + +.test-voice-status { + font-size: 11px; + color: var(--text-dim); + margin-top: 6px; + min-height: 16px; +} +.test-voice-status.playing { color: var(--accent); } +.test-voice-status.error { color: var(--error); } + +/* ═══════════════════════════════════════════════════════════════ + Voice Filters + ═══════════════════════════════════════════════════════════════ */ + +.voice-filters { + display: flex; + gap: 8px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.voice-filters select { + flex: 1; + min-width: 80px; + padding: 8px 10px; + font-size: 12px; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text-secondary); +} +.voice-filters select:focus { + outline: none; + border-color: var(--border-focus); +} + +.voice-search { + display: flex; + gap: 8px; + margin-bottom: 12px; +} +.voice-search .input { flex: 1; } + +/* ═══════════════════════════════════════════════════════════════ + Voice List - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.voice-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 320px; + overflow-y: auto; +} + +.voice-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; +} +.voice-item:hover { + border-color: var(--border-light); + background: var(--bg-elevated); +} +.voice-item.selected { + border-color: var(--accent); + background: var(--accent-soft); +} +.voice-item.in-my-list { + opacity: 0.5; +} +.voice-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} +.voice-item.disabled:hover { + border-color: var(--border); + background: var(--bg-tertiary); +} + +.voice-item-radio { + width: 16px; + height: 16px; + border-radius: 50%; + border: 2px solid var(--text-dim); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.15s; +} +.voice-item:hover .voice-item-radio { + border-color: var(--text-muted); +} +.voice-item.selected .voice-item-radio { + border-color: var(--accent); + background: var(--accent); +} +.voice-item.selected .voice-item-radio::after { + content: '✓'; + color: var(--bg-primary); + font-size: 9px; + font-weight: bold; +} + +.voice-item-info { flex: 1; min-width: 0; } + +.voice-item-name { + font-size: 13px; + font-weight: 500; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: var(--text-primary); +} + +.voice-item-meta { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.voice-item-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +/* Editing State */ +.voice-item.editing { + background: var(--accent-soft); + border-color: var(--accent); +} +.voice-item-edit-form { + display: none; + width: 100%; + margin-top: 8px; +} +.voice-item.editing .voice-item-edit-form { + display: flex; + gap: 8px; +} +.voice-item.editing .voice-item-info > *:not(.voice-item-edit-form) { + display: none; +} +.voice-item-edit-form input { + flex: 1; + padding: 6px 10px; + font-size: 12px; +} + +/* Add Form */ +.voice-add-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); +} +.voice-add-row { + display: flex; + gap: 8px; + align-items: flex-end; +} +.voice-add-row .form-group { + flex: 1; + margin-bottom: 0; +} +.voice-add-row .form-label { + font-size: 11px; + margin-bottom: 4px; +} + +.preset-save-row { + display: flex; + gap: 8px; + align-items: center; + margin-top: 12px; +} +.preset-save-row input { + flex: 1; + padding: 10px 12px; + font-size: 13px; +} + +/* ═══════════════════════════════════════════════════════════════ + API Status Box + ═══════════════════════════════════════════════════════════════ */ + +.api-status-box { + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + margin-bottom: 16px; +} + +.api-status-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + background: var(--bg-elevated); + border: 1px solid var(--border); +} + +.api-status-box.configured .api-status-icon { + color: var(--accent); + border-color: rgba(140, 200, 255, 0.2); +} +.api-status-box.not-configured .api-status-icon { + color: var(--text-dim); +} + +.api-status-info { flex: 1; } +.api-status-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} +.api-status-desc { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +/* ═══════════════════════════════════════════════════════════════ + Stats Card + ═══════════════════════════════════════════════════════════════ */ + +.stats-card { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; +} + +.stats-group { + display: flex; + gap: 32px; +} + +.stats-item { text-align: center; } + +.stats-value { + font-size: 28px; + font-weight: 700; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} + +.stats-label { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* ═══════════════════════════════════════════════════════════════ + Tip Box - 极简版 + ═══════════════════════════════════════════════════════════════ */ + +.tip-box { + display: flex; + gap: 10px; + padding: 12px 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 8px; + font-size: 12px; + color: var(--text-secondary); + line-height: 1.6; +} +.tip-box i { + color: var(--accent); + flex-shrink: 0; + margin-top: 2px; + opacity: 0.7; +} + +.tip-box.warning { + border-left: 3px solid var(--accent); +} +.tip-box.warning i { + color: var(--accent); +} + +/* ═══════════════════════════════════════════════════════════════ + Guide Box + ═══════════════════════════════════════════════════════════════ */ + +.guide-box { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 10px; + padding: 16px; + margin-bottom: 16px; +} + +.guide-box h3 { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} +.guide-box h3 i { + color: var(--accent); + opacity: 0.8; +} + +.guide-box p { + font-size: 13px; + color: var(--text-secondary); + margin-bottom: 10px; + line-height: 1.6; +} + +.guide-box ol, .guide-box ul { + margin-left: 18px; + font-size: 13px; + color: var(--text-secondary); +} +.guide-box li { + margin-bottom: 8px; + line-height: 1.6; +} + +.guide-box a { color: var(--accent); } + +.guide-box code { + background: var(--bg-input); + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + color: var(--accent); + font-family: monospace; + border: 1px solid var(--border); +} + +.guide-box pre { + background: var(--bg-primary); + padding: 12px; + border-radius: 6px; + font-size: 11px; + color: var(--text-secondary); + font-family: monospace; + overflow-x: auto; + margin: 10px 0; + line-height: 1.5; + border: 1px solid var(--border); +} + +.guide-image { + margin-top: 12px; + width: 100%; + height: auto; + border: 1px solid var(--border); + border-radius: 8px; + display: block; + opacity: 0.9; +} +.guide-image:hover { opacity: 1; } + +.guide-link { + display: block; + padding: 10px 12px; + margin: 10px 0; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 12px; + word-break: break-all; +} +.guide-link a { color: var(--accent); } + +/* ═══════════════════════════════════════════════════════════════ + Mobile Navigation + ═══════════════════════════════════════════════════════════════ */ + +.mobile-nav { + display: none; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + var(--safe-area-bottom)); + padding-bottom: var(--safe-area-bottom); + background: var(--bg-secondary); + border-top: 1px solid var(--border); + z-index: 100; +} + +.mobile-nav-inner { + display: flex; + height: 60px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + padding-left: var(--safe-area-left); + padding-right: var(--safe-area-right); +} +.mobile-nav-inner::-webkit-scrollbar { display: none; } + +.mobile-nav-item { + flex: 1; + min-width: 60px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: var(--text-dim); + font-size: 10px; + cursor: pointer; + padding: 8px 4px; + transition: color 0.15s; +} +.mobile-nav-item i { font-size: 18px; } +.mobile-nav-item:hover { color: var(--text-muted); } +.mobile-nav-item.active { color: var(--accent); } + +/* ═══════════════════════════════════════════════════════════════ + Responsive + ═══════════════════════════════════════════════════════════════ */ + +@media (max-width: 768px) { + .app-sidebar { display: none; } + .mobile-nav { display: block; } + + .app-body { + padding-bottom: calc(60px + var(--safe-area-bottom)); + } + + .app-main { + padding: 16px; + padding-left: calc(16px + var(--safe-area-left)); + padding-right: calc(16px + var(--safe-area-right)); + padding-bottom: calc(80px + var(--safe-area-bottom)); + } + + .view { padding-bottom: 20px; } + + .app-header { + padding: 10px 12px; + padding-top: calc(10px + var(--safe-area-top)); + padding-left: calc(12px + var(--safe-area-left)); + padding-right: calc(12px + var(--safe-area-right)); + gap: 8px; + } + .header-logo span { display: none; } + .header-badge span { display: none; } + .header-close { width: 32px; height: 32px; min-width: 32px; font-size: 14px; } + .view-title { font-size: 18px; } + .card { padding: 16px; } + .form-row { grid-template-columns: 1fr; } + .voice-filters select { min-width: calc(50% - 4px); } + .stats-card { flex-direction: column; align-items: stretch; } + .stats-group { justify-content: space-around; } + .voice-add-row { flex-wrap: wrap; } + .voice-add-row .form-group { min-width: calc(50% - 4px); } + .preset-save-row { flex-wrap: wrap; } + .preset-save-row input { min-width: 100%; margin-bottom: 8px; } + .voice-tabs { flex-wrap: wrap; } + .voice-tab { min-width: calc(33% - 3px); font-size: 11px; padding: 8px 6px; } +} + +@media (max-width: 400px) { + .app-header { padding: 8px 10px; } + .mobile-nav-item { min-width: 48px; font-size: 9px; } + .mobile-nav-item i { font-size: 16px; } +} + +@media (hover: none) and (pointer: coarse) { + .btn { min-height: 44px; } + .input { min-height: 44px; padding: 12px; } + .nav-item { min-height: 44px; } + .header-close { width: 44px; height: 44px; min-width: 44px; } + .mobile-nav-item { min-height: 44px; } +} + +/* ═══════════════════════════════════════════════════════════════ + Scrollbar + ═══════════════════════════════════════════════════════════════ */ + +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { + background: rgba(255,255,255,0.08); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: rgba(255,255,255,0.15); +} + +.hidden { display: none !important; } + + + + +
+ +
+ +
+
试用
+
鉴权
+
-
- -
- - -
- - -
-
-

基础配置

-

TTS 服务连接与朗读设置

-
- -
- -
- 试用音色 — 无需配置,使用插件服务器(11个音色)
- 鉴权音色 — 需配置火山引擎 API(200+ 音色 + 复刻) -
-
- -
-
鉴权配置(可选)
-
-
-
-
未配置
-
配置后可使用预设音色库和复刻音色
-
-
-
- - -
-
- -
- - -
-

获取方式见「使用说明」页

-
-
- -
-
朗读设置
-
- - -
-
- -
- - 1.0x -
-
-
- -
-
文本过滤
-
- -

遇到「起始」后跳过,直到「结束」。起始或结束可单独留空。

-
-
- 当前规则 - -
-
-
-
-
-
- - -
-

起始或结束可单独留空。

-
-
- 只读规则 - -
-
-
-
-
- - -
- - -
-
-

音色管理

-

将喜欢的音色重命名加入【我的音色】

-
- -
-
当前默认音色
-
-
-
-
未选择
-
请在下方选择音色
-
-
-
- -
- - - -
- - -
-
-
-
- - -
-
-
- -

- 点击选中设为默认。试用 无需配置,鉴权 需配置 API -

-
-
- - 暂无音色,请从「试用」或「预设库」添加 -
- -
-
手动添加复刻音色 鉴权
-
-
- - -
+
+ +
+ + +
+ + +
+
+

基础配置

+

TTS 服务连接与朗读设置

+
+ +
+ +
+ 试用音色 — 无需配置,使用插件服务器(11个音色)
+ 鉴权音色 — 需配置火山引擎 API(200+ 音色 + 复刻) +
+
+ +
+
鉴权配置(可选)
+
+
+
+
未配置
+
配置后可使用预设音色库和复刻音色
+
+
+
+ + +
+
+ +
+ + +
+

获取方式见「使用说明」页

+
+
+ +
+
朗读设置
+
+ + +
+
+ +
+ + 1.0x +
+
+
+ +
+
文本过滤
+
+ +

遇到「起始」后跳过,直到「结束」。起始或结束可单独留空。

+
+
+ 当前规则 + +
+
+
+
+
+
+ + +
+

起始或结束可单独留空。

+
+
+ 只读规则 + +
+
+
+
+
+ + +
+ + +
+
+

音色管理

+

将喜欢的音色重命名加入【我的音色】

+
+ +
+
当前默认音色
+
+
+
+
未选择
+
请在下方选择音色
+
+
+
+ +
+ + + +
+ + +
+
+
+
+ + +
+
+
+ +

+ 点击选中设为默认。试用 无需配置,鉴权 需配置 API +

+
+
+ + 暂无音色,请从「试用」或「预设库」添加 +
+ +
+
手动添加复刻音色 鉴权
+
+
+ + +
+
+ + +
-
-
- - -
-
-
-
- - -
-
-
- -
- -
- - -
-
-
- - -
-
-
- -
使用预设音色库需要先配置鉴权 API,请前往「基础配置」页面设置。
-
- -
-
- - -
-
-
- - -
- - - - -
- -
- -
- - -
-
-
- - -
- - -
-
-

高级设置

-

计费、缓存与过滤选项(鉴权模式)

-
- -
-
计费与缓存
-
- - -
-
- - -
-
- -
-
过滤与识别
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
- - -
-
-

缓存管理

-

本地音频缓存统计与清理

-
- -
-
-
-
-
0
-
缓存条数
-
-
-
0 MB
-
占用空间
-
-
-
0
-
命中
-
-
-
0
-
未命中
-
-
-
-
- -
-
缓存配置
-
-
- - -
-
- - -
-
- - -
-
-
- -
- - - - -
-
- - -
-
-

使用说明

-

配音指令与开通流程

-
- -
-

配音指令

-

格式:[tts:speaker=音色名;emotion=情绪;context=语气提示] 放在正文前一行

-

speaker、emotion、context 三个参数可任意组合、任意顺序,用分号分隔

-

每遇到一个新 [tts:...] 块会分段朗读,按顺序播放

-

未写 speaker= 的块使用当前选中的默认音色

- -

音色(speaker)

-

只能指定"我的音色"中保存的名称。例如保存了名为"小白"的音色,则可用 speaker=小白

- -

情感(emotion)可用值:

-
中文:开心、悲伤、生气、惊讶、恐惧、厌恶、激动、冷漠、中性、沮丧、撒娇、害羞、安慰、鼓励、咆哮、焦急、温柔、讲故事、自然讲述、情感电台、磁性、广告营销、气泡音、低语、新闻播报、娱乐八卦、方言、对话、闲聊、温暖、深情、权威
-
-英文:happy, sad, angry, surprised, fear, hate, excited, coldness, neutral, depressed, lovey-dovey, shy, comfort, tension, tender, storytelling, radio, magnetic, advertising, vocal-fry, asmr, news, entertainment, dialect, chat, warm, affectionate, authoritative
- -

语气提示(context)仅对 seed-tts-2.0 生效:

-

例如:"用更委屈的语气"、"放慢一点,压低音量"

-
- -
-

复刻音色使用

-
    -
  1. 在火山官网复刻音色
  2. -
  3. 获取音色ID(格式 S_xxxxxxxx
  4. -
  5. 在"音色管理" → "我的音色"中添加
  6. -
-
- -
- -
以下是鉴权模式的开通教程,试用音色无需配置。
-
- -
-

开启 CORS 代理

-
    -
  1. 打开酒馆目录的 config.yaml
  2. -
  3. 将 enableCorsProxy 改为 true 并保存
  4. -
  5. 重启酒馆(重启容器/进程,不是 F5 刷新)
  6. -
-
- -
-

开通服务(推荐一次性开通全部)

- - 开通管理 -
- -
-

获取 Access Token / AppID

- - 获取ID和KEY -
- -
-

声音复刻入口(复刻后去音色库拿ID)

- - 声音复刻 -
-
- -
-
- - - -
- - - + - - + + renderMyVoiceList(); + updateCurrentVoiceDisplay(); + post('xb-tts:save-config', collectForm()); + post('xb-tts:toast', { type: 'success', message: `已添加:${name || id}` }); + }); + + ['saveConfigBtn', 'saveVoiceBtn', 'saveAdvancedBtn', 'saveCacheBtn'].forEach(id => { + $(id)?.addEventListener('click', () => { setSavingState($(id)); post('xb-tts:save-config', collectForm()); }); + }); + + $('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh')); + $('cacheClearExpiredBtn').addEventListener('click', () => post('xb-tts:cache-clear-expired')); + $('cacheClearAllBtn').addEventListener('click', () => post('xb-tts:cache-clear-all')); + + post('xb-tts:ready'); +}); + + + diff --git a/modules/tts/tts.js b/modules/tts/tts.js index 42267ad..f5e72c7 100644 --- a/modules/tts/tts.js +++ b/modules/tts/tts.js @@ -1,621 +1,625 @@ -// ============ 导入 ============ - -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"; -import { - ensureTtsPanel, - updateTtsPanel, - removeAllTtsPanels, - initTtsPanelStyles, - setPanelConfigHandlers, - clearPanelConfigHandlers, - updateAutoSpeakAll, - updateSpeedAll, - updateVoiceAll, - initFloatingPanel, - destroyFloatingPanel, - resetFloatingState, - updateButtonVisibility, -} from "./tts-panel.js"; -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, '&') - .replace(//g, '>') - .replace(/"/g, '"'); -} - -// ============ 音色来源判断 ============ - -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 - }); - } - +// ============ 导入 ============ + +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"; +import { + ensureTtsPanel, + updateTtsPanel, + removeAllTtsPanels, + initTtsPanelStyles, + setPanelConfigHandlers, + clearPanelConfigHandlers, + updateAutoSpeakAll, + updateSpeedAll, + updateVoiceAll, + initFloatingPanel, + destroyFloatingPanel, + resetFloatingState, + updateButtonVisibility, +} from "./tts-panel.js"; +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, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +// ============ 音色来源判断 ============ + +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) + source: defaultItem?.source || getVoiceSource(defaultSpeaker), + resourceId: defaultItem?.resourceId || null }; } - - const byName = list.find(s => s.name === speakerName); - console.log('[TTS Debug] byName 查找结果:', byName); // ★ 调试 - + + 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) + source: byName.source || getVoiceSource(byName.value), + resourceId: byName.resourceId || null }; } - - const byValue = list.find(s => s.value === speakerName); - console.log('[TTS Debug] byValue 查找结果:', byValue); // ★ 调试 - + + 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) + source: byValue.source || getVoiceSource(byValue.value), + resourceId: byValue.resourceId || null }; } - + if (FREE_VOICE_KEYS.has(speakerName)) { - return { value: speakerName, source: 'free' }; + return { value: speakerName, source: 'free', resourceId: null }; } - - // ★ 回退到默认,这是问题发生的地方 - console.warn('[TTS Debug] 未找到匹配音色,回退到默认:', defaultSpeaker); - + + // ★ 回退到默认,这是问题发生的地方 + console.warn('[TTS Debug] 未找到匹配音色,回退到默认:', defaultSpeaker); + const defaultItem = list.find(s => s.value === defaultSpeaker); return { value: defaultSpeaker, - source: defaultItem?.source || getVoiceSource(defaultSpeaker) + source: defaultItem?.source || getVoiceSource(defaultSpeaker), + resourceId: defaultItem?.resourceId || null }; } - -// ============ 缓存管理 ============ - -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; - } - + +// ============ 缓存管理 ============ + +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) @@ -623,746 +627,747 @@ async function speakMessage(messageId, { mode = 'manual' } = {}) { return { ...seg, resolvedSpeaker: resolved.value, - resolvedSource: resolved.source + resolvedSource: resolved.source, + resolvedResourceId: resolved.resourceId }; }); - - 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 = ``; - html += ``; - - if (hasParams) { - const textParts = parts.map(p => `${escapeHtml(p)}`); - html += textParts.join(' · '); - } - - html += ``; - 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(); - resetFloatingState(); - - 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; - config.showFloorButton = config.showFloorButton !== false; - config.showFloatingButton = config.showFloatingButton === 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); - await TtsStorage.set('showFloorButton', config.showFloorButton); - await TtsStorage.set('showFloatingButton', config.showFloatingButton); - - 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 } }); - updateAutoSpeakAll(); - updateSpeedAll(); - updateVoiceAll(); - } else { - postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); - } - break; - } - case 'xb-tts:save-button-mode': { - const { showFloorButton, showFloatingButton } = payload; - config.showFloorButton = showFloorButton; - config.showFloatingButton = showFloatingButton; - const ok = await saveConfig({ showFloorButton, showFloatingButton }); - if (ok) { - updateButtonVisibility(showFloorButton, showFloatingButton); - if (showFloorButton) { - renderExistingMessageUIs(); - } - postToIframe(iframe, { type: 'xb-tts:button-mode-saved' }); - } - 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); - }, - getLastAIMessageId: () => { - const context = getContext(); - const chat = context.chat || []; - for (let i = chat.length - 1; i >= 0; i--) { - if (chat[i] && !chat[i].is_user) return i; - } - return -1; - }, - speakMessage: (messageId) => handleMessagePlayClick(messageId), - }); - - initFloatingPanel(); - - 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; - - case 'progress': - msgState.progress = info?.currentTime || 0; - msgState.duration = info?.duration || msgState.duration || 0; - break; - - case 'playing': - msgState.status = 'playing'; - if (typeof item?.segmentIndex === 'number') { - msgState.currentSegment = item.segmentIndex + 1; - } - break; - - case 'paused': - msgState.status = 'paused'; - break; - - case 'ended': { - // 检查是否是最后一个段落 - const segIdx = typeof item?.segmentIndex === 'number' ? item.segmentIndex : -1; - const total = msgState.totalSegments || 1; - - // 判断是否为最后一个段落 - // segIdx 是 0-based,total 是总数 - // 如果 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'; - } - } - break; - } - - case 'blocked': - msgState.status = 'blocked'; - break; - - case 'error': - msgState.status = 'error'; - break; - - case 'enqueued': - // 只在非播放/暂停状态时更新 - if (msgState.status !== 'playing' && msgState.status !== 'paused') { - msgState.status = 'queued'; - } - break; - - case 'idle': - case 'cleared': - // 播放器空闲,但可能还有段落在请求 - // 不主动改变状态,让请求完成后的逻辑处理 - break; - } - 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(); - destroyFloatingPanel(); - - clearPanelConfigHandlers(); - - messageStateMap.clear(); - cacheCounters.hits = 0; - cacheCounters.misses = 0; - delete window.xiaobaixTts; -} + + 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 = ``; + html += ``; + + if (hasParams) { + const textParts = parts.map(p => `${escapeHtml(p)}`); + html += textParts.join(' · '); + } + + html += ``; + 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(); + resetFloatingState(); + + 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; + config.showFloorButton = config.showFloorButton !== false; + config.showFloatingButton = config.showFloatingButton === 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); + await TtsStorage.set('showFloorButton', config.showFloorButton); + await TtsStorage.set('showFloatingButton', config.showFloatingButton); + + 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 } }); + updateAutoSpeakAll(); + updateSpeedAll(); + updateVoiceAll(); + } else { + postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } }); + } + break; + } + case 'xb-tts:save-button-mode': { + const { showFloorButton, showFloatingButton } = payload; + config.showFloorButton = showFloorButton; + config.showFloatingButton = showFloatingButton; + const ok = await saveConfig({ showFloorButton, showFloatingButton }); + if (ok) { + updateButtonVisibility(showFloorButton, showFloatingButton); + if (showFloorButton) { + renderExistingMessageUIs(); + } + postToIframe(iframe, { type: 'xb-tts:button-mode-saved' }); + } + 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); + }, + getLastAIMessageId: () => { + const context = getContext(); + const chat = context.chat || []; + for (let i = chat.length - 1; i >= 0; i--) { + if (chat[i] && !chat[i].is_user) return i; + } + return -1; + }, + speakMessage: (messageId) => handleMessagePlayClick(messageId), + }); + + initFloatingPanel(); + + 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; + + case 'progress': + msgState.progress = info?.currentTime || 0; + msgState.duration = info?.duration || msgState.duration || 0; + break; + + case 'playing': + msgState.status = 'playing'; + if (typeof item?.segmentIndex === 'number') { + msgState.currentSegment = item.segmentIndex + 1; + } + break; + + case 'paused': + msgState.status = 'paused'; + break; + + case 'ended': { + // 检查是否是最后一个段落 + const segIdx = typeof item?.segmentIndex === 'number' ? item.segmentIndex : -1; + const total = msgState.totalSegments || 1; + + // 判断是否为最后一个段落 + // segIdx 是 0-based,total 是总数 + // 如果 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'; + } + } + break; + } + + case 'blocked': + msgState.status = 'blocked'; + break; + + case 'error': + msgState.status = 'error'; + break; + + case 'enqueued': + // 只在非播放/暂停状态时更新 + if (msgState.status !== 'playing' && msgState.status !== 'paused') { + msgState.status = 'queued'; + } + break; + + case 'idle': + case 'cleared': + // 播放器空闲,但可能还有段落在请求 + // 不主动改变状态,让请求完成后的逻辑处理 + break; + } + 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 || resolved.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(); + destroyFloatingPanel(); + + clearPanelConfigHandlers(); + + messageStateMap.clear(); + cacheCounters.hits = 0; + cacheCounters.misses = 0; + delete window.xiaobaixTts; +}