microphone plugin (zinc:microphone)
Microphones: list them, open one at a sample rate and a channel count, receive the samples in blocks, read a level for a meter. Capture
goes through miniaudio (the copy zinc:audio vendors), so the same code runs on Core Audio, PulseAudio and ALSA. Sources:
plugins/microphone/. Sending audio to a peer (WebRTC audio tracks) is not there yet: see webrtc.
import { devices, requestPermission, Microphone } from 'zinc:microphone';
if (await requestPermission() !== 'granted') throw new Error('no microphone access');
const list = await devices(); // MicrophoneDevice[] { id, name }
const mic = await Microphone.open(list[0].id, { sampleRate: 48000, channels: 1 });
mic.onData((samples, frameIndex) => { /* about 20 ms of samples, -1..1 */ });
gfx.fillRect(0, 0, mic.level() * 300, 8, 0x22c55e); // a level meter, read each frame
mic.close();
| API | |
|---|---|
requestPermission(): Promise<'granted' | 'denied' | 'undetermined'> | macOS asks the person once per app and resolves with the answer (undetermined after a minute without one); a background run (ZINC_BACKGROUND=1, every test) never asks and answers at once; elsewhere granted. denied too when zinc.json refuses microphone |
permissionStatus() | the same status, without asking |
devices(): Promise<MicrophoneDevice[]> | { id, name }; the fake microphone when the machine has none; [] with ZINC_FAKE_MICROPHONE=0 and no microphone. Rejects when zinc.json refuses microphone |
Microphone.open(id?, { sampleRate, channels }?) | '' or nothing opens the first microphone; defaults 48000 Hz, 1 channel (8000 to 192000 Hz, 1 or 2 channels: miniaudio converts what the device has). Rejects with the reason: no such microphone, access not asked (call requestPermission() first) or denied, in use |
sampleRate, channels, id | the format of the samples |
onData(cb) | each block as it arrives (about every 20 ms): samples (number[], channels interleaved, -1..1) and the index of its first frame since open (frameIndex / sampleRate is its time) |
level() | RMS of the last block, 0 to 1 |
frameIndex, isOpen | frames received so far; false once closed |
close() | stops the capture |
navigator.mediaDevices covers it too once the program imports the plugin: enumerateDevices() lists the microphones as audioinput,
getUserMedia({ audio: true }) (or { audio: { sampleRate, channelCount, deviceId } }, with or without video) gives a MediaStream whose
audio track holds the Microphone (track.microphone as Microphone); track.stop() closes it.
Backends
| target | how | notes |
|---|---|---|
| macos | miniaudio on Core Audio; the privacy question through AVFoundation (AVCaptureDevice for audio) | built-in, USB and virtual (BlackHole, Loopback) inputs |
| linux, rpi1 | miniaudio on PulseAudio, else ALSA (loaded at run time, nothing to install to build) | written, not proven on a board yet; the user needs the audio group on a system without PulseAudio |
sim, ZINC_NATIVE=sim | the fake microphone alone (native/microphone.sim.ts): the same samples | |
| windows, android, ios, wasm | not yet (error Z5005: the profile has microphone false) |
The device fills a buffer on miniaudio’s thread (one second at most: a program that stops reading loses the oldest samples); index.ts
takes what came in every 20 ms on the program’s thread and runs the onData handlers. No callback runs on another thread.
Permissions
- zinc.json:
"permissions": ["microphone"]. Without it, in a project that declares permissions,devices()andopen()warn while developing and fail in an exported app (EACCES: microphone needs the permission "microphone" in zinc.json "permissions"). - macOS privacy: the system asks the person the first time an app asks (
requestPermission()); the answer is kept in System Settings > Privacy & Security > Microphone.opennever starts a capture without access: it fails with “call requestPermission() first”, so a program never stops on a question it did not ask. Underzinc runthe system asks for the terminal app that started it. - Exported apps: with
microphonein the permissions,zinc export --target macoswritesNSMicrophoneUsageDescription(”uses the microphone.”) and the entitlement com.apple.security.device.audio-input; Android getsandroid.permission.RECORD_AUDIO. requires: the plugin needs themicrophonecapability (targets/capabilities.json): true on macos and sim, optional on linux and rpi1, false elsewhere.
The fake microphone
“Zinc Fake Microphone” (zinc:fake-microphone) is a triangle wave of period 100 samples whose loudness steps every 20 ms block (0.05 to 0.5,
ten steps), plus noise of ±0.01: a function of the sample index only, so a session gives the same samples on every machine and engine. It
makes one block per poll. It is the only microphone with ZINC_FAKE_MICROPHONE=1 (or "plugins": { "microphone": { "fake": true } }) and
in a deterministic run (ZINC_DETERMINISTIC, unless ZINC_FAKE_MICROPHONE=0 or ZINC_REALTIME), and the one devices() gives when the
machine has none. ZINC_FAKE_MICROPHONE=0 never lists it.
Tests: tests/t1/microphone.sh (interpreter, QuickJS, compiled, the permission checks; no real microphone is opened) and
tests/t0/microphone_sim.sh (the stand-in without native code), against tests/golden/host/microphone_session.out.