Sound-Reactive Nautilus Lamp (Arduino Project)

I made this nautilus light because I thought the shape would make a very cool lamp.

It is Arduino-based and sound-sensitive. When it detects sound, it does a running light effect through the shell, and when it is quiet, it slowly breathes with a calm glow.

Below are the instructions on how to make it.

Bill of Materials (BOM)

  • 3D-printed parts. 3D Models: https://www.thingiverse.com/thing:7336183

  • Arduino UNO R4 (also need USB cable compatible with the Arduino UNO R4)

  • Keyestudio microphone sensor

  • 1 meter WS2812 LED Strip (Must be exactly 1 meter, I used 144 leds)

  • USB Type-C 3.1 PD to 5.5mm Barrel Jack Cable (12V 5A Output, 1.2m long). IMPORTANT WARNING: If you are using an older version of the UNO (e.g., UNO R3) or a Nano instead of the specified R4, it is highly recommended to switch to a 5V power cable, or provide a separate 5V power supply for the LEDs. Feeding 12V to older boards while driving 144 LEDs will cause fatal hardware damage to the voltage regulator.

  • Jumper wires

  • 6 pieces of M3x4 or M3x6 hex socket head cap screws

  • Small zip ties (for wire management and securing sound sensor)

  • M3 hex screwdriver

Step-by-Step Assembly Guide

Step 1: Place the transparent diffuser into the black enclosure (as shown in the picture).

Step 2: Carefully observe the WS2812 LED strip. It has explicit arrows printed on it. Ensure that when installing, these arrows point from the outside towards the inside of the spiral (the arrows must follow the same direction as the LED running light).

Step 3: There is a groove specifically designed for the LED strip right between the black enclosure and the transparent diffuser. Place the LED strip into this groove. The groove should perfectly fit exactly 1 meter of the strip (as shown in the picture, but absolutely double-check the arrow direction again before pressing it in).

Step 4: After placing the LED strip into the groove, install the separator plate that goes between the LEDs and the Arduino compartment. Position it exactly as shown in the picture.

Step 5: Connect the wires. Use the M3 screws and zip ties to securely mount the Arduino and the microphone sensor to the separator plate (as shown in the pictures).

Step 6: After successfully flashing the code to the Arduino, you can plug in the DC power cable.

Step 7: Secure the back cover using the M3 screws. The project is now complete!

Arduino Code:

#include <FastLED.h>

// Hardware setup
#define NUM_LEDS 144
#define DATA_PIN 4
CRGB leds[NUM_LEDS];

const int soundSensor = A0;

// Global tweaks
const int soundThreshold = 72;      // How loud the room needs to be to trigger a new light
const int ledBrightness = 230;      // Master brightness (0-255)
const unsigned long interval = 10;  // Main loop update rate in milliseconds (~100 FPS)

// Sensor state
unsigned long lastTime = 0;
float smoothedSound = 0.0;          // Keeps track of the smoothed audio signal to ignore mic static

// Breathing animation settings
const uint8_t BREATHE_MIN_V = 50;
const uint8_t BREATHE_MAX_V = 200;
const uint16_t BREATHE_PERIOD_MS = 8000; // A full breath takes 8 seconds

uint8_t quietHue = 0;
uint8_t breatheV = 0;
uint8_t lastBreatheV = 0;
uint8_t targetV = 0;

static uint16_t breathPhase = 0;
static unsigned long lastBreathTick = 0;

// Running light animation settings
struct Runner {
  int pos;
  CRGB color;
  bool active;
};

const uint8_t MAX_RUNNERS = 8;
Runner runners[MAX_RUNNERS];

const uint8_t fadeAmt = 12;         // How quickly the trails fade out
unsigned long lastSpawnMs = 0;
const unsigned long spawnCooldownMs = 60; // Minimum time between spawning new lights

// Transition states between running and breathing
static bool wasRunning = false;
static bool inHandover = false;
static unsigned long handoverStart = 0;
const unsigned long HANDOVER_MS = 420; // Crossfade duration

// Checks if there are any light runners currently moving across the strip
bool anyRunnerActive() {
  for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
    if (runners[i].active) return true;
  }
  return false;
}

// Spawns a new light runner at the start of the LED strip
void spawnRunner() {
  int slot = -1;
  
  // Look for an empty slot
  for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
    if (!runners[i].active) {
      slot = i;
      break;
    }
  }

  // If all slots are full, hijack the one that has traveled the furthest
  if (slot == -1) {
    int bestPos = -9999;
    for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
      if (runners[i].pos > bestPos) {
        bestPos = runners[i].pos;
        slot = i;
      }
    }
  }

  runners[slot].pos = 0;
  runners[slot].color = CHSV(random8(), 255, 255);
  runners[slot].active = true;
}

// Moves all active runners forward and fades their trails
void stepRunners() {
  fadeToBlackBy(leds, NUM_LEDS, fadeAmt);

  // Draw current runners
  for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
    if (runners[i].active && runners[i].pos < NUM_LEDS) {
      leds[runners[i].pos] = runners[i].color;
    }
  }

  // Move them forward
  for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
    if (runners[i].active) {
      runners[i].pos++;
    }
  }

  // Retire runners that have reached the end of the strip
  for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
    if (runners[i].active && runners[i].pos >= NUM_LEDS) {
      runners[i].active = false;
    }
  }
}

// Calculates the smooth, sine-wave based breathing effect for idle time
void doBreathing() {
  unsigned long now = millis();
  unsigned long dt = (lastBreathTick == 0) ? 0 : (now - lastBreathTick);
  lastBreathTick = now;

  // Progress the sine wave phase based on elapsed time
  uint16_t inc = (uint32_t(65536UL) * (dt ? dt : interval)) / BREATHE_PERIOD_MS;
  breathPhase += inc;

  int16_t s16 = sin16(breathPhase);
  uint8_t wave8 = (uint16_t(s16) + 32768) >> 8;
  uint8_t eased = ease8InOutQuad(wave8);

  targetV = map(eased, 0, 255, BREATHE_MIN_V, BREATHE_MAX_V);
  breatheV = lerp8by8(breatheV, targetV, 10);

  // Pick a new random color when the breath reaches its dimmest point
  if (breatheV <= BREATHE_MIN_V + 1 && lastBreatheV > BREATHE_MIN_V + 1) {
    quietHue = random8();
  }

  fill_solid(leds, NUM_LEDS, CHSV(quietHue, 255, breatheV));
  lastBreatheV = breatheV;
}

void setup() {
  Serial.begin(9600);
  pinMode(soundSensor, INPUT);

  FastLED.addLeds<WS2812, DATA_PIN, GRB>(leds, NUM_LEDS);
  FastLED.setBrightness(ledBrightness);

  // Start with a nice Tiffany blue before any sounds are detected
  quietHue = 127;
  fill_solid(leds, NUM_LEDS, CHSV(127, 80, 255));
  FastLED.show();

  // Prime the smoothing filter with an initial reading
  smoothedSound = analogRead(soundSensor);
  randomSeed(analogRead(soundSensor));

  // Ensure all runners start inactive
  for (uint8_t i = 0; i < MAX_RUNNERS; i++) {
    runners[i].active = false;
    runners[i].pos = 0;
    runners[i].color = CRGB::Black;
  }

  lastTime = millis();
  lastBreathTick = millis();
  breathPhase = 0;
  breatheV = BREATHE_MIN_V;
  lastBreatheV = BREATHE_MIN_V + 2;

  Serial.println("Setup complete. Sound reactive mode only.");
}

void loop() {
  unsigned long currentTime = millis();
  
  if (currentTime - lastTime > interval) {
    
    // Smooth out the raw audio signal to prevent flickering or false triggers
    int rawSound = analogRead(soundSensor);
    smoothedSound = (smoothedSound * 0.8) + (rawSound * 0.2);
    bool isLoud = (smoothedSound > soundThreshold);

    // If it's loud enough and the cooldown has passed, shoot a new light
    if (isLoud && (currentTime - lastSpawnMs > spawnCooldownMs)) {
      inHandover = false;
      spawnRunner();
      lastSpawnMs = currentTime;
    }

    if (anyRunnerActive()) {
      stepRunners();
      wasRunning = true;
      inHandover = false;
    } else {
      // Transition from running state back to breathing state
      if (wasRunning && !inHandover) {
        breathPhase = 0;
        breatheV = BREATHE_MIN_V;
        lastBreatheV = BREATHE_MIN_V + 2;
        inHandover = true;
        handoverStart = currentTime;
      }

      // Handle the gentle crossfade between modes
      if (inHandover) {
        fadeToBlackBy(leds, NUM_LEDS, 32);

        CHSV baseHSV(quietHue, 255, BREATHE_MIN_V);
        CRGB baseRGB;
        hsv2rgb_rainbow(baseHSV, baseRGB);

        // Blend the running trail leftovers with the new breathing base color
        for (int i = 0; i < NUM_LEDS; i++) {
          nblend(leds[i], baseRGB, 40);
        }

        if (currentTime - handoverStart >= HANDOVER_MS) {
          inHandover = false;
          wasRunning = false;
        }
      } else {
        doBreathing();
      }
    }

    FastLED.show();
    lastTime = currentTime;
  }
}
Next
Next

Toggle Lock Box