Merge pull request 'docs(plip): design téléphone conversationnel France Télécom' (#154) from docs/plip-telephone-design into main
This commit was merged in pull request #154.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
# PLIP — Téléphone conversationnel France Télécom — Design
|
||||
|
||||
**Date** : 2026-06-14
|
||||
**Statut** : design validé, prêt pour plan d'implémentation
|
||||
**Carte** : PLIP (téléphone rétro), `ESP32_ZACUS/plip_voice/`, ESP32-A1S Audio Kit + SLIC classe AG1171, codec ES8388, IP 192.168.0.138
|
||||
|
||||
## 1. Objectif
|
||||
|
||||
Faire du PLIP un vrai **téléphone France Télécom interactif** dans l'escape room « Mystère du Professeur Zacus » : le joueur décroche, entend la tonalité, **compose un numéro** sur le cadran, et tombe sur un PNJ vocal (Professeur Zacus ou un service d'époque) avec qui il **converse** (reconnaissance vocale → réponse IA en personnage). Raccroché = fin + silence.
|
||||
|
||||
## 2. Architecture — Approche retenue (A)
|
||||
|
||||
Le **firmware PLIP possède la machine à états** (la « boucle » téléphonique) ; la **gateway** expose **un endpoint stateless-par-tour** qui enchaîne STT → LLM → TTS. Le PLIP ne fait ni TLS, ni prompt, ni resample.
|
||||
|
||||
```
|
||||
COMBINÉ PLIP (firmware = machine à états)
|
||||
raccroché → décroché → TONALITÉ → numérotation → routage annuaire
|
||||
→ ringback → accueil PNJ → [ ÉCOUTE(VAD) → /v1/voice/turn → JOUE ]* → raccroché→IDLE+mute
|
||||
│
|
||||
└─ HTTP simple (plain, LAN) ─────────────► ZACUS-GATEWAY /v1/voice/turn (stateless/tour)
|
||||
stt(whisper) → chat(ailiance + persona + historique) → tts(ailiance) → resample 16k
|
||||
│ │ │
|
||||
whisper :8300 gateway.ailiance.fr /v1/chat/completions .../v1/audio/speech
|
||||
(À RELANCER) ✅ Ministral & co ✅ tts-1
|
||||
```
|
||||
|
||||
**Frontière** :
|
||||
- **PLIP firmware** : machine à états, `session_id` au décroché, capture (réutilise `/voice/capture` interne), POST le WAV, joue la réponse, reboucle ; raccroché = abort + mute (réutilise le mute-on-hook existant). Aucun TLS / prompt / resample sur l'ESP32.
|
||||
- **Gateway** : pipeline IA d'un tour + **historique court par `session_id`** (mémoire, TTL 10 min). Persona + voix + annuaire = côté serveur, éditables sans reflasher.
|
||||
|
||||
**Décision** : **un seul appel `/v1/voice/turn` par tour** (PLIP envoie le WAV capté, reçoit le WAV de réponse) plutôt que 3 appels stt/chat/tts séparés → firmware bien plus simple, le PLIP garde la maîtrise du séquencement (quand écouter, stop VAD, jouer, reboucler, abort).
|
||||
|
||||
## 3. Séquence téléphonique (machine à états)
|
||||
|
||||
```
|
||||
IDLE (raccroché, PA coupé, muet)
|
||||
└─ DÉCROCHÉ (SHK haut)
|
||||
→ DIAL_TONE : tonalité 440 Hz continu (FR) en boucle, attend le 1er chiffre
|
||||
→ DIALING : collecte les chiffres (coupe la tonalité au 1er), fin = numéro complet (match annuaire)
|
||||
OU timeout inter-chiffre ~3 s
|
||||
→ ROUTING : recherche du numéro dans l'annuaire
|
||||
├─ numéro connu → RINGBACK (440 Hz 1,5 s/3,5 s) → CONNECT
|
||||
│ → GREET (kind=greeting) → [ LISTEN(VAD) → /v1/voice/turn(kind=reply) → SPEAK ]*
|
||||
└─ numéro inconnu → tonalité « non attribué »/occupé (440 Hz 0,5/0,5) ~5 s → IDLE
|
||||
→ RACCROCHÉ (SHK bas) à tout instant → IDLE + mute (abort tour en cours)
|
||||
```
|
||||
|
||||
### Tonalités France Télécom (générées localement, pas de fichier)
|
||||
- **Tonalité (invitation à numéroter)** : 440 Hz continu.
|
||||
- **Occupé / non attribué** : 440 Hz, 0,5 s on / 0,5 s off.
|
||||
- **Retour d'appel (ringback)** : 440 Hz, 1,5 s on / 3,5 s off.
|
||||
- **Handshake Minitel** (pour 3615) : sample audio embarqué (le « bip strident » de connexion).
|
||||
|
||||
La tonalité/ringback passe par l'ampli PA → **forcer le PA on dès le décroché** (cohérent avec le mute-on-hook : décroché ⇒ PA on).
|
||||
|
||||
## 4. Numérotation — module `dialer`
|
||||
|
||||
Abstraction qui émet des événements `digit(0–9)` + `dial_complete`, **3 sources** combinables (Kconfig) :
|
||||
|
||||
- **Impulsions (cadran)** — `CONFIG_PLIP_DIAL_PULSE` : comptage d'impulsions sur le **pin SHK** du SLIC (la boucle s'ouvre/ferme pendant la numérotation au cadran ; 1 impulsion=1 … 10=0), gap inter-chiffre. **Bench-testable** avec un téléphone à cadran vintage branché sur le SLIC.
|
||||
- **DTMF (touches)** — `CONFIG_PLIP_DIAL_DTMF` : décodage **Goertzel** des 8 fréquences DTMF sur l'audio capté via le SLIC. Bench-testable (dépend du chemin micro).
|
||||
- **Debug/banc** — toujours dispo : `POST /debug/dial?number=NNNN` (+ option bouton BOOT = 1 chiffre) pour CI/validation sans téléphone physique.
|
||||
|
||||
Le SLIC classe **AG1171** fournit : hook + impulsions sur **SHK** (le hook GPIO actuel = SHK), audio combiné via le codec ES8388, sonnerie via les pins `RM`/`F-R`. Le mute-on-hook existant mappe exactement (SHK bas = raccroché = muet).
|
||||
|
||||
## 5. Annuaire (`game/scenarios/phone_directory.yaml`)
|
||||
|
||||
Table côté gateway : `numéro → { persona, voice, minitel? }`. Numéros supportés (réf. France Télécom) :
|
||||
|
||||
| Numéro | Service | Comportement |
|
||||
|---|---|---|
|
||||
| **12** | Renseignements (l'annuaire) | opératrice/standard : oriente, indice « quel numéro appeler » |
|
||||
| **3615** | Minitel (kiosque Télétel) | **handshake modem Minitel** en intro, puis agent vocal spécial « service Minitel » (persona datée/robotique), même boucle `/voice/turn` |
|
||||
| **15** | SAMU | PNJ secours médical en personnage |
|
||||
| **17** | Police secours | PNJ police |
|
||||
| **18** | Sapeurs-pompiers | PNJ pompiers |
|
||||
|
||||
Le numéro de **Professeur Zacus** (persona principale) est une entrée d'annuaire supplémentaire, numéro configurable. Le contenu des personas (caractère, ton, voix, anti-triche) est rempli par l'auteur après implémentation ; l'annuaire est extensible (autres PNJ = ajout d'entrées, sans reflasher). Numéro absent de l'annuaire → tonalité « non attribué ».
|
||||
|
||||
## 6. Contrat `/v1/voice/turn`
|
||||
|
||||
`POST /v1/voice/turn` (sous token) :
|
||||
|
||||
| Entrée | Type | Rôle |
|
||||
|---|---|---|
|
||||
| `session_id` | string | généré par le PLIP au décroché ; clé de l'historique |
|
||||
| `number` | string | le numéro composé → choix persona/voix dans l'annuaire |
|
||||
| `kind` | `"greeting"` \| `"reply"` | `greeting` = ouverture (pas d'audio) ; `reply` = tour normal |
|
||||
| `audio` (corps) | WAV 16k mono | la parole captée (absent si `kind=greeting`) |
|
||||
|
||||
**Sortie** : `200`, `Content-Type: audio/wav` = WAV 16k **prêt à jouer**. En-têtes debug : `X-Zacus-Heard` (transcription) + `X-Zacus-Said` (réponse texte).
|
||||
|
||||
`POST /v1/voice/end {session_id}` → purge l'historique (best-effort ; sinon TTL 10 min).
|
||||
|
||||
**Historique serveur** : `dict[session_id] → messages[]` (system persona du numéro + alternance user/assistant), borné aux ~8 derniers tours, purgé sur `/v1/voice/end` ou TTL.
|
||||
|
||||
### Flux d'un appel
|
||||
```
|
||||
1. numéro routé → POST /turn {session_id, number, kind:greeting}
|
||||
→ serveur : chat(persona accueil) → TTS → WAV → PLIP joue l'accueil
|
||||
2. PLIP capture le micro (VAD : démarre à la voix, stop au silence ~800 ms, cap ~8 s)
|
||||
3. POST /turn {session_id, number, kind:reply, audio:WAV}
|
||||
→ serveur : whisper(audio)→texte ; append historique ; chat(persona+historique)→réponse ; TTS→resample 16k → WAV
|
||||
4. PLIP joue la réponse → retour étape 2
|
||||
5. raccroché → abort + mute ; POST /v1/voice/end (best-effort)
|
||||
```
|
||||
|
||||
## 7. Services IA (état constaté 2026-06-14)
|
||||
|
||||
- **LLM (NPC brain)** : `gateway.ailiance.fr /v1/chat/completions` ✅ (Ministral & autres modèles ailiance).
|
||||
- **TTS** : `gateway.ailiance.fr /v1/audio/speech` ✅ (model `tts-1`, voix `nova`/`alloy` ; sortie WAV 22050 Hz → resample 16k côté gateway, réutilise `_wav_to_16k_mono`).
|
||||
- **STT** : **absent d'ailiance** (`/v1/audio/transcriptions` → 404). À fournir par whisper.cpp `:8300` ou la voice-bridge `/voice/transcribe` (studio:8200, **actuellement down**).
|
||||
|
||||
## 8. Dépendances bloquantes (honnêtes)
|
||||
|
||||
1. **Micro = vrai audio** : la capture renvoie aujourd'hui des zéros. Hypothèse forte : le SLIC ne connecte le chemin audio du combiné que **décroché**, et/ou l'entrée ES8388 utilisée n'est pas celle câblée à la sortie audio du SLIC. **Vérif à coût quasi nul** : téléphone branché + décroché → `/voice/capture` → RMS ≠ 0. Sinon, basculer l'entrée ES8388 (LIN2/RIN2 vs LIN1/RIN1) selon le câblage SLIC.
|
||||
2. **Endpoint whisper vivant** : relancer la voice-bridge sur studio OU whisper.cpp `:8300` OU exposer un endpoint STT sur la gateway.
|
||||
|
||||
## 9. Mode dégradé (sans micro / sans STT)
|
||||
|
||||
Le téléphone marche **en sens unique sans ces deux dépendances** : tonalité, numérotation, routage annuaire, ringback, **et chaque PNJ débite sa réplique d'accueil (TTS)**. La boucle d'écoute (LISTEN→STT) est sautée. → **un téléphone-annuaire France Télécom jouable est livrable AVANT le micro/whisper.**
|
||||
|
||||
## 10. Gestion d'erreur
|
||||
|
||||
- **STT vide/échec** → la gateway renvoie un WAV de relance (« Allô ? je vous entends mal, répétez ? », par persona) ; la boucle continue.
|
||||
- **Erreur réseau/ailiance** → WAV de repli (« la ligne est mauvaise… »).
|
||||
- **Raccroché en plein tour** → le PLIP abort le HTTP en cours (best-effort), stoppe l'audio, mute, IDLE.
|
||||
- **Numéro inconnu** → tonalité « non attribué » → IDLE.
|
||||
- **Taille réponse** capée ~7 s (240 KiB), cohérent avec `/v1/voice/say` et le `MAX_BODY` 256K du PLIP (upload/playback streaming).
|
||||
|
||||
## 11. Composants & fichiers
|
||||
|
||||
**Firmware PLIP** (`ESP32_ZACUS/plip_voice/main/`) :
|
||||
- `dialer.c/.h` (neuf) : impulsions SHK + DTMF Goertzel + `/debug/dial` → événements digit/dial_complete.
|
||||
- `tones.c/.h` (neuf) : tonalités FR + sample handshake Minitel.
|
||||
- `conversation.c/.h` (neuf) : machine à états complète, `session_id`, capture→POST `/v1/voice/turn`→play, abort au raccroché.
|
||||
- extension `phone.c` : les événements hook alimentent `conversation` ; SHK pulse → `dialer`.
|
||||
- client HTTP : POST du WAV capté à la gateway, réception du WAV de réponse.
|
||||
- réutilise : `/voice/capture` (mic+VAD), play streaming, mute-on-hook, ring.
|
||||
|
||||
**Gateway** (`tools/zacus-gateway/`) :
|
||||
- `POST /v1/voice/turn`, `POST /v1/voice/end`.
|
||||
- chargement `game/scenarios/phone_directory.yaml` (annuaire → persona/voix).
|
||||
- store de sessions en mémoire (TTL).
|
||||
- réutilise `_synthesise_wav(ailiance)` + `_wav_to_16k_mono`.
|
||||
|
||||
## 12. Étapes de build (incrémental, chaque étape livrable)
|
||||
|
||||
1. **Tonalités + dialer + machine à états** jusqu'à RINGBACK — testable au son, **zéro IA**.
|
||||
2. **Gateway `/v1/voice/turn` voie greeting + annuaire** → composer un numéro = **entendre le PNJ parler** (sens unique, sans micro/whisper). **Livrable jouable.**
|
||||
3. **Vérif micro (SLIC décroché) + whisper** → **conversation deux sens**.
|
||||
4. **3615 Minitel** (handshake + agent robotique).
|
||||
|
||||
## 13. Tests
|
||||
|
||||
- **Gateway** (unit) : `/v1/voice/turn` avec WAV fixture → routage persona par numéro, accumulation historique, voie greeting, fallbacks d'erreur (ailiance mocké ou live) ; chargement annuaire.
|
||||
- **Firmware** (banc, téléphone à cadran via SLIC) : décroché→tonalité ; composer 17→ringback→accueil police ; parler→réponse (gated micro) ; 3615→handshake Minitel→agent robotique ; numéro inconnu→occupé ; raccroché→silence. Impulsions via SHK ; DTMF Goertzel ; `/debug/dial` pour CI.
|
||||
- **Dégradé** : STT down → accueil + fallback fonctionnent.
|
||||
Reference in New Issue
Block a user