Ir al contenido

Introducción a la Arquitectura

Versión de la app: v0.1.1 (fork: DarrellThomas/en-parlant) Stack: Tauri v2 (Rust) + React 19 (TypeScript) + Vite


Tauri es un framework para crear aplicaciones de escritorio. En lugar de incluir un navegador completo como hace Electron, Tauri utiliza el webview nativo del sistema operativo para la interfaz y un proceso en Rust para el backend. El resultado es un binario pequeño y rápido.

Las dos mitades se comunican a través de IPC (comunicación entre procesos):

+---------------------------+ 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 se encarga de todo lo que necesita ser rápido o requiere acceso al sistema.

Registra ~50 comandos que el frontend puede invocar, inicializa plugins (sistema de archivos, diálogos, HTTP, shell, logging, actualizador) y lanza la ventana de la aplicación.

Los comandos se definen con una macro:

#[tauri::command]
async fn get_best_moves(id: String, engine: String, ...) -> Result<...> {
// spawn UCI engine, return analysis
}

El crate specta genera automáticamente definiciones de tipos TypeScript a partir de estas funciones Rust, de modo que el frontend obtiene seguridad de tipos completa sin esfuerzo manual.

MóduloQué hace
db/mod.rsBase de datos SQLite mediante Diesel ORM — consultas de partidas, estadísticas de jugadores, importaciones, búsqueda de posiciones
game.rsMotor de partidas en vivo — gestiona partidas motor-vs-humano y motor-vs-motor, controles de tiempo, validación de jugadas
chess.rsAnálisis de motor — lanza motores UCI, envía resultados de mejores jugadas al frontend mediante eventos en streaming
engine/Implementación del protocolo UCI — creación de procesos, pipes stdin/stdout, soporte multi-PV
pgn.rsLectura/escritura/tokenización de archivos PGN
opening.rsBúsqueda de nombres de apertura a partir de FEN (datos binarios integrados en la app)
puzzle.rsBase de datos de puzzles de Lichess — acceso aleatorio mediante mapeo en memoria
fs.rsDescargas de archivos con reanudación, configuración de permisos de ejecución
sound.rsServidor HTTP local para streaming de audio (solución alternativa para audio en Linux)
tts.rsTTS del sistema vía speech-dispatcher (Linux) / APIs de voz nativas del SO, más gestión del servidor KittenTTS
oauth.rsFlujo OAuth2 para vincular cuentas de Lichess/Chess.com
  • Async en todas partes: runtime Tokio, E/S no bloqueante
  • Estado concurrente: DashMap (HashMap concurrente) para procesos de motor, conexiones a BD, cachés
  • Pool de conexiones: r2d2 gestiona pools de conexiones SQLite
  • Búsqueda con mapeo en memoria: búsqueda de posiciones mediante índice binario mapeado en memoria para resultados instantáneos
  • Streaming de eventos: Rust emite eventos (mejores jugadas, ticks del reloj, fin de partida) que React escucha en tiempo real

vite.config.ts configura:

  • Plugin de React con compilador Babel
  • Plugin de TanStack Router — genera automáticamente el árbol de rutas desde la carpeta routes/
  • Vanilla Extract — CSS-in-JS sin runtime
  • Alias de ruta: @ mapea a ./src
  • Servidor de desarrollo en el puerto 1420

Flujo de compilación:

pnpm dev → Vite on :1420 + Tauri opens webview pointing to it
pnpm build → tsc (typecheck) → vite build (bundle to dist/) → tauri build (native binary)

El componente raíz:

  • Inicializa plugins de Tauri (log, process, updater)
  • Carga las preferencias del usuario desde atoms persistentes
  • Configura el tema de la interfaz con Mantine
  • Registra el router
  • Comprueba si hay actualizaciones de la app

Átomos Jotai (src/state/atoms.ts) — estado reactivo ligero:

CategoríaEjemplos
PestañastabsAtom, activeTabAtom (interfaz multi-documento)
DirectoriosstoredDocumentDirAtom, storedDatabasesDirAtom
Preferencias de UIprimaryColorAtom, fontSizeAtom, pieceSetAtom
MotorengineMovesFamily, engineProgressFamily (por pestaña mediante atomFamily)
TTSttsEnabledAtom, ttsProviderAtom, ttsVoiceIdAtom, ttsVolumeAtom, ttsSpeedAtom, ttsLanguageAtom

Los átomos con atomWithStorage() se persisten automáticamente en localStorage.

Stores Zustand para estado de dominio complejo:

  • src/state/store/tree.ts — navegación del árbol de partida, ramificación de jugadas, anotaciones, comentarios. Usa Immer para actualizaciones inmutables.
  • src/state/store/database.ts — filtros de vista de base de datos, partida seleccionada, paginación

Enrutamiento basado en archivos en 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
GrupoPropósito
boards/Tablero (chessground), entrada de jugadas, barra de evaluación, visualización de análisis, modal de promoción, dibujo de flechas
panels/Paneles laterales: análisis de motor (BestMoves), búsqueda de posiciones en BD, edición de anotaciones, información de partida, modo práctica
databases/Interfaz de base de datos: tabla de partidas, tabla de jugadores, tarjetas de detalle, filtrado
settings/Formularios de preferencias, rutas de motores, configuración de TTS
home/Tarjetas de cuenta, interfaz de importación
common/Compartidos: TreeStateContext, visualización de material, ícono de altavoz en comentarios
tabs/Barra multi-pestaña

Specta genera bindings TypeScript en 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...
}

Los componentes React los llaman como funciones asíncronas normales:

import { commands } from "@/bindings";
const result = await commands.getBestMoves(id, engine, tab, goMode, options);

Para datos en tiempo real (análisis de motor, ticks del reloj, jugadas de partida):

Rust: app.emit("best_moves_payload", BestMovesPayload { depth: 24, ... })
React: listen("best_moves_payload", (event) => updateBestMoves(event.payload))

La app utiliza varios plugins oficiales para acceso al sistema:

PluginPropósito
@tauri-apps/plugin-fsLectura/escritura de archivos
@tauri-apps/plugin-dialogSelectores de archivos, cuadros de mensajes
@tauri-apps/plugin-httpCliente HTTP (descargas de motores, TTS en la nube)
@tauri-apps/plugin-shellEjecución de motores UCI
@tauri-apps/plugin-updaterComprobación automática de actualizaciones
@tauri-apps/plugin-logLogging estructurado
@tauri-apps/plugin-osDetección de CPU/RAM

En Parlant~ puede leer jugadas de ajedrez y comentarios en voz alta mientras navegas por una partida. Esta sección explica cómo está construido el sistema TTS: el pipeline de preprocesamiento, la arquitectura de proveedores y la estrategia de caché. Para instrucciones de configuración, consulta las guías de TTS en el menú TTS.

La síntesis de voz convierte texto escrito en audio hablado. Los sistemas TTS modernos se basan en redes neuronales profundas entrenadas con miles de horas de habla humana. El modelo aprende la relación entre texto (letras, palabras, puntuación) y las características acústicas del habla (tono, ritmo, énfasis, pausas de respiración). En el momento de la inferencia, se envía texto y se obtiene una forma de onda de audio.

Existen dos enfoques generales:

  • TTS en la nube — el texto se envía a un servidor remoto (Google, ElevenLabs, etc.), que ejecuta una red neuronal grande en hardware con GPU y devuelve audio. Excelente calidad, pero requiere internet y tiene costes por solicitud (aunque la mayoría de los proveedores ofrecen niveles gratuitos).

  • TTS local — un modelo se ejecuta directamente en tu máquina. No necesita internet, no tiene coste por solicitud y tu texto nunca sale de tu ordenador. Los modelos de código abierto recientes (como Kokoro y Piper) han reducido significativamente la diferencia de calidad.

Si te interesa saber cómo funcionan los modelos TTS internamente, HuggingFace (huggingface.co) aloja cientos de modelos de síntesis de voz de código abierto que puedes explorar, descargar y ejecutar localmente. Busca “text-to-speech” para encontrar modelos que van desde opciones ligeras para CPU hasta modelos de investigación de última generación.

La implementación central del TTS se encuentra en src/utils/tts.ts. Está diseñada en torno a una interfaz pública única (speakText()) con backends intercambiables. El resto de la app nunca sabe ni le importa qué proveedor está activo — simplemente llama a speakText() y sale audio.

Se soportan cinco proveedores:

ProveedorTipoBackend
ElevenLabsNubeVoces neuronales vía API REST. Devuelve MP3.
Google Cloud TTSNubeVoces WaveNet vía API REST. Devuelve MP3 codificado en base64.
KittenTTSLocalServidor TTS incluido, iniciado automáticamente por el backend Rust. Se comunica por HTTP en localhost.
OpenTTSLocalServidor TTS autoalojado. Soporta muchos motores (espeak, MaryTTS, Piper, etc.).
System TTSLocalMotor de voz nativo del SO mediante comandos Rust/Tauri (speech-dispatcher en Linux, SAPI en Windows, AVSpeechSynthesizer en macOS).

La selección de proveedor se almacena en un único átomo Jotai (ttsProviderAtom). Cambiar de proveedor es instantáneo — cambia el átomo y la siguiente llamada a speakText() se enruta al nuevo backend.

El desafío: la notación de ajedrez no es lenguaje natural

Sección titulada «El desafío: la notación de ajedrez no es lenguaje natural»

Las jugadas de ajedrez se escriben en Notación Algebraica Estándar (SAN): Nf3, Bxe5+, O-O-O, e8=Q#. Si se envía esto directamente a un motor TTS, el resultado es incoherente — podría intentar pronunciar “Nf3” como una palabra, o leer “O-O-O” como “oh oh oh”.

La solución es un pipeline de preprocesamiento que traduce la notación ajedrecística a lenguaje natural antes de que llegue al motor 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"

La función sanToSpoken() utiliza coincidencia de patrones con expresiones regulares para descomponer cualquier cadena SAN en sus componentes (pieza, desambiguación, captura, destino, promoción, jaque/jaque mate) y los reensambla usando lenguaje natural a partir de una tabla de vocabulario.

El vocabulario ajedrecístico está traducido a muchos idiomas (inglés, francés, español, alemán, japonés, ruso, chino, coreano y más). La tabla CHESS_VOCAB mapea cada término:

English: "Knight takes e5, check"
French: "Cavalier prend e5, échec"
German: "Springer schlägt e5, Schach"
Japanese: "ナイト テイクス e5, チェック"
Russian: "Конь берёт e5, шах"

La configuración de idioma determina qué tabla de vocabulario se usa para el preprocesamiento y qué voz/acento utiliza el motor TTS para la síntesis.

Las anotaciones de partidas suelen contener marcado específico de PGN que sonaría terrible si se leyera en voz alta:

Raw comment: "BLUNDER. 7.Nf3 was better [%eval -2.3] [%cal Gg1f3]"
After cleaning: "7, Knight f3 was better"

La función cleanCommentForTTS():

  1. Elimina etiquetas PGN: [%eval ...], [%csl ...], [%cal ...], [%clk ...]
  2. Elimina palabras de anotación duplicadas (cuando ”??” ya dijo “Blunder”)
  3. Expande SAN en línea dentro del texto: "7.Nf3 controls e5""7, Knight f3 controls e5"
  4. Corrige términos de ajedrez que los motores TTS pronuncian mal (p. ej., “en prise” → “on preez”)
  5. Expande abreviaciones de piezas en el texto: "R vs R""Rook versus Rook"

Cuando avanzas a una nueva jugada, buildNarration() ensambla el texto hablado completo a partir de tres fuentes:

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."

El doble espacio entre partes proporciona a los motores TTS una pausa natural de respiración.

Las llamadas a TTS en la nube cuestan dinero y tardan tiempo (~200-500ms de ida y vuelta). Para evitar volver a obtener el mismo audio, cada clip generado se almacena en caché en memoria como una URL blob:

Cache key: "elevenlabs:pNInz6obpgDQGcFmaJgB:en:12, Knight f3, check."
Cache value: blob:http://localhost/abc123 (the MP3 audio in browser memory)

Si hay coincidencia en la caché, la reproducción es instantánea. La clave de caché incluye provider:voice:language:text, de modo que cambiar de voz o idioma crea entradas separadas.

Para partidas con muchas anotaciones, puedes precachear todo el árbol de la partida en segundo plano. La app recorre cada nodo, construye el texto de narración y realiza llamadas secuenciales a la API para llenar la caché antes de que empieces a navegar.

La navegación rápida con las teclas de flecha crea un problema: si el usuario avanza 5 veces rápidamente, no quieres que 5 clips de audio superpuestos compitan entre sí. La solución es un contador de generación:

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

Cada nueva llamada a speakText() incrementa el contador y aborta cualquier solicitud HTTP en curso mediante AbortController. Cuando llega el audio, comprueba si su generación sigue siendo la actual. Si el usuario ya ha avanzado, la respuesta se descarta silenciosamente. Esto proporciona audio limpio y sin fallos incluso al hacer clic rápidamente entre jugadas.

Los puntos de integración son mínimos:

ArchivoQué sucede
src/state/store/tree.tsCada función de navegación (goToNext, goToPrevious, etc.) llama a stopSpeaking(). Cuando la auto-narración está activada, goToNext también llama a speakMoveNarration().
src/components/common/Comment.tsxUn ícono de altavoz junto a cada comentario permite activar manualmente el TTS para ese comentario.
src/components/settings/TTSSettings.tsxInterfaz de configuración para elegir proveedor, voz, idioma, volumen, velocidad e introducir claves API.

Cuando el TTS está desactivado, nada de este código se ejecuta. La app se comporta de forma idéntica a la versión original de 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 hace el trabajo pesado — motores, base de datos, E/S de archivos, análisis de PGN. React nunca accede al sistema de archivos ni lanza procesos directamente.

  2. Seguridad de tipos a través de la frontera — Specta genera tipos TypeScript a partir de structs Rust, de modo que si un comando Rust cambia su firma, la compilación TypeScript falla inmediatamente.

  3. Dos sistemas de estado — Jotai para estado reactivo simple (configuración, preferencias de UI, estado de motor por pestaña), Zustand para estado de dominio complejo (árbol de partida con ramificaciones y actualizaciones inmutables).

  4. TTS es un problema de preprocesamiento — lo difícil no es llamar a una API de voz, sino traducir la notación de ajedrez y el marcado PGN a texto limpio y natural en múltiples idiomas. Los pipelines sanToSpoken() y cleanCommentForTTS() son donde ocurre el verdadero trabajo.

  5. Cinco proveedores, una interfaz — ya sea que el audio provenga de ElevenLabs, Google Cloud, KittenTTS, OpenTTS o el motor de voz de tu SO, el resto de la app solo llama a speakText(). La selección de proveedor es un simple cambio de átomo.

  6. La compilación produce un único binario en src-tauri/target/release/en-parlant que incluye el backend Rust + los assets del frontend compilados con Vite.