← all projects
2026-10-06
  • esp32
  • max9814
  • sound-sensor
  • buzzer
  • st7789
  • office

Office Noise Detector

A desktop ESP32 that hears the room and tells it to be quiet — MAX9814 mic, active buzzer, and a threshold that calibrates itself to the room at boot.

Office Noise Detector

A desktop ESP32 that hears the room and tells it to be quiet. Low level. High impact.

What it does

The unit sits on a desk and listens. At power-up it spends four seconds learning what the quiet room sounds like, then shows the current noise as a percentage of that learned threshold, with a sweep graph scrolling underneath. When the level holds above 100% for most of a second, the screen turns red with BE QUIET, shhh... below it, and the buzzer sounds three short beeps. If the room stays loud it beeps again every five seconds. The ESP32 and its screen live in their own printed case and connect to the sensor enclosure by cable.

Components

PartModule / SpecNotes
Boardideaspark ESP32-WROOM-32 with built-in 1.9” ST7789 TFT (170x320)Screen is pre-wired on the board; stays in its own case
MicrophoneMAX9814 electret amp module, 25x15 mmRun at 3.3 V; GAIN left floating = 60 dB
AlertActive buzzer, ~12 mm diaFixed tone, driven with digitalWrite — no PWM needed
PrototypingMini 170-point breadboard, 35x47x8.5 mmCarrier for the mic and buzzer modules
PowerUSB 5 VSingle supply for the whole unit
FastenersM2 self-tapping screwsClamp bar over the mic module

Wiring

SignalESP32 pinTo
MIC OUTGPIO34MAX9814 OUT
MIC VDD3V3MAX9814 VDD
MIC GNDGNDMAX9814 GND
MIC GAIN—Left unconnected (60 dB)
MIC AR—Left unconnected (default attack/release)
Buzzer +GPIO25Buzzer long leg
Buzzer −GNDBuzzer short leg
TFT CS / DC / RST / BLKGPIO15 / GPIO2 / GPIO4 / GPIO32Pre-wired on the ideaspark board
TFT SPI SCK / MOSIGPIO18 / GPIO23Pre-wired on the ideaspark board

Power is USB 5 V with no level shifting anywhere. Two constraints set the pin choice: the MAX9814 runs from 3V3 rather than 5 V, because at 5 V its output swing can exceed the ESP32’s 3.3 V ADC ceiling; and the mic sits on GPIO34, which is input-only and belongs to ADC1. ADC2 pins stop returning usable values the moment WiFi is active, so an analog sensor always goes on ADC1 (GPIO32–39). This build uses no WiFi, but the habit is worth keeping.

Firmware

  • Toolchain: Arduino IDE, Arduino-ESP32 core 3.3.10, upload speed 115200. No WiFi.
  • Libraries: Adafruit GFX Library, Adafruit ST7735 and ST7789 Library.
  • The MAX9814 has no “loudness” output. It gives the audio waveform itself, resting around a ~1.25 V bias — roughly 1550 on the ADC in a silent room. Every 50 ms the sketch measures the average distance of the samples from that resting centre. Averaging rather than taking the single biggest peak is deliberate: one stray ADC spike should not read as a shout.
  • The threshold is relative, not a fixed number. At boot calibrate() listens for four seconds (discarding the first half second while the mic settles) and stores that as the baseline; the trigger point is baseline x 4.0, with a floor of baseline + 20. The baseline then creeps toward the room whenever things are quiet, so a room that gets gradually busier doesn’t leave the detector permanently triggered. Press RST to re-learn from scratch.
  • The non-obvious part is self-deafening. The buzzer is louder than anything it is listening for, so without protection the first beep triggers the next one forever. Three guards handle it: the mic is ignored for 400 ms after a beep, the level is reset to baseline the moment the beep ends, and baseline learning freezes for 8 seconds while the MAX9814’s automatic gain control recovers from the blast.
  • Debouncing in both directions: the level must stay at 100%+ for 800 ms to fire, so a single clap or a dropped stapler is ignored; it must fall under 70% for a full second, with the alarm up at least 3 seconds, before the screen returns to the meter.
  • Tuning: raise LOUD_RATIO if normal talking already hits 100%, lower it if shouting doesn’t. If the graph sits near the top no matter what, tie MAX9814 GAIN to VDD for 40 dB instead of 60 dB and press RST. The serial monitor at 115200 prints level, baseline and threshold four times a second.

#include <SPI.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7789.h>


#define LCD_CS   15
#define LCD_DC    2
#define LCD_RST   4
#define LCD_BLK  32

const int MIC_PIN    = 34;
const int BUZZER_PIN = 25;


const float    LOUD_RATIO   = 4.0;
const float    MIN_MARGIN   = 20;
const uint32_t HOLD_MS      = 800;
const uint32_t QUIET_MS     = 1000;
const uint32_t ALARM_MIN_MS = 3000;
const uint32_t REBEEP_MS    = 5000;
const uint32_t DEAF_MS      = 400;
const uint32_t FREEZE_MS    = 8000;
const uint32_t WINDOW_MS    = 50;
const uint32_t CALIB_MS     = 4000;
const float    LEVEL_ALPHA  = 0.30;
const float    BASE_ALPHA   = 0.005;


const int GX = 10, GY = 94, GW = 300, GH = 60;
const int PCT_MAX = 150;
const uint16_t COL_GREY = 0x8410;
const uint16_t COL_DARK = 0x4208;

Adafruit_ST7789 lcd = Adafruit_ST7789(LCD_CS, LCD_DC, LCD_RST);


float baseline = 20;
float level    = 20;

bool     alarmOn    = false;
uint32_t alarmStart = 0;

bool     everBeeped = false;
uint32_t beepEndMs  = 0;

bool isLoud  = false;  uint32_t loudStart  = 0;
bool isQuiet = false;  uint32_t quietStart = 0;

uint8_t hist[GW];
int     sweepX = 0;

int      shownPct   = -1;
int      shownState = -1;
uint32_t lastFooter = 0;
uint32_t lastSerial = 0;


int measureSwing() {
  static float centre = 1550;
  long  sum    = 0;
  float absSum = 0;
  long  n      = 0;
  uint32_t t0 = millis();
  while (millis() - t0 < WINDOW_MS) {
    int v = analogRead(MIC_PIN);
    sum    += v;
    absSum += fabsf(v - centre);
    n++;
  }
  if (n == 0) return 0;
  centre = (float)sum / n;
  return (int)(absSum / n);
}

float threshold() {
  float t = baseline * LOUD_RATIO;
  if (t < baseline + MIN_MARGIN) t = baseline + MIN_MARGIN;
  return t;
}

int pctOf(float lvl) {
  float p = (lvl - baseline) * 100.0f / (threshold() - baseline);
  if (p < 0) p = 0;
  if (p > PCT_MAX) p = PCT_MAX;
  return (int)(p + 0.5f);
}

uint16_t colorFor(int pct) {
  if (pct < 60)  return ST77XX_GREEN;
  if (pct < 100) return ST77XX_YELLOW;
  return ST77XX_RED;
}



void beep() {
  for (int i = 0; i < 3; i++) {
    digitalWrite(BUZZER_PIN, HIGH);
    delay(150);
    digitalWrite(BUZZER_PIN, LOW);
    delay(120);
  }
  everBeeped = true;
  beepEndMs  = millis();
  level      = baseline;
  isLoud     = false;
  isQuiet    = false;
}


int yFor(int pct) {
  return GY + GH - 1 - (pct * (GH - 1)) / PCT_MAX;
}

void drawColumn(int i) {
  int x   = GX + i;
  int p   = hist[i];
  int top = yFor(p);
  lcd.drawFastVLine(x, GY, GH, ST77XX_BLACK);
  lcd.drawFastVLine(x, top, GY + GH - top, colorFor(p));
  if ((i % 6) < 3) lcd.drawPixel(x, yFor(100), ST77XX_WHITE);
}

void clearAhead() {
  for (int k = 0; k < 4; k++) {
    lcd.drawFastVLine(GX + (sweepX + k) % GW, GY, GH, ST77XX_BLACK);
  }
}

void pushSample(int pct, bool draw) {
  hist[sweepX] = (uint8_t)pct;
  if (draw) drawColumn(sweepX);
  sweepX = (sweepX + 1) % GW;
  if (draw) clearAhead();
}

void drawStatic() {
  lcd.fillScreen(ST77XX_BLACK);
  lcd.setTextSize(2);
  lcd.setTextColor(ST77XX_CYAN);
  lcd.setCursor(10, 8);
  lcd.print("ThreeBoardsLab");

  lcd.drawLine(0, 30, 319, 30, ST77XX_CYAN);

  lcd.setTextSize(1);
  lcd.setTextColor(ST77XX_WHITE);
  lcd.setCursor(10, 38);
  lcd.print("Noise level:");

  lcd.drawRect(GX - 1, GY - 1, GW + 2, GH + 2, COL_DARK);
  shownPct   = -1;
  shownState = -1;
}

void drawMeterScreen() {
  drawStatic();
  for (int i = 0; i < GW; i++) drawColumn(i);
  clearAhead();
}

void drawLevel(int pct) {
  if (pct == shownPct) return;
  shownPct = pct;
  char buf[8];
  snprintf(buf, sizeof(buf), "%3d%%", pct);
  lcd.setTextSize(4);
  lcd.setTextColor(colorFor(pct), ST77XX_BLACK);
  lcd.setCursor(10, 50);
  lcd.print(buf);
}

void drawState(int pct) {
  int st = (pct < 60) ? 0 : (pct < 100) ? 1 : 2;
  if (st == shownState) return;
  shownState = st;
  lcd.setTextSize(2);
  lcd.setCursor(150, 58);
  if (st == 0)      { lcd.setTextColor(ST77XX_GREEN,  ST77XX_BLACK); lcd.print("QUIET      "); }
  else if (st == 1) { lcd.setTextColor(ST77XX_YELLOW, ST77XX_BLACK); lcd.print("RISING     "); }
  else              { lcd.setTextColor(ST77XX_RED,    ST77XX_BLACK); lcd.print("TOO LOUD   "); }
}

void drawFooter(int swing) {
  char buf[56];
  snprintf(buf, sizeof(buf), "base %4d   thr %4d   now %4d   ",
           (int)baseline, (int)threshold(), swing);
  lcd.setTextSize(1);
  lcd.setTextColor(COL_GREY, ST77XX_BLACK);
  lcd.setCursor(10, 160);
  lcd.print(buf);
}

void drawAlarm() {
  lcd.fillScreen(ST77XX_RED);
  lcd.setTextColor(ST77XX_WHITE);
  lcd.setTextSize(5);
  lcd.setCursor((320 - 240) / 2, 45);
  lcd.print("BE QUIET");
  lcd.setTextSize(3);
  lcd.setCursor((320 - 126) / 2, 110);
  lcd.print("shhh...");
}



void calibrate() {
  drawStatic();
  lcd.setTextSize(2);
  lcd.setTextColor(ST77XX_YELLOW, ST77XX_BLACK);
  lcd.setCursor(150, 58);
  lcd.print("CALIBRATING");
  lcd.setTextSize(1);
  lcd.setTextColor(ST77XX_WHITE, ST77XX_BLACK);
  lcd.setCursor(GX + 80, GY + GH / 2 - 4);
  lcd.print("stay quiet for a moment...");

  Serial.println("Calibrating - keep the room quiet...");
  uint32_t t0 = millis();
  float sum = 0;
  int   n   = 0;
  while (millis() - t0 < CALIB_MS) {
    int s = measureSwing();
    if (millis() - t0 > 500) {
      sum += s;
      n++;
    }
    delay(1);
  }
  baseline = (n > 0) ? sum / n : 20;
  if (baseline < 1) baseline = 1;
  level = baseline;
  Serial.printf("Baseline = %.1f   threshold = %.1f\n", baseline, threshold());

  for (int i = 0; i < GW; i++) hist[i] = 0;
  sweepX = 0;
  drawMeterScreen();
}

void setup() {
  Serial.begin(115200);
  delay(200);

  pinMode(BUZZER_PIN, OUTPUT);
  digitalWrite(BUZZER_PIN, LOW);

  pinMode(LCD_BLK, OUTPUT);
  digitalWrite(LCD_BLK, HIGH);
  lcd.init(170, 320);
  lcd.setRotation(1);

  calibrate();
}



void loop() {
  int swing = measureSwing();
  uint32_t now = millis();

  bool deaf = everBeeped && (now - beepEndMs < DEAF_MS);
  if (!deaf) level += LEVEL_ALPHA * (swing - level);

  
  bool frozen = alarmOn || (everBeeped && now - beepEndMs < FREEZE_MS);
  if (!deaf && !frozen && pctOf(level) < 50) {
    baseline += BASE_ALPHA * (level - baseline);
    if (baseline < 1) baseline = 1;
  }

  int pct = pctOf(level);

  if (!deaf && pct >= 100) { if (!isLoud)  { isLoud  = true; loudStart  = now; } }
  else isLoud = false;
  if (!deaf && pct < 70)   { if (!isQuiet) { isQuiet = true; quietStart = now; } }
  else isQuiet = false;
  bool loudLongEnough  = isLoud  && (now - loudStart  >= HOLD_MS);
  bool quietLongEnough = isQuiet && (now - quietStart >= QUIET_MS);

  if (!alarmOn && loudLongEnough) {
    alarmOn    = true;
    alarmStart = now;
    drawAlarm();
    Serial.println(">>> BE QUIET");
    beep();
  } else if (alarmOn) {
    if (loudLongEnough && millis() - beepEndMs >= REBEEP_MS) {
      Serial.println(">>> still loud - beep again");
      beep();
    } else if (quietLongEnough && now - alarmStart >= ALARM_MIN_MS) {
      alarmOn = false;
      Serial.println("--- quiet again");
      drawMeterScreen();
    }
  }

  pct = pctOf(level);
  pushSample(pct, !alarmOn);

  if (!alarmOn) {
    drawLevel(pct);
    drawState(pct);
    if (millis() - lastFooter >= 500) {
      lastFooter = millis();
      drawFooter(swing);
    }
  }

  if (millis() - lastSerial >= 250) {
    lastSerial = millis();
    Serial.printf("now=%4d  level=%6.1f  base=%6.1f  thr=%6.1f  %3d%%%s\n",
                  swing, level, baseline, threshold(), pct, alarmOn ? "  ALARM" : "");
  }
}

Enclosure

  • Printer: Bambu Lab P2S
  • Filament: standard PLA
  • Logo: ThreeBoardsLab mark (icon + wordmark) recessed into the sloped faceplate
  • Three printed parts — base, faceplate, clamp bar. The faceplate sits at roughly 12 degrees so the unit reads at desk height.
  • The base is 28 mm deeper than the parts inside it need. Dupont connector shells stand about 13 mm above the breadboard and fouled the lid; that depth was added after a test fit, not designed in up front.
  • The buzzer drops into a seat with guide ribs instead of a diameter-exact pocket. Ribs grip a range of sizes, which removes the re-print loop when the part measures 12 mm and the model assumed 11.
  • The MAX9814 is held by a pocket plus a separate screw-down clamp bar — no glue, and the module still comes out. Its 9 mm capsule dome exits through a 13x11 mm oblong opening offset toward the pin-free end of the PCB.
  • Print the faceplate rotated 180 degrees, not mirrored. Mirroring reverses the logo text.