Community project

Portable AI Desk Companion

ESP32
Photo of Portable AI Desk Companion
Generated with AI

BHAVISH MEHTA

Published September 14, 2026

This portable AI desk companion brings a responsive interface to your workspace, combining an ESP32 microcontroller with a compact OLED display, sound sensor, and speaker amplifier. The device runs on a single 18650 Li-ion cell boosted to 5V, making it truly mobile for desk-to-desk use or travel.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions to build your own unit. The included firmware handles four push-button controls for recording, muting, snoozing, and dismissing alerts, while the OLED screen displays real-time feedback and the sound sensor captures audio input for processing.

Wiring diagram

Wiring diagram for Portable AI Desk Companion

Gather all the parts

QtyComponent
1

0.96 inch SSD1306 I2C OLED (128x64, 4-pin)

128 × 64

The small screen that shows the companion's status and simple face graphics.

1

Ky 038 Sound Sensor

KY-038

KY-038 sound-detection module: electret microphone + LM393 comparator. Analog raw + digital threshold output (potentiometer-tuned). Common ringer / clap detector; not suitable for audio capture.

1

PAM8403 Stereo Class-D Amplifier Module

5 V module

The small audio amplifier module that makes the ESP32 audio signal strong enough to drive the speaker.

1

4–8 Ω Small Speaker

4–8 Ω, 3 W or less

The small speaker that plays the companion's tones and audio.

1

Push Button

momentary

Momentary push button switch

1

Push Button

momentary

Momentary push button switch

1

Push Button

momentary

Momentary push button switch

1

Push Button

momentary

Momentary push button switch

1

18650 Li-ion Cell

3.7 V nominal, protected

18650 lithium-ion cell, nominal 3.7 V, ~2500 mAh. Common for higher-capacity portable / battery-bank style projects; needs a holder and protection / charger circuit.

1

18650 Holder

single-cell

Generic holder for one or two removable 18650 Li-ion cells. It is a mechanical/electrical power holder, not a charger or protection circuit; pair with a charger/BMS and regulator appropriate to the cell count.

1

Boost Converter

adjusted to 5.0 V

Small adjustable MT3608-style DC-DC boost converter module for stepping a lower DC input up to a higher rail such as 5V, 9V, or 12V. It is a power-path module with VIN/VOUT terminals, not a GPIO peripheral.

1

Resistor

1 kΩ, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

1 kΩ, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

1

Resistor

10 kΩ, 1/4 W

Through-hole resistor (current-limiting in series with an LED)

Assemble it in 6 steps

1. Set the boost module to 5 V

Before connecting the ESP32, insert the 18650 into holder_1 and connect holder_1 BAT+ to boost_1 VIN+ (battery power) and holder_1 BAT- to boost_1 VIN- (battery return). Use a multimeter across boost_1 VOUT+ and VOUT- and adjust the tiny screw until it reads 5.0 V.

  • Do this adjustment with the ESP32 disconnected so an incorrectly adjusted module cannot damage it.
  • Do not connect the cell straight to the ESP32 VIN pin: the voltage changes as the cell empties and will not run the 5 V VIN input reliably.
  • Do not short the battery contacts; a Li-ion cell can become dangerously hot.

2. Make the 5 V and ground rails

Connect boost_1 VOUT+ to the ESP32 VIN pin (regulated 5 V power) and to amp_1 VCC (amplifier power). Connect boost_1 VOUT- to an ESP32 GND pin (ground), then connect amp_1 GND to the same ground rail (ground).

  • All modules need the same ground connection or their signal wires cannot work correctly.
  • Make sure VOUT+ is 5.0 V before connecting it to VIN or the amplifier — a higher setting can damage them.

3. Wire the display and microphone

Connect oled_1 VCC to ESP32 3V3 (power), oled_1 GND to GND (ground), oled_1 SDA to GPIO21 (data), and oled_1 SCL to GPIO22 (clock). Connect mic_1 VCC to ESP32 3V3 (power), mic_1 GND to GND (ground), and mic_1 AO to GPIO34 (sound-level signal). Leave mic_1 DO unconnected.

  • The screen normally has its pin names printed beside the four header pins. GPIO34 is input-only, which is exactly what the microphone needs.
  • Power the KY-038 from 3.3 V, not 5 V — its AO pin can otherwise rise above the safe ESP32 input voltage.

4. Wire the speaker amplifier

Connect ESP32 GPIO25 to amp_left_resistor P1 (audio signal). Join amp_right_resistor P1 to that same GPIO25-to-amp_left_resistor P1 junction (shared audio signal). Connect amp_left_resistor P2 to amp_1 L_IN (left audio input) and amp_right_resistor P2 to amp_1 R_IN (right audio input). Connect amp_1 L+ to speaker_1 + (speaker drive) and amp_1 L- to speaker_1 - (speaker drive). Leave amp_1 R+ and R- unconnected.

  • The same mono tone is fed into both amplifier inputs, but only the left speaker output is used.
  • The PAM8403 speaker outputs are a pair: connect the speaker only between L+ and L-.
  • Never connect speaker_1 - to GND — the PAM8403 output is not a ground-referenced speaker output and this can damage the amplifier.

5. Wire the four button controls

For push-to-talk, connect ptt_button GND to GND (ground), join ptt_button SIGNAL to ptt_pulldown P1, connect that shared junction to GPIO16 (button signal), and connect ptt_pulldown P2 to GND (keeps the signal low). Repeat the same pattern: snooze_button SIGNAL and snooze_pulldown P1 to GPIO17; dismiss_button SIGNAL and dismiss_pulldown P1 to GPIO18; mute_button SIGNAL and mute_pulldown P1 to GPIO19. Connect every button GND pin and every pull-down resistor P2 pin to GND.

  • On a 4-leg tactile switch, the two legs on each same side are already connected. Put the button across the center gap of a breadboard so pressing it connects the two sides.
  • Each 10 kΩ resistor has no direction; either end may go to ground.
  • Do not connect a button signal wire directly to 3.3 V without its pull-down resistor arrangement — an incorrect connection can force a pin to conflicting voltages.

6. Check the device before closing it

Recheck that every GND connection reaches the same ground rail, that the OLED and microphone use 3.3 V, and that only the ESP32 VIN and PAM8403 VCC receive the regulated 5 V rail. Fit the parts into the enclosure only after these connections are correct.

  • Keep the microphone physically away from the speaker; this reduces squealing and false sound readings.
  • Remove the 18650 before moving wires. Make sure VCC and GND are not swapped — swapped power can damage the screen, microphone, or amplifier.

Review all connections

1. Connections between "oled_1" and "ESP32"

Functionoled_1ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 21
i2cSCLGPIO 22

2. Connections between "mic_1" and "ESP32"

Functionmic_1ESP32
powerVCC3V3
groundGNDGND
analogAOGPIO 34

3. Connections between "amp_1" and "ESP32"

Functionamp_1ESP32
powerVCCVIN
groundGNDGND
dataL+4–8 Ω Small Speaker +EXT
dataL-4–8 Ω Small Speaker -EXT

4. Connections between "amp_left_resistor" and "ESP32"

Functionamp_left_resistorESP32
digitalP1GPIO 25
digitalP2PAM8403 Stereo Class-D Amplifier Module L_INEXT

5. Connections between "amp_right_resistor" and "ESP32"

Functionamp_right_resistorESP32
digitalP2PAM8403 Stereo Class-D Amplifier Module R_INEXT
digitalP1Resistor P1EXT

6. Connections between "ptt_button" and "ESP32"

Functionptt_buttonESP32
groundGNDGND
digitalSIGNALResistor P1EXT

7. Connections between "ptt_pulldown" and "ESP32"

Functionptt_pulldownESP32
digitalP1GPIO 16
groundP2GND

8. Connections between "snooze_button" and "ESP32"

Functionsnooze_buttonESP32
groundGNDGND
digitalSIGNALResistor P1EXT

9. Connections between "snooze_pulldown" and "ESP32"

Functionsnooze_pulldownESP32
digitalP1GPIO 17
groundP2GND

10. Connections between "dismiss_button" and "ESP32"

Functiondismiss_buttonESP32
groundGNDGND
digitalSIGNALResistor P1EXT

11. Connections between "dismiss_pulldown" and "ESP32"

Functiondismiss_pulldownESP32
digitalP1GPIO 18
groundP2GND

12. Connections between "mute_button" and "ESP32"

Functionmute_buttonESP32
groundGNDGND
digitalSIGNALResistor P1EXT

13. Connections between "mute_pulldown" and "ESP32"

Functionmute_pulldownESP32
digitalP1GPIO 19
groundP2GND

14. Connections between "battery_1" and "ESP32"

Functionbattery_1ESP32
power+V18650 Holder BAT+EXT
groundGND18650 Holder BAT-EXT

15. Connections between "holder_1" and "ESP32"

Functionholder_1ESP32
powerBAT+Boost Converter VIN+EXT
groundBAT-Boost Converter VIN-EXT

16. Connections between "boost_1" and "ESP32"

Functionboost_1ESP32
powerVOUT+VIN
groundVOUT-GND

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>


// Hoisted type definitions
struct Button {
  uint8_t pin;
  bool stableState;
  bool lastReading;
  unsigned long changedAt;
};


// Forward declarations
bool wasPressed(Button &button);
void drawFace();
void playTone(uint16_t frequency, uint16_t durationMs);

constexpr int OLED_SDA = 21;
constexpr int OLED_SCL = 22;
constexpr int MIC_PIN = 34;
constexpr int AUDIO_PIN = 25;
constexpr int PTT_PIN = 16;
constexpr int SNOOZE_PIN = 17;
constexpr int DISMISS_PIN = 18;
constexpr int MUTE_PIN = 19;

constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint8_t AUDIO_CHANNEL = 0;
constexpr uint16_t AUDIO_PWM_HZ = 2000;
constexpr uint8_t AUDIO_RESOLUTION = 8;
constexpr uint16_t DEBOUNCE_MS = 35;
constexpr uint16_t SAMPLE_INTERVAL_MS = 100;

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);

bool muted = false;
bool snoozed = false;
bool dismissed = false;
bool recording = false;
int micLevel = 0;
unsigned long lastSampleMs = 0;



Button pttButton{PTT_PIN, false, false, 0};
Button snoozeButton{SNOOZE_PIN, false, false, 0};
Button dismissButton{DISMISS_PIN, false, false, 0};
Button muteButton{MUTE_PIN, false, false, 0};

bool wasPressed(Button &button) {
  const bool reading = digitalRead(button.pin) == HIGH;
  if (reading != button.lastReading) {
    button.changedAt = millis();
    button.lastReading = reading;
  }
  if ((millis() - button.changedAt) > DEBOUNCE_MS && reading != button.stableState) {
    button.stableState = reading;
    return button.stableState;
  }
  return false;
}

void drawFace() {
  display.clearDisplay();
  display.drawCircle(64, 34, 25, SSD1306_WHITE);
  display.fillCircle(54, 29, 3, SSD1306_WHITE);
  display.fillCircle(74, 29, 3, SSD1306_WHITE);

  if (snoozed) {
    display.drawLine(53, 44, 75, 44, SSD1306_WHITE);
    display.setTextSize(1);
    display.setCursor(4, 2);
    display.print("SNOOZED");
  } else if (dismissed) {
    display.drawLine(54, 47, 74, 42, SSD1306_WHITE);
    display.setTextSize(1);
    display.setCursor(4, 2);
    display.print("DISMISSED");
  } else if (recording) {
    display.drawCircle(64, 46, 7, SSD1306_WHITE);
    display.setTextSize(1);
    display.setCursor(4, 2);
    display.print("LISTENING");
  } else {
    display.drawCircle(64, 42, 10, SSD1306_WHITE);
    display.fillRect(54, 37, 21, 5, SSD1306_BLACK);
    display.setTextSize(1);
    display.setCursor(4, 2);
    display.print(muted ? "MUTED" : "READY");
  }

  display.setTextSize(1);
  display.setCursor(4, 54);
  display.print("Mic: ");
  display.print(micLevel);
  display.display();
}

void playTone(uint16_t frequency, uint16_t durationMs) {
  if (muted) return;
  ledcWriteTone(AUDIO_CHANNEL, frequency);
  delay(durationMs);
  ledcWriteTone(AUDIO_CHANNEL, 0);
}

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

  pinMode(PTT_PIN, INPUT);
  pinMode(SNOOZE_PIN, INPUT);
  pinMode(DISMISS_PIN, INPUT);
  pinMode(MUTE_PIN, INPUT);
  analogReadResolution(12);

  ledcSetup(AUDIO_CHANNEL, AUDIO_PWM_HZ, AUDIO_RESOLUTION);
  ledcAttachPin(AUDIO_PIN, AUDIO_CHANNEL);
  ledcWriteTone(AUDIO_CHANNEL, 0);

  Wire.begin(OLED_SDA, OLED_SCL);
  if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS)) {
    Serial.println("SSD1306 display was not found. Check VCC, GND, SDA, and SCL.");
    while (true) delay(1000);
  }

  drawFace();
  playTone(880, 80);
}

void loop() {
  if (wasPressed(snoozeButton)) {
    snoozed = !snoozed;
    dismissed = false;
    playTone(523, 100);
    drawFace();
  }

  if (wasPressed(dismissButton)) {
    dismissed = true;
    snoozed = false;
    playTone(392, 100);
    drawFace();
  }

  if (wasPressed(muteButton)) {
    muted = !muted;
    if (!muted) playTone(660, 80);
    drawFace();
  }

  const bool pttNow = digitalRead(PTT_PIN) == HIGH;
  if (pttNow != recording) {
    recording = pttNow;
    if (recording) {
      snoozed = false;
      dismissed = false;
      playTone(740, 60);
    } else {
      playTone(440, 80);
    }
    drawFace();
  }

  if (recording && millis() - lastSampleMs >= SAMPLE_INTERVAL_MS) {
    lastSampleMs = millis();
    micLevel = analogRead(MIC_PIN);
    Serial.print("Microphone level: ");
    Serial.println(micLevel);
    drawFace();
  }
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik