www_site/app/tts/page.tsx

540 lines
30 KiB
TypeScript
Raw Normal View History

2026-09-08 16:43:56 +00:00
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { ArrowRight, WaveIcon } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { SectionHeading, StatusCard } from "@/components/ui";
2026-09-08 16:43:56 +00:00
const ttsBase = (process.env.NEXT_PUBLIC_TTS_BASE_URL ?? "/tts").replace(/\/+$/, "");
const pausePattern = /<pause:(\d+)>/g;
const typoRules = [
{ wrong: "爷门儿", right: "爷们儿" },
{ wrong: "在见", right: "再见" },
{ wrong: "以经", right: "已经" },
];
type Voice = {
id: string;
name: string;
desc?: string;
category?: string;
gender?: string;
genre?: string;
accent?: string;
style?: string;
styles?: string[];
preview?: string;
};
type VoiceResponse = { categories?: string[]; facets?: Record<string, string[]>; voices?: Voice[] };
type TtsConfig = {
model?: string;
formats?: string[];
default_format?: string;
speed?: { min?: number; max?: number; default?: number };
pause?: { supported?: boolean; min_ms?: number; max_ms?: number; presets?: { ms: number; label: string }[] };
};
type Result = { url: string; fileName: string; format: string };
type SelectedPause = { start: number; ms: number };
const pauseOptions = [500, 1000, 2000];
const symbolPauses: Record<string, number> = { "": 300, "。": 800, "": 800, "": 800 };
const erhuaOptions = ["关闭", "弱", "标准", "强"];
function normalizeText(value: string) {
return value.normalize("NFC").replace(/\r\n?/g, "\n");
}
function apiUrl(path: string) {
return `${ttsBase}${path}`;
}
function escapeHtml(value: string) {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function renderTextHtml(value: string) {
let html = escapeHtml(value);
for (const rule of typoRules) {
html = html.replaceAll(escapeHtml(rule.wrong), `<span class="typo-mark" data-typo-right="${rule.right}">${escapeHtml(rule.wrong)}</span>`);
}
return html.replace(/\n/g, "<br />");
}
function renderEditorHtml(value: string) {
let html = "";
let cursor = 0;
const matcher = new RegExp(pausePattern.source, "g");
let match = matcher.exec(value);
while (match) {
html += renderTextHtml(value.slice(cursor, match.index));
html += `<span class="pause-token" contenteditable="false" data-pause-ms="${match[1]}" role="button" tabindex="0">⏸ ${Number(match[1]) / 1000}s</span>`;
cursor = match.index + match[0].length;
match = matcher.exec(value);
}
return html + renderTextHtml(value.slice(cursor));
}
function serializeEditor(root: HTMLElement) {
function walk(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? "";
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as HTMLElement;
if (element.dataset.pauseMs) return `<pause:${element.dataset.pauseMs}>`;
if (element.tagName === "BR") return "\n";
return Array.from(element.childNodes).map(walk).join("");
}
return walk(root);
}
function visibleText(value: string) {
return value.replace(new RegExp(pausePattern.source, "g"), "");
}
function countTypos(value: string) {
const plain = visibleText(value);
return typoRules.reduce((count, rule) => count + plain.split(rule.wrong).length - 1, 0);
}
export default function TtsPage() {
const [config, setConfig] = useState<TtsConfig | null>(null);
2026-09-08 16:43:56 +00:00
const [voices, setVoices] = useState<Voice[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [facets, setFacets] = useState<Record<string, string[]>>({});
2026-09-08 16:43:56 +00:00
const [text, setText] = useState("");
const [voiceId, setVoiceId] = useState("");
const [category, setCategory] = useState("");
const [filters, setFilters] = useState<Record<string, string>>({});
const [search, setSearch] = useState("");
const [speed, setSpeed] = useState(1);
const [erhuaStrength, setErhuaStrength] = useState(2);
2026-09-08 16:43:56 +00:00
const [format, setFormat] = useState("wav");
const [result, setResult] = useState<Result | null>(null);
const [loading, setLoading] = useState(true);
2026-09-08 16:43:56 +00:00
const [busy, setBusy] = useState(false);
const [helperBusy, setHelperBusy] = useState(false);
2026-09-08 16:43:56 +00:00
const [error, setError] = useState("");
const [loadedAt, setLoadedAt] = useState<Date | null>(null);
const [pauseOpen, setPauseOpen] = useState(false);
const [customPause, setCustomPause] = useState("3500");
const [selectedPause, setSelectedPause] = useState<SelectedPause | null>(null);
const [pauseError, setPauseError] = useState("");
const [correctionMessage, setCorrectionMessage] = useState("");
const [helperMessage, setHelperMessage] = useState("");
const textareaRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const [configResponse, voicesResponse] = await Promise.all([
fetch(apiUrl("/v1/audio/config")),
fetch(apiUrl("/v1/audio/voices")),
]);
if (!configResponse.ok || !voicesResponse.ok) throw new Error("TTS 服务暂时不可用,请稍后重试");
const nextConfig: TtsConfig = await configResponse.json();
const nextVoices: VoiceResponse = await voicesResponse.json();
if (cancelled) return;
const nextList = nextVoices.voices ?? [];
setConfig(nextConfig);
setVoices(nextList);
setCategories(nextVoices.categories ?? Array.from(new Set(nextList.map((item) => item.category).filter(Boolean) as string[])));
setFacets(nextVoices.facets ?? {});
setVoiceId((current) => current || nextList[0]?.id || "");
setFormat(nextConfig.default_format ?? nextConfig.formats?.[0] ?? "wav");
setSpeed(nextConfig.speed?.default ?? 1);
setLoadedAt(new Date());
} catch (loadError) {
if (!cancelled) setError(loadError instanceof Error ? loadError.message : "无法连接 TTS 服务");
} finally {
if (!cancelled) setLoading(false);
}
}
void load();
return () => { cancelled = true; };
}, []);
useEffect(() => () => {
if (result?.url) URL.revokeObjectURL(result.url);
}, [result?.url]);
useEffect(() => {
if (textareaRef.current && !textareaRef.current.innerHTML) textareaRef.current.innerHTML = renderEditorHtml(text);
}, [text]);
const selectedVoice = voices.find((item) => item.id === voiceId) ?? null;
const filteredVoices = useMemo(() => {
const query = search.trim().toLocaleLowerCase();
return voices.filter((voice) => {
if (category && voice.category !== category) return false;
if (query && ![voice.name, voice.id, voice.desc, voice.gender, voice.genre, voice.accent, voice.style].some((value) => value?.toLocaleLowerCase().includes(query))) return false;
return Object.entries(filters).every(([key, value]) => !value || voice[key as keyof Voice] === value || voice.styles?.includes(value));
});
}, [category, filters, search, voices]);
useEffect(() => {
if (filteredVoices.length && !filteredVoices.some((item) => item.id === voiceId)) setVoiceId(filteredVoices[0].id);
}, [filteredVoices, voiceId]);
function setEditorText(nextText: string, caretOffset?: number) {
const next = normalizeText(nextText);
setText(next);
if (textareaRef.current) textareaRef.current.innerHTML = renderEditorHtml(next);
if (caretOffset !== undefined) {
window.requestAnimationFrame(() => setCaretAtRawOffset(caretOffset));
}
}
function setCaretAtRawOffset(offset: number) {
const root = textareaRef.current;
if (!root) return;
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
let remaining = Math.max(0, offset);
let placed = false;
function visit(node: Node): void {
if (placed) return;
if (node.nodeType === Node.TEXT_NODE) {
const length = node.textContent?.length ?? 0;
if (remaining <= length) {
range.setStart(node, remaining);
range.collapse(true);
placed = true;
} else remaining -= length;
return;
}
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.pauseMs) {
const markerLength = `<pause:${(node as HTMLElement).dataset.pauseMs}>`.length;
if (remaining <= markerLength) {
range.setStartAfter(node);
range.collapse(true);
placed = true;
} else remaining -= markerLength;
return;
}
Array.from(node.childNodes).forEach(visit);
}
visit(root);
if (!placed) {
range.selectNodeContents(root);
range.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
root.focus();
}
function getSelectionOffset() {
const root = textareaRef.current;
const currentSelection = window.getSelection();
if (!root || !currentSelection?.anchorNode || !root.contains(currentSelection.anchorNode)) return text.length;
const selection = currentSelection;
let result = 0;
let reached = false;
function walk(node: Node): void {
if (reached) return;
if (node === selection.anchorNode) {
result += selection.anchorOffset;
reached = true;
return;
}
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.pauseMs) {
result += `<pause:${(node as HTMLElement).dataset.pauseMs}>`.length;
return;
}
Array.from(node.childNodes).forEach((child) => {
if (!reached) walk(child);
});
}
walk(root);
return reached ? result : text.length;
}
function insertPause(ms: number) {
const marker = `<pause:${ms}>`;
const start = getSelectionOffset();
const next = `${text.slice(0, start)}${marker}${text.slice(start)}`;
setEditorText(next, start + marker.length);
setSelectedPause({ start, ms });
}
2026-09-08 16:43:56 +00:00
function choosePause(ms: number) {
if (selectedPause) {
const oldMarker = `<pause:${selectedPause.ms}>`;
if (text.slice(selectedPause.start, selectedPause.start + oldMarker.length) === oldMarker) {
const marker = `<pause:${ms}>`;
setEditorText(`${text.slice(0, selectedPause.start)}${marker}${text.slice(selectedPause.start + oldMarker.length)}`, selectedPause.start + marker.length);
setSelectedPause({ start: selectedPause.start, ms });
return;
}
}
insertPause(ms);
}
function removeSelectedPause() {
if (!selectedPause) return;
const marker = `<pause:${selectedPause.ms}>`;
if (text.slice(selectedPause.start, selectedPause.start + marker.length) === marker) {
setEditorText(`${text.slice(0, selectedPause.start)}${text.slice(selectedPause.start + marker.length)}`, selectedPause.start);
}
setSelectedPause(null);
setPauseOpen(false);
}
function applyCustomPause() {
const ms = Number(customPause);
const min = config?.pause?.min_ms ?? 50;
const max = config?.pause?.max_ms ?? 10000;
if (!Number.isInteger(ms) || ms < min || ms > max) {
setPauseError(`请输入 ${min}${max} ms 之间的整数`);
2026-09-08 16:43:56 +00:00
return;
}
setPauseError("");
choosePause(ms);
}
function applySymbolPauses() {
const next = text.replace(/([,。!?])(?!<pause:\d+>)/g, (match) => `${match}<pause:${symbolPauses[match]}>`);
if (next === text) setCorrectionMessage("当前标点已经完成停顿处理");
else setEditorText(next, next.length);
}
function applyParagraphPauses() {
const next = text.replace(/\n+(?!<pause:\d+>)/g, (match) => `${match}<pause:2000>`);
if (next === text) setCorrectionMessage("请先在文本中加入换行,再使用段落停顿");
else setEditorText(next, next.length);
}
function autoSegment() {
const next = text.replace(/([。!?])(?!<pause:\d+>|\n)/g, "$1\n");
if (next === text) setHelperMessage("当前文本已经完成基础分段");
else {
setEditorText(next, next.length);
setHelperMessage("已按句号、问号和感叹号完成自动分段");
}
2026-09-08 16:43:56 +00:00
}
function handleEditorInput() {
if (!textareaRef.current) return;
setText(normalizeText(serializeEditor(textareaRef.current)));
setSelectedPause(null);
setCorrectionMessage("");
}
2026-09-08 16:43:56 +00:00
function handleEditorClick(event: { target: EventTarget | null }) {
const target = event.target instanceof HTMLElement ? event.target : null;
const pause = target?.closest<HTMLElement>("[data-pause-ms]");
if (pause) {
const start = getElementRawOffset(pause);
setSelectedPause({ start, ms: Number(pause.dataset.pauseMs) });
setPauseOpen(true);
setCustomPause(pause.dataset.pauseMs ?? "1000");
return;
}
const typo = target?.closest<HTMLElement>("[data-typo-right]");
if (typo) {
const start = getElementRawOffset(typo);
const wrong = typo.textContent ?? "";
const right = typo.dataset.typoRight ?? wrong;
setEditorText(`${text.slice(0, start)}${right}${text.slice(start + wrong.length)}`, start + right.length);
setCorrectionMessage(`已将「${wrong}」替换为「${right}`);
2026-09-08 16:43:56 +00:00
}
}
function getElementRawOffset(element: HTMLElement) {
const root = textareaRef.current;
if (!root) return 0;
let offset = 0;
let found = false;
function walk(node: Node): void {
if (found) return;
if (node === element) {
found = true;
return;
}
if (node.nodeType === Node.TEXT_NODE) offset += node.textContent?.length ?? 0;
else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.pauseMs) offset += `<pause:${(node as HTMLElement).dataset.pauseMs}>`.length;
else Array.from(node.childNodes).forEach((child) => { if (!found) walk(child); });
}
walk(root);
return offset;
}
async function createSpeech() {
2026-09-08 16:43:56 +00:00
setError("");
if (!text.trim()) return setError("请输入需要合成的文本");
if (!voiceId) return setError("请选择一个音色");
2026-09-08 16:43:56 +00:00
setBusy(true);
try {
const response = await fetch(apiUrl("/v1/audio/speech"), {
2026-09-08 16:43:56 +00:00
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: normalizeText(text), voice: voiceId, response_format: format, speed }),
2026-09-08 16:43:56 +00:00
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error?.message ?? `合成失败(${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
setResult({ url, format, fileName: `kaotings-tts-${new Date().toISOString().replace(/[:.]/g, "-")}.${format}` });
} catch (speechError) {
setError(speechError instanceof Error ? speechError.message : "语音合成失败");
2026-09-08 16:43:56 +00:00
} finally {
setBusy(false);
}
}
async function handleOcr(file: File | undefined) {
if (!file) return;
if (!file.type.startsWith("image/")) {
setHelperMessage("请选择图片文件");
return;
}
if (file.size > 12 * 1024 * 1024) {
setHelperMessage("图片不能超过 12 MB");
return;
}
setHelperBusy(true);
setHelperMessage(`正在识别 ${file.name}`);
try {
const imageDataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result)));
reader.addEventListener("error", () => reject(new Error("图片读取失败")));
reader.readAsDataURL(file);
});
const response = await fetch("/api/v1/ocr", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_data_url: imageDataUrl, filename: file.name }),
});
if (response.status === 401) throw new Error("请先登录后使用图片文字识别");
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error?.message ?? "OCR 服务暂未连接");
}
const data = await response.json();
if (typeof data.text !== "string" || !data.text.trim()) throw new Error("没有识别到可用文字");
setEditorText(`${text ? `${text}\n` : ""}${data.text}`, text.length + data.text.length + (text ? 1 : 0));
setHelperMessage("图片文字已填入上方编辑区,可继续校对");
} catch (ocrError) {
setHelperMessage(ocrError instanceof Error ? ocrError.message : "图片识别失败,请稍后重试");
} finally {
setHelperBusy(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
function checkTypos() {
const count = countTypos(text);
setCorrectionMessage(count ? `发现 ${count} 处疑似错别字,点击红色波浪线即可替换` : "暂未发现内置词库中的疑似错别字");
}
const formatOptions = config?.formats?.length ? config.formats : ["wav", "mp3", "flac", "opus", "aac", "pcm"];
const minSpeed = config?.speed?.min ?? 0.25;
const maxSpeed = config?.speed?.max ?? 4;
const minPause = config?.pause?.min_ms ?? 50;
const maxPause = config?.pause?.max_ms ?? 10000;
2026-09-08 16:43:56 +00:00
return <div className="container-shell py-16 sm:py-24">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end">
<SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音" description="输入文案、选择音色,剩下的交给声音工作台。停顿会以直观胶囊显示,技术标记只在提交时保留。" tone="cyan" />
<span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">{config?.model ?? "Qwen3-TTS"}</span>
</div>
<div className="mt-10 grid gap-6 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-input-title">
<div className="flex items-center justify-between gap-4">
<div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">01 / Input</p><h2 className="mt-2 text-xl font-semibold" id="tts-input-title"></h2></div>
<span className="text-xs text-subtle">{visibleText(text).length} </span>
</div>
<div
aria-label="配音文本编辑区"
className={`tts-editor mt-6 min-h-64 leading-7 ${!text ? "is-empty" : ""}`}
contentEditable
onClick={handleEditorClick}
onInput={handleEditorInput}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
document.execCommand("insertLineBreak");
}
}}
onPaste={(event) => {
event.preventDefault();
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
}}
ref={textareaRef}
aria-multiline="true"
role="textbox"
spellCheck
suppressContentEditableWarning
/>
<div className="mt-5 border-t border-line pt-5">
<div className="flex flex-wrap items-center gap-2">
<span className="mr-1 text-sm text-muted"></span>
<button aria-expanded={pauseOpen} className="button-ghost min-h-9 px-3 py-1.5 text-xs" onClick={() => setPauseOpen((open) => !open)} type="button"> <span aria-hidden="true"></span></button>
<button className="tool-button min-h-9" onClick={applySymbolPauses} type="button"> </button>
<button className="tool-button min-h-9" onClick={applyParagraphPauses} type="button"> </button>
<button className="tool-button min-h-9" onClick={checkTypos} type="button"> </button>
<button className="button-primary" disabled={busy || loading} onClick={() => void createSpeech()} type="button"><BrandIcon />{busy ? "合成中…" : "生成语音"}<ArrowRight /></button>
</div>
{pauseOpen ? <div className="pause-panel mt-4">
<div className="flex flex-wrap items-center gap-2">
{pauseOptions.map((ms) => <button className={selectedPause?.ms === ms ? "pause-option is-selected" : "pause-option"} key={ms} onClick={() => choosePause(ms)} type="button">{ms / 1000}s</button>)}
<span className="text-xs text-subtle">{selectedPause ? "正在编辑已选胶囊" : "点击选项插入到光标处"}</span>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-muted">
<label htmlFor="custom-pause"></label>
<input className="pause-input" id="custom-pause" max={maxPause} min={minPause} onChange={(event) => { setCustomPause(event.target.value); setPauseError(""); }} type="number" value={customPause} />
<span>ms</span>
<input aria-label="自定义停顿时长" className="pause-range" max={maxPause} min={minPause} onChange={(event) => { setCustomPause(event.target.value); setPauseError(""); }} step="50" type="range" value={Math.min(maxPause, Math.max(minPause, Number(customPause) || minPause))} />
<button className="button-ghost min-h-9 px-3 py-1.5 text-xs" onClick={applyCustomPause} type="button"></button>
{selectedPause ? <button className="text-xs text-danger hover:underline" onClick={removeSelectedPause} type="button"></button> : null}
</div>
{pauseError ? <p className="mt-2 text-xs text-danger">{pauseError}</p> : null}
<p className="mt-3 text-xs leading-5 text-subtle"> {minPause}{maxPause} ms </p>
</div> : null}
<p className="mt-3 text-xs leading-5 text-subtle"> 0.3s / / 0.8s</p>
{correctionMessage ? <p className="mt-3 rounded-lg border border-cyan/20 bg-cyan/5 px-3 py-2 text-xs text-cyan">{correctionMessage}</p> : null}
</div>
<p className="mt-5 border-t border-line pt-5 text-sm text-muted">{loadedAt ? `音色清单已更新 · ${voices.length} 个音色` : "正在读取音色清单…"}</p>
<div className="mt-6 grid gap-4 md:grid-cols-3">
<button className="assist-card text-left" disabled={helperBusy} onClick={() => fileInputRef.current?.click()} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { event.preventDefault(); void handleOcr(event.dataTransfer.files[0]); }} type="button"><span className="assist-icon"></span><span><strong></strong><small>稿PPT</small></span><span className="assist-arrow"></span></button>
<button className="assist-card text-left" onClick={checkTypos} type="button"><span className="assist-icon"></span><span><strong></strong><small>线</small></span><span className="assist-arrow"></span></button>
<button className="assist-card text-left" onClick={autoSegment} type="button"><span className="assist-icon"></span><span><strong></strong><small>便</small></span><span className="assist-arrow"></span></button>
</div>
<input accept="image/*" className="hidden" onChange={(event) => void handleOcr(event.target.files?.[0])} ref={fileInputRef} type="file" />
{helperMessage ? <p className="mt-4 rounded-lg border border-cyan/20 bg-cyan/5 px-3 py-2 text-xs text-cyan">{helperMessage}</p> : null}
</section>
<aside className="space-y-6">
<section className="rounded-2xl border border-line bg-panel/60 p-5 sm:p-7" aria-labelledby="tts-settings-title">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">02 / Voice & Parameters</p><h2 className="mt-2 text-xl font-semibold" id="tts-settings-title"></h2>
<div className="mt-6 space-y-5">
<div><label className="field-label" htmlFor="voice-search"></label><input className="field-input" id="voice-search" onChange={(event) => setSearch(event.target.value)} placeholder="按名称、描述、方言或风格搜索" value={search} /></div>
<div className="grid gap-4 sm:grid-cols-2"><div><label className="field-label" htmlFor="voice-category"></label><select className="field-input" id="voice-category" onChange={(event) => setCategory(event.target.value)} value={category}><option value=""></option>{categories.map((item) => <option key={item} value={item}>{item}</option>)}</select></div>{Object.entries(facets).map(([key, values]) => <div key={key}><label className="field-label" htmlFor={`facet-${key}`}>{({ gender: "性别", genre: "类型", accent: "方言", style: "风格" } as Record<string, string>)[key] ?? key}</label><select className="field-input" id={`facet-${key}`} onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))} value={filters[key] ?? ""}><option value=""></option>{values.map((item) => <option key={item} value={item}>{item}</option>)}</select></div>)}</div>
<div><label className="field-label" htmlFor="voice"> <span className="font-normal text-subtle">({filteredVoices.length} )</span></label><select className="field-input" id="voice" onChange={(event) => setVoiceId(event.target.value)} value={voiceId}><option value=""></option>{categories.map((group) => { const groupVoices = filteredVoices.filter((item) => item.category === group); return groupVoices.length ? <optgroup key={group} label={group}>{groupVoices.map((item) => <option key={item.id} value={item.id}>{item.name}{item.accent ? ` · ${item.accent}` : ""}</option>)}</optgroup> : null; })}{filteredVoices.filter((item) => !item.category || !categories.includes(item.category)).map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></div>
{selectedVoice ? <div className="rounded-xl border border-cyan/20 bg-cyan/5 p-4"><p className="font-semibold text-copy">{selectedVoice.name}</p><p className="mt-1 text-sm leading-6 text-muted">{selectedVoice.desc || "暂无音色描述"}</p><div className="mt-3 flex flex-wrap gap-2 text-xs text-cyan">{[selectedVoice.gender, selectedVoice.genre, selectedVoice.accent, selectedVoice.style].filter(Boolean).map((tag) => <span className="rounded-full border border-cyan/20 px-2 py-1" key={tag}>{tag}</span>)}</div>{selectedVoice.preview ? <audio className="mt-4 w-full" controls preload="none" src={apiUrl(selectedVoice.preview)} /> : null}</div> : null}
<div><label className="field-label" htmlFor="speed"> <span className="font-normal text-subtle">{speed.toFixed(2)}x</span></label><input className="w-full accent-cyan" id="speed" max={maxSpeed} min={minSpeed} onChange={(event) => setSpeed(Number(event.target.value))} step="0.05" type="range" value={speed} /><div className="mt-1 flex justify-between text-xs text-subtle"><span>{minSpeed}x</span><span>{maxSpeed}x</span></div></div>
<div><label className="field-label" htmlFor="erhua"> <span className="font-normal text-cyan">{erhuaOptions[erhuaStrength]}</span></label><input className="w-full accent-cyan" id="erhua" max="3" min="0" onChange={(event) => setErhuaStrength(Number(event.target.value))} step="1" type="range" value={erhuaStrength} /><div className="mt-1 flex justify-between text-xs text-subtle"><span></span><span></span><span></span><span></span></div><p className="mt-2 text-xs leading-5 text-subtle"></p></div>
<div><label className="field-label" htmlFor="format"></label><select className="field-input" id="format" onChange={(event) => setFormat(event.target.value)} value={format}>{formatOptions.map((item) => <option key={item} value={item}>{item.toUpperCase()}</option>)}</select></div>
</div>
</section>
<StatusCard title="服务状态" description={error || (loading ? "正在连接 TTS 服务…" : `已连接 · ${voices.length} 个音色可用`)} tone={error ? "warning" : "info"} />
</aside>
</div>
{error ? <p className="mt-6 rounded-xl border border-danger/30 bg-danger/10 px-4 py-3 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
{result ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7" aria-labelledby="tts-result-title"><div className="flex items-start justify-between gap-4"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">04 / Result</p><h2 className="mt-2 text-xl font-semibold" id="tts-result-title"></h2><p className="mt-2 break-all text-sm text-muted">{result.fileName}</p></div><WaveIcon className="h-8 w-8 shrink-0 text-success" /></div><audio autoPlay className="mt-5 w-full" controls src={result.url} /><div className="mt-4 flex justify-end"><a className="button-secondary" download={result.fileName} href={result.url}><BrandIcon /></a></div></section> : <section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7"><div className="flex min-h-28 flex-col items-center justify-center text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-3 font-semibold"></p><p className="mt-1 text-sm text-muted"></p></div></section>}
<details className="mt-6 rounded-xl border border-line bg-panel/30 px-4 py-3 text-xs text-subtle"><summary className="cursor-pointer font-semibold text-muted"></summary><p className="mt-3 leading-6"> <code>&lt;pause:毫秒&gt;</code><code>[pause:毫秒]</code> SSML break 使</p></details>
2026-09-08 16:43:56 +00:00
</div>;
}