Project 1 : Zen FM
Short Description
Zen FM is a radio with one station — the calm channel. It sits somewhere on the dial, and the device asks the user to turn the knob to find it. There is a breathing light and audio signal that guides the user – erratic at first, but then starts to settle out of the static. If the user turns too fast, it keeps you in the noise. In order to find calm, the device forces the user to slow down and settle into a state of calm. Every few minutes, the channel drifts. Finding calm rewards you with an animation of a star, leaf or cat displayed on the OLED screen.
Sensor and Output Used
Inputs:
- 10K potentiometer as a tuning knob
Outputs:
- LED (visual) as a guide that flickers and dims from calm, steadies and brightens closer and breathes when tuned
- 128x64 OLED screen (visual) that warbles as static and resolves into a shape (circle, leaf, cat, star, grass) when tuned
- Speaker (sonic) that sounds glitchy and static-y; wind chime and a breathing hum on the calm channel
Other:
- Arduino Uno as the controller
- 3D printed enclosure as the form that houses the circuitry

Interaction description
What the person does: They can choose a session length in the start screen, then slowly turn the knob to search for the calm channel. Once they find it, they rest there. When the channel drifts they notice and tune back. If used as a timer, then that moment of calm session length is the amount of time chosen to do the activity.
What the sensor detects: Where the dial is and how fast its moving. The Arduino compares that position to the hidden channel’s location, and separately tracks turning speed.
How the object responds:
- Far from the channel: the LED is dim and flickers erratically, the speaker crackles with harsh static, and the screen shows a warbling, unstable circle.
- Getting closer: the light steadies and brightens, the static thins, and the circle starts to hold its form.
- On the channel and turning slowly: the light breathes in a slow, even rhythm, the sound becomes chimes and a soft hum, and a shape settles out of the static.
- On the channel but turning fast: static returns. Clarity only comes when you slow down.
- When the timer ends: the channel jumps to a new spot at least a quarter of the dial away, and the object falls back into noise until it’s found again.
- Holding the knob all the way to the left brings back the reset screen, where you can pick 5, 15 or 25 minutes again.
Circuit / Wiring Diagram
Everything runs on an Arduino Uno. The knob is a 10K potentiometer on A0. The LED goes through a 220Ω resistor to D5, the OLED connects to A4 and A5, and the speaker goes through a resistor to D9.
Right before my first presentation the dial stopped responding and the OLED went blank. I had wired the speaker without a resistor and it kept resetting the Arduino. I found it by rebuilding one part at a time.
The first breadboard version, with a piezo buzzer before I switched to a speaker.
Code
The full sketch is below, or you can download zenfm.ino. It needs the Adafruit SSD1306 and Adafruit GFX libraries.
What the code does:
- Reads where the knob is and how fast it’s turning. Turning fast keeps it in static, even on the channel.
- When the countdown ends, moves the channel to a new spot at least a quarter of the dial away.
- Plays sound with a small synthesizer instead of
tone(), so the chime and hum sound smoother. - Stores the silhouette outlines ahead of time. Calculating them live froze the screen.
// zen.fm: a radio with one station, and the station is calm.
//
// The calm channel hides somewhere on the dial. Tune to it slowly and the static
// clears: a wind chime rings, then a soft hum breathes with you, and the warbling
// circle settles into a silhouette (the circle itself, a leaf, a sleeping cat, a
// star, grass) that breathes on the screen while a countdown runs. When the countdown ends, the channel drifts somewhere else and you have
// to find it again. Turn away before it ends and the countdown starts over.
// Turning fast keeps everything in static: you can't scroll your way to calm.
//
// Setting the timer: after power-on, the dial picks the countdown (5 / 15 / 25
// minutes). Stop turning and it starts. To change it later, turn the knob all the
// way left and hold for 2 seconds (like switching a radio off): the timer screen
// comes back. Each time the signal scrambles, the next graphic is lined up.
//
// Board: Arduino Uno
// Libraries (Library Manager): Adafruit SSD1306, Adafruit GFX
//
// Wiring (same as before)
// Pot: outer pins -> 5V and GND, middle pin -> A0
// LED: D5 -> 220-330 ohm resistor -> LED long leg; short leg -> GND
// OLED: SDA -> A4, SCL -> A5, VCC -> 5V, GND -> GND
// Speaker: D9 -> 100-470 ohm resistor -> speaker; other speaker wire -> GND
// (smaller resistor = louder; 220 is a good library volume)
//
// Sound: the speaker is driven with a small synthesizer (smooth sine tones with
// real volume, not tone()'s square-wave beeps). It uses the Uno's Timer1 and
// Timer2, so don't add tone() or Servo to this sketch, and keep the speaker on D9.
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <avr/pgmspace.h>
#include <util/atomic.h>
// ------------------------------------------------------------------ settings
const bool USE_OLED = true;
const bool SERIAL_PLOTTER = false; // true: dial, channel, guide and clarity in the Serial Plotter (115200)
const bool STREAM_FRAMES = false; // screen capture with capture.py (turns off the plotter)
// Serial is left out entirely when both are false; the Uno needs that memory for the screen.
// Timer choices, picked with the dial at power-on.
const uint8_t NUM_DURATIONS = 3;
const uint8_t DURATIONS[NUM_DURATIONS] = {5, 15, 25};
const bool DEMO_SECONDS = false; // true: 5 / 15 / 25 SECONDS, for showing it in critique
const unsigned long SET_MIN_MS = 8000; // the timer screen stays at least this long
const unsigned long SET_IDLE_MS = 4000; // ...and until the dial has been still this long
// Reset: turn the knob all the way left and hold it to go back to the timer screen.
const int RESET_ZONE = 15; // dial position (0-1000) that counts as all the way left
const unsigned long RESET_HOLD_MS = 2000;
// Volumes, 0-255. MASTER scales everything.
const uint8_t MASTER_VOLUME = 255;
const uint8_t CHIME_LEVEL = 180;
const uint8_t HUM_LEVEL = 180;
const uint8_t CHATTER_LEVEL = 120; // the static: irritating, not loud
const int POT_PIN = A0;
const int LED_PIN = 5; // PWM (Timer0)
const int SPK_PIN = 9; // must be D9 (Timer1 PWM)
const int OLED_WIDTH = 128;
const int OLED_HEIGHT = 64;
const uint8_t OLED_ADDR = 0x3C;
const uint8_t OLED_ROTATION = 0; // 0 = normal, 2 = upside down (flip if your screen reads upside down)
Adafruit_SSD1306 oled(OLED_WIDTH, OLED_HEIGHT, &Wire, -1);
// The hidden station (dial runs 0-1000)
const int STATION_MIN = 80;
const int STATION_MAX = 920;
const int MIN_JUMP = 250; // a new station lands at least this far from your hand
const int CLEAR_BAND = 30; // fully clear within this distance
const int FRINGE = 220; // static fades over this zone
const float SPEED_CALM = 4.0; // per-frame movement at or below this = no penalty
const float SPEED_LOST = 20.0; // at or above this = pure static
// Breathing: in for 40% of the breath, out for 60%. It starts at 5 s per breath
// and slows to 8 s the longer you stay.
const float BREATH_START = 5.0;
const float BREATH_END = 8.0;
const float DEEPEN_SEC = 60.0;
const unsigned long FIND_MS = 400; // hold clear this long to count as found
const unsigned long LOSE_MS = 1000; // off the channel this long resets the countdown
// ------------------------------------------------------------------ synthesizer
// Timer1 makes a 62.5 kHz PWM carrier on D9 (too high to hear). Timer2 fires
// 7812 times a second and sets the PWM level to the next audio sample, so the
// speaker plays smooth waveforms at any volume.
// ---SYNTH-BEGIN---
const uint16_t SAMPLE_RATE = 7812;
const int8_t SINE[256] PROGMEM = {
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,
};
// Three chime voices (struck, ringing out), one hum, one noise, one chirp.
const uint8_t NUM_CHIMES = 3;
volatile uint16_t chimePhase[NUM_CHIMES], chimeInc[NUM_CHIMES], chimeAmp[NUM_CHIMES];
volatile uint16_t humPhase, humInc, humAmp, humTarget;
volatile uint16_t noiseAmp, noiseLen, lfsr = 0xACE1;
volatile uint16_t chirpPhase, chirpInc, chirpAmp, chirpTicks;
volatile int16_t chirpStep;
volatile uint8_t chirpDecay;
volatile uint8_t master = MASTER_VOLUME;
volatile uint8_t envCount;
inline int8_t sine8(uint8_t i) { return (int8_t)pgm_read_byte(&SINE[i]); }
// Every 64 samples (about 8 ms): chimes ring down, the hum glides toward its
// target, the chirp sweeps and fades.
inline void envTick() {
for (uint8_t v = 0; v < NUM_CHIMES; v++) chimeAmp[v] -= chimeAmp[v] >> 7; // ~1 s ring
if (humAmp < humTarget) humAmp = (humTarget - humAmp > 200) ? humAmp + 200 : humTarget;
else if (humAmp > humTarget) humAmp = (humAmp - humTarget > 200) ? humAmp - 200 : humTarget;
if (chirpTicks) {
chirpTicks--;
chirpInc += chirpStep;
chirpAmp -= chirpAmp >> chirpDecay;
if (!chirpTicks) chirpAmp = 0;
}
}
// One audio sample, centred on zero (-127..127).
inline int16_t synthSample() {
int16_t s = 0;
for (uint8_t v = 0; v < NUM_CHIMES; v++) {
uint8_t a = chimeAmp[v] >> 8;
if (a) s += (sine8(chimePhase[v] >> 8) * a) >> 8;
chimePhase[v] += chimeInc[v];
}
uint8_t h = humAmp >> 8;
if (h) {
// Fundamental plus a softer octave, for a rounder hum.
s += (sine8(humPhase >> 8) * h) >> 8;
s += (sine8((uint8_t)(humPhase >> 7)) * h) >> 9;
}
humPhase += humInc;
if (noiseLen) {
noiseLen--;
lfsr = (lfsr >> 1) ^ (-(int16_t)(lfsr & 1) & 0xB400);
s += ((int8_t)(lfsr & 0xFF) * (int16_t)(noiseAmp >> 8)) >> 8;
}
uint8_t c = chirpAmp >> 8;
if (c) s += (sine8(chirpPhase >> 8) * c) >> 8;
chirpPhase += chirpInc;
s = (int16_t)(((int32_t)s * master) >> 8);
if (s > 127) s = 127;
if (s < -127) s = -127;
if (++envCount >= 64) { envCount = 0; envTick(); }
return s;
}
// ---SYNTH-END---
volatile uint8_t bias = 0; // ramps up at power-on so the speaker doesn't pop
volatile uint8_t biasTick = 0;
ISR(TIMER2_COMPA_vect) {
int16_t s = synthSample();
if (bias < 128) {
if (++biasTick >= 8) { biasTick = 0; bias++; }
OCR1A = bias;
} else {
OCR1A = (uint8_t)(128 + s);
}
}
void audioBegin() {
pinMode(SPK_PIN, OUTPUT);
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
// Timer1: 8-bit fast PWM on OC1A (D9), no prescaler -> 62.5 kHz carrier
TCCR1A = _BV(COM1A1) | _BV(WGM10);
TCCR1B = _BV(WGM12) | _BV(CS10);
OCR1A = 0;
// Timer2: CTC, prescaler 8, TOP 255 -> 7812.5 Hz sample clock
TCCR2A = _BV(WGM21);
TCCR2B = _BV(CS21);
OCR2A = 255;
TCNT2 = 0;
TIMSK2 = _BV(OCIE2A);
}
}
uint16_t incFor(float hz) { return (uint16_t)(hz * 65536.0 / SAMPLE_RATE); }
uint8_t nextChime = 0;
void strike(float hz, uint8_t level) {
uint8_t v = nextChime;
nextChime = (nextChime + 1) % NUM_CHIMES;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
chimeInc[v] = incFor(hz);
chimeAmp[v] = (uint16_t)level << 8;
}
}
void noiseBurst(uint16_t ms, uint8_t level) {
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
noiseAmp = (uint16_t)level << 8;
noiseLen = (uint32_t)ms * SAMPLE_RATE / 1000;
}
}
// A pitch sweep from f0 to f1 over ms. decay: 0 = flat, 3-6 = fades as it goes.
void chirp(float f0, float f1, uint16_t ms, uint8_t level, uint8_t decay) {
uint16_t ticks = (uint32_t)ms * SAMPLE_RATE / 64 / 1000;
if (ticks == 0) ticks = 1;
int16_t step = ((int32_t)incFor(f1) - (int32_t)incFor(f0)) / ticks;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
chirpInc = incFor(f0);
chirpStep = step;
chirpTicks = ticks;
chirpDecay = decay ? decay : 15;
chirpAmp = (uint16_t)level << 8;
}
}
void setHum(float level01) { // 0..1 of HUM_LEVEL; the synth glides to it
uint16_t target = (uint16_t)(constrain(level01, 0.0, 1.0) * HUM_LEVEL) << 8;
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { humTarget = target; }
}
// Wind chime notes (G major pentatonic) over a G hum, so they always agree.
const float CHIME_NOTES[] PROGMEM = {784.0, 880.0, 987.8, 1174.7, 1318.5, 1568.0};
const uint8_t NUM_NOTES = sizeof(CHIME_NOTES) / sizeof(CHIME_NOTES[0]);
const float HUM_HZ = 196.0; // G3. Small speakers are weak this low; try 392 (G4) if it is hard to hear
// ------------------------------------------------------------------ state
enum Mode { SETTING, TUNING, FOUND };
Mode mode = SETTING;
uint8_t durIndex = 1;
unsigned long durationMs = 0;
unsigned long bootAt = 0, lastDialChange = 0;
int stationPos = 500;
uint8_t shapeIndex = 0; // which silhouette shows on the next find
bool newShapeDue = false; // switch graphics once the screen is back to the circle
unsigned long leftSince = 0; // how long the knob has been held all the way left
bool resetArmed = false; // the knob has to leave the left end before it can reset
unsigned long clearSince = 0, lostSince = 0, foundAt = 0;
float heldSec = 0;
float guideSmooth = 0;
float breathPhase = 0;
float smoothPos = 500, prevPos = 500, speedAvg = 0;
unsigned long lastFrame = 0;
bool oledOk = false;
// Chime sequence and static stutters, played out over several frames
uint8_t chimesLeft = 0;
unsigned long nextChimeAt = 0;
uint8_t stutterLeft = 0;
unsigned long nextStutterAt = 0;
uint8_t stutterLevel = 0;
unsigned long durationFor(uint8_t i) {
return (unsigned long)DURATIONS[i] * (DEMO_SECONDS ? 1000UL : 60000UL);
}
void newStation() {
int candidate = random(STATION_MIN, STATION_MAX + 1);
for (int tries = 0; tries < 20 && abs(candidate - (int)smoothPos) < MIN_JUMP; tries++) {
candidate = random(STATION_MIN, STATION_MAX + 1);
}
stationPos = candidate;
}
// ------------------------------------------------------------------ drawing
// Every graphic is a closed outline of NPTS points around its centre, the same
// way the warbling circle is drawn. While you tune in, each point slides from
// the wobbling circle to the shape, so the noise bends into the leaf (or cat,
// star, grass) and then the fill fades in with a dither. Each shape is
// measured once, at breath out and breath in, and blended every frame.
const uint8_t NPTS = 40; // multiple of 10 so the star's points land exactly
const uint8_t NUM_SHAPES = 5; // circle, leaf, cat, star, grass
const int8_t SHAPE_CX[NUM_SHAPES] = {64, 64, 92, 64, 64}; // where each shape sits on screen
const int8_t SHAPE_CY[NUM_SHAPES] = {32, 32, 46, 32, 62};
const uint8_t SHAPE_K[NUM_SHAPES] = {100, 125, 150, 100, 100}; // leaf and cat drawn larger (percent)
const int8_t CAT_PX = 74, CAT_PY = 50; // the cat is drawn around this point
const bool SHAPE_FILLED[NUM_SHAPES] = {false, true, true, true, true};
uint8_t shapeR0[NPTS], shapeR1[NPTS]; // radius x2 at breath out / breath in
int8_t polyX[NPTS], polyY[NPTS];
uint8_t preparedShape = 255;
const uint8_t BAYER[4][4] PROGMEM = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
float smooth01(float e0, float e1, float x) {
float k = constrain((x - e0) / (e1 - e0), 0.0, 1.0);
return k * k * (3 - 2 * k);
}
// Leaf outline: widest a little below the middle, pointed at the tip.
float leafWidth(float u) { float v = u * (1.25 - 0.25 * u); return sin(PI * v); }
void leafGeom(float b, float &ang, float &L, float &W) {
float sc = 0.85 + 0.25 * b;
ang = 0.15 + 0.3 * b; // lifts as you breathe in
L = 50 * sc;
W = 13 * sc;
}
bool inTriangle(float px, float py, float ax, float ay, float bx, float by, float cx, float cy) {
float d1 = (px - bx) * (ay - by) - (ax - bx) * (py - by);
float d2 = (px - cx) * (by - cy) - (bx - cx) * (py - cy);
float d3 = (px - ax) * (cy - ay) - (cx - ax) * (py - ay);
bool neg = (d1 < 0) || (d2 < 0) || (d3 < 0), pos = (d1 > 0) || (d2 > 0) || (d3 > 0);
return !(neg && pos);
}
float leafDx, leafDy, leafL, leafW; // the leaf being measured
void setLeaf(float b) { float ang; leafGeom(b, ang, leafL, leafW); leafDx = cos(ang); leafDy = -sin(ang); }
// Is the point (x, y), measured from the shape's centre, inside the shape?
bool insideShape(uint8_t s, float x, float y, float b) {
if (s == 1) { // leaf (geometry cached by prepareShape)
float u = x * leafDx + y * leafDy, v = -x * leafDy + y * leafDx;
if (u < -leafL / 2 || u > leafL / 2) return false;
return fabs(v) < leafW * leafWidth(u / leafL + 0.5);
}
// cat (screen coordinates)
float X = CAT_PX + x, Y = CAT_PY + y;
float ry = 10 + 3 * b, rx = 26 + 2 * b, cyb = 60 - ry;
float ex = (X - 46) / rx, ey = (Y - cyb) / ry;
if (ex * ex + ey * ey < 1) return true; // body
float hy = 45 - b; // head up, resting on the body
if ((X - 74) * (X - 74) + (Y - hy) * (Y - hy) < 81) return true; // head
if (inTriangle(X, Y, 66, hy - 4, 68, hy - 17, 75, hy - 8)) return true; // ears
if (inTriangle(X, Y, 74, hy - 8, 82, hy - 16, 83, hy - 3)) return true;
return X >= 18 && X <= 68 && Y >= 57 && Y <= 60; // tail curled in front
}
float shapeRadius(uint8_t s, uint8_t i, float b) {
switch (s) {
case 0: return 10 + 20 * b; // circle
case 3: { // star: tips every 8 points
float R = 13 + 17 * b, r = R * 0.42;
uint8_t o = (i + NPTS - 30) % NPTS; // index 30 points straight up
uint8_t k = o / 4;
float th = (o % 4) * (2 * PI / NPTS), seg = 4 * (2 * PI / NPTS);
float r0 = (k % 2 == 0) ? R : r, r1 = (k % 2 == 0) ? r : R;
return r0 * r1 * sin(seg) / (r0 * sin(th) + r1 * sin(seg - th));
}
case 4: { // grass: a tuft of 7 blades
static const int8_t LEN[7] PROGMEM = {28, 36, 32, 44, 34, 39, 26};
int8_t d = (int8_t)i - 30;
if (d < -7 || d > 7) return 2.5;
if (d % 2 == 0 && d >= -6 && d <= 6) return (int8_t)pgm_read_byte(&LEN[(d + 6) / 2]) * (0.94 + 0.44 * b);
return (d == -7 || d == 7) ? 3 : 7;
}
default: { // leaf, cat: measure along the ray
float a = i * (2 * PI / NPTS), k = SHAPE_K[s] / 100.0;
float c = cos(a) / k, sn = sin(a) / k; // measure in the shape's own size
for (float r = (s == 2 ? 60 : 34) * k; r > 0; r -= 1) {
if (insideShape(s, r * c, r * sn, b)) {
float lo = r, hi = r + 1;
for (uint8_t j = 0; j < 3; j++) {
float mid = (lo + hi) / 2;
if (insideShape(s, mid * c, mid * sn, b)) lo = mid; else hi = mid;
}
return lo;
}
}
return 0;
}
}
}
void prepareShape(uint8_t s) {
s %= NUM_SHAPES;
if (s == preparedShape) return;
setLeaf(0);
for (uint8_t i = 0; i < NPTS; i++) shapeR0[i] = (uint8_t)constrain(shapeRadius(s, i, 0) * 2, 0.0, 255.0);
setLeaf(1);
for (uint8_t i = 0; i < NPTS; i++) shapeR1[i] = (uint8_t)constrain(shapeRadius(s, i, 1) * 2, 0.0, 255.0);
preparedShape = s;
}
// Fill the outline, dithered: level 16 = solid, 0 = empty.
void fillPoly(uint8_t level) {
int8_t ymin = 127, ymax = -128;
for (uint8_t i = 0; i < NPTS; i++) { if (polyY[i] < ymin) ymin = polyY[i]; if (polyY[i] > ymax) ymax = polyY[i]; }
if (ymin < 0) ymin = 0;
if (ymax > OLED_HEIGHT - 1) ymax = OLED_HEIGHT - 1;
for (int8_t y = ymin; y <= ymax; y++) {
int16_t xs[8];
uint8_t n = 0;
for (uint8_t i = 0; i < NPTS; i++) {
uint8_t j = (i + 1) % NPTS;
int16_t y0 = polyY[i], y1 = polyY[j];
if ((y0 <= y) != (y1 <= y) && n < 8) {
xs[n++] = polyX[i] + (int16_t)(y - y0) * (polyX[j] - polyX[i]) / (y1 - y0);
}
}
for (uint8_t a = 1; a < n; a++) { // sort crossings
int16_t v = xs[a]; int8_t k = a - 1;
while (k >= 0 && xs[k] > v) { xs[k + 1] = xs[k]; k--; }
xs[k + 1] = v;
}
for (uint8_t k = 0; k + 1 < n; k += 2) {
if (level >= 16) oled.drawFastHLine(xs[k], y, xs[k + 1] - xs[k] + 1, SSD1306_WHITE);
else for (int16_t x = xs[k]; x <= xs[k + 1]; x++)
if (pgm_read_byte(&BAYER[y & 3][x & 3]) < level) oled.drawPixel(x, y, SSD1306_WHITE);
}
}
}
// Small details that appear once the shape has settled.
void drawDetails(uint8_t s, float b, float t, float exhale) {
if (s == 1) { // leaf: stem, midrib, veins
float ang, L, W, k = SHAPE_K[1] / 100.0;
leafGeom(b, ang, L, W);
L *= k; W *= k;
float dx = cos(ang), dy = -sin(ang), nx = -dy, ny = dx;
float bx = SHAPE_CX[1] - dx * L / 2, by = SHAPE_CY[1] - dy * L / 2;
oled.drawLine(bx - dx * 11, by - dy * 11 + 4, bx, by, SSD1306_WHITE);
oled.drawLine(bx - dx * 11 + 1, by - dy * 11 + 4, bx + 1, by, SSD1306_WHITE);
oled.drawLine(bx + dx * L * 0.08, by + dy * L * 0.08, bx + dx * L * 0.88, by + dy * L * 0.88, SSD1306_BLACK);
for (uint8_t k = 1; k <= 3; k++) {
float u = 0.2 * k, w = W * leafWidth(u) * 0.6, u2 = u + 0.13;
int mx = bx + dx * L * u, my = by + dy * L * u;
int ex = bx + dx * L * u2, ey = by + dy * L * u2;
oled.drawLine(mx, my, ex + nx * w, ey + ny * w, SSD1306_BLACK);
oled.drawLine(mx, my, ex - nx * w, ey - ny * w, SSD1306_BLACK);
}
} else if (s == 2) { // cat: closed eye, ground, z
float k = SHAPE_K[2] / 100.0;
int ex = SHAPE_CX[2] + (76 - CAT_PX) * k, ey = SHAPE_CY[2] + (45 - b - CAT_PY) * k;
oled.drawFastHLine(ex, ey, 6, SSD1306_BLACK); // closed eye
oled.drawPixel(ex - 1, ey - 1, SSD1306_BLACK);
oled.drawFastHLine(0, 63, 96, SSD1306_WHITE); // ground (stops before the timer)
if (exhale > 0) {
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(108 + exhale * 6, 22 - exhale * 14);
oled.print('z');
if (exhale > 0.4) { oled.setCursor(114 + exhale * 4, 12 - exhale * 10); oled.print('z'); }
}
} else if (s == 3) { // star: twinkles
static const int8_t SX[5] PROGMEM = {16, 110, 12, 104, 24};
static const int8_t SY[5] PROGMEM = {14, 10, 46, 36, 58};
for (uint8_t k = 0; k < 5; k++) {
float tw = sin(t * 1.9 + k * 1.7);
if (tw > 0.2) {
int sz = tw > 0.75 ? 2 : 1;
int8_t sx = pgm_read_byte(&SX[k]), sy = pgm_read_byte(&SY[k]);
oled.drawFastHLine(sx - sz, sy, 2 * sz + 1, SSD1306_WHITE);
oled.drawFastVLine(sx, sy - sz, 2 * sz + 1, SSD1306_WHITE);
}
}
} else if (s == 4) { // grass: ground
oled.drawFastHLine(8, 63, 88, SSD1306_WHITE); // ground (stops before the timer)
}
}
void drawStatic(float turbulence) {
int dots = turbulence * 220;
for (int i = 0; i < dots; i++) oled.drawPixel(random(OLED_WIDTH), random(OLED_HEIGHT), SSD1306_WHITE);
int lines = turbulence * 14;
for (int i = 0; i < lines; i++) {
int x = random(OLED_WIDTH), y = random(OLED_HEIGHT);
oled.drawLine(x, y, x + random(-18, 19), y + random(-10, 11), SSD1306_WHITE);
}
}
// The whole picture: static, and the warbling circle bending into this round's shape.
void drawScene(uint8_t which, float clarity, float b, float t, float exhale) {
uint8_t s = which % NUM_SHAPES;
float turbulence = 1.0 - clarity;
// Eased over a few frames so a nudge of the dial never makes the shape jump.
static float mEase = 0, fillEase = 0;
mEase += (smooth01(0.3, 0.9, clarity) - mEase) * 0.25; // circle -> shape
fillEase += (smooth01(0.75, 1.0, clarity) - fillEase) * 0.25; // dithered fill fades in
float m = mEase, fill = fillEase;
drawStatic(turbulence);
float cx = 64 + (SHAPE_CX[s] - 64) * m, cy = 32 + (SHAPE_CY[s] - 32) * m;
float rc = 10 + turbulence * 4, amp = turbulence * 9;
for (uint8_t i = 0; i < NPTS; i++) {
float a = i * (2 * PI / NPTS);
float rs = (shapeR0[i] + (shapeR1[i] - shapeR0[i]) * b) / 2.0;
if (s == 4) rs *= 1 + 0.05 * sin(t * 1.3 + i); // grass sways a little
float wob = sin(3 * a + t * 7) * 0.5 + sin(7 * a - t * 11) * 0.3;
float jt = (random(-100, 101) / 100.0) * 0.4;
float r = rc + (rs - rc) * m + amp * (wob + jt);
if (r < 0) r = 0;
polyX[i] = constrain(cx + r * cos(a), -120.0, 127.0);
polyY[i] = constrain(cy + r * sin(a), -120.0, 127.0);
}
if (SHAPE_FILLED[s] && fill > 0.02) fillPoly((uint8_t)(fill * 16 + 0.5));
for (uint8_t i = 0; i < NPTS; i++) {
uint8_t j = (i + 1) % NPTS;
oled.drawLine(polyX[i], polyY[i], polyX[j], polyY[j], SSD1306_WHITE);
}
if (m > 0.97 && fill > 0.9) drawDetails(s, b, t, exhale);
}
void drawCountdown(unsigned long remainMs) {
unsigned long secs = (remainMs + 999) / 1000;
char buf[7];
uint8_t n = 0;
unsigned long whole = DEMO_SECONDS ? secs : secs / 60;
if (whole >= 10) buf[n++] = '0' + (whole / 10) % 10;
buf[n++] = '0' + whole % 10;
if (DEMO_SECONDS) buf[n++] = 's';
else {
buf[n++] = ':';
buf[n++] = '0' + (secs % 60) / 10;
buf[n++] = '0' + secs % 10;
}
buf[n] = 0;
int w = strlen(buf) * 6; // bottom-right corner
oled.fillRect(OLED_WIDTH - w - 2, OLED_HEIGHT - 9, w + 2, 9, SSD1306_BLACK);
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(OLED_WIDTH - w, OLED_HEIGHT - 8);
oled.print(buf);
}
void drawSetting(float startFrac) {
oled.clearDisplay();
oled.setTextColor(SSD1306_WHITE);
oled.setTextSize(1);
oled.setCursor((OLED_WIDTH - 9 * 6) / 2, 0);
oled.print(F("set timer"));
int n = DURATIONS[durIndex];
oled.setTextSize(3);
int numW = (n >= 10 ? 2 : 1) * 18;
oled.setCursor((OLED_WIDTH - numW) / 2, 16);
oled.print(n);
oled.setTextSize(1);
oled.setCursor((OLED_WIDTH - 7 * 6) / 2, 44);
if (DEMO_SECONDS) oled.print(F("seconds"));
else oled.print(F("minutes"));
// three marks show where the choices sit on the dial
for (uint8_t i = 0; i < NUM_DURATIONS; i++) {
int x = 34 + i * 30;
if (i == durIndex) oled.fillRect(x - 3, 55, 7, 4, SSD1306_WHITE);
else oled.drawRect(x - 3, 55, 7, 4, SSD1306_WHITE);
}
oled.drawFastHLine(0, OLED_HEIGHT - 1, OLED_WIDTH * startFrac, SSD1306_WHITE); // shrinks to the start
}
// ------------------------------------------------------------------ setup / loop
void setup() {
pinMode(LED_PIN, OUTPUT);
if (SERIAL_PLOTTER || STREAM_FRAMES) Serial.begin(STREAM_FRAMES ? 1000000 : 115200);
randomSeed(analogRead(A3));
audioBegin();
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) { humInc = incFor(HUM_HZ); }
if (USE_OLED) {
Wire.setClock(400000);
oledOk = oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR);
if (oledOk) oled.setRotation(OLED_ROTATION);
else if (SERIAL_PLOTTER) Serial.println(F("OLED not found - check wiring/address"));
}
smoothPos = prevPos = map(analogRead(POT_PIN), 0, 1023, 0, 1000);
durIndex = constrain((int)smoothPos * NUM_DURATIONS / 1001, 0, NUM_DURATIONS - 1);
bootAt = lastDialChange = millis();
}
void enterFound(unsigned long now) {
mode = FOUND;
foundAt = now;
lostSince = 0;
heldSec = 0;
chimesLeft = 5; // wind chime: five notes, loosely spaced
nextChimeAt = now;
stutterLeft = 0;
}
void loop() {
unsigned long now = millis();
if (now - lastFrame < 40) return; // ~25 fps
float dt = (now - lastFrame) / 1000.0;
if (dt > 0.1) dt = 0.1;
lastFrame = now;
float t = now / 1000.0;
// 1. Dial
float pos = map(analogRead(POT_PIN), 0, 1023, 0, 1000);
smoothPos = smoothPos * 0.8 + pos * 0.2;
float speed = abs(smoothPos - prevPos);
prevPos = smoothPos;
speedAvg = speedAvg * 0.85 + speed * 0.15;
// 2. Setting the timer at power-on
if (mode == SETTING) {
uint8_t pick = constrain((int)smoothPos * NUM_DURATIONS / 1001, 0, NUM_DURATIONS - 1);
if (pick != durIndex) {
durIndex = pick;
lastDialChange = now;
noiseBurst(4, 60); // soft click per step
}
if (speed > 1.5) lastDialChange = now;
analogWrite(LED_PIN, 40 + 30 * sin(t * 3)); // gentle glow while waiting
unsigned long sinceBoot = now - bootAt, sinceTurn = now - lastDialChange;
unsigned long left = max(sinceBoot < SET_MIN_MS ? SET_MIN_MS - sinceBoot : 0UL,
sinceTurn < SET_IDLE_MS ? SET_IDLE_MS - sinceTurn : 0UL);
if (oledOk) { drawSetting(min(1.0, (float)left / SET_MIN_MS)); oled.display(); }
if (now - bootAt >= SET_MIN_MS && now - lastDialChange >= SET_IDLE_MS) {
durationMs = durationFor(durIndex);
newStation();
prepareShape(shapeIndex);
resetArmed = false;
strike(pgm_read_float(&CHIME_NOTES[2]), CHIME_LEVEL / 2); // one note: set
mode = TUNING;
}
return;
}
// 2b. Reset: knob held all the way left goes back to the timer screen
if (smoothPos > RESET_ZONE + 60) resetArmed = true;
if (resetArmed && smoothPos < RESET_ZONE) {
if (!leftSince) leftSince = now;
if (now - leftSince >= RESET_HOLD_MS) {
mode = SETTING;
bootAt = lastDialChange = now;
leftSince = 0;
clearSince = 0;
chimesLeft = stutterLeft = 0;
setHum(0);
durIndex = 0;
newShapeDue = true;
noiseBurst(4, 60);
return;
}
} else leftSince = 0;
// 3. How close, how calm
float calm = 1.0 - constrain((speedAvg - SPEED_CALM) / (SPEED_LOST - SPEED_CALM), 0.0, 1.0);
int dist = abs((int)smoothPos - stationPos);
float clarity;
if (dist <= CLEAR_BAND) clarity = 1.0;
else if (dist >= FRINGE) clarity = 0.0;
else clarity = 1.0 - (float)(dist - CLEAR_BAND) / (FRINGE - CLEAR_BAND);
clarity *= calm;
float turbulence = 1.0 - clarity;
float closeness = 1.0 - constrain(dist / 600.0, 0.0, 1.0);
closeness *= closeness;
guideSmooth += (closeness - guideSmooth) * 0.15;
// 4. Found / lost / countdown
unsigned long remain = durationMs;
if (mode == TUNING) {
if (clarity >= 0.95) {
if (!clearSince) clearSince = now;
if (now - clearSince >= FIND_MS) enterFound(now);
} else clearSince = 0;
}
if (mode == FOUND) {
heldSec += dt;
unsigned long elapsed = now - foundAt;
remain = elapsed >= durationMs ? 0 : durationMs - elapsed;
if (clarity < 0.5) {
if (!lostSince) lostSince = now;
if (now - lostSince >= LOSE_MS) { // left: countdown starts over
mode = TUNING;
clearSince = 0;
chimesLeft = 0;
setHum(0);
newShapeDue = true;
}
} else lostSince = 0;
if (mode == FOUND && remain == 0) { // time's up: the channel drifts
newStation();
newShapeDue = true;
mode = TUNING;
clearSince = 0;
chimesLeft = 0;
setHum(0);
chirp(392, 150, 1200, HUM_LEVEL / 2, 0); // the signal sliding away
}
}
// 5. Breath: in for 40%, out for 60%
float period = BREATH_START + (BREATH_END - BREATH_START) * min(heldSec / DEEPEN_SEC, 1.0f);
breathPhase += dt / period;
if (breathPhase >= 1.0) breathPhase -= 1.0;
float b, exhale = 0;
if (breathPhase < 0.4) b = 0.5 - 0.5 * cos(PI * breathPhase / 0.4);
else { exhale = (breathPhase - 0.4) / 0.6; b = 0.5 + 0.5 * cos(PI * exhale); }
// 6. LED: erratic and dim far away, steadier closer, a slow breath when found
float shake = 1.0 - guideSmooth;
float noise = random(0, 100) / 100.0;
float base = 0.15 + 0.6 * guideSmooth;
float jitterL = (noise - 0.5) * 2.0 * shake;
bool dropout = random(0, 100) < shake * shake * 35;
float guideLvl = dropout ? 0.0 : constrain(base + jitterL * 0.6, 0.0, 1.0);
float dimFar = 0.1 + 0.9 * guideSmooth;
float bright;
if (mode == FOUND) bright = 0.08 + 0.92 * b;
else bright = clarity * (0.1 + 0.9 * b) + (1 - clarity) * guideLvl * dimFar;
analogWrite(LED_PIN, (int)(bright * 255));
// 7. Sound
if (mode == FOUND) {
// Wind chime first, then the hum fades in and breathes.
if (chimesLeft && now >= nextChimeAt) {
strike(pgm_read_float(&CHIME_NOTES[random(NUM_NOTES)]), CHIME_LEVEL - random(0, 40));
chimesLeft--;
nextChimeAt = now + random(180, 480);
}
float since = (now - foundAt) / 1000.0;
float fadeIn = constrain((since - 1.5) / 3.0, 0.0, 1.0);
setHum(fadeIn * (0.12 + 0.88 * b) * clarity);
} else {
setHum(0);
// Glitchy chatter: bursts of hiss and quick chirps, denser the further off you are.
if (turbulence > 0.35) {
float k = (turbulence - 0.35) / 0.65;
float rate = 1.5 + 12.0 * k * k; // events per second
uint8_t level = CHATTER_LEVEL * (0.5 + 0.5 * turbulence);
if (random(0, 1000) < rate * dt * 1000) {
if (random(0, 100) < 60) noiseBurst(random(6, 30), level);
else {
float f0 = random(1500, 3500);
float f1 = f0 + (random(0, 2) ? 1 : -1) * random(400, 1500);
chirp(f0, f1, random(15, 50), level, 4);
}
if (random(0, 100) < 30) { // stutter: the same hiss, twice more
stutterLeft = 2;
stutterLevel = level;
nextStutterAt = now + 40;
}
}
if (stutterLeft && now >= nextStutterAt) {
noiseBurst(random(5, 12), stutterLevel);
stutterLeft--;
nextStutterAt = now + 40;
}
}
}
// 8. Screen (a new graphic is swapped in while it's just the warbling circle)
if (mode == TUNING && newShapeDue && clarity < 0.3) {
shapeIndex++;
prepareShape(shapeIndex);
newShapeDue = false;
}
if (oledOk) {
oled.clearDisplay();
drawScene(shapeIndex, clarity, b, t, mode == FOUND ? exhale : 0);
if (mode == FOUND) {
drawCountdown(remain);
}
if (leftSince && now - leftSince > 300) { // holding left: show the reset
oled.fillRect(20, 22, 88, 20, SSD1306_BLACK);
oled.drawRect(20, 22, 88, 20, SSD1306_WHITE);
oled.setTextSize(1);
oled.setTextColor(SSD1306_WHITE);
oled.setCursor(25, 26);
oled.print(F("hold to reset"));
oled.fillRect(24, 36, 80L * (now - leftSince) / RESET_HOLD_MS, 3, SSD1306_WHITE);
}
oled.display();
if (STREAM_FRAMES) {
Serial.write(0xAA);
Serial.write(0x55);
Serial.write(oled.getBuffer(), OLED_WIDTH * OLED_HEIGHT / 8);
}
}
// 9. Serial Plotter: dial, hidden channel, guide, clarity
if (STREAM_FRAMES || !SERIAL_PLOTTER) return;
Serial.print((int)smoothPos); Serial.print(',');
Serial.print(stationPos); Serial.print(',');
Serial.print((int)(guideSmooth * 1000)); Serial.print(',');
Serial.println((int)(clarity * 1000));
}
Process and iterations
I started with different ideas centered around calm and moving away from a screen. Two concepts that I was exploring was Walk Away and Calming Stone. Walk Away was imagined as a time-of-flight sensor and an OLED countdown that reward stepping away from the screen. The idea behind this was how to combat me getting sucked in during work sessions, and how my posture would not be good (hunched, too close to the computer). Calming stone was another exploration that would translate weather and air data into light and vibration, growing out of a past project I worked on called Weather Aura.
After a conversation with Bei, on simplifying the input -> output idea, and focusing on touch and proximity, I mapped poetic gestures (approach, hover, rest, stroke and tap) explored an idea around knocking the door to ask about the weather. I eventually decided to focus not on weather but instead of calm, as it is made it less large stakes to not connect it to Ix3 thesis work. This is when the tuner idea, or a calm radio, became my focus.
There were three key iteration changes
- Swapping the SoftPot for a knob. I was still waiting on the ribbon sensor from Adafruit, so I used a potentiometer I already had. It felt more like a radio dial anyway.
- Making the calm channel move. At first the station stayed in one place, so once you found it you were done. Now it moves when the countdown ends, and you use the LED to find it again.
- After some user testing, some people mentioned that they would like to be able to set the amount of time that “calm station” would be on. I implemented a button, but after trying it on the faceplate I took it out. It was another control, and I wanted it to feel like there’s only one input. Now you hold the knob all the way to the left to get to a reset screen and pick 5, 15 or 25 minutes. This made the code more complex, and it’s not something people would figure out on their own. It would probably need a small manual.
Other feedback from testing was to show a countdown once you find calm, play something calming, make the static more annoying without being too loud, and show images other than the circle. Now a wind chime plays when you find the channel, then a low hum that follows your breathing. The circle turns into a leaf, a cat, a star or grass as you tune in.
For the form, Bei sketched a few retro radio shapes for me to work from. I printed a fit test of the faceplate first, and mounted the screen, LED and knob straight to it so the breadboard wouldn’t keep coming apart.
Enclosure sketches by Bei.

Working with AI
I used Claude (Opus 5.5) on this project. I came up with the concept and the interaction, decided what to keep and what to cut, and did the fine tuning by testing on the hardware, like how fast is too fast and how annoying the static should be.
Claude wrote most of the Arduino code, including the sound and the silhouette animations, and I adjusted the values. It also generated the 3D enclosure as a Python script (with the manifold3d and trimesh libraries) that made the STL files I sent to the Sam Fox print queue. It helped me debug the wiring and helped draft parts of this post, which I edited.
Reflections
What worked was making speed part of the input. The knob reads where you are on the dial and how fast you’re turning, so if you rush you stay in the static. You have to slow your hand down to get to calm.
What changed the most was the input. I started with a time-of-flight sensor that noticed when I walked away from my desk, then touch on a stone, then knocking on a door, and ended up with one knob. Each time I took something out, the idea got easier to explain.
Movement ended up mattering more than touch. Turning fast feels like scrolling and turning slowly feels like listening. Presence is in there too, since the channel moves whether or not you’re paying attention, and you have to keep coming back to it.
I’d keep working on the timer. People wanted it and it’s there, but with one knob it’s hidden. I’d want to test whether a small manual is enough, or whether the object should show it somehow.
On the hardware side, I learned to add one part at a time and keep a test sketch around. Most of my debugging was loose jumpers and one missing resistor.