Перейти до вмісту

Архітектурний огляд

Версія додатку: v0.1.1 (форк: 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 автоматично генерує визначення типів TypeScript з цих функцій Rust, тому фронтенд отримує повну типобезпеку без жодних ручних зусиль.

МодульПризначення
db/mod.rsБаза даних SQLite через Diesel ORM — запити ігор, статистика гравців, імпорт, пошук позицій
game.rsРушій живої гри — керує іграми рушій-проти-людини та рушій-проти-рушія, часовими контролями, валідацією ходів
chess.rsАналіз рушієм — запускає UCI-рушії, передає результати найкращих ходів на фронтенд через події
engine/Реалізація протоколу UCI — створення процесів, канали stdin/stdout, підтримка multi-PV
pgn.rsЧитання/запис/токенізація файлів PGN
opening.rsПошук назви дебюту за FEN (бінарні дані вбудовані в додаток)
puzzle.rsБаза задач Lichess — довільний доступ через відображення в пам’ять
fs.rsЗавантаження файлів з можливістю відновлення, встановлення прав на виконання
sound.rsЛокальний HTTP-сервер для потокового відтворення аудіо (обхідне рішення для аудіо в Linux)
tts.rsСистемний TTS через speech-dispatcher (Linux) / нативні API мовлення ОС, плюс управління сервером KittenTTS
oauth.rsOAuth2-потік для прив’язки акаунтів Lichess/Chess.com
  • Асинхронність скрізь: середовище виконання Tokio, неблокуючий I/O
  • Конкурентний стан: DashMap (конкурентна HashMap) для процесів рушіїв, з’єднань з БД, кешів
  • Пулінг з’єднань: r2d2 керує пулами з’єднань SQLite
  • Пошук через відображення в пам’ять: пошук позицій через mmap’d бінарний індекс для миттєвих результатів
  • Потокова передача подій: Rust генерує події (найкращі ходи, тіки годинника, кінець гри), які React слухає в реальному часі

vite.config.ts налаштовує:

  • Плагін React з компілятором Babel
  • Плагін TanStack Router — автоматично генерує дерево маршрутів з папки routes/
  • Vanilla Extract — CSS-in-JS без рантайму
  • Псевдонім шляху: @ вказує на ./src
  • Dev-сервер на порту 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 (логування, процеси, оновлення)
  • Завантажує налаштування користувача з персистентних атомів
  • Налаштовує тему UI Mantine
  • Реєструє маршрутизатор
  • Перевіряє наявність оновлень додатку

Атоми Jotai (src/state/atoms.ts) — легковісний реактивний стан:

КатегоріяПриклади
ВкладкиtabsAtom, activeTabAtom (багатодокументний інтерфейс)
ДиректоріїstoredDocumentDirAtom, storedDatabasesDirAtom
Налаштування UIprimaryColorAtom, fontSizeAtom, pieceSetAtom
РушійengineMovesFamily, engineProgressFamily (для кожної вкладки через atomFamily)
TTSttsEnabledAtom, ttsProviderAtom, ttsVoiceIdAtom, ttsVolumeAtom, ttsSpeedAtom, ttsLanguageAtom

Атоми з atomWithStorage() автоматично зберігаються в localStorage.

Сховища Zustand для складного доменного стану:

  • 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
ГрупаПризначення
boards/Шахівниця (chessground), введення ходів, шкала оцінки, відображення аналізу, модальне вікно перетворення, малювання стрілок
panels/Бічні панелі: аналіз рушієм (BestMoves), пошук позицій у базі даних, редагування анотацій, інформація про гру, режим практики
databases/UI бази даних: таблиця ігор, таблиця гравців, картки деталей, фільтрація
settings/Форми налаштувань, шляхи до рушіїв, налаштування TTS
home/Картки акаунтів, UI імпорту
common/Спільне: TreeStateContext, відображення матеріалу, іконка динаміка для коментарів
tabs/Панель вкладок

Як фронтенд викликає Rust

Section titled “Як фронтенд викликає Rust”

Команди (запит/відповідь)

Section titled “Команди (запит/відповідь)”

Specta генерує TypeScript-прив’язки в src/bindings/generated.ts:

// 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 до React)

Section titled “Події (потокова передача, від Rust до React)”

Для даних у реальному часі (аналіз рушієм, тіки годинника, ходи гри):

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-osВизначення CPU/RAM

Синтез мовлення (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 до найсучасніших дослідницьких моделей.

Архітектура провайдерів

Section titled “Архітектура провайдерів”

Основна реалізація TTS знаходиться в src/utils/tts.ts. Вона побудована навколо єдиного публічного інтерфейсу (speakText()) зі змінними бекендами. Решта додатку ніколи не знає і не піклується про те, який провайдер активний — вона просто викликає speakText() і отримує аудіо.

Підтримується п’ять провайдерів:

ПровайдерТипБекенд
ElevenLabsХмарнийНейронні голоси через REST API. Повертає MP3.
Google Cloud TTSХмарнийГолоси WaveNet через REST API. Повертає MP3 у кодуванні base64.
KittenTTSЛокальнийВбудований TTS-сервер, автоматично запускається бекендом Rust. Взаємодіє через HTTP на localhost.
OpenTTSЛокальнийСамостійно розміщений TTS-сервер. Підтримує багато рушіїв (espeak, MaryTTS, Piper тощо).
System TTSЛокальнийНативний мовленнєвий рушій ОС через команди Rust/Tauri (speech-dispatcher на Linux, SAPI на Windows, AVSpeechSynthesizer на macOS).

Вибір провайдера зберігається в одному атомі Jotai (ttsProviderAtom). Перемикання провайдерів миттєве — змінюєте атом, і наступний виклик speakText() направляється до нового бекенду.

Виклик: шахова нотація — це не англійська мова

Section titled “Виклик: шахова нотація — це не англійська мова”

Шахові ходи записуються в стандартній алгебраїчній нотації (SAN): Nf3, Bxe5+, O-O-O, e8=Q#. Якщо подати це безпосередньо в TTS-рушій, отримаємо нісенітницю — він може спробувати вимовити “Nf3” як слово або прочитати “O-O-O” як “о о о”.

Рішенням є конвеєр попередньої обробки, який перекладає шахову нотацію в природну мову перед тим, як вона потрапить до 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"

Побудова повної нарації

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-рушіям природну паузу для дихання.

Кешування та відтворення

Section titled “Кешування та відтворення”

Виклики хмарного 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-запити для заповнення кешу перед початком навігації.

Конкурентність та скасування

Section titled “Конкурентність та скасування”

Швидка навігація клавішами-стрілками створює проблему: якщо користувач швидко перейде на 5 ходів вперед, ви не хочете, щоб 5 аудіокліпів накладалися один на одного. Рішення — лічильник генерацій:

const thisGeneration = ++requestGeneration;
// ... fetch audio ...
if (thisGeneration !== requestGeneration) return; // stale — discard

Кожен новий виклик speakText() інкрементує лічильник і скасовує будь-який поточний HTTP-запит через AbortController. Коли аудіо надходить, воно перевіряє, чи його генерація все ще актуальна. Якщо користувач вже перейшов далі, відповідь мовчки відкидається. Це забезпечує чисте, без артефактів аудіо навіть при швидкому перегортанні ходів.

Де TTS підключається до додатку

Section titled “Де TTS підключається до додатку”

Точок інтеграції мінімум:

ФайлЩо відбувається
src/state/store/tree.tsКожна функція навігації (goToNext, goToPrevious тощо) викликає stopSpeaking(). Коли увімкнено автонарацію, goToNext також викликає speakMoveNarration().
src/components/common/Comment.tsxІконка динаміка поруч з кожним коментарем дозволяє вручну запустити TTS для цього коментаря.
src/components/settings/TTSSettings.tsxUI налаштувань для вибору провайдера, голосу, мови, гучності, швидкості та введення API-ключів.

Коли TTS вимкнено, жоден з цього коду не виконується. Додаток працює ідентично до основного En Croissant.


Приклади потоків даних

Section titled “Приклади потоків даних”
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 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 генерує типи TypeScript зі структур Rust, тому якщо Rust-команда змінить свою сигнатуру, збірка TypeScript одразу зламається.

  3. Дві системи стану — Jotai для простого реактивного стану (налаштування, UI-преференції, стан рушія для кожної вкладки), Zustand для складного доменного стану (дерево гри з розгалуженням та незмінними оновленнями).

  4. TTS — це задача попередньої обробки — складність полягає не у виклику API мовлення, а в перекладі шахової нотації та PGN-розмітки у чистий, природно звучний текст багатьма мовами. Конвеєри sanToSpoken() та cleanCommentForTTS() — це те, де відбувається справжня робота.

  5. П’ять провайдерів, один інтерфейс — незалежно від того, чи аудіо надходить від ElevenLabs, Google Cloud, KittenTTS, OpenTTS або мовленнєвого рушія вашої ОС, решта додатку завжди викликає лише speakText(). Вибір провайдера — це перемикання одного атома.

  6. Збірка створює один бінарний файл за шляхом src-tauri/target/release/en-parlant, який об’єднує бекенд Rust та зібрані Vite ресурси фронтенду.