chore(box): commit firmware base + feature docs
Backfills the working firmware that had stayed uncommitted across the build sessions, so the branch is self-contained. - audio: audio_player, radio_pipeline, sd_player, calm_tone, aac_player - net: podcast (RSS), udp_log - modes: balloons, plus calm/games/radio tweaks - ui: fonts header, icons; build files (CMakeLists, idf_component.yml, sdkconfig.defaults) - docs/superpowers: Histoires design + plan Note: sdkconfig stays gitignored (Wi-Fi creds + tuned LFN/FS/mbedtls settings); a fresh checkout must reconfigure those.
This commit is contained in:
+12
-2
@@ -14,8 +14,18 @@ cmake_minimum_required(VERSION 3.16)
|
||||
if(DEFINED ENV{ADF_PATH})
|
||||
set(ADF_COMPONENTS "$ENV{ADF_PATH}/components")
|
||||
if(EXISTS "${ADF_COMPONENTS}")
|
||||
list(APPEND EXTRA_COMPONENT_DIRS "${ADF_COMPONENTS}")
|
||||
message(STATUS "Lisael Box: ESP-ADF components from ${ADF_COMPONENTS}")
|
||||
# On n'ajoute QUE les composants audio nécessaires (pas tout l'arbre ADF) :
|
||||
# `audio_board` épingle esp_lcd_ili9341 ^1 et entre en conflit avec le BSP
|
||||
# esp-box-3 (qui veut ^2.0.1). On exclut donc audio_board / display_service
|
||||
# et on laisse le BSP gérer écran/tactile/SD/codec.
|
||||
set(ADF_AUDIO_COMPONENTS
|
||||
esp-adf-libs audio_pipeline audio_stream audio_sal esp_dispatcher)
|
||||
foreach(_adf_c IN LISTS ADF_AUDIO_COMPONENTS)
|
||||
if(EXISTS "${ADF_COMPONENTS}/${_adf_c}")
|
||||
list(APPEND EXTRA_COMPONENT_DIRS "${ADF_COMPONENTS}/${_adf_c}")
|
||||
endif()
|
||||
endforeach()
|
||||
message(STATUS "Lisael Box: ESP-ADF audio components from ${ADF_COMPONENTS}")
|
||||
else()
|
||||
message(WARNING "Lisael Box: ADF_PATH set but ${ADF_COMPONENTS} not found")
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,867 @@
|
||||
# Écran « Histoires » à vignettes — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Remplacer les menus « Histoires » et « Sons » par un seul écran à vignettes où l'on choisit une histoire par sa pochette (grille 2×2 paginée → liste d'épisodes → lecture plein écran).
|
||||
|
||||
**Architecture:** Le contenu (audio + pochettes `.bin` LVGL + `manifest.json` v2 groupé par show) vit sur la carte SD. Un parseur pur (testable sur l'hôte) charge le manifeste ; un écran LVGL à 3 niveaux l'affiche ; les pochettes sont lues depuis la SD via le driver de fichiers LVGL stdio ; la lecture réutilise le dispatch existant `.m4a → aac_player` / `.mp3 → sd_player`.
|
||||
|
||||
**Tech Stack:** ESP-IDF v5.4, LVGL 9.5 (esp-box-3 BSP), cJSON, FATFS (LFN activé), `LVGLImage.py` (pochettes → `.bin`).
|
||||
|
||||
**Notes d'exécution :**
|
||||
- **Vérification :** ce firmware n'a pas de harnais de test UI. Le seul test unitaire hôte est celui du parseur (Task 1). Pour le reste, la vérification par tâche = `idf.py build` réussit (attrape erreurs de type/compilation) ; la validation finale est l'acceptation sur la vraie carte (Task 8).
|
||||
- **Commits :** locaux uniquement, **ne pas pousser** (politique du repo : push sur demande explicite). Messages en anglais, sujet ≤ 50 car., pas d'attribution IA, pas de `_` dans le scope.
|
||||
- **Activation IDF** (chaque shell) : `. /Users/electron/esp/esp-idf/export.sh`. Port box : `/dev/cu.usbmodem1101`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1 : Parseur de manifeste v2 (pur, testé sur l'hôte)
|
||||
|
||||
**Files:**
|
||||
- Create: `main/audio/podcast_manifest.h`
|
||||
- Create: `main/audio/podcast_manifest.c`
|
||||
- Test: `test/host/test_podcast_manifest.c`
|
||||
|
||||
- [ ] **Step 1 : Écrire le test hôte (échoue d'abord)**
|
||||
|
||||
`test/host/test_podcast_manifest.c` :
|
||||
```c
|
||||
// Host unit test for the v2 podcast manifest parser.
|
||||
#define LISAEL_HOST_TEST
|
||||
#include "audio/podcast_manifest.h"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static const char *SAMPLE =
|
||||
"[{\"show\":\"Une histoire et Oli\",\"cover\":\"oli.bin\",\"episodes\":["
|
||||
"{\"file\":\"oli_01.m4a\",\"title\":\"Concert p.1\"},"
|
||||
"{\"file\":\"oli_02.m4a\",\"title\":\"Concert p.2\"}]},"
|
||||
"{\"show\":\"Bestioles\",\"cover\":\"bestioles.bin\",\"episodes\":["
|
||||
"{\"file\":\"bestioles_01.mp3\",\"title\":\"La gerboise\"}]},"
|
||||
"{\"show\":\"Vide\",\"episodes\":[]}]";
|
||||
|
||||
int main(void) {
|
||||
lisael_show_t shows[LISAEL_MAX_SHOWS];
|
||||
int n = lisael_podcast_parse(SAMPLE, shows, LISAEL_MAX_SHOWS);
|
||||
assert(n == 2); // empty show skipped
|
||||
assert(strcmp(shows[0].show, "Une histoire et Oli") == 0);
|
||||
assert(strcmp(shows[0].cover, "oli.bin") == 0);
|
||||
assert(shows[0].n_eps == 2);
|
||||
assert(strcmp(shows[0].eps[1].file, "oli_02.m4a") == 0);
|
||||
assert(strcmp(shows[0].eps[0].title, "Concert p.1") == 0);
|
||||
assert(strcmp(shows[1].show, "Bestioles") == 0);
|
||||
assert(shows[1].n_eps == 1);
|
||||
assert(lisael_podcast_parse("not json", shows, LISAEL_MAX_SHOWS) == -1);
|
||||
printf("OK: %d shows\n", n);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Écrire le header**
|
||||
|
||||
`main/audio/podcast_manifest.h` :
|
||||
```c
|
||||
// Podcast manifest v2 (grouped by show) for the Lisael Box "Histoires" screen.
|
||||
// /sdcard/podcasts/manifest.json = JSON array of shows, each with a cover and
|
||||
// a list of episodes. ASCII file names; UTF-8 show/title strings.
|
||||
#pragma once
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#define LISAEL_MAX_SHOWS 12
|
||||
#define LISAEL_MAX_EPISODES 24
|
||||
|
||||
typedef struct {
|
||||
char file[64]; // audio basename under /sdcard/podcasts/ (e.g. "oli_01.m4a")
|
||||
char title[80]; // UTF-8 display title
|
||||
} lisael_episode_t;
|
||||
|
||||
typedef struct {
|
||||
char show[48]; // UTF-8 show name
|
||||
char cover[40]; // cover basename ("oli.bin"), "" if none
|
||||
lisael_episode_t eps[LISAEL_MAX_EPISODES];
|
||||
int n_eps;
|
||||
} lisael_show_t;
|
||||
|
||||
// Pure parser: JSON string -> shows[]. Skips shows with zero episodes.
|
||||
// Returns show count (>=0), or -1 if the JSON is not a valid array.
|
||||
int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows);
|
||||
|
||||
// Read /sdcard/podcasts/manifest.json and parse it. Returns count or -1.
|
||||
int lisael_podcast_load(lisael_show_t *shows, int max_shows);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 3 : Vérifier que le test échoue (pas d'implémentation)**
|
||||
|
||||
Run :
|
||||
```bash
|
||||
cc -DLISAEL_HOST_TEST -I/tmp/lisael-box/main \
|
||||
-I/Users/electron/esp/esp-idf/components/json/cJSON \
|
||||
/tmp/lisael-box/test/host/test_podcast_manifest.c \
|
||||
/tmp/lisael-box/main/audio/podcast_manifest.c \
|
||||
/Users/electron/esp/esp-idf/components/json/cJSON/cJSON.c \
|
||||
-o /tmp/test_podcast_manifest 2>&1 | head
|
||||
```
|
||||
Expected : ÉCHEC de link/compile (`podcast_manifest.c` n'existe pas encore / `lisael_podcast_parse` indéfini).
|
||||
|
||||
- [ ] **Step 4 : Implémenter le parseur**
|
||||
|
||||
`main/audio/podcast_manifest.c` :
|
||||
```c
|
||||
// See podcast_manifest.h. The pure parser is host-testable (no ESP-IDF deps);
|
||||
// the SD loader is compiled only on-target (#ifndef LISAEL_HOST_TEST).
|
||||
#include "audio/podcast_manifest.h"
|
||||
#include "cJSON.h"
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int lisael_podcast_parse(const char *json, lisael_show_t *shows, int max_shows)
|
||||
{
|
||||
if (!json || !shows || max_shows <= 0) {
|
||||
return -1;
|
||||
}
|
||||
cJSON *root = cJSON_Parse(json);
|
||||
if (!cJSON_IsArray(root)) {
|
||||
cJSON_Delete(root);
|
||||
return -1;
|
||||
}
|
||||
|
||||
int n = 0;
|
||||
cJSON *jshow;
|
||||
cJSON_ArrayForEach(jshow, root) {
|
||||
if (n >= max_shows) {
|
||||
break;
|
||||
}
|
||||
cJSON *jname = cJSON_GetObjectItem(jshow, "show");
|
||||
cJSON *jcover = cJSON_GetObjectItem(jshow, "cover");
|
||||
cJSON *jeps = cJSON_GetObjectItem(jshow, "episodes");
|
||||
if (!cJSON_IsString(jname) || !cJSON_IsArray(jeps)) {
|
||||
continue;
|
||||
}
|
||||
lisael_show_t *s = &shows[n];
|
||||
memset(s, 0, sizeof(*s));
|
||||
snprintf(s->show, sizeof(s->show), "%s", jname->valuestring);
|
||||
if (cJSON_IsString(jcover)) {
|
||||
snprintf(s->cover, sizeof(s->cover), "%s", jcover->valuestring);
|
||||
}
|
||||
cJSON *jep;
|
||||
cJSON_ArrayForEach(jep, jeps) {
|
||||
if (s->n_eps >= LISAEL_MAX_EPISODES) {
|
||||
break;
|
||||
}
|
||||
cJSON *jf = cJSON_GetObjectItem(jep, "file");
|
||||
cJSON *jt = cJSON_GetObjectItem(jep, "title");
|
||||
if (!cJSON_IsString(jf)) {
|
||||
continue;
|
||||
}
|
||||
lisael_episode_t *e = &s->eps[s->n_eps];
|
||||
snprintf(e->file, sizeof(e->file), "%s", jf->valuestring);
|
||||
snprintf(e->title, sizeof(e->title), "%s",
|
||||
cJSON_IsString(jt) ? jt->valuestring : jf->valuestring);
|
||||
s->n_eps++;
|
||||
}
|
||||
if (s->n_eps > 0) {
|
||||
n++; // skip shows with no playable episode
|
||||
}
|
||||
}
|
||||
cJSON_Delete(root);
|
||||
return n;
|
||||
}
|
||||
|
||||
#ifndef LISAEL_HOST_TEST
|
||||
#include "esp_log.h"
|
||||
int lisael_podcast_load(lisael_show_t *shows, int max_shows)
|
||||
{
|
||||
const char *path = "/sdcard/podcasts/manifest.json";
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) {
|
||||
return -1;
|
||||
}
|
||||
fseek(f, 0, SEEK_END);
|
||||
long sz = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
if (sz <= 0 || sz > 64 * 1024) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
char *buf = malloc((size_t)sz + 1);
|
||||
if (!buf) {
|
||||
fclose(f);
|
||||
return -1;
|
||||
}
|
||||
size_t rd = fread(buf, 1, (size_t)sz, f);
|
||||
buf[rd] = '\0';
|
||||
fclose(f);
|
||||
int n = lisael_podcast_parse(buf, shows, max_shows);
|
||||
free(buf);
|
||||
ESP_LOGI("podcast", "loaded %d show(s) from %s", n, path);
|
||||
return n;
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
- [ ] **Step 5 : Vérifier que le test passe**
|
||||
|
||||
Run (même commande qu'au Step 3) :
|
||||
```bash
|
||||
cc -DLISAEL_HOST_TEST -I/tmp/lisael-box/main \
|
||||
-I/Users/electron/esp/esp-idf/components/json/cJSON \
|
||||
/tmp/lisael-box/test/host/test_podcast_manifest.c \
|
||||
/tmp/lisael-box/main/audio/podcast_manifest.c \
|
||||
/Users/electron/esp/esp-idf/components/json/cJSON/cJSON.c \
|
||||
-o /tmp/test_podcast_manifest && /tmp/test_podcast_manifest
|
||||
```
|
||||
Expected : `OK: 2 shows`.
|
||||
|
||||
- [ ] **Step 6 : Commit (local)**
|
||||
```bash
|
||||
git add main/audio/podcast_manifest.h main/audio/podcast_manifest.c test/host/test_podcast_manifest.c
|
||||
git commit -m "feat(histoires): podcast manifest v2 parser + host test"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 : Activer le driver de fichiers LVGL (stdio) pour lire les `.bin` de la SD
|
||||
|
||||
**Files:**
|
||||
- Modify: `sdkconfig` (section LVGL FS)
|
||||
- Modify: `main/CMakeLists.txt` (ajout de `podcast_manifest.c` aux SRCS)
|
||||
|
||||
- [ ] **Step 1 : Ajouter `podcast_manifest.c` aux sources**
|
||||
|
||||
Dans `main/CMakeLists.txt`, ajouter `"audio/podcast_manifest.c"` à la liste `SRCS` (à côté des autres `audio/*.c`).
|
||||
|
||||
- [ ] **Step 2 : Activer le driver FS stdio dans `sdkconfig`**
|
||||
|
||||
Repérer la ligne `# CONFIG_LV_USE_FS_STDIO is not set` (ou son absence) et la remplacer / ajouter, dans la section LVGL :
|
||||
```
|
||||
CONFIG_LV_USE_FS_STDIO=y
|
||||
CONFIG_LV_FS_STDIO_LETTER=83
|
||||
CONFIG_LV_FS_STDIO_PATH=""
|
||||
CONFIG_LV_FS_STDIO_CACHE_SIZE=0
|
||||
```
|
||||
(`83` = ASCII `'S'` → on adressera les pochettes par `"S:/sdcard/podcasts/<x>.bin"`, que LVGL ouvre via `fopen("/sdcard/podcasts/<x>.bin")`.)
|
||||
|
||||
- [ ] **Step 3 : Build (vérifie la prise en compte + que rien ne casse)**
|
||||
|
||||
Run :
|
||||
```bash
|
||||
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 && idf.py build 2>&1 | tail -5
|
||||
grep -E "LV_USE_FS_STDIO|LV_FS_STDIO_LETTER" sdkconfig
|
||||
```
|
||||
Expected : `Project build complete.` et `CONFIG_LV_USE_FS_STDIO=y` + `CONFIG_LV_FS_STDIO_LETTER=83`.
|
||||
|
||||
- [ ] **Step 4 : Commit (local)**
|
||||
```bash
|
||||
git add sdkconfig main/CMakeLists.txt
|
||||
git commit -m "build(histoires): enable LVGL stdio FS for SD cover images"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3 : Écran « Histoires » à vignettes (3 niveaux) — `modes/histoires.c`
|
||||
|
||||
**Files:**
|
||||
- Create: `main/modes/histoires.c`
|
||||
|
||||
Cet écran gère lui-même ses 3 sous-écrans LVGL (grille / liste / lecteur) et navigue entre eux avec `lv_screen_load_anim`. Seul le niveau grille utilise `lisael_ui_add_back_button` (→ Accueil) ; liste et lecteur ont leur propre bouton retour vers le niveau parent.
|
||||
|
||||
- [ ] **Step 1 : Écrire `main/modes/histoires.c`**
|
||||
|
||||
```c
|
||||
// "Histoires" — Merlin-style vignette browser for the Lisael Box.
|
||||
//
|
||||
// 3 levels, each its own LVGL screen, navigated with lv_screen_load_anim:
|
||||
// 1. paged 2x2 grid of podcast covers (lisael_screen_histoires())
|
||||
// 2. episode list for the chosen show
|
||||
// 3. full-screen player (cover + title + transport)
|
||||
//
|
||||
// Covers are RGB565 .bin images on the SD, loaded via the LVGL stdio FS driver
|
||||
// ("S:" -> /sdcard). Playback dispatches by extension: .m4a -> aac_player,
|
||||
// otherwise -> sd_player (MP3). One audio source at a time.
|
||||
|
||||
#include "ui/ui.h"
|
||||
#include "audio/podcast_manifest.h"
|
||||
#include "audio/aac_player.h"
|
||||
#include "audio/sd_player.h"
|
||||
#include "audio/audio_player.h"
|
||||
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <strings.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "ui.histoires";
|
||||
|
||||
static lisael_show_t s_shows[LISAEL_MAX_SHOWS];
|
||||
static int s_n_shows;
|
||||
static int s_cur_show = -1; // show currently drilled into
|
||||
static int s_cur_ep = -1; // episode currently in the player
|
||||
|
||||
static lv_obj_t *s_grid_screen; // cached level-1 screen (owned by ui.c cache)
|
||||
static lv_obj_t *s_list_screen; // rebuilt per show
|
||||
static lv_obj_t *s_player_screen; // rebuilt per episode
|
||||
|
||||
static const uint32_t s_fallback_colors[6] = {
|
||||
0xE8590C, 0x1971C2, 0x2F9E44, 0x9C36B5, 0xE8A92C, 0xC2255C,
|
||||
};
|
||||
|
||||
// --- a cover image (or a coloured fallback tile if no/unreadable .bin) -------
|
||||
static lv_obj_t *make_cover(lv_obj_t *parent, int show_idx, int px)
|
||||
{
|
||||
lisael_show_t *s = &s_shows[show_idx];
|
||||
if (s->cover[0]) {
|
||||
char real[64];
|
||||
snprintf(real, sizeof(real), "/sdcard/podcasts/%s", s->cover);
|
||||
struct stat st;
|
||||
if (stat(real, &st) == 0 && st.st_size > 0) {
|
||||
char src[72];
|
||||
snprintf(src, sizeof(src), "S:/sdcard/podcasts/%s", s->cover);
|
||||
lv_obj_t *img = lv_image_create(parent);
|
||||
lv_image_set_src(img, src);
|
||||
lv_image_set_scale(img, (uint32_t)(px * 256 / 160)); // stored at 160px
|
||||
return img;
|
||||
}
|
||||
}
|
||||
// fallback: rounded coloured square with the show's first letter
|
||||
lv_obj_t *box = lv_obj_create(parent);
|
||||
lv_obj_remove_style_all(box);
|
||||
lv_obj_set_size(box, px, px);
|
||||
lv_obj_set_style_radius(box, 14, 0);
|
||||
lv_obj_set_style_bg_opa(box, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_bg_color(box, lv_color_hex(s_fallback_colors[show_idx % 6]), 0);
|
||||
lv_obj_clear_flag(box, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_t *l = lv_label_create(box);
|
||||
char init[2] = { s->show[0] ? s->show[0] : '?', '\0' };
|
||||
lv_label_set_text(l, init);
|
||||
lv_obj_set_style_text_color(l, lv_color_white(), 0);
|
||||
lv_obj_center(l);
|
||||
return box;
|
||||
}
|
||||
|
||||
// --- level 3: player ---------------------------------------------------------
|
||||
static void play_current(void)
|
||||
{
|
||||
lisael_show_t *s = &s_shows[s_cur_show];
|
||||
char abs[96];
|
||||
snprintf(abs, sizeof(abs), "/sdcard/podcasts/%s", s->eps[s_cur_ep].file);
|
||||
size_t len = strlen(abs);
|
||||
if (len > 4 && strcasecmp(abs + len - 4, ".m4a") == 0) {
|
||||
lisael_aac_play_file(abs);
|
||||
} else {
|
||||
lisael_sd_play_file(abs);
|
||||
}
|
||||
ESP_LOGI(TAG, "play %s", abs);
|
||||
}
|
||||
|
||||
static void player_back_cb(lv_event_t *e)
|
||||
{
|
||||
(void)e;
|
||||
lisael_audio_stop_all();
|
||||
lv_screen_load_anim(s_list_screen, LV_SCR_LOAD_ANIM_MOVE_RIGHT, 220, 0, false);
|
||||
}
|
||||
|
||||
static void player_toggle_cb(lv_event_t *e)
|
||||
{
|
||||
lv_obj_t *lbl = lv_obj_get_child(lv_event_get_target(e), 0);
|
||||
if (lisael_aac_is_playing() || lisael_sd_is_playing()) {
|
||||
lisael_audio_stop_all();
|
||||
lv_label_set_text(lbl, LV_SYMBOL_PLAY);
|
||||
} else {
|
||||
play_current();
|
||||
lv_label_set_text(lbl, LV_SYMBOL_STOP);
|
||||
}
|
||||
}
|
||||
|
||||
static void player_step_cb(lv_event_t *e)
|
||||
{
|
||||
int dir = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
int n = s_shows[s_cur_show].n_eps;
|
||||
s_cur_ep = (s_cur_ep + dir + n) % n;
|
||||
lisael_audio_stop_all();
|
||||
lv_obj_t *scr = lisael_screen_histoires(); // ensure shows loaded; no-op rebuild
|
||||
(void)scr;
|
||||
// rebuild the player for the new episode
|
||||
extern lv_obj_t *lisael_histoires_open_episode(int ep); // fwd (same file)
|
||||
lisael_histoires_open_episode(s_cur_ep);
|
||||
}
|
||||
|
||||
lv_obj_t *lisael_histoires_open_episode(int ep)
|
||||
{
|
||||
s_cur_ep = ep;
|
||||
s_player_screen = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_player_screen, lv_color_hex(0x0E1726), 0);
|
||||
|
||||
lv_obj_t *back = lv_button_create(s_player_screen);
|
||||
lv_obj_add_style(back, lisael_ui_style_button(), 0);
|
||||
lv_obj_set_size(back, 64, 52);
|
||||
lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6);
|
||||
lv_obj_add_event_cb(back, player_back_cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_t *bl = lv_label_create(back);
|
||||
lv_label_set_text(bl, LV_SYMBOL_LEFT);
|
||||
lv_obj_center(bl);
|
||||
|
||||
make_cover(s_player_screen, s_cur_show, 110);
|
||||
lv_obj_t *cover_holder = lv_obj_get_child(s_player_screen, lv_obj_get_child_count(s_player_screen) - 1);
|
||||
lv_obj_align(cover_holder, LV_ALIGN_TOP_MID, 0, 12);
|
||||
|
||||
lv_obj_t *title = lv_label_create(s_player_screen);
|
||||
lv_label_set_text(title, s_shows[s_cur_show].eps[s_cur_ep].title);
|
||||
lv_obj_set_style_text_color(title, lv_color_white(), 0);
|
||||
lv_obj_set_width(title, 300);
|
||||
lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_label_set_long_mode(title, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_align(title, LV_ALIGN_CENTER, 0, 30);
|
||||
|
||||
// transport: ⏮ ▶/⏹ ⏭
|
||||
lv_obj_t *bar = lv_obj_create(s_player_screen);
|
||||
lv_obj_remove_style_all(bar);
|
||||
lv_obj_set_size(bar, 240, 60);
|
||||
lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -10);
|
||||
lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_clear_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t *prev = lv_button_create(bar);
|
||||
lv_obj_set_size(prev, 60, 52);
|
||||
lv_obj_add_event_cb(prev, player_step_cb, LV_EVENT_CLICKED, (void *)(intptr_t)-1);
|
||||
lv_obj_center(lv_label_create(prev));
|
||||
lv_label_set_text(lv_obj_get_child(prev, 0), LV_SYMBOL_PREV);
|
||||
|
||||
lv_obj_t *toggle = lv_button_create(bar);
|
||||
lv_obj_set_size(toggle, 72, 52);
|
||||
lv_obj_add_event_cb(toggle, player_toggle_cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_center(lv_label_create(toggle));
|
||||
lv_label_set_text(lv_obj_get_child(toggle, 0), LV_SYMBOL_STOP);
|
||||
|
||||
lv_obj_t *next = lv_button_create(bar);
|
||||
lv_obj_set_size(next, 60, 52);
|
||||
lv_obj_add_event_cb(next, player_step_cb, LV_EVENT_CLICKED, (void *)(intptr_t)+1);
|
||||
lv_obj_center(lv_label_create(next));
|
||||
lv_label_set_text(lv_obj_get_child(next, 0), LV_SYMBOL_NEXT);
|
||||
|
||||
lv_screen_load_anim(s_player_screen, LV_SCR_LOAD_ANIM_MOVE_LEFT, 220, 0, true);
|
||||
play_current();
|
||||
return s_player_screen;
|
||||
}
|
||||
|
||||
// --- level 2: episode list ---------------------------------------------------
|
||||
static void ep_cb(lv_event_t *e)
|
||||
{
|
||||
int ep = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
lisael_histoires_open_episode(ep);
|
||||
}
|
||||
|
||||
static void list_back_cb(lv_event_t *e)
|
||||
{
|
||||
(void)e;
|
||||
lv_screen_load_anim(s_grid_screen, LV_SCR_LOAD_ANIM_MOVE_RIGHT, 220, 0, false);
|
||||
}
|
||||
|
||||
static lv_obj_t *open_show(int show_idx)
|
||||
{
|
||||
s_cur_show = show_idx;
|
||||
lisael_show_t *s = &s_shows[show_idx];
|
||||
|
||||
s_list_screen = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_list_screen, lv_color_hex(0xEBFBEE), 0);
|
||||
|
||||
lv_obj_t *back = lv_button_create(s_list_screen);
|
||||
lv_obj_add_style(back, lisael_ui_style_button(), 0);
|
||||
lv_obj_set_size(back, 64, 52);
|
||||
lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6);
|
||||
lv_obj_add_event_cb(back, list_back_cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_center(lv_label_create(back));
|
||||
lv_label_set_text(lv_obj_get_child(back, 0), LV_SYMBOL_LEFT);
|
||||
|
||||
lv_obj_t *title = lv_label_create(s_list_screen);
|
||||
lv_label_set_text(title, s->show);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18);
|
||||
|
||||
lv_obj_t *list = lv_list_create(s_list_screen);
|
||||
lv_obj_set_size(list, LV_PCT(96), 168);
|
||||
lv_obj_align(list, LV_ALIGN_BOTTOM_MID, 0, -4);
|
||||
lv_obj_set_style_bg_color(list, lv_color_white(), 0);
|
||||
lv_obj_set_style_radius(list, 12, 0);
|
||||
|
||||
for (int i = 0; i < s->n_eps; i++) {
|
||||
lv_obj_t *btn = lv_list_add_button(list, NULL, s->eps[i].title);
|
||||
lv_obj_add_event_cb(btn, ep_cb, LV_EVENT_CLICKED, (void *)(intptr_t)i);
|
||||
}
|
||||
|
||||
lv_screen_load_anim(s_list_screen, LV_SCR_LOAD_ANIM_MOVE_LEFT, 220, 0, true);
|
||||
return s_list_screen;
|
||||
}
|
||||
|
||||
// --- level 1: paged 2x2 cover grid -------------------------------------------
|
||||
static lv_obj_t *s_dots[ (LISAEL_MAX_SHOWS + 3) / 4 ];
|
||||
static int s_n_pages;
|
||||
|
||||
static void cover_cb(lv_event_t *e)
|
||||
{
|
||||
int idx = (int)(intptr_t)lv_event_get_user_data(e);
|
||||
open_show(idx);
|
||||
}
|
||||
|
||||
static void tv_scroll_cb(lv_event_t *e)
|
||||
{
|
||||
lv_obj_t *tv = lv_event_get_target(e);
|
||||
int w = lv_obj_get_width(tv);
|
||||
int page = (w > 0) ? (lv_obj_get_scroll_x(tv) + w / 2) / w : 0;
|
||||
for (int i = 0; i < s_n_pages; i++) {
|
||||
lv_obj_set_style_bg_opa(s_dots[i], i == page ? LV_OPA_COVER : LV_OPA_30, 0);
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_t *lisael_screen_histoires(void)
|
||||
{
|
||||
if (s_grid_screen) {
|
||||
return s_grid_screen;
|
||||
}
|
||||
s_n_shows = lisael_podcast_load(s_shows, LISAEL_MAX_SHOWS);
|
||||
|
||||
s_grid_screen = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_grid_screen, lv_color_hex(0xCDE8FF), 0);
|
||||
lv_obj_set_style_bg_grad_color(s_grid_screen, lv_color_hex(0xF6FBFF), 0);
|
||||
lv_obj_set_style_bg_grad_dir(s_grid_screen, LV_GRAD_DIR_VER, 0);
|
||||
|
||||
lisael_ui_add_back_button(s_grid_screen);
|
||||
|
||||
lv_obj_t *title = lv_label_create(s_grid_screen);
|
||||
lv_label_set_text(title, "Histoires");
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 18);
|
||||
|
||||
if (s_n_shows <= 0) {
|
||||
lv_obj_t *info = lv_label_create(s_grid_screen);
|
||||
lv_label_set_text(info,
|
||||
"Aucune histoire pour l'instant.\nBranche la box en USB\npour en ajouter.");
|
||||
lv_obj_set_style_text_align(info, LV_TEXT_ALIGN_CENTER, 0);
|
||||
lv_obj_center(info);
|
||||
return s_grid_screen;
|
||||
}
|
||||
|
||||
lv_obj_t *tv = lv_tileview_create(s_grid_screen);
|
||||
lv_obj_set_size(tv, LV_PCT(100), 168);
|
||||
lv_obj_align(tv, LV_ALIGN_TOP_MID, 0, 50);
|
||||
lv_obj_set_style_bg_opa(tv, LV_OPA_TRANSP, 0);
|
||||
lv_obj_add_event_cb(tv, tv_scroll_cb, LV_EVENT_SCROLL_END, NULL);
|
||||
|
||||
s_n_pages = (s_n_shows + 3) / 4;
|
||||
for (int p = 0; p < s_n_pages; p++) {
|
||||
lv_obj_t *page = lv_tileview_add_tile(tv, p, 0, LV_DIR_HOR);
|
||||
lv_obj_set_flex_flow(page, LV_FLEX_FLOW_ROW_WRAP);
|
||||
lv_obj_set_flex_align(page, LV_FLEX_ALIGN_SPACE_EVENLY,
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(page, 6, 0);
|
||||
for (int k = 0; k < 4; k++) {
|
||||
int idx = p * 4 + k;
|
||||
if (idx >= s_n_shows) {
|
||||
break;
|
||||
}
|
||||
lv_obj_t *cell = lv_button_create(page);
|
||||
lv_obj_remove_style_all(cell);
|
||||
lv_obj_set_size(cell, 150, 96);
|
||||
lv_obj_set_flex_flow(cell, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(cell, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_add_event_cb(cell, cover_cb, LV_EVENT_CLICKED, (void *)(intptr_t)idx);
|
||||
make_cover(cell, idx, 64);
|
||||
lv_obj_t *cap = lv_label_create(cell);
|
||||
lv_label_set_text(cap, s_shows[idx].show);
|
||||
lv_label_set_long_mode(cap, LV_LABEL_LONG_DOT);
|
||||
lv_obj_set_width(cap, 144);
|
||||
lv_obj_set_style_text_align(cap, LV_TEXT_ALIGN_CENTER, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// page dots (drawn objects, no font dependency)
|
||||
if (s_n_pages > 1) {
|
||||
lv_obj_t *dotrow = lv_obj_create(s_grid_screen);
|
||||
lv_obj_remove_style_all(dotrow);
|
||||
lv_obj_set_size(dotrow, LV_PCT(60), 18);
|
||||
lv_obj_align(dotrow, LV_ALIGN_BOTTOM_MID, 0, -6);
|
||||
lv_obj_set_flex_flow(dotrow, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(dotrow, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_clear_flag(dotrow, LV_OBJ_FLAG_SCROLLABLE);
|
||||
for (int i = 0; i < s_n_pages; i++) {
|
||||
lv_obj_t *d = lv_obj_create(dotrow);
|
||||
lv_obj_remove_style_all(d);
|
||||
lv_obj_set_size(d, 10, 10);
|
||||
lv_obj_set_style_radius(d, 5, 0);
|
||||
lv_obj_set_style_margin_left(d, 4, 0);
|
||||
lv_obj_set_style_margin_right(d, 4, 0);
|
||||
lv_obj_set_style_bg_opa(d, i == 0 ? LV_OPA_COVER : LV_OPA_30, 0);
|
||||
lv_obj_set_style_bg_color(d, lv_color_hex(0x1971C2), 0);
|
||||
s_dots[i] = d;
|
||||
}
|
||||
}
|
||||
return s_grid_screen;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2 : Build (vérifie la compilation de l'écran)**
|
||||
|
||||
> ⚠️ `histoires.c` ne sera **lié** qu'après la Task 4 (ajout aux SRCS + retrait de podcasts.c/soundbox.c). Pour vérifier la compilation isolée maintenant :
|
||||
```bash
|
||||
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1
|
||||
xtensa-esp32s3-elf-gcc -fsyntax-only \
|
||||
-I main -I managed_components/lvgl__lvgl/src -I managed_components/lvgl__lvgl \
|
||||
-I build/config -DLV_LVGL_H_INCLUDE_SIMPLE=1 main/modes/histoires.c 2>&1 | head -20 || true
|
||||
```
|
||||
Expected : pas d'erreur de syntaxe (les warnings d'include LVGL résolus à la Task 4 via le composant ; si `-fsyntax-only` peine sur les includes, considérer le build complet de la Task 4 comme la vraie vérification).
|
||||
|
||||
- [ ] **Step 3 : Commit (local)**
|
||||
```bash
|
||||
git add main/modes/histoires.c
|
||||
git commit -m "feat(histoires): vignette grid + episode list + player screen"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4 : Câblage UI — router, fusion des tuiles, suppression des anciens écrans
|
||||
|
||||
**Files:**
|
||||
- Modify: `main/ui/ui.h`
|
||||
- Modify: `main/ui/ui.c:75` (case du routeur)
|
||||
- Modify: `main/modes/home.c:148-158` (tuiles)
|
||||
- Modify: `main/CMakeLists.txt` (SRCS)
|
||||
- Delete: `main/modes/podcasts.c`, `main/modes/soundbox.c`
|
||||
|
||||
- [ ] **Step 1 : `ui.h` — remplacer les déclarations d'écran**
|
||||
|
||||
Dans `main/ui/ui.h`, **supprimer** les lignes :
|
||||
```c
|
||||
lv_obj_t *lisael_screen_soundbox(void);
|
||||
```
|
||||
et
|
||||
```c
|
||||
lv_obj_t *lisael_screen_podcasts(void); // "Histoires" — Radio France podcasts
|
||||
```
|
||||
**Ajouter** à la place (près des autres déclarations d'écran) :
|
||||
```c
|
||||
lv_obj_t *lisael_screen_histoires(void); // "Histoires" — vignettes podcasts
|
||||
```
|
||||
Et dans l'enum, **supprimer** la ligne `LISAEL_MODE_SOUNDBOX,` (les autres modes décalent, sans impact car ils sont utilisés symboliquement).
|
||||
|
||||
- [ ] **Step 2 : `ui.c` — router HISTOIRES vers le nouvel écran, retirer SOUNDBOX**
|
||||
|
||||
Dans `main/ui/ui.c`, fonction `build_screen` :
|
||||
- **Supprimer** la ligne `case LISAEL_MODE_SOUNDBOX: return lisael_screen_soundbox();`
|
||||
- **Remplacer** `case LISAEL_MODE_HISTOIRES:return lisael_screen_podcasts();`
|
||||
par `case LISAEL_MODE_HISTOIRES:return lisael_screen_histoires();`
|
||||
|
||||
- [ ] **Step 3 : `home.c` — fusionner les tuiles (6 → 5)**
|
||||
|
||||
Dans `main/modes/home.c`, le tableau `tiles[]` : **supprimer** la ligne
|
||||
```c
|
||||
{ &lisael_ic_sound, "Sons", LISAEL_MODE_SOUNDBOX, {0} },
|
||||
```
|
||||
Le tableau passe à 5 entrées (Histoires, Radio, Calme, Horloge, Jeux). Le tableau `colors[]` a 6 couleurs — il en reste assez (la boucle n'en lit que 5). La grille flex se réagence seule.
|
||||
|
||||
- [ ] **Step 4 : `CMakeLists.txt` — sources**
|
||||
|
||||
Dans `main/CMakeLists.txt` : **retirer** `"modes/podcasts.c"` et `"modes/soundbox.c"` de `SRCS`, **ajouter** `"modes/histoires.c"` (si pas déjà fait). `podcast_manifest.c` doit y être (Task 2).
|
||||
|
||||
- [ ] **Step 5 : Supprimer les anciens écrans**
|
||||
```bash
|
||||
cd /tmp/lisael-box && git rm main/modes/podcasts.c main/modes/soundbox.c
|
||||
```
|
||||
> Note : `main/net/podcast.c` (téléchargement RSS on-device) n'est PLUS référencé par l'UI. Le laisser tel quel (inoffensif) ou le retirer aussi des SRCS si présent — vérifier `grep -n podcast main/CMakeLists.txt`. Ne PAS supprimer `audio/podcast_manifest.c`.
|
||||
|
||||
- [ ] **Step 6 : Build complet (la vraie vérification de l'écran)**
|
||||
```bash
|
||||
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1 && idf.py build 2>&1 | tail -8
|
||||
```
|
||||
Expected : `Project build complete.` (résout aussi les includes LVGL de `histoires.c`).
|
||||
|
||||
- [ ] **Step 7 : Commit (local)**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat(histoires): route Histoires to vignette screen, drop old menus"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5 : Outil Mac — pochettes `.bin` + manifeste v2
|
||||
|
||||
**Files:**
|
||||
- Create: `/tmp/lisael_episodes/gen_covers.py`
|
||||
|
||||
Réutilise les épisodes déjà téléchargés dans `/tmp/lisael_episodes` (`oli_01.m4a`, `odyssees_*`, `salutinfo_*`, `bestioles_*`). Récupère l'`itunes:image` de chaque flux, redimensionne en 160×160 PNG, convertit en `.bin` LVGL, et écrit `manifest.json` v2 groupé par show.
|
||||
|
||||
- [ ] **Step 1 : Écrire `gen_covers.py`**
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
# Build v2 manifest (grouped by show) + LVGL .bin covers for the Lisael Box.
|
||||
import json, os, re, subprocess, sys, urllib.request, xml.etree.ElementTree as ET
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
OUT = "/tmp/lisael_episodes"
|
||||
LVGLIMG = "/tmp/lisael-box/managed_components/lvgl__lvgl/scripts/LVGLImage.py"
|
||||
UA = {"User-Agent": "Mozilla/5.0"}
|
||||
NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"}
|
||||
|
||||
# key -> (show title, RSS url)
|
||||
FEEDS = {
|
||||
"oli": ("Une histoire et Oli", "https://radiofrance-podcast.net/podcast09/rss_19721.xml"),
|
||||
"odyssees": ("Les Odyssees", "https://radiofrance-podcast.net/podcast09/rss_20108.xml"),
|
||||
"salutinfo": ("Salut l'info", "https://radiofrance-podcast.net/podcast09/rss_20689.xml"),
|
||||
"bestioles": ("Bestioles",
|
||||
"https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/"
|
||||
"podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml"),
|
||||
}
|
||||
|
||||
def fetch(url, t=60):
|
||||
return urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=t).read()
|
||||
|
||||
def cover_url(root):
|
||||
el = root.find(".//channel/itunes:image", NS)
|
||||
if el is not None and el.get("href"):
|
||||
return el.get("href")
|
||||
el = root.find(".//channel/image/url")
|
||||
return el.text if el is not None else None
|
||||
|
||||
manifest = []
|
||||
for key, (show, rss) in FEEDS.items():
|
||||
# episodes already downloaded for this show, from the existing flat manifest if present
|
||||
eps = []
|
||||
flat = os.path.join(OUT, "manifest.json.v1")
|
||||
# derive episodes from files on disk + titles from a saved v1 manifest if available
|
||||
titles = {}
|
||||
if os.path.exists(flat):
|
||||
for e in json.load(open(flat, encoding="utf-8")):
|
||||
titles[e["file"]] = e["title"]
|
||||
for f in sorted(os.listdir(OUT)):
|
||||
if re.match(rf"^{key}_\d+\.(m4a|mp3)$", f):
|
||||
eps.append({"file": f, "title": titles.get(f, f)})
|
||||
if not eps:
|
||||
print(f"[{key}] no episodes on disk, skip"); continue
|
||||
|
||||
cover_name = ""
|
||||
try:
|
||||
root = ET.fromstring(fetch(rss))
|
||||
cu = cover_url(root)
|
||||
if cu:
|
||||
img = Image.open(io.BytesIO(fetch(cu))).convert("RGB").resize((160, 160))
|
||||
png = os.path.join(OUT, f"{key}.png")
|
||||
img.save(png)
|
||||
subprocess.run([sys.executable, LVGLIMG, "--ofmt", "BIN", "--cf", "RGB565",
|
||||
"-o", OUT, png], check=True)
|
||||
cover_name = f"{key}.bin"
|
||||
print(f"[{key}] cover -> {cover_name}")
|
||||
except Exception as ex:
|
||||
print(f"[{key}] cover failed: {ex}")
|
||||
|
||||
manifest.append({"show": show, "cover": cover_name, "episodes": eps})
|
||||
|
||||
json.dump(manifest, open(os.path.join(OUT, "manifest.json"), "w", encoding="utf-8"),
|
||||
ensure_ascii=False, indent=2)
|
||||
print(f"manifest.json: {len(manifest)} shows")
|
||||
```
|
||||
|
||||
> Préparer les titres : si l'ancien manifeste plat existe, le sauver d'abord sous `manifest.json.v1` (sinon les titres retombent sur le nom de fichier) :
|
||||
> ```bash
|
||||
> [ -f /tmp/lisael_episodes/manifest.json ] && cp /tmp/lisael_episodes/manifest.json /tmp/lisael_episodes/manifest.json.v1
|
||||
> ```
|
||||
|
||||
- [ ] **Step 2 : Lancer l'outil**
|
||||
```bash
|
||||
cd /tmp/lisael_episodes
|
||||
[ -f manifest.json ] && cp manifest.json manifest.json.v1
|
||||
/Users/electron/.espressif/python_env/idf5.4_py3.14_env/bin/python gen_covers.py
|
||||
echo "=== .bin produits ==="; ls -la *.bin
|
||||
echo "=== manifest v2 ==="; cat manifest.json
|
||||
```
|
||||
Expected : un `.bin` par show (`oli.bin`, `odyssees.bin`, `salutinfo.bin`, `bestioles.bin`) + `manifest.json` v2 listant 4 shows avec leurs épisodes et `cover`.
|
||||
|
||||
- [ ] **Step 3 : Commit (local — l'outil seulement ; pas de podcasts ni binaires)**
|
||||
```bash
|
||||
cd /tmp/lisael-box
|
||||
mkdir -p tools && cp /tmp/lisael_episodes/gen_covers.py tools/gen_covers.py
|
||||
git add tools/gen_covers.py
|
||||
git commit -m "tools: generate v2 manifest + LVGL cover images from RF feeds"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6 : Charger la SD (USB-MSC) + flasher + recette sur la vraie carte
|
||||
|
||||
**Files:** (aucun — procédure)
|
||||
|
||||
- [ ] **Step 1 : Passer la box en mode disque USB**
|
||||
|
||||
Flasher le firmware MSC (`/tmp/lisael-msc`, déjà construit) après BOOT+RESET :
|
||||
```bash
|
||||
# (demander à l'utilisateur de faire BOOT+RESET, attendre le port)
|
||||
cd /tmp/lisael-msc && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1
|
||||
python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 -b 460800 \
|
||||
--before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \
|
||||
0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/lisael_msc.bin
|
||||
```
|
||||
Attendre que `/Volumes/POT_04` monte (`diskutil list external physical`).
|
||||
|
||||
- [ ] **Step 2 : Copier pochettes + manifeste v2 sur la SD**
|
||||
```bash
|
||||
cp -v /tmp/lisael_episodes/*.bin /tmp/lisael_episodes/manifest.json /Volumes/POT_04/PODCASTS/
|
||||
sync
|
||||
rm -f /Volumes/POT_04/PODCASTS/._* /Volumes/POT_04/PODCASTS/.DS_Store
|
||||
ls -la /Volumes/POT_04/PODCASTS/
|
||||
diskutil eject /Volumes/POT_04
|
||||
```
|
||||
Expected : `oli.bin`…`bestioles.bin` + `manifest.json` (v2) présents ; éjection propre.
|
||||
|
||||
- [ ] **Step 3 : Reflasher le firmware normal**
|
||||
|
||||
Après BOOT+RESET (la box est en MSC, pas de port sinon) :
|
||||
```bash
|
||||
cd /tmp/lisael-box && . /Users/electron/esp/esp-idf/export.sh >/dev/null 2>&1
|
||||
python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 -b 460800 \
|
||||
--before default_reset --after hard_reset write_flash --flash_mode dio --flash_size 16MB --flash_freq 80m \
|
||||
0x0 build/bootloader/bootloader.bin 0x8000 build/partition_table/partition-table.bin 0x10000 build/lisael_box.bin
|
||||
python -m esptool --chip esp32s3 -p /dev/cu.usbmodem1101 --after hard_reset run
|
||||
```
|
||||
|
||||
- [ ] **Step 4 : Recette sur la carte (demander à l'utilisateur de vérifier)**
|
||||
1. Accueil : 5 tuiles, « Histoires » ouvre la grille.
|
||||
2. Grille 2×2 : les **vraies pochettes** des 4 podcasts (pagination/points si > 4).
|
||||
3. Taper une pochette → liste des épisodes du bon show.
|
||||
4. Taper un épisode → lecture plein écran avec pochette ; **son OK en .m4a ET .mp3** ; ⏮/⏭ changent d'épisode ; ▶/⏹ ; retour à chaque niveau.
|
||||
5. (Optionnel) carte sans pochette → tuile de repli colorée, pas de crash.
|
||||
|
||||
- [ ] **Step 5 : Mettre à jour la mémoire projet**
|
||||
|
||||
Ajouter au fichier `project_lisael_box_hw_bringup_2026_06_14.md` une note « Session 5 : écran Histoires à vignettes livré » (composants, format manifeste v2, driver FS LVGL, statut recette).
|
||||
|
||||
---
|
||||
|
||||
## Auto-revue du plan
|
||||
|
||||
**1. Couverture de la spec :**
|
||||
- Navigation 3 niveaux → Task 3 (grille/liste/lecteur). ✅
|
||||
- Grille 2×2 paginée + carrousel/points → Task 3 (`lv_tileview` + dots objets). ✅
|
||||
- Pochettes `.bin` SD via driver FS → Task 2 (config) + Task 3 (`make_cover`/`S:`). ✅
|
||||
- Manifeste v2 par show → Task 1 (parseur) + Task 5 (générateur). ✅
|
||||
- Fusion tuiles + remplacement podcasts.c/soundbox.c → Task 4. ✅
|
||||
- Lecture m4a/mp3 → Task 3 (`play_current`). ✅
|
||||
- Contenu initial via MSC → Task 6. ✅
|
||||
- Gestion d'erreurs (pas de SD/manifeste, pochette manquante) → Task 3 (placeholder + fallback). ✅
|
||||
- **Écart assumé vs spec :** la **barre de progression** du lecteur est **différée** (les lecteurs audio n'exposent pas la position de lecture ; l'ajouter exigerait une API de position — propre follow-up). Le lecteur affiche pochette + titre + transport sans barre live.
|
||||
|
||||
**2. Placeholders :** aucun TODO/TBD ; tout le code est fourni.
|
||||
|
||||
**3. Cohérence des types :** `lisael_show_t`/`lisael_episode_t` (Task 1) utilisés tels quels en Task 3 ; `lisael_screen_histoires()` déclarée (Task 4 ui.h) / définie (Task 3) / routée (Task 4 ui.c) ; `make_cover(parent, idx, px)` cohérent ; pochettes stockées 160px, mises à l'échelle via `px*256/160`.
|
||||
@@ -0,0 +1,133 @@
|
||||
# Lisael Box — Écran « Histoires » à vignettes (design)
|
||||
|
||||
**Date:** 2026-06-14
|
||||
**Statut:** validé (brainstorming), prêt pour le plan d'implémentation.
|
||||
**Projet:** firmware Lisael Box (ESP32-S3-BOX-3), ESP-IDF v5.4, `/tmp/lisael-box`,
|
||||
repo Gitea `electron-rare/lisael-box`.
|
||||
|
||||
## But
|
||||
|
||||
Remplacer les menus « Histoires » (download on-device) et « Sons » (liste) par
|
||||
**un seul écran « Histoires » à vignettes**, où Lisael (6 ans) choisit une histoire
|
||||
**par sa pochette** (image), façon Fabrique à histoires *Merlin*. Le contenu (audio
|
||||
+ pochettes + manifeste) vit sur la carte SD ; la box lit et affiche.
|
||||
|
||||
## Périmètre
|
||||
|
||||
- **DANS** : l'écran à vignettes (3 niveaux), le chargement des pochettes depuis la
|
||||
SD, le manifeste enrichi v2, la fusion des tuiles d'accueil, le chargement initial
|
||||
du contenu sur la SD côté Mac (via le flux USB-MSC déjà rodé).
|
||||
- **HORS** (specs séparées) :
|
||||
- **Spec A** — webapp de gestion de contenu. **Direction figée** : *Tower héberge
|
||||
l'app + télécharge les épisodes + convertit les pochettes, puis pousse les fichiers
|
||||
à la box en HTTP simple* (ou la box tire depuis Tower sur le tailnet). La box ne
|
||||
fait **jamais** de HTTPS lourd (évite le reset de download connu). B produit
|
||||
exactement le format SD que A alimentera ensuite.
|
||||
- Carrousel « grande pochette », bruitages/sons courts, écran réglages.
|
||||
|
||||
## Navigation (3 niveaux)
|
||||
|
||||
1. **Accueil** → la tuile **« Histoires »** ouvre la grille. Les anciennes tuiles
|
||||
« Histoires » + « Sons » fusionnent en cette unique tuile → **5 tuiles** à l'accueil
|
||||
(6ᵉ emplacement laissé libre, futur « Réglages/Web »).
|
||||
2. **Niveau 1 — grille 2×2 paginée** : 4 pochettes par page (grosses cibles tactiles),
|
||||
**balayage horizontal + points indicateurs** (`●○○`) entre pages dès qu'il y a plus
|
||||
de 4 podcasts. Taper une pochette → niveau 2.
|
||||
3. **Niveau 2 — épisodes** : liste verticale des épisodes du podcast choisi (petite
|
||||
vignette du show + titre). Bouton retour vers la grille. Taper un épisode → niveau 3.
|
||||
4. **Niveau 3 — lecture plein écran** : grande pochette + titre + transport
|
||||
`⏮ ⏸/▶ ⏭` + barre de progression + bouton retour. Le « Stop » global reste
|
||||
disponible (`lisael_audio_stop_all`).
|
||||
|
||||
## Pochettes — pipeline image (approche retenue)
|
||||
|
||||
**Approche 1 : pochettes pré-converties au format binaire LVGL (RGB565) sur la SD,
|
||||
lues via un driver de système de fichiers LVGL.** Choisie pour la RAM déterministe
|
||||
(pas de décodeur JPEG à l'exécution — la RAM interne est tendue) et parce qu'elle
|
||||
correspond au « vrai Merlin » (pochettes sur la carte) et prépare la spec A.
|
||||
|
||||
- **Format** : `.bin` LVGL (`LVGLImage.py --ofmt BIN`, `cf RGB565` ou `RGB565A8`),
|
||||
produit côté Mac (pipeline déjà maîtrisé pour les icônes Twemoji). **Une seule taille
|
||||
stockée par pochette : 160×160** ; LVGL la **met à l'échelle** à l'affichage (≈88 px
|
||||
en grille, ≈150 px en lecture, ≈48 px en liste) via `lv_image_set_scale`.
|
||||
- **Driver FS LVGL** : activer `CONFIG_LV_USE_FS_STDIO=y` avec la lettre `'S'`
|
||||
(mappe `S:` → `fopen`/`fread`, donc la SD FATFS). Chargement :
|
||||
`lv_image_set_src(img, "S:/sdcard/podcasts/<cover>.bin")`. Le décodeur `.bin` LVGL
|
||||
est toujours présent (aucun décodeur JPEG/PNG requis).
|
||||
- **Cache image** : les ~4 pochettes décodées tiennent en **PSRAM** (160×160×2 ≈ 51 Ko
|
||||
chacune, ~205 Ko pour 4). Acceptable.
|
||||
- **Source** : artwork des flux Radio France (`<itunes:image>`), redimensionné 160×160
|
||||
côté Mac. Repli si pochette absente : une vignette générée (emoji + couleur du show).
|
||||
|
||||
## Données — manifeste enrichi v2 (par show)
|
||||
|
||||
`/sdcard/podcasts/manifest.json` passe d'une liste plate d'épisodes à **une liste de
|
||||
shows**, chacun portant sa pochette et ses épisodes :
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"show": "Une histoire et Oli",
|
||||
"cover": "oli.bin",
|
||||
"episodes": [
|
||||
{ "file": "oli_01.m4a", "title": "OLI en concert, p.1" },
|
||||
{ "file": "oli_02.m4a", "title": "OLI en concert, p.2" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"show": "Bestioles",
|
||||
"cover": "bestioles.bin",
|
||||
"episodes": [ { "file": "bestioles_01.mp3", "title": "La gerboise…" } ]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
- `cover` = nom de fichier `.bin` sous `/sdcard/podcasts/`. `file` = nom audio ASCII
|
||||
sous `/sdcard/podcasts/`. `show`/`title` = UTF-8 (accents ; rendus par Fredoka).
|
||||
- Les noms de **fichiers** restent **ASCII** (FAT) ; le « joli » texte vit dans le JSON.
|
||||
|
||||
## Composants firmware
|
||||
|
||||
- **`main/modes/histoires.c`** (neuf) — l'écran à 3 niveaux (grille paginée + liste +
|
||||
player). **Remplace** `main/modes/podcasts.c` *et* `main/modes/soundbox.c` (supprimés).
|
||||
- **Chargeur manifeste v2** — dans `main/audio/sd_player.c` (ou un petit
|
||||
`main/net/podcast.c` réutilisé) : parse le tableau de shows en structures
|
||||
`lisael_show_t { char show[48]; char cover[32]; lisael_ep_t eps[N]; int n; }`.
|
||||
Remplace/complète `lisael_sd_load_manifest_dir` (v1 plate, devenue inutile).
|
||||
- **Driver FS LVGL** — `CONFIG_LV_USE_FS_STDIO=y` (lettre `S`), pour `lv_image` depuis SD.
|
||||
- **Lecture** — inchangée : dispatch par extension `.m4a → lisael_aac_play_file`,
|
||||
sinon `lisael_sd_play_file` ; `lisael_audio_stop_all` au changement/Stop. (déjà en place)
|
||||
- **Accueil** — `main/modes/home.c` : tableau de tuiles 6 → 5 (retirer « Sons », garder
|
||||
« Histoires » → `LISAEL_MODE_HISTOIRES`). `main/ui/ui.h` : retirer `LISAEL_MODE_SOUNDBOX`,
|
||||
garder/retituler `lisael_screen_histoires`. `main/ui/ui.c` : router `LISAEL_MODE_HISTOIRES`
|
||||
→ `lisael_screen_histoires`. Mettre à jour `main/CMakeLists.txt` (retrait podcasts.c/
|
||||
soundbox.c, ajout histoires.c).
|
||||
|
||||
## Contenu initial (pour B, maintenant)
|
||||
|
||||
Stand-in manuel de ce que Tower fera plus tard (spec A) :
|
||||
1. Côté Mac : pour chaque show, récupérer l'`<itunes:image>` du flux RF →
|
||||
redimensionner 160×160 → convertir en `.bin` LVGL (`LVGLImage.py`).
|
||||
2. Écrire le `manifest.json` v2 (shows + pochettes + épisodes déjà téléchargés).
|
||||
3. Déposer pochettes `.bin` + manifeste sur `/sdcard/podcasts/` via le **firmware
|
||||
USB-MSC** (`/tmp/lisael-msc`) déjà construit, puis éjecter proprement.
|
||||
4. Reflasher le firmware normal (BOOT+RESET).
|
||||
|
||||
## Gestion d'erreurs
|
||||
|
||||
- **Pas de SD / pas de manifeste** → écran placeholder amical (« Branche la box en USB
|
||||
pour ajouter des histoires »), pas d'erreur.
|
||||
- **Pochette manquante / `.bin` illisible** → vignette de repli (emoji + couleur du show).
|
||||
- **Épisode introuvable à la lecture** → toast/log, on reste sur la liste.
|
||||
- **0 épisode pour un show** → le show n'apparaît pas dans la grille.
|
||||
|
||||
## Tests (acceptation)
|
||||
|
||||
Build + flash, puis sur la vraie carte :
|
||||
1. Accueil affiche 5 tuiles, « Histoires » ouvre la grille.
|
||||
2. Grille 2×2 affiche les **vraies pochettes** des 4 podcasts ; pagination/points si > 4.
|
||||
3. Taper une pochette → liste des épisodes du bon show.
|
||||
4. Taper un épisode → lecture plein écran avec pochette ; **son OK en `.m4a` ET `.mp3`**.
|
||||
5. Retour à chaque niveau + Stop fonctionnels.
|
||||
6. Carte sans pochette → repli affiché, pas de crash.
|
||||
```
|
||||
@@ -0,0 +1,26 @@
|
||||
// Streaming AAC/M4A file player for Lisael Box (Radio France podcasts are AAC).
|
||||
//
|
||||
// Decodes an .m4a file from the microSD card chunk-by-chunk via
|
||||
// esp_audio_codec's simple decoder and writes PCM to the BSP ES8311 codec, on a
|
||||
// background task. One playback at a time; starting stops any other audio.
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Start playing an .m4a file (absolute SD path). Stops any current audio, then
|
||||
// streams+decodes on a background task. Returns ESP_OK once the task is started.
|
||||
esp_err_t lisael_aac_play_file(const char *abs_path);
|
||||
|
||||
// Stop playback (blocks briefly until the decode task has stopped).
|
||||
void lisael_aac_stop(void);
|
||||
|
||||
bool lisael_aac_is_playing(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -3,17 +3,23 @@
|
||||
#include "audio/audio_player.h"
|
||||
#include "audio/radio_pipeline.h"
|
||||
#include "audio/sd_player.h"
|
||||
#include "audio/aac_player.h"
|
||||
#include "lisael_config.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "nvs.h"
|
||||
#include "bsp/esp-box-3.h"
|
||||
#include "esp_codec_dev.h"
|
||||
#include "driver/i2s_std.h"
|
||||
#include <audio_player.h> // chmorgan/esp-audio-player (angle brackets target
|
||||
// its include dir, NOT our audio/audio_player.h)
|
||||
|
||||
static const char *TAG = "audio";
|
||||
|
||||
static esp_codec_dev_handle_t s_codec;
|
||||
static lisael_audio_source_t s_source = LISAEL_AUDIO_IDLE;
|
||||
static int s_volume = LISAEL_VOLUME_DEFAULT;
|
||||
static bool s_player_ready;
|
||||
|
||||
static int clamp_volume(int v)
|
||||
{
|
||||
@@ -44,6 +50,84 @@ static void persist_volume(void)
|
||||
}
|
||||
}
|
||||
|
||||
// --- esp-audio-player <-> BSP codec glue ------------------------------------
|
||||
// The player decodes MP3 to PCM and pushes it through these callbacks, which we
|
||||
// back with the BSP's ES8311 codec device (esp_codec_dev). We do NOT touch I2S
|
||||
// directly — the BSP owns it; we only (re)open the codec at the stream's rate.
|
||||
|
||||
static esp_err_t ap_mute(AUDIO_PLAYER_MUTE_SETTING setting)
|
||||
{
|
||||
if (s_codec) {
|
||||
esp_codec_dev_set_out_mute(s_codec, setting == AUDIO_PLAYER_MUTE);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static esp_err_t ap_clk_set(uint32_t rate, uint32_t bits, i2s_slot_mode_t ch)
|
||||
{
|
||||
if (!s_codec) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
esp_codec_dev_close(s_codec); // harmless if not open
|
||||
esp_codec_dev_sample_info_t fs = {
|
||||
.bits_per_sample = (uint8_t)bits,
|
||||
.channel = (ch == I2S_SLOT_MODE_STEREO) ? 2 : 1,
|
||||
.sample_rate = rate,
|
||||
};
|
||||
esp_err_t err = esp_codec_dev_open(s_codec, &fs);
|
||||
esp_codec_dev_set_out_vol(s_codec, (float)s_volume); // re-apply volume
|
||||
return err;
|
||||
}
|
||||
|
||||
static esp_err_t ap_write(void *buf, size_t len, size_t *written, uint32_t timeout_ms)
|
||||
{
|
||||
(void)timeout_ms;
|
||||
int r = esp_codec_dev_write(s_codec, buf, (int)len);
|
||||
*written = (r == ESP_CODEC_DEV_OK) ? len : 0;
|
||||
return (r == ESP_CODEC_DEV_OK) ? ESP_OK : ESP_FAIL;
|
||||
}
|
||||
|
||||
static void ap_event_cb(audio_player_cb_ctx_t *ctx)
|
||||
{
|
||||
if (ctx->audio_event == AUDIO_PLAYER_CALLBACK_EVENT_IDLE) {
|
||||
// Playback finished (or was stopped): close the codec and mark idle.
|
||||
if (s_codec) {
|
||||
esp_codec_dev_close(s_codec);
|
||||
}
|
||||
if (s_source == LISAEL_AUDIO_SD) {
|
||||
s_source = LISAEL_AUDIO_IDLE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t lisael_audio_player_ensure(void)
|
||||
{
|
||||
if (s_player_ready) {
|
||||
return ESP_OK;
|
||||
}
|
||||
if (!s_codec) {
|
||||
ESP_LOGE(TAG, "codec not ready — call lisael_audio_init first");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
audio_player_config_t cfg = {
|
||||
.mute_fn = ap_mute,
|
||||
.clk_set_fn = ap_clk_set,
|
||||
.write_fn = ap_write,
|
||||
.priority = 5,
|
||||
.coreID = 1,
|
||||
.force_stereo = false,
|
||||
};
|
||||
esp_err_t err = audio_player_new(cfg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "audio_player_new failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
audio_player_callback_register(ap_event_cb, NULL);
|
||||
s_player_ready = true;
|
||||
ESP_LOGI(TAG, "esp-audio-player ready");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t lisael_audio_init(void)
|
||||
{
|
||||
// The BSP returns a ready-to-use ES8311 speaker codec device.
|
||||
@@ -54,6 +138,8 @@ esp_err_t lisael_audio_init(void)
|
||||
return ESP_FAIL;
|
||||
}
|
||||
load_volume();
|
||||
s_volume = LISAEL_VOLUME_MAX; // user request: start at maximum volume
|
||||
persist_volume();
|
||||
esp_codec_dev_set_out_vol(s_codec, (float)s_volume);
|
||||
ESP_LOGI(TAG, "codec ready, volume=%d%% (cap %d%%)", s_volume, LISAEL_VOLUME_MAX);
|
||||
return ESP_OK;
|
||||
@@ -69,6 +155,7 @@ void lisael_audio_stop_all(void)
|
||||
switch (s_source) {
|
||||
case LISAEL_AUDIO_RADIO: lisael_radio_stop(); break;
|
||||
case LISAEL_AUDIO_SD: lisael_sd_stop(); break;
|
||||
case LISAEL_AUDIO_PODCAST: lisael_aac_stop(); break;
|
||||
default: break;
|
||||
}
|
||||
s_source = LISAEL_AUDIO_IDLE;
|
||||
|
||||
@@ -20,13 +20,19 @@ typedef enum {
|
||||
LISAEL_AUDIO_IDLE = 0,
|
||||
LISAEL_AUDIO_RADIO,
|
||||
LISAEL_AUDIO_SD,
|
||||
LISAEL_AUDIO_PODCAST, // streaming AAC/M4A file (aac_player.c)
|
||||
} lisael_audio_source_t;
|
||||
|
||||
// Initialise the BSP audio codec (ES8311 speaker). Call once after bsp_i2c_init.
|
||||
// Loads the persisted volume from NVS (default LISAEL_VOLUME_DEFAULT).
|
||||
esp_err_t lisael_audio_init(void);
|
||||
|
||||
// The shared codec handle, for the ESP-ADF i2s_stream / direct writes.
|
||||
// Lazily create the shared esp-audio-player instance (MP3 -> PCM -> ES8311
|
||||
// codec via callbacks). Safe to call repeatedly; only the first call inits.
|
||||
// Returns ESP_FAIL if the codec isn't ready (lisael_audio_init not done).
|
||||
esp_err_t lisael_audio_player_ensure(void);
|
||||
|
||||
// The shared codec handle, for direct esp_codec_dev writes.
|
||||
// Returns NULL before lisael_audio_init().
|
||||
esp_codec_dev_handle_t lisael_audio_codec(void);
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Calm-corner tone — see calm_tone.h.
|
||||
//
|
||||
// Real-time synthesis: a 432 Hz sine with two soft harmonics (864, 1296 Hz),
|
||||
// amplitude-shaped by a breathing envelope that matches the bubble (4 s rise
|
||||
// "Inspire", 6 s fall "Souffle"). Written to the BSP codec on a task.
|
||||
|
||||
#include "audio/calm_tone.h"
|
||||
#include "audio/audio_player.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_codec_dev.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "calm_tone";
|
||||
|
||||
#define SR 32000 // sample rate
|
||||
#define INHALE_S 4.0f
|
||||
#define EXHALE_S 6.0f
|
||||
#define CYCLE_S (INHALE_S + EXHALE_S)
|
||||
|
||||
static volatile bool s_stop;
|
||||
static volatile bool s_running;
|
||||
|
||||
static inline float smoothstep(float x)
|
||||
{
|
||||
if (x < 0) x = 0;
|
||||
if (x > 1) x = 1;
|
||||
return x * x * (3.0f - 2.0f * x);
|
||||
}
|
||||
|
||||
// Breathing amplitude envelope in [0.32 .. 1.0] over the 10 s cycle.
|
||||
static float breath_env(float t)
|
||||
{
|
||||
const float lo = 0.32f, hi = 1.0f;
|
||||
if (t < INHALE_S) {
|
||||
return lo + (hi - lo) * smoothstep(t / INHALE_S); // inhale: rise
|
||||
}
|
||||
return hi - (hi - lo) * smoothstep((t - INHALE_S) / EXHALE_S); // exhale: fall
|
||||
}
|
||||
|
||||
static void tone_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
s_running = true;
|
||||
esp_codec_dev_handle_t codec = lisael_audio_codec();
|
||||
if (!codec) {
|
||||
s_running = false;
|
||||
vTaskDeleteWithCaps(NULL);
|
||||
return;
|
||||
}
|
||||
esp_codec_dev_close(codec);
|
||||
esp_codec_dev_sample_info_t fs = {
|
||||
.bits_per_sample = 16,
|
||||
.channel = 1,
|
||||
.sample_rate = SR,
|
||||
};
|
||||
esp_codec_dev_open(codec, &fs);
|
||||
esp_codec_dev_set_out_vol(codec, (float)lisael_audio_get_volume());
|
||||
|
||||
const int N = 512;
|
||||
int16_t *buf = malloc(N * sizeof(int16_t));
|
||||
const float TAU = 6.2831853f;
|
||||
float ph1 = 0, ph2 = 0, ph3 = 0; // fundamental + 2 harmonic phases
|
||||
float t = 0; // position in the breathing cycle (s)
|
||||
float gt = 0; // global time (s) for slow LFOs
|
||||
|
||||
while (!s_stop && buf) {
|
||||
// Slow evolving parameters — computed once per buffer (~16 ms) so the
|
||||
// drone never sits on one steady pitch/timbre (less fatiguing). A gentle
|
||||
// pitch drift around 432 Hz, a soft tremolo, and harmonics that swell.
|
||||
float drift = 1.0f + 0.013f * sinf(TAU * 0.05f * gt); // +/-1.3% pitch wander
|
||||
float trem = 0.86f + 0.14f * sinf(TAU * 0.17f * gt); // gentle tremolo
|
||||
float a432 = 0.30f + 0.06f * sinf(TAU * 0.030f * gt + 1.0f); // 432 Hz swell
|
||||
float h3amp = 0.07f + 0.05f * sinf(TAU * 0.021f * gt + 2.2f); // 576 Hz shimmer
|
||||
// Centred on 144 Hz (deep, twice as loud) + 432 Hz (= 3x144), with a
|
||||
// soft 576 Hz shimmer. ph1=144, ph2=432, ph3=576.
|
||||
float f1 = 144.0f * drift;
|
||||
float d1 = TAU * f1 / SR, d2 = TAU * 3.0f * f1 / SR, d3 = TAU * 4.0f * f1 / SR;
|
||||
|
||||
for (int i = 0; i < N; i++) {
|
||||
float env = breath_env(t) * trem;
|
||||
float s = sinf(ph1) * 0.66f + sinf(ph2) * a432 + sinf(ph3) * h3amp;
|
||||
int v = (int)(s * env * 6600.0f);
|
||||
if (v > 32767) v = 32767;
|
||||
if (v < -32768) v = -32768;
|
||||
buf[i] = (int16_t)v;
|
||||
|
||||
ph1 += d1; if (ph1 > TAU) ph1 -= TAU;
|
||||
ph2 += d2; if (ph2 > TAU) ph2 -= TAU;
|
||||
ph3 += d3; if (ph3 > TAU) ph3 -= TAU;
|
||||
t += 1.0f / SR; if (t > CYCLE_S) t -= CYCLE_S;
|
||||
gt += 1.0f / SR; if (gt > 600.0f) gt -= 600.0f;
|
||||
}
|
||||
int w = esp_codec_dev_write(codec, buf, N * sizeof(int16_t));
|
||||
if (w != ESP_CODEC_DEV_OK) {
|
||||
vTaskDelay(pdMS_TO_TICKS(5)); // never busy-spin if the codec balks
|
||||
}
|
||||
}
|
||||
|
||||
free(buf);
|
||||
esp_codec_dev_close(codec);
|
||||
ESP_LOGI(TAG, "stopped");
|
||||
s_running = false;
|
||||
vTaskDeleteWithCaps(NULL);
|
||||
}
|
||||
|
||||
esp_err_t lisael_calm_tone_start(void)
|
||||
{
|
||||
if (s_running) {
|
||||
return ESP_OK;
|
||||
}
|
||||
lisael_audio_stop_all(); // exclusive with radio/SD/podcast
|
||||
s_stop = false;
|
||||
// Priority 3 — BELOW the LVGL task (4) so the breathing animation never
|
||||
// starves: the tone must never preempt the UI. The blocking codec write
|
||||
// paces this task and yields to IDLE, so the watchdog stays happy.
|
||||
if (xTaskCreatePinnedToCoreWithCaps(tone_task, "calm_tone", 4096, NULL, 3, NULL,
|
||||
tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
ESP_LOGI(TAG, "432 Hz breathing tone started");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void lisael_calm_tone_stop(void)
|
||||
{
|
||||
if (!s_running) {
|
||||
return;
|
||||
}
|
||||
s_stop = true;
|
||||
for (int i = 0; i < 100 && s_running; i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
}
|
||||
}
|
||||
|
||||
bool lisael_calm_tone_is_playing(void)
|
||||
{
|
||||
return s_running;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Calm-corner tone generator: a soft 432 Hz sine plus gentle harmonics with a
|
||||
// slow "breathing" amplitude envelope, synthesised in real time and played
|
||||
// through the BSP codec. Used by the Coin calme screen.
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
esp_err_t lisael_calm_tone_start(void);
|
||||
void lisael_calm_tone_stop(void);
|
||||
bool lisael_calm_tone_is_playing(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+319
-120
@@ -1,165 +1,364 @@
|
||||
// Webradio streaming pipeline (ESP-ADF) for Lisael Box.
|
||||
// Webradio live-streaming pipeline for Lisael Box — NO ESP-ADF.
|
||||
//
|
||||
// Pipeline: http_stream -> esp_decoder (auto MP3/AAC) -> i2s_stream
|
||||
// Plays a live HTTP(S) MP3 webradio (Shoutcast/Icecast) straight onto the
|
||||
// shared ES8311 codec, reusing the exact streaming-decode pattern of
|
||||
// aac_player.c (esp_audio_simple_dec -> esp_codec_dev_write) but fed from the
|
||||
// network instead of a FILE.
|
||||
//
|
||||
// IMPORTANT (verify on hardware):
|
||||
// The esp-box BSP also configures I2S/ES8311 (bsp_audio_init /
|
||||
// bsp_audio_codec_speaker_init). ESP-ADF's i2s_stream historically owns the
|
||||
// I2S peripheral itself. On a real BOX-3 you must make these agree on ONE
|
||||
// owner of the I2S port. Two known-good approaches:
|
||||
// (A) Let ADF own I2S: configure i2s_stream with the BOX-3 pins/port and
|
||||
// do NOT call bsp_audio_init(); keep using the BSP codec handle only
|
||||
// for volume.
|
||||
// (B) Let the BSP own I2S (bsp_audio_init) and write decoded PCM to the
|
||||
// codec via esp_codec_dev_write() from a raw_stream sink instead of
|
||||
// i2s_stream.
|
||||
// This file implements (A) — ADF i2s_stream as the output — which is the
|
||||
// most common ADF idiom. The i2s_std config (pins, port) MUST be matched to
|
||||
// the BOX-3 BSP values; left as TODOs below because they depend on the exact
|
||||
// BSP version and are not safe to guess. Marked clearly.
|
||||
// Data flow (two FreeRTOS tasks + one StreamBuffer, no busy-spin anywhere):
|
||||
//
|
||||
// Because ADF is only present when ADF_PATH is set, the whole body is guarded
|
||||
// by __has_include so a no-ADF inspection build still compiles (the functions
|
||||
// then log "audio disabled").
|
||||
// [HTTP task] esp_http_client GET url
|
||||
// -> HTTP_EVENT_ON_DATA callback
|
||||
// -> xStreamBufferSend(net_buf) (blocks if buffer full)
|
||||
//
|
||||
// [decode task] xStreamBufferReceive(net_buf) (blocks if buffer empty)
|
||||
// -> esp_audio_simple_dec_process (MP3 -> PCM)
|
||||
// -> esp_codec_dev_write() (blocks / paces playback)
|
||||
//
|
||||
// The codec is the rate limiter: esp_codec_dev_write() blocks until the I2S DMA
|
||||
// drains, which naturally throttles the whole chain. If the network is slower
|
||||
// than playback, the StreamBuffer empties and the decode task simply blocks on
|
||||
// receive (no spinning). If the network is faster, the HTTP callback blocks on
|
||||
// send until the decoder has consumed room (back-pressure). A single volatile
|
||||
// s_stop flag unwinds both tasks cleanly; lisael_radio_stop() waits (bounded)
|
||||
// for them to exit and restores the IDLE source.
|
||||
|
||||
#include "audio/radio_pipeline.h"
|
||||
#include "audio/audio_player.h"
|
||||
#include "lisael_config.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <inttypes.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "freertos/stream_buffer.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_codec_dev.h"
|
||||
#include "esp_audio_simple_dec.h"
|
||||
#include "esp_audio_simple_dec_default.h"
|
||||
#include "esp_audio_dec_default.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h" // HTTPS CA bundle (RadioKing streams are https)
|
||||
#include "net/udp_log.h" // temporary network debug
|
||||
|
||||
static const char *TAG = "radio";
|
||||
|
||||
#if defined(__has_include)
|
||||
# if __has_include("audio_pipeline.h")
|
||||
# define LISAEL_HAVE_ADF 1
|
||||
# endif
|
||||
#endif
|
||||
// --- Tuning ------------------------------------------------------------------
|
||||
#define NET_STREAM_BUF_BYTES (32 * 1024) // ~32 KB of buffered compressed MP3
|
||||
#define NET_CHUNK_TIMEOUT_MS 2000 // max wait pushing a network chunk
|
||||
#define DEC_READ_TIMEOUT_MS 2000 // max wait pulling MP3 from buffer
|
||||
#define DEC_READ_CHUNK 4096 // bytes pulled per decode iteration
|
||||
#define DEC_OUT_CAP_INIT 8192 // initial PCM output buffer
|
||||
#define HTTP_RX_BUFFER 2048 // esp_http_client receive buffer
|
||||
|
||||
#ifdef LISAEL_HAVE_ADF
|
||||
// --- State (single radio instance; sources are mutually exclusive) ----------
|
||||
static volatile bool s_stop; // request both tasks to unwind
|
||||
static volatile bool s_http_running; // HTTP task alive
|
||||
static volatile bool s_dec_running; // decode task alive
|
||||
static volatile bool s_playing; // public "is playing" state
|
||||
|
||||
#include "audio_pipeline.h"
|
||||
#include "audio_element.h"
|
||||
#include "http_stream.h"
|
||||
#include "i2s_stream.h"
|
||||
#include "esp_decoder.h"
|
||||
#include "audio_mem.h"
|
||||
static StreamBufferHandle_t s_net_buf; // compressed MP3 bytes: HTTP -> decoder
|
||||
static TaskHandle_t s_http_task;
|
||||
static TaskHandle_t s_dec_task;
|
||||
static char s_url[512];
|
||||
|
||||
static audio_pipeline_handle_t s_pipe;
|
||||
static audio_element_handle_t s_http, s_decoder, s_i2s;
|
||||
static bool s_playing;
|
||||
|
||||
static esp_err_t build_pipeline(void)
|
||||
// Register the MP3 frame decoder + simple-decoder container parsers once.
|
||||
static void register_decoders_once(void)
|
||||
{
|
||||
if (s_pipe) {
|
||||
return ESP_OK; // already built
|
||||
static bool done;
|
||||
if (!done) {
|
||||
esp_audio_dec_register_default(); // MP3/AAC frame decoders
|
||||
esp_audio_simple_dec_register_default(); // simple-dec wrappers
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
|
||||
s_pipe = audio_pipeline_init(&pcfg);
|
||||
if (!s_pipe) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
// ----------------------------------------------------------------------------
|
||||
// HTTP task: pull bytes off the network and push them into the StreamBuffer.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// Called by esp_http_client from inside esp_http_client_perform() for every
|
||||
// received body chunk. We forward the bytes into the StreamBuffer; the send
|
||||
// blocks (bounded) when the buffer is full, applying back-pressure to the TCP
|
||||
// read without ever busy-spinning. Returning ESP_FAIL aborts the transfer,
|
||||
// which is how a stop request unwinds the (otherwise endless) live stream.
|
||||
static esp_err_t http_event(esp_http_client_event_t *evt)
|
||||
{
|
||||
if (evt->event_id == HTTP_EVENT_ON_DATA && evt->data_len > 0) {
|
||||
static bool first = false;
|
||||
if (!first) { first = true; lisael_udp_send("RADIO: first net data, len=%d", evt->data_len); }
|
||||
const uint8_t *p = (const uint8_t *)evt->data;
|
||||
size_t remaining = (size_t)evt->data_len;
|
||||
while (remaining > 0) {
|
||||
if (s_stop) {
|
||||
return ESP_FAIL; // abort perform() -> HTTP task returns
|
||||
}
|
||||
size_t sent = xStreamBufferSend(s_net_buf, p, remaining,
|
||||
pdMS_TO_TICKS(NET_CHUNK_TIMEOUT_MS));
|
||||
p += sent;
|
||||
remaining -= sent;
|
||||
// If sent < remaining we timed out with the buffer full (decoder
|
||||
// stalled / paused) — loop again, re-checking s_stop. No spin: the
|
||||
// send call itself blocks for up to NET_CHUNK_TIMEOUT_MS.
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP source ---------------------------------------------------------
|
||||
http_stream_cfg_t http_cfg = HTTP_STREAM_CFG_DEFAULT();
|
||||
http_cfg.type = AUDIO_STREAM_READER;
|
||||
s_http = http_stream_init(&http_cfg);
|
||||
|
||||
// --- Auto decoder (MP3 / AAC / others) ----------------------------------
|
||||
// esp_decoder auto-detects the container/codec from the stream — perfect
|
||||
// for webradios that may serve MP3 or AAC.
|
||||
audio_decoder_t decoders[] = {
|
||||
DEFAULT_ESP_MP3_DECODER_CONFIG(),
|
||||
DEFAULT_ESP_AAC_DECODER_CONFIG(),
|
||||
};
|
||||
esp_decoder_cfg_t dec_cfg = DEFAULT_ESP_DECODER_CONFIG();
|
||||
s_decoder = esp_decoder_init(&dec_cfg, decoders,
|
||||
sizeof(decoders) / sizeof(decoders[0]));
|
||||
|
||||
// --- I2S sink to ES8311 --------------------------------------------------
|
||||
// TODO(hardware): confirm the I2S port number and that these pins match the
|
||||
// BOX-3 BSP. With ADF >= 2.7 the simplest path is i2s_stream_init with the
|
||||
// default config and then i2s_stream_set_clk() once the decoder reports the
|
||||
// sample rate. The codec itself (gain/mute) stays driven by the BSP
|
||||
// esp_codec_dev handle (lisael_audio_codec()).
|
||||
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
|
||||
i2s_cfg.type = AUDIO_STREAM_WRITER;
|
||||
s_i2s = i2s_stream_init(&i2s_cfg);
|
||||
|
||||
audio_pipeline_register(s_pipe, s_http, "http");
|
||||
audio_pipeline_register(s_pipe, s_decoder, "dec");
|
||||
audio_pipeline_register(s_pipe, s_i2s, "i2s");
|
||||
|
||||
const char *link[] = { "http", "dec", "i2s" };
|
||||
audio_pipeline_link(s_pipe, link, 3);
|
||||
|
||||
ESP_LOGI(TAG, "pipeline built: http -> dec -> i2s");
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void http_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
s_http_running = true;
|
||||
ESP_LOGI(TAG, "http: GET %s", s_url);
|
||||
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = s_url,
|
||||
.event_handler = http_event,
|
||||
.timeout_ms = 15000,
|
||||
.buffer_size = HTTP_RX_BUFFER,
|
||||
// Follow 3xx automatically: RadioKing /play/<slug> endpoints redirect to
|
||||
// the live listen.radioking.com mount, and the stream itself is one long
|
||||
// 200 response we want to keep reading. .disable_auto_redirect = false.
|
||||
.disable_auto_redirect = false,
|
||||
.max_redirection_count = 5,
|
||||
// HTTPS support via the certificate bundle (harmless for plain http://).
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
.keep_alive_enable = true,
|
||||
};
|
||||
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
if (client) {
|
||||
lisael_udp_send("RADIO: http perform start");
|
||||
esp_err_t err = esp_http_client_perform(client);
|
||||
int status = esp_http_client_get_status_code(client);
|
||||
lisael_udp_send("RADIO: http ended err=%s status=%d",
|
||||
esp_err_to_name(err), status);
|
||||
if (err != ESP_OK && !s_stop) {
|
||||
ESP_LOGW(TAG, "http perform ended: %s", esp_err_to_name(err));
|
||||
}
|
||||
esp_http_client_cleanup(client);
|
||||
} else {
|
||||
lisael_udp_send("RADIO: http client init FAILED");
|
||||
ESP_LOGE(TAG, "http client init failed");
|
||||
}
|
||||
|
||||
// The network is gone: tell the decoder to wind down too.
|
||||
s_stop = true;
|
||||
s_http_running = false;
|
||||
s_http_task = NULL;
|
||||
ESP_LOGI(TAG, "http task exit");
|
||||
vTaskDeleteWithCaps(NULL);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Decode task: pull MP3 from the StreamBuffer, decode to PCM, write to codec.
|
||||
// ----------------------------------------------------------------------------
|
||||
static void decode_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
s_dec_running = true;
|
||||
register_decoders_once();
|
||||
|
||||
esp_audio_simple_dec_cfg_t dcfg = {
|
||||
.dec_type = ESP_AUDIO_SIMPLE_DEC_TYPE_MP3, // FR kids webradios are MP3
|
||||
.dec_cfg = NULL,
|
||||
.cfg_size = 0,
|
||||
};
|
||||
esp_audio_simple_dec_handle_t dec = NULL;
|
||||
if (esp_audio_simple_dec_open(&dcfg, &dec) != ESP_AUDIO_ERR_OK) {
|
||||
ESP_LOGE(TAG, "decoder open failed");
|
||||
goto done;
|
||||
}
|
||||
|
||||
esp_codec_dev_handle_t codec = lisael_audio_codec();
|
||||
uint8_t *inbuf = malloc(DEC_READ_CHUNK);
|
||||
uint32_t out_cap = DEC_OUT_CAP_INIT;
|
||||
uint8_t *out = malloc(out_cap);
|
||||
bool codec_open = false;
|
||||
|
||||
if (!inbuf || !out || !codec) {
|
||||
ESP_LOGE(TAG, "decode buffers/codec unavailable");
|
||||
free(inbuf);
|
||||
free(out);
|
||||
if (dec) esp_audio_simple_dec_close(dec);
|
||||
goto done;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "decode task started");
|
||||
|
||||
while (!s_stop) {
|
||||
// Block until at least 1 byte is available (or timeout). When the HTTP
|
||||
// task has exited and drained the buffer this returns 0 -> we leave.
|
||||
size_t n = xStreamBufferReceive(s_net_buf, inbuf, DEC_READ_CHUNK,
|
||||
pdMS_TO_TICKS(DEC_READ_TIMEOUT_MS));
|
||||
if (n == 0) {
|
||||
// No data within the timeout. If the producer is gone, stop;
|
||||
// otherwise loop and wait again (the receive blocked, no spin).
|
||||
if (!s_http_running) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
esp_audio_simple_dec_raw_t raw = {
|
||||
.buffer = inbuf,
|
||||
.len = (uint32_t)n,
|
||||
.eos = false,
|
||||
};
|
||||
while (raw.len > 0 && !s_stop) {
|
||||
esp_audio_simple_dec_out_t frame = { .buffer = out, .len = out_cap };
|
||||
esp_audio_err_t e = esp_audio_simple_dec_process(dec, &raw, &frame);
|
||||
if (e == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
|
||||
uint8_t *bigger = realloc(out, frame.needed_size);
|
||||
if (!bigger) { s_stop = true; break; }
|
||||
out = bigger; out_cap = frame.needed_size;
|
||||
continue; // retry with a larger PCM buffer
|
||||
}
|
||||
if (e != ESP_AUDIO_ERR_OK) {
|
||||
// A corrupt MP3 frame at a stream boundary is normal; skip this
|
||||
// input chunk and resync on the next network read.
|
||||
ESP_LOGD(TAG, "decode err %d (resync)", (int)e);
|
||||
break;
|
||||
}
|
||||
if (frame.decoded_size > 0) {
|
||||
if (!codec_open) {
|
||||
// Open the codec at the rate/channels reported by the stream
|
||||
// on the first decoded PCM, exactly like aac_player.c.
|
||||
esp_audio_simple_dec_info_t info = {0};
|
||||
esp_audio_simple_dec_get_info(dec, &info);
|
||||
ESP_LOGI(TAG, "MP3 %" PRIu32 " Hz %u ch %u bit",
|
||||
info.sample_rate, info.channel,
|
||||
info.bits_per_sample);
|
||||
lisael_udp_send("RADIO: decoded MP3 %" PRIu32 " Hz %u ch %u bit -> opening codec",
|
||||
info.sample_rate, info.channel, info.bits_per_sample);
|
||||
esp_codec_dev_close(codec);
|
||||
esp_codec_dev_sample_info_t fs = {
|
||||
.bits_per_sample = info.bits_per_sample,
|
||||
.channel = info.channel,
|
||||
.sample_rate = info.sample_rate,
|
||||
};
|
||||
esp_codec_dev_open(codec, &fs);
|
||||
esp_codec_dev_set_out_vol(codec,
|
||||
(float)lisael_audio_get_volume());
|
||||
codec_open = true;
|
||||
}
|
||||
// Blocking write — this is what paces the whole pipeline.
|
||||
esp_codec_dev_write(codec, frame.buffer, frame.decoded_size);
|
||||
}
|
||||
if (raw.consumed == 0 && frame.decoded_size == 0) {
|
||||
break; // no progress on this chunk; fetch more from the network
|
||||
}
|
||||
raw.buffer += raw.consumed;
|
||||
raw.len -= raw.consumed;
|
||||
}
|
||||
}
|
||||
|
||||
free(inbuf);
|
||||
free(out);
|
||||
if (codec_open) {
|
||||
esp_codec_dev_close(codec);
|
||||
}
|
||||
esp_audio_simple_dec_close(dec);
|
||||
|
||||
done:
|
||||
// Make sure the HTTP task also stops (e.g. when we exited on a decode error).
|
||||
s_stop = true;
|
||||
if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) {
|
||||
lisael_audio_set_source(LISAEL_AUDIO_IDLE);
|
||||
}
|
||||
s_playing = false;
|
||||
s_dec_running = false;
|
||||
s_dec_task = NULL;
|
||||
ESP_LOGI(TAG, "decode task exit");
|
||||
vTaskDeleteWithCaps(NULL);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ----------------------------------------------------------------------------
|
||||
esp_err_t lisael_radio_play(const char *url)
|
||||
{
|
||||
if (!url) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
// Take over the shared codec from any other source.
|
||||
|
||||
// Stop whatever else is playing (and any previous radio session) first.
|
||||
lisael_radio_stop();
|
||||
lisael_audio_stop_all();
|
||||
|
||||
esp_err_t err = build_pipeline();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
// Lazily create the network->decoder StreamBuffer IN PSRAM: it is ~32 KB
|
||||
// and internal RAM is nearly exhausted (xStreamBufferCreate would fail with
|
||||
// NO_MEM, which is exactly why the radio never started).
|
||||
if (!s_net_buf) {
|
||||
s_net_buf = xStreamBufferCreateWithCaps(NET_STREAM_BUF_BYTES, 1,
|
||||
MALLOC_CAP_SPIRAM);
|
||||
if (!s_net_buf) {
|
||||
ESP_LOGE(TAG, "stream buffer alloc failed");
|
||||
lisael_udp_send("RADIO: stream buffer alloc FAILED");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
} else {
|
||||
xStreamBufferReset(s_net_buf);
|
||||
}
|
||||
|
||||
if (s_playing) {
|
||||
audio_pipeline_stop(s_pipe);
|
||||
audio_pipeline_wait_for_stop(s_pipe);
|
||||
audio_pipeline_reset_ringbuffer(s_pipe);
|
||||
audio_pipeline_reset_elements(s_pipe);
|
||||
s_playing = false;
|
||||
}
|
||||
|
||||
audio_element_set_uri(s_http, url);
|
||||
err = audio_pipeline_run(s_pipe);
|
||||
if (err == ESP_OK) {
|
||||
strlcpy(s_url, url, sizeof(s_url));
|
||||
s_stop = false;
|
||||
s_playing = true;
|
||||
lisael_audio_set_source(LISAEL_AUDIO_RADIO);
|
||||
// Re-assert the child-safe volume on the shared codec.
|
||||
lisael_audio_set_volume(lisael_audio_get_volume());
|
||||
ESP_LOGI(TAG, "playing %s", url);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "pipeline run failed: %s", esp_err_to_name(err));
|
||||
|
||||
// Priority 4 == LVGL task priority. We MUST NOT run above the UI task or a
|
||||
// saturated audio path could starve the display. The codec write in the
|
||||
// decode task blocks, so it yields the CPU regularly; priority 4 keeps it
|
||||
// responsive without preempting LVGL's own time slice. pin to no core so
|
||||
// the scheduler can balance with Wi-Fi/LVGL.
|
||||
BaseType_t ok1 = xTaskCreatePinnedToCoreWithCaps(decode_task, "radio_dec", 8192,
|
||||
NULL, 4, &s_dec_task,
|
||||
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
|
||||
BaseType_t ok2 = xTaskCreatePinnedToCoreWithCaps(http_task, "radio_http", 8192,
|
||||
NULL, 4, &s_http_task,
|
||||
tskNO_AFFINITY, MALLOC_CAP_SPIRAM);
|
||||
if (ok1 != pdPASS || ok2 != pdPASS) {
|
||||
ESP_LOGE(TAG, "task create failed");
|
||||
s_stop = true;
|
||||
// Let whichever task did start unwind, then report failure.
|
||||
lisael_radio_stop();
|
||||
return ESP_FAIL;
|
||||
}
|
||||
return err;
|
||||
|
||||
ESP_LOGI(TAG, "playing %s", url);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void lisael_radio_stop(void)
|
||||
{
|
||||
if (s_pipe && s_playing) {
|
||||
audio_pipeline_stop(s_pipe);
|
||||
audio_pipeline_wait_for_stop(s_pipe);
|
||||
audio_pipeline_reset_ringbuffer(s_pipe);
|
||||
audio_pipeline_reset_elements(s_pipe);
|
||||
s_playing = false;
|
||||
ESP_LOGI(TAG, "stopped");
|
||||
if (!s_http_running && !s_dec_running && !s_playing) {
|
||||
return; // nothing to do
|
||||
}
|
||||
|
||||
s_stop = true;
|
||||
// Nudge the decode task in case it is blocked on an empty StreamBuffer.
|
||||
if (s_net_buf) {
|
||||
xStreamBufferReset(s_net_buf);
|
||||
}
|
||||
|
||||
// Wait (bounded, ~2 s) for both tasks to release the codec and exit. The
|
||||
// HTTP perform may take up to its timeout to notice the abort, and the
|
||||
// decode task may be mid codec write; ~100 * 20 ms covers both.
|
||||
for (int i = 0; i < 100 && (s_http_running || s_dec_running); i++) {
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
}
|
||||
|
||||
s_playing = false;
|
||||
if (lisael_audio_get_source() == LISAEL_AUDIO_RADIO) {
|
||||
lisael_audio_set_source(LISAEL_AUDIO_IDLE);
|
||||
}
|
||||
ESP_LOGI(TAG, "stopped");
|
||||
}
|
||||
|
||||
bool lisael_radio_is_playing(void)
|
||||
{
|
||||
return s_playing;
|
||||
}
|
||||
|
||||
#else // !LISAEL_HAVE_ADF — inspection build without ESP-ADF
|
||||
|
||||
esp_err_t lisael_radio_play(const char *url)
|
||||
{
|
||||
(void)url;
|
||||
ESP_LOGW(TAG, "ESP-ADF not present (ADF_PATH unset) — radio disabled");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
void lisael_radio_stop(void) {}
|
||||
bool lisael_radio_is_playing(void) { return false; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
|
||||
+42
-94
@@ -1,5 +1,8 @@
|
||||
// microSD sound-clip player (ESP-ADF fatfs pipeline) for Lisael Box.
|
||||
// See sd_player.h. Same BSP<->ADF I2S ownership caveat as radio_pipeline.c.
|
||||
// microSD sound-clip / story player for Lisael Box. See sd_player.h.
|
||||
//
|
||||
// Playback uses chmorgan/esp-audio-player (MP3 -> PCM) pushed to the BSP's
|
||||
// ES8311 codec through the callbacks installed in audio_player.c. No ESP-ADF,
|
||||
// so no audio_board <-> esp_lcd_ili9341 version conflict with the BOX-3 BSP.
|
||||
|
||||
#include "audio/sd_player.h"
|
||||
#include "audio/audio_player.h"
|
||||
@@ -12,6 +15,8 @@
|
||||
#include "esp_log.h"
|
||||
#include "bsp/esp-box-3.h"
|
||||
#include "cJSON.h"
|
||||
#include <audio_player.h> // chmorgan (angle brackets => its include dir, not
|
||||
// our same-directory audio/audio_player.h)
|
||||
|
||||
static const char *TAG = "sd";
|
||||
static bool s_mounted;
|
||||
@@ -33,14 +38,20 @@ esp_err_t lisael_sd_mount(void)
|
||||
|
||||
int lisael_sd_load_manifest(lisael_sound_t *out, int max)
|
||||
{
|
||||
if (!out || max <= 0) {
|
||||
return lisael_sd_load_manifest_dir("sounds", out, max);
|
||||
}
|
||||
|
||||
int lisael_sd_load_manifest_dir(const char *subdir, lisael_sound_t *out, int max)
|
||||
{
|
||||
if (!out || max <= 0 || !subdir) {
|
||||
return -1;
|
||||
}
|
||||
if (!s_mounted && lisael_sd_mount() != ESP_OK) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const char *path = BSP_SD_MOUNT_POINT "/sounds/manifest.json";
|
||||
char path[96];
|
||||
snprintf(path, sizeof(path), "%s/%s/manifest.json", BSP_SD_MOUNT_POINT, subdir);
|
||||
FILE *f = fopen(path, "r");
|
||||
if (!f) {
|
||||
ESP_LOGW(TAG, "no manifest at %s", path);
|
||||
@@ -78,15 +89,22 @@ int lisael_sd_load_manifest(lisael_sound_t *out, int max)
|
||||
}
|
||||
cJSON *file = cJSON_GetObjectItem(item, "file");
|
||||
cJSON *title = cJSON_GetObjectItem(item, "title");
|
||||
cJSON *show = cJSON_GetObjectItem(item, "show");
|
||||
cJSON *emoji = cJSON_GetObjectItem(item, "emoji");
|
||||
if (!cJSON_IsString(file)) {
|
||||
continue;
|
||||
}
|
||||
memset(&out[n], 0, sizeof(out[n]));
|
||||
strlcpy(out[n].file, file->valuestring, sizeof(out[n].file));
|
||||
strlcpy(out[n].title,
|
||||
cJSON_IsString(title) ? title->valuestring : file->valuestring,
|
||||
sizeof(out[n].title));
|
||||
// Absolute path so callers can play it directly regardless of subdir.
|
||||
snprintf(out[n].file, sizeof(out[n].file), "%s/%s/%s",
|
||||
BSP_SD_MOUNT_POINT, subdir, file->valuestring);
|
||||
const char *t = cJSON_IsString(title) ? title->valuestring : file->valuestring;
|
||||
if (cJSON_IsString(show) && show->valuestring[0]) {
|
||||
snprintf(out[n].title, sizeof(out[n].title), "%s - %s",
|
||||
show->valuestring, t);
|
||||
} else {
|
||||
strlcpy(out[n].title, t, sizeof(out[n].title));
|
||||
}
|
||||
strlcpy(out[n].emoji,
|
||||
cJSON_IsString(emoji) ? emoji->valuestring : "\U0001F50A", // speaker
|
||||
sizeof(out[n].emoji));
|
||||
@@ -104,57 +122,7 @@ esp_err_t lisael_sd_play_sound(const char *basename)
|
||||
return lisael_sd_play_file(abs);
|
||||
}
|
||||
|
||||
// --- ADF-guarded pipeline ---------------------------------------------------
|
||||
#if defined(__has_include)
|
||||
# if __has_include("audio_pipeline.h")
|
||||
# define LISAEL_HAVE_ADF 1
|
||||
# endif
|
||||
#endif
|
||||
|
||||
#ifdef LISAEL_HAVE_ADF
|
||||
|
||||
#include "audio_pipeline.h"
|
||||
#include "audio_element.h"
|
||||
#include "fatfs_stream.h"
|
||||
#include "mp3_decoder.h"
|
||||
#include "i2s_stream.h"
|
||||
|
||||
static audio_pipeline_handle_t s_pipe;
|
||||
static audio_element_handle_t s_fatfs, s_mp3, s_i2s;
|
||||
static bool s_playing;
|
||||
|
||||
static esp_err_t build_pipeline(void)
|
||||
{
|
||||
if (s_pipe) {
|
||||
return ESP_OK;
|
||||
}
|
||||
audio_pipeline_cfg_t pcfg = DEFAULT_AUDIO_PIPELINE_CONFIG();
|
||||
s_pipe = audio_pipeline_init(&pcfg);
|
||||
if (!s_pipe) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
fatfs_stream_cfg_t fs_cfg = FATFS_STREAM_CFG_DEFAULT();
|
||||
fs_cfg.type = AUDIO_STREAM_READER;
|
||||
s_fatfs = fatfs_stream_init(&fs_cfg);
|
||||
|
||||
mp3_decoder_cfg_t mp3_cfg = DEFAULT_MP3_DECODER_CONFIG();
|
||||
s_mp3 = mp3_decoder_init(&mp3_cfg);
|
||||
|
||||
// TODO(hardware): match i2s port/pins to the BOX-3 BSP — same caveat as
|
||||
// radio_pipeline.c (ADF vs BSP I2S ownership).
|
||||
i2s_stream_cfg_t i2s_cfg = I2S_STREAM_CFG_DEFAULT();
|
||||
i2s_cfg.type = AUDIO_STREAM_WRITER;
|
||||
s_i2s = i2s_stream_init(&i2s_cfg);
|
||||
|
||||
audio_pipeline_register(s_pipe, s_fatfs, "file");
|
||||
audio_pipeline_register(s_pipe, s_mp3, "mp3");
|
||||
audio_pipeline_register(s_pipe, s_i2s, "i2s");
|
||||
|
||||
const char *link[] = { "file", "mp3", "i2s" };
|
||||
audio_pipeline_link(s_pipe, link, 3);
|
||||
return ESP_OK;
|
||||
}
|
||||
// --- playback via esp-audio-player ------------------------------------------
|
||||
|
||||
esp_err_t lisael_sd_play_file(const char *abs_path)
|
||||
{
|
||||
@@ -164,59 +132,39 @@ esp_err_t lisael_sd_play_file(const char *abs_path)
|
||||
if (!s_mounted && lisael_sd_mount() != ESP_OK) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
lisael_audio_stop_all();
|
||||
|
||||
esp_err_t err = build_pipeline();
|
||||
esp_err_t err = lisael_audio_player_ensure();
|
||||
if (err != ESP_OK) {
|
||||
return err;
|
||||
}
|
||||
if (s_playing) {
|
||||
audio_pipeline_stop(s_pipe);
|
||||
audio_pipeline_wait_for_stop(s_pipe);
|
||||
audio_pipeline_reset_ringbuffer(s_pipe);
|
||||
audio_pipeline_reset_elements(s_pipe);
|
||||
s_playing = false;
|
||||
}
|
||||
lisael_audio_stop_all(); // mutually exclusive sources
|
||||
|
||||
audio_element_set_uri(s_fatfs, abs_path);
|
||||
err = audio_pipeline_run(s_pipe);
|
||||
FILE *fp = fopen(abs_path, "rb");
|
||||
if (!fp) {
|
||||
ESP_LOGW(TAG, "cannot open %s", abs_path);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
// audio_player_play() takes ownership of fp and fclose()s it on success
|
||||
// (and on playback error). Only close it ourselves if queuing failed.
|
||||
err = audio_player_play(fp);
|
||||
if (err == ESP_OK) {
|
||||
s_playing = true;
|
||||
lisael_audio_set_source(LISAEL_AUDIO_SD);
|
||||
lisael_audio_set_volume(lisael_audio_get_volume());
|
||||
ESP_LOGI(TAG, "playing %s", abs_path);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "run failed: %s", esp_err_to_name(err));
|
||||
fclose(fp);
|
||||
ESP_LOGE(TAG, "play failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
return err;
|
||||
}
|
||||
|
||||
void lisael_sd_stop(void)
|
||||
{
|
||||
if (s_pipe && s_playing) {
|
||||
audio_pipeline_stop(s_pipe);
|
||||
audio_pipeline_wait_for_stop(s_pipe);
|
||||
audio_pipeline_reset_ringbuffer(s_pipe);
|
||||
audio_pipeline_reset_elements(s_pipe);
|
||||
s_playing = false;
|
||||
if (audio_player_get_state() != AUDIO_PLAYER_STATE_IDLE) {
|
||||
audio_player_stop();
|
||||
ESP_LOGI(TAG, "stopped");
|
||||
}
|
||||
}
|
||||
|
||||
bool lisael_sd_is_playing(void)
|
||||
{
|
||||
return s_playing;
|
||||
return audio_player_get_state() == AUDIO_PLAYER_STATE_PLAYING;
|
||||
}
|
||||
|
||||
#else // !LISAEL_HAVE_ADF
|
||||
|
||||
esp_err_t lisael_sd_play_file(const char *abs_path)
|
||||
{
|
||||
(void)abs_path;
|
||||
ESP_LOGW(TAG, "ESP-ADF not present — SD playback disabled");
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
void lisael_sd_stop(void) {}
|
||||
bool lisael_sd_is_playing(void) { return false; }
|
||||
|
||||
#endif
|
||||
|
||||
@@ -29,6 +29,13 @@ esp_err_t lisael_sd_mount(void);
|
||||
// LISAEL_SD_MAX_SOUNDS). Returns -1 on error (no card / no manifest).
|
||||
int lisael_sd_load_manifest(lisael_sound_t *out, int max);
|
||||
|
||||
// Generic: parse <MOUNT>/<subdir>/manifest.json (a JSON array of
|
||||
// {file, title, show?, emoji?}). out[i].file is filled with the ABSOLUTE path
|
||||
// "<MOUNT>/<subdir>/<file>"; when "show" is present, out[i].title is composed as
|
||||
// "show — title". Returns count (>=0), or -1 on error. Used for both the sound
|
||||
// clips ("sounds") and the downloaded podcast episodes ("podcasts").
|
||||
int lisael_sd_load_manifest_dir(const char *subdir, lisael_sound_t *out, int max);
|
||||
|
||||
// Play an absolute SD path (e.g. "/sdcard/sounds/miaou.mp3"). Stops other audio.
|
||||
esp_err_t lisael_sd_play_file(const char *abs_path);
|
||||
|
||||
|
||||
@@ -19,6 +19,18 @@ dependencies:
|
||||
# esp_lcd_ili9341, button, icm42670, aht30 transitively.
|
||||
espressif/esp-box-3: "^3.2.0"
|
||||
|
||||
# Audio playback (MP3 from SD, later HTTP streams) WITHOUT ESP-ADF: a small
|
||||
# high-level player that decodes to PCM and hands it to a write callback we
|
||||
# back with the BSP's ES8311 codec (esp_codec_dev). Avoids the ESP-ADF
|
||||
# audio_board <-> esp_lcd_ili9341 version conflict with the BOX-3 BSP.
|
||||
chmorgan/esp-audio-player: "^1.0.7"
|
||||
chmorgan/esp-libhelix-mp3: "^1.0.3"
|
||||
|
||||
# AAC / M4A decoding (Radio France podcasts are AAC in an MP4/.m4a container).
|
||||
# Espressif's audio codec lib provides a "simple decoder" that demuxes M4A and
|
||||
# decodes AAC/MP3/etc -> PCM. Used to test on-device AAC playback.
|
||||
espressif/esp_audio_codec: "^2.0.0"
|
||||
|
||||
# JSON parsing (manifest.json, open-meteo replies) uses the cJSON shipped with
|
||||
# ESP-IDF as the built-in "json" component — no managed dependency needed.
|
||||
# (REQUIRES json in main/CMakeLists.txt.)
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
// "Calme ta colère" — balloon-popping game, see balloons.h.
|
||||
//
|
||||
// Colourful balloons (a rounded lv_obj with a vertical gradient for a 3D sheen,
|
||||
// plus a thin "string" below) rise from y=240 to off-screen at y=-80 with a
|
||||
// per-balloon random duration. A repeating lv_timer spawns a new balloon every
|
||||
// ~0.9-1.4 s, capped at BALLOON_MAX on screen at once. Tapping a balloon makes
|
||||
// it pop: the rise animation is removed, the balloon scales up while fading out
|
||||
// (~200 ms), then it is deleted; a short synthesised "pop" is played.
|
||||
//
|
||||
// Lifetime / threading:
|
||||
// * The spawn timer, rise animations and pop animations all run on the LVGL
|
||||
// task, so they touch LVGL freely.
|
||||
// * The pop sound runs on its own detached FreeRTOS task and touches ONLY the
|
||||
// shared codec (never LVGL). A single volatile flag serialises pops so two
|
||||
// tasks never fight over the codec.
|
||||
// * On LV_EVENT_SCREEN_UNLOADED everything is torn down: the timer is deleted,
|
||||
// every live balloon (and its rise anim) is removed, and audio is stopped.
|
||||
|
||||
#include "ui/ui.h"
|
||||
#include "ui/fonts/lisael_fonts.h"
|
||||
#include "audio/audio_player.h"
|
||||
|
||||
#include <math.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/idf_additions.h"
|
||||
#include "esp_heap_caps.h"
|
||||
#include "esp_codec_dev.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "ui.balloons";
|
||||
|
||||
// --- Geometry / timing tunables ---------------------------------------------
|
||||
#define SCREEN_W 320
|
||||
#define SCREEN_H 240
|
||||
#define BALLOON_W 56 // balloon body width (px)
|
||||
#define BALLOON_H 66 // balloon body height (px)
|
||||
#define STRING_H 22 // little string under the balloon (px)
|
||||
#define Y_START SCREEN_H // spawn just below the screen
|
||||
#define Y_END (-(BALLOON_H + STRING_H + 14)) // fully off the top
|
||||
#define RISE_MIN_MS 4000
|
||||
#define RISE_MAX_MS 8000
|
||||
#define SPAWN_MIN_MS 900
|
||||
#define SPAWN_MAX_MS 1400
|
||||
#define BALLOON_MAX 8 // max balloons alive at once
|
||||
#define POP_MS 200 // pop grow/fade duration
|
||||
|
||||
// --- Pop sound (detached task) ----------------------------------------------
|
||||
// A satisfying, cathartic "pop": a short explosion (noise burst + descending
|
||||
// boom) immediately followed by a bright melodic note that rings out. Each pop
|
||||
// steps UP a C-major-pentatonic scale (cycling), so popping balloons in a row
|
||||
// plays a cheerful rising melody — the "défouloir" effect.
|
||||
#define POP_SR 32000
|
||||
#define POP_MS_SOUND 520 // longer, rewarding
|
||||
#define POP_SAMPLES ((POP_SR * POP_MS_SOUND) / 1000)
|
||||
|
||||
static volatile bool s_pop_running; // serialise codec access
|
||||
static uint32_t s_rng = 0x1234567u; // tiny noise generator
|
||||
static int s_note = 0; // ascending melody index
|
||||
|
||||
// C5 D5 E5 G5 A5 C6 D6 E6 — major pentatonic, ascending.
|
||||
static const float PENTA[] = {
|
||||
523.25f, 587.33f, 659.25f, 783.99f, 880.00f, 1046.50f, 1174.66f, 1318.51f,
|
||||
};
|
||||
|
||||
static inline float frand(void)
|
||||
{
|
||||
s_rng = s_rng * 1664525u + 1013904223u;
|
||||
return ((float)((s_rng >> 9) & 0xFFFF) / 32768.0f) - 1.0f; // -1..1
|
||||
}
|
||||
|
||||
static void pop_task(void *arg)
|
||||
{
|
||||
int note = (int)(intptr_t)arg;
|
||||
esp_codec_dev_handle_t codec = lisael_audio_codec();
|
||||
if (!codec) {
|
||||
s_pop_running = false;
|
||||
vTaskDeleteWithCaps(NULL);
|
||||
return;
|
||||
}
|
||||
esp_codec_dev_close(codec);
|
||||
esp_codec_dev_sample_info_t fs = {
|
||||
.bits_per_sample = 16, .channel = 1, .sample_rate = POP_SR,
|
||||
};
|
||||
esp_codec_dev_open(codec, &fs);
|
||||
esp_codec_dev_set_out_vol(codec, (float)lisael_audio_get_volume());
|
||||
|
||||
const int N = 256;
|
||||
int16_t *buf = malloc(N * sizeof(int16_t));
|
||||
if (buf) {
|
||||
const float TAU = 6.2831853f;
|
||||
const float fnote = PENTA[note % (int)(sizeof(PENTA) / sizeof(PENTA[0]))];
|
||||
float ph = 0, ph2 = 0, phb = 0; // note, octave, boom phases
|
||||
int written = 0;
|
||||
while (written < POP_SAMPLES) {
|
||||
int n = POP_SAMPLES - written;
|
||||
if (n > N) n = N;
|
||||
for (int i = 0; i < n; i++) {
|
||||
float t = (float)(written + i) / (float)POP_SR; // seconds
|
||||
// 1) Explosion: noise + descending boom, very fast decay (~60 ms).
|
||||
float burst_env = expf(-26.0f * t);
|
||||
float boom_f = 60.0f + 260.0f * expf(-12.0f * t);
|
||||
float burst = (frand() * 0.5f + sinf(phb) * 0.55f) * burst_env;
|
||||
// 2) Melodic note: soft attack, rings out over ~400 ms.
|
||||
float atk = t < 0.012f ? t / 0.012f : 1.0f;
|
||||
float note_env = atk * expf(-3.4f * t);
|
||||
float tone = (sinf(ph) * 0.7f + sinf(ph2) * 0.3f) * note_env;
|
||||
float s = burst * 0.55f + tone * 0.55f;
|
||||
int v = (int)(s * 9000.0f);
|
||||
if (v > 32767) v = 32767;
|
||||
if (v < -32768) v = -32768;
|
||||
buf[i] = (int16_t)v;
|
||||
ph += TAU * fnote / POP_SR; if (ph > TAU) ph -= TAU;
|
||||
ph2 += TAU * fnote * 2.0f / POP_SR; if (ph2 > TAU) ph2 -= TAU;
|
||||
phb += TAU * boom_f / POP_SR; if (phb > TAU) phb -= TAU;
|
||||
}
|
||||
esp_codec_dev_write(codec, buf, n * sizeof(int16_t));
|
||||
written += n;
|
||||
}
|
||||
free(buf);
|
||||
}
|
||||
esp_codec_dev_close(codec);
|
||||
s_pop_running = false;
|
||||
vTaskDeleteWithCaps(NULL);
|
||||
}
|
||||
|
||||
static void play_pop(void)
|
||||
{
|
||||
if (s_pop_running) {
|
||||
return; // one already ringing — don't fight over the codec
|
||||
}
|
||||
s_pop_running = true;
|
||||
int note = s_note++; // ascend the scale on each successful pop
|
||||
// Priority 3 (below the LVGL task at 4) so the pop animation stays smooth.
|
||||
if (xTaskCreatePinnedToCoreWithCaps(pop_task, "balloon_pop", 4096,
|
||||
(void *)(intptr_t)note, 3, NULL,
|
||||
tskNO_AFFINITY, MALLOC_CAP_SPIRAM) != pdPASS) {
|
||||
s_pop_running = false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Balloon bookkeeping (all on the LVGL task) -----------------------------
|
||||
static lv_obj_t *s_root;
|
||||
static lv_obj_t *s_play_area; // balloons are children of this
|
||||
static lv_timer_t *s_spawn_timer; // periodic spawner, NULL when idle
|
||||
static lv_obj_t *s_balloons[BALLOON_MAX]; // live balloons (NULL = free slot)
|
||||
|
||||
// Vivid balloon colours (light top -> vivid bottom for a 3D look).
|
||||
typedef struct { uint32_t top; uint32_t bottom; } balloon_pal_t;
|
||||
static const balloon_pal_t PALETTE[] = {
|
||||
{ 0xFFC9C9, 0xE03131 }, // red
|
||||
{ 0xA5D8FF, 0x1971C2 }, // blue
|
||||
{ 0xB2F2BB, 0x2F9E44 }, // green
|
||||
{ 0xFFEC99, 0xF59F00 }, // yellow
|
||||
{ 0xD0BFFF, 0x7048E8 }, // purple
|
||||
{ 0xFFD8A8, 0xE8590C }, // orange
|
||||
};
|
||||
#define PALETTE_N (sizeof(PALETTE) / sizeof(PALETTE[0]))
|
||||
|
||||
static int balloon_slot(lv_obj_t *b)
|
||||
{
|
||||
for (int i = 0; i < BALLOON_MAX; i++) {
|
||||
if (s_balloons[i] == b) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void balloon_release_slot(lv_obj_t *b)
|
||||
{
|
||||
int i = balloon_slot(b);
|
||||
if (i >= 0) s_balloons[i] = NULL;
|
||||
}
|
||||
|
||||
// Rise animation: move the balloon's y; when it reaches the top, delete it.
|
||||
static void rise_anim_cb(void *obj, int32_t y)
|
||||
{
|
||||
lv_obj_set_y((lv_obj_t *)obj, y);
|
||||
}
|
||||
|
||||
static void rise_done_cb(lv_anim_t *a)
|
||||
{
|
||||
lv_obj_t *b = (lv_obj_t *)lv_anim_get_user_data(a);
|
||||
balloon_release_slot(b);
|
||||
lv_obj_delete(b); // floated off the top of the screen
|
||||
}
|
||||
|
||||
// Pop: scale up + fade out, then delete asynchronously.
|
||||
static void pop_scale_cb(void *obj, int32_t scale)
|
||||
{
|
||||
lv_obj_set_style_transform_scale_x((lv_obj_t *)obj, scale, 0);
|
||||
lv_obj_set_style_transform_scale_y((lv_obj_t *)obj, scale, 0);
|
||||
}
|
||||
|
||||
static void pop_opa_cb(void *obj, int32_t opa)
|
||||
{
|
||||
lv_obj_set_style_opa((lv_obj_t *)obj, (lv_opa_t)opa, 0);
|
||||
}
|
||||
|
||||
static void pop_done_cb(lv_anim_t *a)
|
||||
{
|
||||
lv_obj_t *b = (lv_obj_t *)lv_anim_get_user_data(a);
|
||||
balloon_release_slot(b);
|
||||
lv_obj_delete_async(b); // safe-delete after callbacks unwind
|
||||
}
|
||||
|
||||
static void balloon_clicked_cb(lv_event_t *e)
|
||||
{
|
||||
lv_obj_t *b = lv_event_get_target_obj(e);
|
||||
|
||||
play_pop();
|
||||
|
||||
// Stop the rise so the two animations don't fight over the same object.
|
||||
lv_anim_delete(b, rise_anim_cb);
|
||||
// Free the slot now so the spawner can reuse it during the short pop anim.
|
||||
balloon_release_slot(b);
|
||||
// No more taps while it pops.
|
||||
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
|
||||
// Scale around the centre so it grows in place.
|
||||
lv_obj_set_style_transform_pivot_x(b, BALLOON_W / 2, 0);
|
||||
lv_obj_set_style_transform_pivot_y(b, BALLOON_H / 2, 0);
|
||||
|
||||
// Grow.
|
||||
lv_anim_t grow;
|
||||
lv_anim_init(&grow);
|
||||
lv_anim_set_var(&grow, b);
|
||||
lv_anim_set_exec_cb(&grow, pop_scale_cb);
|
||||
lv_anim_set_values(&grow, LV_SCALE_NONE, LV_SCALE_NONE * 7 / 4); // 1.0 -> 1.75x
|
||||
lv_anim_set_duration(&grow, POP_MS);
|
||||
lv_anim_set_path_cb(&grow, lv_anim_path_ease_out);
|
||||
lv_anim_start(&grow);
|
||||
|
||||
// Fade out in parallel; this animation owns the delete.
|
||||
lv_anim_t fade;
|
||||
lv_anim_init(&fade);
|
||||
lv_anim_set_var(&fade, b);
|
||||
lv_anim_set_exec_cb(&fade, pop_opa_cb);
|
||||
lv_anim_set_user_data(&fade, b);
|
||||
lv_anim_set_values(&fade, LV_OPA_COVER, LV_OPA_TRANSP);
|
||||
lv_anim_set_duration(&fade, POP_MS);
|
||||
lv_anim_set_completed_cb(&fade, pop_done_cb);
|
||||
lv_anim_start(&fade);
|
||||
}
|
||||
|
||||
// Build one balloon (body + string) at a random x with a random colour, and
|
||||
// start its rise animation. Returns the balloon object, or NULL if full.
|
||||
static lv_obj_t *spawn_balloon(void)
|
||||
{
|
||||
int slot = -1;
|
||||
for (int i = 0; i < BALLOON_MAX; i++) {
|
||||
if (!s_balloons[i]) { slot = i; break; }
|
||||
}
|
||||
if (slot < 0) {
|
||||
return NULL; // already at capacity
|
||||
}
|
||||
|
||||
const balloon_pal_t *pal = &PALETTE[lv_rand(0, PALETTE_N - 1)];
|
||||
int x = (int)lv_rand(6, SCREEN_W - BALLOON_W - 6);
|
||||
|
||||
// Balloon body: rounded rectangle with a vertical gradient (light -> vivid).
|
||||
lv_obj_t *b = lv_obj_create(s_play_area);
|
||||
lv_obj_set_size(b, BALLOON_W, BALLOON_H);
|
||||
lv_obj_set_pos(b, x, Y_START);
|
||||
lv_obj_set_style_radius(b, BALLOON_W / 2, 0); // egg-ish rounded balloon
|
||||
lv_obj_set_style_bg_color(b, lv_color_hex(pal->top), 0);
|
||||
lv_obj_set_style_bg_grad_color(b, lv_color_hex(pal->bottom), 0);
|
||||
lv_obj_set_style_bg_grad_dir(b, LV_GRAD_DIR_VER, 0);
|
||||
lv_obj_set_style_border_width(b, 0, 0);
|
||||
lv_obj_set_style_shadow_width(b, 0, 0);
|
||||
lv_obj_set_style_pad_all(b, 0, 0);
|
||||
lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_flag(b, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_add_event_cb(b, balloon_clicked_cb, LV_EVENT_CLICKED, NULL);
|
||||
|
||||
// Little string hanging from the bottom of the balloon.
|
||||
lv_obj_t *string = lv_obj_create(b);
|
||||
lv_obj_set_size(string, 2, STRING_H);
|
||||
lv_obj_align(string, LV_ALIGN_BOTTOM_MID, 0, STRING_H);
|
||||
lv_obj_set_style_radius(string, 1, 0);
|
||||
lv_obj_set_style_bg_color(string, lv_color_hex(0x868E96), 0);
|
||||
lv_obj_set_style_border_width(string, 0, 0);
|
||||
lv_obj_remove_flag(string, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_remove_flag(string, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
s_balloons[slot] = b;
|
||||
|
||||
// Rise from the bottom to off the top, then self-delete.
|
||||
lv_anim_t a;
|
||||
lv_anim_init(&a);
|
||||
lv_anim_set_var(&a, b);
|
||||
lv_anim_set_exec_cb(&a, rise_anim_cb);
|
||||
lv_anim_set_user_data(&a, b);
|
||||
lv_anim_set_values(&a, Y_START, Y_END);
|
||||
lv_anim_set_duration(&a, (uint32_t)lv_rand(RISE_MIN_MS, RISE_MAX_MS));
|
||||
lv_anim_set_path_cb(&a, lv_anim_path_linear);
|
||||
lv_anim_set_completed_cb(&a, rise_done_cb);
|
||||
lv_anim_start(&a);
|
||||
|
||||
return b;
|
||||
}
|
||||
|
||||
// Periodic spawner: add a balloon and reschedule itself with a new random gap.
|
||||
static void spawn_timer_cb(lv_timer_t *t)
|
||||
{
|
||||
spawn_balloon();
|
||||
lv_timer_set_period(t, lv_rand(SPAWN_MIN_MS, SPAWN_MAX_MS));
|
||||
}
|
||||
|
||||
// Remove every live balloon (deleting its rise anim first) and stop spawning.
|
||||
static void teardown(void)
|
||||
{
|
||||
if (s_spawn_timer) {
|
||||
lv_timer_delete(s_spawn_timer);
|
||||
s_spawn_timer = NULL;
|
||||
}
|
||||
for (int i = 0; i < BALLOON_MAX; i++) {
|
||||
if (s_balloons[i]) {
|
||||
lv_anim_delete(s_balloons[i], rise_anim_cb);
|
||||
lv_anim_delete(s_balloons[i], pop_scale_cb);
|
||||
lv_anim_delete(s_balloons[i], pop_opa_cb);
|
||||
lv_obj_delete(s_balloons[i]);
|
||||
s_balloons[i] = NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void on_screen_event(lv_event_t *e)
|
||||
{
|
||||
lv_event_code_t code = lv_event_get_code(e);
|
||||
if (code == LV_EVENT_SCREEN_LOADED) {
|
||||
ESP_LOGI(TAG, "balloons started");
|
||||
lisael_audio_stop_all(); // silence radio / podcast / calme
|
||||
// A couple of balloons already on screen feels livelier than an empty
|
||||
// start, then let the timer keep them coming.
|
||||
spawn_balloon();
|
||||
spawn_balloon();
|
||||
if (!s_spawn_timer) {
|
||||
s_spawn_timer = lv_timer_create(spawn_timer_cb,
|
||||
lv_rand(SPAWN_MIN_MS, SPAWN_MAX_MS),
|
||||
NULL);
|
||||
}
|
||||
} else if (code == LV_EVENT_SCREEN_UNLOADED) {
|
||||
ESP_LOGI(TAG, "balloons stopped");
|
||||
teardown();
|
||||
lisael_audio_stop_all(); // make sure any pop tail is cut
|
||||
}
|
||||
}
|
||||
|
||||
lv_obj_t *lisael_screen_balloons(void)
|
||||
{
|
||||
if (s_root) {
|
||||
return s_root;
|
||||
}
|
||||
s_root = lv_obj_create(NULL);
|
||||
// Soft sky background so the vivid balloons stand out.
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE3FAFC), 0);
|
||||
lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFFFFF), 0);
|
||||
lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
|
||||
lv_obj_remove_flag(s_root, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_LOADED, NULL);
|
||||
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_UNLOADED, NULL);
|
||||
|
||||
lisael_ui_add_back_button(s_root);
|
||||
|
||||
lv_obj_t *title = lv_label_create(s_root);
|
||||
lv_label_set_text(title, "Éclate les ballons !");
|
||||
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
|
||||
|
||||
// Full-screen transparent play area that hosts (and clips nothing of) the
|
||||
// balloons. Keeping balloons in their own container makes teardown simple
|
||||
// and keeps the title/back-button untouched.
|
||||
s_play_area = lv_obj_create(s_root);
|
||||
lv_obj_remove_style_all(s_play_area);
|
||||
lv_obj_set_size(s_play_area, SCREEN_W, SCREEN_H);
|
||||
lv_obj_align(s_play_area, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
lv_obj_remove_flag(s_play_area, LV_OBJ_FLAG_SCROLLABLE);
|
||||
lv_obj_remove_flag(s_play_area, LV_OBJ_FLAG_CLICKABLE);
|
||||
lv_obj_move_to_index(s_play_area, 0); // behind the title + back button
|
||||
|
||||
return s_root;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// "Calme ta colère" balloon-popping game for Lisael Box.
|
||||
//
|
||||
// Colourful balloons drift up from the bottom of the screen; tapping one makes
|
||||
// it pop (grow + fade) with a short "pop" sound. A soothing way for a child to
|
||||
// let off steam. LVGL-only graphics (no images); the pop sound is synthesised
|
||||
// on a detached FreeRTOS task so the LVGL task is never blocked.
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Build (lazily, cached) the balloon game screen and return its root object.
|
||||
// Call while holding the display lock, like the other mode builders.
|
||||
lv_obj_t *lisael_screen_balloons(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+31
-7
@@ -7,7 +7,8 @@
|
||||
|
||||
#include "ui/ui.h"
|
||||
#include "ui/icons.h"
|
||||
#include "audio/sd_player.h"
|
||||
#include "ui/fonts/lisael_fonts.h"
|
||||
#include "audio/calm_tone.h"
|
||||
|
||||
#include "esp_log.h"
|
||||
|
||||
@@ -82,10 +83,12 @@ static void on_screen_event(lv_event_t *e)
|
||||
if (code == LV_EVENT_SCREEN_LOADED) {
|
||||
ESP_LOGI(TAG, "breathing started");
|
||||
start_inhale();
|
||||
// Best-effort soft ambience (optional file; ignored if absent).
|
||||
lisael_sd_play_sound("calme.mp3");
|
||||
// Soft 432 Hz tone with harmonics, breathing-synced (rises on inhale,
|
||||
// falls on exhale) — synthesised in real time, no SD file needed.
|
||||
lisael_calm_tone_start();
|
||||
} else if (code == LV_EVENT_SCREEN_UNLOADED) {
|
||||
lv_anim_delete(s_bubble, anim_size_cb);
|
||||
lisael_calm_tone_stop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,24 +98,45 @@ lv_obj_t *lisael_screen_calm(void)
|
||||
return s_root;
|
||||
}
|
||||
s_root = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xE7F5FF), 0); // calm blue
|
||||
// Calm vertical gradient (soft blue at the top fading to near-white).
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xBFE3FF), 0);
|
||||
lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xF2FAFF), 0);
|
||||
lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
|
||||
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_LOADED, NULL);
|
||||
lv_obj_add_event_cb(s_root, on_screen_event, LV_EVENT_SCREEN_UNLOADED, NULL);
|
||||
|
||||
lisael_ui_add_back_button(s_root);
|
||||
|
||||
// 3D glassy bubble: vertical gradient (light top -> blue bottom), a soft
|
||||
// cyan glow (shadow), and a small white specular highlight near the top.
|
||||
s_bubble = lv_obj_create(s_root);
|
||||
lv_obj_set_size(s_bubble, BUBBLE_MIN, BUBBLE_MIN);
|
||||
lv_obj_center(s_bubble);
|
||||
lv_obj_set_style_radius(s_bubble, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_color(s_bubble, lv_color_hex(0x74C0FC), 0);
|
||||
lv_obj_set_style_bg_opa(s_bubble, LV_OPA_70, 0);
|
||||
lv_obj_set_style_bg_color(s_bubble, lv_color_hex(0xD6F0FF), 0);
|
||||
lv_obj_set_style_bg_grad_color(s_bubble, lv_color_hex(0x4DA9F0), 0);
|
||||
lv_obj_set_style_bg_grad_dir(s_bubble, LV_GRAD_DIR_VER, 0);
|
||||
lv_obj_set_style_bg_opa(s_bubble, LV_OPA_90, 0);
|
||||
lv_obj_set_style_border_width(s_bubble, 0, 0);
|
||||
// NO blurred shadow here: it is recomputed every animation frame as the
|
||||
// bubble breathes (resizes), and the soft-shadow blur over a large circle
|
||||
// with the small draw buffer monopolised the LVGL task -> watchdog/freeze.
|
||||
// The vertical gradient + the white gloss highlight give enough depth.
|
||||
lv_obj_clear_flag(s_bubble, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
lv_obj_t *gloss = lv_obj_create(s_bubble);
|
||||
lv_obj_set_size(gloss, 26, 26);
|
||||
lv_obj_align(gloss, LV_ALIGN_TOP_LEFT, 14, 12);
|
||||
lv_obj_set_style_radius(gloss, LV_RADIUS_CIRCLE, 0);
|
||||
lv_obj_set_style_bg_color(gloss, lv_color_white(), 0);
|
||||
lv_obj_set_style_bg_opa(gloss, LV_OPA_60, 0);
|
||||
lv_obj_set_style_border_width(gloss, 0, 0);
|
||||
lv_obj_clear_flag(gloss, LV_OBJ_FLAG_SCROLLABLE);
|
||||
|
||||
s_phase_lbl = lv_label_create(s_root);
|
||||
lv_label_set_text(s_phase_lbl, "Inspire…");
|
||||
lv_obj_set_style_text_font(s_phase_lbl, &lv_font_montserrat_28, 0);
|
||||
lv_obj_set_style_text_font(s_phase_lbl, &lisael_font_30, 0);
|
||||
lv_obj_set_style_text_color(s_phase_lbl, lv_color_hex(0x2A5B86), 0);
|
||||
lv_obj_align(s_phase_lbl, LV_ALIGN_BOTTOM_MID, 0, -16);
|
||||
|
||||
return s_root;
|
||||
|
||||
+49
-26
@@ -13,6 +13,8 @@
|
||||
|
||||
#include "ui/ui.h"
|
||||
#include "ui/icons.h"
|
||||
#include "ui/assets/lisael_icons.h"
|
||||
#include "ui/fonts/lisael_fonts.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
@@ -34,10 +36,15 @@ static void show_bravo(lv_obj_t *parent)
|
||||
lv_obj_center(toast);
|
||||
lv_obj_set_style_bg_color(toast, lv_color_hex(0xFFF3BF), 0);
|
||||
lv_obj_set_style_radius(toast, 16, 0);
|
||||
lv_obj_set_flex_flow(toast, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(toast, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_column(toast, 8, 0);
|
||||
lv_obj_t *lbl = lv_label_create(toast);
|
||||
lv_label_set_text(lbl, "Bravo ! " LISAEL_EMOJI_STAR);
|
||||
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_28, 0);
|
||||
lv_obj_center(lbl);
|
||||
lv_label_set_text(lbl, "Bravo !");
|
||||
lv_obj_set_style_text_font(lbl, &lisael_font_24, 0);
|
||||
lv_obj_t *star = lv_image_create(toast);
|
||||
lv_image_set_src(star, &lisael_ic_star);
|
||||
// Auto-dismiss after 1.5 s.
|
||||
lv_obj_delete_delayed(toast, 1500);
|
||||
}
|
||||
@@ -48,10 +55,10 @@ static void show_bravo(lv_obj_t *parent)
|
||||
#define MEM_PAIRS 3
|
||||
#define MEM_CARDS (MEM_PAIRS * 2)
|
||||
|
||||
static const char *const MEM_FACES[MEM_PAIRS] = {
|
||||
"\U0001F431", // 🐱
|
||||
"\U0001F436", // 🐶
|
||||
"\U0001F438", // 🐸
|
||||
static const lv_image_dsc_t *const MEM_FACES[MEM_PAIRS] = {
|
||||
&lisael_ic_cat, // 🐱
|
||||
&lisael_ic_dog, // 🐶
|
||||
&lisael_ic_frog, // 🐸
|
||||
};
|
||||
|
||||
typedef struct {
|
||||
@@ -65,8 +72,13 @@ typedef struct {
|
||||
|
||||
static void mem_set_revealed(memory_state_t *st, int i, bool revealed)
|
||||
{
|
||||
lv_obj_t *lbl = lv_obj_get_child(st->cards[i], 0);
|
||||
lv_label_set_text(lbl, revealed ? MEM_FACES[st->face[i]] : "?");
|
||||
lv_obj_t *img = lv_obj_get_child(st->cards[i], 0);
|
||||
if (revealed) {
|
||||
lv_image_set_src(img, MEM_FACES[st->face[i]]);
|
||||
lv_obj_set_style_image_opa(img, LV_OPA_COVER, 0);
|
||||
} else {
|
||||
lv_obj_set_style_image_opa(img, LV_OPA_TRANSP, 0); // face down
|
||||
}
|
||||
lv_obj_set_style_bg_color(st->cards[i],
|
||||
revealed ? lv_color_hex(0xB2F2BB) : lv_color_hex(0x99C8FF), 0);
|
||||
}
|
||||
@@ -147,10 +159,8 @@ static void mem_build(lv_obj_t *parent)
|
||||
lv_obj_set_size(card, 90, 80);
|
||||
lv_obj_set_style_radius(card, 12, 0);
|
||||
lv_obj_add_event_cb(card, mem_card_cb, LV_EVENT_CLICKED, st);
|
||||
lv_obj_t *lbl = lv_label_create(card);
|
||||
lv_label_set_text(lbl, "?");
|
||||
lv_obj_set_style_text_font(lbl, &lv_font_montserrat_28, 0);
|
||||
lv_obj_center(lbl);
|
||||
lv_obj_t *img = lv_image_create(card);
|
||||
lv_obj_center(img);
|
||||
st->cards[i] = card;
|
||||
mem_set_revealed(st, i, false);
|
||||
}
|
||||
@@ -192,9 +202,8 @@ static void count_new_round(count_state_t *st)
|
||||
// Render the objects.
|
||||
lv_obj_clean(st->objects_box);
|
||||
for (int i = 0; i < st->answer; i++) {
|
||||
lv_obj_t *o = lv_label_create(st->objects_box);
|
||||
lv_label_set_text(o, "\U0001F34E"); // 🍎
|
||||
lv_obj_set_style_text_font(o, &lv_font_montserrat_28, 0);
|
||||
lv_obj_t *o = lv_image_create(st->objects_box); // 🍎 apple icon
|
||||
lv_image_set_src(o, &lisael_ic_apple);
|
||||
}
|
||||
|
||||
// Offer the right answer + two distractors in a shuffled set.
|
||||
@@ -294,9 +303,16 @@ static void open_game(game_builder_t builder, const char *title)
|
||||
ESP_LOGI(TAG, "game open: %s", title);
|
||||
}
|
||||
|
||||
static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, LISAEL_EMOJI_GAMES " Memory"); }
|
||||
static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "🔢 Compter"); }
|
||||
static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "🎨 Coloriage"); }
|
||||
static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, "Memory"); }
|
||||
static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "Compter"); }
|
||||
static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "Coloriage"); }
|
||||
static void balloons_cb(lv_event_t *e)
|
||||
{
|
||||
(void)e;
|
||||
// The balloons screen is self-contained (own back button to HOME).
|
||||
lv_screen_load_anim(lisael_screen_balloons(), LV_SCR_LOAD_ANIM_MOVE_LEFT,
|
||||
250, 0, false);
|
||||
}
|
||||
|
||||
lv_obj_t *lisael_screen_games(void)
|
||||
{
|
||||
@@ -309,7 +325,8 @@ lv_obj_t *lisael_screen_games(void)
|
||||
lisael_ui_add_back_button(s_menu_root);
|
||||
|
||||
lv_obj_t *title = lv_label_create(s_menu_root);
|
||||
lv_label_set_text(title, LISAEL_EMOJI_GAMES " Jeux");
|
||||
lv_label_set_text(title, "Jeux");
|
||||
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
|
||||
|
||||
lv_obj_t *list = lv_obj_create(s_menu_root);
|
||||
@@ -321,19 +338,25 @@ lv_obj_t *lisael_screen_games(void)
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(list, 10, 0);
|
||||
|
||||
struct { const char *txt; lv_event_cb_t cb; } games[] = {
|
||||
{ LISAEL_EMOJI_GAMES " Memory", memory_cb },
|
||||
{ "🔢 Compter", count_cb },
|
||||
{ "🎨 Coloriage (bientôt)", color_cb },
|
||||
struct { const char *txt; const lv_image_dsc_t *icon; lv_event_cb_t cb; } games[] = {
|
||||
{ "Memory", &lisael_ic_cat, memory_cb },
|
||||
{ "Compter", &lisael_ic_apple, count_cb },
|
||||
{ "Calme ta colère", &lisael_ic_balloon, balloons_cb },
|
||||
{ "Coloriage", &lisael_ic_palette, color_cb },
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(games) / sizeof(games[0]); i++) {
|
||||
lv_obj_t *b = lv_button_create(list);
|
||||
lv_obj_add_style(b, lisael_ui_style_button(), 0);
|
||||
lv_obj_set_width(b, LV_PCT(80));
|
||||
lv_obj_set_width(b, LV_PCT(82));
|
||||
lv_obj_set_flex_flow(b, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(b, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER,
|
||||
LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_column(b, 12, 0);
|
||||
lv_obj_add_event_cb(b, games[i].cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_t *ic = lv_image_create(b);
|
||||
lv_image_set_src(ic, games[i].icon);
|
||||
lv_obj_t *l = lv_label_create(b);
|
||||
lv_label_set_text(l, games[i].txt);
|
||||
lv_obj_center(l);
|
||||
}
|
||||
return s_menu_root;
|
||||
}
|
||||
|
||||
+75
-58
@@ -1,122 +1,139 @@
|
||||
// Webradio mode screen for Lisael Box.
|
||||
//
|
||||
// A scrollable column of big station buttons + a bottom bar with volume down /
|
||||
// stop / volume up. Tapping a station starts the ESP-ADF http_stream pipeline.
|
||||
// A soft-gradient screen with a "← Accueil" back button, a "Radio" title, a
|
||||
// column of big station buttons (one tap = lisael_radio_play(url)), and a Stop
|
||||
// button (lisael_radio_stop()). A small status label echoes the current action.
|
||||
//
|
||||
// The station table below is local on purpose (radio_pipeline.h is the only
|
||||
// audio contract we depend on). Each entry is a live HTTP/HTTPS MP3 stream.
|
||||
//
|
||||
// ⚠️ STREAM URLs — VERIFY ON HARDWARE BEFORE SHIPPING ⚠️
|
||||
// French kids' webradios are mostly hosted on RadioKing / Zeno.FM, whose direct
|
||||
// stream endpoints are loaded dynamically by their JS players and are not
|
||||
// publicly documented in a stable text form. The URLs below use each platform's
|
||||
// documented direct-play form:
|
||||
// - RadioKing: https://www.radioking.com/play/<slug> 302-redirects to the
|
||||
// live listen.radioking.com/<id> MP3 mount (our HTTP client follows 3xx).
|
||||
// - Zeno.FM: https://stream.zeno.fm/<mount> is a direct MP3 stream.
|
||||
// Confirm with `curl -IL <url>` that the final response is audio/mpeg. If a
|
||||
// station's slug/mount changed, update it here. All are HTTPS (handled by the
|
||||
// certificate bundle in radio_pipeline.c).
|
||||
|
||||
#include "ui/ui.h"
|
||||
#include "ui/icons.h"
|
||||
#include "modes/radios.h"
|
||||
#include "ui/fonts/lisael_fonts.h"
|
||||
#include "audio/radio_pipeline.h"
|
||||
#include "audio/audio_player.h"
|
||||
#include "lisael_config.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stddef.h>
|
||||
#include "esp_log.h"
|
||||
|
||||
static const char *TAG = "ui.radio";
|
||||
|
||||
// --- Station table (name + live MP3 stream URL) -----------------------------
|
||||
typedef struct {
|
||||
const char *name;
|
||||
const char *url;
|
||||
} radio_station_t;
|
||||
|
||||
// DIRECT MP3 stream mounts (content-type audio/mpeg), verified 2026-06-14 from
|
||||
// the RadioKing players. The /play/<slug> pages are HTML — these are the real
|
||||
// listen.radioking.com mounts. If a stream changes, re-read it from
|
||||
// play.radioking.io/<slug> (grep listen.radioking.com/radio/<id>/stream/<id>).
|
||||
static const radio_station_t k_stations[] = {
|
||||
// Radio Doudou — comptines & berceuses pour les tout-petits.
|
||||
{ "Radio Doudou", "https://listen.radioking.com/radio/11565/stream/22961" },
|
||||
// Radio Pomme d'Api (Bayard) — la radio des 3-7 ans.
|
||||
{ "Pomme d'Api", "https://listen.radioking.com/radio/361753/stream/411352" },
|
||||
};
|
||||
|
||||
#define K_STATIONS_COUNT (sizeof(k_stations) / sizeof(k_stations[0]))
|
||||
|
||||
static lv_obj_t *s_root;
|
||||
static lv_obj_t *s_status_lbl;
|
||||
static lv_obj_t *s_vol_lbl;
|
||||
|
||||
static void refresh_volume_label(void)
|
||||
static void set_status(const char *txt)
|
||||
{
|
||||
if (s_vol_lbl) {
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%d%%", lisael_audio_get_volume());
|
||||
lv_label_set_text(s_vol_lbl, buf);
|
||||
if (s_status_lbl) {
|
||||
lv_label_set_text(s_status_lbl, txt);
|
||||
}
|
||||
}
|
||||
|
||||
static void station_cb(lv_event_t *e)
|
||||
{
|
||||
const lisael_radio_station_t *st = lv_event_get_user_data(e);
|
||||
const radio_station_t *st = (const radio_station_t *)lv_event_get_user_data(e);
|
||||
ESP_LOGI(TAG, "station: %s", st->name);
|
||||
esp_err_t err = lisael_radio_play(st->url);
|
||||
if (s_status_lbl) {
|
||||
lv_label_set_text_fmt(s_status_lbl, "%s %s",
|
||||
err == ESP_OK ? "Écoute :" : "Erreur :", st->name);
|
||||
err == ESP_OK ? "Écoute :" : "Oups :", st->name);
|
||||
}
|
||||
}
|
||||
|
||||
static void stop_cb(lv_event_t *e)
|
||||
{
|
||||
(void)e;
|
||||
lisael_audio_stop_all();
|
||||
if (s_status_lbl) {
|
||||
lv_label_set_text(s_status_lbl, "Arrêté");
|
||||
}
|
||||
lisael_radio_stop();
|
||||
set_status("Silence");
|
||||
}
|
||||
|
||||
static void vol_up_cb(lv_event_t *e) { (void)e; lisael_audio_volume_step(+5); refresh_volume_label(); }
|
||||
static void vol_down_cb(lv_event_t *e) { (void)e; lisael_audio_volume_step(-5); refresh_volume_label(); }
|
||||
|
||||
lv_obj_t *lisael_screen_radio(void)
|
||||
{
|
||||
if (s_root) {
|
||||
refresh_volume_label();
|
||||
return s_root;
|
||||
}
|
||||
s_root = lv_obj_create(NULL);
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFF4E6), 0);
|
||||
|
||||
// Soft warm vertical gradient (peach at the top fading to near-white), in
|
||||
// the same gentle spirit as the calm screen.
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(0xFFE3C2), 0);
|
||||
lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFF7EE), 0);
|
||||
lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0);
|
||||
|
||||
lisael_ui_add_back_button(s_root);
|
||||
|
||||
// Title in the kid-friendly Fredoka 24 px font.
|
||||
lv_obj_t *title = lv_label_create(s_root);
|
||||
lv_label_set_text(title, LISAEL_EMOJI_RADIO " Radios");
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
|
||||
lv_label_set_text(title, "Radio");
|
||||
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
|
||||
lv_obj_set_style_text_color(title, lv_color_hex(0x8A4B16), 0);
|
||||
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 14);
|
||||
|
||||
// Station list (scrollable column).
|
||||
// Scrollable column of big station buttons.
|
||||
lv_obj_t *list = lv_obj_create(s_root);
|
||||
lv_obj_remove_style_all(list);
|
||||
lv_obj_set_size(list, LV_PCT(100), 130);
|
||||
lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 66);
|
||||
lv_obj_set_size(list, LV_PCT(100), 132);
|
||||
lv_obj_align(list, LV_ALIGN_TOP_MID, 0, 58);
|
||||
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_flex_align(list, LV_FLEX_ALIGN_START,
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
lv_obj_set_style_pad_row(list, 8, 0);
|
||||
|
||||
for (size_t i = 0; i < LISAEL_RADIOS_COUNT; i++) {
|
||||
for (size_t i = 0; i < K_STATIONS_COUNT; i++) {
|
||||
lv_obj_t *btn = lv_button_create(list);
|
||||
lv_obj_add_style(btn, lisael_ui_style_button(), 0);
|
||||
lv_obj_set_width(btn, LV_PCT(92));
|
||||
lv_obj_add_event_cb(btn, station_cb, LV_EVENT_CLICKED,
|
||||
(void *)&LISAEL_RADIOS[i]);
|
||||
(void *)&k_stations[i]);
|
||||
lv_obj_t *lbl = lv_label_create(btn);
|
||||
lv_label_set_text_fmt(lbl, "%s %s",
|
||||
LISAEL_RADIOS[i].emoji, LISAEL_RADIOS[i].name);
|
||||
lv_label_set_text(lbl, k_stations[i].name);
|
||||
lv_obj_center(lbl);
|
||||
}
|
||||
|
||||
// Bottom control bar: vol-, stop, vol+, and a volume readout.
|
||||
lv_obj_t *bar = lv_obj_create(s_root);
|
||||
lv_obj_remove_style_all(bar);
|
||||
lv_obj_set_size(bar, LV_PCT(100), 60);
|
||||
lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -2);
|
||||
lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
|
||||
lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY,
|
||||
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
||||
|
||||
struct { const char *txt; lv_event_cb_t cb; } ctrls[] = {
|
||||
{ LV_SYMBOL_MINUS, vol_down_cb },
|
||||
{ LV_SYMBOL_STOP, stop_cb },
|
||||
{ LV_SYMBOL_PLUS, vol_up_cb },
|
||||
};
|
||||
for (size_t i = 0; i < 3; i++) {
|
||||
lv_obj_t *b = lv_button_create(bar);
|
||||
lv_obj_add_style(b, lisael_ui_style_button(), 0);
|
||||
lv_obj_set_size(b, 64, 50);
|
||||
lv_obj_add_event_cb(b, ctrls[i].cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_t *l = lv_label_create(b);
|
||||
lv_label_set_text(l, ctrls[i].txt);
|
||||
lv_obj_center(l);
|
||||
}
|
||||
s_vol_lbl = lv_label_create(bar);
|
||||
refresh_volume_label();
|
||||
// Bottom Stop button.
|
||||
lv_obj_t *stop = lv_button_create(s_root);
|
||||
lv_obj_add_style(stop, lisael_ui_style_button(), 0);
|
||||
lv_obj_set_size(stop, 120, 50);
|
||||
lv_obj_align(stop, LV_ALIGN_BOTTOM_MID, 0, -8);
|
||||
lv_obj_add_event_cb(stop, stop_cb, LV_EVENT_CLICKED, NULL);
|
||||
lv_obj_t *stop_lbl = lv_label_create(stop);
|
||||
lv_label_set_text(stop_lbl, LV_SYMBOL_STOP " Stop");
|
||||
lv_obj_center(stop_lbl);
|
||||
|
||||
// Status line just above the Stop button.
|
||||
s_status_lbl = lv_label_create(s_root);
|
||||
lv_obj_set_style_text_color(s_status_lbl, lv_color_hex(0x8A4B16), 0);
|
||||
lv_label_set_text(s_status_lbl, "");
|
||||
lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -66);
|
||||
lv_obj_align(s_status_lbl, LV_ALIGN_BOTTOM_MID, 0, -64);
|
||||
|
||||
return s_root;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// Radio France youth podcasts — see podcast.h.
|
||||
|
||||
#include "net/podcast.h"
|
||||
#include "audio/sd_player.h" // BSP_SD_MOUNT_POINT via sd mount; reuse mount
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <dirent.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_crt_bundle.h"
|
||||
|
||||
static const char *TAG = "podcast";
|
||||
|
||||
#define PODCAST_DIR "/sdcard/podcasts"
|
||||
|
||||
typedef struct {
|
||||
const char *id;
|
||||
const char *title;
|
||||
const char *rss_url;
|
||||
} podcast_show_t;
|
||||
|
||||
// Verified 2026-06-14: first <enclosure> in each feed is the latest episode,
|
||||
// audio/x-m4a (AAC in MP4).
|
||||
static const podcast_show_t s_shows[] = {
|
||||
{ "oli", "Une histoire et Oli",
|
||||
"https://radiofrance-podcast.net/podcast09/rss_19721.xml" },
|
||||
{ "bestioles", "Bestioles",
|
||||
"https://radiofrance-podcast.net/podcast09/35099478-7c72-4f9e-a6de-1b928400e9e5/"
|
||||
"podcast_a80ecbd5-df3d-4c9d-bee7-4e3d9efc1974.xml" },
|
||||
};
|
||||
|
||||
int lisael_podcast_count(void)
|
||||
{
|
||||
return (int)(sizeof(s_shows) / sizeof(s_shows[0]));
|
||||
}
|
||||
|
||||
const char *lisael_podcast_title(int idx)
|
||||
{
|
||||
return (idx >= 0 && idx < lisael_podcast_count()) ? s_shows[idx].title : "";
|
||||
}
|
||||
|
||||
const char *lisael_podcast_id(int idx)
|
||||
{
|
||||
return (idx >= 0 && idx < lisael_podcast_count()) ? s_shows[idx].id : "";
|
||||
}
|
||||
|
||||
void lisael_podcast_local_path(int idx, char *buf, size_t n)
|
||||
{
|
||||
snprintf(buf, n, "%s/%s.m4a", PODCAST_DIR, lisael_podcast_id(idx));
|
||||
}
|
||||
|
||||
bool lisael_podcast_have_local(int idx)
|
||||
{
|
||||
char path[96];
|
||||
lisael_podcast_local_path(idx, path, sizeof(path));
|
||||
struct stat st;
|
||||
return (stat(path, &st) == 0 && st.st_size > 0);
|
||||
}
|
||||
|
||||
// --- RSS: stream the feed and grab the first <enclosure url="..."> ----------
|
||||
|
||||
typedef struct {
|
||||
char buf[49152]; // accumulator (heap/PSRAM): some feeds (Bestioles) have a
|
||||
// large channel header before the first <enclosure>
|
||||
int len;
|
||||
bool found;
|
||||
char url[512];
|
||||
} rss_accum_t;
|
||||
|
||||
static void rss_try_extract(rss_accum_t *a)
|
||||
{
|
||||
char *enc = strstr(a->buf, "<enclosure");
|
||||
if (!enc) {
|
||||
return;
|
||||
}
|
||||
char *u = strstr(enc, "url=\"");
|
||||
if (!u) {
|
||||
return;
|
||||
}
|
||||
u += 5;
|
||||
char *end = strchr(u, '"');
|
||||
if (!end) {
|
||||
return; // URL not fully received yet
|
||||
}
|
||||
size_t n = (size_t)(end - u);
|
||||
if (n >= sizeof(a->url)) {
|
||||
n = sizeof(a->url) - 1;
|
||||
}
|
||||
memcpy(a->url, u, n);
|
||||
a->url[n] = '\0';
|
||||
a->found = true;
|
||||
}
|
||||
|
||||
static esp_err_t rss_event(esp_http_client_event_t *evt)
|
||||
{
|
||||
if (evt->event_id != HTTP_EVENT_ON_DATA) {
|
||||
return ESP_OK;
|
||||
}
|
||||
rss_accum_t *a = (rss_accum_t *)evt->user_data;
|
||||
if (a->found) {
|
||||
return ESP_FAIL; // abort the transfer early — we have what we need
|
||||
}
|
||||
int space = (int)sizeof(a->buf) - 1 - a->len;
|
||||
if (space > 0) {
|
||||
int cp = evt->data_len < space ? evt->data_len : space;
|
||||
memcpy(a->buf + a->len, evt->data, cp);
|
||||
a->len += cp;
|
||||
a->buf[a->len] = '\0';
|
||||
rss_try_extract(a);
|
||||
}
|
||||
if (a->found || space <= 0) {
|
||||
return ESP_FAIL; // stop: found, or our window is full
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t lisael_podcast_latest_url(int idx, char *url_out, size_t url_sz)
|
||||
{
|
||||
if (idx < 0 || idx >= lisael_podcast_count() || !url_out) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
// Heap (PSRAM) — 8 KB, must not sit in internal BSS (would starve Wi-Fi).
|
||||
rss_accum_t *acc = calloc(1, sizeof(*acc));
|
||||
if (!acc) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = s_shows[idx].rss_url,
|
||||
.event_handler = rss_event,
|
||||
.user_data = acc,
|
||||
.timeout_ms = 15000,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
.buffer_size = 2048,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
if (!client) {
|
||||
free(acc);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
esp_http_client_perform(client); // may "fail" because we abort early
|
||||
esp_http_client_cleanup(client);
|
||||
|
||||
esp_err_t ret = ESP_FAIL;
|
||||
if (acc->found) {
|
||||
strlcpy(url_out, acc->url, url_sz);
|
||||
ESP_LOGI(TAG, "[%s] latest: %s", s_shows[idx].id, url_out);
|
||||
ret = ESP_OK;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "[%s] no enclosure found in RSS", s_shows[idx].id);
|
||||
}
|
||||
free(acc);
|
||||
return ret;
|
||||
}
|
||||
|
||||
// --- download the enclosure to SD -------------------------------------------
|
||||
|
||||
typedef struct {
|
||||
FILE *f;
|
||||
int written;
|
||||
char redirect[600]; // Location header captured from the 3xx response
|
||||
} dl_accum_t;
|
||||
|
||||
static esp_err_t dl_event(esp_http_client_event_t *evt)
|
||||
{
|
||||
dl_accum_t *d = (dl_accum_t *)evt->user_data;
|
||||
if (evt->event_id == HTTP_EVENT_ON_HEADER) {
|
||||
// Response headers arrive here (get_header() only reads *request*
|
||||
// headers). Capture Location so we can follow the redirect manually.
|
||||
if (evt->header_key && strcasecmp(evt->header_key, "Location") == 0) {
|
||||
strlcpy(d->redirect, evt->header_value, sizeof(d->redirect));
|
||||
}
|
||||
} else if (evt->event_id == HTTP_EVENT_ON_DATA) {
|
||||
if (d->f && evt->data_len > 0) {
|
||||
d->written += (int)fwrite(evt->data, 1, evt->data_len, d->f);
|
||||
}
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
static void read_sidecar(const char *path, char *out, size_t n)
|
||||
{
|
||||
out[0] = '\0';
|
||||
char side[160];
|
||||
snprintf(side, sizeof(side), "%s.url", path);
|
||||
FILE *f = fopen(side, "r");
|
||||
if (f) {
|
||||
size_t r = fread(out, 1, n - 1, f);
|
||||
out[r] = '\0';
|
||||
// trim trailing newline
|
||||
char *nl = strchr(out, '\n');
|
||||
if (nl) *nl = '\0';
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
static void write_sidecar(const char *path, const char *url)
|
||||
{
|
||||
char side[160];
|
||||
snprintf(side, sizeof(side), "%s.url", path);
|
||||
FILE *f = fopen(side, "w");
|
||||
if (f) {
|
||||
fputs(url, f);
|
||||
fclose(f);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t lisael_podcast_sync(int idx)
|
||||
{
|
||||
if (idx < 0 || idx >= lisael_podcast_count()) {
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
if (lisael_sd_mount() != ESP_OK) {
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
char url[512];
|
||||
if (lisael_podcast_latest_url(idx, url, sizeof(url)) != ESP_OK) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
char path[96];
|
||||
lisael_podcast_local_path(idx, path, sizeof(path));
|
||||
|
||||
// Already have this exact episode? Skip the (large) re-download.
|
||||
char stored[512];
|
||||
read_sidecar(path, stored, sizeof(stored));
|
||||
if (lisael_podcast_have_local(idx) && strcmp(stored, url) == 0) {
|
||||
ESP_LOGI(TAG, "[%s] up to date", s_shows[idx].id);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
mkdir(PODCAST_DIR, 0777);
|
||||
FILE *f = fopen(path, "wb");
|
||||
if (!f) {
|
||||
ESP_LOGE(TAG, "[%s] cannot open %s for write", s_shows[idx].id, path);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
ESP_LOGI(TAG, "[%s] downloading...", s_shows[idx].id);
|
||||
|
||||
// proxycast.radiofrance.fr issues a 302 to media.radiofrance-podcast.net.
|
||||
// Follow redirects with a FRESH client per hop (reusing one handle across a
|
||||
// host change leaves it in a bad state); 3xx bodies are empty so the file
|
||||
// only fills on the final 200.
|
||||
dl_accum_t d = { .f = f, .written = 0 };
|
||||
char cur[600];
|
||||
strlcpy(cur, url, sizeof(cur));
|
||||
esp_err_t err = ESP_FAIL;
|
||||
int status = 0;
|
||||
for (int hop = 0; hop < 5; hop++) {
|
||||
d.redirect[0] = '\0';
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = cur,
|
||||
.event_handler = dl_event,
|
||||
.user_data = &d,
|
||||
.timeout_ms = 30000,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach,
|
||||
.buffer_size = 8192, // media.radiofrance sends large CDN
|
||||
.buffer_size_tx = 2048, // headers + long signed URLs
|
||||
.disable_auto_redirect = true,
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&cfg);
|
||||
err = esp_http_client_perform(client);
|
||||
status = esp_http_client_get_status_code(client);
|
||||
esp_http_client_cleanup(client);
|
||||
if ((status == 301 || status == 302 || status == 303 ||
|
||||
status == 307 || status == 308) && d.redirect[0]) {
|
||||
ESP_LOGI(TAG, "[%s] -> redirect to %.60s...", s_shows[idx].id, d.redirect);
|
||||
strlcpy(cur, d.redirect, sizeof(cur));
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
if (err == ESP_OK && status == 200 && d.written > 0) {
|
||||
write_sidecar(path, url);
|
||||
ESP_LOGI(TAG, "[%s] downloaded %d bytes -> %s", s_shows[idx].id, d.written, path);
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_LOGE(TAG, "[%s] download failed err=%s status=%d written=%d",
|
||||
s_shows[idx].id, esp_err_to_name(err), status, d.written);
|
||||
remove(path);
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
// --- list downloaded episodes on the SD (for the "Sons" browser) ------------
|
||||
|
||||
int lisael_podcast_list_local(char paths[][128], char labels[][48], int max)
|
||||
{
|
||||
DIR *d = opendir(PODCAST_DIR);
|
||||
if (!d) {
|
||||
return 0;
|
||||
}
|
||||
int n = 0;
|
||||
struct dirent *e;
|
||||
while ((e = readdir(d)) != NULL && n < max) {
|
||||
const char *name = e->d_name;
|
||||
size_t len = strlen(name);
|
||||
if (len < 5 || strcmp(name + len - 4, ".m4a") != 0) {
|
||||
continue; // only .m4a episode files
|
||||
}
|
||||
snprintf(paths[n], 128, "%s/%s", PODCAST_DIR, name);
|
||||
|
||||
// Base name without extension; map a show id to its nice title.
|
||||
char base[48];
|
||||
size_t bl = len - 4;
|
||||
if (bl >= sizeof(base)) {
|
||||
bl = sizeof(base) - 1;
|
||||
}
|
||||
memcpy(base, name, bl);
|
||||
base[bl] = '\0';
|
||||
|
||||
const char *label = base;
|
||||
for (int i = 0; i < lisael_podcast_count(); i++) {
|
||||
if (strcmp(base, lisael_podcast_id(i)) == 0) {
|
||||
label = lisael_podcast_title(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
strlcpy(labels[n], label, 48);
|
||||
n++;
|
||||
}
|
||||
closedir(d);
|
||||
ESP_LOGI(TAG, "local episodes: %d", n);
|
||||
return n;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Radio France youth podcasts for Lisael Box.
|
||||
//
|
||||
// Fetches a show's RSS feed over HTTPS, extracts the latest episode's audio
|
||||
// enclosure (AAC/.m4a) and downloads it to the microSD card, where the AAC
|
||||
// player (aac_player.c) plays it offline. Hybrid model: a background prefetch
|
||||
// keeps the latest episode local; the Podcasts screen plays the local file.
|
||||
#pragma once
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdbool.h>
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Number of configured shows, and per-show accessors.
|
||||
int lisael_podcast_count(void);
|
||||
const char *lisael_podcast_title(int idx);
|
||||
const char *lisael_podcast_id(int idx);
|
||||
|
||||
// Absolute SD path of a show's downloaded episode ("/sdcard/podcasts/<id>.m4a").
|
||||
void lisael_podcast_local_path(int idx, char *buf, size_t n);
|
||||
|
||||
// True if a downloaded episode exists locally for this show.
|
||||
bool lisael_podcast_have_local(int idx);
|
||||
|
||||
// Fetch the RSS feed and write the latest episode's enclosure URL into url_out.
|
||||
esp_err_t lisael_podcast_latest_url(int idx, char *url_out, size_t url_sz);
|
||||
|
||||
// Ensure the latest episode is downloaded to SD. Fetches the RSS, and if the
|
||||
// latest enclosure differs from what's stored (or no local file), downloads it.
|
||||
// Returns ESP_OK when a local file is ready to play.
|
||||
esp_err_t lisael_podcast_sync(int idx);
|
||||
|
||||
// List the downloaded episode files on the SD card (/sdcard/podcasts/*.m4a).
|
||||
// Fills paths[i] (absolute) and labels[i] (human title). Returns the count
|
||||
// (<= max). Used by the "Sons" screen to browse/play downloaded episodes.
|
||||
int lisael_podcast_list_local(char paths[][128], char labels[][48], int max);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,22 @@
|
||||
// Lightweight UDP reporter: send debug lines over the LAN (the BOX-3
|
||||
// USB-Serial-JTAG console stops flushing after boot). Called directly from
|
||||
// tasks (NOT a log vprintf hook — that deadlocks: the log mutex is held while
|
||||
// sendto -> lwip might re-enter the logger). Capture on a host on the same LAN
|
||||
// with `nc -ulk 9999` or a UDP listener.
|
||||
#pragma once
|
||||
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Open the broadcast socket. Call once after Wi-Fi has an IP.
|
||||
void lisael_udp_log_start(uint16_t port);
|
||||
|
||||
// Send one line (printf-style) as a UDP broadcast datagram. Non-blocking.
|
||||
void lisael_udp_send(const char *fmt, ...);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+5
-2
@@ -110,8 +110,12 @@ static esp_err_t http_event(esp_http_client_event_t *evt)
|
||||
static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out)
|
||||
{
|
||||
char url[256];
|
||||
// Plain HTTP on purpose: weather is public, non-sensitive data, and the TLS
|
||||
// handshake's transient RAM otherwise starves the Wi-Fi RX DMA buffers
|
||||
// ("wifi:mem fail") now that the LVGL draw buffer lives in internal RAM.
|
||||
// HTTP avoids mbedtls entirely so the fetch is reliable.
|
||||
snprintf(url, sizeof(url),
|
||||
"https://api.open-meteo.com/v1/forecast"
|
||||
"http://api.open-meteo.com/v1/forecast"
|
||||
"?latitude=%s&longitude=%s¤t=temperature_2m,weather_code",
|
||||
lat, lon);
|
||||
|
||||
@@ -126,7 +130,6 @@ static bool fetch_once(const char *lat, const char *lon, lisael_weather_t *out)
|
||||
.event_handler = http_event,
|
||||
.user_data = &acc,
|
||||
.timeout_ms = 8000,
|
||||
.crt_bundle_attach = esp_crt_bundle_attach, // uses the CA bundle
|
||||
};
|
||||
esp_http_client_handle_t client = esp_http_client_init(&config);
|
||||
bool ok = false;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Fredoka (OFL) converted to LVGL fonts with French accents (Latin-1 range
|
||||
// 0xA0-0xFF: é è à ç ù â ê î ô û ë ï ü ...). Rounded, kid-friendly. Generated
|
||||
// with lv_font_conv from tools — see ui/fonts/CREDITS.md.
|
||||
#pragma once
|
||||
|
||||
#include "lvgl.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
extern const lv_font_t lisael_font_18; // body / tiles
|
||||
extern const lv_font_t lisael_font_24; // sub-headers
|
||||
extern const lv_font_t lisael_font_30; // clock / big
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
+23
-3
@@ -1,5 +1,25 @@
|
||||
// Icon helpers translation unit — currently header-only (macros in icons.h).
|
||||
// Kept as a compiled unit so future icon image descriptors / converted emoji
|
||||
// fonts have a home and the build file list stays stable.
|
||||
// Icon helpers for the Lisael Box UI.
|
||||
//
|
||||
// The home tiles and weather use real colour icons (Twemoji PNGs converted to
|
||||
// LVGL RGB565A8 image descriptors, in ui/assets/). This file maps open-meteo
|
||||
// WMO weather codes to the matching icon.
|
||||
|
||||
#include "ui/icons.h"
|
||||
#include "ui/assets/lisael_icons.h"
|
||||
|
||||
const lv_image_dsc_t *lisael_weather_code_icon(int code)
|
||||
{
|
||||
switch (code) {
|
||||
case 0: return &lisael_ic_wsun; // clear
|
||||
case 1: case 2: case 3: return &lisael_ic_wpartly; // partly cloudy
|
||||
case 45: case 48: return &lisael_ic_wfog; // fog
|
||||
case 51: case 53: case 55: return &lisael_ic_wdrizzle; // drizzle
|
||||
case 61: case 63: case 65: return &lisael_ic_wrain; // rain
|
||||
case 66: case 67: return &lisael_ic_wsleet; // freezing rain
|
||||
case 71: case 73: case 75:
|
||||
case 77: case 85: case 86: return &lisael_ic_wsnow; // snow
|
||||
case 80: case 81: case 82: return &lisael_ic_wrain; // showers
|
||||
case 95: case 96: case 99: return &lisael_ic_wthunder; // thunderstorm
|
||||
default: return &lisael_ic_wunknown;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,3 +26,15 @@ CONFIG_ESP_TLS_INSECURE=n
|
||||
# --- esp-box BSP -------------------------------------------------------------
|
||||
# SD card mount point used throughout the firmware (see audio/sd_player.c).
|
||||
CONFIG_BSP_SD_MOUNT_POINT="/sdcard"
|
||||
|
||||
# Polices LVGL utilisées par l'UI Lisael Box (titres date/heure, gros boutons)
|
||||
CONFIG_LV_FONT_MONTSERRAT_28=y
|
||||
CONFIG_LV_FONT_MONTSERRAT_48=y
|
||||
|
||||
# GCC 14 (esp 14.2) ajoute des warnings -Werror agressifs (array-bounds faux positifs)
|
||||
CONFIG_COMPILER_DISABLE_GCC14_WARNINGS=y
|
||||
|
||||
# Diagnostic : console primaire sur USB-Serial-JTAG (logs boot+app sur l'USB-C),
|
||||
# car l'UART0 par défaut n'est pas exposé sur le port USB de la carte.
|
||||
CONFIG_ESP_CONSOLE_UART_DEFAULT=n
|
||||
CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y
|
||||
|
||||
Reference in New Issue
Block a user