From 5da6ec4c11e06f351bb424c4abbf0f9987c69178 Mon Sep 17 00:00:00 2001 From: electron-rare Date: Sun, 14 Jun 2026 21:23:00 +0200 Subject: [PATCH] =?UTF-8?q?docs(plip):=20implementation=20plan=20=E2=80=94?= =?UTF-8?q?=20t=C3=A9l=C3=A9phone=20conversationnel=20(4=20stages,=20TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-06-14-plip-telephone-conversation.md | 948 ++++++++++++++++++ 1 file changed, 948 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-14-plip-telephone-conversation.md diff --git a/docs/superpowers/plans/2026-06-14-plip-telephone-conversation.md b/docs/superpowers/plans/2026-06-14-plip-telephone-conversation.md new file mode 100644 index 0000000..a784225 --- /dev/null +++ b/docs/superpowers/plans/2026-06-14-plip-telephone-conversation.md @@ -0,0 +1,948 @@ +# PLIP — Téléphone conversationnel France Télécom — Plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Faire du PLIP un téléphone France Télécom interactif : décroché → tonalité → numérotation (cadran/DTMF) → annuaire → PNJ vocal conversationnel (Zacus & services d'époque). + +**Architecture:** Le firmware PLIP possède la machine à états téléphonique ; la gateway expose `POST /v1/voice/turn` (stateless/tour) qui enchaîne whisper(STT) → ailiance(chat) → ailiance(TTS) → resample 16k. Le PLIP ne fait ni TLS, ni prompt, ni resample. + +**Tech Stack:** ESP-IDF (esp32, plip_voice : net.c httpd, audio.c I2S/ES8388, phone.c hook), SLIC classe AG1171 (SHK = hook+impulsions). Gateway FastAPI/httpx (`tools/zacus-gateway/main.py`), pytest. TTS+LLM via `gateway.ailiance.fr` (OpenAI-compatible). + +**Spec:** `docs/superpowers/specs/2026-06-14-plip-telephone-conversation-design.md` + +**Référence (numéros de ligne au moment de la rédaction) :** patterns dans `ESP32_ZACUS/plip_voice/main/{net.c,audio.c,phone.c,cmd_exec.c,hook_client.c,Kconfig.projbuild,board_config.h,CMakeLists.txt}` et `tools/zacus-gateway/main.py` (`_synthesise_wav` ~l.701, `_wav_to_16k_mono` ~l.734, route `/v1/voice/say` ~l.1358). + +**Convention build/flash firmware :** `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash`. Série : `/dev/cu.usbserial-0001` (un lecteur à la fois). PLIP IP 192.168.0.138. + +**Convention commits firmware (submodule) :** `git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "..."`. JAMAIS `--no-verify`. Commits gateway : depuis la racine repo. + +--- + +## STAGE 1 — Tonalités + dialer + machine à états (firmware, zéro IA) + +Livrable : décroché → tonalité 440 Hz ; composer un numéro (debug HTTP + impulsions SHK) → ringback si numéro connu / occupé sinon ; raccroché = mute. Aucune conversation encore. + +### Task 1.1 : Module `tones` (tonalités France Télécom) + +**Files:** +- Create: `ESP32_ZACUS/plip_voice/main/tones.h` +- Create: `ESP32_ZACUS/plip_voice/main/tones.c` +- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt` (ajouter `tones.c` aux SRCS) + +- [ ] **Step 1 : Écrire `tones.h`** + +```c +#pragma once +#include "esp_err.h" +// Tonalités France Télécom — générées via le DAC/I2S, non bloquant (tâche dédiée). +// Toutes forcent le PA on (audio_pa_set(true)) le temps de jouer. +void tones_dialtone_start(void); // 440 Hz continu (invitation à numéroter) +void tones_busy_start(void); // 440 Hz 0,5 s on / 0,5 s off (occupé / non attribué) +void tones_ringback_start(void); // 440 Hz 1,5 s on / 3,5 s off (retour d'appel) +void tones_stop(void); // arrête toute tonalité en cours +``` + +- [ ] **Step 2 : Écrire `tones.c`** + +Génère un sinus 440 Hz en PCM 16 kHz mono et l'écrit en boucle sur l'I2S TX (`audio_spk_handle()`), avec un masque on/off selon la tonalité. Une seule tâche FreeRTOS `s_tone_task`, un `volatile tone_mode_t s_mode` (NONE/DIAL/BUSY/RINGBACK). `tones_stop` met NONE + `audio_pa_set` géré par l'appelant (la machine à états). Modèle : la génération de tone dans `audio.c::audio_play_tone` (sinus + `i2s_channel_write` sur `audio_spk_handle()`). + +```c +#include "tones.h" +#include "audio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/i2s_std.h" +#include +#include + +typedef enum { TONE_NONE, TONE_DIAL, TONE_BUSY, TONE_RINGBACK } tone_mode_t; +static volatile tone_mode_t s_mode = TONE_NONE; +static TaskHandle_t s_task; +#define SR 16000 +#define FRAME 320 // 20 ms + +static void fill_440(int16_t *buf, int n, int *phase) { + for (int i = 0; i < n; i++) { + buf[i] = (int16_t)(8000.0f * sinf(2.0f * (float)M_PI * 440.0f * (*phase) / SR)); + (*phase)++; + } +} + +static void tone_task(void *arg) { + int16_t buf[FRAME]; + int phase = 0; + int64_t t = 0; // ms écoulées dans le cycle on/off + for (;;) { + tone_mode_t m = s_mode; + if (m == TONE_NONE) { vTaskDelay(pdMS_TO_TICKS(20)); t = 0; continue; } + bool on = true; + if (m == TONE_BUSY) on = (t % 1000) < 500; + else if (m == TONE_RINGBACK) on = (t % 5000) < 1500; + if (on) fill_440(buf, FRAME, &phase); + else memset(buf, 0, sizeof(buf)); + size_t w; + i2s_channel_write(audio_spk_handle(), buf, sizeof(buf), &w, portMAX_DELAY); + t += 20; + } +} + +static void ensure_task(void) { + if (!s_task) xTaskCreate(tone_task, "tones", 3072, NULL, 5, &s_task); +} +void tones_dialtone_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_DIAL; } +void tones_busy_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_BUSY; } +void tones_ringback_start(void){ audio_pa_set(true); ensure_task(); s_mode = TONE_RINGBACK; } +void tones_stop(void){ s_mode = TONE_NONE; } +``` + +- [ ] **Step 3 : Ajouter `tones.c` au CMakeLists** + +Dans `ESP32_ZACUS/plip_voice/main/CMakeLists.txt`, ajouter `"tones.c"` dans `SRCS` (à côté de `"audio.c"`). + +- [ ] **Step 4 : Build** + +Run: `. ~/esp/esp-idf/export.sh ; cd ESP32_ZACUS/plip_voice ; idf.py build` +Expected: build vert, `tones.c` compilé. + +- [ ] **Step 5 : Commit** + +```bash +git -C ESP32_ZACUS -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -am "feat(plip): tones module — France Télécom dial/busy/ringback" +``` + +### Task 1.2 : Module `dialer` (numéro composé : debug + impulsions SHK) + +**Files:** +- Create: `ESP32_ZACUS/plip_voice/main/dialer.h` +- Create: `ESP32_ZACUS/plip_voice/main/dialer.c` +- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt` +- Modify: `ESP32_ZACUS/plip_voice/main/net.c` (handler `/debug/dial`) + +- [ ] **Step 1 : Écrire `dialer.h`** + +```c +#pragma once +#include "esp_err.h" +#include +// Le dialer accumule des chiffres (source : impulsions SHK, DTMF, ou /debug/dial) +// et expose le numéro courant. La machine à états (conversation.c) lit +// dialer_current()/dialer_complete() et appelle dialer_reset() à la fin d'un appel. +void dialer_init(void); +void dialer_push_digit(int d); // 0..9 ; appelé par les sources (debug, pulse, dtmf) +void dialer_reset(void); // vide le numéro (raccroché / fin d'appel) +const char *dialer_current(void); // ex. "17" (string null-terminée, max 8) +bool dialer_idle(void); // true si aucun chiffre depuis le reset +int dialer_ms_since_last(void); // ms depuis le dernier chiffre (pour timeout inter-chiffre) +``` + +- [ ] **Step 2 : Écrire `dialer.c`** (accumulateur + horodatage) + +```c +#include "dialer.h" +#include "freertos/FreeRTOS.h" +#include "esp_timer.h" +#include + +static char s_num[9]; +static int s_len; +static int64_t s_last_us; + +void dialer_init(void){ dialer_reset(); } +void dialer_reset(void){ s_num[0]=0; s_len=0; s_last_us=0; } +void dialer_push_digit(int d){ + if (d<0||d>9||s_len>=8) return; + s_num[s_len++] = (char)('0'+d); s_num[s_len]=0; + s_last_us = esp_timer_get_time(); +} +const char *dialer_current(void){ return s_num; } +bool dialer_idle(void){ return s_len==0; } +int dialer_ms_since_last(void){ + if (!s_last_us) return 0; + return (int)((esp_timer_get_time()-s_last_us)/1000); +} +``` + +- [ ] **Step 3 : Handler `/debug/dial?number=NNNN` dans `net.c`** + +Calque le pattern `handle_status`/`handle_cmd_post` (net.c:127, 407). Parse le query `number`, appelle `dialer_push_digit` pour chaque chiffre. Enregistre `uri_debug_dial` (HTTP_GET) et **incrémente `cfg.max_uri_handlers`** (actuellement 12 → 14, on ajoutera aussi `/debug/onhook` et plus tard rien d'autre côté net). Include `dialer.h`. + +```c +static esp_err_t handle_debug_dial(httpd_req_t *req) { + char q[64], val[16]; + if (httpd_req_get_url_query_str(req, q, sizeof(q)) == ESP_OK && + httpd_query_key_value(q, "number", val, sizeof(val)) == ESP_OK) { + for (const char *p = val; *p; p++) if (*p>='0'&&*p<='9') dialer_push_digit(*p-'0'); + return send_json(req, "200 OK", "{\"ok\":true}"); + } + return send_json(req, "400 Bad Request", "{\"error\":\"number?\"}"); +} +static const httpd_uri_t uri_debug_dial = { + .uri="/debug/dial", .method=HTTP_GET, .handler=handle_debug_dial, .user_ctx=NULL }; +// dans la fonction d'enregistrement : httpd_register_uri_handler(s_httpd, &uri_debug_dial); +``` + +- [ ] **Step 4 : Ajouter `dialer.c` au CMakeLists + build** + +Run: `cd ESP32_ZACUS/plip_voice ; idf.py build` +Expected: vert. + +- [ ] **Step 5 : Flash + test debug dial** + +```bash +idf.py -p /dev/cu.usbserial-0001 flash +curl -s "http://192.168.0.138/debug/dial?number=17" # -> {"ok":true} +``` +Expected: `{"ok":true}`, série logue les chiffres (ajouter un `ESP_LOGI` dans push_digit pour vérifier). + +- [ ] **Step 6 : Commit** + +```bash +git -C ESP32_ZACUS ... commit -am "feat(plip): dialer module + /debug/dial (digit accumulator)" +``` + +### Task 1.3 : Impulsions cadran sur SHK (comptage) + +**Files:** +- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (compter les impulsions sur le GPIO hook = SHK) +- Modify: `ESP32_ZACUS/plip_voice/main/Kconfig.projbuild` (option pulse) + +- [ ] **Step 1 : Kconfig pulse** + +Dans `Kconfig.projbuild`, ajouter sous le menu PLIP : +``` +config PLIP_DIAL_PULSE + bool "Enable rotary pulse dialing on the hook/SHK GPIO" + default y +config PLIP_DIAL_PULSE_MAX_GAP_MS + int "Inter-pulse max gap (ms) — above this = digit complete" + depends on PLIP_DIAL_PULSE + default 200 +``` + +- [ ] **Step 2 : Compter les impulsions dans `phone.c`** + +Le cadran génère des ouvertures/fermetures rapides de la boucle pendant la composition (le SHK pulse), distinctes du décroché/raccroché (état stable). Dans la task hook (phone.c:86-111) : si `PLIP_DIAL_PULSE` et qu'on est **off-hook**, compter les transitions rapides ; quand le gap depuis la dernière impulsion dépasse `CONFIG_PLIP_DIAL_PULSE_MAX_GAP_MS`, le nombre d'impulsions = le chiffre (10 impulsions = 0) → `dialer_push_digit`. Garder la détection on/off-hook stable (raccroché = boucle ouverte >~500 ms). Include `dialer.h`. + +```c +// pseudo-intégration dans la task hook, après lecture du niveau débouncé : +#if CONFIG_PLIP_DIAL_PULSE +static int s_pulse_count = 0; +static int64_t s_last_pulse_us = 0; +// sur front descendant bref (impulsion) pendant off-hook : s_pulse_count++; s_last_pulse_us=now; +// périodiquement : si s_pulse_count>0 && (now - s_last_pulse_us) > gap_ms : +// int digit = (s_pulse_count==10)?0:s_pulse_count; dialer_push_digit(digit); s_pulse_count=0; +#endif +``` + +(Note implémenteur : le débounce hook actuel 30 ms peut masquer les impulsions cadran ~ 60-100 ms ; ajuster la logique pour distinguer impulsion brève vs raccroché stable. Tester au banc avec un vrai cadran ; sans cadran, `/debug/dial` couvre la suite.) + +- [ ] **Step 3 : Build + flash** + +Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash` +Expected: vert ; au banc avec cadran (si dispo) composer "1" → série logue digit=1. Sans cadran : skip, `/debug/dial` reste la voie de test. + +- [ ] **Step 4 : Commit** + +```bash +git -C ESP32_ZACUS ... commit -am "feat(plip): rotary pulse dialing on SHK GPIO" +``` + +### Task 1.4 : Machine à états `conversation` jusqu'à RINGBACK/BUSY + +**Files:** +- Create: `ESP32_ZACUS/plip_voice/main/conversation.h` +- Create: `ESP32_ZACUS/plip_voice/main/conversation.c` +- Modify: `ESP32_ZACUS/plip_voice/main/phone.c` (notifier conversation des transitions hook) +- Modify: `ESP32_ZACUS/plip_voice/main/main.c` (init conversation) +- Modify: `ESP32_ZACUS/plip_voice/main/CMakeLists.txt` + +- [ ] **Step 1 : Écrire `conversation.h`** + +```c +#pragma once +#include +void conversation_init(void); +void conversation_on_hook_change(bool offhook); // appelé par phone.c +// Stage 1 : IDLE → DIAL_TONE → DIALING → ROUTING → RINGBACK|BUSY → (Stage 2: CONNECT…) +``` + +- [ ] **Step 2 : Écrire `conversation.c`** (tâche d'état, Stage 1) + +Tâche FreeRTOS qui poll l'état. Au décroché : `tones_dialtone_start()`. Dès qu'un chiffre apparaît (`!dialer_idle()`) : `tones_stop()`, passe en DIALING. Quand `dialer_ms_since_last() > 3000` (timeout inter-chiffre) OU le numéro matche une entrée connue : ROUTING. Routing Stage 1 = table locale des numéros valides (`"12","3615","15","17","18"` + Zacus) ; connu → `tones_ringback_start()` (2 s puis stop, Stage 2 enchaînera CONNECT) ; inconnu → `tones_busy_start()` (5 s) → IDLE. Au raccroché : `tones_stop()`, `audio_stop()`, `audio_pa_set(false)`, `dialer_reset()`, IDLE. + +```c +#include "conversation.h" +#include "tones.h" +#include "dialer.h" +#include "audio.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "esp_log.h" +#include + +static const char *TAG = "conv"; +typedef enum { ST_IDLE, ST_DIALTONE, ST_DIALING, ST_RINGBACK, ST_BUSY } st_t; +static volatile bool s_offhook; +static volatile st_t s_st = ST_IDLE; + +static const char *KNOWN[] = { "12","3615","15","17","18", NULL }; +static bool number_known(const char *n){ + for (int i=0; KNOWN[i]; i++) if (!strcmp(n, KNOWN[i])) return true; + return false; +} + +static void conv_task(void *arg){ + for (;;){ + vTaskDelay(pdMS_TO_TICKS(50)); + if (!s_offhook){ + if (s_st != ST_IDLE){ tones_stop(); audio_stop(); audio_pa_set(false); dialer_reset(); s_st=ST_IDLE; } + continue; + } + switch (s_st){ + case ST_IDLE: tones_dialtone_start(); s_st=ST_DIALTONE; break; + case ST_DIALTONE: + if (!dialer_idle()){ tones_stop(); s_st=ST_DIALING; } + break; + case ST_DIALING: { + const char *n = dialer_current(); + if (number_known(n)){ + ESP_LOGI(TAG, "route %s -> known", n); + tones_ringback_start(); s_st=ST_RINGBACK; // Stage 2: enchaîner CONNECT + } else if (dialer_ms_since_last() > 3000){ + ESP_LOGI(TAG, "route %s -> unknown", n); + tones_busy_start(); s_st=ST_BUSY; + } + break; } + case ST_RINGBACK: /* Stage 2 remplira CONNECT/GREET ici */ break; + case ST_BUSY: break; // reste occupé jusqu'au raccroché + } + } +} +void conversation_init(void){ xTaskCreate(conv_task,"conv",4096,NULL,5,NULL); } +void conversation_on_hook_change(bool offhook){ s_offhook = offhook; if(!offhook) s_st=ST_IDLE; } +``` + +- [ ] **Step 3 : Brancher phone.c → conversation** + +Dans la task hook de `phone.c` (là où `hook_client_report` est appelé), ajouter `conversation_on_hook_change(offhook)` (offhook = `phone_is_offhook()`). Include `conversation.h`. + +- [ ] **Step 4 : Init dans main.c** + +Dans `ESP32_ZACUS/plip_voice/main/main.c`, après `audio_init()` et l'init réseau, ajouter `dialer_init(); conversation_init();`. Include les headers. + +- [ ] **Step 5 : Ajouter conversation.c au CMakeLists + build + flash** + +Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash` + +- [ ] **Step 6 : Test au banc (séquence complète Stage 1)** + +Décroché (ou simuler off-hook) → tonalité au HP. Puis : +```bash +curl -s "http://192.168.0.138/debug/dial?number=17" # numéro connu +# série attendue : "route 17 -> known" + ringback au HP +curl -s "http://192.168.0.138/debug/dial?number=99" # après reset/raccroché +# série attendue : "route 99 -> unknown" + busy au HP (après timeout 3s) +``` +Raccroché → silence (PA off). Expected : tonalité → ringback (connu) / occupé (inconnu) → silence au raccroché. + +- [ ] **Step 7 : Commit** + +```bash +git -C ESP32_ZACUS ... commit -am "feat(plip): conversation state machine — dialtone/dialing/routing/ringback/busy (stage 1)" +``` + +### Task 1.5 : Lander Stage 1 (PR submodule + bump pointeur) + +- [ ] **Step 1 : PR submodule ESP32_ZACUS** (branche `feat/plip-phone-stage1`), merge via API Gitea (voir conventions de session : push avec token, PR, merge, ff-only main). +- [ ] **Step 2 : Bump pointeur** dans le superprojet (`git add ESP32_ZACUS`, commit `chore: bump ESP32_ZACUS — PLIP phone stage 1`, PR, merge). + +--- + +## STAGE 2 — Gateway `/v1/voice/turn` (voie greeting) + annuaire (téléphone jouable sens unique) + +Livrable : composer un numéro → **entendre le PNJ débiter son accueil** (TTS ailiance). Sans micro ni whisper. + +### Task 2.1 : Annuaire `phone_directory.yaml` + chargement gateway + +**Files:** +- Create: `game/scenarios/phone_directory.yaml` +- Modify: `tools/zacus-gateway/main.py` (loader + Settings) +- Test: `tests/gateway/test_phone_directory.py` + +- [ ] **Step 1 : Écrire l'annuaire** + +```yaml +# game/scenarios/phone_directory.yaml — numéro → persona vocale +# persona = prompt système (personnage) ; voice = voix ailiance ; greeting = 1re réplique forcée (optionnel) +numbers: + "12": + label: "Renseignements" + voice: "nova" + persona: > + Tu es l'opératrice des renseignements téléphoniques de France Télécom, années 80. + Tu es polie, un peu pressée. Tu orientes l'appelant vers les bons services sans + jamais donner directement la solution d'une énigme. + greeting: "Renseignements, bonjour. Quel numéro demandez-vous ?" + "15": + label: "SAMU" + voice: "nova" + persona: > + Tu es un régulateur du SAMU. Tu réponds avec calme et autorité. Dans cet escape game + tu peux donner un indice médical déguisé, mais jamais la réponse directe. + greeting: "SAMU, j'écoute, quelle est votre urgence ?" + "17": + label: "Police secours" + voice: "alloy" + persona: > + Tu es un agent de Police secours, factuel et sérieux. Indices déguisés, jamais la solution. + greeting: "Police secours, je vous écoute." + "18": + label: "Pompiers" + voice: "alloy" + persona: > + Tu es un sapeur-pompier, direct et rassurant. Indices déguisés, jamais la solution. + greeting: "Sapeurs-pompiers, bonjour, quel est le problème ?" + "3615": + label: "Minitel" + voice: "nova" + minitel: true + persona: > + Tu es un service Minitel automatisé (3615). Tu parles de façon datée, robotique, + télématique. Réponses courtes façon serveur télétel. + greeting: "Trois mille six cent quinze. Service en ligne. Tapez votre requête." + "0142738200": + label: "Professeur Zacus" + voice: "nova" + persona: > + Tu es le Professeur Zacus, savant excentrique et théâtral. Tu guides les joueurs + par des indices déguisés et des énigmes, jamais la solution directe. Tu connais le + scénario en cours mais restes mystérieux. + greeting: "Ah... vous avez réussi à me joindre. Ici le Professeur Zacus." +``` + +- [ ] **Step 2 : Écrire le test (échoue d'abord)** + +`tests/gateway/test_phone_directory.py` : +```python +import sys +from pathlib import Path +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "tools" / "zacus-gateway")) +import main + +def test_directory_loads_known_numbers(): + d = main.load_phone_directory() + assert "17" in d and d["17"]["label"] == "Police secours" + assert "3615" in d and d["3615"].get("minitel") is True + assert "voice" in d["12"] and "persona" in d["12"] + +def test_directory_unknown_number_absent(): + d = main.load_phone_directory() + assert "99" not in d +``` + +- [ ] **Step 3 : Lancer le test → échoue** + +Run: `cd /Users/electron/code/zacus && python3 -m pytest tests/gateway/test_phone_directory.py -v` +Expected: FAIL (`load_phone_directory` n'existe pas). + +- [ ] **Step 4 : Implémenter le loader** + +Dans `tools/zacus-gateway/main.py`, près du `_load_boards_registry` (~l.32) : +```python +def load_phone_directory() -> dict: + import yaml + p = SCENARIOS_DIR / "phone_directory.yaml" # SCENARIOS_DIR = REPO_ROOT/game/scenarios + if not p.is_file(): + return {} + data = yaml.safe_load(p.read_text(encoding="utf-8")) or {} + return data.get("numbers", {}) + +PHONE_DIRECTORY = load_phone_directory() +``` + +- [ ] **Step 5 : Lancer le test → passe** + +Run: `python3 -m pytest tests/gateway/test_phone_directory.py -v` +Expected: PASS (2 tests). + +- [ ] **Step 6 : Commit** + +```bash +git add game/scenarios/phone_directory.yaml tools/zacus-gateway/main.py tests/gateway/test_phone_directory.py +git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): phone directory loader (numéro → persona/voix)" +``` + +### Task 2.2 : Sessions en mémoire (historique conversation) + +**Files:** +- Modify: `tools/zacus-gateway/main.py` +- Test: `tests/gateway/test_voice_session.py` + +- [ ] **Step 1 : Test (échoue)** + +```python +import sys, time +from pathlib import Path +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "tools" / "zacus-gateway")) +import main + +def test_session_appends_and_caps(): + s = main.VoiceSessions(ttl_s=600, max_turns=8) + s.append("sid1", "user", "bonjour") + s.append("sid1", "assistant", "ici Zacus") + msgs = s.history("sid1") + assert msgs[-2:] == [{"role":"user","content":"bonjour"}, + {"role":"assistant","content":"ici Zacus"}] + for i in range(40): s.append("sid1", "user", f"m{i}") + assert len(s.history("sid1")) <= 16 # 8 tours * 2 + +def test_session_end_clears(): + s = main.VoiceSessions() + s.append("sid2","user","x"); s.end("sid2") + assert s.history("sid2") == [] +``` + +- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → FAIL. + +- [ ] **Step 3 : Implémenter `VoiceSessions`** dans `main.py` : +```python +class VoiceSessions: + def __init__(self, ttl_s: int = 600, max_turns: int = 8): + self._d: dict[str, dict] = {} + self._ttl = ttl_s + self._max = max_turns * 2 + def append(self, sid: str, role: str, content: str) -> None: + import time + e = self._d.setdefault(sid, {"msgs": [], "ts": 0.0}) + e["msgs"].append({"role": role, "content": content}) + e["msgs"] = e["msgs"][-self._max:] + e["ts"] = time.time() + def history(self, sid: str) -> list[dict]: + import time + e = self._d.get(sid) + if not e: return [] + if time.time() - e["ts"] > self._ttl: + self._d.pop(sid, None); return [] + return list(e["msgs"]) + def end(self, sid: str) -> None: + self._d.pop(sid, None) + +VOICE_SESSIONS = VoiceSessions() +``` + +- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_session.py -v` → PASS. + +- [ ] **Step 5 : Commit** +```bash +git add tools/zacus-gateway/main.py tests/gateway/test_voice_session.py +git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): in-memory voice sessions (history + TTL)" +``` + +### Task 2.3 : LLM chat helper (ailiance) + +**Files:** +- Modify: `tools/zacus-gateway/main.py` (Settings + `_chat_reply`) +- Test: `tests/gateway/test_chat_reply.py` + +- [ ] **Step 1 : Test (mock httpx)** +```python +import sys, asyncio +from pathlib import Path +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "tools" / "zacus-gateway")) +import main + +class FakeResp: + status_code = 200 + def json(self): return {"choices":[{"message":{"content":"Ici Zacus."}}]} +class FakeHTTP: + async def post(self, *a, **k): return FakeResp() + +def test_chat_reply_returns_content(): + out = asyncio.run(main._chat_reply(FakeHTTP(), "persona", [{"role":"user","content":"allo"}])) + assert out == "Ici Zacus." +``` + +- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → FAIL. + +- [ ] **Step 3 : Implémenter** dans `main.py` (Settings : `ailiance_chat_model: str = "gpt-4o-mini"`) : +```python +async def _chat_reply(http, persona: str, history: list[dict]) -> str: + messages = [{"role": "system", "content": persona}] + history + resp = await http.post( + f"{settings.ailiance_tts_url.rstrip('/')}/v1/chat/completions", + json={"model": settings.ailiance_chat_model, "messages": messages, "max_tokens": 200}, + timeout=60.0, + ) + if resp.status_code != 200: + raise RuntimeError(f"chat HTTP {resp.status_code}") + return resp.json()["choices"][0]["message"]["content"].strip() +``` + +- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_chat_reply.py -v` → PASS. + +- [ ] **Step 5 : Commit** +```bash +git add tools/zacus-gateway/main.py tests/gateway/test_chat_reply.py +git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): ailiance chat helper for NPC replies" +``` + +### Task 2.4 : Route `/v1/voice/turn` (voie greeting) + `/v1/voice/end` + +**Files:** +- Modify: `tools/zacus-gateway/main.py` +- Test: `tests/gateway/test_voice_turn.py` + +- [ ] **Step 1 : Test voie greeting (mock chat + TTS)** +```python +import sys, asyncio +from pathlib import Path +from fastapi.testclient import TestClient +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "tools" / "zacus-gateway")) +import main + +def test_turn_greeting_returns_wav(monkeypatch): + async def fake_chat(http, persona, history): return "Police secours, j'écoute." + async def fake_synth(http, text, voice): + # WAV 16k mono minuscule valide + import struct + pcm = b"\x00\x00" * 1600 + hdr = b"RIFF" + struct.pack(" tuple[bytes, float, bool]: + from types import SimpleNamespace + raw = await _synthesise_wav(http, SimpleNamespace(backend="ailiance", text=text, voice=voice)) + return _wav_to_16k_mono(raw, max_seconds=7.5) +``` +Route : +```python +from fastapi import Response + +class VoiceTurnRequest(BaseModel): + session_id: str + number: str + kind: str = "reply" # "greeting" | "reply" + +@app.post("/v1/voice/turn") +async def voice_turn(body: VoiceTurnRequest, request: Request, _: None = Depends(require_token)): + entry = PHONE_DIRECTORY.get(body.number) + if not entry: + raise HTTPException(404, f"numéro {body.number} non attribué") + persona = entry.get("persona", "") + voice = entry.get("voice") + http = request.app.state.http + heard = "" + if body.kind == "greeting": + said = entry.get("greeting") or await _chat_reply(http, persona, []) + else: + # Stage 3 remplira la transcription depuis le corps audio ; ici fallback vide + heard = "" + VOICE_SESSIONS.append(body.session_id, "user", heard or "(silence)") + said = await _chat_reply(http, persona, VOICE_SESSIONS.history(body.session_id)) + VOICE_SESSIONS.append(body.session_id, "assistant", said) + wav16, _dur, _trunc = await _voice_tts_16k(http, said, voice) + return Response(content=wav16, media_type="audio/wav", + headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]}) + +class VoiceEndRequest(BaseModel): + session_id: str + +@app.post("/v1/voice/end") +async def voice_end(body: VoiceEndRequest, _: None = Depends(require_token)) -> dict: + VOICE_SESSIONS.end(body.session_id) + return {"ok": True} +``` + +- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (2 tests). + +- [ ] **Step 5 : Test live contre ailiance (manuel, hors CI)** + +Monter le gateway local (venv + `ZACUS_HUB_TOKEN`), puis : +```bash +curl -s -XPOST http://127.0.0.1:8400/v1/voice/turn \ + -H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \ + -d '{"session_id":"t1","number":"17","kind":"greeting"}' -o /tmp/greet.wav -D - +file /tmp/greet.wav # RIFF WAVE 16000 Hz +``` +Expected : 200, header `X-Zacus-Said`, WAV 16k jouable. + +- [ ] **Step 6 : Commit** +```bash +git add tools/zacus-gateway/main.py tests/gateway/test_voice_turn.py +git -c user.name=electron-rare -c user.email=c.saillant@gmail.com commit -m "feat(gateway): POST /v1/voice/turn (greeting path) + /v1/voice/end" +``` + +### Task 2.5 : Firmware — CONNECT/GREET appelle `/v1/voice/turn` et joue l'accueil + +**Files:** +- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECT/GREET) +- Create: `ESP32_ZACUS/plip_voice/main/turn_client.h` / `turn_client.c` (POST /v1/voice/turn, reçoit WAV → SPIFFS → play) +- Modify: `Kconfig.projbuild` (URL gateway + token) + +- [ ] **Step 1 : Kconfig gateway** +``` +config PLIP_GATEWAY_URL + string "Zacus gateway base URL" + default "http://192.168.0.188:8400" # ajuster à l'hôte gateway joignable du PLIP +config PLIP_GATEWAY_TOKEN + string "Zacus gateway bearer token" + default "" +``` + +- [ ] **Step 2 : `turn_client.c`** — POST greeting, écrit le WAV reçu dans `/spiffs/turn.wav`, retourne le chemin. + +Calque `hook_client.c` (esp_http_client POST) mais : body JSON `{"session_id","number","kind":"greeting"}`, header `Authorization: Bearer `, et **lecture de la réponse binaire** (WAV) en streaming → `fopen("/spiffs/turn.wav","wb")` + `esp_http_client_read`. Signature : +```c +// turn_client.h +#pragma once +#include +// POST /v1/voice/turn (greeting). Écrit le WAV dans out_path (/spiffs/...). true si OK. +bool turn_client_greeting(const char *session_id, const char *number, const char *out_path); +``` + +- [ ] **Step 3 : État CONNECT/GREET dans conversation.c** + +Après RINGBACK (~2 s), au lieu de rester : générer un `session_id` (ex. `snprintf("%lld", esp_timer_get_time())`), appeler `turn_client_greeting(sid, dialer_current(), "/spiffs/turn.wav")`, puis `tones_stop(); audio_play_async("/spiffs/turn.wav");` → état CONNECTED. (Stage 3 ajoutera la boucle LISTEN.) + +```c +case ST_RINGBACK: + if (ringback_elapsed_ms() > 2000){ + tones_stop(); + snprintf(s_sid, sizeof(s_sid), "%lld", esp_timer_get_time()); + if (turn_client_greeting(s_sid, dialer_current(), "/spiffs/turn.wav")) + audio_play_async("/spiffs/turn.wav"); + s_st = ST_CONNECTED; + } + break; +case ST_CONNECTED: /* Stage 3: LISTEN → turn(reply) → play, en boucle */ break; +``` + +- [ ] **Step 4 : Build + flash + test bout-en-bout (sens unique)** + +Run: `cd ESP32_ZACUS/plip_voice ; idf.py build ; idf.py -p /dev/cu.usbserial-0001 flash` +Test : gateway prod/local joignable depuis le PLIP. Décroché → tonalité → `curl "http://192.168.0.138/debug/dial?number=17"` → série : route known → ringback → POST turn → **le combiné dit « Police secours, je vous écoute »**. (combiné décroché pour entendre, mute-on-hook oblige.) +Expected : le PNJ parle. + +- [ ] **Step 5 : Commit + PR submodule + bump** (branche `feat/plip-phone-stage2-fw`). + +### Task 2.6 : Lander Stage 2 (gateway + déploiement) + +- [ ] **Step 1 : PR gateway** (route + annuaire + sessions) → merge. +- [ ] **Step 2 : Déployer le gateway** (scp `main.py` + `phone_directory.yaml` → `/home/electron/zacus-hub/...`, restart `zacus-hub-gateway`, `.bak` avant) — **nécessite autorisation explicite de déploiement prod**. +- [ ] **Step 3 : Vérif prod** : `POST /v1/voice/turn {number:17,kind:greeting}` → WAV. + +--- + +## STAGE 3 — Micro + whisper → conversation deux sens + +### Task 3.1 : Vérifier la capture micro (dépendance hardware) + +**Files:** aucun changement code tant que non diagnostiqué. + +- [ ] **Step 1 : Test capture combiné DÉCROCHÉ** + +Combiné branché + **décroché** (SLIC connecte l'audio). Parler dans le combiné pendant : +```bash +curl -s -XPOST "http://192.168.0.138/voice/capture?max_ms=4000" -o /tmp/cap.wav +python3 - <<'PY' +import wave,struct +w=wave.open('/tmp/cap.wav'); n=w.getnframes(); d=w.readframes(n) +s=struct.unpack('<%dh'%(len(d)//2), d) +rms=(sum(x*x for x in s)/max(1,len(s)))**0.5 +print("samples",len(s),"rms",round(rms,1)) +PY +``` +Expected : RMS nettement > 0 (voix captée). **Si RMS ≈ 0** : passer au Step 2. + +- [ ] **Step 2 : Si zéros — basculer l'entrée ES8388 (LIN2/RIN2)** + +Dans `es8388.c`, la config ADC sélectionne l'entrée (LINPUT1/RINPUT1). Si le SLIC est câblé sur l'entrée 2, changer la sélection sur LINPUT2/RINPUT2 (registre ADCCONTROL2/3 selon datasheet ES8388). Rebuild + flash + re-tester le Step 1. Documenter le pin réel. (Diagnostic possible via `GET /debug/regs` ajouté précédemment.) + +- [ ] **Step 3 : Commit** (si changement entrée) : `git -C ESP32_ZACUS ... commit -am "fix(plip): ES8388 ADC input selection matches SLIC audio wiring"`. + +### Task 3.2 : Endpoint STT vivant (whisper) + +**Files:** +- Modify: `tools/zacus-gateway/main.py` (Settings `whisper_url` + `_transcribe`) +- Test: `tests/gateway/test_transcribe.py` + +- [ ] **Step 1 : Relancer whisper** : soit la voice-bridge sur studio (`/voice/transcribe` → whisper.cpp :8300), soit whisper.cpp seul. Vérifier `curl -F file=@/tmp/cap.wav ` → JSON `{text}`. Noter l'URL joignable depuis le gateway host. (Infra — hors code ; documenter la commande de démarrage.) + +- [ ] **Step 2 : Test `_transcribe` (mock)** `tests/gateway/test_transcribe.py` : +```python +import sys, asyncio +from pathlib import Path +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(REPO / "tools" / "zacus-gateway")) +import main +class FakeResp: + status_code = 200 + def json(self): return {"text":"bonjour professeur"} +class FakeHTTP: + async def post(self,*a,**k): return FakeResp() +def test_transcribe_returns_text(): + out = asyncio.run(main._transcribe(FakeHTTP(), b"RIFF....", "x.wav")) + assert out == "bonjour professeur" +``` + +- [ ] **Step 3 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → FAIL. + +- [ ] **Step 4 : Implémenter `_transcribe`** (Settings `whisper_url: str = "http://studio:8200"`, endpoint `/voice/transcribe` multipart) : +```python +async def _transcribe(http, wav_bytes: bytes, filename: str = "speech.wav") -> str: + files = {"file": (filename, wav_bytes, "audio/wav")} + resp = await http.post(f"{settings.whisper_url.rstrip('/')}/voice/transcribe", + files=files, timeout=60.0) + if resp.status_code != 200: + raise RuntimeError(f"stt HTTP {resp.status_code}") + return (resp.json().get("text") or "").strip() +``` + +- [ ] **Step 5 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_transcribe.py -v` → PASS. + +- [ ] **Step 6 : Commit** : `git add ... && git commit -m "feat(gateway): whisper STT helper (_transcribe)"`. + +### Task 3.3 : Voie `reply` de `/v1/voice/turn` (audio → STT → chat → TTS) + +**Files:** +- Modify: `tools/zacus-gateway/main.py` (la route accepte le corps audio en `kind=reply`) +- Test: `tests/gateway/test_voice_turn.py` (ajouter un cas reply) + +- [ ] **Step 1 : Test reply (mock _transcribe + _chat_reply + _voice_tts_16k)** + +Ajouter à `test_voice_turn.py` un test qui POST `kind=reply` avec un corps WAV (multipart ou raw body) et `number`/`session_id` en query, mocke `_transcribe`→"allo", `_chat_reply`→"oui ?", `_voice_tts_16k`→WAV ; assert 200, header `X-Zacus-Heard: allo`. + +- [ ] **Step 2 : Lancer → échoue** (la route ignore encore l'audio). Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → le nouveau test FAIL. + +- [ ] **Step 3 : Adapter la route** : pour `kind=reply`, lire le corps audio (le PLIP enverra le WAV en body brut + `number`/`session_id`/`kind` en query params, car le body est l'audio). Appeler `_transcribe(http, audio_bytes)` → `heard`, l'ajouter à l'historique, puis chat + TTS comme avant. + +```python +@app.post("/v1/voice/turn") +async def voice_turn(request: Request, session_id: str, number: str, + kind: str = "reply", _: None = Depends(require_token)): + entry = PHONE_DIRECTORY.get(number) + if not entry: raise HTTPException(404, f"numéro {number} non attribué") + http = request.app.state.http; persona = entry.get("persona",""); voice = entry.get("voice") + heard = "" + if kind == "greeting": + said = entry.get("greeting") or await _chat_reply(http, persona, []) + else: + audio = await request.body() + try: + heard = await _transcribe(http, audio) + except Exception: + heard = "" + VOICE_SESSIONS.append(session_id, "user", heard or "(inaudible)") + if not heard: + said = "Allô ? Je vous entends très mal, pouvez-vous répéter ?" + else: + said = await _chat_reply(http, persona, VOICE_SESSIONS.history(session_id)) + VOICE_SESSIONS.append(session_id, "assistant", said) + wav16,_d,_t = await _voice_tts_16k(http, said, voice) + return Response(content=wav16, media_type="audio/wav", + headers={"X-Zacus-Heard": heard[:200], "X-Zacus-Said": said[:200]}) +``` +(Note : `session_id`/`number`/`kind` passent en query params puisque le body porte l'audio. Mettre à jour le test greeting en conséquence : query params au lieu de json.) + +- [ ] **Step 4 : Lancer → passe.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → PASS (tous). + +- [ ] **Step 5 : Commit** : `git commit -m "feat(gateway): /v1/voice/turn reply path (audio→STT→chat→TTS)"`. + +### Task 3.4 : Firmware — boucle LISTEN → turn(reply) → SPEAK + +**Files:** +- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (état CONNECTED → boucle) +- Modify: `ESP32_ZACUS/plip_voice/main/turn_client.c` (fonction reply : capture → POST WAV → reçoit WAV) + +- [ ] **Step 1 : `turn_client_reply`** — capture le micro (réutilise l'API `audio_capture_begin/read_frame/end`), assemble un WAV en mémoire/SPIFFS, POST en body brut à `/v1/voice/turn?session_id=..&number=..&kind=reply`, écrit la réponse WAV dans `/spiffs/turn.wav`. + +```c +// turn_client.h : bool turn_client_reply(const char *sid, const char *number, const char *out_path); +``` +(Capture : ~8 s max, VAD comme `/voice/capture`. Upload : `esp_http_client` POST streaming. Réutiliser le code de capture de `net.c::handle_voice_capture`.) + +- [ ] **Step 2 : Boucle dans conversation.c** +```c +case ST_CONNECTED: + // après la fin de lecture de l'accueil/réponse : + if (audio_idle()){ + if (turn_client_reply(s_sid, dialer_current(), "/spiffs/turn.wav")) + audio_play_async("/spiffs/turn.wav"); + // reste en CONNECTED → reboucle ; raccroché géré globalement + } + break; +``` +(Ajouter `audio_idle()` si absent : true quand la queue est vide et aucune lecture en cours.) + +- [ ] **Step 3 : Build + flash + test conversation deux sens** (combiné décroché, micro vérifié Task 3.1, whisper up) : décroché → numéro → accueil → parler → Zacus répond → reboucle → raccroché = fin. + +- [ ] **Step 4 : Commit + PR submodule + bump.** + +--- + +## STAGE 4 — 3615 Minitel (handshake + agent robotique) + +### Task 4.1 : Sample handshake Minitel + lecture sur 3615 + +**Files:** +- Create: `ESP32_ZACUS/plip_voice/main/minitel_tone.h` (PCM embarqué, court) OU pousser un WAV sur SPIFFS +- Modify: `ESP32_ZACUS/plip_voice/main/conversation.c` (si `number=="3615"`, jouer le handshake avant GREET) + +- [ ] **Step 1 : Obtenir le son handshake Minitel** : générer/approximer le handshake (séquence de tonalités modem) en WAV 16k court (~3 s), le pousser sur `/spiffs/minitel.wav` via `/game/file`, OU l'embarquer. (Sans fichier, approximer par une séquence de tones via `tones`/`audio_play_tone` : porteuse ~1300/2100 Hz alternées.) + +- [ ] **Step 2 : Cas 3615 dans conversation.c** : à la connexion, si `dialer_current()=="3615"`, jouer `/spiffs/minitel.wav` (ou la séquence), PUIS enchaîner `turn_client_greeting` (l'annuaire a déjà la persona robotique + `minitel:true`). Le reste de la boucle est identique. + +- [ ] **Step 3 : Build + flash + test** : composer 3615 → son modem Minitel → « Trois mille six cent quinze. Service en ligne… » → conversation robotique. + +- [ ] **Step 4 : Commit + PR submodule + bump.** + +### Task 4.2 : Test annuaire complet au banc + +- [ ] **Step 1 :** Pour chaque numéro (12/15/17/18/3615/Zacus) : composer → accueil du bon PNJ (voix/persona). Numéro inconnu → occupé. Raccroché à tout moment → silence immédiat. +- [ ] **Step 2 :** Mode dégradé : whisper down → l'accueil joue quand même, reply → « je vous entends mal » (fallback). Documenter. + +--- + +## Self-Review (couverture spec) + +- **§2 Architecture (A, /v1/voice/turn unique)** → Tasks 2.4, 3.3 ✓ +- **§3 Séquence (tonalité/dialing/routing/ringback/busy/mute)** → Tasks 1.1, 1.4 ✓ +- **§4 Dialer (impulsions SHK + DTMF + debug)** → Tasks 1.2 (debug), 1.3 (impulsions) ✓ ; **DTMF Goertzel = non couvert explicitement** (voir note ci-dessous). +- **§5 Annuaire (12/3615/15/17/18 + Zacus)** → Task 2.1 ✓ +- **§6 Contrat /v1/voice/turn (session_id/number/kind/audio, headers debug, /end)** → Tasks 2.4, 3.3 ✓ +- **§7 Services IA (ailiance chat+TTS, whisper STT)** → Tasks 2.3, 3.2 ✓ +- **§8 Dépendances (micro, whisper)** → Tasks 3.1, 3.2 ✓ +- **§9 Mode dégradé** → Task 4.2 Step 2 ✓ +- **§10 Erreurs (STT vide, réseau, raccroché, inconnu, cap)** → Tasks 2.4 (404 inconnu), 3.3 (fallback STT vide), 1.4 (raccroché/busy) ✓ +- **§12 Build incrémental** → Stages 1-4 ✓ +- **§13 Tests** → tests gateway pytest (2.x, 3.x) + tests banc firmware ✓ + +**Gap identifié — DTMF Goertzel** : le spec liste le DTMF comme 2ᵉ source de numérotation, mais le plan ne le détaille pas (seuls impulsions + debug sont couverts). **Décision** : le DTMF est repoussé en option post-Stage-1 (le cadran à impulsions + debug couvrent la cible « vintage à cadran » prioritaire). À ajouter comme Task 1.6 ultérieure si un téléphone à touches est utilisé : décodage Goertzel des 8 fréquences sur les frames de `audio_capture_read_frame`, derrière `CONFIG_PLIP_DIAL_DTMF`. Ce report est cohérent avec la cible (téléphone à cadran) confirmée au brainstorming.