Log inGet started

The microphone: sound coming in

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. A voice shouting at a puppet, someone speaking into a phone, a…

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

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:

task.spawn(function()
    while true do
        local mouth = entity.find("puppet_jaw")
        mouth.scale = { 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:

StateMeaning
idleNothing has been asked for, or a capture was stopped.
startingA device is being opened.
permissionPendingThe platform is asking the person at the machine for microphone access.
deniedAccess was refused.
runningA device is open and delivering frames.
failedThe 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.

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

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.

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.

  • documentation
  • guide