Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 128 additions & 26 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,46 +68,148 @@ Chaque backend implemente ce trait. Le dispatch se fait via `get_backend(name)`
- **macOS**: `say` (zero latence, voix systeme)
- **Linux / Windows**: `kokoro` (Rust pur, pas de dependance Python)

## SpeakOptions

Structure centrale passee a chaque backend via `TtsBackend::speak()`. Tous les champs sont optionnels — les backends ignorent ceux qu'ils ne supportent pas.

```rust
pub struct SpeakOptions {
pub voice: Option<String>, // Nom de voix (ex: "Chelsie", "af_heart")
pub lang: Option<String>, // Code langue ISO (ex: "fr", "en")
pub rate: Option<u32>, // Debit en mots/min (say uniquement)
pub gender: Option<String>, // "feminine" | "masculine"
pub style: Option<String>, // "calm" | "energetic" | "warm" | ...
pub ref_audio: Option<String>, // Chemin audio pour voice cloning
pub ref_text: Option<String>, // Transcription de l'audio de reference
pub model: Option<String>, // Model ID (ex: Qwen/Qwen3-TTS-12Hz-0.6B-Base)
}
```

**Resolution de priorite** : flags CLI / params MCP > preferences DB > valeurs par defaut du backend.

## Resolution du voice cloning

Quand un utilisateur demande `-v patrick` (ou `voice: "patrick"` via MCP), le systeme :

1. Cherche un clone nomme `patrick` dans la table `voice_clones` via `clone::resolve_voice()`
2. Si trouve : extrait `ref_audio` et `ref_text` du clone
3. Verifie le backend courant — si c'est `say` ou `kokoro` (qui ne supportent pas le cloning), **bascule automatiquement** :
- **macOS** : vers `qwen` (MLX-Audio Python)
- **Linux / Windows** : vers `qwen-native` (Rust pur)
4. Met `voice = None` (ne pas passer le nom du clone comme voix au backend)
5. Passe `ref_audio` + `ref_text` dans `SpeakOptions`

Ce mecanisme est identique dans le CLI (`main.rs::handle_speak`) et le serveur MCP (`mcp.rs::tool_speak`).

## Playback audio asynchrone (PlayHandle)

Le module `audio.rs` fournit deux modes de lecture via `rodio` :

- **`play_audio_blocking(path)`** — bloque le thread jusqu'a la fin de la lecture. Supporte WAV, MP3, OGG, FLAC.
- **`play_wav_async(path)`** — lance la lecture dans un thread et retourne un `PlayHandle`.

```rust
pub struct PlayHandle {
join: Option<thread::JoinHandle<Result<()>>>,
}

impl PlayHandle {
pub fn wait(self) -> Result<()>; // Bloque jusqu'a la fin
}

impl Drop for PlayHandle {
fn drop(&mut self); // Attend la fin du thread au drop
}
```

Le pattern `PlayHandle` est utilise par le backend `qwen` pour le pipeline de chunking : pendant que le chunk N est joue, le chunk N+1 est genere en parallele.

## Pipeline de decoupage par phrases (qwen backend)

Le backend `qwen` decoupe le texte long en phrases pour reduire la latence percue :

1. **Split** : decoupe sur `.` `!` `?` `;`
2. **Merge** : fusionne les petites phrases consecutives tant que `len < MIN_CHUNK_CHARS` (120 caracteres) — pour reduire le nombre d'appels subprocess Python
3. **Pipeline** :
- Si 1 seul chunk : appel direct avec `--play --stream` (latence optimale)
- Si N chunks : pipeline chevauche (overlap generation + playback) :
- Genere chunk 0 → joue chunk 0 (async) + genere chunk 1 en parallele
- Quand chunk 1 genere → attend fin chunk 0 → joue chunk 1 + genere chunk 2...
- Resultat : la latence inter-chunks est masquee

## Base de donnees

SQLite via `rusqlite` avec WAL mode. Fichier: `~/.config/vox/vox.db`.

### Schema
### Schema DDL complet

```sql
-- Preferences utilisateur (une seule ligne, UPSERT)
CREATE TABLE preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
backend TEXT, voice TEXT, lang TEXT, rate INTEGER,
gender TEXT, style TEXT, model TEXT, pack TEXT
CREATE TABLE IF NOT EXISTS preferences (
id INTEGER PRIMARY KEY CHECK (id = 1),
backend TEXT,
voice TEXT,
lang TEXT,
rate INTEGER,
gender TEXT,
style TEXT,
model TEXT
);

-- Voice clones
CREATE TABLE voice_clones (
name TEXT PRIMARY KEY,
ref_audio TEXT NOT NULL,
ref_text TEXT,
created_at TEXT DEFAULT (datetime('now'))
-- Migration ajoutee dynamiquement pour les bases existantes :
-- ALTER TABLE preferences ADD COLUMN pack TEXT;

CREATE TABLE IF NOT EXISTS usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now')),
backend TEXT NOT NULL,
voice TEXT,
lang TEXT,
text_len INTEGER NOT NULL,
duration_ms INTEGER
);

-- Journal d'utilisation
CREATE TABLE usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT DEFAULT (datetime('now')),
backend TEXT NOT NULL,
voice TEXT, lang TEXT,
text_len INTEGER NOT NULL,
duration_ms INTEGER
CREATE TABLE IF NOT EXISTS voice_clones (
name TEXT PRIMARY KEY,
ref_audio TEXT NOT NULL,
ref_text TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S','now'))
);
```

**Migration** : la colonne `pack` sur `preferences` est ajoutee via `ALTER TABLE` si absente (detection par `SELECT pack FROM preferences LIMIT 0`). Cela permet la compatibilite avec les bases creees avant cette fonctionnalite.

**UPSERT** : `set_preference()` insere d'abord une ligne vide avec `ON CONFLICT(id) DO NOTHING`, puis fait `UPDATE`. La contrainte `CHECK (id = 1)` garantit une seule ligne.

### Requetes d'agregation

- `get_usage_summary()` — total calls + total chars
- `get_backend_stats()` — calls, chars, duration par backend
- `get_lang_stats()` — calls par langue
- `get_total_duration_ms()` — temps de parole cumule
- `get_usage_stats()` — 50 dernieres entrees
| Fonction | Requete | Retour |
|----------|---------|--------|
| `get_usage_summary()` | `SELECT COUNT(*), SUM(text_len)` | `(u64, u64)` — total calls + total chars |
| `get_backend_stats()` | `GROUP BY backend` + COUNT/SUM | `Vec<BackendStats>` — calls, chars, duration par backend |
| `get_lang_stats()` | `GROUP BY lang` + COUNT | `Vec<LangStats>` — calls par langue |
| `get_total_duration_ms()` | `SUM(duration_ms)` | `u64` — temps de parole cumule en ms |
| `get_usage_stats()` | `ORDER BY id DESC LIMIT 50` | `Vec<UsageEntry>` — 50 dernieres entrees |

## Strategie de securite

| Vecteur | Protection | Implementation |
|---------|-----------|----------------|
| SQL injection | Parametres lies (`?1`, `?2`, ...) | `rusqlite::params![]` partout, jamais d'interpolation de valeurs utilisateur |
| Cles de preferences invalides | Whitelist validee | `set_preference()` valide `key` contre `["backend", "voice", "lang", "rate", "gender", "style", "model", "pack"]` |
| Valeurs de preferences invalides | Validation par type/enum | `gender` → `Gender::parse()`, `style` → `IntonationStyle::parse()`, `rate` → `parse::<u32>()`, `lang` → `SUPPORTED_LANGS.contains()`, `backend` → whitelist plateforme |
| Path traversal (audio) | Extensions validees | `validate_audio()` verifie existence + extension dans `[wav, mp3, flac, ogg, m4a]` |
| Injection shell | Pas de `sh -c` | Toutes les commandes externes via `std::process::Command` avec args separes |
| Backends invalides | Enum par plateforme | macOS: `[kokoro, say, qwen, qwen-native]`, autres: `[kokoro, qwen-native]` |

## Latence par backend : cold start vs warm start

| Backend | Cold start | Warm start | Notes |
|---------|-----------|------------|-------|
| `say` | ~100ms | ~100ms | Pas de modele a charger, appel systeme direct |
| `kokoro` | ~5-8s | ~2-5s | Chargement ONNX + voices.bin via Python. Pas de cache persistent |
| `qwen` | ~5-15s | ~1-2s | Cold = telechargement modele (~1.2 GB) + chargement Python. Warm = Python startup seul |
| `qwen-native` | ~10-30s | ~2-5s | Cold = telechargement HuggingFace + chargement candle. Warm = modele en memoire (`static Mutex<Option<Qwen3TTS>>`) |

**Note** : `qwen-native` garde le modele en memoire via un `Mutex` global — les appels suivants au meme processus (ex: serveur MCP) sont donc en warm start. Les appels CLI individuels sont toujours en cold start.

## Protocole MCP

Expand Down
73 changes: 71 additions & 2 deletions docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,39 @@ vox config set gender feminine # Genre vocal
vox config set style warm # Style d'intonation
vox config set rate 180 # Debit (say uniquement)
vox config set model <model_id> # Modele TTS specifique
vox config set pack peon # Sound pack actif
vox config reset # Reinitialiser tout
```

Priorite de resolution : **flags CLI > preferences DB > valeurs par defaut**.
Priorite de resolution : **flags CLI / params MCP > preferences DB > valeurs par defaut**.

### Reference des cles de preferences

| Cle | Valeurs acceptees | Validation |
|-----|-------------------|-----------|
| `backend` | macOS: `kokoro`, `say`, `qwen`, `qwen-native` / Autres: `kokoro`, `qwen-native` | Whitelist par plateforme |
| `voice` | Nom de voix ou de clone (texte libre) | Aucune (le backend valide au moment du speak) |
| `lang` | `en`, `fr`, `es`, `de`, `it`, `pt`, `zh`, `ja`, `ko`, `ru`, `ar`, `nl` | Validation contre `SUPPORTED_LANGS` |
| `rate` | Entier positif (mots/min, ex: `150`, `200`) | Parse en `u32`, erreur si non-numerique |
| `gender` | `feminine`, `masculine` | Parse via `Gender::parse()`, erreur sinon |
| `style` | `calm`, `energetic`, `warm`, `authoritative`, `cheerful`, `serious` | Parse via `IntonationStyle::parse()`, erreur sinon |
| `model` | ID de modele HuggingFace (texte libre, ex: `mlx-community/Qwen3-TTS-12Hz-0.6B-Base-4bit`) | Aucune (le backend valide au chargement) |
| `pack` | Nom de pack installe (texte libre) | Aucune (verifie a l'utilisation) |

### Matrice des capacites par backend

| Capacite | `say` | `kokoro` | `qwen` | `qwen-native` |
|----------|-------|----------|--------|----------------|
| Voice cloning | Non | Non | Oui | Oui |
| Rate (debit) | Oui (`-r`) | Non | Non | Non |
| Gender hint | Non | Non | Oui | Oui |
| Style hint | Non | Non | Oui | Oui |
| Choix de voix | Oui (voix Apple) | Oui (prefixe `xx_nom`) | Oui (Chelsie, Aidan, Luna, Ryan) | Non (clones uniquement) |
| Choix de modele | Non | Non | Non | Oui |
| Langues | Toutes (via voix) | en, fr, ja, zh, ko, hi, it, pt, de, es | en, fr, es, de, it, pt, zh, ja, ko, ru, ar, nl | en, fr, es, de, it, pt, zh, ja, ko, ru |
| Plateforme | macOS | Toutes | macOS (Apple Silicon) | Toutes |
| GPU | Non | Non | Non (CPU MLX) | Metal (macOS) / CUDA (Linux) |
| Dependance externe | Aucune | `kokoro-onnx`, `soundfile` (Python) | `mlx-audio` (Python) | Aucune (Rust pur) |

## Sound packs

Expand All @@ -85,7 +114,7 @@ vox pack play error -p peon_fr # Jouer depuis un pack specifique
vox pack remove peon # Desinstaller un pack
```

Categories de sons : greeting, acknowledge, complete, error, permission, annoyed.
Categories de sons : greeting, acknowledge, complete, error, permission, resource_limit, annoyed.

## Statistiques d'utilisation

Expand Down Expand Up @@ -135,6 +164,15 @@ vox init -m all # Les trois modes

L'init est idempotent : relancer `vox init` ne duplique pas les configurations.

### Comparaison des modes d'init

| Mode | Ce qu'il fait | Quand l'utiliser |
|------|--------------|-----------------|
| `mcp` (defaut) | Configure le serveur MCP dans les fichiers de config de 14 outils IA | L'assistant appelle `vox_speak`, `vox_hear`, etc. nativement via le protocole MCP. Meilleure integration. |
| `cli` | Cree `CLAUDE.md` + hook `Stop` dans `.claude/settings.json` | L'assistant appelle `vox` via bash. Plus simple mais moins de fonctionnalites (pas de STT, stats, etc.). |
| `skill` | Cree `/speak` dans `~/.claude/commands/speak.md` | L'utilisateur invoque manuellement `/speak <texte>` dans Claude Code. |
| `all` | Les trois modes combines | Maximum de compatibilite. |

### Mode CLI

Cree un `CLAUDE.md` dans le projet courant avec des instructions pour que l'assistant appelle `vox` apres les taches significatives. Ajoute un hook `Stop` dans `.claude/settings.json` qui dit "Termine" a la fin de chaque reponse.
Expand All @@ -143,6 +181,37 @@ Cree un `CLAUDE.md` dans le projet courant avec des instructions pour que l'assi

Cree une commande `/speak` dans `~/.claude/commands/speak.md` pour invoquer vox via slash command.

## Serveur MCP (`vox serve`)

Lance le serveur MCP sur stdio (JSON-RPC 2.0, protocole `2024-11-05`). C'est cette commande que les outils IA appellent apres `vox init`.

```bash
vox serve # Lance le serveur (bloque, lit stdin, ecrit stdout)
```

Le serveur est normalement lance automatiquement par l'outil IA. Il n'est pas necessaire de le lancer manuellement, sauf pour du debug.

### Reference complete des 14 outils MCP

| Outil | Description | Parametres |
|-------|-------------|------------|
| `vox_speak` | Synthetise et joue du texte | **`text`** (requis, string) : texte a prononcer. `voice` (string) : nom de voix ou clone. `lang` (string) : code langue. `backend` (string) : kokoro/say/qwen/qwen-native. `style` (string) : calm/energetic/warm/authoritative/cheerful/serious. `gender` (string) : feminine/masculine. `rate` (integer) : debit mots/min (say uniquement). |
| `vox_list_voices` | Liste les voix d'un backend | `backend` (string) : kokoro/say/qwen/qwen-native. Defaut : backend par defaut de la plateforme. |
| `vox_clone_list` | Liste les voice clones | Aucun parametre. |
| `vox_clone_add` | Ajoute un voice clone | **`name`** (requis, string) : nom du clone. **`audio`** (requis, string) : chemin du fichier audio de reference. `text` (string) : transcription de l'audio (ameliore la qualite). |
| `vox_clone_remove` | Supprime un voice clone | **`name`** (requis, string) : nom du clone a supprimer. |
| `vox_config_show` | Affiche les preferences | Aucun parametre. Retourne : backend, voice, lang, rate, gender, style, model, pack. |
| `vox_config_set` | Modifie une preference | **`key`** (requis, string) : cle (backend/voice/lang/rate/gender/style/model). **`value`** (requis, string) : valeur. |
| `vox_stats` | Statistiques d'utilisation | Aucun parametre. Retourne : total requests, total chars, 10 dernieres entrees. |
| `vox_pack_list` | Liste les sound packs | Aucun parametre. Retourne : packs installes (avec actif marque) + disponibles. |
| `vox_pack_install` | Installe un sound pack | **`name`** (requis, string) : nom du pack (peon, peon_fr, peon_pl, peasant, peasant_fr, sc_kerrigan, sc_battlecruiser, ra2_soviet_engineer). |
| `vox_pack_set` | Active un sound pack | **`name`** (requis, string) : nom du pack installe. |
| `vox_pack_play` | Joue un son d'un pack | `category` (string, defaut: "greeting") : greeting/acknowledge/complete/error/permission/resource_limit/annoyed. `pack` (string) : nom du pack (utilise le pack actif si omis). |
| `vox_pack_remove` | Supprime un sound pack | **`name`** (requis, string) : nom du pack. Si le pack supprime etait actif, le pack actif est remis a vide. |
| `vox_hear` | Enregistre et transcrit (STT) | `lang` (string, defaut: "fr") : code langue. `timeout` (integer, defaut: 30) : duree max en secondes. `silence` (number, defaut: 2.0) : secondes de silence avant arret. macOS uniquement. |

Les parametres en **gras** sont requis. Le serveur renvoie `isError: true` si un parametre requis est manquant ou invalide.

## Speech-to-Text (macOS)

Transcription locale via mlx-whisper.
Expand Down
Loading
Loading