mirror of
https://github.com/deunix-educ/voice-assistant.git
synced 2026-10-13 00:11:57 +02:00
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:
@@ -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;
|
||||
@@ -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 = "";
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 "?";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 ";
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user