//by mircemk 2026
#include <Adafruit_NeoPixel.h>
#define PIN_L 5
#define PIN_R 6
#define PIN_START 7
#define PIN_BTN_L 8
#define PIN_BTN_R 9
#define PIN_SPK 10
#define PIN_BRIGHT A4
#define PIN_ZOOM A5
const int LEDS = 50;
const uint8_t BRIGHT_MAX = 100; // максимум наместо 255
const unsigned long BRIGHT_UPDATE_MS = 25;
const uint8_t BRIGHT_SMOOTH_SHIFT = 3; // 1/8 smoothing (поголемо = помазно)
const uint8_t BRIGHT_DEADBAND = 1; // игнорирај +/-1 чекор
// ---- LAYOUT ----
const int SCORE_LEDS = 5; // 45..49
const int GAP_LEDS = 5; // 40..44 (always OFF)
const int PLAY_LEDS = LEDS - SCORE_LEDS - GAP_LEDS; // 40 (0..39)
const int GAP_BOTTOM = PLAY_LEDS; // 40
const int GAP_TOP = PLAY_LEDS + GAP_LEDS - 1; // 44
const int SCORE_BOTTOM = PLAY_LEDS + GAP_LEDS; // 45
const int SCORE_TOP = LEDS - 1; // 49
unsigned long lastBrightUpdate = 0;
uint16_t brightFilt = 0; // работи во 0..(BRIGHT_MAX<<8)
uint8_t brightApplied = 255; // force first apply
// ---- TIMING ----
const unsigned long IDLE_STEP_MS = 30;
const unsigned long BLINK_MS = 180;
const unsigned long RED_MIN_MS = 4000;
const unsigned long RED_MAX_MS = 8000;
const unsigned long BAR_STEP_MS = 10;
const unsigned long SECOND_PRESS_TIMEOUT_MS = 2000;
// Winner baseline scale (fixed)
const unsigned long ABS_SCALE_MS = 1000; // 0..1000ms -> 0..40 LEDs
// MATCH FLASH (only first 40 LEDs)
const byte MATCH_FLASH_TIMES = 3;
const unsigned long MATCH_FLASH_PERIOD = 220;
// After match flash: show solid winner color for 5 seconds, then go to idle animation
const unsigned long FINAL_HOLD_MS = 5000;
// Debounce
const unsigned long DEBOUNCE_MS = 20;
// ---- SOUND ----
const uint16_t IDLE_F_MIN = 100;
const uint16_t IDLE_F_MAX = 1200;
const uint16_t BLINK_F1 = 300;
const uint16_t BLINK_F2 = 500;
const uint16_t RED_F = 1000;
const unsigned long ROUND_WIN_DELAY_MS = 500;
// Round win (first 4 notes of match theme) + pause
const uint16_t ROUND_WIN_FREQS[] = { 523, 659, 784, 1046, 0 };
const uint16_t ROUND_WIN_DURS[] = { 180, 180, 200, 260, 120 };
// False-start buzzer (low)
const uint16_t FALSE_FREQS[] = { 160, 120, 90, 0, 90, 0 };
const uint16_t FALSE_DURS[] = { 180, 180, 220, 80, 220, 140 };
// Match win sequence (longer)
const uint16_t MATCH_FREQS[] = { 523, 659, 784, 1046, 784, 659, 523, 0, 880, 0 };
const uint16_t MATCH_DURS[] = { 120, 120, 140, 200, 120, 120, 180, 90, 260, 150 };
Adafruit_NeoPixel stripL(LEDS, PIN_L, NEO_GRB + NEO_KHZ800);
Adafruit_NeoPixel stripR(LEDS, PIN_R, NEO_GRB + NEO_KHZ800);
uint32_t OFF, RED, GREEN, BLUE;
uint32_t L_COL, R_COL;
enum State {
IDLE,
BLINK,
REDGO,
SHOWBARS_ANIM,
RESULT_WAIT_START,
MATCHFLASH,
MATCH_HOLD,
MATCH_IDLE
};
State state = IDLE;
// ---- debounce state ----
bool stableStart = LOW, stableL = LOW, stableR = LOW;
bool lastReadStart = LOW, lastReadL = LOW, lastReadR = LOW;
unsigned long lastChangeStart = 0;
unsigned long lastChangeL = 0;
unsigned long lastChangeR = 0;
// ---- idle chase ----
unsigned long lastIdle = 0;
int chasePos = 0;
byte chaseHue = 0;
// ---- blink ----
unsigned long lastBlink = 0;
bool blinkToggle = false;
unsigned long redGoTime = 0;
// ---- round ----
bool lUsed = false, rUsed = false;
bool gotL = false, gotR = false;
unsigned long tRed = 0;
unsigned long reactL = 0, reactR = 0;
byte winner = 0; // 1 left, 2 right
int targetBarL = 0, targetBarR = 0;
int curBarL = 0, curBarR = 0;
unsigned long lastBarStep = 0;
// ---- score ----
byte scoreL = 0, scoreR = 0;
// ---- match flash ----
uint32_t matchWinnerColor = 0;
unsigned long lastFlashT = 0;
bool flashOn = false;
byte flashCount = 0;
// ---- final hold timer ----
unsigned long finalHoldStart = 0;
// ---- delayed round-win sound trigger ----
bool roundWinSoundPending = false;
unsigned long roundWinSoundAt = 0;
// ---- LED FREEZE DURING ROUND WIN SOUND ----
bool freezeLeds = false; // when true: DO NOT call show() anywhere
int frozenBarL = 0;
int frozenBarR = 0;
// ================= SOUND PLAYER (non-blocking) =================
struct BeepSeq {
const uint16_t* freqs = nullptr;
const uint16_t* durs = nullptr; // ms per tone
uint8_t len = 0;
uint8_t idx = 0;
bool active = false;
unsigned long nextT = 0;
};
BeepSeq seq;
int continuousFreq = 0;
int currentToneFreq = -1;
void soundStop() {
noTone(PIN_SPK);
currentToneFreq = -1;
}
void soundSetContinuous(int f) {
continuousFreq = f;
}
void soundStartSeq(const uint16_t* freqs, const uint16_t* durs, uint8_t len) {
seq.freqs = freqs;
seq.durs = durs;
seq.len = len;
seq.idx = 0;
seq.active = true;
seq.nextT = 0;
}
bool soundSeqActive() { return seq.active; }
void soundUpdate(unsigned long now) {
if (seq.active) {
if (seq.nextT == 0 || (long)(now - seq.nextT) >= 0) {
if (seq.idx >= seq.len) {
seq.active = false;
} else {
uint16_t f = seq.freqs[seq.idx];
uint16_t d = seq.durs[seq.idx];
if (f == 0) {
noTone(PIN_SPK);
currentToneFreq = -1;
} else {
// fixed duration per note
tone(PIN_SPK, f, d);
currentToneFreq = (int)f;
}
seq.nextT = now + d;
seq.idx++;
}
}
return;
}
// No sequence active -> continuous tone
if (continuousFreq <= 0) {
if (currentToneFreq != -1) soundStop();
} else {
if (currentToneFreq != continuousFreq) {
tone(PIN_SPK, (unsigned int)continuousFreq);
currentToneFreq = continuousFreq;
}
}
}
// ================= HELPERS =================
uint32_t wheel(byte p) {
p = 255 - p;
if (p < 85) return stripL.Color(255 - p * 3, 0, p * 3);
if (p < 170) { p -= 85; return stripL.Color(0, p * 3, 255 - p * 3); }
p -= 170; return stripL.Color(p * 3, 255 - p * 3, 0);
}
void showBoth() {
if (freezeLeds) return;
stripL.show();
stripR.show();
}
void clearPlayfield() {
for (int i = 0; i < PLAY_LEDS; i++) {
stripL.setPixelColor(i, OFF);
stripR.setPixelColor(i, OFF);
}
}
void clearGap() {
for (int i = GAP_BOTTOM; i <= GAP_TOP; i++) {
stripL.setPixelColor(i, OFF);
stripR.setPixelColor(i, OFF);
}
}
void setPlayfield(uint32_t c) {
for (int i = 0; i < PLAY_LEDS; i++) {
stripL.setPixelColor(i, c);
stripR.setPixelColor(i, c);
}
}
void drawScore() {
clearGap();
for (int i = SCORE_BOTTOM; i <= SCORE_TOP; i++) {
stripL.setPixelColor(i, OFF);
stripR.setPixelColor(i, OFF);
}
for (byte i = 0; i < scoreL && i < SCORE_LEDS; i++) stripL.setPixelColor(SCORE_TOP - i, L_COL);
for (byte i = 0; i < scoreR && i < SCORE_LEDS; i++) stripR.setPixelColor(SCORE_TOP - i, R_COL);
}
unsigned long readZoom() {
int v = analogRead(PIN_ZOOM);
return 10UL + (unsigned long)((v * 990UL) / 1023UL);
}
int mapAbs(unsigned long ms) {
if (ms > ABS_SCALE_MS) ms = ABS_SCALE_MS;
unsigned long bar = (ms * (unsigned long)PLAY_LEDS + (ABS_SCALE_MS / 2)) / ABS_SCALE_MS;
if (bar > (unsigned long)PLAY_LEDS) bar = PLAY_LEDS;
return (int)bar;
}
int mapDiff(unsigned long diff, unsigned long zoom, int maxExtra) {
if (zoom < 10) zoom = 10;
if (diff > zoom) diff = zoom;
unsigned long extra = (diff * (unsigned long)maxExtra + (zoom / 2)) / zoom;
if ((int)extra > maxExtra) extra = maxExtra;
return (int)extra;
}
void resetRoundVars() {
lUsed = rUsed = false;
gotL = gotR = false;
reactL = reactR = 0;
winner = 0;
targetBarL = targetBarR = 0;
curBarL = curBarR = 0;
roundWinSoundPending = false;
freezeLeds = false;
}
void startRoundKeepScore(unsigned long now) {
state = BLINK;
lastBlink = now;
blinkToggle = false;
redGoTime = now + random(RED_MIN_MS, RED_MAX_MS + 1);
resetRoundVars();
clearPlayfield();
drawScore();
showBoth();
soundSetContinuous(BLINK_F1);
}
void startMatchFlash(uint32_t winColor) {
matchWinnerColor = winColor;
state = MATCHFLASH;
lastFlashT = millis();
flashOn = false;
flashCount = 0;
soundStartSeq(MATCH_FREQS, MATCH_DURS, sizeof(MATCH_FREQS) / sizeof(MATCH_FREQS[0]));
soundSetContinuous(0);
}
// Draw bars once (used when freezing)
void drawBarsStatic(int barL, int barR) {
clearPlayfield();
for (int i = 0; i < barL && i < PLAY_LEDS; i++) stripL.setPixelColor(i, L_COL);
for (int i = 0; i < barR && i < PLAY_LEDS; i++) stripR.setPixelColor(i, R_COL);
drawScore();
showBoth();
}
// ---------- setup ----------
void setup() {
pinMode(PIN_START, INPUT);
pinMode(PIN_BTN_L, INPUT);
pinMode(PIN_BTN_R, INPUT);
pinMode(PIN_SPK, OUTPUT);
pinMode(PIN_BRIGHT, INPUT);
pinMode(PIN_ZOOM, INPUT);
stripL.begin();
stripR.begin();
OFF = stripL.Color(0, 0, 0);
RED = stripL.Color(255, 0, 0);
GREEN = stripL.Color(0, 255, 0);
BLUE = stripL.Color(0, 0, 255);
L_COL = stripL.Color(255, 180, 0);
R_COL = stripL.Color(255, 0, 180);
randomSeed(analogRead(A0));
state = IDLE;
clearPlayfield();
drawScore();
showBoth();
soundSetContinuous(IDLE_F_MIN);
}
// ---------- loop ----------
void loop() {
unsigned long now = millis();
// Brightness
// Brightness (filtered + capped + rate limited)
if (now - lastBrightUpdate >= BRIGHT_UPDATE_MS) {
lastBrightUpdate = now;
// map pot -> 0..BRIGHT_MAX
uint16_t raw = analogRead(PIN_BRIGHT); // 0..1023
uint16_t target = (raw * BRIGHT_MAX + 511) / 1023; // 0..BRIGHT_MAX
// IIR smoothing in fixed point (<<8)
uint16_t targetFP = (uint16_t)(target << 8);
brightFilt = brightFilt + ((int32_t)targetFP - (int32_t)brightFilt) / (1 << BRIGHT_SMOOTH_SHIFT);
uint8_t b = (uint8_t)(brightFilt >> 8);
// deadband to avoid flicker from tiny pot noise
if (b > brightApplied + BRIGHT_DEADBAND || b + BRIGHT_DEADBAND < brightApplied) {
brightApplied = b;
stripL.setBrightness(brightApplied);
stripR.setBrightness(brightApplied);
}
}
// ---- DEBOUNCE -> edges ----
bool startEdge = false, lEdge = false, rEdge = false;
bool readStart = digitalRead(PIN_START);
if (readStart != lastReadStart) { lastChangeStart = now; lastReadStart = readStart; }
if (now - lastChangeStart > DEBOUNCE_MS) {
if (stableStart != readStart) { stableStart = readStart; if (stableStart == HIGH) startEdge = true; }
}
bool readL = digitalRead(PIN_BTN_L);
if (readL != lastReadL) { lastChangeL = now; lastReadL = readL; }
if (now - lastChangeL > DEBOUNCE_MS) {
if (stableL != readL) { stableL = readL; if (stableL == HIGH) lEdge = true; }
}
bool readR = digitalRead(PIN_BTN_R);
if (readR != lastReadR) { lastChangeR = now; lastReadR = readR; }
if (now - lastChangeR > DEBOUNCE_MS) {
if (stableR != readR) { stableR = readR; if (stableR == HIGH) rEdge = true; }
}
// Update sound engine
soundUpdate(now);
// When round-win sound is active, freeze LED updates completely for clean tone
if (freezeLeds) {
if (!soundSeqActive()) {
// sequence ended -> unfreeze
freezeLeds = false;
// redraw once (so zoom changes etc. can resume)
drawBarsStatic(curBarL, curBarR);
}
}
// Trigger delayed round-win sound ONLY when time comes and nothing else is playing
if (roundWinSoundPending && (long)(now - roundWinSoundAt) >= 0 && !soundSeqActive()) {
// Freeze LEDs for the whole round-win sequence
freezeLeds = true;
// show the bars ONCE, then stop calling show()
drawBarsStatic(curBarL, curBarR);
soundStartSeq(ROUND_WIN_FREQS, ROUND_WIN_DURS, sizeof(ROUND_WIN_FREQS) / sizeof(ROUND_WIN_FREQS[0]));
soundSetContinuous(0);
roundWinSoundPending = false;
}
// ---- START behavior ----
if (startEdge) {
if (state == MATCH_HOLD || state == MATCH_IDLE) {
scoreL = 0;
scoreR = 0;
startRoundKeepScore(now);
return;
}
if (state != MATCHFLASH) {
startRoundKeepScore(now);
return;
}
}
// ---- IDLE / MATCH_IDLE ----
if (state == IDLE || state == MATCH_IDLE) {
if (now - lastIdle >= IDLE_STEP_MS) {
lastIdle = now;
clearPlayfield();
uint32_t c = wheel(chaseHue);
stripL.setPixelColor(chasePos, c);
stripR.setPixelColor(chasePos, c);
drawScore();
showBoth();
uint16_t f = (uint16_t)(IDLE_F_MIN + (uint32_t)(IDLE_F_MAX - IDLE_F_MIN) * (uint32_t)chasePos / (uint32_t)(PLAY_LEDS - 1));
soundSetContinuous(f);
chasePos++;
if (chasePos >= PLAY_LEDS) chasePos = 0;
chaseHue += 3;
}
return;
}
// ---- BLINK ----
if (state == BLINK) {
// false start => lose + low buzzer
if (lEdge && !lUsed) {
lUsed = true;
winner = 2;
gotL = gotR = true;
reactL = reactR = 0;
soundStartSeq(FALSE_FREQS, FALSE_DURS, sizeof(FALSE_FREQS) / sizeof(FALSE_FREQS[0]));
soundSetContinuous(0);
if (scoreR < SCORE_LEDS) scoreR++;
if (scoreR >= 5) { startMatchFlash(R_COL); return; }
unsigned long zoom = readZoom();
int base = mapAbs(0);
int extra = mapDiff(0, zoom, PLAY_LEDS - base);
targetBarR = base;
targetBarL = base + extra;
curBarL = curBarR = 0;
lastBarStep = now;
state = SHOWBARS_ANIM;
return;
}
if (rEdge && !rUsed) {
rUsed = true;
winner = 1;
gotL = gotR = true;
reactL = reactR = 0;
soundStartSeq(FALSE_FREQS, FALSE_DURS, sizeof(FALSE_FREQS) / sizeof(FALSE_FREQS[0]));
soundSetContinuous(0);
if (scoreL < SCORE_LEDS) scoreL++;
if (scoreL >= 5) { startMatchFlash(L_COL); return; }
unsigned long zoom = readZoom();
int base = mapAbs(0);
int extra = mapDiff(0, zoom, PLAY_LEDS - base);
targetBarL = base;
targetBarR = base + extra;
curBarL = curBarR = 0;
lastBarStep = now;
state = SHOWBARS_ANIM;
return;
}
if (now - lastBlink >= BLINK_MS) {
lastBlink = now;
blinkToggle = !blinkToggle;
clearPlayfield();
uint32_t c = blinkToggle ? GREEN : BLUE;
for (int i = 0; i < 3; i++) {
stripL.setPixelColor(i, c);
stripR.setPixelColor(i, c);
}
drawScore();
showBoth();
soundSetContinuous(blinkToggle ? BLINK_F1 : BLINK_F2);
}
if ((long)(now - redGoTime) >= 0) {
tRed = now;
clearPlayfield();
for (int i = 0; i < 3; i++) {
stripL.setPixelColor(i, RED);
stripR.setPixelColor(i, RED);
}
drawScore();
showBoth();
state = REDGO;
lUsed = rUsed = false;
gotL = gotR = false;
soundSetContinuous(RED_F);
return;
}
return;
}
// ---- REDGO ----
if (state == REDGO) {
if (lEdge && !lUsed) { lUsed = true; gotL = true; reactL = now - tRed; if (winner == 0) winner = 1; }
if (rEdge && !rUsed) { rUsed = true; gotR = true; reactR = now - tRed; if (winner == 0) winner = 2; }
bool timeout = false;
if (winner != 0 && (now - tRed >= SECOND_PRESS_TIMEOUT_MS)) timeout = true;
if ((gotL && gotR) || timeout) {
if (!gotL) reactL = SECOND_PRESS_TIMEOUT_MS;
if (!gotR) reactR = SECOND_PRESS_TIMEOUT_MS;
if (winner == 1 && scoreL < SCORE_LEDS) scoreL++;
if (winner == 2 && scoreR < SCORE_LEDS) scoreR++;
soundSetContinuous(0);
if (scoreL >= 5) { startMatchFlash(L_COL); return; }
if (scoreR >= 5) { startMatchFlash(R_COL); return; }
unsigned long zoom = readZoom();
unsigned long wMs = (winner == 1) ? reactL : reactR;
unsigned long lMs = (winner == 1) ? reactR : reactL;
int base = mapAbs(wMs);
int maxExtra = PLAY_LEDS - base;
if (maxExtra < 0) maxExtra = 0;
int extra = mapDiff((lMs > wMs) ? (lMs - wMs) : 0, zoom, maxExtra);
if (winner == 1) { targetBarL = base; targetBarR = base + extra; }
else { targetBarR = base; targetBarL = base + extra; }
curBarL = curBarR = 0;
lastBarStep = now;
state = SHOWBARS_ANIM;
}
return;
}
// ---- SHOWBARS_ANIM ----
if (state == SHOWBARS_ANIM) {
if (winner != 0) {
unsigned long zoom = readZoom();
unsigned long wMs = (winner == 1) ? reactL : reactR;
unsigned long lMs = (winner == 1) ? reactR : reactL;
int base = mapAbs(wMs);
int maxExtra = PLAY_LEDS - base;
if (maxExtra < 0) maxExtra = 0;
int extra = mapDiff((lMs > wMs) ? (lMs - wMs) : 0, zoom, maxExtra);
if (winner == 1) { targetBarL = base; targetBarR = base + extra; }
else { targetBarR = base; targetBarL = base + extra; }
}
if (now - lastBarStep >= BAR_STEP_MS) {
lastBarStep = now;
if (curBarL < targetBarL) curBarL++;
if (curBarR < targetBarR) curBarR++;
clearPlayfield();
for (int i = 0; i < curBarL && i < PLAY_LEDS; i++) stripL.setPixelColor(i, L_COL);
for (int i = 0; i < curBarR && i < PLAY_LEDS; i++) stripR.setPixelColor(i, R_COL);
drawScore();
showBoth();
if (curBarL >= targetBarL && curBarR >= targetBarR) {
state = RESULT_WAIT_START;
// schedule clean round win sound after bars shown
roundWinSoundPending = true;
roundWinSoundAt = now + ROUND_WIN_DELAY_MS;
}
}
return;
}
// ---- RESULT_WAIT_START ----
if (state == RESULT_WAIT_START) {
// If LEDs are frozen due to sound -> do not update visuals
if (freezeLeds) return;
unsigned long zoom = readZoom();
unsigned long wMs = (winner == 1) ? reactL : reactR;
unsigned long lMs = (winner == 1) ? reactR : reactL;
int base = mapAbs(wMs);
int maxExtra = PLAY_LEDS - base;
if (maxExtra < 0) maxExtra = 0;
int extra = mapDiff((lMs > wMs) ? (lMs - wMs) : 0, zoom, maxExtra);
if (winner == 1) { targetBarL = base; targetBarR = base + extra; }
else { targetBarR = base; targetBarL = base + extra; }
static unsigned long lastT = 0;
if (now - lastT >= BAR_STEP_MS) {
lastT = now;
if (curBarL < targetBarL) curBarL++;
else if (curBarL > targetBarL) curBarL--;
if (curBarR < targetBarR) curBarR++;
else if (curBarR > targetBarR) curBarR--;
clearPlayfield();
for (int i = 0; i < curBarL && i < PLAY_LEDS; i++) stripL.setPixelColor(i, L_COL);
for (int i = 0; i < curBarR && i < PLAY_LEDS; i++) stripR.setPixelColor(i, R_COL);
drawScore();
showBoth();
}
return;
}
// ---- MATCHFLASH ----
if (state == MATCHFLASH) {
if (now - lastFlashT >= MATCH_FLASH_PERIOD) {
lastFlashT = now;
flashOn = !flashOn;
if (flashOn) setPlayfield(matchWinnerColor);
else clearPlayfield();
drawScore();
showBoth();
if (!flashOn) {
flashCount++;
if (flashCount >= MATCH_FLASH_TIMES) {
setPlayfield(matchWinnerColor);
drawScore();
showBoth();
finalHoldStart = now;
state = MATCH_HOLD;
}
}
}
return;
}
// ---- MATCH_HOLD ----
if (state == MATCH_HOLD) {
if (now - finalHoldStart >= FINAL_HOLD_MS) {
state = MATCH_IDLE;
lastIdle = now;
chasePos = 0;
chaseHue = 0;
clearPlayfield();
drawScore();
showBoth();
soundSetContinuous(IDLE_F_MIN);
}
return;
}
}
Comments