NotReadableError: Could Not Start Audio Source, Fixed
NotReadableError means the browser got microphone permission but the OS never handed over the hardware. The fixes, split by platform, plus the code causes.
NotReadableError means the browser got microphone permission but the OS never handed over the hardware. The fixes, split by platform, plus the code causes.
NotReadableError: Could not start audio source is what navigator.mediaDevices.getUserMedia() rejects with when permission was granted and the microphone still never opened. MDN's wording is precise: "although the user granted permission to use the matching devices, a hardware error occurred at the operating system, browser, or Web page level which prevented access to the device." So the browser is not the thing blocking you — the operating system is refusing to hand over the capture device, usually because something else already holds it. The fix depends almost entirely on which OS the user is on, which is how this guide is organized.
It means your permission code worked. The user clicked Allow, the browser resolved the permission check, and then the layer underneath — Windows audio endpoints, macOS TCC, PulseAudio, an Android WebView — failed to produce a stream.
The message string differs by engine, which matters when you are reading a bug report from a user:
| Browser | What you see |
|---|---|
| Chrome, Edge, other Chromium | NotReadableError: Could not start audio source |
| Firefox | NotReadableError: Failed to allocate audiosource, or Concurrent mic process limit when a second Firefox tab wants the same device |
| Older Chrome (pre-spec-alignment) | TrackStartError — the same condition under the legacy name |
That last row is worth handling in code. Chrome shipped TrackStartError for years before aligning with the spec name, and enough embedded Chromium builds and old WebViews are still in the wild that a switch on error.name should treat both as the same case.
Before changing anything, read error.name. The eight names getUserMedia() can reject with point at completely different fixes:
error.name | What it means | Where to look |
|---|---|---|
NotReadableError | Permission granted, hardware unavailable | This guide |
NotAllowedError | The user or the browser denied permission | Our getUserMedia NotAllowedError guide |
NotFoundError | No device matches the request at all — no mic present | Device enumeration, virtual machines |
OverconstrainedError | Constraints matched zero devices; check error.constraint | Usually a stale exact: { deviceId } |
AbortError | The device was found and available, then something else broke | Rare; treat as retryable |
SecurityError | Media capture disabled at the document level | Iframe allow="microphone", Permissions Policy |
TypeError | Empty constraints, or an insecure context | Serve over HTTPS or localhost |
InvalidStateError | The document is not fully active | Called too early, or on a bfcached page |
If you have NotAllowedError, stop here — that is a permissions problem with a different fix path, and we wrote it up separately. Everything below assumes NotReadableError.
Windows produces more of these reports than every other platform combined, for one reason: applications can take exclusive control of an audio endpoint, and when one does, the browser gets nothing.
Find the app holding the microphone. Teams, Zoom, Discord, OBS, Skype, and any running screen recorder are the usual suspects, and "minimized" is not "closed" — Teams and Discord keep capture sessions open from the tray. Windows 11 shows a microphone icon in the system tray while any process is capturing; click it to see which one. Quit that app fully, then reload the page.
Turn off exclusive mode for the device. Run mmsys.cpl, open the Recording tab, select your microphone, then Properties → Advanced, and clear both "Allow applications to take exclusive control of this device" and "Give exclusive mode applications priority". This is the single highest-yield fix on Windows and it survives reboots.
Check the two privacy toggles, not one. Under Settings → Privacy & security → Microphone, "Microphone access" must be on and "Let desktop apps access your microphone" must be on. The second one is the one people miss, because browsers are desktop apps: your site can hold a granted Chrome permission while Windows blocks Chrome itself, which produces exactly this error rather than a permission prompt. 3CX's support forum has a long-running thread on their Windows web client tracing to precisely this pair of toggles.
Then the boring ones. Unplug and reseat a USB headset, preferring a direct port over a hub; update or roll back the audio driver; reboot to release a capture stream stuck by a crashed process.
macOS gates the microphone at the application level through TCC (Transparency, Consent, and Control), one layer below the per-site permission your JavaScript sees. Chrome can have your site's permission and still be denied by the OS, and the result is NotReadableError.
Open System Settings → Privacy & Security → Microphone and confirm the browser itself is toggled on. If the browser is missing from that list entirely, the OS prompt was never triggered or was dismissed; force it to re-ask by resetting the entitlement:
tccutil reset Microphone com.google.Chrome
Substitute com.apple.Safari, org.mozilla.firefox, or com.microsoft.edg.macos as needed, then restart the browser and reload the page. The first getUserMedia() call after the reset triggers a fresh OS prompt.
Two macOS-specific traps beyond permissions. Virtual audio devices — Loopback, BlackHole, Krisp, an old Soundflower install — insert themselves as the default input and fail to open when their host process is not running. And an Electron or WKWebView wrapper needs NSMicrophoneUsageDescription in its own Info.plist; without it the OS refuses the capture and the web layer reports NotReadableError.
On Linux the error nearly always means another client already holds the capture stream, or the sound server lost the device.
Find what is capturing right now:
# PulseAudio: clients currently holding a capture stream
pactl list source-outputs | grep -E 'Source Output|application.name'
# PipeWire (Fedora 34+, Ubuntu 22.10+)
wpctl status
# Either: which processes hold the ALSA device nodes
fuser -v /dev/snd/*
fuser is the decisive one. If it lists a process you did not expect — a zombie browser helper, a stuck pipewire-pulse, a Snap-packaged Chromium — kill it and retry.
Three more Linux causes worth checking. Applications that bypass the sound server and open ALSA hardware directly (hw:0,0) lock out everything else, so route them through dmix or the server instead. Snap and Flatpak browser builds need the audio-record interface granted (snap connect chromium:audio-record, or the Flatpak --device=all permission) — without it the sandbox blocks capture after the browser has already granted the page. And USB sound cards genuinely drop off the bus under load; the Arch forums have a well-known thread where a USB headset disappears from ALSA at random and comes back on replug.
The largest cluster of Could not start audio source reports on GitHub is not desktop at all — it is getUserMedia() inside an Android WebView, across react-native-webview (issue #3658), Expo (issue #35345), Capacitor (issue #802), and Cordova. The reports share a shape: works in Chrome on the same device, works on an older emulator image, fails on a real phone running Android 13 or 14.
A WebView needs microphone access granted twice, and the second grant is the one people miss.
android.permission.RECORD_AUDIO declared in the manifest and requested at runtime — manifest declaration alone has not been sufficient since Android 6.0. Add MODIFY_AUDIO_SETTINGS alongside it.WebChromeClient.onPermissionRequest() and calling request.grant(request.getResources()). A WebView with no WebChromeClient override silently denies every media request, and the page sees NotReadableError. In react-native-webview this is the mediaCapturePermissionGrantType="grant" prop.One detail from issue #3658 is worth calling out because it is copied around unchanged: the reporter's manifest includes android.permission.MICROPHONE and android.permission.AUDIO_CAPTURE. Neither exists. MICROPHONE is a permission group (android.permission-group.MICROPHONE), not a permission, and AUDIO_CAPTURE is not an Android permission at all. They are silently ignored at install time, which makes a manifest look correct while granting nothing. RECORD_AUDIO is the one that does the work.
On iOS, WKWebView had no getUserMedia() support whatsoever until iOS 14.3 (December 2020) — the accepted answer on Stack Overflow #48775154, the canonical thread for this error at 44,000 views, is a multi-year changelog of that gap. On current iOS it works, provided the host app declares NSMicrophoneUsageDescription and enables inline media playback.
Four application bugs produce NotReadableError on hardware that is working perfectly.
A previous stream you never stopped. Calling getUserMedia() again while an earlier MediaStream is still live is the most common self-inflicted version, and on constrained devices the second call fails outright. The fix is one line, and it is the top-voted answer on the sibling video-source question:
stream.getTracks().forEach((track) => track.stop());
Stop tracks in every teardown path — component unmount, call end, page hide — not only on the happy path.
React StrictMode in development. React 18 and 19 mount, unmount, and remount effects in dev. If your useEffect calls getUserMedia() and its cleanup does not stop the tracks, the second mount requests a device the first mount still holds. The symptom is unmistakable: it fails in npm run dev and works in the production build. Fix the cleanup rather than disabling StrictMode.
A stale deviceId. Persisting the user's chosen microphone in localStorage is the right product decision and a reliable source of this error six months later, when that headset is gone. deviceId values also rotate — they are scoped per origin and reset when the user clears site data. Requesting { audio: { deviceId: { exact: savedId } } } against a device that no longer exists throws OverconstrainedError; requesting one that exists but cannot open throws NotReadableError. Handle both by falling back to the default device.
Requesting the mic before the page is visible. A getUserMedia() call fired from a background or prerendering tab can fail because the OS declines to start capture for a non-foreground document. Gate acquisition on a user gesture, which you want anyway — the same gesture requirement governs playback, and if your ringtone is silent while the mic works, you are looking at the AudioContext autoplay policy instead.
Most production code catches getUserMedia() failures into a single "microphone error" toast, which tells the user nothing they can act on. Map each name to an instruction, and retry once on the default device when the failure looks device-specific:
const MIC_ERROR_MESSAGES = {
NotAllowedError:
'Microphone access is blocked. Click the lock icon in the address bar and allow the microphone, then reload.',
NotReadableError:
'Your microphone is in use by another app. Close Zoom, Teams, Discord, or any recorder, then try again.',
TrackStartError: // legacy Chromium name for NotReadableError
'Your microphone is in use by another app. Close Zoom, Teams, Discord, or any recorder, then try again.',
NotFoundError:
'No microphone was found. Connect one and reload the page.',
OverconstrainedError:
'The saved microphone is unavailable. We switched you to the default device.',
AbortError:
'The microphone stopped responding. Unplug and reconnect it, then try again.',
SecurityError:
'Microphone access is disabled for this page. Contact your administrator.',
NotSupportedError:
'This browser cannot access the microphone. Try Chrome, Edge, or Firefox.',
TypeError:
'This page must be served over HTTPS to use the microphone.',
InvalidStateError:
'The page was not ready for the microphone. Please reload and try again.',
};
const DEVICE_SPECIFIC = new Set([
'NotReadableError',
'TrackStartError',
'OverconstrainedError',
'AbortError',
]);
export async function getMicStream(preferredDeviceId) {
const constraints = preferredDeviceId
? { audio: { deviceId: { exact: preferredDeviceId } } }
: { audio: true };
try {
return await navigator.mediaDevices.getUserMedia(constraints);
} catch (error) {
// A named device failed: fall back to the system default once.
if (preferredDeviceId && DEVICE_SPECIFIC.has(error.name)) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
clearSavedMicPreference(); // stop reusing the dead deviceId
return stream;
} catch (fallbackError) {
throw annotate(fallbackError);
}
}
throw annotate(error);
}
}
function annotate(error) {
error.userMessage =
MIC_ERROR_MESSAGES[error.name] ??
`Microphone error (${error.name}): ${error.message}`;
return error;
}
Two things this buys you. The fallback turns the stale-deviceId case into a call that connects on the default mic instead of a call that fails, and error.userMessage gives support a string worth reading in your logs. Keep error.name and error.message in whatever telemetry you send — the raw pair is what distinguishes a Firefox concurrent-mic limit from a Windows exclusive-mode lock weeks later.
The Twilio Voice JavaScript SDK calls getUserMedia() for you, which means the failure arrives late and wearing a different name.
device.connect() and call.accept() acquire the microphone during call setup, so a device conflict does not appear when your app loads — it appears at the moment the user places a call, which is the worst possible time to discover it. Both methods accept an rtcConstraints object that is passed through to getUserMedia(), so a deviceId you set there carries the same staleness risk described above. device.audio.setInputDevice(deviceId) re-acquires the microphone immediately, and it is the call that most often throws when a user switches headsets mid-session; re-read device.audio.availableInputDevices before calling it rather than trusting a stored id.
Permission denial has its own Twilio code — 31401 UserMedia Permission Denied — and Twilio's documented fix is to call navigator.mediaDevices.getUserMedia({ audio: true }) before creating the Device, which surfaces the prompt early. Their docs add a condition that is easy to skip: stop the returned tracks until you actually need the microphone. Holding that warm-up stream open is a good way to manufacture a device conflict against your own SDK later in the session.
If the microphone acquires cleanly and the call still has no audio, device acquisition is not your problem — start with one-way audio troubleshooting and the media path instead.
Does NotReadableError mean my microphone is broken?
Almost never. Test the same device in your OS sound settings — Windows Recording tab, macOS Sound input level meter — and if the level meter moves, the hardware is fine and another application or a permission layer is holding it. Genuine hardware failure shows up as NotFoundError, not NotReadableError.
Why does it only fail on a real phone and not the emulator?
Android emulators route capture to the host machine's microphone with no contention and looser WebView defaults, so a missing onPermissionRequest override passes unnoticed. Physical devices on Android 13 and 14 enforce both the runtime RECORD_AUDIO grant and the WebView-level grant. Test on hardware before shipping.
Can two browser tabs use the microphone at the same time?
In Chromium, yes — multiple tabs can hold the same input device concurrently. Firefox enforces a concurrent-mic process limit and rejects the second request with NotReadableError. On Windows, exclusive mode overrides all of this: if any application takes exclusive control, no browser tab gets the device.
Does restarting the browser actually fix it? Often, because a crashed renderer can leave a capture stream open that only process termination releases. Quit the browser completely rather than closing the window — check the tray or menu bar. If the error returns within a day, something is genuinely competing for the device and a restart is only masking it.
Can HTTPS cause NotReadableError?
No. An insecure context leaves navigator.mediaDevices undefined and throws TypeError, which is a different failure with a different fix. getUserMedia() requires HTTPS, localhost, or a file:// URL, and if you reached a NotReadableError at all, your context was already secure.
Every fix above is routine. They arrive one customer at a time: someone on Windows with OBS holding the mic, someone on a Mac that never granted the browser TCC access, an Android WebView, a saved deviceId pointing at a headset unplugged in March. Hardware and permission edge cases are the maintenance tail of every hand-rolled dialer, and absorbing that tail is the job of a managed dialer layer. Alloqui is ours: paste your Twilio keys, drop in <Dialer />, and let the layer underneath be someone else's problem.