Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,12 @@ jobs:
target: x86_64-unknown-linux-gnu
artifact_prefix: linux-x86_64 # most Linux distros
binary_postfix: ""
audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
audio_features: "audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
- os: ubuntu-24.04-arm # free native ARM64 runner for public repos
target: aarch64-unknown-linux-gnu
artifact_prefix: linux-aarch64 # Raspberry Pi 4/5, ARM servers, Asahi Linux
binary_postfix: ""
audio_features: "audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
audio_features: "audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
- os: macos-15-intel # Intel runner for x86_64 (available until Aug 2027)
target: x86_64-apple-darwin
artifact_prefix: macos-x86_64
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ jobs:
# whenever a feature is added there, or the release build becomes the
# first place a break shows up.
- name: all-sources
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
# `mcp-server` and `ai-dj` are two front doors on the same `dj-core`
# implementation, and each has to build without the other: the
# field-level `#[cfg(feature = "ai-dj")]` on `DjState::setup` exists
Expand Down Expand Up @@ -111,7 +111,7 @@ jobs:
- name: default
args: "--locked"
- name: all-sources
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
# See the check job for why the two single-door DJ legs exist.
- name: mcp-only
args: "--locked --no-default-features --features telemetry,tui,mcp-server"
Expand Down Expand Up @@ -180,7 +180,7 @@ jobs:
- uses: Swatinem/rust-cache@v2
- uses: taiki-e/install-action@cargo-llvm-cov
- name: Run tests with coverage
run: cargo llvm-cov --locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz --lcov --output-path lcov.info
run: cargo llvm-cov --locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz --lcov --output-path lcov.info
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v7
with:
Expand Down Expand Up @@ -217,7 +217,7 @@ jobs:
- name: default
args: "--locked"
- name: all-sources
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
args: "--locked --no-default-features --features telemetry,tui,streaming,discord-rpc,cover-art,self-update,scripting,mcp-server,ai-dj,audio-viz,audio-viz-cpal,mpris,local-files,subsonic,internet-radio,youtube,qobuz,soloist"
# See the check job for why the two single-door DJ legs exist.
- name: mcp-only
args: "--locked --no-default-features --features telemetry,tui,mcp-server"
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ across a **nine-leg** feature matrix, plus one `macos-latest` job (below):
| Leg | Features |
|-----|----------|
| `default` | a plain `cargo test`: streaming + audio-viz-cpal + scripting + self-update + OS integrations |
| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz, all five sources, soloist) |
| `all-sources` | the **Linux** release feature set from `cd.yml` (adds cover-art, mcp-server, ai-dj, audio-viz with the audio-viz-cpal fallback, all five sources, soloist) |
| `mcp-only` | `telemetry,tui,mcp-server` |
| `ai-dj-only` | `telemetry,tui,ai-dj` |
| `slim` | `telemetry,tui` |
Expand Down Expand Up @@ -390,7 +390,10 @@ never config:
never enabled by hand); `cover-art` layers the ratatui-image terminal
rendering on top and keeps its pre-split meaning for users and CI.
- `audio-viz` (PipeWire, Linux-only) and `audio-viz-cpal` are **visualizer
capture** backends, not playback. The librespot *playback* backend is chosen by
capture** backends, not playback. With both on Linux (the release rows),
`infra/audio/fallback_capture.rs` opens PipeWire and falls back to a cpal
monitor or loopback device exposed through ALSA (never the default input,
which can be a microphone) when no PipeWire server answers. The librespot *playback* backend is chosen by
`[target.'cfg(...)']` dependency blocks in Cargo.toml, not by the `*-backend`
features: linux-gnu→alsa, linux-musl→rodio, Windows→rodio, macOS→portaudio -
the non-default picks avoid librespot's `pipe` sink writing raw audio to stdout
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

### Fixed

- **The Linux visualizer works without PipeWire**: the Linux release captured audio for the visualizer only through PipeWire, so on plain PulseAudio or ALSA systems, and in a proot Linux on Android, it had no audio. When no PipeWire server answers, it now falls back to an ALSA capture device named as a monitor or loopback. On PulseAudio without PipeWire, define the sink monitor as an ALSA PCM (`type pulse` with `device "<sink>.monitor"` and a description that contains "monitor") so the fallback finds it. The PipeWire library (`libpipewire-0.3.so.0`) must still be installed for spotatui to start, and the fallback never records from the default input, which can be a microphone.
- **Soloist download errors include their underlying cause**: failed downloads now include the error chain in the warning, making connection resets and timeouts easier to tell apart ([#950](https://github.com/LargeModGames/spotatui/issues/950)).
- **Smoother scrolling in terminal Settings**: Fixed an issue where navigating the Settings list caused the screen to jump erratically. The view now stays stable, scrolling only when your cursor reaches the top or bottom of the box.
- **The AI DJ prompt's caret follows the cursor**: after Left, Home or a Backspace in the middle of the text, the caret stayed painted at the end of the prompt. It now sits where the next character goes ([#945](https://github.com/LargeModGames/spotatui/issues/945)).
Expand Down
25 changes: 19 additions & 6 deletions src/infra/audio/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@ impl AudioCaptureManager {
/// Create a new audio capture manager sized for `display_bars`
/// Returns None if no suitable audio device is found
pub fn new(display_bars: usize) -> Option<Self> {
Self::open(display_bars, true)
}

/// Like `new`, but on Linux it never falls back to the default input, which
/// can be a microphone.
pub fn new_monitor_only(display_bars: usize) -> Option<Self> {
Self::open(display_bars, false)
}

fn open(display_bars: usize, allow_default_input: bool) -> Option<Self> {
let host = cpal::default_host();

// Try to find a loopback/monitor device
let device = Self::find_loopback_device(&host)?;
let device = Self::find_loopback_device(&host, allow_default_input)?;

// Device name is now stored internally for debugging if needed
let _device_name = device
Expand Down Expand Up @@ -76,7 +86,8 @@ impl AudioCaptureManager {

/// Find a suitable loopback/monitor device
/// Prefers: default output monitor > bluetooth > speakers > HDMI
fn find_loopback_device(host: &cpal::Host) -> Option<Device> {
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))]
fn find_loopback_device(host: &cpal::Host, allow_default_input: bool) -> Option<Device> {
#[cfg(target_os = "windows")]
{
// On Windows, WASAPI supports loopback on output devices
Expand All @@ -85,7 +96,7 @@ impl AudioCaptureManager {

#[cfg(target_os = "linux")]
{
// On Linux with PipeWire/PulseAudio, look for a monitor device
// On Linux with PipeWire/PulseAudio, look for a monitor or ALSA loopback device
// Priority: bluetooth first, then speakers, then anything else
if let Ok(devices) = host.input_devices() {
let mut monitors: Vec<Device> = Vec::new();
Expand All @@ -95,7 +106,7 @@ impl AudioCaptureManager {
for device in devices {
if let Ok(description) = device.description() {
let name_lower = description.name().to_lowercase();
if name_lower.contains("monitor") {
if name_lower.contains("monitor") || name_lower.contains("loopback") {
monitors.push(device);
}
}
Expand Down Expand Up @@ -129,8 +140,10 @@ impl AudioCaptureManager {
// Fallback: try default input device - on PipeWire systems this might
// route correctly, but may be the microphone on pure ALSA systems.
// User can disable audio-viz feature if this causes issues.
if let Some(device) = host.default_input_device() {
return Some(device);
if allow_default_input {
if let Some(device) = host.default_input_device() {
return Some(device);
}
}

None
Expand Down
54 changes: 54 additions & 0 deletions src/infra/audio/fallback_capture.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Linux with both capture backends: PipeWire's sink monitor first, and an ALSA
// monitor or loopback device through cpal when no PipeWire server answers. On
// PulseAudio without PipeWire that means a monitor defined as an ALSA PCM, as
// in a proot container on Android. The fallback never opens the default input,
// which can be a microphone.

use super::analyzer::{RecentAudio, SpectrumData};
use super::capture::AudioCaptureManager as CpalCapture;
use super::pipewire_capture::PipeWireCapture;

pub enum AudioCaptureManager {
PipeWire(PipeWireCapture),
Cpal(CpalCapture),
}

impl AudioCaptureManager {
/// Opens PipeWire capture, or an ALSA monitor or loopback device when PipeWire fails to start.
pub fn new(display_bars: usize) -> Option<Self> {
PipeWireCapture::new(display_bars)
.map(Self::PipeWire)
.or_else(|| {
log::info!("[audio-viz] trying an ALSA monitor or loopback device instead of PipeWire");
let capture = CpalCapture::new_monitor_only(display_bars);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if capture.is_none() {
log::warn!(
"[audio-viz] no ALSA monitor or loopback device; the default input is not used"
);
}
capture.map(Self::Cpal)
})
}

pub fn get_spectrum(&self, desired_bars: usize) -> Option<SpectrumData> {
match self {
Self::PipeWire(capture) => capture.get_spectrum(desired_bars),
Self::Cpal(capture) => capture.get_spectrum(desired_bars),
}
}

#[cfg_attr(not(feature = "gui"), allow(dead_code))]
pub fn recent_audio(&self) -> Option<RecentAudio> {
match self {
Self::PipeWire(capture) => capture.recent_audio(),
Self::Cpal(capture) => capture.recent_audio(),
}
}

pub fn is_active(&self) -> bool {
match self {
Self::PipeWire(capture) => capture.is_active(),
Self::Cpal(capture) => capture.is_active(),
}
}
}
16 changes: 13 additions & 3 deletions src/infra/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,22 @@ mod pipewire_capture;
#[allow(dead_code)]
mod capture;

// On Linux with both backends, PipeWire falls back to cpal when no PipeWire
// server answers.
#[cfg(all(feature = "audio-viz", feature = "audio-viz-cpal", target_os = "linux"))]
mod fallback_capture;

// Re-export the appropriate capture manager based on platform
#[cfg(all(feature = "audio-viz", target_os = "linux"))]
#[cfg(all(feature = "audio-viz", feature = "audio-viz-cpal", target_os = "linux"))]
pub use fallback_capture::AudioCaptureManager;

#[cfg(all(
feature = "audio-viz",
not(feature = "audio-viz-cpal"),
target_os = "linux"
))]
pub use pipewire_capture::PipeWireCapture as AudioCaptureManager;

// On Linux with audio-viz, use cpal only if audio-viz is not enabled
// This prevents conflict when --all-features is used
#[cfg(all(
feature = "audio-viz-cpal",
not(all(feature = "audio-viz", target_os = "linux"))
Expand Down
Loading