Skip to content

Network Mic ​

Turn the ESP32 into a network microphone: capture an ICS-43434 over I2S and serve the live audio over HTTP. Open the URL in a browser, or play it from ffplay, VLC, mpv, or any player that streams a URL. It combines i2s, http/server, and wifi.

The ICS-43434 is 24-bit data left-justified in 32-bit I2S slots, so we read 32-bit and shift down to 16-bit PCM (mono, 48 kHz) for a WiFi-friendly ~96 KB/s stream.

Hardware ​

  • Any ESP32 board with three available GPIO pins and WiFi
  • An InvenSense ICS-43434 I2S microphone
  • USB cable
ESP323V3GPIO 4GPIO 5GPIO 6GNDGNDICS-43434I2S micVDDSCKWSSDGNDL/R

Power the mic from 3V3 (it is not 5V tolerant). Tie L/R (SEL) to GND so it drives the left slot that channels: 'mono' reads.

Endpoints ​

The app serves the same mono 48 kHz signed-16-bit PCM two ways:

PathUse
/stream.wavWAV (streaming header + PCM): open in a browser, ffplay http://<ip>/stream.wav, or VLC
/stream.rawHeaderless PCM: ffplay -f s16le -ar 48000 -ch_layout mono http://<ip>/stream.raw

How it works ​

The mic is opened once. Each HTTP request's response body is an async generator that yields PCM frames forever; the server awaits each chunk (backpressure) and stops pulling when the client disconnects, ending the generator.

The key to sustaining 48 kHz is mic.capture(): it reads a frame and packs it to 16-bit PCM in one native call, with no per-chunk JS allocation. It blocks the loop for ~100 ms per frame, which is fine here: the HTTP server runs on its own task and flushes the previous chunk while the next is captured.

ts
async function* audioBody(withHeader: boolean) {
  if (withHeader) yield wavHeader()
  for (;;) {
    const pcm = mic.capture(FRAME) // Int16Array: read + 32→16-bit pack done in C
    if (!pcm.ok) break
    yield new Uint8Array(pcm.value.buffer, pcm.value.byteOffset, pcm.value.byteLength)
  }
}

const server = createServer((req) => {
  const path = req.url.split('?')[0]
  if (path === '/stream.wav')
    return {status: 200, headers: {'content-type': 'audio/wav'}, body: audioBody(true)}
  if (path === '/stream.raw')
    return {
      status: 200,
      headers: {'content-type': 'audio/L16; rate=48000; channels=1'},
      body: audioBody(false),
    }
  return {status: 404, body: 'not found'}
})
server.listen({port: 80})

The /stream.wav endpoint prepends a 44-byte WAV header with streaming sizes (0xFFFFFFFF, unknown length), so a player reads the format then streams the data until the connection closes. /stream.raw is the same PCM with no header, for tools you tell the format to on the command line.

Run ​

Set your WiFi credentials, then deploy:

sh
cp .env.example .env   # fill in WIFI_SSID and WIFI_PASSPHRASE
pnpm mikro flash       # only needed once per board
pnpm mikro dev         # develop on connected device
sh
cp .env.example .env   # fill in WIFI_SSID and WIFI_PASSPHRASE
npx mikro flash        # only needed once per board
npx mikro dev          # develop on connected device

The device prints the stream URL on connect. Open http://<ip>/stream.wav in a browser to listen, or run ffplay http://<ip>/stream.wav. The ICS-43434 is a quiet mic, so raise GAIN_BITS in app/main.ts if you want a louder stream.

Limitations ​

  • The HTTP server handles one connection at a time, so this is a single-listener demo (one player). A second connection waits for the first to close.

View source on GitHub

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.