/* * PixelJam — combined firmware * XIAO ESP32-S3 Sense · tactile MP3 player with turntable scrub * * Serial debug runs over native USB CDC, so it coexists with the motor * on GPIO43 (UART0 TX). The old "motor blocks Serial" symptom was the * unattached TX pin idling HIGH = motor driver full-on; attaching the * PWM immediately in setup() fixes it. * * Compile with PSRAM enabled: esp32:esp32:XIAO_ESP32S3:PSRAM=opi * (the audio library needs ~704 KB of PSRAM or it OOM-crashes at boot) */ #include "Arduino.h" #include "Audio.h" #include "SD.h" #include "SPI.h" #include "Wire.h" #include #include // ── SD Card ─────────────────────────────────────────────── #define SD_CS 21 #define SPI_MOSI 9 #define SPI_MISO 8 #define SPI_SCK 7 // ── I2S → MAX98357A ─────────────────────────────────────── #define I2S_DOUT D3 #define I2S_BCLK D1 #define I2S_LRC 42 // raw GPIO number, never D11 // ── Controls ────────────────────────────────────────────── #define BTN_PLAY 44 // active LOW #define BTN_NEXT 41 // mini switch (J2 solder pad cut) — fires on any change #define BTN_PREV 3 // D2 — reroute the second mini switch here (GPIO41 can't // distinguish two diode-OR'd switches) #define VOL_PIN 1 // pot wiper, ADC // ── Motor ───────────────────────────────────────────────── #define MOTOR_PIN 43 #define MOTOR_PWM_FREQ 25000 // confirmed silent #define MOTOR_PWM_RES 8 #define MOTOR_KICK_DUTY 80 // burst to overcome static friction #define MOTOR_KICK_MS 150 #define MOTOR_RUN_DUTY 36 // sweet spot: 42 spun too fast, 30 was too weak to // keep the disk turning at all // ── AS5600 encoder / scrub ──────────────────────────────── #define SDA_PIN 5 #define SCL_PIN 6 #define AS5600_ADDR 0x36 #define AS5600_REG_ANGLE 0x0C #define DEG_PER_SECOND 3.0f // 3° of disk rotation = 1 s of audio // hand-on-disk detection: the disk's velocity at a given PWM is learned as a // baseline; a sustained drop below it (same PWM, velocity → 0) means a hand // is on the disk. A big jump above baseline = someone spinning it by hand. #define SPEED_WINDOW_MS 250 // velocity measurement window #define TOUCH_DROP 0.35f // speed below 35% of baseline = touched #define TOUCH_LOW_WINDOWS 2 // for this many consecutive windows (500 ms) #define TOUCH_SPIN_X 4.0f // speed above 4x baseline = hand-spun #define TOUCH_SPIN_MIN 10.0f // deg/window (=40 deg/s) absolute floor for spin: // motor noise measures <1 deg/window, a real hand // turn measures 25-135 (logged 100-540 deg/s) #define MIN_BASELINE 1.25f // deg/window (=5 deg/s): below this the disk isn't // really spinning and hold detection would fire on noise #define STALL_ARM_MS 3000 // spin-up time after motor start before arming #define RELEASE_MS 800 // stillness after scrubbing = hand lifted, resume #define GRAB_TIMEOUT_MS 3000 // grabbed but never scrubbed → resume anyway #define VOLUME_POT_ENABLED 1 // 0 forces max volume (gear-noise test) // ── OLED (shares the AS5600 I2C bus on GPIO5/6) ─────────── #define OLED_ADDR 0x3C // most SSD1306 modules; 0x3D tried as fallback #define LOG(...) Serial.printf(__VA_ARGS__) Audio audio; Adafruit_SSD1306 oled(128, 64, &Wire, -1); bool oledOK = false; float vinylAngle = 0; // current rotation of the on-screen record // playlist #define MAX_TRACKS 32 String tracks[MAX_TRACKS]; int trackCount = 0; int currentTrack = 0; bool isPlaying = false; bool started = false; // stays false until the first play press bool sdReady = false; // SD mounted and tracks found volatile bool trackEnded = false; // primed from the real pin states in setup() so a switch resting LOW // doesn't fire a phantom press at boot bool lastPlayBtn = HIGH; // limit switches (next/prev) may be wired NO or NC — trigger once on any // departure from the resting state, re-arm when back at rest bool nextRest = HIGH, prevRest = HIGH; bool nextArmed = true, prevArmed = true; // motor state machine (kick → run) enum MotorState { MOTOR_OFF, MOTOR_KICK, MOTOR_RUN }; MotorState motorState = MOTOR_OFF; uint32_t motorKickEnd = 0; uint32_t motorRunStart = 0; // encoder / scrub bool encoderOK = false; int lastRawAngle = 0; float scrubDegrees = 0; uint32_t scrubBaseTime = 0; // playback seconds when pause began uint32_t scrubDuration = 0; // track length in seconds // grab-to-scrub state bool heldScrub = false; // paused because a hand stopped the disk bool movedSinceGrab = false; uint32_t lastMoveMs = 0; float windowMovement = 0; // rotation seen in the current speed window uint32_t windowStart = 0; float baselineDeg = 0; // learned °/window of the free-spinning disk uint8_t lowWindows = 0; // consecutive windows below the touch threshold // seek retry (setTimeOffset fails until the stream is running again) int pendingSeek = 0; uint32_t pendingSeekUntil = 0; // ── Motor ───────────────────────────────────────────────── void motorStart() { ledcWrite(MOTOR_PIN, MOTOR_KICK_DUTY); motorKickEnd = millis() + MOTOR_KICK_MS; motorState = MOTOR_KICK; baselineDeg = 0; // relearn the free-spin speed after every start — a lowWindows = 0; // stale baseline makes the real spin look like a touch } void motorStop() { ledcWrite(MOTOR_PIN, 0); motorState = MOTOR_OFF; } void motorUpdate() { if (motorState == MOTOR_KICK && (int32_t)(millis() - motorKickEnd) >= 0) { ledcWrite(MOTOR_PIN, MOTOR_RUN_DUTY); motorState = MOTOR_RUN; motorRunStart = millis(); windowMovement = 0; // fresh stall window once at run speed windowStart = millis(); } } // ── AS5600 ──────────────────────────────────────────────── int readEncoderRaw() { // 0..4095, or -1 on I2C failure Wire.beginTransmission(AS5600_ADDR); Wire.write(AS5600_REG_ANGLE); if (Wire.endTransmission(false) != 0) return -1; if (Wire.requestFrom(AS5600_ADDR, 2) != 2) return -1; int hi = Wire.read(); int lo = Wire.read(); return ((hi << 8) | lo) & 0x0FFF; } void startScrubSession() { scrubDegrees = 0; scrubBaseTime = audio.getAudioCurrentTime(); scrubDuration = audio.getAudioFileDuration(); int raw = readEncoderRaw(); if (raw >= 0) lastRawAngle = raw; LOG("Scrub session: base=%lus dur=%lus\n", (unsigned long)scrubBaseTime, (unsigned long)scrubDuration); } float readEncoderDelta() { // degrees moved since last call, wraparound-safe if (!encoderOK) return 0; int raw = readEncoderRaw(); if (raw < 0) return 0; int d = raw - lastRawAngle; if (d > 2048) d -= 4096; if (d < -2048) d += 4096; lastRawAngle = raw; float deg = d * (360.0f / 4096.0f); // one 50 ms poll can't legitimately move 60° (= 1200 deg/s) — I2C glitch; // lastRawAngle is already resynced above, so just drop the sample if (fabsf(deg) > 60.0f) return 0; return deg; } void applyScrub() { // called right after resuming playback if (scrubDuration == 0) { // no track was loaded when the session started scrubDegrees = 0; return; } if (fabsf(scrubDegrees) < DEG_PER_SECOND) { // deadband: < 1 s of jitter scrubDegrees = 0; return; } int offset = lroundf(scrubDegrees / DEG_PER_SECOND); // clamp so the target stays inside [0, duration-1] int32_t target = (int32_t)scrubBaseTime + offset; if (target < 0) target = 0; if (scrubDuration > 0 && target > (int32_t)scrubDuration - 1) target = (int32_t)scrubDuration - 1; offset = target - (int32_t)scrubBaseTime; scrubDegrees = 0; if (offset == 0) return; bool ok = audio.setTimeOffset(offset); LOG("Scrub seek %+d s -> %ld s: %s\n", offset, (long)target, ok ? "OK" : "failed, will retry"); if (!ok) { // library refuses seeks until the stream is running again pendingSeek = offset; pendingSeekUntil = millis() + 1500; } } // ── OLED ────────────────────────────────────────────────── void drawVinyl(int cx, int cy, int r, float angle) { oled.drawCircle(cx, cy, r, SSD1306_WHITE); // rim oled.drawCircle(cx, cy, r - 3, SSD1306_WHITE); // grooves oled.drawCircle(cx, cy, r - 6, SSD1306_WHITE); oled.fillCircle(cx, cy, r / 3, SSD1306_WHITE); // label oled.fillCircle(cx, cy, 2, SSD1306_BLACK); // spindle hole // marker dot on the label makes the rotation visible int mx = cx + lroundf(cosf(angle) * (r / 3 - 3)); int my = cy + lroundf(sinf(angle) * (r / 3 - 3)); oled.fillCircle(mx, my, 2, SSD1306_BLACK); } void bootAnimation() { if (!oledOK) return; const char* title = "PixelJam"; oled.setTextColor(SSD1306_WHITE); for (int f = 0; f <= 26; f++) { oled.clearDisplay(); int x = 164 - f * 3; // record rolls in from the right if (x < 98) x = 98; drawVinyl(x, 32, 26, f * 0.45f); // spinning as it rolls int letters = f / 3; // title types itself out oled.setTextSize(2); oled.setCursor(2, 25); for (int i = 0; i < letters && title[i]; i++) oled.print(title[i]); oled.display(); delay(40); } delay(350); } void oledMessage(const char* line1, const char* line2) { if (!oledOK) return; oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); oled.setTextSize(2); oled.setCursor(0, 14); oled.println(line1); oled.setTextSize(1); oled.setCursor(0, 40); oled.println(line2); oled.display(); } void oledStatus() { // live screen, called every 100 ms from loop() oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); if (!sdReady) { // no card / no MP3s — retrying in background oled.setTextSize(2); oled.setCursor(0, 14); oled.print("NO MUSIC"); oled.setTextSize(1); oled.setCursor(0, 40); oled.print("check the SD card"); oled.display(); return; } if (!started) { // idle: lazy spin until the first play press vinylAngle += 0.08f; drawVinyl(98, 32, 26, vinylAngle); oled.setTextSize(2); oled.setCursor(0, 14); oled.print("PRESS"); oled.setCursor(0, 36); oled.print("PLAY"); oled.display(); return; } if (isPlaying) { vinylAngle += 0.55f; // record spins while music plays drawVinyl(98, 32, 26, vinylAngle); oled.setTextSize(1); oled.setCursor(0, 2); oled.printf("TRACK %d/%d", currentTrack + 1, trackCount); uint32_t t = audio.getAudioCurrentTime(); uint32_t dur = audio.getAudioFileDuration(); oled.setTextSize(2); oled.setCursor(0, 16); oled.printf("%02lu:%02lu", (unsigned long)(t / 60), (unsigned long)(t % 60)); oled.drawRect(0, 38, 64, 6, SSD1306_WHITE); if (dur > 0) oled.fillRect(0, 38, (int)(64.0f * t / dur), 6, SSD1306_WHITE); oled.setTextSize(1); oled.setCursor(0, 52); oled.print("PLAYING"); } else { // paused: the on-screen record follows the real disk as you scrub drawVinyl(98, 32, 26, vinylAngle + scrubDegrees * DEG_TO_RAD); oled.setTextSize(1); oled.setCursor(0, 2); oled.print("SCRUB"); oled.setTextSize(2); oled.setCursor(0, 22); oled.printf("%+lds", lroundf(scrubDegrees / DEG_PER_SECOND)); oled.setTextSize(1); oled.setCursor(0, 52); oled.print("turn disk"); } oled.display(); } // ── Playlist ────────────────────────────────────────────── void scanTracks() { trackCount = 0; // may be re-run after an SD retry File root = SD.open("/"); while (true) { File f = root.openNextFile(); if (!f) break; if (!f.isDirectory()) { String name = f.name(); int slash = name.lastIndexOf('/'); if (slash >= 0) name = name.substring(slash + 1); String lower = name; lower.toLowerCase(); if (!name.startsWith(".") && lower.endsWith(".mp3") && trackCount < MAX_TRACKS) { tracks[trackCount++] = name; } } f.close(); } root.close(); // alphabetical insertion sort for (int i = 1; i < trackCount; i++) { String key = tracks[i]; int j = i - 1; while (j >= 0 && tracks[j] > key) { tracks[j + 1] = tracks[j]; j--; } tracks[j + 1] = key; } LOG("Found %d tracks:\n", trackCount); for (int i = 0; i < trackCount; i++) LOG(" %d: %s\n", i, tracks[i].c_str()); } void playTrack(int index) { if (trackCount == 0) return; currentTrack = index % trackCount; String path = "/" + tracks[currentTrack]; bool ok = audio.connecttoFS(SD, path.c_str()); isPlaying = true; scrubDegrees = 0; // discard any pending scrub heldScrub = false; pendingSeek = 0; motorStart(); LOG("Playing %s%s\n", path.c_str(), ok ? "" : " — connect FAILED"); } // ── Setup ───────────────────────────────────────────────── void setup() { // attach motor PWM first: unattached GPIO43 is UART0 TX idling HIGH, // which drives the motor at full speed ledcAttach(MOTOR_PIN, MOTOR_PWM_FREQ, MOTOR_PWM_RES); ledcWrite(MOTOR_PIN, 0); Serial.begin(115200); // USB CDC — does not touch GPIO43/44 delay(1000); Serial.println("PixelJam booting..."); pinMode(BTN_PLAY, INPUT_PULLUP); pinMode(BTN_NEXT, INPUT_PULLUP); pinMode(BTN_PREV, INPUT_PULLUP); delay(10); lastPlayBtn = HIGH; // level-driven: a switch booting in PLAY starts playback nextRest = digitalRead(BTN_NEXT); prevRest = digitalRead(BTN_PREV); LOG("Switch rest states: NEXT=%s PREV=%s\n", nextRest ? "HIGH" : "LOW", prevRest ? "HIGH" : "LOW"); Wire.begin(SDA_PIN, SCL_PIN); Wire.setClock(400000); int raw = readEncoderRaw(); encoderOK = (raw >= 0); if (encoderOK) lastRawAngle = raw; // else first delta = huge phantom scrub LOG("AS5600: %s\n", encoderOK ? "OK" : "not found (scrub disabled)"); oledOK = oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR); if (!oledOK) oledOK = oled.begin(SSD1306_SWITCHCAPVCC, 0x3D); LOG("OLED: %s\n", oledOK ? "OK" : "not found (display disabled)"); bootAnimation(); SPI.begin(SPI_SCK, SPI_MISO, SPI_MOSI, SD_CS); // marginal cards/wiring often mount only at lower SPI clocks const uint32_t sdFreqs[] = {4000000, 1000000, 400000}; bool sdOK = false; for (int f = 0; f < 3 && !sdOK; f++) { for (int attempt = 1; attempt <= 3 && !sdOK; attempt++) { sdOK = SD.begin(SD_CS, SPI, sdFreqs[f]); if (sdOK) { LOG("SD OK at %lu kHz\n", (unsigned long)(sdFreqs[f] / 1000)); } else { LOG("SD mount failed (%lu kHz, attempt %d)\n", (unsigned long)(sdFreqs[f] / 1000), attempt); SD.end(); delay(300); } } } if (sdOK) { scanTracks(); if (trackCount > 0) sdReady = true; else LOG("No MP3 files found!\n"); } else { // don't bail out: loop() keeps retrying so a reseated card just works LOG("SD failed — reseat the card, retrying in background\n"); } audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); #if VOLUME_POT_ENABLED audio.setVolume(map(analogRead(VOL_PIN), 0, 4095, 2, 21)); #else audio.setVolume(21); // TEMP: max volume for gear-noise test LOG("Volume forced to MAX (pot disabled for test)\n"); #endif // device boots idle: the OLED is on, the disk and music wait for play LOG(sdReady ? "Ready — press play\n" : "Waiting for SD card...\n"); } // ── Loop ────────────────────────────────────────────────── void loop() { audio.loop(); motorUpdate(); uint32_t now = millis(); // TEMP diagnostic: report raw button-pin levels on any change, no debounce { static uint8_t lastRawPins = 0xFF; uint8_t rawPins = (digitalRead(BTN_PLAY) << 2) | (digitalRead(BTN_NEXT) << 1) | digitalRead(BTN_PREV); if (rawPins != lastRawPins) { LOG("PINS play(44)=%d next(41)=%d prev(3)=%d\n", (rawPins >> 2) & 1, (rawPins >> 1) & 1, rawPins & 1); lastRawPins = rawPins; } } // auto-advance (flag set by audio_eof_mp3 callback) if (trackEnded) { trackEnded = false; playTrack(currentTrack + 1); } // watchdog: we think we're playing but the decoder isn't running // (failed connecttoFS / bad file) — skip ahead instead of wedging static uint32_t notRunningSince = 0; if (isPlaying && !audio.isRunning()) { if (notRunningSince == 0) notRunningSince = now; else if (now - notRunningSince >= 3000) { notRunningSince = 0; LOG("Decoder stalled — skipping to next track\n"); playTrack(currentTrack + 1); } } else { notRunningSince = 0; } // SD didn't mount at boot (flaky socket) — keep trying so a reseated // card starts working without a power cycle if (!sdReady) { static uint32_t lastSdTry = 0; if (now - lastSdTry >= 3000) { lastSdTry = now; SD.end(); bool ok = SD.begin(SD_CS, SPI, 4000000); if (!ok) { SD.end(); ok = SD.begin(SD_CS, SPI, 400000); } if (ok) { scanTracks(); if (trackCount > 0) { sdReady = true; LOG("SD card recovered — ready, press play\n"); } } } } // retry a scrub seek that the library refused right after resume if (pendingSeek != 0 && isPlaying) { static uint32_t lastSeekTry = 0; if (now - lastSeekTry >= 200) { lastSeekTry = now; if (audio.setTimeOffset(pendingSeek)) { LOG("Scrub seek %+d s: OK on retry\n", pendingSeek); pendingSeek = 0; } else if ((int32_t)(now - pendingSeekUntil) >= 0) { LOG("Scrub seek %+d s: giving up\n", pendingSeek); pendingSeek = 0; } } } // play/pause toggle switch — level-driven, debounced 20 ms: // lever LOW = music plays, lever HIGH = paused static bool playCand = HIGH; static uint32_t playChangeMs = 0; bool playRead = digitalRead(BTN_PLAY); if (playRead != playCand) { playCand = playRead; playChangeMs = now; } if (playCand != lastPlayBtn && now - playChangeMs >= 20) { bool wantPlay = (playCand == LOW); if (wantPlay && !started && !sdReady) { // leave lastPlayBtn unchanged so this retries once the card recovers static uint32_t lastNag = 0; if (now - lastNag >= 2000) { lastNag = now; LOG("Play switch is on, but SD/tracks not ready\n"); } } else { lastPlayBtn = playCand; if (wantPlay && !started) { // first flip: spin up and play started = true; playTrack(currentTrack); } else if (wantPlay && !isPlaying) { heldScrub = false; audio.pauseResume(); isPlaying = true; applyScrub(); // seek must happen while audio is running motorStart(); LOG("Playing\n"); } else if (!wantPlay && isPlaying) { audio.pauseResume(); isPlaying = false; motorStop(); startScrubSession(); LOG("Paused — disk free, turn to scrub\n"); } } } // next-track limit switch — fires once on leaving rest state (NO or NC) static bool nextCand = HIGH; static uint32_t nextChangeMs = 0; bool nextRead = digitalRead(BTN_NEXT); if (nextRead != nextCand) { nextCand = nextRead; nextChangeMs = now; } if (now - nextChangeMs >= 20) { if (nextCand != nextRest && nextArmed) { nextArmed = false; LOG("NEXT switch actuated (pin %s)\n", nextCand ? "HIGH" : "LOW"); playTrack(currentTrack + 1); } else if (nextCand == nextRest) { nextArmed = true; } } // previous-track limit switch static bool prevCand = HIGH; static uint32_t prevChangeMs = 0; bool prevRead = digitalRead(BTN_PREV); if (prevRead != prevCand) { prevCand = prevRead; prevChangeMs = now; } if (now - prevChangeMs >= 20) { if (prevCand != prevRest && prevArmed) { prevArmed = false; LOG("PREV switch actuated (pin %s)\n", prevCand ? "HIGH" : "LOW"); playTrack((currentTrack - 1 + trackCount) % trackCount); } else if (prevCand == prevRest) { prevArmed = true; } } #if VOLUME_POT_ENABLED // volume pot — every 100 ms, only act on change static uint32_t lastVolPoll = 0; static int lastVol = -1; if (now - lastVolPoll >= 100) { lastVolPoll = now; int raw = analogRead(VOL_PIN); int vol = map(raw, 0, 4095, 2, 21); if (vol != lastVol) { lastVol = vol; audio.setVolume(vol); LOG("Volume: raw=%d -> %d\n", raw, vol); } } #endif // OLED status — every 100 ms (~25 ms per frame on the I2C bus; audio // decode runs in its own task, so this doesn't starve playback) static uint32_t lastOledMs = 0; if (oledOK && now - lastOledMs >= 100) { lastOledMs = now; oledStatus(); } // encoder — every 50 ms static uint32_t lastEncPoll = 0; if (now - lastEncPoll >= 50) { lastEncPoll = now; float d = readEncoderDelta(); if (isPlaying) { // touch detection by velocity deviation from the learned baseline if (motorState == MOTOR_RUN && now - motorRunStart >= STALL_ARM_MS) { windowMovement += fabsf(d); if (now - windowStart >= SPEED_WINDOW_MS) { float w = windowMovement; // degrees this window windowMovement = 0; windowStart = now; if (baselineDeg <= 0) { baselineDeg = max(w, 0.05f); LOG("Disk baseline: %.2f deg/s\n", baselineDeg * 1000.0f / SPEED_WINDOW_MS); } else { // learn slowly, but never from a window that looks touched if (w > 0.6f * baselineDeg && w < 2.0f * baselineDeg) baselineDeg = 0.9f * baselineDeg + 0.1f * w; bool slowed = (w < TOUCH_DROP * baselineDeg); bool spun = (w > TOUCH_SPIN_X * baselineDeg && w > TOUCH_SPIN_MIN); lowWindows = slowed ? lowWindows + 1 : 0; // a baseline below MIN_BASELINE means the encoder isn't tracking // the motor-driven disk (decoupled magnet), so the slow-down // gesture can't be judged. The spin gesture stays available: // its absolute floor is far above anything the motor produces. bool baseValid = (baselineDeg >= MIN_BASELINE); if ((baseValid && lowWindows >= TOUCH_LOW_WINDOWS) || spun) { lowWindows = 0; audio.pauseResume(); isPlaying = false; motorStop(); startScrubSession(); heldScrub = true; movedSinceGrab = spun; // a spin IS already movement lastMoveMs = now; LOG("Touch detected (%s: %.2f vs baseline %.2f deg/s) — scrub mode\n", spun ? "spin" : "hold", w * 1000.0f / SPEED_WINDOW_MS, baselineDeg * 1000.0f / SPEED_WINDOW_MS); } static uint8_t wn = 0; if (++wn >= 8) { // speed report every ~2 s wn = 0; LOG("Disk speed: %.2f deg/s (baseline %.2f) t=%lu/%lus\n", w * 1000.0f / SPEED_WINDOW_MS, baselineDeg * 1000.0f / SPEED_WINDOW_MS, (unsigned long)audio.getAudioCurrentTime(), (unsigned long)audio.getAudioFileDuration()); } } } } else { windowMovement = 0; windowStart = now; lowWindows = 0; } } else if (started) { // idle drift before the first play is not a scrub scrubDegrees += d; if (heldScrub) { if (fabsf(d) > 0.4f) { // disk is being turned if (!movedSinceGrab) LOG("Scrubbing started\n"); lastMoveMs = now; movedSinceGrab = true; } uint32_t idleNeeded = movedSinceGrab ? RELEASE_MS : GRAB_TIMEOUT_MS; if (lastPlayBtn == HIGH) { heldScrub = false; // play switch was flipped off mid-scrub: stay paused } else if (now - lastMoveMs >= idleNeeded) { heldScrub = false; audio.pauseResume(); isPlaying = true; applyScrub(); motorStart(); LOG("Hand lifted — resuming\n"); } } static uint32_t lastScrubLog = 0; if (fabsf(scrubDegrees) >= DEG_PER_SECOND && now - lastScrubLog >= 500) { lastScrubLog = now; LOG("Scrub: %+.1f° (%+ld s)\n", scrubDegrees, lroundf(scrubDegrees / DEG_PER_SECOND)); } } } } // ── Audio library callbacks ─────────────────────────────── void audio_eof_mp3(const char* info) { trackEnded = true; // advance in loop(); connecttoFS is unsafe here LOG("Track ended: %s\n", info); } void audio_info(const char* info) { Serial.printf("[audio] %s\n", info); }