Assistant vocal local ESP32 : étapes 1 à 15, et 15b écrite

État du projet au premier commit :
- firmware ESP32 : micro I2S, lecture anti-gigue, MQTT (état retenu, testament),
  mains libres, domotique de démonstration, chien de garde, présence du serveur ;
- serveur Python : sessions, VAD, faster-whisper, pyannote, identification,
  droits par profil, domotique, mot de réveil, Piper ;
- broker Mosquitto : configuration, comptes et ACL (non activés en développement) ;
- étape 15b écrite, en attente d'essai : agent MQTT pour machines Linux.

Étapes 1 à 15 validées. 203 tests, pyright sans erreur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Denis Defolie
2026-09-30 17:40:55 +02:00
commit d20976fea2
108 changed files with 15022 additions and 0 deletions
+243
View File
@@ -0,0 +1,243 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
/**
* @file config.h
* @brief Constantes matérielles et audio du firmware.
*
* Toutes les valeurs réglables du firmware sont ici : aucune constante magique
* dans le code. Les broches évitent les broches de « strapping » (0, 2, 5, 12, 15)
* pour les signaux audio, car leur état au démarrage change le mode de boot.
*/
// ---------------------------------------------------------------- Format audio
/// Fréquence d'échantillonnage unique de tout le système (Hz).
static constexpr uint32_t AUDIO_SAMPLE_RATE = 16000;
/// Durée d'un chunk audio (ms). 50 ms = compromis latence / surcoût protocole.
static constexpr uint32_t AUDIO_CHUNK_MS = 50;
/// Nombre d'échantillons 16 bits dans un chunk : 16000 x 0,050 = 800.
static constexpr size_t AUDIO_CHUNK_SAMPLES = (AUDIO_SAMPLE_RATE * AUDIO_CHUNK_MS) / 1000;
/// Taille d'un chunk en octets : 800 x 2 = 1600.
static constexpr size_t AUDIO_CHUNK_BYTES = AUDIO_CHUNK_SAMPLES * sizeof(int16_t);
// --------------------------------------------------------- Broches micro INMP441
static constexpr int PIN_MIC_SCK = 25; ///< Horloge bit (BCLK) du micro
static constexpr int PIN_MIC_WS = 26; ///< Sélection de mot (LRCL / WS)
static constexpr int PIN_MIC_SD = 33; ///< Donnée série sortante du micro (DOUT)
// ------------------------------------------------------ Broches ampli MAX98357A
static constexpr int PIN_AMP_BCLK = 27; ///< Horloge bit
static constexpr int PIN_AMP_LRC = 14; ///< Sélection de mot
static constexpr int PIN_AMP_DIN = 22; ///< Donnée série entrante de l'ampli
// ------------------------------------------------------------ Interface utilisateur
static constexpr int PIN_BUTTON_PTT = 4; ///< Bouton « Push-To-Talk », pull-up interne
static constexpr int PIN_STATUS_LED = 2; ///< LED d'état (broche de strapping, sortie seule : acceptable)
/// LED de démonstration domotique (étape 11) : « la lumière » de la pièce de la carte.
/// GPIO 21 : libre, sans rôle au démarrage. LED + résistance 330 Ω vers GND.
static constexpr int PIN_DEMO_LIGHT = 21;
/// Durée d'anti-rebond du bouton (ms).
static constexpr uint32_t BUTTON_DEBOUNCE_MS = 30;
// ------------------------------------------------------------ Conversion micro
/**
* Gain numérique appliqué après le passe-haut, avant la réduction à 16 bits.
*
* Valeur **mesurée**. Une phrase à 20-30 cm donne, une fois le grondement
* infrasonore retiré par le passe-haut, une crête vers -25 dBFS : un gain de 4
* la place vers -13 dBFS, soit ~22 % de la pleine échelle, avec 13 dB de marge.
*
* Les valeurs 16 puis 1 retenues plus tôt venaient de mesures faussées (cadrage
* des bits erroné, puis infrasons dominants). L'auto-test annonce la valeur à
* retenir à la fin de chaque session ; elle dépend du module et de la distance.
*
* Le gain est numérique : il ne dégrade pas le rapport signal/bruit, il replace
* seulement le signal dans la plage utile.
*/
static constexpr int MIC_GAIN = 4;
/**
* Durée de stabilisation du micro après activation du canal I2S (ms).
*
* L'INMP441 s'endort dès que ses horloges s'arrêtent, et chaque réveil produit
* une composante continue qui dérive lentement. Mesuré sur le banc : encore
* +23 % de pleine échelle et en mouvement 600 ms après le réveil. On jette donc
* ce début, UNE seule fois au démarrage : ensuite, les horloges ne s'arrêtent
* plus jamais (flush() vide le DMA par lecture).
*/
static constexpr uint32_t MIC_SETTLE_MS = 1500;
/**
* Pull-down interne sur la ligne SD du micro.
*
* Le datasheet de l'INMP441 exige une résistance de 100 kOhms entre SD et la
* masse : le micro met sa sortie en haute impédance hors de sa moitié de trame.
* Beaucoup de modules bon marché ne la portent pas. Le pull-down interne de
* l'ESP32 (environ 45 kOhms) remplit le même rôle sans composant à ajouter.
*/
static constexpr bool MIC_SD_PULLDOWN = true;
/// Affiche l'analyse détaillée des bits au démarrage (utile tant que l'étape 1 n'est pas validée).
static constexpr bool MIC_SELFTEST_VERBOSE = true;
/// Durée de l'auto-test du micro au démarrage (ms).
static constexpr uint32_t MIC_PROBE_MS = 300;
/**
* Filtre passe-haut du micro, du premier ordre : bas += (x - bas) >> SHIFT ;
* sortie = x - bas. Fréquence de coupure : fc = fs / (2 pi 2^SHIFT).
*
* 5 donne fc = 16000 / (2 pi 32) = 80 Hz. La voix n'a rien d'utile en dessous.
* Mesuré sur le banc : avec l'ancien réglage (12, soit 0,6 Hz, qui ne retirait
* que la composante continue), 87 % de l'énergie d'un enregistrement était
* infrasonore — mouvements de la plaque d'essai quand on tient le bouton,
* souffle de la parole. Avec 5, il en reste 0,6 %, et la voix 99 %.
*
* Calculé sur les 24 bits du micro : sur 16 bits, un pas de 1/32 laisserait une
* zone morte de 31 LSB, source d'erreur continue et de distorsion à bas niveau.
*/
static constexpr int MIC_HPF_SHIFT = 5;
// ------------------------------------------------------------ Sortie audio (étape 2)
/**
* Cadrage I2S attendu par l'ampli. Le MAX98357**A** suit la norme Philips, le
* MAX98357**B** le cadrage MSB (c'est la lettre après le numéro, sur la puce).
* Si le bip de démarrage sort râpeux ou soufflé au lieu d'être pur : basculer.
*/
static constexpr bool AMP_FORMAT_PHILIPS = true;
/// Bip de démarrage : teste la sortie SEULE, sans PC, sans FIFO, sans tâche.
static constexpr uint32_t BOOT_TONE_HZ = 440;
static constexpr uint32_t BOOT_TONE_MS = 600;
/**
* Amplitude du bip : 8192 = 25 % de pleine échelle = -12 dBFS.
* Avec le gain de 9 dB du MAX98357A sur 4 ohms, environ 0,15 W : net sans agresser.
*/
static constexpr int16_t BOOT_TONE_AMPLITUDE = 8192;
/**
* FIFO de lecture : 16384 octets = 8192 échantillons = 512 ms à 16 kHz.
* Elle contient la réserve anti-gigue (PLAYBACK_START_MS) et encore 212 ms pour
* les rafales : après un retard, le réseau livre souvent plusieurs chunks d'un coup.
*/
static constexpr size_t PLAYBACK_FIFO_BYTES = 16384;
/// Bloc lu par la tâche audio : 256 échantillons = 16 ms = 512 octets.
static constexpr size_t PLAYBACK_BLOCK_SAMPLES = 256;
/**
* Tampon anti-gigue (étape 12) : réserve à constituer avant de jouer le premier
* échantillon, et à reconstituer après une famine. 300 ms = 6 chunks = 9600 octets,
* soit l'avance que le serveur envoie d'emblée : la lecture démarre dès qu'elle est
* arrivée. Un retard réseau plus court que la réserve ne s'entend pas.
*/
static constexpr uint32_t PLAYBACK_START_MS = 300;
/// Tâche audio : au-dessus de loop() (priorité 1), sur le cœur 1 (le Wi-Fi prendra le 0).
static constexpr int AUDIO_TASK_PRIORITY = 5;
static constexpr int AUDIO_TASK_CORE = 1;
static constexpr uint32_t AUDIO_TASK_STACK_BYTES = 4096;
/**
* Tampon de réception série : 8192 octets = 256 ms de PCM au débit de lecture.
* Le tampon par défaut (256 octets) déborderait en 8 ms pendant qu'on enregistre.
*/
static constexpr size_t SERIAL_RX_BUFFER_BYTES = 8192;
// ------------------------------------------------------------ Capture vers le réseau (étape 4)
/**
* FIFO de capture : 32768 octets = 1,02 s de PCM. L'envoi MQTT peut bloquer quand
* le Wi-Fi peine ; la tâche de capture, elle, ne s'arrête jamais. Au-delà d'une
* seconde de retard réseau, des chunks sont perdus — et comptés.
*/
static constexpr size_t CAPTURE_FIFO_BYTES = 32768;
/// Tâche de capture : même priorité et même cœur que la tâche de lecture.
static constexpr int CAPTURE_TASK_PRIORITY = 5;
static constexpr int CAPTURE_TASK_CORE = 1;
static constexpr uint32_t CAPTURE_TASK_STACK_BYTES = 4096;
// ------------------------------------------------------------ Liaison série (étape 1)
/// Débit série : 921 600 baud = ~92 kB/s, largement au-dessus des 32 kB/s d'audio.
static constexpr uint32_t SERIAL_BAUD = 921600;
// ------------------------------------------------------------ Wi-Fi et MQTT (étape 3)
/// Identifiant du nœud, utilisé dans les topics MQTT : voice/<DEVICE_ID>/...
/// Sert aussi d'identifiant client MQTT : deux cartes ne doivent jamais le partager,
/// sinon le broker déconnecte l'une dès que l'autre se connecte, en boucle.
static constexpr const char* DEVICE_ID = "esp32-01";
/// Préfixe commun de tous les topics.
static constexpr const char* MQTT_TOPIC_PREFIX = "voice";
/**
* Intervalle de maintien de connexion MQTT (s). Sans nouvelle de l'ESP32 pendant
* 1,5 x cet intervalle, le broker publie le testament « offline ». 10 s : une
* coupure de courant est signalée en 15 s au plus, pour 1 petit paquet toutes
* les 10 s.
*/
static constexpr uint16_t MQTT_KEEPALIVE_S = 10;
/// Tampon de PubSubClient : un chunk de 1600 octets + topic + en-tête MQTT (étape 4).
static constexpr uint16_t MQTT_BUFFER_BYTES = 2048;
/// Délai entre deux tentatives de connexion au broker (ms).
static constexpr uint32_t MQTT_RETRY_MS = 5000;
/**
* Attente maximale d'une réponse du broker (s). Une tentative de connexion bloque
* loop() au plus MQTT_CONNECT_TIMEOUT_MS + ce temps, soit 3 s, et seulement quand le
* broker est injoignable : l'audio, lui, tourne dans ses propres tâches (étape 15 :
* borné et mesuré plutôt que déplacé dans une tâche, PubSubClient n'étant pas prévu
* pour être partagé entre tâches).
*/
static constexpr uint16_t MQTT_SOCKET_TIMEOUT_S = 2;
/// Délai d'ouverture de la connexion TCP au broker (ms). 3000 par défaut dans le core ;
/// sur un réseau local, un broker présent répond en quelques millisecondes.
static constexpr uint32_t MQTT_CONNECT_TIMEOUT_MS = 1000;
/**
* Présence du serveur (étape 15) : « online » retenu, remplacé par son testament
* « offline » s'il disparaît. Suit MQTT_TOPIC_PREFIX. Serveur absent = micro coupé :
* rien ne part plus sur le réseau sans personne pour l'écouter.
*/
static constexpr const char* MQTT_SERVER_STATUS_TOPIC = "voice/server/status";
/// Période de republication de l'état (ms) : IP, RSSI, durée de fonctionnement.
static constexpr uint32_t STATE_PERIOD_MS = 30000;
// ------------------------------------------------------------ Fiabilisation (étape 15)
/**
* Chien de garde (watchdog) des tâches : une tâche surveillée (loop, capture, audio)
* qui ne donne plus signe de vie pendant ce délai fait redémarrer la carte, avec la
* cause « watchdog » dans l'événement boot. Mieux vaut 2 s de redémarrage qu'une
* carte figée pour toujours.
*
* 15 s : au-dessus du pire blocage LÉGITIME trouvé dans le code réseau du core — un
* envoi TCP attend jusqu'à 10 x 1 s que le Wi-Fi accepte des données
* (NetworkClient::write). Le délai par défaut (5 s) redémarrerait la carte sur un
* simple Wi-Fi faible.
*/
static constexpr uint32_t WATCHDOG_TIMEOUT_S = 15;
/// Wi-Fi absent depuis ce délai (ms) : on relance la connexion de zéro. Filet de
/// sécurité, si la reconnexion automatique n'aboutit pas (point d'accès redémarré...).
static constexpr uint32_t WIFI_RESTART_MS = 30000;
+33
View File
@@ -0,0 +1,33 @@
#pragma once
/**
* @file secrets.h.example
* @brief Modèle de configuration sensible.
*
* Copier ce fichier en « secrets.h » et renseigner les valeurs réelles :
* cp include/secrets.h.example include/secrets.h
*
* « secrets.h » est ignoré par git : il ne doit JAMAIS être versionné.
* Ces valeurs ne sont utilisées qu'à partir de l'étape 3 (Wi-Fi + MQTT).
*/
// ------------------------------------------------------------------------ Wi-Fi
/// Réseau 2,4 GHz obligatoire : l'ESP32 ne sait pas se connecter en 5 GHz.
static constexpr const char* WIFI_SSID = "mon-reseau";
static constexpr const char* WIFI_PASSWORD = "mon-mot-de-passe";
// ------------------------------------------------------------------------- MQTT
/// Adresse IP du broker Mosquitto (Raspberry Pi ou PC). Une adresse IP plutôt
/// qu'un nom : pas de résolution DNS, donc pas d'attente supplémentaire.
/// Sur Linux : `ip -4 addr` sur la machine du broker.
static constexpr const char* MQTT_HOST = "192.168.1.104";
static constexpr uint16_t MQTT_PORT = 1883;
/// Compte MQTT de la carte (étape 15), créé par « make mqtt-user NAME=esp32-01 ».
/// MQTT_USER vide = DEVICE_ID (config.h) : c'est ce nom que les droits du broker
/// (mosquitto/acl) attendent. MQTT_PASSWORD vide = connexion anonyme, refusée par
/// un broker sécurisé.
static constexpr const char* MQTT_USER = "";
static constexpr const char* MQTT_PASSWORD = "";
+37
View File
@@ -0,0 +1,37 @@
; Configuration PlatformIO du firmware de l'assistant vocal.
;
; Nous utilisons le core Arduino-ESP32 3.x (basé sur ESP-IDF 5.x) car il fournit
; le nouveau pilote I2S « i2s_std », le seul maintenu. Le paquet officiel
; « espressif32 » du registre PlatformIO est resté sur le core 2.0.x : on passe
; donc par le fork communautaire « pioarduino », maintenu et recommandé par
; Espressif pour les cores 3.x.
[env:esp32dev]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/54.03.21/platform-espressif32.zip
board = esp32dev
framework = arduino
; Le débit série doit dépasser le débit audio (32 kB/s) : voir README.
monitor_speed = 921600
upload_speed = 921600
; Table de partitions : 3 Mo pour le programme au lieu de 1,3 Mo, sans zone de
; mise à jour à distance (OTA), inutile ici. Avec le Wi-Fi, le programme occupait
; déjà 74 % de la partition par défaut à l'étape 3.
board_build.partitions = huge_app.csv
; Client MQTT minimal et éprouvé (étape 3). Version épinglée : pas de surprise.
lib_deps =
knolleary/PubSubClient @ 2.8
; Lecture des messages JSON du topic control (étape 5), commandes à venir (étape 11).
bblanchon/ArduinoJson @ 7.2.1
build_flags =
-DCORE_DEBUG_LEVEL=3
-Wall
; Le firmware est découpé en classes : include/ pour les constantes, src/ pour le code.
build_src_filter = +<*>
check_tool = cppcheck
check_flags = --enable=warning,style
+37
View File
@@ -0,0 +1,37 @@
#include "AudioFifo.h"
bool AudioFifo::begin(uint8_t* storage, size_t capacity, size_t wakeBytes) {
_handle = xStreamBufferCreateStatic(capacity, wakeBytes, storage, &_control);
return _handle != nullptr;
}
bool AudioFifo::write(const uint8_t* data, size_t length) {
if (_handle == nullptr) {
return false;
}
// Tout ou rien : un seul écrivain, donc la place mesurée ne peut que grandir
// entre ce test et l'écriture (le lecteur ne fait qu'en libérer).
if (xStreamBufferSpacesAvailable(_handle) < length) {
_overruns.fetch_add(1);
return false;
}
xStreamBufferSend(_handle, data, length, 0);
return true;
}
size_t AudioFifo::read(uint8_t* data, size_t length, uint32_t timeoutMs) {
if (_handle == nullptr) {
return 0;
}
return xStreamBufferReceive(_handle, data, length, pdMS_TO_TICKS(timeoutMs));
}
size_t AudioFifo::available() const {
return (_handle == nullptr) ? 0 : xStreamBufferBytesAvailable(_handle);
}
uint32_t AudioFifo::overruns() const {
return _overruns.load();
}
+62
View File
@@ -0,0 +1,62 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/stream_buffer.h>
#include <atomic>
/**
* @brief File d'attente d'octets PCM entre deux rythmes différents.
*
* Deux usages, même mécanisme :
* - lecture (étape 2) : la réception écrit par à-coups, la tâche audio lit au
* rythme régulier du DAC ;
* - capture (étape 4) : la tâche de capture écrit au rythme du micro, l'envoi
* réseau lit par à-coups, au gré du Wi-Fi.
*
* C'est un StreamBuffer FreeRTOS, conçu pour exactement un écrivain et un
* lecteur. Son stockage est fourni par l'appelant (tableau statique) : aucune
* allocation, et chaque FIFO a la taille qui convient à son usage.
*
* Invariant : on n'écrit que des chunks entiers, jamais un morceau. Un chunk
* coupé laisserait un nombre impair d'octets, et tous les échantillons suivants
* seraient lus à cheval sur deux valeurs — un bruit assourdissant.
*/
class AudioFifo {
public:
AudioFifo() = default;
/**
* @brief Crée le StreamBuffer sur le stockage fourni.
* @param storage Tableau d'au moins @p capacity + 1 octets (exigence FreeRTOS).
* @param capacity Capacité utile en octets.
* @param wakeBytes Nombre d'octets qui réveillent un lecteur en attente.
* @return true si la création a réussi.
*/
bool begin(uint8_t* storage, size_t capacity, size_t wakeBytes);
/**
* @brief Ajoute un chunk, sans jamais attendre.
* @return true si le chunk entier a été accepté ; false s'il a été refusé
* faute de place (il est alors compté comme perdu, en entier).
*/
bool write(const uint8_t* data, size_t length);
/**
* @brief Retire au plus @p length octets, en attendant au plus @p timeoutMs.
* @return Nombre d'octets lus (pair, tant qu'on ne demande que des tailles paires).
*/
size_t read(uint8_t* data, size_t length, uint32_t timeoutMs);
/// Octets actuellement en attente.
size_t available() const;
/// Nombre de chunks refusés faute de place depuis le démarrage.
uint32_t overruns() const;
private:
StaticStreamBuffer_t _control = {};
StreamBufferHandle_t _handle = nullptr;
std::atomic<uint32_t> _overruns{0}; ///< lu par loop(), incrémenté par l'écrivain
};
+123
View File
@@ -0,0 +1,123 @@
#include "AudioOutput.h"
#include <math.h>
AudioOutput::AudioOutput(int pinBclk, int pinLrc, int pinDin)
: _pinBclk(pinBclk), _pinLrc(pinLrc), _pinDin(pinDin) {}
bool AudioOutput::begin(uint32_t sampleRate, bool philips) {
// --- 1. Canal I2S1 en émission, l'ESP32 maître d'horloge.
i2s_chan_config_t chanConfig = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_1, I2S_ROLE_MASTER);
// 6 descripteurs de 240 trames = 1440 trames = 90 ms de marge : c'est aussi la
// latence ajoutée par le DMA entre write() et le haut-parleur.
chanConfig.dma_desc_num = 6;
chanConfig.dma_frame_num = 240;
// DMA à court de données : il envoie des zéros (silence) au lieu de répéter
// en boucle le dernier tampon, ce qui produirait un bourdonnement.
chanConfig.auto_clear = true;
if (i2s_new_channel(&chanConfig, &_txHandle, nullptr) != ESP_OK) {
_txHandle = nullptr;
return false;
}
// --- 2. 16 bits, deux voies, cadrage choisi. Horloge bit : 16000 x 32 = 512 kHz.
const i2s_std_slot_config_t philipsSlots =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO);
const i2s_std_slot_config_t msbSlots =
I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_STEREO);
i2s_std_config_t stdConfig = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate),
.slot_cfg = philips ? philipsSlots : msbSlots,
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED, // le MAX98357A n'a pas besoin d'horloge maître
.bclk = static_cast<gpio_num_t>(_pinBclk),
.ws = static_cast<gpio_num_t>(_pinLrc),
.dout = static_cast<gpio_num_t>(_pinDin),
.din = I2S_GPIO_UNUSED,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
if (i2s_channel_init_std_mode(_txHandle, &stdConfig) != ESP_OK ||
i2s_channel_enable(_txHandle) != ESP_OK) {
i2s_del_channel(_txHandle);
_txHandle = nullptr;
return false;
}
_sampleRate = sampleRate;
return true;
}
size_t AudioOutput::write(const int16_t* samples, size_t count) {
if (_txHandle == nullptr) {
return 0;
}
size_t done = 0;
while (done < count) {
size_t frames = count - done;
if (frames > BLOCK_FRAMES) {
frames = BLOCK_FRAMES;
}
// Mono -> stéréo : le même échantillon sur la voie gauche et la droite.
for (size_t i = 0; i < frames; ++i) {
_stereo[i * 2] = samples[done + i];
_stereo[(i * 2) + 1] = samples[done + i];
}
size_t written = 0;
if (i2s_channel_write(_txHandle, _stereo, frames * 2 * sizeof(int16_t), &written,
portMAX_DELAY) != ESP_OK) {
break;
}
done += written / (2 * sizeof(int16_t));
}
return done;
}
void AudioOutput::playTone(uint32_t frequencyHz, uint32_t durationMs, int16_t amplitude) {
if (_txHandle == nullptr || _sampleRate == 0) {
return;
}
const size_t total = (static_cast<size_t>(_sampleRate) * durationMs) / 1000;
const size_t fade = _sampleRate / 100; // 10 ms : un démarrage brutal claquerait
const float step = 2.0F * static_cast<float>(M_PI) * static_cast<float>(frequencyHz) /
static_cast<float>(_sampleRate);
float phase = 0.0F;
size_t done = 0;
while (done < total) {
size_t count = total - done;
if (count > BLOCK_FRAMES) {
count = BLOCK_FRAMES;
}
for (size_t i = 0; i < count; ++i) {
const size_t n = done + i;
float gain = 1.0F;
if (n < fade) {
gain = static_cast<float>(n) / static_cast<float>(fade);
} else if (total - n < fade) {
gain = static_cast<float>(total - n) / static_cast<float>(fade);
}
_tone[i] = static_cast<int16_t>(lroundf(static_cast<float>(amplitude) * gain *
sinf(phase)));
phase += step;
if (phase > 2.0F * static_cast<float>(M_PI)) {
phase -= 2.0F * static_cast<float>(M_PI); // évite la perte de précision
}
}
write(_tone, count);
done += count;
}
}
+70
View File
@@ -0,0 +1,70 @@
#pragma once
#include <Arduino.h>
#include <driver/i2s_std.h>
/**
* @brief Sortie audio vers l'ampli I2S MAX98357A, sur le périphérique I2S1.
*
* Reçoit du PCM 16 bits mono et l'émet en **stéréo, chaque échantillon copié
* sur les deux voies**. Leçon de l'étape 1 : ne rien laisser au comportement
* implicite d'un mode mono. Le MAX98357A joue par défaut (gauche + droite) / 2 :
* avec deux voies identiques, le niveau est exact, et une éventuelle inversion
* gauche/droite n'a aucune conséquence.
*
* Le canal tourne en permanence dès begin(). Quand rien n'est à jouer, le DMA
* envoie des zéros : l'ampli reste cadencé et silencieux, ses broches ne
* flottent jamais (c'était la cause du souffle au premier branchement).
*/
class AudioOutput {
public:
/**
* @param pinBclk Horloge bit vers l'ampli (BCLK).
* @param pinLrc Sélection de mot vers l'ampli (LRC).
* @param pinDin Données vers l'ampli (DIN).
*/
AudioOutput(int pinBclk, int pinLrc, int pinDin);
/**
* @brief Démarre le canal I2S1 en émission ; l'ampli reçoit aussitôt du silence.
* @param sampleRate Fréquence d'échantillonnage en Hz.
* @param philips true : cadrage Philips (MAX98357A) ; false : MSB (MAX98357B).
* @return true si le démarrage a réussi.
*/
bool begin(uint32_t sampleRate, bool philips);
/**
* @brief Émet des échantillons mono ; bloque jusqu'à ce que le DMA les accepte.
*
* Le blocage rythme naturellement l'appelant sur le débit du DAC. À n'appeler
* que depuis un seul contexte (la tâche audio, ou setup() avant elle).
*
* @param samples Échantillons 16 bits signés.
* @param count Nombre d'échantillons.
* @return Nombre d'échantillons émis.
*/
size_t write(const int16_t* samples, size_t count);
/**
* @brief Joue un son pur, de façon bloquante, avec fondus de 10 ms.
*
* Sert de test de la sortie seule : aucun PC, aucune FIFO, aucune tâche.
*
* @param frequencyHz Fréquence en Hz.
* @param durationMs Durée en millisecondes.
* @param amplitude Amplitude crête, 0 à 32767.
*/
void playTone(uint32_t frequencyHz, uint32_t durationMs, int16_t amplitude);
private:
/// Trames traitées par passe : 256 = 16 ms à 16 kHz.
static constexpr size_t BLOCK_FRAMES = 256;
int _pinBclk;
int _pinLrc;
int _pinDin;
i2s_chan_handle_t _txHandle = nullptr;
uint32_t _sampleRate = 0;
int16_t _stereo[BLOCK_FRAMES * 2] = {}; ///< tampon d'émission, voies entrelacées
int16_t _tone[BLOCK_FRAMES] = {}; ///< tampon de génération du son pur
};
+77
View File
@@ -0,0 +1,77 @@
#include "CommandHandler.h"
#include <ArduinoJson.h>
CommandHandler::CommandHandler(PlaybackStream& playback, MqttLink& mqtt, DemoDevices& devices,
ModeCallback onListen, ModeCallback onCapture)
: _playback(playback), _mqtt(mqtt), _devices(devices), _onListen(onListen), _onCapture(onCapture) {}
void CommandHandler::handle(const uint8_t* payload, size_t length) {
JsonDocument doc; // messages de contrôle : quelques dizaines d'octets
const DeserializationError error = deserializeJson(doc, payload, length);
if (error) {
Serial.printf("# control : JSON illisible (%s)\n", error.c_str());
return;
}
const char* event = doc["event"] | "";
const char* command = doc["cmd"] | "";
if (strcmp(event, "start") == 0) {
const char* session = doc["session"] | "?";
const bool accepted = _playback.open(session, doc["rate"] | 0UL, doc["bits"] | 0UL,
doc["channels"] | 0UL, true);
if (!accepted) {
char reply[96];
snprintf(reply, sizeof(reply), "{\"event\":\"rejected\",\"session\":\"%s\"}", session);
_mqtt.publish("event", reply, false);
}
return;
}
if (strcmp(event, "end") == 0) {
_playback.close(doc["chunks"] | -1L);
return;
}
if (strcmp(command, "ping") == 0) {
char reply[64];
snprintf(reply, sizeof(reply), "{\"event\":\"pong\",\"uptime_ms\":%lu}",
static_cast<unsigned long>(millis()));
_mqtt.publish("event", reply, false);
Serial.println("# control : ping -> pong");
return;
}
if (strcmp(command, "listen") == 0) {
_onListen(doc["enabled"] | false);
return;
}
if (strcmp(command, "capture") == 0) {
_onCapture(doc["active"] | false);
return;
}
if (strcmp(command, "device") == 0) {
handleDevice(doc["device"] | "", doc["action"] | "");
return;
}
const int shown = static_cast<int>(length > 120 ? 120 : length);
Serial.printf("# control : message non reconnu : %.*s\n", shown,
reinterpret_cast<const char*>(payload));
}
void CommandHandler::handleDevice(const char* device, const char* action) {
const bool ok = _devices.apply(device, action);
const char* state = _devices.state(device);
Serial.printf("# domotique : %s %s -> %s (%s)\n", device, action, ok ? "fait" : "REFUSE", state);
// Confirmation : le serveur sait que la commande a été reçue ET exécutée.
char reply[128];
snprintf(reply, sizeof(reply),
"{\"event\":\"device\",\"device\":\"%.16s\",\"action\":\"%.8s\",\"ok\":%s,\"state\":\"%s\"}",
device, action, ok ? "true" : "false", state);
_mqtt.publish("event", reply, false);
}
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include <Arduino.h>
#include "DemoDevices.h"
#include "MqttLink.h"
#include "PlaybackStream.h"
/**
* @brief Interprète les messages JSON reçus sur voice/<id>/control.
*
* Deux familles de messages :
* - flux audio sortant (étape 5), clé « event » :
* {"event":"start","session":"s1","rate":16000,"bits":16,"channels":1,...}
* {"event":"end","session":"s1","chunks":42}
* - commandes, clé « cmd » :
* {"cmd":"ping"} -> répond {"event":"pong","uptime_ms":...} sur voice/<id>/event
* {"cmd":"device","device":"light","action":"on"} (étape 11)
* -> applique la commande aux appareils de la pièce, puis confirme :
* {"event":"device","device":"light","action":"on","ok":true,"state":"on"}
* {"cmd":"listen","enabled":true} (étape 13) écoute continue : le micro part
* vers le serveur sur voice/<id>/audio/stream, qui guette le mot de réveil
* {"cmd":"capture","active":true} (étape 13) le serveur a entendu le mot de
* réveil et écoute la commande : LED allumée
*
* Le traitement reste court : il est appelé depuis le rappel MQTT, dans loop().
*/
class CommandHandler {
public:
/// Rappel pour un mode activé ou désactivé par le serveur.
using ModeCallback = void (*)(bool enabled);
CommandHandler(PlaybackStream& playback, MqttLink& mqtt, DemoDevices& devices,
ModeCallback onListen, ModeCallback onCapture);
/// Traite un message du topic control.
void handle(const uint8_t* payload, size_t length);
private:
PlaybackStream& _playback;
MqttLink& _mqtt;
DemoDevices& _devices;
ModeCallback _onListen;
ModeCallback _onCapture;
/// Commande domotique : exécution puis confirmation sur voice/<id>/event.
void handleDevice(const char* device, const char* action);
};
+45
View File
@@ -0,0 +1,45 @@
#include "DemoDevices.h"
#include <string.h>
DemoDevices::DemoDevices(int lightPin) : _lightPin(lightPin) {}
void DemoDevices::begin() {
pinMode(_lightPin, OUTPUT);
digitalWrite(_lightPin, LOW);
}
bool DemoDevices::apply(const char* device, const char* action) {
const bool on = strcmp(action, "on") == 0;
const bool off = strcmp(action, "off") == 0;
const bool open = strcmp(action, "open") == 0;
const bool close = strcmp(action, "close") == 0;
if (strcmp(device, "light") == 0 && (on || off)) {
_lightOn = on;
digitalWrite(_lightPin, on ? HIGH : LOW);
return true;
}
if (strcmp(device, "shutter") == 0 && (open || close)) {
_shutterOpen = open; // simulé : un moteur de volet se piloterait ici
return true;
}
if (strcmp(device, "door") == 0 && (open || close)) {
_doorOpen = open; // simulé : jamais de gâche réelle commandée à la voix (voir README)
return true;
}
return false;
}
const char* DemoDevices::state(const char* device) const {
if (strcmp(device, "light") == 0) {
return _lightOn ? "on" : "off";
}
if (strcmp(device, "shutter") == 0) {
return _shutterOpen ? "open" : "closed";
}
if (strcmp(device, "door") == 0) {
return _doorOpen ? "open" : "closed";
}
return "?";
}
+36
View File
@@ -0,0 +1,36 @@
#pragma once
#include <Arduino.h>
/**
* @brief Appareils de démonstration de la pièce où se trouve la carte (étape 11).
*
* La lumière est une vraie LED (PIN_DEMO_LIGHT, via une résistance de 330 Ω) ;
* les volets et la porte sont simulés : leur état est gardé en mémoire et
* affiché sur la liaison série. Un vrai relais se brancherait comme la LED.
*/
class DemoDevices {
public:
/// @param lightPin Broche de la LED qui figure la lumière.
explicit DemoDevices(int lightPin);
/// Configure la broche de la LED, éteinte.
void begin();
/**
* @brief Applique une commande.
* @param device « light », « shutter » ou « door ».
* @param action « on »/« off » pour la lumière, « open »/« close » pour volets et porte.
* @return false si l'appareil ou l'action est inconnu (rien n'est modifié).
*/
bool apply(const char* device, const char* action);
/// État courant : « on », « off », « open », « closed », ou « ? » si l'appareil est inconnu.
const char* state(const char* device) const;
private:
int _lightPin;
bool _lightOn = false;
bool _shutterOpen = false;
bool _doorOpen = false;
};
+361
View File
@@ -0,0 +1,361 @@
#include "MicCapture.h"
#include <driver/gpio.h>
#include "config.h"
MicCapture::MicCapture(int pinSck, int pinWs, int pinSd)
: _pinSck(pinSck), _pinWs(pinWs), _pinSd(pinSd) {}
bool MicCapture::begin(uint32_t sampleRate, Format format) {
// --- 1. Création du canal I2S0 en réception, l'ESP32 étant maître d'horloge.
i2s_chan_config_t chanConfig = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER);
// 6 descripteurs de 200 trames stéréo de 8 octets = 9600 octets, soit 150 ms
// de marge à 16 kHz : de quoi absorber les pauses de la boucle principale.
chanConfig.dma_desc_num = 6;
chanConfig.dma_frame_num = 200;
chanConfig.auto_clear = false;
if (i2s_new_channel(&chanConfig, nullptr, &_rxHandle) != ESP_OK) {
_rxHandle = nullptr;
return false;
}
// --- 2. Mots de 32 bits, les DEUX moitiés, cadrage choisi par l'appelant.
const i2s_std_slot_config_t philips =
I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO);
const i2s_std_slot_config_t msb =
I2S_STD_MSB_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_32BIT, I2S_SLOT_MODE_STEREO);
i2s_std_config_t stdConfig = {
.clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(sampleRate),
.slot_cfg = (format == Format::Philips) ? philips : msb,
.gpio_cfg = {
.mclk = I2S_GPIO_UNUSED,
.bclk = static_cast<gpio_num_t>(_pinSck),
.ws = static_cast<gpio_num_t>(_pinWs),
.dout = I2S_GPIO_UNUSED,
.din = static_cast<gpio_num_t>(_pinSd),
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
if (i2s_channel_init_std_mode(_rxHandle, &stdConfig) != ESP_OK) {
end();
return false;
}
// --- 3. Pull-down sur la ligne de données, exigé par le datasheet INMP441 :
// le micro met sa sortie en haute impédance hors de sa moitié de trame et
// après ses 24 bits utiles. Sans résistance, la ligne flotte et l'on lit
// n'importe quoi pendant ces instants.
gpio_set_pull_mode(static_cast<gpio_num_t>(_pinSd),
MIC_SD_PULLDOWN ? GPIO_PULLDOWN_ONLY : GPIO_FLOATING);
if (i2s_channel_enable(_rxHandle) != ESP_OK) {
end();
return false;
}
_sampleRate = sampleRate;
_format = format;
_half = Half::First;
_phase = 0; // le canal démarre sur un début de trame : la phase est ancrée ici
_lowBand = 0;
resetStats();
// Le micro vient d'être réveillé par ses horloges : on laisse passer son
// transitoire. À partir d'ici, plus rien n'arrête les horloges.
settle(MIC_SETTLE_MS);
flush();
return true;
}
void MicCapture::end() {
if (_rxHandle == nullptr) {
return;
}
i2s_channel_disable(_rxHandle);
i2s_del_channel(_rxHandle);
_rxHandle = nullptr;
_sampleRate = 0;
}
void MicCapture::useHalf(Half half) {
_half = half;
flush(); // réamorce le filtre de continu sur la nouvelle moitié
}
MicCapture::Half MicCapture::half() const {
return _half;
}
uint32_t MicCapture::sampleRate() const {
return _sampleRate;
}
MicCapture::Format MicCapture::format() const {
return _format;
}
size_t MicCapture::read(int16_t* buffer, size_t count) {
if (_rxHandle == nullptr) {
return 0;
}
// Une trame vaut deux mots. Le pilote rend ce qui est disponible, pas
// forcément un nombre pair de mots : la phase est donc mémorisée entre les
// appels, sinon les deux moitiés s'échangent et l'on enregistre la muette.
const uint8_t wantedPhase = (_half == Half::First) ? 0 : 1;
size_t produced = 0;
while (produced < count) {
size_t framesWanted = count - produced;
if (framesWanted > SCRATCH_WORDS / 2) {
framesWanted = SCRATCH_WORDS / 2;
}
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch,
framesWanted * 2 * sizeof(int32_t), &bytesRead,
pdMS_TO_TICKS(200));
// Les mots reçus sont traités même si la lecture a été interrompue :
// les ignorer décalerait la phase pour tout le reste de la session.
const size_t words = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < words; ++i) {
const bool selected = (_phase == wantedPhase);
_phase ^= 1; // toujours avancer, même pour un mot ignoré
if (!selected || produced >= count) {
continue;
}
const int32_t word = _scratch[i];
accumulate(_stats, word);
// Les 24 bits utiles occupent les bits 31..8 : on travaille en 24 bits
// jusqu'au bout, et l'on ne réduit à 16 bits qu'à la toute fin.
const int32_t sample24 = word >> 8;
// Passe-haut du premier ordre à 80 Hz : _lowBand suit tout ce qui est
// plus lent (composante continue, grondement), on le retire.
_lowBand += (sample24 - _lowBand) >> MIC_HPF_SHIFT;
const int32_t filtered24 = sample24 - _lowBand;
const int32_t magnitude = (filtered24 < 0) ? -filtered24 : filtered24;
if (magnitude > _stats.filteredPeak) {
_stats.filteredPeak = magnitude;
}
// Gain puis réduction 24 -> 16 bits, avec arrondi. |filtered24| < 2^25
// et MIC_GAIN <= 64 : le produit tient dans un int32.
int32_t out = (filtered24 * MIC_GAIN + 128) >> 8;
// Écrêtage explicite : sans lui, un dépassement produirait un craquement.
if (out > 32767) {
out = 32767;
} else if (out < -32768) {
out = -32768;
}
buffer[produced] = static_cast<int16_t>(out);
++produced;
}
if (err != ESP_OK || bytesRead == 0) {
break;
}
}
return produced;
}
MicCapture::FrameStats MicCapture::probe(uint32_t durationMs) {
FrameStats frames;
if (_rxHandle == nullptr || _sampleRate == 0) {
return frames;
}
const size_t target = (static_cast<size_t>(_sampleRate) * durationMs) / 1000;
while (frames.first.wordCount < target) {
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch),
&bytesRead, pdMS_TO_TICKS(200));
const size_t words = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < words; ++i) {
accumulate((_phase == 0) ? frames.first : frames.second, _scratch[i]);
_phase ^= 1;
}
if (err != ESP_OK || bytesRead == 0) {
break;
}
}
return frames;
}
size_t MicCapture::readRaw(int32_t* words, size_t count) {
if (_rxHandle == nullptr) {
return 0;
}
// On ne rend que des trames entières : si le prochain mot est une 2e moitié,
// on le jette pour que words[0] soit toujours une 1re moitié.
if (_phase != 0) {
size_t bytesRead = 0;
i2s_channel_read(_rxHandle, _scratch, sizeof(int32_t), &bytesRead, pdMS_TO_TICKS(200));
_phase ^= static_cast<uint8_t>((bytesRead / sizeof(int32_t)) & 1U);
if (_phase != 0) {
return 0; // lecture impossible : on ne rend rien plutôt que du désaligné
}
}
size_t copied = 0;
while (copied < count) {
size_t wanted = count - copied;
if (wanted > SCRATCH_WORDS) {
wanted = SCRATCH_WORDS;
}
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, wanted * sizeof(int32_t),
&bytesRead, pdMS_TO_TICKS(200));
const size_t got = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < got; ++i) {
words[copied + i] = _scratch[i];
_phase ^= 1;
}
copied += got;
if (err != ESP_OK || bytesRead == 0) {
break;
}
}
return copied;
}
MicCapture::DataLineReport MicCapture::probeDataLine() {
DataLineReport report;
report.samples = 4000; // environ 1 ms, soit une vingtaine de trames à 16 kHz
const gpio_num_t pin = static_cast<gpio_num_t>(_pinSd);
// gpio_get_level() lit l'état réel de la broche, même quand elle est routée
// vers le périphérique I2S : les horloges peuvent donc continuer de tourner.
gpio_set_pull_mode(pin, GPIO_PULLUP_ONLY);
delayMicroseconds(200); // laisse la résistance charger la capacité de la ligne
for (uint32_t i = 0; i < report.samples; ++i) {
report.onesWithPullUp += static_cast<uint32_t>(gpio_get_level(pin));
}
gpio_set_pull_mode(pin, GPIO_PULLDOWN_ONLY);
delayMicroseconds(200);
for (uint32_t i = 0; i < report.samples; ++i) {
report.onesWithPullDown += static_cast<uint32_t>(gpio_get_level(pin));
}
// Retour à la configuration de service.
gpio_set_pull_mode(pin, MIC_SD_PULLDOWN ? GPIO_PULLDOWN_ONLY : GPIO_FLOATING);
return report;
}
void MicCapture::accumulate(RawStats& stats, int32_t word) {
if (stats.wordCount == 0) {
stats.minWord = word;
stats.maxWord = word;
} else if (word < stats.minWord) {
stats.minWord = word;
} else if (word > stats.maxWord) {
stats.maxWord = word;
}
if (word == 0) {
++stats.zeroCount;
}
// Valeur efficace : (mot >> 12) tient sur 20 bits, son carré sur 40 : un
// int64 ne déborde qu'après des millions de mots, bien au-delà d'une session.
const int64_t reduced = static_cast<int64_t>(word >> 12);
stats.sum += reduced;
stats.sumSquares += reduced * reduced;
// Le bit le plus bas jamais mis à 1 dit où s'arrêtent les données : 24 bits
// bien cadrés s'arrêtent au bit 8. Ne dépend pas de l'amplitude du signal.
stats.orBits |= static_cast<uint32_t>(word);
++stats.wordCount;
}
void MicCapture::resetStats() {
_stats = RawStats();
}
MicCapture::RawStats MicCapture::stats() const {
return _stats;
}
void MicCapture::flush() {
if (_rxHandle == nullptr) {
return;
}
// On vide le DMA par lecture, SANS arrêter le canal : arrêter les horloges
// endormirait le micro, et son réveil produirait une dérive lente de la
// composante continue, audible comme un « boum » en début de session.
// La phase est suivie mot par mot, l'alignement est donc conservé.
//
// Au passage, on mesure la composante continue de la moitié retenue pour
// amorcer le passe-haut : la session démarre sans transitoire.
const uint8_t wantedPhase = (_half == Half::First) ? 0 : 1;
int64_t sum = 0;
size_t count = 0;
size_t bytesRead = 0;
do {
bytesRead = 0;
i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch), &bytesRead, 0);
const size_t words = bytesRead / sizeof(int32_t);
for (size_t i = 0; i < words; ++i) {
if (_phase == wantedPhase) {
sum += _scratch[i] >> 8; // en LSB 24 bits, comme le filtre
++count;
}
_phase ^= 1;
}
} while (bytesRead > 0);
if (count > 0) {
_lowBand = static_cast<int32_t>(sum / static_cast<int64_t>(count));
}
}
void MicCapture::settle(uint32_t durationMs) {
if (_rxHandle == nullptr) {
return;
}
const uint32_t deadline = millis() + durationMs;
while (static_cast<int32_t>(deadline - millis()) > 0) {
size_t bytesRead = 0;
const esp_err_t err = i2s_channel_read(_rxHandle, _scratch, sizeof(_scratch),
&bytesRead, pdMS_TO_TICKS(50));
// Mots jetés, mais comptés : un nombre impair inverse la phase.
_phase ^= static_cast<uint8_t>((bytesRead / sizeof(int32_t)) & 1U);
if (err != ESP_OK) {
break;
}
}
}
+169
View File
@@ -0,0 +1,169 @@
#pragma once
#include <Arduino.h>
#include <driver/i2s_std.h>
/**
* @brief Capture audio depuis le micro I2S INMP441.
*
* Le micro fournit des échantillons 24 bits alignés à gauche dans des mots de
* 32 bits. Cette classe lit ces mots via le DMA du périphérique I2S0, les
* convertit en PCM 16 bits signé, supprime l'offset continu et applique un gain.
*
* Lecture en **stéréo** : une trame I2S contient deux moitiés, et l'INMP441
* n'émet que dans l'une des deux, choisie par sa broche L/R. On lit les deux et
* on choisit en logiciel, en suivant la phase mot par mot.
*/
class MicCapture {
public:
/// Moitié de trame I2S retenue.
enum class Half {
First, ///< premier mot de chaque trame
Second, ///< second mot de chaque trame
};
/**
* @brief Cadrage des bits dans la trame.
*
* Philips (I2S standard) : le premier bit de donnée arrive un coup d'horloge
* APRÈS le changement de WS. MSB (justifié à gauche) : il arrive EN MÊME
* TEMPS. Un cadrage faux décale tout le mot d'un bit : le bit de signe tombe
* hors de sa place, et un signal faible prend l'allure d'un bruit pleine
* échelle.
*/
enum class Format {
Philips,
Msb,
};
/// Statistiques sur des mots I2S bruts, avant toute conversion.
struct RawStats {
int32_t minWord = 0; ///< mot le plus négatif observé
int32_t maxWord = 0; ///< mot le plus positif observé
size_t wordCount = 0; ///< nombre de mots examinés
size_t zeroCount = 0; ///< nombre de mots exactement nuls
int64_t sum = 0; ///< somme de (mot >> 12), pour la composante continue
int64_t sumSquares = 0; ///< somme des carrés de (mot >> 12), pour la valeur efficace
uint32_t orBits = 0; ///< OU de tous les mots : quels bits ont servi au moins une fois
int32_t filteredPeak = 0; ///< crête après passe-haut, avant gain, en LSB 24 bits (read() seul)
};
/// Relevé simultané des deux moitiés d'une trame.
struct FrameStats {
RawStats first;
RawStats second;
};
/// Résultat du test électrique de la ligne de données.
struct DataLineReport {
uint32_t samples = 0; ///< nombre de lectures par essai
uint32_t onesWithPullUp = 0; ///< niveaux hauts lus avec pull-up interne
uint32_t onesWithPullDown = 0; ///< niveaux hauts lus avec pull-down interne
};
/**
* @param pinSck Broche d'horloge bit (SCK / BCLK du micro).
* @param pinWs Broche de sélection de mot (WS / LRCL).
* @param pinSd Broche de donnée série venant du micro (SD / DOUT).
*/
MicCapture(int pinSck, int pinWs, int pinSd);
/**
* @brief Initialise le périphérique I2S0 en réception stéréo.
* @param sampleRate Fréquence d'échantillonnage en Hz.
* @param format Cadrage des bits dans la trame.
* @return true si l'initialisation a réussi.
*/
bool begin(uint32_t sampleRate, Format format = Format::Philips);
/// Arrête et libère le canal I2S (obligatoire avant un nouveau begin()).
void end();
/// Choisit la moitié de trame conservée par read().
void useHalf(Half half);
/// Moitié de trame actuellement conservée.
Half half() const;
/// Fréquence d'échantillonnage réellement en service.
uint32_t sampleRate() const;
/// Cadrage actuellement en service.
Format format() const;
/**
* @brief Lit et convertit des échantillons audio.
* @param buffer Destination, au moins @p count échantillons.
* @param count Nombre d'échantillons 16 bits demandés.
* @return Nombre d'échantillons réellement écrits (0 en cas d'erreur).
*/
size_t read(int16_t* buffer, size_t count);
/**
* @brief Écoute pendant une durée donnée et analyse les deux moitiés.
*
* Les échantillons lus sont consommés : ne pas appeler pendant une session.
*
* @param durationMs Durée d'écoute en millisecondes.
* @return Statistiques séparées des deux moitiés de trame.
*/
FrameStats probe(uint32_t durationMs);
/**
* @brief Copie des mots I2S bruts, sans aucune conversion.
*
* Le premier mot copié est toujours une 1re moitié de trame.
*
* @param words Destination.
* @param count Nombre de mots souhaités.
* @return Nombre de mots réellement copiés.
*/
size_t readRaw(int32_t* words, size_t count);
/**
* @brief Teste si la ligne de données est réellement pilotée par le micro.
*
* On applique le pull-up puis le pull-down interne (~45 kOhms) et on
* échantillonne la broche : une sortie logique écrase ces résistances, une
* ligne en l'air les suit. Les horloges doivent être CORRECTES pendant le
* test, sinon le micro ne pilote rien et le verdict ne veut rien dire.
*/
DataLineReport probeDataLine();
/// Remet à zéro les statistiques accumulées par read().
void resetStats();
/// Statistiques de la moitié retenue, depuis le dernier resetStats().
RawStats stats() const;
/**
* @brief Vide les tampons DMA sans arrêter les horloges du micro.
*
* À appeler au début d'une session : sans cela, les premières millisecondes
* enregistrées seraient de l'audio ancien, capté avant l'appui sur le bouton.
* Amorce aussi le filtre de composante continue avec la moyenne mesurée.
*/
void flush();
/// Met à jour un relevé avec un mot brut (public pour les outils de diagnostic).
static void accumulate(RawStats& stats, int32_t word);
private:
/// Taille du tampon de travail 32 bits (1 Kio de RAM, alloué une seule fois).
static constexpr size_t SCRATCH_WORDS = 256;
int _pinSck;
int _pinWs;
int _pinSd;
i2s_chan_handle_t _rxHandle = nullptr;
uint32_t _sampleRate = 0; ///< fréquence retenue par begin()
Format _format = Format::Philips; ///< cadrage retenu par begin()
Half _half = Half::First; ///< moitié de trame conservée
uint8_t _phase = 0; ///< 0 si le prochain mot lu est une 1re moitié
int32_t _lowBand = 0; ///< basses fréquences suivies par le passe-haut, en LSB 24 bits
int32_t _scratch[SCRATCH_WORDS] = {}; ///< tampon de lecture brute
RawStats _stats; ///< statistiques, alimentées par read()
// Jette les échantillons pendant le transitoire de mise en route du micro
void settle(uint32_t durationMs);
};
+295
View File
@@ -0,0 +1,295 @@
#include "MicSelfTest.h"
#include <math.h>
#include "config.h"
namespace {
/// Pleine échelle d'un échantillon 24 bits.
constexpr float FULL_SCALE_24 = 8388608.0F;
/// Résolution de l'INMP441 : bien cadré, le mot porte exactement ce nombre de bits.
constexpr int MIC_DATA_BITS = 24;
/// Au repos, des variations au-dessus de ce seuil méritent une remarque.
constexpr int NOISY_ROOM_DBFS = -30;
} // namespace
MicSelfTest::MicSelfTest(MicCapture& mic) : _mic(mic) {}
bool MicSelfTest::run() {
Serial.println("# auto-test du micro : silence, ne parlez pas (2 s)...");
// Un seul réveil du micro, lu en cadrage MSB. begin() inclut la stabilisation.
_mic.end();
if (!_mic.begin(AUDIO_SAMPLE_RATE, MicCapture::Format::Msb)) {
Serial.println("# ERREUR : initialisation I2S impossible");
return false;
}
Serial.println("# cadrage moitie | continu | variations | bits | verdict");
MicCapture::FrameStats frames;
MicCapture::Half half = MicCapture::Half::First;
const MicCapture::RawStats* active = measure(frames, half);
if (active == nullptr) {
reportFailure("aucune moitie de trame ne porte de donnees.");
return false;
}
// 25 bits en lecture MSB : tout est décalé d'un rang vers le bas, le micro
// émet avec le retard d'un bit du format Philips. On bascule et on vérifie.
if (usedBits(*active) == MIC_DATA_BITS + 1) {
Serial.println("# 25 bits utiles en MSB : le micro suit le cadrage Philips, bascule.");
_mic.end();
if (!_mic.begin(AUDIO_SAMPLE_RATE, MicCapture::Format::Philips)) {
Serial.println("# ERREUR : initialisation I2S impossible");
return false;
}
active = measure(frames, half);
}
if (active == nullptr || usedBits(*active) != MIC_DATA_BITS) {
reportFailure("pas de moitie de trame a 24 bits utiles.");
return false;
}
_mic.useHalf(half);
Serial.printf("# configuration retenue : %lu Hz, cadrage %s, moitie %s\n",
static_cast<unsigned long>(_mic.sampleRate()), formatName(_mic.format()),
halfName(half));
const int variations = dbfs(acRmsLsb24(*active));
Serial.printf("# niveau au repos : continu %d dBFS, variations %d dBFS\n",
dbfs(fabsf(dcLsb24(*active))), variations);
if (variations > NOISY_ROOM_DBFS) {
Serial.println("# NB : bruit de fond eleve, ou micro pas encore stabilise.");
Serial.println("# Sans effet sur le cadrage ; a surveiller sur les enregistrements.");
}
if (MIC_SELFTEST_VERBOSE) {
dumpBits("configuration retenue");
reportDataLine();
}
return true;
}
const MicCapture::RawStats* MicSelfTest::measure(MicCapture::FrameStats& frames,
MicCapture::Half& activeHalf) {
frames = _mic.probe(MIC_PROBE_MS);
const MicCapture::RawStats* halves[] = {&frames.first, &frames.second};
const MicCapture::RawStats* active = nullptr;
int withData = 0;
for (int index = 0; index < 2; ++index) {
const MicCapture::Half half =
(index == 0) ? MicCapture::Half::First : MicCapture::Half::Second;
const MicCapture::RawStats& stats = *halves[index];
Serial.printf("# %-7s %s | %4d dBFS | %5d dBFS | %4d | %s\n",
formatName(_mic.format()), halfName(half), dbfs(fabsf(dcLsb24(stats))),
dbfs(acRmsLsb24(stats)), usedBits(stats), verdictFor(stats));
if (carriesData(stats)) {
++withData;
active = &stats;
activeHalf = half;
}
}
// Le micro n'émet que dans une moitié : deux moitiés actives signalent un
// L/R flottant ou un second micro sur la ligne.
if (withData != 1) {
if (withData == 2) {
Serial.println("# les DEUX moities portent des donnees : L/R du micro bien a GND ?");
}
return nullptr;
}
return active;
}
void MicSelfTest::reportFailure(const char* reason) {
Serial.printf("# ECHEC : %s\n", reason);
dumpBits("echec");
reportDataLine();
Serial.println("# Envoyez ce bloc complet : l'analyse bit a bit dit ou se trouvent");
Serial.println("# les bits du micro dans le mot recu.");
}
void MicSelfTest::reportSession(const MicCapture::RawStats& stats) const {
if (stats.wordCount == 0) {
Serial.println("# parole : aucun mot lu");
return;
}
// La crête retenue est celle qui sort du passe-haut : le grondement
// infrasonore est exclu, c'est bien la voix qu'on mesure.
const int32_t peak24 = stats.filteredPeak;
const int64_t peak16 = (static_cast<int64_t>(peak24) * MIC_GAIN) >> 8;
Serial.printf("# parole : crete %d dBFS apres passe-haut, %d bits utiles\n",
dbfs(static_cast<float>(peak24)), usedBits(stats));
Serial.printf("# apres gain x%d : crete a %ld %% de la pleine echelle\n", MIC_GAIN,
static_cast<long>((peak16 * 100) / 32768));
if (peak24 <= 0) {
return;
}
// Plus grand gain (puissance de 2) plaçant la crête sous 25 % de pleine
// échelle : peak24 x gain / 256 <= 8192, soit gain <= 2^21 / peak24.
const int32_t wanted = static_cast<int32_t>((1L << 21) / peak24);
int gain = 1;
while (gain * 2 <= wanted && gain < 64) {
gain *= 2;
}
if (gain != MIC_GAIN) {
Serial.printf("# -> reglez MIC_GAIN = %d dans firmware/include/config.h\n", gain);
} else {
Serial.println("# -> MIC_GAIN est bien regle.");
}
}
void MicSelfTest::dumpBits(const char* label) {
constexpr size_t WORDS = 2048; // 1024 trames, 64 ms à 16 kHz
static int32_t words[WORDS]; // statique : 8 Kio hors de la pile
_mic.flush();
const size_t got = _mic.readRaw(words, WORDS); // words[0] est une 1re moitié
// Les trames du milieu du relevé sont représentatives du régime établi.
const size_t firstShown = (got / 4) & ~static_cast<size_t>(1);
Serial.printf("# --- analyse bit a bit, %s (%s) ---\n", label, formatName(_mic.format()));
Serial.println("# mots bruts, regime etabli, deux mots par trame :");
for (size_t frame = 0; frame < 8 && firstShown + (frame * 2) + 1 < got; ++frame) {
const size_t index = firstShown + (frame * 2);
Serial.printf("# %08lX %08lX\n", static_cast<unsigned long>(words[index]),
static_cast<unsigned long>(words[index + 1]));
}
// Proportion de 1 à chaque position de bit, du bit 31 (gauche) au bit 0.
for (size_t column = 0; column < 2; ++column) {
uint32_t ones[32] = {};
size_t count = 0;
for (size_t i = column; i < got; i += 2) {
const uint32_t bits = static_cast<uint32_t>(words[i]);
for (int bit = 0; bit < 32; ++bit) {
ones[bit] += (bits >> bit) & 1U;
}
++count;
}
char pattern[33];
for (int bit = 31; bit >= 0; --bit) {
const uint32_t tenth = (count == 0) ? 0 : (ones[bit] * 10U) / (count + 1);
pattern[31 - bit] = static_cast<char>('0' + tenth);
}
pattern[32] = '\0';
Serial.printf("# colonne %u, taux de 1 par bit (31..0) : %s\n",
static_cast<unsigned>(column + 1), pattern);
}
Serial.println("# lecture : 0 = toujours 0, 9 = toujours 1.");
Serial.println("# 24 bits utiles bien cadres = exactement 8 zeros a droite.");
}
void MicSelfTest::reportDataLine() {
const MicCapture::DataLineReport report = _mic.probeDataLine();
if (report.samples == 0) {
return;
}
const uint32_t up = (report.onesWithPullUp * 100) / report.samples;
const uint32_t down = (report.onesWithPullDown * 100) / report.samples;
Serial.printf("# ligne SD : pull-up %lu %% de 1, pull-down %lu %% de 1",
static_cast<unsigned long>(up), static_cast<unsigned long>(down));
if (up >= 98 && down <= 2) {
Serial.println(" -> FLOTTE : rien ne la pilote");
} else if (up <= 2 && down <= 2) {
Serial.println(" -> tenue a 0");
} else if (up >= 98 && down >= 98) {
Serial.println(" -> tenue a 1");
} else {
Serial.println(" -> PILOTEE : le micro emet");
}
}
const char* MicSelfTest::verdictFor(const MicCapture::RawStats& stats) {
if (stats.wordCount == 0) {
return "aucun mot lu";
}
if (stats.zeroCount == stats.wordCount) {
return "silence numerique";
}
if (stats.minWord == stats.maxWord) {
return "valeur CONSTANTE";
}
const int bits = usedBits(stats);
if (bits == MIC_DATA_BITS) {
return "CADRE : 24 bits";
}
if (bits == MIC_DATA_BITS + 1) {
return "un bit de retard (Philips)";
}
if (bits < MIC_DATA_BITS) {
return "DECALE : bit de signe perdu";
}
return "queue du mot non nulle";
}
bool MicSelfTest::carriesData(const MicCapture::RawStats& stats) {
return stats.wordCount > 0 && stats.zeroCount < stats.wordCount &&
stats.minWord != stats.maxWord;
}
float MicSelfTest::dcLsb24(const MicCapture::RawStats& stats) {
if (stats.wordCount == 0) {
return 0.0F;
}
// sum porte sur (mot >> 12) ; en LSB 24 bits (mot >> 8), c'est x16.
return static_cast<float>(static_cast<double>(stats.sum) /
static_cast<double>(stats.wordCount) * 16.0);
}
float MicSelfTest::acRmsLsb24(const MicCapture::RawStats& stats) {
if (stats.wordCount == 0) {
return 0.0F;
}
// Variance = moyenne des carrés - carré de la moyenne.
const double count = static_cast<double>(stats.wordCount);
const double mean = static_cast<double>(stats.sum) / count;
const double variance = static_cast<double>(stats.sumSquares) / count - mean * mean;
if (variance <= 0.0) {
return 0.0F;
}
return static_cast<float>(sqrt(variance) * 16.0);
}
int MicSelfTest::dbfs(float lsb24) {
if (lsb24 <= 0.0F) {
return -999;
}
return static_cast<int>(20.0F * log10f(lsb24 / FULL_SCALE_24));
}
int MicSelfTest::usedBits(const MicCapture::RawStats& stats) {
if (stats.orBits == 0) {
return 0;
}
return 32 - __builtin_ctz(stats.orBits); // ctz : nombre de zéros à droite
}
const char* MicSelfTest::formatName(MicCapture::Format format) {
return (format == MicCapture::Format::Philips) ? "Philips" : "MSB";
}
const char* MicSelfTest::halfName(MicCapture::Half half) {
return (half == MicCapture::Half::First) ? "1re" : "2e ";
}
+78
View File
@@ -0,0 +1,78 @@
#pragma once
#include <Arduino.h>
#include "MicCapture.h"
/**
* @brief Auto-test du micro au démarrage : détermine le cadrage et la moitié de trame.
*
* Le micro n'est réveillé qu'UNE fois : l'INMP441 s'endort dès que ses horloges
* s'arrêtent, et chaque réveil produit une dérive lente de sa composante
* continue. Multiplier les essais, c'était mesurer des réveils, pas la pièce.
*
* Tout se décide sur une capture lue en cadrage MSB, par la **structure** des
* mots reçus, jamais par leur niveau :
*
* - la moitié de trame utile est celle qui n'est ni nulle ni constante ;
* - 24 bits utiles : le micro est cadré MSB, c'est gagné ;
* - 25 bits utiles : le micro suit le cadrage Philips (un bit de retard),
* on bascule et l'on vérifie ;
* - toute autre structure est un défaut, et l'analyse bit à bit s'affiche.
*
* Le niveau au repos est affiché pour information : il dépend du bruit de la
* pièce et de la stabilisation du micro, pas de la justesse du réglage.
*/
class MicSelfTest {
public:
explicit MicSelfTest(MicCapture& mic);
/**
* @brief Détermine le cadrage et la moitié de trame, puis configure le micro.
* @return true si le micro est lu correctement ; il reste alors prêt à capturer.
*/
bool run();
/**
* @brief Imprime le niveau d'une session de parole et le gain conseillé.
* @param stats Statistiques de la moitié retenue pendant la session.
*/
void reportSession(const MicCapture::RawStats& stats) const;
private:
MicCapture& _mic;
// Capture, affiche le tableau et renvoie la moitié qui porte le micro (ou nullptr)
const MicCapture::RawStats* measure(MicCapture::FrameStats& frames,
MicCapture::Half& activeHalf);
// Diagnostic complet en cas d'échec
void reportFailure(const char* reason);
// Analyse bit à bit et vidage hexadécimal de la configuration courante
void dumpBits(const char* label);
// Test électrique de la ligne SD, horloges correctes
void reportDataLine();
// Verdict structurel pour une moitié de trame
static const char* verdictFor(const MicCapture::RawStats& stats);
// true si la moitié porte des données (ni nulle, ni constante)
static bool carriesData(const MicCapture::RawStats& stats);
// Composante continue (moyenne) en LSB 24 bits
static float dcLsb24(const MicCapture::RawStats& stats);
// Valeur efficace des variations, composante continue retirée, en LSB 24 bits
static float acRmsLsb24(const MicCapture::RawStats& stats);
// Conversion en dBFS (pleine échelle 24 bits)
static int dbfs(float lsb24);
// Nombre de bits utiles : du bit 31 au plus bas bit jamais mis à 1
static int usedBits(const MicCapture::RawStats& stats);
static const char* formatName(MicCapture::Format format);
static const char* halfName(MicCapture::Half half);
};
+153
View File
@@ -0,0 +1,153 @@
#include "MqttLink.h"
#include "config.h"
namespace {
/// Message publié par le broker à notre place si l'on disparaît sans prévenir.
const char* const WILL_PAYLOAD = "{\"status\":\"offline\"}";
} // namespace
MqttLink::MqttLink(Client& network, const char* host, uint16_t port, const char* deviceId,
const char* user, const char* password)
: _client(network),
_host(host),
_port(port),
_deviceId(deviceId),
_user((user != nullptr && user[0] != '\0') ? user : deviceId),
_password(password) {}
bool MqttLink::begin(MessageHandler onMessage, ConnectHandler onConnect) {
_onMessage = onMessage;
_onConnect = onConnect;
_client.setServer(_host, _port);
_client.setKeepAlive(MQTT_KEEPALIVE_S);
_client.setSocketTimeout(MQTT_SOCKET_TIMEOUT_S);
_client.setCallback([this](char* topic, uint8_t* payload, unsigned int length) {
const char* suffix = suffixOf(topic);
if (_onMessage) {
_onMessage(suffix != nullptr ? suffix : topic, payload, length);
}
});
buildTopic(_willTopic, sizeof(_willTopic), "state");
if (strcmp(_user, _deviceId) != 0) {
// Les droits du broker (pattern voice/%u/...) portent sur le nom d'utilisateur :
// un autre nom que DEVICE_ID, et la carte n'a le droit de rien publier.
Serial.printf("# mqtt : ATTENTION, utilisateur \"%s\" different de DEVICE_ID \"%s\" : "
"le broker refusera ses messages (mosquitto/acl)\n", _user, _deviceId);
}
// Le tampon par défaut (256 octets) ne contiendrait pas un chunk audio : un
// message trop gros est rejeté sans aucun message d'erreur. Alloué une fois ici.
return _client.setBufferSize(MQTT_BUFFER_BYTES);
}
void MqttLink::update(bool networkUp) {
if (_client.connected()) {
_client.loop(); // traite les messages reçus et entretient la connexion
return;
}
if (!networkUp) {
return;
}
// Tentatives espacées : chaque essai peut bloquer jusqu'à MQTT_SOCKET_TIMEOUT_S.
const uint32_t now = millis();
if (_attempted && (now - _lastAttemptMs) < MQTT_RETRY_MS) {
return;
}
_attempted = true;
_lastAttemptMs = now;
connect();
}
bool MqttLink::connected() {
return _client.connected();
}
bool MqttLink::publish(const char* suffix, const char* payload, bool retained) {
if (!_client.connected()) {
return false;
}
return _client.publish(buildTopic(_topic, sizeof(_topic), suffix), payload, retained);
}
bool MqttLink::publishBinary(const char* suffix, const uint8_t* data, size_t length) {
if (!_client.connected()) {
return false;
}
return _client.publish(buildTopic(_topic, sizeof(_topic), suffix), data,
static_cast<unsigned int>(length), false);
}
bool MqttLink::subscribe(const char* suffix) {
if (!_client.connected()) {
return false;
}
return _client.subscribe(buildTopic(_topic, sizeof(_topic), suffix), 1);
}
bool MqttLink::subscribeTopic(const char* topic) {
if (!_client.connected()) {
return false;
}
return _client.subscribe(topic, 1);
}
uint32_t MqttLink::connections() const {
return _connections;
}
void MqttLink::connect() {
Serial.printf("# mqtt : connexion a %s:%u...\n", _host, _port);
// Mot de passe vide = connexion anonyme, refusée par un broker sécurisé (étape 15).
const bool anonymous = (_password == nullptr || _password[0] == '\0');
const char* user = anonymous ? nullptr : _user;
const char* password = anonymous ? nullptr : _password;
// Testament : QoS 1, retenu. Session propre : on ne veut pas recevoir, au
// retour, des commandes périmées accumulées pendant l'absence.
const bool ok = _client.connect(_deviceId, user, password, _willTopic, 1, true, WILL_PAYLOAD,
true);
if (!ok) {
// Codes de PubSubClient : -4 délai dépassé, -2 connexion TCP refusée
// (broker arrêté, mauvais port, pare-feu), 5 non autorisé...
const int code = _client.state();
Serial.printf("# mqtt : echec (code %d), nouvel essai dans %lu s\n", code,
static_cast<unsigned long>(MQTT_RETRY_MS / 1000));
if (code == MQTT_CONNECT_BAD_CREDENTIALS || code == MQTT_CONNECT_UNAUTHORIZED) {
Serial.printf("# mqtt : compte refuse. secrets.h : MQTT_PASSWORD %s ; le compte \"%s\" "
"existe-t-il (make mqtt-user NAME=%s) ?\n",
anonymous ? "VIDE" : "renseigne", _user, _deviceId);
}
return;
}
++_connections;
Serial.printf("# mqtt : connecte (connexion n°%lu)\n", static_cast<unsigned long>(_connections));
if (_onConnect) {
_onConnect(_connections == 1);
}
}
const char* MqttLink::buildTopic(char* buffer, size_t size, const char* suffix) const {
snprintf(buffer, size, "%s/%s/%s", MQTT_TOPIC_PREFIX, _deviceId, suffix);
return buffer;
}
const char* MqttLink::suffixOf(const char* topic) const {
// Attendu : <préfixe>/<id>/<suffixe>. On vérifie les deux premiers segments.
const size_t prefixLength = strlen(MQTT_TOPIC_PREFIX);
const size_t idLength = strlen(_deviceId);
if (strncmp(topic, MQTT_TOPIC_PREFIX, prefixLength) != 0 || topic[prefixLength] != '/') {
return nullptr;
}
const char* rest = topic + prefixLength + 1;
if (strncmp(rest, _deviceId, idLength) != 0 || rest[idLength] != '/') {
return nullptr;
}
return rest + idLength + 1;
}
+109
View File
@@ -0,0 +1,109 @@
#pragma once
#include <Arduino.h>
#include <PubSubClient.h>
#include <functional>
/**
* @brief Connexion au broker MQTT, avec testament et reconnexion espacée.
*
* Tous les topics ont la forme <préfixe>/<identifiant>/<suffixe>, par exemple
* voice/esp32-01/state. On ne manipule ici que le suffixe.
*
* Le **testament** (Last Will) est confié au broker à la connexion : si l'ESP32
* disparaît sans prévenir (coupure de courant, plantage), c'est le broker qui
* publie « offline » à sa place, en message retenu.
*
* Le rappel des messages reçus doit rester court : il sera appelé depuis
* PubSubClient, dans loop(). À l'étape 5, il ne fera que remplir la FIFO audio.
*
* Étape 15 : le broker exige un compte. Le nom d'utilisateur de la carte est son
* identifiant (DEVICE_ID) : les droits du broker (mosquitto/acl) en dépendent.
*/
class MqttLink {
public:
/// Rappel des messages reçus : suffixe pour nos topics (« control »), topic complet
/// pour ceux de subscribeTopic() (« voice/server/status »), payload, longueur.
using MessageHandler = std::function<void(const char*, const uint8_t*, size_t)>;
/// Rappel de connexion : appelé à chaque (re)connexion au broker.
using ConnectHandler = std::function<void(bool firstTime)>;
/**
* @param network Transport TCP (un WiFiClient).
* @param host Adresse IP du broker.
* @param port Port du broker.
* @param deviceId Identifiant de la carte : client MQTT et segment des topics.
* @param user Utilisateur MQTT ; vide : deviceId (les droits du broker l'exigent).
* @param password Mot de passe MQTT ; vide : connexion anonyme (broker non sécurisé).
*/
MqttLink(Client& network, const char* host, uint16_t port, const char* deviceId,
const char* user, const char* password);
/**
* @brief Prépare le client ; aucune connexion n'est tentée ici.
* @param onMessage Appelé pour chaque message reçu sur un topic abonné.
* @param onConnect Appelé après chaque connexion réussie (abonnements, état).
* @return true si le tampon de @ref MQTT_BUFFER_BYTES a pu être alloué.
*/
bool begin(MessageHandler onMessage, ConnectHandler onConnect);
/**
* @brief À appeler à chaque tour de boucle.
* @param networkUp true si le Wi-Fi est connecté : sinon, inutile d'essayer.
*/
void update(bool networkUp);
/// true si la session MQTT est ouverte.
bool connected();
/**
* @brief Publie un texte (JSON) sur <préfixe>/<id>/<suffixe>.
* @return true si le message a été confié à la pile TCP.
*/
bool publish(const char* suffix, const char* payload, bool retained);
/**
* @brief Publie des octets bruts (PCM) sur <préfixe>/<id>/<suffixe>, non retenu.
*
* Peut bloquer tant que la pile TCP n'a pas de place : ne jamais l'appeler
* depuis une tâche audio, seulement depuis loop().
*
* @return true si le message a été confié à la pile TCP.
*/
bool publishBinary(const char* suffix, const uint8_t* data, size_t length);
/// S'abonne à <préfixe>/<id>/<suffixe>, en QoS 1.
bool subscribe(const char* suffix);
/// S'abonne à un topic complet, hors de nos topics (présence du serveur), en QoS 1.
bool subscribeTopic(const char* topic);
/// Nombre de connexions réussies depuis le démarrage.
uint32_t connections() const;
private:
PubSubClient _client;
const char* _host;
uint16_t _port;
const char* _deviceId;
const char* _user;
const char* _password;
MessageHandler _onMessage;
ConnectHandler _onConnect;
uint32_t _lastAttemptMs = 0;
bool _attempted = false;
uint32_t _connections = 0;
char _topic[96] = {}; ///< topic en cours de construction
char _willTopic[96] = {}; ///< topic du testament, conservé pour chaque connexion
// Tente une connexion, testament compris
void connect();
// Construit <préfixe>/<id>/<suffixe> dans le tampon donné
const char* buildTopic(char* buffer, size_t size, const char* suffix) const;
// Rend le suffixe d'un topic reçu, ou nullptr s'il n'est pas de la forme attendue
const char* suffixOf(const char* topic) const;
};
+185
View File
@@ -0,0 +1,185 @@
#include "PlaybackStream.h"
#include <esp_task_wdt.h>
#include "config.h"
PlaybackStream::PlaybackStream(AudioOutput& speaker, AudioFifo& fifo)
: _speaker(speaker), _fifo(fifo) {}
bool PlaybackStream::begin() {
return xTaskCreateStaticPinnedToCore(taskEntry, "audio", sizeof(_taskStack), this,
AUDIO_TASK_PRIORITY, _taskStack, &_taskControl,
AUDIO_TASK_CORE) != nullptr;
}
bool PlaybackStream::open(const char* session, uint32_t rate, uint32_t bits, uint32_t channels,
bool fromMqtt) {
if (_open) {
// Le flux précédent n'est pas fini : le nouveau prend la place.
Serial.printf("# lecture %s : interrompue par un nouveau flux\n", _report.session);
finish(_closed);
}
if (rate != AUDIO_SAMPLE_RATE || bits != 16 || channels != 1) {
Serial.printf("# lecture refusee : %lu Hz, %lu bits, %lu canaux ; attendu %lu Hz, 16 bits, mono\n",
static_cast<unsigned long>(rate), static_cast<unsigned long>(bits),
static_cast<unsigned long>(channels),
static_cast<unsigned long>(AUDIO_SAMPLE_RATE));
return false;
}
// L'état d'abord : dès cet instant, la tâche audio ne peut plus compter de famine
// pour ce flux (elle ne compte qu'en Playing), quoi qu'elle voie ensuite.
_state.store(State::Priming); // rien ne sera joué avant d'avoir la réserve
_report = Report();
strncpy(_report.session, session, sizeof(_report.session) - 1);
_report.fromMqtt = fromMqtt;
_open = true;
_closed = false;
_startMs = millis();
_lastPushMs = _startMs;
_overrunsAtStart = _fifo.overruns();
_underruns.store(0);
_firstSoundMs.store(0);
_minMarginBytes.store(UINT32_MAX);
for (auto& gap : _gapAtMs) {
gap.store(0);
}
_endOfStream.store(false);
Serial.printf("# lecture %s : debut du flux (%s)\n", _report.session, fromMqtt ? "MQTT" : "serie");
return true;
}
void PlaybackStream::push(const uint8_t* data, size_t length) {
// Longueur impaire : échantillon coupé en deux, jamais accepté.
if (!_open || _closed || (length % 2) != 0) {
return;
}
_fifo.write(data, length);
++_report.chunks;
_lastPushMs = millis();
}
void PlaybackStream::close(int32_t announcedChunks) {
if (!_open || _closed) {
return;
}
_closed = true;
_report.announced = announcedChunks;
_report.endReceived = true;
// Plus rien ne doit arriver : une FIFO qui se vide est désormais normale, et
// un son plus court que la réserve doit être joué sans attendre davantage.
_endOfStream.store(true);
}
bool PlaybackStream::takeReport(Report& report) {
if (!_open) {
return false;
}
const uint32_t silence = millis() - _lastPushMs;
if (_closed) {
// Le bilan attend que la FIFO soit vide : la lecture est alors terminée.
if (_fifo.available() > 0 || silence < DRAIN_SILENCE_MS) {
return false;
}
finish(true);
} else if (silence >= ABANDON_MS) {
_endOfStream.store(true); // la source s'est tue : on joue ce qui reste
finish(false);
} else {
return false;
}
report = _report;
return true;
}
bool PlaybackStream::active() const {
return _open;
}
void PlaybackStream::finish(bool endReceived) {
_open = false;
_report.endReceived = endReceived;
_report.overruns = _fifo.overruns() - _overrunsAtStart;
_report.underruns = _underruns.load();
_report.durationMs = millis() - _startMs;
const uint32_t firstSound = _firstSoundMs.load();
_report.startMs = firstSound != 0 ? firstSound - _startMs : 0;
const uint32_t margin = _minMarginBytes.load();
_report.minMarginMs = margin == UINT32_MAX
? -1 : static_cast<int32_t>(margin / (AUDIO_SAMPLE_RATE / 1000 * sizeof(int16_t)));
for (size_t i = 0; i < MAX_GAPS; ++i) {
_report.gapAtMs[i] = _gapAtMs[i].load();
}
}
void PlaybackStream::taskEntry(void* self) {
static_cast<PlaybackStream*>(self)->taskLoop();
}
void PlaybackStream::taskLoop() {
static int16_t block[PLAYBACK_BLOCK_SAMPLES];
const size_t startBytes = PLAYBACK_START_MS * (AUDIO_SAMPLE_RATE / 1000) * sizeof(int16_t);
esp_task_wdt_add(nullptr); // surveillée par le chien de garde (étape 15)
for (;;) {
esp_task_wdt_reset(); // un tour dure au plus ~20 ms : attente FIFO ou bloc I2S
if (_state.load() == State::Priming) {
// Amorçage : rien n'est joué tant que la réserve n'est pas constituée,
// sauf si la fin est annoncée (son plus court que la réserve).
const size_t waiting = _fifo.available();
if (waiting < startBytes && !(_endOfStream.load() && waiting > 0)) {
vTaskDelay(pdMS_TO_TICKS(PRIME_POLL_MS));
continue;
}
uint32_t notYet = 0;
_firstSoundMs.compare_exchange_strong(notYet, millis()); // premier démarrage seulement
_state.store(State::Playing);
}
if (_state.load() == State::Idle) {
vTaskDelay(pdMS_TO_TICKS(PRIME_POLL_MS));
continue;
}
const size_t bytes = _fifo.read(reinterpret_cast<uint8_t*>(block), sizeof(block), 20);
const size_t samples = bytes / sizeof(int16_t);
if (samples > 0) {
_speaker.write(block, samples); // bloque : c'est ce qui donne la cadence
}
if (_endOfStream.load()) {
// Fin annoncée : la FIFO se vide normalement jusqu'au dernier échantillon,
// puis la tâche se met au repos. Si open() a déjà lancé un nouveau flux,
// l'état n'est plus Playing et l'échange échoue : on ne touche à rien.
if (samples == 0 && _fifo.available() == 0) {
State playing = State::Playing;
_state.compare_exchange_strong(playing, State::Idle);
}
continue;
}
if (samples < PLAYBACK_BLOCK_SAMPLES) {
// Réserve épuisée en plein flux : on s'arrête proprement et on réamorce,
// plutôt que de jouer des bribes hachées au gré des arrivées.
// Échange atomique : on ne compte que si l'on jouait encore CE flux. Un
// START arrivé pendant le read() ci-dessus a déjà remis l'état à Priming,
// et ce n'est alors pas une famine (faux compte trouvé à l'étape 12).
State playing = State::Playing;
if (_state.compare_exchange_strong(playing, State::Priming)) {
const uint32_t index = _underruns.fetch_add(1);
if (index < MAX_GAPS) {
_gapAtMs[index].store(millis() - _firstSoundMs.load());
}
}
continue;
}
// Marge restante : c'est le retard réseau qu'on pourrait encore absorber.
const uint32_t left = static_cast<uint32_t>(_fifo.available());
if (left < _minMarginBytes.load()) {
_minMarginBytes.store(left);
}
}
}
+117
View File
@@ -0,0 +1,117 @@
#pragma once
#include <Arduino.h>
#include <atomic>
#include "AudioFifo.h"
#include "AudioOutput.h"
#include "config.h"
/**
* @brief Un flux de lecture vers le haut-parleur, quel que soit son transport.
*
* Même protocole pour la liaison série (étape 2) et pour MQTT (étape 5) :
* open() à la trame START, push() pour chaque chunk, close() à la trame END.
* push() est court et n'attend jamais : il peut être appelé depuis le rappel
* MQTT. La tâche audio, créée par begin(), vide la FIFO au rythme du DAC.
*
* Tampon anti-gigue (étape 12) : rien n'est joué tant que la FIFO ne contient
* pas PLAYBACK_START_MS de son (amorçage). Le Wi-Fi livre les chunks de façon
* irrégulière ; la réserve absorbe ces écarts. Si elle s'épuise quand même, la
* lecture s'arrête proprement et attend de l'avoir reconstituée (réamorçage),
* au lieu de jouer des bribes hachées. Un son plus court que la réserve est joué
* dès que la fin est annoncée.
*
* Un bilan est produit quand le dernier échantillon a été joué, ou quand la
* source s'est tue sans envoyer de END.
*/
class PlaybackStream {
public:
/// Bilan d'un flux, disponible via takeReport().
struct Report {
char session[16] = {};
bool fromMqtt = false; ///< transport du flux (pour savoir où publier le bilan)
bool endReceived = false; ///< false : la source s'est tue sans END
uint32_t chunks = 0; ///< chunks reçus
int32_t announced = -1; ///< chunks annoncés par le END, -1 si inconnu
uint32_t overruns = 0; ///< chunks refusés, FIFO pleine
uint32_t underruns = 0; ///< réserve épuisée en plein flux : une pause, puis réamorçage
uint32_t durationMs = 0; ///< du START au dernier échantillon joué
uint32_t startMs = 0; ///< du START au premier échantillon joué (amorçage)
int32_t minMarginMs = -1; ///< plus petite réserve pendant la lecture, -1 si non mesurée
uint32_t gapAtMs[4] = {}; ///< instant des premiers réamorçages, depuis le premier son (diagnostic)
};
/// Nombre de réamorçages dont l'instant est gardé.
static constexpr size_t MAX_GAPS = 4;
PlaybackStream(AudioOutput& speaker, AudioFifo& fifo);
/// Crée la tâche audio (la FIFO doit être prête).
bool begin();
/**
* @brief Ouvre un flux. Un flux encore ouvert est clos d'office.
* @return false si le format n'est pas celui du système : le flux est refusé.
*/
bool open(const char* session, uint32_t rate, uint32_t bits, uint32_t channels,
bool fromMqtt);
/// Ajoute un chunk ; ignoré si aucun flux n'est ouvert ou si sa longueur est impaire.
void push(const uint8_t* data, size_t length);
/**
* @brief Ferme le flux ; le bilan viendra quand tout aura été joué.
* @param announcedChunks Chunks annoncés par la source, -1 si inconnu.
*/
void close(int32_t announcedChunks);
/**
* @brief À appeler depuis loop() : true une seule fois, quand un bilan est prêt.
* @param report Reçoit le bilan.
*/
bool takeReport(Report& report);
/// true entre open() et le bilan.
bool active() const;
private:
/// Silence après lequel on considère le flux fini (ms).
static constexpr uint32_t DRAIN_SILENCE_MS = 300;
/// Silence après lequel une source muette est abandonnée, sans END (ms).
static constexpr uint32_t ABANDON_MS = 2000;
/// Période de surveillance de la FIFO pendant l'amorçage (ms).
static constexpr uint32_t PRIME_POLL_MS = 5;
/// Ce que fait la tâche audio.
enum class State : uint8_t {
Idle, ///< aucun flux
Priming, ///< réserve en cours de constitution : on ne joue rien
Playing, ///< lecture au rythme du DAC
};
AudioOutput& _speaker;
AudioFifo& _fifo;
bool _open = false; ///< loop() seulement
bool _closed = false; ///< END reçu, on attend la fin de la lecture
uint32_t _startMs = 0;
uint32_t _lastPushMs = 0;
uint32_t _overrunsAtStart = 0;
Report _report;
// Partagés avec la tâche audio.
std::atomic<State> _state{State::Idle};
std::atomic<bool> _endOfStream{false}; ///< END reçu (ou source abandonnée) : la FIFO peut se vider
std::atomic<uint32_t> _underruns{0};
std::atomic<uint32_t> _firstSoundMs{0}; ///< date du premier échantillon joué, 0 : pas encore
std::atomic<uint32_t> _minMarginBytes{UINT32_MAX};
std::atomic<uint32_t> _gapAtMs[MAX_GAPS] = {};
StaticTask_t _taskControl;
StackType_t _taskStack[AUDIO_TASK_STACK_BYTES]; // ESP-IDF : StackType_t = octet
static void taskEntry(void* self);
void taskLoop();
void finish(bool endReceived);
};
+54
View File
@@ -0,0 +1,54 @@
#include "PushButton.h"
PushButton::PushButton(int pin, uint32_t debounceMs)
: _pin(pin), _debounceMs(debounceMs) {}
void PushButton::begin() {
pinMode(_pin, INPUT_PULLUP);
// Niveau bas = appuyé, car l'autre borne du bouton est reliée à GND.
_lastRawState = (digitalRead(_pin) == LOW);
_stableState = _lastRawState;
_lastChangeMs = millis();
}
void PushButton::update() {
const bool raw = (digitalRead(_pin) == LOW);
const uint32_t now = millis();
if (raw != _lastRawState) {
_lastRawState = raw;
_lastChangeMs = now;
return;
}
// L'état brut est resté identique assez longtemps : on le valide.
if (raw != _stableState && (now - _lastChangeMs) >= _debounceMs) {
_stableState = raw;
if (raw) {
_pressedEvent = true;
} else {
_releasedEvent = true;
}
}
}
bool PushButton::isDown() const {
return _stableState;
}
bool PushButton::wasPressed() {
const bool event = _pressedEvent;
_pressedEvent = false;
return event;
}
bool PushButton::wasReleased() {
const bool event = _releasedEvent;
_releasedEvent = false;
return event;
}
void PushButton::clearEvents() {
_pressedEvent = false;
_releasedEvent = false;
}
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include <Arduino.h>
/**
* @brief Bouton poussoir anti-rebond, câblé entre la broche et GND.
*
* Le rebond (« bounce ») est l'oscillation mécanique du contact pendant quelques
* millisecondes : sans filtrage, un seul appui produirait plusieurs fronts.
* La broche utilise le pull-up interne : niveau haut au repos, bas quand on appuie.
*/
class PushButton {
public:
/**
* @param pin Broche du bouton.
* @param debounceMs Durée de stabilité exigée avant de valider un changement.
*/
PushButton(int pin, uint32_t debounceMs);
/// Configure la broche en entrée avec pull-up interne.
void begin();
/// À appeler à chaque tour de boucle : met à jour l'état filtré.
void update();
/// true tant que le bouton est maintenu enfoncé.
bool isDown() const;
/// true une seule fois, au front d'appui (consomme l'événement).
bool wasPressed();
/// true une seule fois, au front de relâchement (consomme l'événement).
bool wasReleased();
/// Oublie les événements en attente (un appui mémorisé pendant une session, par exemple).
void clearEvents();
private:
int _pin;
uint32_t _debounceMs;
bool _stableState = false; ///< état filtré : true = enfoncé
bool _lastRawState = false; ///< dernière lecture brute
uint32_t _lastChangeMs = 0; ///< date du dernier changement brut
bool _pressedEvent = false;
bool _releasedEvent = false;
};
+85
View File
@@ -0,0 +1,85 @@
#include "SerialFrameReader.h"
using SerialProtocol::FrameType;
SerialFrameReader::SerialFrameReader(Stream& stream) : _stream(stream) {}
bool SerialFrameReader::poll(Frame& frame) {
while (_stream.available() > 0) {
const int value = _stream.read();
if (value < 0) {
break;
}
const uint8_t byte = static_cast<uint8_t>(value);
switch (_state) {
case State::Magic:
if (byte == SerialProtocol::MAGIC[_magicMatched]) {
++_magicMatched;
if (_magicMatched == sizeof(SerialProtocol::MAGIC)) {
_state = State::Header;
_headerRead = 0;
}
} else {
// Aucun caractère ne se répète dans le mot magique : il suffit de
// regarder si l'octet fautif peut commencer un nouveau mot magique.
_magicMatched = (byte == SerialProtocol::MAGIC[0]) ? 1 : 0;
}
break;
case State::Header: {
_header[_headerRead++] = byte;
if (_headerRead < sizeof(_header)) {
break;
}
const uint8_t type = _header[0];
_length = static_cast<size_t>(_header[1]) | (static_cast<size_t>(_header[2]) << 8);
const bool knownType = type >= static_cast<uint8_t>(FrameType::Start) &&
type <= static_cast<uint8_t>(FrameType::End);
if (!knownType || _length > SerialProtocol::MAX_PAYLOAD) {
++_rejected;
restart();
break;
}
_payloadRead = 0;
_checksum = 0;
_state = (_length == 0) ? State::Checksum : State::Payload;
break;
}
case State::Payload:
_payload[_payloadRead++] = byte;
_checksum ^= byte;
if (_payloadRead == _length) {
_state = State::Checksum;
}
break;
case State::Checksum: {
const bool valid = (byte == _checksum);
const FrameType type = static_cast<FrameType>(_header[0]);
restart();
if (!valid) {
++_rejected;
break;
}
frame.type = type;
frame.payload = _payload;
frame.length = _length;
return true; // on rend la main : le payload doit être consommé avant la suite
}
}
}
return false;
}
uint32_t SerialFrameReader::rejectedFrames() const {
return _rejected;
}
void SerialFrameReader::restart() {
_state = State::Magic;
_magicMatched = 0;
_headerRead = 0;
// _payload et _length sont conservés : la trame qui vient d'être rendue les lit encore.
}
+58
View File
@@ -0,0 +1,58 @@
#pragma once
#include <Arduino.h>
#include "SerialProtocol.h"
/**
* @brief Décode les trames reçues du PC sur l'UART, octet par octet, sans bloquer.
*
* Machine à états : on cherche le mot magique, puis on lit l'en-tête, le
* payload et la somme de contrôle. Toute incohérence (type inconnu, longueur
* excessive, somme fausse) fait repartir à la recherche du mot magique.
*
* Aucune allocation : le payload est copié dans un tampon interne de taille fixe.
*/
class SerialFrameReader {
public:
/// Trame décodée. Le payload reste valide jusqu'au prochain appel de poll().
struct Frame {
SerialProtocol::FrameType type = SerialProtocol::FrameType::End;
const uint8_t* payload = nullptr;
size_t length = 0;
};
explicit SerialFrameReader(Stream& stream);
/**
* @brief Consomme les octets disponibles, sans jamais attendre.
* @param frame Reçoit la trame dès qu'une trame complète et valide est lue.
* @return true si @p frame vient d'être rempli ; rappeler poll() pour la suite.
*/
bool poll(Frame& frame);
/// Nombre de trames rejetées depuis le démarrage (somme ou en-tête incohérents).
uint32_t rejectedFrames() const;
private:
enum class State {
Magic, ///< recherche du mot magique
Header, ///< type + longueur
Payload, ///< données
Checksum, ///< somme de contrôle
};
Stream& _stream;
State _state = State::Magic;
size_t _magicMatched = 0;
uint8_t _header[3] = {}; ///< type, longueur (poids faible), longueur (poids fort)
size_t _headerRead = 0;
uint8_t _payload[SerialProtocol::MAX_PAYLOAD] = {};
size_t _length = 0;
size_t _payloadRead = 0;
uint8_t _checksum = 0;
uint32_t _rejected = 0;
// Repart à la recherche du mot magique
void restart();
};
+46
View File
@@ -0,0 +1,46 @@
#include "SerialFramer.h"
SerialFramer::SerialFramer(Stream& stream) : _stream(stream) {}
void SerialFramer::sendStart(uint32_t sampleRate, uint16_t bits, uint16_t channels) {
// Descripteur de 8 octets : fréquence (4), bits (2), canaux (2), en little-endian.
uint8_t payload[8];
payload[0] = static_cast<uint8_t>(sampleRate & 0xFF);
payload[1] = static_cast<uint8_t>((sampleRate >> 8) & 0xFF);
payload[2] = static_cast<uint8_t>((sampleRate >> 16) & 0xFF);
payload[3] = static_cast<uint8_t>((sampleRate >> 24) & 0xFF);
payload[4] = static_cast<uint8_t>(bits & 0xFF);
payload[5] = static_cast<uint8_t>((bits >> 8) & 0xFF);
payload[6] = static_cast<uint8_t>(channels & 0xFF);
payload[7] = static_cast<uint8_t>((channels >> 8) & 0xFF);
sendFrame(FrameType::Start, payload, sizeof(payload));
}
void SerialFramer::sendAudio(const int16_t* pcm, size_t count) {
// L'ESP32 est little-endian : les int16 sont déjà au bon format en mémoire.
sendFrame(FrameType::Audio, reinterpret_cast<const uint8_t*>(pcm), count * sizeof(int16_t));
}
void SerialFramer::sendEnd() {
sendFrame(FrameType::End, nullptr, 0);
}
void SerialFramer::sendFrame(FrameType type, const uint8_t* payload, size_t length) {
uint8_t header[SerialProtocol::HEADER_SIZE];
memcpy(header, SerialProtocol::MAGIC, sizeof(SerialProtocol::MAGIC));
header[4] = static_cast<uint8_t>(type);
header[5] = static_cast<uint8_t>(length & 0xFF);
header[6] = static_cast<uint8_t>((length >> 8) & 0xFF);
uint8_t checksum = 0;
for (size_t i = 0; i < length; ++i) {
checksum ^= payload[i];
}
_stream.write(header, sizeof(header));
if (length > 0) {
_stream.write(payload, length);
}
_stream.write(&checksum, 1);
}
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
#include "SerialProtocol.h"
/**
* @brief Encapsule des données binaires dans des trames envoyées sur l'UART.
*
* Le port série sert aussi aux messages de journalisation : il faut donc pouvoir
* distinguer l'audio du texte. Format des trames : voir SerialProtocol.h.
*/
class SerialFramer {
public:
using FrameType = SerialProtocol::FrameType;
explicit SerialFramer(Stream& stream);
/**
* @brief Envoie une trame START décrivant le format audio.
* @param sampleRate Fréquence d'échantillonnage en Hz.
* @param bits Nombre de bits par échantillon.
* @param channels Nombre de canaux.
*/
void sendStart(uint32_t sampleRate, uint16_t bits, uint16_t channels);
/**
* @brief Envoie un chunk audio.
* @param pcm Échantillons 16 bits signés.
* @param count Nombre d'échantillons.
*/
void sendAudio(const int16_t* pcm, size_t count);
/// Envoie la trame de fin de session.
void sendEnd();
private:
Stream& _stream;
void sendFrame(FrameType type, const uint8_t* payload, size_t length);
};
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
/**
* @file SerialProtocol.h
* @brief Format des trames binaires échangées avec le PC sur l'UART (étapes 1 et 2).
*
* Même format dans les deux sens, et identique à tools/serial_protocol.py :
*
* octets 0..3 : "VSA1" mot magique
* octet 4 : type 1 = START, 2 = AUDIO, 3 = END
* octets 5..6 : length taille utile en octets (uint16 little-endian)
* octets 7.. : payload length octets
* dernier : checksum OU exclusif de tous les octets du payload
*
* Le mot magique permet de se resynchroniser : du texte peut s'intercaler entre
* les trames. À l'étape 4, MQTT remplace ce transport.
*/
namespace SerialProtocol {
/// Mot magique de début de trame (aucun caractère répété : resynchronisation simple).
constexpr uint8_t MAGIC[4] = {'V', 'S', 'A', '1'};
/// Mot magique + type + longueur.
constexpr size_t HEADER_SIZE = 7;
/// Taille maximale d'un payload ; un chunk légitime fait 1600 octets.
constexpr size_t MAX_PAYLOAD = 2048;
/// Taille du descripteur de format porté par une trame START.
constexpr size_t START_PAYLOAD = 8;
/// Types de trames.
enum class FrameType : uint8_t {
Start = 1, ///< début de flux, payload = fréquence (u32), bits (u16), canaux (u16)
Audio = 2, ///< chunk de PCM 16 bits signé little-endian
End = 3, ///< fin de flux, payload vide
};
} // namespace SerialProtocol
+47
View File
@@ -0,0 +1,47 @@
#include "StatusLed.h"
StatusLed::StatusLed(int pin) : _pin(pin) {}
void StatusLed::begin() {
pinMode(_pin, OUTPUT);
write(false);
}
void StatusLed::on() {
_blinking = false;
write(true);
}
void StatusLed::off() {
_blinking = false;
write(false);
}
void StatusLed::blink(uint32_t periodMs, uint32_t onMs) {
const uint32_t lit = onMs == 0 ? periodMs / 2 : onMs;
if (_blinking && _periodMs == periodMs && _onMs == lit) {
return; // déjà dans ce mode : ne pas réinitialiser la phase
}
_blinking = true;
_periodMs = periodMs;
_onMs = lit;
_lastToggleMs = millis();
}
void StatusLed::update() {
if (!_blinking) {
return;
}
const uint32_t now = millis();
const uint32_t phase = _state ? _onMs : (_periodMs - _onMs); // durée de l'état courant
if ((now - _lastToggleMs) >= phase) {
_lastToggleMs = now;
write(!_state);
}
}
void StatusLed::write(bool state) {
_state = state;
digitalWrite(_pin, state ? HIGH : LOW);
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <Arduino.h>
/**
* @brief LED d'état, non bloquante.
*
* Aucun delay() : la LED clignote grâce à une comparaison de dates dans update(),
* ce qui laisse le chemin audio libre de toute attente.
*/
class StatusLed {
public:
explicit StatusLed(int pin);
/// Configure la broche en sortie, LED éteinte.
void begin();
/// Allume la LED en continu.
void on();
/// Éteint la LED.
void off();
/**
* @brief Fait clignoter la LED.
* @param periodMs Période complète du clignotement en millisecondes.
* @param onMs Durée allumée dans chaque période ; 0 = la moitié (clignotement régulier).
* Un éclair bref (100 ms toutes les 2 s) signale l'écoute continue (étape 13).
*/
void blink(uint32_t periodMs, uint32_t onMs = 0);
/// À appeler à chaque tour de boucle : applique le clignotement.
void update();
private:
int _pin;
bool _blinking = false;
uint32_t _periodMs = 500;
uint32_t _onMs = 250;
uint32_t _lastToggleMs = 0;
bool _state = false;
void write(bool state);
};
+102
View File
@@ -0,0 +1,102 @@
#include "VoiceSession.h"
#include <esp_random.h>
#include "config.h"
VoiceSession::VoiceSession(MqttLink& mqtt, SerialFramer& framer) : _mqtt(mqtt), _framer(framer) {}
void VoiceSession::start(Transport transport, uint32_t sampleRate) {
_transport = transport;
_active = true;
_chunks = 0;
_failures = 0;
_startMs = millis();
_durationMs = 0;
// Identifiant court, tiré du générateur matériel : distingue deux sessions
// successives dans les journaux et les noms de fichiers du serveur.
snprintf(_id, sizeof(_id), "%06lx", static_cast<unsigned long>(esp_random() & 0xFFFFFF));
if (_transport == Transport::Serial) {
_framer.sendStart(sampleRate, 16, 1);
return;
}
char payload[192];
snprintf(payload, sizeof(payload),
"{\"event\":\"start\",\"session\":\"%s\",\"rate\":%lu,\"bits\":16,\"channels\":1,"
"\"codec\":\"pcm_s16le\",\"chunk_ms\":%lu}",
_id, static_cast<unsigned long>(sampleRate),
static_cast<unsigned long>(AUDIO_CHUNK_MS));
_mqtt.publish("event", payload, false);
}
bool VoiceSession::sendChunk(const uint8_t* pcm, size_t bytes) {
if (!_active) {
return false;
}
bool ok = true;
if (_transport == Transport::Serial) {
_framer.sendAudio(reinterpret_cast<const int16_t*>(pcm), bytes / sizeof(int16_t));
} else {
ok = _mqtt.publishBinary("audio/in", pcm, bytes);
}
if (ok) {
++_chunks;
} else {
++_failures;
}
return ok;
}
void VoiceSession::end(uint32_t captureOverruns) {
if (!_active) {
return;
}
_active = false;
_durationMs = millis() - _startMs;
if (_transport == Transport::Serial) {
_framer.sendEnd();
return;
}
char payload[192];
snprintf(payload, sizeof(payload),
"{\"event\":\"end\",\"session\":\"%s\",\"chunks\":%lu,\"duration_ms\":%lu,"
"\"send_failures\":%lu,\"capture_overruns\":%lu}",
_id, static_cast<unsigned long>(_chunks), static_cast<unsigned long>(_durationMs),
static_cast<unsigned long>(_failures), static_cast<unsigned long>(captureOverruns));
_mqtt.publish("event", payload, false);
}
bool VoiceSession::active() const {
return _active;
}
VoiceSession::Transport VoiceSession::transport() const {
return _transport;
}
const char* VoiceSession::id() const {
return _id;
}
uint32_t VoiceSession::chunksSent() const {
return _chunks;
}
uint32_t VoiceSession::sendFailures() const {
return _failures;
}
uint32_t VoiceSession::durationMs() const {
return _durationMs;
}
uint32_t VoiceSession::elapsedMs() const {
return millis() - _startMs;
}
+72
View File
@@ -0,0 +1,72 @@
#pragma once
#include <Arduino.h>
#include "MqttLink.h"
#include "SerialFramer.h"
/**
* @brief Une session de parole : START, chunks PCM, END.
*
* Par MQTT (étape 4) :
* voice/<id>/event {"event":"start","session":"a1b2c3","rate":16000,...}
* voice/<id>/audio/in 1600 octets de PCM brut, 20 fois par seconde
* voice/<id>/event {"event":"end","session":"a1b2c3","chunks":42,...}
*
* Le END porte le nombre de chunks envoyés : le serveur le compare au nombre
* reçu et détecte ainsi les pertes, sans numéro de séquence (celui-ci viendra
* à l'étape 16).
*
* Si MQTT n'est pas connecté à l'appui, la session part sur la liaison série,
* au format de l'étape 1 : `make record` reste un outil de dépannage.
*/
class VoiceSession {
public:
/// Transport de la session en cours.
enum class Transport {
Mqtt,
Serial,
};
VoiceSession(MqttLink& mqtt, SerialFramer& framer);
/**
* @brief Ouvre une session et annonce son format.
* @param transport MQTT ou liaison série.
* @param sampleRate Fréquence réellement en service.
*/
void start(Transport transport, uint32_t sampleRate);
/**
* @brief Envoie un chunk de PCM. Peut bloquer (Wi-Fi) : appeler depuis loop().
* @return true si le chunk a été confié au transport.
*/
bool sendChunk(const uint8_t* pcm, size_t bytes);
/**
* @brief Ferme la session et publie son bilan.
* @param captureOverruns Chunks perdus avant l'envoi, faute de place en FIFO.
*/
void end(uint32_t captureOverruns);
bool active() const;
Transport transport() const;
const char* id() const;
uint32_t chunksSent() const;
uint32_t sendFailures() const;
uint32_t durationMs() const;
/// Temps écoulé depuis start(), session en cours ou non.
uint32_t elapsedMs() const;
private:
MqttLink& _mqtt;
SerialFramer& _framer;
Transport _transport = Transport::Mqtt;
bool _active = false;
char _id[8] = {}; ///< 6 chiffres hexadécimaux + zéro final
uint32_t _startMs = 0;
uint32_t _durationMs = 0;
uint32_t _chunks = 0; ///< chunks confiés au transport
uint32_t _failures = 0; ///< chunks refusés par le transport
};
+56
View File
@@ -0,0 +1,56 @@
#include "WifiLink.h"
#include "config.h"
WifiLink::WifiLink(const char* ssid, const char* password, const char* hostname)
: _ssid(ssid), _password(password), _hostname(hostname) {}
void WifiLink::begin() {
WiFi.mode(WIFI_STA);
WiFi.setHostname(_hostname);
// Économie d'énergie coupée : le modem endormi ajoute des retards de 100 ms
// et plus sur la réception. Inacceptable pour un flux audio (étapes 4 et 5).
WiFi.setSleep(false);
WiFi.setAutoReconnect(true);
WiFi.begin(_ssid, _password);
_lostSinceMs = millis();
_retrySinceMs = _lostSinceMs;
}
void WifiLink::update() {
const bool now = connected();
if (!now && (millis() - _retrySinceMs) >= WIFI_RESTART_MS) {
// Filet de sécurité : la reconnexion automatique n'a rien donné, on repart de zéro.
Serial.printf("# wifi : toujours absent apres %lu s, nouvelle connexion\n",
static_cast<unsigned long>((millis() - _lostSinceMs) / 1000));
WiFi.disconnect();
WiFi.begin(_ssid, _password);
_retrySinceMs = millis();
}
if (now == _wasConnected) {
return;
}
_wasConnected = now;
if (now) {
Serial.printf("# wifi : connecte en %lu ms, IP %s, signal %d dBm\n",
static_cast<unsigned long>(millis() - _lostSinceMs),
WiFi.localIP().toString().c_str(), WiFi.RSSI());
} else {
_lostSinceMs = millis();
_retrySinceMs = _lostSinceMs;
Serial.println("# wifi : connexion perdue, reconnexion automatique...");
}
}
bool WifiLink::connected() const {
return WiFi.status() == WL_CONNECTED;
}
int WifiLink::rssi() const {
return connected() ? WiFi.RSSI() : 0;
}
IPAddress WifiLink::ip() const {
return connected() ? WiFi.localIP() : IPAddress();
}
+44
View File
@@ -0,0 +1,44 @@
#pragma once
#include <Arduino.h>
#include <WiFi.h>
/**
* @brief Connexion Wi-Fi en mode station, non bloquante, reconnexion automatique.
*
* begin() lance la connexion et rend la main aussitôt : l'audio ne doit jamais
* attendre le réseau. update() signale les changements d'état et, si le réseau
* reste absent plus de WIFI_RESTART_MS, relance la connexion de zéro (étape 15).
*/
class WifiLink {
public:
/**
* @param ssid Nom du réseau (2,4 GHz).
* @param password Mot de passe.
* @param hostname Nom de la carte sur le réseau local.
*/
WifiLink(const char* ssid, const char* password, const char* hostname);
/// Lance la connexion, sans attendre.
void begin();
/// À appeler à chaque tour de boucle : journalise connexion et perte, relance si besoin.
void update();
/// true si l'ESP32 a une adresse IP.
bool connected() const;
/// Puissance du signal reçu, en dBm (-50 excellent, -80 limite).
int rssi() const;
/// Adresse IP courante, ou 0.0.0.0.
IPAddress ip() const;
private:
const char* _ssid;
const char* _password;
const char* _hostname;
bool _wasConnected = false;
uint32_t _lostSinceMs = 0; ///< début de la coupure en cours, pour la journaliser
uint32_t _retrySinceMs = 0; ///< dernière relance de la connexion
};
+579
View File
@@ -0,0 +1,579 @@
/**
* @file main.cpp
* @brief Étapes 1 à 5 — voix vers le serveur et réponses du serveur, par MQTT.
*
* Entrée (étape 4) : PTT -> micro I2S0 -> tâche de capture -> FIFO -> loop() -> MQTT audio/in
* (ou liaison série si le broker est absent, format de l'étape 1).
* Sortie (étapes 2 et 5) : MQTT audio/out ou trames série -> PlaybackStream -> FIFO
* -> tâche audio -> I2S1 -> ampli.
* Réseau (étape 3) : Wi-Fi + MQTT ; état retenu sur voice/<id>/state, testament « offline ».
* Fiabilisation (étape 15) : compte MQTT, chien de garde des tâches, micro coupé quand
* le serveur annonce son absence (voice/server/status).
*
* Les deux chemins sont indépendants : un micro en panne n'empêche pas la
* lecture, et inversement. Aucune allocation dynamique dans les chemins audio.
*/
#include <Arduino.h>
#include <WiFi.h>
#include <esp_system.h>
#include <esp_task_wdt.h>
#include <atomic>
#include "AudioFifo.h"
#include "AudioOutput.h"
#include "CommandHandler.h"
#include "DemoDevices.h"
#include "MicCapture.h"
#include "MicSelfTest.h"
#include "MqttLink.h"
#include "PlaybackStream.h"
#include "PushButton.h"
#include "SerialFrameReader.h"
#include "SerialFramer.h"
#include "StatusLed.h"
#include "VoiceSession.h"
#include "WifiLink.h"
#include "config.h"
// Les secrets (Wi-Fi, broker) ne sont jamais versionnés : on les exige ici, avec
// un message clair, plutôt qu'une erreur de compilation obscure plus loin.
#if __has_include("secrets.h")
#include "secrets.h"
#else
#error "firmware/include/secrets.h absent : cp firmware/include/secrets.h.example firmware/include/secrets.h, puis renseignez le Wi-Fi et l'adresse du broker"
#endif
namespace {
using SerialProtocol::FrameType;
MicCapture mic(PIN_MIC_SCK, PIN_MIC_WS, PIN_MIC_SD);
MicSelfTest selfTest(mic);
AudioOutput speaker(PIN_AMP_BCLK, PIN_AMP_LRC, PIN_AMP_DIN);
// Deux FIFO, stockage statique fourni par nous (+1 octet exigé par FreeRTOS).
uint8_t playbackStorage[PLAYBACK_FIFO_BYTES + 1];
uint8_t captureStorage[CAPTURE_FIFO_BYTES + 1];
AudioFifo playbackFifo; ///< PC -> tâche audio -> ampli
AudioFifo captureFifo; ///< micro -> tâche de capture -> loop() -> réseau
PushButton button(PIN_BUTTON_PTT, BUTTON_DEBOUNCE_MS);
StatusLed led(PIN_STATUS_LED);
DemoDevices devices(PIN_DEMO_LIGHT);
SerialFramer framer(Serial);
SerialFrameReader reader(Serial);
WiFiClient network;
WifiLink wifi(WIFI_SSID, WIFI_PASSWORD, DEVICE_ID);
MqttLink mqtt(network, MQTT_HOST, MQTT_PORT, DEVICE_ID, MQTT_USER, MQTT_PASSWORD);
/// Chunk en cours d'envoi réseau : 1600 octets, alloué une seule fois.
uint8_t outgoingChunk[AUDIO_CHUNK_BYTES];
/// Durée maximale d'une session d'enregistrement, garde-fou si le bouton reste bloqué (ms).
constexpr uint32_t SESSION_MAX_MS = 15000;
// --- Session de parole (étape 4), manipulée par loop() seulement.
VoiceSession session(mqtt, framer);
uint32_t captureOverrunsAtStart = 0;
bool micReady = false;
// Écoute continue (étape 13), pilotée par le serveur. loop() seulement.
bool listenWanted = false; ///< le serveur demande le flux continu
bool streaming = false; ///< le micro part en continu sur voice/<id>/audio/stream
bool wakeCapturing = false; ///< mot de réveil entendu : le serveur écoute la commande
uint32_t wakeCaptureSinceMs = 0;
bool serverOnline = false; ///< présence annoncée par le serveur (étape 15)
// Partagés entre loop() et la tâche de capture.
std::atomic<bool> captureWanted{false}; ///< loop() demande la capture
std::atomic<bool> captureRunning{false}; ///< la tâche capture effectivement
bool speakerReady = false;
uint32_t lastStateMs = 0;
// --- Lecture (étapes 2 et 5) et commandes (étape 5).
PlaybackStream playback(speaker, playbackFifo);
void onListen(bool enabled);
void onCapture(bool active);
CommandHandler commands(playback, mqtt, devices, onListen, onCapture);
// Pile et contrôle de la tâche de capture, alloués statiquement.
StaticTask_t captureTaskControl;
StackType_t captureTaskStack[CAPTURE_TASK_STACK_BYTES];
/**
* @brief Tâche de capture : micro -> FIFO de capture, au rythme du micro.
*
* Elle ne touche jamais au réseau : un envoi Wi-Fi peut bloquer, et pendant ce
* temps le DMA du micro (75 ms de réserve) déborderait. Ici, rien ne bloque
* sauf la lecture du micro elle-même, qui rend la main toutes les 50 ms.
*/
void captureTask(void* /*unused*/) {
static int16_t chunk[AUDIO_CHUNK_SAMPLES];
esp_task_wdt_add(nullptr); // surveillée par le chien de garde (étape 15)
for (;;) {
esp_task_wdt_reset(); // « je suis vivante » : au moins toutes les 50 ms
if (!captureWanted.load()) {
captureRunning.store(false);
vTaskDelay(pdMS_TO_TICKS(5));
continue;
}
if (!captureRunning.load()) {
mic.flush(); // on jette l'audio capté avant l'appui
mic.resetStats(); // les statistiques ne portent que sur cette session
captureRunning.store(true);
}
const size_t samples = mic.read(chunk, AUDIO_CHUNK_SAMPLES); // ~50 ms
if (samples > 0) {
// Tout ou rien : si la FIFO est pleine, le chunk est compté perdu.
captureFifo.write(reinterpret_cast<const uint8_t*>(chunk), samples * sizeof(int16_t));
}
}
}
/// Lit un entier little-endian dans un payload.
uint32_t readLe(const uint8_t* data, size_t bytes) {
uint32_t value = 0;
for (size_t i = 0; i < bytes; ++i) {
value |= static_cast<uint32_t>(data[i]) << (8 * i);
}
return value;
}
/// Traite une trame reçue du PC par la liaison série (étape 2).
void handleFrame(const SerialFrameReader::Frame& frame) {
switch (frame.type) {
case FrameType::Start:
if (frame.length != SerialProtocol::START_PAYLOAD) {
Serial.println("# lecture refusee : descripteur START invalide");
return;
}
playback.open("serie", readLe(frame.payload, 4), readLe(frame.payload + 4, 2),
readLe(frame.payload + 6, 2), false);
break;
case FrameType::Audio:
playback.push(frame.payload, frame.length);
break;
case FrameType::End:
playback.close(-1); // la liaison série n'annonce pas le nombre de chunks
break;
}
}
/// Affiche le bilan d'un flux joué, et le publie s'il venait du serveur.
void reportPlayback(const PlaybackStream::Report& report) {
Serial.printf("# lecture %s : %s, %lu chunks recus", report.session,
report.endReceived ? "terminee" : "abandonnee (pas de END)",
static_cast<unsigned long>(report.chunks));
if (report.announced >= 0) {
Serial.printf(" sur %ld annonces", static_cast<long>(report.announced));
}
Serial.printf(", %lu refuses (FIFO pleine), %lu reamorcages, premier son a %lu ms, "
"marge mini %ld ms, %lu ms\n",
static_cast<unsigned long>(report.overruns),
static_cast<unsigned long>(report.underruns),
static_cast<unsigned long>(report.startMs),
static_cast<long>(report.minMarginMs),
static_cast<unsigned long>(report.durationMs));
// Instants des réamorçages (depuis le premier son) : au début, au milieu, ou à la fin ?
char gaps[48] = "";
size_t used = 0;
for (size_t i = 0; i < PlaybackStream::MAX_GAPS && i < report.underruns; ++i) {
used += snprintf(gaps + used, sizeof(gaps) - used, "%s%lu", i == 0 ? "" : ",",
static_cast<unsigned long>(report.gapAtMs[i]));
}
if (report.underruns > 0) {
Serial.printf("# lecture %s : reamorcages a [%s] ms du premier son\n", report.session, gaps);
}
if (!report.fromMqtt) {
return;
}
char payload[304];
snprintf(payload, sizeof(payload),
"{\"event\":\"played\",\"session\":\"%s\",\"end\":%s,\"chunks\":%lu,"
"\"announced\":%ld,\"overruns\":%lu,\"underruns\":%lu,\"duration_ms\":%lu,"
"\"start_ms\":%lu,\"min_margin_ms\":%ld,\"gaps_ms\":[%s]}",
report.session, report.endReceived ? "true" : "false",
static_cast<unsigned long>(report.chunks), static_cast<long>(report.announced),
static_cast<unsigned long>(report.overruns),
static_cast<unsigned long>(report.underruns),
static_cast<unsigned long>(report.durationMs),
static_cast<unsigned long>(report.startMs),
static_cast<long>(report.minMarginMs), gaps);
mqtt.publish("event", payload, false);
}
/// Cause du dernier redémarrage, en mot court pour le JSON.
const char* resetReason() {
switch (esp_reset_reason()) {
case ESP_RST_POWERON:
return "poweron";
case ESP_RST_EXT:
return "external"; // broche EN, ou ouverture du port série sur certaines cartes
case ESP_RST_SW:
return "software";
case ESP_RST_PANIC:
return "panic"; // plantage du firmware
case ESP_RST_INT_WDT:
case ESP_RST_TASK_WDT:
case ESP_RST_WDT:
return "watchdog"; // une tâche a monopolisé le processeur
case ESP_RST_BROWNOUT:
return "brownout"; // chute de tension : alimentation trop faible (ampli !)
default:
return "other";
}
}
/**
* @brief Publie l'état de la carte, en message retenu.
*
* Retenu : le broker garde le dernier état et le donne aussitôt à tout nouvel
* abonné. Un serveur qui démarre après l'ESP32 sait donc immédiatement s'il est
* en ligne, sans attendre la prochaine publication.
*/
void publishState() {
char payload[192];
snprintf(payload, sizeof(payload),
"{\"status\":\"online\",\"ip\":\"%s\",\"rssi\":%d,\"uptime_s\":%lu,"
"\"mic\":%s,\"speaker\":%s}",
wifi.ip().toString().c_str(), wifi.rssi(),
static_cast<unsigned long>(millis() / 1000), micReady ? "true" : "false",
speakerReady ? "true" : "false");
mqtt.publish("state", payload, true);
lastStateMs = millis();
}
/// Présence du serveur : absent, plus rien ne sort du micro (étape 15).
void onServerStatus(const uint8_t* payload, size_t length) {
char text[64];
const size_t n = length < sizeof(text) - 1 ? length : sizeof(text) - 1;
memcpy(text, payload, n);
text[n] = '\0';
const bool online = strstr(text, "\"online\"") != nullptr;
if (online == serverOnline) {
return;
}
serverOnline = online;
if (online) {
Serial.println("# serveur : en ligne");
return;
}
// Testament du serveur (plantage) ou arrêt : il ne pourra plus dire « listen false ».
// Il redemandera l'écoute à son retour, en voyant l'état de la carte.
listenWanted = false;
wakeCapturing = false;
Serial.println("# serveur : ABSENT, ecoute continue coupee");
}
/// Appelé à chaque connexion au broker.
void onMqttConnect(bool firstTime) {
serverOnline = false; // l'état retenu du serveur arrive avec l'abonnement
mqtt.subscribe("control");
mqtt.subscribe("audio/out");
mqtt.subscribeTopic(MQTT_SERVER_STATUS_TOPIC);
publishState(); // remplace aussitôt le « offline » retenu d'une absence précédente
if (firstTime) {
char payload[96];
snprintf(payload, sizeof(payload), "{\"event\":\"boot\",\"reason\":\"%s\"}",
resetReason());
mqtt.publish("event", payload, false);
}
}
/// Appelé pour chaque message reçu. Court : pas d'attente, pas de traitement lourd.
void onMqttMessage(const char* suffix, const uint8_t* payload, size_t length) {
if (strcmp(suffix, "audio/out") == 0) {
playback.push(payload, length); // ne fait que remplir la FIFO
} else if (strcmp(suffix, "control") == 0) {
commands.handle(payload, length);
} else if (strcmp(suffix, MQTT_SERVER_STATUS_TOPIC) == 0) {
onServerStatus(payload, length);
}
}
/// Commande « listen » du serveur : active ou coupe l'écoute continue.
void onListen(bool enabled) {
listenWanted = enabled;
Serial.printf("# ecoute continue %s par le serveur\n", enabled ? "activee" : "desactivee");
}
/// Commande « capture » du serveur : mot de réveil entendu (LED allumée) ou commande finie.
void onCapture(bool active) {
wakeCapturing = active;
wakeCaptureSinceMs = millis();
}
/// Arrête le flux continu et jette ce qui restait : rien ne sort du micro sans raison.
void stopStreaming() {
captureWanted.store(false);
const uint32_t waitStart = millis();
while (captureRunning.load() && (millis() - waitStart) < 200) {
delay(1);
}
while (captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0) > 0) {
}
streaming = false;
}
/**
* Écoute continue (étape 13) : le micro part vers le serveur, qui guette le mot de réveil.
* Jamais pendant une réponse parlée : le micro entendrait le haut-parleur, et le Wi-Fi
* doit alors servir la lecture.
*/
void updateStreaming() {
const bool wanted = listenWanted && serverOnline && mqtt.connected() && !playback.active();
if (wanted && !streaming) {
captureWanted.store(true);
streaming = true;
} else if (!wanted && streaming) {
stopStreaming();
}
if (!streaming) {
return;
}
while (captureFifo.available() >= AUDIO_CHUNK_BYTES) {
const size_t bytes = captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0);
if (bytes == 0) {
break;
}
mqtt.publishBinary("audio/stream", outgoingChunk, bytes);
esp_task_wdt_reset(); // chaque envoi peut attendre le Wi-Fi jusqu'à 10 s : on avance
}
}
/// LED au repos : allumée = commande écoutée, éclair bref = écoute continue, lent = prêt.
void updateIdleLed() {
if (wakeCapturing && (millis() - wakeCaptureSinceMs) > SESSION_MAX_MS) {
wakeCapturing = false; // le serveur n'a jamais dit « fini » : on ne reste pas allumé
}
if (wakeCapturing) {
led.on();
} else if (streaming) {
led.blink(2000, 100);
} else {
led.blink(1000);
}
}
/// Ouvre une session de parole : MQTT si possible, liaison série sinon.
void startSession() {
const bool viaMqtt = mqtt.connected();
if (!viaMqtt) {
Serial.println("# session : MQTT non connecte, envoi par la liaison serie (make record)");
}
captureOverrunsAtStart = captureFifo.overruns();
session.start(viaMqtt ? VoiceSession::Transport::Mqtt : VoiceSession::Transport::Serial,
mic.sampleRate());
captureWanted.store(true);
led.on();
}
/// Envoie au réseau les chunks complets en attente. Peut bloquer sur le Wi-Fi.
void drainCapture(bool includePartial) {
for (;;) {
const size_t available = captureFifo.available();
if (available == 0 || (!includePartial && available < AUDIO_CHUNK_BYTES)) {
return;
}
const size_t bytes = captureFifo.read(outgoingChunk, sizeof(outgoingChunk), 0);
if (bytes == 0) {
return;
}
session.sendChunk(outgoingChunk, bytes);
esp_task_wdt_reset(); // chaque envoi peut attendre le Wi-Fi jusqu'à 10 s : on avance
}
}
/// Ferme la session : arrête la capture, envoie le reste, publie le bilan.
void stopSession(const char* reason) {
captureWanted.store(false);
// La tâche finit le chunk en cours (50 ms au plus) avant de s'arrêter.
const uint32_t waitStart = millis();
while (captureRunning.load() && (millis() - waitStart) < 200) {
delay(1);
}
drainCapture(true);
const uint32_t overruns = captureFifo.overruns() - captureOverrunsAtStart;
session.end(overruns);
led.off();
Serial.printf("\n# session %s terminee (%s, %s) : %lu chunks envoyes, %lu echecs d'envoi, "
"%lu perdus faute de place, %lu ms\n",
session.id(), reason,
session.transport() == VoiceSession::Transport::Mqtt ? "MQTT" : "serie",
static_cast<unsigned long>(session.chunksSent()),
static_cast<unsigned long>(session.sendFailures()),
static_cast<unsigned long>(overruns),
static_cast<unsigned long>(session.durationMs()));
if (session.durationMs() < 300) {
// Trop court pour contenir une parole : appui trop bref, ou bouton qui
// « lâche » tout seul (contact intermittent, rebond). Le serveur l'ignore.
Serial.printf("# ATTENTION : session de %lu ms seulement. Si vous teniez le bouton,\n"
"# son contact est intermittent : verifiez ses fils sur la plaque.\n",
static_cast<unsigned long>(session.durationMs()));
}
selfTest.reportSession(mic.stats());
}
/**
* @brief Chien de garde (étape 15) : délai porté à WATCHDOG_TIMEOUT_S, redémarrage si dépassé.
*
* loop() est surveillée à chaque tour ; les tâches capture et audio s'inscrivent elles-mêmes.
* La tâche « idle » du cœur 0 reste surveillée : si le Wi-Fi (cœur 0) monopolise le processeur,
* le chien de garde le voit aussi.
*/
void setupWatchdog() {
const esp_task_wdt_config_t config = {
.timeout_ms = WATCHDOG_TIMEOUT_S * 1000,
.idle_core_mask = 1 << 0,
.trigger_panic = true, // redémarrage, et non simple message
};
if (esp_task_wdt_reconfigure(&config) != ESP_OK) {
Serial.println("# ERREUR : chien de garde non configure");
return;
}
enableLoopWDT();
Serial.printf("# chien de garde : %lu s (loop, capture, audio)\n",
static_cast<unsigned long>(WATCHDOG_TIMEOUT_S));
}
} // namespace
void setup() {
// En tout premier : la sortie audio prend les broches de l'ampli et lui envoie
// du silence. Laissées flottantes, elles le faisaient souffler à plein volume.
speakerReady = speaker.begin(AUDIO_SAMPLE_RATE, AMP_FORMAT_PHILIPS);
Serial.setRxBufferSize(SERIAL_RX_BUFFER_BYTES); // avant begin(), sinon ignoré
Serial.begin(SERIAL_BAUD);
// Hors chemin audio : le moniteur série se reconnecte après le reset de la
// carte. Sans cette attente, les premières lignes de l'auto-test sont perdues.
delay(1500);
led.begin();
devices.begin();
button.begin();
Serial.println("\n# --- assistant vocal, etapes 1 a 3 : audio + Wi-Fi + MQTT ---");
Serial.printf("# format : %lu Hz, 16 bits, mono, chunk %lu ms = %u octets\n",
static_cast<unsigned long>(AUDIO_SAMPLE_RATE),
static_cast<unsigned long>(AUDIO_CHUNK_MS),
static_cast<unsigned>(AUDIO_CHUNK_BYTES));
Serial.printf("# micro : SCK=GPIO %d, WS=GPIO %d, SD=GPIO %d\n", PIN_MIC_SCK, PIN_MIC_WS,
PIN_MIC_SD);
Serial.printf("# ampli : BCLK=GPIO %d, LRC=GPIO %d, DIN=GPIO %d, cadrage %s\n", PIN_AMP_BCLK,
PIN_AMP_LRC, PIN_AMP_DIN, AMP_FORMAT_PHILIPS ? "Philips" : "MSB");
// --- Sortie : test isolé par un bip, puis mise en place de la chaîne de lecture.
if (!speakerReady) {
Serial.println("# ERREUR : sortie audio I2S1 impossible a demarrer");
} else {
Serial.printf("# bip de demarrage : %lu Hz, %lu ms, -12 dBFS\n",
static_cast<unsigned long>(BOOT_TONE_HZ),
static_cast<unsigned long>(BOOT_TONE_MS));
speaker.playTone(BOOT_TONE_HZ, BOOT_TONE_MS, BOOT_TONE_AMPLITUDE);
if (!playbackFifo.begin(playbackStorage, PLAYBACK_FIFO_BYTES,
PLAYBACK_BLOCK_SAMPLES * sizeof(int16_t))) {
Serial.println("# ERREUR : FIFO de lecture impossible a creer");
} else {
playback.begin();
Serial.printf("# lecture prete : FIFO %u ms, tache audio sur le coeur %d\n",
static_cast<unsigned>(PLAYBACK_FIFO_BYTES / (AUDIO_SAMPLE_RATE / 1000 * 2)),
AUDIO_TASK_CORE);
}
}
// --- Entrée : auto-test du micro.
micReady = selfTest.run();
if (micReady) {
// La tâche de capture ne démarre qu'après l'auto-test : ils partagent le micro.
if (!captureFifo.begin(captureStorage, CAPTURE_FIFO_BYTES, AUDIO_CHUNK_BYTES)) {
Serial.println("# ERREUR : FIFO de capture impossible a creer");
micReady = false;
} else {
xTaskCreateStaticPinnedToCore(captureTask, "capture", CAPTURE_TASK_STACK_BYTES,
nullptr, CAPTURE_TASK_PRIORITY, captureTaskStack,
&captureTaskControl, CAPTURE_TASK_CORE);
Serial.println("# micro pret. Appuyez sur le bouton PTT et parlez.");
led.blink(1000); // clignotement lent = prêt, au repos
}
}
// --- Réseau, en dernier : l'auto-test du micro se fait sans les émissions
// radio du Wi-Fi (pointes de 300 mA), qui pourraient fausser la mesure.
Serial.printf("# reseau : Wi-Fi \"%s\", broker %s:%u, topics %s/%s/..., compte MQTT %s\n",
WIFI_SSID, MQTT_HOST, MQTT_PORT, MQTT_TOPIC_PREFIX, DEVICE_ID,
MQTT_PASSWORD[0] != '\0' ? "oui" : "NON (anonyme)");
network.setConnectionTimeout(MQTT_CONNECT_TIMEOUT_MS);
if (!mqtt.begin(onMqttMessage, onMqttConnect)) {
Serial.println("# ERREUR : tampon MQTT impossible a allouer");
}
wifi.begin();
// En dernier : l'auto-test du micro et le bip ont leurs propres durées, hors surveillance.
setupWatchdog();
}
void loop() {
// La réception série passe toujours en premier : elle ne doit jamais attendre.
SerialFrameReader::Frame frame;
while (reader.poll(frame)) {
handleFrame(frame);
}
// Réseau : non bloquant, sauf pendant une tentative de connexion au broker.
wifi.update();
mqtt.update(wifi.connected());
PlaybackStream::Report played;
if (playback.takeReport(played)) {
reportPlayback(played);
}
if (mqtt.connected() && (millis() - lastStateMs) >= STATE_PERIOD_MS) {
publishState();
}
if (!micReady) {
led.blink(150); // clignotement rapide : micro en défaut, lecture toujours possible
led.update();
return;
}
button.update();
if (!session.active()) {
if (button.wasPressed()) {
button.clearEvents(); // un relâchement périmé ne doit pas clore la session
if (streaming) {
stopStreaming(); // le bouton reprend la main sur le micro
}
startSession();
} else {
updateStreaming();
updateIdleLed();
}
led.update();
return;
}
// --- Session en cours : la capture tourne dans sa tâche ; ici on envoie.
drainCapture(false);
if (button.wasReleased() || !button.isDown()) {
stopSession("bouton relache");
button.clearEvents(); // un appui mémorisé pendant la session ne doit pas en relancer une
led.blink(1000);
} else if (session.elapsedMs() > SESSION_MAX_MS) {
stopSession("duree maximale");
led.blink(1000);
}
}