Initial commit
This commit is contained in:
335
modules/tts/tts-api.js
Normal file
335
modules/tts/tts-api.js
Normal 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 };
|
||||
}
|
||||
311
modules/tts/tts-auth-provider.js
Normal file
311
modules/tts/tts-auth-provider.js
Normal 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
171
modules/tts/tts-cache.js
Normal 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;
|
||||
}
|
||||
390
modules/tts/tts-free-provider.js
Normal file
390
modules/tts/tts-free-provider.js
Normal 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
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
776
modules/tts/tts-panel.js
Normal 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
309
modules/tts/tts-player.js
Normal 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
317
modules/tts/tts-text.js
Normal 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
197
modules/tts/tts-voices.js
Normal 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
1284
modules/tts/tts.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
modules/tts/声音复刻.png
Normal file
BIN
modules/tts/声音复刻.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
modules/tts/开通管理.png
Normal file
BIN
modules/tts/开通管理.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
modules/tts/获取ID和KEY.png
Normal file
BIN
modules/tts/获取ID和KEY.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Reference in New Issue
Block a user