Refonte firmware + scénario story + réorganisation fichiers
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
Kit d’enquête pour anniversaire (9–11 ans), ambiance laboratoire / campus scientifique.
|
||||
Style visuel : noir & blanc, graphique, lisible, élégant, homogène.
|
||||
|
||||
## Aperçu visuel
|
||||

|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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",
|
||||
|
||||
+1410
-183
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";
|
||||
}
|
||||
}
|
||||
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
#include "keypad_analog.h"
|
||||
|
||||
#include "config.h"
|
||||
#include "../config.h"
|
||||
|
||||
KeypadAnalog::KeypadAnalog(uint8_t adcPin) : adcPin_(adcPin), thresholds_(defaultThresholds()) {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
+13
-20
@@ -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;
|
||||
};
|
||||
@@ -14,4 +14,4 @@ Bonus :
|
||||
- `stations/` (4–8 stations possibles, modulaires)
|
||||
|
||||
Exports :
|
||||
- `export/pdf/` (si tu ajoutes des versions PDF)
|
||||
- `export/pdf/` (dossier prêt pour les versions PDF imprimables)
|
||||
|
||||
@@ -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.
|
||||
@@ -16,3 +16,8 @@ Exemples :
|
||||
- `regles-1-page-a4-v1.pdf`
|
||||
|
||||
Ordre d’impression : 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).
|
||||
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user