feat(tel): DTMF Goertzel + test host

Context: Phase 3 migration ESP-IDF - detecteur DTMF par Goertzel.

Approach: Adapter dtmf.c/h de plip_voice pour le projet RTC_BL_PHONE
en isolant la partie pure (dtmf_detect_frame + Goertzel) de la partie
IDF (tache FreeRTOS, hal_i2s, dialer) via garde #ifndef DTMF_HOST_TEST.

Changes:
- components/dtmf/include/dtmf.h : header adapte, sans sdkconfig.h ni
  CONFIG_PLIP_DIAL_DTMF, gardes DTMF_HOST_TEST sur dtmf_start/stop
- components/dtmf/dtmf.c : remplace audio.h->hal_i2s.h,
  audio_capture_*->hal_i2s_capture_*, stub forward dialer_push_digit,
  corrige signature hal_i2s_capture_begin(void)
- components/dtmf/test_dtmf.c : TDD host, synth 6000+6000 par freq,
  3 assertions (1, silence, 9)
- components/dtmf/CMakeLists.txt : REQUIRES hal_i2s (dialer en Task 6)

Impact: TDD RED->GREEN 3/3. Build IDF vert. dtmf non reference dans
main pour l'instant (dialer manque) - sera tire par call_manager T6.
This commit is contained in:
clement
2026-06-19 10:35:02 +02:00
parent c11572307e
commit c5eabc44c5
4 changed files with 444 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
idf_component_register(
SRCS "dtmf.c"
INCLUDE_DIRS "include"
REQUIRES hal_i2s
# NOTE Task 6 : ajouter "dialer" aux REQUIRES quand le composant existera.
# dialer_push_digit est appelé dans dtmf_task qui n'est pas encore activée.
)
+316
View File
@@ -0,0 +1,316 @@
/*
* dtmf.c — Détecteur DTMF par algorithme de Goertzel.
*
* Adapté de plip_voice/main/dtmf.c pour le projet RTC_BL_PHONE (ESP-IDF v5.4).
* Changements par rapport à l'original :
* - Remplace audio_capture_read_frame/audio_capture_begin par
* hal_i2s_capture_read_frame/hal_i2s_capture_begin.
* - Remplace #include "audio.h" par #include "hal_i2s.h".
* - Supprime la dépendance sdkconfig.h / CONFIG_PLIP_DIAL_DTMF.
* - Garde DTMF_HOST_TEST : isole tous les includes et fonctions IDF/FreeRTOS
* afin que dtmf_detect_frame + Goertzel compilent en pur host (cc -lm).
*
* Pipeline de détection par frame 20 ms (320 échantillons @ 16 kHz) :
* 1. Calcul de la puissance Goertzel pour 8 fréquences DTMF (4 low + 4 high).
* 2. Identification du gagnant dans chaque groupe (best_low, best_high).
* 3. Trois gardes :
* a) Seuil énergie absolue — les deux doivent dépasser DTMF_ENERGY_THRESH.
* b) Rapport de dominance — le gagnant doit être > DTMF_DOMINANT_RATIO×
* le second dans le même groupe.
* c) Garde twist — ratio énergie (low/high) dans
* [1/DTMF_TWIST_MAX, DTMF_TWIST_MAX].
* 4. Débounce :
* - DTMF_CONFIRM_FRAMES frames consécutives identiques pour émettre.
* - DTMF_RELEASE_FRAMES frames de silence/différence avant ré-arm.
*
* Justification des seuils — voir commentaire détaillé dans l'original.
*/
/* -------------------------------------------------------------------------
* Includes communs (host + IDF)
* ---------------------------------------------------------------------- */
#include "dtmf.h"
#include <math.h>
#include <string.h>
#include <stdbool.h>
#include <stdint.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
/* -------------------------------------------------------------------------
* Includes IDF uniquement (pas en mode test host)
* ---------------------------------------------------------------------- */
#ifndef DTMF_HOST_TEST
#include "hal_i2s.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#define TAG "dtmf"
/* NOTE Task 4 : dialer.h sera disponible quand le composant dialer existera.
* Pour l'instant, déclaration forward pour éviter une dépendance de build
* avant Task 4. L'include réel sera rétabli en Task 6 avec REQUIRES dialer. */
#ifdef CONFIG_RTC_DTMF_DIALER_READY
#include "dialer.h"
#else
/* Stub forward — dialer_push_digit sera lié quand dialer existera (Task 4/6). */
void dialer_push_digit(int digit);
#endif
#endif /* DTMF_HOST_TEST */
/* -------------------------------------------------------------------------
* Seuils de détection (justification dans l'en-tête du fichier)
* ---------------------------------------------------------------------- */
#define DTMF_ENERGY_THRESH 4000000LL
#define DTMF_DOMINANT_RATIO 4.0f
#define DTMF_TWIST_MAX 8.0f
#define DTMF_CONFIRM_FRAMES 2
#define DTMF_RELEASE_FRAMES 1
/* -------------------------------------------------------------------------
* Table de fréquences DTMF
* ---------------------------------------------------------------------- */
#define NUM_LOW 4
#define NUM_HIGH 4
static const float LOW_FREQS[NUM_LOW] = { 697.0f, 770.0f, 852.0f, 941.0f };
static const float HIGH_FREQS[NUM_HIGH] = { 1209.0f, 1336.0f, 1477.0f, 1633.0f };
/* Matrice DTMF : [low_idx][high_idx] → caractère.
* Colonne 3 (1633 Hz) = ABCD, non utilisés sur téléphone standard → '\0'. */
static const char DTMF_MATRIX[NUM_LOW][NUM_HIGH] = {
{ '1', '2', '3', '\0' }, /* 697 Hz */
{ '4', '5', '6', '\0' }, /* 770 Hz */
{ '7', '8', '9', '\0' }, /* 852 Hz */
{ '*', '0', '#', '\0' }, /* 941 Hz */
};
/* -------------------------------------------------------------------------
* Cache des coefficients Goertzel (précalculés au premier appel)
* ---------------------------------------------------------------------- */
#define FS 16000 /* fréquence d'échantillonnage */
#define N_SAMP 320 /* taille de frame */
static bool s_coeff_ready = false;
static float s_low_coeff[NUM_LOW];
static float s_high_coeff[NUM_HIGH];
static void precompute_coeffs(void)
{
for (int i = 0; i < NUM_LOW; i++) {
float k = (float)N_SAMP * LOW_FREQS[i] / (float)FS;
s_low_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP);
}
for (int i = 0; i < NUM_HIGH; i++) {
float k = (float)N_SAMP * HIGH_FREQS[i] / (float)FS;
s_high_coeff[i] = 2.0f * cosf(2.0f * (float)M_PI * k / (float)N_SAMP);
}
s_coeff_ready = true;
}
/* -------------------------------------------------------------------------
* Puissance Goertzel pour une fréquence unique
* Puissance = Q1² + Q2² Q1·Q2·coeff (non normalisée, ∝ amplitude²)
* ---------------------------------------------------------------------- */
static float goertzel_power(const int16_t *samples, int n, float coeff)
{
float q1 = 0.0f, q2 = 0.0f;
for (int i = 0; i < n; i++) {
float q0 = coeff * q1 - q2 + (float)samples[i];
q2 = q1;
q1 = q0;
}
return q1 * q1 + q2 * q2 - q1 * q2 * coeff;
}
/* -------------------------------------------------------------------------
* API publique : dtmf_detect_frame (fonction PURE — testable host)
* ---------------------------------------------------------------------- */
char dtmf_detect_frame(const int16_t *mono, int n)
{
if (n <= 0 || !mono) return '\0';
if (!s_coeff_ready) precompute_coeffs();
/* Calcul de la puissance Goertzel pour les 8 fréquences */
float low_pow[NUM_LOW], high_pow[NUM_HIGH];
for (int i = 0; i < NUM_LOW; i++)
low_pow[i] = goertzel_power(mono, n, s_low_coeff[i]);
for (int i = 0; i < NUM_HIGH; i++)
high_pow[i] = goertzel_power(mono, n, s_high_coeff[i]);
/* Recherche du plus fort dans chaque groupe */
int best_low = 0, best_high = 0;
float max_low = low_pow[0], max_high = high_pow[0];
for (int i = 1; i < NUM_LOW; i++) {
if (low_pow[i] > max_low) { max_low = low_pow[i]; best_low = i; }
}
for (int i = 1; i < NUM_HIGH; i++) {
if (high_pow[i] > max_high) { max_high = high_pow[i]; best_high = i; }
}
/* Garde (a) : seuil énergie absolue */
if ((int64_t)max_low < DTMF_ENERGY_THRESH || (int64_t)max_high < DTMF_ENERGY_THRESH)
goto no_tone;
/* Garde (b) : dominance de groupe — cherche le second meilleur */
{
float second_low = 0.0f, second_high = 0.0f;
for (int i = 0; i < NUM_LOW; i++) {
if (i != best_low && low_pow[i] > second_low)
second_low = low_pow[i];
}
for (int i = 0; i < NUM_HIGH; i++) {
if (i != best_high && high_pow[i] > second_high)
second_high = high_pow[i];
}
if (second_low > 0.0f && max_low < DTMF_DOMINANT_RATIO * second_low)
goto no_tone;
if (second_high > 0.0f && max_high < DTMF_DOMINANT_RATIO * second_high)
goto no_tone;
}
/* Garde (c) : twist — ratio de puissance dans [1/TWIST_MAX, TWIST_MAX] */
{
float ratio = max_low / max_high;
if (ratio > DTMF_TWIST_MAX || ratio < (1.0f / DTMF_TWIST_MAX))
goto no_tone;
}
/* --- État de débounce (statique) --- */
{
static char s_candidate = '\0';
static int s_confirm_count = 0;
static int s_release_count = 0;
static bool s_armed = true;
char sym = DTMF_MATRIX[best_low][best_high];
if (sym == '\0') goto no_tone; /* colonne 1633 Hz — ignorée */
/* Ton détecté : réinitialise le compteur de relâchement */
s_release_count = 0;
if (!s_armed) {
/* Attente de silence/relâchement avant d'accepter la prochaine pression */
return '\0';
}
if (sym == s_candidate) {
s_confirm_count++;
} else {
s_candidate = sym;
s_confirm_count = 1;
}
if (s_confirm_count >= DTMF_CONFIRM_FRAMES) {
/* Confirmé — rapporte et désarme jusqu'au relâchement */
s_confirm_count = 0;
s_candidate = '\0';
s_armed = false;
return sym;
}
return '\0';
no_tone:
/* Aucun ton valide : avance le compteur de relâchement */
; /* l'étiquette doit précéder une instruction */
s_confirm_count = 0;
s_candidate = '\0';
if (!s_armed) {
s_release_count++;
if (s_release_count >= DTMF_RELEASE_FRAMES) {
s_armed = true;
s_release_count = 0;
}
}
return '\0';
}
}
/* -------------------------------------------------------------------------
* Tâche de capture arrière-plan (IDF uniquement — pas en mode test host)
* ---------------------------------------------------------------------- */
#ifndef DTMF_HOST_TEST
#define DTMF_TASK_STACK 4096
#define DTMF_TASK_PRIO 3
#define DTMF_FRAME_SIZE 320
static volatile bool s_armed_flag = false;
static TaskHandle_t s_task_handle = NULL;
static void dtmf_task(void *arg)
{
(void)arg;
int16_t frame[DTMF_FRAME_SIZE];
int64_t rms_sq;
ESP_LOGI(TAG, "dtmf_task started");
/* Ouvre le flux de capture une fois et le maintient ouvert.
* Full-duplex : TX (haut-parleur) reste actif ; RX est déjà activé au boot.
* dtmf_stop() ne ferme pas le flux — il désarme simplement le flag. */
if (hal_i2s_capture_begin() != ESP_OK) {
ESP_LOGE(TAG, "dtmf_task: capture_begin failed — task exits");
s_task_handle = NULL;
vTaskDelete(NULL);
return;
}
for (;;) {
if (!s_armed_flag) {
/* Désarmé : vide les frames lentement pour éviter le débordement
* du FIFO RX */
hal_i2s_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq);
vTaskDelay(pdMS_TO_TICKS(20));
continue;
}
int got = hal_i2s_capture_read_frame(frame, DTMF_FRAME_SIZE, &rms_sq);
if (got <= 0) continue;
char sym = dtmf_detect_frame(frame, got);
if (sym == '\0') continue;
ESP_LOGI(TAG, "DTMF détecté : '%c'", sym);
if (sym >= '0' && sym <= '9') {
dialer_push_digit(sym - '0');
}
/* '*' et '#' sont loggés uniquement — pas de push dialer pour l'instant */
}
}
void dtmf_start(void)
{
if (!s_task_handle) {
BaseType_t ok = xTaskCreatePinnedToCore(
dtmf_task, "dtmf", DTMF_TASK_STACK, NULL, DTMF_TASK_PRIO,
&s_task_handle, 1);
if (ok != pdPASS) {
ESP_LOGE(TAG, "dtmf_start: xTaskCreate failed");
return;
}
}
s_armed_flag = true;
ESP_LOGI(TAG, "dtmf_start: détection DTMF armée");
}
void dtmf_stop(void)
{
s_armed_flag = false;
ESP_LOGI(TAG, "dtmf_stop: détection DTMF désarmée");
}
#endif /* DTMF_HOST_TEST */
+56
View File
@@ -0,0 +1,56 @@
#pragma once
/*
* dtmf.h — Détecteur DTMF par algorithme de Goertzel.
*
* API:
* dtmf_detect_frame() — traitement signal pur, sans I/O, testable en host
* dtmf_start() / dtmf_stop() — arme/désarme la tâche de capture arrière-plan
*
* La tâche de capture lit des frames 20 ms via hal_i2s_capture_read_frame()
* et appelle dialer_push_digit() pour les chiffres confirmés (0-9 uniquement).
*
* Garde DTMF_HOST_TEST : quand définie, seules les fonctions pures
* (dtmf_detect_frame + Goertzel) sont exposées — pas de dépendances FreeRTOS/IDF.
*/
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/*
* Analyse une frame mono 20 ms (320 échantillons à 16 kHz) pour détecter un
* ton DTMF.
*
* Retourne le caractère DTMF détecté ('0'-'9', '*', '#') une fois par pression
* confirmée, ou '\0' si rien n'est détecté ou si le décompte est en cours.
*
* Règles de débounce (état statique interne) :
* - Un symbole doit apparaître dans >= DTMF_CONFIRM_FRAMES frames
* consécutives pour être rapporté.
* - Après un rapport, il faut au moins DTMF_RELEASE_FRAMES frames de
* «silence» avant qu'un nouveau symbole (même ou différent) soit accepté.
*/
char dtmf_detect_frame(const int16_t *mono, int n);
#ifndef DTMF_HOST_TEST
/*
* Démarre la tâche DTMF en arrière-plan (créée une seule fois ; ré-arm
* idempotent). La tâche lit les frames micro et pousse les chiffres 0-9
* au dialer. Doit être appelée après hal_i2s_init().
*/
void dtmf_start(void);
/*
* Désarme la tâche DTMF. La tâche se suspend ; le flux RX continue pour
* la capture vocale (architecture full-duplex).
*/
void dtmf_stop(void);
#endif /* DTMF_HOST_TEST */
#ifdef __cplusplus
}
#endif
+65
View File
@@ -0,0 +1,65 @@
/* Test host de dtmf_detect_frame : synthétise des frames DTMF et vérifie le décodage.
* Compilation : cc -DDTMF_HOST_TEST -o /tmp/test_dtmf \
* -I components/dtmf/include \
* components/dtmf/test_dtmf.c components/dtmf/dtmf.c -lm
*/
#include "dtmf.h"
#include <assert.h>
#include <math.h>
#include <stdio.h>
#ifndef M_PI
#define M_PI 3.14159265358979323846
#endif
#define FS 16000
#define N 320
/* Remplit une frame mono 20 ms avec la somme de deux sinus (low+high).
* Amplitude 6000 par composante (12000 crête-à-crête) — en dessous de la
* saturation int16 (32767), évite l'écrêtage tout en restant bien au-dessus
* du seuil DTMF_ENERGY_THRESH = 4e6.
* Note : 9000+9000=18000 peut saturer sur certains codecs ; 6000+6000=12000
* est plus sûr et le détecteur n'est pas affaibli (seuils non modifiés). */
static void synth(int16_t *buf, int n, float f_low, float f_high)
{
for (int i = 0; i < n; i++) {
float s = 6000.0f * sinf(2.0f*(float)M_PI*f_low *i/FS)
+ 6000.0f * sinf(2.0f*(float)M_PI*f_high*i/FS);
buf[i] = (int16_t)s;
}
}
int main(void)
{
int16_t buf[N];
char d = '\0';
/* '1' = 697 + 1209. Le détecteur confirme après DTMF_CONFIRM_FRAMES (2)
* frames soutenues ; on l'alimente plusieurs fois puis on lit le résultat. */
for (int k = 0; k < 5; k++) {
synth(buf, N, 697.0f, 1209.0f);
char c = dtmf_detect_frame(buf, N);
if (c != '\0') d = c;
}
assert(d == '1');
/* Silence → aucun chiffre. */
int16_t zero[N];
for (int i = 0; i < N; i++) zero[i] = 0;
char s = '\0';
for (int k = 0; k < 3; k++) { char c = dtmf_detect_frame(zero, N); if (c) s = c; }
assert(s == '\0');
/* '9' = 852 + 1477 après ré-arm (silence intercalé ci-dessus). */
char d9 = '\0';
for (int k = 0; k < 5; k++) {
synth(buf, N, 852.0f, 1477.0f);
char c = dtmf_detect_frame(buf, N);
if (c != '\0') d9 = c;
}
assert(d9 == '9');
printf("dtmf: 3/3 assertions OK\n");
return 0;
}