Refonte firmware + scénario story + réorganisation fichiers

This commit is contained in:
Clément SAILLANT
2026-02-13 05:03:27 +01:00
parent 456569fb9d
commit d3dc9a8776
76 changed files with 6688 additions and 904 deletions
+4
View File
@@ -3,6 +3,10 @@
## [Unreleased]
- Documentation : ajout d'un plan de maintenance du dépôt (hors firmware).
- Contribution : ajout d'une checklist PR doc-only dans `CONTRIBUTING.md`.
- Documentation : ajout d'une illustration SVG de structure du dépôt dans le README (`docs/assets/repo-map.svg`).
- Documentation : revue de cohérence étendue sur les Markdown/README et mise à jour de `docs/repo-audit.md`.
- Structure : ajout du dossier `kit-maitre-du-jeu/export/pdf/` pour aligner la documentation avec l'arborescence.
- Printables : clarification de l'état actuel et du niveau de préparation des dossiers `src/` et `export/{pdf,png}/`.
## [0.2.0] - 2026-02-12
+1 -1
View File
@@ -23,7 +23,7 @@ Objectif : un kit cohérent, fluide à animer, facile à imprimer (noir & blanc)
5. PR avec explication + previews PNG si possible
## Licence des contributions
- Contenu creatif : CC BY-SA 4.0
- Contenu créatif : CC BY-SA 4.0
- Code : GPL-3.0-or-later
## Checklist PR (recommandée)
+9 -4
View File
@@ -3,6 +3,9 @@
Kit denquête pour anniversaire (911 ans), ambiance laboratoire / campus scientifique.
Style visuel : noir & blanc, graphique, lisible, élégant, homogène.
## Aperçu visuel
![Vue d'ensemble du dépôt](docs/assets/repo-map.svg)
## Contenu
### 1) Kit Maître du jeu
Dossier : `kit-maitre-du-jeu/`
@@ -29,12 +32,12 @@ Dossier : `hardware/`
- firmware Arduino / ESP32
## État du projet
- **Kit MJ** : structure documentaire en place (`kit-maitre-du-jeu/`).
- **Printables** : conventions définies, arborescence `src/export` à compléter selon les livrables.
- **Kit MJ** : documentation d'animation complète, exports PDF possibles via `kit-maitre-du-jeu/export/pdf/`.
- **Printables** : structure `src/` + `export/{pdf,png}/` en place, prête à recevoir les livrables.
- **Hardware/firmware** : flux `U_LOCK`/`U-SON`/MP3 opérationnel et documenté (`hardware/firmware/esp32/`).
## Licence (open source)
- Contenus creatifs (documents, PDFs, PNG, SVG, textes de jeu) : **CC BY-SA 4.0**
- Contenus créatifs (documents, PDFs, PNG, SVG, textes de jeu) : **CC BY-SA 4.0**
- Code (firmware, scripts) : **GPL-3.0-or-later**
Voir `LICENSE.md` et `LICENSES/`.
@@ -43,7 +46,9 @@ Projet indépendant, non affilié à aucune marque ou éditeur.
Voir `DISCLAIMER.md`.
## Maintenance du dépôt (hors firmware)
Voir `docs/maintenance-repo.md`.
- Plan : `docs/maintenance-repo.md`
- Audit : `docs/repo-audit.md`
- Règles anti-conflits docs : section dédiée dans `docs/maintenance-repo.md`
## Contribuer
Voir `CONTRIBUTING.md`.
+57
View File
@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="680" viewBox="0 0 1200 680" role="img" aria-labelledby="title desc">
<title id="title">Le Mystère du Professeur Zacus - Vue d'ensemble du dépôt</title>
<desc id="desc">Schéma des trois piliers du dépôt: Kit Maître du jeu, Printables et Hardware.</desc>
<defs>
<style>
.bg{fill:#f7f7f7}
.panel{fill:#fff;stroke:#222;stroke-width:2}
.h{font:700 32px 'Segoe UI',Arial,sans-serif;fill:#111}
.t{font:600 22px 'Segoe UI',Arial,sans-serif;fill:#111}
.s{font:400 18px 'Segoe UI',Arial,sans-serif;fill:#333}
.cap{font:700 20px 'Segoe UI',Arial,sans-serif;fill:#111}
.line{stroke:#222;stroke-width:2}
</style>
</defs>
<rect class="bg" x="0" y="0" width="1200" height="680"/>
<text class="h" x="600" y="62" text-anchor="middle">Le Mystère du Professeur Zacus</text>
<text class="s" x="600" y="92" text-anchor="middle">Structure actuelle du dépôt</text>
<rect class="panel" x="70" y="150" rx="12" ry="12" width="320" height="430"/>
<text class="t" x="230" y="195" text-anchor="middle">Kit Maître du jeu</text>
<text class="s" x="95" y="240">• script minute-par-minute</text>
<text class="s" x="95" y="272">• solution complète</text>
<text class="s" x="95" y="304">• checklist matériel</text>
<text class="s" x="95" y="336">• distribution des rôles</text>
<text class="s" x="95" y="368">• guide anti-chaos</text>
<text class="s" x="95" y="400">• stations modulaires</text>
<text class="s" x="95" y="446">Path: kit-maitre-du-jeu/</text>
<rect class="panel" x="440" y="150" rx="12" ry="12" width="320" height="430"/>
<text class="t" x="600" y="195" text-anchor="middle">Printables</text>
<text class="s" x="465" y="240">• invitations</text>
<text class="s" x="465" y="272">• cartes / feuille d'enquête</text>
<text class="s" x="465" y="304">• badges / règles 1 page</text>
<text class="s" x="465" y="336">• ordre d'impression</text>
<text class="s" x="465" y="384">Sous-structure:</text>
<text class="s" x="465" y="416"> - src/</text>
<text class="s" x="465" y="448"> - export/pdf/</text>
<text class="s" x="465" y="480"> - export/png/</text>
<text class="s" x="465" y="526">Path: printables/</text>
<rect class="panel" x="810" y="150" rx="12" ry="12" width="320" height="430"/>
<text class="t" x="970" y="195" text-anchor="middle">Hardware (optionnel)</text>
<text class="s" x="835" y="240">• BOM</text>
<text class="s" x="835" y="272">• câblage</text>
<text class="s" x="835" y="304">• firmware Arduino / ESP32</text>
<text class="s" x="835" y="336">• boîtier</text>
<text class="s" x="835" y="384">Firmware ESP32:</text>
<text class="s" x="835" y="416"> U_LOCK → U-SON → MP3</text>
<text class="s" x="835" y="462">Path: hardware/</text>
<line class="line" x1="390" y1="365" x2="440" y2="365"/>
<line class="line" x1="760" y1="365" x2="810" y2="365"/>
<text class="cap" x="600" y="635" text-anchor="middle">Objectif: documentation cohérente, livrables imprimables et firmware stable</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+2
View File
@@ -3,5 +3,7 @@
- Kit MJ : `kit-maitre-du-jeu/`
- Printables : `printables/`
- Hardware : `hardware/`
- Maintenance dépôt : `docs/maintenance-repo.md`
- Audit de cohérence : `docs/repo-audit.md`
Astuce : activer GitHub Pages sur `/docs` pour afficher cette page.
+7
View File
@@ -67,3 +67,10 @@ sans modifier le code firmware.
- [ ] Cohérence de ton et vocabulaire (FR).
- [ ] `CHANGELOG.md` mis à jour.
- [ ] Diff relu pour supprimer le bruit (formatage inutile, doublons).
## Règles anti-conflits (PR docs)
- Toujours rebaser la branche sur `main` avant de modifier `CHANGELOG.md`.
- Dans `Unreleased`, ajouter les lignes en tête (plus récent en premier).
- Éviter de réécrire les anciennes sections de release (`0.x.y`) sauf correction factuelle.
- Limiter une PR doc à un thème principal (ex: printables, kit MJ, gouvernance).
+66
View File
@@ -0,0 +1,66 @@
# Audit de cohérence du dépôt
Date: 2026-02-12 (mise à jour cohérence globale)
Périmètre: **contenu documentaire + structure fichiers**, sans modification firmware.
## Résumé exécutif
- Le dépôt est globalement bien structuré en 3 piliers: `kit-maitre-du-jeu/`, `printables/`, `hardware/`.
- La zone la plus mature côté exécution est le firmware ESP32/ESP8266 (documenté et outillé).
- Le principal point de cohérence identifié concernait les chemins printables documentés mais absents.
- Aucune image versionnée (`png/jpg/svg/webp/gif`) n'a été trouvée à ce stade.
## Couverture de revue
- Revue des fichiers Markdown/README du dépôt (hors licences externes inchangées).
- Vérification de cohérence des chemins documentés vs arborescence versionnée.
- Vérification des assets image versionnés.
## Analyse du contenu des fichiers
### 1) Documentation racine
- `README.md` décrit clairement le périmètre et les licences.
- `CONTRIBUTING.md` couvre les règles éditoriales et le workflow.
- `CHANGELOG.md` contient désormais une release `0.2.0` et un `Unreleased` exploitable.
### 2) Kit MJ
- Le dossier `kit-maitre-du-jeu/` est cohérent et orienté animation terrain.
- Les sous-docs couvrent script, solution, rôles, anti-chaos et stations.
### 3) Printables
- La convention de nommage est explicite.
- Le dépôt déclarait une structure `src/export` non matérialisée: ce point est désormais aligné.
### 4) Hardware
- Arborescence hardware lisible (`bom`, `wiring`, `firmware`, `enclosure`).
- Le firmware reste hors périmètre de modification dans ce cycle.
## Analyse des images
Commande utilisée:
- `rg --files | rg -i '\\.(png|jpe?g|gif|webp|svg|bmp)$'`
Résultat:
- Aucun fichier image détecté dans les fichiers versionnés.
Conséquence:
- Pas d'audit visuel possible sur des assets exportés.
- Recommandé: ajouter des previews PNG sous `printables/export/png/` pour revue PR.
## Corrections de cohérence appliquées
1. Création de la structure printables attendue:
- `printables/src/`
- `printables/export/pdf/`
- `printables/export/png/`
2. Création des sous-dossiers invitations cohérents avec son README:
- `printables/invitations/src/`
- `printables/invitations/export/pdf/`
- `printables/invitations/export/png/`
3. Ajout de README ciblés dans les nouveaux dossiers printables.
## Recommandations immédiates (hors firmware)
- Ajouter 1 à 3 documents pilotes dans `printables/export/pdf/` et leurs previews dans `printables/export/png/`.
- Maintenir `CHANGELOG.md` à chaque PR doc-only.
- Utiliser la checklist PR de `CONTRIBUTING.md` comme gate de merge.
+58 -9
View File
@@ -18,6 +18,10 @@ Ce dossier contient le firmware principal pour **ESP32 Audio Kit V2.2 A252**.
- en `U_LOCK`: appui sur une touche pour lancer la detection du LA (440 Hz, micro onboard)
- affichage OLED pendant detection: bargraphe volume + bargraphe accordage + scope micro (optionnel si `kUseI2SMicInput=true`)
- apres cumul de 3 secondes de detection LA (continue ou repetee): pictogramme de validation, puis passage en `MODULE U-SON Fonctionnel`
- scenario STORY:
- fin `U_LOCK`: sequence story unifiee `unlock -> WIN` (random `*WIN*`, fallback effet I2S WIN)
- puis `ETAPE_2` declenchee apres delai story (defaut 15 min)
- mode lecteur SD autorise seulement apres `ETAPE_2`
- ensuite: activation detection SD, puis passage auto en `MODE LECTEUR U-SON` si SD + fichiers audio supportes
- Touches: clavier analogique sur une seule entree ADC
- Ecran distant: ESP8266 NodeMCU OLED via UART
@@ -31,14 +35,18 @@ Ce dossier contient le firmware principal pour **ESP32 Audio Kit V2.2 A252**.
- Compatibilite includes historiques: `src/app_state.h`
- Codec ES8388 (wrapper `arduino-audio-driver`): `src/audio/codec_es8388_driver.h`, `src/audio/codec_es8388_driver.cpp`
- Config hardware/audio: `src/config.h`
- Lecteur audio SD_MMC + I2S (multi-format): `src/mp3_player.h`, `src/mp3_player.cpp`
- Touches analogiques: `src/keypad_analog.h`, `src/keypad_analog.cpp`
- Lien ecran ESP8266: `src/screen_link.h`, `src/screen_link.cpp`
- Lecteur audio SD_MMC + I2S (multi-format): `src/audio/mp3_player.h`, `src/audio/mp3_player.cpp`
- Generation tonalite/jingle: `src/audio/sine_dac.h`, `src/audio/sine_dac.cpp`, `src/audio/i2s_jingle_player.h`, `src/audio/i2s_jingle_player.cpp`
- Touches analogiques: `src/input/keypad_analog.h`, `src/input/keypad_analog.cpp`
- UI LED: `src/ui/led_controller.h`, `src/ui/led_controller.cpp`
- Lien ecran ESP8266: `src/screen/screen_link.h`, `src/screen/screen_link.cpp`, `src/screen/screen_frame.h`
- Moteur de scenario STORY: `src/story/story_engine.h`, `src/story/story_engine.cpp`
- Memo board A252: `README_A252.md`
- Cablage: `WIRING.md`
- Validation terrain: `TESTING.md`
- Commandes de travail: `Makefile`
- Assets LittleFS (sons internes): `data/`
- Archives non actives: `old/` (ex: sources audio de travail)
## GPIO utilises (A252)
@@ -65,7 +73,9 @@ Ce dossier contient le firmware principal pour **ESP32 Audio Kit V2.2 A252**.
- Au demarrage, le firmware lit un FX boot LittleFS (par defaut `uson_boot_arcade_lowmono.mp3`, cible ~20 s).
- Ensuite il lance un scan radio I2S continu (bruit FM/recherche) en boucle.
- Pendant cette phase, les commandes de mode normal sont bloquees.
- Le passage a l'app suivante se fait sur appui d'une touche `K1..K6` (pas de timeout auto).
- Le passage a l'app suivante se fait sur appui d'une touche `K1..K6`.
- Un timeout auto est possible si `kBootAudioValidationTimeoutMs > 0` dans `src/config.h`
(par defaut `0`, donc timeout desactive).
Touches actives:
@@ -73,7 +83,7 @@ Touches actives:
Commandes serie (moniteur ESP32):
- `BOOT_NEXT` (alias: `NEXT`, `OK`, `SKIP`)
- `BOOT_NEXT`
- `BOOT_REPLAY` (relit l'intro + relance le scan radio)
- `BOOT_STATUS`
- `BOOT_HELP`
@@ -92,12 +102,33 @@ Commandes serie (moniteur ESP32):
- `CODEC_RD 0x2E` / `CODEC_WR 0x2E 0x10` (lecture/ecriture registre brut)
- `CODEC_VOL 70` (volume codec + gain logiciel lecteur a 70%)
- `CODEC_VOL_RAW 0x12` (force volume brut registres 0x2E..0x31)
- Alias courts acceptes: `NEXT`, `OK`, `REPLAY`, `SKIP`, `STATUS`, `HELP`, `PAINV`, `FS_INFO`, `FS_LIST`, `FSTEST`
- Les aliases historiques sont desactives: utiliser uniquement les commandes canoniques `PREFIXE_ACTION`.
Le firmware publie un rappel periodique tant que l'attente touche est active.
Hors fenetre boot: les commandes BOOT qui declenchent de l'audio sont autorisees uniquement en `U_LOCK`.
En `U-SON`/`MP3`, seules les commandes de statut restent autorisees (`BOOT_STATUS`, `BOOT_HELP`, `BOOT_PA_STATUS`, `BOOT_FS_INFO`, `BOOT_FS_LIST`).
### Scenario STORY (serial)
Commandes scenario (moniteur ESP32):
- `STORY_STATUS`
- `STORY_HELP`
- `STORY_RESET`
- `STORY_ARM`
- `STORY_TEST_ON` / `STORY_TEST_OFF`
- `STORY_TEST_DELAY <ms>` (borne 100..300000)
- `STORY_FORCE_ETAPE2`
Notes:
- Le mode test STORY est pilote uniquement par commandes serie (pas de raccourci clavier dedie).
- `STORY_ARM` lance maintenant le scenario complet: armement + tentative de lecture `WIN`.
- `STORY_STATUS` expose un `stage` explicite: `WAIT_UNLOCK`, `WIN_PENDING`, `WAIT_ETAPE2`, `ETAPE2_DONE`.
- Les delais par defaut sont configures dans `src/config.h`:
- `kStoryEtape2DelayMs` (production, defaut 15 min)
- `kStoryEtape2TestDelayMs` (test rapide)
### Mode U_LOCK (au boot, detection SD bloquee)
- Ecran initial: module casse avec effet glitch (sans texte)
@@ -109,8 +140,8 @@ En `U-SON`/`MP3`, seules les commandes de statut restent autorisees (`BOOT_STATU
### Module U-SON fonctionnel (apres detection du LA)
- `K1` : LA detect on/off
- `K2` : tone test I2S `440 Hz`
- `K3` : sequence diag I2S `220 -> 440 -> 880 Hz`
- `K2` : FX FM sweep I2S (asynchrone)
- `K3` : FX sonar I2S (asynchrone)
- `K4` : replay FX boot I2S
- `K5` : demande refresh SD
- `K6` : lance une calibration micro serie (30 s)
@@ -171,9 +202,26 @@ Depuis la racine de ce dossier (`hardware/firmware/esp32`):
- `pio run -e esp8266_oled`
- `pio run -e esp8266_oled -t upload --upload-port /dev/ttyUSB1`
- `pio device monitor -e esp8266_oled --port /dev/ttyUSB1`
- (sous-projet ecran) `cd screen_esp8266_hw630 && pio run -e nodemcuv2`
Sans variable `PORT`, PlatformIO choisit automatiquement le port serie.
### Test live (ordre recommande)
1. Brancher l'ESP32 en USB.
2. Uploader ESP32:
- `pio run -e esp32dev -t upload --upload-port <PORT_ESP32>`
- optionnel assets: `pio run -e esp32dev -t uploadfs --upload-port <PORT_ESP32>`
3. Brancher l'ESP8266 OLED en USB.
4. Uploader ESP8266:
- `pio run -e esp8266_oled -t upload --upload-port <PORT_ESP8266>`
5. Ouvrir les moniteurs:
- `pio device monitor -e esp32dev --port <PORT_ESP32>`
- `pio device monitor -e esp8266_oled --port <PORT_ESP8266>`
Astuce detection ports:
- `pio device list`
## Lecteur audio evolue
Le lecteur:
@@ -222,7 +270,8 @@ Reglage live (sans reflash):
- `KEY_TEST_STATUS` : etat `OK/KO` + min/max `raw` par touche
- `KEY_TEST_RESET` : remet l'auto-test a zero
- `KEY_TEST_STOP` : arrete l'auto-test
- `FS_INFO` / `FS_LIST` / `FSTEST` : debug LittleFS et test lecture FX boot
- `BOOT_FS_INFO` / `BOOT_FS_LIST` / `BOOT_FS_TEST` : debug LittleFS et test lecture FX boot
- `STORY_STATUS` / `STORY_TEST_ON` / `STORY_TEST_OFF` / `STORY_TEST_DELAY` / `STORY_ARM` / `STORY_FORCE_ETAPE2` : pilotage scenario STORY
- `CODEC_STATUS` / `CODEC_DUMP` : debug codec I2C ES8388
- `CODEC_RD reg` / `CODEC_WR reg val` : lecture/ecriture registre codec
- `CODEC_VOL pct` / `CODEC_VOL_RAW raw [out2]` : reglage volume sortie codec
+58 -9
View File
@@ -9,6 +9,21 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
- Si sons internes utilises: image LittleFS flashee (`make uploadfs-esp32 ...`).
- Liaison `GPIO22 -> D6` et `GND <-> GND` cablee.
- Moniteur serie disponible sur les deux cartes.
- Ports series identifies (`pio device list`).
## Upload live guide (2 cartes)
1. Brancher uniquement l'ESP32.
2. Uploader:
- `pio run -e esp32dev -t upload --upload-port <PORT_ESP32>`
- optionnel: `pio run -e esp32dev -t uploadfs --upload-port <PORT_ESP32>`
3. Brancher ensuite l'ESP8266 OLED.
4. Uploader:
- `pio run -e esp8266_oled -t upload --upload-port <PORT_ESP8266>`
- ou dans `screen_esp8266_hw630`: `pio run -e nodemcuv2 -t upload --upload-port <PORT_ESP8266>`
5. Verifier les 2 moniteurs:
- `pio device monitor -e esp32dev --port <PORT_ESP32>`
- `pio device monitor -e esp8266_oled --port <PORT_ESP8266>`
## 1) Boot sans SD
@@ -28,6 +43,7 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
- verifier que le scan radio I2S tourne en continu
- appuyer `K1..K6`: verifier `[BOOT_PROTO] DONE status=VALIDATED ...`
2. Validation serial:
- note: aliases legacy desactives, utiliser uniquement les commandes canoniques `PREFIXE_ACTION`
- envoyer `BOOT_STATUS` puis verifier `waiting_key=1 ...`
- envoyer `BOOT_REPLAY` pour relire intro + relancer scan
- envoyer `BOOT_TEST_TONE` puis `BOOT_TEST_DIAG` pour test audio
@@ -35,10 +51,9 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
- si silence audio: envoyer `BOOT_PA_INV` puis re-tester `BOOT_TEST_TONE`
- envoyer `BOOT_FS_INFO` puis `BOOT_FS_LIST`
- optionnel: envoyer `BOOT_FS_TEST` pour lire le FX boot LittleFS
- envoyer `BOOT_NEXT` (ou alias `OK`) pour passer a l'etape suivante
- envoyer `BOOT_NEXT` pour passer a l'etape suivante
- envoyer `BOOT_REOPEN` pour relancer le protocole sans reset carte
3. Limite replay:
- declencher plus de 6 replays et verifier `REPLAY refuse: max atteint.`
- si `kBootAudioValidationTimeoutMs > 0`, verifier aussi le passage auto au timeout
## 1c) Validation codec I2C (ES8388) pas a pas
@@ -79,6 +94,39 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
- pictogramme de validation
- puis ecran `U-SON FONCTIONNEL`
## 2b) Scenario STORY (test direct sans attente)
Objectif: valider la logique STORY sans attendre 15 minutes.
Prerequis:
- Etre en `MODULE U-SON Fonctionnel` (unlock fait).
- Moniteur serie ESP32 ouvert.
Procedure rapide:
1. Envoyer `STORY_STATUS` pour lire l'etat initial.
2. Envoyer `STORY_TEST_ON`.
3. Envoyer `STORY_TEST_DELAY 5000` (5 s).
4. Envoyer `STORY_ARM` pour armer la timeline.
5. Verifier `STORY_STATUS`:
- `stage=WAIT_ETAPE2` (ou `WIN_PENDING` si audio WIN en cours)
- `armed=1`
- `win=1` (ou passe a 1 apres fin du WIN)
- `etape2=0`
- `test=1`
- `left~5s`
6. Deux options de declenchement `ETAPE_2`:
- attendre 5 s
- ou envoyer `STORY_FORCE_ETAPE2`
7. Verifier les logs:
- `[STORY] ETAPE_2 trigger.`
- `[STORY] ETAPE_2 done ...`
8. Verifier `STORY_STATUS` final:
- `stage=ETAPE2_DONE`
- `etape2=1`
9. Envoyer `STORY_TEST_OFF` pour revenir aux delais de prod.
## 3) SD et lecteur audio
1. Inserer une SD avec au moins un fichier audio supporte a la racine (`.mp3`, `.wav`, `.aac`, `.flac`, `.opus`, `.ogg`).
@@ -99,9 +147,9 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
- `[FS] ... LittleFS mounted ...`
- `[FS] Boot FX ready: ...` (si fichier boot present)
4. En serie:
- `FS_INFO`
- `FS_LIST`
- `FSTEST` (lecture du FX boot detecte)
- `BOOT_FS_INFO`
- `BOOT_FS_LIST`
- `BOOT_FS_TEST` (lecture du FX boot detecte)
## 4) Touches
@@ -110,8 +158,8 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
- verifier que `K6` lance la calibration micro
2. En `U-SON FONCTIONNEL`:
- `K1`: toggle detection LA
- `K2`: tone test I2S 440 Hz
- `K3`: sequence diag I2S 220/440/880 Hz
- `K2`: FX FM sweep I2S (asynchrone)
- `K3`: FX sonar I2S (asynchrone)
- `K4`: replay FX boot I2S
- `K5`: refresh SD (rescan immediat)
- `K6`: calibration micro
@@ -150,7 +198,8 @@ Cette checklist couvre le comportement attendu du couple ESP32 + ESP8266 OLED.
1. Deconnecter temporairement le fil `GPIO22 -> D6`.
2. Verifier l'OLED:
- affichage `LINK DOWN` apres timeout (~10 s + anti-flicker ~1.5 s)
- etat `RECONNEXION MODULE` d'abord (grace transitoire)
- puis `LINK DOWN` seulement si la liaison ne revient pas (grace ~30 s)
3. Reconnecter le fil.
4. Verifier retour automatique vers un ecran de mode.
+9 -2
View File
@@ -2,7 +2,7 @@
Ce document decrit le branchement entre:
- la carte principale ESP32 (firmware principal),
- la carte ESP8266 NodeMCU + ecran OLED (firmware `esp8266_oled`).
- la carte ESP8266 NodeMCU + ecran OLED (env racine `esp8266_oled`, sous-projet `nodemcuv2`).
## 1) Liaison serie entre les 2 cartes (obligatoire)
@@ -14,7 +14,7 @@ Ce document decrit le branchement entre:
Notes:
- Le lien est unidirectionnel: ESP32 vers ESP8266.
- `D5 (TX)` de l'ESP8266 n'est pas utilise.
- Debit serie: `38400 bauds`.
- Debit serie: `19200 bauds`.
## 2) OLED sur la carte ESP8266
@@ -59,6 +59,7 @@ OLED SCL ------------- D2 (recommande)
1. Flasher l'ESP32: `pio run -e esp32dev -t upload --upload-port <PORT_ESP32>`.
2. Flasher l'ESP8266: `pio run -e esp8266_oled -t upload --upload-port <PORT_ESP8266>`.
- ou dans `screen_esp8266_hw630`: `pio run -e nodemcuv2 -t upload --upload-port <PORT_ESP8266>`.
3. Ouvrir le moniteur ESP8266: `pio device monitor -e esp8266_oled --port <PORT_ESP8266>`.
4. Verifier que l'ecran passe de `Demarrage...` a un ecran de mode (`U_LOCK`, `U-SON FONCTIONNEL` ou `LECTEUR U-SON`) apres reception des trames.
@@ -66,3 +67,9 @@ Equivalents Makefile:
- `make upload-esp32 ESP32_PORT=<PORT_ESP32>`
- `make upload-screen SCREEN_PORT=<PORT_ESP8266>`
- `make monitor-screen SCREEN_PORT=<PORT_ESP8266>`
## 6) Sequence USB pour test live
1. Brancher d'abord l'ESP32, flasher, verifier le boot serial.
2. Brancher ensuite l'ESP8266 OLED, flasher, verifier reception trames `STAT,...`.
3. Garder les deux USB branches pendant la validation fonctionnelle complete.
+8
View File
@@ -0,0 +1,8 @@
Archive locale des fichiers non utilises par le build firmware courant.
Contenu actuel:
- `old/sonGPT/`: exports audio de travail conserves pour reference.
Regle:
- ne rien inclure depuis `old/` dans le code compile.
- conserver uniquement ce qui est utile en historique.
@@ -30,7 +30,7 @@ Compatibilite:
- **Masse commune obligatoire**: ESP32 GND <-> ESP8266 GND
- ESP32 `GPIO22` (TX) -> ESP8266 `D6` (RX SoftwareSerial)
- ESP8266 `D5` (TX) non utilise (laisser deconnecte)
- Debit UART: `38400 bauds`
- Debit UART: `19200 bauds`
- OLED I2C sur ESP8266:
- recommande: `D1` SDA, `D2` SCL
- fallback supporte: `D2` SDA, `D1` SCL
@@ -10,7 +10,7 @@ namespace {
constexpr uint8_t kLinkRx = D6; // ESP8266 RX <- ESP32 TX (GPIO22)
constexpr uint8_t kLinkTx = D5; // Non utilise dans le profil actuel
constexpr uint32_t kLinkBaud = 38400;
constexpr uint32_t kLinkBaud = 19200;
constexpr int kLinkRxBufferBytes = 256;
constexpr int kLinkIsrBufferBytes = 2048;
@@ -19,10 +19,16 @@ constexpr uint8_t kScreenHeight = 64;
constexpr int8_t kOledReset = -1;
constexpr uint16_t kRenderPeriodMs = 250;
constexpr uint16_t kLinkTimeoutMs = 10000;
constexpr uint16_t kLinkDownConfirmMs = 1500;
constexpr uint16_t kLinkTimeoutMs = 15000;
constexpr uint16_t kLinkDownConfirmMs = 2500;
constexpr uint32_t kLinkRecoverGraceMs = 30000;
constexpr uint32_t kPeerRebootGraceMs = 8000;
constexpr uint32_t kPeerUptimeRollbackSlackMs = 2000;
constexpr uint16_t kDiagPeriodMs = 5000;
constexpr uint16_t kBootVisualTestMs = 250;
constexpr uint16_t kBootVisualTestMs = 400;
constexpr uint16_t kBootSplashMinMs = 3600;
constexpr uint8_t kOledInitRetries = 3;
constexpr uint16_t kOledInitRetryDelayMs = 80;
constexpr uint16_t kUnlockFrameMs = 2500;
constexpr uint8_t kUnlockFrameCount = 6;
constexpr uint8_t kInvalidPin = 0xFF;
@@ -77,6 +83,13 @@ struct TelemetryState {
uint8_t unlockHoldPercent = 0; // 0..100
uint8_t startupStage = kStartupStageInactive;
uint8_t appStage = kAppStageULockWaiting;
uint32_t frameSeq = 0;
uint8_t uiPage = 0;
uint8_t repeatMode = 0;
bool fxActive = false;
uint8_t backendMode = 0;
bool scanBusy = false;
uint8_t errorCode = 0;
int8_t tuningOffset = 0; // -8..+8 (left/right around LA)
uint8_t tuningConfidence = 0; // 0..100
uint32_t lastRxMs = 0;
@@ -93,7 +106,7 @@ bool g_linkWasAlive = false;
uint32_t g_linkLossCount = 0;
uint32_t g_parseErrorCount = 0;
uint32_t g_rxOverflowCount = 0;
char g_lineBuffer[128];
char g_lineBuffer[220];
uint8_t g_lineLen = 0;
uint8_t g_oledSdaPin = kInvalidPin;
uint8_t g_oledSclPin = kInvalidPin;
@@ -104,6 +117,9 @@ bool g_scopeFilled = false;
uint32_t g_unlockSequenceStartMs = 0;
uint32_t g_lastByteMs = 0;
uint32_t g_linkDownSinceMs = 0;
uint32_t g_linkLostSinceMs = 0;
uint32_t g_peerRebootUntilMs = 0;
uint32_t g_bootSplashUntilMs = 0;
uint32_t latestLinkTickMs() {
if (g_state.lastRxMs > g_lastByteMs) {
@@ -112,6 +128,13 @@ uint32_t latestLinkTickMs() {
return g_lastByteMs;
}
uint32_t safeAgeMs(uint32_t nowMs, uint32_t tickMs) {
if (tickMs == 0U || nowMs < tickMs) {
return 0U;
}
return nowMs - tickMs;
}
bool isPhysicalLinkAlive(uint32_t nowMs) {
if (!g_linkEnabled) {
return false;
@@ -121,6 +144,9 @@ bool isPhysicalLinkAlive(uint32_t nowMs) {
if (lastTickMs == 0) {
return false;
}
if (nowMs < lastTickMs) {
return true;
}
return (nowMs - lastTickMs) <= kLinkTimeoutMs;
}
@@ -146,6 +172,10 @@ bool isLinkAlive(uint32_t nowMs) {
return (nowMs - g_linkDownSinceMs) < kLinkDownConfirmMs;
}
bool isPeerRebootGraceActive(uint32_t nowMs) {
return g_peerRebootUntilMs != 0U && static_cast<int32_t>(nowMs - g_peerRebootUntilMs) < 0;
}
int16_t textWidth(const char* text, uint8_t textSize) {
return static_cast<int16_t>(strlen(text)) * 6 * textSize;
}
@@ -601,6 +631,36 @@ void drawMissionGrid(uint32_t nowMs, int16_t x, int16_t y, int16_t w, int16_t h)
g_display.drawRect(cursor - 1, pathY - 1, 3, 3, SSD1306_WHITE);
}
const char* uiPageShortLabel(uint8_t page) {
switch (page) {
case 1:
return "BRW";
case 2:
return "QUE";
case 3:
return "SET";
case 0:
default:
return "NOW";
}
}
const char* repeatShortLabel(uint8_t repeatMode) {
return (repeatMode == 1U) ? "ONE" : "ALL";
}
const char* backendShortLabel(uint8_t backendMode) {
switch (backendMode) {
case 1:
return "AT";
case 2:
return "LEG";
case 0:
default:
return "AUTO";
}
}
void renderMp3Screen() {
drawTitleBar("LECTEUR U-SON");
@@ -637,7 +697,26 @@ void renderMp3Screen() {
static_cast<unsigned int>(g_state.key));
}
drawCenteredText(infoLine, 43, 1);
drawHorizontalGauge(12, 54, 104, 8, g_state.volumePercent);
char modeLine[32];
snprintf(modeLine,
sizeof(modeLine),
"%s %s %s%s%s",
uiPageShortLabel(g_state.uiPage),
repeatShortLabel(g_state.repeatMode),
backendShortLabel(g_state.backendMode),
g_state.fxActive ? " FX" : "",
g_state.scanBusy ? " SC" : "");
drawCenteredText(modeLine, 52, 1);
drawHorizontalGauge(12, 58, 104, 5, g_state.volumePercent);
if (g_state.errorCode != 0U) {
char errLine[10];
snprintf(errLine, sizeof(errLine), "E%u", static_cast<unsigned int>(g_state.errorCode));
g_display.setCursor(103, 0);
g_display.setTextSize(1);
g_display.print(errLine);
}
}
void renderULockWaitingScreen(uint32_t nowMs) {
@@ -663,12 +742,25 @@ void renderULockScreen(uint32_t nowMs) {
}
void renderStartupBootScreen(uint32_t nowMs) {
drawProtoTitleBar();
drawCenteredText("SEQUENCE DEMARRAGE", 15, 1);
drawCenteredText("SON + SCAN RADIO", 27, 1);
drawCenteredText("ACTION: K1..K6", 39, 1);
const uint16_t phase = static_cast<uint16_t>((nowMs / 35U) % 200U);
const uint8_t sweep = static_cast<uint8_t>((phase <= 100U) ? phase : (200U - phase));
drawBrokenModuleGlitch(nowMs, 64, 32);
g_display.fillRect(0, 0, kScreenWidth, 12, SSD1306_WHITE);
g_display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
drawCenteredText("U-SON SCREEN", 2, 1);
g_display.setTextColor(SSD1306_WHITE);
g_display.fillRect(6, 15, 116, 30, SSD1306_BLACK);
g_display.drawRect(6, 15, 116, 30, SSD1306_WHITE);
drawCenteredDemoText("DECOUVERTE MODULE", 19, 1, nowMs, true);
char waitLine[24] = {};
const uint8_t dots = static_cast<uint8_t>((nowMs / 280U) % 4U);
snprintf(waitLine, sizeof(waitLine), "EN ATTENTE%.*s", static_cast<int>(dots), "...");
drawCenteredText(waitLine, 32, 1);
drawCenteredText("K1..K6 -> U_LOCK", 46, 1);
const uint16_t sweepPhase = static_cast<uint16_t>((nowMs / 35U) % 200U);
const uint8_t sweep = static_cast<uint8_t>((sweepPhase <= 100U) ? sweepPhase : (200U - sweepPhase));
drawHorizontalGauge(12, 54, 104, 8, sweep);
}
@@ -768,7 +860,7 @@ void renderUnlockSequenceScreen(uint32_t nowMs) {
void renderLinkDownScreen(uint32_t nowMs) {
const uint32_t lastTickMs = latestLinkTickMs();
const uint32_t ageMs = (lastTickMs > 0) ? (nowMs - lastTickMs) : 0;
const uint32_t ageMs = safeAgeMs(nowMs, lastTickMs);
drawTitleBar("U-SON SCREEN");
drawCenteredText("LINK DOWN", 18, 2);
@@ -782,6 +874,31 @@ void renderLinkDownScreen(uint32_t nowMs) {
drawCenteredText(lossLine, 54, 1);
}
void renderLinkRecoveringScreen(uint32_t nowMs) {
const uint32_t lastTickMs = latestLinkTickMs();
const uint32_t ageMs = safeAgeMs(nowMs, lastTickMs);
drawBrokenModuleGlitch(nowMs, 64, 32);
g_display.fillRect(0, 0, kScreenWidth, 12, SSD1306_WHITE);
g_display.setTextColor(SSD1306_BLACK, SSD1306_WHITE);
drawCenteredText("U-SON SCREEN", 2, 1);
g_display.setTextColor(SSD1306_WHITE);
drawCenteredText("RECONNEXION MODULE", 18, 1);
char ageLine[26];
snprintf(ageLine, sizeof(ageLine), "Derniere trame %lus", static_cast<unsigned long>(ageMs / 1000UL));
drawCenteredText(ageLine, 32, 1);
char retryLine[22];
snprintf(retryLine, sizeof(retryLine), "Pertes %lu", static_cast<unsigned long>(g_linkLossCount));
drawCenteredText(retryLine, 43, 1);
const uint16_t sweepPhase = static_cast<uint16_t>((nowMs / 35U) % 200U);
const uint8_t sweep = static_cast<uint8_t>((sweepPhase <= 100U) ? sweepPhase : (200U - sweepPhase));
drawHorizontalGauge(12, 54, 104, 8, sweep);
}
void renderScreen(uint32_t nowMs, bool linkAlive) {
if (!g_displayReady) {
return;
@@ -790,16 +907,37 @@ void renderScreen(uint32_t nowMs, bool linkAlive) {
g_display.clearDisplay();
g_display.setTextColor(SSD1306_WHITE);
if (g_bootSplashUntilMs != 0U && static_cast<int32_t>(nowMs - g_bootSplashUntilMs) < 0) {
drawTitleBar("U-SON SCREEN");
char line[22] = {};
const uint8_t dots = static_cast<uint8_t>((nowMs / 280U) % 4U);
snprintf(line, sizeof(line), "Demarrage%.*s", static_cast<int>(dots), "...");
drawCenteredText(line, 20, 2);
drawCenteredText(g_linkEnabled ? "Init OLED + lien ESP32" : "Init OLED", 43, 1);
const uint16_t sweepPhase = static_cast<uint16_t>((nowMs / 35U) % 200U);
const uint8_t sweep = static_cast<uint8_t>((sweepPhase <= 100U) ? sweepPhase : (200U - sweepPhase));
drawHorizontalGauge(12, 54, 104, 8, sweep);
g_display.display();
return;
}
if (!g_linkEnabled) {
drawTitleBar("U-SON SCREEN");
drawCenteredText("Liaison indisponible", 22, 1);
drawCenteredText("Verifier cablage", 34, 1);
} else if (!g_hasValidState) {
drawTitleBar("U-SON SCREEN");
drawCenteredText("Demarrage...", 18, 2);
drawCenteredText("En attente des donnees", 45, 1);
renderStartupBootScreen(nowMs);
} else if (!linkAlive) {
renderLinkDownScreen(nowMs);
const bool recovering = isPeerRebootGraceActive(nowMs) || g_linkLostSinceMs == 0U ||
(nowMs - g_linkLostSinceMs) < kLinkRecoverGraceMs;
if (recovering) {
renderLinkRecoveringScreen(nowMs);
} else {
renderLinkDownScreen(nowMs);
}
} else {
if (g_state.startupStage == kStartupStageBootValidation) {
renderStartupBootScreen(nowMs);
@@ -869,9 +1007,16 @@ bool parseFrame(const char* frame, TelemetryState* out) {
unsigned int unlockHoldPercent = 0;
unsigned int startupStage = 0;
unsigned int appStage = 0;
unsigned long frameSeq = 0;
unsigned int uiPage = 0;
unsigned int repeatMode = 0;
unsigned int fxActive = 0;
unsigned int backendMode = 0;
unsigned int scanBusy = 0;
unsigned int errorCode = 0;
const int parsed = sscanf(frame,
"STAT,%u,%u,%u,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%u,%u,%u,%u,%u,%u,%u",
"STAT,%u,%u,%u,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%u,%u,%u,%u,%u,%u,%u,%lu,%u,%u,%u,%u,%u,%u",
&la,
&mp3,
&sd,
@@ -890,8 +1035,15 @@ bool parseFrame(const char* frame, TelemetryState* out) {
&micScopeEnabled,
&unlockHoldPercent,
&startupStage,
&appStage);
if (parsed < 5) {
&appStage,
&frameSeq,
&uiPage,
&repeatMode,
&fxActive,
&backendMode,
&scanBusy,
&errorCode);
if (parsed < 19) {
return false;
}
@@ -966,6 +1118,16 @@ bool parseFrame(const char* frame, TelemetryState* out) {
out->appStage = kAppStageULockWaiting;
}
if (parsed >= 20) {
out->frameSeq = static_cast<uint32_t>(frameSeq);
}
out->uiPage = (parsed >= 21) ? static_cast<uint8_t>(uiPage) : 0U;
out->repeatMode = (parsed >= 22) ? static_cast<uint8_t>(repeatMode) : 0U;
out->fxActive = (parsed >= 23) ? (fxActive != 0U) : false;
out->backendMode = (parsed >= 24) ? static_cast<uint8_t>(backendMode) : 0U;
out->scanBusy = (parsed >= 25) ? (scanBusy != 0U) : false;
out->errorCode = (parsed >= 26) ? static_cast<uint8_t>(errorCode) : 0U;
out->lastRxMs = millis();
return true;
}
@@ -982,6 +1144,13 @@ void handleIncoming() {
g_lineBuffer[g_lineLen] = '\0';
TelemetryState parsed = g_state;
if (parseFrame(g_lineBuffer, &parsed)) {
if (g_hasValidState &&
(parsed.uptimeMs + kPeerUptimeRollbackSlackMs) < g_state.uptimeMs) {
g_peerRebootUntilMs = millis() + kPeerRebootGraceMs;
Serial.printf("[SCREEN] Peer reboot detecte: uptime %lu -> %lu\n",
static_cast<unsigned long>(g_state.uptimeMs),
static_cast<unsigned long>(parsed.uptimeMs));
}
if (g_state.appStage != kAppStageUSonFunctional &&
parsed.appStage == kAppStageUSonFunctional) {
g_unlockSequenceStartMs = millis();
@@ -1015,11 +1184,20 @@ void handleIncoming() {
void initDisplay() {
Serial.println("[SCREEN] OLED init...");
for (const auto& candidate : kI2cCandidates) {
Serial.printf("[SCREEN] Test I2C %s\n", candidate.label);
if (initDisplayOnPins(candidate.sda, candidate.scl)) {
g_displayReady = true;
g_oledSdaPin = candidate.sda;
g_oledSclPin = candidate.scl;
for (uint8_t attempt = 1U; attempt <= kOledInitRetries; ++attempt) {
Serial.printf("[SCREEN] Test I2C %s try=%u/%u\n",
candidate.label,
static_cast<unsigned int>(attempt),
static_cast<unsigned int>(kOledInitRetries));
if (initDisplayOnPins(candidate.sda, candidate.scl)) {
g_displayReady = true;
g_oledSdaPin = candidate.sda;
g_oledSclPin = candidate.scl;
break;
}
delay(kOledInitRetryDelayMs);
}
if (g_displayReady) {
break;
}
}
@@ -1041,6 +1219,7 @@ void initDisplay() {
g_display.println("U-SON SCREEN");
g_display.println("Boot...");
g_display.display();
g_bootSplashUntilMs = millis() + static_cast<uint32_t>(kBootSplashMinMs);
if (g_oledSdaPin == kLinkRx || g_oledSdaPin == kLinkTx ||
g_oledSclPin == kLinkRx || g_oledSclPin == kLinkTx) {
@@ -1085,6 +1264,15 @@ void loop() {
++g_linkLossCount;
g_stateDirty = true;
}
if (linkAlive) {
if (g_linkLostSinceMs != 0U) {
g_stateDirty = true;
}
g_linkLostSinceMs = 0U;
} else if (g_linkLostSinceMs == 0U) {
g_linkLostSinceMs = nowMs;
g_stateDirty = true;
}
if (linkAlive != g_linkWasAlive) {
g_stateDirty = true;
}
@@ -1098,7 +1286,7 @@ void loop() {
if ((nowMs - g_lastDiagMs) >= kDiagPeriodMs) {
const uint32_t lastTickMs = latestLinkTickMs();
const uint32_t ageMs = (lastTickMs > 0) ? (nowMs - lastTickMs) : 0;
const uint32_t ageMs = safeAgeMs(nowMs, lastTickMs);
Serial.printf("[SCREEN] oled=%s link=%s phys=%s valid=%u age_ms=%lu losses=%lu parse_err=%lu rx_ovf=%lu sda=%u scl=%u addr=0x%02X\n",
g_displayReady ? "OK" : "KO",
g_linkEnabled ? (linkAlive ? "OK" : "DOWN") : "OFF",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,709 @@
#include "track_catalog.h"
#include <ctype.h>
#include <stdio.h>
#include <string.h>
namespace {
uint32_t parseSynchsafe32(const uint8_t* in) {
if (in == nullptr) {
return 0U;
}
return (static_cast<uint32_t>(in[0] & 0x7FU) << 21U) |
(static_cast<uint32_t>(in[1] & 0x7FU) << 14U) |
(static_cast<uint32_t>(in[2] & 0x7FU) << 7U) |
static_cast<uint32_t>(in[3] & 0x7FU);
}
uint32_t parseBigEndian32(const uint8_t* in) {
if (in == nullptr) {
return 0U;
}
return (static_cast<uint32_t>(in[0]) << 24U) |
(static_cast<uint32_t>(in[1]) << 16U) |
(static_cast<uint32_t>(in[2]) << 8U) |
static_cast<uint32_t>(in[3]);
}
bool readBounded(fs::File& file, uint8_t* out, size_t len, uint32_t timeoutMs) {
if (out == nullptr || len == 0U) {
return false;
}
const uint32_t startMs = millis();
size_t pos = 0U;
while (pos < len) {
const int v = file.read();
if (v < 0) {
if (static_cast<uint32_t>(millis() - startMs) >= timeoutMs) {
break;
}
delay(1);
continue;
}
out[pos++] = static_cast<uint8_t>(v);
}
return pos == len;
}
void trimTrailing(char* text) {
if (text == nullptr) {
return;
}
size_t len = strlen(text);
while (len > 0U) {
const char c = text[len - 1U];
if (c == '\0' || c == '\r' || c == '\n' || c == '\t' || c == ' ') {
text[len - 1U] = '\0';
--len;
continue;
}
break;
}
}
void trimLeading(char* text) {
if (text == nullptr) {
return;
}
size_t start = 0U;
while (text[start] != '\0') {
const char c = text[start];
if (c == ' ' || c == '\t' || c == '\r' || c == '\n') {
++start;
continue;
}
break;
}
if (start == 0U) {
return;
}
size_t dst = 0U;
while (text[start] != '\0') {
text[dst++] = text[start++];
}
text[dst] = '\0';
}
void normalizePath(char* path, size_t len) {
if (path == nullptr || len == 0U) {
return;
}
for (size_t i = 0U; i < len && path[i] != '\0'; ++i) {
if (path[i] == '\\') {
path[i] = '/';
}
}
if (path[0] == '\0') {
snprintf(path, len, "/");
return;
}
if (path[0] != '/') {
char tmp[120] = {};
snprintf(tmp, sizeof(tmp), "/%s", path);
snprintf(path, len, "%s", tmp);
}
}
const char* basenamePtr(const char* path) {
if (path == nullptr) {
return "";
}
const char* slash = strrchr(path, '/');
return (slash == nullptr) ? path : (slash + 1);
}
int compareSegment(const char* a, const char* b, size_t* usedA, size_t* usedB) {
size_t ia = 0U;
size_t ib = 0U;
if (isdigit(static_cast<unsigned char>(a[0])) != 0 &&
isdigit(static_cast<unsigned char>(b[0])) != 0) {
unsigned long va = 0UL;
unsigned long vb = 0UL;
while (isdigit(static_cast<unsigned char>(a[ia])) != 0) {
va = (va * 10UL) + static_cast<unsigned long>(a[ia] - '0');
++ia;
}
while (isdigit(static_cast<unsigned char>(b[ib])) != 0) {
vb = (vb * 10UL) + static_cast<unsigned long>(b[ib] - '0');
++ib;
}
*usedA = ia;
*usedB = ib;
if (va < vb) {
return -1;
}
if (va > vb) {
return 1;
}
return 0;
}
const unsigned char ca = static_cast<unsigned char>(tolower(a[0]));
const unsigned char cb = static_cast<unsigned char>(tolower(b[0]));
*usedA = 1U;
*usedB = 1U;
if (ca < cb) {
return -1;
}
if (ca > cb) {
return 1;
}
return 0;
}
} // namespace
const char* catalogCodecLabel(CatalogCodec codec) {
switch (codec) {
case CatalogCodec::kMp3:
return "MP3";
case CatalogCodec::kWav:
return "WAV";
case CatalogCodec::kAac:
return "AAC";
case CatalogCodec::kFlac:
return "FLAC";
case CatalogCodec::kOpus:
return "OPUS";
case CatalogCodec::kUnknown:
default:
return "UNKNOWN";
}
}
CatalogCodec catalogCodecFromPath(const char* path) {
if (path == nullptr || path[0] == '\0') {
return CatalogCodec::kUnknown;
}
String lower(path);
lower.toLowerCase();
if (lower.endsWith(".mp3")) {
return CatalogCodec::kMp3;
}
if (lower.endsWith(".wav")) {
return CatalogCodec::kWav;
}
if (lower.endsWith(".aac") || lower.endsWith(".m4a")) {
return CatalogCodec::kAac;
}
if (lower.endsWith(".flac")) {
return CatalogCodec::kFlac;
}
if (lower.endsWith(".opus") || lower.endsWith(".ogg")) {
return CatalogCodec::kOpus;
}
return CatalogCodec::kUnknown;
}
void TrackCatalog::clear() {
count_ = 0U;
}
bool TrackCatalog::scan(fs::FS& storage,
const char* rootPath,
uint8_t maxDepth,
uint32_t metadataTimeoutMs,
CatalogStats* outStats) {
clear();
CatalogStats stats;
const uint32_t beginMs = millis();
const char* root = (rootPath != nullptr && rootPath[0] != '\0') ? rootPath : "/";
const bool ok = scanDirRecursive(storage, root, 0U, maxDepth, metadataTimeoutMs, &stats);
if (ok) {
sortEntries();
stats.tracks = count_;
stats.scanMs = millis() - beginMs;
stats.indexed = true;
}
if (outStats != nullptr) {
*outStats = stats;
}
return ok;
}
bool TrackCatalog::loadIndex(fs::FS& storage, const char* path, CatalogStats* outStats) {
if (path == nullptr || path[0] == '\0') {
return false;
}
fs::File file = storage.open(path, FILE_READ);
if (!file || file.isDirectory()) {
return false;
}
clear();
CatalogStats stats;
stats.indexed = true;
while (file.available() > 0) {
String line = file.readStringUntil('\n');
line.trim();
if (line.isEmpty()) {
continue;
}
TrackEntry entry;
memset(&entry, 0, sizeof(entry));
const int p1 = line.indexOf('\t');
if (p1 < 0) {
continue;
}
const int p2 = line.indexOf('\t', p1 + 1);
const int p3 = line.indexOf('\t', (p2 >= 0) ? p2 + 1 : -1);
const int p4 = line.indexOf('\t', (p3 >= 0) ? p3 + 1 : -1);
const int p5 = line.indexOf('\t', (p4 >= 0) ? p4 + 1 : -1);
const int p6 = line.indexOf('\t', (p5 >= 0) ? p5 + 1 : -1);
if (p2 < 0 || p3 < 0 || p4 < 0 || p5 < 0 || p6 < 0) {
continue;
}
const String pathValue = line.substring(0, p1);
const String codecValue = line.substring(p1 + 1, p2);
const String sizeValue = line.substring(p2 + 1, p3);
const String titleValue = line.substring(p3 + 1, p4);
const String artistValue = line.substring(p4 + 1, p5);
const String albumValue = line.substring(p5 + 1, p6);
const String durationValue = line.substring(p6 + 1);
copyStr(entry.path, sizeof(entry.path), pathValue.c_str());
copyStr(entry.codec, sizeof(entry.codec), codecValue.c_str());
copyStr(entry.title, sizeof(entry.title), titleValue.c_str());
copyStr(entry.artist, sizeof(entry.artist), artistValue.c_str());
copyStr(entry.album, sizeof(entry.album), albumValue.c_str());
entry.sizeBytes = static_cast<uint32_t>(sizeValue.toInt());
entry.durationMs = static_cast<uint32_t>(durationValue.toInt());
normalizePath(entry.path, sizeof(entry.path));
sanitizeText(entry.title, sizeof(entry.title));
sanitizeText(entry.artist, sizeof(entry.artist));
sanitizeText(entry.album, sizeof(entry.album));
if (!addTrackEntry(entry)) {
break;
}
}
file.close();
stats.tracks = count_;
if (outStats != nullptr) {
*outStats = stats;
}
return count_ > 0U;
}
bool TrackCatalog::saveIndex(fs::FS& storage, const char* path) const {
if (path == nullptr || path[0] == '\0') {
return false;
}
if (storage.exists(path)) {
storage.remove(path);
}
fs::File file = storage.open(path, FILE_WRITE);
if (!file || file.isDirectory()) {
return false;
}
for (uint16_t i = 0U; i < count_; ++i) {
const TrackEntry& e = entries_[i];
file.printf("%s\t%s\t%lu\t%s\t%s\t%s\t%lu\n",
e.path,
e.codec,
static_cast<unsigned long>(e.sizeBytes),
e.title,
e.artist,
e.album,
static_cast<unsigned long>(e.durationMs));
}
file.close();
return true;
}
bool TrackCatalog::appendFallbackPath(const char* path, uint32_t sizeBytes) {
if (path == nullptr || path[0] == '\0') {
return false;
}
TrackEntry entry;
memset(&entry, 0, sizeof(entry));
copyStr(entry.path, sizeof(entry.path), path);
normalizePath(entry.path, sizeof(entry.path));
const CatalogCodec codec = catalogCodecFromPath(entry.path);
if (codec == CatalogCodec::kUnknown) {
return false;
}
copyStr(entry.codec, sizeof(entry.codec), catalogCodecLabel(codec));
entry.sizeBytes = sizeBytes;
return addTrackEntry(entry);
}
uint16_t TrackCatalog::size() const {
return count_;
}
const TrackEntry* TrackCatalog::entry(uint16_t index) const {
if (index >= count_) {
return nullptr;
}
return &entries_[index];
}
int16_t TrackCatalog::indexOfPath(const char* path) const {
if (path == nullptr || path[0] == '\0') {
return -1;
}
char normalized[120] = {};
copyStr(normalized, sizeof(normalized), path);
normalizePath(normalized, sizeof(normalized));
for (uint16_t i = 0U; i < count_; ++i) {
if (strcmp(entries_[i].path, normalized) == 0) {
return static_cast<int16_t>(i);
}
}
return -1;
}
uint16_t TrackCatalog::listByPrefix(const char* prefix,
uint16_t offset,
uint16_t limit,
Print& out) const {
const char* safePrefix = (prefix == nullptr) ? "/" : prefix;
uint16_t total = 0U;
uint16_t emitted = 0U;
for (uint16_t i = 0U; i < count_; ++i) {
const TrackEntry& e = entries_[i];
if (!startsWithPathPrefix(e.path, safePrefix)) {
continue;
}
++total;
if (total <= offset) {
continue;
}
if (emitted >= limit) {
continue;
}
++emitted;
const char* title = (e.title[0] != '\0') ? e.title : basenamePtr(e.path);
out.printf("[%u] %s | %s | %s | %s\n",
static_cast<unsigned int>(i + 1U),
title,
e.artist[0] != '\0' ? e.artist : "-",
e.codec,
e.path);
}
return total;
}
uint16_t TrackCatalog::countByPrefix(const char* prefix) const {
const char* safePrefix = (prefix == nullptr) ? "/" : prefix;
uint16_t total = 0U;
for (uint16_t i = 0U; i < count_; ++i) {
if (startsWithPathPrefix(entries_[i].path, safePrefix)) {
++total;
}
}
return total;
}
bool TrackCatalog::isSupportedPath(const char* path, CatalogCodec* outCodec) {
const CatalogCodec codec = catalogCodecFromPath(path);
if (outCodec != nullptr) {
*outCodec = codec;
}
return codec != CatalogCodec::kUnknown;
}
int TrackCatalog::compareNatural(const char* lhs, const char* rhs) {
if (lhs == nullptr && rhs == nullptr) {
return 0;
}
if (lhs == nullptr) {
return -1;
}
if (rhs == nullptr) {
return 1;
}
size_t il = 0U;
size_t ir = 0U;
while (lhs[il] != '\0' && rhs[ir] != '\0') {
size_t usedL = 0U;
size_t usedR = 0U;
const int cmp = compareSegment(lhs + il, rhs + ir, &usedL, &usedR);
if (cmp != 0) {
return cmp;
}
il += usedL;
ir += usedR;
}
if (lhs[il] == '\0' && rhs[ir] == '\0') {
return 0;
}
return (lhs[il] == '\0') ? -1 : 1;
}
void TrackCatalog::sanitizeText(char* text, size_t len) {
if (text == nullptr || len == 0U) {
return;
}
for (size_t i = 0U; i < len && text[i] != '\0'; ++i) {
if (text[i] == '\t' || text[i] == '\r' || text[i] == '\n') {
text[i] = ' ';
}
}
trimLeading(text);
trimTrailing(text);
}
bool TrackCatalog::copyStr(char* out, size_t outLen, const char* in) {
if (out == nullptr || outLen == 0U) {
return false;
}
out[0] = '\0';
if (in == nullptr) {
return false;
}
snprintf(out, outLen, "%s", in);
return out[0] != '\0';
}
bool TrackCatalog::startsWithPathPrefix(const char* path, const char* prefix) {
if (path == nullptr || prefix == nullptr) {
return false;
}
if (strcmp(prefix, "/") == 0 || prefix[0] == '\0') {
return true;
}
const size_t prefixLen = strlen(prefix);
if (strncmp(path, prefix, prefixLen) != 0) {
return false;
}
const char next = path[prefixLen];
return next == '\0' || next == '/';
}
void TrackCatalog::parseMetadata(fs::FS& storage, TrackEntry* entry, uint32_t timeoutMs) {
if (entry == nullptr || entry->path[0] == '\0') {
return;
}
fs::File file = storage.open(entry->path, FILE_READ);
if (!file || file.isDirectory()) {
return;
}
parseId3v2(file, entry, timeoutMs);
parseId3v1(file, entry);
sanitizeText(entry->title, sizeof(entry->title));
sanitizeText(entry->artist, sizeof(entry->artist));
sanitizeText(entry->album, sizeof(entry->album));
file.close();
}
void TrackCatalog::parseId3v2(fs::File& file, TrackEntry* entry, uint32_t timeoutMs) {
if (!file || entry == nullptr) {
return;
}
if (file.seek(0) == false) {
return;
}
uint8_t header[10] = {};
if (!readBounded(file, header, sizeof(header), timeoutMs)) {
return;
}
if (!(header[0] == 'I' && header[1] == 'D' && header[2] == '3')) {
return;
}
const uint8_t version = header[3];
const uint32_t tagSize = parseSynchsafe32(&header[6]);
if (tagSize == 0U || tagSize > 64U * 1024U) {
return;
}
uint32_t consumed = 0U;
while (consumed + 10U <= tagSize) {
uint8_t fh[10] = {};
if (!readBounded(file, fh, sizeof(fh), timeoutMs)) {
return;
}
consumed += 10U;
if (fh[0] == 0U || fh[1] == 0U || fh[2] == 0U || fh[3] == 0U) {
return;
}
const char fid[5] = {static_cast<char>(fh[0]),
static_cast<char>(fh[1]),
static_cast<char>(fh[2]),
static_cast<char>(fh[3]),
'\0'};
uint32_t frameSize = 0U;
if (version >= 4U) {
frameSize = parseSynchsafe32(&fh[4]);
} else {
frameSize = parseBigEndian32(&fh[4]);
}
if (frameSize == 0U || frameSize > (tagSize - consumed)) {
return;
}
const bool wanted = (strcmp(fid, "TIT2") == 0) ||
(strcmp(fid, "TPE1") == 0) ||
(strcmp(fid, "TALB") == 0);
if (!wanted) {
if (file.seek(file.position() + frameSize) == false) {
return;
}
consumed += frameSize;
continue;
}
char local[96] = {};
const size_t readLen = (frameSize < (sizeof(local) - 1U)) ? frameSize : (sizeof(local) - 1U);
if (!readBounded(file, reinterpret_cast<uint8_t*>(local), readLen, timeoutMs)) {
return;
}
local[readLen] = '\0';
consumed += frameSize;
if (frameSize > readLen) {
if (file.seek(file.position() + (frameSize - readLen)) == false) {
return;
}
}
const char* textStart = local;
if (readLen > 0U) {
// encoding byte
textStart = local + 1;
}
if (strcmp(fid, "TIT2") == 0 && entry->title[0] == '\0') {
copyStr(entry->title, sizeof(entry->title), textStart);
} else if (strcmp(fid, "TPE1") == 0 && entry->artist[0] == '\0') {
copyStr(entry->artist, sizeof(entry->artist), textStart);
} else if (strcmp(fid, "TALB") == 0 && entry->album[0] == '\0') {
copyStr(entry->album, sizeof(entry->album), textStart);
}
}
}
void TrackCatalog::parseId3v1(fs::File& file, TrackEntry* entry) {
if (!file || entry == nullptr) {
return;
}
const size_t total = file.size();
if (total < 128U) {
return;
}
if (file.seek(total - 128U) == false) {
return;
}
uint8_t buf[128] = {};
if (file.read(buf, sizeof(buf)) != static_cast<int>(sizeof(buf))) {
return;
}
if (!(buf[0] == 'T' && buf[1] == 'A' && buf[2] == 'G')) {
return;
}
if (entry->title[0] == '\0') {
char tmp[31] = {};
memcpy(tmp, &buf[3], 30U);
sanitizeText(tmp, sizeof(tmp));
copyStr(entry->title, sizeof(entry->title), tmp);
}
if (entry->artist[0] == '\0') {
char tmp[31] = {};
memcpy(tmp, &buf[33], 30U);
sanitizeText(tmp, sizeof(tmp));
copyStr(entry->artist, sizeof(entry->artist), tmp);
}
if (entry->album[0] == '\0') {
char tmp[31] = {};
memcpy(tmp, &buf[63], 30U);
sanitizeText(tmp, sizeof(tmp));
copyStr(entry->album, sizeof(entry->album), tmp);
}
}
bool TrackCatalog::addTrackEntry(const TrackEntry& entry) {
if (count_ >= kMaxTracks) {
return false;
}
entries_[count_++] = entry;
return true;
}
void TrackCatalog::sortEntries() {
if (count_ < 2U) {
return;
}
for (uint16_t i = 0U; i < count_ - 1U; ++i) {
for (uint16_t j = i + 1U; j < count_; ++j) {
if (compareNatural(entries_[j].path, entries_[i].path) < 0) {
const TrackEntry tmp = entries_[i];
entries_[i] = entries_[j];
entries_[j] = tmp;
}
}
}
}
bool TrackCatalog::scanDirRecursive(fs::FS& storage,
const char* dirPath,
uint8_t depth,
uint8_t maxDepth,
uint32_t metadataTimeoutMs,
CatalogStats* stats) {
fs::File dir = storage.open(dirPath);
if (!dir || !dir.isDirectory()) {
return false;
}
if (stats != nullptr) {
++stats->folders;
}
fs::File file = dir.openNextFile();
while (file) {
if (count_ >= kMaxTracks) {
file.close();
break;
}
if (file.isDirectory()) {
if (depth < maxDepth) {
String next = String(file.name());
if (!next.startsWith("/")) {
next = "/" + next;
}
file.close();
scanDirRecursive(storage, next.c_str(), static_cast<uint8_t>(depth + 1U), maxDepth, metadataTimeoutMs, stats);
} else {
file.close();
}
} else {
String path = String(file.name());
if (!path.startsWith("/")) {
path = "/" + path;
}
CatalogCodec codec = CatalogCodec::kUnknown;
if (isSupportedPath(path.c_str(), &codec)) {
TrackEntry entry;
memset(&entry, 0, sizeof(entry));
copyStr(entry.path, sizeof(entry.path), path.c_str());
copyStr(entry.codec, sizeof(entry.codec), catalogCodecLabel(codec));
entry.sizeBytes = static_cast<uint32_t>(file.size());
parseMetadata(storage, &entry, metadataTimeoutMs);
if (!addTrackEntry(entry)) {
file.close();
break;
}
}
file.close();
}
file = dir.openNextFile();
}
dir.close();
return true;
}
@@ -0,0 +1,85 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
enum class CatalogCodec : uint8_t {
kUnknown = 0,
kMp3,
kWav,
kAac,
kFlac,
kOpus,
};
const char* catalogCodecLabel(CatalogCodec codec);
CatalogCodec catalogCodecFromPath(const char* path);
struct TrackEntry {
char path[120] = {};
char title[40] = {};
char artist[32] = {};
char album[32] = {};
char codec[8] = {};
uint32_t durationMs = 0;
uint32_t sizeBytes = 0;
};
struct CatalogStats {
uint16_t tracks = 0;
uint16_t folders = 0;
uint32_t scanMs = 0;
bool indexed = false;
bool metadataBestEffort = true;
};
class TrackCatalog {
public:
static constexpr uint16_t kMaxTracks = 250;
static constexpr uint8_t kDefaultMaxDepth = 4;
void clear();
bool scan(fs::FS& storage,
const char* rootPath,
uint8_t maxDepth,
uint32_t metadataTimeoutMs,
CatalogStats* outStats = nullptr);
bool loadIndex(fs::FS& storage, const char* path, CatalogStats* outStats = nullptr);
bool saveIndex(fs::FS& storage, const char* path) const;
bool appendFallbackPath(const char* path, uint32_t sizeBytes);
uint16_t size() const;
const TrackEntry* entry(uint16_t index) const;
int16_t indexOfPath(const char* path) const;
uint16_t listByPrefix(const char* prefix,
uint16_t offset,
uint16_t limit,
Print& out) const;
uint16_t countByPrefix(const char* prefix) const;
private:
static bool isSupportedPath(const char* path, CatalogCodec* outCodec = nullptr);
static int compareNatural(const char* lhs, const char* rhs);
static void sanitizeText(char* text, size_t len);
static bool copyStr(char* out, size_t outLen, const char* in);
static bool startsWithPathPrefix(const char* path, const char* prefix);
void parseMetadata(fs::FS& storage, TrackEntry* entry, uint32_t timeoutMs);
void parseId3v2(fs::File& file, TrackEntry* entry, uint32_t timeoutMs);
void parseId3v1(fs::File& file, TrackEntry* entry);
bool addTrackEntry(const TrackEntry& entry);
void sortEntries();
bool scanDirRecursive(fs::FS& storage,
const char* dirPath,
uint8_t depth,
uint8_t maxDepth,
uint32_t metadataTimeoutMs,
CatalogStats* stats);
TrackEntry entries_[kMaxTracks];
uint16_t count_ = 0;
};
@@ -0,0 +1,66 @@
#include "audio_effect_id.h"
#include <cctype>
#include <cstring>
namespace {
void upperTokenCopy(const char* src, char* dst, size_t dstLen) {
if (dst == nullptr || dstLen == 0U) {
return;
}
dst[0] = '\0';
if (src == nullptr) {
return;
}
size_t i = 0U;
while (src[i] != '\0' && i < (dstLen - 1U)) {
dst[i] = static_cast<char>(toupper(static_cast<unsigned char>(src[i])));
++i;
}
dst[i] = '\0';
}
} // namespace
const char* audioEffectLabel(AudioEffectId effect) {
switch (effect) {
case AudioEffectId::kFmSweep:
return "FM";
case AudioEffectId::kSonar:
return "SONAR";
case AudioEffectId::kMorse:
return "MORSE";
case AudioEffectId::kWin:
return "WIN";
default:
return "UNKNOWN";
}
}
bool parseAudioEffectToken(const char* token, AudioEffectId* outEffect) {
if (token == nullptr || outEffect == nullptr) {
return false;
}
char upper[16] = {};
upperTokenCopy(token, upper, sizeof(upper));
if (strcmp(upper, "FM") == 0 || strcmp(upper, "FMSWEEP") == 0) {
*outEffect = AudioEffectId::kFmSweep;
return true;
}
if (strcmp(upper, "SONAR") == 0) {
*outEffect = AudioEffectId::kSonar;
return true;
}
if (strcmp(upper, "MORSE") == 0) {
*outEffect = AudioEffectId::kMorse;
return true;
}
if (strcmp(upper, "WIN") == 0) {
*outEffect = AudioEffectId::kWin;
return true;
}
return false;
}
@@ -0,0 +1,13 @@
#pragma once
#include <Arduino.h>
enum class AudioEffectId : uint8_t {
kFmSweep = 0,
kSonar,
kMorse,
kWin,
};
const char* audioEffectLabel(AudioEffectId effect);
bool parseAudioEffectToken(const char* token, AudioEffectId* outEffect);
@@ -14,6 +14,16 @@ constexpr float kTwoPi = 6.28318530718f;
constexpr uint16_t kBlockFrames = 96U;
constexpr uint16_t kSynthRateHz = 22050U;
constexpr char kMorsePattern[] = ".-- .. -."; // "WIN"
constexpr uint16_t kMorseUnitMs = 90U;
constexpr uint16_t kMorseFreqHz = 680U;
constexpr uint16_t kWinNotesHz[] = {
523U, 659U, 784U, 1047U, 1319U, 1047U, 1568U, 1319U, 0U};
constexpr uint16_t kWinNotesMs[] = {
120U, 120U, 120U, 150U, 180U, 120U, 210U, 260U, 180U};
constexpr uint16_t kWinNoteCount = sizeof(kWinNotesHz) / sizeof(kWinNotesHz[0]);
float clampf(float value, float minValue, float maxValue) {
if (value < minValue) {
return minValue;
@@ -51,6 +61,22 @@ void FmRadioScanFx::setSampleRate(uint32_t sampleRateHz) {
sampleRateHz_ = sampleRateHz;
}
void FmRadioScanFx::setEffect(Effect effect) {
effect_ = effect;
if (active_) {
resetSynthesisState();
}
}
FmRadioScanFx::Effect FmRadioScanFx::effect() const {
return effect_;
}
bool FmRadioScanFx::start(Effect effect) {
setEffect(effect);
return start();
}
bool FmRadioScanFx::start() {
stop();
@@ -138,6 +164,11 @@ void FmRadioScanFx::update(uint32_t nowMs, uint16_t chunkMs) {
}
}
bool FmRadioScanFx::playBlocking(Effect effect, uint32_t durationMs, uint16_t chunkMs) {
setEffect(effect);
return playBlocking(durationMs, chunkMs);
}
bool FmRadioScanFx::playBlocking(uint32_t durationMs, uint16_t chunkMs) {
if (durationMs == 0U) {
return true;
@@ -199,15 +230,46 @@ void FmRadioScanFx::resetSynthesisState() {
sweepCycle_ = static_cast<uint32_t>(random(0L, 2L));
sweepPosInCycle_ = 0U;
sampleClock_ = 0U;
sonarPhase_ = 0.0f;
sonarEchoPhase_ = 0.0f;
morsePhase_ = 0.0f;
winPhase_ = 0.0f;
morseToneSamplesLeft_ = 0U;
morseGapSamplesLeft_ = 0U;
morsePatternPos_ = 0U;
winStepSamplesLeft_ = 0U;
winStepTotalSamples_ = 0U;
winStepIndex_ = 0U;
winCurrentFreqHz_ = 0U;
if (synth_ != nullptr) {
synth_->sweepOsc.setPhase(0U);
synth_->stationOsc.setPhase(0U);
synth_->carrierOsc.setPhase(0U);
}
if (effect_ == Effect::kMorse) {
morsePrepareNextState();
} else if (effect_ == Effect::kWin) {
winPrepareNextStep();
}
}
int16_t FmRadioScanFx::nextSample() {
switch (effect_) {
case Effect::kSonar:
return nextSampleSonar();
case Effect::kMorse:
return nextSampleMorse();
case Effect::kWin:
return nextSampleWin();
case Effect::kFmSweep:
default:
return nextSampleFmSweep();
}
}
int16_t FmRadioScanFx::nextSampleFmSweep() {
if (synth_ == nullptr) {
return 0;
}
@@ -253,8 +315,7 @@ int16_t FmRadioScanFx::nextSample() {
const float t = static_cast<float>(sampleClock_) / static_cast<float>(sampleRateHz_);
const float seekFlutter = 0.83f + (0.17f * sinf(kTwoPi * 0.45f * t));
const bool softDropout =
!stationWindow && (((sampleClock_ / ((sampleRateHz_ / 11U) + 1U)) % 19U) == 7U);
const bool softDropout = !stationWindow && (((sampleClock_ / ((sampleRateHz_ / 11U) + 1U)) % 19U) == 7U);
const float dropoutGain = softDropout ? 0.34f : 1.0f;
float sampleF = 0.0f;
@@ -285,3 +346,189 @@ int16_t FmRadioScanFx::nextSample() {
return static_cast<int16_t>(sampleF * 32000.0f);
}
int16_t FmRadioScanFx::nextSampleSonar() {
const uint32_t periodSamples = (sampleRateHz_ * 1300UL) / 1000UL;
const uint32_t pingSamples = (sampleRateHz_ * 150UL) / 1000UL;
const uint32_t echoStartSamples = (sampleRateHz_ * 220UL) / 1000UL;
const uint32_t echoLenSamples = (sampleRateHz_ * 540UL) / 1000UL;
const uint32_t cycle = (periodSamples > 0U) ? (sampleClock_ % periodSamples) : 0U;
float sampleF = 0.0f;
if (cycle < pingSamples && pingSamples > 0U) {
const float pingT = static_cast<float>(cycle) / static_cast<float>(pingSamples);
const float freqHz = 1800.0f - (1200.0f * pingT);
sonarPhase_ += kTwoPi * (freqHz / static_cast<float>(sampleRateHz_));
if (sonarPhase_ >= kTwoPi) {
sonarPhase_ -= kTwoPi;
}
const float env = (1.0f - pingT) * (1.0f - pingT);
sampleF += 0.90f * sinf(sonarPhase_) * env;
if (cycle < ((sampleRateHz_ * 4UL) / 1000UL)) {
sampleF += 0.22f;
}
}
if (cycle >= echoStartSamples && cycle < (echoStartSamples + echoLenSamples) && echoLenSamples > 0U) {
const uint32_t echoPos = cycle - echoStartSamples;
const float echoT = static_cast<float>(echoPos) / static_cast<float>(echoLenSamples);
const float freqHz = 760.0f - (240.0f * echoT);
sonarEchoPhase_ += kTwoPi * (freqHz / static_cast<float>(sampleRateHz_));
if (sonarEchoPhase_ >= kTwoPi) {
sonarEchoPhase_ -= kTwoPi;
}
const float env = expf(-4.5f * echoT);
sampleF += 0.46f * sinf(sonarEchoPhase_) * env;
}
sampleF += 0.03f * (static_cast<float>(random(-128L, 128L)) / 128.0f);
sampleF *= gain_;
sampleF = clampf(sampleF, -1.0f, 1.0f);
++sampleClock_;
return static_cast<int16_t>(sampleF * 32000.0f);
}
bool FmRadioScanFx::morsePrepareNextState() {
const uint32_t unitSamplesRaw = (sampleRateHz_ * static_cast<uint32_t>(kMorseUnitMs)) / 1000UL;
const uint32_t unitSamples = (unitSamplesRaw > 0U) ? unitSamplesRaw : 1U;
while (true) {
const char symbol = kMorsePattern[morsePatternPos_];
if (symbol == '\0') {
morsePatternPos_ = 0U;
morseGapSamplesLeft_ = unitSamples * 7U;
return false;
}
++morsePatternPos_;
if (symbol == ' ') {
morseGapSamplesLeft_ = unitSamples * 3U;
return false;
}
if (symbol == '.') {
morseToneSamplesLeft_ = unitSamples;
morseGapSamplesLeft_ = unitSamples;
return true;
}
if (symbol == '-') {
morseToneSamplesLeft_ = unitSamples * 3U;
morseGapSamplesLeft_ = unitSamples;
return true;
}
}
}
int16_t FmRadioScanFx::nextSampleMorse() {
if (morseToneSamplesLeft_ == 0U) {
if (morseGapSamplesLeft_ > 0U) {
--morseGapSamplesLeft_;
++sampleClock_;
return 0;
}
if (!morsePrepareNextState()) {
++sampleClock_;
return 0;
}
}
const float warble = 1.0f + (0.05f * sinf(kTwoPi * 0.7f *
(static_cast<float>(sampleClock_) /
static_cast<float>(sampleRateHz_))));
const float freqHz = static_cast<float>(kMorseFreqHz) * warble;
morsePhase_ += kTwoPi * (freqHz / static_cast<float>(sampleRateHz_));
if (morsePhase_ >= kTwoPi) {
morsePhase_ -= kTwoPi;
}
float sampleF = 0.82f * sinf(morsePhase_);
sampleF += 0.10f * sinf(morsePhase_ * 2.0f);
sampleF *= gain_;
sampleF = clampf(sampleF, -1.0f, 1.0f);
--morseToneSamplesLeft_;
++sampleClock_;
return static_cast<int16_t>(sampleF * 32000.0f);
}
bool FmRadioScanFx::winPrepareNextStep() {
if (kWinNoteCount == 0U) {
return false;
}
if (winStepIndex_ >= kWinNoteCount) {
winStepIndex_ = 0U;
}
const uint16_t idx = winStepIndex_;
winCurrentFreqHz_ = kWinNotesHz[idx];
uint32_t stepSamples =
(sampleRateHz_ * static_cast<uint32_t>(kWinNotesMs[idx])) / 1000UL;
if (stepSamples == 0U) {
stepSamples = 1U;
}
winStepSamplesLeft_ = stepSamples;
winStepTotalSamples_ = stepSamples;
++winStepIndex_;
return true;
}
int16_t FmRadioScanFx::nextSampleWin() {
if (winStepSamplesLeft_ == 0U && !winPrepareNextStep()) {
++sampleClock_;
return 0;
}
float sampleF = 0.0f;
if (winCurrentFreqHz_ > 0U) {
winPhase_ += kTwoPi * (static_cast<float>(winCurrentFreqHz_) /
static_cast<float>(sampleRateHz_));
if (winPhase_ >= kTwoPi) {
winPhase_ -= kTwoPi;
}
const float sineWave = sinf(winPhase_);
const float squareWave = (sineWave >= 0.0f) ? 1.0f : -1.0f;
const float progress =
1.0f - (static_cast<float>(winStepSamplesLeft_) /
static_cast<float>(winStepTotalSamples_));
float env = 1.0f - (0.72f * progress);
const uint32_t attackSamplesRaw = (sampleRateHz_ * 4UL) / 1000UL;
const uint32_t releaseSamplesRaw = (sampleRateHz_ * 16UL) / 1000UL;
const uint32_t attackSamples = (attackSamplesRaw > 0U) ? attackSamplesRaw : 1U;
const uint32_t releaseSamples = (releaseSamplesRaw > 0U) ? releaseSamplesRaw : 1U;
if (winStepSamplesLeft_ < releaseSamples) {
const float releaseEnv = static_cast<float>(winStepSamplesLeft_) /
static_cast<float>(releaseSamples);
if (releaseEnv < env) {
env = releaseEnv;
}
}
const uint32_t elapsedSamples = winStepTotalSamples_ - winStepSamplesLeft_;
if (elapsedSamples < attackSamples) {
const float attackEnv = static_cast<float>(elapsedSamples) /
static_cast<float>(attackSamples);
if (attackEnv < env) {
env = attackEnv;
}
}
sampleF = (0.72f * sineWave) + (0.28f * squareWave);
sampleF += 0.18f * sinf(winPhase_ * 1.5f);
sampleF *= env;
}
if (winStepSamplesLeft_ > 0U) {
--winStepSamplesLeft_;
}
sampleF *= gain_;
sampleF = clampf(sampleF, -1.0f, 1.0f);
++sampleClock_;
return static_cast<int16_t>(sampleF * 32000.0f);
}
@@ -2,6 +2,8 @@
#include <Arduino.h>
#include "effects/audio_effect_id.h"
namespace audio_tools {
class I2SStream;
}
@@ -9,23 +11,35 @@ struct FmRadioScanSynth;
class FmRadioScanFx {
public:
using Effect = AudioEffectId;
FmRadioScanFx(uint8_t bclkPin, uint8_t wsPin, uint8_t doutPin, uint8_t i2sPort);
~FmRadioScanFx() = default;
void setGain(float gain);
void setSampleRate(uint32_t sampleRateHz);
void setEffect(Effect effect);
Effect effect() const;
bool start(Effect effect);
bool start();
void stop();
void update(uint32_t nowMs, uint16_t chunkMs);
bool isActive() const;
bool playBlocking(Effect effect, uint32_t durationMs, uint16_t chunkMs = 22U);
bool playBlocking(uint32_t durationMs, uint16_t chunkMs = 22U);
private:
bool writeFrameBuffer(const int16_t* interleavedStereo, size_t frameCount);
void resetSynthesisState();
int16_t nextSample();
int16_t nextSampleFmSweep();
int16_t nextSampleSonar();
int16_t nextSampleMorse();
int16_t nextSampleWin();
bool morsePrepareNextState();
bool winPrepareNextStep();
static constexpr uint16_t kSynthRateHz = 22050U;
@@ -39,6 +53,7 @@ class FmRadioScanFx {
bool active_ = false;
float gain_ = 0.18f;
uint32_t sampleRateHz_ = 22050U;
Effect effect_ = Effect::kFmSweep;
float sweepLfoPhase_ = 0.0f;
float driftLfoPhase_ = 0.0f;
@@ -48,4 +63,15 @@ class FmRadioScanFx {
uint32_t sweepCycle_ = 0;
uint32_t sweepPosInCycle_ = 0;
uint32_t sampleClock_ = 0;
float sonarPhase_ = 0.0f;
float sonarEchoPhase_ = 0.0f;
float morsePhase_ = 0.0f;
float winPhase_ = 0.0f;
uint32_t morseToneSamplesLeft_ = 0;
uint32_t morseGapSamplesLeft_ = 0;
uint16_t morsePatternPos_ = 0;
uint32_t winStepSamplesLeft_ = 0;
uint32_t winStepTotalSamples_ = 0;
uint16_t winStepIndex_ = 0;
uint16_t winCurrentFreqHz_ = 0;
};
@@ -0,0 +1,397 @@
#include "mp3_fx_overlay_output.h"
#include <cmath>
namespace {
constexpr float kTwoPi = 6.28318530718f;
constexpr uint16_t kMorseUnitMs = 90U;
constexpr uint16_t kMorseFreqHz = 680U;
constexpr uint16_t kWinNotesHz[] = {
523U, 659U, 784U, 1047U, 1319U, 1047U, 1568U, 1319U, 0U};
constexpr uint16_t kWinNotesMs[] = {
120U, 120U, 120U, 150U, 180U, 120U, 210U, 260U, 180U};
constexpr uint16_t kWinNoteCount = sizeof(kWinNotesHz) / sizeof(kWinNotesHz[0]);
} // namespace
constexpr char Mp3FxOverlayOutput::kMorsePattern_[];
Mp3FxOverlayOutput::Mp3FxOverlayOutput(int port, int output_mode, int dma_buf_count, int use_apll)
: AudioOutputI2S(port, output_mode, dma_buf_count, use_apll) {}
bool Mp3FxOverlayOutput::SetRate(int hz) {
if (hz > 0) {
sampleRateHz_ = static_cast<uint32_t>(hz);
}
return AudioOutputI2S::SetRate(hz);
}
bool Mp3FxOverlayOutput::ConsumeSample(int16_t sample[2]) {
if (!fxActive_) {
return AudioOutputI2S::ConsumeSample(sample);
}
int32_t left = sample[0];
int32_t right = sample[1];
if (mode_ == Mp3FxMode::kDucking) {
left = static_cast<int32_t>(static_cast<float>(left) * duckingGain_);
right = static_cast<int32_t>(static_cast<float>(right) * duckingGain_);
}
const int16_t fxSample = nextFxSample();
if (fxActive_ || fxSample != 0) {
const int32_t fxMixed = static_cast<int32_t>(static_cast<float>(fxSample) * overlayGain_);
left += fxMixed;
right += fxMixed;
}
int16_t mixed[2] = {clamp16(left), clamp16(right)};
return AudioOutputI2S::ConsumeSample(mixed);
}
void Mp3FxOverlayOutput::setFxMode(Mp3FxMode mode) {
mode_ = mode;
}
Mp3FxMode Mp3FxOverlayOutput::fxMode() const {
return mode_;
}
void Mp3FxOverlayOutput::setDuckingGain(float gain) {
duckingGain_ = clampf(gain, 0.0f, 1.0f);
}
float Mp3FxOverlayOutput::duckingGain() const {
return duckingGain_;
}
void Mp3FxOverlayOutput::setOverlayGain(float gain) {
overlayGain_ = clampf(gain, 0.0f, 1.0f);
}
float Mp3FxOverlayOutput::overlayGain() const {
return overlayGain_;
}
bool Mp3FxOverlayOutput::triggerFx(Mp3FxEffect effect, uint32_t durationMs) {
if (durationMs == 0U || sampleRateHz_ == 0U) {
return false;
}
fxEffect_ = effect;
fxRemainingSamples_ = (sampleRateHz_ * durationMs) / 1000UL;
if (fxRemainingSamples_ == 0U) {
fxRemainingSamples_ = 1U;
}
fxSampleClock_ = 0U;
fmPhaseA_ = 0.0f;
fmPhaseB_ = 0.0f;
fmNoiseLp_ = 0.0f;
sonarPhase_ = 0.0f;
sonarEchoPhase_ = 0.0f;
morsePhase_ = 0.0f;
morseToneSamplesLeft_ = 0U;
morseGapSamplesLeft_ = 0U;
morsePatternPos_ = 0U;
winPhase_ = 0.0f;
winStepSamplesLeft_ = 0U;
winStepTotalSamples_ = 0U;
winStepIndex_ = 0U;
winCurrentFreqHz_ = 0U;
if (effect == Mp3FxEffect::kMorse) {
prepareMorseState();
} else if (effect == Mp3FxEffect::kWin) {
prepareWinState();
}
fxActive_ = true;
return true;
}
void Mp3FxOverlayOutput::stopFx() {
fxActive_ = false;
fxRemainingSamples_ = 0U;
}
bool Mp3FxOverlayOutput::isFxActive() const {
return fxActive_;
}
Mp3FxEffect Mp3FxOverlayOutput::activeFx() const {
return fxEffect_;
}
uint32_t Mp3FxOverlayOutput::fxRemainingMs() const {
if (!fxActive_ || sampleRateHz_ == 0U) {
return 0U;
}
return (fxRemainingSamples_ * 1000UL) / sampleRateHz_;
}
int16_t Mp3FxOverlayOutput::nextFxSample() {
if (!fxActive_ || fxRemainingSamples_ == 0U || sampleRateHz_ == 0U) {
fxActive_ = false;
return 0;
}
int16_t sample = 0;
switch (fxEffect_) {
case Mp3FxEffect::kSonar:
sample = nextSonarSample();
break;
case Mp3FxEffect::kMorse:
sample = nextMorseSample();
break;
case Mp3FxEffect::kWin:
sample = nextWinSample();
break;
case Mp3FxEffect::kFmSweep:
default:
sample = nextFmSample();
break;
}
++fxSampleClock_;
--fxRemainingSamples_;
if (fxRemainingSamples_ == 0U) {
fxActive_ = false;
}
return sample;
}
int16_t Mp3FxOverlayOutput::nextFmSample() {
const uint32_t sweepPeriodSamples = (sampleRateHz_ * 2600UL) / 1000UL;
float sweepT = 0.0f;
if (sweepPeriodSamples > 0U) {
sweepT = static_cast<float>(fxSampleClock_ % sweepPeriodSamples) /
static_cast<float>(sweepPeriodSamples);
if (((fxSampleClock_ / sweepPeriodSamples) & 1U) != 0U) {
sweepT = 1.0f - sweepT;
}
}
const bool stationWindow = (sweepT > 0.20f && sweepT < 0.34f) || (sweepT > 0.58f && sweepT < 0.74f);
const float sweepHz = stationWindow ? (240.0f + (130.0f * sinf(kTwoPi * sweepT * 2.0f)))
: (95.0f + (1300.0f * sweepT));
const float carrierHz = stationWindow ? (560.0f + (120.0f * sinf(fmPhaseB_)))
: (760.0f + (280.0f * sinf(fmPhaseB_)));
fmPhaseA_ += kTwoPi * (sweepHz / static_cast<float>(sampleRateHz_));
if (fmPhaseA_ >= kTwoPi) {
fmPhaseA_ -= kTwoPi;
}
fmPhaseB_ += kTwoPi * (carrierHz / static_cast<float>(sampleRateHz_));
if (fmPhaseB_ >= kTwoPi) {
fmPhaseB_ -= kTwoPi;
}
const float rawNoise = static_cast<float>(random(-128L, 128L)) / 128.0f;
fmNoiseLp_ = (0.985f * fmNoiseLp_) + (0.015f * rawNoise);
const float hiss = rawNoise - fmNoiseLp_;
float sampleF = 0.0f;
sampleF += stationWindow ? 0.28f * sinf(fmPhaseA_) : 0.45f * sinf(fmPhaseA_);
sampleF += stationWindow ? 0.20f * sinf(fmPhaseB_) : 0.15f * sinf(fmPhaseB_);
sampleF += stationWindow ? 0.16f * hiss : 0.32f * hiss;
sampleF = clampf(sampleF, -1.0f, 1.0f);
return static_cast<int16_t>(sampleF * 28000.0f);
}
int16_t Mp3FxOverlayOutput::nextSonarSample() {
const uint32_t periodSamples = (sampleRateHz_ * 1200UL) / 1000UL;
const uint32_t pingSamples = (sampleRateHz_ * 130UL) / 1000UL;
const uint32_t echoStartSamples = (sampleRateHz_ * 200UL) / 1000UL;
const uint32_t echoLenSamples = (sampleRateHz_ * 420UL) / 1000UL;
const uint32_t cycle = (periodSamples > 0U) ? (fxSampleClock_ % periodSamples) : 0U;
float sampleF = 0.0f;
if (cycle < pingSamples && pingSamples > 0U) {
const float pingT = static_cast<float>(cycle) / static_cast<float>(pingSamples);
const float freqHz = 1800.0f - (1300.0f * pingT);
sonarPhase_ += kTwoPi * (freqHz / static_cast<float>(sampleRateHz_));
if (sonarPhase_ >= kTwoPi) {
sonarPhase_ -= kTwoPi;
}
const float env = (1.0f - pingT) * (1.0f - pingT);
sampleF += 0.92f * sinf(sonarPhase_) * env;
}
if (cycle >= echoStartSamples && cycle < (echoStartSamples + echoLenSamples) && echoLenSamples > 0U) {
const uint32_t echoPos = cycle - echoStartSamples;
const float echoT = static_cast<float>(echoPos) / static_cast<float>(echoLenSamples);
const float freqHz = 680.0f - (220.0f * echoT);
sonarEchoPhase_ += kTwoPi * (freqHz / static_cast<float>(sampleRateHz_));
if (sonarEchoPhase_ >= kTwoPi) {
sonarEchoPhase_ -= kTwoPi;
}
const float env = expf(-4.0f * echoT);
sampleF += 0.46f * sinf(sonarEchoPhase_) * env;
}
sampleF = clampf(sampleF, -1.0f, 1.0f);
return static_cast<int16_t>(sampleF * 30000.0f);
}
bool Mp3FxOverlayOutput::prepareMorseState() {
const uint32_t unitSamplesRaw = (sampleRateHz_ * static_cast<uint32_t>(kMorseUnitMs)) / 1000UL;
const uint32_t unitSamples = (unitSamplesRaw > 0U) ? unitSamplesRaw : 1U;
while (true) {
const char symbol = kMorsePattern_[morsePatternPos_];
if (symbol == '\0') {
morsePatternPos_ = 0U;
morseGapSamplesLeft_ = unitSamples * 7U;
return false;
}
++morsePatternPos_;
if (symbol == ' ') {
morseGapSamplesLeft_ = unitSamples * 3U;
return false;
}
if (symbol == '.') {
morseToneSamplesLeft_ = unitSamples;
morseGapSamplesLeft_ = unitSamples;
return true;
}
if (symbol == '-') {
morseToneSamplesLeft_ = unitSamples * 3U;
morseGapSamplesLeft_ = unitSamples;
return true;
}
}
}
int16_t Mp3FxOverlayOutput::nextMorseSample() {
if (morseToneSamplesLeft_ == 0U) {
if (morseGapSamplesLeft_ > 0U) {
--morseGapSamplesLeft_;
return 0;
}
if (!prepareMorseState()) {
return 0;
}
}
const float warble = 1.0f +
(0.05f * sinf(kTwoPi * 0.8f *
(static_cast<float>(fxSampleClock_) /
static_cast<float>(sampleRateHz_))));
const float freqHz = static_cast<float>(kMorseFreqHz) * warble;
morsePhase_ += kTwoPi * (freqHz / static_cast<float>(sampleRateHz_));
if (morsePhase_ >= kTwoPi) {
morsePhase_ -= kTwoPi;
}
float sampleF = 0.80f * sinf(morsePhase_);
sampleF += 0.10f * sinf(morsePhase_ * 2.0f);
sampleF = clampf(sampleF, -1.0f, 1.0f);
--morseToneSamplesLeft_;
return static_cast<int16_t>(sampleF * 30000.0f);
}
bool Mp3FxOverlayOutput::prepareWinState() {
if (kWinNoteCount == 0U) {
return false;
}
if (winStepIndex_ >= kWinNoteCount) {
winStepIndex_ = 0U;
}
const uint16_t idx = winStepIndex_;
winCurrentFreqHz_ = kWinNotesHz[idx];
uint32_t stepSamples =
(sampleRateHz_ * static_cast<uint32_t>(kWinNotesMs[idx])) / 1000UL;
if (stepSamples == 0U) {
stepSamples = 1U;
}
winStepSamplesLeft_ = stepSamples;
winStepTotalSamples_ = stepSamples;
++winStepIndex_;
return true;
}
int16_t Mp3FxOverlayOutput::nextWinSample() {
if (winStepSamplesLeft_ == 0U && !prepareWinState()) {
return 0;
}
float sampleF = 0.0f;
if (winCurrentFreqHz_ > 0U) {
winPhase_ += kTwoPi * (static_cast<float>(winCurrentFreqHz_) /
static_cast<float>(sampleRateHz_));
if (winPhase_ >= kTwoPi) {
winPhase_ -= kTwoPi;
}
const float sineWave = sinf(winPhase_);
const float squareWave = (sineWave >= 0.0f) ? 1.0f : -1.0f;
const float progress =
1.0f - (static_cast<float>(winStepSamplesLeft_) /
static_cast<float>(winStepTotalSamples_));
float env = 1.0f - (0.70f * progress);
const uint32_t attackSamplesRaw = (sampleRateHz_ * 5UL) / 1000UL;
const uint32_t releaseSamplesRaw = (sampleRateHz_ * 18UL) / 1000UL;
const uint32_t attackSamples = (attackSamplesRaw > 0U) ? attackSamplesRaw : 1U;
const uint32_t releaseSamples = (releaseSamplesRaw > 0U) ? releaseSamplesRaw : 1U;
if (winStepSamplesLeft_ < releaseSamples) {
const float releaseEnv = static_cast<float>(winStepSamplesLeft_) /
static_cast<float>(releaseSamples);
if (releaseEnv < env) {
env = releaseEnv;
}
}
const uint32_t elapsedSamples = winStepTotalSamples_ - winStepSamplesLeft_;
if (elapsedSamples < attackSamples) {
const float attackEnv = static_cast<float>(elapsedSamples) /
static_cast<float>(attackSamples);
if (attackEnv < env) {
env = attackEnv;
}
}
sampleF = (0.70f * sineWave) + (0.30f * squareWave);
sampleF += 0.18f * sinf(winPhase_ * 1.5f);
sampleF *= env;
}
if (winStepSamplesLeft_ > 0U) {
--winStepSamplesLeft_;
}
sampleF = clampf(sampleF, -1.0f, 1.0f);
return static_cast<int16_t>(sampleF * 30000.0f);
}
float Mp3FxOverlayOutput::clampf(float value, float minValue, float maxValue) {
if (value < minValue) {
return minValue;
}
if (value > maxValue) {
return maxValue;
}
return value;
}
int16_t Mp3FxOverlayOutput::clamp16(int32_t value) {
if (value < -32767) {
return -32767;
}
if (value > 32767) {
return 32767;
}
return static_cast<int16_t>(value);
}
@@ -0,0 +1,78 @@
#pragma once
#include <Arduino.h>
#include <AudioOutputI2S.h>
#include "effects/audio_effect_id.h"
using Mp3FxEffect = AudioEffectId;
enum class Mp3FxMode : uint8_t {
kDucking = 0,
kOverlay,
};
class Mp3FxOverlayOutput : public AudioOutputI2S {
public:
Mp3FxOverlayOutput(int port = 0, int output_mode = EXTERNAL_I2S, int dma_buf_count = 8, int use_apll = APLL_DISABLE);
bool SetRate(int hz) override;
bool ConsumeSample(int16_t sample[2]) override;
void setFxMode(Mp3FxMode mode);
Mp3FxMode fxMode() const;
void setDuckingGain(float gain);
float duckingGain() const;
void setOverlayGain(float gain);
float overlayGain() const;
bool triggerFx(Mp3FxEffect effect, uint32_t durationMs);
void stopFx();
bool isFxActive() const;
Mp3FxEffect activeFx() const;
uint32_t fxRemainingMs() const;
private:
int16_t nextFxSample();
int16_t nextFmSample();
int16_t nextSonarSample();
int16_t nextMorseSample();
int16_t nextWinSample();
bool prepareMorseState();
bool prepareWinState();
static float clampf(float value, float minValue, float maxValue);
static int16_t clamp16(int32_t value);
static constexpr char kMorsePattern_[] = ".-- .. -."; // WIN
uint32_t sampleRateHz_ = 44100U;
Mp3FxMode mode_ = Mp3FxMode::kDucking;
float duckingGain_ = 0.45f;
float overlayGain_ = 0.42f;
bool fxActive_ = false;
Mp3FxEffect fxEffect_ = Mp3FxEffect::kFmSweep;
uint32_t fxRemainingSamples_ = 0;
uint32_t fxSampleClock_ = 0;
float fmPhaseA_ = 0.0f;
float fmPhaseB_ = 0.0f;
float fmNoiseLp_ = 0.0f;
float sonarPhase_ = 0.0f;
float sonarEchoPhase_ = 0.0f;
float morsePhase_ = 0.0f;
uint32_t morseToneSamplesLeft_ = 0;
uint32_t morseGapSamplesLeft_ = 0;
uint16_t morsePatternPos_ = 0;
float winPhase_ = 0.0f;
uint32_t winStepSamplesLeft_ = 0;
uint32_t winStepTotalSamples_ = 0;
uint16_t winStepIndex_ = 0;
uint16_t winCurrentFreqHz_ = 0;
};
@@ -0,0 +1,951 @@
#include "mp3_player.h"
#include <new>
#include <strings.h>
#include <AudioFileSourceFS.h>
#include <AudioGenerator.h>
#include <AudioGeneratorAAC.h>
#include <AudioGeneratorFLAC.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorOpus.h>
#include <AudioGeneratorWAV.h>
#include <FS.h>
#include <SD_MMC.h>
#include "../config.h"
#include "effects/audio_effect_id.h"
namespace {
constexpr const char* kIndexPath = "/.uson_index_v1.csv";
constexpr const char* kStatePath = "/.uson_player_state_v1.json";
constexpr uint8_t kMetadataTimeoutMs = 40U;
void copyCStr(char* out, size_t outLen, const char* value) {
if (out == nullptr || outLen == 0U) {
return;
}
out[0] = '\0';
if (value == nullptr || value[0] == '\0') {
return;
}
snprintf(out, outLen, "%s", value);
}
bool isJsonSafeChar(char c) {
return c != '\\' && c != '"' && c != '\n' && c != '\r';
}
String jsonEscape(const String& in) {
String out;
out.reserve(in.length() + 8U);
for (size_t i = 0; i < in.length(); ++i) {
const char c = in[i];
if (isJsonSafeChar(c)) {
out += c;
continue;
}
if (c == '"' || c == '\\') {
out += '\\';
out += c;
continue;
}
if (c == '\n' || c == '\r') {
out += ' ';
continue;
}
}
return out;
}
} // namespace
Mp3Player::Mp3Player(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
const char* mp3Path,
int8_t paEnablePin)
: i2sBclk_(i2sBclk),
i2sLrc_(i2sLrc),
i2sDout_(i2sDout),
paEnablePin_(paEnablePin),
mp3Path_(mp3Path),
audioTools_(i2sBclk, i2sLrc, i2sDout, config::kI2sOutputPort) {}
Mp3Player::~Mp3Player() {
stop();
}
void Mp3Player::begin() {
if (paEnablePin_ >= 0) {
pinMode(paEnablePin_, OUTPUT);
digitalWrite(paEnablePin_, HIGH);
}
}
void Mp3Player::update(uint32_t nowMs, bool allowPlayback) {
refreshStorage(nowMs);
updateDeferredStateSave(nowMs);
if (!sdReady_ || trackCount_ == 0U) {
stop();
return;
}
if (!allowPlayback) {
stop();
return;
}
if (paused_) {
return;
}
if (activeBackend_ == PlayerBackendId::kAudioTools) {
audioTools_.update();
if (audioTools_.isActive()) {
return;
}
if (repeatMode_ == RepeatMode::kAll && trackCount_ > 0U) {
currentTrack_ = static_cast<uint16_t>((currentTrack_ + 1U) % trackCount_);
}
startCurrentTrack();
return;
}
if (activeBackend_ == PlayerBackendId::kLegacy && decoder_ != nullptr) {
if (decoder_->isRunning()) {
if (decoder_->loop()) {
return;
}
const String failedTrack = currentTrackName();
Serial.printf("[MP3] Decoder loop stop [%s]: %s\n",
codecLabel(activeCodec_),
failedTrack.isEmpty() ? "-" : failedTrack.c_str());
}
stopLegacyTrack();
if (repeatMode_ == RepeatMode::kAll && trackCount_ > 0U) {
currentTrack_ = static_cast<uint16_t>((currentTrack_ + 1U) % trackCount_);
}
startCurrentTrack();
return;
}
if (nowMs < nextRetryMs_) {
return;
}
startCurrentTrack();
}
void Mp3Player::togglePause() {
if (!sdReady_ || trackCount_ == 0U) {
return;
}
paused_ = !paused_;
markStateDirty();
}
void Mp3Player::restartTrack() {
if (!sdReady_ || trackCount_ == 0U) {
return;
}
paused_ = false;
stop();
startCurrentTrack();
}
void Mp3Player::nextTrack() {
if (!sdReady_ || trackCount_ == 0U) {
return;
}
paused_ = false;
stop();
currentTrack_ = static_cast<uint16_t>((currentTrack_ + 1U) % trackCount_);
markStateDirty();
startCurrentTrack();
}
void Mp3Player::previousTrack() {
if (!sdReady_ || trackCount_ == 0U) {
return;
}
paused_ = false;
stop();
if (currentTrack_ == 0U) {
currentTrack_ = static_cast<uint16_t>(trackCount_ - 1U);
} else {
--currentTrack_;
}
markStateDirty();
startCurrentTrack();
}
void Mp3Player::cycleRepeatMode() {
repeatMode_ = (repeatMode_ == RepeatMode::kAll) ? RepeatMode::kOne : RepeatMode::kAll;
markStateDirty();
}
void Mp3Player::requestStorageRefresh() {
forceRescan_ = true;
nextMountAttemptMs_ = 0;
nextRescanMs_ = 0;
}
void Mp3Player::setGain(float gain) {
if (gain < 0.0f) {
gain = 0.0f;
} else if (gain > 1.0f) {
gain = 1.0f;
}
gain_ = gain;
if (i2sOut_ != nullptr) {
i2sOut_->SetGain(gain_);
}
audioTools_.setGain(gain_);
markStateDirty();
}
float Mp3Player::gain() const {
return gain_;
}
uint8_t Mp3Player::volumePercent() const {
return static_cast<uint8_t>(gain_ * 100.0f);
}
void Mp3Player::setFxMode(Mp3FxMode mode) {
fxMode_ = mode;
if (i2sOut_ != nullptr) {
i2sOut_->setFxMode(mode);
}
markStateDirty();
}
Mp3FxMode Mp3Player::fxMode() const {
return fxMode_;
}
const char* Mp3Player::fxModeLabel() const {
return (fxMode_ == Mp3FxMode::kDucking) ? "DUCKING" : "OVERLAY";
}
void Mp3Player::setFxDuckingGain(float gain) {
if (gain < 0.0f) {
gain = 0.0f;
} else if (gain > 1.0f) {
gain = 1.0f;
}
fxDuckingGain_ = gain;
if (i2sOut_ != nullptr) {
i2sOut_->setDuckingGain(gain);
}
}
float Mp3Player::fxDuckingGain() const {
return fxDuckingGain_;
}
void Mp3Player::setFxOverlayGain(float gain) {
if (gain < 0.0f) {
gain = 0.0f;
} else if (gain > 1.0f) {
gain = 1.0f;
}
fxOverlayGain_ = gain;
if (i2sOut_ != nullptr) {
i2sOut_->setOverlayGain(gain);
}
}
float Mp3Player::fxOverlayGain() const {
return fxOverlayGain_;
}
bool Mp3Player::triggerFx(Mp3FxEffect effect, uint32_t durationMs) {
fxLastEffect_ = effect;
if (activeBackend_ != PlayerBackendId::kLegacy) {
return false;
}
if (i2sOut_ == nullptr || decoder_ == nullptr || !decoder_->isRunning() || paused_) {
return false;
}
return i2sOut_->triggerFx(effect, durationMs);
}
void Mp3Player::stopFx() {
if (i2sOut_ != nullptr) {
i2sOut_->stopFx();
}
}
bool Mp3Player::isFxActive() const {
return i2sOut_ != nullptr && i2sOut_->isFxActive();
}
uint32_t Mp3Player::fxRemainingMs() const {
if (i2sOut_ == nullptr) {
return 0U;
}
return i2sOut_->fxRemainingMs();
}
const char* Mp3Player::fxEffectLabel() const {
const Mp3FxEffect effect =
(i2sOut_ != nullptr && i2sOut_->isFxActive()) ? i2sOut_->activeFx() : fxLastEffect_;
return audioEffectLabel(effect);
}
bool Mp3Player::isPaused() const {
return paused_;
}
bool Mp3Player::isSdReady() const {
return sdReady_;
}
bool Mp3Player::hasTracks() const {
return trackCount_ > 0U;
}
bool Mp3Player::isPlaying() const {
if (paused_) {
return false;
}
if (activeBackend_ == PlayerBackendId::kAudioTools) {
return audioTools_.isActive();
}
return decoder_ != nullptr && decoder_->isRunning();
}
uint16_t Mp3Player::trackCount() const {
return trackCount_;
}
uint16_t Mp3Player::currentTrackNumber() const {
if (trackCount_ == 0U) {
return 0U;
}
return static_cast<uint16_t>(currentTrack_ + 1U);
}
String Mp3Player::currentTrackName() const {
if (trackCount_ == 0U) {
return String();
}
const TrackEntry* entry = catalog_.entry(currentTrack_);
if (entry == nullptr) {
return String();
}
return String(entry->path);
}
RepeatMode Mp3Player::repeatMode() const {
return repeatMode_;
}
const char* Mp3Player::repeatModeLabel() const {
return (repeatMode_ == RepeatMode::kAll) ? "ALL" : "ONE";
}
void Mp3Player::setBackendMode(PlayerBackendMode mode) {
if (backendMode_ == mode) {
return;
}
backendMode_ = mode;
markStateDirty();
if (isPlaying()) {
restartTrack();
}
}
PlayerBackendMode Mp3Player::backendMode() const {
return backendMode_;
}
PlayerBackendId Mp3Player::activeBackend() const {
return activeBackend_;
}
const char* Mp3Player::backendModeLabel() const {
return playerBackendModeLabel(backendMode_);
}
const char* Mp3Player::activeBackendLabel() const {
return playerBackendIdLabel(activeBackend_);
}
const char* Mp3Player::lastBackendError() const {
return backendError_;
}
bool Mp3Player::selectTrackByIndex(uint16_t index, bool restart) {
if (index >= trackCount_) {
return false;
}
currentTrack_ = index;
markStateDirty();
if (restart) {
restartTrack();
}
return true;
}
bool Mp3Player::selectTrackByPath(const char* path, bool restart) {
const int16_t idx = catalog_.indexOfPath(path);
if (idx < 0) {
return false;
}
return selectTrackByIndex(static_cast<uint16_t>(idx), restart);
}
bool Mp3Player::playPath(const char* path) {
return selectTrackByPath(path, true);
}
CatalogStats Mp3Player::catalogStats() const {
return catalogStats_;
}
bool Mp3Player::isScanBusy() const {
return scanBusy_;
}
const TrackEntry* Mp3Player::trackEntryByNumber(uint16_t oneBasedNumber) const {
if (oneBasedNumber == 0U) {
return nullptr;
}
return catalog_.entry(static_cast<uint16_t>(oneBasedNumber - 1U));
}
uint16_t Mp3Player::listTracks(const char* prefix,
uint16_t offset,
uint16_t limit,
Print& out) const {
return catalog_.listByPrefix(prefix, offset, limit, out);
}
uint16_t Mp3Player::countTracks(const char* prefix) const {
return catalog_.countByPrefix(prefix);
}
bool Mp3Player::savePlayerState() {
if (!sdReady_) {
return false;
}
const String currentPath = currentTrackName();
const String escapedPath = jsonEscape(currentPath);
if (SD_MMC.exists(kStatePath)) {
SD_MMC.remove(kStatePath);
}
fs::File file = SD_MMC.open(kStatePath, FILE_WRITE);
if (!file || file.isDirectory()) {
return false;
}
file.printf("{\"last_path\":\"%s\",\"volume\":%.3f,\"repeat\":\"%s\",\"backend_mode\":\"%s\",\"last_position_ms\":%lu}\n",
escapedPath.c_str(),
static_cast<double>(gain_),
repeatModeToToken(repeatMode_),
backendModeLabel(),
static_cast<unsigned long>(lastPositionMs_));
file.close();
stateDirty_ = false;
return true;
}
bool Mp3Player::loadPlayerState() {
selectedPathFromState_.clear();
fs::File file = SD_MMC.open(kStatePath, FILE_READ);
if (!file || file.isDirectory()) {
return false;
}
const String json = file.readString();
file.close();
String tmp;
if (parseJsonString(json, "last_path", &tmp)) {
selectedPathFromState_ = tmp;
}
float savedVolume = gain_;
if (parseJsonFloat(json, "volume", &savedVolume)) {
setGain(savedVolume);
}
if (parseJsonString(json, "repeat", &tmp)) {
repeatMode_ = repeatModeFromToken(tmp.c_str());
}
if (parseJsonString(json, "backend_mode", &tmp)) {
if (tmp == "AUDIO_TOOLS_ONLY") {
backendMode_ = PlayerBackendMode::kAudioToolsOnly;
} else if (tmp == "LEGACY_ONLY") {
backendMode_ = PlayerBackendMode::kLegacyOnly;
} else {
backendMode_ = PlayerBackendMode::kAutoFallback;
}
}
float savedPos = 0.0f;
if (parseJsonFloat(json, "last_position_ms", &savedPos) && savedPos >= 0.0f) {
lastPositionMs_ = static_cast<uint32_t>(savedPos);
}
return true;
}
bool Mp3Player::resetPlayerState() {
selectedPathFromState_.clear();
lastPositionMs_ = 0U;
stateDirty_ = false;
if (!sdReady_) {
return false;
}
if (SD_MMC.exists(kStatePath)) {
return SD_MMC.remove(kStatePath);
}
return true;
}
bool Mp3Player::mountStorage(uint32_t nowMs) {
if (!SD_MMC.begin("/sdcard", true)) {
nextMountAttemptMs_ = nowMs + 2000U;
return false;
}
sdReady_ = true;
nextCardCheckMs_ = nowMs + 1000U;
nextRescanMs_ = nowMs;
loadPlayerState();
Serial.println("[MP3] SD_MMC mounted.");
scanTracks();
return true;
}
void Mp3Player::unmountStorage(uint32_t nowMs) {
stop();
SD_MMC.end();
sdReady_ = false;
paused_ = false;
trackCount_ = 0U;
currentTrack_ = 0U;
nextMountAttemptMs_ = nowMs + 1500U;
nextCardCheckMs_ = 0U;
nextRescanMs_ = 0U;
nextRetryMs_ = 0U;
catalog_.clear();
Serial.println("[MP3] SD removed/unmounted.");
}
void Mp3Player::refreshStorage(uint32_t nowMs) {
if (!sdReady_) {
if (nowMs >= nextMountAttemptMs_) {
mountStorage(nowMs);
}
return;
}
if (nowMs >= nextCardCheckMs_) {
nextCardCheckMs_ = nowMs + 1000U;
if (SD_MMC.cardType() == CARD_NONE) {
unmountStorage(nowMs);
return;
}
}
if (trackCount_ == 0U && nowMs >= nextRescanMs_) {
scanTracks();
forceRescan_ = false;
nextRescanMs_ = nowMs + 3000U;
return;
}
if (forceRescan_) {
scanTracks();
forceRescan_ = false;
nextRescanMs_ = nowMs + 3000U;
return;
}
if (currentTrack_ >= trackCount_) {
currentTrack_ = 0U;
}
}
void Mp3Player::scanTracks() {
scanBusy_ = true;
catalog_.clear();
catalogStats_ = CatalogStats();
bool loadedFromIndex = false;
if (!forceRescan_) {
loadedFromIndex = catalog_.loadIndex(SD_MMC, kIndexPath, &catalogStats_);
}
if (!loadedFromIndex) {
catalog_.scan(SD_MMC,
"/",
TrackCatalog::kDefaultMaxDepth,
kMetadataTimeoutMs,
&catalogStats_);
catalog_.saveIndex(SD_MMC, kIndexPath);
}
if (catalog_.size() == 0U && isSupportedAudioFile(String(mp3Path_)) && SD_MMC.exists(mp3Path_)) {
uint32_t fallbackSize = 0U;
fs::File f = SD_MMC.open(mp3Path_, FILE_READ);
if (f) {
fallbackSize = static_cast<uint32_t>(f.size());
f.close();
}
catalog_.appendFallbackPath(mp3Path_, fallbackSize);
}
trackCount_ = catalog_.size();
if (trackCount_ == 0U) {
Serial.println("[MP3] No supported audio file found on SD.");
scanBusy_ = false;
return;
}
if (currentTrack_ >= trackCount_) {
currentTrack_ = 0U;
}
restoreTrackFromStatePath();
scanBusy_ = false;
Serial.printf("[MP3] %u track(s) loaded. index=%s\n",
static_cast<unsigned int>(trackCount_),
loadedFromIndex ? "HIT" : "REBUILD");
}
void Mp3Player::updateDeferredStateSave(uint32_t nowMs) {
if (!stateDirty_ || !sdReady_) {
return;
}
if (static_cast<int32_t>(nowMs - nextStateSaveMs_) < 0) {
return;
}
savePlayerState();
}
bool Mp3Player::startLegacyTrack() {
if (!sdReady_ || trackCount_ == 0U || currentTrack_ >= trackCount_) {
return false;
}
const TrackEntry* entry = catalog_.entry(currentTrack_);
if (entry == nullptr || entry->path[0] == '\0') {
return false;
}
const String trackPath = String(entry->path);
const AudioCodec trackCodec = codecForPath(trackPath);
if (trackCodec == AudioCodec::kUnknown) {
Serial.printf("[MP3] Unsupported file type: %s\n", trackPath.c_str());
nextRetryMs_ = millis() + 250U;
return false;
}
if (!SD_MMC.exists(trackPath.c_str())) {
Serial.printf("[MP3] Missing track: %s\n", trackPath.c_str());
scanTracks();
nextRetryMs_ = millis() + 1000U;
return false;
}
mp3File_ = new (std::nothrow) AudioFileSourceFS(SD_MMC, trackPath.c_str());
i2sOut_ = new (std::nothrow) Mp3FxOverlayOutput();
decoder_ = createDecoder(trackCodec);
activeCodec_ = trackCodec;
if (mp3File_ == nullptr || i2sOut_ == nullptr || decoder_ == nullptr) {
Serial.println("[MP3] Memory allocation failed.");
stopLegacyTrack();
nextRetryMs_ = millis() + 1000U;
return false;
}
i2sOut_->SetPinout(i2sBclk_, i2sLrc_, i2sDout_);
i2sOut_->SetGain(gain_);
i2sOut_->setFxMode(fxMode_);
i2sOut_->setDuckingGain(fxDuckingGain_);
i2sOut_->setOverlayGain(fxOverlayGain_);
if (!decoder_->begin(mp3File_, i2sOut_)) {
Serial.printf("[MP3] Unable to start %s playback.\n", codecLabel(trackCodec));
stopLegacyTrack();
nextRetryMs_ = millis() + 1000U;
return false;
}
activeBackend_ = PlayerBackendId::kLegacy;
copyCStr(backendError_, sizeof(backendError_), "OK");
Serial.printf("[MP3] Playing %u/%u [%s|LEGACY]: %s\n",
static_cast<unsigned int>(currentTrack_ + 1U),
static_cast<unsigned int>(trackCount_),
codecLabel(trackCodec),
trackPath.c_str());
return true;
}
bool Mp3Player::startAudioToolsTrack() {
if (!sdReady_ || trackCount_ == 0U || currentTrack_ >= trackCount_) {
return false;
}
const TrackEntry* entry = catalog_.entry(currentTrack_);
if (entry == nullptr || entry->path[0] == '\0') {
return false;
}
if (!audioTools_.canHandlePath(entry->path)) {
copyCStr(backendError_, sizeof(backendError_), "UNSUPPORTED");
return false;
}
if (!audioTools_.start(entry->path, gain_)) {
copyCStr(backendError_, sizeof(backendError_), audioTools_.lastError());
return false;
}
activeBackend_ = PlayerBackendId::kAudioTools;
activeCodec_ = codecForPath(String(entry->path));
copyCStr(backendError_, sizeof(backendError_), "OK");
Serial.printf("[MP3] Playing %u/%u [%s|AUDIO_TOOLS]: %s\n",
static_cast<unsigned int>(currentTrack_ + 1U),
static_cast<unsigned int>(trackCount_),
codecLabel(activeCodec_),
entry->path);
return true;
}
void Mp3Player::startCurrentTrack() {
stop();
if (!sdReady_ || trackCount_ == 0U || currentTrack_ >= trackCount_) {
return;
}
fallbackUsed_ = false;
bool started = false;
if (backendMode_ != PlayerBackendMode::kLegacyOnly) {
started = startAudioToolsTrack();
if (!started && backendMode_ == PlayerBackendMode::kAutoFallback) {
fallbackUsed_ = true;
started = startLegacyTrack();
}
} else {
started = startLegacyTrack();
}
if (!started && backendMode_ == PlayerBackendMode::kAudioToolsOnly) {
nextRetryMs_ = millis() + 1000U;
Serial.printf("[MP3] AudioTools start failed (mode=%s err=%s).\n",
backendModeLabel(),
backendError_);
return;
}
if (!started) {
nextRetryMs_ = millis() + 1000U;
return;
}
if (fallbackUsed_) {
Serial.printf("[MP3] Backend fallback AUDIO_TOOLS->LEGACY active.\n");
}
}
void Mp3Player::stopLegacyTrack() {
if (decoder_ != nullptr) {
if (decoder_->isRunning()) {
decoder_->stop();
}
delete decoder_;
decoder_ = nullptr;
}
if (mp3File_ != nullptr) {
delete mp3File_;
mp3File_ = nullptr;
}
if (i2sOut_ != nullptr) {
delete i2sOut_;
i2sOut_ = nullptr;
}
}
void Mp3Player::stopAudioToolsTrack() {
audioTools_.stop();
}
void Mp3Player::stop() {
stopLegacyTrack();
stopAudioToolsTrack();
activeBackend_ = PlayerBackendId::kNone;
activeCodec_ = AudioCodec::kUnknown;
}
void Mp3Player::markStateDirty() {
stateDirty_ = true;
nextStateSaveMs_ = millis() + kStateSaveDebounceMs;
}
void Mp3Player::syncCurrentTrackToStatePath() {
selectedPathFromState_ = currentTrackName();
}
bool Mp3Player::restoreTrackFromStatePath() {
if (selectedPathFromState_.isEmpty()) {
return false;
}
const int16_t idx = catalog_.indexOfPath(selectedPathFromState_.c_str());
if (idx < 0) {
return false;
}
currentTrack_ = static_cast<uint16_t>(idx);
return true;
}
bool Mp3Player::isSupportedAudioFile(const String& filename) {
return codecForPath(filename) != AudioCodec::kUnknown;
}
AudioCodec Mp3Player::codecForPath(const String& filename) {
String lower = filename;
lower.toLowerCase();
if (lower.endsWith(".mp3")) {
return AudioCodec::kMp3;
}
if (lower.endsWith(".wav")) {
return AudioCodec::kWav;
}
if (lower.endsWith(".aac") || lower.endsWith(".m4a")) {
return AudioCodec::kAac;
}
if (lower.endsWith(".flac")) {
return AudioCodec::kFlac;
}
if (lower.endsWith(".opus") || lower.endsWith(".ogg")) {
return AudioCodec::kOpus;
}
return AudioCodec::kUnknown;
}
const char* Mp3Player::codecLabel(AudioCodec codec) {
switch (codec) {
case AudioCodec::kMp3:
return "MP3";
case AudioCodec::kWav:
return "WAV";
case AudioCodec::kAac:
return "AAC";
case AudioCodec::kFlac:
return "FLAC";
case AudioCodec::kOpus:
return "OPUS";
case AudioCodec::kUnknown:
default:
return "UNKNOWN";
}
}
AudioGenerator* Mp3Player::createDecoder(AudioCodec codec) {
switch (codec) {
case AudioCodec::kMp3:
return new (std::nothrow) AudioGeneratorMP3();
case AudioCodec::kWav:
return new (std::nothrow) AudioGeneratorWAV();
case AudioCodec::kAac:
return new (std::nothrow) AudioGeneratorAAC();
case AudioCodec::kFlac:
return new (std::nothrow) AudioGeneratorFLAC();
case AudioCodec::kOpus:
return new (std::nothrow) AudioGeneratorOpus();
case AudioCodec::kUnknown:
default:
return nullptr;
}
}
const char* Mp3Player::repeatModeToToken(RepeatMode mode) {
return (mode == RepeatMode::kOne) ? "ONE" : "ALL";
}
RepeatMode Mp3Player::repeatModeFromToken(const char* token) {
if (token == nullptr) {
return RepeatMode::kAll;
}
if (strcasecmp(token, "ONE") == 0) {
return RepeatMode::kOne;
}
return RepeatMode::kAll;
}
bool Mp3Player::parseJsonString(const String& json, const char* key, String* outValue) {
if (outValue == nullptr || key == nullptr || key[0] == '\0') {
return false;
}
const String token = String("\"") + key + "\"";
const int keyPos = json.indexOf(token);
if (keyPos < 0) {
return false;
}
const int colonPos = json.indexOf(':', keyPos + token.length());
if (colonPos < 0) {
return false;
}
int firstQuote = json.indexOf('"', colonPos + 1);
if (firstQuote < 0) {
return false;
}
int secondQuote = json.indexOf('"', firstQuote + 1);
if (secondQuote < 0) {
return false;
}
*outValue = json.substring(firstQuote + 1, secondQuote);
return true;
}
bool Mp3Player::parseJsonFloat(const String& json, const char* key, float* outValue) {
if (outValue == nullptr || key == nullptr || key[0] == '\0') {
return false;
}
const String token = String("\"") + key + "\"";
const int keyPos = json.indexOf(token);
if (keyPos < 0) {
return false;
}
const int colonPos = json.indexOf(':', keyPos + token.length());
if (colonPos < 0) {
return false;
}
int valueStart = colonPos + 1;
while (valueStart < static_cast<int>(json.length()) &&
(json[valueStart] == ' ' || json[valueStart] == '\t')) {
++valueStart;
}
int valueEnd = valueStart;
while (valueEnd < static_cast<int>(json.length())) {
const char c = json[valueEnd];
if ((c >= '0' && c <= '9') || c == '.' || c == '-' || c == '+') {
++valueEnd;
continue;
}
break;
}
if (valueEnd <= valueStart) {
return false;
}
*outValue = json.substring(valueStart, valueEnd).toFloat();
return true;
}
@@ -0,0 +1,160 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
#include "catalog/track_catalog.h"
#include "mp3_fx_overlay_output.h"
#include "player/audio_tools_backend.h"
#include "player/player_backend.h"
class AudioFileSourceFS;
class AudioGenerator;
enum class AudioCodec : uint8_t {
kUnknown = 0,
kMp3,
kWav,
kAac,
kFlac,
kOpus,
};
enum class RepeatMode : uint8_t {
kAll = 0,
kOne = 1,
};
class Mp3Player {
public:
Mp3Player(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
const char* mp3Path,
int8_t paEnablePin = -1);
~Mp3Player();
void begin();
void update(uint32_t nowMs, bool allowPlayback = true);
void togglePause();
void restartTrack();
void nextTrack();
void previousTrack();
void cycleRepeatMode();
void requestStorageRefresh();
void setGain(float gain);
float gain() const;
uint8_t volumePercent() const;
void setFxMode(Mp3FxMode mode);
Mp3FxMode fxMode() const;
const char* fxModeLabel() const;
void setFxDuckingGain(float gain);
float fxDuckingGain() const;
void setFxOverlayGain(float gain);
float fxOverlayGain() const;
bool triggerFx(Mp3FxEffect effect, uint32_t durationMs);
void stopFx();
bool isFxActive() const;
uint32_t fxRemainingMs() const;
const char* fxEffectLabel() const;
bool isPaused() const;
bool isSdReady() const;
bool hasTracks() const;
bool isPlaying() const;
uint16_t trackCount() const;
uint16_t currentTrackNumber() const;
String currentTrackName() const;
RepeatMode repeatMode() const;
const char* repeatModeLabel() const;
void setBackendMode(PlayerBackendMode mode);
PlayerBackendMode backendMode() const;
PlayerBackendId activeBackend() const;
const char* backendModeLabel() const;
const char* activeBackendLabel() const;
const char* lastBackendError() const;
bool selectTrackByIndex(uint16_t index, bool restart = true);
bool selectTrackByPath(const char* path, bool restart = true);
bool playPath(const char* path);
CatalogStats catalogStats() const;
bool isScanBusy() const;
const TrackEntry* trackEntryByNumber(uint16_t oneBasedNumber) const;
uint16_t listTracks(const char* prefix,
uint16_t offset,
uint16_t limit,
Print& out) const;
uint16_t countTracks(const char* prefix) const;
bool savePlayerState();
bool loadPlayerState();
bool resetPlayerState();
private:
static constexpr uint16_t kStateSaveDebounceMs = 1200;
bool mountStorage(uint32_t nowMs);
void unmountStorage(uint32_t nowMs);
void refreshStorage(uint32_t nowMs);
void scanTracks();
void updateDeferredStateSave(uint32_t nowMs);
bool startLegacyTrack();
bool startAudioToolsTrack();
void stopLegacyTrack();
void stopAudioToolsTrack();
void markStateDirty();
void syncCurrentTrackToStatePath();
bool restoreTrackFromStatePath();
static bool isSupportedAudioFile(const String& filename);
static AudioCodec codecForPath(const String& filename);
static const char* codecLabel(AudioCodec codec);
static AudioGenerator* createDecoder(AudioCodec codec);
static const char* repeatModeToToken(RepeatMode mode);
static RepeatMode repeatModeFromToken(const char* token);
static bool parseJsonString(const String& json, const char* key, String* outValue);
static bool parseJsonFloat(const String& json, const char* key, float* outValue);
void startCurrentTrack();
void stop();
uint8_t i2sBclk_;
uint8_t i2sLrc_;
uint8_t i2sDout_;
int8_t paEnablePin_;
const char* mp3Path_;
bool sdReady_ = false;
bool paused_ = false;
float gain_ = 0.20f;
uint32_t nextMountAttemptMs_ = 0;
uint32_t nextCardCheckMs_ = 0;
uint32_t nextRescanMs_ = 0;
uint32_t nextRetryMs_ = 0;
uint16_t trackCount_ = 0;
uint16_t currentTrack_ = 0;
String selectedPathFromState_;
RepeatMode repeatMode_ = RepeatMode::kAll;
bool forceRescan_ = false;
bool scanBusy_ = false;
CatalogStats catalogStats_;
TrackCatalog catalog_;
AudioCodec activeCodec_ = AudioCodec::kUnknown;
bool stateDirty_ = false;
uint32_t nextStateSaveMs_ = 0;
uint32_t lastPositionMs_ = 0;
PlayerBackendMode backendMode_ = PlayerBackendMode::kAutoFallback;
PlayerBackendId activeBackend_ = PlayerBackendId::kNone;
bool fallbackUsed_ = false;
char backendError_[24] = "OK";
AudioToolsBackend audioTools_;
Mp3FxMode fxMode_ = Mp3FxMode::kDucking;
float fxDuckingGain_ = 0.45f;
float fxOverlayGain_ = 0.42f;
Mp3FxEffect fxLastEffect_ = Mp3FxEffect::kFmSweep;
AudioGenerator* decoder_ = nullptr;
AudioFileSourceFS* mp3File_ = nullptr;
Mp3FxOverlayOutput* i2sOut_ = nullptr;
};
@@ -0,0 +1,248 @@
#include "audio_tools_backend.h"
#include <FS.h>
#include <SD_MMC.h>
#include <AudioTools.h>
#include <AudioTools/AudioCodecs/CodecWAV.h>
namespace {
using audio_tools::AudioDecoder;
using audio_tools::EncodedAudioStream;
using audio_tools::I2SConfig;
using audio_tools::I2SStream;
using audio_tools::StreamCopy;
using audio_tools::TX_MODE;
using audio_tools::WAVDecoder;
bool endsWithIgnoreCase(const char* value, const char* suffix) {
if (value == nullptr || suffix == nullptr) {
return false;
}
const size_t valueLen = strlen(value);
const size_t suffixLen = strlen(suffix);
if (valueLen < suffixLen) {
return false;
}
const char* tail = value + (valueLen - suffixLen);
for (size_t i = 0U; i < suffixLen; ++i) {
const char a = static_cast<char>(tolower(static_cast<unsigned char>(tail[i])));
const char b = static_cast<char>(tolower(static_cast<unsigned char>(suffix[i])));
if (a != b) {
return false;
}
}
return true;
}
} // namespace
AudioToolsBackend::AudioToolsBackend(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
uint8_t i2sPort)
: i2sBclk_(i2sBclk),
i2sLrc_(i2sLrc),
i2sDout_(i2sDout),
i2sPort_(i2sPort) {}
bool AudioToolsBackend::start(const char* path, float gain) {
stop();
setGain(gain);
if (path == nullptr || path[0] == '\0') {
setLastError("BAD_PATH");
return false;
}
if (!canHandlePath(path)) {
setLastError("UNSUPPORTED");
return false;
}
if (!setupI2s()) {
return false;
}
auto* file = new fs::File();
*file = SD_MMC.open(path, FILE_READ);
if (!(*file) || file->isDirectory()) {
delete file;
setLastError("OPEN_FAIL");
return false;
}
file_ = file;
if (!setupDecoderForPath(path)) {
stop();
return false;
}
auto* encoded = static_cast<EncodedAudioStream*>(encoded_);
auto* fileStream = static_cast<fs::File*>(file_);
auto* copy = new StreamCopy(*encoded, *fileStream);
copier_ = copy;
active_ = true;
eof_ = false;
idleLoops_ = 0U;
setLastError("OK");
return true;
}
void AudioToolsBackend::update() {
if (!active_) {
return;
}
auto* copy = static_cast<StreamCopy*>(copier_);
auto* file = static_cast<fs::File*>(file_);
if (copy == nullptr || file == nullptr || !(*file)) {
setLastError("RUNTIME");
stop();
return;
}
const size_t moved = copy->copy();
if (moved > 0U) {
idleLoops_ = 0U;
return;
}
if (file->available() > 0) {
return;
}
++idleLoops_;
if (idleLoops_ > 2U) {
eof_ = true;
stop();
}
}
void AudioToolsBackend::stop() {
active_ = false;
idleLoops_ = 0U;
auto* copy = static_cast<StreamCopy*>(copier_);
if (copy != nullptr) {
delete copy;
copier_ = nullptr;
}
auto* encoded = static_cast<EncodedAudioStream*>(encoded_);
if (encoded != nullptr) {
encoded->end();
delete encoded;
encoded_ = nullptr;
}
auto* decoder = static_cast<AudioDecoder*>(decoder_);
if (decoder != nullptr) {
decoder->end();
delete decoder;
decoder_ = nullptr;
}
auto* file = static_cast<fs::File*>(file_);
if (file != nullptr) {
if (*file) {
file->close();
}
delete file;
file_ = nullptr;
}
auto* i2s = static_cast<I2SStream*>(i2s_);
if (i2s != nullptr) {
i2s->end();
}
}
bool AudioToolsBackend::isActive() const {
return active_;
}
bool AudioToolsBackend::canHandlePath(const char* path) const {
return endsWithIgnoreCase(path, ".wav");
}
const char* AudioToolsBackend::lastError() const {
return lastError_;
}
void AudioToolsBackend::setGain(float gain) {
if (gain < 0.0f) {
gain = 0.0f;
} else if (gain > 1.0f) {
gain = 1.0f;
}
gain_ = gain;
}
float AudioToolsBackend::gain() const {
return gain_;
}
bool AudioToolsBackend::setupI2s() {
auto* i2s = static_cast<I2SStream*>(i2s_);
if (i2s == nullptr) {
i2s = new I2SStream();
i2s_ = i2s;
}
I2SConfig cfg = i2s->defaultConfig(TX_MODE);
cfg.pin_bck = static_cast<int>(i2sBclk_);
cfg.pin_ws = static_cast<int>(i2sLrc_);
cfg.pin_data = static_cast<int>(i2sDout_);
cfg.port_no = static_cast<int>(i2sPort_);
cfg.sample_rate = 44100;
cfg.channels = 2;
cfg.bits_per_sample = 16;
if (!i2s->begin(cfg)) {
setLastError("I2S_FAIL");
return false;
}
return true;
}
bool AudioToolsBackend::setupDecoderForPath(const char* path) {
AudioDecoder* decoder = nullptr;
if (endsWithIgnoreCase(path, ".wav")) {
decoder = new WAVDecoder();
} else {
setLastError("UNSUPPORTED");
return false;
}
if (decoder == nullptr) {
setLastError("OOM");
return false;
}
auto* i2s = static_cast<I2SStream*>(i2s_);
auto* encoded = new EncodedAudioStream(i2s, decoder);
if (encoded == nullptr) {
delete decoder;
setLastError("OOM");
return false;
}
if (!encoded->begin()) {
delete encoded;
delete decoder;
setLastError("DEC_FAIL");
return false;
}
decoder_ = decoder;
encoded_ = encoded;
return true;
}
void AudioToolsBackend::setLastError(const char* code) {
if (code == nullptr || code[0] == '\0') {
snprintf(lastError_, sizeof(lastError_), "%s", "UNKNOWN");
return;
}
snprintf(lastError_, sizeof(lastError_), "%s", code);
}
@@ -0,0 +1,41 @@
#pragma once
#include <Arduino.h>
class AudioToolsBackend {
public:
AudioToolsBackend(uint8_t i2sBclk, uint8_t i2sLrc, uint8_t i2sDout, uint8_t i2sPort);
bool start(const char* path, float gain);
void update();
void stop();
bool isActive() const;
bool canHandlePath(const char* path) const;
const char* lastError() const;
void setGain(float gain);
float gain() const;
private:
bool setupI2s();
bool setupDecoderForPath(const char* path);
void setLastError(const char* code);
uint8_t i2sBclk_;
uint8_t i2sLrc_;
uint8_t i2sDout_;
uint8_t i2sPort_;
float gain_ = 0.20f;
bool active_ = false;
bool eof_ = false;
uint8_t idleLoops_ = 0U;
char lastError_[24] = "OK";
void* i2s_ = nullptr;
void* decoder_ = nullptr;
void* encoded_ = nullptr;
void* copier_ = nullptr;
void* file_ = nullptr;
};
@@ -0,0 +1,47 @@
#pragma once
#include <Arduino.h>
enum class PlayerBackendMode : uint8_t {
kAutoFallback = 0,
kAudioToolsOnly = 1,
kLegacyOnly = 2,
};
enum class PlayerBackendId : uint8_t {
kNone = 0,
kAudioTools = 1,
kLegacy = 2,
};
struct PlayerBackendStatus {
PlayerBackendMode mode = PlayerBackendMode::kAutoFallback;
PlayerBackendId active = PlayerBackendId::kNone;
bool fallbackUsed = false;
bool supportsOverlayFx = true;
char lastError[24] = {};
};
inline const char* playerBackendModeLabel(PlayerBackendMode mode) {
switch (mode) {
case PlayerBackendMode::kAudioToolsOnly:
return "AUDIO_TOOLS_ONLY";
case PlayerBackendMode::kLegacyOnly:
return "LEGACY_ONLY";
case PlayerBackendMode::kAutoFallback:
default:
return "AUTO_FALLBACK";
}
}
inline const char* playerBackendIdLabel(PlayerBackendId id) {
switch (id) {
case PlayerBackendId::kAudioTools:
return "AUDIO_TOOLS";
case PlayerBackendId::kLegacy:
return "LEGACY";
case PlayerBackendId::kNone:
default:
return "NONE";
}
}
+8 -1
View File
@@ -57,6 +57,8 @@ constexpr uint32_t kBootAudioValidationTimeoutMs = 0; // 0 => pas de timeout au
constexpr uint8_t kBootAudioValidationMaxReplays = 6;
constexpr uint16_t kBootProtocolPromptPeriodMs = 3000;
constexpr uint16_t kBootRadioScanChunkMs = 18;
constexpr uint32_t kStoryEtape2DelayMs = 15UL * 60UL * 1000UL;
constexpr uint32_t kStoryEtape2TestDelayMs = 5000U;
constexpr bool kEnableInternalLittleFs = true;
constexpr bool kInternalLittleFsFormatOnFail = false;
constexpr bool kPreferLittleFsBootFx = true;
@@ -66,8 +68,9 @@ constexpr uint32_t kBootFxLittleFsMaxDurationMs = 22000;
// ESP32 -> ESP8266 (ecran) en UART unidirectionnel
constexpr uint8_t kPinScreenTx = 22;
constexpr uint32_t kScreenBaud = 38400;
constexpr uint32_t kScreenBaud = 19200;
constexpr uint16_t kScreenUpdatePeriodMs = 250;
constexpr uint16_t kScreenChangeMinPeriodMs = 90;
// Clavier analogique (6 touches sur 1 entree ADC)
constexpr uint8_t kPinKeysAdc = 36;
@@ -84,6 +87,10 @@ constexpr uint16_t kKey5Max = 1770;
constexpr uint16_t kKey6Max = 2200;
constexpr char kMp3Path[] = "/track001.mp3";
constexpr bool kMp3FxOverlayModeDefault = false; // false=DUCKING, true=OVERLAY
constexpr float kMp3FxDuckingGainDefault = 0.45f;
constexpr float kMp3FxOverlayGainDefault = 0.42f;
constexpr uint16_t kMp3FxDefaultDurationMs = 2200;
constexpr float kSineFreqHz = 440.0f;
constexpr float kSineFreqStepHz = 20.0f;
@@ -0,0 +1,28 @@
#include "boot_protocol_controller.h"
BootProtocolController::BootProtocolController(const Hooks& hooks) : hooks_(hooks) {}
void BootProtocolController::start(uint32_t nowMs) {
if (hooks_.start != nullptr) {
hooks_.start(nowMs);
}
}
void BootProtocolController::update(uint32_t nowMs) {
if (hooks_.update != nullptr) {
hooks_.update(nowMs);
}
}
void BootProtocolController::onKey(uint8_t key, uint32_t nowMs) {
if (hooks_.onKey != nullptr) {
hooks_.onKey(key, nowMs);
}
}
bool BootProtocolController::isActive() const {
if (hooks_.isActive == nullptr) {
return false;
}
return hooks_.isActive();
}
@@ -0,0 +1,23 @@
#pragma once
#include <Arduino.h>
class BootProtocolController {
public:
struct Hooks {
void (*start)(uint32_t nowMs) = nullptr;
void (*update)(uint32_t nowMs) = nullptr;
void (*onKey)(uint8_t key, uint32_t nowMs) = nullptr;
bool (*isActive)() = nullptr;
};
explicit BootProtocolController(const Hooks& hooks);
void start(uint32_t nowMs);
void update(uint32_t nowMs);
void onKey(uint8_t key, uint32_t nowMs);
bool isActive() const;
private:
Hooks hooks_;
};
@@ -0,0 +1,124 @@
#include "story_controller.h"
#include <cstring>
StoryController::StoryController(StoryEngine& engine, AudioService& audio, const Hooks& hooks)
: engine_(engine), audio_(audio), hooks_(hooks) {}
void StoryController::reset(const char* source) {
winAudioPending_ = false;
etape2AudioPending_ = false;
engine_.reset(source);
}
void StoryController::onUnlock(uint32_t nowMs, const char* source) {
winAudioPending_ = false;
etape2AudioPending_ = false;
engine_.armAfterUnlock(nowMs, source);
if (triggerWinAudio(nowMs, "unlock_story_win")) {
return;
}
engine_.markWinPlayed(nowMs, false, "unlock_story_no_audio");
}
void StoryController::armAfterUnlock(uint32_t nowMs, const char* source) {
onUnlock(nowMs, source);
}
bool StoryController::isMp3GateOpen() const {
return engine_.isMp3GateOpen();
}
void StoryController::update(uint32_t nowMs) {
if (winAudioPending_) {
if (audio_.isBaseBusy()) {
return;
}
winAudioPending_ = false;
engine_.markWinPlayed(nowMs, true, "unlock_story_async_done");
}
if (etape2AudioPending_) {
if (audio_.isBaseBusy()) {
return;
}
etape2AudioPending_ = false;
engine_.markEtape2Played(nowMs, true, "timeline_async_done");
return;
}
if (!engine_.shouldTriggerEtape2(nowMs)) {
return;
}
Serial.println("[STORY] ETAPE_2 trigger.");
if (triggerEtape2Audio(nowMs, "story_etape2")) {
etape2AudioPending_ = true;
return;
}
Serial.println("[STORY] ETAPE_2 absent: passage sans audio.");
engine_.markEtape2Played(nowMs, false, "timeline_no_audio");
}
void StoryController::forceEtape2DueNow(uint32_t nowMs, const char* source) {
engine_.forceEtape2DueNow(nowMs, source);
}
void StoryController::setTestMode(bool enabled, uint32_t nowMs, const char* source) {
engine_.setTestMode(enabled, nowMs, source);
}
void StoryController::setTestDelayMs(uint32_t delayMs, uint32_t nowMs, const char* source) {
engine_.setTestDelayMs(delayMs, nowMs, source);
}
void StoryController::printStatus(uint32_t nowMs, const char* source) const {
engine_.printStatus(nowMs, source);
}
bool StoryController::triggerWinAudio(uint32_t nowMs, const char* source) {
(void)nowMs;
bool started = false;
if (hooks_.startRandomTokenBase != nullptr &&
hooks_.winToken != nullptr &&
hooks_.winToken[0] != '\0') {
started = hooks_.startRandomTokenBase(hooks_.winToken, source, true, hooks_.winMaxDurationMs);
}
if (!started && hooks_.startFallbackBaseFx != nullptr) {
Serial.println("[STORY] WIN absent: fallback FX WIN.");
started = hooks_.startFallbackBaseFx(AudioEffectId::kWin,
hooks_.winFallbackDurationMs,
hooks_.fallbackGain,
"story_win_fallback");
}
if (started) {
winAudioPending_ = true;
return true;
}
Serial.println("[STORY] WIN absent: passage sans audio.");
return false;
}
bool StoryController::triggerEtape2Audio(uint32_t nowMs, const char* source) {
(void)nowMs;
bool started = false;
if (hooks_.startRandomTokenBase != nullptr &&
hooks_.etape2Token != nullptr &&
hooks_.etape2Token[0] != '\0') {
started = hooks_.startRandomTokenBase(hooks_.etape2Token, source, true, hooks_.etape2MaxDurationMs);
}
if (!started && hooks_.startFallbackBaseFx != nullptr) {
Serial.println("[STORY] ETAPE_2 absent: fallback FX WIN.");
started = hooks_.startFallbackBaseFx(AudioEffectId::kWin,
hooks_.etape2FallbackDurationMs,
hooks_.fallbackGain,
"story_etape2_fallback");
}
return started;
}
@@ -0,0 +1,52 @@
#pragma once
#include <Arduino.h>
#include "../audio/effects/audio_effect_id.h"
#include "../services/audio/audio_service.h"
#include "../story/story_engine.h"
class StoryController {
public:
struct Hooks {
bool (*startRandomTokenBase)(const char* token,
const char* source,
bool allowSdFallback,
uint32_t maxDurationMs);
bool (*startFallbackBaseFx)(AudioEffectId effect,
uint32_t durationMs,
float gain,
const char* source);
float fallbackGain = 0.22f;
const char* winToken = "WIN";
const char* etape2Token = "ETAPE_2";
uint32_t winMaxDurationMs = 6000U;
uint32_t etape2MaxDurationMs = 6000U;
uint32_t winFallbackDurationMs = 1800U;
uint32_t etape2FallbackDurationMs = 1800U;
};
StoryController(StoryEngine& engine, AudioService& audio, const Hooks& hooks);
void reset(const char* source);
void onUnlock(uint32_t nowMs, const char* source);
void armAfterUnlock(uint32_t nowMs, const char* source);
bool isMp3GateOpen() const;
void update(uint32_t nowMs);
void forceEtape2DueNow(uint32_t nowMs, const char* source);
void setTestMode(bool enabled, uint32_t nowMs, const char* source);
void setTestDelayMs(uint32_t delayMs, uint32_t nowMs, const char* source);
void printStatus(uint32_t nowMs, const char* source) const;
private:
StoryEngine& engine_;
AudioService& audio_;
Hooks hooks_;
bool winAudioPending_ = false;
bool etape2AudioPending_ = false;
bool triggerWinAudio(uint32_t nowMs, const char* source);
bool triggerEtape2Audio(uint32_t nowMs, const char* source);
};
@@ -1,6 +1,6 @@
#include "keypad_analog.h"
#include "config.h"
#include "../config.h"
KeypadAnalog::KeypadAnalog(uint8_t adcPin) : adcPin_(adcPin), thresholds_(defaultThresholds()) {}
+12 -3
View File
@@ -204,10 +204,17 @@ void LaDetector::captureFromAdc() {
void LaDetector::captureFromI2s() {
int16_t i2sBuffer[32];
const uint8_t maxSamplesPerLoop =
(config::kDetectMaxSamplesPerLoop == 0U) ? 1U : config::kDetectMaxSamplesPerLoop;
uint8_t samplesCaptured = 0U;
while (sampleIndex_ < config::kDetectN) {
while (sampleIndex_ < config::kDetectN && samplesCaptured < maxSamplesPerLoop) {
const size_t remaining = static_cast<size_t>(config::kDetectN - sampleIndex_);
const size_t requested = (remaining < 32U) ? remaining : 32U;
const size_t loopBudget = static_cast<size_t>(maxSamplesPerLoop - samplesCaptured);
size_t requested = (remaining < 32U) ? remaining : 32U;
if (requested > loopBudget) {
requested = loopBudget;
}
size_t bytesRead = 0;
const esp_err_t readErr =
i2s_read(i2sPort_, i2sBuffer, requested * sizeof(int16_t), &bytesRead, 0);
@@ -216,7 +223,8 @@ void LaDetector::captureFromI2s() {
}
const size_t samplesRead = bytesRead / sizeof(int16_t);
for (size_t i = 0; i < samplesRead && sampleIndex_ < config::kDetectN; ++i) {
for (size_t i = 0; i < samplesRead && sampleIndex_ < config::kDetectN && samplesCaptured < maxSamplesPerLoop;
++i) {
// Convert signed PCM16 to pseudo-ADC 12-bit range [0..4095].
int32_t normalized = static_cast<int32_t>(i2sBuffer[i]) + 32768;
if (normalized < 0) {
@@ -225,6 +233,7 @@ void LaDetector::captureFromI2s() {
normalized = 65535;
}
samples_[sampleIndex_++] = static_cast<int16_t>(normalized >> 4);
++samplesCaptured;
}
}
}
-449
View File
@@ -1,449 +0,0 @@
#include "mp3_player.h"
#include <new>
#include <AudioFileSourceFS.h>
#include <AudioGenerator.h>
#include <AudioGeneratorAAC.h>
#include <AudioGeneratorFLAC.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorOpus.h>
#include <AudioGeneratorWAV.h>
#include <AudioOutputI2S.h>
#include <FS.h>
#include <SD_MMC.h>
Mp3Player::Mp3Player(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
const char* mp3Path,
int8_t paEnablePin)
: i2sBclk_(i2sBclk),
i2sLrc_(i2sLrc),
i2sDout_(i2sDout),
paEnablePin_(paEnablePin),
mp3Path_(mp3Path) {}
Mp3Player::~Mp3Player() {
stop();
}
void Mp3Player::begin() {
if (paEnablePin_ >= 0) {
pinMode(paEnablePin_, OUTPUT);
digitalWrite(paEnablePin_, HIGH);
}
}
void Mp3Player::update(uint32_t nowMs, bool allowPlayback) {
refreshStorage(nowMs);
if (!sdReady_ || trackCount_ == 0) {
stop();
return;
}
if (!allowPlayback) {
stop();
return;
}
if (decoder_ == nullptr) {
if (paused_) {
return;
}
if (nowMs < nextRetryMs_) {
return;
}
startCurrentTrack();
return;
}
if (paused_) {
return;
}
if (decoder_->isRunning()) {
decoder_->loop();
return;
}
stop();
if (repeatMode_ == RepeatMode::kAll && trackCount_ > 0) {
currentTrack_ = static_cast<uint16_t>((currentTrack_ + 1U) % trackCount_);
}
startCurrentTrack();
}
void Mp3Player::togglePause() {
if (!sdReady_ || trackCount_ == 0) {
return;
}
paused_ = !paused_;
}
void Mp3Player::restartTrack() {
if (!sdReady_ || trackCount_ == 0) {
return;
}
paused_ = false;
stop();
startCurrentTrack();
}
void Mp3Player::nextTrack() {
if (!sdReady_ || trackCount_ == 0) {
return;
}
paused_ = false;
stop();
currentTrack_ = static_cast<uint16_t>((currentTrack_ + 1U) % trackCount_);
startCurrentTrack();
}
void Mp3Player::previousTrack() {
if (!sdReady_ || trackCount_ == 0) {
return;
}
paused_ = false;
stop();
if (currentTrack_ == 0) {
currentTrack_ = static_cast<uint16_t>(trackCount_ - 1U);
} else {
--currentTrack_;
}
startCurrentTrack();
}
void Mp3Player::cycleRepeatMode() {
repeatMode_ = (repeatMode_ == RepeatMode::kAll) ? RepeatMode::kOne : RepeatMode::kAll;
}
void Mp3Player::requestStorageRefresh() {
forceRescan_ = true;
nextMountAttemptMs_ = 0;
nextRescanMs_ = 0;
}
void Mp3Player::setGain(float gain) {
if (gain < 0.0f) {
gain = 0.0f;
} else if (gain > 1.0f) {
gain = 1.0f;
}
gain_ = gain;
if (i2sOut_ != nullptr) {
i2sOut_->SetGain(gain_);
}
}
float Mp3Player::gain() const {
return gain_;
}
uint8_t Mp3Player::volumePercent() const {
return static_cast<uint8_t>(gain_ * 100.0f);
}
bool Mp3Player::isPaused() const {
return paused_;
}
bool Mp3Player::isSdReady() const {
return sdReady_;
}
bool Mp3Player::hasTracks() const {
return trackCount_ > 0;
}
bool Mp3Player::isPlaying() const {
return decoder_ != nullptr && decoder_->isRunning() && !paused_;
}
uint16_t Mp3Player::trackCount() const {
return trackCount_;
}
uint16_t Mp3Player::currentTrackNumber() const {
if (trackCount_ == 0) {
return 0;
}
return static_cast<uint16_t>(currentTrack_ + 1U);
}
String Mp3Player::currentTrackName() const {
if (trackCount_ == 0) {
return String();
}
return tracks_[currentTrack_];
}
RepeatMode Mp3Player::repeatMode() const {
return repeatMode_;
}
const char* Mp3Player::repeatModeLabel() const {
return (repeatMode_ == RepeatMode::kAll) ? "ALL" : "ONE";
}
bool Mp3Player::mountStorage(uint32_t nowMs) {
if (!SD_MMC.begin("/sdcard", true)) {
nextMountAttemptMs_ = nowMs + 2000;
return false;
}
sdReady_ = true;
nextCardCheckMs_ = nowMs + 1000;
nextRescanMs_ = nowMs;
Serial.println("[MP3] SD_MMC mounted.");
scanTracks();
return true;
}
void Mp3Player::unmountStorage(uint32_t nowMs) {
stop();
SD_MMC.end();
sdReady_ = false;
paused_ = false;
trackCount_ = 0;
currentTrack_ = 0;
nextMountAttemptMs_ = nowMs + 1500;
nextCardCheckMs_ = 0;
nextRescanMs_ = 0;
nextRetryMs_ = 0;
Serial.println("[MP3] SD removed/unmounted.");
}
void Mp3Player::refreshStorage(uint32_t nowMs) {
if (!sdReady_) {
if (nowMs >= nextMountAttemptMs_) {
mountStorage(nowMs);
}
return;
}
if (nowMs >= nextCardCheckMs_) {
nextCardCheckMs_ = nowMs + 1000;
if (SD_MMC.cardType() == CARD_NONE) {
unmountStorage(nowMs);
return;
}
}
if (trackCount_ == 0 && nowMs >= nextRescanMs_) {
scanTracks();
forceRescan_ = false;
nextRescanMs_ = nowMs + 3000;
return;
}
if (forceRescan_) {
scanTracks();
forceRescan_ = false;
nextRescanMs_ = nowMs + 3000;
return;
}
if (currentTrack_ >= trackCount_) {
currentTrack_ = 0;
}
}
void Mp3Player::scanTracks() {
trackCount_ = 0;
fs::File root = SD_MMC.open("/");
if (!root || !root.isDirectory()) {
Serial.println("[MP3] Cannot open SD root.");
return;
}
fs::File file = root.openNextFile();
while (file) {
if (!file.isDirectory()) {
String filename = String(file.name());
if (isSupportedAudioFile(filename) && trackCount_ < kMaxTracks) {
if (!filename.startsWith("/")) {
filename = "/" + filename;
}
tracks_[trackCount_++] = filename;
}
}
file.close();
file = root.openNextFile();
}
root.close();
sortTracks();
if (trackCount_ == 0) {
if (isSupportedAudioFile(String(mp3Path_)) && SD_MMC.exists(mp3Path_)) {
tracks_[0] = String(mp3Path_);
trackCount_ = 1;
} else {
Serial.println("[MP3] No supported audio file found on SD.");
return;
}
}
if (currentTrack_ >= trackCount_) {
currentTrack_ = 0;
}
Serial.printf("[MP3] %u track(s) loaded.\n", static_cast<unsigned int>(trackCount_));
}
bool Mp3Player::isSupportedAudioFile(const String& filename) {
return codecForPath(filename) != AudioCodec::kUnknown;
}
AudioCodec Mp3Player::codecForPath(const String& filename) {
String lower = filename;
lower.toLowerCase();
if (lower.endsWith(".mp3")) {
return AudioCodec::kMp3;
}
if (lower.endsWith(".wav")) {
return AudioCodec::kWav;
}
if (lower.endsWith(".aac")) {
return AudioCodec::kAac;
}
if (lower.endsWith(".flac")) {
return AudioCodec::kFlac;
}
if (lower.endsWith(".opus") || lower.endsWith(".ogg")) {
return AudioCodec::kOpus;
}
return AudioCodec::kUnknown;
}
const char* Mp3Player::codecLabel(AudioCodec codec) {
switch (codec) {
case AudioCodec::kMp3:
return "MP3";
case AudioCodec::kWav:
return "WAV";
case AudioCodec::kAac:
return "AAC";
case AudioCodec::kFlac:
return "FLAC";
case AudioCodec::kOpus:
return "OPUS";
case AudioCodec::kUnknown:
default:
return "UNKNOWN";
}
}
AudioGenerator* Mp3Player::createDecoder(AudioCodec codec) {
switch (codec) {
case AudioCodec::kMp3:
return new (std::nothrow) AudioGeneratorMP3();
case AudioCodec::kWav:
return new (std::nothrow) AudioGeneratorWAV();
case AudioCodec::kAac:
return new (std::nothrow) AudioGeneratorAAC();
case AudioCodec::kFlac:
return new (std::nothrow) AudioGeneratorFLAC();
case AudioCodec::kOpus:
return new (std::nothrow) AudioGeneratorOpus();
case AudioCodec::kUnknown:
default:
return nullptr;
}
}
void Mp3Player::sortTracks() {
if (trackCount_ < 2) {
return;
}
for (uint16_t i = 0; i < trackCount_ - 1U; ++i) {
for (uint16_t j = i + 1U; j < trackCount_; ++j) {
if (tracks_[j].compareTo(tracks_[i]) < 0) {
const String tmp = tracks_[i];
tracks_[i] = tracks_[j];
tracks_[j] = tmp;
}
}
}
}
void Mp3Player::startCurrentTrack() {
if (!sdReady_ || trackCount_ == 0 || currentTrack_ >= trackCount_) {
return;
}
const String& trackPath = tracks_[currentTrack_];
const AudioCodec trackCodec = codecForPath(trackPath);
if (trackCodec == AudioCodec::kUnknown) {
Serial.printf("[MP3] Unsupported file type: %s\n", trackPath.c_str());
if (trackCount_ > 0U) {
currentTrack_ = static_cast<uint16_t>((currentTrack_ + 1U) % trackCount_);
}
nextRetryMs_ = millis() + 250U;
return;
}
if (!SD_MMC.exists(trackPath.c_str())) {
Serial.printf("[MP3] Missing track: %s\n", trackPath.c_str());
scanTracks();
nextRetryMs_ = millis() + 1000;
return;
}
mp3File_ = new (std::nothrow) AudioFileSourceFS(SD_MMC, trackPath.c_str());
i2sOut_ = new (std::nothrow) AudioOutputI2S();
decoder_ = createDecoder(trackCodec);
activeCodec_ = trackCodec;
if (mp3File_ == nullptr || i2sOut_ == nullptr || decoder_ == nullptr) {
Serial.println("[MP3] Memory allocation failed.");
stop();
nextRetryMs_ = millis() + 1000;
return;
}
i2sOut_->SetPinout(i2sBclk_, i2sLrc_, i2sDout_);
i2sOut_->SetGain(gain_);
if (!decoder_->begin(mp3File_, i2sOut_)) {
Serial.printf("[MP3] Unable to start %s playback.\n", codecLabel(trackCodec));
stop();
nextRetryMs_ = millis() + 1000;
return;
}
Serial.printf("[MP3] Playing %u/%u [%s]: %s\n",
static_cast<unsigned int>(currentTrack_ + 1U),
static_cast<unsigned int>(trackCount_),
codecLabel(trackCodec),
trackPath.c_str());
}
void Mp3Player::stop() {
if (decoder_ != nullptr) {
if (decoder_->isRunning()) {
decoder_->stop();
}
delete decoder_;
decoder_ = nullptr;
}
activeCodec_ = AudioCodec::kUnknown;
if (mp3File_ != nullptr) {
delete mp3File_;
mp3File_ = nullptr;
}
if (i2sOut_ != nullptr) {
delete i2sOut_;
i2sOut_ = nullptr;
}
}
-91
View File
@@ -1,91 +0,0 @@
#pragma once
#include <Arduino.h>
class AudioFileSourceFS;
class AudioGenerator;
class AudioOutputI2S;
enum class AudioCodec : uint8_t {
kUnknown = 0,
kMp3,
kWav,
kAac,
kFlac,
kOpus,
};
enum class RepeatMode : uint8_t {
kAll = 0,
kOne = 1,
};
class Mp3Player {
public:
Mp3Player(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
const char* mp3Path,
int8_t paEnablePin = -1);
~Mp3Player();
void begin();
void update(uint32_t nowMs, bool allowPlayback = true);
void togglePause();
void restartTrack();
void nextTrack();
void previousTrack();
void cycleRepeatMode();
void requestStorageRefresh();
void setGain(float gain);
float gain() const;
uint8_t volumePercent() const;
bool isPaused() const;
bool isSdReady() const;
bool hasTracks() const;
bool isPlaying() const;
uint16_t trackCount() const;
uint16_t currentTrackNumber() const;
String currentTrackName() const;
RepeatMode repeatMode() const;
const char* repeatModeLabel() const;
private:
static constexpr uint16_t kMaxTracks = 64;
bool mountStorage(uint32_t nowMs);
void unmountStorage(uint32_t nowMs);
void refreshStorage(uint32_t nowMs);
void scanTracks();
static bool isSupportedAudioFile(const String& filename);
static AudioCodec codecForPath(const String& filename);
static const char* codecLabel(AudioCodec codec);
void sortTracks();
static AudioGenerator* createDecoder(AudioCodec codec);
void startCurrentTrack();
void stop();
uint8_t i2sBclk_;
uint8_t i2sLrc_;
uint8_t i2sDout_;
int8_t paEnablePin_;
const char* mp3Path_;
bool sdReady_ = false;
bool paused_ = false;
float gain_ = 0.20f;
uint32_t nextMountAttemptMs_ = 0;
uint32_t nextCardCheckMs_ = 0;
uint32_t nextRescanMs_ = 0;
uint32_t nextRetryMs_ = 0;
uint16_t trackCount_ = 0;
uint16_t currentTrack_ = 0;
String tracks_[kMaxTracks];
RepeatMode repeatMode_ = RepeatMode::kAll;
bool forceRescan_ = false;
AudioCodec activeCodec_ = AudioCodec::kUnknown;
AudioGenerator* decoder_ = nullptr;
AudioFileSourceFS* mp3File_ = nullptr;
AudioOutputI2S* i2sOut_ = nullptr;
};
@@ -11,7 +11,8 @@ KeypadAnalog g_keypad(config::kPinKeysAdc);
ScreenLink g_screen(Serial2,
config::kPinScreenTx,
config::kScreenBaud,
config::kScreenUpdatePeriodMs);
config::kScreenUpdatePeriodMs,
config::kScreenChangeMinPeriodMs);
Mp3Player g_mp3(config::kPinI2SBclk,
config::kPinI2SLrc,
config::kPinI2SDout,
@@ -21,6 +22,11 @@ I2sJinglePlayer g_unlockJinglePlayer(config::kPinI2SBclk,
config::kPinI2SLrc,
config::kPinI2SDout,
config::kI2sOutputPort);
AsyncAudioService g_asyncAudio(config::kPinI2SBclk,
config::kPinI2SLrc,
config::kPinI2SDout,
config::kI2sOutputPort,
config::kBootRadioScanChunkMs);
RuntimeMode g_mode = RuntimeMode::kSignal;
bool g_laDetectionEnabled = true;
@@ -3,13 +3,14 @@
#include <Arduino.h>
#include "../config.h"
#include "../i2s_jingle_player.h"
#include "../keypad_analog.h"
#include "../audio/i2s_jingle_player.h"
#include "../services/audio/async_audio_service.h"
#include "../input/keypad_analog.h"
#include "../la_detector.h"
#include "../led_controller.h"
#include "../mp3_player.h"
#include "../screen_link.h"
#include "../sine_dac.h"
#include "../ui/led_controller.h"
#include "../audio/mp3_player.h"
#include "../screen/screen_link.h"
#include "../audio/sine_dac.h"
#include "runtime_mode.h"
extern LedController g_led;
@@ -19,6 +20,7 @@ extern KeypadAnalog g_keypad;
extern ScreenLink g_screen;
extern Mp3Player g_mp3;
extern I2sJinglePlayer g_unlockJinglePlayer;
extern AsyncAudioService g_asyncAudio;
extern RuntimeMode g_mode;
extern bool g_laDetectionEnabled;
@@ -39,9 +41,12 @@ extern UnlockJingleState g_unlockJingle;
struct BootAudioProtocolState {
bool active = false;
bool validated = false;
bool waitingAudio = false;
uint16_t replayCount = 0;
uint32_t startMs = 0;
uint32_t deadlineMs = 0;
uint32_t nextReminderMs = 0;
char cycleSourceTag[24] = {};
char serialCmdBuffer[32] = {};
uint8_t serialCmdLen = 0;
};
@@ -0,0 +1,32 @@
#pragma once
#include <Arduino.h>
struct ScreenFrame {
bool laDetected = false;
bool mp3Playing = false;
bool sdReady = false;
bool mp3Mode = false;
bool uLockMode = false;
bool uLockListening = false;
bool uSonFunctional = false;
uint8_t key = 0;
uint16_t track = 0;
uint16_t trackCount = 0;
uint8_t volumePercent = 0;
uint8_t micLevelPercent = 0;
int8_t tuningOffset = 0;
uint8_t tuningConfidence = 0;
bool micScopeEnabled = false;
uint8_t unlockHoldPercent = 0;
uint8_t startupStage = 0;
uint8_t appStage = 0;
uint8_t uiPage = 0;
uint8_t repeatMode = 0;
bool fxActive = false;
uint8_t backendMode = 0;
bool scanBusy = false;
uint8_t errorCode = 0;
uint32_t sequence = 0;
uint32_t nowMs = 0;
};
@@ -0,0 +1,116 @@
#include "screen_link.h"
#include <cstdio>
ScreenLink::ScreenLink(HardwareSerial& serial,
uint8_t txPin,
uint32_t baud,
uint16_t updatePeriodMs,
uint16_t changeMinPeriodMs)
: serial_(serial),
txPin_(txPin),
baud_(baud),
updatePeriodMs_(updatePeriodMs),
changeMinPeriodMs_(changeMinPeriodMs) {}
void ScreenLink::begin() {
serial_.begin(baud_, SERIAL_8N1, -1, txPin_);
}
void ScreenLink::update(const ScreenFrame& frame) {
const bool changed = !hasState_ || frame.laDetected != lastLa_ || frame.mp3Playing != lastMp3_ ||
frame.sdReady != lastSd_ || frame.mp3Mode != lastMp3Mode_ || frame.key != lastKey_ ||
frame.track != lastTrack_ || frame.trackCount != lastTrackCount_ ||
frame.volumePercent != lastVolumePercent_ ||
frame.micLevelPercent != lastMicLevelPercent_ ||
frame.uLockMode != lastULockMode_ || frame.uLockListening != lastULockListening_ ||
frame.uSonFunctional != lastUSonFunctional_ ||
frame.tuningOffset != lastTuningOffset_ ||
frame.tuningConfidence != lastTuningConfidence_ ||
frame.micScopeEnabled != lastMicScopeEnabled_ ||
frame.unlockHoldPercent != lastUnlockHoldPercent_ ||
frame.startupStage != lastStartupStage_ ||
frame.appStage != lastAppStage_ ||
frame.uiPage != lastUiPage_ ||
frame.repeatMode != lastRepeatMode_ ||
frame.fxActive != lastFxActive_ ||
frame.backendMode != lastBackendMode_ ||
frame.scanBusy != lastScanBusy_ ||
frame.errorCode != lastErrorCode_;
const uint32_t elapsedMs = frame.nowMs - lastTxMs_;
const bool due = elapsedMs >= updatePeriodMs_;
if (!changed && !due) {
return;
}
if (hasState_ && !due && elapsedMs < changeMinPeriodMs_) {
return;
}
char txFrame[232] = {};
const int len = snprintf(txFrame,
sizeof(txFrame),
"STAT,%u,%u,%u,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%u,%u,%u,%u,%u,%u,%u,%lu,%u,%u,%u,%u,%u,%u\n",
frame.laDetected ? 1U : 0U,
frame.mp3Playing ? 1U : 0U,
frame.sdReady ? 1U : 0U,
static_cast<unsigned long>(frame.nowMs),
static_cast<unsigned int>(frame.key),
frame.mp3Mode ? 1U : 0U,
static_cast<unsigned int>(frame.track),
static_cast<unsigned int>(frame.trackCount),
static_cast<unsigned int>(frame.volumePercent),
frame.uLockMode ? 1U : 0U,
frame.uSonFunctional ? 1U : 0U,
static_cast<int>(frame.tuningOffset),
static_cast<unsigned int>(frame.tuningConfidence),
frame.uLockListening ? 1U : 0U,
static_cast<unsigned int>(frame.micLevelPercent),
frame.micScopeEnabled ? 1U : 0U,
static_cast<unsigned int>(frame.unlockHoldPercent),
static_cast<unsigned int>(frame.startupStage),
static_cast<unsigned int>(frame.appStage),
static_cast<unsigned long>(frame.sequence),
static_cast<unsigned int>(frame.uiPage),
static_cast<unsigned int>(frame.repeatMode),
frame.fxActive ? 1U : 0U,
static_cast<unsigned int>(frame.backendMode),
frame.scanBusy ? 1U : 0U,
static_cast<unsigned int>(frame.errorCode));
if (len <= 0) {
return;
}
const int available = serial_.availableForWrite();
if (available >= 0 && available < len) {
return;
}
serial_.write(reinterpret_cast<const uint8_t*>(txFrame), static_cast<size_t>(len));
hasState_ = true;
lastLa_ = frame.laDetected;
lastMp3_ = frame.mp3Playing;
lastSd_ = frame.sdReady;
lastMp3Mode_ = frame.mp3Mode;
lastULockMode_ = frame.uLockMode;
lastULockListening_ = frame.uLockListening;
lastUSonFunctional_ = frame.uSonFunctional;
lastKey_ = frame.key;
lastTrack_ = frame.track;
lastTrackCount_ = frame.trackCount;
lastVolumePercent_ = frame.volumePercent;
lastMicLevelPercent_ = frame.micLevelPercent;
lastTuningOffset_ = frame.tuningOffset;
lastTuningConfidence_ = frame.tuningConfidence;
lastMicScopeEnabled_ = frame.micScopeEnabled;
lastUnlockHoldPercent_ = frame.unlockHoldPercent;
lastStartupStage_ = frame.startupStage;
lastAppStage_ = frame.appStage;
lastUiPage_ = frame.uiPage;
lastRepeatMode_ = frame.repeatMode;
lastFxActive_ = frame.fxActive;
lastBackendMode_ = frame.backendMode;
lastScanBusy_ = frame.scanBusy;
lastErrorCode_ = frame.errorCode;
lastSequence_ = frame.sequence;
lastTxMs_ = frame.nowMs;
}
@@ -2,39 +2,25 @@
#include <Arduino.h>
#include "screen_frame.h"
class ScreenLink {
public:
ScreenLink(HardwareSerial& serial,
uint8_t txPin,
uint32_t baud,
uint16_t updatePeriodMs);
uint16_t updatePeriodMs,
uint16_t changeMinPeriodMs);
void begin();
void update(bool laDetected,
bool mp3Playing,
bool sdReady,
bool mp3Mode,
bool uLockMode,
bool uLockListening,
bool uSonFunctional,
uint8_t key,
uint16_t track,
uint16_t trackCount,
uint8_t volumePercent,
uint8_t micLevelPercent,
int8_t tuningOffset,
uint8_t tuningConfidence,
bool micScopeEnabled,
uint8_t unlockHoldPercent,
uint8_t startupStage,
uint8_t appStage,
uint32_t nowMs);
void update(const ScreenFrame& frame);
private:
HardwareSerial& serial_;
uint8_t txPin_;
uint32_t baud_;
uint16_t updatePeriodMs_;
uint16_t changeMinPeriodMs_;
bool hasState_ = false;
bool lastLa_ = false;
@@ -55,5 +41,12 @@ class ScreenLink {
uint8_t lastUnlockHoldPercent_ = 0;
uint8_t lastStartupStage_ = 0;
uint8_t lastAppStage_ = 0;
uint8_t lastUiPage_ = 0;
uint8_t lastRepeatMode_ = 0;
bool lastFxActive_ = false;
uint8_t lastBackendMode_ = 0;
bool lastScanBusy_ = false;
uint8_t lastErrorCode_ = 0;
uint32_t lastSequence_ = 0;
uint32_t lastTxMs_ = 0;
};
@@ -1,94 +0,0 @@
#include "screen_link.h"
ScreenLink::ScreenLink(HardwareSerial& serial,
uint8_t txPin,
uint32_t baud,
uint16_t updatePeriodMs)
: serial_(serial),
txPin_(txPin),
baud_(baud),
updatePeriodMs_(updatePeriodMs) {}
void ScreenLink::begin() {
serial_.begin(baud_, SERIAL_8N1, -1, txPin_);
}
void ScreenLink::update(bool laDetected,
bool mp3Playing,
bool sdReady,
bool mp3Mode,
bool uLockMode,
bool uLockListening,
bool uSonFunctional,
uint8_t key,
uint16_t track,
uint16_t trackCount,
uint8_t volumePercent,
uint8_t micLevelPercent,
int8_t tuningOffset,
uint8_t tuningConfidence,
bool micScopeEnabled,
uint8_t unlockHoldPercent,
uint8_t startupStage,
uint8_t appStage,
uint32_t nowMs) {
const bool changed = !hasState_ || laDetected != lastLa_ || mp3Playing != lastMp3_ ||
sdReady != lastSd_ || mp3Mode != lastMp3Mode_ || key != lastKey_ ||
track != lastTrack_ || trackCount != lastTrackCount_ ||
volumePercent != lastVolumePercent_ ||
micLevelPercent != lastMicLevelPercent_ ||
uLockMode != lastULockMode_ || uLockListening != lastULockListening_ ||
uSonFunctional != lastUSonFunctional_ ||
tuningOffset != lastTuningOffset_ ||
tuningConfidence != lastTuningConfidence_ ||
micScopeEnabled != lastMicScopeEnabled_ ||
unlockHoldPercent != lastUnlockHoldPercent_ ||
startupStage != lastStartupStage_ ||
appStage != lastAppStage_;
const bool due = (nowMs - lastTxMs_) >= updatePeriodMs_;
if (!changed && !due) {
return;
}
serial_.printf("STAT,%u,%u,%u,%lu,%u,%u,%u,%u,%u,%u,%u,%d,%u,%u,%u,%u,%u,%u,%u\n",
laDetected ? 1U : 0U,
mp3Playing ? 1U : 0U,
sdReady ? 1U : 0U,
static_cast<unsigned long>(nowMs),
static_cast<unsigned int>(key),
mp3Mode ? 1U : 0U,
static_cast<unsigned int>(track),
static_cast<unsigned int>(trackCount),
static_cast<unsigned int>(volumePercent),
uLockMode ? 1U : 0U,
uSonFunctional ? 1U : 0U,
static_cast<int>(tuningOffset),
static_cast<unsigned int>(tuningConfidence),
uLockListening ? 1U : 0U,
static_cast<unsigned int>(micLevelPercent),
micScopeEnabled ? 1U : 0U,
static_cast<unsigned int>(unlockHoldPercent),
static_cast<unsigned int>(startupStage),
static_cast<unsigned int>(appStage));
hasState_ = true;
lastLa_ = laDetected;
lastMp3_ = mp3Playing;
lastSd_ = sdReady;
lastMp3Mode_ = mp3Mode;
lastULockMode_ = uLockMode;
lastULockListening_ = uLockListening;
lastUSonFunctional_ = uSonFunctional;
lastKey_ = key;
lastTrack_ = track;
lastTrackCount_ = trackCount;
lastVolumePercent_ = volumePercent;
lastMicLevelPercent_ = micLevelPercent;
lastTuningOffset_ = tuningOffset;
lastTuningConfidence_ = tuningConfidence;
lastMicScopeEnabled_ = micScopeEnabled;
lastUnlockHoldPercent_ = unlockHoldPercent;
lastStartupStage_ = startupStage;
lastAppStage_ = appStage;
lastTxMs_ = nowMs;
}
@@ -0,0 +1,271 @@
#include "async_audio_service.h"
#include <cctype>
#include <cstdio>
#include <cstring>
#include <AudioFileSourceFS.h>
#include <AudioGenerator.h>
#include <AudioGeneratorAAC.h>
#include <AudioGeneratorFLAC.h>
#include <AudioGeneratorMP3.h>
#include <AudioGeneratorOpus.h>
#include <AudioGeneratorWAV.h>
#include <AudioOutputI2S.h>
AsyncAudioService::AsyncAudioService(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
uint8_t i2sPort,
uint16_t fxChunkMs)
: i2sBclk_(i2sBclk),
i2sLrc_(i2sLrc),
i2sDout_(i2sDout),
i2sPort_(i2sPort),
fxChunkMs_(fxChunkMs) {}
AsyncAudioService::~AsyncAudioService() {
cancel("destructor");
}
void AsyncAudioService::copyTag(char* out, size_t outLen, const char* tag) {
if (out == nullptr || outLen == 0U) {
return;
}
out[0] = '\0';
if (tag == nullptr || tag[0] == '\0') {
return;
}
snprintf(out, outLen, "%s", tag);
}
AsyncAudioService::FsCodec AsyncAudioService::codecFromPath(const char* path) {
if (path == nullptr) {
return FsCodec::kUnknown;
}
const char* dot = strrchr(path, '.');
if (dot == nullptr) {
return FsCodec::kUnknown;
}
char ext[8] = {};
size_t i = 0;
++dot;
while (dot[i] != '\0' && i < (sizeof(ext) - 1U)) {
ext[i] = static_cast<char>(tolower(static_cast<unsigned char>(dot[i])));
++i;
}
ext[i] = '\0';
if (strcmp(ext, "mp3") == 0) {
return FsCodec::kMp3;
}
if (strcmp(ext, "wav") == 0) {
return FsCodec::kWav;
}
if (strcmp(ext, "aac") == 0) {
return FsCodec::kAac;
}
if (strcmp(ext, "flac") == 0) {
return FsCodec::kFlac;
}
if (strcmp(ext, "opus") == 0 || strcmp(ext, "ogg") == 0) {
return FsCodec::kOpus;
}
return FsCodec::kUnknown;
}
AudioGenerator* AsyncAudioService::createDecoder(FsCodec codec) {
switch (codec) {
case FsCodec::kMp3:
return new AudioGeneratorMP3();
case FsCodec::kWav:
return new AudioGeneratorWAV();
case FsCodec::kAac:
return new AudioGeneratorAAC();
case FsCodec::kFlac:
return new AudioGeneratorFLAC();
case FsCodec::kOpus:
return new AudioGeneratorOpus();
default:
return nullptr;
}
}
void AsyncAudioService::cleanupFs() {
if (fsDecoder_ != nullptr) {
fsDecoder_->stop();
delete fsDecoder_;
fsDecoder_ = nullptr;
}
if (fsOutput_ != nullptr) {
fsOutput_->stop();
delete fsOutput_;
fsOutput_ = nullptr;
}
if (fsFile_ != nullptr) {
delete fsFile_;
fsFile_ = nullptr;
}
fsStorage_ = nullptr;
}
void AsyncAudioService::complete(Kind kind, Result result, const char* tag) {
activeKind_ = Kind::kNone;
startMs_ = 0U;
deadlineMs_ = 0U;
activeTag_[0] = '\0';
fx_ = nullptr;
cleanupFs();
event_.kind = kind;
event_.result = result;
copyTag(event_.tag, sizeof(event_.tag), tag);
hasEvent_ = true;
}
bool AsyncAudioService::startFs(fs::FS& storage,
const char* path,
float gain,
uint32_t maxDurationMs,
const char* tag) {
cancel("replace");
if (path == nullptr || path[0] == '\0' || !storage.exists(path)) {
return false;
}
const FsCodec codec = codecFromPath(path);
if (codec == FsCodec::kUnknown) {
return false;
}
fsFile_ = new AudioFileSourceFS(storage, path);
fsOutput_ = new AudioOutputI2S(static_cast<int>(i2sPort_), AudioOutputI2S::EXTERNAL_I2S);
fsOutput_->SetPinout(static_cast<int>(i2sBclk_),
static_cast<int>(i2sLrc_),
static_cast<int>(i2sDout_));
fsOutput_->SetGain(gain);
fsDecoder_ = createDecoder(codec);
if (fsDecoder_ == nullptr) {
cleanupFs();
return false;
}
if (!fsDecoder_->begin(fsFile_, fsOutput_)) {
cleanupFs();
return false;
}
fsStorage_ = &storage;
startMs_ = millis();
deadlineMs_ = (maxDurationMs > 0U) ? (startMs_ + maxDurationMs) : 0U;
activeKind_ = Kind::kFs;
copyTag(activeTag_, sizeof(activeTag_), tag);
return true;
}
bool AsyncAudioService::startFx(FmRadioScanFx& fx,
FmRadioScanFx::Effect effect,
uint32_t durationMs,
float gain,
const char* tag) {
cancel("replace");
if (durationMs == 0U) {
return false;
}
fx.setGain(gain);
if (!fx.start(effect)) {
return false;
}
startMs_ = millis();
deadlineMs_ = startMs_ + durationMs;
activeKind_ = Kind::kFx;
fx_ = &fx;
copyTag(activeTag_, sizeof(activeTag_), tag);
return true;
}
void AsyncAudioService::update(uint32_t nowMs) {
if (activeKind_ == Kind::kNone) {
return;
}
if (activeKind_ == Kind::kFs) {
if (deadlineMs_ != 0U && static_cast<int32_t>(nowMs - deadlineMs_) >= 0) {
complete(Kind::kFs, Result::kTimeout, activeTag_);
return;
}
if (fsDecoder_ == nullptr || !fsDecoder_->isRunning()) {
complete(Kind::kFs, Result::kDone, activeTag_);
return;
}
if (fsDecoder_->loop()) {
return;
}
complete(Kind::kFs, Result::kDone, activeTag_);
return;
}
if (activeKind_ == Kind::kFx) {
if (fx_ == nullptr) {
complete(Kind::kFx, Result::kFailed, activeTag_);
return;
}
if (deadlineMs_ != 0U && static_cast<int32_t>(nowMs - deadlineMs_) >= 0) {
fx_->stop();
complete(Kind::kFx, Result::kDone, activeTag_);
return;
}
if (!fx_->isActive()) {
complete(Kind::kFx, Result::kDone, activeTag_);
return;
}
fx_->update(nowMs, fxChunkMs_);
}
}
void AsyncAudioService::cancel(const char* tag) {
if (activeKind_ == Kind::kNone) {
return;
}
if (activeKind_ == Kind::kFx && fx_ != nullptr) {
fx_->stop();
}
complete(activeKind_, Result::kCanceled, (tag != nullptr) ? tag : activeTag_);
}
bool AsyncAudioService::isBusy() const {
return activeKind_ != Kind::kNone;
}
AsyncAudioService::Kind AsyncAudioService::activeKind() const {
return activeKind_;
}
const char* AsyncAudioService::activeTag() const {
return activeTag_;
}
bool AsyncAudioService::hasEvent() const {
return hasEvent_;
}
AsyncAudioService::Event AsyncAudioService::popEvent() {
Event out = event_;
hasEvent_ = false;
event_ = Event{};
return out;
}
@@ -0,0 +1,101 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
#include "../../audio/fm_radio_scan_fx.h"
class AudioGenerator;
class AudioFileSourceFS;
class AudioOutputI2S;
class AsyncAudioService {
public:
enum class Kind : uint8_t {
kNone = 0,
kFs = 1,
kFx = 2,
};
enum class Result : uint8_t {
kNone = 0,
kDone = 1,
kFailed = 2,
kTimeout = 3,
kCanceled = 4,
};
struct Event {
Kind kind = Kind::kNone;
Result result = Result::kNone;
char tag[24] = {};
};
AsyncAudioService(uint8_t i2sBclk,
uint8_t i2sLrc,
uint8_t i2sDout,
uint8_t i2sPort,
uint16_t fxChunkMs);
~AsyncAudioService();
bool startFs(fs::FS& storage,
const char* path,
float gain,
uint32_t maxDurationMs,
const char* tag);
bool startFx(FmRadioScanFx& fx,
FmRadioScanFx::Effect effect,
uint32_t durationMs,
float gain,
const char* tag);
void update(uint32_t nowMs);
void cancel(const char* tag = nullptr);
bool isBusy() const;
Kind activeKind() const;
const char* activeTag() const;
bool hasEvent() const;
Event popEvent();
private:
enum class FsCodec : uint8_t {
kUnknown = 0,
kMp3 = 1,
kWav = 2,
kAac = 3,
kFlac = 4,
kOpus = 5,
};
static FsCodec codecFromPath(const char* path);
static AudioGenerator* createDecoder(FsCodec codec);
static void copyTag(char* out, size_t outLen, const char* tag);
void cleanupFs();
void complete(Kind kind, Result result, const char* tag);
uint8_t i2sBclk_;
uint8_t i2sLrc_;
uint8_t i2sDout_;
uint8_t i2sPort_;
uint16_t fxChunkMs_;
Kind activeKind_ = Kind::kNone;
uint32_t startMs_ = 0U;
uint32_t deadlineMs_ = 0U;
char activeTag_[24] = {};
fs::FS* fsStorage_ = nullptr;
AudioFileSourceFS* fsFile_ = nullptr;
AudioOutputI2S* fsOutput_ = nullptr;
AudioGenerator* fsDecoder_ = nullptr;
FmRadioScanFx* fx_ = nullptr;
bool hasEvent_ = false;
Event event_;
};
@@ -0,0 +1,188 @@
#include "audio_service.h"
#include <cstdio>
#include <cstring>
namespace {
AudioEffectId toEffect(FmRadioScanFx::Effect effect) {
return static_cast<AudioEffectId>(effect);
}
FmRadioScanFx::Effect toFmEffect(AudioEffectId effect) {
return static_cast<FmRadioScanFx::Effect>(effect);
}
} // namespace
AudioService::AudioService(AsyncAudioService& baseAsync, FmRadioScanFx& baseFx, Mp3Player& mp3)
: baseAsync_(baseAsync), baseFx_(baseFx), mp3_(mp3) {}
void AudioService::copyTag(char* out, size_t outLen, const char* tag) {
if (out == nullptr || outLen == 0U) {
return;
}
out[0] = '\0';
if (tag == nullptr || tag[0] == '\0') {
return;
}
snprintf(out, outLen, "%s", tag);
}
AudioService::Result AudioService::mapBaseResult(AsyncAudioService::Result result) {
switch (result) {
case AsyncAudioService::Result::kDone:
return Result::kDone;
case AsyncAudioService::Result::kTimeout:
return Result::kTimeout;
case AsyncAudioService::Result::kFailed:
return Result::kFailed;
case AsyncAudioService::Result::kCanceled:
return Result::kCanceled;
case AsyncAudioService::Result::kNone:
default:
return Result::kNone;
}
}
bool AudioService::startBaseFs(fs::FS& storage,
const char* path,
float gain,
uint32_t timeoutMs,
const char* tag) {
if (!baseAsync_.startFs(storage, path, gain, timeoutMs, tag)) {
base_.lastResult = Result::kFailed;
return false;
}
base_.active = true;
base_.fsSource = true;
base_.lastResult = Result::kStarted;
base_.remainingMs = timeoutMs;
base_.effect = AudioEffectId::kFmSweep;
copyTag(base_.tag, sizeof(base_.tag), tag);
return true;
}
bool AudioService::startBaseFx(AudioEffectId effect,
float gain,
uint32_t durationMs,
const char* tag) {
if (!baseAsync_.startFx(baseFx_, toFmEffect(effect), durationMs, gain, tag)) {
base_.lastResult = Result::kFailed;
return false;
}
base_.active = true;
base_.fsSource = false;
base_.effect = effect;
base_.lastResult = Result::kStarted;
base_.remainingMs = durationMs;
copyTag(base_.tag, sizeof(base_.tag), tag);
return true;
}
bool AudioService::startOverlayFx(AudioEffectId effect,
float gain,
uint32_t durationMs,
const char* tag) {
if (!mp3_.isPlaying()) {
overlay_.lastResult = Result::kFailed;
return false;
}
mp3_.setFxOverlayGain(gain);
if (!mp3_.triggerFx(effect, durationMs)) {
overlay_.lastResult = Result::kFailed;
return false;
}
overlay_.active = true;
overlay_.fsSource = false;
overlay_.effect = effect;
overlay_.remainingMs = durationMs;
overlay_.lastResult = Result::kStarted;
overlayDeadlineMs_ = millis() + durationMs;
copyTag(overlay_.tag, sizeof(overlay_.tag), tag);
return true;
}
void AudioService::stopBase(const char* reason) {
if (!baseAsync_.isBusy()) {
return;
}
baseAsync_.cancel(reason);
base_.active = false;
base_.remainingMs = 0U;
base_.lastResult = Result::kCanceled;
}
void AudioService::stopOverlay(const char* reason) {
(void)reason;
if (!mp3_.isFxActive() && !overlay_.active) {
return;
}
mp3_.stopFx();
overlay_.active = false;
overlay_.remainingMs = 0U;
overlay_.lastResult = Result::kCanceled;
overlayDeadlineMs_ = 0U;
}
void AudioService::stopAll(const char* reason) {
stopOverlay(reason);
stopBase(reason);
}
void AudioService::update(uint32_t nowMs) {
baseAsync_.update(nowMs);
if (baseAsync_.hasEvent()) {
const AsyncAudioService::Event event = baseAsync_.popEvent();
base_.active = false;
base_.remainingMs = 0U;
base_.lastResult = mapBaseResult(event.result);
if (event.kind == AsyncAudioService::Kind::kFx) {
base_.fsSource = false;
base_.effect = toEffect(baseFx_.effect());
}
if (event.tag[0] != '\0') {
copyTag(base_.tag, sizeof(base_.tag), event.tag);
}
}
if (base_.active && base_.remainingMs > 0U) {
base_.remainingMs = base_.remainingMs - ((base_.remainingMs > 8U) ? 8U : base_.remainingMs);
}
const bool fxActive = mp3_.isFxActive();
if (!fxActive && overlay_.active) {
overlay_.active = false;
overlay_.remainingMs = 0U;
overlay_.lastResult = Result::kDone;
overlayDeadlineMs_ = 0U;
} else if (fxActive) {
overlay_.active = true;
overlay_.remainingMs = mp3_.fxRemainingMs();
if (overlayDeadlineMs_ != 0U && static_cast<int32_t>(nowMs - overlayDeadlineMs_) >= 0) {
overlay_.active = false;
overlay_.remainingMs = 0U;
overlay_.lastResult = Result::kDone;
overlayDeadlineMs_ = 0U;
}
}
}
AudioService::AudioSnapshot AudioService::snapshot() const {
AudioSnapshot out;
out.base = base_;
out.overlay = overlay_;
return out;
}
bool AudioService::isBaseBusy() const {
return baseAsync_.isBusy();
}
bool AudioService::isOverlayBusy() const {
return overlay_.active;
}
@@ -0,0 +1,78 @@
#pragma once
#include <Arduino.h>
#include <FS.h>
#include "../../audio/effects/audio_effect_id.h"
#include "../../audio/fm_radio_scan_fx.h"
#include "../../audio/mp3_player.h"
#include "async_audio_service.h"
class AudioService {
public:
enum class Channel : uint8_t {
kBase = 0,
kOverlay,
};
enum class Result : uint8_t {
kNone = 0,
kStarted,
kDone,
kTimeout,
kFailed,
kCanceled,
};
struct ChannelSnapshot {
bool active = false;
bool fsSource = false;
AudioEffectId effect = AudioEffectId::kFmSweep;
uint32_t remainingMs = 0;
Result lastResult = Result::kNone;
char tag[24] = {};
};
struct AudioSnapshot {
ChannelSnapshot base;
ChannelSnapshot overlay;
};
AudioService(AsyncAudioService& baseAsync, FmRadioScanFx& baseFx, Mp3Player& mp3);
bool startBaseFs(fs::FS& storage,
const char* path,
float gain,
uint32_t timeoutMs,
const char* tag);
bool startBaseFx(AudioEffectId effect,
float gain,
uint32_t durationMs,
const char* tag);
bool startOverlayFx(AudioEffectId effect,
float gain,
uint32_t durationMs,
const char* tag);
void stopBase(const char* reason);
void stopOverlay(const char* reason);
void stopAll(const char* reason);
void update(uint32_t nowMs);
AudioSnapshot snapshot() const;
bool isBaseBusy() const;
bool isOverlayBusy() const;
private:
static Result mapBaseResult(AsyncAudioService::Result result);
static void copyTag(char* out, size_t outLen, const char* tag);
AsyncAudioService& baseAsync_;
FmRadioScanFx& baseFx_;
Mp3Player& mp3_;
ChannelSnapshot base_;
ChannelSnapshot overlay_;
uint32_t overlayDeadlineMs_ = 0U;
};
@@ -0,0 +1,35 @@
#include "input_service.h"
InputService::InputService(KeypadAnalog& keypad) : keypad_(keypad) {}
void InputService::begin() {
keypad_.begin();
}
void InputService::update(uint32_t nowMs) {
keypad_.update(nowMs);
}
bool InputService::consumePress(KeyEvent* event) {
if (event == nullptr) {
return false;
}
uint8_t key = 0U;
uint16_t raw = 0U;
if (!keypad_.consumePress(&key, &raw)) {
return false;
}
event->key = key;
event->raw = raw;
return true;
}
uint16_t InputService::lastRaw() const {
return keypad_.lastRaw();
}
uint8_t InputService::stableKey() const {
return keypad_.currentKey();
}
@@ -0,0 +1,24 @@
#pragma once
#include <Arduino.h>
#include "../../input/keypad_analog.h"
struct KeyEvent {
uint8_t key = 0;
uint16_t raw = 0;
};
class InputService {
public:
explicit InputService(KeypadAnalog& keypad);
void begin();
void update(uint32_t nowMs);
bool consumePress(KeyEvent* event);
uint16_t lastRaw() const;
uint8_t stableKey() const;
private:
KeypadAnalog& keypad_;
};
@@ -0,0 +1,58 @@
#include "serial_router.h"
#include <cctype>
#include <cstring>
SerialRouter::SerialRouter(HardwareSerial& serial) : serial_(serial) {}
void SerialRouter::setDispatcher(DispatchFn dispatcher, void* ctx) {
dispatcher_ = dispatcher;
dispatcherCtx_ = ctx;
}
void SerialRouter::normalize(char* line) {
if (line == nullptr) {
return;
}
size_t start = 0U;
while (line[start] != '\0' && isspace(static_cast<unsigned char>(line[start])) != 0) {
++start;
}
size_t end = strlen(line);
while (end > start && isspace(static_cast<unsigned char>(line[end - 1U])) != 0) {
--end;
}
size_t dst = 0U;
for (size_t i = start; i < end; ++i) {
line[dst++] = static_cast<char>(toupper(static_cast<unsigned char>(line[i])));
}
line[dst] = '\0';
}
void SerialRouter::update(uint32_t nowMs) {
while (serial_.available() > 0) {
const char c = static_cast<char>(serial_.read());
if (c == '\r') {
continue;
}
if (c == '\n') {
buffer_[len_] = '\0';
normalize(buffer_);
if (buffer_[0] != '\0' && dispatcher_ != nullptr) {
dispatcher_(buffer_, nowMs, dispatcherCtx_);
}
len_ = 0U;
continue;
}
if (len_ < (sizeof(buffer_) - 1U)) {
buffer_[len_++] = c;
} else {
len_ = 0U;
}
}
}
@@ -0,0 +1,22 @@
#pragma once
#include <Arduino.h>
class SerialRouter {
public:
using DispatchFn = void (*)(const char* cmd, uint32_t nowMs, void* ctx);
explicit SerialRouter(HardwareSerial& serial);
void setDispatcher(DispatchFn dispatcher, void* ctx);
void update(uint32_t nowMs);
private:
static void normalize(char* line);
HardwareSerial& serial_;
DispatchFn dispatcher_ = nullptr;
void* dispatcherCtx_ = nullptr;
char buffer_[192] = {};
uint8_t len_ = 0;
};
@@ -0,0 +1,164 @@
#include "story_engine.h"
#include <cstdio>
StoryEngine::StoryEngine(const Options& options)
: options_(options),
testDelayMs_(options.etape2TestDelayMs) {}
void StoryEngine::recomputeDueFrom(uint32_t nowMs) {
const uint32_t delayMs = activeDelayMs();
unlockMs_ = nowMs;
etape2DueMs_ = nowMs + delayMs;
}
void StoryEngine::reset(const char* source) {
unlockArmed_ = false;
winPlayed_ = false;
winAudioPlayed_ = false;
etape2Played_ = false;
unlockMs_ = 0U;
etape2DueMs_ = 0U;
Serial.printf("[STORY] reset (%s)\n", source);
}
void StoryEngine::armAfterUnlock(uint32_t nowMs, const char* source) {
unlockArmed_ = true;
winPlayed_ = false;
winAudioPlayed_ = false;
etape2Played_ = false;
recomputeDueFrom(nowMs);
Serial.printf("[STORY] unlock armed (%s): ETAPE_2 due in %lus%s\n",
source,
static_cast<unsigned long>(activeDelayMs() / 1000UL),
testMode_ ? " [TEST_MODE]" : "");
}
bool StoryEngine::isMp3GateOpen() const {
return !unlockArmed_ || etape2Played_;
}
void StoryEngine::markWinPlayed(uint32_t nowMs, bool audioPlayed, const char* source) {
(void)nowMs;
winPlayed_ = true;
winAudioPlayed_ = audioPlayed;
Serial.printf("[STORY] WIN done (%s) audio=%u\n", source, audioPlayed ? 1U : 0U);
}
bool StoryEngine::shouldTriggerEtape2(uint32_t nowMs) const {
if (!unlockArmed_ || !winPlayed_ || etape2Played_) {
return false;
}
return static_cast<int32_t>(nowMs - etape2DueMs_) >= 0;
}
void StoryEngine::markEtape2Played(uint32_t nowMs, bool audioPlayed, const char* source) {
(void)nowMs;
etape2Played_ = true;
Serial.printf("[STORY] ETAPE_2 done (%s) audio=%u\n",
source,
audioPlayed ? 1U : 0U);
}
void StoryEngine::forceEtape2DueNow(uint32_t nowMs, const char* source) {
if (!unlockArmed_) {
Serial.printf("[STORY] force due ignored (%s): unlock not armed.\n", source);
return;
}
if (!winPlayed_) {
winPlayed_ = true;
winAudioPlayed_ = false;
Serial.printf("[STORY] force due (%s): WIN bypassed.\n", source);
}
etape2DueMs_ = nowMs;
Serial.printf("[STORY] force due now (%s).\n", source);
}
void StoryEngine::setTestMode(bool enabled, uint32_t nowMs, const char* source) {
if (testMode_ == enabled) {
Serial.printf("[STORY] test mode unchanged (%s): %s\n",
source,
testMode_ ? "ON" : "OFF");
return;
}
testMode_ = enabled;
if (unlockArmed_ && !etape2Played_) {
recomputeDueFrom(nowMs);
}
Serial.printf("[STORY] test mode %s (%s), delay=%lums\n",
testMode_ ? "ON" : "OFF",
source,
static_cast<unsigned long>(activeDelayMs()));
}
void StoryEngine::setTestDelayMs(uint32_t delayMs, uint32_t nowMs, const char* source) {
if (delayMs < 100U) {
delayMs = 100U;
} else if (delayMs > 300000U) {
delayMs = 300000U;
}
testDelayMs_ = delayMs;
if (testMode_ && unlockArmed_ && !etape2Played_) {
recomputeDueFrom(nowMs);
}
Serial.printf("[STORY] test delay set %lums (%s)\n",
static_cast<unsigned long>(testDelayMs_),
source);
}
void StoryEngine::printStatus(uint32_t nowMs, const char* source) const {
uint32_t leftMs = 0U;
if (unlockArmed_ && !etape2Played_ && static_cast<int32_t>(etape2DueMs_ - nowMs) > 0) {
leftMs = etape2DueMs_ - nowMs;
}
const char* stage = "WAIT_UNLOCK";
if (unlockArmed_ && !winPlayed_) {
stage = "WIN_PENDING";
} else if (unlockArmed_ && winPlayed_ && !etape2Played_) {
stage = "WAIT_ETAPE2";
} else if (etape2Played_) {
stage = "ETAPE2_DONE";
}
Serial.printf("[STORY] STATUS via=%s stage=%s armed=%u win=%u win_audio=%u etape2=%u test=%u delay=%lus left=%lus\n",
source,
stage,
unlockArmed_ ? 1U : 0U,
winPlayed_ ? 1U : 0U,
winAudioPlayed_ ? 1U : 0U,
etape2Played_ ? 1U : 0U,
testMode_ ? 1U : 0U,
static_cast<unsigned long>(activeDelayMs() / 1000UL),
static_cast<unsigned long>(leftMs / 1000UL));
}
bool StoryEngine::unlockArmed() const {
return unlockArmed_;
}
bool StoryEngine::winPlayed() const {
return winPlayed_;
}
bool StoryEngine::winAudioPlayed() const {
return winAudioPlayed_;
}
bool StoryEngine::etape2Played() const {
return etape2Played_;
}
bool StoryEngine::testMode() const {
return testMode_;
}
uint32_t StoryEngine::unlockMs() const {
return unlockMs_;
}
uint32_t StoryEngine::dueMs() const {
return etape2DueMs_;
}
uint32_t StoryEngine::activeDelayMs() const {
return testMode_ ? testDelayMs_ : options_.etape2DelayMs;
}
@@ -0,0 +1,49 @@
#pragma once
#include <Arduino.h>
class StoryEngine {
public:
struct Options {
uint32_t etape2DelayMs = 15UL * 60UL * 1000UL;
uint32_t etape2TestDelayMs = 5000U;
};
explicit StoryEngine(const Options& options);
void reset(const char* source);
void armAfterUnlock(uint32_t nowMs, const char* source);
bool isMp3GateOpen() const;
void markWinPlayed(uint32_t nowMs, bool audioPlayed, const char* source);
bool shouldTriggerEtape2(uint32_t nowMs) const;
void markEtape2Played(uint32_t nowMs, bool audioPlayed, const char* source);
void forceEtape2DueNow(uint32_t nowMs, const char* source);
void setTestMode(bool enabled, uint32_t nowMs, const char* source);
void setTestDelayMs(uint32_t delayMs, uint32_t nowMs, const char* source);
void printStatus(uint32_t nowMs, const char* source) const;
bool unlockArmed() const;
bool winPlayed() const;
bool winAudioPlayed() const;
bool etape2Played() const;
bool testMode() const;
uint32_t unlockMs() const;
uint32_t dueMs() const;
uint32_t activeDelayMs() const;
private:
void recomputeDueFrom(uint32_t nowMs);
Options options_;
bool unlockArmed_ = false;
bool winPlayed_ = false;
bool winAudioPlayed_ = false;
bool etape2Played_ = false;
bool testMode_ = false;
uint32_t unlockMs_ = 0;
uint32_t etape2DueMs_ = 0;
uint32_t testDelayMs_ = 5000U;
};
@@ -0,0 +1,195 @@
#include "player_ui_model.h"
namespace {
constexpr uint16_t kBrowserPageSize = 5;
} // namespace
const char* playerUiPageLabel(PlayerUiPage page) {
switch (page) {
case PlayerUiPage::kBrowser:
return "BROWSE";
case PlayerUiPage::kQueue:
return "QUEUE";
case PlayerUiPage::kSettings:
return "SET";
case PlayerUiPage::kNowPlaying:
default:
return "NOW";
}
}
void PlayerUiModel::reset() {
page_ = PlayerUiPage::kNowPlaying;
browserCount_ = 0;
cursor_ = 0;
offset_ = 0;
dirty_ = true;
}
void PlayerUiModel::setPage(PlayerUiPage page) {
if (page_ == page) {
return;
}
page_ = page;
clampBrowser();
dirty_ = true;
}
void PlayerUiModel::setBrowserBounds(uint16_t count) {
browserCount_ = count;
clampBrowser();
}
void PlayerUiModel::applyAction(const UiAction& action) {
if (action.hasTargetPage) {
setPage(action.targetPage);
return;
}
if (action.source == UiActionSource::kSerial) {
return;
}
const bool isLong = (action.source == UiActionSource::kKeyLong);
switch (action.key) {
case 2:
if (isLong) {
moveCursor(-1);
} else if (page_ != PlayerUiPage::kNowPlaying) {
moveCursor(-1);
}
break;
case 3:
if (isLong) {
moveCursor(1);
} else if (page_ != PlayerUiPage::kNowPlaying) {
moveCursor(1);
}
break;
case 6:
if (isLong) {
nextPage();
} else {
prevPage();
}
break;
default:
break;
}
}
PlayerUiSnapshot PlayerUiModel::snapshot() const {
PlayerUiSnapshot out;
out.page = page_;
out.cursor = cursor_;
out.offset = offset_;
out.dirty = dirty_;
return out;
}
PlayerUiPage PlayerUiModel::page() const {
return page_;
}
uint16_t PlayerUiModel::cursor() const {
return cursor_;
}
uint16_t PlayerUiModel::offset() const {
return offset_;
}
bool PlayerUiModel::consumeDirty() {
const bool current = dirty_;
dirty_ = false;
return current;
}
void PlayerUiModel::clampBrowser() {
if (page_ != PlayerUiPage::kBrowser) {
cursor_ = 0;
offset_ = 0;
return;
}
if (browserCount_ == 0U) {
cursor_ = 0;
offset_ = 0;
return;
}
if (cursor_ >= browserCount_) {
cursor_ = static_cast<uint16_t>(browserCount_ - 1U);
dirty_ = true;
}
if (cursor_ < offset_) {
offset_ = cursor_;
dirty_ = true;
} else if (cursor_ >= static_cast<uint16_t>(offset_ + kBrowserPageSize)) {
offset_ = static_cast<uint16_t>(cursor_ - (kBrowserPageSize - 1U));
dirty_ = true;
}
}
void PlayerUiModel::moveCursor(int16_t delta) {
if (page_ != PlayerUiPage::kBrowser || browserCount_ == 0U) {
return;
}
int32_t next = static_cast<int32_t>(cursor_) + static_cast<int32_t>(delta);
if (next < 0) {
next = 0;
} else if (next >= static_cast<int32_t>(browserCount_)) {
next = static_cast<int32_t>(browserCount_ - 1U);
}
if (static_cast<uint16_t>(next) != cursor_) {
cursor_ = static_cast<uint16_t>(next);
dirty_ = true;
}
clampBrowser();
}
void PlayerUiModel::nextPage() {
const PlayerUiPage previous = page_;
switch (page_) {
case PlayerUiPage::kNowPlaying:
page_ = PlayerUiPage::kBrowser;
break;
case PlayerUiPage::kBrowser:
page_ = PlayerUiPage::kQueue;
break;
case PlayerUiPage::kQueue:
page_ = PlayerUiPage::kSettings;
break;
case PlayerUiPage::kSettings:
default:
page_ = PlayerUiPage::kNowPlaying;
break;
}
if (page_ != previous) {
dirty_ = true;
clampBrowser();
}
}
void PlayerUiModel::prevPage() {
const PlayerUiPage previous = page_;
switch (page_) {
case PlayerUiPage::kNowPlaying:
page_ = PlayerUiPage::kSettings;
break;
case PlayerUiPage::kBrowser:
page_ = PlayerUiPage::kNowPlaying;
break;
case PlayerUiPage::kQueue:
page_ = PlayerUiPage::kBrowser;
break;
case PlayerUiPage::kSettings:
default:
page_ = PlayerUiPage::kQueue;
break;
}
if (page_ != previous) {
dirty_ = true;
clampBrowser();
}
}
@@ -0,0 +1,58 @@
#pragma once
#include <Arduino.h>
enum class PlayerUiPage : uint8_t {
kNowPlaying = 0,
kBrowser = 1,
kQueue = 2,
kSettings = 3,
};
enum class UiActionSource : uint8_t {
kKeyShort = 0,
kKeyLong = 1,
kSerial = 2,
};
struct UiAction {
UiActionSource source = UiActionSource::kKeyShort;
uint8_t key = 0;
PlayerUiPage targetPage = PlayerUiPage::kNowPlaying;
bool hasTargetPage = false;
};
struct PlayerUiSnapshot {
PlayerUiPage page = PlayerUiPage::kNowPlaying;
uint16_t cursor = 0;
uint16_t offset = 0;
bool dirty = false;
};
const char* playerUiPageLabel(PlayerUiPage page);
class PlayerUiModel {
public:
void reset();
void setPage(PlayerUiPage page);
void setBrowserBounds(uint16_t count);
void applyAction(const UiAction& action);
PlayerUiSnapshot snapshot() const;
PlayerUiPage page() const;
uint16_t cursor() const;
uint16_t offset() const;
bool consumeDirty();
private:
void clampBrowser();
void moveCursor(int16_t delta);
void nextPage();
void prevPage();
PlayerUiPage page_ = PlayerUiPage::kNowPlaying;
uint16_t browserCount_ = 0;
uint16_t cursor_ = 0;
uint16_t offset_ = 0;
bool dirty_ = true;
};
+1 -1
View File
@@ -14,4 +14,4 @@ Bonus :
- `stations/` (48 stations possibles, modulaires)
Exports :
- `export/pdf/` (si tu ajoutes des versions PDF)
- `export/pdf/` (dossier prêt pour les versions PDF imprimables)
+8
View File
@@ -0,0 +1,8 @@
# Exports PDF — Kit Maître du jeu
Ce dossier accueille les versions PDF prêtes à imprimer des documents du kit MJ.
Recommandations:
- nommer les fichiers avec une version (`v1`, `v2`, ...),
- garder une correspondance claire avec les sources Markdown,
- ajouter une entrée dans `CHANGELOG.md` lors d'un ajout/modification.
+5
View File
@@ -16,3 +16,8 @@ Exemples :
- `regles-1-page-a4-v1.pdf`
Ordre dimpression : voir `ordre-dimpression.md`.
## État actuel
- Structure prête : `src/`, `export/pdf/`, `export/png/`.
- Invitations initialisées : `invitations/src/` et `invitations/export/{pdf,png}/`.
- Aucun asset image versionné pour l'instant (préviews à ajouter au fil des PR).
+10
View File
@@ -0,0 +1,10 @@
# Exports PDF prêts à imprimer
Ce dossier contient les PDF finaux.
Convention recommandée:
- format : `a6` / `a5` / `a4`
- faces : `recto` / `verso`
- version : `v1`, `v2`, ...
Exemple: `invite-a6-recto-v1.pdf`
+7
View File
@@ -0,0 +1,7 @@
# Previews PNG
Ce dossier contient des aperçus PNG des documents imprimables.
Objectif:
- faciliter la revue visuelle en PR,
- valider lisibilité, contraste et hiérarchie avant impression.
+5
View File
@@ -8,3 +8,8 @@ Dossiers :
Convention de nommage (recommandée) :
- PDF : `invite-a6-recto-v1.pdf`, `invite-a6-verso-v1.pdf`
- PNG : `invite-a6-recto-v1.png`, `invite-a6-verso-v1.png`
État actuel :
- arborescence prête,
- exports à ajouter selon les prochaines versions graphiques.
View File
+8
View File
@@ -0,0 +1,8 @@
# Sources éditables (printables)
Déposer ici les fichiers sources (ex: SVG, ODG, AI, etc.) qui servent à générer les exports.
Bonnes pratiques:
- conserver un fichier source par livrable,
- indiquer la version dans le nom (`v1`, `v2`, ...),
- privilégier une mise en page lisible en noir & blanc.