Thomas Vikström
Published © GPL3+

Visualize Brain Data on an Elecrow Display

This project shows how you can visualize EEG-data from your brain on an Elecrow display

BeginnerFull instructions provided2 hours12
Visualize Brain Data on an Elecrow Display

Things used in this project

Hardware components

Elecrow Crowpanel 7.0" HMI Display 800x480
×1
Muse
Muse
×1

Software apps and online services

MindMonitor
Visual Studio Code Extension for Arduino
Microsoft Visual Studio Code Extension for Arduino
PlatformIO IDE
PlatformIO IDE

Story

Read more

Code

main.cpp

C/C++
// Program: ESP32-S3 Elecrow 7" HMI — Bands scope, FFT spectrum, Aurora, and computed Focus/Mellow + Settings tab (slider + raw). 2025-09-16 00:40 (Europe/Helsinki) — Thomas Vikström.

#include <Arduino.h>
#include <WiFi.h>
#include <WiFiUdp.h>
#include <cstring>
#include <cstdint>
#include <cmath>

#include <lvgl.h>
#include <esp_heap_caps.h>

#include <LovyanGFX.hpp>
#include <lgfx/v1/platforms/esp32s3/Panel_RGB.hpp>
#include <lgfx/v1/platforms/esp32s3/Bus_RGB.hpp>

#include "touch.h"      // your existing touch driver
#include "ui.h"         // optional (SquareLine UI)

#include <arduinoFFT.h> // lib_deps: lvgl/lvgl @ ^8.3.11, kosme/arduinoFFT @ ^1.6

/* =========================
   ===== Display setup =====
   ========================= */

class LGFX : public lgfx::LGFX_Device {
public:
  lgfx::Bus_RGB   _bus;
  lgfx::Panel_RGB _panel;
  LGFX(void) {
    { // RGB bus config
      auto cfg = _bus.config();
      cfg.panel = &_panel;
      cfg.pin_d0  = GPIO_NUM_15; cfg.pin_d1  = GPIO_NUM_7;  cfg.pin_d2  = GPIO_NUM_6;  cfg.pin_d3  = GPIO_NUM_5;  cfg.pin_d4  = GPIO_NUM_4;
      cfg.pin_d5  = GPIO_NUM_9;  cfg.pin_d6  = GPIO_NUM_46; cfg.pin_d7  = GPIO_NUM_3;  cfg.pin_d8  = GPIO_NUM_8;  cfg.pin_d9  = GPIO_NUM_16;
      cfg.pin_d10 = GPIO_NUM_1;  cfg.pin_d11 = GPIO_NUM_14; cfg.pin_d12 = GPIO_NUM_21; cfg.pin_d13 = GPIO_NUM_47; cfg.pin_d14 = GPIO_NUM_48;
      cfg.pin_d15 = GPIO_NUM_45;
      cfg.pin_henable = GPIO_NUM_41; cfg.pin_vsync=GPIO_NUM_40; cfg.pin_hsync=GPIO_NUM_39; cfg.pin_pclk=GPIO_NUM_0;
      cfg.freq_write  = 14000000;
      cfg.hsync_polarity=0; cfg.hsync_front_porch=40; cfg.hsync_pulse_width=48; cfg.hsync_back_porch=40;
      cfg.vsync_polarity=0; cfg.vsync_front_porch=1;  cfg.vsync_pulse_width=31; cfg.vsync_back_porch=13;
      cfg.pclk_active_neg=1; cfg.de_idle_high=0; cfg.pclk_idle_high=0;
      _bus.config(cfg);
    }
    { // Panel config
      auto cfg = _panel.config();
      cfg.memory_width=800; cfg.memory_height=480; cfg.panel_width=800; cfg.panel_height=480; cfg.offset_x=0; cfg.offset_y=0;
      _panel.config(cfg);
    }
    _panel.setBus(&_bus);
    setPanel(&_panel);
  }
};

LGFX lcd;

/* =========================
   ===== LVGL plumbing =====
   ========================= */

static uint32_t screenW, screenH;
static lv_disp_draw_buf_t draw_buf;
static lv_disp_drv_t disp_drv;
static lv_color_t* buf1 = nullptr;
static lv_color_t* buf2 = nullptr;
static int draw_lines = 80;

static void lvgl_flush_cb(lv_disp_drv_t *disp, const lv_area_t *area, lv_color_t *color_p) {
  uint32_t w = (area->x2 - area->x1 + 1);
  uint32_t h = (area->y2 - area->y1 + 1);
  lcd.pushImageDMA(area->x1, area->y1, w, h, (lgfx::rgb565_t*)&color_p->full);
  lv_disp_flush_ready(disp);
}
static void lvgl_touch_cb(lv_indev_drv_t *indev, lv_indev_data_t *data) {
  if (touch_has_signal()) {
    if (touch_touched()) { data->state = LV_INDEV_STATE_PRESSED; data->point.x = touch_last_x; data->point.y = touch_last_y; }
    else if (touch_released()) { data->state = LV_INDEV_STATE_RELEASED; }
  } else data->state = LV_INDEV_STATE_RELEASED;
}

/* =========================
   ===== Backlight PWM =====
   ========================= */
static constexpr int TFT_BL_PIN = 2;
static constexpr int BL_CH = 1;  // LEDC channel
static constexpr int BL_FREQ = 300;
static constexpr int BL_RES  = 8;
static inline void setBacklight(uint8_t lvl){ ledcWrite(BL_CH, lvl); }

/* =========================
   ===== UI components =====
   ========================= */

// Tabs
static lv_obj_t* tabview=nullptr;
static lv_obj_t* tab_bands=nullptr;
static lv_obj_t* tab_spec =nullptr;
static lv_obj_t* tab_aurora=nullptr;
static lv_obj_t* tab_bio=nullptr;
static lv_obj_t* tab_settings=nullptr;

// Settings widgets
static lv_obj_t* bl_slider=nullptr;
static lv_obj_t* bl_label =nullptr;
static lv_obj_t* lbl_ip=nullptr;
static lv_obj_t* lbl_port=nullptr;

// Raw bands label (moved to Settings)
static lv_obj_t* band_label=nullptr;

static const uint32_t UI_PERIOD_MS = 33;      // ~30 FPS
static uint8_t backlight_level_255 = 160;

/* Bands (tab 1) */
static lv_obj_t* eeg_chart=nullptr;
static lv_chart_series_t *ser_delta=nullptr, *ser_theta=nullptr, *ser_alpha=nullptr, *ser_beta=nullptr, *ser_gamma=nullptr;
static const uint16_t EEG_POINTS=240;
static int chart_y_min=-50, chart_y_max=50; // autorange
static float band_vis_max=1.0f; uint8_t range_tick=0;

/* Spectrum (tab 2) */
static lv_obj_t* spec_chart=nullptr; static lv_chart_series_t* ser_spec=nullptr; static lv_obj_t* spec_info=nullptr;
static const int SPEC_MIN_HZ=1, SPEC_MAX_HZ=20, SPEC_BINS=(SPEC_MAX_HZ-SPEC_MIN_HZ+1);

/* Aurora (tab 3) */
static lv_obj_t* aurora_canvas=nullptr; static lv_color_t* aurora_buf=nullptr; static int aurora_w=0, aurora_h=0;
static int16_t* sin1024=nullptr;

/* Biofeedback meters (tab 4) */
struct MeterRefs {
  lv_obj_t* container{nullptr};
  lv_obj_t* meter{nullptr};
  lv_meter_scale_t* scale{nullptr};
  lv_meter_indicator_t* needle{nullptr};
  lv_obj_t* label{nullptr};
};
static MeterRefs MET_FOCUS, MET_MELLOW;

/* =========================
   ===== OSC decoding  =====
   ========================= */

static uint8_t oscBuf[1024];
static inline uint32_t be32(const uint8_t* p){ return (uint32_t(p[0])<<24)|(uint32_t(p[1])<<16)|(uint32_t(p[2])<<8)|(uint32_t(p[3])); }

static int parse_osc_element(const uint8_t *buf, int len, char *addrBuf, int addrBufSize, float *out, int max) {
  if (!buf || len <= 0) return 0;
  const char *addr = (const char*)buf; int addr_len = strnlen(addr, len); if (addr_len == len) return 0;
  int off = ((addr_len + 1) + 3) & ~3; if (off >= len) return 0;
  int copy_len = (addr_len < addrBufSize-1)? addr_len : (addrBufSize-1);
  memcpy(addrBuf, addr, copy_len); addrBuf[copy_len]=0;
  const char* tt = (const char*)(buf + off); int tt_len = strnlen(tt, len - off); if (tt_len==0 || tt[0]!=',') return 0;
  off += ((tt_len+1)+3) & ~3; if (off > len) return 0;
  int count=0;
  for (int i=1; i<tt_len && off+4<=len && count<max; ++i) {
    if (tt[i]=='f'){ uint32_t be=be32(buf+off); float f; memcpy(&f,&be,4); out[count++]=f; off+=4; }
    else if (tt[i]=='i'){ uint32_t be=be32(buf+off); int32_t v; memcpy(&v,&be,4); out[count++]=(float)v; off+=4; }
    else break;
  }
  return count;
}
static void extract_bands_from_packet(const uint8_t *buf, int len, float bands[5]) {
  for (int i=0;i<5;++i) bands[i]=NAN; if (!buf || len<=0) return;
  auto handle=[&](const uint8_t* m,int mlen){
    char addr[128]; float v[16]; int n=parse_osc_element(m,mlen,addr,sizeof(addr),v,16);
    if (n<=0) return; float avg=0; for(int i=0;i<n;++i) avg+=v[i]; avg/= (float)n;
    if      (strstr(addr,"delta_absolute")) bands[0]=avg;
    else if (strstr(addr,"theta_absolute")) bands[1]=avg;
    else if (strstr(addr,"alpha_absolute")) bands[2]=avg;
    else if (strstr(addr,"beta_absolute"))  bands[3]=avg;
    else if (strstr(addr,"gamma_absolute")) bands[4]=avg;
  };
  if (len>=8 && memcmp(buf,"#bundle",7)==0) { if (len<16) return; int pos=16; while(pos+4<=len){ uint32_t sz=be32(buf+pos); pos+=4; if(sz==0||pos+(int)sz>len) break; handle(buf+pos,(int)sz); pos+=sz; } }
  else handle(buf,len);
}

/* =========================
   ===== Networking    =====
   ========================= */

static const char* WIFI_SSID = "your ssid here";
static const char* WIFI_PASS = "your password here";
static const uint16_t OSC_UDP_PORT = 5000;
static WiFiUDP Udp;

/* =========================
   ===== Bands smoothing =====
   ========================= */

static volatile int q_delta=0,q_theta=0,q_alpha=0,q_beta=0,q_gamma=0; static volatile bool q_have=false;
static float ema_delta=0, ema_theta=0, ema_alpha=0, ema_beta=0, ema_gamma=0;
static const float BAND_EMA_A=0.50f;

/* =========================
   ===== Spectrum (FFT) =====
   ========================= */

static const uint16_t FFT_N=64; static const double EEG_FS_HZ=256.0;
static double fft_vReal[FFT_N], fft_vImag[FFT_N]; static uint16_t fft_w=0; static bool fft_ready=false;
static arduinoFFT FFT;
static float spec_smooth[SPEC_BINS]={0}; static const float SPEC_EMA_A=0.40f;

volatile bool     q_spec_have = false;
volatile uint8_t  q_spec_vals[SPEC_BINS] = {0};

static inline double hp_filter(double x){ static double ema=0.0; const double a=0.005; ema=(1.0-a)*ema + a*x; return x-ema; }
static inline void fft_feed(double af7){ double x=hp_filter(af7); fft_vReal[fft_w]=x; fft_vImag[fft_w]=0.0; if(++fft_w>=FFT_N){ fft_w=0; fft_ready=true; } }

static void fft_compute_and_queue() {
  if (!fft_ready) return; fft_ready=false;
  FFT.Windowing(fft_vReal, FFT_N, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
  FFT.Compute(fft_vReal, fft_vImag, FFT_N, FFT_FORWARD);
  FFT.ComplexToMagnitude(fft_vReal, fft_vImag, FFT_N);
  auto mag_at_hz=[&](float f)->float{
    if (f<=0) return 0.0f;
    float bin=f*(float)FFT_N/(float)EEG_FS_HZ; if (bin<1.0f) bin=1.0f; float maxBin=(float)FFT_N*0.5f-1.0f; if (bin>maxBin) bin=maxBin;
    int i0=(int)floorf(bin); int i1=i0+1; float frac=bin-(float)i0; float m0=(float)fft_vReal[i0]; float m1=(float)fft_vReal[i1];
    return m0 + (m1-m0)*frac;
  };
  float mags[SPEC_BINS]; float peak=0; int peakHz=SPEC_MIN_HZ;
  for (int h=SPEC_MIN_HZ; h<=SPEC_MAX_HZ; ++h) {
    float v = log10f(mag_at_hz((float)h)+1e-6f)+6.0f; if (v<0) v=0;
    mags[h-SPEC_MIN_HZ]=v; if (v>peak){ peak=v; peakHz=h; }
  }
  for (int i=0;i<SPEC_BINS;++i){
    float s = (peak<1e-6f?0.0f:mags[i]/peak);
    s = SPEC_EMA_A*s + (1.0f-SPEC_EMA_A)*spec_smooth[i];
    spec_smooth[i]=s; int v=(int)lroundf(s*100.0f); if(v<0)v=0; if(v>100)v=100;
    q_spec_vals[i]=(uint8_t)v;
  }
  q_spec_have=true;
  if (spec_info){ char t[32]; snprintf(t,sizeof(t),"Peak: %d Hz",peakHz); lv_label_set_text(spec_info,t); }
}

/* =========================
   ===== Aurora helpers =====
   ========================= */

static inline void init_sinlut(){
  if (!sin1024){
  sin1024 = (int16_t*)heap_caps_malloc(sizeof(int16_t)*1024, MALLOC_CAP_INTERNAL | MALLOC_CAP_8BIT);
  if (!sin1024) sin1024 = (int16_t*)heap_caps_malloc(sizeof(int16_t)*1024, MALLOC_CAP_8BIT);
    for (int i=0;i<1024;++i){ float ang=(2.0f*M_PI*i)/1024.0f; sin1024[i]=(int16_t)lroundf(sinf(ang)*32767.0f); }
  }
}
static inline float fsin1024(int idx){ idx&=1023; return (float)sin1024[idx]/32767.0f; }
static inline lv_color_t aurora_color(int band){
  switch(band){ case 0: return lv_color_hex(0x0A2A6D); case 1: return lv_color_hex(0x0F6E6D);
                case 2: return lv_color_hex(0x1AAE48); case 3: return lv_color_hex(0xE79E2E);
                default:return lv_color_hex(0xBB33CC); }
}
static const int AURORA_PTS=128; static const int AURORA_GLOW_SKIP=3; static uint16_t phase_idx[5]={0,128,256,384,512};

static void aurora_draw_frame(){
  if (!aurora_canvas || !aurora_buf || !sin1024) return;
  { lv_draw_rect_dsc_t r; lv_draw_rect_dsc_init(&r); r.bg_color=lv_color_hex(0x061018); r.bg_opa=220;
    lv_canvas_draw_rect(aurora_canvas, 0, 0, aurora_w, aurora_h, &r); }
  float amps[5] = {
    fminf(fabsf(ema_delta),3.0f)*60.0f, fminf(fabsf(ema_theta),3.0f)*60.0f,
    fminf(fabsf(ema_alpha),3.0f)*60.0f, fminf(fabsf(ema_beta), 3.0f)*60.0f,
    fminf(fabsf(ema_gamma),3.0f)*60.0f
  };
  const uint8_t speed[5]={2,2,3,4,5};
  int pad=18; int usable_h=aurora_h-2*pad; int gap=usable_h/6; int base_y[5]; for(int i=0;i<5;++i) base_y[i]=pad+gap*(i+1);
  lv_draw_line_dsc_t main_d; lv_draw_line_dsc_init(&main_d); main_d.width=6; main_d.opa=LV_OPA_COVER; main_d.round_start=1; main_d.round_end=1;
  lv_draw_line_dsc_t glow_d=main_d; glow_d.width=12; glow_d.opa=96;
  lv_point_t pts[AURORA_PTS]; lv_point_t gpts[AURORA_PTS]; const float x_step=(AURORA_PTS>1)?(float)(aurora_w-1)/(float)(AURORA_PTS-1):(float)aurora_w;
  for (int b=0;b<5;++b){
    main_d.color=aurora_color(b); glow_d.color=main_d.color;
    float neighbor=0.0f; if(b>0) neighbor+=amps[b-1]*0.25f; if(b<4) neighbor+=amps[b+1]*0.25f;
    for (int i=0;i<AURORA_PTS;++i){
      int x=(int)lroundf(i*x_step); int idx=(phase_idx[b]+(x*3))&1023; float y=base_y[b]+fsin1024(idx)*amps[b];
      int drift=(phase_idx[1]+x)&1023; y += fsin1024(drift)*neighbor*0.4f;
      int yi=(int)lroundf(y); if (yi<0) yi=0; if(yi>=aurora_h) yi=aurora_h-1; pts[i].x=x; pts[i].y=yi;
    }
    lv_canvas_draw_line(aurora_canvas, pts, AURORA_PTS, &main_d);
    int gcnt=0; for (int i=0;i<AURORA_PTS;i+=AURORA_GLOW_SKIP) gpts[gcnt++]=pts[i];
    if (gcnt>1) lv_canvas_draw_line(aurora_canvas, gpts, gcnt, &glow_d);
    phase_idx[b] += speed[b] + (uint8_t)(fabsf(ema_theta)*3.0f);
  }
  if (ema_gamma>0.02f){
    uint8_t dots=(uint8_t)min(18.0f, ema_gamma*30.0f);
    lv_draw_rect_dsc_t r; lv_draw_rect_dsc_init(&r); r.radius=1; r.bg_opa=140; r.bg_color=lv_color_hex(0xA0D8FF);
    for (int i=0;i<dots;++i){ int x=(millis()*37+i*73)%aurora_w; int y=(millis()*53+i*29)%aurora_h; lv_canvas_draw_rect(aurora_canvas,x,y,2,2,&r); }
  }
}

/* =========================
   ===== Biofeedback (computed Focus/Mellow) =====
   ========================= */

struct ZNorm {
  float mean = 0.0f;
  float mad  = 1.0f;   // mean absolute deviation (EMA)
  float a = 0.01f;     // update rate
  inline void update(float x){
    mean = (1.0f - a)*mean + a*x;
    float dev = fabsf(x - mean);
    mad  = (1.0f - a)*mad  + a*dev;
    if (mad < 1e-6f) mad = 1e-6f;
  }
  inline float norm01(float x) const {
    float z = (x - mean) / (3.0f*mad);
    return 1.0f / (1.0f + expf(-z));
  }
};

static ZNorm FOCUS_Z, MELLOW_Z;

static volatile int q_focus=-1, q_mellow=-1; static volatile bool q_bio_have=false;
static float ema_focus=0.0f, ema_mellow=0.0f; static const float BIO_EMA_A=0.40f;

static MeterRefs build_meter(lv_obj_t* parent, const char* title, lv_color_t color){
  MeterRefs mr;
  mr.container = lv_obj_create(parent);
  lv_obj_set_size(mr.container, 360, 300);
  lv_obj_set_style_bg_opa(mr.container, LV_OPA_0, 0);
  lv_obj_set_style_border_width(mr.container, 0, 0);
  lv_obj_set_style_pad_all(mr.container, 8, 0);

  lv_obj_t* t = lv_label_create(mr.container);
  lv_label_set_text(t, title);
  lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 0);

  mr.meter = lv_meter_create(mr.container);
  lv_obj_set_size(mr.meter, 240, 240);
  lv_obj_align(mr.meter, LV_ALIGN_CENTER, 0, 10);

  mr.scale = lv_meter_add_scale(mr.meter);
  lv_meter_set_scale_ticks(mr.meter, mr.scale, 41, 2, 8, lv_palette_main(LV_PALETTE_GREY));
  lv_meter_set_scale_major_ticks(mr.meter, mr.scale, 10, 4, 14, lv_color_white(), 12);
  lv_meter_set_scale_range(mr.meter, mr.scale, 0, 100, 240, 150);

  lv_meter_indicator_t* arc_lo  = lv_meter_add_arc(mr.meter, mr.scale, 10, lv_palette_main(LV_PALETTE_RED), 0);
  lv_meter_set_indicator_start_value(mr.meter, arc_lo, 0);  lv_meter_set_indicator_end_value(mr.meter, arc_lo, 40);
  lv_meter_indicator_t* arc_mid = lv_meter_add_arc(mr.meter, mr.scale, 10, lv_palette_main(LV_PALETTE_AMBER), 0);
  lv_meter_set_indicator_start_value(mr.meter, arc_mid, 40); lv_meter_set_indicator_end_value(mr.meter, arc_mid, 70);
  lv_meter_indicator_t* arc_hi  = lv_meter_add_arc(mr.meter, mr.scale, 10, color, 0);
  lv_meter_set_indicator_start_value(mr.meter, arc_hi, 70); lv_meter_set_indicator_end_value(mr.meter, arc_hi, 100);

  // Needle color
  mr.needle = lv_meter_add_needle_line(mr.meter, mr.scale, 4, color, -12);

  mr.label = lv_label_create(mr.container);
  lv_label_set_text(mr.label, "-- %");
  lv_obj_align(mr.label, LV_ALIGN_BOTTOM_MID, 0, -4);

  return mr;
}
static inline void meter_set_value(const MeterRefs& mr, int val){
  if (!mr.meter || !mr.scale || !mr.needle) return;
  if (val<0) val=0; if (val>100) val=100;
  lv_meter_set_indicator_value(mr.meter, mr.needle, val);
  if (mr.label){ char t[24]; snprintf(t,sizeof(t), "%d %%", val); lv_label_set_text(mr.label, t); }
}

/* =========================
   ===== UI builders   =====
   ========================= */

static void build_ui() {
  // Tabs (order: Bands, Spectrum, Aurora, Biofeedback, Settings)
  tabview     = lv_tabview_create(lv_scr_act(), LV_DIR_TOP, 36);
  tab_bands   = lv_tabview_add_tab(tabview, "Bands");
  tab_spec    = lv_tabview_add_tab(tabview, "Spectrum");
  tab_aurora  = lv_tabview_add_tab(tabview, "Aurora");
  tab_bio     = lv_tabview_add_tab(tabview, "Biofeedback");
  tab_settings= lv_tabview_add_tab(tabview, "Settings");
  

  /* -------- Bands chart -------- */
  eeg_chart = lv_chart_create(tab_bands);
  lv_obj_set_size(eeg_chart, lv_pct(96), lv_pct(85));
  lv_obj_align(eeg_chart, LV_ALIGN_BOTTOM_MID, 0, -6);
  lv_chart_set_type(eeg_chart, LV_CHART_TYPE_LINE);
  lv_chart_set_update_mode(eeg_chart, LV_CHART_UPDATE_MODE_SHIFT);
  lv_chart_set_point_count(eeg_chart, EEG_POINTS);
  lv_chart_set_range(eeg_chart, LV_CHART_AXIS_PRIMARY_Y, chart_y_min, chart_y_max);
  lv_chart_set_div_line_count(eeg_chart, 4, 4);
  lv_obj_set_style_bg_opa(eeg_chart, LV_OPA_0, 0);
  lv_obj_set_style_border_width(eeg_chart, 0, 0);
  lv_obj_set_style_line_width(eeg_chart, 2, LV_PART_ITEMS);
  lv_obj_set_style_size(eeg_chart, 0, LV_PART_INDICATOR);
  lv_obj_set_style_line_rounded(eeg_chart, true, LV_PART_ITEMS);

  ser_delta = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_GREY ), LV_CHART_AXIS_PRIMARY_Y);
  ser_theta = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_TEAL ), LV_CHART_AXIS_PRIMARY_Y);
  ser_alpha = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_GREEN), LV_CHART_AXIS_PRIMARY_Y);
  ser_beta  = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_AMBER), LV_CHART_AXIS_PRIMARY_Y);
  ser_gamma = lv_chart_add_series(eeg_chart, lv_palette_main(LV_PALETTE_PINK ), LV_CHART_AXIS_PRIMARY_Y);

  for (int i=0;i<EEG_POINTS;++i){
    lv_chart_set_next_value(eeg_chart, ser_delta, 0);
    lv_chart_set_next_value(eeg_chart, ser_theta, 0);
    lv_chart_set_next_value(eeg_chart, ser_alpha, 0);
    lv_chart_set_next_value(eeg_chart, ser_beta,  0);
    lv_chart_set_next_value(eeg_chart, ser_gamma, 0);
  }

  /* -------- Spectrum -------- */
  spec_chart = lv_chart_create(tab_spec);
  lv_obj_set_size(spec_chart, lv_pct(96), lv_pct(80));
  lv_obj_align(spec_chart, LV_ALIGN_BOTTOM_MID, 0, -6);
  lv_chart_set_type(spec_chart, LV_CHART_TYPE_BAR);
  lv_chart_set_point_count(spec_chart, SPEC_BINS);
  lv_chart_set_range(spec_chart, LV_CHART_AXIS_PRIMARY_Y, 0, 100);
  lv_chart_set_div_line_count(spec_chart, 4, 4);
  lv_obj_set_style_bg_opa(spec_chart, LV_OPA_0, 0);
  lv_obj_set_style_border_width(spec_chart, 0, 0);
  lv_obj_set_style_size(spec_chart, 6, LV_PART_ITEMS);
  ser_spec = lv_chart_add_series(spec_chart, lv_palette_main(LV_PALETTE_BLUE), LV_CHART_AXIS_PRIMARY_Y);
  for (int i=0;i<SPEC_BINS;++i) lv_chart_set_next_value(spec_chart, ser_spec, 0);
  spec_info = lv_label_create(tab_spec); lv_label_set_text(spec_info, "Peak: -- Hz");
  lv_obj_align(spec_info, LV_ALIGN_TOP_LEFT, 6, 4);

  /* -------- Aurora -------- */
  aurora_canvas = lv_canvas_create(tab_aurora);
  aurora_w = min<int>(800*0.96f, 640); aurora_h = min<int>(480*0.80f, 360);
  lv_obj_set_size(aurora_canvas, aurora_w, aurora_h);
  lv_obj_align(aurora_canvas, LV_ALIGN_BOTTOM_MID, 0, -6);
  {
    size_t px = (size_t)aurora_w*(size_t)aurora_h;
    aurora_buf = (lv_color_t*)heap_caps_malloc(px*sizeof(lv_color_t), MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT);
    if (!aurora_buf){
      aurora_w=480; aurora_h=240; lv_obj_set_size(aurora_canvas, aurora_w, aurora_h);
      px=(size_t)aurora_w*(size_t)aurora_h;
      aurora_buf = (lv_color_t*)heap_caps_malloc(px*sizeof(lv_color_t), MALLOC_CAP_SPIRAM|MALLOC_CAP_8BIT);
    }
    if (!aurora_buf){
      aurora_w=320; aurora_h=180; lv_obj_set_size(aurora_canvas, aurora_w, aurora_h);
      px=(size_t)aurora_w*(size_t)aurora_h;
      aurora_buf = (lv_color_t*)heap_caps_malloc(px*sizeof(lv_color_t), MALLOC_CAP_8BIT);
    }
    lv_canvas_set_buffer(aurora_canvas, aurora_buf, aurora_w, aurora_h, LV_IMG_CF_TRUE_COLOR);
  }
  init_sinlut();

  /* -------- Biofeedback -------- */
  lv_obj_t* bio_row = lv_obj_create(tab_bio);
  lv_obj_set_size(bio_row, lv_pct(96), lv_pct(85));
  lv_obj_align(bio_row, LV_ALIGN_BOTTOM_MID, 0, -6);
  lv_obj_set_style_bg_opa(bio_row, LV_OPA_0, 0);
  lv_obj_set_style_border_width(bio_row, 0, 0);
  lv_obj_set_flex_flow(bio_row, LV_FLEX_FLOW_ROW);
  lv_obj_set_flex_align(bio_row, LV_FLEX_ALIGN_SPACE_EVENLY, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
  lv_obj_set_style_pad_row(bio_row, 0, 0);
  lv_obj_set_style_pad_column(bio_row, 14, 0);
  MET_FOCUS  = build_meter(bio_row, "Focus",  lv_palette_main(LV_PALETTE_BLUE));
  MET_MELLOW = build_meter(bio_row, "Mellow", lv_palette_main(LV_PALETTE_GREEN));

  /* -------- Settings (slider + raw + IP/port) -------- */
  // Brightness slider
  bl_slider = lv_slider_create(tab_settings);
  lv_obj_set_size(bl_slider, 300, 22);
  lv_obj_align(bl_slider, LV_ALIGN_TOP_RIGHT, -20, 18);
  lv_slider_set_range(bl_slider, 0, 100);
  int start_pct = map(backlight_level_255, 0, 255, 0, 100);
  lv_slider_set_value(bl_slider, start_pct, LV_ANIM_OFF);

  bl_label = lv_label_create(tab_settings);
  static char bl_txt[32]; snprintf(bl_txt, sizeof(bl_txt), "Brightness %d%%", start_pct);
  lv_label_set_text(bl_label, bl_txt);
  lv_obj_align_to(bl_label, bl_slider, LV_ALIGN_OUT_LEFT_MID, -12, 0);

  lv_obj_add_event_cb(bl_slider, [](lv_event_t* e){
    int pct = lv_slider_get_value(lv_event_get_target(e));
    uint8_t lvl = (uint8_t) map(pct, 0, 100, 0, 255);
    setBacklight(lvl); backlight_level_255 = lvl;
    static char txt[32]; snprintf(txt, sizeof(txt), "Brightness %d%%", pct);
    lv_label_set_text(bl_label, txt);
  }, LV_EVENT_VALUE_CHANGED, nullptr);

  // Raw bands (moved here)
  band_label = lv_label_create(tab_settings);
  lv_obj_set_width(band_label, lv_pct(94));
  lv_label_set_long_mode(band_label, LV_LABEL_LONG_SCROLL_CIRCULAR);
  lv_label_set_text(band_label, "delta: --  theta: --  alpha: --  beta: --  gamma: --");
  lv_obj_set_style_bg_color(band_label, lv_color_hex(0x001018), 0);
  lv_obj_set_style_bg_opa(band_label, LV_OPA_COVER, 0);
  lv_obj_set_style_border_color(band_label, lv_color_hex(0xFFFFFF), 0);
  lv_obj_set_style_border_width(band_label, 2, 0);
  lv_obj_set_style_pad_all(band_label, 6, 0);
  lv_obj_set_style_text_color(band_label, lv_color_hex(0xFFFFFF), 0);
  lv_obj_align(band_label, LV_ALIGN_TOP_LEFT, 12, 60);

  // IP / Port info
  lbl_ip   = lv_label_create(tab_settings);
  lbl_port = lv_label_create(tab_settings);
  lv_label_set_text(lbl_ip,   "IP: --");
  lv_label_set_text(lbl_port, "UDP Port: --");
  lv_obj_align(lbl_ip,   LV_ALIGN_BOTTOM_LEFT, 12, -16);
  lv_obj_align(lbl_port, LV_ALIGN_BOTTOM_LEFT, 12, -40);

  lv_obj_move_foreground(tabview);
}

/* =========================
   ===== LVGL task     =====
   ========================= */

static void lvgl_task(void*){
  const TickType_t period = pdMS_TO_TICKS(UI_PERIOD_MS);
  TickType_t last = xTaskGetTickCount();
  for(;;){
    // Bands
    if (q_have && eeg_chart){
      q_have=false;
      float fd=q_delta/100.0f, ft=q_theta/100.0f, fa=q_alpha/100.0f, fb=q_beta/100.0f, fg=q_gamma/100.0f;
      ema_delta=BAND_EMA_A*fd+(1-BAND_EMA_A)*ema_delta;
      ema_theta=BAND_EMA_A*ft+(1-BAND_EMA_A)*ema_theta;
      ema_alpha=BAND_EMA_A*fa+(1-BAND_EMA_A)*ema_alpha;
      ema_beta =BAND_EMA_A*fb+(1-BAND_EMA_A)*ema_beta;
      ema_gamma=BAND_EMA_A*fg+(1-BAND_EMA_A)*ema_gamma;

      float maxAbs=fmaxf(fabsf(ema_delta),fmaxf(fabsf(ema_theta),fmaxf(fabsf(ema_alpha),fmaxf(fabsf(ema_beta),fabsf(ema_gamma)))));
      const float a_up=0.40f,a_down=0.10f;
      if (maxAbs>band_vis_max) band_vis_max=a_up*maxAbs+(1-a_up)*band_vis_max;
      else                     band_vis_max=a_down*maxAbs+(1-a_down)*band_vis_max;

      int target=(int)lroundf(band_vis_max*100.0f*1.2f); if (target<120) target=120; if (target>1000) target=1000;
      if (++range_tick>=30){
        range_tick=0;
        if (target>(int)(0.95f*chart_y_max) || target<(int)(0.5f*chart_y_max)){
          chart_y_min=-target; chart_y_max=target; lv_chart_set_range(eeg_chart, LV_CHART_AXIS_PRIMARY_Y, chart_y_min, chart_y_max);
        }
      }

      auto toChart=[](float v){ return (int)lroundf(v*100.0f); };
      lv_chart_set_next_value(eeg_chart, ser_delta, toChart(ema_delta));
      lv_chart_set_next_value(eeg_chart, ser_theta, toChart(ema_theta));
      lv_chart_set_next_value(eeg_chart, ser_alpha, toChart(ema_alpha));
      lv_chart_set_next_value(eeg_chart, ser_beta,  toChart(ema_beta));
      lv_chart_set_next_value(eeg_chart, ser_gamma, toChart(ema_gamma));

      if (band_label){ char lbl[160]; snprintf(lbl,sizeof(lbl),"delta: %.3f  theta: %.3f  alpha: %.3f  beta: %.3f  gamma: %.3f",
                    ema_delta,ema_theta,ema_alpha,ema_beta,ema_gamma); lv_label_set_text(band_label,lbl); }
    }

    // Spectrum
    if (q_spec_have && spec_chart){
      q_spec_have=false;
      for (int i=0;i<SPEC_BINS;++i) lv_chart_set_value_by_id(spec_chart, ser_spec, i, q_spec_vals[i]);
      lv_chart_refresh(spec_chart);
    }

    // Aurora when visible
    if (tabview && aurora_canvas){
      if (lv_tabview_get_tab_act(tabview)==2) aurora_draw_frame();
    }

    // Biofeedback
    if (q_bio_have){
      q_bio_have=false;
      if (q_focus >=0)  ema_focus  = BIO_EMA_A*(q_focus /100.0f)+(1.0f-BIO_EMA_A)*ema_focus;
      if (q_mellow>=0)  ema_mellow = BIO_EMA_A*(q_mellow/100.0f)+(1.0f-BIO_EMA_A)*ema_mellow;
      meter_set_value(MET_FOCUS,  (int)lroundf(ema_focus *100.0f));
      meter_set_value(MET_MELLOW, (int)lroundf(ema_mellow*100.0f));
    }

    lv_timer_handler();
    vTaskDelayUntil(&last, period);
  }
}

/* =========================
   ===== Helpers      =====
   ========================= */

static bool alloc_lvgl_draw_buffers_adaptive() {
  const int tries[] = {160, 120, 96, 80, 64, 48, 40, 32, 24, 16, 12};
  for (int lines : tries){
    size_t need = (size_t)screenW * (size_t)lines * sizeof(lv_color_t);
    buf1 = (lv_color_t*)heap_caps_malloc(need, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    buf2 = (lv_color_t*)heap_caps_malloc(need, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
    if (buf1 && buf2){
      draw_lines=lines; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, screenW*draw_lines);
      Serial.printf("LVGL draw buffer in PSRAM: %d lines (%.1f KB each)\n", draw_lines, need/1024.0f);
      return true;
    }
    if (buf1){ heap_caps_free(buf1); buf1=nullptr; }
    if (buf2){ heap_caps_free(buf2); buf2=nullptr; }
  }
  int lines=12; size_t need=(size_t)screenW*(size_t)lines*sizeof(lv_color_t);
  buf1=(lv_color_t*)heap_caps_malloc(need,MALLOC_CAP_8BIT);
  buf2=(lv_color_t*)heap_caps_malloc(need,MALLOC_CAP_8BIT);
  if (buf1 && buf2){ draw_lines=lines; lv_disp_draw_buf_init(&draw_buf, buf1, buf2, screenW*draw_lines);
    Serial.printf("LVGL draw buffer in DRAM: %d lines (%.1f KB each)\n", draw_lines, need/1024.0f); return true; }
  Serial.println("FATAL: draw buffers alloc failed."); return false;
}

/* =========================
   ===== Arduino core  =====
   ========================= */

void setup() {
  Serial.begin(115200);
  delay(100);

  ledcSetup(BL_CH, BL_FREQ, BL_RES);
  ledcAttachPin(TFT_BL_PIN, BL_CH);
  setBacklight(backlight_level_255);

  lcd.begin(); lcd.fillScreen(TFT_BLACK);
  screenW = lcd.width(); screenH = lcd.height();

  lv_init();
  touch_init();

  alloc_lvgl_draw_buffers_adaptive();

  lv_disp_drv_init(&disp_drv);
  disp_drv.hor_res=screenW; disp_drv.ver_res=screenH;
  disp_drv.flush_cb=lvgl_flush_cb; disp_drv.draw_buf=&draw_buf;
  disp_drv.full_refresh=0; disp_drv.direct_mode=0;
  lv_disp_drv_register(&disp_drv);

  static lv_indev_drv_t indev_drv;
  lv_indev_drv_init(&indev_drv);
  indev_drv.type=LV_INDEV_TYPE_POINTER; indev_drv.read_cb=lvgl_touch_cb;
  lv_indev_drv_register(&indev_drv);

#ifdef ui_init
  ui_init();
#endif

  build_ui();

  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  uint32_t t0=millis();
  while (WiFi.status()!=WL_CONNECTED && millis()-t0<15000) delay(100);
  if (WiFi.status()==WL_CONNECTED){
    Udp.begin(OSC_UDP_PORT);
    Serial.printf("WiFi OK. IP=%s, UDP port=%u\n", WiFi.localIP().toString().c_str(), OSC_UDP_PORT);
    if (lbl_ip)   { String s="IP: "+WiFi.localIP().toString(); lv_label_set_text(lbl_ip, s.c_str()); }
    if (lbl_port) { char t[32]; snprintf(t,sizeof(t),"UDP Port: %u", OSC_UDP_PORT); lv_label_set_text(lbl_port, t); }
  } else {
    Serial.println("WiFi FAILED.");
    if (lbl_ip)   lv_label_set_text(lbl_ip, "IP: (not connected)");
    if (lbl_port) { char t[32]; snprintf(t,sizeof(t),"UDP Port: %u", OSC_UDP_PORT); lv_label_set_text(lbl_port, t); }
  }

  xTaskCreatePinnedToCore(lvgl_task, "lvgl", 6144, nullptr, 1, nullptr, 1);
}

void loop() {
  if (WiFi.status()==WL_CONNECTED){
    int pkt=Udp.parsePacket();
    if (pkt>0 && pkt<=(int)sizeof(oscBuf)){
      int len=Udp.read(oscBuf,sizeof(oscBuf));
      if (len>0){
        // 1) Bands
        float bands[5]; extract_bands_from_packet(oscBuf,len,bands);
        static float last[5]={NAN,NAN,NAN,NAN,NAN};
        for (int i=0;i<5;++i) if(!isnan(bands[i])) last[i]=bands[i];

        const float MULT=100.0f;
        q_delta = isnan(last[0])?0:(int)roundf(last[0]*MULT);
        q_theta = isnan(last[1])?0:(int)roundf(last[1]*MULT);
        q_alpha = isnan(last[2])?0:(int)roundf(last[2]*MULT);
        q_beta  = isnan(last[3])?0:(int)roundf(last[3]*MULT);
        q_gamma = isnan(last[4])?0:(int)roundf(last[4]*MULT);
        q_have  = true;

        // 2) Spectrum: get AF7 sample if present
        bool got_af7=false; double af7_sample=0.0;
        const uint8_t* buf=oscBuf;
        auto scan_elem_af7=[&](const uint8_t* base,int blen){
          char addr[128]; float v[8]; int n=parse_osc_element(base,blen,addr,sizeof(addr),v,8);
          if (n>=3 && strstr(addr,"/muse/eeg")){ af7_sample=(double)v[1]; got_af7=true; }
        };
        if (len>=8 && memcmp(buf,"#bundle",7)==0){
          int pos=16; while(pos+4<=len){ uint32_t sz=be32(buf+pos); pos+=4; if (sz==0 || pos+(int)sz>len) break; scan_elem_af7(buf+pos,(int)sz); pos+=sz; }
        } else scan_elem_af7(buf,len);

        if (got_af7){ fft_feed(af7_sample); fft_compute_and_queue(); }

        // 3) Compute Focus & Mellow from bands (no MindMonitor /algorithm/)
        float d = isnan(last[0])?0.0f:last[0];
        float t = isnan(last[1])?0.0f:last[1];
        float a = isnan(last[2])?0.0f:last[2];
        float b = isnan(last[3])?0.0f:last[3];
        float g = isnan(last[4])?0.0f:last[4];
        const float K_EPS = 1e-6f;

        float focus_feat  = b / (a + t + K_EPS);
        float mellow_feat = a / (b + 0.5f*g + K_EPS);

        FOCUS_Z.update(focus_feat);
        MELLOW_Z.update(mellow_feat);

        int f_pct = (int)lroundf(FOCUS_Z.norm01(focus_feat)  * 100.0f);
        int m_pct = (int)lroundf(MELLOW_Z.norm01(mellow_feat)* 100.0f);
        if (f_pct<0) f_pct=0; if (f_pct>100) f_pct=100;
        if (m_pct<0) m_pct=0; if (m_pct>100) m_pct=100;

        q_focus  = f_pct;
        q_mellow = m_pct;
        q_bio_have = true;
      }
    }
  }
  delay(1);
}

platformio.ini

INI
; PlatformIO Project Configuration File
;
;   Build options: build flags, source filter
;   Upload options: custom upload port, speed and extra flags
;   Library options: dependencies, extra library storages
;   Advanced options: extra scripting
;
; Please visit documentation for the other options and examples
; https://docs.platformio.org/page/projectconf.html

[platformio]
src_dir = src
boards_dir = .

[env:esp32-s3-devkitc-1-myboard]
platform = espressif32
board = esp32-s3-devkitc-1-myboard
framework = arduino
monitor_speed = 115200
upload_speed = 460800

platform_packages = framework-arduinoespressif32 @ https://github.com/espressif/arduino-esp32#2.0.3
build_flags = 
	-D LV_LVGL_H_INCLUDE_SIMPLE
	-I./include
	  -DBOARD_HAS_PSRAM
  -mfix-esp32-psram-cache-issue
    -DCORE_DEBUG_LEVEL=3
  -D CONFIG_COMPILER_STACK_CHECK_MODE_STRONG=1
lib_deps = 
	lvgl/lvgl@8.3.11
	tamctec/TAMC_GT911@^1.0.2
	moononournation/GFX Library for Arduino@1.2.8
	lovyan03/LovyanGFX@^1.1.12
	maxpromer/PCA9557-arduino@^1.0.0
	adafruit/Adafruit GFX Library@^1.11.9
	kosme/arduinoFFT @ ^1.6
board_build.partitions = huge_app.csv

Credits

Thomas Vikström
3 projects • 2 followers

Comments