// ============================================================ // INTERMISSION OBJECT // XIAO ESP32-S3 · Custom PCB // // State machine: IDLE → EXPERIENCE → ENDING → IDLE // // Libraries required: // - "AudioTools" by pschatzmann (Library Manager) // - "DFRobot_TM6605" (Library Manager) // ============================================================ #include "AudioTools.h" #include #include "DFRobot_TM6605.h" #include "config.h" #include "wifi_ota.h" // ── State machine ──────────────────────────────────────────── enum State { IDLE, EXPERIENCE, ENDING }; State state = IDLE; // ── Peripherals ────────────────────────────────────────────── DFRobot_TM6605 TM6605; bool hapticAvailable = false; // [fix #4] set only if TM6605 inits AudioInfo audioInfo(SAMPLE_RATE, CHANNELS, BITS); I2SStream i2s; SineWaveGenerator sine; GeneratedSoundStream sound(sine); StreamCopy copier(i2s, sound); // ── Hall sensor ISR ────────────────────────────────────────── volatile bool pulseFlag = false; volatile uint32_t lastPulseUs = 0; void IRAM_ATTR onPulse() { uint32_t now = micros(); if (now - lastPulseUs > (uint32_t)DEBOUNCE_MS * 1000UL) { pulseFlag = true; lastPulseUs = now; } } // ── Experience globals ─────────────────────────────────────── unsigned long experienceDuration = 0; unsigned long experienceStart = 0; unsigned long lastPulseMs = 0; bool dialHeld = false; // ── Breath envelope globals ────────────────────────────────── float breathPhase = 0.0f; float heldBlend = 0.0f; // [fix #6] 0=normal, 1=held; ramps smoothly unsigned long lastLoopMs = 0; // ── Idle pulse tracking ────────────────────────────────────── int idlePulseCount = 0; unsigned long lastIdlePulseMs = 0; unsigned long idleFlashStartMs = 0; // [fix #1] non-blocking idle flash // ──────────────────────────────────────────────────────────── // startI2S — (re)configure and start I2S + sine generator // ──────────────────────────────────────────────────────────── void startI2S(int frequency, int16_t amplitude) { auto cfg = i2s.defaultConfig(TX_MODE); cfg.pin_bck = PIN_I2S_BCLK; cfg.pin_ws = PIN_I2S_LRC; cfg.pin_data = PIN_I2S_DIN; cfg.copyFrom(audioInfo); i2s.begin(cfg); sine.begin(audioInfo, frequency); sine.setAmplitude(amplitude); sound.begin(audioInfo); } // ──────────────────────────────────────────────────────────── // handleIdle — runs each loop() iteration when state == IDLE // ──────────────────────────────────────────────────────────── void handleIdle() { unsigned long now = millis(); #ifdef AUTO_START if (now > 5000UL) { Serial.println("[AUTO_START] Bypassing Hall sensor — entering experience."); ledcWrite(PIN_LED_PWM, 0); enterExperience(); return; } #endif // Reset pulse count if dial has been still too long if (idlePulseCount > 0 && (now - lastIdlePulseMs) > IDLE_RESET_MS) { idlePulseCount = 0; Serial.println("[IDLE] Pulse count reset — dial stopped."); } // Consume Hall pulse if (pulseFlag) { pulseFlag = false; lastIdlePulseMs = now; idleFlashStartMs = now; // [fix #1] arm the soft-pulse timer idlePulseCount++; Serial.printf("[IDLE] Pulse %d / %d\n", idlePulseCount, TRIGGER_PULSES); if (idlePulseCount >= TRIGGER_PULSES) { ledcWrite(PIN_LED_PWM, 0); enterExperience(); return; } } // [fix #1] Non-blocking soft sine-envelope pulse on LED unsigned long elapsed = now - idleFlashStartMs; if (elapsed < IDLE_FLASH_DURATION_MS) { float p = (float)elapsed / (float)IDLE_FLASH_DURATION_MS; float env = sinf(p * (float)PI); // smooth 0 → peak → 0 ledcWrite(PIN_LED_PWM, (uint8_t)(env * LED_IDLE_FLASH)); } else { ledcWrite(PIN_LED_PWM, 0); } } // ──────────────────────────────────────────────────────────── // enterExperience — one-shot transition from IDLE // ──────────────────────────────────────────────────────────── void enterExperience() { Serial.println("[EXPERIENCE] Starting..."); experienceDuration = random((long)EXP_MIN_MS, (long)EXP_MAX_MS + 1); experienceStart = millis(); lastPulseMs = millis(); // initialise so dialHeld starts false breathPhase = 0.0f; heldBlend = 0.0f; // [fix #6] start in normal mode lastLoopMs = millis(); dialHeld = false; idlePulseCount = 0; Serial.printf("[EXPERIENCE] Duration: %lu ms\n", experienceDuration); // Start motor ledcWrite(PIN_MOTOR_PWM, MOTOR_DUTY); // Start audio at silence startI2S(AUDIO_AMBIENT_HZ, 0); // Fade-in sequence — LED, audio, and haptic build together over 3000 ms // LED: 0 → 255 over first 1800 ms, then 255 → LED_BREATHE_MAX over 1200 ms // Haptic: short boost → medium boost → long boost, cued to LED arc int hapticStage = 0; unsigned long t0 = millis(); while (millis() - t0 < 3000UL) { unsigned long elapsed = millis() - t0; // LED arc uint8_t ledDuty; if (elapsed < 1800) { ledDuty = (uint8_t)((float)elapsed / 1800.0f * 255.0f); } else { float q = (float)(elapsed - 1800) / 1200.0f; ledDuty = (uint8_t)(255.0f - q * (255.0f - LED_BREATHE_MAX)); } ledcWrite(PIN_LED_PWM, ledDuty); // Single smooth boost — no chained transitions, one continuous waveform if (hapticAvailable && hapticStage == 0) { TM6605.selectEffect(DFRobot_TM6605::eLongSlowBoostTransition1); TM6605.play(); hapticStage = 1; } copier.copy(); delay(4); } if (hapticAvailable) TM6605.stop(); lastLoopMs = millis(); state = EXPERIENCE; Serial.println("[EXPERIENCE] Active."); } // ──────────────────────────────────────────────────────────── // handleExperience — runs each loop() iteration during EXPERIENCE // ──────────────────────────────────────────────────────────── void handleExperience() { unsigned long now = millis(); unsigned long dt = now - lastLoopMs; lastLoopMs = now; // Consume Hall pulse if (pulseFlag) { pulseFlag = false; lastPulseMs = now; } // dialHeld = no Hall pulse for DIAL_HELD_TIMEOUT ms bool newDialHeld = (now - lastPulseMs > DIAL_HELD_TIMEOUT); if (newDialHeld != dialHeld) { dialHeld = newDialHeld; Serial.printf("[EXPERIENCE] dialHeld=%d\n", (int)dialHeld); } // [fix #6] Smoothly blend toward target held/normal state float blendTarget = dialHeld ? 1.0f : 0.0f; float blendStep = (float)dt / HELD_TRANSITION_MS; if (heldBlend < blendTarget) heldBlend = min(heldBlend + blendStep, 1.0f); else heldBlend = max(heldBlend - blendStep, 0.0f); // Advance breath phase using blended period float blendedPeriod = BREATHE_PERIOD_MS + heldBlend * ((float)BREATHE_HELD_MS - (float)BREATHE_PERIOD_MS); breathPhase += (float)TWO_PI * (float)dt / blendedPeriod; if (breathPhase > (float)TWO_PI) breathPhase -= (float)TWO_PI; float breathVal = (sinf(breathPhase) + 1.0f) / 2.0f; // 0.0–1.0 // [fix #6] LED driven by blended brightness range — no snapping float ledMin = LED_BREATHE_MIN + heldBlend * (LED_HELD_MIN - LED_BREATHE_MIN); float ledMax = LED_BREATHE_MAX + heldBlend * (LED_HELD_MAX - LED_BREATHE_MAX); ledcWrite(PIN_LED_PWM, (uint8_t)(ledMin + breathVal * (ledMax - ledMin))); // Audio amplitude follows same heldBlend as LED — slow/quiet ↔ fast/loud float audioFloor = AUDIO_NORMAL_FLOOR + heldBlend * (AUDIO_HELD_FLOOR - AUDIO_NORMAL_FLOOR); float audioRange = AUDIO_NORMAL_RANGE + heldBlend * (AUDIO_HELD_RANGE - AUDIO_NORMAL_RANGE); int16_t amp = (int16_t)(audioFloor + breathVal * audioRange); sine.setAmplitude(amp); copier.copy(); // Check experience timer if (now - experienceStart >= experienceDuration) { enterEnding(); } } // ──────────────────────────────────────────────────────────── // enterEnding — one-shot ending sequence; fully blocking // ──────────────────────────────────────────────────────────── void enterEnding() { Serial.println("[ENDING] Starting..."); state = ENDING; // Silence ambient audio immediately i2s.end(); // Switch to soothing ending tone startI2S(AUDIO_END_HZ, 0); // Capture current LED level for a smooth ramp start float breathVal = (sinf(breathPhase) + 1.0f) / 2.0f; float ledMin = LED_BREATHE_MIN + heldBlend * (LED_HELD_MIN - LED_BREATHE_MIN); float ledMax = LED_BREATHE_MAX + heldBlend * (LED_HELD_MAX - LED_BREATHE_MAX); uint8_t startDuty = (uint8_t)(ledMin + breathVal * (ledMax - ledMin)); // Ending sequence — LED, audio, and haptic in sync over 3100 ms // 0–2000 ms : ramp up — haptic boosts, LED startDuty→255, audio 0→AUDIO_END_AMP // 2000–2500 ms : hold — haptic at peak, LED/audio at max // 2500–3100 ms : fade — haptic fades, LED 255→0, audio AUDIO_END_AMP→0 int hapticStage = 0; unsigned long t0 = millis(); while (millis() - t0 < 3100UL) { unsigned long elapsed = millis() - t0; // LED + audio arc float ledDuty, audioAmp; if (elapsed < 2000) { float p = (float)elapsed / 2000.0f; ledDuty = startDuty + p * (255.0f - startDuty); audioAmp = p * AUDIO_END_AMP; } else if (elapsed < 2500) { ledDuty = 255.0f; audioAmp = AUDIO_END_AMP; } else { float p = (float)(elapsed - 2500) / 600.0f; ledDuty = 255.0f * (1.0f - p); audioAmp = AUDIO_END_AMP * (1.0f - p); } ledcWrite(PIN_LED_PWM, (uint8_t)ledDuty); sine.setAmplitude((int16_t)audioAmp); // Boost during ramp, then direct switch to fade at peak — no stop() between them if (hapticAvailable) { if (hapticStage == 0) { TM6605.selectEffect(DFRobot_TM6605::eLongSlowBoostTransition1); TM6605.play(); hapticStage = 1; } else if (hapticStage == 1 && elapsed >= 2500) { TM6605.selectEffect(DFRobot_TM6605::eLongSlowFadeTransition1); TM6605.play(); hapticStage = 2; } } copier.copy(); delay(4); } ledcWrite(PIN_LED_PWM, 0); // Clean up all outputs if (hapticAvailable) TM6605.stop(); i2s.end(); ledcWrite(PIN_MOTOR_PWM, 0); Serial.println("[ENDING] Complete. Returning to idle.\n"); // Reset for next experience idlePulseCount = 0; lastIdlePulseMs = 0; idleFlashStartMs = 0; breathPhase = 0.0f; heldBlend = 0.0f; dialHeld = false; pulseFlag = false; state = IDLE; } // ──────────────────────────────────────────────────────────── // setup() // ──────────────────────────────────────────────────────────── void setup() { Serial.begin(115200); AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning); delay(1500); // generous settling time — I2C needs the bus stable before first command Serial.println("\n=== INTERMISSION OBJECT ==="); #ifdef TEST_MODE Serial.println(" [TEST MODE: 30–60 s experience]"); #else Serial.println(" [PRODUCTION MODE: 3–5 min experience]"); #endif Serial.println(); // I2C first — before PWM init, with slower clock for reliable TM6605 comms Wire.begin(PIN_I2C_SDA, PIN_I2C_SCL); Wire.setClock(50000); // 50 kHz: more robust than 100 kHz for this module delay(500); // I2C bus scan — helps diagnose TM6605 address / wiring issues Serial.println("I2C scan:"); bool anyFound = false; for (byte addr = 8; addr < 120; addr++) { Wire.beginTransmission(addr); if (Wire.endTransmission() == 0) { Serial.printf(" device at 0x%02X\n", addr); anyFound = true; } } if (!anyFound) Serial.println(" nothing found — check SDA/SCL wiring"); int attempts = 0; Serial.print("TM6605 init... "); while (TM6605.begin() != 0) { // begin() returns 0 on success, non-zero on failure Serial.println("not found, retrying..."); delay(1000); if (++attempts >= 5) { Serial.println("TM6605 failed — continuing without haptics."); break; } } if (attempts < 5) { Serial.println("ok"); hapticAvailable = true; } // PWM channels ledcAttach(PIN_MOTOR_PWM, PWM_FREQ_MOTOR, PWM_RES); ledcAttach(PIN_LED_PWM, PWM_FREQ_LED, PWM_RES); ledcWrite(PIN_MOTOR_PWM, 0); ledcWrite(PIN_LED_PWM, 0); // Hall effect sensor pinMode(PIN_HALL, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(PIN_HALL), onPulse, FALLING); randomSeed(analogRead(0)); setupWiFiOTA(); state = IDLE; Serial.println("Ready. Turn the dial two full rotations to begin.\n"); } // ──────────────────────────────────────────────────────────── // loop() // ──────────────────────────────────────────────────────────── void loop() { handleOTA(); switch (state) { case IDLE: handleIdle(); break; case EXPERIENCE: handleExperience(); break; case ENDING: break; // fully managed inside enterEnding() } }