架构入门
应用版本: v0.1.1 (fork: DarrellThomas/en-parlant) 技术栈: Tauri v2 (Rust) + React 19 (TypeScript) + Vite
什么是 Tauri?
Section titled “什么是 Tauri?”Tauri 是一个用于构建桌面应用的框架。与 Electron 打包完整浏览器不同,Tauri 使用操作系统内置的 webview 来渲染界面,并用 Rust 进程处理后端逻辑。最终生成一个体积小、速度快的二进制文件。
两个部分通过 IPC(进程间通信)进行交互:
+---------------------------+ IPC +---------------------------+| Rust Backend | <--------------> | React/TS Frontend || | (commands + | || - Chess engines (UCI) | events) | - Chessboard UI || - SQLite database | | - Analysis panels || - File I/O | | - Settings || - PGN parsing | | - Game tree navigation || - Position search index | | - TTS narration |+---------------------------+ +---------------------------+ src-tauri/src/ src/Rust 端:src-tauri/src/
Section titled “Rust 端:src-tauri/src/”Rust 负责处理所有需要高性能或系统级访问的任务。
入口点:main.rs
Section titled “入口点:main.rs”注册约 50 个前端可调用的命令,初始化插件(文件系统、对话框、HTTP、Shell、日志、更新器),并启动应用窗口。
命令通过宏来定义:
#[tauri::command]async fn get_best_moves(id: String, engine: String, ...) -> Result<...> { // spawn UCI engine, return analysis}specta crate 会根据这些 Rust 函数自动生成 TypeScript 类型定义,因此前端无需任何手动操作即可获得完整的类型安全。
| 模块 | 功能说明 |
|---|---|
db/mod.rs | 通过 Diesel ORM 操作 SQLite 数据库——游戏查询、棋手统计、导入、局面搜索 |
game.rs | 实时对局引擎——管理引擎对人和引擎对引擎的对局、时间控制、走子验证 |
chess.rs | 引擎分析——启动 UCI 引擎,通过事件将最佳走法结果流式传输回前端 |
engine/ | UCI 协议实现——进程启动、stdin/stdout 管道、多 PV 支持 |
pgn.rs | PGN 文件的读写和词法分析 |
opening.rs | 通过 FEN 查找开局名称(二进制数据内嵌于应用中) |
puzzle.rs | Lichess 战术题数据库——内存映射随机访问 |
fs.rs | 支持断点续传的文件下载、可执行权限设置 |
sound.rs | 用于音频流的本地 HTTP 服务器(Linux 音频解决方案) |
tts.rs | 通过 speech-dispatcher (Linux) / 原生 OS 语音 API 实现系统 TTS,以及 KittenTTS 服务器管理 |
oauth.rs | Lichess/Chess.com 账户关联的 OAuth2 流程 |
- 全面异步: Tokio 运行时,非阻塞 I/O
- 并发状态: 使用
DashMap(并发 HashMap)管理引擎进程、数据库连接和缓存 - 连接池: r2d2 管理 SQLite 连接池
- 内存映射搜索: 通过 mmap 映射的二进制索引进行局面查找,实现即时结果返回
- 事件流: Rust 发出事件(最佳走法、时钟计时、对局结束),React 实时监听
React/TypeScript 端:src/
Section titled “React/TypeScript 端:src/”构建流水线:Vite
Section titled “构建流水线:Vite”vite.config.ts 配置了:
- React 插件 搭配 Babel 编译器
- TanStack Router 插件 —— 根据
routes/文件夹自动生成路由树 - Vanilla Extract —— 零运行时 CSS-in-JS
- 路径别名:
@映射到./src - 开发服务器 运行在端口 1420
构建流程:
pnpm dev → Vite on :1420 + Tauri opens webview pointing to itpnpm build → tsc (typecheck) → vite build (bundle to dist/) → tauri build (native binary)入口:App.tsx
Section titled “入口:App.tsx”根组件:
- 初始化 Tauri 插件(日志、进程、更新器)
- 从持久化 atoms 加载用户偏好设置
- 设置 Mantine UI 主题
- 注册路由
- 检查应用更新
Jotai atoms(src/state/atoms.ts)—— 轻量级响应式状态:
| 类别 | 示例 |
|---|---|
| 标签页 | tabsAtom、activeTabAtom(多文档界面) |
| 目录 | storedDocumentDirAtom、storedDatabasesDirAtom |
| 界面偏好 | primaryColorAtom、fontSizeAtom、pieceSetAtom |
| 引擎 | engineMovesFamily、engineProgressFamily(通过 atomFamily 实现按标签页管理) |
| TTS | ttsEnabledAtom、ttsProviderAtom、ttsVoiceIdAtom、ttsVolumeAtom、ttsSpeedAtom、ttsLanguageAtom |
使用 atomWithStorage() 的 atoms 会自动持久化到 localStorage。
Zustand stores 用于复杂的领域状态:
src/state/store/tree.ts—— 对局树导航、分支走法、注释、评论。使用 Immer 实现不可变更新。src/state/store/database.ts—— 数据库视图过滤器、选中的对局、分页
路由:TanStack Router
Section titled “路由:TanStack Router”基于文件的路由位于 src/routes/:
routes/ __root.tsx # Root layout (AppShell, menu bar) index.tsx # Home/dashboard databases/ # Database browsing accounts.tsx # Lichess/Chess.com accounts settings.tsx # App preferences engines.tsx # Engine management组件:src/components/
Section titled “组件:src/components/”| 分组 | 用途 |
|---|---|
boards/ | 棋盘(chessground)、走子输入、评估条、分析显示、升变弹窗、箭头绘制 |
panels/ | 侧面板:引擎分析(BestMoves)、数据库局面搜索、注释编辑、对局信息、练习模式 |
databases/ | 数据库界面:对局表格、棋手表格、详情卡片、筛选功能 |
settings/ | 偏好设置表单、引擎路径、TTS 设置 |
home/ | 账户卡片、导入界面 |
common/ | 共享组件:TreeStateContext、子力显示、评论播放图标 |
tabs/ | 多标签页栏 |
前端如何调用 Rust
Section titled “前端如何调用 Rust”命令(请求/响应)
Section titled “命令(请求/响应)”Specta 在 src/bindings/generated.ts 中生成 TypeScript 绑定:
// Auto-generated from Rust #[tauri::command] functionsexport const commands = { async getBestMoves(id, engine, tab, goMode, options) { return await TAURI_INVOKE("get_best_moves", { id, engine, tab, goMode, options }); }, // ~50 more commands...}React 组件像普通异步函数一样调用它们:
import { commands } from "@/bindings";const result = await commands.getBestMoves(id, engine, tab, goMode, options);事件(流式传输,Rust 到 React)
Section titled “事件(流式传输,Rust 到 React)”用于实时数据(引擎分析、时钟计时、对局走子):
Rust: app.emit("best_moves_payload", BestMovesPayload { depth: 24, ... }) ↓React: listen("best_moves_payload", (event) => updateBestMoves(event.payload))Tauri 插件
Section titled “Tauri 插件”应用使用多个官方插件来访问系统功能:
| 插件 | 用途 |
|---|---|
@tauri-apps/plugin-fs | 读写文件 |
@tauri-apps/plugin-dialog | 文件选择器、消息框 |
@tauri-apps/plugin-http | HTTP 客户端(引擎下载、云端 TTS) |
@tauri-apps/plugin-shell | 执行 UCI 引擎 |
@tauri-apps/plugin-updater | 自动更新检查 |
@tauri-apps/plugin-log | 结构化日志 |
@tauri-apps/plugin-os | CPU/内存检测 |
文本转语音(TTS):入门介绍
Section titled “文本转语音(TTS):入门介绍”En Parlant~ 可以在你逐步浏览对局时朗读棋步和评论。本节介绍 TTS 系统的构建方式——预处理流水线、提供者架构和缓存策略。有关设置说明,请参阅 TTS 菜单中的 TTS 指南。
TTS 的工作原理(简述)
Section titled “TTS 的工作原理(简述)”文本转语音将书面文字转换为语音音频。现代 TTS 系统基于深度神经网络构建,使用数千小时的人类语音数据进行训练。模型学习文本(字母、单词、标点)与语音声学特征(音调、节奏、重音、呼吸停顿)之间的关系。在推理时,你输入文本,模型输出音频波形。
有两种主要方式:
-
云端 TTS —— 文本发送到远程服务器(Google、ElevenLabs 等),服务器在 GPU 硬件上运行大型神经网络并返回音频。质量出色,但需要联网且按请求计费(不过大多数提供商提供免费额度)。
-
本地 TTS —— 模型直接在你的机器上运行。无需联网,无按请求费用,你的文本也不会离开你的电脑。最近的开源模型(如 Kokoro 和 Piper)已经大幅缩小了与云端的质量差距。
如果你对 TTS 模型的底层原理感兴趣,HuggingFace (huggingface.co) 上托管了数百个开源语音合成模型,你可以浏览、下载和本地运行。搜索 “text-to-speech” 即可找到从轻量级 CPU 友好方案到最前沿研究模型的各类选择。
TTS 的核心实现位于 src/utils/tts.ts。它围绕一个统一的公开接口(speakText())设计,支持可替换的后端。应用的其余部分不需要知道也不关心当前使用的是哪个提供者——只需调用 speakText() 就能播放音频。
支持五个提供者:
| 提供者 | 类型 | 后端 |
|---|---|---|
| ElevenLabs | 云端 | 通过 REST API 使用神经语音。返回 MP3。 |
| Google Cloud TTS | 云端 | 通过 REST API 使用 WaveNet 语音。返回 base64 编码的 MP3。 |
| KittenTTS | 本地 | 内置 TTS 服务器,由 Rust 后端自动启动。通过 localhost 上的 HTTP 通信。 |
| OpenTTS | 本地 | 自托管 TTS 服务器。支持多种引擎(espeak、MaryTTS、Piper 等)。 |
| System TTS | 本地 | 通过 Rust/Tauri 命令调用操作系统原生语音引擎(Linux 上为 speech-dispatcher,Windows 上为 SAPI,macOS 上为 AVSpeechSynthesizer)。 |
提供者选择存储在单个 Jotai atom(ttsProviderAtom)中。切换提供者是即时的——更改 atom 后,下一次 speakText() 调用就会路由到新的后端。
挑战:国际象棋记谱法不是自然语言
Section titled “挑战:国际象棋记谱法不是自然语言”棋步使用标准代数记谱法(SAN)书写:Nf3、Bxe5+、O-O-O、e8=Q#。如果直接将其输入 TTS 引擎,得到的是无意义的发音——引擎可能会把 “Nf3” 当作一个单词来发音,或者把 “O-O-O” 读成 “oh oh oh”。
解决方案是一个预处理流水线,在文本到达 TTS 引擎之前将棋步记谱翻译为自然语言:
SAN Input → Preprocessing → Spoken Output─────────────────────────────────────────────────────"Nf3" → sanToSpoken() → "Knight f3""Bxe5+" → sanToSpoken() → "Bishop takes e5, check""O-O-O" → sanToSpoken() → "castles queenside""e8=Q#" → sanToSpoken() → "e8 promotes to Queen, checkmate"sanToSpoken() 函数使用正则表达式模式匹配将任何 SAN 字符串分解为其组成部分(棋子、消歧义、吃子、目标格、升变、将军/将杀),并使用词汇表中的自然语言重新组装。
国际象棋词汇已被翻译成多种语言(英语、法语、西班牙语、德语、日语、俄语、中文、韩语等)。CHESS_VOCAB 表映射了每个术语:
English: "Knight takes e5, check"French: "Cavalier prend e5, échec"German: "Springer schlägt e5, Schach"Japanese: "ナイト テイクス e5, チェック"Russian: "Конь берёт e5, шах"语言设置决定了预处理时使用哪个词汇表,以及 TTS 引擎合成语音时使用哪种声音/口音。
对局注释通常包含 PGN 特有的标记,如果直接朗读会非常糟糕:
Raw comment: "BLUNDER. 7.Nf3 was better [%eval -2.3] [%cal Gg1f3]"After cleaning: "7, Knight f3 was better"cleanCommentForTTS() 函数:
- 去除 PGN 标签:
[%eval ...]、[%csl ...]、[%cal ...]、[%clk ...] - 移除重复的注释词(当 ”??” 已经表达了 “Blunder” 时)
- 展开文本中的内联 SAN:
"7.Nf3 controls e5"→"7, Knight f3 controls e5" - 修正 TTS 引擎容易读错的国际象棋术语(例如 “en prise” → “on preez”)
- 展开文本中的棋子缩写:
"R vs R"→"Rook versus Rook"
构建完整的解说
Section titled “构建完整的解说”当你步进到新的一步时,buildNarration() 从三个来源组装完整的朗读文本:
Move: "12, Knight f3, check." ← from sanToSpoken()Annotation: "Good move." ← from annotation symbol (!)Comment: "Developing with tempo." ← from cleanCommentForTTS()
Full narration: "12, Knight f3, check. Good move. Developing with tempo."各部分之间的双空格为 TTS 引擎提供了自然的呼吸停顿。
云端 TTS 调用需要花费金钱和时间(约 200-500 毫秒往返)。为避免重复获取相同的音频,每个生成的音频片段都以 blob URL 的形式缓存在内存中:
Cache key: "elevenlabs:pNInz6obpgDQGcFmaJgB:en:12, Knight f3, check."Cache value: blob:http://localhost/abc123 (the MP3 audio in browser memory)命中缓存时,播放是即时的。缓存键基于 provider:voice:language:text,因此切换声音或语言会创建独立的缓存条目。
对于包含大量注释的对局,你可以在后台预缓存整个对局树。应用会遍历每个节点,构建解说文本,并按顺序发起 API 调用来填充缓存,然后你再开始浏览。
快速的方向键导航会产生一个问题:如果用户快速前进 5 步,你不希望 5 段音频同时播放互相干扰。解决方案是代数计数器:
const thisGeneration = ++requestGeneration;// ... fetch audio ...if (thisGeneration !== requestGeneration) return; // stale — discard每次新的 speakText() 调用会递增计数器,并通过 AbortController 中止正在进行的 HTTP 请求。当音频返回时,会检查其代数是否仍然是当前的。如果用户已经移动到了其他位置,响应会被静默丢弃。这样即使快速点击浏览棋步,也能获得干净、无杂音的音频效果。
TTS 在应用中的集成点
Section titled “TTS 在应用中的集成点”集成点非常少:
| 文件 | 功能说明 |
|---|---|
src/state/store/tree.ts | 每个导航函数(goToNext、goToPrevious 等)都会调用 stopSpeaking()。当自动解说开启时,goToNext 还会调用 speakMoveNarration()。 |
src/components/common/Comment.tsx | 每条评论旁边的播放图标可以手动触发该评论的 TTS 朗读。 |
src/components/settings/TTSSettings.tsx | TTS 设置界面,用于选择提供者、声音、语言、音量、语速以及输入 API 密钥。 |
当 TTS 关闭时,这些代码都不会执行。应用的行为与上游 En Croissant 完全一致。
User clicks "Analyze" → React calls commands.getBestMoves(position, engine, settings) → Rust spawns UCI engine process, sends position via stdin → Engine writes "info depth 18 score cp 45 pv e2e4 ..." to stdout → Rust parses UCI output, emits BestMovesPayload event → React's EvalListener receives event, updates atoms → UI re-renders: eval bar moves, best move arrows appear → User clicks "Stop" → commands.stopEngine() → Rust sets AtomicBool flag数据库局面搜索
Section titled “数据库局面搜索”User reaches a position on the board → React calls commands.searchPosition(fen, gameQuery) → Rust queries memory-mapped binary search index → Returns: PositionStats (wins/losses/draws) + matching games → React renders DatabasePanel with results tableTTS 解说
Section titled “TTS 解说”User steps forward with arrow key → tree.ts calls stopSpeaking(), then checks isAutoNarrateEnabled() → Calls speakMoveNarration(san, comment, annotations, halfMoves) → buildNarration() assembles text: sanToSpoken("Nf3+") → "Knight f3, check" annotationsToSpoken(["!"]) → "Good move." cleanCommentForTTS(comment) → strips [%eval], expands inline SAN → speakText() checks audioCache HIT → play blob URL instantly MISS → fetch from provider API → cache as blob URL → play → HTMLAudioElement.play() with volume and playbackRate from atomsen-parlant/├── src-tauri/ # RUST BACKEND│ ├── src/│ │ ├── main.rs # Entry, command registration, plugins│ │ ├── chess.rs # Engine analysis│ │ ├── game.rs # Live game management│ │ ├── db/ # SQLite database (largest module)│ │ ├── engine/ # UCI protocol│ │ ├── pgn.rs # PGN parsing│ │ ├── puzzle.rs # Puzzle database│ │ ├── opening.rs # Opening lookup│ │ └── tts.rs # System TTS + KittenTTS management│ ├── Cargo.toml # Rust dependencies│ ├── tauri.conf.json # Tauri config│ └── capabilities/main.json # Security permissions│├── src/ # REACT/TS FRONTEND│ ├── App.tsx # Root component│ ├── state/│ │ ├── atoms.ts # Jotai atoms (all app state)│ │ └── store/tree.ts # Game tree (Zustand + TTS hooks)│ ├── routes/ # TanStack Router (file-based)│ ├── components/│ │ ├── boards/ # Chessboard + analysis│ │ ├── panels/ # Side panels│ │ ├── databases/ # DB browsing UI│ │ ├── common/ # Comment display (with TTS speaker icon)│ │ └── settings/ # Preferences, TTS settings│ ├── utils/│ │ ├── chess.ts # Game logic│ │ ├── tts.ts # TTS engine (SAN-to-spoken, caching, 5 providers)│ │ └── treeReducer.ts # Tree data structure│ ├── bindings/ # Auto-generated TS from Rust│ └── translation/ # i18n (13 languages)│├── docs/ # Bundled documentation (shown in Help menu)├── vite.config.ts # Build config└── package.json # Frontend deps-
Rust 承担繁重工作 —— 引擎、数据库、文件 I/O、PGN 解析。React 不直接接触文件系统或启动进程。
-
跨边界的类型安全 —— Specta 从 Rust 结构体生成 TypeScript 类型,因此如果 Rust 命令修改了签名,TypeScript 构建会立即报错。
-
两套状态系统 —— Jotai 用于简单的响应式状态(设置、界面偏好、按标签页的引擎状态),Zustand 用于复杂的领域状态(带分支和不可变更新的对局树)。
-
TTS 本质上是一个预处理问题 —— 难点不在于调用语音 API,而在于将国际象棋记谱法和 PGN 标记翻译为干净、自然的多语言文本。
sanToSpoken()和cleanCommentForTTS()流水线才是真正的核心工作所在。 -
五个提供者,一个接口 —— 无论音频来自 ElevenLabs、Google Cloud、KittenTTS、OpenTTS 还是操作系统自带的语音引擎,应用的其余部分只需调用
speakText()。提供者的选择仅是一个 atom 的切换。 -
构建产物是单个二进制文件,位于
src-tauri/target/release/en-parlant,其中打包了 Rust 后端和 Vite 构建的前端资源。