Initial commit

This commit is contained in:
TYt50
2026-01-17 16:34:39 +08:00
commit 73b8a6d23f
72 changed files with 45972 additions and 0 deletions

335
modules/tts/tts-api.js Normal file
View File

@@ -0,0 +1,335 @@
/**
* 火山引擎 TTS API 封装
* V3 单向流式 + V1试用
*/
const V3_URL = 'https://openspeech.bytedance.com/api/v3/tts/unidirectional';
const FREE_V1_URL = 'https://hstts.velure.top';
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' },
];
export const FREE_DEFAULT_VOICE = 'female_1';
// ============ 内部工具 ============
async function proxyFetch(url, options = {}) {
const proxyUrl = '/proxy/' + encodeURIComponent(url);
return fetch(proxyUrl, options);
}
function safeTail(value) {
return value ? String(value).slice(-4) : '';
}
// ============ V3 鉴权模式 ============
/**
* V3 单向流式合成(完整下载)
*/
export async function synthesizeV3(params, authHeaders = {}) {
const {
appId,
accessKey,
resourceId = 'seed-tts-2.0',
uid = 'st_user',
text,
speaker,
model,
format = 'mp3',
sampleRate = 24000,
speechRate = 0,
loudnessRate = 0,
emotion,
emotionScale,
contextTexts,
explicitLanguage,
disableMarkdownFilter = true,
disableEmojiFilter,
enableLanguageDetector,
maxLengthToFilterParenthesis,
postProcessPitch,
cacheConfig,
} = params;
if (!appId || !accessKey || !text || !speaker) {
throw new Error('缺少必要参数: appId/accessKey/text/speaker');
}
console.log('[TTS API] V3 request:', {
appIdTail: safeTail(appId),
accessKeyTail: safeTail(accessKey),
resourceId,
speaker,
textLength: text.length,
hasContextTexts: !!contextTexts?.length,
hasEmotion: !!emotion,
});
const additions = {};
if (contextTexts?.length) additions.context_texts = contextTexts;
if (explicitLanguage) additions.explicit_language = explicitLanguage;
if (disableMarkdownFilter) additions.disable_markdown_filter = true;
if (disableEmojiFilter) additions.disable_emoji_filter = true;
if (enableLanguageDetector) additions.enable_language_detector = true;
if (Number.isFinite(maxLengthToFilterParenthesis)) {
additions.max_length_to_filter_parenthesis = maxLengthToFilterParenthesis;
}
if (Number.isFinite(postProcessPitch) && postProcessPitch !== 0) {
additions.post_process = { pitch: postProcessPitch };
}
if (cacheConfig && typeof cacheConfig === 'object') {
additions.cache_config = cacheConfig;
}
const body = {
user: { uid },
req_params: {
text,
speaker,
audio_params: {
format,
sample_rate: sampleRate,
speech_rate: speechRate,
loudness_rate: loudnessRate,
},
},
};
if (model) body.req_params.model = model;
if (emotion) {
body.req_params.audio_params.emotion = emotion;
body.req_params.audio_params.emotion_scale = emotionScale || 4;
}
if (Object.keys(additions).length > 0) {
body.req_params.additions = JSON.stringify(additions);
}
const resp = await proxyFetch(V3_URL, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(body),
});
const logid = resp.headers.get('X-Tt-Logid') || '';
if (!resp.ok) {
const errText = await resp.text().catch(() => '');
throw new Error(`V3 请求失败: ${resp.status} ${errText}${logid ? ` (logid: ${logid})` : ''}`);
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
const audioChunks = [];
let usage = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const json = JSON.parse(line);
if (json.data) {
const binary = atob(json.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
audioChunks.push(bytes);
}
if (json.code === 20000000 && json.usage) {
usage = json.usage;
}
} catch {}
}
}
if (audioChunks.length === 0) {
throw new Error(`未收到音频数据${logid ? ` (logid: ${logid})` : ''}`);
}
return {
audioBlob: new Blob(audioChunks, { type: 'audio/mpeg' }),
usage,
logid,
};
}
/**
* V3 单向流式合成(边生成边回调)
*/
export async function synthesizeV3Stream(params, authHeaders = {}, options = {}) {
const {
appId,
accessKey,
uid = 'st_user',
text,
speaker,
model,
format = 'mp3',
sampleRate = 24000,
speechRate = 0,
loudnessRate = 0,
emotion,
emotionScale,
contextTexts,
explicitLanguage,
disableMarkdownFilter = true,
disableEmojiFilter,
enableLanguageDetector,
maxLengthToFilterParenthesis,
postProcessPitch,
cacheConfig,
} = params;
if (!appId || !accessKey || !text || !speaker) {
throw new Error('缺少必要参数: appId/accessKey/text/speaker');
}
const additions = {};
if (contextTexts?.length) additions.context_texts = contextTexts;
if (explicitLanguage) additions.explicit_language = explicitLanguage;
if (disableMarkdownFilter) additions.disable_markdown_filter = true;
if (disableEmojiFilter) additions.disable_emoji_filter = true;
if (enableLanguageDetector) additions.enable_language_detector = true;
if (Number.isFinite(maxLengthToFilterParenthesis)) {
additions.max_length_to_filter_parenthesis = maxLengthToFilterParenthesis;
}
if (Number.isFinite(postProcessPitch) && postProcessPitch !== 0) {
additions.post_process = { pitch: postProcessPitch };
}
if (cacheConfig && typeof cacheConfig === 'object') {
additions.cache_config = cacheConfig;
}
const body = {
user: { uid },
req_params: {
text,
speaker,
audio_params: {
format,
sample_rate: sampleRate,
speech_rate: speechRate,
loudness_rate: loudnessRate,
},
},
};
if (model) body.req_params.model = model;
if (emotion) {
body.req_params.audio_params.emotion = emotion;
body.req_params.audio_params.emotion_scale = emotionScale || 4;
}
if (Object.keys(additions).length > 0) {
body.req_params.additions = JSON.stringify(additions);
}
const resp = await proxyFetch(V3_URL, {
method: 'POST',
headers: authHeaders,
body: JSON.stringify(body),
signal: options.signal,
});
const logid = resp.headers.get('X-Tt-Logid') || '';
if (!resp.ok) {
const errText = await resp.text().catch(() => '');
throw new Error(`V3 请求失败: ${resp.status} ${errText}${logid ? ` (logid: ${logid})` : ''}`);
}
const reader = resp.body?.getReader();
if (!reader) throw new Error('V3 响应流不可用');
const decoder = new TextDecoder();
let usage = null;
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const json = JSON.parse(line);
if (json.data) {
const binary = atob(json.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
options.onChunk?.(bytes);
}
if (json.code === 20000000 && json.usage) {
usage = json.usage;
}
} catch {}
}
}
return { usage, logid };
}
// ============ 试用模式 ============
export async function synthesizeFreeV1(params, options = {}) {
const {
voiceKey = FREE_DEFAULT_VOICE,
text,
speed = 1.0,
emotion = null,
} = params || {};
if (!text) {
throw new Error('缺少必要参数: text');
}
const requestBody = {
voiceKey,
text: String(text || ''),
speed: Number(speed) || 1.0,
uid: 'xb_' + Date.now(),
reqid: crypto.randomUUID?.() || `${Date.now()}_${Math.random().toString(36).slice(2)}`,
};
if (emotion) {
requestBody.emotion = emotion;
requestBody.emotionScale = 5;
}
const res = await fetch(FREE_V1_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: options.signal,
});
if (!res.ok) throw new Error(`TTS HTTP ${res.status}`);
const data = await res.json();
if (data.code !== 3000) throw new Error(data.message || 'TTS 合成失败');
return { audioBase64: data.data };
}

View File

@@ -0,0 +1,311 @@
// tts-auth-provider.js
/**
* TTS 鉴权模式播放服务
* 负责火山引擎 V3 API 的调用与流式播放
*/
import { synthesizeV3, synthesizeV3Stream } from './tts-api.js';
import { normalizeEmotion } from './tts-text.js';
import { getRequestHeaders } from "../../../../../../script.js";
// ============ 工具函数(内部) ============
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 estimateDuration(text) {
return Math.max(2, Math.ceil(String(text || '').length / 4));
}
function supportsStreaming() {
try {
return typeof MediaSource !== 'undefined' && MediaSource.isTypeSupported('audio/mpeg');
} catch {
return false;
}
}
function resolveContextTexts(context, resourceId) {
const text = String(context || '').trim();
if (!text || resourceId !== 'seed-tts-2.0') return [];
return [text];
}
// ============ 导出的工具函数 ============
export function speedToV3SpeechRate(speed) {
return Math.round((normalizeSpeed(speed) - 1) * 100);
}
export function inferResourceIdBySpeaker(value) {
const v = (value || '').trim();
const lower = v.toLowerCase();
if (lower.startsWith('icl_') || lower.startsWith('s_')) {
return 'seed-icl-2.0';
}
if (v.includes('_uranus_') || v.includes('_saturn_') || v.includes('_moon_')) {
return 'seed-tts-2.0';
}
return 'seed-tts-1.0';
}
export function buildV3Headers(resourceId, config) {
const stHeaders = getRequestHeaders() || {};
const headers = {
...stHeaders,
'Content-Type': 'application/json',
'X-Api-App-Id': config.volc.appId,
'X-Api-Access-Key': config.volc.accessKey,
'X-Api-Resource-Id': resourceId,
};
if (config.volc.usageReturn) {
headers['X-Control-Require-Usage-Tokens-Return'] = 'text_words';
}
return headers;
}
// ============ 参数构建 ============
function buildSynthesizeParams({ text, speaker, resourceId }, config) {
const params = {
providerMode: 'auth',
appId: config.volc.appId,
accessKey: config.volc.accessKey,
resourceId,
speaker,
text,
format: 'mp3',
sampleRate: 24000,
speechRate: speedToV3SpeechRate(config.volc.speechRate),
loudnessRate: 0,
emotionScale: config.volc.emotionScale,
explicitLanguage: config.volc.explicitLanguage,
disableMarkdownFilter: config.volc.disableMarkdownFilter,
disableEmojiFilter: config.volc.disableEmojiFilter,
enableLanguageDetector: config.volc.enableLanguageDetector,
maxLengthToFilterParenthesis: config.volc.maxLengthToFilterParenthesis,
postProcessPitch: config.volc.postProcessPitch,
};
if (resourceId === 'seed-tts-1.0' && config.volc.useTts11 !== false) {
params.model = 'seed-tts-1.1';
}
if (config.volc.serverCacheEnabled) {
params.cacheConfig = { text_type: 1, use_cache: true };
}
return params;
}
// ============ 单段播放(导出供混合模式使用) ============
export async function speakSegmentAuth(messageId, segment, segmentIndex, batchId, ctx) {
const {
isFirst,
config,
player,
tryLoadLocalCache,
updateState
} = ctx;
const speaker = segment.resolvedSpeaker;
const resourceId = inferResourceIdBySpeaker(speaker);
const params = buildSynthesizeParams({ text: segment.text, speaker, resourceId }, config);
const emotion = normalizeEmotion(segment.emotion);
const contextTexts = resolveContextTexts(segment.context, resourceId);
if (emotion) params.emotion = emotion;
if (contextTexts.length) params.contextTexts = contextTexts;
// 首段初始化状态
if (isFirst) {
updateState({
status: 'sending',
text: segment.text,
textLength: segment.text.length,
cached: false,
usage: null,
error: '',
duration: estimateDuration(segment.text),
});
}
updateState({ currentSegment: segmentIndex + 1 });
// 尝试缓存
const cacheHit = await tryLoadLocalCache(params);
if (cacheHit?.entry?.blob) {
updateState({
cached: true,
status: 'cached',
audioBlob: cacheHit.entry.blob,
cacheKey: cacheHit.key
});
player.enqueue({
id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`,
messageId,
segmentIndex,
batchId,
audioBlob: cacheHit.entry.blob,
text: segment.text,
});
return;
}
const headers = buildV3Headers(resourceId, config);
try {
if (supportsStreaming()) {
await playWithStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx);
} else {
await playWithoutStreaming(messageId, segment, segmentIndex, batchId, params, headers, ctx);
}
} catch (err) {
updateState({ status: 'error', error: err?.message || '请求失败' });
}
}
// ============ 流式播放 ============
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 controller = new AbortController();
const chunks = [];
let resolved = false;
const donePromise = new Promise((resolve, reject) => {
const streamItem = {
id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`,
messageId,
segmentIndex,
batchId,
text: segment.text,
streamFactory: () => ({
mimeType: 'audio/mpeg',
abort: () => controller.abort(),
start: async (append, end, fail) => {
try {
const result = await synthesizeV3Stream(params, headers, {
signal: controller.signal,
onChunk: (bytes) => {
chunks.push(bytes);
append(bytes);
},
});
end();
if (!resolved) {
resolved = true;
resolve({
audioBlob: new Blob(chunks, { type: 'audio/mpeg' }),
usage: result.usage || null,
logid: result.logid
});
}
} catch (err) {
if (!resolved) {
resolved = true;
fail(err);
reject(err);
}
}
},
}),
};
const ok = player.enqueue(streamItem);
if (!ok && !resolved) {
resolved = true;
reject(new Error('播放队列已存在相同任务'));
}
});
donePromise.then(async (result) => {
if (!result?.audioBlob) return;
updateState({ audioBlob: result.audioBlob, usage: result.usage || null });
const cacheKey = buildCacheKey(params);
updateState({ cacheKey });
await storeLocalCache(cacheKey, result.audioBlob, {
text: segment.text.slice(0, 200),
textLength: segment.text.length,
speaker,
resourceId,
usage: result.usage || null,
});
}).catch((err) => {
if (err?.name === 'AbortError' || /aborted/i.test(err?.message || '')) return;
updateState({ status: 'error', error: err?.message || '请求失败' });
});
updateState({ status: 'queued' });
}
// ============ 非流式播放 ============
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 result = await synthesizeV3(params, headers);
updateState({ audioBlob: result.audioBlob, usage: result.usage, status: 'queued' });
const cacheKey = buildCacheKey(params);
updateState({ cacheKey });
await storeLocalCache(cacheKey, result.audioBlob, {
text: segment.text.slice(0, 200),
textLength: segment.text.length,
speaker,
resourceId,
usage: result.usage || null,
});
player.enqueue({
id: `msg-${messageId}-batch-${batchId}-seg-${segmentIndex}`,
messageId,
segmentIndex,
batchId,
audioBlob: result.audioBlob,
text: segment.text,
});
}
// ============ 主入口 ============
export async function speakMessageAuth(options) {
const {
messageId,
segments,
batchId,
config,
player,
tryLoadLocalCache,
storeLocalCache,
buildCacheKey,
updateState,
isModuleEnabled,
} = options;
const ctx = {
config,
player,
tryLoadLocalCache,
storeLocalCache,
buildCacheKey,
updateState
};
for (let i = 0; i < segments.length; i++) {
if (isModuleEnabled && !isModuleEnabled()) return;
await speakSegmentAuth(messageId, segments[i], i, batchId, {
isFirst: i === 0,
...ctx
});
}
}

171
modules/tts/tts-cache.js Normal file
View File

@@ -0,0 +1,171 @@
/**
* Local TTS cache (IndexedDB)
*/
const DB_NAME = 'xb-tts-cache';
const STORE_NAME = 'audio';
const DB_VERSION = 1;
let dbPromise = null;
function openDb() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, { keyPath: 'key' });
store.createIndex('createdAt', 'createdAt', { unique: false });
store.createIndex('lastAccessAt', 'lastAccessAt', { unique: false });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbPromise;
}
async function withStore(mode, fn) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
const result = fn(store);
tx.oncomplete = () => resolve(result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
}
export async function getCacheEntry(key) {
const entry = await withStore('readonly', store => {
return new Promise((resolve, reject) => {
const req = store.get(key);
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => reject(req.error);
});
});
if (!entry) return null;
const now = Date.now();
if (entry.lastAccessAt !== now) {
entry.lastAccessAt = now;
await withStore('readwrite', store => store.put(entry));
}
return entry;
}
export async function setCacheEntry(key, blob, meta = {}) {
const now = Date.now();
const entry = {
key,
blob,
size: blob?.size || 0,
createdAt: now,
lastAccessAt: now,
meta,
};
await withStore('readwrite', store => store.put(entry));
return entry;
}
export async function deleteCacheEntry(key) {
await withStore('readwrite', store => store.delete(key));
}
export async function getCacheStats() {
const stats = await withStore('readonly', store => {
return new Promise((resolve, reject) => {
let count = 0;
let totalBytes = 0;
const req = store.openCursor();
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return resolve({ count, totalBytes });
count += 1;
totalBytes += cursor.value?.size || 0;
cursor.continue();
};
req.onerror = () => reject(req.error);
});
});
return {
count: stats.count,
totalBytes: stats.totalBytes,
sizeMB: (stats.totalBytes / (1024 * 1024)).toFixed(2),
};
}
export async function clearExpiredCache(days = 7) {
const cutoff = Date.now() - Math.max(1, days) * 24 * 60 * 60 * 1000;
return withStore('readwrite', store => {
return new Promise((resolve, reject) => {
let removed = 0;
const req = store.openCursor();
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return resolve(removed);
const createdAt = cursor.value?.createdAt || 0;
if (createdAt && createdAt < cutoff) {
cursor.delete();
removed += 1;
}
cursor.continue();
};
req.onerror = () => reject(req.error);
});
});
}
export async function clearAllCache() {
await withStore('readwrite', store => store.clear());
}
export async function pruneCache({ maxEntries, maxBytes }) {
const limits = {
maxEntries: Number.isFinite(maxEntries) ? maxEntries : null,
maxBytes: Number.isFinite(maxBytes) ? maxBytes : null,
};
if (!limits.maxEntries && !limits.maxBytes) return 0;
const entries = await withStore('readonly', store => {
return new Promise((resolve, reject) => {
const list = [];
const req = store.openCursor();
req.onsuccess = () => {
const cursor = req.result;
if (!cursor) return resolve(list);
const v = cursor.value || {};
list.push({
key: v.key,
size: v.size || 0,
lastAccessAt: v.lastAccessAt || v.createdAt || 0,
});
cursor.continue();
};
req.onerror = () => reject(req.error);
});
});
if (!entries.length) return 0;
let totalBytes = entries.reduce((sum, e) => sum + (e.size || 0), 0);
entries.sort((a, b) => (a.lastAccessAt || 0) - (b.lastAccessAt || 0));
let removed = 0;
const shouldTrim = () => (
(limits.maxEntries && entries.length - removed > limits.maxEntries) ||
(limits.maxBytes && totalBytes > limits.maxBytes)
);
for (const entry of entries) {
if (!shouldTrim()) break;
await deleteCacheEntry(entry.key);
totalBytes -= entry.size || 0;
removed += 1;
}
return removed;
}

View File

@@ -0,0 +1,390 @@
import { synthesizeFreeV1, FREE_VOICES, FREE_DEFAULT_VOICE } from './tts-api.js';
import { normalizeEmotion, splitTtsSegmentsForFree } from './tts-text.js';
const MAX_RETRIES = 3;
const RETRY_DELAYS = [500, 1000, 2000];
const activeQueueManagers = new Map();
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 generateBatchId() {
return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function estimateDuration(text) {
return Math.max(2, Math.ceil(String(text || '').length / 4));
}
function resolveFreeVoiceByName(speakerName, mySpeakers, defaultSpeaker) {
if (!speakerName) return defaultSpeaker;
const list = Array.isArray(mySpeakers) ? mySpeakers : [];
const byName = list.find(s => s.name === speakerName);
if (byName?.value) return byName.value;
const byValue = list.find(s => s.value === speakerName);
if (byValue?.value) return byValue.value;
const isFreeVoice = FREE_VOICES.some(v => v.key === speakerName);
if (isFreeVoice) return speakerName;
return defaultSpeaker;
}
class SegmentQueueManager {
constructor(options) {
const { player, messageId, batchId, totalSegments } = options;
this.player = player;
this.messageId = messageId;
this.batchId = batchId;
this.totalSegments = totalSegments;
this.segments = Array(totalSegments).fill(null).map((_, i) => ({
index: i,
status: 'pending',
audioBlob: null,
text: '',
retryCount: 0,
error: null,
retryTimer: null,
}));
this.nextEnqueueIndex = 0;
this.onSegmentReady = null;
this.onSegmentSkipped = null;
this.onRetryNeeded = null;
this.onComplete = null;
this.onProgress = null;
this._completed = false;
this._destroyed = false;
this.abortController = new AbortController();
}
get signal() {
return this.abortController.signal;
}
markLoading(index) {
if (this._destroyed) return;
const seg = this.segments[index];
if (seg && seg.status === 'pending') {
seg.status = 'loading';
}
}
setReady(index, audioBlob, text = '') {
if (this._destroyed) return;
const seg = this.segments[index];
if (!seg) return;
seg.status = 'ready';
seg.audioBlob = audioBlob;
seg.text = text;
seg.error = null;
this.onSegmentReady?.(index, seg);
this._tryEnqueueNext();
}
setFailed(index, error) {
if (this._destroyed) return false;
const seg = this.segments[index];
if (!seg) return false;
seg.retryCount++;
seg.error = error;
if (seg.retryCount >= MAX_RETRIES) {
seg.status = 'skipped';
this.onSegmentSkipped?.(index, seg);
this._tryEnqueueNext();
return false;
}
seg.status = 'pending';
const delay = RETRY_DELAYS[seg.retryCount - 1] || 2000;
seg.retryTimer = setTimeout(() => {
seg.retryTimer = null;
if (!this._destroyed) {
this.onRetryNeeded?.(index, seg.retryCount);
}
}, delay);
return true;
}
_tryEnqueueNext() {
if (this._destroyed) return;
while (this.nextEnqueueIndex < this.totalSegments) {
const seg = this.segments[this.nextEnqueueIndex];
if (seg.status === 'ready' && seg.audioBlob) {
this.player.enqueue({
id: `msg-${this.messageId}-batch-${this.batchId}-seg-${seg.index}`,
messageId: this.messageId,
segmentIndex: seg.index,
batchId: this.batchId,
audioBlob: seg.audioBlob,
text: seg.text,
});
seg.status = 'enqueued';
this.nextEnqueueIndex++;
this.onProgress?.(this.getStats());
continue;
}
if (seg.status === 'skipped') {
this.nextEnqueueIndex++;
this.onProgress?.(this.getStats());
continue;
}
break;
}
this._checkCompletion();
}
_checkCompletion() {
if (this._completed || this._destroyed) return;
if (this.nextEnqueueIndex >= this.totalSegments) {
this._completed = true;
this.onComplete?.(this.getStats());
}
}
getStats() {
let ready = 0, skipped = 0, pending = 0, loading = 0, enqueued = 0;
for (const seg of this.segments) {
switch (seg.status) {
case 'ready': ready++; break;
case 'enqueued': enqueued++; break;
case 'skipped': skipped++; break;
case 'loading': loading++; break;
default: pending++; break;
}
}
return {
total: this.totalSegments,
enqueued,
ready,
skipped,
pending,
loading,
nextEnqueue: this.nextEnqueueIndex,
completed: this._completed
};
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
try {
this.abortController.abort();
} catch {}
for (const seg of this.segments) {
if (seg.retryTimer) {
clearTimeout(seg.retryTimer);
seg.retryTimer = null;
}
}
this.onComplete = null;
this.onSegmentReady = null;
this.onSegmentSkipped = null;
this.onRetryNeeded = null;
this.onProgress = null;
this.segments = [];
}
}
export function clearAllFreeQueues() {
for (const qm of activeQueueManagers.values()) {
qm.destroy();
}
activeQueueManagers.clear();
}
export function clearFreeQueueForMessage(messageId) {
const qm = activeQueueManagers.get(messageId);
if (qm) {
qm.destroy();
activeQueueManagers.delete(messageId);
}
}
export async function speakMessageFree(options) {
const {
messageId,
segments,
defaultSpeaker = FREE_DEFAULT_VOICE,
mySpeakers = [],
player,
config,
tryLoadLocalCache,
storeLocalCache,
buildCacheKey,
updateState,
clearMessageFromQueue,
mode = 'auto',
} = options;
if (!segments?.length) return { success: false };
clearFreeQueueForMessage(messageId);
const freeSpeed = normalizeSpeed(config?.volc?.speechRate);
const splitSegments = splitTtsSegmentsForFree(segments);
if (!splitSegments.length) return { success: false };
const batchId = generateBatchId();
if (mode === 'manual') clearMessageFromQueue?.(messageId);
updateState?.({
status: 'sending',
text: splitSegments.map(s => s.text).join('\n').slice(0, 200),
textLength: splitSegments.reduce((sum, s) => sum + s.text.length, 0),
cached: false,
error: '',
duration: splitSegments.reduce((sum, s) => sum + estimateDuration(s.text), 0),
currentSegment: 0,
totalSegments: splitSegments.length,
});
const queueManager = new SegmentQueueManager({
player,
messageId,
batchId,
totalSegments: splitSegments.length
});
activeQueueManagers.set(messageId, queueManager);
const fetchSegment = async (index) => {
if (queueManager._destroyed) return;
const segment = splitSegments[index];
if (!segment) return;
queueManager.markLoading(index);
updateState?.({
currentSegment: index + 1,
status: 'sending',
});
const emotion = normalizeEmotion(segment.emotion);
const voiceKey = segment.resolvedSpeaker
|| (segment.speaker
? resolveFreeVoiceByName(segment.speaker, mySpeakers, defaultSpeaker)
: (defaultSpeaker || FREE_DEFAULT_VOICE));
const cacheParams = {
providerMode: 'free',
text: segment.text,
speaker: voiceKey,
freeSpeed,
emotion: emotion || '',
};
if (tryLoadLocalCache) {
try {
const cacheHit = await tryLoadLocalCache(cacheParams);
if (cacheHit?.entry?.blob) {
queueManager.setReady(index, cacheHit.entry.blob, segment.text);
return;
}
} catch {}
}
try {
const { audioBase64 } = await synthesizeFreeV1({
text: segment.text,
voiceKey,
speed: freeSpeed,
emotion: emotion || null,
}, { signal: queueManager.signal });
if (queueManager._destroyed) return;
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' });
if (storeLocalCache && buildCacheKey) {
const cacheKey = buildCacheKey(cacheParams);
storeLocalCache(cacheKey, audioBlob, {
text: segment.text.slice(0, 200),
textLength: segment.text.length,
speaker: voiceKey,
resourceId: 'free',
}).catch(() => {});
}
queueManager.setReady(index, audioBlob, segment.text);
} catch (err) {
if (err?.name === 'AbortError' || queueManager._destroyed) {
return;
}
queueManager.setFailed(index, err);
}
};
queueManager.onRetryNeeded = (index, retryCount) => {
fetchSegment(index);
};
queueManager.onSegmentReady = (index, seg) => {
const stats = queueManager.getStats();
updateState?.({
currentSegment: stats.enqueued + stats.ready,
status: stats.enqueued > 0 ? 'queued' : 'sending',
});
};
queueManager.onSegmentSkipped = (index, seg) => {
};
queueManager.onProgress = (stats) => {
updateState?.({
currentSegment: stats.enqueued,
totalSegments: stats.total,
});
};
queueManager.onComplete = (stats) => {
if (stats.enqueued === 0) {
updateState?.({
status: 'error',
error: '全部段落请求失败',
});
}
activeQueueManagers.delete(messageId);
queueManager.destroy();
};
for (let i = 0; i < splitSegments.length; i++) {
fetchSegment(i);
}
return { success: true };
}
export { FREE_VOICES, FREE_DEFAULT_VOICE };

1750
modules/tts/tts-overlay.html Normal file

File diff suppressed because it is too large Load Diff

776
modules/tts/tts-panel.js Normal file
View File

@@ -0,0 +1,776 @@
/**
* TTS 播放器面板 - 极简胶囊版 v2
* 黑白灰配色,舒缓动画
*/
let stylesInjected = false;
const panelMap = new Map();
const pendingCallbacks = new Map();
let observer = null;
// 配置接口
let getConfigFn = null;
let saveConfigFn = null;
let openSettingsFn = null;
let clearQueueFn = null;
export function setPanelConfigHandlers({ getConfig, saveConfig, openSettings, clearQueue }) {
getConfigFn = getConfig;
saveConfigFn = saveConfig;
openSettingsFn = openSettings;
clearQueueFn = clearQueue;
}
export function clearPanelConfigHandlers() {
getConfigFn = null;
saveConfigFn = null;
openSettingsFn = null;
clearQueueFn = null;
}
// ============ 工具函数 ============
// ============ 样式 ============
function injectStyles() {
if (stylesInjected) return;
const css = `
/* ═══════════════════════════════════════════════════════════════
TTS 播放器 - 极简胶囊
═══════════════════════════════════════════════════════════════ */
.xb-tts-panel {
--h: 30px;
--bg: rgba(0, 0, 0, 0.55);
--bg-hover: rgba(0, 0, 0, 0.7);
--border: rgba(255, 255, 255, 0.08);
--border-active: rgba(255, 255, 255, 0.2);
--text: rgba(255, 255, 255, 0.85);
--text-sub: rgba(255, 255, 255, 0.45);
--text-dim: rgba(255, 255, 255, 0.25);
--success: rgba(255, 255, 255, 0.9);
--error: rgba(239, 68, 68, 0.8);
position: relative;
display: inline-flex;
flex-direction: column;
margin: 8px 0;
z-index: 10;
user-select: none;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* ═══════════════════════════════════════════════════════════════
胶囊主体
═══════════════════════════════════════════════════════════════ */
.xb-tts-capsule {
display: flex;
align-items: center;
height: var(--h);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 15px;
padding: 0 3px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
width: fit-content;
gap: 1px;
}
.xb-tts-panel:hover .xb-tts-capsule {
background: var(--bg-hover);
border-color: var(--border-active);
}
/* 状态边框 */
.xb-tts-panel[data-status="playing"] .xb-tts-capsule {
border-color: rgba(255, 255, 255, 0.25);
}
.xb-tts-panel[data-status="error"] .xb-tts-capsule {
border-color: var(--error);
}
/* ═══════════════════════════════════════════════════════════════
按钮
═══════════════════════════════════════════════════════════════ */
.xb-tts-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text);
cursor: pointer;
border-radius: 50%;
font-size: 10px;
transition: all 0.25s ease;
flex-shrink: 0;
}
.xb-tts-btn:hover {
background: rgba(255, 255, 255, 0.12);
}
.xb-tts-btn:active {
transform: scale(0.92);
}
/* 播放按钮 */
.xb-tts-btn.play-btn {
font-size: 11px;
}
/* 停止按钮 - 正方形图标 */
.xb-tts-btn.stop-btn {
color: var(--text-sub);
font-size: 8px;
}
.xb-tts-btn.stop-btn:hover {
color: var(--error);
background: rgba(239, 68, 68, 0.1);
}
/* 展开按钮 */
.xb-tts-btn.expand-btn {
width: 22px;
height: 22px;
font-size: 8px;
color: var(--text-dim);
opacity: 0.6;
transition: opacity 0.25s, transform 0.25s;
}
.xb-tts-panel:hover .xb-tts-btn.expand-btn {
opacity: 1;
}
.xb-tts-panel.expanded .xb-tts-btn.expand-btn {
transform: rotate(180deg);
}
/* ═══════════════════════════════════════════════════════════════
分隔线
═══════════════════════════════════════════════════════════════ */
.xb-tts-sep {
width: 1px;
height: 12px;
background: var(--border);
margin: 0 2px;
flex-shrink: 0;
}
/* ═══════════════════════════════════════════════════════════════
信息区
═══════════════════════════════════════════════════════════════ */
.xb-tts-info {
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px;
min-width: 50px;
}
.xb-tts-status {
font-size: 11px;
color: var(--text-sub);
white-space: nowrap;
transition: color 0.25s;
}
.xb-tts-panel[data-status="playing"] .xb-tts-status {
color: var(--text);
}
.xb-tts-panel[data-status="error"] .xb-tts-status {
color: var(--error);
}
/* 队列徽标 */
.xb-tts-badge {
display: none;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
color: var(--text);
padding: 2px 6px;
border-radius: 8px;
font-size: 10px;
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.xb-tts-panel[data-has-queue="true"] .xb-tts-badge {
display: flex;
}
/* ═══════════════════════════════════════════════════════════════
波形动画 - 舒缓版
═══════════════════════════════════════════════════════════════ */
.xb-tts-wave {
display: none;
align-items: center;
gap: 2px;
height: 14px;
padding: 0 4px;
}
.xb-tts-panel[data-status="playing"] .xb-tts-wave {
display: flex;
}
.xb-tts-panel[data-status="playing"] .xb-tts-status {
display: none;
}
.xb-tts-bar {
width: 2px;
background: var(--text);
border-radius: 1px;
animation: xb-tts-wave 1.6s infinite ease-in-out;
opacity: 0.7;
}
.xb-tts-bar:nth-child(1) { height: 4px; animation-delay: 0.0s; }
.xb-tts-bar:nth-child(2) { height: 10px; animation-delay: 0.2s; }
.xb-tts-bar:nth-child(3) { height: 6px; animation-delay: 0.4s; }
.xb-tts-bar:nth-child(4) { height: 8px; animation-delay: 0.6s; }
@keyframes xb-tts-wave {
0%, 100% {
transform: scaleY(0.4);
opacity: 0.4;
}
50% {
transform: scaleY(1);
opacity: 0.85;
}
}
/* ═══════════════════════════════════════════════════════════════
加载动画
═══════════════════════════════════════════════════════════════ */
.xb-tts-loading {
display: none;
width: 12px;
height: 12px;
border: 1.5px solid rgba(255, 255, 255, 0.15);
border-top-color: var(--text);
border-radius: 50%;
animation: xb-tts-spin 1s linear infinite;
margin: 0 4px;
}
.xb-tts-panel[data-status="sending"] .xb-tts-loading,
.xb-tts-panel[data-status="queued"] .xb-tts-loading {
display: block;
}
.xb-tts-panel[data-status="sending"] .play-btn,
.xb-tts-panel[data-status="queued"] .play-btn {
display: none;
}
@keyframes xb-tts-spin {
to { transform: rotate(360deg); }
}
/* ═══════════════════════════════════════════════════════════════
底部进度条
═══════════════════════════════════════════════════════════════ */
.xb-tts-progress {
position: absolute;
bottom: 0;
left: 8px;
right: 8px;
height: 2px;
background: rgba(255, 255, 255, 0.08);
border-radius: 1px;
overflow: hidden;
opacity: 0;
transition: opacity 0.3s;
}
.xb-tts-panel[data-status="playing"] .xb-tts-progress,
.xb-tts-panel[data-has-queue="true"] .xb-tts-progress {
opacity: 1;
}
.xb-tts-progress-inner {
height: 100%;
background: rgba(255, 255, 255, 0.6);
width: 0%;
transition: width 0.4s ease-out;
border-radius: 1px;
}
/* ═══════════════════════════════════════════════════════════════
展开菜单
═══════════════════════════════════════════════════════════════ */
.xb-tts-menu {
position: absolute;
top: calc(100% + 8px);
left: 0;
background: rgba(18, 18, 22, 0.96);
border: 1px solid var(--border);
border-radius: 12px;
padding: 10px;
min-width: 220px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
opacity: 0;
visibility: hidden;
transform: translateY(-6px) scale(0.96);
transform-origin: top left;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 100;
}
.xb-tts-panel.expanded .xb-tts-menu {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.xb-tts-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 2px;
}
.xb-tts-label {
font-size: 11px;
color: var(--text-sub);
width: 32px;
flex-shrink: 0;
}
.xb-tts-select {
flex: 1;
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--border);
color: var(--text);
font-size: 11px;
border-radius: 6px;
padding: 6px 8px;
outline: none;
cursor: pointer;
transition: border-color 0.2s;
}
.xb-tts-select:hover {
border-color: rgba(255, 255, 255, 0.2);
}
.xb-tts-select:focus {
border-color: rgba(255, 255, 255, 0.3);
}
.xb-tts-slider {
flex: 1;
height: 4px;
accent-color: #fff;
cursor: pointer;
}
.xb-tts-val {
font-size: 11px;
color: var(--text);
width: 32px;
text-align: right;
font-variant-numeric: tabular-nums;
}
/* 工具栏 */
.xb-tts-tools {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.xb-tts-usage {
font-size: 10px;
color: var(--text-dim);
}
.xb-tts-icon-btn {
color: var(--text-sub);
cursor: pointer;
font-size: 13px;
padding: 4px 6px;
border-radius: 4px;
transition: all 0.2s;
}
.xb-tts-icon-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
}
/* ═══════════════════════════════════════════════════════════════
TTS 指令块样式
═══════════════════════════════════════════════════════════════ */
.xb-tts-tag {
display: inline-flex;
align-items: center;
gap: 3px;
color: rgba(255, 255, 255, 0.25);
font-size: 11px;
font-style: italic;
vertical-align: baseline;
user-select: none;
transition: color 0.3s ease;
}
.xb-tts-tag:hover {
color: rgba(255, 255, 255, 0.45);
}
.xb-tts-tag-icon {
font-style: normal;
font-size: 10px;
opacity: 0.7;
}
.xb-tts-tag-dot {
opacity: 0.4;
}
.xb-tts-tag[data-has-params="true"] {
color: rgba(255, 255, 255, 0.3);
}
`;
const style = document.createElement('style');
style.id = 'xb-tts-panel-styles';
style.textContent = css;
document.head.appendChild(style);
stylesInjected = true;
}
// ============ 面板创建 ============
function createPanel(messageId) {
const config = getConfigFn?.() || {};
const currentSpeed = config?.volc?.speechRate || 1.0;
const div = document.createElement('div');
div.className = 'xb-tts-panel';
div.dataset.messageId = messageId;
div.dataset.status = 'idle';
div.dataset.hasQueue = 'false';
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
div.innerHTML = `
<div class="xb-tts-capsule">
<div class="xb-tts-loading"></div>
<button class="xb-tts-btn play-btn" title="播放">▶</button>
<div class="xb-tts-info">
<div class="xb-tts-wave">
<div class="xb-tts-bar"></div>
<div class="xb-tts-bar"></div>
<div class="xb-tts-bar"></div>
<div class="xb-tts-bar"></div>
</div>
<span class="xb-tts-status">播放</span>
<span class="xb-tts-badge">0/0</span>
</div>
<button class="xb-tts-btn stop-btn" title="停止">■</button>
<div class="xb-tts-sep"></div>
<button class="xb-tts-btn expand-btn" title="设置">▼</button>
<div class="xb-tts-progress">
<div class="xb-tts-progress-inner"></div>
</div>
</div>
<div class="xb-tts-menu">
<div class="xb-tts-row">
<span class="xb-tts-label">音色</span>
<select class="xb-tts-select voice-select"></select>
</div>
<div class="xb-tts-row">
<span class="xb-tts-label">语速</span>
<input type="range" class="xb-tts-slider speed-slider" min="0.5" max="2.0" step="0.1" value="${currentSpeed}">
<span class="xb-tts-val speed-val">${currentSpeed.toFixed(1)}x</span>
</div>
<div class="xb-tts-tools">
<span class="xb-tts-usage">--</span>
<span class="xb-tts-icon-btn settings-btn" title="TTS 设置">⚙</span>
</div>
</div>
`;
return div;
}
function buildVoiceOptions(select, config) {
const mySpeakers = config?.volc?.mySpeakers || [];
const current = config?.volc?.defaultSpeaker || '';
if (mySpeakers.length === 0) {
// Template-only UI markup.
// eslint-disable-next-line no-unsanitized/property
select.innerHTML = '<option value="" disabled>暂无音色</option>';
select.selectedIndex = -1;
return;
}
const isMyVoice = current && mySpeakers.some(s => s.value === current);
// UI options from config values only.
// eslint-disable-next-line no-unsanitized/property
select.innerHTML = mySpeakers.map(s => {
const selected = isMyVoice && s.value === current ? ' selected' : '';
return `<option value="${s.value}"${selected}>${s.name || s.value}</option>`;
}).join('');
if (!isMyVoice) {
select.selectedIndex = -1;
}
}
function mountPanel(messageEl, messageId, onPlay) {
if (panelMap.has(messageId)) return panelMap.get(messageId);
const nameBlock = messageEl.querySelector('.mes_block > .ch_name') ||
messageEl.querySelector('.name_text')?.parentElement;
if (!nameBlock) return null;
const panel = createPanel(messageId);
if (nameBlock.nextSibling) {
nameBlock.parentNode.insertBefore(panel, nameBlock.nextSibling);
} else {
nameBlock.parentNode.appendChild(panel);
}
const ui = {
root: panel,
playBtn: panel.querySelector('.play-btn'),
stopBtn: panel.querySelector('.stop-btn'),
statusText: panel.querySelector('.xb-tts-status'),
badge: panel.querySelector('.xb-tts-badge'),
progressInner: panel.querySelector('.xb-tts-progress-inner'),
voiceSelect: panel.querySelector('.voice-select'),
speedSlider: panel.querySelector('.speed-slider'),
speedVal: panel.querySelector('.speed-val'),
usageText: panel.querySelector('.xb-tts-usage'),
};
ui.playBtn.onclick = (e) => {
e.stopPropagation();
onPlay(messageId);
};
ui.stopBtn.onclick = (e) => {
e.stopPropagation();
clearQueueFn?.(messageId);
};
panel.querySelector('.expand-btn').onclick = (e) => {
e.stopPropagation();
panel.classList.toggle('expanded');
if (panel.classList.contains('expanded')) {
buildVoiceOptions(ui.voiceSelect, getConfigFn?.());
}
};
panel.querySelector('.settings-btn').onclick = (e) => {
e.stopPropagation();
panel.classList.remove('expanded');
openSettingsFn?.();
};
ui.voiceSelect.onchange = async (e) => {
const config = getConfigFn?.();
if (config?.volc) {
config.volc.defaultSpeaker = e.target.value;
await saveConfigFn?.({ volc: config.volc });
}
};
ui.speedSlider.oninput = (e) => {
ui.speedVal.textContent = Number(e.target.value).toFixed(1) + 'x';
};
ui.speedSlider.onchange = async (e) => {
const config = getConfigFn?.();
if (config?.volc) {
config.volc.speechRate = Number(e.target.value);
await saveConfigFn?.({ volc: config.volc });
}
};
const closeMenu = (e) => {
if (!panel.contains(e.target)) {
panel.classList.remove('expanded');
}
};
document.addEventListener('click', closeMenu, { passive: true });
ui._cleanup = () => {
document.removeEventListener('click', closeMenu);
};
panelMap.set(messageId, ui);
return ui;
}
// ============ 对外接口 ============
export function initTtsPanelStyles() {
injectStyles();
}
export function ensureTtsPanel(messageEl, messageId, onPlay) {
injectStyles();
if (panelMap.has(messageId)) {
const existingUi = panelMap.get(messageId);
if (existingUi.root && existingUi.root.isConnected) {
return existingUi;
}
existingUi._cleanup?.();
panelMap.delete(messageId);
}
const rect = messageEl.getBoundingClientRect();
if (rect.top < window.innerHeight + 500 && rect.bottom > -500) {
return mountPanel(messageEl, messageId, onPlay);
}
if (!observer) {
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const mid = Number(el.getAttribute('mesid'));
const cb = pendingCallbacks.get(mid);
if (cb) {
mountPanel(el, mid, cb);
pendingCallbacks.delete(mid);
observer.unobserve(el);
}
}
});
}, { rootMargin: '500px' });
}
pendingCallbacks.set(messageId, onPlay);
observer.observe(messageEl);
return null;
}
export function updateTtsPanel(messageId, state) {
const ui = panelMap.get(messageId);
if (!ui || !state) return;
const status = state.status || 'idle';
const current = state.currentSegment || 0;
const total = state.totalSegments || 0;
const hasQueue = total > 1;
ui.root.dataset.status = status;
ui.root.dataset.hasQueue = hasQueue ? 'true' : 'false';
// 状态文本和图标
let statusText = '';
let playIcon = '▶';
let showStop = false;
switch (status) {
case 'idle':
statusText = '播放';
playIcon = '▶';
break;
case 'sending':
case 'queued':
statusText = hasQueue ? `${current}/${total}` : '准备';
playIcon = '■';
showStop = true;
break;
case 'cached':
statusText = hasQueue ? `${current}/${total}` : '缓存';
playIcon = '▶';
break;
case 'playing':
statusText = hasQueue ? `${current}/${total}` : '';
playIcon = '⏸';
showStop = true;
break;
case 'paused':
statusText = hasQueue ? `${current}/${total}` : '暂停';
playIcon = '▶';
showStop = true;
break;
case 'ended':
statusText = '完成';
playIcon = '↻';
break;
case 'blocked':
statusText = '受阻';
playIcon = '▶';
break;
case 'error':
statusText = (state.error || '失败').slice(0, 8);
playIcon = '↻';
break;
default:
statusText = '播放';
playIcon = '▶';
}
ui.playBtn.textContent = playIcon;
ui.statusText.textContent = statusText;
// 队列徽标
if (hasQueue && current > 0) {
ui.badge.textContent = `${current}/${total}`;
}
// 停止按钮显示
ui.stopBtn.style.display = showStop ? '' : 'none';
// 进度条
if (hasQueue && total > 0) {
const pct = Math.min(100, (current / total) * 100);
ui.progressInner.style.width = `${pct}%`;
} else if (state.progress && state.duration) {
const pct = Math.min(100, (state.progress / state.duration) * 100);
ui.progressInner.style.width = `${pct}%`;
} else {
ui.progressInner.style.width = '0%';
}
// 用量显示
if (state.textLength) {
ui.usageText.textContent = `${state.textLength}`;
}
}
export function removeAllTtsPanels() {
panelMap.forEach(ui => {
ui._cleanup?.();
ui.root?.remove();
});
panelMap.clear();
pendingCallbacks.clear();
observer?.disconnect();
observer = null;
}
export function removeTtsPanel(messageId) {
const ui = panelMap.get(messageId);
if (ui) {
ui._cleanup?.();
ui.root?.remove();
panelMap.delete(messageId);
}
pendingCallbacks.delete(messageId);
}

309
modules/tts/tts-player.js Normal file
View File

@@ -0,0 +1,309 @@
/**
* TTS 队列播放器
*/
export class TtsPlayer {
constructor() {
this.queue = [];
this.currentAudio = null;
this.currentItem = null;
this.currentStream = null;
this.currentCleanup = null;
this.isPlaying = false;
this.onStateChange = null; // 回调:(state, item, info) => void
}
/**
* 入队
* @param {Object} item - { id, audioBlob, text? }
* @returns {boolean} 是否成功入队重复id会跳过
*/
enqueue(item) {
if (!item?.audioBlob && !item?.streamFactory) return false;
// 防重复
if (item.id && this.queue.some(q => q.id === item.id)) {
return false;
}
this.queue.push(item);
this._notifyState('enqueued', item);
if (!this.isPlaying) {
this._playNext();
}
return true;
}
/**
* 清空队列并停止播放
*/
clear() {
this.queue = [];
this._stopCurrent(true);
this.currentItem = null;
this.isPlaying = false;
this._notifyState('cleared', null);
}
/**
* 获取队列长度
*/
get length() {
return this.queue.length;
}
/**
* 立即播放(打断队列)
* @param {Object} item
*/
playNow(item) {
if (!item?.audioBlob && !item?.streamFactory) return false;
this.queue = [];
this._stopCurrent(true);
this._playItem(item);
return true;
}
/**
* 切换播放(同一条则暂停/继续)
* @param {Object} item
*/
toggle(item) {
if (!item?.audioBlob && !item?.streamFactory) return false;
if (this.currentItem?.id === item.id && this.currentAudio) {
if (this.currentAudio.paused) {
this.currentAudio.play().catch(err => {
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
this._notifyState('blocked', item);
});
} else {
this.currentAudio.pause();
}
return true;
}
return this.playNow(item);
}
_playNext() {
if (this.queue.length === 0) {
this.isPlaying = false;
this.currentItem = null;
this._notifyState('idle', null);
return;
}
const item = this.queue.shift();
this._playItem(item);
}
_playItem(item) {
this.isPlaying = true;
this.currentItem = item;
this._notifyState('playing', item);
if (item.streamFactory) {
this._playStreamItem(item);
return;
}
const url = URL.createObjectURL(item.audioBlob);
const audio = new Audio(url);
this.currentAudio = audio;
this.currentCleanup = () => {
URL.revokeObjectURL(url);
};
audio.onloadedmetadata = () => {
this._notifyState('metadata', item, { duration: audio.duration || 0 });
};
audio.ontimeupdate = () => {
this._notifyState('progress', item, { currentTime: audio.currentTime || 0, duration: audio.duration || 0 });
};
audio.onplay = () => {
this._notifyState('playing', item);
};
audio.onpause = () => {
if (!audio.ended) this._notifyState('paused', item);
};
audio.onended = () => {
this.currentCleanup?.();
this.currentCleanup = null;
this.currentAudio = null;
this.currentItem = null;
this._notifyState('ended', item);
this._playNext();
};
audio.onerror = (e) => {
console.error('[TTS Player] 播放失败:', e);
this.currentCleanup?.();
this.currentCleanup = null;
this.currentAudio = null;
this.currentItem = null;
this._notifyState('error', item);
this._playNext();
};
audio.play().catch(err => {
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
this._notifyState('blocked', item);
this._playNext();
});
}
_playStreamItem(item) {
let objectUrl = '';
let mediaSource = null;
let sourceBuffer = null;
let streamEnded = false;
let hasError = false;
const queue = [];
const stream = item.streamFactory();
this.currentStream = stream;
const audio = new Audio();
this.currentAudio = audio;
const cleanup = () => {
if (this.currentAudio) {
this.currentAudio.pause();
}
this.currentAudio = null;
this.currentItem = null;
this.currentStream = null;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
objectUrl = '';
}
};
this.currentCleanup = cleanup;
const pump = () => {
if (!sourceBuffer || sourceBuffer.updating || queue.length === 0) {
if (streamEnded && sourceBuffer && !sourceBuffer.updating && queue.length === 0) {
try {
if (mediaSource?.readyState === 'open') mediaSource.endOfStream();
} catch {}
}
return;
}
const chunk = queue.shift();
if (chunk) {
try {
sourceBuffer.appendBuffer(chunk);
} catch (err) {
handleStreamError(err);
}
}
};
const handleStreamError = (err) => {
if (hasError) return;
if (this.currentItem !== item) return;
hasError = true;
console.error('[TTS Player] 流式播放失败:', err);
try { stream?.abort?.(); } catch {}
cleanup();
this.currentCleanup = null;
this._notifyState('error', item);
this._playNext();
};
mediaSource = new MediaSource();
objectUrl = URL.createObjectURL(mediaSource);
audio.src = objectUrl;
mediaSource.addEventListener('sourceopen', () => {
if (hasError) return;
if (this.currentItem !== item) return;
try {
const mimeType = stream?.mimeType || 'audio/mpeg';
if (!MediaSource.isTypeSupported(mimeType)) {
throw new Error(`不支持的流式音频类型: ${mimeType}`);
}
sourceBuffer = mediaSource.addSourceBuffer(mimeType);
sourceBuffer.mode = 'sequence';
sourceBuffer.addEventListener('updateend', pump);
} catch (err) {
handleStreamError(err);
return;
}
const append = (chunk) => {
if (hasError) return;
queue.push(chunk);
pump();
};
const end = () => {
streamEnded = true;
pump();
};
const fail = (err) => {
handleStreamError(err);
};
Promise.resolve(stream?.start?.(append, end, fail)).catch(fail);
});
audio.onloadedmetadata = () => {
this._notifyState('metadata', item, { duration: audio.duration || 0 });
};
audio.ontimeupdate = () => {
this._notifyState('progress', item, { currentTime: audio.currentTime || 0, duration: audio.duration || 0 });
};
audio.onplay = () => {
this._notifyState('playing', item);
};
audio.onpause = () => {
if (!audio.ended) this._notifyState('paused', item);
};
audio.onended = () => {
if (this.currentItem !== item) return;
cleanup();
this.currentCleanup = null;
this._notifyState('ended', item);
this._playNext();
};
audio.onerror = (e) => {
console.error('[TTS Player] 播放失败:', e);
handleStreamError(e);
};
audio.play().catch(err => {
console.warn('[TTS Player] 播放被阻止(需用户手势):', err);
try { stream?.abort?.(); } catch {}
cleanup();
this._notifyState('blocked', item);
this._playNext();
});
}
_stopCurrent(abortStream = false) {
if (abortStream) {
try { this.currentStream?.abort?.(); } catch {}
}
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio = null;
}
this.currentCleanup?.();
this.currentCleanup = null;
this.currentStream = null;
}
_notifyState(state, item, info = null) {
if (typeof this.onStateChange === 'function') {
try { this.onStateChange(state, item, info); } catch (e) {}
}
}
}

317
modules/tts/tts-text.js Normal file
View File

@@ -0,0 +1,317 @@
// tts-text.js
/**
* TTS 文本提取与情绪处理
*/
// ============ 文本提取 ============
export function extractSpeakText(rawText, rules = {}) {
if (!rawText || typeof rawText !== 'string') return '';
let text = rawText;
const ttsPlaceholders = [];
text = text.replace(/\[tts:[^\]]*\]/gi, (match) => {
const placeholder = `__TTS_TAG_${ttsPlaceholders.length}__`;
ttsPlaceholders.push(match);
return placeholder;
});
const ranges = Array.isArray(rules.skipRanges) ? rules.skipRanges : [];
for (const range of ranges) {
const start = String(range?.start ?? '').trim();
const end = String(range?.end ?? '').trim();
if (!start && !end) continue;
if (!start && end) {
const endIdx = text.indexOf(end);
if (endIdx !== -1) text = text.slice(endIdx + end.length);
continue;
}
if (start && !end) {
const startIdx = text.indexOf(start);
if (startIdx !== -1) text = text.slice(0, startIdx);
continue;
}
let out = '';
let i = 0;
while (true) {
const sIdx = text.indexOf(start, i);
if (sIdx === -1) {
out += text.slice(i);
break;
}
out += text.slice(i, sIdx);
const eIdx = text.indexOf(end, sIdx + start.length);
if (eIdx === -1) break;
i = eIdx + end.length;
}
text = out;
}
const readRanges = Array.isArray(rules.readRanges) ? rules.readRanges : [];
if (rules.readRangesEnabled && readRanges.length) {
const keepSpans = [];
for (const range of readRanges) {
const start = String(range?.start ?? '').trim();
const end = String(range?.end ?? '').trim();
if (!start && !end) {
keepSpans.push({ start: 0, end: text.length });
continue;
}
if (!start && end) {
const endIdx = text.indexOf(end);
if (endIdx !== -1) keepSpans.push({ start: 0, end: endIdx });
continue;
}
if (start && !end) {
const startIdx = text.indexOf(start);
if (startIdx !== -1) keepSpans.push({ start: startIdx + start.length, end: text.length });
continue;
}
let i = 0;
while (true) {
const sIdx = text.indexOf(start, i);
if (sIdx === -1) break;
const eIdx = text.indexOf(end, sIdx + start.length);
if (eIdx === -1) {
keepSpans.push({ start: sIdx + start.length, end: text.length });
break;
}
keepSpans.push({ start: sIdx + start.length, end: eIdx });
i = eIdx + end.length;
}
}
if (keepSpans.length) {
keepSpans.sort((a, b) => a.start - b.start || a.end - b.end);
const merged = [];
for (const span of keepSpans) {
if (!merged.length || span.start > merged[merged.length - 1].end) {
merged.push({ start: span.start, end: span.end });
} else {
merged[merged.length - 1].end = Math.max(merged[merged.length - 1].end, span.end);
}
}
text = merged.map(span => text.slice(span.start, span.end)).join('');
} else {
text = '';
}
}
text = text.replace(/<script[\s\S]*?<\/script>/gi, '');
text = text.replace(/<style[\s\S]*?<\/style>/gi, '');
text = text.replace(/\n{3,}/g, '\n\n').trim();
for (let i = 0; i < ttsPlaceholders.length; i++) {
text = text.replace(`__TTS_TAG_${i}__`, ttsPlaceholders[i]);
}
return text;
}
// ============ 分段解析 ============
export function parseTtsSegments(text) {
if (!text || typeof text !== 'string') return [];
const segments = [];
const re = /\[tts:([^\]]*)\]/gi;
let lastIndex = 0;
let match = null;
// 当前块的配置,每遇到新 [tts:] 块都重置
let current = { emotion: '', context: '', speaker: '' };
const pushSegment = (segmentText) => {
const t = String(segmentText || '').trim();
if (!t) return;
segments.push({
text: t,
emotion: current.emotion || '',
context: current.context || '',
speaker: current.speaker || '', // 空字符串表示使用 UI 默认
});
};
const parseDirective = (raw) => {
// ★ 关键修改:每个新块都重置为空,不继承上一个块的 speaker
const next = { emotion: '', context: '', speaker: '' };
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).trim();
}
if (key === 'emotion') next.emotion = val;
if (key === 'context') next.context = val;
if (key === 'speaker') next.speaker = val;
}
current = next;
};
while ((match = re.exec(text)) !== null) {
pushSegment(text.slice(lastIndex, match.index));
parseDirective(match[1]);
lastIndex = match.index + match[0].length;
}
pushSegment(text.slice(lastIndex));
return segments;
}
// ============ 非鉴权分段切割 ============
const FREE_MAX_TEXT = 200;
const FREE_MIN_TEXT = 50;
const FREE_SENTENCE_DELIMS = new Set(['。', '', '', '!', '?', ';', '', '…', '.', '', ',', '、', ':', '']);
function splitLongTextBySentence(text, maxLength) {
const sentences = [];
let buf = '';
for (const ch of String(text || '')) {
buf += ch;
if (FREE_SENTENCE_DELIMS.has(ch)) {
sentences.push(buf);
buf = '';
}
}
if (buf) sentences.push(buf);
const chunks = [];
let current = '';
for (const sentence of sentences) {
if (!sentence) continue;
if (sentence.length > maxLength) {
if (current) {
chunks.push(current);
current = '';
}
for (let i = 0; i < sentence.length; i += maxLength) {
chunks.push(sentence.slice(i, i + maxLength));
}
continue;
}
if (!current) {
current = sentence;
continue;
}
if (current.length + sentence.length > maxLength) {
chunks.push(current);
current = sentence;
continue;
}
current += sentence;
}
if (current) chunks.push(current);
return chunks;
}
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);
for (const para of paragraphs) {
if (para.length <= maxLength) {
chunks.push(para);
continue;
}
chunks.push(...splitLongTextBySentence(para, maxLength));
}
return chunks;
}
export function splitTtsSegmentsForFree(segments, maxLength = FREE_MAX_TEXT) {
if (!Array.isArray(segments) || !segments.length) return [];
const out = [];
for (const seg of segments) {
const parts = splitTextForFree(seg.text, maxLength);
if (!parts.length) continue;
let buffer = '';
for (const part of parts) {
const t = String(part || '').trim();
if (!t) continue;
if (!buffer) {
buffer = t;
continue;
}
if (buffer.length < FREE_MIN_TEXT && buffer.length + t.length <= maxLength) {
buffer += `\n${t}`;
continue;
}
out.push({
text: buffer,
emotion: seg.emotion || '',
context: seg.context || '',
speaker: seg.speaker || '',
resolvedSpeaker: seg.resolvedSpeaker || '',
resolvedSource: seg.resolvedSource || '',
});
buffer = t;
}
if (buffer) {
out.push({
text: buffer,
emotion: seg.emotion || '',
context: seg.context || '',
speaker: seg.speaker || '',
resolvedSpeaker: seg.resolvedSpeaker || '',
resolvedSource: seg.resolvedSource || '',
});
}
}
return out;
}
// ============ 默认跳过标签 ============
export const DEFAULT_SKIP_TAGS = ['状态栏'];
// ============ 情绪处理 ============
export const TTS_EMOTIONS = new Set([
'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',
]);
export const EMOTION_CN_MAP = {
'开心': 'happy', '高兴': 'happy', '愉悦': 'happy',
'悲伤': 'sad', '难过': 'sad',
'生气': 'angry', '愤怒': 'angry',
'惊讶': 'surprised',
'恐惧': 'fear', '害怕': 'fear',
'厌恶': 'hate',
'激动': 'excited', '兴奋': 'excited',
'冷漠': 'coldness', '中性': 'neutral', '沮丧': 'depressed',
'撒娇': 'lovey-dovey', '害羞': 'shy',
'安慰': 'comfort', '鼓励': 'comfort',
'咆哮': 'tension', '焦急': 'tension',
'温柔': 'tender',
'讲故事': 'storytelling', '自然讲述': 'storytelling',
'情感电台': 'radio', '磁性': 'magnetic',
'广告营销': 'advertising', '气泡音': 'vocal-fry',
'低语': 'asmr', '新闻播报': 'news',
'娱乐八卦': 'entertainment', '方言': 'dialect',
'对话': 'chat', '闲聊': 'chat',
'温暖': 'warm', '深情': 'affectionate', '权威': 'authoritative',
};
export function normalizeEmotion(raw) {
if (!raw) return '';
let val = String(raw).trim();
if (!val) return '';
val = EMOTION_CN_MAP[val] || EMOTION_CN_MAP[val.toLowerCase()] || val.toLowerCase();
if (val === 'vocal - fry' || val === 'vocal_fry') val = 'vocal-fry';
if (val === 'surprise') val = 'surprised';
if (val === 'scare') val = 'fear';
return TTS_EMOTIONS.has(val) ? val : '';
}

197
modules/tts/tts-voices.js Normal file
View File

@@ -0,0 +1,197 @@
// tts-voices.js
// 已移除所有 _tob 企业音色
window.XB_TTS_TTS2_VOICE_INFO = [
{ "value": "zh_female_vv_uranus_bigtts", "name": "Vivi 2.0", "scene": "通用场景" },
{ "value": "zh_female_xiaohe_uranus_bigtts", "name": "小何", "scene": "通用场景" },
{ "value": "zh_male_m191_uranus_bigtts", "name": "云舟", "scene": "通用场景" },
{ "value": "zh_male_taocheng_uranus_bigtts", "name": "小天", "scene": "通用场景" },
{ "value": "zh_male_dayi_saturn_bigtts", "name": "大壹", "scene": "视频配音" },
{ "value": "zh_female_mizai_saturn_bigtts", "name": "黑猫侦探社咪仔", "scene": "视频配音" },
{ "value": "zh_female_jitangnv_saturn_bigtts", "name": "鸡汤女", "scene": "视频配音" },
{ "value": "zh_female_meilinvyou_saturn_bigtts", "name": "魅力女友", "scene": "视频配音" },
{ "value": "zh_female_santongyongns_saturn_bigtts", "name": "流畅女声", "scene": "视频配音" },
{ "value": "zh_male_ruyayichen_saturn_bigtts", "name": "儒雅逸辰", "scene": "视频配音" },
{ "value": "zh_female_xueayi_saturn_bigtts", "name": "儿童绘本", "scene": "有声阅读" }
];
window.XB_TTS_VOICE_DATA = [
// ========== TTS 2.0 ==========
{ "value": "zh_female_vv_uranus_bigtts", "name": "Vivi 2.0", "scene": "通用场景" },
{ "value": "zh_female_xiaohe_uranus_bigtts", "name": "小何", "scene": "通用场景" },
{ "value": "zh_male_m191_uranus_bigtts", "name": "云舟", "scene": "通用场景" },
{ "value": "zh_male_taocheng_uranus_bigtts", "name": "小天", "scene": "通用场景" },
{ "value": "zh_male_dayi_saturn_bigtts", "name": "大壹", "scene": "视频配音" },
{ "value": "zh_female_mizai_saturn_bigtts", "name": "黑猫侦探社咪仔", "scene": "视频配音" },
{ "value": "zh_female_jitangnv_saturn_bigtts", "name": "鸡汤女", "scene": "视频配音" },
{ "value": "zh_female_meilinvyou_saturn_bigtts", "name": "魅力女友", "scene": "视频配音" },
{ "value": "zh_female_santongyongns_saturn_bigtts", "name": "流畅女声", "scene": "视频配音" },
{ "value": "zh_male_ruyayichen_saturn_bigtts", "name": "儒雅逸辰", "scene": "视频配音" },
{ "value": "zh_female_xueayi_saturn_bigtts", "name": "儿童绘本", "scene": "有声阅读" },
// ========== TTS 1.0 方言 ==========
{ "value": "zh_female_wanqudashu_moon_bigtts", "name": "湾区大叔", "scene": "趣味方言" },
{ "value": "zh_female_daimengchuanmei_moon_bigtts", "name": "呆萌川妹", "scene": "趣味方言" },
{ "value": "zh_male_guozhoudege_moon_bigtts", "name": "广州德哥", "scene": "趣味方言" },
{ "value": "zh_male_beijingxiaoye_moon_bigtts", "name": "北京小爷", "scene": "趣味方言" },
{ "value": "zh_male_haoyuxiaoge_moon_bigtts", "name": "浩宇小哥", "scene": "趣味方言" },
{ "value": "zh_male_guangxiyuanzhou_moon_bigtts", "name": "广西远舟", "scene": "趣味方言" },
{ "value": "zh_female_meituojieer_moon_bigtts", "name": "妹坨洁儿", "scene": "趣味方言" },
{ "value": "zh_male_yuzhouzixuan_moon_bigtts", "name": "豫州子轩", "scene": "趣味方言" },
{ "value": "zh_male_jingqiangkanye_moon_bigtts", "name": "京腔侃爷/Harmony", "scene": "趣味方言" },
{ "value": "zh_female_wanwanxiaohe_moon_bigtts", "name": "湾湾小何", "scene": "趣味方言" },
// ========== TTS 1.0 通用 ==========
{ "value": "zh_male_shaonianzixin_moon_bigtts", "name": "少年梓辛/Brayan", "scene": "通用场景" },
{ "value": "zh_female_linjianvhai_moon_bigtts", "name": "邻家女孩", "scene": "通用场景" },
{ "value": "zh_male_yuanboxiaoshu_moon_bigtts", "name": "渊博小叔", "scene": "通用场景" },
{ "value": "zh_male_yangguangqingnian_moon_bigtts", "name": "阳光青年", "scene": "通用场景" },
{ "value": "zh_female_shuangkuaisisi_moon_bigtts", "name": "爽快思思/Skye", "scene": "通用场景" },
{ "value": "zh_male_wennuanahu_moon_bigtts", "name": "温暖阿虎/Alvin", "scene": "通用场景" },
{ "value": "zh_female_tianmeixiaoyuan_moon_bigtts", "name": "甜美小源", "scene": "通用场景" },
{ "value": "zh_female_qingchezizi_moon_bigtts", "name": "清澈梓梓", "scene": "通用场景" },
{ "value": "zh_male_jieshuoxiaoming_moon_bigtts", "name": "解说小明", "scene": "通用场景" },
{ "value": "zh_female_kailangjiejie_moon_bigtts", "name": "开朗姐姐", "scene": "通用场景" },
{ "value": "zh_male_linjiananhai_moon_bigtts", "name": "邻家男孩", "scene": "通用场景" },
{ "value": "zh_female_tianmeiyueyue_moon_bigtts", "name": "甜美悦悦", "scene": "通用场景" },
{ "value": "zh_female_xinlingjitang_moon_bigtts", "name": "心灵鸡汤", "scene": "通用场景" },
{ "value": "zh_female_qinqienvsheng_moon_bigtts", "name": "亲切女声", "scene": "通用场景" },
{ "value": "zh_female_cancan_mars_bigtts", "name": "灿灿", "scene": "通用场景" },
{ "value": "zh_female_zhixingnvsheng_mars_bigtts", "name": "知性女声", "scene": "通用场景" },
{ "value": "zh_female_qingxinnvsheng_mars_bigtts", "name": "清新女声", "scene": "通用场景" },
{ "value": "zh_female_linjia_mars_bigtts", "name": "邻家小妹", "scene": "通用场景" },
{ "value": "zh_male_qingshuangnanda_mars_bigtts", "name": "清爽男大", "scene": "通用场景" },
{ "value": "zh_female_tiexinnvsheng_mars_bigtts", "name": "贴心女声", "scene": "通用场景" },
{ "value": "zh_male_wenrouxiaoge_mars_bigtts", "name": "温柔小哥", "scene": "通用场景" },
{ "value": "zh_female_tianmeitaozi_mars_bigtts", "name": "甜美桃子", "scene": "通用场景" },
{ "value": "zh_female_kefunvsheng_mars_bigtts", "name": "暖阳女声", "scene": "通用场景" },
{ "value": "zh_male_qingyiyuxuan_mars_bigtts", "name": "阳光阿辰", "scene": "通用场景" },
{ "value": "zh_female_vv_mars_bigtts", "name": "Vivi", "scene": "通用场景" },
{ "value": "zh_male_ruyayichen_emo_v2_mars_bigtts", "name": "儒雅男友", "scene": "通用场景" },
{ "value": "zh_female_maomao_conversation_wvae_bigtts", "name": "文静毛毛", "scene": "通用场景" },
{ "value": "en_male_jason_conversation_wvae_bigtts", "name": "开朗学长", "scene": "通用场景" },
// ========== TTS 1.0 角色扮演 ==========
{ "value": "zh_female_meilinvyou_moon_bigtts", "name": "魅力女友", "scene": "角色扮演" },
{ "value": "zh_male_shenyeboke_moon_bigtts", "name": "深夜播客", "scene": "角色扮演" },
{ "value": "zh_female_sajiaonvyou_moon_bigtts", "name": "柔美女友", "scene": "角色扮演" },
{ "value": "zh_female_yuanqinvyou_moon_bigtts", "name": "撒娇学妹", "scene": "角色扮演" },
{ "value": "zh_female_gaolengyujie_moon_bigtts", "name": "高冷御姐", "scene": "角色扮演" },
{ "value": "zh_male_aojiaobazong_moon_bigtts", "name": "傲娇霸总", "scene": "角色扮演" },
{ "value": "zh_female_wenrouxiaoya_moon_bigtts", "name": "温柔小雅", "scene": "角色扮演" },
{ "value": "zh_male_dongfanghaoran_moon_bigtts", "name": "东方浩然", "scene": "角色扮演" },
{ "value": "zh_male_tiancaitongsheng_mars_bigtts", "name": "天才童声", "scene": "角色扮演" },
{ "value": "zh_male_naiqimengwa_mars_bigtts", "name": "奶气萌娃", "scene": "角色扮演" },
{ "value": "zh_male_sunwukong_mars_bigtts", "name": "猴哥", "scene": "角色扮演" },
{ "value": "zh_male_xionger_mars_bigtts", "name": "熊二", "scene": "角色扮演" },
{ "value": "zh_female_peiqi_mars_bigtts", "name": "佩奇猪", "scene": "角色扮演" },
{ "value": "zh_female_popo_mars_bigtts", "name": "婆婆", "scene": "角色扮演" },
{ "value": "zh_female_wuzetian_mars_bigtts", "name": "武则天", "scene": "角色扮演" },
{ "value": "zh_female_shaoergushi_mars_bigtts", "name": "少儿故事", "scene": "角色扮演" },
{ "value": "zh_male_silang_mars_bigtts", "name": "四郎", "scene": "角色扮演" },
{ "value": "zh_female_gujie_mars_bigtts", "name": "顾姐", "scene": "角色扮演" },
{ "value": "zh_female_yingtaowanzi_mars_bigtts", "name": "樱桃丸子", "scene": "角色扮演" },
{ "value": "zh_female_qiaopinvsheng_mars_bigtts", "name": "俏皮女声", "scene": "角色扮演" },
{ "value": "zh_female_mengyatou_mars_bigtts", "name": "萌丫头", "scene": "角色扮演" },
{ "value": "zh_male_zhoujielun_emo_v2_mars_bigtts", "name": "双节棍小哥", "scene": "角色扮演" },
{ "value": "zh_female_jiaochuan_mars_bigtts", "name": "娇喘女声", "scene": "角色扮演" },
{ "value": "zh_male_livelybro_mars_bigtts", "name": "开朗弟弟", "scene": "角色扮演" },
{ "value": "zh_female_flattery_mars_bigtts", "name": "谄媚女声", "scene": "角色扮演" },
// ========== TTS 1.0 播报解说 ==========
{ "value": "en_female_anna_mars_bigtts", "name": "Anna", "scene": "播报解说" },
{ "value": "zh_male_changtianyi_mars_bigtts", "name": "悬疑解说", "scene": "播报解说" },
{ "value": "zh_male_jieshuonansheng_mars_bigtts", "name": "磁性解说男声", "scene": "播报解说" },
{ "value": "zh_female_jitangmeimei_mars_bigtts", "name": "鸡汤妹妹", "scene": "播报解说" },
{ "value": "zh_male_chunhui_mars_bigtts", "name": "广告解说", "scene": "播报解说" },
// ========== TTS 1.0 有声阅读 ==========
{ "value": "zh_male_ruyaqingnian_mars_bigtts", "name": "儒雅青年", "scene": "有声阅读" },
{ "value": "zh_male_baqiqingshu_mars_bigtts", "name": "霸气青叔", "scene": "有声阅读" },
{ "value": "zh_male_qingcang_mars_bigtts", "name": "擎苍", "scene": "有声阅读" },
{ "value": "zh_male_yangguangqingnian_mars_bigtts", "name": "活力小哥", "scene": "有声阅读" },
{ "value": "zh_female_gufengshaoyu_mars_bigtts", "name": "古风少御", "scene": "有声阅读" },
{ "value": "zh_female_wenroushunv_mars_bigtts", "name": "温柔淑女", "scene": "有声阅读" },
{ "value": "zh_male_fanjuanqingnian_mars_bigtts", "name": "反卷青年", "scene": "有声阅读" },
// ========== TTS 1.0 视频配音 ==========
{ "value": "zh_male_dongmanhaimian_mars_bigtts", "name": "亮嗓萌仔", "scene": "视频配音" },
{ "value": "zh_male_lanxiaoyang_mars_bigtts", "name": "懒音绵宝", "scene": "视频配音" },
// ========== TTS 1.0 教育场景 ==========
{ "value": "zh_female_yingyujiaoyu_mars_bigtts", "name": "Tina老师", "scene": "教育场景" },
// ========== TTS 1.0 趣味口音 ==========
{ "value": "zh_male_hupunan_mars_bigtts", "name": "沪普男", "scene": "趣味口音" },
{ "value": "zh_male_lubanqihao_mars_bigtts", "name": "鲁班七号", "scene": "趣味口音" },
{ "value": "zh_female_yangmi_mars_bigtts", "name": "林潇", "scene": "趣味口音" },
{ "value": "zh_female_linzhiling_mars_bigtts", "name": "玲玲姐姐", "scene": "趣味口音" },
{ "value": "zh_female_jiyejizi2_mars_bigtts", "name": "春日部姐姐", "scene": "趣味口音" },
{ "value": "zh_male_tangseng_mars_bigtts", "name": "唐僧", "scene": "趣味口音" },
{ "value": "zh_male_zhuangzhou_mars_bigtts", "name": "庄周", "scene": "趣味口音" },
{ "value": "zh_male_zhubajie_mars_bigtts", "name": "猪八戒", "scene": "趣味口音" },
{ "value": "zh_female_ganmaodianyin_mars_bigtts", "name": "感冒电音姐姐", "scene": "趣味口音" },
{ "value": "zh_female_naying_mars_bigtts", "name": "直率英子", "scene": "趣味口音" },
{ "value": "zh_female_leidian_mars_bigtts", "name": "女雷神", "scene": "趣味口音" },
{ "value": "zh_female_yueyunv_mars_bigtts", "name": "粤语小溏", "scene": "趣味口音" },
// ========== TTS 1.0 多情感 ==========
{ "value": "zh_male_beijingxiaoye_emo_v2_mars_bigtts", "name": "北京小爷(多情感)", "scene": "多情感" },
{ "value": "zh_female_roumeinvyou_emo_v2_mars_bigtts", "name": "柔美女友(多情感)", "scene": "多情感" },
{ "value": "zh_male_yangguangqingnian_emo_v2_mars_bigtts", "name": "阳光青年(多情感)", "scene": "多情感" },
{ "value": "zh_female_meilinvyou_emo_v2_mars_bigtts", "name": "魅力女友(多情感)", "scene": "多情感" },
{ "value": "zh_female_shuangkuaisisi_emo_v2_mars_bigtts", "name": "爽快思思(多情感)", "scene": "多情感" },
{ "value": "zh_male_junlangnanyou_emo_v2_mars_bigtts", "name": "俊朗男友(多情感)", "scene": "多情感" },
{ "value": "zh_male_yourougongzi_emo_v2_mars_bigtts", "name": "优柔公子(多情感)", "scene": "多情感" },
{ "value": "zh_female_linjuayi_emo_v2_mars_bigtts", "name": "邻居阿姨(多情感)", "scene": "多情感" },
{ "value": "zh_male_jingqiangkanye_emo_mars_bigtts", "name": "京腔侃爷(多情感)", "scene": "多情感" },
{ "value": "zh_male_guangzhoudege_emo_mars_bigtts", "name": "广州德哥(多情感)", "scene": "多情感" },
{ "value": "zh_male_aojiaobazong_emo_v2_mars_bigtts", "name": "傲娇霸总(多情感)", "scene": "多情感" },
{ "value": "zh_female_tianxinxiaomei_emo_v2_mars_bigtts", "name": "甜心小美(多情感)", "scene": "多情感" },
{ "value": "zh_female_gaolengyujie_emo_v2_mars_bigtts", "name": "高冷御姐(多情感)", "scene": "多情感" },
{ "value": "zh_male_lengkugege_emo_v2_mars_bigtts", "name": "冷酷哥哥(多情感)", "scene": "多情感" },
{ "value": "zh_male_shenyeboke_emo_v2_mars_bigtts", "name": "深夜播客(多情感)", "scene": "多情感" },
// ========== TTS 1.0 多语种 ==========
{ "value": "multi_female_shuangkuaisisi_moon_bigtts", "name": "はるこ/Esmeralda", "scene": "多语种" },
{ "value": "multi_male_jingqiangkanye_moon_bigtts", "name": "かずね/Javier", "scene": "多语种" },
{ "value": "multi_female_gaolengyujie_moon_bigtts", "name": "あけみ", "scene": "多语种" },
{ "value": "multi_male_wanqudashu_moon_bigtts", "name": "ひろし/Roberto", "scene": "多语种" },
{ "value": "en_male_adam_mars_bigtts", "name": "Adam", "scene": "多语种" },
{ "value": "en_female_sarah_mars_bigtts", "name": "Sarah", "scene": "多语种" },
{ "value": "en_male_dryw_mars_bigtts", "name": "Dryw", "scene": "多语种" },
{ "value": "en_male_smith_mars_bigtts", "name": "Smith", "scene": "多语种" },
{ "value": "en_male_jackson_mars_bigtts", "name": "Jackson", "scene": "多语种" },
{ "value": "en_female_amanda_mars_bigtts", "name": "Amanda", "scene": "多语种" },
{ "value": "en_female_emily_mars_bigtts", "name": "Emily", "scene": "多语种" },
{ "value": "multi_male_xudong_conversation_wvae_bigtts", "name": "まさお/Daníel", "scene": "多语种" },
{ "value": "multi_female_sophie_conversation_wvae_bigtts", "name": "さとみ/Sofía", "scene": "多语种" },
{ "value": "zh_male_M100_conversation_wvae_bigtts", "name": "悠悠君子/Lucas", "scene": "多语种" },
{ "value": "zh_male_xudong_conversation_wvae_bigtts", "name": "快乐小东/Daniel", "scene": "多语种" },
{ "value": "zh_female_sophie_conversation_wvae_bigtts", "name": "魅力苏菲/Sophie", "scene": "多语种" },
{ "value": "multi_zh_male_youyoujunzi_moon_bigtts", "name": "ひかる(光)", "scene": "多语种" },
{ "value": "en_male_charlie_conversation_wvae_bigtts", "name": "Owen", "scene": "多语种" },
{ "value": "en_female_sarah_new_conversation_wvae_bigtts", "name": "Luna", "scene": "多语种" },
{ "value": "en_female_dacey_conversation_wvae_bigtts", "name": "Daisy", "scene": "多语种" },
{ "value": "multi_female_maomao_conversation_wvae_bigtts", "name": "つき/Diana", "scene": "多语种" },
{ "value": "multi_male_M100_conversation_wvae_bigtts", "name": "Lucía", "scene": "多语种" },
{ "value": "en_male_campaign_jamal_moon_bigtts", "name": "Energetic Male II", "scene": "多语种" },
{ "value": "en_male_chris_moon_bigtts", "name": "Gotham Hero", "scene": "多语种" },
{ "value": "en_female_daisy_moon_bigtts", "name": "Delicate Girl", "scene": "多语种" },
{ "value": "en_female_product_darcie_moon_bigtts", "name": "Flirty Female", "scene": "多语种" },
{ "value": "en_female_emotional_moon_bigtts", "name": "Peaceful Female", "scene": "多语种" },
{ "value": "en_male_bruce_moon_bigtts", "name": "Bruce", "scene": "多语种" },
{ "value": "en_male_dave_moon_bigtts", "name": "Dave", "scene": "多语种" },
{ "value": "en_male_hades_moon_bigtts", "name": "Hades", "scene": "多语种" },
{ "value": "en_male_michael_moon_bigtts", "name": "Michael", "scene": "多语种" },
{ "value": "en_female_onez_moon_bigtts", "name": "Onez", "scene": "多语种" },
{ "value": "en_female_nara_moon_bigtts", "name": "Nara", "scene": "多语种" },
{ "value": "en_female_lauren_moon_bigtts", "name": "Lauren", "scene": "多语种" },
{ "value": "en_female_candice_emo_v2_mars_bigtts", "name": "Candice", "scene": "多语种" },
{ "value": "en_male_corey_emo_v2_mars_bigtts", "name": "Corey", "scene": "多语种" },
{ "value": "en_male_glen_emo_v2_mars_bigtts", "name": "Glen", "scene": "多语种" },
{ "value": "en_female_nadia_tips_emo_v2_mars_bigtts", "name": "Nadia1", "scene": "多语种" },
{ "value": "en_female_nadia_poetry_emo_v2_mars_bigtts", "name": "Nadia2", "scene": "多语种" },
{ "value": "en_male_sylus_emo_v2_mars_bigtts", "name": "Sylus", "scene": "多语种" },
{ "value": "en_female_skye_emo_v2_mars_bigtts", "name": "Serena", "scene": "多语种" }
];

1284
modules/tts/tts.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB