mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
426 lines
14 KiB
Rust
426 lines
14 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
#![allow(dead_code)]
|
|
use std::collections::BTreeMap;
|
|
|
|
pub const AUDIO_BUFFERING_MAX_TICKS: u32 = 64;
|
|
|
|
pub const SOURCE_RESET_AFTER_BUFFERED_TICKS: u32 = 128;
|
|
|
|
pub const SOURCE_STALE_AFTER_NS: u64 = 5_000_000_000;
|
|
|
|
pub const NEVER_PUSHED_SENTINEL: u64 = u64::MAX;
|
|
|
|
pub fn compute_source_age_ns(last_push_ns: u64, registered_at_ns: u64, now_ns: u64) -> u64 {
|
|
let baseline_ns = if last_push_ns == NEVER_PUSHED_SENTINEL {
|
|
registered_at_ns
|
|
} else {
|
|
last_push_ns
|
|
};
|
|
assert!(baseline_ns != NEVER_PUSHED_SENTINEL);
|
|
if now_ns <= baseline_ns {
|
|
return 0;
|
|
}
|
|
now_ns - baseline_ns
|
|
}
|
|
|
|
pub const SAMPLE_RATE_HZ_MIN: u32 = 8_000;
|
|
pub const SAMPLE_RATE_HZ_MAX: u32 = 384_000;
|
|
|
|
pub const TICK_PERIOD_NS_MIN: u64 = 1_000_000;
|
|
pub const TICK_PERIOD_NS_MAX: u64 = 100_000_000;
|
|
|
|
pub const BUFFERED_FRAMES_MAX: u64 = 1 << 28;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum IgnoreAudioDecision {
|
|
Mix,
|
|
IgnoreThisTick,
|
|
ResetSource,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum IgnoreAudioResetReason {
|
|
BufferOverflow,
|
|
StaleSource,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct IgnoreAudioSourceState {
|
|
pub id: u64,
|
|
pub buffered_frames: u64,
|
|
pub last_frame_age_ns: u64,
|
|
pub is_muted: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct IgnoreAudioTick {
|
|
pub at_ns: u64,
|
|
pub period_ns: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct IgnoreAudioMetrics {
|
|
pub ignored_tick_count: u64,
|
|
pub reset_count: u64,
|
|
}
|
|
|
|
impl IgnoreAudioMetrics {
|
|
pub const ZERO: IgnoreAudioMetrics = IgnoreAudioMetrics {
|
|
ignored_tick_count: 0,
|
|
reset_count: 0,
|
|
};
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct IgnoreAudioSourceResetEvent {
|
|
pub source_id: u64,
|
|
pub at_ns: u64,
|
|
pub buffered_frames_at_reset: u64,
|
|
pub last_frame_age_ns: u64,
|
|
pub reason: IgnoreAudioResetReason,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct IgnoreAudioEvaluation {
|
|
pub decision: IgnoreAudioDecision,
|
|
pub event: Option<IgnoreAudioSourceResetEvent>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
#[allow(clippy::enum_variant_names)]
|
|
pub enum IgnoreAudioError {
|
|
SampleRateOutOfRange { sample_rate_hz: u32 },
|
|
TickPeriodOutOfRange { period_ns: u64 },
|
|
BufferedFramesOutOfRange { buffered_frames: u64 },
|
|
}
|
|
|
|
pub struct IgnoreAudioPolicy {
|
|
sample_rate_hz: u32,
|
|
metrics_by_source: BTreeMap<u64, IgnoreAudioMetrics>,
|
|
}
|
|
|
|
impl IgnoreAudioPolicy {
|
|
pub fn new(sample_rate_hz: u32) -> Result<Self, IgnoreAudioError> {
|
|
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
|
|
return Err(IgnoreAudioError::SampleRateOutOfRange { sample_rate_hz });
|
|
}
|
|
assert!(sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
|
|
assert!(sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
|
|
Ok(Self {
|
|
sample_rate_hz,
|
|
metrics_by_source: BTreeMap::new(),
|
|
})
|
|
}
|
|
|
|
pub fn sample_rate_hz(&self) -> u32 {
|
|
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
|
|
assert!(self.sample_rate_hz <= SAMPLE_RATE_HZ_MAX);
|
|
self.sample_rate_hz
|
|
}
|
|
|
|
pub fn metrics_for(&self, source_id: u64) -> IgnoreAudioMetrics {
|
|
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
|
|
self.metrics_by_source
|
|
.get(&source_id)
|
|
.copied()
|
|
.unwrap_or(IgnoreAudioMetrics::ZERO)
|
|
}
|
|
|
|
pub fn evaluate(
|
|
&mut self,
|
|
source_state: &IgnoreAudioSourceState,
|
|
tick: IgnoreAudioTick,
|
|
) -> Result<IgnoreAudioEvaluation, IgnoreAudioError> {
|
|
validate_source_state(source_state)?;
|
|
validate_tick(tick)?;
|
|
let tick_frames = compute_tick_frames(tick.period_ns, self.sample_rate_hz)?;
|
|
assert!(tick_frames >= 1);
|
|
let decision = decide(source_state, tick_frames);
|
|
let event = self.apply_decision(source_state, tick, decision);
|
|
let evaluation = IgnoreAudioEvaluation { decision, event };
|
|
assert_evaluation_invariant(&evaluation);
|
|
Ok(evaluation)
|
|
}
|
|
|
|
fn apply_decision(
|
|
&mut self,
|
|
source_state: &IgnoreAudioSourceState,
|
|
tick: IgnoreAudioTick,
|
|
decision: IgnoreAudioDecision,
|
|
) -> Option<IgnoreAudioSourceResetEvent> {
|
|
assert!(self.sample_rate_hz >= SAMPLE_RATE_HZ_MIN);
|
|
match decision {
|
|
IgnoreAudioDecision::Mix => None,
|
|
IgnoreAudioDecision::IgnoreThisTick => {
|
|
let entry = self
|
|
.metrics_by_source
|
|
.entry(source_state.id)
|
|
.or_insert(IgnoreAudioMetrics::ZERO);
|
|
entry.ignored_tick_count = entry.ignored_tick_count.saturating_add(1);
|
|
None
|
|
}
|
|
IgnoreAudioDecision::ResetSource => {
|
|
let entry = self
|
|
.metrics_by_source
|
|
.entry(source_state.id)
|
|
.or_insert(IgnoreAudioMetrics::ZERO);
|
|
entry.reset_count = entry.reset_count.saturating_add(1);
|
|
let reason = if source_state.last_frame_age_ns > SOURCE_STALE_AFTER_NS {
|
|
IgnoreAudioResetReason::StaleSource
|
|
} else {
|
|
IgnoreAudioResetReason::BufferOverflow
|
|
};
|
|
Some(IgnoreAudioSourceResetEvent {
|
|
source_id: source_state.id,
|
|
at_ns: tick.at_ns,
|
|
buffered_frames_at_reset: source_state.buffered_frames,
|
|
last_frame_age_ns: source_state.last_frame_age_ns,
|
|
reason,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn compute_tick_frames(period_ns: u64, sample_rate_hz: u32) -> Result<u64, IgnoreAudioError> {
|
|
if !(TICK_PERIOD_NS_MIN..=TICK_PERIOD_NS_MAX).contains(&period_ns) {
|
|
return Err(IgnoreAudioError::TickPeriodOutOfRange { period_ns });
|
|
}
|
|
if !(SAMPLE_RATE_HZ_MIN..=SAMPLE_RATE_HZ_MAX).contains(&sample_rate_hz) {
|
|
return Err(IgnoreAudioError::SampleRateOutOfRange { sample_rate_hz });
|
|
}
|
|
let product: u128 = (period_ns as u128) * (sample_rate_hz as u128);
|
|
let frames = (product / 1_000_000_000u128) as u64;
|
|
let frames = frames.max(1);
|
|
assert!(frames >= 1);
|
|
assert!(frames <= BUFFERED_FRAMES_MAX);
|
|
Ok(frames)
|
|
}
|
|
|
|
fn decide(source_state: &IgnoreAudioSourceState, tick_frames: u64) -> IgnoreAudioDecision {
|
|
assert!(tick_frames >= 1);
|
|
if source_state.is_muted {
|
|
return IgnoreAudioDecision::Mix;
|
|
}
|
|
let reset_by_stale = source_state.last_frame_age_ns > SOURCE_STALE_AFTER_NS;
|
|
let reset_by_buffer =
|
|
source_state.buffered_frames > (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * tick_frames;
|
|
if reset_by_stale {
|
|
return IgnoreAudioDecision::ResetSource;
|
|
}
|
|
if reset_by_buffer {
|
|
return IgnoreAudioDecision::ResetSource;
|
|
}
|
|
if source_state.buffered_frames <= tick_frames {
|
|
return IgnoreAudioDecision::Mix;
|
|
}
|
|
let over_buffering =
|
|
source_state.buffered_frames > (AUDIO_BUFFERING_MAX_TICKS as u64) * tick_frames;
|
|
if over_buffering {
|
|
return IgnoreAudioDecision::IgnoreThisTick;
|
|
}
|
|
IgnoreAudioDecision::Mix
|
|
}
|
|
|
|
fn validate_source_state(state: &IgnoreAudioSourceState) -> Result<(), IgnoreAudioError> {
|
|
if state.buffered_frames > BUFFERED_FRAMES_MAX {
|
|
return Err(IgnoreAudioError::BufferedFramesOutOfRange {
|
|
buffered_frames: state.buffered_frames,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_tick(tick: IgnoreAudioTick) -> Result<(), IgnoreAudioError> {
|
|
if !(TICK_PERIOD_NS_MIN..=TICK_PERIOD_NS_MAX).contains(&tick.period_ns) {
|
|
return Err(IgnoreAudioError::TickPeriodOutOfRange {
|
|
period_ns: tick.period_ns,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn assert_evaluation_invariant(evaluation: &IgnoreAudioEvaluation) {
|
|
match evaluation.decision {
|
|
IgnoreAudioDecision::ResetSource => {
|
|
assert!(evaluation.event.is_some());
|
|
}
|
|
_ => {
|
|
assert!(evaluation.event.is_none());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn canonical_tick(at_ns: u64) -> IgnoreAudioTick {
|
|
IgnoreAudioTick {
|
|
at_ns,
|
|
period_ns: 21_333_333,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_sample_rate_below_min() {
|
|
let err = IgnoreAudioPolicy::new(4_000).err();
|
|
assert!(matches!(
|
|
err,
|
|
Some(IgnoreAudioError::SampleRateOutOfRange { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_sample_rate_above_max() {
|
|
let err = IgnoreAudioPolicy::new(500_000).err();
|
|
assert!(matches!(
|
|
err,
|
|
Some(IgnoreAudioError::SampleRateOutOfRange { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn compute_tick_frames_at_48k_21_3ms_yields_1023_ish() {
|
|
let frames = compute_tick_frames(21_333_333, 48_000).expect("ok");
|
|
assert!(frames >= 1023);
|
|
assert!(frames <= 1024);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_buffer_yields_mix_decision() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 1,
|
|
buffered_frames: 0,
|
|
last_frame_age_ns: 0,
|
|
is_muted: false,
|
|
};
|
|
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
|
|
assert_eq!(evaluation.decision, IgnoreAudioDecision::Mix);
|
|
assert!(evaluation.event.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn muted_source_always_yields_mix_decision() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 5,
|
|
buffered_frames: 10_000_000,
|
|
last_frame_age_ns: 0,
|
|
is_muted: true,
|
|
};
|
|
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
|
|
assert_eq!(evaluation.decision, IgnoreAudioDecision::Mix);
|
|
}
|
|
|
|
#[test]
|
|
fn over_buffered_yields_ignore_this_tick() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 9,
|
|
buffered_frames: (AUDIO_BUFFERING_MAX_TICKS as u64) * 1024 + 1,
|
|
last_frame_age_ns: 0,
|
|
is_muted: false,
|
|
};
|
|
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
|
|
assert_eq!(evaluation.decision, IgnoreAudioDecision::IgnoreThisTick);
|
|
assert_eq!(policy.metrics_for(9).ignored_tick_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn over_threshold_buffer_triggers_reset() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 12,
|
|
buffered_frames: (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * 1024 + 1,
|
|
last_frame_age_ns: 0,
|
|
is_muted: false,
|
|
};
|
|
let evaluation = policy.evaluate(&state, canonical_tick(7)).expect("ok");
|
|
assert_eq!(evaluation.decision, IgnoreAudioDecision::ResetSource);
|
|
let event = evaluation.event.expect("reset emits event");
|
|
assert_eq!(event.source_id, 12);
|
|
assert_eq!(event.at_ns, 7);
|
|
assert_eq!(event.reason, IgnoreAudioResetReason::BufferOverflow);
|
|
assert_eq!(policy.metrics_for(12).reset_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_source_triggers_reset_with_stale_reason() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 1,
|
|
buffered_frames: 0,
|
|
last_frame_age_ns: SOURCE_STALE_AFTER_NS + 1,
|
|
is_muted: false,
|
|
};
|
|
let evaluation = policy.evaluate(&state, canonical_tick(0)).expect("ok");
|
|
assert_eq!(evaluation.decision, IgnoreAudioDecision::ResetSource);
|
|
let event = evaluation.event.expect("reset emits event");
|
|
assert_eq!(event.reason, IgnoreAudioResetReason::StaleSource);
|
|
}
|
|
|
|
#[test]
|
|
fn period_too_small_rejected() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 1,
|
|
buffered_frames: 0,
|
|
last_frame_age_ns: 0,
|
|
is_muted: false,
|
|
};
|
|
let err = policy
|
|
.evaluate(
|
|
&state,
|
|
IgnoreAudioTick {
|
|
at_ns: 0,
|
|
period_ns: 0,
|
|
},
|
|
)
|
|
.err();
|
|
assert!(matches!(
|
|
err,
|
|
Some(IgnoreAudioError::TickPeriodOutOfRange { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn compute_source_age_uses_registration_for_never_pushed() {
|
|
let registered_at_ns = 1_000_000;
|
|
let now_ns = 1_000_000 + 6_000_000_000;
|
|
let age = compute_source_age_ns(NEVER_PUSHED_SENTINEL, registered_at_ns, now_ns);
|
|
assert_eq!(age, 6_000_000_000);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_source_age_uses_last_push_after_first_push() {
|
|
let registered_at_ns = 1_000;
|
|
let last_push_ns = 5_000;
|
|
let age = compute_source_age_ns(last_push_ns, registered_at_ns, 9_000);
|
|
assert_eq!(age, 4_000);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_source_age_zero_when_now_before_baseline() {
|
|
let age = compute_source_age_ns(NEVER_PUSHED_SENTINEL, 5_000, 1_000);
|
|
assert_eq!(age, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn metrics_accumulate_across_calls() {
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 2,
|
|
buffered_frames: (AUDIO_BUFFERING_MAX_TICKS as u64) * 1024 + 1,
|
|
last_frame_age_ns: 0,
|
|
is_muted: false,
|
|
};
|
|
for _ in 0..5 {
|
|
let _ = policy.evaluate(&state, canonical_tick(0));
|
|
}
|
|
assert_eq!(policy.metrics_for(2).ignored_tick_count, 5);
|
|
}
|
|
}
|