cjpais/Handy · error · anyhow::Error
Failed to open recorder
Error message
Failed to open recorder: {} What it means
Thrown when the cpal-backed Recorder::open() fails on the resolved input device, even after one re-resolution retry. The underlying cpal build_input_stream error occurs when the device cannot be opened with the requested config: it is unplugged, exclusively held by another app, lacks OS microphone permission, or rejects the stream config. The code already invalidates the device cache, re-enumerates, and retries once before surfacing this error, so seeing it means both attempts failed.
Solutions
- Check OS microphone permission for the app (macOS: System Settings > Privacy & Security > Microphone; Windows: Settings > Privacy > Microphone) and restart the app after granting.
- Close other applications that may hold the microphone exclusively (Zoom, Discord, OBS) and retry recording.
- Open settings and re-select the microphone (or set it to System Default) so a fresh device handle is resolved, then retry.
- Replug or re-pair the audio device and verify it appears in the device list before recording.
- If a specific device consistently fails, try another input device to isolate a hardware/driver problem.
Example fix
// before
let mut rec = Recorder::new();
rec.open(device)?; // hard fail on stale handle
// after — re-resolve from a fresh enumeration and retry once (mirrors audio.rs:699)
if let Err(first_err) = rec.open(device.clone()) {
warn!("Recorder open failed ({first_err}); re-resolving device");
devices.invalidate_cache();
let fresh = devices.resolve_microphone_device(&settings);
rec.open(fresh.device)?;
} Defensive patterns
Strategy: retry
Validate before calling
// Before recording: confirm the configured device still exists
let devices = manager.enumerate_input_devices()?;
let name = settings.selected_microphone.clone();
if let Some(wanted) = &name {
if !devices.iter().any(|d| &d.name == wanted) {
warn!("configured mic '{wanted}' not present; falling back to default");
}
} Try / catch
match start_recording(&manager, &settings) {
Ok(_) => {},
Err(e) if e.to_string().contains("Failed to open recorder") => {
// code already retried once with a fresh enumeration;
// surface to the user to re-pick the device / check OS permission
emit_event("recording-error", e.to_string());
}
Err(e) => return Err(e),
} Prevention
- Re-resolve the device from a fresh enumeration right before opening instead of caching Device handles across recordings.
- Request microphone permission up front (macOS TCC) during onboarding rather than at first record.
- Handle device-change notifications (plug/unplug) by invalidating the cached device.
- Avoid exclusive-mode audio apps while recording, and prefer the system default device when reliability matters more than a specific mic.
When it happens
Trigger: Calling managers/audio.rs start-recording flow when: the selected microphone was unplugged/changed (stale cached Device handle); another application holds the device exclusively; macOS TCC microphone permission was denied; the device does not support the sample rate/format requested by Recorder::open (audio_toolkit/audio/recorder.rs:154).
Common situations: Hot-unplugging a USB headset between settings selection and recording; Bluetooth headset re-pairing changing the device index; a VoIP app holding exclusive mode on Windows; fresh macOS install where Terminal/the app has not been granted mic access; USB audio interfaces that power-sleep and refuse the first open.
Related errors
- {error_message}
- Failed to create AudioRecorder
- No input device found
- Failed to check Windows microphone permissions:
- failed to create cancel item
AI-assisted analysis of cjpais/Handy@c6fa60da2f (2026-08-17).
Data as JSON: /api/errors/7e33c332915c0221.
Report an issue: GitHub.
Appendix: source
Thrown at src-tauri/src/managers/audio.rs:720
let resolve_elapsed = resolve_started.elapsed();
// Ensure VAD is loaded if it wasn't for whatever reason
let vad_started = Instant::now();
self.preload_vad()?;
let vad_elapsed = vad_started.elapsed();
let open_started = Instant::now();
let mut recorder_opt = self.recorder.lock().unwrap();
if let Some(rec) = recorder_opt.as_mut() {
if let Err(first_err) = rec.open(resolution.device.clone()) {
// A cached device or config may have gone stale (unplugged,
// rate/format changed). Re-resolve from a fresh enumeration and
// retry once before surfacing the error.
warn!("Recorder open failed ({first_err}); re-resolving device and retrying once");
self.invalidate_device_cache();
resolution = self.resolve_microphone_device(&settings);
rec.open(resolution.device.clone())
.map_err(|e| anyhow::anyhow!("Failed to open recorder: {}", e))?;
}
}
debug!(
"mic stream breakdown: device_resolve={:?} vad_ensure={:?} open={:?}",
resolve_elapsed,
vad_elapsed,
open_started.elapsed()
);
drop(recorder_opt);
*open_flag = true;
if let Some(unavailable_name) = resolution.unavailable_selected_microphone {
// Do this only after the default stream opened successfully. A
// failed fallback must not erase the user's microphone preference.
self.persist_default_microphone_after_fallback(&unavailable_name);
}
// This timing covers through cpal's stream.play() returning — i.e. the
// point cpal surfaces as "stream running." It does NOT guarantee theView on GitHub (pinned to c6fa60da2f)