Files
lisael-box/main/audio/calm_tone.c
T
Clément Saillant e35058f53d 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.
2026-06-15 00:20:47 +02:00

146 lines
4.8 KiB
C

// 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;
}