diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -7,6 +7,20 @@ work apply to everyone, people and AI agents alike, and they are not negotiable.
 
 You need [Rust](https://rustup.rs/) 1.95 or newer.
 
+On Linux the desktop app plays preview sound through `cpal`, so the build also needs the ALSA
+development headers. Without them `alsa-sys` fails at link time with an unrelated-looking error;
+`cargo xtask ci` checks for them up front and names the package:
+
+```sh
+sudo dnf install alsa-lib-devel        # Fedora
+sudo apt install libasound2-dev        # Debian / Ubuntu
+sudo pacman -S alsa-lib                # Arch
+```
+
+If you cannot install the package, point `PKG_CONFIG_PATH` at a directory holding an `alsa.pc`
+whose `Libs` line names your system's `libasound` (usually `libasound.so.2`); every CI run needs
+that variable set.
+
 ```sh
 cargo run -p effectcraft -- --demo       # the desktop app, with the demo project
 cargo test --workspace
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -407,6 +407,49 @@ fn serve_dir(dir: &std::path::Path, port: u16) -> Result<(), String> {
     Ok(())
 }
 
+/// The guidance printed when the ALSA development package is missing. Split out so the test can
+/// check the package names without needing a machine that is actually missing them.
+fn audio_backend_hint(found: bool) -> Result<(), String> {
+    if found {
+        return Ok(());
+    }
+    Err("Linux audio needs the ALSA development package, and pkg-config cannot find it.\n\
+         The desktop app plays preview sound through cpal, whose alsa-sys dependency asks\n\
+         pkg-config for `alsa`; without the package the build dies minutes later with an\n\
+         unrelated-looking linker error.\n\
+         \n\
+         Fedora:         sudo dnf install alsa-lib-devel\n\
+         Debian/Ubuntu:  sudo apt install libasound2-dev\n\
+         Arch:           sudo pacman -S alsa-lib\n\
+         \n\
+         Install one of those and run `cargo xtask ci` again. If you cannot install it, point\n\
+         PKG_CONFIG_PATH at a directory holding an `alsa.pc` whose Libs line names your system's\n\
+         libasound (usually `libasound.so.2`); every CI run will need that variable set."
+        .into())
+}
+
+/// `cargo xtask ci` builds the desktop app on the host, so on Linux it needs ALSA headers to be
+/// present. Checking here turns a late, confusing link error into one line naming the package.
+/// The probe reads `PKG_CONFIG` first, like the pkg-config crate alsa-sys goes through: the
+/// variable may name a working pkg-config that is not on `PATH`, and the build would use it too.
+/// A probe that could not launch is not "ALSA missing" — it reports the launch failure separately.
+#[cfg(target_os = "linux")]
+fn audio_backend() -> Result<(), String> {
+    let program = std::env::var_os("PKG_CONFIG").unwrap_or_else(|| "pkg-config".into());
+    match Command::new(&program).args(["--exists", "alsa"]).status() {
+        Ok(status) => audio_backend_hint(status.success()),
+        Err(e) => Err(format!(
+            "The {} executable could not be run: {e}. Install pkg-config, or point PKG_CONFIG at a working one — it is the same executable alsa-sys's pkg-config dependency asks.",
+            std::path::Path::new(&program).display()
+        )),
+    }
+}
+
+#[cfg(not(target_os = "linux"))]
+fn audio_backend() -> Result<(), String> {
+    Ok(())
+}
+
 /// Lib, bin and integration tests (examples aren't linked; clippy checked them), then doctests,
 /// which nextest doesn't run.
 ///
@@ -438,7 +481,8 @@ type Stage = fn() -> Result<(), String>;
 
 /// Every stage runs even when an earlier one fails, so one run reports all the problems.
 fn ci() -> Result<(), String> {
-    let stages: [(&str, Stage); 7] = [
+    let stages: [(&str, Stage); 8] = [
+        ("audio backend", audio_backend),
         ("fmt", || run(Command::new(env!("CARGO")).args(["fmt", "--check"]))),
         ("clippy", || run(Command::new(env!("CARGO")).args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]))),
         ("tests", tests),
@@ -484,3 +528,21 @@ fn main() -> ExitCode {
 pub fn root() -> std::path::PathBuf {
     std::path::Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
 }
+
+#[cfg(test)]
+mod tests {
+    use super::audio_backend_hint;
+
+    #[test]
+    fn alsa_present_is_not_an_error() {
+        assert!(audio_backend_hint(true).is_ok());
+    }
+
+    #[test]
+    fn alsa_missing_names_the_package_for_each_distro() {
+        let e = audio_backend_hint(false).expect_err("missing ALSA must fail CI");
+        for want in ["alsa-lib-devel", "libasound2-dev", "alsa-lib", "pkg-config", "PKG_CONFIG_PATH"] {
+            assert!(e.contains(want), "hint lost `{want}`:\n{e}");
+        }
+    }
+}
