zeroclaw-labs/zeroclaw · error · anyhow::Error

VoiceWake: audio stream ended unexpectedly

Error message

VoiceWake: audio stream ended unexpectedly

What it means

VoiceWakeChannel::listen runs a state machine over chunks received from an mpsc channel fed by the cpal input-stream callback; the loop only exits when audio_rx.recv() yields None, i.e. every sender was dropped. Since the stream is intentionally leaked, this bail means the capture callback stopped sending — the microphone stream terminated underneath the channel (device removed, host error, or the stream was dropped by the OS/driver). It is a liveness error: the wake-word listener is dead until listen() is restarted.

Source

Thrown at crates/zeroclaw-channels/src/voice_wake.rs:477

                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
                                    "VoiceWake: transcription error for utterance"
                                );
                            }
                        }

                        state = WakeState::Listening;
                        capture_buf.clear();
                    }
                }
                WakeState::Processing => {
                    // Should not receive chunks while processing, but just buffer them.
                    // State transitions happen above synchronously after transcription.
                }
            }
        }

        bail!("VoiceWake: audio stream ended unexpectedly");
    }
}

// ── Audio utilities ────────────────────────────────────────────

/// Compute RMS (root-mean-square) energy of an audio chunk.
pub fn compute_rms_energy(samples: &[f32]) -> f32 {
    if samples.is_empty() {
        return 0.0;
    }
    let sum_sq: f32 = samples.iter().map(|s| s * s).sum();
    (sum_sq / samples.len() as f32).sqrt()
}

/// Encode raw f32 PCM samples as a WAV byte buffer (16-bit PCM).
/// This produces a minimal valid WAV file that Whisper-compatible APIs accept.
pub fn encode_wav_from_f32(samples: &[f32], sample_rate: u32, channels: u16) -> Vec<u8> {
    let bits_per_sample: u16 = 16;

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Check physical/default device state: is the mic still connected and still the system default input?
  2. Restart the daemon (or re-invoke listen) — startup re-acquires default_input_device and rebuilds the stream.
  3. Use a stable, always-present input device (built-in mic or virtual device) instead of hot-pluggable hardware for an always-on bot.
  4. Inspect preceding WARN 'VoiceWake: audio stream error' log entries — they carry the cpal error that preceded the stream death.

Example fix

// before — one-shot listen dies with the device
voice_wake.listen(tx).await?; // someday: "audio stream ended unexpectedly"

// after — supervise and rebuild the stream on failure
loop {
    if let Err(e) = voice_wake.listen(tx.clone()).await {
        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
            .with_attrs(::serde_json::json!({"error": format!("{e}")})),
            "voice_wake listener stopped; restarting in 5s");
    }
    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
}
Defensive patterns

Strategy: retry

Validate before calling

use cpal::traits::{DeviceTrait, HostTrait};
// Verify an input device still exists before (re)starting the listener.
let device_available = cpal::default_host().default_input_device()
    .and_then(|d| d.default_input_config().ok().is_some());
anyhow::ensure!(device_available, "no usable default input device; skipping voice_wake restart");

Try / catch

match voice_wake.listen(tx.clone()).await {
    Ok(()) => {}
    Err(e) if e.to_string().contains("audio stream ended unexpectedly") => {
        // Device-level failure: wait, re-probe the default input device, then
        // call listen() again. Back off between restarts to avoid hot-looping
        // on a permanently missing device.
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: listen() is running and (1) a USB microphone or Bluetooth headset is unplugged/disconnected mid-session; (2) the OS suspends or reclaims the audio device (system sleep/resume, another app takes exclusive control); (3) an ALSA/JACK error kills the stream and the callback's sender is dropped; (4) the daemon's audio thread panics. The error appears asynchronously, long after startup.

Common situations: Long-running bot on a laptop: closing the lid or unplugging the USB mic kills the listener; Bluetooth headset battery dying; PulseAudio/PipeWire restarting; headless box losing the default input after an OS update.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/2c9ff09cee67a584. Report an issue: GitHub.