feat: updates to test branch

This commit is contained in:
RT15548
2026-02-25 23:58:05 +08:00
parent 8bfa0dd537
commit c3bb162a10
15 changed files with 3596 additions and 2291 deletions

View File

@@ -1,23 +1,33 @@
/**
/**
* 火山引擎 TTS API 封装
* V3 单向流式 + V1试用
*/
const V3_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
const FREE_V1_URL = 'https://hstts.velure.codes';
const FREE_V1_URL = 'https://edgetts.velure.codes';
export const FREE_VOICES = [
{ key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' },
{ key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' },
{ key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' },
{ key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' },
{ key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' },
{ key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' },
{ key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' },
{ key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' },
{ key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' },
{ key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' },
{ key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' },
{ key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' },
{ key: 'female_2', name: '晓伊', tag: '清冷知性', gender: 'female' },
{ key: 'female_3', name: '小北', tag: '东北甜妹', gender: 'female' },
{ key: 'female_4', name: '小妮', tag: '陕西姑娘', gender: 'female' },
{ key: 'hk_female_1', name: '曉佳', tag: '粤语女声', gender: 'female' },
{ key: 'hk_female_2', name: '曉曼', tag: '粤语温柔', gender: 'female' },
{ key: 'hk_male_1', name: '雲龍', tag: '粤语男声', gender: 'male' },
{ key: 'tw_female_1', name: '曉臻', tag: '台灣女聲', gender: 'female' },
{ key: 'tw_female_2', name: '曉雨', tag: '台灣温柔', gender: 'female' },
{ key: 'tw_male_1', name: '雲哲', tag: '台灣男聲', gender: 'male' },
{ key: 'male_1', name: '云希', tag: '少年温暖', gender: 'male' },
{ key: 'male_2', name: '云健', tag: '阳刚有力', gender: 'male' },
{ key: 'male_3', name: '云扬', tag: '专业播报', gender: 'male' },
{ key: 'male_4', name: '云夏', tag: '少年活力', gender: 'male' },
{ key: 'en_female_1', name: 'Jenny', tag: '美式甜美', gender: 'female' },
{ key: 'en_female_2', name: 'Aria', tag: '美式知性', gender: 'female' },
{ key: 'en_female_3', name: 'Sonia', tag: '英式优雅', gender: 'female' },
{ key: 'en_male_1', name: 'Guy', tag: '美式磁性', gender: 'male' },
{ key: 'en_male_2', name: 'Ryan', tag: '英式绅士', gender: 'male' },
{ key: 'ja_female_1', name: '七海', tag: '日式温柔', gender: 'female' },
{ key: 'ja_male_1', name: '圭太', tag: '日式少年', gender: 'male' },
];
export const FREE_DEFAULT_VOICE = 'female_1';
@@ -333,3 +343,4 @@ export async function synthesizeFreeV1(params, options = {}) {
return { audioBase64: data.data };
}

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
@@ -1275,7 +1275,7 @@ select.input { cursor: pointer; }
<div class="tip-box" style="margin-bottom: 16px;">
<i class="fa-solid fa-info-circle"></i>
<div>
<strong>试用音色</strong> — 无需配置,使用插件服务器(11个音色<br>
<strong>试用音色</strong> — 无需配置,使用插件服务器(21个音色<br>
<strong>鉴权音色</strong> — 需配置火山引擎 API200+ 音色 + 复刻)
</div>
</div>
@@ -1719,19 +1719,30 @@ let selectedTrialVoiceValue = '';
let selectedAuthVoiceValue = '';
let editingVoiceValue = null;
let activeSaveBtn = null;
let pendingSaveRequest = null;
const TRIAL_VOICES = [
{ key: 'female_1', name: '桃夭', tag: '甜蜜仙子', gender: 'female' },
{ key: 'female_2', name: '霜华', tag: '清冷仙子', gender: 'female' },
{ key: 'female_3', name: '顾姐', tag: '御姐烟嗓', gender: 'female' },
{ key: 'female_4', name: '苏菲', tag: '优雅知性', gender: 'female' },
{ key: 'female_5', name: '嘉欣', tag: '港风甜心', gender: 'female' },
{ key: 'female_6', name: '青梅', tag: '清秀少年音', gender: 'female' },
{ key: 'female_7', name: '可莉', tag: '奶音萝莉', gender: 'female' },
{ key: 'male_1', name: '夜枭', tag: '磁性低音', gender: 'male' },
{ key: 'male_2', name: '君泽', tag: '温润公子', gender: 'male' },
{ key: 'male_3', name: '沐阳', tag: '沉稳暖男', gender: 'male' },
{ key: 'male_4', name: '梓辛', tag: '青春少年', gender: 'male' },
{ key: 'female_1', name: '晓晓', tag: '温暖百变', gender: 'female' },
{ key: 'female_2', name: '晓伊', tag: '清冷知性', gender: 'female' },
{ key: 'female_3', name: '小北', tag: '东北甜妹', gender: 'female' },
{ key: 'female_4', name: '小妮', tag: '陕西姑娘', gender: 'female' },
{ key: 'hk_female_1', name: '曉佳', tag: '粤语女声', gender: 'female' },
{ key: 'hk_female_2', name: '曉曼', tag: '粤语温柔', gender: 'female' },
{ key: 'hk_male_1', name: '雲龍', tag: '粤语男声', gender: 'male' },
{ key: 'tw_female_1', name: '曉臻', tag: '台灣女聲', gender: 'female' },
{ key: 'tw_female_2', name: '曉雨', tag: '台灣温柔', gender: 'female' },
{ key: 'tw_male_1', name: '雲哲', tag: '台灣男聲', gender: 'male' },
{ key: 'male_1', name: '云希', tag: '少年温暖', gender: 'male' },
{ key: 'male_2', name: '云健', tag: '阳刚有力', gender: 'male' },
{ key: 'male_3', name: '云扬', tag: '专业播报', gender: 'male' },
{ key: 'male_4', name: '云夏', tag: '少年活力', gender: 'male' },
{ key: 'en_female_1', name: 'Jenny', tag: '美式甜美', gender: 'female' },
{ key: 'en_female_2', name: 'Aria', tag: '美式知性', gender: 'female' },
{ key: 'en_female_3', name: 'Sonia', tag: '英式优雅', gender: 'female' },
{ key: 'en_male_1', name: 'Guy', tag: '美式磁性', gender: 'male' },
{ key: 'en_male_2', name: 'Ryan', tag: '英式绅士', gender: 'male' },
{ key: 'ja_female_1', name: '七海', tag: '日式温柔', gender: 'female' },
{ key: 'ja_male_1', name: '圭太', tag: '日式少年', gender: 'male' },
];
const TRIAL_VOICE_KEYS = new Set(TRIAL_VOICES.map(v => v.key));
@@ -1781,6 +1792,25 @@ function handleSaveResult(success) {
}
}
function requestSaveConfig(form, btn = null) {
const requestId = `tts_save_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer);
if (btn) setSavingState(btn);
pendingSaveRequest = {
requestId,
timer: setTimeout(() => {
if (!pendingSaveRequest || pendingSaveRequest.requestId !== requestId) return;
pendingSaveRequest = null;
handleSaveResult(false);
post('xb-tts:toast', { type: 'error', message: '保存超时3秒' });
}, 3000),
};
post('xb-tts:save-config', { requestId, patch: form });
}
function setTestStatus(elId, status, text) {
const el = $(elId);
if (!el) return;
@@ -2050,7 +2080,7 @@ function bindMyVoiceEvents(listEl) {
const input = btn.closest('.voice-item').querySelector('.voice-edit-input');
if (item && input?.value?.trim()) {
item.name = input.value.trim();
post('xb-tts:save-config', collectForm());
requestSaveConfig(collectForm());
}
editingVoiceValue = null;
renderMyVoiceList();
@@ -2080,7 +2110,7 @@ function bindMyVoiceEvents(listEl) {
renderTrialVoiceList();
renderAuthVoiceList();
updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm());
requestSaveConfig(collectForm());
}
});
});
@@ -2163,7 +2193,12 @@ function normalizeMySpeakers(list) {
value: String(item?.value || '').trim(),
source: item?.source || getVoiceSource(item?.value || ''),
resourceId: item?.resourceId || null,
})).filter(item => item.value);
})).filter(item => {
if (!item.value) return false;
// Keep UI behavior aligned with runtime: remove unsupported legacy free voices.
if (item.source === 'free' && !TRIAL_VOICE_KEYS.has(item.value)) return false;
return true;
});
}
function applyCacheStats(stats = {}) {
@@ -2298,11 +2333,17 @@ window.addEventListener('message', ev => {
fillForm(payload);
break;
case 'xb-tts:config-saved':
if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break;
if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer);
pendingSaveRequest = null;
fillForm(payload);
handleSaveResult(true);
post('xb-tts:toast', { type: 'success', message: '配置已保存' });
break;
case 'xb-tts:config-save-error':
if (pendingSaveRequest?.requestId && payload?.requestId && pendingSaveRequest.requestId !== payload.requestId) break;
if (pendingSaveRequest?.timer) clearTimeout(pendingSaveRequest.timer);
pendingSaveRequest = null;
handleSaveResult(false);
post('xb-tts:toast', { type: 'error', message: payload?.message || '保存失败' });
break;
@@ -2417,7 +2458,7 @@ document.addEventListener('DOMContentLoaded', () => {
$$('.voice-tab')[0].classList.add('active');
$('panel-myVoice').classList.add('active');
post('xb-tts:save-config', collectForm());
requestSaveConfig(collectForm());
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
});
@@ -2441,7 +2482,7 @@ document.addEventListener('DOMContentLoaded', () => {
$$('.voice-tab')[0].classList.add('active');
$('panel-myVoice').classList.add('active');
post('xb-tts:save-config', collectForm());
requestSaveConfig(collectForm());
post('xb-tts:toast', { type: 'success', message: `已添加:${name}` });
});
@@ -2460,12 +2501,12 @@ document.addEventListener('DOMContentLoaded', () => {
renderMyVoiceList();
updateCurrentVoiceDisplay();
post('xb-tts:save-config', collectForm());
requestSaveConfig(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()); });
$(id)?.addEventListener('click', () => { requestSaveConfig(collectForm(), $(id)); });
});
$('cacheRefreshBtn').addEventListener('click', () => post('xb-tts:cache-refresh'));
@@ -2477,3 +2518,4 @@ document.addEventListener('DOMContentLoaded', () => {
</script>
</body>
</html>

View File

@@ -169,7 +169,7 @@ export function parseTtsSegments(text) {
// ============ 非鉴权分段切割 ============
const FREE_MAX_TEXT = 200;
const FREE_MAX_TEXT = 1000;
const FREE_MIN_TEXT = 50;
const FREE_SENTENCE_DELIMS = new Set(['。', '', '', '!', '?', ';', '', '…', '.', '', ',', '、', ':', '']);
@@ -218,20 +218,98 @@ function splitTextForFree(text, maxLength = FREE_MAX_TEXT) {
const chunks = [];
const paragraphs = String(text || '').split(/\n\s*\n/).map(s => s.replace(/\n+/g, '\n').trim()).filter(Boolean);
let current = '';
const pushCurrent = () => {
if (!current) return;
chunks.push(current);
current = '';
};
for (const para of paragraphs) {
if (para.length <= maxLength) {
chunks.push(para);
if (!para) continue;
if (para.length > maxLength) {
// Flush buffered short paragraphs before handling a long paragraph.
pushCurrent();
const longParts = splitLongTextBySentence(para, maxLength);
for (const part of longParts) {
const t = String(part || '').trim();
if (!t) continue;
if (!current) {
current = t;
continue;
}
if (current.length + t.length + 2 <= maxLength) {
current += `\n\n${t}`;
continue;
}
pushCurrent();
current = t;
}
continue;
}
chunks.push(...splitLongTextBySentence(para, maxLength));
if (!current) {
current = para;
continue;
}
// Cross-paragraph merge: keep fewer requests while preserving paragraph boundary.
if (current.length + para.length + 2 <= maxLength) {
current += `\n\n${para}`;
continue;
}
pushCurrent();
current = para;
}
pushCurrent();
return chunks;
}
export function splitTtsSegmentsForFree(segments, maxLength = FREE_MAX_TEXT) {
if (!Array.isArray(segments) || !segments.length) return [];
const out = [];
const normalizedSegments = [];
// In free mode, only explicit speaker directives are semantic split points.
// Adjacent segments without speaker= are merged to reduce request count.
let mergeBuffer = null;
const flushMergeBuffer = () => {
if (!mergeBuffer) return;
normalizedSegments.push(mergeBuffer);
mergeBuffer = null;
};
for (const seg of segments) {
const hasExplicitSpeaker = !!String(seg?.speaker || '').trim();
const text = String(seg?.text || '').trim();
if (!text) continue;
if (hasExplicitSpeaker) {
flushMergeBuffer();
normalizedSegments.push({
...seg,
text,
});
continue;
}
if (!mergeBuffer) {
mergeBuffer = {
...seg,
text,
speaker: '',
};
continue;
}
mergeBuffer.text += `\n${text}`;
}
flushMergeBuffer();
const out = [];
for (const seg of normalizedSegments) {
const parts = splitTextForFree(seg.text, maxLength);
if (!parts.length) continue;
let buffer = '';

View File

@@ -1,4 +1,4 @@
// ============ 导入 ============
// ============ 导入 ============
import { event_types } from "../../../../../../script.js";
import { extension_settings, getContext } from "../../../../../extensions.js";
@@ -42,8 +42,12 @@ 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'
'female_1', 'female_2', 'female_3', 'female_4',
'hk_female_1', 'hk_female_2', 'hk_male_1',
'tw_female_1', 'tw_female_2', 'tw_male_1',
'male_1', 'male_2', 'male_3', 'male_4',
'en_female_1', 'en_female_2', 'en_female_3', 'en_male_1', 'en_male_2',
'ja_female_1', 'ja_male_1',
]);
// ============ NovelDraw 兼容 ============
@@ -913,11 +917,26 @@ async function loadConfig() {
config = await TtsStorage.load();
config.volc = config.volc || {};
let legacyPurged = false;
if (Array.isArray(config.volc.mySpeakers)) {
config.volc.mySpeakers = config.volc.mySpeakers.map(s => ({
const normalized = config.volc.mySpeakers.map(s => ({
...s,
source: s.source || getVoiceSource(s.value)
}));
const filtered = normalized.filter(s => {
// Purge legacy free voices that are no longer supported by the current free voice map.
if (s.source === 'free' && !FREE_VOICE_KEYS.has(s.value)) {
legacyPurged = true;
return false;
}
return true;
});
config.volc.mySpeakers = filtered;
}
if (config.volc.defaultSpeaker && getVoiceSource(config.volc.defaultSpeaker) === 'free' && !FREE_VOICE_KEYS.has(config.volc.defaultSpeaker)) {
config.volc.defaultSpeaker = FREE_DEFAULT_VOICE;
legacyPurged = true;
}
config.volc.disableMarkdownFilter = config.volc.disableMarkdownFilter !== false;
@@ -943,6 +962,12 @@ async function loadConfig() {
config.showFloorButton = config.showFloorButton !== false;
config.showFloatingButton = config.showFloatingButton === true;
if (legacyPurged) {
await TtsStorage.set('volc', config.volc);
await TtsStorage.saveNow({ silent: true });
console.info('[TTS] Purged legacy free voices from mySpeakers.');
}
return config;
}
@@ -1054,15 +1079,17 @@ async function handleIframeMessage(ev) {
closeSettings();
break;
case 'xb-tts:save-config': {
const ok = await saveConfig(payload);
const requestId = payload?.requestId || '';
const patch = (payload && typeof payload.patch === 'object') ? payload.patch : payload;
const ok = await saveConfig(patch);
if (ok) {
const cacheStats = await getCacheStatsSafe();
postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats } });
postToIframe(iframe, { type: 'xb-tts:config-saved', payload: { ...config, cacheStats, requestId } });
updateAutoSpeakAll();
updateSpeedAll();
updateVoiceAll();
} else {
postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败' } });
postToIframe(iframe, { type: 'xb-tts:config-save-error', payload: { message: '保存失败', requestId } });
}
break;
}
@@ -1472,3 +1499,4 @@ export function cleanupTts() {
cacheCounters.misses = 0;
delete window.xiaobaixTts;
}