feat: separate monitoring and output streams (#178)

* feat: split playback into separate monitoring and mic streams

AudioPlayer held a single rodio stream. WirePlumber auto-connected its node to
the default sink (that is the monitoring path) while PWSP explicitly linked the
very same node into pwsp-virtual-mic. Both paths were fed by one Player, so
Player::set_volume scaled them together and they could not be separated — hence
the report of "volume 2 is fine on the other side but deafening locally".

There are now two independent streams with independent gains:

  mic stream        --ensure_route()--> pwsp-virtual-mic
  monitoring stream --ensure_route()--> selected Audio/Sink (or WirePlumber's
                                        choice when no device is pinned)

PipeWire side:
- DeviceType::Sink, so sinks are discoverable like inputs already were, plus the
  playback_* port mapping that Audio/Sink nodes need (monitor_* stays unmapped:
  linking a stream there would be a feedback loop).
- Link globals are tracked in the registry listener; GetLinks/DestroyGlobal make
  ensure_route() possible. It points a node at one target and prunes every other
  link leaving it, creating before pruning so the node is never left unlinked —
  otherwise WirePlumber's autoconnect would re-attach it behind our back.
  Replaces link_player_to_virtual_mic().

Audio side:
- PlayerPair wraps the two rodio Players of a track so transport controls reach
  both from one place instead of being fanned out at every call site.
- Streams are identified by diffing our own stream nodes before and after opening
  one, not by indexing a sorted list: PipeWire reuses freed node ids, so the
  stream opened second can end up with the lower id. Only nodes that look like
  ours are ever considered — pruning a stranger's node would silence another
  application. Opens are therefore sequential, and node discovery checks the
  graph before its first sleep to stay off the play latency path.
- Streams are dropped once nothing is playing, as before: an open stream keeps
  the audio device busy and stops laptops from suspending. The routing is
  rebuilt on the next play.
- Each track decodes twice, once per path. rodio's Buffered is the only shareable
  source and it cannot seek, which the position slider depends on.
- effective_gain() is the single home of the master * track * multiplier math and
  collapses non-finite or negative gains to silence.

Protocol, config and GUI:
- get/set_monitoring_volume, get/set_mic_volume, get/set_output, get_outputs.
- DaemonConfig gains default_output_name, default_monitoring_volume and
  default_mic_volume.
- Volumes arriving over IPC are validated: hotkeys store raw Request JSON, so a
  bad value can reach the daemon without passing through the CLI.
- The footer carries a monitoring slider and a mic slider, both running to 200%,
  plus an output combo box; with four widgets it no longer fits on one line and
  is laid out as two rows.
- SliderLatch owns the "local value wins while the user is interacting" logic
  every slider needs — the daemon is polled at 60 Hz, so without it a slider
  fights the user mid-drag and snaps back on release. That was previously
  copy-pasted per slider as a value/dragged/ignore-until triple.

Fixes along the way: setting the volume multiplier no longer overwrites the
master volume, and get_volume(Some(id)) reports the track's own volume instead of
the product of all three factors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(cli): add monitoring-volume, mic-volume and output commands

get/set volume keeps its old meaning — without --id it moves both masters at
once, which is the "make everything quieter" shortcut. The new commands address
one path each, and values above 1.0 pass through on purpose: amplifying what
goes into the microphone without deafening yourself is the point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): move device selection into settings

The footer tried to hold two combo boxes, two sliders and two buttons on one
line. Their labels alone run ~300px, so at the 800px minimum window size the
sliders and buttons were pushed off the edge and simply vanished.

Device pickers are set-once controls and belong next to the theme selector, so
they move to the settings screen. The footer keeps the two volume sliders, which
are the ones worth reaching for mid-call, and now fits at the minimum width.

The right-edge spacer is also clamped at zero: computed negative, it used to
shove the buttons out of view rather than merely crowding them.

Verified with screenshots at 800px and 1200px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): align the footer volume icons and sliders

The speaker icon and its slider sat a couple of pixels above the microphone
pair. A horizontal layout centres each widget against the row height known when
that widget is placed, so a row that grows while being filled leaves whatever
was added first sitting too high.

Every footer element is now allocated the same box height, which makes the
centring independent of placement order, and the row is given that height up
front. Measured from screenshots at 800px: icon ink centres were 776.5 and
780.5, now both 776.5; slider rails were 2.5px apart, now 0.5px, which is
sub-pixel rounding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(gui): let the output device be unpinned again

The output picker listed devices but no way back, so choosing one was a one-way
door. It now offers "system default" as the first entry, which sends set_output
with an empty name; the daemon takes that as "stop pinning".

Unpinning deliberately leaves the existing link alone rather than tearing it
down. WirePlumber's autoconnect only runs when a node first appears, so removing
the link would strand the monitoring stream with no output at all. Streams close
as soon as playback stops, so the next sound opens a fresh node that gets routed
like any other application's.

Picking the default target ourselves was tried and reverted: the global
default.audio.sink is only a fallback, and a per-stream target.node overrides it.
On this machine that meant monitoring would have gone straight to the hardware
sink, bypassing the user's EasyEffects chain that WirePlumber routes it through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: trim comments and drop an abstraction that earned nothing

Removes the RouteTarget trait and its two unit-struct implementations. They
existed to let route() resolve a target lazily, but both call sites resolve one
line earlier just as well, so a trait, two types and an impl block collapse into
one parameter.

Narrows should_sync, commit and prune_links_from to private: all three were
public but only ever called from within their own module.

Comment pass: drops the decorative section dividers that this file never had,
and rewrites the ones that leaned on a particular machine's setup or narrated a
past bug. What is left explains things the code cannot: why streams close when
idle, why they are opened one at a time, why a link is created before stale ones
are pruned, why playback_* maps to inputs while monitor_* stays unmapped, and
why the footer row height is pinned up front.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tarasov Aleksandr
2026-07-26 20:36:10 +03:00
committed by GitHub
co-authored by Claude Opus 5
parent 1616d80ee9
commit 785b835237
17 changed files with 1431 additions and 467 deletions
+504 -191
View File
@@ -1,21 +1,34 @@
use crate::{
types::pipewire::DeviceType,
types::pipewire::{AudioDevice, DeviceType},
utils::{
daemon::with_daemon_config,
pipewire::{PwTerminator, create_link, get_device, link_player_to_virtual_mic},
pipewire::{
PwTerminator, create_link, ensure_route, get_all_devices, get_device, get_device_by_id,
get_sink,
},
},
};
use anyhow::{Result, anyhow};
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
error::Error,
collections::{HashMap, HashSet},
fs,
io::BufReader,
path::{Path, PathBuf},
time::Duration,
};
const VIRTUAL_MIC_NAME: &str = "pwsp-virtual-mic";
/// Streams are re-opened on the first play after an idle period, so this poll sits in the
/// path between pressing a key and hearing the sound. The node normally shows up within a
/// poll or two; the attempt count only bounds the pathological case.
const NODE_DISCOVERY_ATTEMPTS: u32 = 200;
const NODE_DISCOVERY_INTERVAL: Duration = Duration::from_millis(5);
type FileDecoder = Decoder<BufReader<fs::File>>;
#[derive(Debug, Eq, PartialEq, Default, Clone, Serialize, Deserialize)]
pub enum PlayerState {
#[default]
@@ -39,55 +52,135 @@ pub struct TrackInfo {
pub struct FullState {
pub state: PlayerState,
pub tracks: Vec<TrackInfo>,
pub volume: f32,
pub monitoring_volume: f32,
pub mic_volume: f32,
pub volume_multiplier: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub current_output: String,
pub all_outputs: HashMap<String, String>,
}
/// Which of the two independent output paths a volume applies to.
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum VolumeTarget {
/// What the user hears locally.
Monitoring,
/// What is fed into the virtual microphone, i.e. what everybody else hears.
Mic,
}
/// The two rodio players backing a single track, one per output path.
///
/// Every playback control has to reach both, so they are wrapped rather than fanned out
/// by hand at each call site.
pub struct PlayerPair {
pub monitoring: Player,
pub mic: Option<Player>,
}
impl PlayerPair {
pub fn for_each(&self, f: impl Fn(&Player)) {
f(&self.monitoring);
if let Some(mic) = &self.mic {
f(mic);
}
}
/// The player that answers queries about position and paused state for the pair.
pub fn primary(&self) -> &Player {
&self.monitoring
}
/// True once every player of the pair has run out of samples.
pub fn empty(&self) -> bool {
self.monitoring.empty() && self.mic.as_ref().is_none_or(|mic| mic.empty())
}
}
pub struct PlayingSound {
pub id: u32,
pub sink: Player,
pub players: PlayerPair,
pub path: PathBuf,
pub duration: Option<f32>,
pub looped: bool,
pub volume: f32,
}
/// Final linear gain handed to a rodio `Player`.
///
/// Values above `1.0` are allowed on purpose; anything non-finite or negative collapses to
/// silence rather than reaching the mixer.
pub fn effective_gain(master: f32, track: f32, multiplier: f32) -> f32 {
let gain = master * track * multiplier;
if gain.is_finite() && gain > 0.0 {
gain
} else {
0.0
}
}
pub struct AudioPlayer {
stream_handle: Option<MixerDeviceSink>,
monitoring_stream: Option<MixerDeviceSink>,
mic_stream: Option<MixerDeviceSink>,
/// PipeWire nodes backing the two streams, once discovered.
monitoring_node: Option<AudioDevice>,
mic_node: Option<AudioDevice>,
/// Links we created ourselves; dropping them tears the routes down.
monitoring_route: Option<PwTerminator>,
mic_route: Option<PwTerminator>,
input_link_sender: Option<PwTerminator>,
pub tracks: HashMap<u32, PlayingSound>,
pub next_id: u32,
input_link_sender: Option<PwTerminator>,
player_link_sender: Option<PwTerminator>,
pub input_device_name: Option<String>,
/// `None` pins nothing, leaving the monitoring stream to be routed like any other
/// application's.
pub output_device_name: Option<String>,
pub volume: f32, // Master volume
pub monitoring_volume: f32,
pub mic_volume: f32,
pub volume_multiplier: f32,
}
impl AudioPlayer {
pub async fn new() -> Result<Self> {
let (default_input_name, default_volume, default_volume_multiplier) =
with_daemon_config(|c| {
(
c.default_input_name.clone(),
c.default_volume.unwrap_or(1.0),
c.default_volume_multiplier.unwrap_or(1.0),
)
});
let (
default_input_name,
default_output_name,
default_monitoring_volume,
default_mic_volume,
default_volume_multiplier,
) = with_daemon_config(|c| {
(
c.default_input_name.clone(),
c.default_output_name.clone(),
c.default_monitoring_volume.unwrap_or(1.0),
c.default_mic_volume.unwrap_or(1.0),
c.default_volume_multiplier.unwrap_or(1.0),
)
});
let mut audio_player = AudioPlayer {
stream_handle: None,
monitoring_stream: None,
mic_stream: None,
monitoring_node: None,
mic_node: None,
monitoring_route: None,
mic_route: None,
input_link_sender: None,
tracks: HashMap::new(),
next_id: 1,
input_link_sender: None,
player_link_sender: None,
input_device_name: default_input_name,
output_device_name: default_output_name,
volume: default_volume,
monitoring_volume: default_monitoring_volume,
mic_volume: default_mic_volume,
volume_multiplier: default_volume_multiplier,
};
@@ -98,22 +191,83 @@ impl AudioPlayer {
Ok(audio_player)
}
fn ensure_stream(&mut self) -> Result<&MixerDeviceSink> {
if self.stream_handle.is_none() {
let mut sink = DeviceSinkBuilder::open_default_sink()?;
sink.log_on_drop(false);
self.stream_handle = Some(sink);
/// Opens both output streams and routes them, if that has not happened yet.
///
/// Streams are opened one at a time: node discovery below tells them apart by diffing
/// the graph around each open, which only works if the opens do not overlap.
async fn ensure_streams(&mut self) -> Result<()> {
if self.monitoring_stream.is_none() {
let (stream, node) = open_stream_and_identify().await?;
self.monitoring_stream = Some(stream);
self.monitoring_node = node;
}
self.stream_handle
.as_ref()
.ok_or_else(|| anyhow!("Failed to initialize stream_handle"))
if self.mic_stream.is_none() {
let (stream, node) = open_stream_and_identify().await?;
self.mic_stream = Some(stream);
self.mic_node = node;
}
self.ensure_routes().await;
Ok(())
}
fn drop_stream(&mut self) {
if self.stream_handle.is_some() {
self.stream_handle = None;
self.abort_player_link_thread();
/// Closes both output streams once nothing is playing.
///
/// This is what lets a laptop suspend: an open stream keeps the audio device busy and
/// blocks sleep. Routing is rebuilt on the next `play()`, since re-opening the streams
/// mints new node ids.
fn drop_streams(&mut self) {
if self.monitoring_stream.is_none() && self.mic_stream.is_none() {
return;
}
self.monitoring_stream = None;
self.mic_stream = None;
// The nodes and our links die with the streams, so none of this may outlive them.
self.monitoring_node = None;
self.mic_node = None;
self.monitoring_route = None;
self.mic_route = None;
}
/// Re-asserts both routes. Idempotent, so it can run on every device-check tick.
async fn ensure_routes(&mut self) {
if let Some(node) = self.mic_node.clone() {
match self.route(&node, get_device(VIRTUAL_MIC_NAME).await).await {
Ok(Some(terminator)) => self.mic_route = Some(terminator),
Ok(None) => {}
Err(err) => eprintln!("Failed to route mic stream to virtual mic: {}", err),
}
}
// With nothing pinned the monitoring stream is left alone: picking a target
// ourselves would mean re-implementing the session manager's policy, where the
// default sink is only a fallback that a per-stream target overrides.
if let Some(name) = self.output_device_name.clone()
&& let Some(node) = self.monitoring_node.clone()
{
match self.route(&node, get_sink(&name).await).await {
Ok(Some(terminator)) => self.monitoring_route = Some(terminator),
Ok(None) => {}
Err(err) => eprintln!(
"Failed to route monitoring stream to output device {}: {}",
name, err
),
}
}
}
/// Re-reads the source node before linking, so a route survives the node's ports
/// being rediscovered.
async fn route(
&self,
source: &AudioDevice,
target: Result<AudioDevice>,
) -> Result<Option<PwTerminator>> {
let source = get_device_by_id(source.id).await?;
ensure_route(&source, &target?).await
}
fn abort_link_thread(&mut self) {
@@ -123,27 +277,6 @@ impl AudioPlayer {
}
}
fn abort_player_link_thread(&mut self) {
if self.player_link_sender.is_some() {
println!("Sent terminate signal to player link thread");
self.player_link_sender = None;
}
}
async fn link_player(&mut self) -> Result<()> {
if self.player_link_sender.is_some() {
return Ok(());
}
match link_player_to_virtual_mic().await {
Ok(sender) => {
self.player_link_sender = Some(sender);
Ok(())
}
Err(_) => Ok(()),
}
}
async fn link_devices(&mut self) -> Result<()> {
self.abort_link_thread();
@@ -164,7 +297,7 @@ impl AudioPlayer {
}
let daemon_input;
if let Ok(device) = get_device("pwsp-virtual-mic").await {
if let Ok(device) = get_device(VIRTUAL_MIC_NAME).await {
daemon_input = device;
} else {
eprintln!("Could not find pwsp-virtual-mic device, skipping device linking");
@@ -172,19 +305,19 @@ impl AudioPlayer {
}
let Some(output_fl) = input_device.output_fl.clone() else {
eprintln!("Failed to get pwsp-daemon output_fl");
eprintln!("Failed to get input device output_fl");
return Ok(());
};
let Some(output_fr) = input_device.output_fr.clone() else {
eprintln!("Failed to get pwsp-daemon output_fr");
eprintln!("Failed to get input device output_fr");
return Ok(());
};
let Some(input_fl) = daemon_input.input_fl.clone() else {
eprintln!("Failed to get pwsp-daemon input_fl");
eprintln!("Failed to get pwsp-virtual-mic input_fl");
return Ok(());
};
let Some(input_fr) = daemon_input.input_fr.clone() else {
eprintln!("Failed to get pwsp-daemon input_fr");
eprintln!("Failed to get pwsp-virtual-mic input_fr");
return Ok(());
};
@@ -194,25 +327,21 @@ impl AudioPlayer {
}
pub fn pause(&mut self, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.pause();
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.pause();
}
}
self.for_selected(id, |sound| sound.players.for_each(|p| p.pause()));
}
pub fn resume(&mut self, id: Option<u32>) {
self.for_selected(id, |sound| sound.players.for_each(|p| p.play()));
}
fn for_selected(&mut self, id: Option<u32>, f: impl Fn(&mut PlayingSound)) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.play();
f(sound);
}
} else {
for sound in self.tracks.values_mut() {
sound.sink.play();
f(sound);
}
}
}
@@ -224,7 +353,7 @@ impl AudioPlayer {
self.tracks.clear();
}
if self.tracks.is_empty() {
self.drop_stream();
self.drop_streams();
}
}
@@ -232,7 +361,9 @@ impl AudioPlayer {
if self.tracks.is_empty() {
return false;
}
self.tracks.values().all(|s| s.sink.is_paused())
self.tracks
.values()
.all(|s| s.players.primary().is_paused())
}
pub fn get_state(&self) -> PlayerState {
@@ -243,7 +374,7 @@ impl AudioPlayer {
if self
.tracks
.values()
.any(|s| !s.sink.is_paused() && !s.sink.empty())
.any(|s| !s.players.primary().is_paused() && !s.players.primary().empty())
{
return PlayerState::Playing;
}
@@ -255,59 +386,95 @@ impl AudioPlayer {
PlayerState::Stopped
}
pub fn get_volume(&mut self, id: Option<u32>) -> Option<f32> {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
Some(sound.sink.volume())
} else {
None
}
} else {
Some(self.volume)
pub fn get_volume(&self, id: Option<u32>) -> Option<f32> {
match id {
Some(id) => self.tracks.get(&id).map(|sound| sound.volume),
None => Some(self.monitoring_volume),
}
}
/// Pushes the current master/track/multiplier state onto every live player.
///
/// Single source of truth for the gain math — every setter below funnels through it.
fn reapply_volumes(&mut self) {
for sound in self.tracks.values() {
sound.players.monitoring.set_volume(effective_gain(
self.monitoring_volume,
sound.volume,
self.volume_multiplier,
));
if let Some(mic) = &sound.players.mic {
mic.set_volume(effective_gain(
self.mic_volume,
sound.volume,
self.volume_multiplier,
));
}
}
}
pub fn set_master_volume(&mut self, volume: f32, target: VolumeTarget) {
match target {
VolumeTarget::Monitoring => self.monitoring_volume = volume,
VolumeTarget::Mic => self.mic_volume = volume,
}
self.reapply_volumes();
}
pub fn get_master_volume(&self, target: VolumeTarget) -> f32 {
match target {
VolumeTarget::Monitoring => self.monitoring_volume,
VolumeTarget::Mic => self.mic_volume,
}
}
pub fn set_volume_multiplier(&mut self, multiplier: f32) {
self.volume_multiplier = multiplier;
self.reapply_volumes();
}
/// Sets a single track's volume, or — without an id — moves both masters at once,
/// which is the "make everything quieter" shortcut.
pub fn set_volume(&mut self, volume: f32, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.volume = volume;
sound
.sink
.set_volume(self.volume * sound.volume * self.volume_multiplier);
}
} else {
self.volume = volume;
for sound in self.tracks.values_mut() {
sound
.sink
.set_volume(self.volume * sound.volume * self.volume_multiplier);
}
self.monitoring_volume = volume;
self.mic_volume = volume;
}
self.reapply_volumes();
}
pub fn get_position(&self, id: Option<u32>) -> f32 {
if let Some(id) = id {
if let Some(sound) = self.tracks.get(&id) {
return sound.sink.get_pos().as_secs_f32();
return sound.players.primary().get_pos().as_secs_f32();
}
} else if let Some(sound) = self.tracks.values().last() {
// Fallback to last added track if no ID
return sound.sink.get_pos().as_secs_f32();
return sound.players.primary().get_pos().as_secs_f32();
}
0.0
}
pub fn seek(&mut self, position: f32, id: Option<u32>) -> Result<()> {
let position = if position < 0.0 { 0.0 } else { position };
let position = Duration::from_secs_f32(position);
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.sink.try_seek(Duration::from_secs_f32(position))?;
sound.players.monitoring.try_seek(position)?;
if let Some(mic) = &sound.players.mic {
mic.try_seek(position).ok();
}
}
} else {
// Seek all? Or last? Let's seek all for now if no ID provided
for sound in self.tracks.values_mut() {
sound.sink.try_seek(Duration::from_secs_f32(position)).ok();
sound.players.for_each(|p| {
p.try_seek(position).ok();
});
}
}
Ok(())
@@ -325,74 +492,65 @@ impl AudioPlayer {
}
pub async fn play(&mut self, file_path: &Path, concurrent: bool) -> Result<u32> {
let path_buf = file_path.to_path_buf();
// One decoder per output path: the two streams run on different device clocks,
// so they cannot share a source anyway, and rodio's `Buffered` (the only shareable
// source) does not support seeking.
let (monitoring_source, mic_source) = tokio::try_join!(
decode(file_path.to_path_buf()),
decode(file_path.to_path_buf()),
)?;
let decoder_result =
tokio::task::spawn_blocking(move || -> Result<_, Box<dyn Error + Send + Sync>> {
if !path_buf.exists() {
return Err(format!("File does not exist: {}", path_buf.display()).into());
}
let file = fs::File::open(&path_buf)?;
let decoder = Decoder::try_from(file)
.map_err(|e| Box::new(e) as Box<dyn Error + Send + Sync>)?;
Ok(decoder)
})
.await?;
match decoder_result {
Ok(source) => {
if !concurrent {
self.tracks.clear();
}
self.ensure_stream()?;
self.link_player().await.ok();
let id = self.next_id;
self.next_id += 1;
let duration = source.total_duration().map(|d| d.as_secs_f32());
let mixer = self
.stream_handle
.as_ref()
.ok_or_else(|| anyhow::anyhow!("stream_handle is unexpectedly missing"))?
.mixer();
let sink = Player::connect_new(mixer);
sink.set_volume(self.volume * self.volume_multiplier); // Default volume is 1.0 * master
sink.append(source);
sink.play();
let sound = PlayingSound {
id,
sink,
path: file_path.to_path_buf(),
duration,
looped: false,
volume: 1.0,
};
self.tracks.insert(id, sound);
Ok(id)
}
Err(err) => Err(anyhow!(err)),
if !concurrent {
self.tracks.clear();
}
self.ensure_streams().await?;
let duration = monitoring_source.total_duration().map(|d| d.as_secs_f32());
let monitoring_mixer = self
.monitoring_stream
.as_ref()
.ok_or_else(|| anyhow!("monitoring stream is unexpectedly missing"))?
.mixer();
let monitoring = Player::connect_new(monitoring_mixer);
monitoring.set_volume(effective_gain(
self.monitoring_volume,
1.0,
self.volume_multiplier,
));
monitoring.append(monitoring_source);
monitoring.play();
// A missing mic stream degrades to monitoring-only playback rather than failing.
let mic = self.mic_stream.as_ref().map(|stream| {
let player = Player::connect_new(stream.mixer());
player.set_volume(effective_gain(self.mic_volume, 1.0, self.volume_multiplier));
player.append(mic_source);
player.play();
player
});
let id = self.next_id;
self.next_id += 1;
self.tracks.insert(
id,
PlayingSound {
id,
players: PlayerPair { monitoring, mic },
path: file_path.to_path_buf(),
duration,
looped: false,
volume: 1.0,
},
);
Ok(id)
}
pub fn set_loop(&mut self, enabled: bool, id: Option<u32>) {
if let Some(id) = id {
if let Some(sound) = self.tracks.get_mut(&id) {
sound.looped = enabled;
}
} else {
// Set loop for all? Or just last?
// Let's set for all.
for sound in self.tracks.values_mut() {
sound.looped = enabled;
}
}
self.for_selected(id, |sound| sound.looped = enabled);
}
pub fn get_tracks(&self) -> Vec<TrackInfo> {
@@ -403,10 +561,10 @@ impl AudioPlayer {
id: sound.id,
path: sound.path.clone(),
duration: sound.duration,
position: sound.sink.get_pos().as_secs_f32(),
position: sound.players.primary().get_pos().as_secs_f32(),
volume: sound.volume,
looped: sound.looped,
paused: sound.sink.is_paused(),
paused: sound.players.primary().is_paused(),
})
.collect();
tracks.sort_by_key(|t| t.id);
@@ -431,53 +589,57 @@ impl AudioPlayer {
}
}
if self.stream_handle.is_some() && self.player_link_sender.is_none() {
self.link_player().await.ok();
if self.monitoring_stream.is_some() || self.mic_stream.is_some() {
self.ensure_routes().await;
}
}
// Handle looped sounds
let mut restarts = vec![];
self.restart_looped_tracks().await;
for (id, sound) in &self.tracks {
if sound.sink.empty() && sound.looped {
restarts.push(*id);
}
self.tracks
.retain(|_, sound| !sound.players.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_streams();
}
}
async fn restart_looped_tracks(&mut self) {
let restarts: Vec<u32> = self
.tracks
.iter()
.filter(|(_, sound)| sound.looped && sound.players.empty())
.map(|(id, _)| *id)
.collect();
let mut restart_futures = vec![];
for id in restarts {
if let Some(sound) = self.tracks.get(&id) {
let path = sound.path.clone();
let handle = tokio::task::spawn_blocking(move || {
if let Ok(file) = fs::File::open(&path)
&& let Ok(source) = Decoder::try_from(file)
{
return Some((id, source));
}
None
restart_futures.push(async move {
tokio::try_join!(decode(path.clone()), decode(path))
.ok()
.map(|(monitoring, mic)| (id, monitoring, mic))
});
restart_futures.push(handle);
}
}
for handle in restart_futures {
if let Ok(res) = handle.await
&& let Some((id, source)) = res
for future in restart_futures {
if let Some((id, monitoring_source, mic_source)) = future.await
&& let Some(sound) = self.tracks.get_mut(&id)
{
sound.sink.append(source);
sound.sink.play();
if sound.players.monitoring.empty() {
sound.players.monitoring.append(monitoring_source);
sound.players.monitoring.play();
}
if let Some(mic) = &sound.players.mic
&& mic.empty()
{
mic.append(mic_source);
mic.play();
}
}
}
self.tracks
.retain(|_, sound| !sound.sink.empty() || sound.looped);
if self.tracks.is_empty() {
self.drop_stream();
}
}
pub async fn set_current_input_device(&mut self, name: &str) -> Result<()> {
@@ -493,4 +655,155 @@ impl AudioPlayer {
Ok(())
}
pub async fn set_current_output_device(&mut self, name: &str) -> Result<()> {
// Fails early with a useful message if the name does not name a sink.
get_sink(name).await?;
self.output_device_name = Some(name.to_string());
self.monitoring_route = None;
self.ensure_routes().await;
Ok(())
}
/// Stops pinning an output device.
///
/// The existing link is deliberately left in place: tearing it down would strand the
/// node with no output at all, since autoconnect only runs when a node first appears.
/// Streams close as soon as playback stops, so the next sound opens a fresh node that
/// gets routed normally.
pub fn clear_current_output_device(&mut self) {
self.output_device_name = None;
}
}
async fn decode(path: PathBuf) -> Result<FileDecoder> {
tokio::task::spawn_blocking(move || {
if !path.exists() {
return Err(anyhow!("File does not exist: {}", path.display()));
}
let file = fs::File::open(&path)?;
Decoder::try_from(file).map_err(|e| anyhow!(e))
})
.await?
}
/// True for the `Stream/Output/Audio` nodes that belong to us.
///
/// The guard matters: node discovery below hands the result to link pruning, and pruning
/// a stranger's node would silence another application.
fn is_own_stream_node(device: &AudioDevice) -> bool {
let is_pwsp = |s: &str| s.to_ascii_lowercase().contains("pwsp");
(is_pwsp(&device.name) || is_pwsp(&device.nick))
&& device.output_fl.is_some()
&& device.output_fr.is_some()
}
async fn own_stream_node_ids() -> HashSet<u32> {
match get_all_devices().await {
Ok(devices) => devices
.outputs
.iter()
.filter(|d| is_own_stream_node(d))
.map(|d| d.id)
.collect(),
Err(_) => HashSet::new(),
}
}
/// Opens one rodio output stream and works out which PipeWire node it produced.
///
/// Identification is a before/after diff of our own stream nodes rather than an index
/// into a sorted list, so it does not depend on enumeration order. Callers must open
/// streams one at a time for the diff to stay unambiguous.
///
/// A stream that cannot be matched to a node yields `None`: playback still works, only
/// explicit routing is skipped.
async fn open_stream_and_identify() -> Result<(MixerDeviceSink, Option<AudioDevice>)> {
let before = own_stream_node_ids().await;
let mut stream = DeviceSinkBuilder::open_default_sink()?;
stream.log_on_drop(false);
// Checked before the first sleep: the PCM is already open by the time open_stream
// returns, so the node is often registered and this costs nothing.
for attempt in 0..NODE_DISCOVERY_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(NODE_DISCOVERY_INTERVAL).await;
}
let devices = match get_all_devices().await {
Ok(devices) => devices,
Err(_) => continue,
};
if let Some(node) = devices
.outputs
.into_iter()
.find(|d| !before.contains(&d.id) && is_own_stream_node(d))
{
return Ok((stream, Some(node)));
}
}
eprintln!("Timed out waiting for the new PipeWire node, routing will be skipped");
Ok((stream, None))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_effective_gain() {
assert_eq!(effective_gain(1.0, 1.0, 1.0), 1.0);
assert_eq!(effective_gain(0.5, 0.5, 1.0), 0.25);
// Amplification beyond 1.0 is allowed on purpose.
assert_eq!(effective_gain(2.0, 1.0, 1.0), 2.0);
assert_eq!(effective_gain(2.0, 1.0, 3.0), 6.0);
// Anything that is not a usable gain collapses to silence.
assert_eq!(effective_gain(0.0, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(-1.0, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(f32::NAN, 1.0, 1.0), 0.0);
assert_eq!(effective_gain(f32::INFINITY, 1.0, 1.0), 0.0);
}
fn stream_node(name: &str, with_ports: bool) -> AudioDevice {
use crate::types::pipewire::Port;
let mut device = AudioDevice::new(1, None, None, Some(name), DeviceType::Output);
if with_ports {
device.add_port(Port {
node_id: 1,
port_id: 1,
name: "output_FL".to_string(),
});
device.add_port(Port {
node_id: 1,
port_id: 2,
name: "output_FR".to_string(),
});
}
device
}
#[test]
fn test_is_own_stream_node() {
assert!(is_own_stream_node(&stream_node(
"alsa_playback.pwsp-daemon",
true
)));
assert!(is_own_stream_node(&stream_node("PWSP-daemon", true)));
// Somebody else's playback stream must never be treated as ours.
assert!(!is_own_stream_node(&stream_node("Firefox", true)));
// Nor a node whose ports have not been discovered yet.
assert!(!is_own_stream_node(&stream_node(
"alsa_playback.pwsp-daemon",
false
)));
}
}
+139 -12
View File
@@ -1,6 +1,6 @@
use crate::{
types::{
audio_player::{FullState, PlayerState},
audio_player::{FullState, PlayerState, VolumeTarget},
config::{DaemonConfig, HotkeyConfig},
socket::{Request, Response},
},
@@ -57,6 +57,15 @@ pub struct SetVolumeMultiplierCommand {
pub volume_multiplier: Option<f32>,
}
pub struct GetMasterVolumeCommand {
pub target: VolumeTarget,
}
pub struct SetMasterVolumeCommand {
pub volume: Option<f32>,
pub target: VolumeTarget,
}
pub struct GetPositionCommand {
pub id: Option<u32>,
}
@@ -85,6 +94,14 @@ pub struct SetCurrentInputCommand {
pub name: Option<String>,
}
pub struct GetCurrentOutputCommand {}
pub struct GetAllOutputsCommand {}
pub struct SetCurrentOutputCommand {
pub name: Option<String>,
}
pub struct SetLoopCommand {
pub enabled: Option<bool>,
pub id: Option<u32>,
@@ -198,7 +215,7 @@ impl Executable for TogglePauseCommand {
if let Some(id) = self.id {
if let Some(track) = audio_player.tracks.get(&id) {
if track.sink.is_paused() {
if track.players.primary().is_paused() {
audio_player.resume(Some(id));
Response::new(true, "Audio was resumed")
} else {
@@ -262,7 +279,7 @@ impl Executable for GetStateCommand {
#[async_trait]
impl Executable for GetVolumeCommand {
async fn execute(&self) -> Response {
let mut audio_player = match get_audio_player().await {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
@@ -288,10 +305,16 @@ impl Executable for GetVolumeMultiplierCommand {
}
}
/// Rejects values that would corrupt the mixer. The daemon is the trust boundary here:
/// hotkeys store raw `Request` JSON, so a bad value can arrive without passing the CLI.
fn validate_volume(volume: Option<f32>) -> Option<f32> {
volume.filter(|v| v.is_finite() && *v >= 0.0)
}
#[async_trait]
impl Executable for SetVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = self.volume {
if let Some(volume) = validate_volume(self.volume) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
@@ -305,15 +328,49 @@ impl Executable for SetVolumeCommand {
}
#[async_trait]
impl Executable for SetVolumeMultiplierCommand {
impl Executable for GetMasterVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume_multiplier) = self.volume_multiplier {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
Response::new(
true,
audio_player.get_master_volume(self.target).to_string(),
)
}
}
#[async_trait]
impl Executable for SetMasterVolumeCommand {
async fn execute(&self) -> Response {
if let Some(volume) = validate_volume(self.volume) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.volume_multiplier = volume_multiplier;
audio_player.set_volume(volume_multiplier, None); // Reset current volume for all tracks to apply multiplier
audio_player.set_master_volume(volume, self.target);
let name = match self.target {
VolumeTarget::Monitoring => "Monitoring",
VolumeTarget::Mic => "Microphone",
};
Response::new(true, format!("{} volume was set to {}", name, volume))
} else {
Response::new(false, "Invalid volume value")
}
}
}
#[async_trait]
impl Executable for SetVolumeMultiplierCommand {
async fn execute(&self) -> Response {
if let Some(volume_multiplier) = validate_volume(self.volume_multiplier) {
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
audio_player.set_volume_multiplier(volume_multiplier);
Response::new(
true,
format!("Audio volume multiplier was set to {}", volume_multiplier),
@@ -429,8 +486,8 @@ impl Executable for GetCurrentInputCommand {
#[async_trait]
impl Executable for GetAllInputsCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await {
Ok(devices) => devices,
let input_devices = match get_all_devices().await {
Ok(devices) => devices.inputs,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let mut input_devices_strings = vec![];
@@ -466,6 +523,66 @@ impl Executable for SetCurrentInputCommand {
}
}
#[async_trait]
impl Executable for GetCurrentOutputCommand {
async fn execute(&self) -> Response {
let audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
match &audio_player.output_device_name {
Some(name) => Response::new(true, name),
// No pinned device means playback follows whatever the system default is.
None => Response::new(true, ""),
}
}
}
#[async_trait]
impl Executable for GetAllOutputsCommand {
async fn execute(&self) -> Response {
let sinks = match get_all_devices().await {
Ok(devices) => devices.sinks,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let response_message = sinks
.into_iter()
.map(|device| format!("{} - {}", device.name, device.nick))
.collect::<Vec<_>>()
.join("; ");
Response::new(true, response_message)
}
}
#[async_trait]
impl Executable for SetCurrentOutputCommand {
async fn execute(&self) -> Response {
let Some(name) = &self.name else {
return Response::new(false, "Invalid device name");
};
let mut audio_player = match get_audio_player().await {
Ok(player) => player.lock().await,
Err(err) => return Response::new(false, format!("Audio player error: {}", err)),
};
// An empty name is how a client asks to stop pinning a device and follow the
// system default again.
if name.is_empty() {
audio_player.clear_current_output_device();
return Response::new(true, "Output device follows the system default");
}
match audio_player.set_current_output_device(name).await {
Ok(_) => Response::new(true, "Output device was set"),
Err(err) => Response::new(false, err.to_string()),
}
}
}
#[async_trait]
impl Executable for SetLoopCommand {
async fn execute(&self) -> Response {
@@ -518,10 +635,11 @@ impl Executable for GetDaemonVersionCommand {
#[async_trait]
impl Executable for GetFullStateCommand {
async fn execute(&self) -> Response {
let (input_devices, _output_devices) = match get_all_devices().await {
let devices = match get_all_devices().await {
Ok(devices) => devices,
Err(err) => return Response::new(false, format!("Failed to get devices: {}", err)),
};
let input_devices = devices.inputs;
let mut all_inputs = HashMap::new();
let mut current_input_nick = String::new();
@@ -550,13 +668,22 @@ impl Executable for GetFullStateCommand {
}
}
let all_outputs: HashMap<String, String> = devices
.sinks
.into_iter()
.map(|device| (device.name, device.nick))
.collect();
let full_state = FullState {
state: audio_player.get_state(),
tracks: audio_player.get_tracks(),
volume: audio_player.volume,
monitoring_volume: audio_player.monitoring_volume,
mic_volume: audio_player.mic_volume,
volume_multiplier: audio_player.volume_multiplier,
current_input: current_input_nick,
all_inputs,
current_output: audio_player.output_device_name.clone().unwrap_or_default(),
all_outputs,
};
match serde_json::to_string(&full_state) {
+3 -1
View File
@@ -16,7 +16,9 @@ use std::{
#[serde(default)]
pub struct DaemonConfig {
pub default_input_name: Option<String>,
pub default_volume: Option<f32>,
pub default_output_name: Option<String>,
pub default_monitoring_volume: Option<f32>,
pub default_mic_volume: Option<f32>,
pub default_volume_multiplier: Option<f32>,
}
+103 -17
View File
@@ -9,21 +9,64 @@ use std::{
collections::{HashMap, HashSet},
path::PathBuf,
sync::{Arc, Mutex},
time::Instant,
time::{Duration, Instant},
};
pub type ScanResult = (PathBuf, Vec<PathBuf>, HashMap<PathBuf, Vec<PathBuf>>);
/// How long the daemon's value is ignored after a slider commits, so the knob does not
/// snap back before the daemon has caught up.
const SETTLE_TIME: Duration = Duration::from_millis(300);
/// A slider whose local value wins over the daemon's while the user is interacting.
///
/// The daemon is polled at 60 Hz, so without this the value would fight the user mid-drag
/// and jump back right after release.
#[derive(Default, Debug)]
pub struct SliderLatch {
pub value: f32,
pub dragged: bool,
pub ignore_update_until: Option<Instant>,
}
impl SliderLatch {
/// Whether the daemon's value may overwrite the local one this frame.
fn should_sync(&self) -> bool {
!self.dragged
&& self
.ignore_update_until
.is_none_or(|until| Instant::now() > until)
}
/// Adopts the daemon's value unless the user is currently driving the slider.
pub fn sync(&mut self, value: f32) {
if self.should_sync() {
self.value = value;
}
}
/// Called once the pending change has been sent to the daemon.
fn commit(&mut self) {
self.dragged = false;
self.ignore_update_until = Some(Instant::now() + SETTLE_TIME);
}
/// Returns the value to send, if the user finished a change that has not been sent yet.
pub fn take_pending(&mut self) -> Option<f32> {
if self.dragged {
let value = self.value;
self.commit();
Some(value)
} else {
None
}
}
}
#[derive(Default, Debug)]
pub struct TrackUiState {
pub position_slider_value: f32,
pub volume_slider_value: f32,
pub position_dragged: bool,
pub volume_dragged: bool,
pub ignore_position_update_until: Option<Instant>,
pub ignore_volume_update_until: Option<Instant>,
pub position: SliderLatch,
pub volume: SliderLatch,
}
#[derive(Default, Debug)]
@@ -33,18 +76,14 @@ pub struct AppState {
pub track_ui_states: HashMap<u32, TrackUiState>,
pub show_settings: bool,
pub volume_dragged: bool,
pub volume_multiplier_dragged: bool,
pub force_focus_search: bool,
pub volume_slider_value: f32,
pub volume_multiplier_slider_value: f32,
pub monitoring_volume: SliderLatch,
pub mic_volume: SliderLatch,
pub volume_multiplier: SliderLatch,
pub search_field_id: Option<Id>,
pub ignore_volume_update_until: Option<Instant>,
pub ignore_volume_multiplier_update_until: Option<Instant>,
pub current_dir: Option<PathBuf>,
pub dirs: Vec<PathBuf>,
pub dirs_to_remove: HashSet<PathBuf>,
@@ -75,14 +114,61 @@ pub struct AudioPlayerState {
pub tracks: Vec<TrackInfo>,
pub volume: f32, // Master volume
/// What the user hears locally.
pub monitoring_volume: f32,
/// What is sent to the virtual microphone.
pub mic_volume: f32,
pub volume_multiplier: f32,
pub current_input: String,
pub all_inputs: HashMap<String, String>,
pub all_inputs_sorted: Vec<(String, String)>,
/// Empty means "follow the system default sink".
pub current_output: String,
pub all_outputs: HashMap<String, String>,
pub all_outputs_sorted: Vec<(String, String)>,
pub is_daemon_running: bool,
pub hotkey_config: Option<HotkeyConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_slider_latch_syncs_when_idle() {
let mut latch = SliderLatch::default();
latch.sync(0.7);
assert_eq!(latch.value, 0.7);
// While the user drags, the daemon must not move the knob out from under them.
latch.dragged = true;
latch.sync(0.2);
assert_eq!(latch.value, 0.7);
}
#[test]
fn test_slider_latch_take_pending() {
let mut latch = SliderLatch::default();
// Nothing to send until the user actually changes something.
assert_eq!(latch.take_pending(), None);
latch.value = 1.5;
latch.dragged = true;
assert_eq!(latch.take_pending(), Some(1.5));
// The change is sent exactly once.
assert_eq!(latch.take_pending(), None);
// The daemon is then ignored briefly, so a stale value already in flight cannot
// snap the slider back.
assert!(!latch.should_sync());
latch.sync(0.1);
assert_eq!(latch.value, 1.5);
}
}
+37 -3
View File
@@ -13,6 +13,15 @@ pub struct Port {
pub enum DeviceType {
Input,
Output,
Sink,
}
/// A link between two nodes in the PipeWire graph.
#[derive(Debug, Clone, Copy, Ord, PartialOrd, Eq, PartialEq)]
pub struct LinkInfo {
pub id: u32,
pub output_node: u32,
pub input_node: u32,
}
#[derive(Debug, Clone, Ord, PartialOrd, Eq, PartialEq)]
@@ -55,12 +64,14 @@ impl AudioDevice {
}
pub fn add_port(&mut self, port: Port) {
// `playback_*` are the input ports of an `Audio/Sink` node. `monitor_*` are its
// output ports and are deliberately left unmapped — we never record from a sink.
match port.name.as_str() {
"input_FL" => self.input_fl = Some(port),
"input_FR" => self.input_fr = Some(port),
"input_FL" | "playback_FL" => self.input_fl = Some(port),
"input_FR" | "playback_FR" => self.input_fr = Some(port),
"output_FL" | "capture_FL" => self.output_fl = Some(port),
"output_FR" | "capture_FR" => self.output_fr = Some(port),
"input_MONO" => {
"input_MONO" | "playback_MONO" => {
self.input_fl = Some(port.clone());
self.input_fr = Some(port);
}
@@ -100,6 +111,29 @@ mod tests {
assert_eq!(device_no_desc.nick, "Name");
}
#[test]
fn test_audio_device_sink_ports() {
let mut sink = AudioDevice::new(1, None, None, Some("speakers"), DeviceType::Sink);
let port = |id: u32, name: &str| Port {
node_id: 1,
port_id: id,
name: name.to_string(),
};
// playback_* carries audio into the sink, so those are its inputs.
sink.add_port(port(10, "playback_FL"));
sink.add_port(port(11, "playback_FR"));
assert_eq!(sink.input_fl, Some(port(10, "playback_FL")));
assert_eq!(sink.input_fr, Some(port(11, "playback_FR")));
// monitor_* is what the sink plays out; linking a stream there would be a loop.
sink.add_port(port(12, "monitor_FL"));
sink.add_port(port(13, "monitor_FR"));
assert_eq!(sink.output_fl, None);
assert_eq!(sink.output_fr, None);
}
#[test]
fn test_audio_device_add_port() {
let mut device = AudioDevice::new(1, None, None, Some("device-name"), DeviceType::Input);
+66
View File
@@ -143,6 +143,25 @@ impl Request {
Request::new("set_volume".to_string(), args)
}
pub fn get_monitoring_volume() -> Self {
Request::new("get_monitoring_volume", vec![])
}
pub fn set_monitoring_volume(volume: f32) -> Self {
Request::new(
"set_monitoring_volume",
vec![("volume", &volume.to_string())],
)
}
pub fn get_mic_volume() -> Self {
Request::new("get_mic_volume", vec![])
}
pub fn set_mic_volume(volume: f32) -> Self {
Request::new("set_mic_volume", vec![("volume", &volume.to_string())])
}
pub fn set_volume_multiplier(volume: f32) -> Self {
Request::new(
"set_volume_multiplier",
@@ -162,6 +181,18 @@ impl Request {
Request::new("set_input", vec![("input_name", name)])
}
pub fn get_output() -> Self {
Request::new("get_output", vec![])
}
pub fn get_outputs() -> Self {
Request::new("get_outputs", vec![])
}
pub fn set_output(name: &str) -> Self {
Request::new("set_output", vec![("output_name", name)])
}
pub fn set_loop(enabled: &str, id: Option<u32>) -> Self {
let mut args = vec![("enabled".to_string(), enabled.to_string())];
if let Some(id) = id {
@@ -280,6 +311,41 @@ mod tests {
assert_eq!(res.message, "success-msg");
}
#[test]
fn test_volume_path_request_constructors() {
let monitoring = Request::set_monitoring_volume(1.5);
assert_eq!(monitoring.name, "set_monitoring_volume");
assert_eq!(
monitoring.args.get("volume").map(|s| s.as_str()),
Some("1.5")
);
let mic = Request::set_mic_volume(2.0);
assert_eq!(mic.name, "set_mic_volume");
assert_eq!(mic.args.get("volume").map(|s| s.as_str()), Some("2"));
assert_eq!(
Request::get_monitoring_volume().name,
"get_monitoring_volume"
);
assert!(Request::get_monitoring_volume().args.is_empty());
assert_eq!(Request::get_mic_volume().name, "get_mic_volume");
}
#[test]
fn test_output_request_constructors() {
let set_output = Request::set_output("some-sink");
assert_eq!(set_output.name, "set_output");
assert_eq!(
set_output.args.get("output_name").map(|s| s.as_str()),
Some("some-sink")
);
assert_eq!(Request::get_output().name, "get_output");
assert_eq!(Request::get_outputs().name, "get_outputs");
assert!(Request::get_outputs().args.is_empty());
}
#[test]
fn test_request_constructors() {
// test ping
+86 -1
View File
@@ -1,7 +1,18 @@
use crate::types::{commands::*, config::DaemonConfig, socket::Request};
use crate::types::{
audio_player::VolumeTarget, commands::*, config::DaemonConfig, socket::Request,
};
use std::path::PathBuf;
/// Which of the two output paths a `*_monitoring_volume` / `*_mic_volume` command means.
fn volume_target(command_name: &str) -> Option<VolumeTarget> {
match command_name {
"get_monitoring_volume" | "set_monitoring_volume" => Some(VolumeTarget::Monitoring),
"get_mic_volume" | "set_mic_volume" => Some(VolumeTarget::Mic),
_ => None,
}
}
pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let id = request.args.get("id").and_then(|s| s.parse::<u32>().ok());
@@ -25,6 +36,21 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
.ok();
Some(Box::new(SetVolumeCommand { volume, id }))
}
"get_monitoring_volume" | "get_mic_volume" => Some(Box::new(GetMasterVolumeCommand {
target: volume_target(&request.name)?,
})),
"set_monitoring_volume" | "set_mic_volume" => {
let volume = request
.args
.get("volume")
.unwrap_or(&String::new())
.parse::<f32>()
.ok();
Some(Box::new(SetMasterVolumeCommand {
volume,
target: volume_target(&request.name)?,
}))
}
"set_volume_multiplier" => {
let volume_multiplier = request
.args
@@ -70,6 +96,12 @@ pub fn parse_command(request: &Request) -> Option<Box<dyn Executable + Send>> {
let name = Some(request.args.get("input_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentInputCommand { name }))
}
"get_output" => Some(Box::new(GetCurrentOutputCommand {})),
"get_outputs" => Some(Box::new(GetAllOutputsCommand {})),
"set_output" => {
let name = Some(request.args.get("output_name").unwrap_or(&String::new())).cloned();
Some(Box::new(SetCurrentOutputCommand { name }))
}
"set_loop" => {
let enabled = request
.args
@@ -227,4 +259,57 @@ mod tests {
let cmd = parse_command(&request);
assert!(cmd.is_some());
}
#[test]
fn test_volume_target_mapping() {
assert_eq!(
volume_target("set_monitoring_volume"),
Some(VolumeTarget::Monitoring)
);
assert_eq!(
volume_target("get_monitoring_volume"),
Some(VolumeTarget::Monitoring)
);
assert_eq!(volume_target("set_mic_volume"), Some(VolumeTarget::Mic));
assert_eq!(volume_target("get_mic_volume"), Some(VolumeTarget::Mic));
// The two paths share a dispatch arm, so a name that is neither must not
// silently fall through to one of them.
assert_eq!(volume_target("set_volume"), None);
assert_eq!(volume_target("mic"), None);
}
#[test]
fn test_parse_new_commands() {
let with_volume = |name: &str| {
let mut args = HashMap::new();
args.insert("volume".to_string(), "2.0".to_string());
Request {
name: name.to_string(),
args,
}
};
assert!(parse_command(&with_volume("set_monitoring_volume")).is_some());
assert!(parse_command(&with_volume("set_mic_volume")).is_some());
for name in [
"get_monitoring_volume",
"get_mic_volume",
"get_output",
"get_outputs",
] {
let request = Request::new(name, vec![]);
assert!(
parse_command(&request).is_some(),
"{} did not dispatch",
name
);
}
let set_output = Request::set_output("some-sink");
assert!(parse_command(&set_output).is_some());
assert!(parse_command(&Request::new("set_speaker_volume", vec![])).is_none());
}
}
+20 -8
View File
@@ -9,6 +9,7 @@ use crate::{
};
use anyhow::{Result, anyhow};
use std::{
collections::HashMap,
fs,
path::PathBuf,
sync::{Arc, Mutex},
@@ -77,6 +78,16 @@ pub fn format_time_pair(position: f32, duration: f32) -> String {
format!("{}/{}", format_time(position), format_time(duration))
}
/// Turns a name→nickname map into the stable order the combo boxes render in.
fn sorted_devices(devices: &HashMap<String, String>) -> Vec<(String, String)> {
let mut sorted: Vec<(String, String)> = devices
.iter()
.map(|(name, nick)| (name.clone(), nick.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
sorted
}
pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerState>>) {
tokio::spawn(async move {
let sleep_duration = Duration::from_secs_f32(1.0 / 60.0);
@@ -115,7 +126,8 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
None => full_state.state,
};
guard.tracks = full_state.tracks;
guard.volume = full_state.volume;
guard.monitoring_volume = full_state.monitoring_volume;
guard.mic_volume = full_state.mic_volume;
guard.volume_multiplier = full_state.volume_multiplier;
guard.current_input = full_state
.current_input
@@ -123,16 +135,16 @@ pub fn start_app_state_thread(audio_player_state_shared: Arc<Mutex<AudioPlayerSt
.next()
.unwrap_or_default()
.to_string();
guard.current_output = full_state.current_output;
if guard.all_inputs != full_state.all_inputs {
guard.all_inputs = full_state.all_inputs;
let mut sorted: Vec<(String, String)> = guard
.all_inputs
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
guard.all_inputs_sorted = sorted;
guard.all_inputs_sorted = sorted_devices(&guard.all_inputs);
}
if guard.all_outputs != full_state.all_outputs {
guard.all_outputs = full_state.all_outputs;
guard.all_outputs_sorted = sorted_devices(&guard.all_outputs);
}
guard.is_daemon_running = true;
+224 -91
View File
@@ -1,4 +1,4 @@
use crate::types::pipewire::{AudioDevice, DeviceType, Port};
use crate::types::pipewire::{AudioDevice, DeviceType, LinkInfo, Port};
use anyhow::{Result, anyhow};
use pipewire::{
context::ContextRc, link::Link, main_loop::MainLoopRc, properties::properties,
@@ -7,9 +7,31 @@ use pipewire::{
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::OnceLock, thread};
use tokio::sync::oneshot;
/// Every audio node PWSP knows about, split by role.
pub struct AllDevices {
/// `Audio/Source*` nodes — real and virtual microphones.
pub inputs: Vec<AudioDevice>,
/// `Stream/Output/Audio` nodes — application playback streams, including our own.
pub outputs: Vec<AudioDevice>,
/// `Audio/Sink` nodes — speakers and headphones.
pub sinks: Vec<AudioDevice>,
}
impl AllDevices {
pub fn iter(&self) -> impl Iterator<Item = &AudioDevice> {
self.inputs
.iter()
.chain(self.outputs.iter())
.chain(self.sinks.iter())
}
}
pub enum PwCommand {
GetDevices {
resp: oneshot::Sender<(Vec<AudioDevice>, Vec<AudioDevice>)>,
resp: oneshot::Sender<AllDevices>,
},
GetLinks {
resp: oneshot::Sender<Vec<LinkInfo>>,
},
CreateVirtualMic {
resp: oneshot::Sender<Result<u32, String>>,
@@ -21,14 +43,21 @@ pub enum PwCommand {
input_fr: Port,
resp: oneshot::Sender<Result<(u32, u32), String>>,
},
/// Drops a proxy we created ourselves, which destroys the underlying object.
DestroyObject {
id: u32,
},
/// Destroys a global object owned by somebody else, such as an auto-created link.
DestroyGlobal {
id: u32,
},
}
struct AppState {
input_devices: HashMap<u32, AudioDevice>,
output_devices: HashMap<u32, AudioDevice>,
sink_devices: HashMap<u32, AudioDevice>,
links: HashMap<u32, LinkInfo>,
ports: HashMap<u32, Port>,
proxies: HashMap<u32, Box<dyn std::any::Any>>,
proxy_id_counter: u32,
@@ -53,19 +82,23 @@ pub fn get_manager() -> &'static PipewireManager {
let main_loop = Box::leak(Box::new(main_loop));
let context = Box::leak(Box::new(context));
// Leak to fix lifetime issues since this thread lives forever
let core = Box::leak(Box::new(
// Leak to fix lifetime issues since this thread lives forever. Shared rather
// than mutable so both `core` and the `registry` borrowed from it can be
// captured by the command closure below.
let core: &'static _ = Box::leak(Box::new(
context
.connect(None)
.expect("Failed to connect to pipewire"),
));
let registry = Box::leak(Box::new(
let registry: &'static _ = Box::leak(Box::new(
core.get_registry().expect("Failed to get registry"),
));
let state = Rc::new(RefCell::new(AppState {
input_devices: HashMap::new(),
output_devices: HashMap::new(),
sink_devices: HashMap::new(),
links: HashMap::new(),
ports: HashMap::new(),
proxies: HashMap::new(),
proxy_id_counter: 10000,
@@ -78,31 +111,44 @@ pub fn get_manager() -> &'static PipewireManager {
let _listener = registry
.add_listener_local()
.global(move |global| {
let (device, port) = parse_global_object(global);
let mut s = state_for_registry_add.borrow_mut();
if let Some(device) = device {
match device.device_type {
match parse_global_object(global) {
ParsedGlobal::Device(device) => match device.device_type {
DeviceType::Input => {
s.input_devices.insert(device.id, device);
}
DeviceType::Output => {
s.output_devices.insert(device.id, device);
}
DeviceType::Sink => {
s.sink_devices.insert(device.id, device);
}
},
ParsedGlobal::Port(port) => {
let node_id = port.node_id;
s.ports.insert(port.port_id, port.clone());
if let Some(d) = s.input_devices.get_mut(&node_id) {
d.add_port(port);
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
d.add_port(port);
} else if let Some(d) = s.sink_devices.get_mut(&node_id) {
d.add_port(port);
}
}
} else if let Some(port) = port {
let node_id = port.node_id;
s.ports.insert(port.port_id, port.clone());
if let Some(d) = s.input_devices.get_mut(&node_id) {
d.add_port(port.clone());
} else if let Some(d) = s.output_devices.get_mut(&node_id) {
d.add_port(port);
ParsedGlobal::Link(link) => {
s.links.insert(link.id, link);
}
ParsedGlobal::Unknown => {}
}
})
.global_remove(move |id| {
let mut s = state_for_registry_remove.borrow_mut();
s.input_devices.remove(&id);
s.output_devices.remove(&id);
s.sink_devices.remove(&id);
s.links.remove(&id);
s.links
.retain(|_, link| link.output_node != id && link.input_node != id);
s.ports.retain(|_, port| port.node_id != id);
s.ports.remove(&id);
})
@@ -133,9 +179,21 @@ pub fn get_manager() -> &'static PipewireManager {
s.input_devices.values().cloned().collect();
let mut outputs: Vec<AudioDevice> =
s.output_devices.values().cloned().collect();
let mut sinks: Vec<AudioDevice> =
s.sink_devices.values().cloned().collect();
inputs.sort_by_key(|a| a.id);
outputs.sort_by_key(|a| a.id);
let _ = resp.send((inputs, outputs));
sinks.sort_by_key(|a| a.id);
let _ = resp.send(AllDevices {
inputs,
outputs,
sinks,
});
}
PwCommand::GetLinks { resp } => {
let mut links: Vec<LinkInfo> = s.links.values().copied().collect();
links.sort_by_key(|l| l.id);
let _ = resp.send(links);
}
PwCommand::CreateVirtualMic { resp } => {
let props = properties!(
@@ -207,6 +265,10 @@ pub fn get_manager() -> &'static PipewireManager {
PwCommand::DestroyObject { id } => {
s.proxies.remove(&id);
}
PwCommand::DestroyGlobal { id } => {
s.links.remove(&id);
registry.destroy_global(id);
}
}
});
@@ -227,12 +289,17 @@ pub fn setup_pipewire_context() -> Result<(MainLoopRc, ContextRc), String> {
Ok((main_loop, context))
}
fn parse_global_object(
global_object: &GlobalObject<&DictRef>,
) -> (Option<AudioDevice>, Option<Port>) {
enum ParsedGlobal {
Device(AudioDevice),
Port(Port),
Link(LinkInfo),
Unknown,
}
fn parse_global_object(global_object: &GlobalObject<&DictRef>) -> ParsedGlobal {
let props = match global_object.props {
Some(p) => p,
None => return (None, None),
None => return ParsedGlobal::Unknown,
};
if let Some(media_class) = props.get("media.class") {
@@ -241,26 +308,40 @@ fn parse_global_object(
let node_name = props.get("node.name");
let node_description = props.get("node.description");
if media_class.starts_with("Audio/Source") {
let input_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Input,
);
return (Some(input_device), None);
// `Audio/Source/Virtual` (our own virtual mic) also matches "Audio/Source",
// which is intended — it is a microphone as far as the rest of PWSP cares.
let device_type = if media_class.starts_with("Audio/Source") {
DeviceType::Input
} else if media_class.starts_with("Stream/Output/Audio") {
let output_device = AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
DeviceType::Output,
);
return (Some(output_device), None);
}
return (None, None);
DeviceType::Output
} else if media_class.starts_with("Audio/Sink") {
DeviceType::Sink
} else {
return ParsedGlobal::Unknown;
};
return ParsedGlobal::Device(AudioDevice::new(
node_id,
node_nick,
node_description,
node_name,
device_type,
));
}
if let (Some(output_node), Some(input_node)) = (
props
.get("link.output.node")
.and_then(|id| id.parse::<u32>().ok()),
props
.get("link.input.node")
.and_then(|id| id.parse::<u32>().ok()),
) {
return ParsedGlobal::Link(LinkInfo {
id: global_object.id,
output_node,
input_node,
});
}
if props.get("port.direction").is_some()
@@ -270,18 +351,17 @@ fn parse_global_object(
props.get("port.name"),
)
{
let port = Port {
return ParsedGlobal::Port(Port {
node_id,
port_id,
name: port_name.to_string(),
};
return (None, Some(port));
});
}
(None, None)
ParsedGlobal::Unknown
}
pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
pub async fn get_all_devices() -> Result<AllDevices> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
@@ -294,19 +374,52 @@ pub async fn get_all_devices() -> Result<(Vec<AudioDevice>, Vec<AudioDevice>)> {
Ok(res)
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let (input_devices, output_devices) = get_all_devices().await?;
pub async fn get_all_links() -> Result<Vec<LinkInfo>> {
let (tx, rx) = oneshot::channel();
let manager = get_manager();
manager
.sender
.send(PwCommand::GetLinks { resp: tx })
.map_err(|_| anyhow!("Failed to send GetLinks to manager"))?;
rx.await
.map_err(|e| anyhow!("Failed to receive response: {}", e))
}
input_devices
fn matches_device_name(device: &AudioDevice, device_name: &str) -> bool {
device.name == device_name
|| device.nick == device_name
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
}
pub async fn get_device(device_name: &str) -> Result<AudioDevice> {
let devices = get_all_devices().await?;
devices
.iter()
.find(|device| matches_device_name(device, device_name))
.cloned()
.ok_or_else(|| anyhow!("Device not found: {}", device_name))
}
/// Re-reads a node by its PipeWire id, so callers always link against fresh ports.
pub async fn get_device_by_id(id: u32) -> Result<AudioDevice> {
get_all_devices()
.await?
.iter()
.find(|device| device.id == id)
.cloned()
.ok_or_else(|| anyhow!("Node {} is gone", id))
}
/// Looks up an `Audio/Sink` node by name.
pub async fn get_sink(name: &str) -> Result<AudioDevice> {
get_all_devices()
.await?
.sinks
.into_iter()
.chain(output_devices)
.find(|device| {
device.name == device_name
|| device.nick == device_name
|| device.name.contains(device_name)
|| device.nick.contains(device_name)
})
.ok_or_else(|| anyhow!("Device not found"))
.find(|d| matches_device_name(d, name))
.ok_or_else(|| anyhow!("Output device not found: {}", name))
}
pub struct PwTerminator {
@@ -338,43 +451,63 @@ pub async fn create_virtual_mic() -> Result<PwTerminator> {
Ok(PwTerminator { ids: vec![id] })
}
pub async fn link_player_to_virtual_mic() -> Result<PwTerminator> {
let pwsp_daemon_output = match get_device("pwsp-daemon").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find alsa_playback.pwsp-daemon device, skipping device linking"
));
/// Makes `source_node` feed `target` and nothing else.
///
/// Idempotent: safe to call on every device-check tick. Returns `Some` only when a new link
/// was created, so an already-correct route is left in place and the caller never tears
/// down a link it does not own.
///
/// The link is created *before* stale ones are pruned. That order matters: the node is
/// never left unlinked, which is the state that would invite the session manager to
/// re-attach it somewhere else.
pub async fn ensure_route(
source_node: &AudioDevice,
target: &AudioDevice,
) -> Result<Option<PwTerminator>> {
let existing = get_all_links().await?;
let already_routed = existing
.iter()
.any(|link| link.output_node == source_node.id && link.input_node == target.id);
let terminator = if already_routed {
None
} else {
let output_fl = source_node
.output_fl
.clone()
.ok_or_else(|| anyhow!("Node {} has no output_FL port", source_node.name))?;
let output_fr = source_node
.output_fr
.clone()
.ok_or_else(|| anyhow!("Node {} has no output_FR port", source_node.name))?;
let input_fl = target
.input_fl
.clone()
.ok_or_else(|| anyhow!("Node {} has no input_FL port", target.name))?;
let input_fr = target
.input_fr
.clone()
.ok_or_else(|| anyhow!("Node {} has no input_FR port", target.name))?;
Some(create_link(output_fl, output_fr, input_fl, input_fr).await?)
};
prune_links_from(source_node.id, target.id).await?;
Ok(terminator)
}
/// Destroys every link leaving `source_node` that does not end at `keep_target`.
async fn prune_links_from(source_node: u32, keep_target: u32) -> Result<()> {
let manager = get_manager();
for link in get_all_links().await? {
if link.output_node == source_node && link.input_node != keep_target {
let _ = manager
.sender
.send(PwCommand::DestroyGlobal { id: link.id });
}
};
let pwsp_daemon_input = match get_device("pwsp-virtual-mic").await {
Ok(device) => device,
Err(_) => {
return Err(anyhow!(
"Could not find pwsp-virtual-mic device, skipping device linking"
));
}
};
let output_fl = match pwsp_daemon_output.output_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fl")),
};
let output_fr = match pwsp_daemon_output.output_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-daemon output_fr")),
};
let input_fl = match pwsp_daemon_input.input_fl {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fl")),
};
let input_fr = match pwsp_daemon_input.input_fr {
Some(port) => port,
None => return Err(anyhow!("Failed to get pwsp-virtual-mic input_fr")),
};
create_link(output_fl, output_fr, input_fl, input_fr).await
}
Ok(())
}
pub async fn create_link(