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

ffmpeg transcode to opus failed: {stderr}

Error message

ffmpeg transcode to opus failed: {stderr}

What it means

TtsManager::synthesize_opus calls transcode_to_opus whenever the resolved provider's output_format is not already opus. The function pipes the provider's audio into `ffmpeg -i pipe:0 -f ogg -acodec libopus -b:a 32k -vbr on pipe:1`; if ffmpeg exits non-zero, the drained stderr is bailed with this message. A separate earlier error covers ffmpeg failing to spawn, and a follow-up ensure! covers empty-but-successful output.

Source

Thrown at crates/zeroclaw-channels/src/tts.rs:976

            "-vbr",
            "on",
            "pipe:1",
        ])
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .kill_on_drop(true)
        .spawn()
        .context(
            "failed to spawn ffmpeg — ensure ffmpeg with libopus support is installed \
             (e.g. `sudo dnf install ffmpeg` / `sudo apt install ffmpeg`)",
        )?;

    let output = write_audio_and_wait_with_output(child, audio, FFMPEG_TRANSCODE_TIMEOUT).await?;

    if !output.status.success() {
        let stderr = String::from_utf8_lossy(&output.stderr);
        bail!("ffmpeg transcode to opus failed: {stderr}");
    }

    anyhow::ensure!(
        !output.stdout.is_empty(),
        "ffmpeg produced empty output — check that libopus is available"
    );

    Ok(output.stdout)
}

pub struct TtsManager {
    tts_providers: HashMap<String, Box<dyn TtsProvider>>,
    voice_by_alias: HashMap<String, String>,
    /// Resolved alias for the agent that owns this manager. Empty when
    /// the agent has no TTS preference (opt-out).
    agent_tts_provider: String,
    default_voice: String,
    max_text_length: usize,

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Read the stderr in the message; "Unknown encoder 'libopus'" is the classic case.
  2. Install an ffmpeg build with libopus (`sudo apt install ffmpeg` on Debian/Ubuntu) and verify with `ffmpeg -encoders | grep libopus`.
  3. Prefer provider-native opus: set [providers.tts.openai.<alias>].response_format = "opus" so synthesize_opus returns the bytes without ffmpeg.
  4. If stderr shows a decode error, the input audio from the provider is corrupt — re-check the provider response, not ffmpeg.

Example fix

# before — ffmpeg present but built without libopus
# stderr: Unknown encoder 'libopus'

# after — install a full build and verify the encoder exists
sudo apt install ffmpeg
ffmpeg -hide_banner -encoders | grep libopus   # must list libopus

# alternative — skip the transcode entirely
[providers.tts.openai.main]
response_format = "opus"
Defensive patterns

Strategy: fallback

Validate before calling

// Startup check: ffmpeg must exist AND expose the libopus encoder.
fn ffmpeg_has_libopus() -> bool {
    std::process::Command::new("ffmpeg")
        .args(["-hide_banner", "-encoders"])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).contains("libopus"))
        .unwrap_or(false)
}

assert!(ffmpeg_has_libopus(), "transcode_to_opus requires ffmpeg built with libopus");

Try / catch

// Prefer native opus; if the transcode fails, degrade gracefully to the
// provider's native format instead of dropping the voice note.
match mgr.synthesize_opus(text).await {
    Ok(opus) => send_voice_note(&opus).await,
    Err(err) if err.to_string().contains("ffmpeg transcode to opus failed") => {
        let native = mgr.synthesize(text).await?; // e.g. mp3/wav
        send_audio_file(&native).await
    }
    Err(err) => Err(err),
}

Prevention

When it happens

Trigger: ffmpeg is installed without libopus, so encoding fails with "Unknown encoder 'libopus'" on stderr; the provider bytes are not valid audio ffmpeg can decode (corrupt upstream response); an exotic/old ffmpeg build rejecting the ogg muxer flags.

Common situations: Minimal distro containers (Alpine, slim images) ship ffmpeg without libopus. Debian/Ubuntu main builds include it, self-compiled builds frequently omit --enable-libopus. The cheapest escape is to make the provider emit opus natively, which skips transcode entirely.

Related errors


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