Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

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:

statemeaning
idlenothing has been asked for, or a capture was stopped
startinga device is being opened
permissionPendingthe platform is asking for microphone access
deniedmicrophone access was refused
runninga device is open and delivering frames
failedthe 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:

  • 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.

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.