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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- [playback] Prevent clicks caused by decoded PCM discontinuities when manually switching tracks in gapless mode, and when pausing or resuming mid-track
- [audio] Fixed integer overflow in throughput calculation
- [main] Fixed `--volume-ctrl fixed` not disabling volume control
- [core] Fix default permissions on credentials file and warn user if file is world readable
Expand Down
139 changes: 138 additions & 1 deletion playback/src/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,12 @@ use symphonia::core::io::MediaSource;
use symphonia::core::probe::Hint;
use tokio::sync::{mpsc, oneshot};

use crate::SAMPLES_PER_SECOND;
use crate::{NUM_CHANNELS, SAMPLES_PER_SECOND};

const PRELOAD_NEXT_TRACK_BEFORE_END_DURATION_MS: u32 = 30000;
const OUTPUT_FADE_DURATION_MS: usize = 20;
const OUTPUT_FADE_SAMPLES: usize = SAMPLES_PER_SECOND as usize * OUTPUT_FADE_DURATION_MS / 1000;
const OUTPUT_FADE_FRAMES: usize = OUTPUT_FADE_SAMPLES / NUM_CHANNELS as usize;
pub const DB_VOLTAGE_RATIO: f64 = 20.0;
pub const PCM_AT_0DBFS: f64 = 1.0;

Expand Down Expand Up @@ -83,6 +86,9 @@ struct PlayerInternal {
event_senders: Vec<mpsc::UnboundedSender<PlayerEvent>>,
converter: Converter,

last_output_frame: Option<[f64; NUM_CHANNELS as usize]>,
output_fade_in_remaining: usize,

normalisation_integrators: [f64; 2],
normalisation_peaks: [f64; 2],
normalisation_channel: usize,
Expand Down Expand Up @@ -518,6 +524,9 @@ impl Player {
event_senders: vec![],
converter,

last_output_frame: None,
output_fade_in_remaining: 0,

normalisation_peaks: [0.0; 2],
normalisation_integrators: [0.0; 2],
normalisation_channel: 0,
Expand Down Expand Up @@ -1620,7 +1629,57 @@ impl Future for PlayerInternal {
}
}

fn output_fade_out(last_frame: [f64; NUM_CHANNELS as usize]) -> Vec<f64> {
let mut fade = Vec::with_capacity(OUTPUT_FADE_SAMPLES);
for frame in 0..OUTPUT_FADE_FRAMES {
let gain = 1.0 - frame as f64 / (OUTPUT_FADE_FRAMES - 1) as f64;
for sample in last_frame {
fade.push(sample * gain);
}
}
fade
}

fn apply_output_fade_in(data: &mut [f64], remaining: &mut usize) {
if *remaining == 0 {
return;
}

let completed = OUTPUT_FADE_SAMPLES - *remaining;
let count = (*remaining).min(data.len());

for (offset, sample) in data[..count].iter_mut().enumerate() {
let frame = (completed + offset) / NUM_CHANNELS as usize;
let gain = frame as f64 / (OUTPUT_FADE_FRAMES - 1) as f64;
*sample *= gain;
}
*remaining -= count;
}

impl PlayerInternal {
fn write_output_fade_out(&mut self) {
let Some(last_frame) = self.last_output_frame.take() else {
return;
};
if self.sink_status != SinkStatus::Running {
return;
}

let fade = output_fade_out(last_frame);
if let Err(e) = self
.sink
.write(AudioPacket::Samples(fade), &mut self.converter)
{
error!("Unable to fade output to silence: {e}");
self.output_fade_in_remaining = 0;
}
}

fn prepare_manual_track_switch(&mut self) {
self.output_fade_in_remaining = OUTPUT_FADE_SAMPLES;
self.write_output_fade_out();
}

fn ensure_sink_running(&mut self) {
if self.sink_status != SinkStatus::Running {
trace!("== Starting sink ==");
Expand Down Expand Up @@ -1671,6 +1730,10 @@ impl PlayerInternal {
}

fn handle_player_stop(&mut self) {
self.output_fade_in_remaining = 0;
self.write_output_fade_out();
self.last_output_frame = None;

match self.state {
PlayerState::Playing {
ref track_id,
Expand Down Expand Up @@ -1719,6 +1782,7 @@ impl PlayerInternal {
} => {
let track_id = track_id.clone();

self.output_fade_in_remaining = OUTPUT_FADE_SAMPLES;
self.state.paused_to_playing();
self.send_event(PlayerEvent::Playing {
track_id,
Expand Down Expand Up @@ -1748,6 +1812,8 @@ impl PlayerInternal {
} => {
let track_id = track_id.clone();

self.output_fade_in_remaining = 0;
self.write_output_fade_out();
self.state.playing_to_paused();

self.ensure_sink_stopped(false);
Expand Down Expand Up @@ -1866,6 +1932,14 @@ impl PlayerInternal {
}
}
}

apply_output_fade_in(data, &mut self.output_fade_in_remaining);
if data.len() >= NUM_CHANNELS as usize {
let start = data.len() - NUM_CHANNELS as usize;
let mut frame = [0.0; NUM_CHANNELS as usize];
frame.copy_from_slice(&data[start..]);
self.last_output_frame = Some(frame);
}
}

if let Err(e) = self.sink.write(packet, &mut self.converter) {
Expand Down Expand Up @@ -1981,6 +2055,21 @@ impl PlayerInternal {

self.send_event(PlayerEvent::PlayRequestIdChanged { play_request_id });

let switching_tracks = match &self.state {
PlayerState::Playing {
track_id: current_track_id,
..
}
| PlayerState::Paused {
track_id: current_track_id,
..
} => current_track_id != &track_id,
_ => false,
};
if self.config.gapless && switching_tracks {
self.prepare_manual_track_switch();
}

if !self.config.gapless {
self.ensure_sink_stopped(play);
}
Expand Down Expand Up @@ -2702,3 +2791,51 @@ where
Some(self.length)
}
}

#[cfg(test)]
mod tests {
use super::{OUTPUT_FADE_FRAMES, OUTPUT_FADE_SAMPLES, apply_output_fade_in, output_fade_out};

fn assert_near(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 1e-12, "{actual} != {expected}");
}

#[test]
fn output_fade_out_preserves_channels_and_ends_at_zero() {
let fade = output_fade_out([1.0, -0.5]);

assert_eq!(fade.len(), OUTPUT_FADE_SAMPLES);
assert_near(fade[0], 1.0);
assert_near(fade[1], -0.5);
assert_near(fade[fade.len() - 2], 0.0);
assert_near(fade[fade.len() - 1], 0.0);
}

#[test]
fn output_fade_in_is_frame_aligned_across_packets() {
let mut remaining = OUTPUT_FADE_SAMPLES;
let mut first_packet = [1.0; 6];
apply_output_fade_in(&mut first_packet, &mut remaining);

assert_near(first_packet[0], 0.0);
assert_near(first_packet[1], first_packet[0]);
assert_near(first_packet[2], 1.0 / (OUTPUT_FADE_FRAMES - 1) as f64);
assert_near(first_packet[3], first_packet[2]);
assert_eq!(remaining, OUTPUT_FADE_SAMPLES - first_packet.len());

let mut rest = vec![1.0; remaining];
apply_output_fade_in(&mut rest, &mut remaining);
assert_near(*rest.last().unwrap(), 1.0);
assert_eq!(remaining, 0);
}

#[test]
fn output_fade_in_is_a_no_op_when_finished() {
let mut remaining = 0;
let mut packet = [0.75, -0.25];
apply_output_fade_in(&mut packet, &mut remaining);
assert_near(packet[0], 0.75);
assert_near(packet[1], -0.25);
assert_eq!(remaining, 0);
}
}
Loading