跳到內容

架構入門

應用程式版本: v0.1.1 (fork: DarrellThomas/en-parlant) 技術堆疊: Tauri v2 (Rust) + React 19 (TypeScript) + Vite


Tauri 是一個用於建構桌面應用程式的框架。與 Electron 打包完整瀏覽器不同,Tauri 使用作業系統內建的 webview 作為 UI,並以 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 外掛(log、process、updater)
  • 從持久化 atoms 載入使用者偏好設定
  • 設定 Mantine UI 主題
  • 註冊路由器
  • 檢查應用程式更新

Jotai atomssrc/state/atoms.ts)— 輕量級響應式狀態:

類別範例
分頁tabsAtomactiveTabAtom(多文件介面)
目錄storedDocumentDirAtomstoredDatabasesDirAtom
UI 偏好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/資料庫 UI:棋局表格、棋手表格、詳細卡片、篩選
settings/偏好設定表單、引擎路徑、TTS 設定
home/帳號卡片、匯入 UI
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/RAM 偵測

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 命令使用 OS 原生語音引擎(Linux 上的 speech-dispatcher、Windows 上的 SAPI、macOS 上的 AVSpeechSynthesizer)。

提供者選擇存儲在單一 Jotai atom(ttsProviderAtom)中。切換提供者是即時的 — 變更 atom,下一次 speakText() 呼叫就會路由到新的後端。

西洋棋走步使用標準代數記譜法(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.tsx用於選擇提供者、語音、語言、音量、速度和輸入 API 金鑰的設定 UI。

當 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 用於簡單的響應式狀態(設定、UI 偏好、每個分頁的引擎狀態),Zustand 用於複雜的領域狀態(具有分支和不可變更新的棋局樹)。

  4. TTS 是一個預處理問題 — 困難的部分不是呼叫語音 API,而是將西洋棋記譜法和 PGN 標記轉譯為乾淨、自然流暢的多語言文字。sanToSpoken()cleanCommentForTTS() 管線才是真正的核心工作。

  5. 五個提供者,一個介面 — 無論音訊來自 ElevenLabs、Google Cloud、KittenTTS、OpenTTS,還是您的 OS 語音引擎,應用程式的其餘部分只會呼叫 speakText()。提供者的選擇只是一個 atom 的切換。

  6. 建構產出單一二進位檔,位於 src-tauri/target/release/en-parlant,其中打包了 Rust 後端和 Vite 建構的前端資源。