{"slug": "show-hn-connect-a-voice-agent-to-your-phone-as-a-bluetooth-headset-with-a-esp32", "title": "Show HN: Connect a voice agent to your phone as a Bluetooth headset with a ESP32", "summary": "A developer released experimental firmware for the AI-Thinker ESP32 Audio Kit V2.2 that turns the board into a Bluetooth handset connecting a phone to a voice agent via WebRTC and a Go bridge, with total hardware cost under $20. The demo uses a free full-duplex voice API in China, raising privacy concerns, but users can self-host MiniCPM-o 2.5 or use Western providers. The project is intended for controlled testing only, as it automatically answers all incoming calls without caller allow-lists or privacy controls.", "body_md": "Experimental firmware for an AI-Thinker ESP32 Audio Kit V2.2 voice-agent handset with a typical total device hardware cost under US$20, excluding the phone/tablet and bridge computer. The included demo sends live microphone audio and conversation data to a free full-duplex voice API endpoint operated by a company in China, which may raise privacy and data-residency concerns. Users can instead self-host MiniCPM-o 4.5 or adapt the bridge to another realtime voice API, including services offered by Western providers; review any provider's privacy and retention terms before sending sensitive audio.\n\nThe board pairs with a phone or tablet as a Bluetooth HFP Hands-Free Unit, allowing a compatible mobile app to exchange bidirectional audio with a voice agent. It sends that audio over WebRTC to a Go bridge on the local network; the bridge owns the voice API WebSocket session and an embedded STUN/TURN server.\n\nThe ESP32 becomes the mobile device's Bluetooth handset while WebRTC carries bidirectional audio over the local 2.4 GHz network. The Go bridge handles signaling, realtime voice API conversion, browser monitoring, and relational SQLite telemetry.\n\n- Advertises as\n`ESP32 Voice Agent`\n\nusing Bluetooth Classic HFP. - Advertises the Bluetooth audio/telephony hands-free device class so iOS or Android can offer it as a communication input and output.\n- Uses Secure Simple Pairing (Just Works); legacy PIN is\n`0000`\n\n. - Starts a local WebRTC session and requests SCO whenever the phone/tablet connects; a cellular call is not required.\n- Also answers an incoming cellular call after 1.8 seconds.\n- Uses decoded CVSD (8 kHz) PCM from ESP-IDF. Wideband mSBC is disabled because WebRTC is already 8 kHz and the extra coexistence load destabilizes Wi-Fi.\n- Carries 8 kHz PCMU audio to and from the laptop using DTLS-SRTP.\n- Bundles three 20 ms PCMU frames into each 60 ms downlink RTP packet and suppresses idle RTP to reduce Wi-Fi/Bluetooth coexistence pressure.\n- Negotiates RTCP NACK and uses a hardware-tested ESP receive/jitter budget to recover short UDP loss bursts without turning them into long playback stalls.\n- The Go bridge converts PCMU to MiniCPM's 16 kHz input and converts MiniCPM's 24 kHz output back to PCMU.\n- Sends silence back to the app while the model or network is not ready.\n- Closes WebRTC and the laptop's MiniCPM session when Bluetooth disconnects.\n- Optionally shows Wi-Fi, Bluetooth, SCO, call, and agent status on a 128x64\nSSD1306 I2C OLED at address\n`0x3C`\n\n(SDA GPIO21, SCL GPIO22).\n\nThis first version automatically answers **every incoming call**. Use it only\nfor controlled testing. Caller allow-lists, physical enable/disable controls,\nand production privacy controls have not been added yet.\n\nThe hardware-tested target is the AI-Thinker ESP32 Audio Kit V2.2 with an ESP32-A1S module, Classic Bluetooth, PSRAM, and an attached 2.4 GHz IPEX/U.FL antenna. Boards without Classic Bluetooth or PSRAM are not equivalent targets. The current firmware stores its persistent status buffer in PSRAM and will fail initialization if that allocation is unavailable.\n\n**Typical total device hardware cost: under US$20**, including the ESP32-A1S\nboard, 2.4 GHz antenna, OLED, and an optional small LiPo battery. This estimate\ndoes not include the phone/tablet or the computer running the bridge.\n\nThe original ESP32 supports only 2.4 GHz Wi-Fi. A 5 GHz-only network will not appear during scanning. Because Wi-Fi and Bluetooth share the same 2.4 GHz radio, place the access point reasonably close to the board during testing.\n\nAttach a matching 2.4 GHz IPEX/U.FL-style antenna before normal Wi-Fi and Bluetooth use. Press the connector straight down, avoid sideways force, and strain-relieve the coax. Some board revisions have an antenna-selection link or zero-ohm resistor; confirm that it selects the IPEX connector before use.\n\nThe board has separate `UART`\n\nand `POWER`\n\nMicro-USB ports:\n\n- Use the\n`UART`\n\nport with a known data-capable cable for flashing and serial monitoring. On the tested board it also supplies 5 V power. - The\n`POWER`\n\nport is power-only and does not expose the USB-to-UART adapter. - Avoid powering the board simultaneously through multiple USB ports and the battery connector unless the exact board revision's power-path documentation confirms that configuration.\n\nThe display is optional. The default driver expects a generic 128x64 SSD1306\nI2C OLED at address `0x3C`\n\n:\n\n| OLED pin | ESP32 Audio Kit connection |\n|---|---|\n`GND` |\n`GND` |\n`VCC` |\n`3V3` |\n`SDA` |\n`GPIO21` |\n`SCL` |\n`GPIO22` |\n\nPower the OLED from 3.3 V even if its listing claims 5 V compatibility; ESP32 GPIO is not 5 V tolerant. Module pin order varies, so follow the labels printed on the actual OLED rather than relying on connector position. Wire it only while the board is powered off.\n\nUse only a protected single-cell LiPo/Li-ion battery: 3.7 V nominal and 4.2 V maximum. Verify the connector polarity against the board's battery and ground markings with a multimeter before insertion. Prewired XH2.54 leads are sold in both polarities, so do not trust wire color or plug orientation. Never connect 5 V or a multi-cell pack to the battery input. Charging and load-sharing behavior varies by board revision; confirm it from the matching schematic and do not leave an initial charging test unattended.\n\nThe onboard ES8388 microphone/speaker path is not used by this application. Voice audio travels directly through Bluetooth HFP/SCO.\n\n- PlatformIO Core 6.1.19 or newer\n- PlatformIO Espressif 32 platform 6.12.0\n- ESP-IDF 5.5.0\n`espressif/esp_websocket_client`\n\n1.5.0- Espressif\n`esp_peer`\n\n1.5.0 from`esp-webrtc-solution`\n\nrevision`7850d1c`\n\n- Go 1.24 or newer\n\nThe versions are pinned by `platformio.ini`\n\n, `main/idf_component.yml`\n\n,\n`bridge/go.mod`\n\n, and the WebRTC revision in `scripts/bootstrap_webrtc.sh`\n\n.\nPlatformIO's downloaded toolchain is kept under `.pio-core/`\n\nand ignored by\nGit. The generated `dependencies.lock`\n\nis also ignored because the local\n`esp_peer`\n\noverride records an absolute checkout path on each machine.\n\nFetch the ignored Espressif source checkout before building firmware:\n\n```\nscripts/bootstrap_webrtc.sh\n```\n\nCopy the template and edit the new local file:\n\n```\ncp main/credentials.example.h main/credentials.local.h\n```\n\nSet `APP_WIFI_SSID`\n\n, `APP_WIFI_PASSWORD`\n\n, and `APP_BRIDGE_SIGNAL_URL`\n\n. The local\ncredentials file is ignored by Git.\n\nWithout Wi-Fi and a reachable bridge, the firmware still boots and pairs but the mobile app receives silence from the Bluetooth microphone route.\n\nCopy `bridge/config.example.json`\n\nto the ignored\n`bridge/config.local.json`\n\n. Set `bind_ip`\n\nto the laptop's active LAN address,\nchoose a private TURN password, and update `APP_BRIDGE_SIGNAL_URL`\n\nto the same\naddress. `telemetry_db`\n\nselects the local SQLite history file. Then run:\n\n```\ncd bridge\ngo test ./...\ngo build -o esp32-voice-agent-bridge .\n./esp32-voice-agent-bridge -config config.local.json\n```\n\nThe bridge is a foreground process. Stopping its terminal—or closing the app that launched it—stops signaling, TURN, and MiniCPM. Run the compiled binary from a persistent terminal or install it as an operating-system service for unattended use.\n\nThe default ports are HTTP/WebSocket `8080`\n\n, STUN/TURN UDP `3478`\n\n, TURN relay\nUDP `50000-50100`\n\n, and direct WebRTC UDP `50101-50200`\n\n. Health and live counters\nare available at `/healthz`\n\nand `/status`\n\n. The generic bilingual system prompt\nis stored in `bridge/system-prompt.txt`\n\n.\n\nThe bridge currently has no application-layer authentication. Its monitor can expose live audio and model text, and its reset/test controls change the active session. Bind it only to a trusted LAN interface, use a private TURN password, and never forward these ports directly to the public internet.\n\nThe bridge keeps full ICE for STUN/TURN support, but uses continuous ESP RTP as its authoritative media-liveness signal. This avoids an interoperability issue where the ESP peer misses Pion consent responses despite healthy media. Eight seconds without uplink RTP forces a clean signaling and peer restart.\n\nOpen `/monitor`\n\nin a browser on the same network to see connection state,\nlive RTP counters, the latest model text, and separate level meters for mobile\napp audio entering the agent and agent audio sent as the Bluetooth microphone.\nOptional playback for either direction is off by default; use headphones before\nenabling it to avoid an acoustic feedback loop.\n\nThe bridge also records telemetry asynchronously to `bridge/telemetry.sqlite3`\n\nby default. A dedicated writer goroutine batches WAL-mode SQLite transactions;\nthe WebRTC path only makes non-blocking channel sends. Each input/output audio\nframe stores its timestamp, direction, sample count, peak, and RMS, while status\nevents retain the same timestamps but store counters and state in typed columns\nin `telemetry_status`\n\n, joined through `event_id`\n\n. New status rows do not duplicate\nJSON payloads. Database files and compressed history archives are ignored by Git.\n\nThe offline migration mode imports a legacy JSON-history database without\nputting JSON blobs back into the active database. It preserves exact bridge-run\ntimestamps, optional status fields, status-level session presence, ordered ICE\ncandidate arrays, and the distinction between SQL `NULL`\n\n, an empty-string\npayload sentinel, and a valid migrated status object. Existing event IDs are\nnever reassigned; imported IDs are recorded in `telemetry_imported_events`\n\n.\n\nStop the bridge and serial monitor before migration. A check-only pass accepts\neither an uncompressed SQLite file or its `.gz`\n\narchive:\n\n```\ncd bridge\ngo build -o esp32-voice-agent-bridge .\n./esp32-voice-agent-bridge \\\n  -config config.local.json \\\n  -migrate-telemetry-from legacy-telemetry.sqlite3.gz \\\n  -migration-source-name legacy-telemetry \\\n  -check-only\n```\n\nApply only after check-only succeeds:\n\n```\n./esp32-voice-agent-bridge \\\n  -config config.local.json \\\n  -migrate-telemetry-from legacy-telemetry.sqlite3.gz \\\n  -migration-source-name legacy-telemetry \\\n  -apply-migration\n```\n\nApply mode verifies both databases, creates a compressed consistent backup of\nthe active database, imports and verifies everything in one transaction, then\ncheckpoints and integrity-checks the merged database. The source archive and\nbackup are retained. A decompressed temporary source is deleted only after a\nsuccessful verification. Reapplying the same source checksum performs a full\nverification and imports zero rows.\nThe optional `minicpm.inference`\n\nobject is forwarded to MiniCPM's\n`session.init`\n\npayload. For a conversational voice agent, a `listen_prob_scale`\n\nbelow the default `1.0`\n\nmakes the duplex model more eager to speak; `0.65`\n\nis\nthe current hardware-tested starting point.\n\nMiniCPM output is buffered by response because its one-second audio deltas can\narrive slower than realtime. Short responses start when MiniCPM emits `listen`\n\n;\nlong responses start after six seconds are buffered, with a 12-second failsafe.\nPlayback remains in 60 ms RTP packets while browser and SQLite audio telemetry\nretain their original 20 ms frame granularity.\nFor example:\n\n```\nsqlite3 -header -column bridge/telemetry.sqlite3 \\\n  \"SELECT captured_at, direction, audio_peak, audio_rms\n   FROM telemetry_events WHERE kind='audio' ORDER BY id DESC LIMIT 30;\"\n```\n\nFor an end-to-end mobile-app audio check, send `POST /test-tone`\n\n. The bridge\nqueues a two-second 880 Hz tone through WebRTC and Bluetooth HFP. Another\nparticipant receiving the app's microphone audio should hear it; the tablet\nitself normally will not monitor its own microphone.\n\n`POST /test-tone?seconds=60`\n\nsends a sustained pulse/silence pattern for UDP\nloss diagnosis. For a deterministic speech test that bypasses MiniCPM, send\none to 60 seconds of raw 8 kHz mono PCMU to `POST /test-audio`\n\n. The body must be\naligned to complete 160-byte (20 ms) frames; a 60-second fixture is exactly\n480,000 bytes:\n\n```\ncurl -X POST --data-binary @speech-60s.pcmu \\\n  http://BRIDGE_LAN_IP:8080/test-audio\n```\n\nThe hardware-validated active-SCO run delivered all 1,000 bundled RTP packets,\nall 3,000 source frames, and all 480,000 decoded samples without PCM drops or\nbuffer overruns. See `docs/debug-notes.md`\n\nfor the full result and tuning\nrationale.\n\nConnect the board's Micro-USB `UART`\n\nport, then run:\n\n```\npio run\npio run --target upload\npio device monitor\n```\n\nIf upload auto-reset fails, hold `BOOT`\n\n, tap `RST`\n\n, release `BOOT`\n\n, and retry\nthe upload. Do not hold `BOOT`\n\nduring normal audio operation because GPIO0 is\nalso used by the Audio Kit hardware.\n\n- Boot the board and open the serial monitor.\n- If the phone/tablet cached an older pairing, forget\n`ESP32 Voice Agent`\n\nfirst. - Pair\n`ESP32 Voice Agent`\n\nand enable its**Phone calls**,** Input device**, or equivalent communication-audio option. - Open the mobile voice app and select the Bluetooth headset route if the app exposes an audio-device picker.\n- Watch for WebRTC state 7, agent state\n`listening`\n\n, and nonzero`SCO PCM rx=... B/s tx=... B/s`\n\nmessages in the serial log.\n\nOrdinary media playback is not a valid test. The firmware provides HFP/SCO for two-way speech, not an A2DP media sink. An app that only uses Android's media stream may continue playing through the tablet speaker and may not expose its microphone to this device.\n\n``` php\nCompatible mobile app <-> iOS/Android HFP/SCO <-> ESP32-A1S\n                                              |\n                                          WebRTC/PCMU\n                                              |\n                                       Go bridge + TURN\n                                              |\n                                       MiniCPM-o 4.5 WSS\n```\n\nThe ES8388 codec and wired speaker are not in the voice-agent path. HFP audio comes directly from the ESP32 Bluetooth controller through Voice over HCI.\n\n- The iOS/Android app must support a Bluetooth communication/headset route. The ESP32 cannot force an app that deliberately uses only media audio or the tablet's built-in microphone to switch routes.\n- A2DP media playback is not implemented; HFP is used because the voice agent needs bidirectional audio.\n- One ESP32 session is supported at a time.\n- The laptop bridge must remain running on the configured LAN address.\n- The ESP32 supports 2.4 GHz Wi-Fi only. Wi-Fi and Bluetooth share that radio; weak Wi-Fi can still cause call-audio underruns outside the tested conditions.\n- The MiniCPM public audio session currently has a 600-second limit.\n- MiniCPM can generate one second of output audio several seconds apart. The bridge rebuffers those responses, but it cannot make a slower-than-realtime model source both continuous and low-latency.\n- WebRTC is narrowband PCMU because it matches CVSD HFP and keeps the original ESP32's CPU and memory use predictable.\n- The firmware relies on ESP-IDF's legacy decoded-PCM HFP callbacks. The code is pinned to ESP-IDF 5.5.x because Espressif plans to remove that path.\n- The default prompt forbids claims of being human, but server/model behavior should be verified before conversations with real people.", "url": "https://wpnews.pro/news/show-hn-connect-a-voice-agent-to-your-phone-as-a-bluetooth-headset-with-a-esp32", "canonical_source": "https://github.com/michaellee8/esp32-bluetooth-voice-agent", "published_at": "2026-07-13 17:26:28+00:00", "updated_at": "2026-07-13 17:34:58.115920+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "developer-tools"], "entities": ["AI-Thinker", "ESP32 Audio Kit V2.2", "ESP32-A1S", "MiniCPM-o 2.5", "WebRTC", "Go", "Bluetooth HFP", "SSD1306 OLED"], "alternates": {"html": "https://wpnews.pro/news/show-hn-connect-a-voice-agent-to-your-phone-as-a-bluetooth-headset-with-a-esp32", "markdown": "https://wpnews.pro/news/show-hn-connect-a-voice-agent-to-your-phone-as-a-bluetooth-headset-with-a-esp32.md", "text": "https://wpnews.pro/news/show-hn-connect-a-voice-agent-to-your-phone-as-a-bluetooth-headset-with-a-esp32.txt", "jsonld": "https://wpnews.pro/news/show-hn-connect-a-voice-agent-to-your-phone-as-a-bluetooth-headset-with-a-esp32.jsonld"}}