I was building a PNGTuber overlay: a character in an OBS Browser Source whose mouth opens when the streamer talks. In Chrome it worked on the first try. getUserMedia, an AnalyserNode, a threshold, done.
Then I pasted the URL into OBS and the character never opened its mouth.
Why the microphone is silent inside OBS
OBS's Browser Source runs an embedded Chromium. When a page in it calls getUserMedia, the request is denied by default, with no prompt. obs-browser doesn't register a permission handler, so there's nobody to say yes.
The usual fix you'll find in forums is to launch OBS with --enable-media-stream. That works, but:
- On Windows it means editing the OBS shortcut.
- On macOS it means launching OBS from Terminal every single time.
- In Streamlabs Desktop there's no equivalent at all.
Asking every viewer of your setup guide to change how they launch OBS is a lot of friction for one overlay. So I went looking for another way to hear the streamer.
OBS already measures the mic
Since OBS 28, obs-websocket v5 ships inside OBS. It listens on ws://127.0.0.1:4455, and a Browser Source can reach localhost without any special permission.
One of the events it publishes is InputVolumeMeters: the current level of every active audio input, about every 50 ms.
This turned out to be a better signal than the browser mic, not just a workaround:
- It's measured after OBS's filters, fader and mute. If the streamer has a noise gate, it's respected. If they hit mute, the mouth stays shut.
- It's the same mic, with the same processing, that the stream actually hears.
The handshake
obs-websocket v5 speaks JSON messages with an op code:
| op | Message | Direction |
|---|---|---|
| 0 | Hello | server → client |
| 1 | Identify | client → server |
| 2 | Identified | server → client |
| 3 | Reidentify | client → server |
| 5 | Event | server → client |
| 6 / 7 | Request / RequestResponse | both |
The flow is: connect, receive Hello, answer with Identify, wait for Identified.
Two details tripped me up.
1. InputVolumeMeters is not part of "All events". It's a high-volume event, so you have to ask for it by its own bit:
const EVENT_SUB_INPUT_VOLUME_METERS = 1 << 16;
ws.send(JSON.stringify({
op: 1, // Identify
d: {
rpcVersion: 1,
authentication, // only if Hello asked for it
eventSubscriptions: EVENT_SUB_INPUT_VOLUME_METERS,
},
}));
2. Authentication is a double SHA-256. If the server has a password, Hello carries a salt and a challenge, and the answer is base64(sha256(base64(sha256(password + salt)) + challenge)):
async function sha256Base64(text: string): Promise<string> {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(text)
);
let binary = '';
for (const byte of new Uint8Array(digest)) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
async function obsAuthString(password: string, salt: string, challenge: string) {
const secret = await sha256Base64(password + salt);
return sha256Base64(secret + challenge);
}
crypto.subtle only exists in a secure context. An https page, or localhost in development, is fine.
Which input is "the mic"?
InputVolumeMeters lists every active input by name, so you need to know which one to watch. I ask OBS twice:
-
GetSpecialInputsreturns the global audio devices (mic1tomic4). If "Mic/Aux" is set, use it. - Many newer setups disable the global device and add an "Audio Input Capture" source to a scene instead. Then
mic1is null, so fall back toGetInputListand take the first input whose kind is a microphone:
const MIC_INPUT_KINDS = new Set([
'wasapi_input_capture', // Windows
'coreaudio_input_capture', // macOS
'pulse_input_capture', // Linux (PulseAudio / PipeWire)
'alsa_input_capture', // Linux (ALSA)
]);
function pickMicInput(special, inputs) {
for (const key of ['mic1', 'mic2', 'mic3', 'mic4']) {
if (special?.[key]) return special[key];
}
const mic = inputs.find((input) =>
MIC_INPUT_KINDS.has(input.unversionedInputKind ?? input.inputKind ?? '')
);
return mic?.inputName ?? null;
}
Compare unversionedInputKind, so a future _v2 of the same source still matches.
Reading the level (and the trap in it)
Each input in the event has inputLevelsMul: one array per channel, each [magnitude, peak, inputPeak] as linear multipliers.
The trap: index 2 is the raw input peak, before mute and fader. If you drive the mouth from it, a muted streamer still "talks" on screen. Use index 0 and take the loudest channel:
function micLevelFromMeters(inputs, inputName): number | null {
const input = inputs.find((i) => i.inputName === inputName);
if (!input) return null; // not listed = inactive = silence
let level = 0;
for (const channel of input.inputLevelsMul) {
const magnitude = channel[0];
if (Number.isFinite(magnitude) && magnitude > level) level = magnitude;
}
return level;
}
From a level to a mouth
A raw level makes a jittery mouth. Three things fixed it:
- A noise gate and a gain. Subtract a small floor so room noise never moves the mouth, then scale normal speech up to the top of the range.
- Open fast, close slow. A mouth that snaps shut looks broken.
- A hold. The mouth closes only after the level has stayed below the threshold for ~100 ms, so the gap between syllables doesn't flap it.
One bug worth sharing: my close-slow smoothing was written per animation frame. That's fine at 60 fps, but the meter arrives at ~20 Hz, so the mouth closed three times slower in OBS than in the browser preview. (On a 120 Hz monitor it closed twice as fast.) Making the decay a time constant fixed both:
const CLOSE_RATE_PER_FRAME = 0.22; // tuned at 60 fps
const REFERENCE_FRAME_MS = 1000 / 60;
function nextLevel(prev: number, target: number, dtMs: number): number {
if (target > prev) return target; // open instantly
const frames = Math.max(0, dtMs) / REFERENCE_FRAME_MS;
const keep = (1 - CLOSE_RATE_PER_FRAME) ** frames;
return target + (prev - target) * keep; // close by elapsed time
}
Hidden sources
A common PNGTuber setup is one Browser Source per expression, toggled with hotkeys. Five hidden sources each parsing twenty meter messages a second is wasted work.
OBS tells the page when its source is shown or hidden with a obsSourceVisibleChanged event. When hidden, I Reidentify with no event subscriptions, and resubscribe when it's shown again:
window.addEventListener('obsSourceVisibleChanged', (event) => {
const visible = event.detail?.visible;
ws.send(JSON.stringify({
op: 3, // Reidentify
d: { eventSubscriptions: visible ? 1 << 16 : 0 },
}));
});
Don't tear things down on document.hidden instead. OBS marks a hidden source's document as hidden, so a source that releases its audio on visibilitychange comes back deaf.
Failure modes
obs-websocket closes with its own codes, and they mean different things:
-
4009(authentication failed): wrong password. Don't retry; tell the user. -
4010(unsupported RPC version): too old or too new. Don't retry. -
1006before anyHello: nothing is listening. OBS is closed or its WebSocket server is off. Retry with a backoff (I use 1, 2, 5, 10, then every 15 seconds).
If the socket can't work at all, I still try getUserMedia as a fallback, for the people who did add --enable-media-stream.
To know whether you're inside OBS at all, check window.obsstudio. It only exists in the Browser Source.
Where to put the password
The overlay needs the obs-websocket password, and the URL is the only thing the streamer pastes into OBS. I put it in the fragment (#obs=...), which the browser never sends to the server.
Two more precautions on that page: Referrer-Policy: no-referrer, and no analytics script, because most analytics tools record the full URL, fragment included. obs-websocket also listens on the LAN by default, so that password is the only thing guarding it.
Limits
- Streamlabs Desktop doesn't have obs-websocket, so this route doesn't exist there.
- The streamer has to enable the WebSocket server once (Tools → WebSocket Server Settings in OBS).
That one checkbox is still a much easier ask than a launch flag. And the bonus of respecting mute and noise gates for free made me prefer it even where the flag is already set.
I built this for the OBS player in PNGTuber Maker, an AI PNGTuber generator. If you're building any audio-reactive overlay, the same approach works for anything that needs to react to the streamer's voice.
Top comments (0)