// 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 #include #include #include #include // ------------------------------------------------------------------ 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)); }