跳转到内容

架构入门

应用版本: v0.1.1 (fork: DarrellThomas/en-parlant) 技术栈: Tauri v2 (Rust) + React 19 (TypeScript) + Vite


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 负责处理所有需要高性能或系统级访问的任务。

注册约 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.rsPGN 文件的读写和词法分析
opening.rs通过 FEN 查找开局名称(二进制数据内嵌于应用中)
puzzle.rsLichess 战术题数据库——内存映射随机访问
fs.rs支持断点续传的文件下载、可执行权限设置
sound.rs用于音频流的本地 HTTP 服务器(Linux 音频解决方案)
tts.rs通过 speech-dispatcher (Linux) / 原生 OS 语音 API 实现系统 TTS,以及 KittenTTS 服务器管理
oauth.rsLichess/Chess.com 账户关联的 OAuth2 流程
  • 全面异步: Tokio 运行时,非阻塞 I/O
  • 并发状态: 使用 DashMap(并发 HashMap)管理引擎进程、数据库连接和缓存
  • 连接池: r2d2 管理 SQLite 连接池
  • 内存映射搜索: 通过 mmap 映射的二进制索引进行局面查找,实现即时结果返回
  • 事件流: Rust 发出事件(最佳走法、时钟计时、对局结束),React 实时监听

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 it
pnpm build → tsc (typecheck) → vite build (bundle to dist/) → tauri build (native binary)

根组件:

  • 初始化 Tauri 插件(日志、进程、更新器)
  • 从持久化 atoms 加载用户偏好设置
  • 设置 Mantine UI 主题
  • 注册路由
  • 检查应用更新

Jotai atomssrc/state/atoms.ts)—— 轻量级响应式状态:

类别示例
标签页tabsAtomactiveTabAtom(多文档界面)
目录storedDocumentDirAtomstoredDatabasesDirAtom
界面偏好primaryColorAtomfontSizeAtompieceSetAtom
引擎engineMovesFamilyengineProgressFamily(通过 atomFamily 实现按标签页管理)
TTSttsEnabledAtomttsProviderAtomttsVoiceIdAtomttsVolumeAtomttsSpeedAtomttsLanguageAtom

使用 atomWithStorage() 的 atoms 会自动持久化到 localStorage。

Zustand stores 用于复杂的领域状态:

  • src/state/store/tree.ts —— 对局树导航、分支走法、注释、评论。使用 Immer 实现不可变更新。
  • src/state/store/database.ts —— 数据库视图过滤器、选中的对局、分页

基于文件的路由位于 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
分组用途
boards/棋盘(chessground)、走子输入、评估条、分析显示、升变弹窗、箭头绘制
panels/侧面板:引擎分析(BestMoves)、数据库局面搜索、注释编辑、对局信息、练习模式
databases/数据库界面:对局表格、棋手表格、详情卡片、筛选功能
settings/偏好设置表单、引擎路径、TTS 设置
home/账户卡片、导入界面
common/共享组件:TreeStateContext、子力显示、评论播放图标
tabs/多标签页栏

Specta 在 src/bindings/generated.ts 中生成 TypeScript 绑定:

// Auto-generated from Rust #[tauri::command] functions
export 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: app.emit("best_moves_payload", BestMovesPayload { depth: 24, ... })
React: listen("best_moves_payload", (event) => updateBestMoves(event.payload))

应用使用多个官方插件来访问系统功能:

插件用途
@tauri-apps/plugin-fs读写文件
@tauri-apps/plugin-dialog文件选择器、消息框
@tauri-apps/plugin-httpHTTP 客户端(引擎下载、云端 TTS)
@tauri-apps/plugin-shell执行 UCI 引擎
@tauri-apps/plugin-updater自动更新检查
@tauri-apps/plugin-log结构化日志
@tauri-apps/plugin-osCPU/内存检测

En Parlant~ 可以在你逐步浏览对局时朗读棋步和评论。本节介绍 TTS 系统的构建方式——预处理流水线、提供者架构和缓存策略。有关设置说明,请参阅 TTS 菜单中的 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)书写:Nf3Bxe5+O-O-Oe8=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() 函数:

  1. 去除 PGN 标签:[%eval ...][%csl ...][%cal ...][%clk ...]
  2. 移除重复的注释词(当 ”??” 已经表达了 “Blunder” 时)
  3. 展开文本中的内联 SAN:"7.Nf3 controls e5""7, Knight f3 controls e5"
  4. 修正 TTS 引擎容易读错的国际象棋术语(例如 “en prise” → “on preez”)
  5. 展开文本中的棋子缩写:"R vs R""Rook versus Rook"

当你步进到新的一步时,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 请求。当音频返回时,会检查其代数是否仍然是当前的。如果用户已经移动到了其他位置,响应会被静默丢弃。这样即使快速点击浏览棋步,也能获得干净、无杂音的音频效果。

集成点非常少:

文件功能说明
src/state/store/tree.ts每个导航函数(goToNextgoToPrevious 等)都会调用 stopSpeaking()。当自动解说开启时,goToNext 还会调用 speakMoveNarration()
src/components/common/Comment.tsx每条评论旁边的播放图标可以手动触发该评论的 TTS 朗读。
src/components/settings/TTSSettings.tsxTTS 设置界面,用于选择提供者、声音、语言、音量、语速以及输入 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
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 table
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 atoms

en-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

  1. Rust 承担繁重工作 —— 引擎、数据库、文件 I/O、PGN 解析。React 不直接接触文件系统或启动进程。

  2. 跨边界的类型安全 —— Specta 从 Rust 结构体生成 TypeScript 类型,因此如果 Rust 命令修改了签名,TypeScript 构建会立即报错。

  3. 两套状态系统 —— Jotai 用于简单的响应式状态(设置、界面偏好、按标签页的引擎状态),Zustand 用于复杂的领域状态(带分支和不可变更新的对局树)。

  4. TTS 本质上是一个预处理问题 —— 难点不在于调用语音 API,而在于将国际象棋记谱法和 PGN 标记翻译为干净、自然的多语言文本。sanToSpoken()cleanCommentForTTS() 流水线才是真正的核心工作所在。

  5. 五个提供者,一个接口 —— 无论音频来自 ElevenLabs、Google Cloud、KittenTTS、OpenTTS 还是操作系统自带的语音引擎,应用的其余部分只需调用 speakText()。提供者的选择仅是一个 atom 的切换。

  6. 构建产物是单个二进制文件,位于 src-tauri/target/release/en-parlant,其中打包了 Rust 后端和 Vite 构建的前端资源。