Skip to content

Orbit-Q Visualizer V2: Build & Code Walkthrough

This tutorial explains Orbit-Q_Visualizer_v2.ino: a 6-mode ambient display running on the ORBIT-Q board with an STM32F405RGT6 MCU card, driving a 128×32 I2C OLED and a 10-pixel WS2812B strip as one coordinated visual surface, not two separate animations. If you're following along to build this yourself, read the "Core Concept" section first — every mode in the code only makes sense once you understand that one idea.

1. What this demo actually does

Six modes cycle automatically, ~90 seconds total, then loop forever:

# Mode OLED shows LED strip shows
0 Wave Cross scrolling waveform the same waveform function, sampled past the right edge of the screen
1 Orbit Sweep a dot orbiting with a fading trail a comet at the matching angular position
2 Plasma Bloom a dithered 2D plasma field the same field, sampled off-screen
3 Radar Sweep rotating sweep line + 4 decaying blips a sharp point mirroring the sweep angle, flashing on blip hits
4 VU Spectrum 8 bars driven by synthesized envelopes (not real audio — there's no mic on this board) a level-meter bar with a decaying peak-hold marker
5 Matrix Cascade falling column drops a flash on the mapped LED the instant a column's drop crosses the bottom edge

Transitions fade in/out over 600 ms so mode changes aren't an abrupt cut.

2. Hardware you need

  • ORBIT-Q board with the STM32F405RGT6 MCU card (Cortex-M4F core, hardware single-precision FPU)
  • SSD1306 128×32 OLED, I2C, address 0x3C.
  • WS2812B strip/ring, 10 pixels
  • ST-Link (or your usual ORBIT-Q upload path)

Wiring used in this sketch

  • OLED → I2C1 default pins (SDA/SCL), power from on-board power supply.
  • WS2812B data - PB3
  • Onboard status LED - PC13

These pins are already brought out on the ORBIT-Q carrier to the onboard OLED, LED strip header, and status LED — no extra wiring needed beyond confirming your card's pin definitions match.

3. Libraries (Arduino IDE → Library Manager)

  • Adafruit GFX Library
  • Adafruit SSD1306
  • Adafruit NeoPixel
  • The two fonts (FreeSansBold9pt7b, FreeSans9pt7b) ship inside Adafruit GFX — no separate install.
  • STM32 board support package installed via Boards Manager (STM32duino), with the F405RGT6 variant selected.

CODE

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <Adafruit_NeoPixel.h>
#include <Fonts/FreeSansBold9pt7b.h>
#include <Fonts/FreeSans9pt7b.h>
#include <math.h>

/* =========================================================
   CONCEPT v2
   ---------------------------------------------------------
   Same core idea as the original: the OLED and the LED strip
   are not two independent animations. Every mode is a
   function of one shared "phase" clock (t), and wherever
   possible the LED strip is treated as a physical extension
   of the OLED's coordinate space rather than a separate
   effect — e.g. the plasma field samples the identical
   plasmaField(x,y,t) that draws the screen, just at x values
   past the right edge; the matrix mode's LED flashes fire at
   the exact moment a column's drop falls off the bottom of
   the visible screen.

   WHAT CHANGED FROM v1:
   - 6 modes instead of 2 (wave cross, orbit sweep, plasma
     bloom, radar sweep w/ blips, VU-style spectrum bars,
     matrix-style cascade), each with its own visual identity
     but the same shared-field philosophy.
   - All sinf()/cosf() calls replaced with an 8-bit sine LUT.
     The STM32F103 ("blue pill") has no hardware FPU, so
     runtime sinf/cosf are software-emulated and comparatively
     slow; a 256-entry lookup table is the standard embedded
     fix and is what makes 6 richer modes affordable inside
     the same 20 ms frame budget.
   - Per-mode duration array instead of one fixed duration, so
     pacing can be tuned per effect (a full cycle through all
     6 modes is now roughly 90 seconds, then it loops
     indefinitely — this is meant to run continuously as a
     demo/kiosk show, not a one-shot animation).
   - Per-mode state (radar blip decay, VU peak-hold, matrix
     column phase) is reset on mode entry so switching modes
     never carries stale state into the next effect.

   HONESTY NOTE: the "VU spectrum" mode is a synthesized
   envelope (sums of sine harmonics), not a real audio input —
   there is no microphone in this design. It's labeled that way
   in code so it's not misrepresented as audio-reactive.

   Everything remains non-blocking (millis-paced) outside of
   the one-time boot animation, exactly as in v1.
   ========================================================= */

/* =======================
   MCU / BOARD DEFINES
   ======================= */
#define PC13_LED    PC13

/* =======================
   WS2812B CONFIG
   ======================= */
#define WS_PIN      PB3
#define NUMPIXELS   10
#define MAX_BRIGHTNESS  60   // ceiling brightness for the show (0-255)

Adafruit_NeoPixel pixels(NUMPIXELS, WS_PIN, NEO_GRB + NEO_KHZ800);

/* =======================
   OLED CONFIG (I2C1 default)
   ======================= */
#define SCREEN_WIDTH  128
#define SCREEN_HEIGHT 32
#define OLED_RESET    -1
#define OLED_ADDR     0x3C

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);

/* =======================
   TIMING / SHOW STATE
   ======================= */
const uint16_t FRAME_MS      = 20;     // ~50 fps render pace
const uint16_t TRANSITION_MS = 600;    // fade in/out window at mode edges
const uint8_t  NUM_MODES     = 6;

// per-mode runtime; sum ≈ 94 s per full cycle, then it repeats forever
const uint32_t MODE_DURATION[NUM_MODES] = {
  16000, // 0 wave cross
  15000, // 1 orbit sweep
  17000, // 2 plasma bloom
  15000, // 3 radar sweep
  17000, // 4 VU spectrum (simulated)
  14000  // 5 matrix cascade
};

unsigned long frameMillis   = 0;
unsigned long modeStartMs   = 0;
unsigned long pc13Millis    = 0;
uint8_t  currentMode        = 0;
bool     pc13State          = false;

float phase = 0.0f;
const float PHASE_STEP = 0.045f;   // tune this to speed up/slow down the whole show

/* =======================
   8-BIT SINE LOOKUP TABLE
   sin(2*pi*i/256) * 127, i = 0..255.
   Regular flash-resident const array — fine as-is on STM32
   (and ESP32/SAMD); if this is ever ported to classic AVR,
   wrap it in PROGMEM and read with pgm_read_byte().
   ======================= */
const int8_t SINE_TABLE[256] = {
     0,    3,    6,    9,   12,   16,   19,   22,   25,   28,   31,   34,   37,   40,   43,   46,
    49,   51,   54,   57,   60,   63,   65,   68,   71,   73,   76,   78,   81,   83,   85,   88,
    90,   92,   94,   96,   98,  100,  102,  104,  106,  107,  109,  111,  112,  113,  115,  116,
   117,  118,  120,  121,  122,  122,  123,  124,  125,  125,  126,  126,  126,  127,  127,  127,
   127,  127,  127,  127,  126,  126,  126,  125,  125,  124,  123,  122,  122,  121,  120,  118,
   117,  116,  115,  113,  112,  111,  109,  107,  106,  104,  102,  100,   98,   96,   94,   92,
    90,   88,   85,   83,   81,   78,   76,   73,   71,   68,   65,   63,   60,   57,   54,   51,
    49,   46,   43,   40,   37,   34,   31,   28,   25,   22,   19,   16,   12,    9,    6,    3,
     0,   -3,   -6,   -9,  -12,  -16,  -19,  -22,  -25,  -28,  -31,  -34,  -37,  -40,  -43,  -46,
   -49,  -51,  -54,  -57,  -60,  -63,  -65,  -68,  -71,  -73,  -76,  -78,  -81,  -83,  -85,  -88,
   -90,  -92,  -94,  -96,  -98, -100, -102, -104, -106, -107, -109, -111, -112, -113, -115, -116,
  -117, -118, -120, -121, -122, -122, -123, -124, -125, -125, -126, -126, -126, -127, -127, -127,
  -127, -127, -127, -127, -126, -126, -126, -125, -125, -124, -123, -122, -122, -121, -120, -118,
  -117, -116, -115, -113, -112, -111, -109, -107, -106, -104, -102, -100,  -98,  -96,  -94,  -92,
   -90,  -88,  -85,  -83,  -81,  -78,  -76,  -73,  -71,  -68,  -65,  -63,  -60,  -57,  -54,  -51,
   -49,  -46,  -43,  -40,  -37,  -34,  -31,  -28,  -25,  -22,  -19,  -16,  -12,   -9,   -6,   -3,
};

inline uint8_t angleIdx(float radians) {
  float w = fmodf(radians, 2.0f * PI);
  if (w < 0) w += 2.0f * PI;
  return (uint8_t)(w * (256.0f / (2.0f * PI)));
}
inline float sinTab(uint8_t idx) { return SINE_TABLE[idx] / 127.0f; }
inline float cosTab(uint8_t idx) { return SINE_TABLE[(uint8_t)(idx + 64)] / 127.0f; }
inline float fastSinF(float radians) { return sinTab(angleIdx(radians)); }
inline float fastCosF(float radians) { return cosTab(angleIdx(radians)); }
inline float wrapAngle(float a) {
  a = fmodf(a, 2.0f * PI);
  if (a < 0) a += 2.0f * PI;
  return a;
}

/* =======================
   FUNCTION DECLARATIONS
   ======================= */
void blinkPC13();
void ntxBootAnimation();
void updateShow();
void resetModeState(uint8_t mode);
float waveAt(float x, float t);
float plasmaField(float x, float y, float t);
float barEnvelope(int i, float t);
void renderWaveCross(float t, float fade);
void renderOrbitSweep(float t, float fade);
void renderPlasmaBloom(float t, float fade);
void renderRadarSweep(float t, float fade);
void renderVuSpectrum(float t, float fade);
void renderMatrixCascade(float t, float fade);

/* =======================
   MODE 3 STATE: radar blips
   ======================= */
struct Blip { float angle; float radiusFrac; float brightness; };
Blip blips[4] = {
  {0.70f, 0.85f, 0.0f},
  {2.30f, 0.55f, 0.0f},
  {4.10f, 0.95f, 0.0f},
  {5.40f, 0.35f, 0.0f}
};

/* =======================
   MODE 4 STATE: VU peak hold
   ======================= */
float peakLevel = 0.0f;

/* =======================
   MODE 5 STATE: matrix columns
   ======================= */
const int NUM_COLS = 16;
float prevDropPos[NUM_COLS];
float flashBrightness[NUMPIXELS];

/* =======================
   SETUP
   ======================= */
void setup() {
  pinMode(PC13_LED, OUTPUT);
  digitalWrite(PC13_LED, HIGH); // OFF (active low)

  pinMode(WS_PIN, OUTPUT);
  pixels.begin();
  pixels.setBrightness(MAX_BRIGHTNESS);
  pixels.clear();
  pixels.show();

  delay(300); // OLED power-up delay

  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
    while (1);
  }

  delay(100);
  ntxBootAnimation();

  resetModeState(currentMode);
  frameMillis = millis();
  modeStartMs = millis();
}

/* =======================
   LOOP
   ======================= */
void loop() {
  blinkPC13();
  updateShow();
}

/* =======================
   PC13 HEARTBEAT
   ======================= */
void blinkPC13() {
  if (millis() - pc13Millis >= 500) {
    pc13Millis = millis();
    pc13State = !pc13State;
    digitalWrite(PC13_LED, pc13State ? LOW : HIGH);
  }
}

/* =======================
   MODE 0: WAVE CROSS
   OLED draws a scrolling waveform. The LED strip is treated as
   pixels sitting just past x=127, so it displays the SAME wave
   function continuing off the edge of the screen.
   ======================= */
float waveAt(float x, float t) {
  return 0.6f * fastSinF(x * 0.055f + t) + 0.4f * fastSinF(x * 0.13f - t * 1.35f);
}

void renderWaveCross(float t, float fade) {
  display.clearDisplay();

  int prevY = -1;
  for (int x = 0; x < SCREEN_WIDTH; x++) {
    float v = waveAt((float)x, t);
    int y = (SCREEN_HEIGHT / 2) + (int)(v * (SCREEN_HEIGHT / 2 - 3));
    if (prevY >= 0) display.drawLine(x - 1, prevY, x, y, SSD1306_WHITE);
    prevY = y;
  }
  display.display();

  for (int i = 0; i < NUMPIXELS; i++) {
    float ledX = SCREEN_WIDTH + i * 7.0f;
    float v = waveAt(ledX, t);
    float amp = (v + 1.0f) * 0.5f;

    uint16_t hue = (uint16_t)(30000 + amp * 20000); // blue -> violet -> pink band
    uint8_t val  = (uint8_t)(amp * 255);
    pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(hue, 255, val)));
  }
  pixels.setBrightness((uint8_t)(MAX_BRIGHTNESS * fade));
  pixels.show();
}

/* =======================
   MODE 1: ORBIT SWEEP
   One rotating angle drives a dot orbiting on the OLED (with a
   short fading trail) AND a comet sweep on the LED ring at the
   same angular position.
   ======================= */
void renderOrbitSweep(float t, float fade) {
  display.clearDisplay();
  display.setTextSize(1);
  display.setTextColor(SSD1306_WHITE);
  display.setCursor(43, 3);
  display.print("ORBIT-Q");

  const int cx = 64, cy = 20, rx = 50, ry = 9;
  for (int trail = 5; trail >= 0; trail--) {
    float a = t - trail * 0.15f;
    int dx = cx + (int)(rx * fastCosF(a));
    int dy = cy + (int)(ry * fastSinF(a));
    if (trail == 0) display.fillCircle(dx, dy, 2, SSD1306_WHITE);
    else            display.drawPixel(dx, dy, SSD1306_WHITE);
  }
  display.display();

  float angleNorm = wrapAngle(t);
  float pos = (angleNorm / (2.0f * PI)) * NUMPIXELS;
  uint16_t baseHue = (uint16_t)(angleNorm / (2.0f * PI) * 65535.0f);

  for (int i = 0; i < NUMPIXELS; i++) {
    float d = fabsf((float)i - pos);
    d = fminf(d, NUMPIXELS - d);
    float b = fmaxf(0.0f, 1.0f - d / 2.2f);

    uint8_t val = (uint8_t)(b * 255);
    pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(baseHue, 255, val)));
  }
  pixels.setBrightness((uint8_t)(MAX_BRIGHTNESS * fade));
  pixels.show();
}

/* =======================
   MODE 2: PLASMA BLOOM
   Classic three-harmonic plasma field, rendered on the OLED at
   quarter resolution (32x8 grid, blitted as 4x4 blocks) with a
   two-level dither to fake a mid-tone on a 1-bit display. The
   LED strip samples the exact same plasmaField() at x positions
   past the screen edge - identical mechanism to Mode 0's "shared
   coordinate space", just with a richer 2D field.
   ======================= */
float plasmaField(float x, float y, float t) {
  return fastSinF(x * 0.10f + t)
       + fastSinF(y * 0.18f - t * 0.8f)
       + fastSinF((x + y) * 0.05f + t * 1.3f);   // range approx -3..3
}

void renderPlasmaBloom(float t, float fade) {
  display.clearDisplay();
  const int gridCols = 32, gridRows = 8;
  const int bw = SCREEN_WIDTH / gridCols;   // 4
  const int bh = SCREEN_HEIGHT / gridRows;  // 4

  for (int gy = 0; gy < gridRows; gy++) {
    for (int gx = 0; gx < gridCols; gx++) {
      float px = gx * bw + bw * 0.5f;
      float py = gy * bh + bh * 0.5f;
      float n = plasmaField(px, py, t) / 3.0f;   // -1..1

      if (n > 0.35f) {
        display.fillRect(gx * bw, gy * bh, bw, bh, SSD1306_WHITE);       // bright block
      } else if (n > -0.15f) {
        display.fillRect(gx * bw + 1, gy * bh + 1, bw - 2, bh - 2, SSD1306_WHITE); // mid-tone dot
      }
    }
  }
  display.display();

  for (int i = 0; i < NUMPIXELS; i++) {
    float ledX = SCREEN_WIDTH + i * 9.0f;
    float n = constrain(plasmaField(ledX, SCREEN_HEIGHT * 0.5f, t) / 3.0f, -1.0f, 1.0f);
    float amp = (n + 1.0f) * 0.5f;

    uint16_t hue = (uint16_t)(38000 + amp * 10000); // teal -> green -> lime, distinct from Mode 0's palette
    uint8_t val  = (uint8_t)(amp * 255);
    pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(hue, 255, val)));
  }
  pixels.setBrightness((uint8_t)(MAX_BRIGHTNESS * fade));
  pixels.show();
}

/* =======================
   MODE 3: RADAR SWEEP
   A sweep line rotates around a dotted elliptical boundary. Four
   fixed "targets" flash and decay when the sweep passes their
   angle. The LED strip mirrors the sweep as a crisp point (not a
   soft comet, to read as a radar blip) and overlays a white flash
   on whichever LED corresponds to a target's angle while it's lit.
   ======================= */
void renderRadarSweep(float t, float fade) {
  display.clearDisplay();
  const int cx = 64, cy = 16;
  const float rx = 58.0f, ry = 13.0f;

  for (uint8_t s = 0; s < 252; s += 6) {
    int fx = cx + (int)(rx * cosTab(s));
    int fy = cy + (int)(ry * sinTab(s));
    display.drawPixel(fx, fy, SSD1306_WHITE);
  }

  float a = wrapAngle(t * 0.6f);
  int ex = cx + (int)(rx * fastCosF(a));
  int ey = cy + (int)(ry * fastSinF(a));
  display.drawLine(cx, cy, ex, ey, SSD1306_WHITE);

  for (int i = 0; i < 4; i++) {
    float d = fabsf(wrapAngle(a - blips[i].angle));
    if (d > PI) d = 2.0f * PI - d;
    if (d < 0.12f) blips[i].brightness = 1.0f;      // sweep just crossed this target
    else           blips[i].brightness *= 0.93f;     // decay (~20 ms/frame)

    int bx = cx + (int)(blips[i].radiusFrac * rx * fastCosF(blips[i].angle));
    int by = cy + (int)(blips[i].radiusFrac * ry * fastSinF(blips[i].angle));
    if (blips[i].brightness > 0.15f) display.fillCircle(bx, by, 2, SSD1306_WHITE);
    else                             display.drawPixel(bx, by, SSD1306_WHITE);
  }
  display.display();

  float pos = (a / (2.0f * PI)) * NUMPIXELS;
  for (int i = 0; i < NUMPIXELS; i++) {
    float d = fabsf((float)i - pos);
    d = fminf(d, NUMPIXELS - d);
    float sweepB = fmaxf(0.0f, 1.0f - d / 1.3f);      // sharper falloff than Mode 1's comet
    uint32_t c = pixels.gamma32(pixels.ColorHSV(24000, 255, (uint8_t)(sweepB * 200)));

    for (int b = 0; b < 4; b++) {
      if (blips[b].brightness > 0.05f) {
        float bpos = (blips[b].angle / (2.0f * PI)) * NUMPIXELS;
        float bd = fabsf((float)i - bpos);
        bd = fminf(bd, NUMPIXELS - bd);
        if (bd < 1.0f) c = pixels.gamma32(pixels.ColorHSV(0, 0, (uint8_t)(blips[b].brightness * 255)));
      }
    }
    pixels.setPixelColor(i, c);
  }
  pixels.setBrightness((uint8_t)(MAX_BRIGHTNESS * fade));
  pixels.show();
}

/* =======================
   MODE 4: VU SPECTRUM (simulated envelope, not real audio)
   8 bars, each driven by its own sum of two sine harmonics so
   they move independently rather than in lockstep. The LED strip
   acts as a level meter of the averaged "loudness" with a
   decaying peak-hold marker - a standard VU meter behavior.
   ======================= */
float barEnvelope(int i, float t) {
  float base   = 0.5f + 0.5f * fastSinF(t * (0.9f + 0.17f * i) + i * 0.85f);
  float jitter = 0.5f + 0.5f * fastSinF(t * (3.1f + 0.4f * i) + i * 2.3f);
  return base * 0.7f + jitter * 0.3f;   // 0..1
}

void renderVuSpectrum(float t, float fade) {
  display.clearDisplay();
  const int NUM_BARS = 8;
  const int barW = 12, gap = 4;
  const int totalW = NUM_BARS * barW + (NUM_BARS - 1) * gap;
  int startX = (SCREEN_WIDTH - totalW) / 2;
  float levelSum = 0.0f;

  for (int i = 0; i < NUM_BARS; i++) {
    float env = barEnvelope(i, t);
    levelSum += env;
    int h = (int)(env * (SCREEN_HEIGHT - 4));
    int x = startX + i * (barW + gap);
    int y = SCREEN_HEIGHT - 1 - h;
    display.fillRect(x, y, barW, h, SSD1306_WHITE);
  }
  display.display();

  float level = levelSum / NUM_BARS;
  peakLevel = fmaxf(level, peakLevel * 0.985f);

  int lit = (int)(level * NUMPIXELS);
  for (int i = 0; i < NUMPIXELS; i++) {
    if (i < lit) {
      float frac = (float)i / (NUMPIXELS - 1);
      uint16_t hue = (uint16_t)(25000 - frac * 25000);   // green -> red along the strip
      pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(hue, 255, 255)));
    } else {
      pixels.setPixelColor(i, 0);
    }
  }
  int peakIdx = constrain((int)(peakLevel * NUMPIXELS), 0, NUMPIXELS - 1);
  pixels.setPixelColor(peakIdx, pixels.Color(255, 255, 255));   // peak-hold marker

  pixels.setBrightness((uint8_t)(MAX_BRIGHTNESS * fade));
  pixels.show();
}

/* =======================
   MODE 5: MATRIX CASCADE
   Falling column drops, purely integer/positional (no trig) for
   variety in technique. Each column's vertical position is a
   direct function of the shared phase (fmodf), so no per-frame
   integration is needed. When a column's drop crosses the bottom
   of the OLED, the mapped LED gets a bright flash - the drop
   "falling off the screen" and continuing on the strip, the same
   metaphor as Mode 0.
   ======================= */
void resetModeState(uint8_t mode) {
  switch (mode) {
    case 3:
      for (int i = 0; i < 4; i++) blips[i].brightness = 0.0f;
      break;
    case 4:
      peakLevel = 0.0f;
      break;
    case 5:
      for (int i = 0; i < NUM_COLS; i++) {
        float speed  = 1.4f + (i % 5) * 0.35f;
        float offset = i * 17.3f;
        prevDropPos[i] = fmodf(phase * speed * 6.0f + offset, SCREEN_HEIGHT + 10.0f);
      }
      for (int i = 0; i < NUMPIXELS; i++) flashBrightness[i] = 0.0f;
      break;
  }
}

void renderMatrixCascade(float t, float fade) {
  display.clearDisplay();
  const int colW = SCREEN_WIDTH / NUM_COLS;   // 8
  const int trailLen = 10;
  const float cycleLen = SCREEN_HEIGHT + trailLen;

  for (int i = 0; i < NUM_COLS; i++) {
    float speed  = 1.4f + (i % 5) * 0.35f;
    float offset = i * 17.3f;
    float dropPos = fmodf(t * speed * 6.0f + offset, cycleLen);

    if (dropPos < prevDropPos[i]) {                      // wrapped -> fell off the bottom
      int ledIdx = (i * NUMPIXELS) / NUM_COLS;
      flashBrightness[ledIdx] = 1.0f;
    }
    prevDropPos[i] = dropPos;

    int headY = (int)dropPos;
    int x = i * colW + colW / 2;
    display.drawFastVLine(x, headY - 2, 3, SSD1306_WHITE);
    for (int k = 4; k < trailLen; k += 3) {
      int ty = headY - k;
      if (ty >= 0 && ty < SCREEN_HEIGHT) display.drawPixel(x, ty, SSD1306_WHITE);
    }
  }
  display.display();

  for (int i = 0; i < NUMPIXELS; i++) {
    float ambient = 0.10f + 0.05f * fastSinF(t * 0.5f + i * 0.7f);
    float b = fmaxf(ambient, flashBrightness[i]);
    pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(27000, 255, (uint8_t)(constrain(b, 0.0f, 1.0f) * 255))));
    flashBrightness[i] *= 0.80f;
  }
  pixels.setBrightness((uint8_t)(MAX_BRIGHTNESS * fade));
  pixels.show();
}

/* =======================
   SHOW SEQUENCER
   Advances the shared phase clock once per frame, handles mode
   switching using each mode's own duration, resets mode-local
   state on entry, and computes a fade multiplier at each mode's
   edges so transitions aren't an abrupt cut.
   ======================= */
void updateShow() {
  unsigned long now = millis();
  if (now - frameMillis < FRAME_MS) return;
  frameMillis = now;

  unsigned long elapsed = now - modeStartMs;
  if (elapsed >= MODE_DURATION[currentMode]) {
    currentMode = (currentMode + 1) % NUM_MODES;
    modeStartMs = now;
    elapsed = 0;
    resetModeState(currentMode);
  }

  float fade = 1.0f;
  uint32_t dur = MODE_DURATION[currentMode];
  if (elapsed < TRANSITION_MS) {
    fade = (float)elapsed / TRANSITION_MS;
  } else if (elapsed > dur - TRANSITION_MS) {
    fade = (float)(dur - elapsed) / TRANSITION_MS;
  }
  fade = constrain(fade, 0.0f, 1.0f);

  switch (currentMode) {
    case 0: renderWaveCross(phase, fade);     break;
    case 1: renderOrbitSweep(phase, fade);    break;
    case 2: renderPlasmaBloom(phase, fade);   break;
    case 3: renderRadarSweep(phase, fade);    break;
    case 4: renderVuSpectrum(phase, fade);    break;
    case 5: renderMatrixCascade(phase, fade); break;
  }

  phase += PHASE_STEP;
}

/* =======================
   NTX SYSTEMS OLED BOOT LOGO
   ======================= */
void ntxBootAnimation() {
  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);

  int16_t x1, y1;
  uint16_t wNTX, hNTX;
  uint16_t wSYS, hSYS;

  display.setFont(&FreeSansBold9pt7b);
  display.getTextBounds("NTX", 0, 0, &x1, &y1, &wNTX, &hNTX);

  display.setFont(&FreeSans9pt7b);
  display.getTextBounds("SYSTEMS", 0, 0, &x1, &y1, &wSYS, &hSYS);

  int totalWidth = wNTX + 4 + wSYS;
  int startX = (SCREEN_WIDTH - totalWidth) / 2;
  int baselineY = 22;

  for (int x = -totalWidth; x <= startX; x += 2) {
    display.clearDisplay();

    display.setFont(&FreeSansBold9pt7b);
    display.setCursor(x, baselineY);
    display.print("NTX");

    display.setFont(&FreeSans9pt7b);
    display.setCursor(x + wNTX + 4, baselineY);
    display.print("SYSTEMS");

    display.display();
    delay(20);
  }

  for (int i = 0; i <= totalWidth; i += 4) {
    display.drawLine(startX, 30, startX + i, 30, SSD1306_WHITE);
    display.display();
    delay(8);
  }

  // LED boot wipe - a color band chases across the strip using the same
  // sine table as the main show, instead of a flat single-color flash
  for (int step = 0; step < NUMPIXELS + 6; step++) {
    for (int i = 0; i < NUMPIXELS; i++) {
      int d = step - i;
      if (d >= 0 && d < 6) {
        uint16_t hue = (uint16_t)(40000 + i * 2000);
        uint8_t val = (uint8_t)(255 - d * 40);
        pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV(hue, 255, val)));
      } else {
        pixels.setPixelColor(i, 0);
      }
    }
    pixels.show();
    delay(25);
  }
  delay(150);
  pixels.clear();
  pixels.show();

  display.setFont(); // reset to default font for main show
}

4. Core concept: one shared clock drives everything

Almost every animation bug in multi-effect demos comes from each effect keeping its own independent counter, which drifts and desyncs. This sketch avoids that with a single global:

float phase = 0.0f;
const float PHASE_STEP = 0.045f;
phase advances by PHASE_STEP exactly once per rendered frame, in updateShow(). Every mode's math is a function of this one variable — waveAt(x, t), plasmaField(x, y, t), barEnvelope(i, t) all take t as an argument, and that t is always phase. The payoff is Mode 0: the OLED draws waveAt(x, t) for x from 0–127. The LED strip then calls the same function for x = 128, 135, 142... — positions that would sit just past the right edge of the screen if it kept going. The strip isn't imitating the wave; it's mathematically continuing it. Mode 2 (plasma) does the identical trick in 2D. This is why PHASE_STEP is the one knob that speeds up or slows down the entire show in perfect sync — change it and both the screen and the strip move together, because they're reading the same clock.

5. Why there's a sine lookup table (even with a hardware FPU)

const int8_t SINE_TABLE[256] = { ... };
inline float fastSinF(float radians) { return sinTab(angleIdx(radians)); }
The STM32F405RGT6 has a hardware FPU, so sinf()/cosf() are hardware-accelerated on this card, not software-emulated — this is a different situation from the F103 "blue pill," where the same LUT is load-bearing because that MCU has no FPU at all. * A 256-entry table lookup is still cheaper per call than a full transcendental function, so it's not wasted effort even with an FPU present. * It keeps the math identical across every ORBIT-Q MCU card — F103, F405, and future cards on the roadmap — so the same sketch behaves identically regardless of which card it's running on

If you're optimizing purely for the F405RGT6's headroom and don't need that cross-card consistency, you can swap fastSinF/fastCosF for direct sinf/cosf calls — the hardware FPU handles it comfortably inside the 20 ms frame budget. * angleIdx(radians) — converts any angle (including negative or >2π) into an index 0–255 * sinTab(idx) / cosTab(idx) — table lookup, normalized back to −1.0..1.0 (cosine is just sine read 64 slots ahead, since cos(θ) = sin(θ + π/2), and 64/256 = a quarter turn) * fastSinF() / fastCosF() — the drop-in replacements used everywhere else in the code

If you're porting this to classic AVR (Uno/Nano): wrap the table in PROGMEM and read with pgm_read_byte(), since AVR doesn't have the flash-mapped memory that STM32/ESP32/SAMD have. On F405, no changes needed.

6. The non-blocking timing pattern

Nothing in the main show uses delay() — that would freeze the LED strip while the OLED draws, or vice versa. Instead it's a classic millis()-paced state machine:

void loop() {
  blinkPC13();
  updateShow();
}
blinkPC13() toggles the status LED every 500 ms by checking elapsed time, not by sleeping. updateShow() does the same for frame pacing:
if (now - frameMillis < FRAME_MS) return;   // not time for a new frame yet
This means loop() runs continuously and returns instantly most of the time — both timers just check "has enough time passed?" independently, so they never block each other. The only place delay() shows up at all is ntxBootAnimation(), which is explicitly a one-time sequence at boot, before the main loop starts.

7. How mode switching and fades work

const uint32_t MODE_DURATION[NUM_MODES] = { 16000, 15000, 17000, 15000, 17000, 14000 };
Each mode gets its own duration (tunable per-effect, not one fixed length for all six). updateShow() checks elapsed time in the current mode against this array; when it expires, it advances to the next mode and calls resetModeState(currentMode). That reset call matters: Mode 3's radar blips, Mode 4's peak-hold level, and Mode 5's column phases are all stateful between frames. Without resetting them on mode entry, switching into radar sweep could start with a blip already at full brightness from whatever value was left over — visually wrong, and confusing to debug. resetModeState() zeroes exactly the state each mode owns. The fade multiplier is computed the same way for every mode:
if (elapsed < TRANSITION_MS)        fade = elapsed / TRANSITION_MS;        // fading in
else if (elapsed > dur - TRANSITION_MS) fade = (dur - elapsed) / TRANSITION_MS; // fading out
fade (0.0–1.0) is passed into every render function and multiplied into pixels.setBrightness(). The OLED doesn't fade (1-bit display, no brightness channel to scale) — only the LED strip dims at transition edges.

8. Walking through each mode

Mode 0 — Wave Cross: waveAt(x, t) sums two sine waves at different frequencies and speeds (0.6*sin(x*0.055+t) + 0.4*sin(x*0.13-1.35t)), producing a non-repeating scrolling waveform rather than a single clean sine. The LED strip samples this same function at x = 128 + i*7.

Mode 1 — Orbit Sweep: a single angle t drives an elliptical orbit (rx=50, ry=9) with a 6-sample fading trail on the OLED, drawn oldest-to-newest so the newest point is a filled circle and the rest are single pixels. The LED strip maps the same angle to a strip position (angleNorm / 2π * NUMPIXELS) and colors the whole strip by hue-shifting with that angle, creating a soft comet via a falloff function (1 - d/2.2).

Mode 2 — Plasma Bloom: plasmaField(x, y, t) sums three sine harmonics at different (x, y, t) weightings — the classic three-oscillator plasma technique. Since the OLED is 1-bit (no grayscale), the sketch renders at quarter resolution (32×8 grid of 4×4 blocks) and fakes a mid-tone with two brightness thresholds instead of one, giving a rough 3-level dither. The strip again samples the identical field function off-screen.

Mode 3 — Radar Sweep: a dotted ellipse outline is drawn once per frame using the sine table directly (cosTab(s)/sinTab(s) stepped by index, not angle — slightly cheaper than calling fastCosF repeatedly). Four fixed target blips (defined once in the Blip struct array) light up when the sweep angle passes within 0.12 radians of them, then decay by ×0.93 per frame — an exponential decay, not a linear one, which is why the flash feels like a fade rather than a countdown. The strip mirrors the sweep as a sharp point (steeper falloff, d/1.3 vs Mode 1's d/2.2) deliberately, so it reads as a hard radar blip rather than a soft comet.

Mode 4 — VU Spectrum (labeled honestly as simulated): there is no microphone on this board. barEnvelope(i, t) fakes an audio-reactive look by summing a slow "base" sine and a faster "jitter" sine per bar, each with a different frequency multiplier and phase offset per bar index i — this is what makes the 8 bars move independently instead of in lockstep. The strip becomes a level meter: peakLevel uses a decay formula (fmaxf(level, peakLevel * 0.985)) that's a standard VU meter behavior — it snaps up instantly on a peak but drifts down slowly, so the peak-hold marker is readable.

Mode 5 — Matrix Cascade: deliberately avoids trig entirely, for technique variety. Each of 16 columns has a fixed speed and offset, and its vertical position is fmodf(t * speed * 6 + offset, cycleLen) — a direct position function, not something integrated frame-by-frame. Because prevDropPos[i] is compared against the current position each frame, a wraparound (dropPos < prevDropPos[i]) is detected the instant a column's drop crosses back to the top — which visually means it just fell off the bottom of the screen. That's the trigger for flashing the mapped LED, using the same "screen edge continues onto the strip" metaphor as Mode 0, just triggered by an event instead of continuous sampling.

9. The boot sequence

ntxBootAnimation() runs once in setup(), before the main loop starts — it's the one place delay() is acceptable, since nothing else needs to run concurrently at boot: * Slides "NTX" (bold font) + "SYSTEMS" (regular font) in from off-screen left, redrawing every 2px * Draws an underline that grows left-to-right beneath the wordmark * Chases a color band across the LED strip using the same SINE_TABLE-backed HSV coloring as the main show, rather than a flat single-color flash — visually ties the boot sequence to the show that follows

10. Uploading

  • Install the STM32 board package and the three Adafruit libraries listed above.
  • Board: select the STM32F405RGT6 variant in Boards Manager (STM32duino).
  • Upload method: ST-Link (or your usual method for the ORBIT-Q board).
  • Wire OLED to I2C1, strip data to PB3, confirm 3.3V power to both.
  • Upload. On first boot, you should see the NTX wordmark slide in, followed by the LED color-chase, then Mode 0 starting.

11. Tuning it for your own build

  • Overall speed: PHASE_STEP — smaller = slower, larger = faster. This shifts every mode at once since they all share phase.
  • Time per mode: edit MODE_DURATION[] — each entry is milliseconds for that mode index.
  • Brightness ceiling: MAX_BRIGHTNESS (0–255) — this is a cap, not a fixed value; fade still scales beneath it during transitions.
  • Frame rate: FRAME_MS — 20 ms targets ~50 fps; raising this reduces CPU load if you add more modes later. On F405, there's real headroom to push this lower if you add more modes.

12. If something doesn't come up

  • OLED stays blank / sketch hangs at boot: display.begin() failing drops into while(1); intentionally — check the I2C address (0x3C here; some SSD1306 modules ship as 0x3D) and wiring first.
  • LEDs don't light: confirm WS_PIN matches your actual data-line connection, and that the strip's ground is common with the board's ground.
  • Animations look wrong-speed but not frozen: check PHASE_STEP and FRAME_MS — if FRAME_MS is too small for the board to keep up, frames will still run but pacing may drift under load.
  • Porting to AVR (Uno/Nano) and it won't compile or behaves oddly: wrap SINE_TABLE in PROGMEM and switch lookups to pgm_read_byte() — this table is currently written assuming flash-mapped memory access (fine on STM32/ESP32/SAMD, not on classic AVR).