Automated Meeting Status Indicator
ESP32-powered status display that shows when I'm busy or available.
Working from home has its perks, but it also means family members can barge into your workspace at any time. I've wanted a "busy" indicator outside my office for years, and I finally got around to building one after half a decade.
Designing the Solution
The biggest challenge was determining what to monitor to decide if I'm busy. There are multiple ways I could be engaged in a conversation—Microsoft Teams, Zoom, Google Meet, or random phone calls. Initially, I considered pulling my calendar events, but that wouldn't cover spontaneous meetings or calls.
Instead of tracking individual apps, I approached the problem differently: sound. If I could detect whether my microphone was picking up speech, I could infer whether I was on a call and display my availability accordingly.

The Plan:
- A client-server setup, where:
- The client (laptop) monitors the microphone audio levels and determines if I'm speaking.
- The server (ESP32) receives the status from the laptop and updates a small T-Display screen via Wi-Fi through an API endpoint.
This allows real-time updates to the indicator light, changing colours based on my microphone activity.
Hardware Used
- ESP32 with T-Display (same as in the Pomodoro timer project).
- Laptop with Python running the microphone detection script.
Software Setup
1. Laptop (Client) - Detecting Microphone Activity
The client is a Python script that:
- Continuously listens to the microphone audio levels.
- Uses a moving average filter to smooth out fluctuations.
- Determines if the microphone is "ACTIVE" or "MUTED" based on a volume threshold.
- Sends an HTTP POST request to the ESP32 whenever the status changes.
Code Explanation
import sounddevice as sd
import numpy as np
import requests
import time
import threading
# ESP32 API Endpoint
ESP32_IP = "http://ESP32-IP/update"
TARGET_MIC_NAME = "Microphone Name"
# Detection Parameters
SENSITIVITY_THRESHOLD = 0.02 # Sensitivity to speech
CONFIRMATION_COUNT = 5 # How many detections before switching to "ACTIVE"
DECAY_RATE = 0.4 # Slow fade to "MUTED"
COOLDOWN_TIME = 2.5 # Prevents frequent status flips
# Audio Settings
SAMPLE_RATE = 16000 # Balanced for speech
BLOCK_SIZE = 1024 # Optimized for responsiveness
# State Tracking
mic_status = "MUTED"
active_count = 0
last_status = None
last_change_time = time.time()
lock = threading.Lock()
volume_history = [] # Moving average buffer
# Identify the correct microphone
def get_microphone_index():
devices = sd.query_devices()
for index, device in enumerate(devices):
if TARGET_MIC_NAME in device["name"] and device["max_input_channels"] > 0:
print(f"Using Microphone: {device['name']} (Index {index})")
return index
return None
mic_index = get_microphone_index()
if mic_index is None:
print(f"Microphone '{TARGET_MIC_NAME}' not found!")
exit(1)
# Send microphone status to ESP32
def send_status_to_esp32(status):
global last_status
if status == last_status:
return # Avoid duplicate requests
def request_thread():
retries = 3
for attempt in range(retries):
try:
response = requests.post(ESP32_IP, data=status, timeout=1)
print(f"Sent status: {status} (Response: {response.status_code})")
return
except requests.RequestException as e:
print(f"Failed to send update (Attempt {attempt+1}/{retries}): {e}")
time.sleep(0.5)
threading.Thread(target=request_thread, daemon=True).start()
last_status = status
# Callback function for audio processing
def callback(indata, frames, timing_info, status):
global mic_status, active_count, last_change_time, volume_history
if status:
print(f"Audio stream status: {status}")
volume = np.sqrt(np.mean(np.square(indata))) # Calculate RMS volume
volume_history.append(volume)
if len(volume_history) > 10:
volume_history.pop(0) # Keep last 10 readings
smoothed_volume = np.mean(volume_history)
with lock:
if smoothed_volume > SENSITIVITY_THRESHOLD:
active_count = min(active_count + 1, CONFIRMATION_COUNT)
else:
active_count = max(0, active_count - DECAY_RATE)
new_status = "ACTIVE" if active_count >= CONFIRMATION_COUNT else "MUTED"
if new_status != mic_status and (time.time() - last_change_time) > COOLDOWN_TIME:
mic_status = new_status
send_status_to_esp32(mic_status)
last_change_time = time.time()
# Start microphone monitoring
with sd.InputStream(device=mic_index, channels=1, samplerate=SAMPLE_RATE, blocksize=BLOCK_SIZE, callback=callback):
while True:
time.sleep(0.1) # Keep CPU usage low
This script runs in the background and automatically detects whether I'm speaking. If I am, it sends a status update to the ESP32.
2. Compiling the Script into an Executable
To make the script run independently on Windows as a portable exe:
Install PyInstaller:
pip install pyinstaller
Create an executable:
pyinstaller --onefile --noconsole myscript.py
--onefile
: Generates a single EXE file.--noconsole
: Prevents a command window from appearing.
The generated file will be found in dist/myscript.exe
.
You can then set this to auto-launch during start-up, making it a set-and-forget solution.
2. ESP32 (Server) - Displaying Status on T-Display
First, initialise the board and libraries as per the Pomodoro timer project post.
Breakdown of the ESP32 logic:
- Runs a web server that listens for status updates from the laptop.
- Displays "ACTIVE" (red) or "MUTED" (green) on the screen.
- Transitions between colours.
- Has a screen dimming function if inactive for 15 minutes.
- Randomly shifts pixels every 5 minutes to prevent screen burn-in.
#include <WiFi.h>
#include <WebServer.h>
#include <TFT_eSPI.h>
// WiFi Credentials
const char* ssid = "SSID";
const char* password = "PASSWORD";
// Web server on port 80
WebServer server(80);
// TFT Display
TFT_eSPI tft = TFT_eSPI();
String micStatus = "MUTED";
String targetStatus = "MUTED";
float fadeAmount = 0.0;
unsigned long lastChangeTime = 0;
unsigned long lastShiftTime = 0;
int shiftX = 0, shiftY = 0;
bool dimmed = false;
// Colors
uint16_t colorRed = tft.color565(97, 27, 36); // #611b24
uint16_t colorGreen = tft.color565(2, 84, 36); // #025424
void handleMicStatus() {
if (server.hasArg("plain")) {
String body = server.arg("plain");
if (body.indexOf("ACTIVE") != -1) {
targetStatus = "ACTIVE";
} else if (body.indexOf("MUTED") != -1) {
targetStatus = "MUTED";
}
lastChangeTime = millis();
dimmed = false;
server.send(200, "text/plain", "Status updated");
} else {
server.send(400, "text/plain", "Invalid request");
}
}
uint16_t blendColors(uint16_t color1, uint16_t color2, float blendFactor) {
uint8_t r1 = (color1 >> 11) & 0x1F;
uint8_t g1 = (color1 >> 5) & 0x3F;
uint8_t b1 = color1 & 0x1F;
uint8_t r2 = (color2 >> 11) & 0x1F;
uint8_t g2 = (color2 >> 5) & 0x3F;
uint8_t b2 = color2 & 0x1F;
uint8_t r = r1 + blendFactor * (r2 - r1);
uint8_t g = g1 + blendFactor * (g2 - g1);
uint8_t b = b1 + blendFactor * (b2 - b1);
return tft.color565(r << 3, g << 2, b << 3);
}
// Function to smoothly transition between colors
void drawFadeTransition() {
uint16_t startColor = (micStatus == "ACTIVE") ? colorRed : colorGreen;
uint16_t endColor = (targetStatus == "ACTIVE") ? colorRed : colorGreen;
if (startColor != endColor) {
fadeAmount += 0.05;
if (fadeAmount >= 1.0) {
fadeAmount = 1.0;
micStatus = targetStatus;
}
} else {
fadeAmount = 0.0;
}
uint16_t blendedColor = blendColors(startColor, endColor, fadeAmount);
tft.fillScreen(blendedColor);
}
// Function to shift screen pixels to prevent burn-in
void shiftScreen() {
if (millis() - lastShiftTime > 300000) { // Shift every 5 minutes
shiftX = random(-3, 4); // Small random shift
shiftY = random(-3, 4);
uint16_t buffer[tft.width()]; // Buffer to store row data
for (int y = 0; y < tft.height(); y++) {
tft.readRect(0, y, tft.width(), 1, buffer); // Read row of pixels
tft.pushImage(shiftX, y + shiftY, tft.width(), 1, buffer); // Redraw with offset
}
lastShiftTime = millis();
}
}
// Function to dim screen if inactive for 15 min
void dimScreen() {
if (millis() - lastChangeTime > 900000) { // 15 min timeout
if (!dimmed) {
tft.fillScreen(tft.color565(10, 10, 10)); // Dimmed dark screen
dimmed = true;
}
}
}
void setup() {
Serial.begin(115200);
tft.init();
tft.setRotation(1);
tft.fillScreen(colorGreen);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) {
delay(1000);
Serial.print(".");
}
Serial.println("\nWiFi connected!");
server.on("/update", HTTP_POST, handleMicStatus);
server.begin();
Serial.println("Server started!");
}
void loop() {
server.handleClient();
drawFadeTransition();
shiftScreen();
dimScreen();
delay(30);
}
Wrap-up
This project is a simple way to automatically indicate when I'm busy without manually toggling an indicator. The audio detection provides real-time updates, and the ESP32 display makes it easy for others to see my status.