feat: updates to test branch
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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> — 需配置火山引擎 API(200+ 音色 + 复刻)
|
||||
</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>
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user