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
16 changes: 15 additions & 1 deletion crates/erika/src/android.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AudioPushResult> {
let mut control = lock(&self.control)?;
self.recover_disconnected_stream_locked(&mut control)?;
Expand Down Expand Up @@ -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<AudioPushResult> {
AAudioOutput::push(self, frame)
.map_err(|error| crate::audio::AudioError::Backend(error.to_string()))
Expand Down
78 changes: 64 additions & 14 deletions crates/erika/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -32,6 +35,22 @@ pub enum AudioError {

pub type Result<T> = std::result::Result<T, AudioError>;

/// 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,
Expand Down Expand Up @@ -287,6 +306,7 @@ struct AudioTempoProcessor {
playback_rate: f64,
processor: SoundTouch,
pending_pts: Option<Duration>,
scratch: Vec<f32>,
}

impl AudioTempoProcessor {
Expand All @@ -306,6 +326,7 @@ impl AudioTempoProcessor {
playback_rate,
processor,
pending_pts: None,
scratch: Vec::new(),
}
}

Expand All @@ -314,7 +335,7 @@ impl AudioTempoProcessor {
&& (self.playback_rate - normalize_playback_rate(playback_rate)).abs() < 0.001
}

fn process(&mut self, frame: PcmAudioFrame) -> (Vec<f32>, Option<Duration>, f64) {
fn process(&mut self, mut frame: PcmAudioFrame) -> (Vec<f32>, Option<Duration>, f64) {
let channels = self.format.channels.max(1) as usize;
let input_frames = frame.samples.len() / channels;
if input_frames == 0 {
Expand All @@ -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(
Expand All @@ -337,25 +363,25 @@ impl AudioTempoProcessor {
);
}
}
(output, start, self.playback_rate)
(frame.samples, start, self.playback_rate)
}

fn receive_available(&mut self) -> Vec<f32> {
fn receive_available_into(&mut self, output: &mut Vec<f32>) {
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
}
}

Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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<AudioPushResult>;
fn state(&self) -> AudioOutputState;
fn stats(&self) -> AudioRingBufferStats;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand All @@ -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]
Expand Down
5 changes: 4 additions & 1 deletion crates/erika/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 15 additions & 1 deletion crates/erika/src/ohos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<AudioPushResult> {
let mut control = lock(&self.control)?;
self.recover_disconnected_stream_locked(&mut control)?;
Expand Down Expand Up @@ -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<AudioPushResult> {
OHAudioOutput::push(self, frame)
.map_err(|error| crate::audio::AudioError::Backend(error.to_string()))
Expand Down
Loading
Loading