01 March 2026

Mapping: Sound-Based Lockpicking

By: Michael Mccallum and Andrew Parkinson

Our game is a kind of balancing game where you use sound to try to hold the proper volume level to win. Blue lights will display your sound level relative to the goal and 2 white lights on either side will display the area in which you want to hold your volume. As you get your volume level to the goal area it will turn green, if you go above they will turn red, and if you are able to hold your volume level within the white area you will win the round. After a round is finished, you must press the button on the breadboard to move onto the next round, the white lights will pulse slightly until you do. Using the dial will change the sensitivity of the sound sensor, for instance, turning the dial to the right will make it so that sound rises at a faster rate. The strategy of the game would be to turn the dial to a sensitivity that makes your volume level reach the goal zone without going over too much. We have equated this process to that of a lockpicking mechanic in a game like Skyrim, however you would use your voice and a dial rather than a traditional lock picking set. 


The video demonstration of our game:


The schematic drawing of our game:


The Code for our game:

#include <Adafruit_CircuitPlayground.h>

/*
  _____ by Michael McCallum and Andrew Parkinson
*/

// ----------------------------------------------------
// pins
// ----------------------------------------------------
const int SOUND_PIN = A4;
const int POT_PIN = A2;
const int BUTTON_PIN = 10;

// ----------------------------------------------------
// game constants
// ----------------------------------------------------
const int NUM_PIXELS = 10;
const int DB_MIN = 30;
const int DB_MAX = 100;

const unsigned long HOLD_TIME_MS = 300;
const unsigned long WIN_SHOW_MS = 1000;
const unsigned long DEBUG_INTERVAL_MS = 120;
const unsigned long OUT_OF_RANGE_GRACE_MS = 220;

// stabilize noisy mic values
const int DB_BIN_SIZE = 3;
const int IN_RANGE_EXIT_EXTRA_DB = 2;

// Pot mapping values
const int MIC_GAIN_MIN_PERCENT = 110;
const int MIC_GAIN_MAX_PERCENT = 260;
const int MIC_GATE_MAX = 16;
const int MIC_GATE_MIN = 2;
const int MIC_ACTIVITY_MAX = 140;

// ----------------------------------------------------
// Main game variables
// ----------------------------------------------------
bool inPlay = false;
bool roundWon = false;

int noiseFloor = 0;
int smoothActivity = 0;
int smoothPot = 0;

int currentDb = DB_MIN;
int rawDb = DB_MIN;
int currentDbTop = DB_MAX;
int currentPotPercent = 0;

int targetCenterDb = 75;
int targetBaseHalfWidth = 3;
int targetLowDb = 65;
int targetHighDb = 85;

int bottomPixel = 0;
int topPixel = 5;
int waterFillPixels = 0;

unsigned long holdStartMs = 0;
unsigned long outOfRangeSinceMs = 0;
unsigned long winShowStartMs = 0;
unsigned long lastDebugMs = 0;

bool inRangeLatched = false;
bool lastButton = HIGH;

// ----------------------------------------------------
// Small helper functions
// ----------------------------------------------------

// keep pixel index between 0 and 9
int wrapPixel(int index) {
  if (index >= NUM_PIXELS) {
    return index - NUM_PIXELS;
  }
  if (index < 0) {
    return index + NUM_PIXELS;
  }
  return index;
}

// T=turn off all LEDs
void clearAllPixels() {
  for (int i = 0; i < NUM_PIXELS; i++) {
    CircuitPlayground.setPixelColor(i, 0, 0, 0);
  }
}

// check button press edge (not hold)
bool buttonPressed() {
  bool now = digitalRead(BUTTON_PIN);
  bool pressed = (lastButton == HIGH && now == LOW);
  lastButton = now;
  return pressed;
}

// snap dB to small ranges (ex: 31 -> 30, 32 -> 33)
// reduce jitter
int snapDbToRange(int dbValue) {
  int offset = dbValue - DB_MIN;
  int snappedOffset = ((offset + (DB_BIN_SIZE / 2)) / DB_BIN_SIZE) * DB_BIN_SIZE;
  int snappedDb = DB_MIN + snappedOffset;
  return constrain(snappedDb, DB_MIN, DB_MAX);
}

// ----------------------------------------------------
// Setup helpers
// ----------------------------------------------------

// read mic in quiet room once
void calibrateMic() {
  long total = 0;
  for (int i = 0; i < 120; i++) {
    total += analogRead(SOUND_PIN);
    delay(3);
  }
  noiseFloor = total / 120;
  smoothActivity = 0;
}

// start a new random round
void startNewRound() {
  targetCenterDb = random(42, 90);
  targetBaseHalfWidth = random(2, 5);

  targetLowDb = constrain(targetCenterDb - targetBaseHalfWidth, DB_MIN, DB_MAX);
  targetHighDb = constrain(targetCenterDb + targetBaseHalfWidth, DB_MIN, DB_MAX);

  bottomPixel = random(0, NUM_PIXELS);
  topPixel = wrapPixel(bottomPixel + 5);

  holdStartMs = 0;
  outOfRangeSinceMs = 0;
  waterFillPixels = 0;
  roundWon = false;
  inRangeLatched = false;
}

// ----------------------------------------------------
// Input + mapping
// ----------------------------------------------------

void readAudioAndMap() {
  int potRaw = analogRead(POT_PIN);

  // smooth pot so it doesn't jump
  smoothPot = (smoothPot * 7 + potRaw) / 8;
  currentPotPercent = map(smoothPot, 0, 1023, 0, 100);

  // pot changes gain and noise gate
  int micGainPercent = map(smoothPot, 0, 1023, MIC_GAIN_MIN_PERCENT, MIC_GAIN_MAX_PERCENT);
  int noiseGate = map(smoothPot, 0, 1023, MIC_GATE_MAX, MIC_GATE_MIN);

  // pot also changes top dB capture
  currentDbTop = map(smoothPot, 0, 1023, 68, DB_MAX);
  currentDbTop = constrain(currentDbTop, DB_MIN + 10, DB_MAX);

  // pot changes strictness a little
  int strictnessAdjust = map(smoothPot, 0, 1023, 1, -1);
  int half = constrain(targetBaseHalfWidth + strictnessAdjust, 2, 8);
  targetLowDb = constrain(targetCenterDb - half, DB_MIN, DB_MAX);
  targetHighDb = constrain(targetCenterDb + half, DB_MIN, DB_MAX);

  // read microphone and convert to activity value
  int micRaw = analogRead(SOUND_PIN);
  int activity = abs(micRaw - noiseFloor);

  // smooth mic activity
  smoothActivity = (smoothActivity * 7 + activity) / 8;

  // ignore tiny noise
  int gated = smoothActivity - noiseGate;
  if (gated < 0) {
    gated = 0;
  }

  // scale into a game dB range
  int scaled = (gated * micGainPercent) / 100;
  scaled = constrain(scaled, 0, MIC_ACTIVITY_MAX);

  rawDb = map(scaled, 0, MIC_ACTIVITY_MAX, DB_MIN, currentDbTop);
  rawDb = constrain(rawDb, DB_MIN, DB_MAX);

  // use snapped value for stable gameplay
  currentDb = snapDbToRange(rawDb);
}

// In-range check
// Enter with exact range, exit with extra margin
bool checkInRangeStable() {
  int enterLow = targetLowDb;
  int enterHigh = targetHighDb;
  int exitLow = targetLowDb - IN_RANGE_EXIT_EXTRA_DB;
  int exitHigh = targetHighDb + IN_RANGE_EXIT_EXTRA_DB;

  if (!inRangeLatched) {
    if (currentDb >= enterLow && currentDb <= enterHigh) {
      inRangeLatched = true;
    }
  } else {
    if (currentDb < exitLow || currentDb > exitHigh) {
      inRangeLatched = false;
    }
  }

  return inRangeLatched;
}

// ----------------------------------------------------
// LED drawing
// ----------------------------------------------------

void renderIdle() {
  clearAllPixels();

  // blue breathing effect while waiting
  int pulse = map((millis() / 8) % 255, 0, 254, 8, 45);
  for (int i = 0; i < NUM_PIXELS; i++) {
    CircuitPlayground.setPixelColor(i, 0, 0, pulse);
  }
}

void renderPlay(bool inRange) {
  clearAllPixels();

  // white target markers
  CircuitPlayground.setPixelColor(bottomPixel, 25, 25, 25);
  CircuitPlayground.setPixelColor(topPixel, 25, 25, 25);

  // blue water fill is based on dB relative to target
  int fillPairs = 0;
  if (currentDb < targetLowDb) {
    fillPairs = map(currentDb, DB_MIN, targetLowDb, 0, 2);
    fillPairs = constrain(fillPairs, 0, 2);
  } else if (currentDb <= targetHighDb) {
    fillPairs = 3;
  } else {
    int overflowPairs = map(currentDb, targetHighDb, DB_MAX, 0, 2);
    overflowPairs = constrain(overflowPairs, 0, 2);
    fillPairs = 3 + overflowPairs;
  }

  // middle area between opposite markers
  int leftCenter = wrapPixel(bottomPixel + 2);
  int rightCenter = wrapPixel(bottomPixel + 3);

  // draw mirrored pairs
  for (int ring = 0; ring < fillPairs; ring++) {
    int leftPixel = wrapPixel(leftCenter - ring);
    int rightPixel = wrapPixel(rightCenter + ring);
    CircuitPlayground.setPixelColor(leftPixel, 0, 0, 85);
    CircuitPlayground.setPixelColor(rightPixel, 0, 0, 85);
  }

  waterFillPixels = fillPairs * 2;

  // redraw markers on top
  CircuitPlayground.setPixelColor(bottomPixel, 25, 25, 25);
  CircuitPlayground.setPixelColor(topPixel, 25, 25, 25);

  // green = in range, Red = too high
  if (inRange) {
    CircuitPlayground.setPixelColor(bottomPixel, 0, 90, 0);
    CircuitPlayground.setPixelColor(topPixel, 0, 90, 0);
  } else if (currentDb > targetHighDb) {
    CircuitPlayground.setPixelColor(bottomPixel, 90, 0, 0);
    CircuitPlayground.setPixelColor(topPixel, 90, 0, 0);
  }
}

void renderWinShow() {
  clearAllPixels();

  // small animation across opposite pairs
  int phase = (millis() / 90) % 5;
  for (int level = 0; level < 5; level++) {
    int bright = 90 - abs(level - phase) * 25;
    bright = constrain(bright, 10, 90);

    int a = level;
    int b = level + 5;

    if (((millis() / 180) % 2) == 0) {
      CircuitPlayground.setPixelColor(a, 0, bright, 30);
      CircuitPlayground.setPixelColor(b, 0, bright, 30);
    } else {
      CircuitPlayground.setPixelColor(a, 0, 30, bright);
      CircuitPlayground.setPixelColor(b, 0, 30, bright);
    }
  }
}

void renderWinReady() {
  clearAllPixels();

  // pulse the two target markers while waiting for button
  int pulse = map((millis() / 10) % 255, 0, 254, 10, 55);
  CircuitPlayground.setPixelColor(bottomPixel, pulse, pulse, pulse);
  CircuitPlayground.setPixelColor(topPixel, pulse, pulse, pulse);
}

// ----------------------------------------------------
// Serial debug
// ----------------------------------------------------

void printDebug(bool inRange) {
  if (millis() - lastDebugMs < DEBUG_INTERVAL_MS) {
    return;
  }
  lastDebugMs = millis();

  unsigned long holdMs = 0;
  if (holdStartMs > 0) {
    holdMs = millis() - holdStartMs;
  }

  Serial.print("state=");
  Serial.print(inPlay ? 1 : 0);
  Serial.print(", db=");
  Serial.print(currentDb);
  Serial.print(", rawDb=");
  Serial.print(rawDb);
  Serial.print(", pot=");
  Serial.print(currentPotPercent);
  Serial.print("%");
  Serial.print(", dbTop=");
  Serial.print(currentDbTop);
  Serial.print(", target=");
  Serial.print(targetLowDb);
  Serial.print("-");
  Serial.print(targetHighDb);
  Serial.print(", pair=");
  Serial.print(bottomPixel);
  Serial.print("-");
  Serial.print(topPixel);
  Serial.print(", fill=");
  Serial.print(waterFillPixels);
  Serial.print(", inRange=");
  Serial.print(inRange ? 1 : 0);
  Serial.print(", won=");
  Serial.print(roundWon ? 1 : 0);
  Serial.print(", holdMs=");
  Serial.println(holdMs);
}

// ----------------------------------------------------
// Arduino setup + loop
// ----------------------------------------------------

void setup() {
  Serial.begin(115200);
  CircuitPlayground.begin();
  pinMode(BUTTON_PIN, INPUT_PULLUP);

  randomSeed(analogRead(A1));
  calibrateMic();

  // Start pot smoother at current pot position
  smoothPot = analogRead(POT_PIN);
}

void loop() {
  bool pressed = buttonPressed();

  // Always update sensor values each frame
  readAudioAndMap();

  // IDLE: waiting for player to press button
  if (!inPlay) {
    renderIdle();
    printDebug(false);

    if (pressed) {
      startNewRound();
      inPlay = true;
    }
  }

  // PLAY: active round or win/next-round flow
  else {
    if (!roundWon) {
      bool inRange = checkInRangeStable();
      renderPlay(inRange);
      printDebug(inRange);

      // If in range, keep counting hold time
      if (inRange) {
        outOfRangeSinceMs = 0;

        if (holdStartMs == 0) {
          holdStartMs = millis();
        }

        if (millis() - holdStartMs >= HOLD_TIME_MS) {
          roundWon = true;
          winShowStartMs = millis();
          holdStartMs = 0;
        }
      }

      // If out of range, allow short grace period before reset
      else {
        if (holdStartMs > 0) {
          if (outOfRangeSinceMs == 0) {
            outOfRangeSinceMs = millis();
          }

          if (millis() - outOfRangeSinceMs > OUT_OF_RANGE_GRACE_MS) {
            holdStartMs = 0;
            outOfRangeSinceMs = 0;
          }
        }
      }
    }

    // Won this round: show animation then wait for button
    else {
      if (millis() - winShowStartMs < WIN_SHOW_MS) {
        renderWinShow();
      } else {
        renderWinReady();

        if (pressed) {
          startNewRound();
        }
      }

      printDebug(false);
    }
  }

  // Small frame delay for stability
  delay(16);
}

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.