diff options
Diffstat (limited to '')
| -rw-r--r-- | src/audio.rs | 159 |
1 files changed, 132 insertions, 27 deletions
diff --git a/src/audio.rs b/src/audio.rs index 5ce00ae..dc7bc56 100644 --- a/src/audio.rs +++ b/src/audio.rs | |||
| @@ -138,7 +138,8 @@ pub struct AudioEngine { | |||
| 138 | 138 | ||
| 139 | #[allow(clippy::new_without_default)] | 139 | #[allow(clippy::new_without_default)] |
| 140 | impl AudioEngine { | 140 | impl AudioEngine { |
| 141 | pub fn new() -> Self { | 141 | /// `sample_rate` forces the output rate; `None` follows each track. |
| 142 | pub fn new(sample_rate: Option<u32>) -> Self { | ||
| 142 | let (tx, rx) = mpsc::channel(); | 143 | let (tx, rx) = mpsc::channel(); |
| 143 | let is_playing = Arc::new(AtomicBool::new(false)); | 144 | let is_playing = Arc::new(AtomicBool::new(false)); |
| 144 | let is_playing_clone = Arc::clone(&is_playing); | 145 | let is_playing_clone = Arc::clone(&is_playing); |
| @@ -161,6 +162,7 @@ impl AudioEngine { | |||
| 161 | volume_clone, | 162 | volume_clone, |
| 162 | track_ended_clone, | 163 | track_ended_clone, |
| 163 | position_clone, | 164 | position_clone, |
| 165 | sample_rate, | ||
| 164 | ); | 166 | ); |
| 165 | }); | 167 | }); |
| 166 | 168 | ||
| @@ -575,6 +577,63 @@ impl RecoveryState { | |||
| 575 | } | 577 | } |
| 576 | } | 578 | } |
| 577 | 579 | ||
| 580 | /// Picks the output config for a requested sample rate. | ||
| 581 | /// | ||
| 582 | /// `requested` is the rate the audio is already in — the track's own, or the | ||
| 583 | /// value forced by the `sample_rate` setting — and `None` keeps whatever the | ||
| 584 | /// device reports as its default. | ||
| 585 | /// | ||
| 586 | /// cpal asks the ALSA "default" PCM for its default config, and pipewire-alsa | ||
| 587 | /// answers 44.1 kHz regardless of what the graph runs at. Opening the stream | ||
| 588 | /// at the track's rate instead means our (linear, cheap) resampler stays out of | ||
| 589 | /// the path and PipeWire's polyphase one converts to the graph rate only when | ||
| 590 | /// the track does not already match it. | ||
| 591 | fn choose_output_config( | ||
| 592 | device: &cpal::Device, | ||
| 593 | requested: Option<u32>, | ||
| 594 | ) -> Result<cpal::SupportedStreamConfig, String> { | ||
| 595 | use cpal::traits::DeviceTrait; | ||
| 596 | |||
| 597 | let default = device | ||
| 598 | .default_output_config() | ||
| 599 | .map_err(|e| format!("Failed to get output config: {}", e))?; | ||
| 600 | let Some(target) = requested else { | ||
| 601 | return Ok(default); | ||
| 602 | }; | ||
| 603 | if default.sample_rate() == target { | ||
| 604 | return Ok(default); | ||
| 605 | } | ||
| 606 | |||
| 607 | let ranges = device | ||
| 608 | .supported_output_configs() | ||
| 609 | .map_err(|e| format!("Failed to query output configs: {}", e))?; | ||
| 610 | let mut fallback = None; | ||
| 611 | for range in ranges { | ||
| 612 | if range.min_sample_rate() > target || range.max_sample_rate() < target { | ||
| 613 | continue; | ||
| 614 | } | ||
| 615 | // Prefer a range that keeps the default's channel count and format. | ||
| 616 | if range.channels() == default.channels() | ||
| 617 | && range.sample_format() == default.sample_format() | ||
| 618 | { | ||
| 619 | return Ok(range.with_sample_rate(target)); | ||
| 620 | } | ||
| 621 | fallback.get_or_insert(range); | ||
| 622 | } | ||
| 623 | |||
| 624 | match fallback { | ||
| 625 | Some(range) => Ok(range.with_sample_rate(target)), | ||
| 626 | None => { | ||
| 627 | crate::debug_eprintln(format_args!( | ||
| 628 | "Output does not support {} Hz, using {} Hz", | ||
| 629 | target, | ||
| 630 | default.sample_rate() | ||
| 631 | )); | ||
| 632 | Ok(default) | ||
| 633 | } | ||
| 634 | } | ||
| 635 | } | ||
| 636 | |||
| 578 | /// Rebuilds the output stream after the backend broke it. | 637 | /// Rebuilds the output stream after the backend broke it. |
| 579 | /// | 638 | /// |
| 580 | /// The default output can change while the daemon runs (a re-plugged DAC, a | 639 | /// The default output can change while the daemon runs (a re-plugged DAC, a |
| @@ -584,16 +643,21 @@ fn rebuild_stream( | |||
| 584 | host: &cpal::Host, | 643 | host: &cpal::Host, |
| 585 | device: &mut cpal::Device, | 644 | device: &mut cpal::Device, |
| 586 | ctx: &StreamContext, | 645 | ctx: &StreamContext, |
| 646 | sample_rate: Option<u32>, | ||
| 587 | ) -> Result<cpal::Stream, String> { | 647 | ) -> Result<cpal::Stream, String> { |
| 588 | use cpal::traits::{DeviceTrait, HostTrait}; | 648 | use cpal::traits::HostTrait; |
| 589 | 649 | ||
| 590 | if let Some(fresh) = host.default_output_device() { | 650 | if let Some(fresh) = host.default_output_device() { |
| 591 | *device = fresh; | 651 | *device = fresh; |
| 592 | } | 652 | } |
| 593 | 653 | ||
| 594 | let config = device | 654 | // Keep the rate the track was opened at, so the resampler ratio stays valid. |
| 595 | .default_output_config() | 655 | let track_rate = ctx |
| 596 | .map_err(|e| format!("Failed to get output config while rebuilding: {}", e))?; | 656 | .audio_data |
| 657 | .lock() | ||
| 658 | .ok() | ||
| 659 | .and_then(|data| data.as_ref().map(|state| state.sample_rate)); | ||
| 660 | let config = choose_output_config(device, sample_rate.or(track_rate))?; | ||
| 597 | let device_sample_rate = config.sample_rate(); | 661 | let device_sample_rate = config.sample_rate(); |
| 598 | 662 | ||
| 599 | // The device may report a different rate now than when the track was | 663 | // The device may report a different rate now than when the track was |
| @@ -619,8 +683,9 @@ fn audio_thread( | |||
| 619 | volume: Arc<Mutex<f32>>, | 683 | volume: Arc<Mutex<f32>>, |
| 620 | track_ended: Arc<AtomicBool>, | 684 | track_ended: Arc<AtomicBool>, |
| 621 | position: Arc<Mutex<f64>>, | 685 | position: Arc<Mutex<f64>>, |
| 686 | sample_rate: Option<u32>, | ||
| 622 | ) { | 687 | ) { |
| 623 | use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; | 688 | use cpal::traits::{HostTrait, StreamTrait}; |
| 624 | 689 | ||
| 625 | let host = cpal::default_host(); | 690 | let host = cpal::default_host(); |
| 626 | let mut device = match host.default_output_device() { | 691 | let mut device = match host.default_output_device() { |
| @@ -682,25 +747,6 @@ fn audio_thread( | |||
| 682 | recovery.clear(); | 747 | recovery.clear(); |
| 683 | ctx.failed.store(false, Ordering::SeqCst); | 748 | ctx.failed.store(false, Ordering::SeqCst); |
| 684 | 749 | ||
| 685 | // Query the config every time instead of caching it at | ||
| 686 | // startup: the default device or its rate can change while | ||
| 687 | // the daemon runs. | ||
| 688 | let config = match device.default_output_config() { | ||
| 689 | Ok(c) => c, | ||
| 690 | Err(e) => { | ||
| 691 | eprintln!("Failed to get output config: {}", e); | ||
| 692 | notification::notify_error("Failed to query audio output"); | ||
| 693 | is_playing.store(false, Ordering::SeqCst); | ||
| 694 | continue; | ||
| 695 | } | ||
| 696 | }; | ||
| 697 | let device_sample_rate = config.sample_rate(); | ||
| 698 | crate::debug_eprintln(format_args!( | ||
| 699 | "Default output: {} Hz, {} channels", | ||
| 700 | device_sample_rate, | ||
| 701 | config.channels() | ||
| 702 | )); | ||
| 703 | |||
| 704 | match open_audio_file(&path) { | 750 | match open_audio_file(&path) { |
| 705 | Ok(mut data) => { | 751 | Ok(mut data) => { |
| 706 | crate::debug_eprintln(format_args!( | 752 | crate::debug_eprintln(format_args!( |
| @@ -708,6 +754,29 @@ fn audio_thread( | |||
| 708 | data.sample_rate, data.channels | 754 | data.sample_rate, data.channels |
| 709 | )); | 755 | )); |
| 710 | 756 | ||
| 757 | // Ask for the track's own rate unless one is forced: | ||
| 758 | // the output then runs at the rate the samples are | ||
| 759 | // already in, and PipeWire converts to the graph rate | ||
| 760 | // with its polyphase resampler. | ||
| 761 | let config = match choose_output_config( | ||
| 762 | &device, | ||
| 763 | sample_rate.or(Some(data.sample_rate)), | ||
| 764 | ) { | ||
| 765 | Ok(c) => c, | ||
| 766 | Err(e) => { | ||
| 767 | eprintln!("Failed to get output config: {}", e); | ||
| 768 | notification::notify_error("Failed to query audio output"); | ||
| 769 | is_playing.store(false, Ordering::SeqCst); | ||
| 770 | continue; | ||
| 771 | } | ||
| 772 | }; | ||
| 773 | let device_sample_rate = config.sample_rate(); | ||
| 774 | crate::debug_eprintln(format_args!( | ||
| 775 | "Output: {} Hz, {} channels", | ||
| 776 | device_sample_rate, | ||
| 777 | config.channels() | ||
| 778 | )); | ||
| 779 | |||
| 711 | if data.sample_rate != device_sample_rate { | 780 | if data.sample_rate != device_sample_rate { |
| 712 | crate::debug_eprintln(format_args!( | 781 | crate::debug_eprintln(format_args!( |
| 713 | "Resampling from {} Hz to {} Hz", | 782 | "Resampling from {} Hz to {} Hz", |
| @@ -870,7 +939,7 @@ fn audio_thread( | |||
| 870 | && recovery.retry_at.is_some_and(|at| at <= Instant::now()); | 939 | && recovery.retry_at.is_some_and(|at| at <= Instant::now()); |
| 871 | if retry_due { | 940 | if retry_due { |
| 872 | recovery.retry_at = None; | 941 | recovery.retry_at = None; |
| 873 | match rebuild_stream(&host, &mut device, &ctx) { | 942 | match rebuild_stream(&host, &mut device, &ctx, sample_rate) { |
| 874 | Ok(stream) => { | 943 | Ok(stream) => { |
| 875 | current_stream = Some(stream); | 944 | current_stream = Some(stream); |
| 876 | recovery.stream_built(Instant::now()); | 945 | recovery.stream_built(Instant::now()); |
| @@ -1117,6 +1186,42 @@ mod tests { | |||
| 1117 | } | 1186 | } |
| 1118 | 1187 | ||
| 1119 | #[test] | 1188 | #[test] |
| 1189 | fn keeps_the_device_rate_when_nothing_is_requested() { | ||
| 1190 | use cpal::traits::{DeviceTrait, HostTrait}; | ||
| 1191 | |||
| 1192 | let host = cpal::default_host(); | ||
| 1193 | let Some(device) = host.default_output_device() else { | ||
| 1194 | eprintln!("skipped: no output device available"); | ||
| 1195 | return; | ||
| 1196 | }; | ||
| 1197 | let Ok(default) = device.default_output_config() else { | ||
| 1198 | eprintln!("skipped: no default output config available"); | ||
| 1199 | return; | ||
| 1200 | }; | ||
| 1201 | |||
| 1202 | let config = choose_output_config(&device, None).expect("config"); | ||
| 1203 | |||
| 1204 | assert_eq!(config.sample_rate(), default.sample_rate()); | ||
| 1205 | assert_eq!(config.channels(), default.channels()); | ||
| 1206 | } | ||
| 1207 | |||
| 1208 | #[test] | ||
| 1209 | fn opens_at_the_requested_rate() { | ||
| 1210 | use cpal::traits::{DeviceTrait, HostTrait}; | ||
| 1211 | |||
| 1212 | let host = cpal::default_host(); | ||
| 1213 | let Some(device) = host.default_output_device() else { | ||
| 1214 | eprintln!("skipped: no output device available"); | ||
| 1215 | return; | ||
| 1216 | }; | ||
| 1217 | |||
| 1218 | // 48 kHz is supported by any output that pipes through a graph. | ||
| 1219 | let config = choose_output_config(&device, Some(48_000)).expect("config"); | ||
| 1220 | |||
| 1221 | assert_eq!(config.sample_rate(), 48_000); | ||
| 1222 | } | ||
| 1223 | |||
| 1224 | #[test] | ||
| 1120 | fn rebuilds_stream_after_error() { | 1225 | fn rebuilds_stream_after_error() { |
| 1121 | use cpal::traits::{HostTrait, StreamTrait}; | 1226 | use cpal::traits::{HostTrait, StreamTrait}; |
| 1122 | 1227 | ||
| @@ -1127,7 +1232,7 @@ mod tests { | |||
| 1127 | }; | 1232 | }; |
| 1128 | 1233 | ||
| 1129 | let ctx = test_context(); | 1234 | let ctx = test_context(); |
| 1130 | let Ok(stream) = rebuild_stream(&host, &mut device, &ctx) else { | 1235 | let Ok(stream) = rebuild_stream(&host, &mut device, &ctx, None) else { |
| 1131 | eprintln!("skipped: cannot build output stream"); | 1236 | eprintln!("skipped: cannot build output stream"); |
| 1132 | return; | 1237 | return; |
| 1133 | }; | 1238 | }; |
