From 14bc7598bc9a2f316914bd7df1e550faf0070385 Mon Sep 17 00:00:00 2001 From: "2570050763@qq.com" <2570050763@qq.com> Date: Fri, 18 Sep 2026 13:10:14 +0800 Subject: [PATCH] =?UTF-8?q?tts:=20=E6=96=B0=E5=A2=9E=E7=9C=9F=E4=BA=BA?= =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E9=85=8D=E9=9F=B3=E6=A8=A1=E5=BC=8F=EF=BC=88?= =?UTF-8?q?=E5=BD=95=E9=9F=B3/=E4=B8=8A=E4=BC=A0=E5=A3=B0=E9=9F=B3?= =?UTF-8?q?=E6=A0=B7=E6=9C=AC=EF=BC=8C=E7=94=A8=E8=87=AA=E5=B7=B1=E7=9A=84?= =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E5=90=88=E6=88=90=E8=AF=AD=E9=9F=B3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 13 +++ app/tts-clone/route.ts | 65 ++++++++++++ app/tts/page.tsx | 108 +++++++++++++++++-- services/mock-tts/server.mjs | 195 +++++++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 app/tts-clone/route.ts create mode 100644 services/mock-tts/server.mjs diff --git a/app/globals.css b/app/globals.css index 980ce39..8a2d344 100644 --- a/app/globals.css +++ b/app/globals.css @@ -377,6 +377,19 @@ button:disabled { transition: border-color 180ms ease, background-color 180ms ease, transform 180ms ease; } +.voice-clone-panel { + border: 1px solid rgba(0, 209, 230, 0.3); + border-radius: 0.85rem; + background: rgba(0, 209, 230, 0.05); + padding: 1rem; + transition: border-color 180ms ease, background-color 180ms ease; +} + +.voice-clone-panel.is-ready { + border-color: rgba(0, 209, 230, 0.7); + background: rgba(0, 209, 230, 0.09); +} + .assist-card:hover { border-color: rgba(0, 209, 230, 0.65); background: rgba(0, 209, 230, 0.06); diff --git a/app/tts-clone/route.ts b/app/tts-clone/route.ts new file mode 100644 index 0000000..c0fb326 --- /dev/null +++ b/app/tts-clone/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +const formatMime: Record = { + wav: "audio/wav", + mp3: "audio/mpeg", + flac: "audio/flac", + opus: "audio/opus", + aac: "audio/aac", + pcm: "application/octet-stream", +}; + +// 真人声音配音(声音克隆): +// 前端把「文本 + 客户声音样本(data URL)」发到这里,服务端再调用配置的声音克隆引擎,返回音频流。 +// 生产环境需在服务端配置 TTS_CLONE_URL 指向 TTS 引擎的克隆接口; +// 未配置时返回 501,前端会提示「真人配音引擎尚未接通,请先选择内置音色」。 +export async function POST(request: Request) { + let body: { text?: unknown; reference_audio?: unknown; response_format?: unknown; speed?: unknown }; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: { message: "请求体不是合法 JSON" } }, { status: 400 }); + } + + const text = typeof body.text === "string" ? body.text.trim() : ""; + const referenceAudio = typeof body.reference_audio === "string" ? body.reference_audio : ""; + const format = typeof body.response_format === "string" && body.response_format ? body.response_format : "wav"; + const speed = Number(body.speed) || 1; + + if (!text) return NextResponse.json({ error: { message: "请输入需要合成的文本" } }, { status: 400 }); + if (!referenceAudio.startsWith("data:audio/")) { + return NextResponse.json({ error: { message: "请先录制或上传声音样本" } }, { status: 400 }); + } + if (referenceAudio.length > 12 * 1024 * 1024) { + return NextResponse.json({ error: { message: "声音样本过大,请控制在 8 MB 以内" } }, { status: 400 }); + } + + const cloneUrl = process.env.TTS_CLONE_URL; + if (!cloneUrl) { + return NextResponse.json( + { error: { message: "真人配音引擎尚未接通,请先选择内置音色" } }, + { status: 501 }, + ); + } + + try { + const upstream = await fetch(cloneUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input: text, reference_audio: referenceAudio, response_format: format, speed }), + }); + if (!upstream.ok) { + const errBody = (await upstream.json().catch(() => ({}))) as { error?: { message?: string } }; + throw new Error(errBody?.error?.message ?? `配音引擎返回 ${upstream.status}`); + } + const audio = Buffer.from(await upstream.arrayBuffer()); + return new NextResponse(audio, { + headers: { "Content-Type": formatMime[format] ?? "application/octet-stream" }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "真人语音合成失败"; + return NextResponse.json({ error: { message } }, { status: 502 }); + } +} diff --git a/app/tts/page.tsx b/app/tts/page.tsx index 3730655..c68ad51 100644 --- a/app/tts/page.tsx +++ b/app/tts/page.tsx @@ -121,8 +121,12 @@ export default function TtsPage() { 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(null); const fileInputRef = useRef(null); + const recorderRef = useRef(null); + const voiceFileRef = useRef(null); useEffect(() => { let cancelled = false; @@ -367,19 +371,33 @@ export default function TtsPage() { async function createSpeech() { setError(""); if (!text.trim()) return setError("请输入需要合成的文本"); - if (!voiceId) return setError("请选择一个音色"); + if (!myVoice && !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})`); + 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 blob = await response.blob(); const url = URL.createObjectURL(blob); setResult({ url, format, fileName: `kaotings-tts-${new Date().toISOString().replace(/[:.]/g, "-")}.${format}` }); } catch (speechError) { @@ -389,6 +407,57 @@ export default function TtsPage() { } } + 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,建议 10–60 秒清晰人声"); + 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/")) { @@ -512,6 +581,25 @@ export default function TtsPage() { void handleOcr(event.target.files?.[0])} ref={fileInputRef} type="file" /> {helperMessage ?

{helperMessage}

: null} +
+
+
+

真人声音配音模式

+

录制自己的声音或上传声音样本;就绪后在上方编辑区输入文案,点击「生成语音」即可用自己的声音朗读。

+
+
+ {recording ? : } + + {myVoice ? : null} +
+
+ {myVoice ?
+ ✓ {myVoice.name} 已就绪,生成时将使用此声音 +
: null} + {recording ?

● 正在录音…请朗读 10–60 秒清晰人声,完成后点击「停止录音」

: null} + { handleVoiceFile(event.target.files?.[0]); event.target.value = ""; }} ref={voiceFileRef} type="file" /> +