mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
1690 lines
55 KiB
Rust
1690 lines
55 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
#![allow(dead_code)]
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::thread::{self, JoinHandle};
|
|
|
|
use fluxer_audio_apm::{
|
|
APM_MAX_FRAME_SAMPLES, ApmConfig, ApmError, AudioProcessor, StubAudioProcessor,
|
|
expected_frame_samples,
|
|
};
|
|
use fluxer_audio_mix::{
|
|
AUDIO_OUTPUT_FRAMES, AudioMixError, AudioMixSession, MAX_MIX_SOURCES, MixTickResult,
|
|
SourceRing, SourceRingConsumer, SourceRingProducer,
|
|
};
|
|
use fluxer_audio_timing::{AudioTimingSmoother, SourceGainRamp, StaleSourceTracker};
|
|
use fluxer_rt_thread::{
|
|
MonotonicClock, PriorityProfile, RealtimePriorityGuard, RtError, RtOutcome,
|
|
SystemMonotonicClock, TickDriver,
|
|
};
|
|
|
|
use crate::ignore_audio_runtime::{
|
|
IgnoreAudioPolicy, IgnoreAudioSourceResetEvent, IgnoreAudioSourceState, IgnoreAudioTick,
|
|
compute_source_age_ns,
|
|
};
|
|
|
|
pub const MIX_TICK_PERIOD_NS: u64 = 21_333_333;
|
|
|
|
pub const SOURCE_RING_CAP_FRAMES: usize = 8_192;
|
|
|
|
pub const MIX_SAMPLE_RATE_HZ: u32 = 48_000;
|
|
|
|
pub const MIX_CHANNELS: u16 = 1;
|
|
|
|
pub const OUTPUT_BUFFER_POOL_DEPTH: usize = 4;
|
|
|
|
const NEVER_PUSHED_SENTINEL: u64 = u64::MAX;
|
|
|
|
pub struct DynMonotonicClock {
|
|
inner: Arc<dyn MonotonicClock>,
|
|
}
|
|
|
|
impl DynMonotonicClock {
|
|
pub fn new(inner: Arc<dyn MonotonicClock>) -> Self {
|
|
Self { inner }
|
|
}
|
|
}
|
|
|
|
impl MonotonicClock for DynMonotonicClock {
|
|
fn now_ns(&self) -> u64 {
|
|
self.inner.now_ns()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum MixRuntimeError {
|
|
NoSources,
|
|
TooManySources { requested: usize, limit: usize },
|
|
Mix(AudioMixError),
|
|
Apm(ApmError),
|
|
Rt(RtError),
|
|
AlreadyRunning,
|
|
ThreadSpawn,
|
|
}
|
|
|
|
impl core::fmt::Display for MixRuntimeError {
|
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
|
match self {
|
|
MixRuntimeError::NoSources => write!(f, "mix runtime requires at least one source"),
|
|
MixRuntimeError::TooManySources { requested, limit } => {
|
|
write!(f, "source count {requested} exceeds limit {limit}")
|
|
}
|
|
MixRuntimeError::Mix(e) => write!(f, "{e}"),
|
|
MixRuntimeError::Apm(e) => write!(f, "{e}"),
|
|
MixRuntimeError::Rt(e) => write!(f, "{e}"),
|
|
MixRuntimeError::AlreadyRunning => write!(f, "mix runtime already running"),
|
|
MixRuntimeError::ThreadSpawn => write!(f, "failed to spawn mix thread"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for MixRuntimeError {}
|
|
|
|
impl From<AudioMixError> for MixRuntimeError {
|
|
fn from(value: AudioMixError) -> Self {
|
|
MixRuntimeError::Mix(value)
|
|
}
|
|
}
|
|
|
|
impl From<ApmError> for MixRuntimeError {
|
|
fn from(value: ApmError) -> Self {
|
|
MixRuntimeError::Apm(value)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct MixOutputFrame {
|
|
pub samples: Arc<[i16; AUDIO_OUTPUT_FRAMES]>,
|
|
pub tick_index: u64,
|
|
pub scheduled_ns: u64,
|
|
pub actual_ns: u64,
|
|
pub total_drained: u64,
|
|
pub total_silence: u64,
|
|
pub saturated_samples: u32,
|
|
}
|
|
|
|
impl PartialEq for MixOutputFrame {
|
|
fn eq(&self, other: &Self) -> bool {
|
|
self.tick_index == other.tick_index
|
|
&& self.scheduled_ns == other.scheduled_ns
|
|
&& self.actual_ns == other.actual_ns
|
|
&& self.total_drained == other.total_drained
|
|
&& self.total_silence == other.total_silence
|
|
&& self.saturated_samples == other.saturated_samples
|
|
&& self.samples.as_ref() == other.samples.as_ref()
|
|
}
|
|
}
|
|
|
|
pub trait MixOutputSink: Send {
|
|
fn on_mixed_frame(&mut self, frame: &MixOutputFrame);
|
|
|
|
fn on_source_reset(&mut self, event: &IgnoreAudioSourceResetEvent);
|
|
}
|
|
|
|
pub struct NullMixOutputSink;
|
|
|
|
impl MixOutputSink for NullMixOutputSink {
|
|
fn on_mixed_frame(&mut self, _frame: &MixOutputFrame) {}
|
|
fn on_source_reset(&mut self, _event: &IgnoreAudioSourceResetEvent) {}
|
|
}
|
|
|
|
pub struct CapturedMixOutputSink {
|
|
frames: Arc<Mutex<Vec<MixOutputFrame>>>,
|
|
resets: Arc<Mutex<Vec<IgnoreAudioSourceResetEvent>>>,
|
|
}
|
|
|
|
impl CapturedMixOutputSink {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
frames: Arc::new(Mutex::new(Vec::new())),
|
|
resets: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
|
|
pub fn frames(&self) -> Arc<Mutex<Vec<MixOutputFrame>>> {
|
|
Arc::clone(&self.frames)
|
|
}
|
|
|
|
pub fn resets(&self) -> Arc<Mutex<Vec<IgnoreAudioSourceResetEvent>>> {
|
|
Arc::clone(&self.resets)
|
|
}
|
|
}
|
|
|
|
impl Default for CapturedMixOutputSink {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl MixOutputSink for CapturedMixOutputSink {
|
|
fn on_mixed_frame(&mut self, frame: &MixOutputFrame) {
|
|
if let Ok(mut guard) = self.frames.lock() {
|
|
guard.push(frame.clone());
|
|
}
|
|
}
|
|
|
|
fn on_source_reset(&mut self, event: &IgnoreAudioSourceResetEvent) {
|
|
if let Ok(mut guard) = self.resets.lock() {
|
|
guard.push(*event);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct SourceFreshnessHandle {
|
|
last_push_ns: Arc<AtomicU64>,
|
|
registered_at_ns: u64,
|
|
clock: Arc<dyn MonotonicClock>,
|
|
}
|
|
|
|
impl SourceFreshnessHandle {
|
|
pub fn mark_push_now(&self) {
|
|
let now = self.clock.now_ns();
|
|
assert!(now > 0);
|
|
self.last_push_ns.store(now, Ordering::Release);
|
|
}
|
|
|
|
pub fn mark_push_at(&self, push_ns: u64) {
|
|
assert!(push_ns > 0);
|
|
self.last_push_ns.store(push_ns, Ordering::Release);
|
|
}
|
|
|
|
pub fn last_push_ns(&self) -> u64 {
|
|
self.last_push_ns.load(Ordering::Acquire)
|
|
}
|
|
|
|
pub fn registered_at_ns(&self) -> u64 {
|
|
assert!(self.registered_at_ns != NEVER_PUSHED_SENTINEL);
|
|
self.registered_at_ns
|
|
}
|
|
}
|
|
|
|
pub struct CaptureSource {
|
|
pub source_id: u64,
|
|
pub producer: SourceRingProducer,
|
|
pub apm: StubAudioProcessor,
|
|
apm_sample_rate_hz: u32,
|
|
apm_channels: u16,
|
|
apm_frame_samples: usize,
|
|
freshness: SourceFreshnessHandle,
|
|
}
|
|
|
|
impl CaptureSource {
|
|
pub fn create(
|
|
source_id: u64,
|
|
sample_rate_hz: u32,
|
|
channels: u16,
|
|
) -> Result<(Self, SourceRingConsumer), MixRuntimeError> {
|
|
assert!(source_id != 0);
|
|
assert!(sample_rate_hz >= 8_000);
|
|
assert!(channels >= 1);
|
|
let (producer, consumer) = SourceRing::create(SOURCE_RING_CAP_FRAMES, sample_rate_hz)?;
|
|
let apm = StubAudioProcessor::new(ApmConfig::default(), sample_rate_hz, channels)?;
|
|
let apm_frame_samples = expected_frame_samples(sample_rate_hz, channels);
|
|
assert!(apm_frame_samples > 0);
|
|
assert!(apm_frame_samples <= APM_MAX_FRAME_SAMPLES * (channels as usize));
|
|
let clock_for_freshness: Arc<dyn MonotonicClock> = Arc::new(SystemMonotonicClock::new());
|
|
let registered_at_ns = clock_for_freshness.now_ns().max(1);
|
|
assert!(registered_at_ns != NEVER_PUSHED_SENTINEL);
|
|
let freshness = SourceFreshnessHandle {
|
|
last_push_ns: Arc::new(AtomicU64::new(NEVER_PUSHED_SENTINEL)),
|
|
registered_at_ns,
|
|
clock: clock_for_freshness,
|
|
};
|
|
let me = Self {
|
|
source_id,
|
|
producer,
|
|
apm,
|
|
apm_sample_rate_hz: sample_rate_hz,
|
|
apm_channels: channels,
|
|
apm_frame_samples,
|
|
freshness,
|
|
};
|
|
Ok((me, consumer))
|
|
}
|
|
|
|
pub fn ingest(&mut self, samples: &mut [i16]) -> Result<usize, MixRuntimeError> {
|
|
assert!(!samples.is_empty());
|
|
assert_eq!(samples.len() % self.apm_frame_samples, 0);
|
|
for chunk in samples.chunks_mut(self.apm_frame_samples) {
|
|
self.apm
|
|
.process_capture_frame(chunk, self.apm_sample_rate_hz, self.apm_channels)?;
|
|
}
|
|
let pushed = self.producer.try_push_slice(samples);
|
|
self.freshness.mark_push_now();
|
|
Ok(pushed)
|
|
}
|
|
|
|
pub fn ingest_skip_apm(&mut self, samples: &[i16]) -> usize {
|
|
assert!(!samples.is_empty());
|
|
let pushed = self.producer.try_push_slice(samples);
|
|
self.freshness.mark_push_now();
|
|
pushed
|
|
}
|
|
|
|
pub fn source_id(&self) -> u64 {
|
|
assert!(self.source_id != 0);
|
|
self.source_id
|
|
}
|
|
|
|
pub fn buffered_frames(&self) -> u64 {
|
|
self.producer
|
|
.capacity_frames()
|
|
.saturating_sub(self.producer.slots()) as u64
|
|
}
|
|
|
|
pub fn freshness_handle(&self) -> &SourceFreshnessHandle {
|
|
&self.freshness
|
|
}
|
|
|
|
pub fn last_push_ns_arc(&self) -> Arc<AtomicU64> {
|
|
Arc::clone(&self.freshness.last_push_ns)
|
|
}
|
|
|
|
pub fn apm_frames_processed(&self) -> u64 {
|
|
self.apm.capture_frames_processed()
|
|
}
|
|
}
|
|
|
|
struct BuilderSourceEntry {
|
|
source_id: u64,
|
|
consumer: SourceRingConsumer,
|
|
last_push_ns: Arc<AtomicU64>,
|
|
registered_at_ns: u64,
|
|
}
|
|
|
|
pub struct AudioMixRuntimeBuilder {
|
|
consumers: Vec<BuilderSourceEntry>,
|
|
sample_rate_hz: u32,
|
|
period_ns: u64,
|
|
clock: Arc<dyn MonotonicClock>,
|
|
}
|
|
|
|
impl AudioMixRuntimeBuilder {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
consumers: Vec::new(),
|
|
sample_rate_hz: MIX_SAMPLE_RATE_HZ,
|
|
period_ns: MIX_TICK_PERIOD_NS,
|
|
clock: Arc::new(SystemMonotonicClock::new()),
|
|
}
|
|
}
|
|
|
|
pub fn with_period_ns(mut self, period_ns: u64) -> Self {
|
|
assert!(period_ns > 0);
|
|
self.period_ns = period_ns;
|
|
self
|
|
}
|
|
|
|
pub fn with_sample_rate_hz(mut self, sample_rate_hz: u32) -> Self {
|
|
assert!(sample_rate_hz >= 8_000);
|
|
self.sample_rate_hz = sample_rate_hz;
|
|
self
|
|
}
|
|
|
|
pub fn with_clock(mut self, clock: Arc<dyn MonotonicClock>) -> Self {
|
|
self.clock = clock;
|
|
self
|
|
}
|
|
|
|
pub fn add_source(mut self, source_id: u64, consumer: SourceRingConsumer) -> Self {
|
|
assert!(self.consumers.len() < MAX_MIX_SOURCES);
|
|
assert!(source_id != 0);
|
|
let placeholder = Arc::new(AtomicU64::new(NEVER_PUSHED_SENTINEL));
|
|
let registered_at_ns = self.clock.now_ns().max(1);
|
|
assert!(registered_at_ns != NEVER_PUSHED_SENTINEL);
|
|
self.consumers.push(BuilderSourceEntry {
|
|
source_id,
|
|
consumer,
|
|
last_push_ns: placeholder,
|
|
registered_at_ns,
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn add_source_with_freshness(
|
|
mut self,
|
|
source_id: u64,
|
|
consumer: SourceRingConsumer,
|
|
last_push_ns: Arc<AtomicU64>,
|
|
) -> Self {
|
|
assert!(self.consumers.len() < MAX_MIX_SOURCES);
|
|
assert!(source_id != 0);
|
|
let registered_at_ns = self.clock.now_ns().max(1);
|
|
assert!(registered_at_ns != NEVER_PUSHED_SENTINEL);
|
|
self.consumers.push(BuilderSourceEntry {
|
|
source_id,
|
|
consumer,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
});
|
|
self
|
|
}
|
|
|
|
pub fn build<S: MixOutputSink + 'static>(
|
|
self,
|
|
sink: S,
|
|
) -> Result<AudioMixRuntime, MixRuntimeError> {
|
|
if self.consumers.is_empty() {
|
|
return Err(MixRuntimeError::NoSources);
|
|
}
|
|
if self.consumers.len() > MAX_MIX_SOURCES {
|
|
return Err(MixRuntimeError::TooManySources {
|
|
requested: self.consumers.len(),
|
|
limit: MAX_MIX_SOURCES,
|
|
});
|
|
}
|
|
let consumer_count = self.consumers.len();
|
|
let mut source_ids: Vec<u64> = Vec::with_capacity(consumer_count);
|
|
let mut consumers: Vec<SourceRingConsumer> = Vec::with_capacity(consumer_count);
|
|
let mut last_push_ns: Vec<Arc<AtomicU64>> = Vec::with_capacity(consumer_count);
|
|
let mut registered_at_ns: Vec<u64> = Vec::with_capacity(consumer_count);
|
|
let mut smoothers: Vec<AudioTimingSmoother> = Vec::with_capacity(consumer_count);
|
|
let mut ramps: Vec<SourceGainRamp> = Vec::with_capacity(consumer_count);
|
|
let mut stale_tracker = StaleSourceTracker::new(consumer_count)
|
|
.map_err(|_| MixRuntimeError::Mix(AudioMixError::ZeroSources))?;
|
|
for entry in self.consumers {
|
|
source_ids.push(entry.source_id);
|
|
consumers.push(entry.consumer);
|
|
last_push_ns.push(entry.last_push_ns);
|
|
registered_at_ns.push(entry.registered_at_ns);
|
|
let smoother = AudioTimingSmoother::new(entry.source_id, self.sample_rate_hz)
|
|
.map_err(|_| MixRuntimeError::Mix(AudioMixError::ZeroSources))?;
|
|
smoothers.push(smoother);
|
|
ramps.push(SourceGainRamp::new());
|
|
stale_tracker
|
|
.register_source(entry.source_id, entry.registered_at_ns)
|
|
.map_err(|_| MixRuntimeError::Mix(AudioMixError::ZeroSources))?;
|
|
}
|
|
let session = AudioMixSession::new(consumers, AUDIO_OUTPUT_FRAMES)?;
|
|
let policy = IgnoreAudioPolicy::new(self.sample_rate_hz)
|
|
.map_err(|_| MixRuntimeError::Mix(AudioMixError::ZeroSources))?;
|
|
let pool = build_output_pool();
|
|
let last_marked_push_ns = vec![NEVER_PUSHED_SENTINEL; consumer_count];
|
|
Ok(AudioMixRuntime {
|
|
session: Some(session),
|
|
policy: Some(policy),
|
|
source_ids,
|
|
last_push_ns,
|
|
last_marked_push_ns,
|
|
registered_at_ns,
|
|
smoothers,
|
|
ramps,
|
|
stale_tracker,
|
|
mark_pushed_total: Arc::new(AtomicU64::new(0)),
|
|
clock: self.clock,
|
|
sample_rate_hz: self.sample_rate_hz,
|
|
period_ns: self.period_ns,
|
|
sink: Some(Box::new(sink)),
|
|
running: Arc::new(AtomicBool::new(false)),
|
|
thread: None,
|
|
rt_outcome: Arc::new(Mutex::new(None)),
|
|
manual_tick_index: 0,
|
|
pre_stats: [0u64; MAX_MIX_SOURCES],
|
|
output_pool: pool,
|
|
output_pool_cursor: 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Default for AudioMixRuntimeBuilder {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
fn build_output_pool() -> [Arc<[i16; AUDIO_OUTPUT_FRAMES]>; OUTPUT_BUFFER_POOL_DEPTH] {
|
|
[
|
|
Arc::new([0i16; AUDIO_OUTPUT_FRAMES]),
|
|
Arc::new([0i16; AUDIO_OUTPUT_FRAMES]),
|
|
Arc::new([0i16; AUDIO_OUTPUT_FRAMES]),
|
|
Arc::new([0i16; AUDIO_OUTPUT_FRAMES]),
|
|
]
|
|
}
|
|
|
|
pub struct AudioMixRuntime {
|
|
session: Option<AudioMixSession>,
|
|
policy: Option<IgnoreAudioPolicy>,
|
|
source_ids: Vec<u64>,
|
|
last_push_ns: Vec<Arc<AtomicU64>>,
|
|
last_marked_push_ns: Vec<u64>,
|
|
registered_at_ns: Vec<u64>,
|
|
smoothers: Vec<AudioTimingSmoother>,
|
|
ramps: Vec<SourceGainRamp>,
|
|
stale_tracker: StaleSourceTracker,
|
|
mark_pushed_total: Arc<AtomicU64>,
|
|
clock: Arc<dyn MonotonicClock>,
|
|
sample_rate_hz: u32,
|
|
period_ns: u64,
|
|
sink: Option<Box<dyn MixOutputSink>>,
|
|
running: Arc<AtomicBool>,
|
|
thread: Option<JoinHandle<()>>,
|
|
rt_outcome: Arc<Mutex<Option<MixRtOutcome>>>,
|
|
manual_tick_index: u64,
|
|
pre_stats: [u64; MAX_MIX_SOURCES],
|
|
output_pool: [Arc<[i16; AUDIO_OUTPUT_FRAMES]>; OUTPUT_BUFFER_POOL_DEPTH],
|
|
output_pool_cursor: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum MixRtOutcome {
|
|
Acquired,
|
|
PartialFallback,
|
|
Denied,
|
|
}
|
|
|
|
impl AudioMixRuntime {
|
|
pub fn source_count(&self) -> usize {
|
|
assert!(!self.source_ids.is_empty());
|
|
assert!(self.source_ids.len() <= MAX_MIX_SOURCES);
|
|
self.source_ids.len()
|
|
}
|
|
|
|
pub fn sample_rate_hz(&self) -> u32 {
|
|
assert!(self.sample_rate_hz >= 8_000);
|
|
self.sample_rate_hz
|
|
}
|
|
|
|
pub fn period_ns(&self) -> u64 {
|
|
assert!(self.period_ns > 0);
|
|
self.period_ns
|
|
}
|
|
|
|
pub fn run_one_tick_blocking(
|
|
&mut self,
|
|
tick_at_ns: u64,
|
|
) -> Result<MixOutputFrame, MixRuntimeError> {
|
|
let tick_index = self.manual_tick_index;
|
|
self.manual_tick_index = self.manual_tick_index.saturating_add(1);
|
|
let tick_info = fluxer_rt_thread::TickInfo {
|
|
tick_index,
|
|
scheduled_ns: tick_at_ns,
|
|
actual_ns: tick_at_ns,
|
|
lag_ns: 0,
|
|
};
|
|
self.run_tick_internal(tick_info)
|
|
}
|
|
|
|
pub fn observe_source_pushes_without_mix(
|
|
&mut self,
|
|
tick_at_ns: u64,
|
|
) -> Result<u64, MixRuntimeError> {
|
|
assert!(tick_at_ns > 0);
|
|
let before = self.mark_pushed_total.load(Ordering::Acquire);
|
|
let session = self
|
|
.session
|
|
.as_mut()
|
|
.ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let policy = self
|
|
.policy
|
|
.as_mut()
|
|
.ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let sink = self.sink.as_mut().ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let now_ns = self.clock.now_ns();
|
|
snapshot_pre_stats(session, &mut self.pre_stats);
|
|
drain_pushes_into_tracker(
|
|
&mut self.stale_tracker,
|
|
&self.source_ids,
|
|
&self.last_push_ns,
|
|
&mut self.last_marked_push_ns,
|
|
&self.mark_pushed_total,
|
|
);
|
|
evaluate_policy_for_all_sources(
|
|
policy,
|
|
&self.source_ids,
|
|
&self.last_push_ns,
|
|
&self.registered_at_ns,
|
|
&self.pre_stats,
|
|
now_ns,
|
|
tick_at_ns,
|
|
self.period_ns,
|
|
sink.as_mut(),
|
|
);
|
|
advance_smoothers_and_ramps(
|
|
&mut self.smoothers,
|
|
&mut self.ramps,
|
|
&self.last_push_ns,
|
|
tick_at_ns,
|
|
self.period_ns,
|
|
self.sample_rate_hz,
|
|
);
|
|
let after = self.mark_pushed_total.load(Ordering::Acquire);
|
|
Ok(after.saturating_sub(before))
|
|
}
|
|
|
|
fn run_tick_internal(
|
|
&mut self,
|
|
tick_info: fluxer_rt_thread::TickInfo,
|
|
) -> Result<MixOutputFrame, MixRuntimeError> {
|
|
let period_ns = self.period_ns;
|
|
let session = self
|
|
.session
|
|
.as_mut()
|
|
.ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let policy = self
|
|
.policy
|
|
.as_mut()
|
|
.ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let sink = self.sink.as_mut().ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let now_ns = self.clock.now_ns();
|
|
snapshot_pre_stats(session, &mut self.pre_stats);
|
|
drain_pushes_into_tracker(
|
|
&mut self.stale_tracker,
|
|
&self.source_ids,
|
|
&self.last_push_ns,
|
|
&mut self.last_marked_push_ns,
|
|
&self.mark_pushed_total,
|
|
);
|
|
evaluate_policy_for_all_sources(
|
|
policy,
|
|
&self.source_ids,
|
|
&self.last_push_ns,
|
|
&self.registered_at_ns,
|
|
&self.pre_stats,
|
|
now_ns,
|
|
tick_info.actual_ns,
|
|
period_ns,
|
|
sink.as_mut(),
|
|
);
|
|
advance_smoothers_and_ramps(
|
|
&mut self.smoothers,
|
|
&mut self.ramps,
|
|
&self.last_push_ns,
|
|
tick_info.actual_ns,
|
|
period_ns,
|
|
self.sample_rate_hz,
|
|
);
|
|
let result: MixTickResult = session.tick(tick_info);
|
|
let frame = build_output_frame_pooled(
|
|
session,
|
|
&result,
|
|
&mut self.output_pool,
|
|
&mut self.output_pool_cursor,
|
|
);
|
|
sink.on_mixed_frame(&frame);
|
|
Ok(frame)
|
|
}
|
|
|
|
pub fn mark_pushed_total(&self) -> u64 {
|
|
assert!(!self.source_ids.is_empty());
|
|
let value = self.mark_pushed_total.load(Ordering::Acquire);
|
|
assert!(self.source_ids.len() <= MAX_MIX_SOURCES);
|
|
value
|
|
}
|
|
|
|
pub fn mark_pushed_total_arc(&self) -> Arc<AtomicU64> {
|
|
assert!(!self.source_ids.is_empty());
|
|
assert!(self.source_ids.len() <= MAX_MIX_SOURCES);
|
|
Arc::clone(&self.mark_pushed_total)
|
|
}
|
|
|
|
pub fn current_ramp_gain(&self, index: usize) -> f32 {
|
|
assert!(index < self.ramps.len());
|
|
self.ramps[index].current_gain()
|
|
}
|
|
|
|
pub fn smoother_initialised(&self, index: usize) -> bool {
|
|
assert!(index < self.smoothers.len());
|
|
self.smoothers[index].initialised()
|
|
}
|
|
|
|
pub fn is_source_stale(&self, index: usize, now_ns: u64, stale_threshold_ns: u64) -> bool {
|
|
assert!(index < self.source_ids.len());
|
|
assert!(self.source_ids.len() <= MAX_MIX_SOURCES);
|
|
let id = self.source_ids[index];
|
|
self.stale_tracker
|
|
.is_stale_at(index, id, now_ns, stale_threshold_ns)
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
pub fn start(&mut self) -> Result<(), MixRuntimeError> {
|
|
if self.running.swap(true, Ordering::AcqRel) {
|
|
return Err(MixRuntimeError::AlreadyRunning);
|
|
}
|
|
let session = self.session.take().ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let policy = self.policy.take().ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let sink = self.sink.take().ok_or(MixRuntimeError::AlreadyRunning)?;
|
|
let source_ids = self.source_ids.clone();
|
|
let last_push_ns = self.last_push_ns.clone();
|
|
let registered_at_ns = self.registered_at_ns.clone();
|
|
let period_ns = self.period_ns;
|
|
let sample_rate_hz = self.sample_rate_hz;
|
|
let running = Arc::clone(&self.running);
|
|
let rt_outcome = Arc::clone(&self.rt_outcome);
|
|
let clock = Arc::clone(&self.clock);
|
|
let pool = self.output_pool.clone();
|
|
let smoothers = std::mem::take(&mut self.smoothers);
|
|
let ramps = std::mem::take(&mut self.ramps);
|
|
let stale_tracker = std::mem::replace(
|
|
&mut self.stale_tracker,
|
|
StaleSourceTracker::new(self.source_ids.len())
|
|
.map_err(|_| MixRuntimeError::Mix(AudioMixError::ZeroSources))?,
|
|
);
|
|
let last_marked = std::mem::take(&mut self.last_marked_push_ns);
|
|
let mark_pushed_total = Arc::clone(&self.mark_pushed_total);
|
|
let handle = thread::Builder::new()
|
|
.name("fluxer-audio-mix".into())
|
|
.spawn(move || {
|
|
spawn_mix_thread(MixThreadArgs {
|
|
session,
|
|
policy,
|
|
source_ids,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
smoothers,
|
|
ramps,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
sink,
|
|
running,
|
|
rt_outcome,
|
|
clock,
|
|
pool,
|
|
stale_tracker,
|
|
last_marked,
|
|
mark_pushed_total,
|
|
});
|
|
})
|
|
.map_err(|_| MixRuntimeError::ThreadSpawn)?;
|
|
self.thread = Some(handle);
|
|
Ok(())
|
|
}
|
|
|
|
pub fn stop(&mut self) {
|
|
self.running.store(false, Ordering::Release);
|
|
if let Some(handle) = self.thread.take() {
|
|
let _ = handle.join();
|
|
}
|
|
}
|
|
|
|
pub fn rt_outcome(&self) -> Option<MixRtOutcome> {
|
|
match self.rt_outcome.lock() {
|
|
Ok(guard) => *guard,
|
|
Err(_) => None,
|
|
}
|
|
}
|
|
|
|
pub fn last_push_ns_for(&self, index: usize) -> u64 {
|
|
assert!(index < self.last_push_ns.len());
|
|
self.last_push_ns[index].load(Ordering::Acquire)
|
|
}
|
|
|
|
pub fn output_pool_depth(&self) -> usize {
|
|
assert_eq!(self.output_pool.len(), OUTPUT_BUFFER_POOL_DEPTH);
|
|
OUTPUT_BUFFER_POOL_DEPTH
|
|
}
|
|
}
|
|
|
|
impl Drop for AudioMixRuntime {
|
|
fn drop(&mut self) {
|
|
self.stop();
|
|
}
|
|
}
|
|
|
|
struct MixThreadArgs {
|
|
session: AudioMixSession,
|
|
policy: IgnoreAudioPolicy,
|
|
source_ids: Vec<u64>,
|
|
last_push_ns: Vec<Arc<AtomicU64>>,
|
|
registered_at_ns: Vec<u64>,
|
|
smoothers: Vec<AudioTimingSmoother>,
|
|
ramps: Vec<SourceGainRamp>,
|
|
period_ns: u64,
|
|
sample_rate_hz: u32,
|
|
sink: Box<dyn MixOutputSink>,
|
|
running: Arc<AtomicBool>,
|
|
rt_outcome: Arc<Mutex<Option<MixRtOutcome>>>,
|
|
clock: Arc<dyn MonotonicClock>,
|
|
pool: [Arc<[i16; AUDIO_OUTPUT_FRAMES]>; OUTPUT_BUFFER_POOL_DEPTH],
|
|
stale_tracker: StaleSourceTracker,
|
|
last_marked: Vec<u64>,
|
|
mark_pushed_total: Arc<AtomicU64>,
|
|
}
|
|
|
|
fn spawn_mix_thread(args: MixThreadArgs) {
|
|
let MixThreadArgs {
|
|
mut session,
|
|
mut policy,
|
|
source_ids,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
mut smoothers,
|
|
mut ramps,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
mut sink,
|
|
running,
|
|
rt_outcome,
|
|
clock,
|
|
pool,
|
|
mut stale_tracker,
|
|
mut last_marked,
|
|
mark_pushed_total,
|
|
} = args;
|
|
assert!(!source_ids.is_empty());
|
|
assert_eq!(source_ids.len(), last_push_ns.len());
|
|
assert_eq!(source_ids.len(), registered_at_ns.len());
|
|
assert_eq!(source_ids.len(), last_marked.len());
|
|
let guard_result = RealtimePriorityGuard::acquire(PriorityProfile::Audio);
|
|
let (rt_state, _rt_guard) = match guard_result {
|
|
Ok(guard) => {
|
|
let state = match guard.outcome() {
|
|
RtOutcome::Acquired => MixRtOutcome::Acquired,
|
|
RtOutcome::PartialFallback => MixRtOutcome::PartialFallback,
|
|
};
|
|
(state, Some(guard))
|
|
}
|
|
Err(_) => (MixRtOutcome::Denied, None),
|
|
};
|
|
if let Ok(mut guard_slot) = rt_outcome.lock() {
|
|
*guard_slot = Some(rt_state);
|
|
}
|
|
let mut pre_stats = [0u64; MAX_MIX_SOURCES];
|
|
let mut pool = pool;
|
|
let mut cursor: usize = 0;
|
|
let _ = run_mix_thread_loop(MixLoopArgs {
|
|
session: &mut session,
|
|
policy: &mut policy,
|
|
source_ids: &source_ids,
|
|
last_push_ns: &last_push_ns,
|
|
registered_at_ns: ®istered_at_ns,
|
|
smoothers: &mut smoothers,
|
|
ramps: &mut ramps,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
sink: sink.as_mut(),
|
|
running: &running,
|
|
clock: &clock,
|
|
pre_stats: &mut pre_stats,
|
|
pool: &mut pool,
|
|
cursor: &mut cursor,
|
|
stale_tracker: &mut stale_tracker,
|
|
last_marked: &mut last_marked,
|
|
mark_pushed_total: &mark_pushed_total,
|
|
});
|
|
}
|
|
|
|
struct MixLoopArgs<'a> {
|
|
session: &'a mut AudioMixSession,
|
|
policy: &'a mut IgnoreAudioPolicy,
|
|
source_ids: &'a [u64],
|
|
last_push_ns: &'a [Arc<AtomicU64>],
|
|
registered_at_ns: &'a [u64],
|
|
smoothers: &'a mut [AudioTimingSmoother],
|
|
ramps: &'a mut [SourceGainRamp],
|
|
period_ns: u64,
|
|
sample_rate_hz: u32,
|
|
sink: &'a mut dyn MixOutputSink,
|
|
running: &'a AtomicBool,
|
|
clock: &'a Arc<dyn MonotonicClock>,
|
|
pre_stats: &'a mut [u64; MAX_MIX_SOURCES],
|
|
pool: &'a mut [Arc<[i16; AUDIO_OUTPUT_FRAMES]>; OUTPUT_BUFFER_POOL_DEPTH],
|
|
cursor: &'a mut usize,
|
|
stale_tracker: &'a mut StaleSourceTracker,
|
|
last_marked: &'a mut [u64],
|
|
mark_pushed_total: &'a Arc<AtomicU64>,
|
|
}
|
|
|
|
fn run_mix_thread_loop(args: MixLoopArgs<'_>) -> Result<(), MixRuntimeError> {
|
|
let MixLoopArgs {
|
|
session,
|
|
policy,
|
|
source_ids,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
smoothers,
|
|
ramps,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
sink,
|
|
running,
|
|
clock,
|
|
pre_stats,
|
|
pool,
|
|
cursor,
|
|
stale_tracker,
|
|
last_marked,
|
|
mark_pushed_total,
|
|
} = args;
|
|
assert!(!source_ids.is_empty());
|
|
assert_eq!(source_ids.len(), last_push_ns.len());
|
|
let driver_clock = Arc::new(DynMonotonicClock::new(Arc::clone(clock)));
|
|
let mut driver = TickDriver::new(driver_clock, period_ns)
|
|
.map_err(|_| MixRuntimeError::Mix(AudioMixError::ZeroSources))?;
|
|
while running.load(Ordering::Acquire) {
|
|
let tick_info = match driver.wait_until_next_tick() {
|
|
Ok(info) => info,
|
|
Err(_) => break,
|
|
};
|
|
if !running.load(Ordering::Acquire) {
|
|
break;
|
|
}
|
|
let _ = run_single_tick(SingleTickArgs {
|
|
session,
|
|
policy,
|
|
source_ids,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
smoothers,
|
|
ramps,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
tick_info,
|
|
sink,
|
|
clock,
|
|
pre_stats,
|
|
pool,
|
|
cursor,
|
|
stale_tracker,
|
|
last_marked,
|
|
mark_pushed_total,
|
|
});
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
struct SingleTickArgs<'a> {
|
|
session: &'a mut AudioMixSession,
|
|
policy: &'a mut IgnoreAudioPolicy,
|
|
source_ids: &'a [u64],
|
|
last_push_ns: &'a [Arc<AtomicU64>],
|
|
registered_at_ns: &'a [u64],
|
|
smoothers: &'a mut [AudioTimingSmoother],
|
|
ramps: &'a mut [SourceGainRamp],
|
|
period_ns: u64,
|
|
sample_rate_hz: u32,
|
|
tick_info: fluxer_rt_thread::TickInfo,
|
|
sink: &'a mut dyn MixOutputSink,
|
|
clock: &'a Arc<dyn MonotonicClock>,
|
|
pre_stats: &'a mut [u64; MAX_MIX_SOURCES],
|
|
pool: &'a mut [Arc<[i16; AUDIO_OUTPUT_FRAMES]>; OUTPUT_BUFFER_POOL_DEPTH],
|
|
cursor: &'a mut usize,
|
|
stale_tracker: &'a mut StaleSourceTracker,
|
|
last_marked: &'a mut [u64],
|
|
mark_pushed_total: &'a Arc<AtomicU64>,
|
|
}
|
|
|
|
fn run_single_tick(args: SingleTickArgs<'_>) -> Result<MixOutputFrame, MixRuntimeError> {
|
|
let SingleTickArgs {
|
|
session,
|
|
policy,
|
|
source_ids,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
smoothers,
|
|
ramps,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
tick_info,
|
|
sink,
|
|
clock,
|
|
pre_stats,
|
|
pool,
|
|
cursor,
|
|
stale_tracker,
|
|
last_marked,
|
|
mark_pushed_total,
|
|
} = args;
|
|
assert!(!source_ids.is_empty());
|
|
assert_eq!(source_ids.len(), session.source_count());
|
|
assert_eq!(source_ids.len(), last_push_ns.len());
|
|
let now_ns = clock.now_ns();
|
|
snapshot_pre_stats(session, pre_stats);
|
|
drain_pushes_into_tracker(
|
|
stale_tracker,
|
|
source_ids,
|
|
last_push_ns,
|
|
last_marked,
|
|
mark_pushed_total,
|
|
);
|
|
evaluate_policy_for_all_sources(
|
|
policy,
|
|
source_ids,
|
|
last_push_ns,
|
|
registered_at_ns,
|
|
pre_stats,
|
|
now_ns,
|
|
tick_info.actual_ns,
|
|
period_ns,
|
|
sink,
|
|
);
|
|
advance_smoothers_and_ramps(
|
|
smoothers,
|
|
ramps,
|
|
last_push_ns,
|
|
tick_info.actual_ns,
|
|
period_ns,
|
|
sample_rate_hz,
|
|
);
|
|
let result: MixTickResult = session.tick(tick_info);
|
|
let frame = build_output_frame_pooled(session, &result, pool, cursor);
|
|
sink.on_mixed_frame(&frame);
|
|
Ok(frame)
|
|
}
|
|
|
|
fn drain_pushes_into_tracker(
|
|
stale_tracker: &mut StaleSourceTracker,
|
|
source_ids: &[u64],
|
|
last_push_ns: &[Arc<AtomicU64>],
|
|
last_marked: &mut [u64],
|
|
mark_pushed_total: &Arc<AtomicU64>,
|
|
) {
|
|
assert_eq!(source_ids.len(), last_push_ns.len());
|
|
assert_eq!(source_ids.len(), last_marked.len());
|
|
assert_eq!(source_ids.len(), stale_tracker.len());
|
|
for idx in 0..source_ids.len() {
|
|
let observed = last_push_ns[idx].load(Ordering::Acquire);
|
|
if observed == NEVER_PUSHED_SENTINEL {
|
|
continue;
|
|
}
|
|
if observed == last_marked[idx] {
|
|
continue;
|
|
}
|
|
let source_id = source_ids[idx];
|
|
if stale_tracker
|
|
.mark_pushed_at(idx, source_id, observed)
|
|
.is_ok()
|
|
{
|
|
last_marked[idx] = observed;
|
|
mark_pushed_total.fetch_add(1, Ordering::AcqRel);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn advance_smoothers_and_ramps(
|
|
smoothers: &mut [AudioTimingSmoother],
|
|
ramps: &mut [SourceGainRamp],
|
|
last_push_ns: &[Arc<AtomicU64>],
|
|
tick_at_ns: u64,
|
|
period_ns: u64,
|
|
sample_rate_hz: u32,
|
|
) {
|
|
assert_eq!(smoothers.len(), ramps.len());
|
|
assert_eq!(smoothers.len(), last_push_ns.len());
|
|
let frames_per_tick = compute_frames_per_tick(period_ns, sample_rate_hz);
|
|
for index in 0..smoothers.len() {
|
|
let last_push = last_push_ns[index].load(Ordering::Acquire);
|
|
ramps[index].advance_tick();
|
|
if last_push == NEVER_PUSHED_SENTINEL {
|
|
continue;
|
|
}
|
|
let smoother = &mut smoothers[index];
|
|
let input = fluxer_audio_timing::SmootherInput {
|
|
media_ts_ns: last_push,
|
|
frames: frames_per_tick,
|
|
wall_ns: tick_at_ns,
|
|
};
|
|
let _ = smoother.adjust(input);
|
|
}
|
|
}
|
|
|
|
fn compute_frames_per_tick(period_ns: u64, sample_rate_hz: u32) -> u32 {
|
|
assert!(period_ns > 0);
|
|
assert!(sample_rate_hz >= 8_000);
|
|
let product = (period_ns as u128) * (sample_rate_hz as u128);
|
|
let frames = product / 1_000_000_000u128;
|
|
let frames_u32 = frames.min(u32::MAX as u128) as u32;
|
|
frames_u32.max(1)
|
|
}
|
|
|
|
fn snapshot_pre_stats(session: &AudioMixSession, pre_stats: &mut [u64; MAX_MIX_SOURCES]) {
|
|
let stats = session.per_source_stats();
|
|
assert!(stats.len() <= MAX_MIX_SOURCES);
|
|
for (slot, stat) in pre_stats.iter_mut().zip(stats.iter()) {
|
|
*slot = stat.drained_count as u64;
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn evaluate_policy_for_all_sources(
|
|
policy: &mut IgnoreAudioPolicy,
|
|
source_ids: &[u64],
|
|
last_push_ns: &[Arc<AtomicU64>],
|
|
registered_at_ns: &[u64],
|
|
pre_stats: &[u64; MAX_MIX_SOURCES],
|
|
now_ns: u64,
|
|
tick_at_ns: u64,
|
|
period_ns: u64,
|
|
sink: &mut dyn MixOutputSink,
|
|
) {
|
|
assert_eq!(source_ids.len(), last_push_ns.len());
|
|
assert_eq!(source_ids.len(), registered_at_ns.len());
|
|
let tick = IgnoreAudioTick {
|
|
at_ns: tick_at_ns,
|
|
period_ns,
|
|
};
|
|
for (idx, source_id) in source_ids.iter().enumerate() {
|
|
let drained_before = pre_stats[idx];
|
|
let last_push = last_push_ns[idx].load(Ordering::Acquire);
|
|
let age_ns = compute_age_ns(last_push, registered_at_ns[idx], now_ns);
|
|
let state = IgnoreAudioSourceState {
|
|
id: *source_id,
|
|
buffered_frames: drained_before,
|
|
last_frame_age_ns: age_ns,
|
|
is_muted: false,
|
|
};
|
|
if let Ok(eval) = policy.evaluate(&state, tick)
|
|
&& let Some(event) = eval.event
|
|
{
|
|
sink.on_source_reset(&event);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compute_age_ns(last_push_ns: u64, registered_at_ns: u64, now_ns: u64) -> u64 {
|
|
compute_source_age_ns(last_push_ns, registered_at_ns, now_ns)
|
|
}
|
|
|
|
fn build_output_frame_pooled(
|
|
session: &AudioMixSession,
|
|
result: &MixTickResult,
|
|
pool: &mut [Arc<[i16; AUDIO_OUTPUT_FRAMES]>; OUTPUT_BUFFER_POOL_DEPTH],
|
|
cursor: &mut usize,
|
|
) -> MixOutputFrame {
|
|
let slot_index = *cursor;
|
|
assert!(slot_index < OUTPUT_BUFFER_POOL_DEPTH);
|
|
let src = session.last_output();
|
|
let target_arc = &mut pool[slot_index];
|
|
let target = Arc::make_mut(target_arc);
|
|
target.copy_from_slice(src);
|
|
assert_eq!(target.len(), AUDIO_OUTPUT_FRAMES);
|
|
*cursor = (slot_index + 1) % OUTPUT_BUFFER_POOL_DEPTH;
|
|
MixOutputFrame {
|
|
samples: Arc::clone(&pool[slot_index]),
|
|
tick_index: result.tick_index,
|
|
scheduled_ns: result.scheduled_ns,
|
|
actual_ns: result.actual_ns,
|
|
total_drained: result.total_drained,
|
|
total_silence: result.total_silence,
|
|
saturated_samples: result.saturated_samples,
|
|
}
|
|
}
|
|
|
|
pub fn audio_apm_frame_samples(sample_rate_hz: u32, channels: u16) -> usize {
|
|
assert!(sample_rate_hz >= 8_000);
|
|
assert!(channels >= 1);
|
|
expected_frame_samples(sample_rate_hz, channels)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) static ALLOC_PROBE: std::sync::atomic::AtomicUsize =
|
|
std::sync::atomic::AtomicUsize::new(0);
|
|
|
|
#[cfg(test)]
|
|
thread_local! {
|
|
pub(crate) static THREAD_ALLOC_TRACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
|
pub(crate) static THREAD_ALLOC_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
|
}
|
|
|
|
#[cfg(test)]
|
|
struct CountingAllocator;
|
|
|
|
#[cfg(test)]
|
|
unsafe impl std::alloc::GlobalAlloc for CountingAllocator {
|
|
unsafe fn alloc(&self, layout: std::alloc::Layout) -> *mut u8 {
|
|
ALLOC_PROBE.fetch_add(1, Ordering::Relaxed);
|
|
let _ = THREAD_ALLOC_TRACK.try_with(|tracked| {
|
|
if tracked.get() {
|
|
let _ = THREAD_ALLOC_COUNT.try_with(|c| c.set(c.get().saturating_add(1)));
|
|
}
|
|
});
|
|
unsafe { std::alloc::System.alloc(layout) }
|
|
}
|
|
|
|
unsafe fn dealloc(&self, ptr: *mut u8, layout: std::alloc::Layout) {
|
|
unsafe { std::alloc::System.dealloc(ptr, layout) }
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[global_allocator]
|
|
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn begin_thread_alloc_probe() {
|
|
THREAD_ALLOC_COUNT.with(|c| c.set(0));
|
|
THREAD_ALLOC_TRACK.with(|t| t.set(true));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn end_thread_alloc_probe() -> usize {
|
|
THREAD_ALLOC_TRACK.with(|t| t.set(false));
|
|
THREAD_ALLOC_COUNT.with(|c| c.get())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use fluxer_rt_thread::TickInfo;
|
|
|
|
fn snapshot_allocs() -> usize {
|
|
ALLOC_PROBE.load(Ordering::Relaxed)
|
|
}
|
|
|
|
fn begin_thread_alloc_count() {
|
|
begin_thread_alloc_probe();
|
|
}
|
|
|
|
fn end_thread_alloc_count() -> usize {
|
|
end_thread_alloc_probe()
|
|
}
|
|
|
|
fn build_test_runtime(
|
|
source_count: usize,
|
|
) -> (Vec<CaptureSource>, AudioMixRuntime, CapturedMixOutputSink) {
|
|
assert!(source_count > 0);
|
|
let mut sources: Vec<CaptureSource> = Vec::with_capacity(source_count);
|
|
let mut builder = AudioMixRuntimeBuilder::new();
|
|
for n in 0..source_count {
|
|
let (source, consumer) =
|
|
CaptureSource::create(n as u64 + 1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("source");
|
|
let push_ns = source.last_push_ns_arc();
|
|
builder = builder.add_source_with_freshness(n as u64 + 1, consumer, push_ns);
|
|
sources.push(source);
|
|
}
|
|
let sink = CapturedMixOutputSink::new();
|
|
let cloned_frames = sink.frames();
|
|
let cloned_resets = sink.resets();
|
|
let runtime = builder
|
|
.build(CapturedMixOutputSink {
|
|
frames: cloned_frames,
|
|
resets: cloned_resets,
|
|
})
|
|
.expect("build");
|
|
(sources, runtime, sink)
|
|
}
|
|
|
|
fn make_pure_tone(amplitude: i16, len: usize) -> Vec<i16> {
|
|
assert!(len > 0);
|
|
(0..len).map(|n| (n as i16 % 8) * amplitude / 8).collect()
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct FakeClock {
|
|
value_ns: AtomicU64,
|
|
}
|
|
|
|
impl FakeClock {
|
|
fn new(initial_ns: u64) -> Self {
|
|
Self {
|
|
value_ns: AtomicU64::new(initial_ns),
|
|
}
|
|
}
|
|
fn set(&self, value_ns: u64) {
|
|
self.value_ns.store(value_ns, Ordering::Release);
|
|
}
|
|
}
|
|
|
|
impl MonotonicClock for FakeClock {
|
|
fn now_ns(&self) -> u64 {
|
|
self.value_ns.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
fn build_runtime_with_clock(
|
|
source_count: usize,
|
|
clock: Arc<dyn MonotonicClock>,
|
|
) -> (Vec<CaptureSource>, AudioMixRuntime, CapturedMixOutputSink) {
|
|
assert!(source_count > 0);
|
|
let mut sources: Vec<CaptureSource> = Vec::with_capacity(source_count);
|
|
let mut builder = AudioMixRuntimeBuilder::new().with_clock(clock);
|
|
for n in 0..source_count {
|
|
let (source, consumer) =
|
|
CaptureSource::create(n as u64 + 1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("source");
|
|
let push_ns = source.last_push_ns_arc();
|
|
builder = builder.add_source_with_freshness(n as u64 + 1, consumer, push_ns);
|
|
sources.push(source);
|
|
}
|
|
let sink = CapturedMixOutputSink::new();
|
|
let cloned_frames = sink.frames();
|
|
let cloned_resets = sink.resets();
|
|
let runtime = builder
|
|
.build(CapturedMixOutputSink {
|
|
frames: cloned_frames,
|
|
resets: cloned_resets,
|
|
})
|
|
.expect("build");
|
|
(sources, runtime, sink)
|
|
}
|
|
|
|
#[test]
|
|
fn realtime_priority_guard_acquires_and_releases() {
|
|
let guard = RealtimePriorityGuard::acquire(PriorityProfile::Audio);
|
|
match guard {
|
|
Ok(g) => {
|
|
let is_valid = matches!(
|
|
g.outcome(),
|
|
RtOutcome::Acquired | RtOutcome::PartialFallback
|
|
);
|
|
drop(g);
|
|
assert!(is_valid);
|
|
}
|
|
Err(RtError::PlatformDenied(_)) => {}
|
|
Err(other) => panic!("unexpected guard error: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn source_ring_produce_consume_1000_frames_deterministic() {
|
|
let (producer, mut consumer) = SourceRing::create(2048, 48_000).expect("pair");
|
|
let mut producer = producer;
|
|
let payload: Vec<i16> = (0..1000).map(|n| (n as i16 % 4096) - 2048).collect();
|
|
let pushed = producer.try_push_slice(&payload);
|
|
assert_eq!(pushed, 1000);
|
|
let mut buf = vec![0i16; 1000];
|
|
let drained = consumer.drain_into(&mut buf);
|
|
assert_eq!(drained, 1000);
|
|
assert_eq!(buf, payload);
|
|
}
|
|
|
|
#[test]
|
|
fn audio_mix_session_with_eight_pure_tone_sources_sums_to_saturated() {
|
|
let (mut sources, mut runtime, sink) = build_test_runtime(8);
|
|
let payload = vec![i16::MAX / 4; AUDIO_OUTPUT_FRAMES];
|
|
for source in sources.iter_mut() {
|
|
let pushed = source.ingest_skip_apm(&payload);
|
|
assert_eq!(pushed, AUDIO_OUTPUT_FRAMES);
|
|
}
|
|
let frame = runtime.run_one_tick_blocking(0).expect("frame");
|
|
assert_eq!(frame.samples.len(), AUDIO_OUTPUT_FRAMES);
|
|
for sample in frame.samples.iter() {
|
|
assert!(*sample > 0);
|
|
}
|
|
let frames_arc = sink.frames();
|
|
let frames_guard = frames_arc.lock().expect("lock");
|
|
assert_eq!(frames_guard.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn tick_driver_maintains_cumulative_drift_free_schedule_over_100_ticks() {
|
|
let clock = Arc::new(SystemMonotonicClock::new());
|
|
let period_ns = 1_000_000;
|
|
let mut driver = TickDriver::new(clock, period_ns).expect("driver");
|
|
for _ in 0..100 {
|
|
let info = driver.wait_until_next_tick().expect("tick");
|
|
assert!(info.actual_ns >= info.scheduled_ns.saturating_sub(period_ns));
|
|
}
|
|
assert!(driver.next_tick_index() >= 100);
|
|
}
|
|
|
|
#[test]
|
|
fn apm_stub_is_noop_on_capture_frame_but_counts() {
|
|
let mut source = CaptureSource::create(1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("src")
|
|
.0;
|
|
let original = make_pure_tone(1000, source.apm_frame_samples * 2);
|
|
let mut samples = original.clone();
|
|
let before = source.apm_frames_processed();
|
|
source.ingest(&mut samples).expect("ingest");
|
|
let after = source.apm_frames_processed();
|
|
assert_eq!(samples, original);
|
|
assert_eq!(after - before, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn ignore_audio_policy_reset_triggered_after_threshold() {
|
|
use crate::ignore_audio_runtime::{
|
|
IgnoreAudioDecision, IgnoreAudioSourceState, IgnoreAudioTick,
|
|
SOURCE_RESET_AFTER_BUFFERED_TICKS,
|
|
};
|
|
let mut policy = IgnoreAudioPolicy::new(48_000).expect("ok");
|
|
let state = IgnoreAudioSourceState {
|
|
id: 1,
|
|
buffered_frames: (SOURCE_RESET_AFTER_BUFFERED_TICKS as u64) * 1024 + 1,
|
|
last_frame_age_ns: 0,
|
|
is_muted: false,
|
|
};
|
|
let eval = policy
|
|
.evaluate(
|
|
&state,
|
|
IgnoreAudioTick {
|
|
at_ns: 1,
|
|
period_ns: 21_333_333,
|
|
},
|
|
)
|
|
.expect("ok");
|
|
assert_eq!(eval.decision, IgnoreAudioDecision::ResetSource);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_source_yields_silence_frame() {
|
|
let (_sources, mut runtime, _sink) = build_test_runtime(1);
|
|
let frame = runtime.run_one_tick_blocking(0).expect("frame");
|
|
for sample in frame.samples.iter() {
|
|
assert_eq!(*sample, 0);
|
|
}
|
|
assert_eq!(frame.total_drained, 0);
|
|
assert_eq!(frame.total_silence, AUDIO_OUTPUT_FRAMES as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn determinism_same_input_same_output() {
|
|
let payload: Vec<i16> = (0..AUDIO_OUTPUT_FRAMES)
|
|
.map(|n| (n as i16 % 1000) - 500)
|
|
.collect();
|
|
let mut outputs: Vec<Vec<i16>> = Vec::new();
|
|
for _ in 0..2 {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(2);
|
|
for source in sources.iter_mut() {
|
|
let _ = source.ingest_skip_apm(&payload);
|
|
}
|
|
let frame = runtime.run_one_tick_blocking(0).expect("frame");
|
|
outputs.push(frame.samples.to_vec());
|
|
}
|
|
assert_eq!(outputs[0], outputs[1]);
|
|
}
|
|
|
|
#[test]
|
|
fn skipped_tick_increments_silence_count_on_empty_ring() {
|
|
let (_sources, mut runtime, _sink) = build_test_runtime(1);
|
|
let frame = runtime.run_one_tick_blocking(0).expect("frame");
|
|
assert_eq!(frame.total_silence, AUDIO_OUTPUT_FRAMES as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn end_to_end_one_source_drained_to_mix_session() {
|
|
let (mut sources, mut runtime, sink) = build_test_runtime(1);
|
|
let payload: Vec<i16> = (0..AUDIO_OUTPUT_FRAMES)
|
|
.map(|n| (n as i16 % 2000) - 1000)
|
|
.collect();
|
|
let pushed = sources[0].ingest_skip_apm(&payload);
|
|
assert_eq!(pushed, AUDIO_OUTPUT_FRAMES);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let frames = sink.frames();
|
|
let frames_guard = frames.lock().expect("lock");
|
|
assert_eq!(frames_guard.len(), 1);
|
|
assert_eq!(frames_guard[0].samples.as_ref(), &payload[..]);
|
|
}
|
|
|
|
#[test]
|
|
fn build_rejects_zero_sources() {
|
|
let sink = NullMixOutputSink;
|
|
let err = AudioMixRuntimeBuilder::new().build(sink).err();
|
|
assert!(matches!(err, Some(MixRuntimeError::NoSources)));
|
|
}
|
|
|
|
#[test]
|
|
fn capture_source_buffered_frames_grows_on_ingest() {
|
|
let mut source = CaptureSource::create(1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("src")
|
|
.0;
|
|
let initial = source.buffered_frames();
|
|
assert_eq!(initial, 0);
|
|
let payload = vec![5i16; 256];
|
|
let pushed = source.ingest_skip_apm(&payload);
|
|
assert_eq!(pushed, 256);
|
|
assert!(source.buffered_frames() > 0);
|
|
}
|
|
|
|
#[test]
|
|
fn run_single_tick_lap_through_eight_sources_executes() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(8);
|
|
for (idx, source) in sources.iter_mut().enumerate() {
|
|
let payload = vec![(idx as i16) * 100; AUDIO_OUTPUT_FRAMES];
|
|
let _ = source.ingest_skip_apm(&payload);
|
|
}
|
|
let frame = runtime.run_one_tick_blocking(0).expect("frame");
|
|
assert!(frame.total_drained > 0);
|
|
}
|
|
|
|
#[allow(clippy::no_effect_underscore_binding)]
|
|
#[test]
|
|
fn tick_info_synthetic_can_be_consumed() {
|
|
let _ti = TickInfo {
|
|
tick_index: 0,
|
|
scheduled_ns: 0,
|
|
actual_ns: 0,
|
|
lag_ns: 0,
|
|
};
|
|
}
|
|
|
|
#[test]
|
|
fn ingest_increments_apm_frame_count_per_chunk() {
|
|
let mut source = CaptureSource::create(1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("src")
|
|
.0;
|
|
assert_eq!(source.apm_frames_processed(), 0);
|
|
let chunks = 7;
|
|
let mut samples = vec![0i16; source.apm_frame_samples * chunks];
|
|
source.ingest(&mut samples).expect("ingest");
|
|
assert_eq!(source.apm_frames_processed(), chunks as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_source_triggered_via_clock_emits_reset() {
|
|
let clock = Arc::new(FakeClock::new(1_000_000));
|
|
let (sources, mut runtime, sink) =
|
|
build_runtime_with_clock(1, Arc::clone(&clock) as Arc<dyn MonotonicClock>);
|
|
sources[0].freshness.mark_push_at(1_000_000);
|
|
clock.set(1_000_000 + 10_000_000_000);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let resets = sink.resets();
|
|
let guard = resets.lock().expect("lock");
|
|
assert!(!guard.is_empty());
|
|
assert!(
|
|
guard
|
|
.iter()
|
|
.any(|e| e.reason
|
|
== crate::ignore_audio_runtime::IgnoreAudioResetReason::StaleSource)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn last_frame_age_monotonic_within_quiescence() {
|
|
let clock = Arc::new(FakeClock::new(1_000_000));
|
|
let (sources, mut runtime, _sink) =
|
|
build_runtime_with_clock(1, Arc::clone(&clock) as Arc<dyn MonotonicClock>);
|
|
sources[0].freshness.mark_push_at(1_000_000);
|
|
let mut prior = 0u64;
|
|
for n in 0..5 {
|
|
clock.set(1_000_000 + (n + 1) * 1_000_000);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let push = runtime.last_push_ns_for(0);
|
|
let now = clock.now_ns();
|
|
let age = compute_age_ns(push, 1_000_000, now);
|
|
assert!(age >= prior);
|
|
prior = age;
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn output_pool_buffers_rotate_modulo_depth_when_consumer_releases() {
|
|
let (mut sources, mut runtime) = build_null_runtime(1);
|
|
let payload = vec![123i16; AUDIO_OUTPUT_FRAMES];
|
|
let _ = sources[0].ingest_skip_apm(&payload);
|
|
let f0 = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let p0 = Arc::as_ptr(&f0.samples);
|
|
drop(f0);
|
|
let _ = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS)
|
|
.expect("frame");
|
|
let _ = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS * 2)
|
|
.expect("frame");
|
|
let _ = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS * 3)
|
|
.expect("frame");
|
|
let f4 = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS * 4)
|
|
.expect("frame");
|
|
let p4 = Arc::as_ptr(&f4.samples);
|
|
assert_eq!(p0, p4);
|
|
}
|
|
|
|
#[test]
|
|
fn output_pool_returns_distinct_buffers_when_consumer_retains() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(1);
|
|
let payload = vec![7i16; AUDIO_OUTPUT_FRAMES];
|
|
let _ = sources[0].ingest_skip_apm(&payload);
|
|
let f0 = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let f1 = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS)
|
|
.expect("frame");
|
|
let f2 = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS * 2)
|
|
.expect("frame");
|
|
let f3 = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS * 3)
|
|
.expect("frame");
|
|
let p0 = Arc::as_ptr(&f0.samples);
|
|
let p1 = Arc::as_ptr(&f1.samples);
|
|
let p2 = Arc::as_ptr(&f2.samples);
|
|
let p3 = Arc::as_ptr(&f3.samples);
|
|
assert!(p0 != p1);
|
|
assert!(p1 != p2);
|
|
assert!(p2 != p3);
|
|
assert_eq!(f0.samples.len(), AUDIO_OUTPUT_FRAMES);
|
|
}
|
|
|
|
#[test]
|
|
fn output_arc_samples_length_invariant_holds() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(1);
|
|
let payload = vec![1i16; AUDIO_OUTPUT_FRAMES];
|
|
let _ = sources[0].ingest_skip_apm(&payload);
|
|
let frame = runtime.run_one_tick_blocking(0).expect("frame");
|
|
assert_eq!(frame.samples.len(), AUDIO_OUTPUT_FRAMES);
|
|
assert_eq!(frame.samples.as_ref().len(), 1024);
|
|
}
|
|
|
|
fn build_null_runtime(source_count: usize) -> (Vec<CaptureSource>, AudioMixRuntime) {
|
|
assert!(source_count > 0);
|
|
let mut sources: Vec<CaptureSource> = Vec::with_capacity(source_count);
|
|
let mut builder = AudioMixRuntimeBuilder::new();
|
|
for n in 0..source_count {
|
|
let (source, consumer) =
|
|
CaptureSource::create(n as u64 + 1, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("source");
|
|
let push_ns = source.last_push_ns_arc();
|
|
builder = builder.add_source_with_freshness(n as u64 + 1, consumer, push_ns);
|
|
sources.push(source);
|
|
}
|
|
let runtime = builder.build(NullMixOutputSink).expect("build");
|
|
(sources, runtime)
|
|
}
|
|
|
|
#[test]
|
|
fn tick_steady_state_does_not_allocate() {
|
|
let (mut sources, mut runtime) = build_null_runtime(4);
|
|
let payload = vec![10i16; AUDIO_OUTPUT_FRAMES];
|
|
for _warm in 0..OUTPUT_BUFFER_POOL_DEPTH + 2 {
|
|
for source in sources.iter_mut() {
|
|
let _ = source.ingest_skip_apm(&payload);
|
|
}
|
|
let _ = runtime.run_one_tick_blocking(0).expect("warmup");
|
|
}
|
|
for source in sources.iter_mut() {
|
|
let _ = source.ingest_skip_apm(&payload);
|
|
}
|
|
begin_thread_alloc_count();
|
|
let _ = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS * 10)
|
|
.expect("frame");
|
|
let allocations = end_thread_alloc_count();
|
|
assert_eq!(
|
|
allocations, 0,
|
|
"steady-state tick allocated {allocations} times",
|
|
);
|
|
let _ = snapshot_allocs();
|
|
}
|
|
|
|
#[test]
|
|
fn freshness_handle_records_push_time() {
|
|
let source = CaptureSource::create(7, MIX_SAMPLE_RATE_HZ, MIX_CHANNELS)
|
|
.expect("src")
|
|
.0;
|
|
let initial = source.freshness_handle().last_push_ns();
|
|
assert_eq!(initial, NEVER_PUSHED_SENTINEL);
|
|
source.freshness_handle().mark_push_at(42);
|
|
assert_eq!(source.freshness_handle().last_push_ns(), 42);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_age_ns_uses_registration_for_never_pushed_source() {
|
|
let age = compute_age_ns(NEVER_PUSHED_SENTINEL, 1_000, 5_000);
|
|
assert_eq!(age, 4_000);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_age_ns_returns_zero_when_now_before_push() {
|
|
let age = compute_age_ns(1_000, 500, 500);
|
|
assert_eq!(age, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn compute_age_ns_returns_difference_after_push() {
|
|
let age = compute_age_ns(1_000, 0, 5_000);
|
|
assert_eq!(age, 4_000);
|
|
}
|
|
|
|
#[test]
|
|
fn ramp_advances_per_tick_through_runtime() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(1);
|
|
let payload = vec![100i16; AUDIO_OUTPUT_FRAMES];
|
|
let _ = sources[0].ingest_skip_apm(&payload);
|
|
assert_eq!(runtime.current_ramp_gain(0), 0.0);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let g1 = runtime.current_ramp_gain(0);
|
|
assert!(g1 > 0.0);
|
|
for n in 1..6u64 {
|
|
let _ = runtime
|
|
.run_one_tick_blocking(n * MIX_TICK_PERIOD_NS)
|
|
.expect("frame");
|
|
}
|
|
let g_after = runtime.current_ramp_gain(0);
|
|
assert_eq!(g_after, 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn smoother_initialises_after_first_push() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(1);
|
|
assert!(!runtime.smoother_initialised(0));
|
|
let payload = vec![1i16; AUDIO_OUTPUT_FRAMES];
|
|
let _ = sources[0].ingest_skip_apm(&payload);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
assert!(runtime.smoother_initialised(0));
|
|
}
|
|
|
|
#[test]
|
|
fn registered_never_pushed_source_eligible_for_stale_after_threshold() {
|
|
let clock = Arc::new(FakeClock::new(1_000_000));
|
|
let (_sources, runtime, _sink) =
|
|
build_runtime_with_clock(1, Arc::clone(&clock) as Arc<dyn MonotonicClock>);
|
|
clock.set(1_000_000 + 6_000_000_000);
|
|
let stale = runtime.is_source_stale(0, 1_000_000 + 6_000_000_000, 5_000_000_000);
|
|
assert!(stale);
|
|
}
|
|
|
|
#[test]
|
|
fn tick_marks_pushed_when_freshness_atomic_advances() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(1);
|
|
assert_eq!(runtime.mark_pushed_total(), 0);
|
|
let payload = vec![100i16; AUDIO_OUTPUT_FRAMES];
|
|
let pushed = sources[0].ingest_skip_apm(&payload);
|
|
assert_eq!(pushed, AUDIO_OUTPUT_FRAMES);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
assert!(runtime.mark_pushed_total() >= 1);
|
|
let observed_push = sources[0].freshness_handle().last_push_ns();
|
|
assert_ne!(observed_push, u64::MAX);
|
|
let stale = runtime.is_source_stale(0, observed_push + 1_000_000, 5_000_000_000);
|
|
assert!(!stale);
|
|
}
|
|
|
|
#[test]
|
|
fn tick_does_not_double_count_unchanged_push_atomic() {
|
|
let (mut sources, mut runtime, _sink) = build_test_runtime(1);
|
|
let payload = vec![50i16; AUDIO_OUTPUT_FRAMES];
|
|
let _ = sources[0].ingest_skip_apm(&payload);
|
|
let _ = runtime.run_one_tick_blocking(0).expect("frame");
|
|
let first_total = runtime.mark_pushed_total();
|
|
assert!(first_total >= 1);
|
|
let _ = runtime
|
|
.run_one_tick_blocking(MIX_TICK_PERIOD_NS)
|
|
.expect("frame");
|
|
let second_total = runtime.mark_pushed_total();
|
|
assert_eq!(first_total, second_total);
|
|
}
|
|
}
|