아키텍처 입문서
앱 버전: v0.1.1 (fork: DarrellThomas/en-parlant) 스택: Tauri v2 (Rust) + React 19 (TypeScript) + Vite
Tauri란 무엇인가?
섹션 제목: “Tauri란 무엇인가?”Tauri는 데스크톱 앱을 만들기 위한 프레임워크입니다. Electron처럼 전체 브라우저를 함께 배포하는 대신, Tauri는 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 영역: src-tauri/src/
섹션 제목: “Rust 영역: src-tauri/src/”Rust는 빠른 처리가 필요하거나 시스템 접근이 필요한 모든 것을 담당합니다.
진입점: main.rs
섹션 제목: “진입점: main.rs”프론트엔드가 호출할 수 있는 약 50개의 커맨드를 등록하고, 플러그인(파일시스템, 다이얼로그, HTTP, 셸, 로깅, 업데이터)을 초기화하며, 앱 창을 시작합니다.
커맨드는 매크로로 정의됩니다:
#[tauri::command]async fn get_best_moves(id: String, engine: String, ...) -> Result<...> { // spawn UCI engine, return analysis}specta 크레이트가 이러한 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
- 동시성 상태: 엔진 프로세스, DB 연결, 캐시를 위한
DashMap(동시성 HashMap) - 커넥션 풀링: r2d2가 SQLite 커넥션 풀 관리
- 메모리 매핑 검색: mmap된 바이너리 인덱스를 통한 포지션 조회로 즉각적인 결과 반환
- 이벤트 스트리밍: Rust가 이벤트(최선수, 시계 틱, 게임 종료)를 발행하고 React가 실시간으로 수신
React/TypeScript 영역: src/
섹션 제목: “React/TypeScript 영역: src/”빌드 파이프라인: Vite
섹션 제목: “빌드 파이프라인: Vite”vite.config.ts 설정 내용:
- Babel 컴파일러를 사용하는 React 플러그인
- 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
섹션 제목: “진입점: App.tsx”루트 컴포넌트:
- Tauri 플러그인 초기화 (log, process, updater)
- 영속 atom에서 사용자 환경설정 로드
- Mantine UI 테마 설정
- 라우터 등록
- 앱 업데이트 확인
상태 관리
섹션 제목: “상태 관리”Jotai atoms (src/state/atoms.ts) — 경량 반응형 상태:
| 카테고리 | 예시 |
|---|---|
| 탭 | tabsAtom, activeTabAtom (멀티 문서 인터페이스) |
| 디렉터리 | storedDocumentDirAtom, storedDatabasesDirAtom |
| UI 환경설정 | primaryColorAtom, fontSizeAtom, pieceSetAtom |
| 엔진 | engineMovesFamily, engineProgressFamily (atomFamily를 통한 탭별 상태) |
| TTS | ttsEnabledAtom, ttsProviderAtom, ttsVoiceIdAtom, ttsVolumeAtom, ttsSpeedAtom, ttsLanguageAtom |
atomWithStorage()를 사용하는 atom은 localStorage에 자동으로 영속됩니다.
복잡한 도메인 상태를 위한 Zustand 스토어:
src/state/store/tree.ts— 게임 트리 탐색, 수 분기, 주석, 코멘트. 불변 업데이트를 위해 Immer 사용.src/state/store/database.ts— 데이터베이스 뷰 필터, 선택된 게임, 페이지네이션
라우팅: TanStack Router
섹션 제목: “라우팅: 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/
섹션 제목: “컴포넌트: src/components/”| 그룹 | 용도 |
|---|---|
boards/ | 체스보드 (chessground), 수 입력, 평가 바, 분석 표시, 프로모션 모달, 화살표 그리기 |
panels/ | 사이드 패널: 엔진 분석 (BestMoves), 데이터베이스 포지션 검색, 주석 편집, 게임 정보, 연습 모드 |
databases/ | 데이터베이스 UI: 게임 테이블, 플레이어 테이블, 상세 카드, 필터링 |
settings/ | 환경설정 폼, 엔진 경로, TTS 설정 |
home/ | 계정 카드, 가져오기 UI |
common/ | 공유: TreeStateContext, 기물 표시, 코멘트 스피커 아이콘 |
tabs/ | 멀티탭 바 |
프론트엔드에서 Rust 호출 방법
섹션 제목: “프론트엔드에서 Rust 호출 방법”커맨드 (요청/응답)
섹션 제목: “커맨드 (요청/응답)”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로)
섹션 제목: “이벤트 (스트리밍, Rust에서 React로)”실시간 데이터(엔진 분석, 시계 틱, 게임 수)의 경우:
Rust: app.emit("best_moves_payload", BestMovesPayload { depth: 24, ... }) ↓React: listen("best_moves_payload", (event) => updateBestMoves(event.payload))Tauri 플러그인
섹션 제목: “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/RAM 감지 |
텍스트 음성 변환 (TTS): 입문서
섹션 제목: “텍스트 음성 변환 (TTS): 입문서”En Parlant~는 게임을 탐색할 때 체스 수와 코멘트를 소리 내어 읽어줄 수 있습니다. 이 섹션에서는 TTS 시스템이 어떻게 구축되었는지 — 전처리 파이프라인, 프로바이더 아키텍처, 캐싱 전략을 설명합니다. 설정 방법은 TTS 메뉴의 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)으로 기록됩니다: 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() 함수는:
- 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"
전체 내레이션 구성
섹션 제목: “전체 내레이션 구성”새로운 수로 이동하면, 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-500ms). 동일한 오디오를 다시 가져오는 것을 방지하기 위해, 생성된 모든 클립은 메모리에 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가 앱에 연결되는 지점
섹션 제목: “TTS가 앱에 연결되는 지점”통합 지점은 최소화되어 있습니다:
| 파일 | 동작 |
|---|---|
src/state/store/tree.ts | 모든 탐색 함수(goToNext, goToPrevious 등)가 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 tableTTS 내레이션
섹션 제목: “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 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핵심 요약
섹션 제목: “핵심 요약”-
Rust가 무거운 작업을 담당합니다 — 엔진, 데이터베이스, 파일 I/O, PGN 파싱. React는 파일시스템에 직접 접근하거나 프로세스를 직접 생성하지 않습니다.
-
경계를 넘나드는 타입 안전성 — Specta가 Rust 구조체에서 TypeScript 타입을 생성하므로, Rust 커맨드의 시그니처가 변경되면 TypeScript 빌드가 즉시 실패합니다.
-
두 가지 상태 시스템 — 단순한 반응형 상태(설정, UI 환경설정, 탭별 엔진 상태)에는 Jotai, 복잡한 도메인 상태(분기와 불변 업데이트가 있는 게임 트리)에는 Zustand를 사용합니다.
-
TTS는 전처리 문제입니다 — 어려운 부분은 음성 API를 호출하는 것이 아니라, 체스 기보와 PGN 마크업을 여러 언어에 걸쳐 깔끔하고 자연스럽게 들리는 텍스트로 변환하는 것입니다.
sanToSpoken()과cleanCommentForTTS()파이프라인이 실제 작업이 이루어지는 곳입니다. -
다섯 개의 프로바이더, 하나의 인터페이스 — 오디오가 ElevenLabs, Google Cloud, KittenTTS, OpenTTS, 또는 OS의 음성 엔진 중 어디에서 오든, 앱의 나머지 부분은 오직
speakText()만 호출합니다. 프로바이더 선택은 단일 atom 토글입니다. -
빌드 결과물은 단일 바이너리로
src-tauri/target/release/en-parlant에 생성되며, Rust 백엔드와 Vite로 빌드된 프론트엔드 자산을 함께 번들합니다.