Compare commits

..

1 Commits

Author SHA1 Message Date
631fce55b3 docs: 更新 README 至 V1 全阶段完成并上线的实际状态
- 修正过时的“TTS/CMS 未接入”描述,补充 Phase 0-5 状态表
- 新增架构拓扑、目录结构、路由速查与本地开发命令
- 补全文档索引(Phase 5 报告、生产部署记录、部署/运维/验收等)
2026-09-17 20:08:04 +08:00
13 changed files with 215 additions and 1249 deletions

View File

@ -1,9 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

View File

@ -1 +0,0 @@
@AGENTS.md

122
README.md
View File

@ -1,22 +1,104 @@
# 考町科技官网暨 AI 工具平台
当前阶段Phase 2 用户认证与权益基础已完成TTS 生成和 CMS 尚未接入。
- [产品与技术规划(含分阶段工作清单)](docs/V1_PLAN.md)
- [Word 规划书](docs/考町科技官网暨AI工具平台_V1.1.docx)
- [局域网测试环境](docs/TEST_ENVIRONMENT.md)
- [Phase 0 现状审计](docs/AUDIT.md)
# 考町科技官网暨 AI 工具平台
考町科技KAOTINGS公司官网 + AI 工具平台 V1。公司以蓝牙周边、音频及智能硬件为主业官网承担品牌展示、产品介绍和客户联系AI Lab 首个工具为文本转语音TTS提供从注册登录到生成、保存、回放、下载的完整闭环。
## 当前状态
**V1 全部 5 个阶段已完成并上线生产**(域名 `www.kaotings.com`,测试/生产服务器 `192.168.199.22`,发布目录 `/home/flym/releases/`,发布与回滚记录见 `docs/production-deployment-*.md`)。
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| Phase 0 | 现状审计(`docs/AUDIT.md`、`docs/DECISIONS.md` | ✅ 完成 |
| Phase 1 | 设计系统与官网骨架首页、产品、关于、联系、TTS/账户界面) | ✅ 完成 |
| Phase 2 | 用户认证与权益基础注册、登录、会话、Role/Plan、额度结构、验证抽象 | ✅ 完成 |
| Phase 3 | TTS 产品闭环(任务持久化、额度冻结/结算、Worker、私有音频、历史回放下载 | ✅ 完成 |
| Phase 4 | CMSPayload与业务管理后台用户/VIP/额度/TTS/审计) | ✅ 完成 |
| Phase 5 | 集成验收、异常矩阵、备份恢复、部署与回滚说明 | ✅ 完成 |
已上线能力要点:
- 注册强制邮箱验证(腾讯企业邮 SMTP图形验证码防爆破短信验证阿里云号码认证 PNVS已实现、当前未启用配置见 `docs/sms-verification.md`
- TTS 上游地址与 API Key 支持管理后台配置DB 优先于 env保存前探测任务采用幂等键 + 租约 + 超时恢复,结算保证额度不变式。
- CMS 独立库独立管理员,产品 draft/published/unpublished 状态流,官网产品列表经 `CMS_PUBLIC_API_URL` 拉取已发布内容(未配置时回退本地占位数据,仅限开发)。
- 已知阻断项:上游 TTS 服务仍走公网 HTTP`docs/phase-5-report.md` §4需上游提供 HTTPS/来源限制后解除。
## 架构
```text
浏览器
└─ CaddyHTTPS :443:80 跳转)
├─ /api/* → FastAPI 业务 API127.0.0.1:8000
│ ├─ PostgreSQL 业务库 kaotings用户/会话/额度/TTS 任务)
│ ├─ 进程内 asyncio Worker → 内部 TTS API → 私有音频目录
│ └─ SMTP邮箱验证码/ 阿里云 PNVS短信未启用
├─ /cms/* → Payload CMS127.0.0.1:3001独立库 kaotings_cms
└─ 其余 → Next.js 官网127.0.0.1:3000
```
- 前端Next.js 16 + React 19 + Tailwind CSS 4 + TypeScriptApp Router
- 业务 APIFastAPI + psycopg 3 + Argon2id + httpx`services/api/app/main.py`
- 内容管理Payload CMS`apps/cms`
- 部署Caddy 反代 + 3 个 systemd 服务(`kaotings-web` / `kaotings-cms` / `kaotings-api`+ 发布目录软链切换,见 `infra/`
## 目录结构
```text
app/ 官网页面(/、/products、/tts、/login、/register、/account、/admin/*
components/ 共享组件导航、页脚、认证表单、账户面板、admin/* 管理界面)
lib/ 内容适配器CMS 拉取/本地回退)、管理端 API 客户端
services/api/ FastAPI 业务 APIapp/ 代码、migrations/ 迁移、tests/ 故障注入测试)
apps/cms/ Payload CMS 应用collections/ 内容模型、migrations/
infra/ Caddy 配置、systemd 单元、运维脚本(备份、告警、上游探测)
assets/brand/ 正式品牌素材(使用规则见 docs/ASSETS.md
docs/ 规划、审计、契约、阶段报告、部署与运维文档
```
## 路由
| 路由 | 页面 | 访问要求 |
| --- | --- | --- |
| `/`、`/products`、`/products/[slug]`、`/about`、`/contact` | 官网公开页 | 公开 |
| `/tts` | TTS 工作台(生成/历史/回放/下载) | 浏览公开,生成与历史须登录 |
| `/login`、`/register`、`/account` | 认证与账户中心 | 未登录 / 当前用户 |
| `/admin`、`/admin/users`、`/admin/tts` | 业务管理后台 | role=admin |
| `/cms/admin` | CMS 内容管理 | 受控 CMS 管理员 |
## 本地开发
```bash
# 官网
npm install
npm run dev # http://localhost:3000
npm run typecheck
npm run build # 生产构建;生产环境需设 CMS_PUBLIC_API_URL
# 业务 API
cd services/api
pip install -r requirements.txt
cp .env.example .env # 配置 DATABASE_URL、CSRF_SECRET 等
python -m app.migrate # 执行 migrations/
# 受控初始化管理员(凭据只从进程环境变量注入,不覆盖已存在账户):
# ADMIN_EMAIL=... ADMIN_PASSWORD=... python -m app.init_admin
uvicorn app.main:app --host 127.0.0.1 --port 8000
# CMS独立应用默认端口 3001
cd apps/cms
npm install
npm run dev
```
生产部署、发布/回滚流程见 `docs/DEPLOYMENT.md`,音频/数据库备份恢复与告警见 `docs/OPERATIONS.md`
## 文档索引
- [产品与技术规划 V1.1(含分阶段工作清单)](docs/V1_PLAN.md)
- [Word 规划书](docs/考町科技官网暨AI工具平台_V1.1.docx)
- [Phase 0 现状审计](docs/AUDIT.md) · [技术决策](docs/DECISIONS.md) · [后续执行计划](docs/EXECUTION_PLAN.md)
- [TTS 接口契约](docs/API_CONTRACT.md)
- [技术决策](docs/DECISIONS.md)
- [后续执行计划](docs/EXECUTION_PLAN.md)
- [Phase 1 阶段报告](docs/phase-1-report.md)
- [Phase 2 阶段报告](docs/phase-2-report.md)
- [Phase 3 阶段报告](docs/phase-3-report.md)
- [Phase 4 CMS 阶段报告](docs/phase-4-report.md)
- [阶段报告](docs/phase-1-report.md)Phase 1 · [Phase 2](docs/phase-2-report.md) · [Phase 3](docs/phase-3-report.md) · [Phase 4 CMS 与管理](docs/phase-4-report.md) · [Phase 5 集成与上线准备](docs/phase-5-report.md)
- [生产部署记录](docs/production-deployment-2026-09-12.md)09-10 / 09-11 / 09-12 共三份)
- [部署说明](docs/DEPLOYMENT.md) · [运维方案](docs/OPERATIONS.md) · [验收标准](docs/ACCEPTANCE.md)
- [短信验证配置](docs/sms-verification.md) · [CMS 管理员恢复](docs/cms-admin-recovery.md)
- [TTS 上游资料请求清单](docs/TTS_UPSTREAM_REQUEST.md)
项目根目录:`E:\RD\Kaotings\www_site`,以本次新仓库为准。
实施从规划书 Phase 0 开始,先核实工程和测试环境,再按阶段推进。
- [品牌素材与设计参考清单](docs/ASSETS.md)
- [局域网测试环境](docs/TEST_ENVIRONMENT.md)
- [品牌素材与设计参考清单](docs/ASSETS.md)

View File

@ -265,174 +265,6 @@ 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;
}
.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);
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);

View File

@ -1,65 +0,0 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
const formatMime: Record<string, string> = {
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 });
}
}

View File

@ -1,627 +1,158 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { ArrowRight, WaveIcon } from "@/components/icons";
import { BrandIcon } from "@/components/brand-icon";
import { SectionHeading, StatusCard } from "@/components/ui";
import { PreviewNotice, 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: "已经" },
];
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
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 = ["关闭", "弱", "标准", "强"];
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 };
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 [csrf, setCsrf] = useState("");
const [loggedIn, setLoggedIn] = useState(false);
const [voices, setVoices] = useState<Voice[]>([]);
const [categories, setCategories] = useState<string[]>([]);
const [facets, setFacets] = useState<Record<string, string[]>>({});
const [usage, setUsage] = useState<Usage | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
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 [voice, setVoice] = useState("");
const [speed, setSpeed] = useState("1");
const [format, setFormat] = useState("wav");
const [result, setResult] = useState<Result | null>(null);
const [loading, setLoading] = useState(true);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [renaming, setRenaming] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [renameDraft, setRenameDraft] = useState("");
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 之间的整数`);
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);
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("已按句号、问号和感叹号完成自动分段");
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]);
}
}
function handleEditorInput() {
if (!textareaRef.current) return;
setText(normalizeText(serializeEditor(textareaRef.current)));
setSelectedPause(null);
setCorrectionMessage("");
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 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() {
async function createTask() {
setError("");
if (!text.trim()) return setError("请输入需要合成的文本");
if (!myVoice && !voiceId) return setError("请选择一个音色");
if (!loggedIn) return;
if (!voice) return setError("当前没有可用音色,请先配置真实上游音色");
if (!text.trim()) 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 : "语音合成失败");
const response = await fetch(`${apiBase}/tts/tasks`, {
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) } }),
});
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 : "任务失败");
} finally {
setBusy(false);
}
}
async function startRecording() {
if (recording) return;
async function renameTask(taskId: string, rawName: string) {
const draft = rawName.trim();
if (renaming) return;
setRenaming(true);
setError("");
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",
const response = await fetch(`${apiBase}/tts/tasks/${taskId}/file-name`, {
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_data_url: imageDataUrl, filename: file.name }),
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf },
body: JSON.stringify({ file_name: draft }),
});
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 : "图片识别失败,请稍后重试");
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 : "重命名失败");
} finally {
setHelperBusy(false);
if (fileInputRef.current) fileInputRef.current.value = "";
setRenaming(false);
}
}
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 className="flex flex-col justify-between gap-8 lg:flex-row lg:items-end"><SectionHeading eyebrow="AI Lab / Text to Speech" title="把文字变成声音。" description="TTS 任务现在由业务 API 持久化并归属当前用户;生成、历史、回放和下载使用真实服务状态。" tone="cyan" /><span className="rounded-full border border-cyan/30 bg-cyan/8 px-3 py-1 text-xs font-semibold text-cyan">Phase 3</span></div>
<div className="mt-10"><PreviewNotice>{loggedIn ? "当前使用测试数据库和真实任务 Worker。上游未配置时任务会明确失败并释放额度。" : "生成、历史、回放和下载需要登录。"}</PreviewNotice></div>
{!loggedIn ? <div className="mt-8"><StatusCard title="请先登录" description="登录后可使用真实任务、额度和历史功能。"><Link className="button-primary mt-5" href="/login"><BrandIcon /> <ArrowRight /></Link></StatusCard></div> : <>
<div className="mt-8 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">{normalizeText(text).length} / </span></div><textarea className="field-input mt-6 min-h-64 resize-y leading-7" onChange={(event) => setText(event.target.value)} placeholder="输入需要转换的文本。" value={text} /><div className="mt-5 flex flex-col gap-4 border-t border-line pt-5 sm:flex-row sm:items-center sm:justify-between"><p className="text-sm text-muted">{usage?.available.toLocaleString() ?? "加载中"}</p><button className="button-primary" disabled={busy || !csrf} onClick={createTask} type="button"><BrandIcon />{busy ? "生成中…" : "生成语音"} <ArrowRight /></button></div></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 / 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"></label><select className="field-input" id="voice" onChange={(event) => setVoice(event.target.value)} value={voice}><option value=""></option>{voices.map((item) => <option key={item.id} value={item.provider_voice_id}>{item.name}{item.language ? ` · ${item.language}` : ""}</option>)}</select></div><div><span className="field-label"></span><div className="field-input text-muted">1.0xV1 </div></div><div><span className="field-label"></span><div className="field-input text-muted">WAVV1 </div></div></div></section><StatusCard title="额度" description={`${usage?.plan?.toUpperCase() ?? ""} · 已用 ${usage?.used.toLocaleString() ?? "-"} · 冻结 ${usage?.reserved.toLocaleString() ?? "-"}`} tone="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}
{activeTask?.status === "succeeded" ? <section className="mt-8 rounded-2xl border border-success/30 bg-success/5 p-5 sm:p-7"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-success">03 / Result</p><h2 className="mt-2 text-xl font-semibold"></h2><p className="mt-2 break-all text-sm text-muted">{activeTask.file_name ?? "音频已就绪"}</p></div><div className="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center"><div className="min-w-0 flex-1"><audio className="w-full" controls src={`${apiBase}/tts/tasks/${activeTask.id}/audio`} /></div><div className="flex shrink-0 gap-3"><a className="button-secondary" download href={`${apiBase}/tts/tasks/${activeTask.id}/download`}><BrandIcon /></a><button className="button-ghost" onClick={() => { setRenameDraft(activeTask.file_name ?? ""); setRenameOpen(true); }} type="button"><BrandIcon /></button></div></div></section> : null}
<section className="mt-8 rounded-2xl border border-line bg-panel/40 p-5 sm:p-7" aria-labelledby="tts-history-title"><div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center"><div><p className="text-xs font-semibold uppercase tracking-[0.18em] text-subtle">04 / History</p><h2 className="mt-2 text-xl font-semibold" id="tts-history-title"></h2></div><span className="text-sm text-muted">{tasks.length} </span></div>{tasks.length ? <div className="mt-6 divide-y divide-line">{tasks.map((task) => <div className="flex flex-col gap-3 py-4 sm:flex-row sm:items-center sm:justify-between" key={task.id}><div><p className="font-semibold text-copy">{task.status === "succeeded" ? "生成完成" : task.status === "failed" ? "生成失败" : "处理中"}</p><p className="mt-1 break-all text-sm text-muted">{task.status === "succeeded" ? (task.file_name ?? "音频已就绪") : `${task.voice_id} · ${task.text_length}`}</p><p className="mt-0.5 text-xs text-subtle">{new Date(task.created_at).toLocaleString()}</p></div>{task.audio_available ? <div className="flex gap-3"><a className="text-sm text-cyan hover:text-copy" href={`${apiBase}/tts/tasks/${task.id}/audio`}></a><a className="text-sm text-cyan hover:text-copy" download href={`${apiBase}/tts/tasks/${task.id}/download`}></a></div> : null}</div>)}</div> : <div className="mt-6 flex flex-col items-center justify-center rounded-xl border border-dashed border-line py-14 text-center"><span className="text-cyan"><WaveIcon className="h-9 w-9" /></span><p className="mt-4 font-semibold"></p><p className="mt-2 max-w-sm text-sm leading-6 text-muted">访</p></div>}</section>
</>}
{renameOpen && activeTask ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" role="dialog" aria-modal="true" aria-label="重命名文件">
<div className="w-full max-w-md rounded-2xl border border-line bg-panel p-6">
<h3 className="text-lg font-semibold"></h3>
<p className="mt-1 text-sm text-muted">使</p>
<input
autoFocus
className="field-input mt-4"
maxLength={60}
placeholder="输入新文件名(不含扩展名)"
value={renameDraft}
onChange={(event) => setRenameDraft(event.target.value)}
onKeyDown={(event) => { if (event.key === "Enter") void renameTask(activeTask.id, renameDraft); }}
/>
<div className="mt-5 flex justify-end gap-3">
<button className="button-ghost" onClick={() => setRenameOpen(false)} type="button"><BrandIcon /></button>
<button className="button-primary" disabled={renaming || !csrf} onClick={() => void renameTask(activeTask.id, renameDraft)} type="button"><BrandIcon />{renaming ? "保存中…" : "确认"}</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>
) : null}
</div>;
}

View File

@ -1,72 +0,0 @@
# 改动记录CHANGELOG
本文件按时间**倒序**记录每次改动的**位置**与**内容**,中文注释,便于后续接手的同学快速定位。
---
## 2026-09-18TTS 页面调整 + 真人声音配音模式 + 推送凭据修复
**分支**`feat/chip-resources`
**最新提交**`74110fb`(已推送)
**线上状态**:已发布到 `www.kaotings.com`,服务器 `45.197.145.138` 上的 release 为 `~/releases/74110fb-www`systemd 服务 `kaotings-web`Caddy 反代 `127.0.0.1:3000`
### 1. 删除「减负区」标题与说明文字(提交 `b4c3a99`
- **文件**`app/tts/page.tsx`
- **位置**:原输入卡片下方独立区块的头部
- **删除内容**`03 / Assist`、`减负区`、`把识别、校对和分段交给系统,客户只需要确认内容和听结果。`
- **连带处理**:移除该区块上已失效的 `aria-labelledby="assist-title"`(全站无其它引用)
### 2. 三个功能卡移到「音色清单」文字下方(提交 `d79c6cb`、`12eef4b`
- **文件**`app/tts/page.tsx`(现约 576582 行)
- **改动**:把「图片转文字 / 生成前查错字 / 自动分段」三张 `assist-card` 从页面底部独立区块移入「01 / Input 输入文本」卡片内部,放在 `音色清单已更新 · N 个音色` 这行文字**下方**,用 `md:grid-cols-3` 三列对齐
- **功能保全**:隐藏的图片选择 `input``fileInputRef`)与 OCR 提示 `helperMessage` 一并迁入,行为不变
- **删除**:原底部独立区块(移走卡片后只剩「少打字 · 少校对 · 少返工」徽标,已随区块删除)
- **说明**`d79c6cb` 先放到编辑器下方,`12eef4b` 按反馈修正到「音色清单」下方,最终位置以后者为准
### 3. 新增「真人声音配音模式」(提交 `14bc759`
**前端 `app/tts/page.tsx`**
- 新增状态:`myVoice`(声音样本 `{ name, dataUrl }`)、`recording`(是否正在录音)
- 新增 ref`recorderRef`MediaRecorder 实例)、`voiceFileRef`(音频文件选择)
- 新增函数(约 405470 行):
- `startRecording()``navigator.mediaDevices.getUserMedia` + `MediaRecorder` 录音,停止后转成 data URL 存入 `myVoice`
- `stopRecording()`:停止录音
- `handleVoiceFile()`:上传音频文件(类型校验 + 8 MB 上限)
- `clearMyVoice()`:清除样本,回到内置音色
- `createSpeech()` 改为**双通道**:有 `myVoice` → 调 `/tts-clone`;否则走原 `/tts/v1/audio/speech`
- 面板 JSX约 584600 行):位于三张功能卡下方,包含 `🎙 真人声音配音模式` 标题、使用说明、`● 录制我的声音 / ■ 停止录音`、`上传声音`、`清除样本`、样本试听条与「已就绪」状态
**服务端 `app/tts-clone/route.ts`(新增文件)**
- `POST /tts-clone`,入参 `{ text, reference_audio(data URL), response_format, speed }`
- 读取服务端环境变量 `TTS_CLONE_URL` 转发给克隆引擎;**未配置时返回 501**(前端提示「真人配音引擎尚未接通,请先选择内置音色」,不假装成功);上游失败返回 502
- **路径选择原因**:线上 Caddy 中 `/api/*` 转发到 FastAPI:8000、`/tts/*` 转发到 TTS 引擎,只有 `/tts-clone` 这类路径会落到 Next.js
**样式 `app/globals.css`**
- 新增 `.voice-clone-panel`380 行)与 `.voice-clone-panel.is-ready`388 行):青色描边面板,样本就绪后高亮
**本地联调 `services/mock-tts/server.mjs`**
- 新增 `POST /v1/audio/clone-speech`174 行):校验文本与 `reference_audio` 后返回演示音频
- 配合**未提交**的 `.env.local``TTS_CLONE_URL=http://127.0.0.1:8100/v1/audio/clone-speech`,本地可跑通「录音/上传 → 生成」全流程
**待后续程序员跟进**
- 实测上游 TTS 引擎(`qwen3-tts`129 个固定音色)**目前没有声音克隆接口**:无音色注册端点,未知参数会被静默忽略
- 引擎方提供克隆接口后,只需在服务器为 `kaotings-web` 增加环境变量 `TTS_CLONE_URL`(指向克隆接口)即可启用,**前端零改动**
### 4. 推送凭据问题修复(提交 `438a4d7`、`cee2074`
- **文件**`docs/OPERATIONS.md` §8
- **内容**Gitea 的 OAuth access token 仅 1 小时有效,而 Git Credential Manager 无法感知过期 → 闲置超 1 小时后**第一次推送必失败**`remote: Verify` / `Authentication failed`
- **修复**:新增凭据助手 `C:/Users/kts/.git-credential-kaotings.mjs`,每次用 refresh token 换新 access token全局配置中按「空值重置 + 助手 + manager」排序详见该章节
### 5. 本地配置纳入版本管理(提交 `74110fb`
- `next.config.ts`:新增 `rewrites()`**仅当**设置了 `TTS_MOCK_UPSTREAM` 时把 `/tts/v1/*` 代理到本地 mock TTS生产不设置该变量行为不变
- `AGENTS.md` / `CLAUDE.md`Next.js 16 `next dev` 自动生成的 AI 说明文件
- `next-env.d.ts`:自动生成文件,保持生产路径(`.next/types/...`),未提交 dev 路径的临时改动

View File

@ -96,55 +96,3 @@
- **上游公网 HTTP**:上线阻断,待加密 + 来源限制 + 外部复测(`DEPLOYMENT.md` §4
- **音频容量/保留/清理参数**待用户确认§5
- **备份失败通知渠道 / 是否降权**待用户确认§6
## 8. Git 推送凭据Gitea OAuth token 1 小时过期导致「首次推送必失败」(已修复)
### 8.1 现象
推送时第一次总是失败,重试第二次才成功:
```
remote: Verify
fatal: Authentication failed for 'http://rand.team:44000/Kaotings/www_site.git/'
```
### 8.2 根因(上游 Gitea token 有效期 + GCM 缓存)
- 上游 Gitea`rand.team:44000`)签发的 OAuth token 是一对 JWT解码 payload 可见:
- access token`{"gnt":2,"tt":0,"exp":iat+3600}` → **仅 1 小时有效**
- refresh token`{"gnt":2,"tt":1,"exp":iat+2628000}` → 30 天有效(每次刷新会轮换)
- Git Credential Manager 把 access token 缓存进 Windows 凭据管理器,而它对 generic OAuth 主机**无法感知 token 过期时间**,于是闲置超过 1 小时后:
1. 第一次推送 → GCM 返回已过期的 access token → 服务器拒绝(`remote: Verify`
2. GCM 删除该缓存凭据;
3. 第二次推送 → 缓存已空 → GCM 用 refresh token 静默换新 token → 成功。
不是服务器故障也不是密码错误而是「1 小时 token + 无过期感知的缓存」。
### 8.3 解决方式(已在本机实施)
新增凭据助手 `C:/Users/kts/.git-credential-kaotings.mjs`,改为**每次都用 refresh token 换取新 access token**,并在本地记录过期时间提前 5 分钟刷新,从而第一次推送即成功。
关键配置(**顺序很重要**git 的 helper 按 `系统 → 全局 → 仓库` 顺序追加,空值 `helper =` 会清空此前累积的列表。系统级 `C:/Program Files/Git/etc/gitconfig` 中存在 `helper = manager`(修改它需管理员权限),因此必须在全局配置 `C:/Cadence/SPB_Data/.gitconfig` 里先重置再排序:
```
[credential]
helper =
helper = !node C:/Users/kts/.git-credential-kaotings.mjs
helper = manager
```
- 助手只响应 `host=rand.team:44000`,其它主机静默退出并继续交给 GCMGitHub / Azure DevOps 等不受影响)。取用凭据失败时(如令牌被撤销、服务器不可达)同样静默退出,交由 GCM 走浏览器重新授权,不会阻断推送流程。
- 长期 refresh token **只存于 Windows 凭据管理器**DPAPI 保护);本地缓存 `~/.kaotings-git-token.json` 只存 1 小时的 access token刷新时轮换得到的新 refresh token 自动回写凭据管理器。
- 手动兜底(不依赖上述配置,例如换机器时):
```
git -c credential.helper= -c credential.helper="!node C:/Users/kts/.git-credential-kaotings.mjs" push origin <branch>
```
- 换新机器/新环境:仍需先走一次浏览器 OAuthGCM拿到 refresh token 后本助手才有凭据可用。
### 8.4 验证记录
- 手动把缓存 access token 置为已过期后,单次 `git push` 直接成功(日志显示助手先执行并刷 token无 401
- 非目标主机(如 `example.com`)调用助手无输出、正常放行给 GCM。
- 轮换后 refresh token `iat` 更新且已写入凭据管理器。

View File

@ -2,13 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
async rewrites() {
// 设置 TTS_MOCK_UPSTREAM见 .env.local把 AI Lab 页面的 /tts/v1/* 请求代理到本地 mock TTS 服务。
// 生产环境不设置该变量,走 NEXT_PUBLIC_TTS_BASE_URL 指向真实引擎,行为不变。
const target = process.env.TTS_MOCK_UPSTREAM;
if (!target) return [];
return [{ source: "/tts/v1/:path*", destination: `${target}/v1/:path*` }];
},
};
export default nextConfig;

View File

@ -24,7 +24,6 @@ class Settings:
tts_upstream_url: str
tts_api_key: str
tts_timeout_seconds: int
vision_model: str
audio_storage_dir: str
audio_retention_seconds: int
smtp_host: str
@ -59,7 +58,6 @@ class Settings:
tts_upstream_url=os.getenv("TTS_UPSTREAM_URL", "").rstrip("/"),
tts_api_key=os.getenv("TTS_API_KEY", ""),
tts_timeout_seconds=int(os.getenv("TTS_TIMEOUT_SECONDS", "120")),
vision_model=os.getenv("VISION_MODEL", "Qwen3-VL-30B"),
audio_storage_dir=os.getenv("AUDIO_STORAGE_DIR", "./data/audio"),
audio_retention_seconds=int(os.getenv("AUDIO_RETENTION_SECONDS", "604800")),
smtp_host=os.getenv("SMTP_HOST", ""),

View File

@ -1,6 +1,4 @@
import asyncio
import base64
import binascii
import hashlib
import json
import re
@ -31,7 +29,6 @@ from .schemas import (
LoginRequest,
MembershipRequest,
MembershipRevokeRequest,
OcrRequest,
PasswordChangeRequest,
QuotaAdjustmentRequest,
RegisterRequest,
@ -438,74 +435,6 @@ def health(connection: Connection = Depends(get_connection)):
return {"status": "ok", "database": "ok", "service": "api"}
def extract_vision_text(body: dict[str, Any]) -> str:
choices = body.get("choices")
if not isinstance(choices, list) or not choices:
return ""
message = choices[0].get("message") if isinstance(choices[0], dict) else None
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
return "".join(str(item.get("text", "")) for item in content if isinstance(item, dict)).strip()
return ""
@app.post("/api/v1/ocr")
async def ocr(payload: OcrRequest, request: Request, user: dict = Depends(current_user)):
check_origin(request)
if not rate_limiter.allow(f"ocr:{user['id']}", 10, 300):
raise error("RATE_LIMITED", "图片识别请求过于频繁,请稍后重试", 429)
header, separator, encoded = payload.image_data_url.partition(",")
if separator != "," or not header.startswith("data:image/") or ";base64" not in header:
raise error("OCR_INVALID_IMAGE", "请上传有效的图片文件", 422)
try:
image = base64.b64decode(encoded, validate=True)
except (ValueError, binascii.Error):
raise error("OCR_INVALID_IMAGE", "图片数据无效,请重新上传", 422)
if not image or len(image) > 12 * 1024 * 1024:
raise error("OCR_IMAGE_TOO_LARGE", "图片不能超过 12 MB", 422)
cfg = await asyncio.to_thread(read_tts_config)
if not cfg["upstream_url"]:
raise error("UPSTREAM_NOT_CONFIGURED", "视觉模型上游尚未配置", 503)
headers = {"Content-Type": "application/json"}
if cfg["api_key"]:
headers["Authorization"] = f"Bearer {cfg['api_key']}"
upstream_payload = {
"model": settings.vision_model,
"temperature": 0,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "请识别图片中的全部文字。保持原有阅读顺序和段落换行,只返回识别出的文字,不要解释,不要添加 Markdown。"},
{"type": "image_url", "image_url": {"url": payload.image_data_url}},
],
}],
}
try:
async with httpx.AsyncClient(timeout=cfg["timeout_seconds"]) as client:
response = await client.post(f"{cfg['upstream_url']}/v1/chat/completions", headers=headers, json=upstream_payload)
if response.status_code in {401, 403}:
raise error("UPSTREAM_UNAUTHORIZED", "视觉模型鉴权失败", 502)
if response.status_code >= 400:
raise error("UPSTREAM_VISION_FAILED", "图片识别服务暂时不可用", 502)
try:
result = response.json()
except ValueError:
raise error("UPSTREAM_VISION_INVALID", "图片识别服务返回格式无效", 502)
text = extract_vision_text(result)
if not text:
raise error("OCR_EMPTY_RESULT", "没有识别到可用文字", 422)
return {"text": text, "model": settings.vision_model}
except httpx.TimeoutException:
raise error("UPSTREAM_TIMEOUT", "图片识别超时,请稍后重试", 504)
except HTTPException:
raise
except httpx.HTTPError:
raise error("UPSTREAM_UNREACHABLE", "图片识别服务暂时无法连接", 502)
@app.get("/api/v1/auth/csrf")
def csrf(response: Response, request: Request):
check_origin(request)

View File

@ -230,11 +230,6 @@ class TtsTaskRequest(BaseModel):
parameters: dict[str, Any] = Field(default_factory=dict)
class OcrRequest(BaseModel):
image_data_url: str = Field(min_length=32, max_length=16_000_000)
filename: str | None = Field(default=None, max_length=200)
class TtsVoicePublic(BaseModel):
id: UUID
provider_voice_id: str

View File

@ -1,195 +0,0 @@
// 本地演示用 mock TTS 服务:实现 AI Lab 页面依赖的 /v1/audio/* 接口。
// 启动node services/mock-tts/server.mjs配合 .env.local 里 TTS_MOCK_UPSTREAM=http://127.0.0.1:8100
// 真实上游为外部 TTS 引擎,需要服务端凭据;此服务仅用于无引擎环境下演示与联调。
import { createServer } from "node:http";
const PORT = Number(process.env.MOCK_TTS_PORT ?? 8100);
const SAMPLE_RATE = 22050;
const voices = [
{
id: "xiaoman", name: "小满", desc: "温暖亲和的女声,适合品牌宣传与产品介绍。",
category: "通用女声", gender: "女声", genre: "温和", accent: "普通话", style: "亲切",
styles: ["亲切", "自然"], preview: "/v1/previews/xiaoman.wav", range: [240, 400],
},
{
id: "qingluo", name: "青落", desc: "清亮利落的女声,适合新闻播报与知识讲解。",
category: "通用女声", gender: "女声", genre: "专业", accent: "普通话", style: "专业",
styles: ["专业", "清晰"], preview: "/v1/previews/qingluo.wav", range: [260, 430],
},
{
id: "tianxin", name: "甜芯", desc: "甜美活泼的女声,适合促销活动与社媒内容。",
category: "通用女声", gender: "女声", genre: "甜美", accent: "台湾腔", style: "活泼",
styles: ["活泼", "甜美"], preview: "/v1/previews/tianxin.wav", range: [280, 460],
},
{
id: "mufan", name: "沐帆", desc: "低沉沉稳的男声,适合纪录片旁白与高端品牌。",
category: "通用男声", gender: "男声", genre: "磁性", accent: "普通话", style: "沉稳",
styles: ["沉稳", "磁性"], preview: "/v1/previews/mufan.wav", range: [110, 200],
},
{
id: "haoran", name: "浩然", desc: "爽朗有力的男声,适合广告口号与活动主持。",
category: "通用男声", gender: "男声", genre: "活力", accent: "普通话", style: "有力",
styles: ["有力", "活力"], preview: "/v1/previews/haoran.wav", range: [130, 230],
},
{
id: "awan", name: "阿万", desc: "带粤语口音的男声,适合大湾区本地化内容。",
category: "通用男声", gender: "男声", genre: "温和", accent: "粤语", style: "自然",
styles: ["自然"], preview: "/v1/previews/awan.wav", range: [120, 210],
},
{
id: "doudou", name: "豆豆", desc: "元气满满的童声,适合儿歌故事与亲子内容。",
category: "童声", gender: "童声", genre: "活泼", accent: "普通话", style: "可爱",
styles: ["可爱", "活泼"], preview: "/v1/previews/doudou.wav", range: [300, 480],
},
{
id: "dongbei-ge", name: "东北哥", desc: "幽默豪爽的东北男声,适合短视频与段子配音。",
category: "情景演绎", gender: "男声", genre: "活力", accent: "东北腔", style: "幽默",
styles: ["幽默", "豪爽"], preview: "/v1/previews/dongbei-ge.wav", range: [115, 205],
},
];
const facets = {
gender: [...new Set(voices.map((v) => v.gender))],
genre: [...new Set(voices.map((v) => v.genre))],
accent: [...new Set(voices.map((v) => v.accent))],
style: [...new Set(voices.map((v) => v.style))],
};
function json(res, status, body) {
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" });
res.end(JSON.stringify(body));
}
// 极简 WAV 合成:按字符生成短音,<pause:ms> 生成静音。仅作演示,非真实语音。
function synthesizeWav(text, voiceId, speed) {
const voice = voices.find((v) => v.id === voiceId) ?? voices[0];
const rate = Math.min(4, Math.max(0.25, Number(speed) || 1));
const segments = [];
for (const part of String(text ?? "").split(/(<pause:\d+>)/)) {
const pause = part.match(/^<pause:(\d+)>$/);
if (pause) segments.push({ pauseMs: Math.min(10000, Math.max(50, Number(pause[1]))) });
else if (part) segments.push({ chars: [...part] });
}
const samples = [];
for (const segment of segments) {
if (segment.pauseMs) {
const silence = Math.floor((SAMPLE_RATE * segment.pauseMs) / 1000 / rate);
samples.push(...new Array(silence).fill(0));
continue;
}
for (const [index, ch] of segment.chars.entries()) {
if (/\s/.test(ch)) {
samples.push(...new Array(Math.floor(SAMPLE_RATE * 0.06 / rate)).fill(0));
continue;
}
// 以字符与音色做种子生成 160320Hz 的基频,让不同文本/音色听感有区分
const seed = (ch.codePointAt(0) * 31 + voice.id.charCodeAt(0) * 7) % 1000 / 1000;
const freq = voice.range[0] + seed * (voice.range[1] - voice.range[0]);
const duration = 0.13 + ((index % 3) * 0.02);
const count = Math.floor((SAMPLE_RATE * duration) / rate);
for (let i = 0; i < count; i += 1) {
const t = i / SAMPLE_RATE;
const envelope = Math.min(1, i / (count * 0.15), (count - i) / (count * 0.25));
const value = Math.sin(2 * Math.PI * freq * t) * 0.55 + Math.sin(2 * Math.PI * freq * 2 * t) * 0.18;
samples.push(Math.round(Math.max(-1, Math.min(1, value * envelope)) * 32000));
}
samples.push(...new Array(Math.floor(SAMPLE_RATE * 0.025 / rate)).fill(0));
}
}
const capped = samples.slice(0, SAMPLE_RATE * 30);
const buffer = Buffer.alloc(44 + capped.length * 2);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(36 + capped.length * 2, 4);
buffer.write("WAVE", 8);
buffer.write("fmt ", 12);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20); // PCM
buffer.writeUInt16LE(1, 22); // mono
buffer.writeUInt32LE(SAMPLE_RATE, 24);
buffer.writeUInt32LE(SAMPLE_RATE * 2, 28);
buffer.writeUInt16LE(2, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(capped.length * 2, 40);
capped.forEach((value, i) => buffer.writeInt16LE(value, 44 + i * 2));
return buffer;
}
function voiceById(id) {
return voices.find((v) => v.id === id);
}
const server = createServer((req, res) => {
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
if (req.method === "OPTIONS") {
res.writeHead(204, {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
});
return res.end();
}
if (req.method === "GET" && url.pathname === "/v1/audio/config") {
return json(res, 200, {
model: "qwen3-tts-mock",
formats: ["wav"],
default_format: "wav",
speed: { min: 0.25, max: 4, default: 1 },
pause: {
supported: true, min_ms: 50, max_ms: 10000,
presets: [{ ms: 500, label: "0.5s" }, { ms: 1000, label: "1s" }, { ms: 2000, label: "2s" }],
},
});
}
if (req.method === "GET" && url.pathname === "/v1/audio/voices") {
return json(res, 200, {
categories: [...new Set(voices.map((v) => v.category))],
facets,
voices: voices.map(({ range, ...voice }) => voice),
});
}
const preview = req.method === "GET" && url.pathname.match(/^\/v1\/previews\/([\w-]+)\.wav$/);
if (preview) {
const voice = voiceById(preview[1]);
if (!voice) return json(res, 404, { error: { message: "音色不存在" } });
const audio = synthesizeWav(`你好,我是${voice.name}`, voice.id, 1);
res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": audio.length, "Access-Control-Allow-Origin": "*" });
return res.end(audio);
}
if (req.method === "POST" && url.pathname === "/v1/audio/speech") {
let raw = "";
req.on("data", (chunk) => { raw += chunk; if (raw.length > 1_000_000) req.destroy(); });
req.on("end", () => {
let body = {};
try { body = JSON.parse(raw || "{}"); } catch { return json(res, 400, { error: { message: "请求体不是合法 JSON" } }); }
if (!voiceById(body.voice)) return json(res, 400, { error: { message: "音色不存在或未提供" } });
if (!String(body.input ?? "").trim()) return json(res, 400, { error: { message: "文本不能为空" } });
const audio = synthesizeWav(body.input, body.voice, body.speed);
res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": audio.length, "Access-Control-Allow-Origin": "*" });
res.end(audio);
});
return;
}
if (req.method === "POST" && url.pathname === "/v1/audio/clone-speech") {
// 真人声音配音 mock校验文本与声音样本后返回演示音频非真实克隆
let raw = "";
req.on("data", (chunk) => { raw += chunk; if (raw.length > 16_000_000) req.destroy(); });
req.on("end", () => {
let body = {};
try { body = JSON.parse(raw || "{}"); } catch { return json(res, 400, { error: { message: "请求体不是合法 JSON" } }); }
if (!String(body.input ?? "").trim()) return json(res, 400, { error: { message: "文本不能为空" } });
if (!String(body.reference_audio ?? "").startsWith("data:audio/")) return json(res, 400, { error: { message: "未提供声音样本 reference_audio" } });
const audio = synthesizeWav(body.input, "xiaoman", body.speed);
res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": audio.length, "Access-Control-Allow-Origin": "*" });
res.end(audio);
});
return;
}
if (req.method === "GET" && url.pathname === "/healthz") return json(res, 200, { status: "ok" });
json(res, 404, { error: { message: `mock-tts 未实现 ${req.method} ${url.pathname}` } });
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`[mock-tts] listening on http://127.0.0.1:${PORT}`);
});