diff --git a/src/frontend_bridge/commands.rs b/src/frontend_bridge/commands.rs index 6666743d..1c1651c6 100644 --- a/src/frontend_bridge/commands.rs +++ b/src/frontend_bridge/commands.rs @@ -121,6 +121,17 @@ pub(super) fn handle_bridge_command( } } +/// Set the user's volume to `volume`, and have the playback engine play at +/// it. Every `SetVolume` the engine gets is sent here, so that the bridge +/// can tell a volume changed outside the app from one it sent since; see +/// [`crate::playback::PlaybackEvent::OutsideVolume`]. +pub(super) fn command_volume(state: &mut BridgeState, playback: &PlaybackEngine, volume: f32) { + let volume = volume.clamp(0.0, 1.0); + state.settings.volume = volume; + state.volume_commands_sent += 1; + playback.command(PlaybackCommand::SetVolume(volume)); +} + fn handle_playback_bridge_command( cmd: &BridgePlaybackCommand, state: &mut BridgeState, @@ -136,9 +147,7 @@ fn handle_playback_bridge_command( context.playback.command(PlaybackCommand::Seek(*pos)); } BridgePlaybackCommand::SetVolume(volume) => { - let volume = volume.clamp(0.0, 1.0); - context.playback.command(PlaybackCommand::SetVolume(volume)); - state.settings.volume = volume; + command_volume(state, context.playback, *volume); *context.settings_dirty = true; } BridgePlaybackCommand::SetRepeatMode(mode) => { @@ -183,9 +192,8 @@ fn handle_load_settings_from_disk(state: &mut BridgeState, context: &mut BridgeC .library .set_watching(state.settings.library_watch_enabled); state.lastfm.enabled = state.settings.integrations.lastfm_scrobbling_enabled; - context - .playback - .command(PlaybackCommand::SetVolume(state.settings.volume)); + let volume = state.settings.volume; + command_volume(state, context.playback, volume); apply_analysis_spectrogram_settings(state, context.analysis); context.lastfm.command(LastFmCommand::SetEnabled( state.settings.integrations.lastfm_scrobbling_enabled, @@ -273,9 +281,7 @@ fn handle_settings_bridge_command( } BridgeSettingsCommand::SaveToDisk => save_settings_now(context), BridgeSettingsCommand::SetVolume(volume) => { - let volume = volume.clamp(0.0, 1.0); - state.settings.volume = volume; - context.playback.command(PlaybackCommand::SetVolume(volume)); + command_volume(state, context.playback, *volume); *context.settings_dirty = true; } BridgeSettingsCommand::SetFftSize(size) => set_fft_size(state, context, *size), diff --git a/src/frontend_bridge/event_log.rs b/src/frontend_bridge/event_log.rs index 9a3e08e3..144dbd2a 100644 --- a/src/frontend_bridge/event_log.rs +++ b/src/frontend_bridge/event_log.rs @@ -110,6 +110,18 @@ pub(super) fn log_playback_changes(previous: &PlaybackSnapshot, now: &PlaybackSn } } +/// Logs that the volume was changed outside the app to `level`, such as +/// with Plasma's per-app volume slider. +pub(super) fn log_outside_volume(level: f32) { + log_info( + ErrorCategory::Playback, + &format!( + "Volume changed outside the app: {:.0}%.", + f64::from(level) * 100.0 + ), + ); +} + /// Logs a seek that landed at `position`. pub(super) fn log_seek(position: std::time::Duration) { log_info( diff --git a/src/frontend_bridge/events.rs b/src/frontend_bridge/events.rs index 6d489b83..8b462b50 100644 --- a/src/frontend_bridge/events.rs +++ b/src/frontend_bridge/events.rs @@ -298,6 +298,21 @@ pub(super) fn process_playback_event( state.unacknowledged_engine_renames.pop_front(); SnapshotUrgency::None } + PlaybackEvent::OutsideVolume { + level, + set_volume_commands, + } => { + // A volume the user set in the app since is newer, and the + // engine plays at it. + if set_volume_commands != state.volume_commands_sent + || state.settings.volume.to_bits() == level.to_bits() + { + return SnapshotUrgency::None; + } + super::event_log::log_outside_volume(level); + state.settings.volume = level; + SnapshotUrgency::Immediate + } PlaybackEvent::Error(mut error) => { if let Some(path) = error.path.as_mut() { remap_unacknowledged_engine_path(state, path); diff --git a/src/frontend_bridge/mod.rs b/src/frontend_bridge/mod.rs index 2c37ffc3..008ea016 100644 --- a/src/frontend_bridge/mod.rs +++ b/src/frontend_bridge/mod.rs @@ -814,6 +814,9 @@ pub(super) struct BridgeState { /// Renames sent to the playback engine that it has not acknowledged. /// Events it sent before the acknowledgement still carry the old paths. unacknowledged_engine_renames: VecDeque>, + /// Number of `SetVolume` commands sent to the playback engine; see + /// [`PlaybackEvent::OutsideVolume`]. + volume_commands_sent: u64, /// Keys of the restored session file that this build does not know, /// saved again as they were. session_unknown_keys: serde_json::Map, @@ -2160,6 +2163,7 @@ impl BridgeLoopRuntime { event: PlaybackEvent, event_tx: &Sender, ) -> SnapshotUrgency { + let volume = self.state.settings.volume; let urgency = process_playback_event( event, &self.analysis, @@ -2167,12 +2171,22 @@ impl BridgeLoopRuntime { event_tx, &mut self.state, ); + self.note_volume_set_outside(volume); if urgency.is_pending() { self.flags.session_dirty = true; } urgency } + /// Save the settings if playback events changed the user's volume from + /// `before`: a volume set outside the app, which is the user's setting + /// as much as one set in it. + fn note_volume_set_outside(&mut self, before: f32) { + if self.state.settings.volume.to_bits() != before.to_bits() { + self.flags.settings_dirty = true; + } + } + fn handle_analysis_event( &mut self, event: AnalysisEvent, @@ -2443,6 +2457,7 @@ impl BridgeLoopRuntime { } fn pump_bridge_events(&mut self, event_tx: &Sender) -> SnapshotUrgency { + let volume = self.state.settings.volume; let mut urgency = drain_playback_events( &self.playback_rx, &self.analysis, @@ -2450,6 +2465,7 @@ impl BridgeLoopRuntime { event_tx, &mut self.state, ); + self.note_volume_set_outside(volume); let analysis_urgency = drain_analysis_events(&self.analysis_rx, event_tx, &mut self.state); let metadata_urgency = drain_metadata_events(&self.metadata_rx, &mut self.state); self.read_playing_metadata_again(); @@ -2911,8 +2927,9 @@ fn restore_initial_bridge_state( lastfm: &LastFmHandle, persisted_state_enabled: bool, ) { - state.playback.volume = state.settings.volume; - playback.command(PlaybackCommand::SetVolume(state.settings.volume)); + let volume = state.settings.volume; + state.playback.volume = volume; + commands::command_volume(state, playback, volume); // Display mode first so self.display_mode is correct before // SetFftSize / SetSpectrogramViewMode restart the session. analysis.command(AnalysisCommand::SetSpectrogramDisplayMode( @@ -7160,6 +7177,74 @@ mod tests { } } + #[test] + fn a_volume_set_outside_the_app_is_shown_and_saved_unless_the_app_set_one_since() { + let _guard = test_guard(); + let mut runtime = BridgeLoopRuntime::new(BridgeRuntimeOptions::default(), None); + let (event_tx, event_rx) = bounded::(64); + drain_initial_events(&mut runtime, &event_tx, &event_rx); + let (engine_tx, engine_rx) = unbounded(); + runtime.playback = PlaybackEngine::from_command_sender(engine_tx); + let set_volume = |runtime: &mut BridgeLoopRuntime, volume: f32| { + runtime.handle_wake( + BridgeLoopWake::Command(BridgeCommand::Playback(BridgePlaybackCommand::SetVolume( + volume, + ))), + &event_tx, + ); + }; + set_volume(&mut runtime, 0.5); + let sent = runtime.state.volume_commands_sent; + assert!(engine_rx + .try_iter() + .any(|command| matches!(command, PlaybackCommand::SetVolume(volume) if volume == 0.5))); + while event_rx.try_recv().is_ok() {} + runtime.flags.settings_dirty = false; + + // The engine took a volume set elsewhere after the app's last one: + // the UI and MPRIS show it, and it is saved. + runtime.handle_wake( + BridgeLoopWake::Playback(PlaybackEvent::OutsideVolume { + level: 0.66, + set_volume_commands: sent, + }), + &event_tx, + ); + assert_eq!(runtime.state.settings.volume, 0.66); + assert!(runtime.flags.settings_dirty); + let snapshot = emitted_snapshots(&event_rx) + .pop() + .expect("a snapshot of the volume set elsewhere"); + assert_eq!(snapshot.settings.volume, 0.66); + + // One the engine took before the app's latest is older than it. + set_volume(&mut runtime, 0.3); + runtime.flags.settings_dirty = false; + runtime.handle_wake( + BridgeLoopWake::Playback(PlaybackEvent::OutsideVolume { + level: 0.9, + set_volume_commands: sent, + }), + &event_tx, + ); + assert_eq!(runtime.state.settings.volume, 0.3); + assert!(!runtime.flags.settings_dirty); + + // The same holds for events that wait as the loop handles another + // wake. + let (playback_tx, playback_rx) = unbounded(); + runtime.playback_rx = playback_rx; + playback_tx + .send(PlaybackEvent::OutsideVolume { + level: 0.0, + set_volume_commands: sent + 1, + }) + .unwrap(); + runtime.handle_wake(BridgeLoopWake::Tick, &event_tx); + assert_eq!(runtime.state.settings.volume, 0.0); + assert!(runtime.flags.settings_dirty); + } + #[test] fn a_gapless_track_change_keeps_a_queue_edit_in_the_snapshot() { let _guard = test_guard(); diff --git a/src/playback/backend_gst.rs b/src/playback/backend_gst.rs index 1468225e..2e120365 100644 --- a/src/playback/backend_gst.rs +++ b/src/playback/backend_gst.rs @@ -31,6 +31,7 @@ use super::queue::{ }; use super::seek_locate::{Located, LocatedFrame, SeekLocator}; use super::source_activation::SourceActivation; +use super::stream_volume::{self, StreamVolumeChange, StreamVolumeReport, StreamVolumeWatch}; use super::{ EngineExit, FileStamp, PathRenames, PlaybackCommand, PlaybackError, PlaybackErrorOutcome, PlaybackEvent, PlaybackSnapshot, PlaybackState, RepeatMode, TrackChangeKind, @@ -376,6 +377,20 @@ struct GstPlaybackRuntime { /// Time of the previous step of a fade or silence hold; `None` while /// neither runs. volume_ramp_tick_at: Option, + /// Tells the engine's writes of the stream volume from changes made + /// elsewhere, such as in Plasma's volume applet, to an output that + /// controls the volume of its stream; see `stream_volume`. + stream_volume: Arc, + /// What the output's sinks read when they told of a change of their + /// stream's volume or mute. + stream_volume_rx: Receiver, + /// Whether the stream was muted elsewhere. The user's volume is shown + /// as 0 meanwhile; the stream keeps its volume, which an unmute + /// elsewhere brings back. A volume the user sets in the app unmutes it. + stream_muted: bool, + /// Number of `SetVolume` commands handled, which a + /// [`PlaybackEvent::OutsideVolume`] tells. + volume_commands: u64, buffering_active: bool, /// Number of `Seek` commands received, which numbers each seek. seek_requests: u64, @@ -617,6 +632,7 @@ impl GstPlaybackRuntime { let (located_tx, located_rx) = unbounded(); let flac_parsers = track_flac_parsers(&playbin); releases.track(&playbin); + let (stream_volume, stream_volume_rx) = StreamVolumeWatch::new(1.0); Self { playbin, queue_state, @@ -633,6 +649,10 @@ impl GstPlaybackRuntime { output_rise: OutputRise::Steady, startup_ramp_hold_left: None, volume_ramp_tick_at: None, + stream_volume, + stream_volume_rx, + stream_muted: false, + volume_commands: 0, buffering_active: false, seek_requests: 0, queued_seek: None, @@ -1377,8 +1397,7 @@ impl GstPlaybackRuntime { if was_stopped || resumed_at_track_start { // Re-assert the volume after the state transition, so that // elements the new run created also have it. - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + self.write_applied_volume(); } self.snapshot.state = PlaybackState::Playing; // The position advances once the pipeline has reached Playing. @@ -1865,8 +1884,7 @@ impl GstPlaybackRuntime { self.begin_startup_ramp(); } else { // Re-asserted after the state change, as `play` does. - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + self.write_applied_volume(); } } @@ -1923,8 +1941,7 @@ impl GstPlaybackRuntime { return; } self.applied_volume = self.target_volume; - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + self.write_applied_volume(); self.output_rise = OutputRise::Steady; self.startup_ramp_hold_left = None; self.volume_ramp_tick_at = None; @@ -1933,7 +1950,7 @@ impl GstPlaybackRuntime { /// Silence the output and fade the current track in from silence. fn begin_startup_ramp(&mut self) { self.applied_volume = 0.0; - self.playbin.set_property("volume", 0.0_f64); + self.write_applied_volume(); self.output_rise = OutputRise::StartupFade; // The fade-in starts its own clock. Otherwise the first step would // count the time since a running fade last stepped, which can span @@ -1989,9 +2006,12 @@ impl GstPlaybackRuntime { // The output sets up its sink again for the next run, and // autoaudiosink makes a new one, which starts at its own volume. // Playsink gives that sink the volume set since the run ended, - // but not one set before, which went to the old sink. - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + // but not one set before, which went to the old sink. So also + // a mute set elsewhere, which the new stream keeps. + self.write_applied_volume(); + if self.stream_muted { + self.write_stream_mute(); + } result } else { let result = change(); @@ -2170,7 +2190,37 @@ impl GstPlaybackRuntime { true } + /// Set the output to `applied_volume`. + fn write_applied_volume(&self) { + let gain = f64::from(self.applied_volume); + self.stream_volume.write(gain, self.stream_muted, || { + self.playbin.set_property("volume", gain); + }); + } + + /// Mute or unmute the output's stream as `stream_muted` says. + fn write_stream_mute(&self) { + let muted = self.stream_muted; + self.stream_volume + .write(f64::from(self.applied_volume), muted, || { + self.playbin.set_property("mute", muted); + }); + } + fn set_volume(&mut self, level: f32) { + self.volume_commands += 1; + // Newer than anything a sink has read of its stream so far, also + // while a fade has yet to write it: such a read is not taken in + // after this volume, and the `OutsideVolume` of one read later + // follows this command. + self.stream_volume + .mark(f64::from(self.applied_volume), self.stream_muted); + if self.stream_muted { + // The volume the user sets plays, also where the stream was + // muted elsewhere. + self.stream_muted = false; + self.write_stream_mute(); + } self.snapshot.volume = level.clamp(0.0, 1.0); self.target_volume = super::volume_level_to_gain(self.snapshot.volume); if self.applies_volume_while_paused() { @@ -2179,12 +2229,66 @@ impl GstPlaybackRuntime { // muted, a resume plays no sample at the old volume. self.applied_volume = self.target_volume; self.volume_ramp_tick_at = None; - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + self.write_applied_volume(); + } else if (self.target_volume - self.applied_volume).abs() <= f32::EPSILON + && !self.output_silenced() + && self.startup_ramp_hold_left.is_none() + && self.output_applies_volume() == Some(true) + { + // At the volume already, so no fade writes it. The stream can + // have been changed elsewhere since the engine last wrote it, + // by a change whose report the mark above set aside, and that + // need not be told again: the user's volume is the newer. + self.write_applied_volume(); } self.emit_snapshot(); } + /// Take in what an output's sink read of its stream: a volume or mute + /// set elsewhere, such as with Plasma's per-app volume slider, becomes + /// the user's volume, as if set in the app. The stream already plays + /// at it, so it applies at once, ending a fade toward the volume + /// before. An output silenced until its run plays, or holding the + /// silence of a track start, stays silent, and comes up to the new + /// volume as it would have to the old one. A mute is shown as the + /// volume 0; the stream keeps its volume, which the unmute brings + /// back. + fn handle_stream_volume(&mut self, report: StreamVolumeReport) { + let StreamVolumeChange::Outside { gain, muted } = self.stream_volume.classify(report) + else { + return; + }; + self.stream_volume.adopt(gain, muted); + self.stream_muted = muted; + #[allow(clippy::cast_possible_truncation)] + let level = (stream_volume::gain_to_level(gain) as f32).clamp(0.0, 1.0); + self.target_volume = super::volume_level_to_gain(level); + if self.output_silenced() || self.startup_ramp_hold_left.is_some() { + // Silent again until the run plays. + self.write_applied_volume(); + } else { + self.applied_volume = self.target_volume; + self.end_startup_fade(); + self.volume_ramp_tick_at = None; + } + let shown = if muted { 0.0 } else { level }; + if (shown - self.snapshot.volume).abs() > f32::EPSILON { + self.snapshot.volume = shown; + let _ = self.event_tx.send(PlaybackEvent::OutsideVolume { + level: shown, + set_volume_commands: self.volume_commands, + }); + self.emit_snapshot(); + } + } + + /// Take in every report of the stream volume that waits. + fn handle_stream_volume_reports(&mut self) { + while let Ok(report) = self.stream_volume_rx.try_recv() { + self.handle_stream_volume(report); + } + } + /// Whether a volume change applies at once rather than when playback /// resumes: while paused, with an output that applies the volume as it /// plays, and with the output at the volume rather than silenced for a @@ -2300,7 +2404,7 @@ impl GstPlaybackRuntime { self.startup_ramp_hold_left = Some(left); if self.applied_volume != 0.0 { self.applied_volume = 0.0; - self.playbin.set_property("volume", 0.0_f64); + self.write_applied_volume(); } return; } @@ -2334,8 +2438,7 @@ impl GstPlaybackRuntime { self.end_startup_fade(); self.volume_ramp_tick_at = None; } - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + self.write_applied_volume(); } fn poll(&mut self) { @@ -2970,7 +3073,7 @@ impl GstPlaybackRuntime { self.applied_volume = 0.0; self.output_rise = OutputRise::Silenced; self.volume_ramp_tick_at = None; - self.playbin.set_property("volume", 0.0_f64); + self.write_applied_volume(); } fn switch_track(&mut self, path: &Path, uri: &str, force_play: bool) { @@ -2992,8 +3095,7 @@ impl GstPlaybackRuntime { self.snapshot.state = PlaybackState::Playing; // Re-assert the volume after the state change, so that elements // the new run created also have it. - self.playbin - .set_property("volume", f64::from(self.applied_volume)); + self.write_applied_volume(); } else if self.snapshot.state == PlaybackState::Paused { let _ = self.set_pipeline_state(gst::State::Paused); self.end_startup_fade(); @@ -3062,10 +3164,11 @@ fn start_gst_engine( ); runtime.output_segments = output_segments; runtime.input_switches.connect(&runtime.playbin); - connect_about_to_finish(&runtime); runtime - .playbin - .set_property("volume", f64::from(runtime.applied_volume)); + .stream_volume + .watch_output(analysis_sink.upcast_ref()); + connect_about_to_finish(&runtime); + runtime.write_applied_volume(); Ok((runtime, bus)) } @@ -3128,6 +3231,7 @@ fn run_gst_engine( // stopped, there is none. let (wake_tx, wake_rx) = bounded::<()>(1); let located_rx = runtime.located_rx.clone(); + let stream_volume_rx = runtime.stream_volume_rx.clone(); runtime.releases.wake_with(wake_tx.clone()); // Nothing posts on the bus of the stopped pipeline meanwhile, so every // message from here on is counted. @@ -3170,6 +3274,13 @@ fn run_gst_engine( runtime.handle_located(located); } } + recv(stream_volume_rx) -> report => { + if let Ok(report) = report { + runtime.drain_bus(bus); + runtime.handle_stream_volume(report); + runtime.handle_stream_volume_reports(); + } + } recv(timer) -> _ => {} } runtime.drain_bus(bus); @@ -6415,6 +6526,85 @@ mod engine_tests { }); } + /// The sink that the current run of an output made by + /// `volume_applying_output` plays to. + fn stream_sink(playbin: &gst::Element) -> gst::Element { + playbin + .clone() + .downcast::() + .unwrap() + .iterate_recurse() + .into_iter() + .filter_map(Result::ok) + .find(|element| { + element + .factory() + .is_some_and(|factory| factory.name() == "fakeaudiosink") + }) + .expect("the output's sink") + } + + /// Change the stream of `sink` as another program does through the + /// sound server, such as Plasma's per-app volume slider: the sink tells + /// of it on a thread of its own, not the engine's. + fn change_stream_elsewhere(sink: &gst::Element, change: impl FnOnce(&gst::Element) + Send) { + std::thread::scope(|scope| { + scope.spawn(|| change(sink)); + }); + } + + /// The linear gain of a stream that the sound server, and Plasma, show + /// at volume `level`. + fn server_gain(level: f64) -> f64 { + gstreamer_audio::StreamVolume::convert_volume( + gstreamer_audio::StreamVolumeFormat::Cubic, + gstreamer_audio::StreamVolumeFormat::Linear, + level, + ) + } + + /// Have each sink of the output of `playbin` notify its volume and mute + /// again on another thread after every change, as pulsesink does once + /// the sound server has told it that its stream changed, which the + /// server does for the engine's own writes too. + fn echo_stream_changes(playbin: &gst::Element) { + const ECHO_THREAD: &str = "ferrous-test-stream-echo"; + let (echo_tx, echo_rx) = unbounded::>(); + std::thread::Builder::new() + .name(ECHO_THREAD.to_string()) + .spawn(move || { + while let Ok(sink) = echo_rx.recv() { + if let Some(sink) = sink.upgrade() { + sink.notify("volume"); + sink.notify("mute"); + } + } + }) + .unwrap(); + let output = playbin + .property::("audio-sink") + .downcast::() + .unwrap(); + output.connect_deep_element_added(move |_, _, element| { + if element + .factory() + .is_none_or(|factory| factory.name() != "fakeaudiosink") + { + return; + } + for property in ["notify::volume", "notify::mute"] { + let echo_tx = echo_tx.clone(); + element.connect(property, false, move |values| { + if std::thread::current().name() != Some(ECHO_THREAD) { + let sink = values.first()?.get::().ok()?; + let _ = echo_tx.send(sink.downgrade()); + } + None + }); + } + }); + } + /// The output a test plays to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TestOutput { @@ -6548,6 +6738,8 @@ mod engine_tests { errors: Vec, /// Positions of the `Seeked` events. seeked: Vec, + /// Levels of the `OutsideVolume` events. + outside_volumes: Vec, prepared: Vec, cancelled_staging: usize, trace: Trace, @@ -6607,6 +6799,7 @@ mod engine_tests { changes: Vec::new(), errors: Vec::new(), seeked: Vec::new(), + outside_volumes: Vec::new(), prepared: Vec::new(), cancelled_staging: 0, trace, @@ -6693,6 +6886,7 @@ mod engine_tests { PlaybackEvent::Error(error) => self.errors.push(error), PlaybackEvent::Seeked { position } => self.seeked.push(position), PlaybackEvent::PathsRemapped => {} + PlaybackEvent::OutsideVolume { level, .. } => self.outside_volumes.push(level), } } @@ -9261,6 +9455,7 @@ mod engine_tests { while let Ok(located) = self.runtime.located_rx.try_recv() { self.runtime.handle_located(located); } + self.runtime.handle_stream_volume_reports(); self.runtime.poll(); } } @@ -12489,6 +12684,322 @@ mod engine_tests { engine.assert_plays_at_once_at(1.0); } + #[test] + fn a_volume_set_on_the_stream_elsewhere_becomes_the_users_volume_and_stays() { + let fixtures = Fixtures::new("outside-volume"); + let a = fixtures.wav_at("a.wav", 4_000, 48_000); + let b = fixtures.wav_at("b.wav", 4_000, 48_000); + let mut engine = Engine::start_with_output(volume_applying_output); + echo_stream_changes(&engine.playbin); + engine.send(PlaybackCommand::LoadQueue(vec![a, b])); + engine.send(PlaybackCommand::Play); + engine.wait_until("the position did not reach 0.5 s", |engine| { + engine.snapshot.position >= millis(500) + }); + // 66 % on Plasma's per-app slider reads as 66 %. + let gain = server_gain(0.66); + let sink = stream_sink(&engine.playbin); + change_stream_elsewhere(&sink, |sink| sink.set_property("volume", gain)); + engine.wait_until("the volume set elsewhere was not taken", |engine| { + engine.snapshot.volume < 1.0 + }); + assert_eq!(engine.outside_volumes, [0.66]); + assert_eq!(engine.snapshot.volume, 0.66); + // The stream plays on at it: no fade takes it back. + engine.wait_until("the position did not reach 1 s", |engine| { + engine.snapshot.position >= millis(1_000) + }); + assert!((sink.property::("volume") - gain).abs() < 1e-12); + + // A restart, for a seek that reaches the successor handed over, + // plays to a new sink, at the volume set elsewhere. + engine.wait_until("the successor was not handed over by 3 s", |engine| { + engine.queue.lock().unwrap().successor_handed_to_pipeline() + && engine.snapshot.position >= millis(3_000) + }); + engine.send(PlaybackCommand::Seek(millis(1_000))); + engine.wait_until("the restarted run did not play to 1.6 s", |engine| { + engine.snapshot.seek_landed_serial == 1 + && engine.snapshot.current_queue_index == Some(0) + && engine.snapshot.position >= millis(1_600) + }); + let restarted = stream_sink(&engine.playbin); + assert_ne!(restarted, sink, "the run was not restarted"); + let level_gain = f64::from(super::super::volume_level_to_gain(0.66)); + assert!((restarted.property::("volume") - level_gain).abs() < 1e-6); + + // So does a track change. + engine.send(PlaybackCommand::Next); + engine.wait_until("the next track did not play to 0.5 s", |engine| { + engine.snapshot.current_queue_index == Some(1) + && engine.snapshot.position >= millis(500) + }); + let next = stream_sink(&engine.playbin); + assert_ne!(next, restarted); + assert!((next.property::("volume") - level_gain).abs() < 1e-6); + assert_eq!(engine.snapshot.volume, 0.66); + assert_eq!(engine.outside_volumes, [0.66]); + } + + #[test] + fn a_mute_set_on_the_stream_elsewhere_shows_as_volume_0_and_keeps_the_volume() { + let fixtures = Fixtures::new("outside-mute"); + let a = fixtures.wav_at("a.wav", 4_000, 48_000); + let b = fixtures.wav_at("b.wav", 4_000, 48_000); + let mut engine = Engine::start_with_output(volume_applying_output); + echo_stream_changes(&engine.playbin); + let gain = |level: f32| f64::from(super::super::volume_level_to_gain(level)); + engine.send(PlaybackCommand::SetVolume(0.8)); + engine.send(PlaybackCommand::LoadQueue(vec![a, b])); + engine.send(PlaybackCommand::Play); + engine.wait_until("the position did not reach 0.5 s", |engine| { + engine.snapshot.position >= millis(500) + }); + let sink = stream_sink(&engine.playbin); + change_stream_elsewhere(&sink, |sink| sink.set_property("mute", true)); + engine.wait_until("the mute set elsewhere was not taken", |engine| { + engine.snapshot.volume == 0.0 + }); + assert_eq!(engine.outside_volumes, [0.0]); + assert!(sink.property::("mute")); + assert!((sink.property::("volume") - gain(0.8)).abs() < 1e-6); + + // The next track's stream stays muted, at the volume. + engine.send(PlaybackCommand::Next); + engine.wait_until("the next track did not play to 0.5 s", |engine| { + engine.snapshot.current_queue_index == Some(1) + && engine.snapshot.position >= millis(500) + }); + let sink = stream_sink(&engine.playbin); + assert!(sink.property::("mute")); + assert!((sink.property::("volume") - gain(0.8)).abs() < 1e-6); + assert_eq!(engine.snapshot.volume, 0.0); + + // Unmuted elsewhere, the volume comes back. + change_stream_elsewhere(&sink, |sink| sink.set_property("mute", false)); + engine.wait_until("the unmute set elsewhere was not taken", |engine| { + engine.snapshot.volume > 0.0 + }); + assert_eq!(engine.snapshot.volume, 0.8); + + // A volume set in the app unmutes the stream. + change_stream_elsewhere(&sink, |sink| sink.set_property("mute", true)); + engine.wait_until("the mute set elsewhere was not taken", |engine| { + engine.snapshot.volume == 0.0 + }); + engine.send(PlaybackCommand::SetVolume(0.4)); + engine.wait_until("the output did not reach the volume set", |engine| { + engine.snapshot.volume == 0.4 + && (sink.property::("volume") - gain(0.4)).abs() < 1e-6 + }); + assert!(!sink.property::("mute")); + assert_eq!(engine.outside_volumes, [0.0, 0.8, 0.0]); + } + + #[test] + fn the_engines_own_volume_changes_are_never_taken_for_changes_made_elsewhere() { + // Fades, the silence of new runs and restarts, gapless and + // Repeat-One switches, with every change notified again later as + // pulsesink does. + let fixtures = Fixtures::new("own-volume-changes"); + let a = fixtures.wav_at("a.wav", 2_000, 48_000); + let b = fixtures.wav_at("b.wav", 2_000, 48_000); + let c = fixtures.wav_at("c.wav", 2_000, 48_000); + let mut engine = Engine::start_with_output(volume_applying_output); + echo_stream_changes(&engine.playbin); + engine.send(PlaybackCommand::SetVolume(0.8)); + engine.send(PlaybackCommand::LoadQueue(vec![a, b.clone(), c])); + engine.send(PlaybackCommand::Play); + engine.wait_until("the position did not reach 0.3 s", |engine| { + engine.snapshot.position >= millis(300) + }); + // A fade while playing. + engine.send(PlaybackCommand::SetVolume(0.3)); + engine.wait_until("the position did not reach 0.7 s", |engine| { + engine.snapshot.position >= millis(700) + }); + // Volume changes while paused, and the resume. + engine.send(PlaybackCommand::Pause); + engine.wait_until("playback did not pause", |engine| { + engine.snapshot.state == PlaybackState::Paused + }); + engine.send(PlaybackCommand::SetVolume(0.0)); + engine.send(PlaybackCommand::SetVolume(0.6)); + engine.send(PlaybackCommand::Play); + // A seek, then the gapless switch to b, and its Repeat-One replay. + engine.send(PlaybackCommand::Seek(millis(1_200))); + engine.wait_until("b did not start", |engine| { + engine.snapshot.current_queue_index == Some(1) + }); + engine.send(PlaybackCommand::SetRepeatMode(RepeatMode::One)); + engine.wait_until("b did not play again", |engine| { + engine + .changes + .iter() + .filter(|change| change.path == b) + .count() + >= 2 + }); + engine.send(PlaybackCommand::SetRepeatMode(RepeatMode::Off)); + // A track change, and a stop and start. + engine.send(PlaybackCommand::Next); + engine.wait_until("c did not play to 0.3 s", |engine| { + engine.snapshot.current_queue_index == Some(2) + && engine.snapshot.position >= millis(300) + }); + engine.send(PlaybackCommand::Stop); + engine.send(PlaybackCommand::Play); + engine.wait_until("c did not play to 0.5 s again", |engine| { + engine.snapshot.state == PlaybackState::Playing + && engine.snapshot.position >= millis(500) + }); + assert!( + engine.outside_volumes.is_empty(), + "{:?}", + engine.outside_volumes + ); + assert_eq!(engine.snapshot.volume, 0.6); + let gain = f64::from(super::super::volume_level_to_gain(0.6)); + assert!((stream_sink(&engine.playbin).property::("volume") - gain).abs() < 1e-6); + } + + /// Have `sink` tell of its stream again, as pulsesink does from its + /// streaming thread as it plays once the server has told it of a + /// change. + fn renotify_stream(sink: &gst::Element) { + change_stream_elsewhere(sink, |sink| sink.notify("volume")); + } + + #[test] + fn a_volume_set_elsewhere_to_one_the_app_set_while_paused_is_taken() { + // At 80 %, muted in the app while paused and set back to 80 %, with + // the stream read only before the pause and after the resume. + let fixtures = Fixtures::new("outside-volume-repeated"); + let a = fixtures.wav("a.wav", 4_000); + let mut engine = DirectEngine::start_with_output(volume_applying_output); + engine + .runtime + .apply_command(PlaybackCommand::SetVolume(0.8)); + engine.play_until_opened(vec![a], RepeatMode::Off); + engine.finish_fade(); + let sink = stream_sink(&engine.runtime.playbin); + renotify_stream(&sink); + engine.runtime.handle_stream_volume_reports(); + engine.runtime.apply_command(PlaybackCommand::Pause); + engine + .runtime + .apply_command(PlaybackCommand::SetVolume(0.0)); + engine + .runtime + .apply_command(PlaybackCommand::SetVolume(0.8)); + engine.runtime.apply_command(PlaybackCommand::Play); + renotify_stream(&sink); + engine.runtime.handle_stream_volume_reports(); + assert_eq!(engine.runtime.snapshot.volume, 0.8); + // 0 % set in Plasma is the user's volume, not the app's mute. + change_stream_elsewhere(&sink, |sink| sink.set_property("volume", 0.0_f64)); + engine.runtime.handle_stream_volume_reports(); + assert_eq!(engine.runtime.snapshot.volume, 0.0); + assert!(engine.events.try_iter().any(|event| matches!( + event, + PlaybackEvent::OutsideVolume { level, .. } if level == 0.0 + ))); + } + + #[test] + fn a_volume_set_elsewhere_before_one_set_in_the_app_does_not_replace_it() { + let fixtures = Fixtures::new("outside-volume-before-app"); + let a = fixtures.wav("a.wav", 4_000); + let mut engine = DirectEngine::start_with_output(volume_applying_output); + engine.play_until_opened(vec![a], RepeatMode::Off); + engine.finish_fade(); + let sink = stream_sink(&engine.runtime.playbin); + // 66 % in Plasma is read, and waits, as the user's 30 % arrives and + // is taken first; its fade has not written yet. + change_stream_elsewhere(&sink, |sink| sink.set_property("volume", server_gain(0.66))); + engine + .runtime + .apply_command(PlaybackCommand::SetVolume(0.3)); + let now = Instant::now(); + engine.runtime.poll_volume_ramp(now); + assert!(engine.runtime.volume_ramp_pending()); + engine.runtime.handle_stream_volume_reports(); + assert_eq!(engine.runtime.snapshot.volume, 0.3); + assert!( + (engine.runtime.target_volume - super::super::volume_level_to_gain(0.3)).abs() + < f32::EPSILON + ); + assert!(!engine + .events + .try_iter() + .any(|event| matches!(event, PlaybackEvent::OutsideVolume { .. }))); + // The fade goes on to the user's volume. + engine + .runtime + .poll_volume_ramp(now + Duration::from_secs(1)); + let gain = f64::from(super::super::volume_level_to_gain(0.3)); + assert!((sink.property::("volume") - gain).abs() < 1e-6); + } + + #[test] + fn the_same_volume_set_in_the_app_after_one_set_elsewhere_plays() { + // At 80 %, 66 % set in Plasma waits to be taken in as the user sets + // 80 % again, such as by releasing the slider where it was. + let fixtures = Fixtures::new("outside-volume-then-same"); + let a = fixtures.wav("a.wav", 4_000); + let mut engine = DirectEngine::start_with_output(volume_applying_output); + engine + .runtime + .apply_command(PlaybackCommand::SetVolume(0.8)); + engine.play_until_opened(vec![a], RepeatMode::Off); + engine.finish_fade(); + let sink = stream_sink(&engine.runtime.playbin); + change_stream_elsewhere(&sink, |sink| sink.set_property("volume", server_gain(0.66))); + engine + .runtime + .apply_command(PlaybackCommand::SetVolume(0.8)); + engine.runtime.handle_stream_volume_reports(); + let now = Instant::now(); + engine.runtime.poll_volume_ramp(now); + engine + .runtime + .poll_volume_ramp(now + Duration::from_secs(1)); + assert_eq!(engine.runtime.snapshot.volume, 0.8); + let gain = f64::from(super::super::volume_level_to_gain(0.8)); + assert!( + (sink.property::("volume") - gain).abs() < 1e-6, + "the stream stayed at {}", + sink.property::("volume") + ); + } + + #[test] + fn a_volume_set_elsewhere_while_a_run_is_silenced_waits_until_it_plays() { + let fixtures = Fixtures::new("outside-volume-silenced"); + let a = fixtures.wav("a.wav", 4_000); + let b = fixtures.wav("b.wav", 4_000); + let mut engine = DirectEngine::start_with_output(volume_applying_output); + engine.play_until_opened(vec![a, b], RepeatMode::Off); + engine.finish_fade(); + engine.runtime.apply_command(PlaybackCommand::Pause); + // A track picked while paused, silenced until it plays. + engine.runtime.apply_command(PlaybackCommand::PlayAt(1)); + engine.wait_until("row 1 did not preroll", |runtime| { + runtime.pipeline_prerolled() + }); + assert!(engine.runtime.output_silenced()); + let sink = stream_sink(&engine.runtime.playbin); + change_stream_elsewhere(&sink, |sink| sink.set_property("volume", server_gain(0.5))); + engine.runtime.handle_stream_volume_reports(); + assert_eq!(engine.runtime.snapshot.volume, 0.5); + // The stream is silent again until the run plays, then plays at + // once at the volume. + assert!(engine.runtime.output_silenced()); + assert!(sink.property::("volume").abs() < f64::EPSILON); + engine.runtime.apply_command(PlaybackCommand::Play); + engine.assert_plays_at_once_at(0.5); + } + #[test] fn a_cued_start_within_a_track_fades_in_on_an_output_that_applies_the_volume() { // Within a track, a start from a preroll silenced for the cue fades diff --git a/src/playback/mod.rs b/src/playback/mod.rs index bf0d8dbf..6ac3d13c 100644 --- a/src/playback/mod.rs +++ b/src/playback/mod.rs @@ -227,6 +227,15 @@ pub enum PlaybackEvent { /// The engine applied a [`PlaybackCommand::RemapPaths`]. Events sent /// before this one may still carry the old paths; later ones do not. PathsRemapped, + /// The volume was changed outside the app, on the output's stream, such + /// as with Plasma's per-app volume slider, and is now `level` (see + /// [`volume_level_to_gain`]); a mute there shows as 0. The engine took + /// it as the user's volume after `set_volume_commands` + /// [`PlaybackCommand::SetVolume`] commands; a later one replaces it. + OutsideVolume { + level: f32, + set_volume_commands: u64, + }, /// A track could not be played, or the engine cannot play at all. Error(PlaybackError), } @@ -1656,6 +1665,8 @@ mod queue; mod seek_locate; #[cfg(feature = "gst")] pub(crate) mod source_activation; +#[cfg(feature = "gst")] +mod stream_volume; #[cfg(not(feature = "gst"))] #[path = "backend_mock.rs"] diff --git a/src/playback/stream_volume.rs b/src/playback/stream_volume.rs new file mode 100644 index 00000000..cc47ee9d --- /dev/null +++ b/src/playback/stream_volume.rs @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Following the volume of the output's stream, which other programs can +//! change too. +//! +//! An output that controls the volume of its stream, as pulsesink does, +//! gets the engine's volume from playsink, and the sound server applies it +//! as the stream plays. Plasma's volume applet and the task manager's +//! per-app slider set that same stream volume and mute. The sink tells of +//! a change with `notify::volume` and `notify::mute`: pulsesink from its +//! streaming thread, as it plays, once the server has told it of any change +//! of the stream, the engine's own included, and every sink also from the +//! thread that sets the property. +//! +//! The engine writes the stream volume itself: the volume the user picked, +//! its fades, and the silence of a new run. To tell those writes from a +//! change made elsewhere, it numbers them. The server applies them in +//! order, but some time after it has taken them: pipewire-pulse reads back +//! a stream's earlier volume for a while. So a value the stream shows is +//! the engine's own if it is one of its writes from the latest the stream +//! has shown on; a value that none of them explains was set by someone +//! else. A value read before the engine's latest write began tells nothing: +//! that write is newer on the server, and a change made after it is +//! notified again. + +use std::cell::Cell; +use std::collections::VecDeque; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +use crossbeam_channel::{Receiver, Sender}; +use gstreamer as gst; +use gstreamer::prelude::*; +use gstreamer_audio::{StreamVolume, StreamVolumeFormat}; + +thread_local! { + /// Whether this thread is writing the stream volume for the engine. + /// A sink notifies a property that is set from the thread that sets + /// it, so a notification on such a thread is the engine's own write. + static WRITING: Cell = const { Cell::new(false) }; + /// Whether this thread is reading a sink's stream volume to report it. + static READING: Cell = const { Cell::new(false) }; +} + +/// What a sink that controls the volume of its stream read when it told of +/// a change. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) struct StreamVolumeReport { + /// The linear gain of the stream. + pub(super) gain: f64, + pub(super) muted: bool, + /// The engine's writes that had begun when the read had returned: the + /// value shows at most these. + pub(super) writes_begun: u64, +} + +/// What the engine makes of a [`StreamVolumeReport`]. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(super) enum StreamVolumeChange { + /// The stream is as the engine set it, now or a little earlier. + Unchanged, + /// Something other than the engine set the stream to this gain and + /// mute. + Outside { gain: f64, muted: bool }, +} + +/// The stream as the engine left it after one of its writes. +#[derive(Debug, Clone, Copy)] +struct WrittenState { + /// The number of the write; 0 is the state the engine started from. + write: u64, + gain: f64, + muted: bool, +} + +/// The most writes the stream has not shown yet that are kept. The stream +/// shows them as it plays, so they pile up only while it does not, such as +/// for volume changes while paused, and then only the latest ones matter. +const WRITES_KEPT: usize = 256; + +/// How close, on the cubic scale, a gain read back from the stream must be +/// to a gain the engine wrote to be that write. The sound server keeps the +/// volume as a 16-bit fraction on that scale, so a write reads back within +/// half of its 1/65536 step; a step of a volume slider is 100 times more. +const SAME_LEVEL: f64 = 1e-4; + +/// The engine's side of following the stream volume, shared with the +/// notification handlers of the output's sinks. +pub(super) struct StreamVolumeWatch { + /// The engine's writes that have begun. + begun: AtomicU64, + /// The latest write the stream has shown, then the writes after it, + /// newest last. Only the engine's thread uses it; the lock lets the + /// writes stay `&self`. + written: Mutex>, + /// Held while a sink's stream is read and reported, so that the + /// reports arrive in the order of their reads, also when sinks notify + /// from more than one thread: a value read before another is never + /// taken in after it. + reading: Mutex<()>, + reports: Sender, +} + +impl StreamVolumeWatch { + /// A watch of a stream that the engine has set to `gain`, unmuted, and + /// the receiver of its reports. + pub(super) fn new(gain: f64) -> (Arc, Receiver) { + let (reports, received) = crossbeam_channel::unbounded(); + let watch = Arc::new(Self { + begun: AtomicU64::new(0), + written: Mutex::new(VecDeque::from([WrittenState { + write: 0, + gain, + muted: false, + }])), + reading: Mutex::new(()), + reports, + }); + (watch, received) + } + + /// Follow the sinks inside `output` that control the volume of their + /// stream, those it has and those it makes later, as autoaudiosink + /// does for each run. These are the sinks playsink gives the volume to. + pub(super) fn watch_output(self: &Arc, output: &gst::Element) { + if let Some(bin) = output.downcast_ref::() { + for element in bin.iterate_recurse().into_iter().flatten() { + self.watch_sink(&element); + } + let watch = Arc::downgrade(self); + bin.connect_deep_element_added(move |_, _, element| { + if let Some(watch) = watch.upgrade() { + watch.watch_sink(element); + } + }); + } else { + self.watch_sink(output); + } + } + + /// Report the volume and mute of `element` whenever it tells of a + /// change, if it controls the volume of its stream. + fn watch_sink(self: &Arc, element: &gst::Element) { + if !element.is::() { + return; + } + for property in ["notify::volume", "notify::mute"] { + let watch = Arc::downgrade(self); + element.connect(property, false, move |values| { + let sink = values.first()?.get::().ok()?; + if let Some(watch) = watch.upgrade() { + watch.report(&sink); + } + None + }); + } + } + + /// Read the volume and mute of `sink` and report them, unless the + /// engine is setting them on this thread, or this thread reads them + /// already. A sink below Paused has not opened its stream, so nothing + /// else can have changed it: it is notified as playsink sets it up for + /// a run, giving it the volume, then the mute, the engine set. Runs on + /// the thread that notifies, such as pulsesink's streaming thread, + /// whose read asks the sound server; it waits only for another such + /// read, never for the engine. + fn report(&self, sink: &gst::Element) { + if WRITING.with(Cell::get) + || READING.with(Cell::get) + || sink.current_state() < gst::State::Paused + { + return; + } + let _reading = self.reading.lock().unwrap_or_else(PoisonError::into_inner); + READING.with(|reading| reading.set(true)); + let gain = sink.property::("volume"); + let muted = sink.property::("mute"); + let writes_begun = self.begun.load(Ordering::SeqCst); + READING.with(|reading| reading.set(false)); + let _ = self.reports.send(StreamVolumeReport { + gain, + muted, + writes_begun, + }); + } + + /// Mark a change of the volume the user picked, which the engine may + /// write only later, as by a fade: what a sink read before it is older + /// than that volume, and tells nothing. The stream is still at `gain` + /// and `muted`. + pub(super) fn mark(&self, gain: f64, muted: bool) { + self.write(gain, muted, || {}); + } + + /// Write the stream volume with `write`, which leaves the stream at + /// `gain` and `muted`. The sink's notifications of the write on this + /// thread are not reported. + pub(super) fn write(&self, gain: f64, muted: bool, write: impl FnOnce()) { + let number = self.begun.fetch_add(1, Ordering::SeqCst) + 1; + WRITING.with(|writing| writing.set(true)); + write(); + WRITING.with(|writing| writing.set(false)); + if let Ok(mut written) = self.written.lock() { + if written.len() == WRITES_KEPT { + written.pop_front(); + } + written.push_back(WrittenState { + write: number, + gain, + muted, + }); + } + } + + /// What `report` tells of the stream. A write the stream shows, and the + /// writes after it, are all it can show of the engine's from then on. + /// A value the engine wrote more than once is taken as its latest write + /// of it, so a value the stream went past is not explained by an older + /// write of its own; the engine does not write a value again within + /// the moment the server takes to apply its writes, apart from writing + /// the same value twice in a row. + /// Called on the engine's thread, which writes, so no write is under + /// way. + pub(super) fn classify(&self, report: StreamVolumeReport) -> StreamVolumeChange { + let Ok(mut written) = self.written.lock() else { + return StreamVolumeChange::Unchanged; + }; + // Read before the engine's latest write began, which is newer on + // the server. + if written + .back() + .is_none_or(|latest| report.writes_begun != latest.write) + { + return StreamVolumeChange::Unchanged; + } + let level = gain_to_level(report.gain); + // The latest write of the value: an earlier one of the same value + // may have been followed by writes the stream never showed, such + // as volume changes while paused, which it has gone past too. + let shown = written.iter().rposition(|state| { + state.muted == report.muted && (gain_to_level(state.gain) - level).abs() < SAME_LEVEL + }); + if let Some(shown) = shown { + written.drain(..shown); + StreamVolumeChange::Unchanged + } else { + StreamVolumeChange::Outside { + gain: report.gain, + muted: report.muted, + } + } + } + + /// Take the stream's `gain` and `muted`, set elsewhere, as the state + /// the engine left it in: the latest the stream shows, so that later + /// reports of it are no change. + pub(super) fn adopt(&self, gain: f64, muted: bool) { + if let Ok(mut written) = self.written.lock() { + let latest = written.len().saturating_sub(1); + written.drain(..latest); + if let Some(latest) = written.back_mut() { + latest.gain = gain; + latest.muted = muted; + } + } + } +} + +/// The volume level, as the slider shows it, of a stream's linear gain: +/// `GStreamer`'s cubic stream volume, which is also the percentage the +/// sound server and Plasma show. Not clamped. +pub(super) fn gain_to_level(gain: f64) -> f64 { + StreamVolume::convert_volume( + StreamVolumeFormat::Linear, + StreamVolumeFormat::Cubic, + gain.max(0.0), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn report(gain: f64, muted: bool, writes_begun: u64) -> StreamVolumeReport { + StreamVolumeReport { + gain, + muted, + writes_begun, + } + } + + #[test] + fn a_cubic_level_survives_the_linear_stream_volume_exactly() { + gst::init().unwrap(); + // 66 % on Ferrous's slider, as the engine sets it on the stream. + let gain = f64::from(crate::playback::volume_level_to_gain(0.66)); + #[allow(clippy::cast_possible_truncation)] + let level = gain_to_level(gain) as f32; + assert_eq!(level, 0.66_f32); + // 66 % set by Plasma: the sound server's cubic volume, which + // pulsesink reads as linear. + let plasma = StreamVolume::convert_volume( + StreamVolumeFormat::Cubic, + StreamVolumeFormat::Linear, + 0.66, + ); + #[allow(clippy::cast_possible_truncation)] + let level = gain_to_level(plasma) as f32; + assert_eq!(level, 0.66_f32); + assert_eq!((gain_to_level(plasma) * 100.0).round(), 66.0); + } + + #[test] + fn the_engines_own_writes_are_no_change() { + gst::init().unwrap(); + let (watch, _reports) = StreamVolumeWatch::new(1.0); + assert_eq!( + watch.classify(report(1.0, false, 0)), + StreamVolumeChange::Unchanged + ); + // A fade: the stream shows each step, a little off as the server's + // volume steps are, and late, as the server applies the steps after + // it has taken them. + for gain in [0.5_f64, 0.25, 0.125] { + watch.write(gain, false, || {}); + } + for gain in [1.0, 0.5 * 1.000_01, 0.25, 0.125] { + assert_eq!( + watch.classify(report(gain, false, 3)), + StreamVolumeChange::Unchanged, + "{gain}" + ); + } + // A read before the latest write began tells nothing, whatever it + // read. + assert_eq!( + watch.classify(report(0.9, false, 2)), + StreamVolumeChange::Unchanged + ); + } + + #[test] + fn a_change_made_elsewhere_is_told_once() { + gst::init().unwrap(); + let (watch, _reports) = StreamVolumeWatch::new(1.0); + watch.write(0.5, false, || {}); + let change = watch.classify(report(0.2, false, 1)); + assert_eq!( + change, + StreamVolumeChange::Outside { + gain: 0.2, + muted: false + } + ); + watch.adopt(0.2, false); + assert_eq!( + watch.classify(report(0.2, false, 1)), + StreamVolumeChange::Unchanged + ); + // A mute set elsewhere is a change too, at the same volume. + assert_eq!( + watch.classify(report(0.2, true, 1)), + StreamVolumeChange::Outside { + gain: 0.2, + muted: true + } + ); + watch.adopt(0.2, true); + // So is a write of the engine's that the stream has gone past: the + // server applies the writes in order, so someone set it back. + watch.write(0.3, true, || {}); + watch.write(0.4, true, || {}); + assert_eq!( + watch.classify(report(0.4, true, 3)), + StreamVolumeChange::Unchanged + ); + assert_eq!( + watch.classify(report(0.3, true, 3)), + StreamVolumeChange::Outside { + gain: 0.3, + muted: true + } + ); + } + + #[test] + fn a_value_written_again_after_writes_the_stream_did_not_show_is_the_latest() { + gst::init().unwrap(); + // At 80 %, paused, muted in the app, and back to 80 %, with no read + // of the stream meanwhile, as pulsesink reads none while paused. + let (watch, _reports) = StreamVolumeWatch::new(0.512); + watch.write(0.0, false, || {}); + watch.write(0.512, false, || {}); + assert_eq!( + watch.classify(report(0.512, false, 2)), + StreamVolumeChange::Unchanged + ); + // The stream went past the mute: 0 set in Plasma is a change. + assert_eq!( + watch.classify(report(0.0, false, 2)), + StreamVolumeChange::Outside { + gain: 0.0, + muted: false + } + ); + } + + #[test] + fn a_read_before_a_volume_change_the_engine_has_yet_to_write_tells_nothing() { + gst::init().unwrap(); + let (watch, _reports) = StreamVolumeWatch::new(1.0); + // Read at 66 %, set in Plasma, before the user's volume change, + // whose fade has not written yet. + let read = report(0.287_496, false, 0); + watch.mark(1.0, false); + assert_eq!(watch.classify(read), StreamVolumeChange::Unchanged); + // Read after it, the stream still shows the volume before. + assert_eq!( + watch.classify(report(1.0, false, 1)), + StreamVolumeChange::Unchanged + ); + } + + #[test] + fn notifications_of_the_engines_own_writes_on_its_thread_are_not_read() { + gst::init().unwrap(); + let (watch, reports) = StreamVolumeWatch::new(1.0); + let source = gst::ElementFactory::make("audiotestsrc").build().unwrap(); + let sink = gst::ElementFactory::make("fakeaudiosink") + .build() + .expect("fakeaudiosink, from gst-plugins-bad"); + let output = gst::Bin::new(); + watch.watch_output(output.upcast_ref()); + let pipeline = gst::Pipeline::new(); + pipeline.add_many([&source, output.upcast_ref()]).unwrap(); + output.add(&sink).unwrap(); + let pad = sink.static_pad("sink").unwrap(); + output + .add_pad(&gst::GhostPad::with_target(&pad).unwrap()) + .unwrap(); + source.link(&output).unwrap(); + // Set up for a run before it has a stream: nothing else can have + // changed it. + std::thread::scope(|scope| { + scope.spawn(|| sink.set_property("volume", 0.25_f64)); + }); + assert!(reports.try_recv().is_err()); + pipeline.set_state(gst::State::Paused).unwrap(); + let (changed, _, _) = pipeline.state(gst::ClockTime::from_seconds(10)); + changed.unwrap(); + watch.write(0.5, false, || sink.set_property("volume", 0.5_f64)); + assert!(reports.try_recv().is_err()); + // Set elsewhere: reported with the writes it may show. + std::thread::scope(|scope| { + scope.spawn(|| sink.set_property("mute", true)); + }); + assert_eq!(reports.try_recv().unwrap(), report(0.5, true, 1)); + pipeline.set_state(gst::State::Null).unwrap(); + } +} diff --git a/ui/tests/tst_bridge_client.cpp b/ui/tests/tst_bridge_client.cpp index 9f0e5e27..ea35fcec 100644 --- a/ui/tests/tst_bridge_client.cpp +++ b/ui/tests/tst_bridge_client.cpp @@ -42,6 +42,7 @@ #define private public #include "../src/BridgeClient.h" #include "../src/DiagnosticsLog.h" +#include "../src/MprisController.h" #include "../src/TagEditorController.h" #include "../src/CoverImageProvider.h" #include "../src/CoverRevisionLinks.h" @@ -428,6 +429,7 @@ private slots: void testPlaybackTrackTokenDecoding(); void testChannelButtonsVisibilityDecoding(); void libraryViewStateAvailabilityFollowsSnapshot(); + void volumeTheBackendSendsReachesTheSliderAndMpris(); void libraryChangesNotifyOnlyLibraryProperties(); void testBinariesSetNoFileBrowserNameDetector(); void detectedFileBrowserNameNotifiesOnlyWhenItChanges(); @@ -8738,6 +8740,31 @@ void BridgeClientTest::testChannelButtonsVisibilityDecoding() QCOMPARE(decoded.settings.stretchShortTracks, true); } +// A volume set outside the app, such as with Plasma's per-app slider, comes +// as the volume setting: the slider and MPRIS show it. +void BridgeClientTest::volumeTheBackendSendsReachesTheSliderAndMpris() { + BridgeClient client; + isolateBridgeClient(client); + // Not registered on the bus: the test reads its properties. + MprisController mpris(&client); + BinaryBridgeCodec::DecodedSnapshot snapshot; + snapshot.settings.present = true; + snapshot.settings.systemMediaControlsEnabled = false; + snapshot.settings.volume = 0.66f; + QVERIFY(client.processBinarySnapshot(snapshot)); + QVERIFY(client.m_pollSnapshotChanged); + QCOMPARE(client.volume(), double(0.66f)); + QCOMPARE(mpris.volume(), double(0.66f)); + + // A mute there shows as the volume 0. + client.m_pollSnapshotChanged = false; + snapshot.settings.volume = 0.0f; + QVERIFY(client.processBinarySnapshot(snapshot)); + QVERIFY(client.m_pollSnapshotChanged); + QCOMPARE(client.volume(), 0.0); + QCOMPARE(mpris.volume(), 0.0); +} + void BridgeClientTest::libraryViewStateAvailabilityFollowsSnapshot() { BridgeClient client; isolateBridgeClient(client);