index.html
Rama main · réplica de lectura del repositorio original.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="docTitle">TTS-API</title>
<!-- 在 body 渲染前定好主题,避免首屏闪白(FOUC) -->
<script>
if (localStorage.getItem("theme") === "dark") {
document.documentElement.classList.add("dark");
}
</script>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<!-- Tailwind browser 引擎只处理内联 type="text/tailwindcss" 指令,dark 变体必须放这里 -->
<style type="text/tailwindcss">
@custom-variant dark (&:where(.dark, .dark *));
</style>
<link rel="stylesheet" href="/static/style.css">
<link rel="icon" href="/favicon.ico">
</head>
<body class="min-h-screen text-slate-800 dark:text-slate-100 flex items-center justify-center p-4 sm:p-6 font-sans antialiased">
<div class="w-full max-w-6xl glass rounded-3xl shadow-2xl border border-slate-900/5 dark:border-white/10 overflow-hidden float-in">
<!-- Header -->
<div class="px-5 sm:px-8 pt-5 sm:pt-6 pb-4 sm:pb-5 border-b border-slate-900/5 dark:border-white/10">
<div class="flex items-center gap-3">
<div class="h-10 w-10 shrink-0 rounded-2xl bg-gradient-to-br from-sky-400 to-indigo-500 flex items-center justify-center shadow-lg shadow-sky-500/20">
<svg viewBox="0 0 24 24" class="h-5 w-5 text-white" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 5L6 9H2v6h4l5 4V5z"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/>
</svg>
</div>
<div class="min-w-0">
<h1 class="text-lg font-semibold text-slate-900 dark:text-white tracking-tight" data-i18n="title">TTS-API</h1>
<p class="text-xs text-slate-500 dark:text-slate-300 hidden sm:block" data-i18n="subtitle">把文字变成自然的声音</p>
</div>
<div class="ml-auto flex items-center gap-3">
<!-- GitHub 仓库 -->
<a href="https://github.com/babutree/TTS-API" target="_blank" rel="noopener noreferrer" title="GitHub"
class="h-8 w-8 rounded-lg flex items-center justify-center text-slate-500 dark:text-slate-200 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 transition-colors cursor-pointer">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor" aria-hidden="true">
<path d="M12 .5C5.73.5.5 5.73.5 12a11.5 11.5 0 0 0 7.86 10.92c.575.106.785-.25.785-.555 0-.274-.01-1.002-.015-1.967-3.196.695-3.87-1.54-3.87-1.54-.523-1.328-1.276-1.682-1.276-1.682-1.043-.713.08-.699.08-.699 1.153.081 1.76 1.184 1.76 1.184 1.026 1.758 2.692 1.25 3.348.956.104-.743.401-1.25.73-1.538-2.552-.29-5.236-1.276-5.236-5.68 0-1.255.448-2.28 1.183-3.084-.119-.29-.513-1.459.112-3.042 0 0 .965-.309 3.163 1.178a10.98 10.98 0 0 1 2.88-.388c.977.004 1.962.132 2.88.388 2.196-1.487 3.16-1.178 3.16-1.178.626 1.583.232 2.752.114 3.042.737.804 1.182 1.829 1.182 3.084 0 4.415-2.688 5.386-5.248 5.671.413.356.78 1.057.78 2.131 0 1.539-.014 2.779-.014 3.157 0 .308.207.667.79.554A11.5 11.5 0 0 0 23.5 12C23.5 5.73 18.27.5 12 .5z"/>
</svg>
</a>
<!-- 界面语言切换 -->
<button id="langBtn" onclick="toggleLang()" title="中文 / English" data-i18n-title="langTitle"
class="h-8 px-2.5 rounded-lg flex items-center justify-center text-xs font-medium text-slate-500 dark:text-slate-200 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 transition-colors cursor-pointer">
<span id="langLabel">EN</span>
</button>
<!-- 主题切换 -->
<button id="themeBtn" onclick="toggleTheme()" title="切换白天 / 夜间" data-i18n-title="themeTitle"
class="h-8 w-8 rounded-lg flex items-center justify-center text-slate-500 dark:text-slate-200 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 transition-colors cursor-pointer">
<!-- 太阳(白天显示) -->
<svg id="iconSun" viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
<!-- 月亮(夜间显示) -->
<svg id="iconMoon" viewBox="0 0 24 24" class="h-4 w-4 hidden" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<div class="flex items-center gap-2 text-xs">
<span id="statusDot" class="h-2 w-2 rounded-full bg-slate-400 dark:bg-slate-500 transition-colors"></span>
<span id="statusText" class="text-slate-500 dark:text-slate-300">待机</span>
</div>
</div>
</div>
</div>
<!-- Body: 左右两栏,文本框占主导 -->
<div class="p-5 sm:p-8 grid grid-cols-1 lg:grid-cols-3 gap-5 sm:gap-8">
<!-- 左栏:文本输入(占 2/3) -->
<div class="lg:col-span-2 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<label class="text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wide" data-i18n="textLabel">文本内容</label>
<span id="charCount" class="text-xs text-slate-400 dark:text-slate-400 tabular-nums shrink-0">0 字</span>
</div>
<!-- 双层输入:底层高亮从 max_text_length 起的超限段;上层 textarea 透明字+可见光标,滚动同步 -->
<div id="textShell" class="text-input-shell relative flex-1 min-h-[200px] sm:min-h-[340px] rounded-xl border border-slate-300 dark:border-white/15 bg-white/60 dark:bg-white/5 overflow-hidden">
<div id="textHighlight" class="text-highlight text-base sm:text-sm leading-relaxed" aria-hidden="true"></div>
<textarea id="t" data-i18n-ph="placeholder"
class="text-input-area w-full h-full min-h-[200px] sm:min-h-[340px] p-4 text-base sm:text-sm leading-relaxed outline-none resize-none bg-transparent"
placeholder="在这里粘贴想要朗读的文字,长文也可以…">你好,这是一个把文字变成**自然语音**的小工具。Hello, this is a handy tool for converting text into speech.
直接点击下面的「朗读」按钮,就能听到这段示例。Simply click **Read** below to hear this sample spoken.
你也可以换成自己的文字,再选音色和语速。You can also replace this with your own text and pick a voice and speed.
试试 Auto:中文和英文会按句分开朗读。Try **Auto** — Chinese and English are spoken with matching voices.
粘贴 `Markdown` 也可以,标题和列表不会被读出来。Enjoy the seamless experience!</textarea>
</div>
<p id="textLimitHint" class="hidden text-[11px] text-rose-600 dark:text-rose-400 leading-relaxed"></p>
</div>
<!-- 右栏:设置(占 1/3) -->
<div class="lg:col-span-1 flex flex-col gap-5">
<div class="flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wide" data-i18n="engineLabel">合成方式</label>
<select id="engine" onchange="updateVoices()"
class="w-full rounded-xl border border-slate-300 dark:border-white/15 bg-white/60 dark:bg-white/5 p-3 text-sm text-slate-800 dark:text-slate-100 outline-none cursor-pointer">
<option value="auto" data-i18n="engineAuto">Auto · 智能路由</option>
<option value="kokoro" data-i18n="engineKokoro">Kokoro · 本机合成</option>
<option value="edge" data-i18n="engineEdge">Edge · 微软在线</option>
</select>
</div>
<div id="voiceSingle" class="flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wide" data-i18n="voiceLabel">音色</label>
<select id="voice"
class="w-full rounded-xl border border-slate-300 dark:border-white/15 bg-white/60 dark:bg-white/5 p-3 text-sm text-slate-800 dark:text-slate-100 outline-none cursor-pointer"></select>
</div>
<div id="voiceAuto" class="hidden flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wide" data-i18n="voiceZhLabel">中文音色</label>
<select id="voiceZh"
class="w-full rounded-xl border border-slate-300 dark:border-white/15 bg-white/60 dark:bg-white/5 p-3 text-sm text-slate-800 dark:text-slate-100 outline-none cursor-pointer"></select>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wide" data-i18n="voiceEnLabel">英文音色</label>
<select id="voiceEnAuto"
class="w-full rounded-xl border border-slate-300 dark:border-white/15 bg-white/60 dark:bg-white/5 p-3 text-sm text-slate-800 dark:text-slate-100 outline-none cursor-pointer"></select>
</div>
<p class="text-[11px] text-slate-400 dark:text-slate-500 leading-relaxed" data-i18n="autoHint">按句自动识别语言:中文句用中文音色、英文句用英文音色,无缝衔接</p>
</div>
<div class="flex flex-col gap-2">
<label class="text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wide" data-i18n="speedLabel">语速</label>
<div id="speedGroup" class="grid grid-cols-6 gap-1.5 p-1 rounded-xl bg-slate-200/60 dark:bg-white/5 border border-slate-300 dark:border-white/15">
<button data-speed="0.5" class="seg rounded-lg py-2 text-sm font-medium text-slate-500 dark:text-slate-300 hover:text-slate-800 dark:hover:text-white">0.5x</button>
<button data-speed="0.75" class="seg rounded-lg py-2 text-sm font-medium text-slate-500 dark:text-slate-300 hover:text-slate-800 dark:hover:text-white">0.75x</button>
<button data-speed="1.0" class="seg seg-active rounded-lg py-2 text-sm font-medium text-slate-700 dark:text-slate-100">1.0x</button>
<button data-speed="1.25" class="seg rounded-lg py-2 text-sm font-medium text-slate-500 dark:text-slate-300 hover:text-slate-800 dark:hover:text-white">1.25x</button>
<button data-speed="1.5" class="seg rounded-lg py-2 text-sm font-medium text-slate-500 dark:text-slate-300 hover:text-slate-800 dark:hover:text-white">1.5x</button>
<button data-speed="2.0" class="seg rounded-lg py-2 text-sm font-medium text-slate-500 dark:text-slate-300 hover:text-slate-800 dark:hover:text-white">2.0x</button>
</div>
</div>
</div>
</div>
<!-- 底部:波形 + 播放控制条(整行) -->
<div class="px-5 sm:px-8 pb-5 sm:pb-8 flex flex-col gap-4">
<!-- Visualizer -->
<div class="relative rounded-xl border border-slate-300 dark:border-white/15 bg-white/40 dark:bg-white/5 overflow-hidden">
<canvas id="viz" class="w-full block" style="height:72px"></canvas>
<div id="vizHint" class="absolute inset-0 flex items-center justify-center pointer-events-none">
<span class="text-xs text-slate-400 dark:text-slate-500" data-i18n="vizHint">点击「朗读」后,这里会显示声音的波形</span>
</div>
</div>
<!-- 控制条 -->
<div class="flex flex-col sm:flex-row gap-3">
<!-- 朗读(主操作) -->
<button id="speakBtn" onclick="start()"
class="sm:flex-1 bg-gradient-to-r from-sky-500 to-indigo-500 hover:from-sky-400 hover:to-indigo-400 text-white font-medium py-3 rounded-xl transition-all shadow-lg shadow-sky-500/20 active:scale-[.98] flex items-center justify-center gap-2">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<span data-i18n="speak">朗读</span>
</button>
<button id="previewBtn" onclick="previewVoice()" title="试听音色" data-i18n-title="previewTitle"
class="sm:w-auto px-5 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 text-slate-700 dark:text-slate-100 font-medium py-3 rounded-xl transition-all border border-slate-300 dark:border-white/15 active:scale-[.98] flex items-center justify-center gap-2">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor"><path d="M11 5L6 9H2v6h4l5 4V5zm4.5 3.5a1 1 0 0 0-1.4 1.4 3 3 0 0 1 0 4.2 1 1 0 0 0 1.4 1.4 5 5 0 0 0 0-7z"/></svg>
<span class="text-sm" data-i18n="previewBtn">试听</span>
</button>
<!-- 后退 / 暂停继续 / 前进 -->
<div class="flex gap-2 sm:flex-1">
<button onclick="seek(-10)" title="后退 10 秒" data-i18n-title="seekBack"
class="flex-1 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 text-slate-700 dark:text-slate-100 font-medium py-3 rounded-xl transition-all border border-slate-300 dark:border-white/15 active:scale-[.98] flex items-center justify-center gap-1.5">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 18l-7-6 7-6M20 18l-7-6 7-6"/></svg>
<span class="text-sm">10s</span>
</button>
<button id="playPauseBtn" onclick="togglePlay()"
class="flex-1 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 text-slate-700 dark:text-slate-100 font-medium py-3 rounded-xl transition-all border border-slate-300 dark:border-white/15 active:scale-[.98] flex items-center justify-center gap-1.5">
<svg id="iconPause" viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>
<svg id="iconResume" viewBox="0 0 24 24" class="h-4 w-4 hidden" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<span id="playPauseText" class="text-sm">暂停</span>
</button>
<button onclick="seek(10)" title="前进 10 秒" data-i18n-title="seekFwd"
class="flex-1 bg-slate-900/5 dark:bg-white/10 hover:bg-slate-900/10 dark:hover:bg-white/20 text-slate-700 dark:text-slate-100 font-medium py-3 rounded-xl transition-all border border-slate-300 dark:border-white/15 active:scale-[.98] flex items-center justify-center gap-1.5">
<span class="text-sm">10s</span>
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 18l7-6-7-6M4 18l7-6-7-6"/></svg>
</button>
</div>
<!-- 停止 -->
<button onclick="stop()" title="停止" data-i18n-title="stopTitle"
class="sm:w-auto px-5 bg-slate-900/5 dark:bg-white/10 hover:bg-rose-500/10 hover:text-rose-500 dark:hover:text-rose-400 text-slate-700 dark:text-slate-100 font-medium py-3 rounded-xl transition-all border border-slate-300 dark:border-white/15 active:scale-[.98] flex items-center justify-center gap-2">
<svg viewBox="0 0 24 24" class="h-4 w-4" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
<span class="text-sm" data-i18n="stopBtn">停止</span>
</button>
</div>
</div>
</div>
<script>
// ============================================================
// 主题切换 —— 默认白天,可手动切换并记忆
// ============================================================
function applyTheme(theme) {
const isDark = theme === "dark";
document.documentElement.classList.toggle("dark", isDark);
document.getElementById("iconSun").classList.toggle("hidden", isDark);
document.getElementById("iconMoon").classList.toggle("hidden", !isDark);
}
function toggleTheme() {
const next = document.documentElement.classList.contains("dark") ? "light" : "dark";
localStorage.setItem("theme", next);
applyTheme(next);
}
// .dark 类已由 <head> 阻塞脚本设好(避免 FOUC),这里仅同步图标显隐
applyTheme(document.documentElement.classList.contains("dark") ? "dark" : "light");
// ============================================================
// 界面语言 —— 中/英切换,记忆到 localStorage
// ============================================================
const I18N = {
zh: {
docTitle: "TTS-API",
subtitle: "把文字变成自然的声音",
textLabel: "文本内容",
engineLabel: "合成方式",
engineAuto: "Auto · 智能路由",
engineKokoro: "Kokoro · 本机合成",
engineEdge: "Edge · 微软在线",
voiceLabel: "音色",
voiceZhLabel: "中文音色",
voiceEnLabel: "英文音色",
previewBtn: "试听",
previewTitle: "试听当前音色",
autoHint: "按句自动识别语言:中文句用中文音色、英文句用英文音色,无缝衔接",
speedLabel: "语速",
vizHint: "点击「朗读」后,这里会显示声音的波形",
speak: "朗读",
pause: "暂停",
resume: "继续",
stopBtn: "停止",
title: "TTS-API",
themeTitle: "切换白天 / 夜间",
langTitle: "中文 / English",
seekBack: "后退 10 秒",
seekFwd: "前进 10 秒",
stopTitle: "停止",
placeholder: "在这里粘贴想要朗读的文字,长文也可以…",
charUnit: "字",
charOver: (n, limit, excess) => `${n} / ${limit} 字 · 超出 ${excess}`,
limitHint: (limit) => `内容已超过最大长度(${limit} 字),仅朗读前 ${limit} 字;如需放宽请联系管理员调整。`,
st_idle: "待机", st_connecting: "连接中", st_streaming: "合成中",
st_playing: "播放中", st_paused: "已暂停", st_done: "完成", st_error: "出错",
vf: "女声", vm: "男声",
tagLocal: "本地", tagOnline: "在线",
dialectCantonese: "粤语", dialectTaiwan: "台湾", dialectLiaoning: "东北", dialectShaanxi: "陕西",
},
en: {
docTitle: "TTS-API",
subtitle: "Turn text into natural speech",
textLabel: "Text",
engineLabel: "Engine",
engineAuto: "Auto · Smart routing",
engineKokoro: "Kokoro · On-device",
engineEdge: "Edge · Microsoft online",
voiceLabel: "Voice",
voiceZhLabel: "Chinese voice",
voiceEnLabel: "English voice",
previewBtn: "Preview",
previewTitle: "Preview current voice",
autoHint: "Auto-detects language per sentence: Chinese uses the Chinese voice, English uses the English voice, seamlessly.",
speedLabel: "Speed",
vizHint: "After clicking Read, the waveform appears here",
speak: "Read",
pause: "Pause",
resume: "Resume",
stopBtn: "Stop",
title: "TTS-API",
themeTitle: "Toggle light / dark",
langTitle: "中文 / English",
seekBack: "Back 10s",
seekFwd: "Forward 10s",
stopTitle: "Stop",
placeholder: "Paste the text you want read aloud, long text is fine…",
charUnit: "chars",
charOver: (n, limit, excess) => `${n} / ${limit} chars · ${excess} over`,
limitHint: (limit) => `Text exceeds the configured max length (${limit} chars); only the in-limit portion will be spoken. Contact your administrator to raise it.`,
st_idle: "Idle", st_connecting: "Connecting", st_streaming: "Synthesizing",
st_playing: "Playing", st_paused: "Paused", st_done: "Done", st_error: "Error",
vf: "Female", vm: "Male",
tagLocal: "Local", tagOnline: "Online",
dialectCantonese: "Cantonese", dialectTaiwan: "Taiwan", dialectLiaoning: "Northeastern", dialectShaanxi: "Shaanxi",
},
};
let currentLang = localStorage.getItem("lang") === "zh" ? "zh" : "en";
function t(key) { return (I18N[currentLang] && I18N[currentLang][key]) || key; }
// 应用语言到所有静态标注元素 + 动态文本
function applyLang() {
const dict = I18N[currentLang];
document.querySelectorAll("[data-i18n]").forEach(el => {
const k = el.getAttribute("data-i18n");
if (dict[k] !== undefined) el.textContent = dict[k];
});
document.querySelectorAll("[data-i18n-title]").forEach(el => {
const k = el.getAttribute("data-i18n-title");
if (dict[k] !== undefined) el.setAttribute("title", dict[k]);
});
document.querySelectorAll("[data-i18n-ph]").forEach(el => {
const k = el.getAttribute("data-i18n-ph");
if (dict[k] !== undefined) el.setAttribute("placeholder", dict[k]);
});
document.documentElement.lang = currentLang === "en" ? "en" : "zh-CN";
document.title = t("docTitle");
document.getElementById("langLabel").textContent = currentLang === "zh" ? "EN" : "中";
// 动态文本随语言刷新
updateVoices();
updateCharCount();
updatePlayBtn();
if (lastStatus) setStatus(lastStatus);
}
function toggleLang() {
currentLang = currentLang === "zh" ? "en" : "zh";
localStorage.setItem("lang", currentLang);
applyLang();
}
// ============================================================
// 音色映射表
// ============================================================
// 音色元数据(语言无关):gender 为 i18n 键(vf/vm),name 为专名(不翻译),region 为 CN/US/UK。
// label 运行时按当前界面语言用 t(gender) 拼出,随语言切换刷新。
// 单一事实源为后端 /api/voices:页面加载时拉取全部音色填充下拉,避免前后端列表漂移。
// kokoro 为固定列表(全量);edge 为微软动态列表(数百个),按主流 locale 白名单精简。
const EDGE_LOCALE_WHITELIST = ["zh-CN", "zh-CN-liaoning", "zh-CN-shaanxi", "zh-HK", "zh-TW", "en-US", "en-GB"];
let voiceDatabase = { kokoro: [], edge: [] };
// 音色目录是否就绪:/api/voices 成功且非空才置真。未就绪禁止发起合成,避免发空/无效 voice。
let voicesReady = false;
let voicesLoadError = false;
let voicesPromise = null;
// 后端 gender → 前端 i18n 键(vf/vm):区域另由 region 字段承载,性别只分男女
function toGenderKey(rawGender) {
return String(rawGender || "").toLowerCase().startsWith("f") ? "vf" : "vm";
}
// locale → 短区域码(US/UK/CN),避免微软 FriendlyName 里 "United States" 之类冗长文案。
// 中文各 locale(普通话/方言/粤语/台湾)统一归 CN:均属中国、均进中文下拉、均被 isZhVoice 判为中文,
// 语言路由零改动;具体方言差异由音色名后缀标注(见 EDGE_DIALECT_TAG + voiceLabel)。
const LOCALE_SHORT = {
"en-US": "US", "en-GB": "UK",
"zh-CN": "CN", "zh-CN-liaoning": "CN", "zh-CN-shaanxi": "CN",
"zh-HK": "CN", "zh-TW": "CN",
};
// 中文非普通话 locale → 方言标注(拼在音色名后,让用户知道自己在选什么)。
// 普通话 zh-CN 不标注(默认)。i18n 键,随界面语言切换。
const EDGE_DIALECT_TAG = {
"zh-CN-liaoning": "dialectLiaoning",
"zh-CN-shaanxi": "dialectShaanxi",
"zh-HK": "dialectCantonese",
"zh-TW": "dialectTaiwan",
};
// 从 Edge ShortName 解析干净专名;微软 FriendlyName 太长且含
// "Online...(Natural) - English (United States)",与前端 "在线" tag 重复,故弃用。
// 兼容两段式(en-US-AvaNeural → Ava)与三段式方言(zh-CN-liaoning-XiaobeiNeural → Xiaobei):
// 取最后一个 '-' 到 'Neural'/'Multilingual' 之间的人名段,剥掉 locale/方言前缀。
function parseEdgeName(shortName) {
const m = String(shortName || "").match(/^[a-z]{2}-[A-Z]{2}(?:-[a-z]+)?-(.+?)(?:Neural|Multilingual.*)?$/);
return m ? m[1] : shortName;
}
// Edge 会同时返回 AvaNeural / AvaMultilingualNeural 等同角色变体,而当前 UI
// 不展示变体能力。只折叠这种 ID 级变体族,避免按显示名误删跨 locale 的同名音色;
// 两者并存时优先能力更广的 Multilingual,只有普通 Neural 时仍保留普通版。
function dedupeEdgeVoiceVariants(list) {
const byVariantFamily = new Map();
for (const voice of list || []) {
const id = String(voice.value || "");
const familyId = id.replace(/Multilingual(?=Neural$)/i, "").toLowerCase();
const key = `${familyId}\u0000${voice.gender || ""}`;
const previous = byVariantFamily.get(key);
const isMultilingual = /MultilingualNeural$/i.test(id);
const previousIsMultilingual = previous && /MultilingualNeural$/i.test(String(previous.value || ""));
if (!previous || (!previousIsMultilingual && isMultilingual)) {
byVariantFamily.set(key, voice);
}
}
return [...byVariantFamily.values()];
}
// 拉取后端音色目录,映射为前端结构后刷新下拉。失败则保持空列表并提示,不静默伪装。
// 统一模型 { value, gender(vf/vm), name(专名), region(CN/US/UK) }。
async function loadVoices() {
const resp = await fetch("/api/voices");
if (!resp.ok) throw new Error("获取音色失败: HTTP " + resp.status);
const data = await resp.json();
voiceDatabase.kokoro = (data.kokoro || []).map(v => ({
value: v.id,
gender: toGenderKey(v.gender),
name: v.name,
region: v.language === "en" ? "US" : "CN", // Kokoro 英文为美音、中文为普通话
locale: v.language === "en" ? "en-US" : "zh-CN",
}));
voiceDatabase.edge = dedupeEdgeVoiceVariants((data.edge || [])
.filter(v => EDGE_LOCALE_WHITELIST.includes(v.locale))
.map(v => ({
value: v.id,
gender: toGenderKey(v.gender),
name: parseEdgeName(v.id),
region: LOCALE_SHORT[v.locale] || v.locale,
locale: v.locale || "",
dialect: EDGE_DIALECT_TAG[v.locale] || "", // 方言标注(i18n 键):普通话为空,粤语/台湾/东北/陕西非空
})));
// 语言族 → locale → 女→男 → 专名(不依赖微软返回序)
voiceDatabase.edge = sortVoices(voiceDatabase.edge);
voiceDatabase.kokoro = sortVoices(voiceDatabase.kokoro);
updateVoices();
voicesReady = true;
voicesLoadError = false;
return voiceDatabase;
}
// 音色显示 label(随界面语言):性别 · 专名[(方言)] · 区域。统一命名规范,各引擎一致。
// 中文非普通话音色(粤语/台湾/东北/陕西)在专名后括注方言;括号随 UI 语言:中文全角、英文半角。
function voiceLabel(v) {
if (!v.dialect) return `${t(v.gender)} · ${v.name} · ${v.region}`;
const open = currentLang === "zh" ? "(" : "(";
const close = currentLang === "zh" ? ")" : ")";
return `${t(v.gender)} · ${v.name}${open}${t(v.dialect)}${close} · ${v.region}`;
}
// 下拉排序:语言族(中文→英文) → locale(普通话→方言→台湾→粤语;美音→英音) → 女声优先 → 专名。
// 不依赖微软 list_voices 的原始顺序(常把 zh-HK 等插在前面、UK/US 与男女混排)。
const EDGE_LOCALE_RANK = {
"zh-CN": 0, "zh-CN-liaoning": 1, "zh-CN-shaanxi": 2, "zh-TW": 3, "zh-HK": 4,
"en-US": 0, "en-GB": 1,
};
function languageFamilyRank(locale) {
const s = String(locale || "");
if (s.startsWith("zh")) return 0;
if (s.startsWith("en")) return 1;
return 50;
}
function sortVoices(list) {
return (list || []).slice().sort((a, b) => {
const fa = languageFamilyRank(a.locale);
const fb = languageFamilyRank(b.locale);
if (fa !== fb) return fa - fb;
const ra = EDGE_LOCALE_RANK[a.locale] ?? 50;
const rb = EDGE_LOCALE_RANK[b.locale] ?? 50;
if (ra !== rb) return ra - rb;
if (a.gender !== b.gender) return a.gender === "vf" ? -1 : 1;
return String(a.name || "").localeCompare(String(b.name || ""), "en");
});
}
// 音色 → 引擎:本地 Kokoro 前缀 zf_/zm_/af_/am_,其余为 Edge 在线
function engineOfVoice(voice) {
return voiceDatabase.kokoro.some(v => v.value === voice) ? "kokoro" : "edge";
}
// 句级切分:与后端 split_text 严格同规则(单一事实源)。
// 英文句末标点后须跟空格 " +" 才切(保护 3.14/U.S.A/省略号);中文句末标点零宽后切;换行直接切。
// 注意用 " +"(单空格)而非 "\s+":后端是 " +",用 \s+ 会在"标点后跟 tab/回车"时与后端切句结果漂移。
function splitSentences(text) {
return text
.split(/(?<=[.!?。!?]) +|(?<=[。!?])|\n+/)
.map(pythonStrip)
.filter(Boolean);
}
// 把一句话按语言边界切成子片段。铁律:Kokoro 每个 pipeline 单语言,中文 pipeline 连
// DNS 这类孤立英文缩写都读错(官方 lang_code 必须匹配 voice)。故绝不把英文合并进中文段,
// 一律按语言路由到对应音色——英文(含术语/缩写)全部走英文音色。非字母(标点/空格/数字)
// 归并到前一段,保持标点与语流。返回 [{ text, isZh }],相邻同语言已合并。
// 前后端共享的 Unicode 15.0 已分配 Han 分段,不依赖 Python 运行时 UCD 版本。
// 不要把区段之间的未分配空洞或区间尾部也路由到中文音色。
const HAN_CHAR_RE = /[\u3007\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufa6d\ufa70-\ufad9\u{20000}-\u{2a6df}\u{2a700}-\u{2b739}\u{2b740}-\u{2b81d}\u{2b820}-\u{2cea1}\u{2ceb0}-\u{2ebe0}\u{2f800}-\u{2fa1d}\u{30000}-\u{3134a}\u{31350}-\u{323af}]/u;
const UNICODE_LETTER_RE = /\p{L}/u;
// Python str.strip() 的 Unicode 15 空白集合。不能使用 JS trim():后者不删
// U+0085,却会删除 Python 保留的 U+FEFF,导致 UI 与服务端请求门禁不一致。
const PYTHON_STRIP_EDGE_RE = /^[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+|[\u0009-\u000d\u001c-\u0020\u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]+$/gu;
function pythonStrip(value) {
return String(value).replace(PYTHON_STRIP_EDGE_RE, "");
}
function splitByLanguage(text) {
const segs = [];
let cur = '';
let curZh = null;
for (const ch of text) {
const isZh = HAN_CHAR_RE.test(ch);
const isLetter = isZh || UNICODE_LETTER_RE.test(ch);
if (!isLetter) { cur += ch; continue; }
if (curZh === null || isZh === curZh) {
curZh = isZh;
cur += ch;
} else {
const cleaned = pythonStrip(cur);
if (cleaned) segs.push({ text: cleaned, isZh: curZh });
cur = ch;
curZh = isZh;
}
}
const cleaned = pythonStrip(cur);
if (cleaned) {
// 无字母整句(如独立 "---" 行):语言未知,诚实标记 null,交由
// computeSentences 按跨句上下文回填。绝不能在此凭空兜底 en/zh——
// 本函数按句调用、segs 每次新建,句内继承不可达;判成 en 会让
// 中文文本的分隔线开出幻影 en run,判成 zh 会同样毁掉英文文本
// (38b9e67 的教训:两种硬编码各毁一种语言)。null 段在消费端
// 并入相邻 run,清洗后仅保留停顿语义,seg 计数对偶不变。
// (句内尾段如 "hello ---" 的 curZh 必非 null,正常跟随句语言。)
segs.push({ text: cleaned, isZh: curZh === null ? null : curZh });
}
// 合并相邻同语言段(切分过程中非字母字符可能造成同语言相邻)
const merged = [];
for (const seg of segs) {
const last = merged[merged.length - 1];
if (last && last.isZh === seg.isZh) {
last.text += seg.text;
} else {
merged.push(seg);
}
}
return merged;
}
// 无字母段(isZh=null,语言未知)的跨句级联回填——前邻优先:
// forward 用前邻语言填充(seed=null,文档开头无前邻则保持 null);
// backward 只兜仍为 null 的段(并入后邻 run)。整篇仍为 null(全文档
// 无字母)时不在此兜底,由消费端退化:kokoro 按所选音色、auto 按 zh,
// 沿用后端"清洗后为空"的既有 error 语义。任何固定规则兜底(??en/??zh)
// 都会给另一种语言留下"清洗后为空"的幻影 run 半空间(38b9e67 与其前身
// 的双重教训),只有邻段级联能全谱消除。
function backfillLetterless(perSentence) {
let prevLang = null;
for (const segs of perSentence) {
for (const seg of segs) {
if (seg.isZh === null) seg.isZh = prevLang;
else prevLang = seg.isZh;
}
}
let nextLang = null;
for (let i = perSentence.length - 1; i >= 0; i--) {
const segs = perSentence[i];
for (let j = segs.length - 1; j >= 0; j--) {
if (segs[j].isZh === null) segs[j].isZh = nextLang;
else nextLang = segs[j].isZh;
}
}
}
// 某语言的默认 Kokoro 音色:voiceDatabase.kokoro 中该语言(按 region)的首个音色。
// 用于混排文本里"另一语言"的兜底路由,避免硬编码可能不存在的 ID。
function defaultKokoroVoice(isZh) {
const region = isZh ? "CN" : "US";
const v = voiceDatabase.kokoro.find(x => x.region === region);
return v ? v.value : null;
}
// 合成用文本:超限时截到 maxTextLength,仅播放未超出部分(输入框全文保留,高亮示超限起点)。
// 前端体验:不阻断朗读;后端 REST/WS 仍按单次请求校验 MAX_TEXT_LENGTH。
function codePoints(text) {
return Array.from(String(text));
}
function codePointLength(text) {
let count = 0;
for (const _ of String(text)) count += 1;
return count;
}
function textForSynthesis() {
const raw = textArea.value;
const points = codePoints(raw);
return points.length > maxTextLength
? points.slice(0, maxTextLength).join("")
: raw;
}
// 把整篇文本解析为句级列表,每句标注目标 (engine, voice)。单一事实源。
// 铁律:Kokoro 每个 pipeline 单语言,中文/英文文本必须各走对应语言音色,否则读错(连 DNS 都错)。
// - Auto:按句切→按语言切子片段,中文段用中文音色、英文段用英文音色。
// - Kokoro 单音色:同样按语言切;选中音色读其本语言,另一语言用同引擎默认音色兜底(不丢词)。
// - Edge:多语言引擎,整段单音色,不分段。
// 返回 [{ engine, voice, text }]
function computeSentences() {
const engineSel = document.getElementById("engine").value;
const sentences = splitSentences(textForSynthesis());
if (engineSel === "edge") {
// Edge 原生多语言,整段交给选中音色,不按语言拆分
const voice = document.getElementById("voice").value;
return sentences.map(s => ({ engine: "edge", voice, text: s }));
}
if (engineSel === "kokoro") {
// Kokoro 单音色:按语言分段。选中音色负责其本语言,另一语言用同引擎默认音色。
const picked = document.getElementById("voice").value;
const pickedIsZh = (voiceDatabase.kokoro.find(v => v.value === picked) || {}).region === "CN";
const perSentence = sentences.map((s) => splitByLanguage(s));
// 无字母段(isZh=null)经邻段级联并入相邻 run;全文档无字母才退化
// 到所选音色(沿用后端"清洗后为空"的既有 error 语义)。
backfillLetterless(perSentence);
const out = [];
for (const segs of perSentence) {
for (const seg of segs) {
const isZh = seg.isZh === null ? pickedIsZh : seg.isZh;
const voice = (isZh === pickedIsZh) ? picked : (defaultKokoroVoice(isZh) || picked);
out.push({ engine: "kokoro", voice, text: seg.text });
}
}
return out;
}
// Auto:逐句判定语言,中文段用中文音色、英文段用英文音色(可跨引擎)。
// 无字母段(isZh=null,如独立 "---" 行)按跨句上下文回填:前邻优先、
// 后邻兜底、全文档无字母才兜底 zh。若在句级凭空兜底 en/zh,中/英文
// 文本各自的分隔线都会开出清洗后为空的幻影 run(后端必回 error,前端
// 整场停止)——38b9e67 与其前身的双重教训。
const zhVoice = document.getElementById("voiceZh").value;
const enVoice = document.getElementById("voiceEnAuto").value;
const perSentence = sentences.map((s) => splitByLanguage(s));
backfillLetterless(perSentence);
const out = [];
for (const segs of perSentence) {
for (const seg of segs) {
const isZh = seg.isZh ?? true;
const voice = isZh ? zhVoice : enVoice;
out.push({ engine: engineOfVoice(voice), voice, text: seg.text });
}
}
return out;
}
// 从句级列表的 startIdx 起,把连续同 (engine,voice) 的句子合并为运行段(run)。
// 每个 run 记录 startSentence(在 allSentences 中的起始下标)与 count(覆盖句数),
// 供变速时按句定位与续播。text 用 \n 连接(后端按 \n 还原切句)。
function buildRunsFrom(startIdx) {
const runs = [];
for (let i = startIdx; i < allSentences.length; i++) {
const item = allSentences[i];
const last = runs[runs.length - 1];
if (last && last.engine === item.engine && last.voice === item.voice) {
last.text += "\n" + item.text;
last.count++;
} else {
runs.push({
engine: item.engine,
voice: item.voice,
text: item.text,
startSentence: i,
count: 1,
});
}
}
return runs;
}
function selectedVoicesReady() {
const engine = document.getElementById("engine").value;
if (engine === "auto") {
return Boolean(document.getElementById("voiceZh").value && document.getElementById("voiceEnAuto").value);
}
return Boolean(document.getElementById("voice").value);
}
// Auto 模式返回 [中文, 英文] 两个试听目标;单引擎只返回当前下拉音色。
// 此前 Auto 只取 voiceZh,英文音色永远听不到——用户反馈的试听缺口。
function selectedPreviewTargets() {
const engine = document.getElementById("engine").value;
if (engine === "auto") {
const zh = document.getElementById("voiceZh").value;
const en = document.getElementById("voiceEnAuto").value;
const out = [];
if (zh) out.push({ engine: engineOfVoice(zh), voice: zh });
if (en) out.push({ engine: engineOfVoice(en), voice: en });
return out;
}
const voice = document.getElementById("voice").value;
return voice ? [{ engine: engineOfVoice(voice), voice }] : [];
}
function selectedPreviewVoice() {
// 兼容旧名:取试听序列的第一项(单引擎即当前音色;Auto 为中文)。
return selectedPreviewTargets()[0] || { engine: "kokoro", voice: "" };
}
// 播放一段已解码 buffer,播完 resolve;stopAllSources 会打断当前预览。
function playDecodedPreview(audioBuffer) {
return new Promise((resolve) => {
stopAllSources();
const src = ctx.createBufferSource();
src.buffer = audioBuffer;
src.connect(ctx.destination);
src.onended = () => {
sources = sources.filter(s => s !== src);
resolve();
};
sources.push(src);
src.start();
});
}
let previewSeq = 0; // 连点试听时作废上一次序列,避免两路预览交错
// 作废进行中的试听序列(仅递增序号,不碰 sources)。主播放接管前调用:
// 否则 Auto 试听的多段循环会在主播放 stopAllSources 后继续推进,
// 其内部 playDecodedPreview 的 stopAllSources 会反过来掐掉刚起播的主音频。
// 注意:不可并入 stopAllSources —— 试听自身也调它,并入会让 Auto 第二段(英文)永不播放。
function cancelPreview() {
previewSeq++;
}
async function previewVoice() {
// 主朗读进行中点试听:先停主播,再试听,避免两路声音重叠。
if (isPlaying || isPaused || (ws && ws.readyState === WebSocket.OPEN)) {
stop();
}
if (!voicesReady) {
try {
voicesPromise = voicesPromise || loadVoices();
await voicesPromise;
} catch (err) {
console.error(err);
setStatus("error");
return;
}
}
const targets = selectedPreviewTargets();
if (!targets.length) { setStatus("error"); return; }
if (!ctx) initAudio();
if (ctx.state === "suspended") { ctx.resume().catch(() => {}); }
const seq = ++previewSeq;
try {
// Auto:中文样句 → 英文样句依次播放,与双下拉选择一致;单引擎只播一项。
for (const picked of targets) {
if (seq !== previewSeq) return;
const params = new URLSearchParams({
engine: picked.engine,
voice: picked.voice,
speed: String(currentSpeed),
});
const resp = await fetch(`/api/voices/preview?${params.toString()}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
if (seq !== previewSeq) return;
const arrayBuffer = await resp.arrayBuffer();
const audioBuffer = await ctx.decodeAudioData(arrayBuffer.slice(0));
if (seq !== previewSeq) return;
await playDecodedPreview(audioBuffer);
}
} catch (err) {
if (seq !== previewSeq) return;
console.error(err);
setStatus("error");
}
}
// en-US-Ana 是女童声,不适合做默认;Ava 等成年女声可作默认。
// Ana 仍保留在列表中可手动选择,只是不作为 Auto 英文默认。
function isChildishEnglishVoice(id) {
return /^en-US-Ana/i.test(String(id || ""));
}
function isPreferredAvaEnglishVoice(id) {
return /^en-US-Ava(?:Multilingual)?Neural$/i.test(String(id || ""));
}
// 重建下拉后恢复选中:优先保留旧值;否则用 prefer(value) 挑默认;再否则首项。
function restoreSelectValue(sel, oldValue, prefer) {
const opts = [...sel.options];
if (oldValue && opts.some(o => o.value === oldValue)) {
sel.value = oldValue;
return;
}
const preferences = Array.isArray(prefer) ? prefer : [prefer];
for (const predicate of preferences) {
if (typeof predicate !== "function") continue;
const hit = opts.find(o => predicate(o.value));
if (hit) { sel.value = hit.value; return; }
}
}
function updateVoices() {
const engine = document.getElementById("engine").value;
const voiceSingle = document.getElementById("voiceSingle");
const voiceAuto = document.getElementById("voiceAuto");
voiceSingle.classList.toggle("hidden", engine === "auto");
voiceAuto.classList.toggle("hidden", engine !== "auto");
if (engine === "auto") {
// 中文音色 / 英文音色 各汇总两引擎的对应语言音色,Edge 在线在前
const mixedSel = document.getElementById("voiceZh");
const enSel = document.getElementById("voiceEnAuto");
const oldZh = mixedSel.value;
const oldEn = enSel.value;
mixedSel.innerHTML = '';
enSel.innerHTML = '';
const isZhVoice = v => v.region === "CN";
const addOpt = (sel, v, tag) => {
const opt = document.createElement('option');
opt.value = v.value;
opt.textContent = `${voiceLabel(v)} · ${tag}`;
sel.appendChild(opt);
};
// Edge 在线优先;不改排序,空/失效选择时显式优先成年 Ava,再避开女童 Ana。
voiceDatabase.edge.forEach(v => addOpt(isZhVoice(v) ? mixedSel : enSel, v, t("tagOnline")));
voiceDatabase.kokoro.forEach(v => addOpt(isZhVoice(v) ? mixedSel : enSel, v, t("tagLocal")));
restoreSelectValue(mixedSel, oldZh);
restoreSelectValue(enSel, oldEn, [
isPreferredAvaEnglishVoice,
(id) => !isChildishEnglishVoice(id),
]);
} else {
const sel = document.getElementById("voice");
const old = sel.value;
sel.innerHTML = '';
voiceDatabase[engine].forEach(v => {
const opt = document.createElement('option');
opt.value = v.value;
opt.textContent = voiceLabel(v);
sel.appendChild(opt);
});
// 单引擎仍保持现有排序的首项规则;只恢复用户已有选择。
restoreSelectValue(sel, old);
}
}
updateVoices();
// 语速分段控件
let currentSpeed = 1.0;
document.getElementById("speedGroup").addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
currentSpeed = parseFloat(btn.dataset.speed);
document.querySelectorAll("#speedGroup button").forEach(b => {
const on = b === btn;
b.classList.toggle("seg-active", on);
b.classList.toggle("text-slate-500", !on);
b.classList.toggle("dark:text-slate-300", !on);
b.classList.toggle("text-slate-700", on);
b.classList.toggle("dark:text-slate-100", on);
});
// 播放中或暂停中点倍速 → 从当前句起用新速度续播(Bug5-A:保留位置,不从头)
if (ws && (isPlaying || isPaused)) {
restartFromCurrentSentence();
}
});
// 变速续播(Bug5-A):定位当前播放头所在句 → 截断 buffer 到该句起点(保留已播句以支持
// 后退) → 从该句起用新速度重建 run 队列并续播。无法在句中切速度(音频已固化),故按句粒度。
function restartFromCurrentSentence() {
clearMainInactivityTimer();
abortAllPrefetch();
const headSample = Math.round(getHead() * SAMPLE_RATE);
// timeline 按 startSample 递增;找到起点 <= 播放头的最后一句
let k = 0;
for (let i = 0; i < timeline.length; i++) {
if (timeline[i].startSample <= headSample) k = i;
else break;
}
const cut = timeline[k] ? timeline[k].startSample : 0;
const sentenceIdx = timeline[k] ? timeline[k].sentenceIndex : 0;
const wasPaused = isPaused; // 暂停态点变速:重合成后仍保持暂停,不强制起播(Bug5)
try { ws.onclose = null; ws.onerror = null; ws.close(); } catch (_) {}
ws = null;
stopAllSources();
// 截断到当前句起点:已播句(0..cut)保留支持后退,当前句及之后丢弃待新速度重合成
writePos = cut;
scheduledPos = cut;
timeline = timeline.slice(0, k); // 当前句要重合成,删掉它及之后的时间线
runQueue = buildRunsFrom(sentenceIdx);
runIndex = 0;
segWithinRun = 0;
anchorSet = false;
pendingLead = 0.08;
pausedHead = cut / SAMPLE_RATE;
// 暂停态:isPlaying=false,新音频只进 buffer 不起播,等用户点继续(startPlayback 从 cut 起新速度)
isPlaying = !wasPaused;
isPaused = wasPaused;
streamEnded = false;
setStatus(wasPaused ? "paused" : "streaming");
updatePlayBtn();
sendRequest();
}
// 字数统计 + 超限高亮:上限与后端 MAX_TEXT_LENGTH 对齐(GET / 下发 max_text_length)。
// 体验策略:不阻断朗读;高亮超限段 + 提示「建议改配置」;合成仅取前 maxTextLength 字。
const textArea = document.getElementById("t");
const charCount = document.getElementById("charCount");
const textHighlight = document.getElementById("textHighlight");
const textLimitHint = document.getElementById("textLimitHint");
const textShell = document.getElementById("textShell");
// 与 app.DEFAULT_MAX_TEXT_LENGTH 同默认;health 成功后覆盖为运行时配置
let maxTextLength = 100000;
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
function isTextOverLimit() {
return codePointLength(textArea.value) > maxTextLength;
}
function syncTextHighlightScroll() {
if (textHighlight) textHighlight.scrollTop = textArea.scrollTop;
if (textHighlight) textHighlight.scrollLeft = textArea.scrollLeft;
}
function updateTextHighlight() {
if (!textHighlight) return;
const raw = textArea.value;
const limit = maxTextLength;
const points = codePoints(raw);
if (points.length <= limit) {
textHighlight.innerHTML = escapeHtml(raw);
} else {
// 从第 limit+1 字起标红底:可见「从哪里开始超出」
textHighlight.innerHTML =
escapeHtml(points.slice(0, limit).join("")) +
'<mark class="text-over">' + escapeHtml(points.slice(limit).join("")) + "</mark>";
}
syncTextHighlightScroll();
}
function updateCharCount() {
const n = codePointLength(textArea.value);
const limit = maxTextLength;
const over = n > limit;
if (over) {
const excess = n - limit;
const fmt = I18N[currentLang].charOver;
charCount.textContent = typeof fmt === "function" ? fmt(n, limit, excess) : `${n}/${limit}`;
charCount.classList.add("text-rose-600", "dark:text-rose-400");
charCount.classList.remove("text-slate-400", "dark:text-slate-400");
} else {
charCount.textContent = `${n} ${t("charUnit")}`;
charCount.classList.remove("text-rose-600", "dark:text-rose-400");
charCount.classList.add("text-slate-400", "dark:text-slate-400");
}
if (textShell) textShell.classList.toggle("text-over-limit", over);
if (textLimitHint) {
if (over) {
const fmt = I18N[currentLang].limitHint;
textLimitHint.textContent = typeof fmt === "function"
? fmt(limit)
: `over ${limit}`;
textLimitHint.classList.remove("hidden");
} else {
textLimitHint.textContent = "";
textLimitHint.classList.add("hidden");
}
}
updateTextHighlight();
}
async function loadTextLimit() {
try {
const resp = await fetch("/");
const data = await resp.json();
const n = data && data.max_text_length;
if (typeof n === "number" && Number.isFinite(n) && n > 0) {
maxTextLength = Math.floor(n);
}
} catch (_) {
// 拉取失败保留默认 100000,与后端 DEFAULT 一致;不静默取消限长
}
updateCharCount();
}
textArea.addEventListener("input", updateCharCount);
textArea.addEventListener("scroll", syncTextHighlightScroll);
updateCharCount();
// 示例文案作为真实 value(未编辑直接点朗读即播放示例);用户首次聚焦时清空一次,
// 之后当普通输入框。isDemo 标记仅在用户未动过示例前为真。
let isDemo = true;
function clearDemoOnce() {
if (!isDemo) return;
isDemo = false;
textArea.value = "";
updateCharCount();
}
textArea.addEventListener("focus", clearDemoOnce, { once: true });
// 用户真正输入过就不再视为示例(防止聚焦后未清空的边缘情况)
textArea.addEventListener("input", () => { isDemo = false; });
// 状态指示。颜色与语言无关,文案随界面语言(t)刷新;lastStatus 供切语言时复刷。
let lastStatus = "idle";
function setStatus(state) {
lastStatus = state;
const dot = document.getElementById("statusDot");
const txt = document.getElementById("statusText");
const colorMap = {
idle: "bg-slate-400 dark:bg-slate-500",
connecting:"bg-amber-400 animate-pulse",
streaming: "bg-emerald-400 animate-pulse",
playing: "bg-sky-400 animate-pulse",
paused: "bg-amber-400",
done: "bg-emerald-500",
error: "bg-rose-500"
};
dot.className = "h-2 w-2 rounded-full transition-colors " + colorMap[state];
txt.textContent = t("st_" + state);
}
// 暂停 / 继续 按钮:仅用户主动暂停时显示“继续”,其余(默认/播放/停止/完成)显示“暂停”
function updatePlayBtn() {
document.getElementById("iconPause").classList.toggle("hidden", isPaused);
document.getElementById("iconResume").classList.toggle("hidden", !isPaused);
document.getElementById("playPauseText").textContent = isPaused ? t("resume") : t("pause");
}
// ============================================================
// 音频引擎 —— 累积全部 PCM + 按播放头调度
// 支持:无缝(gapless)流式、暂停/继续、前进/后退 10 秒
// ============================================================
const SAMPLE_RATE = 24000;
const LEAD_TIME = 0.15; // 首次起播预缓冲,吸收网络抖动(秒)
// 前瞻式调度参数:只维持约 SCHEDULE_AHEAD 秒的已排队音频,单次最多排 SCHEDULE_CHUNK 秒。
// 关键:Edge 云端会远快于实时地猛灌整段 PCM,若"每帧把全部数据一次性排入时间轴",同时存活的
// AudioBufferSourceNode 会无限膨胀(长播可达上千)且 lastEnd 无限领先,最终音频线程被压垮致卡顿/静音。
// 节流后同时存活的 source 恒定在个位数(≈AHEAD/CHUNK),缓冲深度封顶,与播放时长解耦。
const SCHEDULE_AHEAD = 0.8; // 已排队未来音频的目标深度(秒):够吸收 ticker 抖动,又不过量堆积
const SCHEDULE_CHUNK = 0.25; // 单次排入时间轴的最大时长(秒)
let pumpTicker = null; // 持续驱动 pump 的定时器(与数据到达速率解耦)
let ws = null;
let ctx = null;
let masterGain = null;
let analyser = null;
// 累积缓冲:保留全部已合成样本,以支持任意位置跳转
let buffer = new Float32Array(SAMPLE_RATE * 30); // 初始 30 秒,按需倍增
let writePos = 0; // 已写入(已合成)样本数
let scheduledPos = 0; // 已调度到的样本位置
let sources = []; // 已排入时间轴的音源(供暂停/跳转时取消)
// 播放头锚点:head(秒) = anchorHead + (ctx.currentTime - anchorCtx)
let isPlaying = false;
let anchorSet = false; // 锚点是否已绑定到实际起播时刻
let anchorCtx = 0;
let anchorHead = 0;
let pendingLead = 0; // 起播前缓冲
let pausedHead = 0; // 暂停/未播时的逻辑位置(秒)
let isPaused = false; // 是否由用户主动暂停(用于按钮显示"继续")
let streamEnded = false;
let lastEnd = 0; // 上一段音频实际结束的时间轴位置(秒),用于无缝且不重叠地排下一段
// 句级事实源:computeSentences() 产出,[{ engine, voice, text }]。变速续播的定位基准
let allSentences = [];
// 运行段队列(段级流式):buildRunsFrom() 产出。
// 同引擎/音色:主 WS 等 {end} 再发(后端合成期任意入站消息会 cancel 当前合成)。
// 跨引擎/音色(中英 Auto):另开 WS 预取后续段 PCM(最多两槽),当前段播完即拼接。
let runQueue = [];
let runIndex = 0;
const PREFETCH_AHEAD = 2;
const PREFETCH_WAIT_GRACE_MS = 1500;
const WS_INACTIVITY_TIMEOUT_MS = 60000;
// 仅约束尚未成为 current 的 PCM:单槽最多 20 秒、所有 future 槽合计最多 30 秒。
// promotion 后音频立即进入主 buffer,不再计入投机缓存预算。
const PREFETCH_MAX_STATE_BUFFERED_SAMPLES = SAMPLE_RATE * 20;
const PREFETCH_MAX_BUFFERED_SAMPLES = SAMPLE_RATE * 30;
const prefetchMap = new Map(); // runIndex → { ws, runIndex, chunks, ended, error, sampleCount, segOffsets }
const prefetchWaitTimers = new WeakMap(); // state → 当前成为消费阻塞点后启动的 deadline timer
const prefetchInactivityTimers = new WeakMap(); // state → 最近一次有效 WS 活动后的静默 deadline
const prefetchCommitMeta = new WeakMap(); // state → current promotion 的 base/已提交音频与 seg 进度
let prefetchTerminalHandler = null;
let prefetchProgressHandler = null;
let mainInactivityTimer = null;
let mainInactivitySocket = null;
// 句级时间线:每句音频在 buffer 中的起始样本 + 句下标。用于变速时按句定位、续播
// timeline[i] = { startSample, sentenceIndex }
let timeline = [];
// 当前 run 内已收到的 seg 序号。Kokoro 每句一个 seg,Edge 整个 run 一个 seg,
// 故句下标 = run.startSentence + segWithinRun,而非全局 seg 计数
let segWithinRun = 0;
function initAudio() {
ctx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: SAMPLE_RATE });
masterGain = ctx.createGain();
analyser = ctx.createAnalyser();
analyser.fftSize = 256;
masterGain.connect(analyser);
analyser.connect(ctx.destination);
}
// 当前逻辑播放头(秒)
function getHead() {
if (!isPlaying || !anchorSet) return pausedHead;
// 已调度内容末尾(scheduledPos/SR)对应 ctx 时间 lastEnd;当前头 = 末尾 - 剩余未播时长。
// 这样把欠载留白也计入,避免线性推算高估播放头导致 seek 不准。
const scheduledSec = scheduledPos / SAMPLE_RATE;
const remaining = Math.max(0, lastEnd - ctx.currentTime);
const h = scheduledSec - remaining;
return Math.min(Math.max(h, anchorHead), writePos / SAMPLE_RATE);
}
// 追加新合成的样本到累积缓冲
function appendSamples(f32) {
if (writePos + f32.length > buffer.length) {
let cap = buffer.length * 2;
while (cap < writePos + f32.length) cap *= 2;
const nb = new Float32Array(cap);
nb.set(buffer.subarray(0, writePos));
buffer = nb;
}
buffer.set(f32, writePos);
writePos += f32.length;
}
function stopAllSources() {
sources.forEach(s => { s.onended = null; try { s.stop(); } catch (_) {} try { s.disconnect(); } catch (_) {} });
sources = [];
}
// 前瞻式节流调度:把 [scheduledPos, writePos) 中尚未调度的样本"按需、分块"排入时间轴。
// 每次只在"已排队未来音频不足 SCHEDULE_AHEAD 秒"时补排至多 SCHEDULE_CHUNK 秒,
// 由 pumpTicker 持续驱动追赶 writePos。这样同时存活的 source 数封顶(≈AHEAD/CHUNK),
// 与数据到达速率、播放时长解耦——根治 Edge 快灌导致的 source 爆炸 + lastEnd 无限领先。
function pump() {
if (!isPlaying || !ctx) return;
// 懒绑定起播时刻(等首块数据到达)
if (!anchorSet) {
if (scheduledPos >= writePos) return; // 还没有数据可播,等下一帧/下一 tick
anchorCtx = ctx.currentTime + pendingLead;
anchorHead = scheduledPos / SAMPLE_RATE;
lastEnd = anchorCtx; // 第一段从锚点起播
anchorSet = true;
}
// 循环补排,直到"已排队未来音频"达到目标深度或数据排尽。
// 已排队深度 = lastEnd - now(未来还能连续播多久)。
let scheduledAny = false;
while (scheduledPos < writePos && (lastEnd - ctx.currentTime) < SCHEDULE_AHEAD) {
const chunkSamples = Math.round(SCHEDULE_CHUNK * SAMPLE_RATE);
const end = Math.min(scheduledPos + chunkSamples, writePos);
const seg = buffer.slice(scheduledPos, end);
const ab = ctx.createBuffer(1, seg.length, SAMPLE_RATE);
ab.copyToChannel(seg, 0);
const src = ctx.createBufferSource();
src.buffer = ab;
src.connect(masterGain);
// 紧贴上一段末尾排队:取 max(now, lastEnd) 保证既不重叠(踩踏/电音)也无负延迟
const when = Math.max(ctx.currentTime, lastEnd);
src.start(when);
lastEnd = when + ab.duration;
scheduledPos = end;
scheduledAny = true;
sources.push(src);
src.onended = () => {
sources = sources.filter(s => s !== src);
maybeDone();
};
}
// 仅在本次真正排入了音频时才置"播放中",避免 ticker 空转反复覆盖状态(尤其掩盖将到的 done)
if (scheduledAny) setStatus("playing");
}
// 启动/停止 pump ticker:节流调度后单次 pump 只排一小段,数据到齐后不再有 onmessage 事件驱动,
// 故需一个持续时钟周期性调用 pump 追赶 writePos。间隔取 CHUNK 的一半,保证追赶及时又不空转过密。
function startPumpTicker() {
if (pumpTicker !== null) return;
pumpTicker = setInterval(() => {
if (!isPlaying) { stopPumpTicker(); return; }
pump();
}, SCHEDULE_CHUNK * 500); // 秒→毫秒再取半:0.25s → 125ms
}
function stopPumpTicker() {
if (pumpTicker !== null) { clearInterval(pumpTicker); pumpTicker = null; }
}
function maybeDone() {
if (!streamEnded || !isPlaying) return;
if (scheduledPos < writePos || sources.length > 0) return;
isPlaying = false;
stopPumpTicker();
pausedHead = writePos / SAMPLE_RATE;
setStatus("done");
updatePlayBtn();
}
// 从指定位置开始播放(用于继续 / 跳转)
function startPlayback(fromSec, lead) {
cancelPreview(); // 接管播放前作废进行中的试听
stopAllSources();
isPlaying = true;
isPaused = false;
anchorSet = false;
pendingLead = lead;
pausedHead = fromSec;
scheduledPos = Math.round(fromSec * SAMPLE_RATE);
updatePlayBtn();
if (ctx.state === "suspended") { ctx.resume().catch(() => {}); }
startPumpTicker();
pump();
}
// 发送当前 run:runQueue[runIndex](主 WS)。
function sendCurrentRun(markRunPending) {
const run = runQueue[runIndex];
if (!run) return;
markRunPending();
ws.send(JSON.stringify({
text: run.text,
engine: run.engine,
voice: run.voice,
speed: currentSpeed,
}));
}
function clearPrefetchWaitTimer(state) {
const waitTimer = prefetchWaitTimers.get(state);
if (waitTimer !== undefined) {
clearTimeout(waitTimer);
prefetchWaitTimers.delete(state);
}
}
function clearPrefetchInactivityTimer(state) {
const timer = prefetchInactivityTimers.get(state);
if (timer !== undefined) {
clearTimeout(timer);
prefetchInactivityTimers.delete(state);
}
}
function clearMainInactivityTimer(socket = null) {
if (socket && mainInactivitySocket !== socket) return;
if (mainInactivityTimer !== null) clearTimeout(mainInactivityTimer);
mainInactivityTimer = null;
mainInactivitySocket = null;
}
function closePrefetchState(state) {
if (!state) return;
clearPrefetchWaitTimer(state);
clearPrefetchInactivityTimer(state);
prefetchCommitMeta.delete(state);
const sock = state.ws;
if (sock) {
try { sock.onopen = sock.onmessage = sock.onerror = sock.onclose = null; } catch (_) {}
try { sock.close(); } catch (_) {}
}
}
function abortPrefetch(targetRunIndex) {
const state = prefetchMap.get(targetRunIndex);
if (!state) return;
prefetchMap.delete(targetRunIndex);
closePrefetchState(state);
}
function abortAllPrefetch() {
const states = Array.from(prefetchMap.values());
prefetchMap.clear();
prefetchTerminalHandler = null;
prefetchProgressHandler = null;
for (const state of states) closePrefetchState(state);
}
function hasCommittedPrefetchAudio(state) {
const meta = prefetchCommitMeta.get(state);
return Boolean(meta && meta.committedAudio);
}
function isUncommittedFuturePrefetch(state) {
return Boolean(
state
&& state.runIndex !== runIndex
&& !state.error
&& !hasCommittedPrefetchAudio(state)
);
}
function bufferedFuturePrefetchSamples() {
let total = 0;
for (const state of prefetchMap.values()) {
if (isUncommittedFuturePrefetch(state)) total += state.sampleCount;
}
return total;
}
// 未来槽仍只缓存;轮到 current run 后,把已有及后续 PCM 立即交给主播放 buffer,
// 不再等待整段 end。baseSample 固定为 promotion 时的 writePos,segOffsets 仍按 run
// 相对样本位移恢复 timeline。WeakMap 元数据避免改变既有 state 公共形状。
function commitPrefetchProgress(state, final = false) {
if (!state || state.error) return false;
const hasAudio = state.chunks.some(chunk => chunk.length > 0);
let meta = prefetchCommitMeta.get(state);
if (!(meta && meta.committedAudio) && !hasAudio && !final) return false;
if (!meta) {
meta = {
baseSample: writePos,
committedAudio: false,
committedSegCount: 0,
fallbackBoundaryAdded: false,
};
prefetchCommitMeta.set(state, meta);
}
const run = runQueue[state.runIndex];
if (!run) return false;
while (meta.committedSegCount < state.segOffsets.length) {
const i = meta.committedSegCount++;
timeline.push({
startSample: meta.baseSample + state.segOffsets[i],
sentenceIndex: run.startSentence + i,
});
}
if (final && meta.committedSegCount === 0 && !meta.fallbackBoundaryAdded) {
timeline.push({ startSample: meta.baseSample, sentenceIndex: run.startSentence });
meta.fallbackBoundaryAdded = true;
}
if (hasAudio) {
for (const f32 of state.chunks) appendSamples(f32);
state.chunks = [];
meta.committedAudio = true;
clearPrefetchWaitTimer(state);
pump();
}
return meta.committedAudio || final;
}
// 跨引擎/音色时用独立 WS 预取后续 PCM;同 key 去重,所有异步事件均校验 state 身份。
function beginPrefetch(targetRunIndex) {
if (targetRunIndex < 0 || targetRunIndex >= runQueue.length) return;
if (prefetchMap.has(targetRunIndex)) return;
const run = runQueue[targetRunIndex];
const prev = runQueue[targetRunIndex - 1];
if (!run || !prev) return;
if (prev.engine === run.engine && prev.voice === run.voice) return;
if (prefetchMap.size >= PREFETCH_AHEAD) return;
if (run.engine === "edge") {
const hasPendingEdge = Array.from(prefetchMap.values()).some((state) => {
const stateRun = runQueue[state.runIndex];
return stateRun && stateRun.engine === "edge" && !state.ended && !state.error;
});
if (hasPendingEdge) return;
}
const proto = window.location.protocol === "https:" ? "wss://" : "ws://";
const socket = new WebSocket(`${proto}${window.location.host}/ws/tts?prefetch=1`);
// segOffsets:相对本 run 音频起点的样本下标,与主路径 seg→timeline 对齐
const state = {
ws: socket, runIndex: targetRunIndex, chunks: [],
ended: false, error: false, sampleCount: 0, segOffsets: [],
};
prefetchMap.set(targetRunIndex, state);
const settle = (isError) => {
if (prefetchMap.get(targetRunIndex) !== state) return;
if (state.ended || state.error) return;
clearPrefetchWaitTimer(state);
clearPrefetchInactivityTimer(state);
if (isError) state.error = true;
else state.ended = true;
if (prefetchTerminalHandler) prefetchTerminalHandler(state);
};
const touchInactivity = () => {
clearPrefetchInactivityTimer(state);
if (state.ended || state.error) return;
const timer = setTimeout(() => {
if (prefetchInactivityTimers.get(state) !== timer) return;
prefetchInactivityTimers.delete(state);
if (prefetchMap.get(targetRunIndex) !== state) return;
if (state.ended || state.error) return;
// 未 promotion 的 PCM 尚未进入主 buffer,可安全丢弃;已提交 PCM 时
// terminal handler 会显式失败,绝不回退重放。
if (!hasCommittedPrefetchAudio(state)) {
state.chunks = [];
state.segOffsets = [];
state.sampleCount = 0;
}
settle(true);
closePrefetchState(state);
}, WS_INACTIVITY_TIMEOUT_MS);
prefetchInactivityTimers.set(state, timer);
};
socket.binaryType = "arraybuffer";
socket.onopen = () => {
if (prefetchMap.get(targetRunIndex) !== state) return;
touchInactivity();
socket.send(JSON.stringify({
text: run.text,
engine: run.engine,
voice: run.voice,
speed: currentSpeed,
}));
};
socket.onmessage = (e) => {
if (prefetchMap.get(targetRunIndex) !== state) return;
if (state.ended || state.error) return;
if (typeof e.data !== "string") {
const len = Math.floor(e.data.byteLength / 2);
if (
isUncommittedFuturePrefetch(state)
&& (
state.sampleCount + len > PREFETCH_MAX_STATE_BUFFERED_SAMPLES
|| bufferedFuturePrefetchSamples() + len > PREFETCH_MAX_BUFFERED_SAMPLES
)
) {
// 音频尚未进入主 buffer,可安全丢弃并保留 error tombstone;
// 轮到该 run 时按序走主 WS fallback,绝不重放已提交 PCM。
state.chunks = [];
state.segOffsets = [];
state.sampleCount = 0;
settle(true);
closePrefetchState(state);
return;
}
const dv = new DataView(e.data);
if (len > 0) touchInactivity();
const f32 = new Float32Array(len);
for (let i = 0; i < len; i++) f32[i] = dv.getInt16(i * 2, true) / 32768;
state.chunks.push(f32);
state.sampleCount += len;
if (prefetchProgressHandler) prefetchProgressHandler(state);
return;
}
try {
const meta = JSON.parse(e.data);
if (meta.type === "start") touchInactivity();
// 合成心跳:与主路径同理,证明生成仍存活(不计入 segOffsets)。
if (meta.type === "ping") touchInactivity();
// 与主路径一致:seg 标记「下一单元音频」起点 = 当前已收样本数
if (meta.type === "seg") {
touchInactivity();
state.segOffsets.push(state.sampleCount);
if (prefetchProgressHandler) prefetchProgressHandler(state);
}
if (meta.type === "end") settle(false);
if (meta.type === "error") settle(true);
} catch (_) {}
};
socket.onerror = () => { settle(true); };
socket.onclose = () => { settle(true); };
touchInactivity();
}
function schedulePrefetch(fromIdx, allowEdge = true) {
const end = Math.min(runQueue.length, fromIdx + PREFETCH_AHEAD);
for (let i = fromIdx; i < end && prefetchMap.size < PREFETCH_AHEAD; i++) {
if (!allowEdge && runQueue[i] && runQueue[i].engine === "edge") continue;
beginPrefetch(i);
}
}
// 仅消费当前 runIndex 的 ready 槽;pending/error/absent 由统一填充控制器分别处理。
function flushPrefetchIfReady() {
const state = prefetchMap.get(runIndex);
if (!state || state.error || !state.ended) return false;
// helper 会补齐尚未提交的 chunks 与 segOffsets;已 promotion 的部分不会重复。
commitPrefetchProgress(state, true);
abortPrefetch(runIndex);
return true;
}
// 主 WS 串行;当前预取仍在飞时等待其终态回调,ready 顺序消费,error 才回退主 WS。
function sendRequest() {
clearMainInactivityTimer();
abortAllPrefetch();
const proto = window.location.protocol === "https:" ? "wss://" : "ws://";
const socket = new WebSocket(`${proto}${window.location.host}/ws/tts`);
socket.binaryType = "arraybuffer";
ws = socket;
let awaitingRunTerminal = false;
let terminalState = null;
let mainEdgeAdmitted = false;
const armMainInactivityDeadline = () => {
clearMainInactivityTimer();
const timer = setTimeout(() => {
if (mainInactivityTimer !== timer || mainInactivitySocket !== socket) return;
mainInactivityTimer = null;
mainInactivitySocket = null;
if (socket !== ws || terminalState === "error") return;
failCurrentStream();
}, WS_INACTIVITY_TIMEOUT_MS);
mainInactivityTimer = timer;
mainInactivitySocket = socket;
};
const sendRun = () => {
const run = runQueue[runIndex];
mainEdgeAdmitted = !run || run.engine !== "edge";
sendCurrentRun(() => { awaitingRunTerminal = true; });
if (run) armMainInactivityDeadline();
// Edge 必须等主 decoder admission(seg)后才允许 Edge 预取;Kokoro 不占 ffmpeg,可立即预取。
schedulePrefetch(runIndex + 1, mainEdgeAdmitted);
};
const armPrefetchWaitDeadline = (state) => {
if (prefetchWaitTimers.has(state)) return;
const timer = setTimeout(() => {
if (prefetchWaitTimers.get(state) !== timer) return;
prefetchWaitTimers.delete(state);
if (socket !== ws) return;
if (prefetchMap.get(state.runIndex) !== state) return;
if (state.runIndex !== runIndex || state.ended || state.error || awaitingRunTerminal) return;
if (hasCommittedPrefetchAudio(state)) return;
abortPrefetch(state.runIndex);
terminalState = null;
sendRun();
}, PREFETCH_WAIT_GRACE_MS);
prefetchWaitTimers.set(state, timer);
};
// runIndex 指向待填充段。四种状态互斥:ready flush、pending 等待、
// error 删除后回退主 WS、absent 直接主 WS 请求。
const fillFromCurrentRun = () => {
while (runIndex < runQueue.length) {
const state = prefetchMap.get(runIndex);
// 当前 run 已由可用预取槽持有时才能滑动补窗;error/absent 必须先走
// sendRun(),由当前主 Edge 的 seg admission 决定能否发送更远 Edge。
if (state && !state.error) {
schedulePrefetch(runIndex + 1);
}
if (state && !state.ended && !state.error) {
commitPrefetchProgress(state);
if (!hasCommittedPrefetchAudio(state)) armPrefetchWaitDeadline(state);
return;
}
if (state && state.error) {
abortPrefetch(runIndex);
terminalState = null;
sendRun();
return;
}
if (flushPrefetchIfReady()) {
runIndex++;
segWithinRun = 0;
continue;
}
terminalState = null;
sendRun();
return;
}
streamEnded = true;
clearMainInactivityTimer(socket);
abortAllPrefetch();
maybeDone();
};
const failCurrentStream = () => {
clearMainInactivityTimer(socket);
awaitingRunTerminal = false;
terminalState = "error";
abortAllPrefetch();
if (socket === ws) {
ws = null;
try {
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
} catch (_) {}
try { socket.close(); } catch (_) {}
}
streamEnded = true;
stopPumpTicker();
stopAllSources();
isPlaying = false;
isPaused = false;
setStatus("error");
updatePlayBtn();
};
prefetchProgressHandler = (state) => {
if (socket !== ws) return;
if (prefetchMap.get(state.runIndex) !== state) return;
if (state.runIndex !== runIndex || awaitingRunTerminal || state.error) return;
commitPrefetchProgress(state);
};
prefetchTerminalHandler = (state) => {
if (socket !== ws) return;
if (prefetchMap.get(state.runIndex) !== state) return;
// 已有 PCM 进入主 buffer 后不能再安全回退重合成,否则会重复朗读;显式失败。
if (state.error && hasCommittedPrefetchAudio(state)) {
failCurrentStream();
return;
}
// 成功槽已释放后端资源后才滑动补槽;error 要先给当前 fallback 保留 admission。
if (state.ended) {
schedulePrefetch(runIndex + 1, mainEdgeAdmitted || !awaitingRunTerminal);
}
if (state.runIndex !== runIndex || awaitingRunTerminal) return;
fillFromCurrentRun();
};
const advanceAfterRunEnd = () => {
clearMainInactivityTimer(socket);
awaitingRunTerminal = false;
terminalState = "end";
runIndex++;
segWithinRun = 0;
fillFromCurrentRun();
};
socket.onopen = () => {
if (socket !== ws) return;
setStatus("streaming");
sendRun();
};
socket.onmessage = (e) => {
if (socket !== ws) return;
if (typeof e.data !== "string") {
const dv = new DataView(e.data);
const len = e.data.byteLength >> 1;
if (len > 0) armMainInactivityDeadline();
const f32 = new Float32Array(len);
for (let i = 0; i < len; i++) f32[i] = dv.getInt16(i * 2, true) / 32768;
appendSamples(f32);
pump();
return;
}
let meta;
try { meta = JSON.parse(e.data); } catch (_) { return; }
if (meta.type === "start") {
armMainInactivityDeadline();
return;
}
// 合成心跳:Edge 长文本/长单元推理存在远超预期的无帧间隙,
// ping 仅用于证明连接与生成仍存活,不计入句级时间线。
if (meta.type === "ping") {
armMainInactivityDeadline();
return;
}
if (meta.type === "error") {
if (streamEnded && runIndex >= runQueue.length) return;
failCurrentStream();
return;
}
if (meta.type === "seg") {
armMainInactivityDeadline();
const run = runQueue[runIndex] || { startSentence: 0 };
const sentenceIndex = run.startSentence + segWithinRun;
segWithinRun++;
timeline.push({ startSample: writePos, sentenceIndex });
if (run.engine === "edge" && !mainEdgeAdmitted) {
mainEdgeAdmitted = true;
schedulePrefetch(runIndex + 1, true);
}
return;
}
if (meta.type === "end") {
if (!awaitingRunTerminal) return;
advanceAfterRunEnd();
}
};
socket.onerror = () => {
if (socket !== ws) return;
if (streamEnded && runIndex >= runQueue.length) return;
failCurrentStream();
};
socket.onclose = () => {
if (socket !== ws) return;
clearMainInactivityTimer(socket);
if (terminalState === "error") return;
if (streamEnded && runIndex >= runQueue.length) {
if (isPlaying) maybeDone();
return;
}
if (awaitingRunTerminal || runIndex < runQueue.length) {
abortAllPrefetch();
stopAllSources();
isPlaying = false;
isPaused = false;
streamEnded = true;
setStatus("error");
updatePlayBtn();
return;
}
streamEnded = true;
if (isPlaying) {
maybeDone();
} else {
setStatus("idle");
}
};
armMainInactivityDeadline();
}
async function start() {
// 守卫:音色目录未就绪(未加载完/加载失败)时不得合成。否则 select.value 为空,
// engineOfVoice("") 会误回落 edge,向后端发空 voice → 400/合成失败。此处显式拦截并提示。
if (!voicesReady) {
setStatus("connecting");
try {
if (voicesLoadError) voicesPromise = null;
voicesPromise = voicesPromise || loadVoices();
await voicesPromise;
} catch (err) {
console.error(err);
voicesLoadError = true;
setStatus("error");
return;
}
}
if (!selectedVoicesReady()) {
setStatus("error");
return;
}
// 超限不阻断:仅提示 + 高亮;computeSentences 经 textForSynthesis 截到上限内再切句发送。
if (isTextOverLimit()) updateCharCount();
// 构建句级事实源 + 运行段队列:非 Auto = 同引擎合并;Auto = 按句语言分段。空文本直接忽略
allSentences = computeSentences();
runQueue = buildRunsFrom(0);
if (!runQueue.length) { stop(); return; }
if (!ctx) initAudio();
if (ctx.state === "suspended") { ctx.resume().catch(() => {}); }
clearMainInactivityTimer();
if (ws) { try { ws.onclose = null; ws.close(); } catch (_) {} ws = null; }
cancelPreview(); // 作废进行中的试听,避免其 stopAllSources 掐掉本次朗读
stopAllSources();
runIndex = 0;
segWithinRun = 0;
timeline = [];
buffer = new Float32Array(SAMPLE_RATE * 30);
writePos = 0;
scheduledPos = 0;
pausedHead = 0;
isPlaying = true;
isPaused = false;
anchorSet = false;
pendingLead = LEAD_TIME;
streamEnded = false;
setStatus("connecting");
updatePlayBtn();
startPumpTicker(); // 节流调度依赖 ticker 持续驱动 pump(数据到达速率与调度解耦)
sendRequest();
}
// 停止:清空一切
function stop() {
cancelPreview(); // 主播放停止:作废进行中的试听序列,防其 stopAllSources 误触后续
clearMainInactivityTimer();
abortAllPrefetch(); // 关闭全部跨引擎预取 WS,避免后台继续合成
stopPumpTicker();
stopAllSources();
if (ws) { try { ws.close(); } catch (_) {} ws = null; }
runQueue = [];
runIndex = 0;
segWithinRun = 0;
timeline = [];
writePos = 0;
scheduledPos = 0;
pausedHead = 0;
isPlaying = false;
isPaused = false;
anchorSet = false;
streamEnded = false;
setStatus("idle");
updatePlayBtn();
}
// 暂停 / 继续
function togglePlay() {
if (!ctx || writePos === 0) return;
if (isPlaying) {
pausedHead = getHead();
isPlaying = false;
isPaused = true;
stopPumpTicker();
stopAllSources();
setStatus("paused");
updatePlayBtn();
} else {
startPlayback(pausedHead, 0.08);
}
}
// 前进 / 后退 (delta 秒)
function seek(delta) {
if (!ctx || writePos === 0) return;
const dur = writePos / SAMPLE_RATE;
const target = Math.max(0, Math.min(dur, getHead() + delta));
if (isPlaying) {
startPlayback(target, 0.05);
} else {
pausedHead = target;
scheduledPos = Math.round(target * SAMPLE_RATE);
}
}
// ============================================================
// 可视化波形
// ============================================================
function drawViz() {
const canvas = document.getElementById("viz");
const hint = document.getElementById("vizHint");
const dpr = window.devicePixelRatio || 1;
const c = canvas.getContext("2d");
function resize() {
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
}
resize();
window.addEventListener("resize", resize);
// 固定柱数(128),不依赖 analyser:页面加载(analyser 未建)时也能跑待机波浪动画
const BARS = 128;
const data = new Uint8Array(BARS);
// 渐变随 canvas 尺寸变化才需重建,缓存复用,避免每帧每柱重复创建(#19)
let activeGrad = null;
function buildGradient() {
const g = c.createLinearGradient(0, 0, 0, canvas.height);
g.addColorStop(0, "rgba(56,189,248,0.95)");
g.addColorStop(1, "rgba(129,140,248,0.6)");
activeGrad = g;
}
buildGradient();
window.addEventListener("resize", buildGradient);
function frame(now) {
requestAnimationFrame(frame);
// 仅在播放且 analyser 就绪时读频率数据;否则走待机正弦波浪(页面加载即动)
const active = isPlaying && analyser;
if (active) analyser.getByteFrequencyData(data);
const w = canvas.width, h = canvas.height;
c.clearRect(0, 0, w, h);
const bars = BARS;
const gap = 2 * dpr;
const bw = (w - gap * bars) / bars;
const dark = document.documentElement.classList.contains("dark");
const idleColor = dark ? "rgba(148,163,184,0.28)" : "rgba(100,116,139,0.28)";
// 待机时显示占位提示文字;播放时隐藏
hint.style.opacity = active ? "0" : "1";
// 整帧统一 fillStyle,所有柱子共用同一个缓存渐变(覆盖全高)
c.fillStyle = active ? activeGrad : idleColor;
const t = (now || 0) / 1000;
const r = Math.min(bw / 2, 2 * dpr);
for (let i = 0; i < bars; i++) {
let bh;
if (active) {
bh = Math.max(2 * dpr, (data[i] / 255) * h * 0.95);
} else {
// 待机:柔和起伏的正弦波形作为占位,提示这是音频可视化区
const wave = Math.sin(i * 0.35 + t * 1.6) * 0.5 + 0.5;
bh = (0.12 + wave * 0.18) * h;
}
const x = i * (bw + gap);
const y = (h - bh) / 2;
c.beginPath();
c.roundRect(x, y, bw, bh, r);
c.fill();
}
}
requestAnimationFrame(frame);
}
applyLang(); // 初始化界面语言(内部刷新 voices/charCount/playBtn/status)
drawViz(); // 页面加载即启动波形动画(待机走正弦波浪,播放时读 analyser)
// 拉取运行时文本上限(与 MAX_TEXT_LENGTH 对齐) + 音色目录;失败不静默取消限长
loadTextLimit();
voicesPromise = loadVoices().catch(err => {
console.error(err);
voicesLoadError = true; // 标记加载失败:点朗读时显式报错并重试,不静默发空 voice
setStatus("error");
throw err;
});
</script>
</body>
</html>