---
title: "The microphone: sound coming in"
description: "The audio guide is about sound the world makes. This one is about sound the world hears — audio input from the machine it is running on."
section: "Topics"
slug: "topics-microphone"
canonical: "https://origozero.ai/docs/topics-microphone"
updated: "2026-10-15T11:08:39.798175346+00:00"
tags: ["documentation", "guide"]
---

# The microphone: sound coming in

That is what audio-reactive content is made of — a mouth that moves with a voice, a level meter, a visualiser that pulses on the beat, a light that brightens with the room, a puzzle you solve by humming the right note.

## The shortest thing that works

```lua
local state, why = microphone.start({ fftSize = 512 })
if not state then error(why) end

if microphone.awaitRunning(5) == "running" then
    -- Loudness of the latest window, RMS 0..1. Silence reads 0.
    print("level", microphone.level())

    -- The strongest frequency right now, and how strong.
    local p = microphone.peak()
    print("peak", p.hz, p.amplitude)
end

microphone.stop()
```

Drive something with it by reading `level()` once a frame:

```lua
task.spawn(function()
    while true do
        local mouth = entity.find("puppet_jaw")
        mouth.localScale = { 1, 1 + microphone.level() * 4, 1 }
        task.wait()
    end
end)
```

## A capture is a state, not a call

`microphone.start()` returns the state the capture reached, and `microphone.status().state` says where it stands from then on:

| State | Meaning |
|---|---|
| `idle` | Nothing has been asked for, or a capture was stopped. |
| `starting` | A device is being opened. |
| `permissionPending` | The platform is asking the person at the machine for microphone access. |
| `denied` | Access was refused. |
| `running` | A device is open and delivering frames. |
| `failed` | The device did not carry through; `status().reason` says why. |

This shape exists because of the browser: opening an input device there means asking a human for permission and waiting for an answer that may never come. So the wait is a state you read rather than an error you catch. `microphone.awaitRunning(timeout)` waits out `starting` and `permissionPending`, is always bounded, and reports where it landed.

A request that cannot be made at all answers `nil` plus the reason instead: an `fftSize` that is not a whole power of two between 64 and 16384, a device this machine does not offer, a rate the device does not capture at, or a capture that is already running. One capture runs at a time.

## Reading what is arriving

Three readings, all off the same live capture:

- **`microphone.level()`** — RMS amplitude, 0..1, over the most recent analysis window. A full-scale sine reads about 0.707. This is the loudness meter, and it is all a "is someone talking" check needs.
- **`microphone.spectrum()`** — `fftSize / 2 + 1` amplitudes, DC at index 1 through the Nyquist frequency at the last. `microphone.frequencies()` returns the parallel array of centre frequencies in Hz, and `microphone.peak()` hands back `{ bin, hz, amplitude, level }` for the loudest bin.
- **`microphone.samples(max?)`** — the mono PCM nobody has taken yet, as a `buffer` of little-endian f32 read with `buffer.readf32`. The samples are removed as you take them, so successive calls walk forward through the capture and your own analysis sees every frame once.

Reading a level or a spectrum takes nothing away from `samples()` — they run off a rolling window of their own, so a caller can do both.

```lua
local hz   = microphone.frequencies()
local bins = microphone.spectrum()
for i = 1, #bins do
    if bins[i] > 0.1 then
        print(string.format("%.0f Hz at %.2f", hz[i], bins[i]))
    end
end
```

Each spectrum value is an amplitude estimate rather than a raw transform magnitude, so a full-scale tone on a bin centre reads about 1.0 and the numbers stay comparable across transform sizes. The window is tapered with a **Hann** window first: a tone occupies about three bins rather than one, in exchange for sidelobes that fall away steeply and let neighbouring tones be told apart. Read a peak as "a tone near here".

`fftSize` is the trade between the two axes. At 48 kHz, 512 spans ~10 ms and resolves ~94 Hz per bin — what a mouth or a beat-reactive visual wants; 2048 spans ~43 ms and resolves ~23 Hz — what telling two vowels or two notes apart wants.

## Knowing the readings are real

`microphone.status()` carries two counters that answer different questions:

- `framesCaptured` counts every mono frame the device delivered, whether or not anything drained it. A silent room and a stalled device both read level 0; this is what tells them apart.
- `overruns` counts samples discarded because a consumer did not keep up. It standing still is what says the readings are continuous; it climbing is why a caller sees gaps. Drain `samples()` once a frame to keep it still.

## Devices

```lua
for _, d in ipairs(microphone.devices()) do print(d.id, d.name, d.default) end
microphone.start({ device = "…the id…" })
```

An empty list is a legitimate answer: a machine with no input hardware offers none, and a browser names none until access has been granted at least once — the labels are part of what the permission protects, so enumerate again after a capture has run. Omitting `device` opens the platform default, and omitting `sampleRate` takes the device's own rate, which is what avoids a resample.

A native engine started with `--fake-microphone tone` presents the test harnesses' fake microphone in place of the machine's own: one device, `Zero fake microphone (tone.wav)`, delivering a 1500 Hz sine at half of full scale at 48 kHz. `--fake-microphone deny` presents a microphone whose every start reads `denied`, and `--fake-microphone none` presents none; the `ZERO_FAKE_MICROPHONE` environment variable takes the same names.

## Finding the rest

`lsp.methods("microphone")` lists this API and `lsp.describe("microphone.start")` gives its exact signature. The **audio** guide covers the other direction — `Audio` and `AudioListener` components, spatial sound, and the `.soundClip` assets they play. Whatever a capture drives can also leave the machine: the **byte-streams** guide carries bytes to another program, the **frame-streaming** guide carries a camera's rendered frames the same way, and the **http-server** guide lets a phone poll a level the world is measuring.

The model to hold: a capture is opened once and then observed — a state to read, and three views of the same arriving sound.
