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

  1. Check OS microphone permission for the app (macOS: System Settings > Privacy & Security > Microphone; Windows: Settings > Privacy > Microphone) and restart the app after granting.
  2. Close other applications that may hold the microphone exclusively (Zoom, Discord, OBS) and retry recording.
  3. Open settings and re-select the microphone (or set it to System Default) so a fresh device handle is resolved, then retry.
  4. Replug or re-pair the audio device and verify it appears in the device list before recording.
  5. 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

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


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 the

View on GitHub (pinned to c6fa60da2f)