"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 = //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; 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 = { ",": 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, "&").replace(//g, ">").replace(/"/g, """); } function renderTextHtml(value: string) { let html = escapeHtml(value); for (const rule of typoRules) { html = html.replaceAll(escapeHtml(rule.wrong), `${escapeHtml(rule.wrong)}`); } return html.replace(/\n/g, "
"); } 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 += `⏸ ${Number(match[1]) / 1000}s`; 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 ``; 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(null); const [voices, setVoices] = useState([]); const [categories, setCategories] = useState([]); const [facets, setFacets] = useState>({}); const [text, setText] = useState(""); const [voiceId, setVoiceId] = useState(""); const [category, setCategory] = useState(""); const [filters, setFilters] = useState>({}); const [search, setSearch] = useState(""); const [speed, setSpeed] = useState(1); const [erhuaStrength, setErhuaStrength] = useState(2); const [format, setFormat] = useState("wav"); const [result, setResult] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [helperBusy, setHelperBusy] = useState(false); const [error, setError] = useState(""); const [loadedAt, setLoadedAt] = useState(null); const [pauseOpen, setPauseOpen] = useState(false); const [customPause, setCustomPause] = useState("3500"); const [selectedPause, setSelectedPause] = useState(null); const [pauseError, setPauseError] = useState(""); const [correctionMessage, setCorrectionMessage] = useState(""); const [helperMessage, setHelperMessage] = useState(""); const textareaRef = useRef(null); const fileInputRef = useRef(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 = ``.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 += ``.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 = ``; 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 = ``; if (text.slice(selectedPause.start, selectedPause.start + oldMarker.length) === oldMarker) { const marker = ``; 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 = ``; 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(/([,。!?])(?!)/g, (match) => `${match}`); if (next === text) setCorrectionMessage("当前标点已经完成停顿处理"); else setEditorText(next, next.length); } function applyParagraphPauses() { const next = text.replace(/\n+(?!)/g, (match) => `${match}`); if (next === text) setCorrectionMessage("请先在文本中加入换行,再使用段落停顿"); else setEditorText(next, next.length); } function autoSegment() { const next = text.replace(/([。!?])(?!|\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("[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("[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 += ``.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 (!voiceId) return setError("请选择一个音色"); setBusy(true); try { 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})`); } 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 : "语音合成失败"); } 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((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
{config?.model ?? "Qwen3-TTS"}

01 / Input

输入文本

{visibleText(text).length} 字
{ 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 />
输入停顿
{pauseOpen ?
{pauseOptions.map((ms) => )} {selectedPause ? "正在编辑已选胶囊" : "点击选项插入到光标处"}
{ setCustomPause(event.target.value); setPauseError(""); }} type="number" value={customPause} /> ms { setCustomPause(event.target.value); setPauseError(""); }} step="50" type="range" value={Math.min(maxPause, Math.max(minPause, Number(customPause) || minPause))} /> {selectedPause ? : null}
{pauseError ?

{pauseError}

: null}

范围 {minPause}–{maxPause} ms。技术写法已隐藏,点击编辑区里的 ⏸ 胶囊可修改或删除。

: null}

点按钮或选时长即可插入停顿,无需手写代码。标点停顿默认:逗号 0.3s,句号 / 问号 / 感叹号 0.8s。

{correctionMessage ?

{correctionMessage}

: null}

{loadedAt ? `音色清单已更新 · ${voices.length} 个音色` : "正在读取音色清单…"}

void handleOcr(event.target.files?.[0])} ref={fileInputRef} type="file" /> {helperMessage ?

{helperMessage}

: null}
{error ?

{error}

: null} {result ?

04 / Result

生成完成

{result.fileName}

:

生成结果会显示在这里

选择音色并输入文本后,即可试听和下载。

}
高级:停顿技术写法

服务端仍兼容 <pause:毫秒>[pause:毫秒] 和 SSML break 写法;普通使用无需接触这些标记,编辑区里的停顿胶囊会在提交时自动转换。

; }