diff --git a/app/globals.css b/app/globals.css index 82bfdbe..980ce39 100644 --- a/app/globals.css +++ b/app/globals.css @@ -265,6 +265,161 @@ button:disabled { color: #6b7280; } +.tts-editor { + position: relative; + overflow-y: auto; + border: 1px solid #2a2f36; + border-radius: 0.65rem; + background: #14181d; + color: #f5f7fa; + padding: 1rem; + white-space: pre-wrap; + word-break: break-word; + caret-color: #00d1e6; +} + +.tts-editor.is-empty::before { + color: #4b5563; + content: "输入需要转换的文本,停顿会显示为胶囊,不需要手写代码。"; + pointer-events: none; +} + +.tts-editor:focus { + border-color: rgba(0, 209, 230, 0.7); + outline: 2px solid rgba(0, 209, 230, 0.12); + outline-offset: 2px; +} + +.pause-token { + display: inline-flex; + align-items: center; + margin: 0 0.18rem; + border: 1px solid rgba(0, 209, 230, 0.48); + border-radius: 999px; + background: rgba(0, 209, 230, 0.1); + color: #55e4ef; + padding: 0.12rem 0.55rem; + font-size: 0.8em; + line-height: 1.5; + cursor: pointer; + vertical-align: baseline; +} + +.pause-token:hover, +.pause-token:focus { + border-color: #00d1e6; + background: rgba(0, 209, 230, 0.2); +} + +.typo-mark { + text-decoration: underline wavy #ef4444 1.5px; + text-underline-offset: 0.25rem; + cursor: pointer; +} + +.tool-button, +.pause-option { + display: inline-flex; + min-height: 2.25rem; + align-items: center; + justify-content: center; + gap: 0.35rem; + border: 1px solid #2a2f36; + border-radius: 0.55rem; + background: #14181d; + color: #d1d5db; + padding: 0.45rem 0.75rem; + font-size: 0.75rem; + transition: border-color 180ms ease, background-color 180ms ease, color 180ms ease; +} + +.tool-button:hover, +.pause-option:hover, +.pause-option.is-selected { + border-color: #00d1e6; + background: rgba(0, 209, 230, 0.1); + color: #55e4ef; +} + +.pause-panel { + border: 1px solid rgba(0, 209, 230, 0.2); + border-radius: 0.75rem; + background: #101317; + padding: 0.85rem; +} + +.pause-input { + width: 5.5rem; + border: 1px solid #2a2f36; + border-radius: 0.5rem; + background: #1a1d21; + color: #f5f7fa; + padding: 0.45rem 0.55rem; + font-size: 0.8rem; +} + +.pause-range { + width: min(12rem, 100%); + accent-color: #00d1e6; +} + +.assist-card { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.85rem; + min-height: 6.25rem; + border: 1px solid #2a2f36; + border-radius: 0.85rem; + background: rgba(20, 24, 29, 0.76); + padding: 1rem; + color: #f5f7fa; + transition: border-color 180ms ease, background-color 180ms ease, transform 180ms ease; +} + +.assist-card:hover { + border-color: rgba(0, 209, 230, 0.65); + background: rgba(0, 209, 230, 0.06); + transform: translateY(-2px); +} + +.assist-card:disabled { + cursor: wait; + opacity: 0.6; +} + +.assist-card strong, +.assist-card small { + display: block; +} + +.assist-card strong { + font-size: 0.9rem; +} + +.assist-card small { + margin-top: 0.35rem; + color: #9ca3af; + font-size: 0.75rem; + line-height: 1.55; +} + +.assist-icon { + display: grid; + width: 2.25rem; + height: 2.25rem; + place-items: center; + border: 1px solid rgba(0, 209, 230, 0.3); + border-radius: 0.65rem; + color: #55e4ef; + font-size: 1.1rem; +} + +.assist-arrow { + color: #4b5563; + font-size: 1.2rem; +} + @media (max-width: 640px) { .container-shell { width: min(100% - 2rem, 1180px); diff --git a/app/tts/page.tsx b/app/tts/page.tsx index e90569c..b11d968 100644 --- a/app/tts/page.tsx +++ b/app/tts/page.tsx @@ -1,158 +1,542 @@ "use client"; -import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { ArrowRight, WaveIcon } from "@/components/icons"; import { BrandIcon } from "@/components/brand-icon"; -import { PreviewNotice, SectionHeading, StatusCard } from "@/components/ui"; +import { SectionHeading, StatusCard } from "@/components/ui"; -const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1"; +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; provider_voice_id: string; name: string; language?: string | null }; -type Usage = { available: number; used: number; reserved: number; plan: string }; -type Task = { id: string; status: string; text_length: number; voice_id: string; parameters: { format?: string; speed?: number }; error_code?: string | null; audio_available: boolean; file_name?: string | null; created_at: string; finished_at?: string | null }; +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"); } -export default function TtsPage() { - const [csrf, setCsrf] = useState(""); - const [loggedIn, setLoggedIn] = useState(false); - const [voices, setVoices] = useState([]); - const [usage, setUsage] = useState(null); - const [tasks, setTasks] = useState([]); - const [text, setText] = useState(""); - const [voice, setVoice] = useState(""); - const [speed, setSpeed] = useState("1"); - const [format, setFormat] = useState("wav"); - const [activeTask, setActiveTask] = useState(null); - const [renaming, setRenaming] = useState(false); - const [renameOpen, setRenameOpen] = useState(false); - const [renameDraft, setRenameDraft] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); +function apiUrl(path: string) { + return `${ttsBase}${path}`; +} - async function loadWorkspace() { - const csrfResponse = await fetch(`${apiBase}/auth/csrf`, { credentials: "include" }); - const csrfBody = await csrfResponse.json(); - setCsrf(csrfBody.csrf_token ?? ""); - const meResponse = await fetch(`${apiBase}/auth/me`, { credentials: "include" }); - if (!meResponse.ok) { - setLoggedIn(false); +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; } - setLoggedIn(true); - const [voiceResponse, usageResponse, taskResponse] = await Promise.all([ - fetch(`${apiBase}/tts/voices`, { credentials: "include" }), - fetch(`${apiBase}/account/usage`, { credentials: "include" }), - fetch(`${apiBase}/tts/tasks`, { credentials: "include" }), - ]); - const nextVoices = voiceResponse.ok ? await voiceResponse.json() : []; - setVoices(nextVoices); - setVoice((current) => current || nextVoices[0]?.provider_voice_id || ""); - if (usageResponse.ok) setUsage(await usageResponse.json()); - if (taskResponse.ok) { - const taskData: Task[] = await taskResponse.json(); - setTasks(taskData); - if (!activeTask && taskData.length > 0 && taskData[0].status === "succeeded") setActiveTask(taskData[0]); + 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("已按句号、问号和感叹号完成自动分段"); } } - useEffect(() => { loadWorkspace().catch(() => setError("暂时无法连接 TTS 工作台")); }, []); - - async function pollTask(taskId: string) { - for (let attempt = 0; attempt < 180; attempt += 1) { - const response = await fetch(`${apiBase}/tts/tasks/${taskId}`, { credentials: "include" }); - if (!response.ok) throw new Error("无法读取任务状态"); - const nextTask: Task = await response.json(); - setActiveTask(nextTask); - if (["succeeded", "failed"].includes(nextTask.status)) return nextTask; - await new Promise((resolve) => window.setTimeout(resolve, 1000)); - } - throw new Error("任务等待超时"); + function handleEditorInput() { + if (!textareaRef.current) return; + setText(normalizeText(serializeEditor(textareaRef.current))); + setSelectedPause(null); + setCorrectionMessage(""); } - async function createTask() { + 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 (!loggedIn) return; - if (!voice) return setError("当前没有可用音色,请先配置真实上游音色"); - if (!text.trim()) return setError("请输入需要转换的文本"); + if (!text.trim()) return setError("请输入需要合成的文本"); + if (!voiceId) return setError("请选择一个音色"); setBusy(true); try { - const response = await fetch(`${apiBase}/tts/tasks`, { + const response = await fetch(apiUrl("/v1/audio/speech"), { method: "POST", - credentials: "include", - headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf, "Idempotency-Key": crypto.randomUUID() }, - body: JSON.stringify({ text: normalizeText(text), voice_id: voice, parameters: { format, speed: Number(speed) } }), + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: normalizeText(text), voice: voiceId, response_format: format, speed }), }); - const body = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(body.error?.message ?? "任务创建失败"); - const result = await pollTask(body.id); - if (result.status === "failed") throw new Error(`任务失败:${result.error_code ?? "TASK_FAILED"}`); - await loadWorkspace(); - } catch (taskError) { - setError(taskError instanceof Error ? taskError.message : "任务失败"); + 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 renameTask(taskId: string, rawName: string) { - const draft = rawName.trim(); - if (renaming) return; - setRenaming(true); - setError(""); + 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 response = await fetch(`${apiBase}/tts/tasks/${taskId}/file-name`, { - method: "PATCH", - credentials: "include", - headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, - body: JSON.stringify({ file_name: draft }), + 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 body = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(body.error?.message ?? "重命名失败"); - setRenameOpen(false); - await loadWorkspace(); - } catch (e) { - setError(e instanceof Error ? e.message : "重命名失败"); + 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 { - setRenaming(false); + 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
-
Phase 3
-
{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}
- {!loggedIn ?
前往登录
: <> -
-

01 / Input

输入文本

{normalizeText(text).length} / 当前计划限制