diff --git a/crates/erika/src/android.rs b/crates/erika/src/android.rs index 2ffb5cd..efac812 100644 --- a/crates/erika/src/android.rs +++ b/crates/erika/src/android.rs @@ -192,7 +192,7 @@ pub mod aaudio { use crate::audio::{ AudioClockSnapshot, AudioOutputBackend, AudioOutputRuntimeStats, AudioOutputState, AudioPushResult, AudioRecoveryState, AudioRingBuffer, AudioRingBufferConfig, - AudioRingBufferStats, normalize_volume, + AudioRingBufferStats, audio_output_queue_has_capacity, normalize_volume, }; use crate::ffmpeg::{PcmAudioFrame, PcmFormat, PcmSampleFormat}; use crate::trace; @@ -759,6 +759,16 @@ pub mod aaudio { } } + pub fn can_accept_audio_frame(&self) -> bool { + let Ok(control) = self.control.lock() else { + return true; + }; + let (Some(callback), Some(format)) = (&control.callback, control.format) else { + return true; + }; + audio_output_queue_has_capacity(callback.ring.stats().queued_frames, format.sample_rate) + } + pub fn push(&self, frame: PcmAudioFrame) -> Result { let mut control = lock(&self.control)?; self.recover_disconnected_stream_locked(&mut control)?; @@ -1030,6 +1040,10 @@ pub mod aaudio { AAudioOutput::set_playback_rate(self, rate); } + fn can_accept_audio_frame(&self) -> bool { + AAudioOutput::can_accept_audio_frame(self) + } + fn push(&mut self, frame: PcmAudioFrame) -> crate::audio::Result { AAudioOutput::push(self, frame) .map_err(|error| crate::audio::AudioError::Backend(error.to_string())) diff --git a/crates/erika/src/audio.rs b/crates/erika/src/audio.rs index a13c6d8..fbe2787 100644 --- a/crates/erika/src/audio.rs +++ b/crates/erika/src/audio.rs @@ -10,7 +10,10 @@ use crate::trace; pub(crate) mod spsc; -const RATE_CHANGE_AUDIO_BRIDGE: Duration = Duration::from_millis(80); +// Keep enough old-rate PCM to cover SoundTouch startup and one normal output +// prefill. The presenter commits the media clock at the end of this bridge. +pub(crate) const AUDIO_OUTPUT_QUEUE_HIGH_WATER: Duration = Duration::from_millis(250); +const RATE_CHANGE_AUDIO_BRIDGE: Duration = AUDIO_OUTPUT_QUEUE_HIGH_WATER; const SOUNDTOUCH_SEQUENCE_MS: i32 = 25; const SOUNDTOUCH_SEEK_WINDOW_MS: i32 = 12; const SOUNDTOUCH_OVERLAP_MS: i32 = 6; @@ -32,6 +35,22 @@ pub enum AudioError { pub type Result = std::result::Result; +/// Whether another decoded frame may enter an output queue without exceeding +/// the bounded latency budget used for playback-rate transitions. +#[cfg(any(test, target_os = "android", target_env = "ohos"))] +pub(crate) fn audio_output_queue_has_capacity(queued_frames: usize, sample_rate: u32) -> bool { + if sample_rate == 0 { + return true; + } + let high_water_frames = usize::try_from( + (sample_rate as u64).saturating_mul(AUDIO_OUTPUT_QUEUE_HIGH_WATER.as_millis() as u64) + / 1_000, + ) + .unwrap_or(usize::MAX) + .max(1); + queued_frames < high_water_frames +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AudioOutputState { Stopped, @@ -287,6 +306,7 @@ struct AudioTempoProcessor { playback_rate: f64, processor: SoundTouch, pending_pts: Option, + scratch: Vec, } impl AudioTempoProcessor { @@ -306,6 +326,7 @@ impl AudioTempoProcessor { playback_rate, processor, pending_pts: None, + scratch: Vec::new(), } } @@ -314,7 +335,7 @@ impl AudioTempoProcessor { && (self.playback_rate - normalize_playback_rate(playback_rate)).abs() < 0.001 } - fn process(&mut self, frame: PcmAudioFrame) -> (Vec, Option, f64) { + fn process(&mut self, mut frame: PcmAudioFrame) -> (Vec, Option, f64) { let channels = self.format.channels.max(1) as usize; let input_frames = frame.samples.len() / channels; if input_frames == 0 { @@ -324,9 +345,14 @@ impl AudioTempoProcessor { self.pending_pts = frame.pts; } self.processor.put_samples(&frame.samples, input_frames); - let output = self.receive_available(); + // Reuse the decoder-owned allocation for the transformed output and + // keep the SoundTouch scratch buffer on the processor. This path runs + // from the display-driven presenter, so avoiding fresh allocations + // directly protects the next frame deadline after a rate change. + frame.samples.clear(); + self.receive_available_into(&mut frame.samples); let start = self.pending_pts; - let output_frames = output.len() / channels; + let output_frames = frame.samples.len() / channels; if output_frames > 0 { if let Some(start) = self.pending_pts { self.pending_pts = offset_pts_scaled( @@ -337,25 +363,25 @@ impl AudioTempoProcessor { ); } } - (output, start, self.playback_rate) + (frame.samples, start, self.playback_rate) } - fn receive_available(&mut self) -> Vec { + fn receive_available_into(&mut self, output: &mut Vec) { const OUTPUT_FRAMES: usize = 4096; let channels = self.format.channels.max(1) as usize; - let mut chunk = vec![0.0f32; OUTPUT_FRAMES * channels]; - let mut output = Vec::new(); + self.scratch.resize(OUTPUT_FRAMES * channels, 0.0); loop { - let frames = self.processor.receive_samples(&mut chunk, OUTPUT_FRAMES); + let frames = self + .processor + .receive_samples(&mut self.scratch, OUTPUT_FRAMES); if frames == 0 { break; } - output.extend_from_slice(&chunk[..frames * channels]); + output.extend_from_slice(&self.scratch[..frames * channels]); if frames < OUTPUT_FRAMES { break; } } - output } } @@ -462,6 +488,10 @@ impl AudioRingBuffer { if (self.playback_rate - rate).abs() <= 0.001 { return; } + + // Samples already in this queue were processed at the previous rate. + // Retain a bounded bridge while SoundTouch warms up for the new rate; + // the presenter changes the media clock when this bridge has played. self.playback_rate = rate; self.tempo_processor = None; self.trim_queued_to_front_frames(self.rate_change_bridge_frames()); @@ -747,6 +777,12 @@ pub trait AudioOutputBackend { fn set_volume(&mut self, volume: f32); fn volume(&self) -> f32; fn set_playback_rate(&mut self, _rate: f64) {} + /// Returns false while the output owns enough queued PCM to preserve the + /// bounded playback-rate transition latency. The presenter leaves decoded + /// frames in the worker channel, which applies backpressure safely. + fn can_accept_audio_frame(&self) -> bool { + true + } fn push(&mut self, frame: PcmAudioFrame) -> Result; fn state(&self) -> AudioOutputState; fn stats(&self) -> AudioRingBufferStats; @@ -1047,6 +1083,15 @@ mod tests { assert_eq!(output.volume(), 1.0); } + #[test] + fn output_queue_high_water_bounds_rate_transition_latency() { + assert!(audio_output_queue_has_capacity(11_999, 48_000)); + assert!(!audio_output_queue_has_capacity(12_000, 48_000)); + assert!(audio_output_queue_has_capacity(11_024, 44_100)); + assert!(!audio_output_queue_has_capacity(11_025, 44_100)); + assert!(audio_output_queue_has_capacity(usize::MAX, 0)); + } + #[test] fn volume_ramp_interpolates_per_frame_and_lands_on_target() { let mut samples = [1.0f32; 8]; @@ -1251,7 +1296,7 @@ mod tests { } #[test] - fn ring_buffer_rate_change_keeps_short_audio_bridge() { + fn ring_buffer_rate_change_keeps_a_full_prefill_bridge() { let mut buffer = AudioRingBuffer::with_format( AudioRingBufferConfig { capacity_frames: 48_000, @@ -1261,16 +1306,21 @@ mod tests { ) .unwrap(); buffer - .push_frame(timed_frame(Duration::from_secs(4), 4_800)) + .push_frame(timed_frame(Duration::from_secs(4), 24_000)) .unwrap(); buffer.set_playback_rate(2.0); - assert_eq!(buffer.clock_snapshot().queued_frames, 3_840); + assert_eq!(buffer.clock_snapshot().queued_frames, 12_000); assert_eq!( buffer.clock_snapshot().media_time, Some(Duration::from_secs(4)) ); + + let mut output = vec![0.0; 12_000 * 2]; + let result = buffer.read_interleaved(&mut output).unwrap(); + assert_eq!(result.frames, 12_000); + assert_eq!(result.underflow_frames, 0); } #[test] diff --git a/crates/erika/src/core.rs b/crates/erika/src/core.rs index e84dd6b..f2b4987 100644 --- a/crates/erika/src/core.rs +++ b/crates/erika/src/core.rs @@ -25,7 +25,10 @@ const EXTERNAL_SUBTITLE_TRACK_ID_BASE: i64 = 1_000_000; const AUDIO_PREFILL_AFTER_VIDEO_LIMIT: usize = 8; const AUDIO_PREFILL_PACKET_BUDGET: usize = 6; const AUDIO_PREFILL_TIME_BUDGET: Duration = Duration::from_millis(5); -const AUDIO_PREFILL_LOW_WATER: Duration = Duration::from_millis(350); +// Keep producer prefill aligned with the output queue high-water mark. This +// lets callback-driven backends hold a short rate-change bridge without +// accumulating stale-rate PCM in the presenter channel. +const AUDIO_PREFILL_LOW_WATER: Duration = crate::audio::AUDIO_OUTPUT_QUEUE_HIGH_WATER; const AUDIO_CLOCK_SNAPSHOT_STALE_AFTER: Duration = Duration::from_millis(500); const PLAYBACK_STARVATION_GRACE: Duration = Duration::from_millis(500); const PLAYBACK_BUFFER_RECOVERY_AUDIO: Duration = Duration::from_millis(250); diff --git a/crates/erika/src/ohos.rs b/crates/erika/src/ohos.rs index 692d97c..27b6728 100644 --- a/crates/erika/src/ohos.rs +++ b/crates/erika/src/ohos.rs @@ -16,7 +16,7 @@ pub mod ohaudio { use crate::audio::{ AudioClockSnapshot, AudioOutputBackend, AudioOutputRuntimeStats, AudioOutputState, AudioPushResult, AudioRecoveryState, AudioRingBuffer, AudioRingBufferConfig, - AudioRingBufferStats, normalize_volume, + AudioRingBufferStats, audio_output_queue_has_capacity, normalize_volume, }; use crate::ffmpeg::{PcmAudioFrame, PcmFormat, PcmSampleFormat}; use crate::trace; @@ -628,6 +628,16 @@ pub mod ohaudio { } } + pub fn can_accept_audio_frame(&self) -> bool { + let Ok(control) = self.control.lock() else { + return true; + }; + let (Some(callback), Some(format)) = (&control.callback, control.format) else { + return true; + }; + audio_output_queue_has_capacity(callback.ring.stats().queued_frames, format.sample_rate) + } + pub fn push(&self, frame: PcmAudioFrame) -> Result { let mut control = lock(&self.control)?; self.recover_disconnected_stream_locked(&mut control)?; @@ -957,6 +967,10 @@ pub mod ohaudio { OHAudioOutput::set_playback_rate(self, rate); } + fn can_accept_audio_frame(&self) -> bool { + OHAudioOutput::can_accept_audio_frame(self) + } + fn push(&mut self, frame: PcmAudioFrame) -> crate::audio::Result { OHAudioOutput::push(self, frame) .map_err(|error| crate::audio::AudioError::Backend(error.to_string())) diff --git a/crates/erika/src/presenter.rs b/crates/erika/src/presenter.rs index a066142..6b700c4 100644 --- a/crates/erika/src/presenter.rs +++ b/crates/erika/src/presenter.rs @@ -69,8 +69,11 @@ use crate::{PlayerError, Result}; const AUDIO_START_BUFFER: Duration = Duration::from_millis(250); const AUDIO_PUMP_FRAME_LIMIT: usize = 16; const AUDIO_PUMP_TIME_BUDGET: Duration = Duration::from_millis(4); -const AUDIO_FAST_RATE_PUMP_FRAME_LIMIT: usize = 48; -const AUDIO_FAST_RATE_PUMP_TIME_BUDGET: Duration = Duration::from_millis(8); +// The audio transform currently runs on the display-driven presenter path. +// Keep a rate-change refill bounded to one normal audio-pump slice so it +// cannot consume an entire render frame while SoundTouch is warming up. +const AUDIO_FAST_RATE_PUMP_FRAME_LIMIT: usize = 24; +const AUDIO_FAST_RATE_PUMP_TIME_BUDGET: Duration = Duration::from_millis(4); const PLAYBACK_RATE_EPSILON: f64 = 0.001; const VIDEO_PUMP_FRAME_LIMIT: usize = 8; const VIDEO_PUMP_TIME_BUDGET: Duration = Duration::from_millis(4); @@ -287,6 +290,7 @@ pub struct PresenterRuntime { last_audio_clock_report: Option, last_audio_runtime_stats: AudioOutputRuntimeStats, playback_rate: f64, + pending_playback_rate: Option, audio_only_tick_active: bool, latest_video_decoder: Option, current_overlay: Option, @@ -359,6 +363,12 @@ struct AudioClockReportState { underflow_frames: u64, } +#[derive(Debug, Clone, Copy)] +struct PendingPlaybackRate { + rate: f64, + commit_at: Instant, +} + #[derive(Debug, Clone, Copy, PartialEq)] struct DanmakuPlanKey { media_time: Duration, @@ -676,6 +686,7 @@ impl PresenterRuntime { last_audio_clock_report: None, last_audio_runtime_stats: AudioOutputRuntimeStats::default(), playback_rate: 1.0, + pending_playback_rate: None, audio_only_tick_active: false, latest_video_decoder: None, current_overlay: None, @@ -867,10 +878,28 @@ impl PresenterRuntime { pub fn set_playback_rate(&mut self, rate: f64) -> Result<()> { let next_rate = normalize_playback_rate(rate); - self.player.set_playback_rate(next_rate)?; - self.playback_rate = next_rate; self.audio_output.set_playback_rate(next_rate); self.last_audio_clock_report = None; + + let bridge = self + .audio_started + .then(|| self.audio_output.clock_snapshot()) + .flatten() + .and_then(|snapshot| snapshot.queued_duration) + .filter(|duration| !duration.is_zero()); + if self.is_playing() + && let Some(bridge) = bridge + { + self.pending_playback_rate = Some(PendingPlaybackRate { + rate: next_rate, + commit_at: Instant::now() + bridge, + }); + return Ok(()); + } + + self.player.set_playback_rate(next_rate)?; + self.playback_rate = next_rate; + self.pending_playback_rate = None; Ok(()) } @@ -1268,6 +1297,7 @@ impl PresenterRuntime { self.audio_only_tick_active = false; } let tick_started = Instant::now(); + self.commit_pending_playback_rate()?; let pump_started = Instant::now(); self.refresh_video_decoder_status(); @@ -1440,6 +1470,7 @@ impl PresenterRuntime { pub fn audio_only_tick(&mut self) -> Result { let tick_started = Instant::now(); + self.commit_pending_playback_rate()?; if !self.audio_only_tick_active { self.player.set_video_decode_suspended(true)?; self.discard_pending_video_frames(); @@ -2720,6 +2751,9 @@ impl PresenterRuntime { if pumped >= frame_limit || started.elapsed() >= time_budget { break; } + if !self.audio_output.can_accept_audio_frame() { + break; + } match self.audio_frames.try_recv() { Ok(frame) => { if frame.generation != self.player.playback_generation() { @@ -2739,14 +2773,10 @@ impl PresenterRuntime { } fn audio_pump_limits(&self) -> (usize, Duration) { - if (self.playback_rate - 1.0).abs() > PLAYBACK_RATE_EPSILON { - ( - AUDIO_FAST_RATE_PUMP_FRAME_LIMIT, - AUDIO_FAST_RATE_PUMP_TIME_BUDGET, - ) - } else { - (AUDIO_PUMP_FRAME_LIMIT, AUDIO_PUMP_TIME_BUDGET) - } + let rate = self + .pending_playback_rate + .map_or(self.playback_rate, |pending| pending.rate); + audio_pump_limits_for_rate(rate) } fn report_audio_clock_snapshot(&mut self) { @@ -2848,9 +2878,24 @@ impl PresenterRuntime { } self.audio_configured = false; self.audio_started = false; + self.pending_playback_rate = None; self.last_audio_clock_report = None; } + fn commit_pending_playback_rate(&mut self) -> Result<()> { + let Some(pending) = self.pending_playback_rate else { + return Ok(()); + }; + if !self.is_playing() || Instant::now() < pending.commit_at { + return Ok(()); + } + self.player.set_playback_rate(pending.rate)?; + self.playback_rate = pending.rate; + self.pending_playback_rate = None; + self.last_audio_clock_report = None; + Ok(()) + } + fn report_audio_output_runtime_stats(&mut self) { let stats = self.audio_output.runtime_stats(); if stats.transition_sequence == self.last_audio_runtime_stats.transition_sequence { @@ -2881,6 +2926,17 @@ fn normalize_playback_rate(rate: f64) -> f64 { } } +fn audio_pump_limits_for_rate(rate: f64) -> (usize, Duration) { + if (rate - 1.0).abs() > PLAYBACK_RATE_EPSILON { + ( + AUDIO_FAST_RATE_PUMP_FRAME_LIMIT, + AUDIO_FAST_RATE_PUMP_TIME_BUDGET, + ) + } else { + (AUDIO_PUMP_FRAME_LIMIT, AUDIO_PUMP_TIME_BUDGET) + } +} + #[cfg(test)] fn refresh_danmaku_plan( current_plan: &mut Option, @@ -4343,6 +4399,21 @@ Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text assert!(!PresenterConfig::default().render_test_pattern_when_idle); } + #[test] + fn playback_rate_uses_fast_audio_pump_limits() { + assert_eq!( + audio_pump_limits_for_rate(1.0), + (AUDIO_PUMP_FRAME_LIMIT, AUDIO_PUMP_TIME_BUDGET) + ); + assert_eq!( + audio_pump_limits_for_rate(2.0), + ( + AUDIO_FAST_RATE_PUMP_FRAME_LIMIT, + AUDIO_FAST_RATE_PUMP_TIME_BUDGET + ) + ); + } + #[test] #[cfg(feature = "wgpu")] fn layout_config_generation_does_not_disturb_the_playback_clock() {