perf(audio): standard mode resource optimization — lower CPU at 44.1kHz

Stream switch: skip entirely when hi_res_enabled=false. Previously the
engine compared 44100 vs. device-default 48000 Hz and re-opened the device
on every track start — unnecessary IPC + PipeWire reconfigure in safe mode.

MSS buffer: 512 KB in standard mode (was 4 MB). For in-memory MP3/AAC
the large buffer caused eager allocation competing with playback startup.
4 MB is kept only for hi-res (high-bitrate FLAC benefits from fewer reads).

ThreadPriority: no longer set Max at audio-stream-thread startup. Max is
now applied only when a hi-res reopen is requested, keeping the scheduler
budget for the actual decode threads during standard playback.

audio_preload: 8 s throttle delay + gen guard so background pre-fetch does
not compete with the decode/sink-feed cycle of the just-started track.

needs_prefill: now gated on hi_res_enabled to make the condition explicit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 13:12:03 +02:00
parent 5ec8fa8ba3
commit 1c2aa79e29
+67 -50
View File
@@ -902,19 +902,20 @@ struct SizedDecoder {
} }
impl SizedDecoder { impl SizedDecoder {
fn new(data: Vec<u8>, format_hint: Option<&str>) -> Result<Self, String> { fn new(data: Vec<u8>, format_hint: Option<&str>, hi_res: bool) -> Result<Self, String> {
let data_len = data.len() as u64; let data_len = data.len() as u64;
let source = SizedCursorSource { let source = SizedCursorSource {
inner: Cursor::new(data), inner: Cursor::new(data),
len: data_len, len: data_len,
}; };
// 4 MB read-ahead buffer for Symphonia. Since the source is an in-memory // Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// Cursor<Vec<u8>>, reads are free — the large buffer reduces the number of // high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Read::read() calls Symphonia makes while demuxing, lowering decode-loop // Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// overhead for high-bitrate hi-res files (88.2kHz/24-bit FLAC ≈1800 kbps). // and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new( let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>, Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: 4 * 1024 * 1024 }, MediaSourceStreamOptions { buffer_len: buf_len },
); );
let mut hint = Hint::new(); let mut hint = Hint::new();
@@ -1260,10 +1261,11 @@ fn build_source(
sample_counter: Arc<AtomicU64>, sample_counter: Arc<AtomicU64>,
target_rate: u32, target_rate: u32,
format_hint: Option<&str>, format_hint: Option<&str>,
hi_res: bool,
) -> Result<BuiltSource, String> { ) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data); let gapless = parse_gapless_info(&data);
let decoder = SizedDecoder::new(data, format_hint)?; let decoder = SizedDecoder::new(data, format_hint, hi_res)?;
let sample_rate = decoder.sample_rate(); let sample_rate = decoder.sample_rate();
let channels = decoder.channels(); let channels = decoder.channels();
@@ -1359,9 +1361,9 @@ pub struct AudioEngine {
pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>, pub stream_handle: Arc<std::sync::Mutex<rodio::OutputStreamHandle>>,
/// Sample rate the output stream was last opened at (updated on every re-open). /// Sample rate the output stream was last opened at (updated on every re-open).
pub stream_sample_rate: Arc<AtomicU32>, pub stream_sample_rate: Arc<AtomicU32>,
/// Sends `(desired_rate, reply_tx)` to the audio-stream thread to re-open the /// Sends `(desired_rate, is_hi_res, reply_tx)` to the audio-stream thread to
/// output device at a different native sample rate. /// re-open the output device. `is_hi_res` controls thread-priority escalation.
pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>, pub stream_reopen_tx: std::sync::mpsc::SyncSender<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>,
pub current: Arc<Mutex<AudioCurrent>>, pub current: Arc<Mutex<AudioCurrent>>,
/// Monotonically incremented on each audio_play (non-chain) / audio_stop call. /// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
pub generation: Arc<AtomicU64>, pub generation: Arc<AtomicU64>,
@@ -1519,7 +1521,7 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
let (init_tx, init_rx) = let (init_tx, init_rx) =
std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0); std::sync::mpsc::sync_channel::<(rodio::OutputStreamHandle, u32)>(0);
let (reopen_tx, reopen_rx) = let (reopen_tx, reopen_rx) =
std::sync::mpsc::sync_channel::<(u32, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4); std::sync::mpsc::sync_channel::<(u32, bool, std::sync::mpsc::SyncSender<rodio::OutputStreamHandle>)>(4);
let thread = std::thread::Builder::new() let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into()) .name("psysonic-audio-stream".into())
@@ -1535,17 +1537,24 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
} }
} }
// Boost scheduler priority so the audio thread is not pre-empted // Thread priority is kept at default during standard-mode playback.
// during high-rate playback. Silently ignored without CAP_SYS_NICE. // It is escalated to Max only when a Hi-Res stream reopen is requested,
thread_priority::set_current_thread_priority( // to prevent PipeWire underruns at high quantum sizes (8192 frames).
thread_priority::ThreadPriority::Max
).ok();
let (mut _stream, handle, rate) = open_stream_for_rate(0); let (mut _stream, handle, rate) = open_stream_for_rate(0);
init_tx.send((handle, rate)).ok(); init_tx.send((handle, rate)).ok();
// Keep the stream alive and handle sample-rate switch requests. // Keep the stream alive and handle sample-rate switch requests.
while let Ok((desired_rate, reply_tx)) = reopen_rx.recv() { while let Ok((desired_rate, is_hi_res, reply_tx)) = reopen_rx.recv() {
// Escalate to Max for Hi-Res reopens (large PipeWire quanta need
// real-time scheduling to avoid underruns). No escalation for
// standard mode — the thread blocks on recv() between reopens so
// elevated priority would only waste scheduler budget.
if is_hi_res {
thread_priority::set_current_thread_priority(
thread_priority::ThreadPriority::Max
).ok();
}
drop(_stream); // close old stream before opening new one drop(_stream); // close old stream before opening new one
// Scale the PipeWire quantum with the sample rate so wall-clock // Scale the PipeWire quantum with the sample rate so wall-clock
@@ -1866,6 +1875,7 @@ pub async fn audio_play(
state.samples_played.clone(), state.samples_played.clone(),
target_rate, target_rate,
format_hint.as_deref(), format_hint.as_deref(),
hi_res_enabled,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?; ).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source; let source = built.source;
let duration_secs = built.duration_secs; let duration_secs = built.duration_secs;
@@ -1880,42 +1890,39 @@ pub async fn audio_play(
return Ok(()); return Ok(());
} }
// ── Native-rate stream switch ───────────────────────────────────────────── // ── Native-rate stream switch (Hi-Res only) ───────────────────────────────
// If the decoded track's sample rate differs from the current output stream, // Standard mode: skip entirely — zero atomic reads, zero IPC, rodio resamples
// ask the audio-stream thread to re-open the device at the native rate. // transparently from the file's native rate to whatever the device runs at.
// This keeps the signal bit-perfect (no rodio resampler in the path). // Hi-Res mode: re-open the device at the file's native rate if it changed,
// Falls back silently if the switch times out (rodio will resample instead). // keeping the signal bit-perfect (no rodio resampler in the path).
// Safe mode (default): lock to 44.1 kHz — rodio resamples internally. if hi_res_enabled {
// Hi-Res mode (alpha): request the file's native rate from the audio thread. let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed);
let effective_rate = if hi_res_enabled { output_rate } else { 44_100 }; if output_rate != current_stream_rate && output_rate > 0 {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0);
let current_stream_rate = state.stream_sample_rate.load(Ordering::Relaxed); if state.stream_reopen_tx.send((output_rate, true, reply_tx)).is_ok() {
let stream_was_switched = effective_rate != current_stream_rate && effective_rate > 0; match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) {
if stream_was_switched { Ok(new_handle) => {
let (reply_tx, reply_rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0); *state.stream_handle.lock().unwrap() = new_handle;
if state.stream_reopen_tx.send((effective_rate, reply_tx)).is_ok() { state.stream_sample_rate.store(output_rate, Ordering::Relaxed);
match reply_rx.recv_timeout(std::time::Duration::from_secs(5)) { // Give PipeWire time to reconfigure its processing graph at
Ok(new_handle) => { // the new sample rate before we open a Sink and start feeding
*state.stream_handle.lock().unwrap() = new_handle; // frames. Without this delay the first quantum is often stale
state.stream_sample_rate.store(effective_rate, Ordering::Relaxed); // and triggers an snd_pcm_recover underrun.
// Give PipeWire time to reconfigure its processing graph at if output_rate > 48_000 {
// the new sample rate before we open a Sink and start feeding tokio::time::sleep(Duration::from_millis(150)).await;
// frames. Without this delay the first quantum is often stale }
// and triggers an snd_pcm_recover underrun. }
if effective_rate > 48_000 { Err(_) => {
tokio::time::sleep(Duration::from_millis(150)).await; eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
} }
}
Err(_) => {
eprintln!("[psysonic] stream rate switch timed out, keeping {current_stream_rate} Hz");
} }
} }
} }
}
// Re-check gen: a rapid skip during the settle sleep would have bumped it. // Re-check gen: a rapid skip during the settle sleep would have bumped it.
if state.generation.load(Ordering::SeqCst) != gen { if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); return Ok(());
}
} }
let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?; let sink = Sink::try_new(&*state.stream_handle.lock().unwrap()).map_err(|e| e.to_string())?;
@@ -1928,7 +1935,8 @@ pub async fn audio_play(
// decodes into its ring buffer ahead of the hardware. After a short delay // decodes into its ring buffer ahead of the hardware. After a short delay
// we resume — the buffer is already full and the hardware gets its frames // we resume — the buffer is already full and the hardware gets its frames
// without an underrun on the very first period. // without an underrun on the very first period.
let needs_prefill = effective_rate > 48_000; // Standard mode: no pre-fill needed — default 44.1/48 kHz quantum is small.
let needs_prefill = hi_res_enabled && output_rate > 48_000;
if needs_prefill { if needs_prefill {
sink.pause(); sink.pause();
} }
@@ -2137,6 +2145,7 @@ pub async fn audio_chain_preload(
chain_counter.clone(), chain_counter.clone(),
target_rate, target_rate,
format_hint.as_deref(), format_hint.as_deref(),
hi_res_enabled,
).map_err(|e| e.to_string())?; ).map_err(|e| e.to_string())?;
let source = built.source; let source = built.source;
let duration_secs = built.duration_secs; let duration_secs = built.duration_secs;
@@ -2572,6 +2581,14 @@ pub async fn audio_preload(
return Ok(()); return Ok(());
} }
} }
// Throttle: wait 8 s before starting the background download so it does not
// compete with the decode + sink-feed work of the just-started current track.
// If the user skips during the wait the generation counter changes and we abort.
let gen_snapshot = state.generation.load(Ordering::Relaxed);
tokio::time::sleep(Duration::from_secs(8)).await;
if state.generation.load(Ordering::Relaxed) != gen_snapshot {
return Ok(());
}
let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") { let data: Vec<u8> = if let Some(path) = url.strip_prefix("psysonic-local://") {
tokio::fs::read(path).await.map_err(|e| e.to_string())? tokio::fs::read(path).await.map_err(|e| e.to_string())?
} else { } else {