docs(plip): implementation plan — téléphone conversationnel (4 stages, TDD)
This commit is contained in:
@@ -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 <math.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
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 <stdbool.h>
|
||||||
|
// 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 <string.h>
|
||||||
|
|
||||||
|
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 <stdbool.h>
|
||||||
|
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 <string.h>
|
||||||
|
|
||||||
|
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("<I", 36+len(pcm)) + b"WAVEfmt " + struct.pack("<IHHIIHH",16,1,1,16000,32000,2,16) + b"data" + struct.pack("<I", len(pcm))
|
||||||
|
return hdr + pcm, 0.1, False
|
||||||
|
monkeypatch.setattr(main, "_chat_reply", fake_chat)
|
||||||
|
monkeypatch.setattr(main, "_voice_tts_16k", fake_synth)
|
||||||
|
c = TestClient(main.app)
|
||||||
|
r = c.post("/v1/voice/turn",
|
||||||
|
headers={"Authorization": f"Bearer {main.settings.token}"},
|
||||||
|
json={"session_id":"s1","number":"17","kind":"greeting"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.headers["content-type"] == "audio/wav"
|
||||||
|
assert r.headers.get("x-zacus-said") # texte de réponse en header
|
||||||
|
assert r.content[:4] == b"RIFF"
|
||||||
|
|
||||||
|
def test_turn_unknown_number_404():
|
||||||
|
c = TestClient(main.app)
|
||||||
|
r = c.post("/v1/voice/turn",
|
||||||
|
headers={"Authorization": f"Bearer {main.settings.token}"},
|
||||||
|
json={"session_id":"s1","number":"99","kind":"greeting"})
|
||||||
|
assert r.status_code == 404
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2 : Lancer → échoue.** Run: `python3 -m pytest tests/gateway/test_voice_turn.py -v` → FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3 : Implémenter le helper TTS 16k + la route**
|
||||||
|
|
||||||
|
Helper (réutilise `_synthesise_wav` ailiance + `_wav_to_16k_mono`) :
|
||||||
|
```python
|
||||||
|
async def _voice_tts_16k(http, text: str, voice: str | None) -> 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 <token>`, 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 <stdbool.h>
|
||||||
|
// 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 <whisper_url>` → 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.
|
||||||
Reference in New Issue
Block a user