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:
@@ -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.
|
||||
)
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user