Builtin Library
README.md
microphone
Capture from an input device and read what is arriving: a loudness, a magnitude spectrum, and the raw PCM.
local state, why = microphone.start()
if state == nil then
print("no capture:", why)
return
end
state = microphone.awaitRunning()
if state ~= "running" then
print("the capture settled at", state, microphone.status().reason)
return
end
print(microphone.level(), microphone.peak().hz)
microphone.stop()
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 for microphone access |
denied | microphone access was refused |
running | a device is open and delivering frames |
failed | the device did not carry through; status().reason says why |
In a browser, opening a device means asking the person at the machine for
access and waiting for an answer that may never arrive — so the wait is a state
to read rather than an error to catch. microphone.awaitRunning(timeout) waits
out starting and permissionPending for you and reports where it landed.
A request that cannot be made at all returns nil and the reason instead: an
fftSize that is not a whole power of two between 64 and 16384, a device no
machine here offers, a rate the device does not capture at, or a capture that is
already running.
What is arriving
microphone.status() reports 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.
Reading the spectrum
microphone.spectrum() returns fftSize / 2 + 1 amplitudes, DC at index 1
through the Nyquist frequency at the last. Bin i is centred on
(i - 1) * status().binHz Hz, and microphone.frequencies() builds that array
for you.
Each value is an amplitude estimate rather than a raw transform magnitude, so a full-scale tone sitting on a bin centre reads about 1.0 and the numbers stay comparable across transform sizes.
The window is multiplied by a Hann taper before the transform. An untapered window ends abruptly at both edges, and the transform reads that discontinuity as energy spread across every bin — one tone smears into a skirt that buries quieter tones beside it. Hann trades a slightly wider main lobe, so a tone occupies about three bins rather than one, for sidelobes that fall away steeply. Read a peak as "a tone near here", not "a tone exactly here".
fftSize is the trade between the two axes: 2048 at 48 kHz resolves ~23 Hz per
bin over a ~43 ms window, and 256 resolves ~188 Hz per bin over ~5 ms. A
visualiser reacting to a beat wants the short window; telling two vowels apart
wants the long one.
microphone.start({ fftSize = 2048 })
microphone.awaitRunning()
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
Reading the raw PCM
microphone.samples(max?) hands back mono f32 samples nobody has taken yet, as
a Luau buffer:
local pcm = microphone.samples()
if pcm then
for offset = 0, buffer.len(pcm) - 4, 4 do
local sample = buffer.readf32(pcm, offset)
-- ...
end
end
The samples are removed, so successive calls walk forward through the capture and analysis of your own sees every frame once. Reading a level or a spectrum takes nothing away from this — they run off a rolling window of their own.
Samples nobody takes are discarded once the queue fills, and overruns counts
every one. Drain once per frame if you want continuity.
audio.encodePcm takes that buffer as it is, so a capture becomes a clip in two
calls:
local status = microphone.status()
local zaud = audio.encodePcm(microphone.samples(), status.sampleRate, 1)
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 microphone access has been granted at least once — the labels are part of what the permission protects, so enumerate again after a capture has run.