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:
| 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 + 1amplitudes, DC at index 1 through the Nyquist frequency at the last.microphone.frequencies()returns the parallel array of centre frequencies in Hz, andmicrophone.peak()hands back{ bin, hz, amplitude, level }for the loudest bin.microphone.samples(max?)— the mono PCM nobody has taken yet, as abufferof little-endian f32 read withbuffer.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:
framesCapturedcounts 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.overrunscounts 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. Drainsamples()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.