Перейти к содержимому

Обзор архитектуры

Версия приложения: 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, неблокирующий ввод/вывод
  • Параллельное состояние: DashMap (параллельный HashMap) для процессов движков, подключений к БД, кэшей
  • Пул соединений: r2d2 управляет пулами подключений к SQLite
  • Поиск с отображением в память: поиск позиций через бинарный индекс, отображённый в память (mmap), для мгновенных результатов
  • Потоковая передача событий: 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 (log, process, updater)
  • Загружает пользовательские настройки из персистентных атомов
  • Устанавливает тему Mantine UI
  • Регистрирует маршрутизатор
  • Проверяет наличие обновлений приложения

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

КатегорияПримеры
ВкладкиtabsAtom, activeTabAtom (многодокументный интерфейс)
ДиректорииstoredDocumentDirAtom, storedDatabasesDirAtom
Настройки интерфейсаprimaryColorAtom, 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 — фильтры отображения базы данных, выбранная партия, пагинация

Файловая маршрутизация в 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 генерирует привязки 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: 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

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ОблачныйГолоса 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() направится к новому бэкенду.

Проблема: шахматная нотация — это не естественный язык

Заголовок раздела «Проблема: шахматная нотация — это не естественный язык»

Шахматные ходы записываются в стандартной алгебраической нотации (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. Убирает дублирующиеся слова аннотаций (когда «??» уже произнесло «Грубая ошибка»)
  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() увеличивает счётчик и отменяет все текущие HTTP-запросы через AbortController. Когда аудио приходит, проверяется, является ли его поколение всё ещё актуальным. Если пользователь уже перешёл дальше, ответ тихо отбрасывается. Это обеспечивает чистое воспроизведение без артефактов даже при быстром прокликивании ходов.

Точки интеграции минимальны:

ФайлЧто происходит
src/state/store/tree.tsКаждая функция навигации (goToNext, goToPrevious и т.д.) вызывает stopSpeaking(). Когда автоозвучивание включено, goToNext также вызывает speakMoveNarration().
src/components/common/Comment.tsxИконка динамика рядом с каждым комментарием позволяет вручную запустить TTS для этого комментария.
src/components/settings/TTSSettings.tsxИнтерфейс настроек для выбора провайдера, голоса, языка, громкости, скорости и ввода 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 берёт на себя тяжёлую работу — движки, база данных, файловый ввод/вывод, разбор PGN. React никогда не обращается к файловой системе и не запускает процессы напрямую.

  2. Типобезопасность через границу — Specta генерирует типы TypeScript из структур Rust, поэтому если Rust-команда изменит свою сигнатуру, сборка TypeScript немедленно сломается.

  3. Две системы управления состоянием — Jotai для простого реактивного состояния (настройки, предпочтения интерфейса, состояние движка по вкладкам), Zustand для сложного доменного состояния (дерево партии с ветвлением и иммутабельными обновлениями).

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

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

  6. Сборка создаёт единый бинарный файл по пути src-tauri/target/release/en-parlant, объединяющий бэкенд Rust и фронтенд-ресурсы, собранные Vite.