Trimmed monorepo checkout (fluxer_desktop + packages/voice_engine_v2 + tools/ci) with a "Connect to a Different Server" menu item and popout that lets the desktop app switch to any self-hosted Fluxer instance, plus fixes for well-known discovery on single-domain self-hosted deployments and a false-positive ERR_ABORTED on same-origin client redirects during the switch. Defaults to chat.fluxr.chat and uses an isolated userData directory from the official build.
412 lines
15 KiB
Rust
412 lines
15 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use fluxer_rt_thread::TickInfo;
|
|
|
|
use crate::AudioMixError;
|
|
use crate::source_ring::{AUDIO_OUTPUT_FRAMES, SourceRingConsumer};
|
|
|
|
pub const MAX_MIX_SOURCES: usize = 256;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SourceTickStat {
|
|
pub drained_count: u32,
|
|
pub silence_count: u32,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct MixedFrame<'a> {
|
|
pub samples: &'a [i16; AUDIO_OUTPUT_FRAMES],
|
|
pub tick_index: u64,
|
|
pub scheduled_ns: u64,
|
|
pub actual_ns: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct MixTickResult {
|
|
pub tick_index: u64,
|
|
pub scheduled_ns: u64,
|
|
pub actual_ns: u64,
|
|
pub lag_ns: i64,
|
|
pub total_drained: u64,
|
|
pub total_silence: u64,
|
|
pub saturated_samples: u32,
|
|
}
|
|
|
|
pub struct AudioMixSession {
|
|
consumers: Vec<SourceRingConsumer>,
|
|
per_source_stats: Vec<SourceTickStat>,
|
|
sample_rate_hz: u32,
|
|
mix_buffer_len: usize,
|
|
accumulator: Box<[i32; AUDIO_OUTPUT_FRAMES]>,
|
|
scratch: Box<[i16; AUDIO_OUTPUT_FRAMES]>,
|
|
output: Box<[i16; AUDIO_OUTPUT_FRAMES]>,
|
|
last_tick_index: Option<u64>,
|
|
last_actual_ns: u64,
|
|
ticks_completed: u64,
|
|
}
|
|
|
|
impl AudioMixSession {
|
|
pub fn new(
|
|
consumers: Vec<SourceRingConsumer>,
|
|
mix_buffer_len: usize,
|
|
) -> Result<Self, AudioMixError> {
|
|
if consumers.is_empty() {
|
|
return Err(AudioMixError::ZeroSources);
|
|
}
|
|
if consumers.len() > MAX_MIX_SOURCES {
|
|
return Err(AudioMixError::TooManySources {
|
|
requested: consumers.len(),
|
|
limit: MAX_MIX_SOURCES,
|
|
});
|
|
}
|
|
if mix_buffer_len != AUDIO_OUTPUT_FRAMES {
|
|
return Err(AudioMixError::MixBufferLenMismatch {
|
|
expected: AUDIO_OUTPUT_FRAMES,
|
|
observed: mix_buffer_len,
|
|
});
|
|
}
|
|
let sample_rate_hz = consumers[0].sample_rate_hz();
|
|
for consumer in &consumers {
|
|
let observed = consumer.sample_rate_hz();
|
|
if observed != sample_rate_hz {
|
|
return Err(AudioMixError::SampleRateMismatch {
|
|
expected_hz: sample_rate_hz,
|
|
observed_hz: observed,
|
|
});
|
|
}
|
|
}
|
|
assert!(!consumers.is_empty());
|
|
assert!(consumers.len() <= MAX_MIX_SOURCES);
|
|
let per_source_stats = vec![
|
|
SourceTickStat {
|
|
drained_count: 0,
|
|
silence_count: 0,
|
|
};
|
|
consumers.len()
|
|
];
|
|
let accumulator = Box::new([0i32; AUDIO_OUTPUT_FRAMES]);
|
|
let scratch = Box::new([0i16; AUDIO_OUTPUT_FRAMES]);
|
|
let output = Box::new([0i16; AUDIO_OUTPUT_FRAMES]);
|
|
Ok(Self {
|
|
consumers,
|
|
per_source_stats,
|
|
sample_rate_hz,
|
|
mix_buffer_len,
|
|
accumulator,
|
|
scratch,
|
|
output,
|
|
last_tick_index: None,
|
|
last_actual_ns: 0,
|
|
ticks_completed: 0,
|
|
})
|
|
}
|
|
|
|
pub fn sample_rate_hz(&self) -> u32 {
|
|
assert!(self.sample_rate_hz > 0);
|
|
assert!(self.sample_rate_hz <= 384_000);
|
|
self.sample_rate_hz
|
|
}
|
|
|
|
pub fn mix_buffer_len(&self) -> usize {
|
|
assert_eq!(self.mix_buffer_len, AUDIO_OUTPUT_FRAMES);
|
|
self.mix_buffer_len
|
|
}
|
|
|
|
pub fn source_count(&self) -> usize {
|
|
assert!(!self.consumers.is_empty());
|
|
assert!(self.consumers.len() <= MAX_MIX_SOURCES);
|
|
self.consumers.len()
|
|
}
|
|
|
|
pub fn ticks_completed(&self) -> u64 {
|
|
self.ticks_completed
|
|
}
|
|
|
|
pub fn per_source_stats(&self) -> &[SourceTickStat] {
|
|
assert_eq!(self.per_source_stats.len(), self.consumers.len());
|
|
&self.per_source_stats
|
|
}
|
|
|
|
pub fn tick(&mut self, tick_info: TickInfo) -> MixTickResult {
|
|
assert!(!self.consumers.is_empty());
|
|
assert!(tick_info.actual_ns >= self.last_actual_ns);
|
|
if let Some(prior) = self.last_tick_index {
|
|
assert!(tick_info.tick_index > prior);
|
|
}
|
|
for slot in self.accumulator.iter_mut() {
|
|
*slot = 0;
|
|
}
|
|
let (total_drained, total_silence) = self.mix_all_sources();
|
|
let saturated = self.finalise_output();
|
|
self.ticks_completed = self.ticks_completed.saturating_add(1);
|
|
self.last_tick_index = Some(tick_info.tick_index);
|
|
self.last_actual_ns = tick_info.actual_ns;
|
|
let result = MixTickResult {
|
|
tick_index: tick_info.tick_index,
|
|
scheduled_ns: tick_info.scheduled_ns,
|
|
actual_ns: tick_info.actual_ns,
|
|
lag_ns: tick_info.lag_ns,
|
|
total_drained,
|
|
total_silence,
|
|
saturated_samples: saturated,
|
|
};
|
|
assert_eq!(result.tick_index, tick_info.tick_index);
|
|
assert!(result.saturated_samples as usize <= AUDIO_OUTPUT_FRAMES);
|
|
result
|
|
}
|
|
|
|
fn mix_all_sources(&mut self) -> (u64, u64) {
|
|
let n = self.consumers.len();
|
|
assert_eq!(self.per_source_stats.len(), n);
|
|
let mut total_drained: u64 = 0;
|
|
let mut total_silence: u64 = 0;
|
|
for index in 0..n {
|
|
let drained = self.consumers[index].drain_into(self.scratch.as_mut_slice());
|
|
assert!(drained <= AUDIO_OUTPUT_FRAMES);
|
|
let silence = AUDIO_OUTPUT_FRAMES - drained;
|
|
let stat = &mut self.per_source_stats[index];
|
|
stat.drained_count = stat.drained_count.saturating_add(drained as u32);
|
|
stat.silence_count = stat.silence_count.saturating_add(silence as u32);
|
|
total_drained = total_drained.saturating_add(drained as u64);
|
|
total_silence = total_silence.saturating_add(silence as u64);
|
|
for (acc, sample) in self.accumulator[..drained]
|
|
.iter_mut()
|
|
.zip(self.scratch[..drained].iter())
|
|
{
|
|
*acc = acc.saturating_add(*sample as i32);
|
|
}
|
|
}
|
|
(total_drained, total_silence)
|
|
}
|
|
|
|
fn finalise_output(&mut self) -> u32 {
|
|
let mut saturated: u32 = 0;
|
|
for (out, acc) in self.output.iter_mut().zip(self.accumulator.iter()) {
|
|
let value = *acc;
|
|
let clamped = if value > i16::MAX as i32 {
|
|
saturated = saturated.saturating_add(1);
|
|
i16::MAX
|
|
} else if value < i16::MIN as i32 {
|
|
saturated = saturated.saturating_add(1);
|
|
i16::MIN
|
|
} else {
|
|
value as i16
|
|
};
|
|
*out = clamped;
|
|
}
|
|
assert!(saturated as usize <= AUDIO_OUTPUT_FRAMES);
|
|
saturated
|
|
}
|
|
|
|
pub fn last_output(&self) -> &[i16; AUDIO_OUTPUT_FRAMES] {
|
|
assert_eq!(self.output.len(), AUDIO_OUTPUT_FRAMES);
|
|
&self.output
|
|
}
|
|
|
|
pub fn last_mixed_frame(&self) -> Option<MixedFrame<'_>> {
|
|
let tick_index = self.last_tick_index?;
|
|
assert!(self.ticks_completed > 0);
|
|
Some(MixedFrame {
|
|
samples: &self.output,
|
|
tick_index,
|
|
scheduled_ns: 0,
|
|
actual_ns: self.last_actual_ns,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::source_ring::SourceRing;
|
|
use fluxer_rt_thread::TickInfo;
|
|
use std::sync::{Arc, Barrier};
|
|
use std::thread;
|
|
|
|
fn synthetic_tick(index: u64) -> TickInfo {
|
|
let scheduled_ns = index * 21_333_333;
|
|
TickInfo {
|
|
tick_index: index,
|
|
scheduled_ns,
|
|
actual_ns: scheduled_ns,
|
|
lag_ns: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_empty_consumer_list() {
|
|
let err = AudioMixSession::new(Vec::new(), AUDIO_OUTPUT_FRAMES).err();
|
|
assert_eq!(err, Some(AudioMixError::ZeroSources));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_mismatched_sample_rates() {
|
|
let (_p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
|
|
let (_p2, c2) = SourceRing::create(2048, 44_100).expect("pair 2");
|
|
let err = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).err();
|
|
assert!(matches!(
|
|
err,
|
|
Some(AudioMixError::SampleRateMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_wrong_mix_buffer_len() {
|
|
let (_p, c) = SourceRing::create(2048, 48_000).expect("pair");
|
|
let err = AudioMixSession::new(vec![c], 512).err();
|
|
assert!(matches!(
|
|
err,
|
|
Some(AudioMixError::MixBufferLenMismatch { .. })
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn single_source_passes_through_samples() {
|
|
let (mut producer, consumer) = SourceRing::create(2048, 48_000).expect("pair");
|
|
let payload: Vec<i16> = (0..AUDIO_OUTPUT_FRAMES)
|
|
.map(|n| (n as i16) % 1000)
|
|
.collect();
|
|
let pushed = producer.try_push_slice(&payload);
|
|
assert_eq!(pushed, AUDIO_OUTPUT_FRAMES);
|
|
let mut session =
|
|
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let result = session.tick(synthetic_tick(0));
|
|
assert_eq!(result.total_drained, AUDIO_OUTPUT_FRAMES as u64);
|
|
assert_eq!(result.total_silence, 0);
|
|
assert_eq!(&session.last_output()[..], &payload[..]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_ring_produces_silence_without_blocking() {
|
|
let (_producer, consumer) = SourceRing::create(2048, 48_000).expect("pair");
|
|
let mut session =
|
|
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let result = session.tick(synthetic_tick(0));
|
|
assert_eq!(result.total_drained, 0);
|
|
assert_eq!(result.total_silence, AUDIO_OUTPUT_FRAMES as u64);
|
|
for sample in session.last_output().iter() {
|
|
assert_eq!(*sample, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn drained_count_accuracy_across_partial_fills() {
|
|
let (mut producer, consumer) = SourceRing::create(2048, 48_000).expect("pair");
|
|
let half: Vec<i16> = vec![100; AUDIO_OUTPUT_FRAMES / 2];
|
|
producer.try_push_slice(&half);
|
|
let mut session =
|
|
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let result = session.tick(synthetic_tick(0));
|
|
assert_eq!(result.total_drained, (AUDIO_OUTPUT_FRAMES / 2) as u64);
|
|
assert_eq!(result.total_silence, (AUDIO_OUTPUT_FRAMES / 2) as u64);
|
|
let stats = session.per_source_stats();
|
|
assert_eq!(stats.len(), 1);
|
|
assert_eq!(stats[0].drained_count as usize, AUDIO_OUTPUT_FRAMES / 2);
|
|
assert_eq!(stats[0].silence_count as usize, AUDIO_OUTPUT_FRAMES / 2);
|
|
}
|
|
|
|
#[test]
|
|
fn opposite_phase_sources_sum_to_silence() {
|
|
let (mut p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
|
|
let (mut p2, c2) = SourceRing::create(2048, 48_000).expect("pair 2");
|
|
let wave: Vec<i16> = (0..AUDIO_OUTPUT_FRAMES)
|
|
.map(|n| ((n as i16) % 1000) - 500)
|
|
.collect();
|
|
let inverse: Vec<i16> = wave.iter().map(|s| -*s).collect();
|
|
p1.try_push_slice(&wave);
|
|
p2.try_push_slice(&inverse);
|
|
let mut session = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let _ = session.tick(synthetic_tick(0));
|
|
for sample in session.last_output().iter() {
|
|
assert_eq!(*sample, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn saturation_clamps_at_i16_limits() {
|
|
let (mut p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
|
|
let (mut p2, c2) = SourceRing::create(2048, 48_000).expect("pair 2");
|
|
let high: Vec<i16> = vec![i16::MAX; AUDIO_OUTPUT_FRAMES];
|
|
p1.try_push_slice(&high);
|
|
p2.try_push_slice(&high);
|
|
let mut session = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let result = session.tick(synthetic_tick(0));
|
|
assert_eq!(result.saturated_samples as usize, AUDIO_OUTPUT_FRAMES);
|
|
for sample in session.last_output().iter() {
|
|
assert_eq!(*sample, i16::MAX);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn saturation_clamps_at_i16_min_limits() {
|
|
let (mut p1, c1) = SourceRing::create(2048, 48_000).expect("pair 1");
|
|
let (mut p2, c2) = SourceRing::create(2048, 48_000).expect("pair 2");
|
|
let low: Vec<i16> = vec![i16::MIN; AUDIO_OUTPUT_FRAMES];
|
|
p1.try_push_slice(&low);
|
|
p2.try_push_slice(&low);
|
|
let mut session = AudioMixSession::new(vec![c1, c2], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let result = session.tick(synthetic_tick(0));
|
|
assert_eq!(result.saturated_samples as usize, AUDIO_OUTPUT_FRAMES);
|
|
for sample in session.last_output().iter() {
|
|
assert_eq!(*sample, i16::MIN);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn full_ring_producer_drops_overflow() {
|
|
let (mut producer, consumer) =
|
|
SourceRing::create(AUDIO_OUTPUT_FRAMES, 48_000).expect("pair");
|
|
let payload: Vec<i16> = vec![7; AUDIO_OUTPUT_FRAMES];
|
|
let pushed_first = producer.try_push_slice(&payload);
|
|
assert_eq!(pushed_first, AUDIO_OUTPUT_FRAMES);
|
|
let pushed_second = producer.try_push_slice(&payload);
|
|
assert_eq!(pushed_second, 0);
|
|
assert_eq!(producer.dropped_total(), AUDIO_OUTPUT_FRAMES as u64);
|
|
let mut session =
|
|
AudioMixSession::new(vec![consumer], AUDIO_OUTPUT_FRAMES).expect("session");
|
|
let result = session.tick(synthetic_tick(0));
|
|
assert_eq!(result.total_drained, AUDIO_OUTPUT_FRAMES as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn ten_producer_threads_one_mixer_no_panic() {
|
|
const SOURCE_COUNT: usize = 10;
|
|
const TICKS: u64 = 50;
|
|
let mut producers = Vec::with_capacity(SOURCE_COUNT);
|
|
let mut consumers = Vec::with_capacity(SOURCE_COUNT);
|
|
for _ in 0..SOURCE_COUNT {
|
|
let (producer, consumer) = SourceRing::create(8192, 48_000).expect("pair");
|
|
producers.push(producer);
|
|
consumers.push(consumer);
|
|
}
|
|
let barrier = Arc::new(Barrier::new(SOURCE_COUNT + 1));
|
|
let stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let mut handles = Vec::with_capacity(SOURCE_COUNT);
|
|
for (index, mut producer) in producers.into_iter().enumerate() {
|
|
let barrier = Arc::clone(&barrier);
|
|
let stop = Arc::clone(&stop);
|
|
handles.push(thread::spawn(move || {
|
|
barrier.wait();
|
|
let mut value: i16 = index as i16;
|
|
while !stop.load(std::sync::atomic::Ordering::Acquire) {
|
|
let _ = producer.try_push(value);
|
|
value = value.wrapping_add(1);
|
|
}
|
|
}));
|
|
}
|
|
let mut session = AudioMixSession::new(consumers, AUDIO_OUTPUT_FRAMES).expect("session");
|
|
barrier.wait();
|
|
let mut last_drained: u64 = 0;
|
|
for tick_index in 0..TICKS {
|
|
let result = session.tick(synthetic_tick(tick_index));
|
|
last_drained = last_drained.saturating_add(result.total_drained);
|
|
}
|
|
stop.store(true, std::sync::atomic::Ordering::Release);
|
|
for handle in handles {
|
|
handle.join().expect("producer join");
|
|
}
|
|
assert_eq!(session.ticks_completed(), TICKS);
|
|
assert!(last_drained > 0);
|
|
}
|
|
}
|