Hotline ringback + Freenove U_SON playlist and LEFOU sequence

This commit is contained in:
Clément SAILLANT
2026-02-28 06:31:59 +01:00
parent cca9e1fbb6
commit 0aee3ca834
12 changed files with 4630 additions and 392 deletions
+79 -28
View File
@@ -37,8 +37,8 @@ scenes:
show_symbol: true
effect_speed_ms: 220
theme:
bg: '#04030A'
accent: '#F35DFF'
bg: '#020208'
accent: '#FF9C3A'
text: '#FFFFFF'
transition:
effect: glitch
@@ -47,21 +47,21 @@ scenes:
effect: text_glitch
timeline:
loop: true
duration_ms: 30000
duration_ms: 36000
keyframes:
- at_ms: 0
effect: tunnel3d
speed_ms: 15000
speed_ms: 18000
theme:
bg: '#06040B'
accent: '#F35DFF'
bg: '#03030A'
accent: '#FF9C3A'
text: '#FFFFFF'
- at_ms: 15000
- at_ms: 18000
effect: rotozoom
speed_ms: 15000
speed_ms: 18000
theme:
bg: '#080513'
accent: '#EF7FE7'
bg: '#050510'
accent: '#52E67A'
text: '#FFFFFF'
text:
show_title: true
@@ -72,14 +72,15 @@ scenes:
symbol_align: top
title_align: center
subtitle_align: bottom
title_font_face: ibm_bold_24
title_font_face: bungee_24
subtitle_font_face: ibm_bold_16
symbol_font_face: orbitron_28
glitch: 42
size: 50
size: 88
render:
text_backend: lgfx_overlay
disable_lvgl_text: true
lgfx_hard_mode: true
foreground_fx: text_glitch
wave: false
hardware:
@@ -101,12 +102,14 @@ scenes:
audio:
ambient_random:
enabled: true
mode: random_file
playlist_root: /music/v8_pack
track: /music/boot_radio.mp3
non_interruptive: true
volume_min: 21
volume_max: 21
delay_min_ms: 9000
delay_max_ms: 26000
delay_min_ms: 60000
delay_max_ms: 240000
eq_profiles: []
- scene_id: SCENE_LA_DETECTOR
screen_json: hardware/firmware/data/story/screens/SCENE_LA_DETECTOR.json
@@ -124,13 +127,13 @@ scenes:
effect_speed_ms: 960
waveform:
enabled: true
sample_count: 16
sample_count: 12
amplitude_pct: 95
jitter: true
theme:
bg: '#041F1B'
accent: '#2CE5A6'
text: '#EFFFF8'
accent: '#4ABEFF'
text: '#FFFFFF'
transition:
effect: slide_up
duration_ms: 220
@@ -161,7 +164,7 @@ scenes:
la_detector:
caption: Balayage en cours
show_progress_ring: true
show_hourglass: true
show_hourglass: false
show_caption: false
show_pitch_text: false
caption_font: 1
@@ -170,20 +173,33 @@ scenes:
progress_layout: ring
hourglass_style: modern
caption_font_face: ibm_bold_16
flip_on_timeout: false
reset_flip_ms: 3000
background_preset: hourglass_demoscene_ultra
background_sync: mic_smoothed
background_intensity_pct: 32
hourglass_x_offset_px: -25
hourglass_height_px: 150
hourglass_width_px: 80
bargraph_palette: blue
bargraph_peak_hold_ms: 900
bargraph_decay_per_s: 90
waveform_mode: audio_player
waveform_window_ms: 220
- scene_id: SCENE_WIN_ETAPE1
screen_json: hardware/firmware/data/story/screens/SCENE_WIN_ETAPE1.json
default_audio_pack_id: PACK_CONFIRM_WIN_ETAPE1
runtime_step_ids:
- WIN_ETAPE1
lvgl:
title: WIN ETAPE 1
title: VICTORY
subtitle: Validation distante confirmee
symbol: WIN
visual:
show_title: true
show_subtitle: true
show_symbol: true
effect_speed_ms: 260
effect_speed_ms: 280
theme:
bg: '#0A1027'
accent: '#6AD4FF'
@@ -192,29 +208,40 @@ scenes:
effect: fade
duration_ms: 220
fx:
effect: tunnel3d
effect: celebrate
timeline:
loop: true
duration_ms: 30000
duration_ms: 60000
keyframes:
- at_ms: 0
effect: tunnel3d
speed_ms: 15000
effect: celebrate
speed_ms: 20000
theme:
bg: '#0A1027'
accent: '#6AD4FF'
text: '#F4FAFF'
- at_ms: 15000
effect: rotozoom
speed_ms: 15000
- at_ms: 20000
effect: winner
speed_ms: 20000
theme:
bg: '#11193B'
accent: '#8DE6FF'
text: '#FFFFFF'
- at_ms: 40000
effect: none
speed_ms: 20000
theme:
bg: '#050914'
accent: '#7EDBFF'
text: '#F4FAFF'
render:
text_backend: lgfx_overlay
disable_lvgl_text: true
wave: false
text:
title_font_face: bungee_24
subtitle_font_face: ibm_bold_20
size: 100
- scene_id: SCENE_WARNING
screen_json: hardware/firmware/data/story/screens/SCENE_WARNING.json
default_audio_pack_id: PACK_BOOT_RADIO
@@ -232,7 +259,7 @@ scenes:
theme:
bg: '#261209'
accent: '#FF9A4A'
text: '#FFF2E6'
text: '#FFFFFF'
transition:
effect: fade
duration_ms: 200
@@ -259,6 +286,22 @@ scenes:
render:
text_backend: lgfx_overlay
disable_lvgl_text: true
warning:
lgfx_only: true
siren: true
gyrophare:
enabled: true
fps: 25
speed_deg_per_sec: 180
beam_width_deg: 70
message: SIGNAL ANORMAL
disable_direct_fx: true
text:
title_align: center
subtitle_align: center
title_font_face: bungee_24
subtitle_font_face: ibm_bold_20
size: 92
- scene_id: SCENE_LEFOU_DETECTOR
screen_json: hardware/firmware/data/story/screens/SCENE_LEFOU_DETECTOR.json
default_audio_pack_id: ''
@@ -303,6 +346,14 @@ scenes:
render:
text_backend: lgfx_overlay
disable_lvgl_text: true
lefou_detector:
note_sequence_hz:
- 440
- 880
- 330
- 349
- 147
- 98
- scene_id: SCENE_WIN_ETAPE2
screen_json: hardware/firmware/data/story/screens/SCENE_WIN_ETAPE2.json
default_audio_pack_id: PACK_CONFIRM_WIN_ETAPE2
@@ -6,8 +6,8 @@
"effect_speed_ms": 960,
"theme": {
"bg": "#041F1B",
"accent": "#2CE5A6",
"text": "#EFFFF8"
"accent": "#4ABEFF",
"text": "#FFFFFF"
},
"render": {
"text_backend": "lgfx_overlay",
@@ -16,14 +16,27 @@
"la_detector": {
"caption": "Balayage en cours",
"show_progress_ring": true,
"show_hourglass": true,
"show_hourglass": false,
"background_preset": "hourglass_demoscene_ultra",
"background_sync": "mic_smoothed",
"background_intensity_pct": 32,
"hourglass_x_offset_px": -25,
"hourglass_height_px": 150,
"hourglass_width_px": 80,
"bargraph_palette": "blue",
"bargraph_peak_hold_ms": 900,
"bargraph_decay_per_s": 90,
"waveform_mode": "audio_player",
"waveform_window_ms": 220,
"show_caption": false,
"show_pitch_text": false,
"caption_font_face": "ibm_bold_16",
"caption_size": 1,
"meter_layout": "bottom_horizontal",
"progress_layout": "ring",
"hourglass_style": "modern"
"hourglass_style": "modern",
"flip_on_timeout": false,
"reset_flip_ms": 3000
}
},
"transition": {
@@ -66,7 +79,7 @@
"symbol_align": "top",
"title_align": "top",
"subtitle_align": "top",
"size": 72
"size": 68
},
"visual": {
"show_title": true,
@@ -75,7 +88,7 @@
"effect_speed_ms": 960,
"waveform": {
"enabled": true,
"sample_count": 16,
"sample_count": 12,
"amplitude_pct": 95,
"jitter": true
}
@@ -74,6 +74,16 @@
},
"render": {
"text_backend": "lgfx_overlay",
"disable_lvgl_text": true
"disable_lvgl_text": true,
"lefou_detector": {
"note_sequence_hz": [
440,
880,
330,
349,
147,
98
]
}
}
}
@@ -6,13 +6,14 @@
"effect": "text_glitch",
"effect_speed_ms": 220,
"theme": {
"bg": "#04030A",
"accent": "#F35DFF",
"bg": "#020208",
"accent": "#FF9C3A",
"text": "#FFFFFF"
},
"render": {
"text_backend": "lgfx_overlay",
"disable_lvgl_text": true,
"lgfx_hard_mode": true,
"foreground_fx": "text_glitch",
"wave": false
},
@@ -22,25 +23,25 @@
},
"timeline": {
"loop": true,
"duration_ms": 30000,
"duration_ms": 36000,
"keyframes": [
{
"at_ms": 0,
"effect": "tunnel3d",
"speed_ms": 15000,
"speed_ms": 18000,
"theme": {
"bg": "#06040B",
"accent": "#F35DFF",
"bg": "#03030A",
"accent": "#FF9C3A",
"text": "#FFFFFF"
}
},
{
"at_ms": 15000,
"at_ms": 18000,
"effect": "rotozoom",
"speed_ms": 15000,
"speed_ms": 18000,
"theme": {
"bg": "#080513",
"accent": "#EF7FE7",
"bg": "#050510",
"accent": "#52E67A",
"text": "#FFFFFF"
}
}
@@ -55,17 +56,17 @@
"symbol_align": "top",
"title_align": "center",
"subtitle_align": "bottom",
"title_font_face": "ibm_bold_24",
"title_font_face": "bungee_24",
"subtitle_font_face": "ibm_bold_16",
"symbol_font_face": "orbitron_28",
"glitch": 42,
"size": 50
"size": 88
},
"visual": {
"show_title": true,
"show_subtitle": true,
"show_symbol": true,
"effect_speed_ms": 220,
"effect_speed_ms": 260,
"waveform": {
"enabled": false,
"sample_count": 0,
@@ -95,12 +96,14 @@
"audio": {
"ambient_random": {
"enabled": true,
"mode": "random_file",
"playlist_root": "/music/v8_pack",
"track": "/music/boot_radio.mp3",
"non_interruptive": true,
"volume_min": 21,
"volume_max": 21,
"delay_min_ms": 9000,
"delay_max_ms": 26000,
"delay_min_ms": 60000,
"delay_max_ms": 240000,
"eq_profiles": []
}
},
@@ -7,7 +7,7 @@
"theme": {
"bg": "#261209",
"accent": "#FF9A4A",
"text": "#FFF2E6"
"text": "#FFFFFF"
},
"transition": {
"effect": "fade",
@@ -46,8 +46,11 @@
"show_symbol": true,
"title_case": "upper",
"subtitle_case": "raw",
"title_align": "top",
"subtitle_align": "bottom"
"title_align": "center",
"subtitle_align": "center",
"title_font_face": "bungee_24",
"subtitle_font_face": "ibm_bold_20",
"size": 92
},
"visual": {
"show_title": true,
@@ -74,6 +77,18 @@
},
"render": {
"text_backend": "lgfx_overlay",
"disable_lvgl_text": true
"disable_lvgl_text": true,
"warning": {
"lgfx_only": true,
"siren": true,
"gyrophare": {
"enabled": true,
"fps": 25,
"speed_deg_per_sec": 180,
"beam_width_deg": 70,
"message": "SIGNAL ANORMAL",
"disable_direct_fx": true
}
}
}
}
+615
View File
@@ -1,3 +1,243 @@
## [2026-02-28] Freenove scenes + A252 hotline (ringback 5-25s, LEFOU sequence, U_SON playlist root)
- Objectif: appliquer le lot prioritaire du plan global sans toucher `hardware/firmware/esp32/`:
- hotline A252: ringback aleatoire 5..25s,
- Freenove: playlist random U_SON via `audio.ambient_random.mode=random_file` + `playlist_root`,
- LEFOU: validation par sequence de notes configurable (`render.lefou_detector.note_sequence_hz`),
- WARNING: nouveaux flags de rendu `render.warning.lgfx_only` + `render.warning.siren`.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_060439_wip.patch`
- `/tmp/zacus_checkpoint/20260228_060439_status.txt`
- Correctifs appliques:
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- ringback random ramene a `kHotlineRingbackMinMs=5000`, `kHotlineRingbackMaxMs=25000`.
- `hardware/firmware/ui_freenove_allinone/include/runtime/runtime_config_types.h`
- ajout config sequence LA/LEFOU (`mic_la_sequence_*`) + etat runtime sequence.
- `hardware/firmware/ui_freenove_allinone/src/runtime/la_trigger_service.cpp`
- trigger etendu a `SCENE_LEFOU_DETECTOR`,
- branche sequence stricte (ordre impose) avec hold par note et progression stable.
- `hardware/firmware/ui_freenove_allinone/src/app/main.cpp`
- parser `audio.ambient_random.mode`, `playlist_root`, `playlist_recursive`,
- scan playlist (LittleFS/SD_MMC) + selection random d'un fichier audio,
- fallback robuste sur `track` si playlist vide/invalide,
- parser `render.lefou_detector.note_sequence_hz` + `note_hold_ms`,
- activation scene LEFOU sans timeout (timeout LA force a 0 sur LEFOU, restore default hors LEFOU),
- statut enrichi (`la_trigger.sequence_*`).
- `hardware/firmware/ui_freenove_allinone/include/ui/ui_manager.h`
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
- parser `render.warning.lgfx_only` et `render.warning.siren`,
- `SCENE_WARNING` force LGFX overlay (pas de gyrophare LVGL quand `lgfx_only=true`).
- Source de verite scene + ecrans:
- `../../game/scenarios/scene_editor_all.yaml`
- `data/story/screens/SCENE_U_SON_PROTO.json`
- `data/story/screens/SCENE_LA_DETECTOR.json`
- `data/story/screens/SCENE_WARNING.json`
- `data/story/screens/SCENE_LEFOU_DETECTOR.json`
- U_SON: `mode=random_file`, `playlist_root=/music/v8_pack`.
- LA: `hourglass_x_offset_px=-25`, `hourglass_width_px=80`, `hourglass_height_px=150`, `bargraph_peak_hold_ms=900`.
- LEFOU: sequence par defaut `[440, 880, 330, 349, 147, 98]`.
- Gates executees:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev` -> OK.
- `pio run -e freenove_esp32s3` -> OK.
- `python3 ../../tools/scenario/validate_scenario.py ../../game/scenarios/default_unlock_win_etape2.yaml` -> FAIL (schema non compatible avec ce validateur: champs `title/players/...` requis).
- `python3 ../../tools/scenario/export_md.py ../../game/scenarios/default_unlock_win_etape2.yaml` -> OK.
- `python3 ../../tools/audio/validate_manifest.py ../../audio/manifests/zacus_v1_audio.yaml` -> OK.
- `python3 ../../tools/printables/validate_manifest.py ../../printables/manifests/zacus_v1_printables.yaml` -> OK.
## [2026-02-28] A252 hotline - hybrid telco clock + telephony power forced on
- Objectif: garder le SLIC alimente en permanence sur A252 et permettre un playback adaptable a la source (policy hybrid).
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_052436_wip.patch`
- `/tmp/zacus_checkpoint/20260228_052436_status.txt`
- Correctifs appliques:
- `hardware/RTC_SLIC_PHONE/src/config/A252ConfigStore.h`
- defaults A252: `clock_policy=HYBRID_TELCO`, `adc_dsp_enabled=false`, `adc_fft_enabled=false`.
- `hardware/RTC_SLIC_PHONE/src/config/A252ConfigStore.cpp`
- validation `clock_policy`: accepte `HYBRID_TELCO` et `FIXED_TELCO`.
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- ajout helper `isHybridTelcoClockPolicy(...)` et application runtime du policy dans `buildI2sConfig`.
- `ensureA252AudioDefaults()` force: capture off, adc dsp/fft off, `clock_policy=HYBRID_TELCO`.
- `applyAudioPatch(...)` force aussi ces valeurs sur profil A252.
- `hardware/RTC_SLIC_PHONE/src/telephony/TelephonyService.h/.cpp`
- policy A252: `slic_power` force `on` (pas de power-down idle/probe).
- Build execute:
- `pio run -e esp32dev` -> OK.
## [2026-02-28] Telephony gating - no boot autoplay (on-hook) + hard stop on hangup
- Objectif: garantir "aucune lecture au boot sauf combine deja decroche" et "stop total audio au raccroche".
- Correctifs appliques:
- `hardware/RTC_SLIC_PHONE/src/telephony/TelephonyService.cpp`
- blocage auto-relance dial tone quand un media est deja en cours (`!audio_->isPlaying()` requis),
- blocage dial tone instantane a l'entree OFF_HOOK si playback actif.
- coupure immediate tone+playback des le premier edge ON_HOOK (avant fin de debounce hangup).
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- gate setup: si hook ON_HOOK au boot -> `stopPlayback()` + `stopTone()` + log explicite,
- gate loop: `enforceOnHookSilence()` stoppe toute lecture/tonalite des que hook ON_HOOK.
- Validation:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev` -> OK
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> OK
- log boot observe: `[RTC_BL_PHONE] boot hook=ON_HOOK -> audio autoplay blocked`.
## [2026-02-28] A252 audio - hard-disable auto loudness processing
- Objectif: verrouiller la desactivation du loudness auto (normalize/limiter) au runtime pour eviter toute reactivation via config.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_032602_wip.patch`
- `/tmp/zacus_checkpoint/20260228_032602_status.txt`
- Correctifs appliques:
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- ajout d'un verrou runtime `kHardDisableAutoLoudnessProcessing=true`,
- centralisation de la policy via `wavAutoLoudnessEnabled(...)`,
- forçage `wav_auto_normalize_limiter=false` dans `AudioEngine::begin(...)`,
- desactivation explicite des chemins analyse loudness dans `prepareWavPlayback`, `analyzeWavLoudnessGainDb` et `probePlaybackFileFromSource`.
- Effet attendu:
- `playback_loudness_auto=false`, `loudness_gain_db=0`, `limiter_active=false` en playback/probe.
## [2026-02-28] A252 HOT-LINE - sync scene ESP-NOW + dial/ringback aleatoires
- Objectif: stabiliser la coherence hotline avec Freenove en ajoutant:
- refresh ESP-NOW toutes les 30s,
- synchro scene/status Freenove au decroche + periodique,
- dial tone aleatoire (FR_FR/ETSI_EU/UK_GB/NA_US),
- ringback aleatoire 5..25s avant lecture media hotline.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_015105_wip.patch`
- `/tmp/zacus_checkpoint/20260228_015105_status.txt`
- Correctifs appliques:
- `hardware/RTC_SLIC_PHONE/src/telephony/TelephonyService.h/.cpp`
- ajout profil dial off-hook aleatoire et relance deterministic du dial tone selectionne.
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- interval peer discovery ESP-NOW ramene a 30s,
- runtime `scene sync` ESP-NOW (request `UI_SCENE_STATUS`, ack tracking, update `g_active_scene_id/g_active_step_id`, validation state inferee),
- trigger scene sync au decroche + periodique,
- ajout ringback aleatoire (profil + duree 5..25s) avant `post_ringback_route`,
- enrichissement statut hotline/espnow avec telemetrie ringback + scene sync.
- Build execute:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev` -> OK.
## [2026-02-27] Hotfix FX rearm + line-buffer fallback (SCENE_U_SON_PROTO)
- Objectif: restaurer le fond anime direct-FX apres regression (`fx_enabled=1` mais `fx_frames=0`) et stopper les boucles d'allocation `fx_line`.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_232630_wip.patch`
- `/tmp/zacus_checkpoint/20260227_232630_status.txt`
- Correctifs appliques:
- `hardware/firmware/ui_freenove_allinone/src/ui/fx/fx_engine.cpp`:
- fallback line-buffer `allocDefault(...)` quand `UI_FX_DMA_BLIT=0` (CPU blit) si `allocInternalDmaAligned` echoue.
- `FxEngine::setEnabled(...)` conditionne a `ready_` + ajout getter `ready()`.
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager_effects.cpp`:
- rearm direct-FX avec retry `begin(...)` + fallback sprite compact.
- cooldown retry (`fx_rearm_retry_after_ms_`) pour eviter retry every-frame.
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`:
- application du cooldown dans les deux chemins de rearm (`update` + `renderScene`).
- telemetry `UI_GFX_STATUS` enrichie: `fx_enabled`, `fx_scene`.
- `hardware/firmware/ui_freenove_allinone/include/ui/ui_manager.h`:
- ajout etat runtime `fx_rearm_retry_after_ms_`.
- Validation executee:
- `pio run -e freenove_esp32s3` -> OK
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> OK
- Serie:
- `SCENE_GOTO SCENE_U_SON_PROTO` puis `UI_GFX_STATUS` -> `fx_enabled=1`, `fx_scene=1`, `fx_frames=121`, `fx_fps=9`.
- plus de spam continu `alloc_fail ... fx_line` en regime nominal.
## [2026-02-27] SCENE_WIN_ETAPE - anti-saccade overlay LGFX (DMA busy recovery)
- Objectif: reduire le blink/saccade et les pertes de texte LGFX sur `SCENE_WIN_ETAPE` en cas de contention flush/DMA.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_220518_wip.patch`
- `/tmp/zacus_checkpoint/20260227_220518_status.txt`
- Audit cible:
- contention constatee dans `UiManager::update()` entre rendu FX, flush LVGL et overlay LGFX (timeouts `overlay_skip_busy_count_`).
- mode `SCENE_WIN_ETAPE` detecte comme scene sensible en `text_backend=lgfx_overlay`.
- Correctifs appliques:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`:
- cadence frame WIN_ETAPE LGFX alignee a 25 FPS via `kUiUpdateFrameMsLaDetectorLgfx` (40 ms),
- ajout d'un mode priorite overlay WIN_ETAPE (budget attente DMA augmente + retry drain DMA final),
- ajout d'un mecanisme de recovery court: suspension FX sur 2 frames apres timeout overlay pour restaurer la lisibilite texte,
- conservation stricte de l'ordre de rendu (FX -> LVGL -> overlay LGFX).
- `hardware/firmware/ui_freenove_allinone/include/ui/ui_manager.h`:
- ajout etat runtime `overlay_recovery_frames_`.
- Gates executees:
- `pio run -e freenove_esp32s3` -> OK
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> OK
- `python3 tools/dev/serial_smoke.py --role esp32 --port /dev/cu.usbmodem5AB90753301 --timeout 3` -> PASS (`PING/PONG` stable)
- Limitation / suite:
- validation visuelle et serie hardware encore a confirmer sur banc (`UI_GFX_STATUS`, `overlay_skip_busy`, perception blink).
- injection scene manuelle `SCENE SCENE_WIN_ETAPE` non disponible sur ce binaire (retour serie: `UNKNOWN SCENE SCENE_WIN_ETAPE`).
## [2026-02-27] SCENE_LA_DETECTOR — preset reutilisable wirecube+rotozoom (LGFX)
- Objectif: remplacer le fond sablier de `SCENE_LA_DETECTOR` par un preset fond demoscene subtil (`wirecube + rotozoom`) avec sync micro lissee, en restant retro-compatible.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_191811_wip.patch`
- `/tmp/zacus_checkpoint/20260227_191811_status.txt`
- Correctifs appliques:
- `hardware/firmware/hardware/firmware/ui_freenove_allinone/include/ui/ui_manager.h`:
- ajout enums `LaBackgroundPreset` / `LaBackgroundSync`,
- ajout etats runtime `la_bg_preset_`, `la_bg_sync_`, `la_bg_intensity_pct_`, `la_bg_mic_lpf_`, `la_bg_last_ms_`.
- `hardware/firmware/hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`:
- parsing JSON `render.la_detector.background_preset|background_sync|background_intensity_pct`,
- clamp strict `background_intensity_pct` (`0..100`),
- branche rendu `kLegacyHourglass` vs `kWirecubeRotozoomSubtle`,
- sync micro lissee (LPF) + mode `fixed`/`mic_smoothed`/`mic_direct`,
- sablier legacy affiche uniquement si preset legacy.
- `hardware/firmware/data/story/screens/SCENE_LA_DETECTOR.json`:
- `show_hourglass=false`,
- activation preset: `background_preset=wirecube_rotozoom_subtle`,
- sync: `background_sync=mic_smoothed`,
- intensite: `background_intensity_pct=32`.
- `hardware/firmware/data/ui/fx/demoscene_fx_manifest.v1.json`:
- version `1.3.3`,
- notes preset `la_detector` maj (wirecube+rotozoom + fallback legacy),
- ajout `overlay_fx_types.la_detector_bg_wirecube_rotozoom` avec contrat payload.
- Gates executees:
- `python3 /Users/cils/.codex/skills/demoscene-fx-creator/scripts/fx_inventory.py --repo-root /Users/cils/Documents/Enfants/anniv isaac 10a/le-mystere-professeur-zacus --check-manifest --strict` -> OK
- `pio run -e freenove_esp32s3` -> OK
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> OK
- Artefacts trackes scan:
- aucun fichier tracke detecte dans `.pio`, `.platformio`, `logs`, `dist`, `build`, `node_modules`, `.venv`.
## [2026-02-27] SCENE_LA_DETECTOR — fond demoscene wirecube+rotozoom discret (LGFX)
- Objectif: renforcer le fond sablier en style demoscene (wirecube + rotozoom), sombre et non intrusif, avec modulation sur niveau micro.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_185939_wip.patch`
- `/tmp/zacus_checkpoint/20260227_185939_status.txt`
- Correctifs appliques:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`:
- fond plein ecran sombre explicite sur overlay LGFX (evite rendu parasite LVGL visible),
- waveform LA recadree (zone cible ~75% largeur / ~50% hauteur, centree en Y),
- jauge circulaire recadree a droite (diametre derive de 30% largeur / 50% hauteur, centree en Y),
- ajout fond demoscene discret: grille rotozoom sombre + wirecube filaire, vitesse/intensite liees a `mic_level_percent`,
- aucun glitch/jitter ajoute.
- Gates executees:
- `pio run -e freenove_esp32s3` -> OK
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> OK
- Artefacts trackes scan:
- aucun fichier tracke detecte dans `.pio`, `.platformio`, `logs`, `dist`, `build`, `node_modules`, `.venv`.
## [2026-02-27] SCENE_LA_DETECTOR LGFX only (sans glitch) + double jauge
- Objectif: rendre `SCENE_LA_DETECTOR` en overlay LovyanGFX visible-only (LVGL masque), sans effet glitch, avec sablier wireframe 3D timeout + double anneau timeout/stabilite + spectre vert->rouge.
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_184236_wip.patch`
- `/tmp/zacus_checkpoint/20260227_184236_status.txt`
- Correctifs appliques:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`:
- desactivation runtime du preset FX direct pour `SCENE_LA_DETECTOR` (`direct_fx_scene_runtime` exclut LA detector),
- overlay LA refondu sans jitter/glitch local,
- sablier wireframe pseudo-3D anime (roto doux) pilote par `gate_remain`,
- double anneau circulaire (externe timeout 96 segments, interne stabilite 84 segments),
- spectre 60 bandes conserve en pleine largeur, couleur mappee par amplitude vert->jaune->rouge.
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager_effects.cpp`:
- masquage explicite des widgets LVGL de base (`setBaseSceneFxVisible(false)`) quand LA detector est en `lgfx_overlay`.
- Gate executee:
- `pio run -e freenove_esp32s3` -> OK
- Artefacts trackes scan:
- aucun fichier tracke detecte dans `.pio`, `.platformio`, `logs`, `dist`, `build`, `node_modules`, `.venv`.
## [2026-02-27] A252 FS_LIST firmware (liste SD/LittleFS paginee)
- Objectif: exposer une commande firmware `FS_LIST` pour inventorier les fichiers depuis la carte A252 sans montage PC.
@@ -3639,3 +3879,378 @@
- `HOTLINE_VALIDATE WIN1 NOACK` -> `OK HOTLINE_VALIDATE ACK_WIN1`
- `WAITING_VALIDATION` -> `OK WAITING_VALIDATION`
- `STATUS.telephony.pending_espnow_call_audio=/hotline/enter_code_5__fr-fr-deniseneural.mp3`
## [2026-02-27] Freenove LA detector: FFT full-width LGFX (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_181016_wip.patch`
- `/tmp/zacus_checkpoint/20260227_181016_status.txt`
- Scope execute:
- `SCENE_LA_DETECTOR` overlay LGFX: bargraphe spectre 60 bandes passe en mapping pleine largeur ecran (marges 2px, clamp anti-debordement), sans changer la hauteur FFT.
- marqueur A4 force au centre X (`marker_x = width / 2`).
- Validation:
- `pio run -e freenove_esp32s3` -> SUCCESS
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> SUCCESS
## [2026-02-27] Freenove LA detector: demoscene hourglass background + circular progress gauge (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_181016_wip.patch`
- `/tmp/zacus_checkpoint/20260227_181016_status.txt`
- Scope execute:
- `SCENE_LA_DETECTOR` LGFX overlay: suppression du petit sablier lateral;
- ajout d'un sablier de fond style demoscene/3D (wireframe + profondeur + flux de sable anime);
- ajout d'une jauge circulaire d'incrementation autour de la waveform micro centrale (progression detection);
- maintien FFT 60 bandes pleine largeur + marqueur A4 centre X.
- Validation:
- `pio run -e freenove_esp32s3` -> SUCCESS
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> SUCCESS
## [2026-02-27] Freenove LA detector: hourglass_demoscene_ultra + oscilloscope 200ms/div (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_194435_wip.patch`
- `/tmp/zacus_checkpoint/20260227_194435_status.txt`
- Scope execute:
- ajout preset `hourglass_demoscene_ultra` (parser + runtime) dans `ui_manager` pour `render.la_detector.background_preset`;
- remplacement fond LA detector par sablier demoscene LGFX offscreen (`pushImageDma`) avec simulation sable, swing amorti, flip 180deg sur timeout;
- waveform oscilloscope: base temps verrouillee 200ms/div x10 divisions, trace plus reactive avec head highlight;
- bargraph FFT: degrade vertical par barre vert->jaune->rouge conserve;
- scene data/manifeste mis a jour (`SCENE_LA_DETECTOR` preset ultra + contrat manifest).
- Validation:
- `pio run -e freenove_esp32s3` -> SUCCESS
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> SUCCESS
- `pio run -e freenove_esp32s3 -t uploadfs --upload-port /dev/cu.usbmodem5AB90753301` -> SUCCESS
## 2026-02-27 20:40 — RTOS triage + U_SON ambient constraints (Codex)
- Scope: `ui_freenove_allinone` crash triage on `SCENE_LA_DETECTOR` and ambient audio policy lock for `SCENE_U_SON_PROTO`.
- Changes:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`: replaced full-frame `pushImageDma(...)` for hourglass ultra background with `setAddrWindow + pushColors(...)` to avoid PSRAM DMA instability in loopTask.
- `hardware/firmware/ui_freenove_allinone/src/app/main.cpp`: removed U_SON ambient audio FX forcing/restoration; keep track playback non-interruptive only.
- `hardware/firmware/ui_freenove_allinone/src/app/main.cpp`: enforced U_SON ambient defaults/floor/ceiling: track `/music/boot_radio.mp3`, volume fixed 21, delay window 60000..240000 ms.
- `data/story/screens/SCENE_U_SON_PROTO.json` and builtin mirror in `hardware/firmware/ui_freenove_allinone/src/storage_manager.cpp`: delay window set to 60000..240000.
- Evidence:
- Build: `pio run -e freenove_esp32s3` PASS.
- Flash: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` PASS.
- FS: `pio run -e freenove_esp32s3 -t uploadfs --upload-port /dev/cu.usbmodem5AB90753301` PASS.
- Serial triage: 55s after `SCENE_GOTO SCENE_LA_DETECTOR` => `PANIC_COUNT=0` (no stack canary / guru seen in capture window).
- Remaining risk:
- `post_upload_checklist.sh` still reports missing explicit `SCENE_GOTO` ACK in sampled window; functional scene transition logs present.
## 2026-02-27 20:55 — ESP-NOW only boot mode + display visibility guard (Codex)
- Boot network policy updated in `ui_freenove_allinone/src/app/main.cpp`:
- `kBootEspNowOnlyMode=true`.
- Disable AP/STA/web boot paths while keeping `g_network.begin(...)` and `g_network.enableEspNow()`.
- Explicit logs: `[NET] wifi+web disabled (espnow_only_mode=1)` and `[WEB] disabled (espnow_only_mode=1)`.
- Display visibility guard:
- default backlight raised from 30 to 80 at boot globals.
- Validation:
- Build PASS: `pio run -e freenove_esp32s3`.
- Flash PASS: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`.
- Serial checks:
- `STATUS/NET_STATUS`: `mode=STA sta=0 ap=0 espnow=1 ip=0.0.0.0`.
- `LCD_BACKLIGHT level=80`.
- `SCENE_GOTO` returns `ACK ... ok=1` with `[UI] render ...` logs.
- panic scan on LA scene: `panic_hits=0`.
## 2026-02-27 21:01 — LA detector hourglass timing/placement/reactivity tuning (Codex)
- Scope: `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
- Changes:
- Hourglass flip duration now 10s (`kHourglassFlipDurationMs=10000.0f`) when timeout reaches zero.
- Swing slowed x10 (initial omega, damping/stiffness path, jitter impulse, flip kick reduced).
- Hourglass shifted right by +20% screen width (`center_x = width * 0.7f`).
- Waveform temporal window made faster (`max_points` reduced to 20, oscilloscope now `100ms/div` over 10 divisions).
- FFT bargraph made more reactive with stronger amplitude gain from live spectrum + mic level.
- LA timeout source sync:
- `runtime_config_service.cpp` now reads `/story/apps/APP_LA.json` `config.timeout_ms` and maps it to `mic_la_timeout_ms` so hourglass/timer use scene APP_LA timeout.
- Validation:
- Build PASS: `pio run -e freenove_esp32s3`.
- Flash PASS: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`.
- Serial evidence:
- `HW_STATUS ... la_timeout_ms=30000 ... scene=SCENE_LA_DETECTOR`.
- Timeout observed at ~30008ms then routed to `SCENE_U_SON_PROTO` (`la_timeout_recovery`).
## 2026-02-27 21:10 — LA detector hourglass/waveform retune v2 (Codex)
- Scope: `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
- Changes:
- Hourglass swing oscillator normalized to ~12s period (inside requested 10..15s), with softer damping.
- Timeout reset flip kept at 10s (`kHourglassFlipDurationMs=10000`).
- Hourglass height reduced by `/1.33` from prior setting (`scale_y = 0.9/1.33`).
- Hourglass render density increased by x3 vertical sub-lines per logical row.
- Hourglass position shifted right by +20% (`center_x = width * 0.7f`).
- Waveform acquisition window shortened further (`max_points=12`) and timescale tightened (`50ms/div`) for fast audio-player-like trace.
- FFT bargraph reactivity boosted again (stronger gain mapping from live spectrum + mic level).
- Validation:
- Build PASS: `pio run -e freenove_esp32s3`.
- Flash PASS: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`.
- Serial check PASS:
- `HW_STATUS` alive.
- `UI_GFX_STATUS` stable (no panic/overflow/stall/recover increments in sampled output).
## 2026-02-27 21:19 — LA detector hourglass timeout mapping + reset flip (Codex)
- Scope:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
- `hardware/firmware/ui_freenove_allinone/include/ui/ui_manager.h`
- Changes:
- Hourglass visual depletion stays mapped to 80% of real timeout (`kHourglassTimeoutScale=0.80f`) so the visual hourglass finishes earlier.
- Added timeout-reset detection (`gate_elapsed` drop to ~0 after prior progress) and trigger a 10s hourglass return flip animation (`kHourglassFlipDurationMs=10000.0f`).
- Added runtime state to track previous gate elapsed for reset-edge detection (`la_hg_prev_gate_elapsed_ms_`, `la_hg_prev_gate_valid_`).
- Validation:
- Build PASS: `pio run -e freenove_esp32s3`.
- Flash PASS: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`.
- Notes:
- No FS payload changed in this pass, so no `uploadfs` required.
## 2026-02-27 21:27 — LA hourglass damping retune + reset flip hardening (Codex)
- Scope:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
- Changes:
- Hourglass swing made much more damped: reduced startup omega, reduced max angle, stronger damping in normal and flip phases.
- Reduced post-flip impulse and edge rebound to avoid visible overshoot/oscillation.
- Reset detection hardened (dynamic min progress threshold + wider near-zero window) and flip restart is now always forced on reset edge.
- 10s flip/rotation path kept unchanged (`kHourglassFlipDurationMs=10000`).
- Validation:
- Build PASS: `pio run -e freenove_esp32s3`.
- Flash PASS: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`.
- Notes:
- No FS changes in this pass (`uploadfs` not required).
## 2026-02-27 21:32 — LA detector flip lock during active 10s animation (Codex)
- Scope:
- `hardware/firmware/ui_freenove_allinone/src/ui/ui_manager.cpp`
- Change:
- Reset-triggered flip now arms only when `!la_hg_flipping_`, preventing relaunch while a 10s flip is already running.
- No post-flip cooldown added (new flip allowed immediately after animation end).
- Validation:
- Build PASS: `pio run -e freenove_esp32s3`.
- Flash PASS: `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`.
- Notes:
- No FS/story data changes in this pass.
## [2026-02-27] Hotline MP3 etat-jeu + synchro scene Freenove->A252 (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260227_214828_wip.patch`
- `/tmp/zacus_checkpoint/20260227_214828_status.txt`
- Scope execute:
- A252 (`hardware/RTC_SLIC_PHONE/src/main.cpp`):
- ajout contexte hotline runtime `scene/step/validation_state` (none|waiting|granted|refused);
- `SCENE` accepte maintenant JSON enrichi (`id/scene_id`, `step_id`, `validation_state`) et met a jour le contexte;
- `WAITING_VALIDATION` route un prompt MP3 contextualise avec fallback (`enter_code_5`);
- `HOTLINE_VALIDATE` met a jour l'etat validation et choisit des cues MP3 contextualises (granted/refused) avec fallback;
- `HOTLINE_TRIGGER 1/2/3` tente d'abord des MP3 d'indice contextuels scene+etat, puis fallback sur map hotline WAV existante;
- `STATUS` + `HOTLINE_STATUS` exposent `active_step` et `hotline_validation_state`.
- Freenove (`hardware/firmware/ui_freenove_allinone/src/app/main.cpp`):
- synchro automatique de contexte hotline a chaque changement de scene via ESP-NOW:
- payload `SCENE {"id","step_id","validation_state"}`;
- `validation_state` infere depuis `step_id/screen_scene_id` (waiting/granted/refused/none).
- Doc A252 (`hardware/RTC_SLIC_PHONE/README.md`) mise a jour pour le contrat scene/hotline contextualise.
- Validation effectuee:
- Build:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev` -> SUCCESS
- `pio run -e freenove_esp32s3` -> SUCCESS
- Flash:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> SUCCESS
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301` -> SUCCESS
- Smoke serie A252:
- `SCENE {"id":"SCENE_LA_DETECTOR","step_id":"RTC_ESP_ETAPE1","validation_state":"waiting"}` -> JSON `validation_state=waiting`
- `HOTLINE_STATUS` -> expose `scene`, `step`, `validation_state`
- `SCENE {"id":"SCENE_WARNING","step_id":"STEP_WARNING"}` -> JSON `validation_state=refused`
- `WAITING_VALIDATION` -> `OK WAITING_VALIDATION`
- Smoke serie Freenove:
- `SCENE_GOTO SCENE_WARNING` -> log `HOTLINE_SYNC scene=SCENE_WARNING step=STEP_WARNING validation=refused ok=1`.
- Note bench dual-board:
- `HOTLINE_STATUS` cote A252 reste `scene=""` sur ce run; necessite verification radio/channel peer en conditions E2E ESPNOW pour confirmer la reception live de la synchro scene.
## [2026-02-28] HOT-LINE MP3 etat/scene: contextualisation WAITING_VALIDATION (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_005032_wip.patch`
- `/tmp/zacus_checkpoint/20260228_005032_status.txt`
- Scope execute:
- `hardware/firmware/ui_freenove_allinone/src/app/main.cpp`
- enrichissement automatique du payload `WAITING_VALIDATION` vers:
- `WAITING_VALIDATION {"scene_id":"...","step_id":"...","validation_state":"waiting"}`
- conserve le format legacy `WAITING_VALIDATION` (non-breaking).
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- `WAITING_VALIDATION` accepte maintenant les args JSON scene/step/validation.
- mise a jour du contexte hotline (`g_active_scene_id`, `g_active_step_id`) avant resolution route MP3.
- route toujours forcee sur l'etat `waiting` pour cette commande.
- `hardware/RTC_SLIC_PHONE/README.md`
- doc de la variante `WAITING_VALIDATION {...}`.
- Validation effectuee:
- Build Freenove PASS: `pio run -e freenove_esp32s3`
- Build A252 PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev`
## [2026-02-28] Hotline ringback 2s + debug lecture MP3 telephone (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_022430_wip.patch`
- `/tmp/zacus_checkpoint/20260228_022430_status.txt`
- Checkpoint securite (lot suivant):
- `/tmp/zacus_checkpoint/20260228_023407_wip.patch`
- `/tmp/zacus_checkpoint/20260228_023407_status.txt`
- Objectif lot:
- forcer provisoirement la sonnerie/ringback hotline a 2 secondes;
- corriger la lecture MP3 A252 (priorite SD) quand le telephone est en appel hotline.
- Modifications appliquees:
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- ringback hotline force a 2s (temporaire).
- rescan du catalogue de suffixes voix quand la SD devient disponible apres boot.
- fallback de routage hotline vers `scene_route` (MP3 de scene) avant `dial_map` WAV si les hints ne sont pas trouves.
- montage SD pour scan hotline/`FS_LIST` avec fallback SPI (pins A1S) si `SD_MMC` echoue.
- log hotline SD numerote dans `/hotline/log.txt` (events route/ringback/sync/errors) pour debug terrain.
- fallback MP3 global (`default_voice_fallback`) si scene/hints indisponibles (evite silence sur numerotation).
- couverture scene->stem hotline ajoutee pour `SCENE_CREDITS` et `SCENE_POLICE_CHASE_ARCADE`.
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- remount SD/LittleFS avec retry periodique (au lieu d'un seul essai au boot).
- fallback montage SD en SPI (`SD.begin(...)`) quand `SD_MMC.begin()` echoue.
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.h`
- pointeur `sd_fs_` pour ouvrir les medias SD sur backend actif (`SD_MMC` ou `SD` SPI).
- `hardware/firmware/ui_freenove_allinone/src/app/main.cpp`
- commande ESP-NOW `UI_SCENE_STATUS` exposee dans l'ack JSON (`scene_id`, `step_id`, `validation_state`) pour synchronisation hotline A252.
- Validation executee:
- Build PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev`.
- Flash PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001`.
- Build/flash Freenove PASS:
- `pio run -e freenove_esp32s3`
- `pio run -e freenove_esp32s3 -t upload --upload-port /dev/cu.usbmodem5AB90753301`
- Runtime:
- `HOTLINE_TRIGGER 1` -> log `ringback ... duration_ms=2000`.
- `FS_LIST {"source":"sd","path":"/hotline","page_size":200}` -> OK (`count=116`, `mp3=100`), SD accessible.
- `AUDIO_PROBE sd:/hotline/fiches-hotline_2__fr-fr-deniseneural.mp3` -> OK (decodage MP3 valide).
- `SCENE {"id":"SCENE_U_SON_PROTO"...}` -> `audio_started=true` avec route MP3 SD.
- `/hotline/log.txt` detecte sur SD et taille incrementee apres numerotation hotline (evidence debug).
## [2026-02-28] Hotline scene list explicite (U_SON/LA/WIN/WARNING/QR/LEFOU/POLICE/CREDITS) (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_025723_wip.patch`
- `/tmp/zacus_checkpoint/20260228_025723_status.txt`
- Modifications:
- `hardware/RTC_SLIC_PHONE/src/main.cpp`
- ajout d'une table explicite `scene_key -> stem` pour:
- `SCENE_U_SON_PROTO`
- `SCENE_LA_DETECTOR`
- `SCENE_WIN_ETAPE`
- `SCENE_WARNING`
- `SCENE_CREDITS`
- `SCENE_WIN_ETAPE1`
- `SCENE_WIN_ETAPE2`
- `SCENE_QR_DETECTOR`
- `SCENE_LEFOU_DETECTOR`
- `SCENE_POLICE_CHASE_ARCADE`
- lookup explicite applique avant les fallbacks historiques.
- `hardware/RTC_SLIC_PHONE/README.md`
- documentation de la table de mapping scene hotline.
- Validation:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev` -> SUCCESS.
## [2026-02-28] Upload A252 apres mapping scenes hotline (agent)
- Flash execute:
- `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001` -> SUCCESS.
- Verification serie immediate:
- `STATUS` lu sur `/dev/cu.usbserial-0001` (115200) -> firmware repondu, `espnow.scene_sync_enabled=true`, `active_scene=""` au boot.
## [2026-02-28] Triage qualite audio MP3 hotline (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_031139_wip.patch`
- `/tmp/zacus_checkpoint/20260228_031139_status.txt`
- Diagnostic runtime:
- `AUDIO_PROBE sd:/hotline/fiches-hotline_2__fr-fr-deniseneural.mp3` remontait:
- `input_sample_rate=24000`,
- `output_sample_rate=22050`,
- `resampler_active=true`.
- Pendant `PLAY ...mp3`: spam erreurs `[E] ResampleStream.h : 191 - write error 0 vs ...` (artefacts audio).
- Cause racine:
- 24 kHz n'etait pas considere stable dans `kStableRatesHz`; la chaine forçait un resampling inutile `24k -> 22.05k`.
- Correctif:
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- ajout de `24000` dans `kStableRatesHz` pour eviter le resampling inutile des prompts TTS hotline.
- Validation:
- Build PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev`.
- Flash PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001`.
- `AUDIO_PROBE` apres fix:
- `input_sample_rate=24000`,
- `output_sample_rate=24000`,
- `resampler_active=false`.
- Re-test `PLAY ...mp3` sur meme fichier: plus de logs `ResampleStream write error`.
## [2026-02-28] A252 MP3 white-noise triage - decoder pacing + fixed I2S output rate lock
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_035946_wip.patch`
- `/tmp/zacus_checkpoint/20260228_035946_status.txt`
- Skills utilises:
- `rtc-audio-stack`
- `firmware-embedded-audio-player-expert`
- `rtc-a252-bitrate-adaptive`
- `esp32-audio-runtime-gating`
- nouveau skill cree: `~/.codex/skills/compressed-audio-decoder-libs/SKILL.md`
- Correctifs appliques:
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- ajout stockage bitrate MP3 parse (`playback_mp3_bitrate_bps_`) pour timing de chunk MP3,
- pacing MP3 corrige: calcul delay base sur bitrate compresse (bits/s),
- reset bitrate MP3 sur init/stop,
- lock format sortie A252 sur sample rate I2S base (`_config.sample_rate`) pour stabilite ES8388,
- log decode MP3 enrichi avec bitrate detecte.
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.h`
- ajout champ runtime `playback_mp3_bitrate_bps_`.
- Validation:
- Build PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev`
- Flash PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001`
- Commandes runtime envoyees: `TONE_STOP`, `PLAY sd:/hotline/fiches-hotline_2__fr-fr-deniseneural.mp3` -> `OK`.
- Etat observe ensuite: `hook=ON_HOOK`, donc playback auto-stop conforme policy on-hook.
- Watch OFF_HOOK 45s lance pour auto-play direct: timeout (pas de decroche pendant la fenetre).
## [2026-02-28] A252 MP3 white-noise hotfix follow-up (resampler error loop)
- Symptom observee sur banc:
- `ResampleStream write error 0 vs ...` en boucle pendant lecture hotline,
- `STATUS.audio`: `playback_input_sample_rate=24000`, `playback_output_sample_rate=8000`, `playback_resampler_active=true`.
- Cause:
- lock sortie I2S a 8 kHz sur A252 forcait un resampling MP3 24k->8k et degradait la chaine.
- Correctif applique:
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- suppression du lock force 8k dans `resolvePlaybackFormat(...)`, retour policy HYBRID_TELCO (24k stable conserve),
- conservation du fix pacing MP3 base bitrate.
- Validation:
- Build PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev`
- Flash PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev -t upload --upload-port /dev/cu.usbserial-0001`
- Runtime (serial): `HOTLINE_TRIGGER 2` -> logs:
- `mp3 header parsed ... out_sr=24000 ... resampler=false`
- `play mp3 from SD ...`
## [2026-02-28] A252 MP3 white-noise deep triage v2 (agent)
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_042005_wip.patch`
- `/tmp/zacus_checkpoint/20260228_042005_status.txt`
- Scope cible:
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.h`
- `hardware/RTC_SLIC_PHONE/src/audio/AudioOutput.*` (si necessaire)
- Objectif:
- eliminer bruit blanc en lecture MP3 SD avec verification decode->buffer->I2S,
- conserver policy: stop playback immediat ON_HOOK.
## [2026-02-28] A252 switch MP3 decode path to ESP8266Audio
- Checkpoint securite:
- `/tmp/zacus_checkpoint/20260228_044901_wip.patch`
- `/tmp/zacus_checkpoint/20260228_044901_status.txt`
- Scope:
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.cpp`
- `hardware/RTC_SLIC_PHONE/src/audio/AudioEngine.h`
- `hardware/RTC_SLIC_PHONE/platformio.ini`
- Actions:
- remplacement du decodeur MP3 Helix AudioTools par `AudioGeneratorMP3` (ESP8266Audio) sur la pile A252,
- ajout d'un bridge `AudioOutput` ESP8266Audio -> pipeline AudioTools (resample/channel-convert/volume existants),
- activation de la dependance `earlephilhower/ESP8266Audio` sur les env A252.
- Validation:
- Build PASS: `pio run -d hardware/RTC_SLIC_PHONE -e esp32dev`.
## [2026-02-28] A252 MP3 playback verification crash (ESP8266Audio path)
- Actions:
- Flashed `hardware/RTC_SLIC_PHONE` on A252 (`esp32dev`, port `/dev/cu.usbserial-0001`).
- Verified SD catalog with `FS_LIST` under `/hotline_tts/SCENE_U_SON_PROTO` (MP3 files present).
- Forced MP3 playback test: `PLAY sd:/hotline_tts/SCENE_U_SON_PROTO/attente_validation_mystere_denise.mp3`.
- Evidence:
- Decoder start log observed: `mp3 header parsed sr=22050 ch=1 bits=16 bitrate=64000`.
- Immediate runtime crash after `OK PLAY`: `Guru Meditation Error: Core 1 panic'ed (LoadProhibited)` followed by reboot.
- Status:
- STOP condition raised (`build/test regression not fixed quickly`).
- See `docs/STOP_REQUIRED.md` for required decision.
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,8 @@ struct RuntimeNetworkConfig {
};
struct RuntimeHardwareConfig {
static constexpr uint8_t kLaSequenceMaxNotes = 8U;
bool enabled_on_boot = true;
uint32_t telemetry_period_ms = 2500U;
bool led_auto_from_scene = true;
@@ -40,6 +42,10 @@ struct RuntimeHardwareConfig {
uint16_t mic_la_release_ms = 180U;
uint16_t mic_la_cooldown_ms = 1400U;
uint32_t mic_la_timeout_ms = 60000U;
bool mic_la_sequence_enabled = false;
uint8_t mic_la_sequence_count = 0U;
uint16_t mic_la_sequence_hz[kLaSequenceMaxNotes] = {};
uint16_t mic_la_sequence_note_hold_ms = 350U;
char mic_la_event_name[32] = "SERIAL:BTN_NEXT";
bool battery_enabled = true;
uint8_t battery_low_pct = 20U;
@@ -64,4 +70,7 @@ struct LaTriggerRuntimeState {
int16_t last_cents = 0;
uint8_t last_confidence = 0U;
uint8_t last_level_pct = 0U;
uint8_t sequence_index = 0U;
uint32_t sequence_note_started_ms = 0U;
uint16_t sequence_target_hz = 0U;
};
@@ -8,6 +8,7 @@
#include "drivers/display/display_hal.h"
#include "hardware_manager.h"
#include "ui/fx/fx_engine.h"
#include "ui/effects/scene_gyrophare.h"
#include "ui/player_ui_model.h"
#include "ui/qr/qr_scene_controller.h"
#include "ui/qr/qr_scan_controller.h"
@@ -198,6 +199,18 @@ class UiManager {
kMarquee,
};
enum class LaBackgroundPreset : uint8_t {
kLegacyHourglass = 0,
kWirecubeRotozoomSubtle,
kHourglassDemosceneUltra,
};
enum class LaBackgroundSync : uint8_t {
kFixed = 0,
kMicSmoothed,
kMicDirect,
};
enum class IntroState : uint8_t {
PHASE_A_CRACKTRO = 0,
PHASE_B_TRANSITION,
@@ -431,6 +444,10 @@ class UiManager {
void onTimelineTick(uint16_t elapsed_ms);
bool isWinEtapeSceneId(const char* scene_id) const;
bool isDirectFxSceneId(const char* scene_id) const;
void armDirectFxScene(const char* scene_id,
bool test_lab_lgfx_scroller,
const char* title_text,
const char* subtitle_text);
void cleanupSceneTransitionAssets(const char* from_scene_id, const char* to_scene_id);
static void displayFlushCb(lv_disp_drv_t* disp, const lv_area_t* area, lv_color_t* color_p);
@@ -562,6 +579,7 @@ class UiManager {
uint8_t text_glitch_pct_ = 65U;
uint8_t text_size_pct_ = 100U;
bool scene_use_lgfx_text_overlay_ = false;
bool scene_lgfx_hard_mode_ = false;
bool scene_disable_lvgl_text_ = false;
SceneTextAlign overlay_title_align_ = SceneTextAlign::kCenter;
SceneTextAlign overlay_subtitle_align_ = SceneTextAlign::kBottom;
@@ -668,6 +686,44 @@ class UiManager {
bool la_overlay_show_pitch_text_ = true;
bool la_overlay_meter_bottom_horizontal_ = true;
bool la_overlay_hourglass_modern_ = true;
LaBackgroundPreset la_bg_preset_ = LaBackgroundPreset::kLegacyHourglass;
LaBackgroundSync la_bg_sync_ = LaBackgroundSync::kMicSmoothed;
uint8_t la_bg_intensity_pct_ = 32U;
float la_bg_mic_lpf_ = 0.15f;
uint32_t la_bg_last_ms_ = 0U;
int16_t la_hg_x_offset_px_ = 0;
uint16_t la_hg_target_height_px_ = 0U;
uint16_t la_hg_target_width_px_ = 0U;
bool la_bargraph_blue_palette_ = false;
uint16_t la_bargraph_peak_hold_ms_ = 320U;
uint16_t la_bargraph_decay_per_s_ = 120U;
bool la_waveform_audio_player_mode_ = false;
uint16_t la_waveform_window_ms_ = 300U;
static constexpr uint16_t kLaHourglassGridWMax = 96U;
static constexpr uint16_t kLaHourglassGridHMax = 72U;
uint16_t* la_bg_sprite_buf_ = nullptr;
size_t la_bg_sprite_pixels_ = 0U;
int16_t la_bg_sprite_w_ = 0;
int16_t la_bg_sprite_h_ = 0;
uint16_t la_hg_grid_w_ = 0U;
uint16_t la_hg_grid_h_ = 0U;
uint8_t la_hg_orient_ = 0U;
bool la_hg_flipping_ = false;
bool la_hg_flip_on_timeout_ = true;
bool la_hg_timeout_latched_ = false;
uint32_t la_hg_flip_started_ms_ = 0U;
uint32_t la_hg_flip_duration_ms_ = 10000U;
uint32_t la_hg_prev_gate_elapsed_ms_ = 0U;
bool la_hg_prev_gate_valid_ = false;
float la_hg_theta_ = 0.0f;
float la_hg_omega_ = 0.0f;
uint32_t la_hg_rng_ = 0xC0FFEE12UL;
bool la_hg_ready_ = false;
uint8_t la_hg_mask_[kLaHourglassGridWMax * kLaHourglassGridHMax] = {};
uint8_t la_hg_outline_[kLaHourglassGridWMax * kLaHourglassGridHMax] = {};
int8_t la_hg_depth_[kLaHourglassGridWMax * kLaHourglassGridHMax] = {};
uint8_t la_hg_halfw_[kLaHourglassGridHMax] = {};
uint8_t la_hg_sand_[kLaHourglassGridWMax * kLaHourglassGridHMax] = {};
drivers::display::OverlayFontFace la_overlay_caption_font_ = drivers::display::OverlayFontFace::kBuiltinSmall;
uint8_t la_overlay_caption_size_ = 1U;
char la_overlay_caption_[64] = "Recherche d'accordance";
@@ -676,6 +732,27 @@ class UiManager {
uint32_t overlay_draw_fail_count_ = 0U;
uint32_t overlay_startwrite_fail_count_ = 0U;
uint32_t overlay_skip_busy_count_ = 0U;
uint8_t overlay_recovery_frames_ = 0U;
uint32_t fx_rearm_retry_after_ms_ = 0U;
uint32_t scene_runtime_started_ms_ = 0U;
ui::effects::SceneGyrophare warning_gyrophare_;
bool warning_gyrophare_enabled_ = false;
bool warning_gyrophare_disable_direct_fx_ = false;
bool warning_lgfx_only_ = false;
bool warning_siren_enabled_ = false;
uint8_t warning_gyrophare_fps_ = 25U;
uint16_t warning_gyrophare_speed_deg_per_sec_ = 180U;
uint16_t warning_gyrophare_beam_width_deg_ = 70U;
char warning_gyrophare_message_[64] = "SIGNAL ANORMAL";
static constexpr uint8_t kWinEtapeCreditsMaxLines = 48U;
static constexpr uint8_t kWinEtapeCreditsMaxLineChars = 96U;
bool win_etape_credits_loaded_ = false;
uint8_t win_etape_credits_count_ = 0U;
char win_etape_credits_lines_[kWinEtapeCreditsMaxLines][kWinEtapeCreditsMaxLineChars] = {};
uint8_t win_etape_credits_size_[kWinEtapeCreditsMaxLines] = {};
uint8_t win_etape_credits_align_[kWinEtapeCreditsMaxLines] = {};
uint16_t win_etape_credits_pause_ms_[kWinEtapeCreditsMaxLines] = {};
uint16_t win_etape_credits_scroll_px_per_sec_ = 16U;
int16_t touch_x_ = 0;
int16_t touch_y_ = 0;
@@ -1,6 +1,7 @@
// main.cpp - Freenove ESP32-S3 all-in-one runtime loop.
#include <Arduino.h>
#include <ArduinoJson.h>
#include <FS.h>
#include <LittleFS.h>
#include <Preferences.h>
#include <WebServer.h>
@@ -10,9 +11,18 @@
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <vector>
#if defined(ARDUINO_ARCH_ESP32)
#include <esp_heap_caps.h>
#if __has_include(<SD_MMC.h>)
#include <SD_MMC.h>
#define ZACUS_HAS_SD_AUDIO 1
#else
#define ZACUS_HAS_SD_AUDIO 0
#endif
#else
#define ZACUS_HAS_SD_AUDIO 0
#endif
#include "audio_manager.h"
@@ -60,10 +70,12 @@ constexpr const char* kDefaultScenarioFile = "/story/scenarios/DEFAULT.json";
constexpr const char* kFirmwareName = "freenove_esp32s3";
constexpr const char* kDiagAudioFile = "/music/boot_radio.mp3";
constexpr const char* kUsonAmbientTrack = "/music/boot_radio.mp3";
constexpr uint8_t kUsonAmbientVolumeMin = FREENOVE_AUDIO_MAX_VOLUME;
constexpr uint8_t kUsonAmbientVolumeMax = FREENOVE_AUDIO_MAX_VOLUME;
constexpr uint32_t kUsonAmbientDelayMinMs = 9000U;
constexpr uint32_t kUsonAmbientDelayMaxMs = 26000U;
constexpr uint8_t kUsonAmbientVolumeFixed = 21U;
constexpr uint8_t kUsonAmbientVolumeMin =
(kUsonAmbientVolumeFixed > FREENOVE_AUDIO_MAX_VOLUME) ? FREENOVE_AUDIO_MAX_VOLUME : kUsonAmbientVolumeFixed;
constexpr uint8_t kUsonAmbientVolumeMax = kUsonAmbientVolumeMin;
constexpr uint32_t kUsonAmbientDelayMinMs = 60000U;
constexpr uint32_t kUsonAmbientDelayMaxMs = 240000U;
constexpr size_t kSerialLineCapacity = 192U;
constexpr bool kBootDiagnosticTone = true;
constexpr const char* kEspNowBroadcastTarget = "broadcast";
@@ -89,6 +101,7 @@ constexpr const char* kCameraSceneId = "SCENE_PHOTO_MANAGER";
constexpr const char* kMediaManagerSceneId = "SCENE_MEDIA_MANAGER";
constexpr const char* kTestLabSceneId = "SCENE_TEST_LAB";
constexpr const char* kDefaultBootSceneId = "SCENE_U_SON_PROTO";
constexpr bool kBootEspNowOnlyMode = true;
constexpr const char* kTestLabLockStepId = "TEST_LAB_LOCK";
constexpr bool kLockNvsMediaManagerMode = true;
constexpr bool kForceTestLabSceneLock = false;
@@ -123,7 +136,10 @@ struct SceneWs2812FxState {
struct SceneAmbientAudioFxState {
bool enabled = false;
bool non_interruptive = true;
char mode[24] = "single_track";
char track[64] = "/music/boot_radio.mp3";
char playlist_root[96] = "";
bool playlist_recursive = true;
uint8_t volume_min = kUsonAmbientVolumeMin;
uint8_t volume_max = kUsonAmbientVolumeMax;
uint32_t delay_min_ms = kUsonAmbientDelayMinMs;
@@ -165,8 +181,8 @@ bool g_la_dispatch_in_progress = false;
bool g_has_ring_sent_for_win_etape = false;
bool g_win_etape_ui_refresh_pending = false;
bool g_boot_media_manager_mode = false;
uint8_t g_lcd_backlight_level = 30U;
uint8_t g_lcd_backlight_base_level = 30U;
uint8_t g_lcd_backlight_level = 80U;
uint8_t g_lcd_backlight_base_level = 80U;
bool g_setup_mode = true;
bool g_web_auth_required = false;
bool g_resource_profile_auto = true;
@@ -192,7 +208,6 @@ uint8_t g_scene_ws2812_runtime_brightness = 0U;
uint32_t g_scene_ws2812_runtime_next_update_ms = 0U;
bool g_uson_ambient_audio_active = false;
uint8_t g_uson_ambient_restore_volume = FREENOVE_AUDIO_MAX_VOLUME;
uint8_t g_uson_ambient_restore_fx_profile = 0U;
uint32_t g_uson_ambient_next_play_ms = 0U;
#if defined(USE_AUDIO) && (USE_AUDIO != 0)
ui::audio::AmigaAudioPlayer g_amp_player;
@@ -241,6 +256,52 @@ const char* stepIdFromSnapshot(const ScenarioSnapshot& snapshot) {
return (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a";
}
const char* inferHotlineValidationStateTag(const ScenarioSnapshot& snapshot) {
const char* step_id = (snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "";
const char* scene_id = (snapshot.screen_scene_id != nullptr) ? snapshot.screen_scene_id : "";
if (std::strstr(scene_id, "WARNING") != nullptr || std::strstr(step_id, "WARNING") != nullptr) {
return "refused";
}
if (std::strstr(step_id, "RTC_ESP_ETAPE") != nullptr ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE") == 0 ||
std::strcmp(scene_id, "SCENE_WIN_ETAPE2") == 0) {
return "waiting";
}
if (std::strstr(scene_id, "WIN") != nullptr || std::strstr(step_id, "WIN") != nullptr ||
std::strstr(scene_id, "FINAL") != nullptr || std::strstr(step_id, "FINAL") != nullptr ||
std::strstr(scene_id, "REWARD") != nullptr) {
return "granted";
}
return "none";
}
void syncHotlineSceneState(const ScenarioSnapshot& snapshot) {
if (snapshot.screen_scene_id == nullptr || snapshot.screen_scene_id[0] == '\0') {
return;
}
StaticJsonDocument<224> scene_state_doc;
scene_state_doc["id"] = snapshot.screen_scene_id;
if (snapshot.step != nullptr && snapshot.step->id != nullptr && snapshot.step->id[0] != '\0') {
scene_state_doc["step_id"] = snapshot.step->id;
}
const char* validation_state = inferHotlineValidationStateTag(snapshot);
scene_state_doc["validation_state"] = validation_state;
String args_json;
serializeJson(scene_state_doc, args_json);
String payload = "SCENE ";
payload += args_json;
const bool ok = g_network.sendEspNowTarget(kEspNowBroadcastTarget, payload.c_str());
Serial.printf("[HOTLINE_SYNC] scene=%s step=%s validation=%s ok=%u\n",
snapshot.screen_scene_id,
(snapshot.step != nullptr && snapshot.step->id != nullptr) ? snapshot.step->id : "n/a",
validation_state,
ok ? 1U : 0U);
}
bool isFixedTestSceneActive(const ScenarioSnapshot& snapshot) {
return snapshot.screen_scene_id != nullptr && std::strcmp(snapshot.screen_scene_id, kTestLabSceneId) == 0;
}
@@ -488,6 +549,145 @@ bool parseHexRgbColor(const char* text, uint32_t* out_rgb) {
return true;
}
bool isAmbientRandomFileModeToken(const char* token) {
if (token == nullptr || token[0] == '\0') {
return false;
}
String mode = token;
mode.trim();
mode.toLowerCase();
return (mode == "random_file" || mode == "playlist" || mode == "random");
}
bool isAmbientAudioFilePath(const String& path) {
String lower = path;
lower.toLowerCase();
return lower.endsWith(".mp3") || lower.endsWith(".wav") || lower.endsWith(".aac") ||
lower.endsWith(".m4a") || lower.endsWith(".flac");
}
bool resolveAmbientRootFs(const char* raw_root, fs::FS** out_fs, String* out_root, bool* out_use_sd) {
if (out_fs == nullptr || out_root == nullptr || out_use_sd == nullptr) {
return false;
}
*out_fs = &LittleFS;
*out_use_sd = false;
out_root->remove(0);
if (raw_root == nullptr || raw_root[0] == '\0') {
return false;
}
String root = raw_root;
root.trim();
if (root.isEmpty()) {
return false;
}
#if ZACUS_HAS_SD_AUDIO
if (root.startsWith("sd:/")) {
*out_fs = &SD_MMC;
*out_use_sd = true;
root.remove(0, 3);
} else if (root.startsWith("/sd/")) {
*out_fs = &SD_MMC;
*out_use_sd = true;
root.remove(0, 3);
}
#endif
if (root.startsWith("littlefs:/")) {
root.remove(0, 9);
} else if (root.startsWith("/littlefs/")) {
root.remove(0, 9);
}
if (!root.startsWith("/")) {
root = "/" + root;
}
while (root.length() > 1U && root.endsWith("/")) {
root.remove(root.length() - 1U);
}
if (root.isEmpty()) {
root = "/";
}
*out_root = root;
return true;
}
void collectAmbientPlaylistFiles(fs::FS& file_system,
const String& root,
bool recursive,
uint8_t depth,
std::vector<String>* out_files) {
if (out_files == nullptr) {
return;
}
if (out_files->size() >= 128U) {
return;
}
File dir = file_system.open(root.c_str(), "r");
if (!dir || !dir.isDirectory()) {
return;
}
File entry = dir.openNextFile();
while (entry) {
String entry_name = entry.name();
if (!entry_name.startsWith("/")) {
entry_name = "/" + entry_name;
}
if (entry.isDirectory()) {
if (recursive && depth < 6U) {
collectAmbientPlaylistFiles(file_system, entry_name, recursive, static_cast<uint8_t>(depth + 1U), out_files);
}
} else if (isAmbientAudioFilePath(entry_name)) {
out_files->push_back(entry_name);
if (out_files->size() >= 128U) {
entry.close();
break;
}
}
entry.close();
entry = dir.openNextFile();
}
dir.close();
}
bool chooseAmbientPlaylistTrack(const SceneAmbientAudioFxState& ambient, uint32_t now_ms, String* out_track) {
if (out_track == nullptr) {
return false;
}
out_track->remove(0);
if (!isAmbientRandomFileModeToken(ambient.mode)) {
return false;
}
fs::FS* file_system = nullptr;
String root;
bool use_sd = false;
if (!resolveAmbientRootFs(ambient.playlist_root, &file_system, &root, &use_sd) || file_system == nullptr) {
return false;
}
std::vector<String> files;
files.reserve(24U);
collectAmbientPlaylistFiles(*file_system, root, ambient.playlist_recursive, 0U, &files);
if (files.empty()) {
return false;
}
const uint32_t seed = hashSceneFxSeed(now_ms ^ 0x59A31BUL ^ static_cast<uint32_t>(files.size()));
const size_t index = static_cast<size_t>(seed % static_cast<uint32_t>(files.size()));
const String selected = files[index];
if (use_sd) {
*out_track = String("/sd") + selected;
} else {
*out_track = selected;
}
return true;
}
void applyLcdBacklightEffective(uint8_t level) {
#if (FREENOVE_TFT_BL >= 0)
g_lcd_backlight_level = level;
@@ -510,6 +710,8 @@ void applyLcdBacklight(uint8_t level) {
void configureSceneVisualHardwareHints(const char* scene_id, const String& payload_json) {
const bool is_u_son_proto = (scene_id != nullptr) && (std::strcmp(scene_id, "SCENE_U_SON_PROTO") == 0);
const bool is_lefou_scene = (scene_id != nullptr) && (std::strcmp(scene_id, "SCENE_LEFOU_DETECTOR") == 0);
static constexpr uint16_t kLefouDefaultSequenceHz[] = {440U, 880U, 330U, 349U, 147U, 98U};
SceneBacklightFxState backlight = {};
backlight.enabled = is_u_son_proto;
@@ -536,8 +738,25 @@ void configureSceneVisualHardwareHints(const char* scene_id, const String& paylo
SceneAmbientAudioFxState ambient = {};
ambient.enabled = is_u_son_proto;
ambient.non_interruptive = true;
std::strncpy(ambient.mode, is_u_son_proto ? "random_file" : "single_track", sizeof(ambient.mode) - 1U);
ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
std::strncpy(ambient.track, kUsonAmbientTrack, sizeof(ambient.track) - 1U);
ambient.track[sizeof(ambient.track) - 1U] = '\0';
std::strncpy(ambient.playlist_root, is_u_son_proto ? "/music/v8_pack" : "", sizeof(ambient.playlist_root) - 1U);
ambient.playlist_root[sizeof(ambient.playlist_root) - 1U] = '\0';
ambient.playlist_recursive = true;
uint16_t lefou_sequence_hz[RuntimeHardwareConfig::kLaSequenceMaxNotes] = {};
uint8_t lefou_sequence_count = 0U;
uint16_t lefou_note_hold_ms = 350U;
if (is_lefou_scene) {
lefou_sequence_count = static_cast<uint8_t>(sizeof(kLefouDefaultSequenceHz) / sizeof(kLefouDefaultSequenceHz[0]));
if (lefou_sequence_count > RuntimeHardwareConfig::kLaSequenceMaxNotes) {
lefou_sequence_count = RuntimeHardwareConfig::kLaSequenceMaxNotes;
}
for (uint8_t idx = 0U; idx < lefou_sequence_count; ++idx) {
lefou_sequence_hz[idx] = kLefouDefaultSequenceHz[idx];
}
}
uint32_t accent_rgb = 0x6FD8FFUL;
bool ws_use_theme_accent = ws.single_random_blink;
@@ -671,11 +890,24 @@ void configureSceneVisualHardwareHints(const char* scene_id, const String& paylo
if (ambient_cfg["non_interruptive"].is<bool>()) {
ambient.non_interruptive = ambient_cfg["non_interruptive"].as<bool>();
}
const char* mode = ambient_cfg["mode"] | "";
if (mode[0] != '\0') {
std::strncpy(ambient.mode, mode, sizeof(ambient.mode) - 1U);
ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
}
const char* track = ambient_cfg["track"] | "";
if (track[0] != '\0') {
std::strncpy(ambient.track, track, sizeof(ambient.track) - 1U);
ambient.track[sizeof(ambient.track) - 1U] = '\0';
}
const char* playlist_root = ambient_cfg["playlist_root"] | "";
if (playlist_root[0] != '\0') {
std::strncpy(ambient.playlist_root, playlist_root, sizeof(ambient.playlist_root) - 1U);
ambient.playlist_root[sizeof(ambient.playlist_root) - 1U] = '\0';
}
if (ambient_cfg["playlist_recursive"].is<bool>()) {
ambient.playlist_recursive = ambient_cfg["playlist_recursive"].as<bool>();
}
if (ambient_cfg["volume_min"].is<unsigned int>()) {
uint32_t parsed = ambient_cfg["volume_min"].as<uint32_t>();
if (parsed > FREENOVE_AUDIO_MAX_VOLUME) {
@@ -697,6 +929,34 @@ void configureSceneVisualHardwareHints(const char* scene_id, const String& paylo
ambient.delay_max_ms = ambient_cfg["delay_max_ms"].as<uint32_t>();
}
}
if (is_lefou_scene && document["render"]["lefou_detector"].is<JsonObjectConst>()) {
const JsonObjectConst lefou_render = document["render"]["lefou_detector"].as<JsonObjectConst>();
if (lefou_render["note_sequence_hz"].is<JsonArrayConst>()) {
const JsonArrayConst note_sequence = lefou_render["note_sequence_hz"].as<JsonArrayConst>();
uint8_t note_count = 0U;
for (uint8_t idx = 0U; idx < RuntimeHardwareConfig::kLaSequenceMaxNotes; ++idx) {
lefou_sequence_hz[idx] = 0U;
}
for (JsonVariantConst note_node : note_sequence) {
if (!note_node.is<unsigned int>()) {
continue;
}
const uint32_t parsed_hz = note_node.as<uint32_t>();
if (parsed_hz < 40U || parsed_hz > 3000U) {
continue;
}
if (note_count >= RuntimeHardwareConfig::kLaSequenceMaxNotes) {
break;
}
lefou_sequence_hz[note_count++] = static_cast<uint16_t>(parsed_hz);
}
lefou_sequence_count = note_count;
}
if (lefou_render["note_hold_ms"].is<unsigned int>()) {
lefou_note_hold_ms = static_cast<uint16_t>(lefou_render["note_hold_ms"].as<uint32_t>());
}
}
}
}
@@ -748,14 +1008,81 @@ void configureSceneVisualHardwareHints(const char* scene_id, const String& paylo
if (ambient.volume_min > ambient.volume_max) {
ambient.volume_min = ambient.volume_max;
}
{
String mode = ambient.mode;
mode.trim();
mode.toLowerCase();
if (mode.isEmpty()) {
mode = "single_track";
}
std::strncpy(ambient.mode, mode.c_str(), sizeof(ambient.mode) - 1U);
ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
}
if (is_u_son_proto) {
ambient.enabled = true;
ambient.non_interruptive = true;
if (ambient.track[0] == '\0') {
std::strncpy(ambient.track, kUsonAmbientTrack, sizeof(ambient.track) - 1U);
ambient.track[sizeof(ambient.track) - 1U] = '\0';
}
if (!isAmbientRandomFileModeToken(ambient.mode)) {
std::strncpy(ambient.mode, "random_file", sizeof(ambient.mode) - 1U);
ambient.mode[sizeof(ambient.mode) - 1U] = '\0';
}
if (ambient.playlist_root[0] == '\0') {
std::strncpy(ambient.playlist_root, "/music/v8_pack", sizeof(ambient.playlist_root) - 1U);
ambient.playlist_root[sizeof(ambient.playlist_root) - 1U] = '\0';
}
ambient.volume_min = kUsonAmbientVolumeMin;
ambient.volume_max = kUsonAmbientVolumeMax;
if (ambient.delay_min_ms < kUsonAmbientDelayMinMs) {
ambient.delay_min_ms = kUsonAmbientDelayMinMs;
}
if (ambient.delay_max_ms > kUsonAmbientDelayMaxMs) {
ambient.delay_max_ms = kUsonAmbientDelayMaxMs;
}
}
if (ambient.delay_min_ms < 1000U) {
ambient.delay_min_ms = 1000U;
}
if (ambient.delay_max_ms < ambient.delay_min_ms) {
ambient.delay_max_ms = ambient.delay_min_ms;
}
if (ambient.delay_max_ms > 120000U) {
ambient.delay_max_ms = 120000U;
if (ambient.delay_max_ms > kUsonAmbientDelayMaxMs) {
ambient.delay_max_ms = kUsonAmbientDelayMaxMs;
}
if (!isAmbientRandomFileModeToken(ambient.mode)) {
ambient.playlist_root[0] = '\0';
}
g_hardware_cfg.mic_la_sequence_enabled = false;
g_hardware_cfg.mic_la_sequence_count = 0U;
g_hardware_cfg.mic_la_sequence_note_hold_ms = 350U;
for (uint8_t idx = 0U; idx < RuntimeHardwareConfig::kLaSequenceMaxNotes; ++idx) {
g_hardware_cfg.mic_la_sequence_hz[idx] = 0U;
}
if (is_lefou_scene) {
g_hardware_cfg.mic_la_timeout_ms = 0U;
if (lefou_note_hold_ms < 100U) {
lefou_note_hold_ms = 100U;
} else if (lefou_note_hold_ms > 3000U) {
lefou_note_hold_ms = 3000U;
}
g_hardware_cfg.mic_la_sequence_note_hold_ms = lefou_note_hold_ms;
if (lefou_sequence_count > RuntimeHardwareConfig::kLaSequenceMaxNotes) {
lefou_sequence_count = RuntimeHardwareConfig::kLaSequenceMaxNotes;
}
g_hardware_cfg.mic_la_sequence_count = lefou_sequence_count;
g_hardware_cfg.mic_la_sequence_enabled = (lefou_sequence_count > 0U);
for (uint8_t idx = 0U; idx < lefou_sequence_count; ++idx) {
g_hardware_cfg.mic_la_sequence_hz[idx] = lefou_sequence_hz[idx];
}
if (g_hardware_cfg.mic_la_sequence_count > 0U) {
g_hardware_cfg.mic_la_target_hz = g_hardware_cfg.mic_la_sequence_hz[0];
}
} else if (g_hardware_cfg.mic_la_timeout_ms == 0U) {
// SCENE_LEFOU disables timeout; restore the default timeout on other scenes.
g_hardware_cfg.mic_la_timeout_ms = RuntimeHardwareConfig().mic_la_timeout_ms;
}
g_scene_backlight_fx = backlight;
@@ -1013,7 +1340,6 @@ void updateUsonProtoAmbientAudio(const ScenarioSnapshot& snapshot, uint32_t now_
if (!uson_scene_active) {
if (g_uson_ambient_audio_active) {
g_audio.setVolume(g_uson_ambient_restore_volume);
g_audio.setFxProfile(g_uson_ambient_restore_fx_profile);
g_uson_ambient_audio_active = false;
g_uson_ambient_next_play_ms = 0U;
}
@@ -1023,11 +1349,9 @@ void updateUsonProtoAmbientAudio(const ScenarioSnapshot& snapshot, uint32_t now_
if (!g_uson_ambient_audio_active) {
g_uson_ambient_audio_active = true;
g_uson_ambient_restore_volume = g_audio.volume();
g_uson_ambient_restore_fx_profile = g_audio.fxProfile();
if (!g_scene_ambient_audio_fx.non_interruptive && g_audio.isPlaying()) {
g_audio.stop();
}
g_audio.setFxProfile(0U);
g_uson_ambient_next_play_ms = now_ms;
}
@@ -1042,21 +1366,24 @@ void updateUsonProtoAmbientAudio(const ScenarioSnapshot& snapshot, uint32_t now_
g_audio.stop();
}
g_audio.setFxProfile(0U);
const uint8_t volume = chooseAmbientVolume(0x0091A24UL);
g_audio.setVolume(volume);
const char* ambient_track = (g_scene_ambient_audio_fx.track[0] != '\0') ? g_scene_ambient_audio_fx.track : kUsonAmbientTrack;
const bool ok = g_audio.play(ambient_track);
String ambient_track_path;
if (!chooseAmbientPlaylistTrack(g_scene_ambient_audio_fx, now_ms, &ambient_track_path)) {
ambient_track_path =
(g_scene_ambient_audio_fx.track[0] != '\0') ? String(g_scene_ambient_audio_fx.track) : String(kUsonAmbientTrack);
}
const bool ok = g_audio.play(ambient_track_path.c_str());
if (!ok) {
g_uson_ambient_next_play_ms = now_ms + 4000U;
Serial.printf("[AUDIO] uson ambient missing/failed track=%s\n", ambient_track);
Serial.printf("[AUDIO] uson ambient missing/failed track=%s\n", ambient_track_path.c_str());
return;
}
const uint32_t next_delay_ms = chooseAmbientDelayMs(0x007A3B2UL);
g_uson_ambient_next_play_ms = now_ms + next_delay_ms;
Serial.printf("[AUDIO] uson ambient play track=%s volume=%u non_interruptive=%u next_in_ms=%lu\n",
ambient_track,
ambient_track_path.c_str(),
static_cast<unsigned int>(volume),
g_scene_ambient_audio_fx.non_interruptive ? 1U : 0U,
static_cast<unsigned long>(next_delay_ms));
@@ -1826,6 +2153,47 @@ bool executeEspNowCommandPayload(const char* payload_text, uint32_t now_ms, EspN
out_result->ok = true;
return true;
}
if (command == "UI_SCENE_STATUS") {
const UiSceneStatusSnapshot ui = g_ui.sceneStatusSnapshot();
String scene_id = ui.scene_id;
String step_id = ui.step_id;
String scene_upper = scene_id;
String step_upper = step_id;
scene_upper.toUpperCase();
step_upper.toUpperCase();
const char* validation_state = "none";
if (step_upper.indexOf("RTC_ESP_ETAPE") >= 0 ||
step_upper.indexOf("WAITING") >= 0 ||
step_upper.indexOf("PENDING") >= 0 ||
scene_upper == "SCENE_WIN_ETAPE" ||
scene_upper == "SCENE_WIN_ETAPE2") {
validation_state = "waiting";
} else if (step_upper.indexOf("WARNING") >= 0 ||
step_upper.indexOf("REFUS") >= 0 ||
step_upper.indexOf("BROKEN") >= 0 ||
scene_upper == "SCENE_WARNING" ||
scene_upper == "SCENE_BROKEN") {
validation_state = "refused";
} else if (step_upper.indexOf("WIN") >= 0 ||
step_upper.indexOf("FINAL") >= 0 ||
step_upper.indexOf("REWARD") >= 0 ||
scene_upper == "SCENE_WIN_ETAPE1" ||
scene_upper == "SCENE_WIN" ||
scene_upper == "SCENE_REWARD") {
validation_state = "granted";
}
StaticJsonDocument<320> response;
response["valid"] = ui.valid;
response["scenario_id"] = ui.scenario_id;
response["scene_id"] = ui.scene_id;
response["step_id"] = ui.step_id;
response["validation_state"] = validation_state;
serializeJson(response, out_result->data_json);
out_result->ok = true;
return true;
}
if (command == "WIFI_STATUS") {
StaticJsonDocument<384> response;
webFillWifiStatus(response.to<JsonObject>(), g_network.snapshot());
@@ -3311,6 +3679,15 @@ void webFillHardwareStatus(JsonObject out) {
la_trigger["release_ms"] = g_hardware_cfg.mic_la_release_ms;
la_trigger["cooldown_ms"] = g_hardware_cfg.mic_la_cooldown_ms;
la_trigger["timeout_ms"] = g_hardware_cfg.mic_la_timeout_ms;
la_trigger["sequence_enabled"] = g_hardware_cfg.mic_la_sequence_enabled;
la_trigger["sequence_note_hold_ms"] = g_hardware_cfg.mic_la_sequence_note_hold_ms;
la_trigger["sequence_count"] = g_hardware_cfg.mic_la_sequence_count;
JsonArray sequence = la_trigger["sequence_hz"].to<JsonArray>();
for (uint8_t idx = 0U; idx < g_hardware_cfg.mic_la_sequence_count &&
idx < RuntimeHardwareConfig::kLaSequenceMaxNotes;
++idx) {
sequence.add(g_hardware_cfg.mic_la_sequence_hz[idx]);
}
la_trigger["event_name"] = g_hardware_cfg.mic_la_event_name;
la_trigger["gate_active"] = g_la_trigger.gate_active;
la_trigger["sample_match"] = g_la_trigger.sample_match;
@@ -3318,6 +3695,8 @@ void webFillHardwareStatus(JsonObject out) {
la_trigger["timeout_pending"] = g_la_trigger.timeout_pending;
la_trigger["stable_now_ms"] = g_la_trigger.stable_ms;
la_trigger["stable_pct"] = laStablePercent();
la_trigger["sequence_index"] = g_la_trigger.sequence_index;
la_trigger["sequence_target_hz"] = g_la_trigger.sequence_target_hz;
out["battery_enabled"] = g_hardware_cfg.battery_enabled;
out["battery_low_pct"] = g_hardware_cfg.battery_low_pct;
out["battery_pct"] = hw.battery_percent;
@@ -3699,7 +4078,7 @@ bool executeStoryAction(const char* action_id, const ScenarioSnapshot& snapshot,
std::strcmp(action_type, "espnow_send") == 0) {
const char* target = action_doc["config"]["target"] | action_doc["config"]["peer"] | "broadcast";
const char* payload = action_doc["config"]["payload"] | "";
String fallback_payload;
String payload_text;
if (payload[0] == '\0') {
const char* inferred = "WAITING_VALIDATION";
if (std::strstr(action_id, "ETAPE2") != nullptr) {
@@ -3707,8 +4086,23 @@ bool executeStoryAction(const char* action_id, const ScenarioSnapshot& snapshot,
} else if (std::strstr(action_id, "ETAPE1") != nullptr) {
inferred = "ACK_WIN1";
}
fallback_payload = inferred;
payload = fallback_payload.c_str();
payload_text = inferred;
payload = payload_text.c_str();
} else {
payload_text = payload;
}
if ((std::strcmp(action_id, "ACTION_ESP_NOW_WAITING") == 0 ||
std::strcmp(action_id, "ACTION_ESP_NOW_WAITING_VALIDATION") == 0 || payload_text == "WAITING_VALIDATION") &&
!payload_text.startsWith("WAITING_VALIDATION {")) {
StaticJsonDocument<192> waiting_doc;
waiting_doc["scene_id"] = snapshot.screen_scene_id != nullptr ? snapshot.screen_scene_id : "";
waiting_doc["step_id"] = stepIdFromSnapshot(snapshot);
waiting_doc["validation_state"] = "waiting";
String waiting_args;
serializeJson(waiting_doc, waiting_args);
payload_text = String("WAITING_VALIDATION ") + waiting_args;
payload = payload_text.c_str();
}
const bool ok = g_network.sendEspNowTarget(target, payload);
Serial.printf("[ACTION] ESPNOW_SEND id=%s target=%s payload=%s ok=%u\n",
@@ -5268,6 +5662,10 @@ void refreshSceneIfNeededImpl(bool force_render) {
}
}
if (transition.scene_changed && !kForceTestLabSceneLock) {
syncHotlineSceneState(snapshot);
}
const char* led_scene_id = snapshot.screen_scene_id;
if (kForceTestLabSceneLock) {
led_scene_id = kTestLabSceneId;
@@ -6350,20 +6748,26 @@ void setup() {
g_buttons.begin();
g_touch.begin();
g_network.begin(g_network_cfg.hostname);
g_network.configureFallbackAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
g_network.configureLocalPolicy(g_network_cfg.local_ssid,
g_network_cfg.local_password,
g_network_cfg.force_ap_if_not_local,
g_network_cfg.local_retry_ms,
g_network_cfg.pause_local_retry_when_ap_client);
if (g_setup_mode && g_network_cfg.ap_default_ssid[0] != '\0') {
g_network.startAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
}
if (g_network_cfg.local_ssid[0] != '\0') {
const bool connect_started = g_network.connectSta(g_network_cfg.local_ssid, g_network_cfg.local_password);
Serial.printf("[NET] boot wifi target=%s started=%u\n",
g_network_cfg.local_ssid,
connect_started ? 1U : 0U);
if (kBootEspNowOnlyMode) {
g_network.configureFallbackAp("", "");
g_network.configureLocalPolicy("", "", false, g_network_cfg.local_retry_ms, false);
Serial.println("[NET] wifi+web disabled (espnow_only_mode=1)");
} else {
g_network.configureFallbackAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
g_network.configureLocalPolicy(g_network_cfg.local_ssid,
g_network_cfg.local_password,
g_network_cfg.force_ap_if_not_local,
g_network_cfg.local_retry_ms,
g_network_cfg.pause_local_retry_when_ap_client);
if (g_setup_mode && g_network_cfg.ap_default_ssid[0] != '\0') {
g_network.startAp(g_network_cfg.ap_default_ssid, g_network_cfg.ap_default_password);
}
if (g_network_cfg.local_ssid[0] != '\0') {
const bool connect_started = g_network.connectSta(g_network_cfg.local_ssid, g_network_cfg.local_password);
Serial.printf("[NET] boot wifi target=%s started=%u\n",
g_network_cfg.local_ssid,
connect_started ? 1U : 0U);
}
}
if (g_network_cfg.espnow_enabled_on_boot) {
if (g_network.enableEspNow()) {
@@ -6380,7 +6784,11 @@ void setup() {
Serial.println("[NET] ESP-NOW boot disabled by APP_ESPNOW config");
}
g_next_espnow_discovery_ms = millis() + 2000U;
setupWebUi();
if (!kBootEspNowOnlyMode) {
setupWebUi();
} else {
Serial.println("[WEB] disabled (espnow_only_mode=1)");
}
g_audio.begin();
Serial.printf("[MAIN] audio profile=%u:%s count=%u\n",
g_audio.outputProfile(),
@@ -31,6 +31,25 @@ uint8_t estimatedLevelPct(const HardwareManager::Snapshot& hw) {
std::min<uint16_t>(100U, static_cast<uint16_t>((effective_peak * 100U) / kLaMatchLevelDenom)));
}
bool isSceneId(const ScenarioSnapshot& snapshot, const char* expected) {
if (snapshot.screen_scene_id == nullptr || expected == nullptr) {
return false;
}
const char* normalized_scene_id = storyNormalizeScreenSceneId(snapshot.screen_scene_id);
if (normalized_scene_id == nullptr) {
return false;
}
return std::strcmp(normalized_scene_id, expected) == 0;
}
bool isLaDetectorScene(const ScenarioSnapshot& snapshot) {
return isSceneId(snapshot, "SCENE_LA_DETECTOR");
}
bool isLefouDetectorScene(const ScenarioSnapshot& snapshot) {
return isSceneId(snapshot, "SCENE_LEFOU_DETECTOR");
}
uint16_t toleranceForTarget(const RuntimeHardwareConfig& config) {
const uint16_t configured = config.mic_la_tolerance_hz;
if (configured == 0U) {
@@ -39,13 +58,14 @@ uint16_t toleranceForTarget(const RuntimeHardwareConfig& config) {
return (configured > kLaDetectionToleranceHz) ? kLaDetectionToleranceHz : configured;
}
uint8_t centsLimitForTarget(const RuntimeHardwareConfig& config, uint16_t effective_tolerance_hz) {
uint8_t centsLimitForTarget(const RuntimeHardwareConfig& config,
uint16_t target_hz,
uint16_t effective_tolerance_hz) {
uint8_t limit = config.mic_la_max_abs_cents;
if (config.mic_la_target_hz > 0U && effective_tolerance_hz > 0U &&
config.mic_la_target_hz >= effective_tolerance_hz) {
const float target_hz = static_cast<float>(config.mic_la_target_hz);
const float upper_hz = target_hz + static_cast<float>(effective_tolerance_hz);
const float tolerance_cents_f = 1200.0f * std::log2(upper_hz / target_hz);
if (target_hz > 0U && effective_tolerance_hz > 0U && target_hz >= effective_tolerance_hz) {
const float target_hz_f = static_cast<float>(target_hz);
const float upper_hz = target_hz_f + static_cast<float>(effective_tolerance_hz);
const float tolerance_cents_f = 1200.0f * std::log2(upper_hz / target_hz_f);
if (std::isfinite(tolerance_cents_f) && tolerance_cents_f > 0.0f) {
const uint8_t tolerance_cents =
static_cast<uint8_t>(std::min<float>(120.0f, std::ceil(tolerance_cents_f)));
@@ -84,14 +104,7 @@ bool LaTriggerService::isTriggerStep(const ScenarioSnapshot& snapshot) {
std::strcmp(snapshot.step->id, "STEP_WAIT_ETAPE2") == 0) {
return true;
}
if (snapshot.screen_scene_id == nullptr) {
return false;
}
const char* normalized_scene_id = storyNormalizeScreenSceneId(snapshot.screen_scene_id);
if (normalized_scene_id == nullptr) {
return false;
}
return (std::strcmp(normalized_scene_id, "SCENE_LA_DETECTOR") == 0);
return isLaDetectorScene(snapshot) || isLefouDetectorScene(snapshot);
}
bool LaTriggerService::shouldEnforceMatchOnly(const RuntimeHardwareConfig& config,
@@ -139,9 +152,13 @@ uint8_t LaTriggerService::stablePercent(const RuntimeHardwareConfig& config,
return static_cast<uint8_t>(percent);
}
bool LaTriggerService::isSampleMatching(const RuntimeHardwareConfig& config,
const HardwareManager::Snapshot& hw,
bool relaxed_for_continuity) {
bool isSampleMatchingForTarget(const RuntimeHardwareConfig& config,
const HardwareManager::Snapshot& hw,
bool relaxed_for_continuity,
uint16_t target_hz) {
if (target_hz == 0U) {
return false;
}
if (!hw.mic_ready || hw.mic_freq_hz == 0U) {
return false;
}
@@ -172,7 +189,8 @@ bool LaTriggerService::isSampleMatching(const RuntimeHardwareConfig& config,
if (abs_cents < 0) {
abs_cents = static_cast<int16_t>(-abs_cents);
}
uint8_t cents_limit = centsLimitForTarget(config, toleranceForTarget(config));
const uint16_t effective_tolerance_hz = toleranceForTarget(config);
uint8_t cents_limit = centsLimitForTarget(config, target_hz, effective_tolerance_hz);
if (relaxed_for_continuity && cents_limit < 120U) {
cents_limit = static_cast<uint8_t>(std::min<uint16_t>(120U, static_cast<uint16_t>(cents_limit + 4U)));
}
@@ -180,12 +198,18 @@ bool LaTriggerService::isSampleMatching(const RuntimeHardwareConfig& config,
return false;
}
const uint16_t tolerance_hz =
static_cast<uint16_t>(toleranceForTarget(config) + (relaxed_for_continuity ? 2U : 0U));
const int32_t delta_hz = static_cast<int32_t>(hw.mic_freq_hz) - static_cast<int32_t>(config.mic_la_target_hz);
static_cast<uint16_t>(effective_tolerance_hz + (relaxed_for_continuity ? 2U : 0U));
const int32_t delta_hz = static_cast<int32_t>(hw.mic_freq_hz) - static_cast<int32_t>(target_hz);
const int32_t abs_delta_hz = (delta_hz < 0) ? -delta_hz : delta_hz;
return abs_delta_hz <= static_cast<int32_t>(tolerance_hz);
}
bool LaTriggerService::isSampleMatching(const RuntimeHardwareConfig& config,
const HardwareManager::Snapshot& hw,
bool relaxed_for_continuity) {
return isSampleMatchingForTarget(config, hw, relaxed_for_continuity, config.mic_la_target_hz);
}
LaTriggerService::UpdateResult LaTriggerService::update(const RuntimeHardwareConfig& config,
LaTriggerRuntimeState* state,
const ScenarioSnapshot& snapshot,
@@ -220,6 +244,100 @@ LaTriggerService::UpdateResult LaTriggerService::update(const RuntimeHardwareCon
return result;
}
const bool sequence_mode =
config.mic_la_sequence_enabled &&
isLefouDetectorScene(snapshot) &&
(config.mic_la_sequence_count > 0U);
if (sequence_mode) {
const uint8_t max_notes = RuntimeHardwareConfig::kLaSequenceMaxNotes;
uint8_t sequence_count = config.mic_la_sequence_count;
if (sequence_count > max_notes) {
sequence_count = max_notes;
}
if (sequence_count == 0U) {
return result;
}
if (state->sequence_index >= sequence_count) {
state->sequence_index = sequence_count;
state->locked = true;
state->stable_ms = config.mic_la_stable_ms;
} else {
const uint16_t note_hold_ms =
(config.mic_la_sequence_note_hold_ms < 100U) ? 100U : config.mic_la_sequence_note_hold_ms;
const uint16_t target_hz = config.mic_la_sequence_hz[state->sequence_index];
state->sequence_target_hz = target_hz;
const bool strict_match = isSampleMatchingForTarget(config, hw, false, target_hz);
const bool relaxed_match = isSampleMatchingForTarget(config, hw, true, target_hz);
state->sample_match = strict_match;
const uint32_t continuity_ms = std::max<uint32_t>(1U, static_cast<uint32_t>(config.mic_la_release_ms));
const uint32_t dt_ms = (state->last_match_ms == 0U) ? 0U : (now_ms - state->last_match_ms);
const bool continuity_ok = (dt_ms == 0U) || (dt_ms <= continuity_ms);
if (strict_match || (relaxed_match && continuity_ok)) {
if (state->sequence_note_started_ms == 0U) {
state->sequence_note_started_ms = now_ms;
state->stable_since_ms = now_ms;
}
if (strict_match) {
state->last_match_ms = now_ms;
}
const uint32_t held_ms = now_ms - state->sequence_note_started_ms;
if (held_ms >= note_hold_ms) {
if (state->sequence_index < sequence_count) {
++state->sequence_index;
}
state->sequence_note_started_ms = 0U;
state->la_consecutive_match_count = 0U;
state->la_match_start_ms = now_ms;
state->last_match_ms = now_ms;
if (state->sequence_index >= sequence_count) {
state->locked = true;
state->stable_ms = config.mic_la_stable_ms;
}
}
} else {
const bool expired = (state->last_match_ms != 0U) && (dt_ms > continuity_ms);
if (expired) {
state->last_match_ms = 0U;
state->sequence_note_started_ms = 0U;
state->la_consecutive_match_count = 0U;
}
}
if (!state->locked) {
uint32_t progress_units = static_cast<uint32_t>(state->sequence_index) * 100U;
if (state->sequence_index < sequence_count && state->sequence_note_started_ms > 0U) {
const uint32_t held_ms = now_ms - state->sequence_note_started_ms;
const uint32_t note_pct = std::min<uint32_t>(100U, (held_ms * 100U) / note_hold_ms);
progress_units += note_pct;
}
const uint32_t progress_pct = std::min<uint32_t>(
100U, progress_units / std::max<uint8_t>(1U, sequence_count));
state->stable_ms = (config.mic_la_stable_ms > 0U)
? (config.mic_la_stable_ms * progress_pct) / 100U
: progress_pct;
}
}
if (!state->locked && config.mic_la_timeout_ms > 0U && state->gate_entered_ms > 0U &&
(now_ms - state->gate_entered_ms) >= config.mic_la_timeout_ms) {
result.timed_out = true;
return result;
}
if (!state->locked || state->dispatched) {
return result;
}
if (state->last_trigger_ms > 0U && (now_ms - state->last_trigger_ms) < config.mic_la_cooldown_ms) {
return result;
}
result.lock_ready = true;
return result;
}
state->sequence_index = 0U;
state->sequence_note_started_ms = 0U;
state->sequence_target_hz = 0U;
const uint32_t effective_window_ms = (match_continuity_window_ms == 0U) ? 1U : match_continuity_window_ms;
const bool sample_match = isSampleMatching(config, hw, false);
const bool continuity_match = isSampleMatching(config, hw, true);
File diff suppressed because it is too large Load Diff