www_site/app/tts/page.tsx

628 lines
35 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
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);
const [voices, setVoices] = useState<Voice[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [facets, setFacets] = useState<Record<string, string[]>>({});
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);
const [format, setFormat] = useState("wav");
const [result, setResult] = useState<Result | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [helperBusy, setHelperBusy] = useState(false);
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 [myVoice, setMyVoice] = useState<{ name: string; dataUrl: string } | null>(null);
const [recording, setRecording] = useState(false);
const textareaRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const voiceFileRef = 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 });
}
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 之间的整数`);
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("已按句号、问号和感叹号完成自动分段");
}
}
function handleEditorInput() {
if (!textareaRef.current) return;
setText(normalizeText(serializeEditor(textareaRef.current)));
setSelectedPause(null);
setCorrectionMessage("");
}
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}`);
}
}
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() {
setError("");
if (!text.trim()) return setError("请输入需要合成的文本");
if (!myVoice && !voiceId) return setError("请选择一个音色");
setBusy(true);
try {
let blob: Blob;
if (myVoice) {
const response = await fetch("/tts-clone", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: normalizeText(text), reference_audio: myVoice.dataUrl, response_format: format, speed }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error?.message ?? `真人语音合成失败(${response.status}`);
}
blob = await response.blob();
} else {
const response = await fetch(apiUrl("/v1/audio/speech"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: normalizeText(text), voice: voiceId, response_format: format, speed }),
});
if (!response.ok) {
const body = await response.json().catch(() => ({}));
throw new Error(body.error?.message ?? `合成失败(${response.status}`);
}
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 : "语音合成失败");
} finally {
setBusy(false);
}
}
async function startRecording() {
if (recording) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const preferred = ["audio/webm", "audio/mp4", "audio/ogg"].find((mime) => MediaRecorder.isTypeSupported(mime));
const recorder = new MediaRecorder(stream, preferred ? { mimeType: preferred } : undefined);
const chunks: Blob[] = [];
recorder.ondataavailable = (event) => { if (event.data.size > 0) chunks.push(event.data); };
recorder.onstop = () => {
stream.getTracks().forEach((track) => track.stop());
const blob = new Blob(chunks, { type: recorder.mimeType || "audio/webm" });
const reader = new FileReader();
reader.onload = () => {
setMyVoice({ dataUrl: String(reader.result), name: `我的声音 ${new Date().toLocaleTimeString("zh-CN", { hour12: false })}` });
setRecording(false);
};
reader.onerror = () => setRecording(false);
reader.readAsDataURL(blob);
};
recorderRef.current = recorder;
recorder.start();
setRecording(true);
} catch {
setHelperMessage("无法访问麦克风,请允许浏览器使用麦克风,或直接上传录好的音频文件");
}
}
function stopRecording() {
if (recorderRef.current && recorderRef.current.state !== "inactive") recorderRef.current.stop();
}
function handleVoiceFile(file: File | undefined) {
if (!file) return;
const isAudio = file.type.startsWith("audio/") || /\.(wav|mp3|m4a|ogg|webm|aac)$/i.test(file.name);
if (!isAudio) {
setHelperMessage("请上传音频文件wav / mp3 / m4a 等)");
return;
}
if (file.size > 8 * 1024 * 1024) {
setHelperMessage("声音样本不能超过 8 MB建议 1060 秒清晰人声");
return;
}
const reader = new FileReader();
reader.onload = () => setMyVoice({ dataUrl: String(reader.result), name: file.name });
reader.readAsDataURL(file);
}
function clearMyVoice() {
setMyVoice(null);
}
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;
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 aria-label="真人声音配音模式" className={`voice-clone-panel mt-6 ${myVoice ? "is-ready" : ""}`}>
<div className="flex flex-wrap items-center gap-x-4 gap-y-3">
<div className="min-w-0 flex-1">
<p className="font-semibold"><span aria-hidden="true">🎙</span> </p>
<p className="mt-1 text-xs leading-5 text-muted"></p>
</div>
<div className="flex flex-wrap items-center gap-2">
{recording ? <button className="button-secondary" onClick={stopRecording} type="button"> </button> : <button className="button-secondary" onClick={() => { void startRecording(); }} type="button"> </button>}
<button className="button-ghost min-h-9 px-3 py-1.5 text-xs" onClick={() => voiceFileRef.current?.click()} type="button"></button>
{myVoice ? <button className="text-xs text-danger hover:underline" onClick={clearMyVoice} type="button"></button> : null}
</div>
</div>
{myVoice ? <div className="mt-4 flex flex-wrap items-center gap-3">
<span className="rounded-full border border-cyan/30 bg-cyan/10 px-3 py-1 text-xs font-semibold text-cyan"> {myVoice.name} 使</span>
<audio className="h-9 w-full max-w-96" controls preload="metadata" src={myVoice.dataUrl} />
</div> : null}
{recording ? <p className="mt-3 text-xs text-cyan"> 1060 </p> : null}
<input accept="audio/*,.wav,.mp3,.m4a" className="hidden" onChange={(event) => { handleVoiceFile(event.target.files?.[0]); event.target.value = ""; }} ref={voiceFileRef} type="file" />
</section>
</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>
</div>;
}