lesavka/server/src/output_delay_probe/timeline_config.rs

204 lines
7.5 KiB
Rust
Raw Normal View History

impl OutputDelayProbeTimeline {
fn new(config: &ProbeConfig, camera: &CameraConfig, server_start_unix_ns: u128) -> Self {
let event_count = config.event_count();
let events = (0..event_count)
.map(|event_id| {
let slot = config.event_slot_by_id(event_id as usize);
OutputDelayProbeEventTimeline {
event_id: event_id as usize,
code: slot.code,
planned_start_us: slot.planned_start_us,
planned_end_us: slot.planned_end_us,
video_seq: None,
audio_seq: None,
video_feed_monotonic_us: None,
audio_push_monotonic_us: None,
video_feed_unix_ns: None,
audio_push_unix_ns: None,
server_feed_delta_ms: None,
}
})
.collect();
Self {
schema: "lesavka.output-delay-server-timeline.v1",
origin: "theia-server-generated",
media_path: "server generator -> UVC/UAC sinks",
injection_scope: "server-final-output-handoff",
server_pipeline_reference: "generated media PTS before intentional sync delay",
sink_handoff_path: "video CameraRelay::feed; audio Voice::push",
client_uplink_included: false,
camera_width: camera.width,
camera_height: camera.height,
camera_fps: camera.fps,
audio_sample_rate: AUDIO_SAMPLE_RATE,
audio_channels: AUDIO_CHANNELS,
audio_chunk_ms: AUDIO_CHUNK_MS,
audio_delay_us: duration_us(config.audio_delay),
video_delay_us: duration_us(config.video_delay),
server_start_unix_ns,
pulse_period_ms: config.pulse_period.as_millis() as u64,
pulse_width_ms: config.pulse_width.as_millis() as u64,
warmup_us: duration_us(config.warmup),
duration_us: duration_us(config.duration),
events,
}
}
fn mark_audio(&mut self, slot: ProbeEventSlot, seq: u64, monotonic_us: u64, unix_ns: u128) {
let Some(event) = self.events.get_mut(slot.event_id) else {
return;
};
if event.audio_push_monotonic_us.is_none() {
event.audio_seq = Some(seq);
event.audio_push_monotonic_us = Some(monotonic_us);
event.audio_push_unix_ns = Some(unix_ns);
event.update_delta();
}
}
fn mark_video(&mut self, slot: ProbeEventSlot, seq: u64, monotonic_us: u64, unix_ns: u128) {
let Some(event) = self.events.get_mut(slot.event_id) else {
return;
};
if event.video_feed_monotonic_us.is_none() {
event.video_seq = Some(seq);
event.video_feed_monotonic_us = Some(monotonic_us);
event.video_feed_unix_ns = Some(unix_ns);
event.update_delta();
}
}
}
impl OutputDelayProbeEventTimeline {
fn update_delta(&mut self) {
let (Some(audio_us), Some(video_us)) =
(self.audio_push_monotonic_us, self.video_feed_monotonic_us)
else {
return;
};
self.server_feed_delta_ms = Some((audio_us as f64 - video_us as f64) / 1000.0);
}
}
impl ProbeConfig {
fn from_request(request: &OutputDelayProbeRequest) -> Result<Self> {
let duration_seconds = non_zero_or_default(
request.duration_seconds,
DEFAULT_DURATION_SECONDS,
"duration_seconds",
)?;
let warmup_seconds = if request.warmup_seconds == 0 {
DEFAULT_WARMUP_SECONDS
} else {
request.warmup_seconds
};
let pulse_period_ms = non_zero_or_default(
request.pulse_period_ms,
DEFAULT_PULSE_PERIOD_MS,
"pulse_period_ms",
)?;
let pulse_width_ms = non_zero_or_default(
request.pulse_width_ms,
DEFAULT_PULSE_WIDTH_MS,
"pulse_width_ms",
)?;
if pulse_width_ms >= pulse_period_ms {
bail!("pulse_width_ms must stay smaller than pulse_period_ms");
}
let event_width_codes = parse_event_width_codes(&request.event_width_codes)?;
Ok(Self {
duration: Duration::from_secs(u64::from(duration_seconds)),
warmup: Duration::from_secs(u64::from(warmup_seconds)),
pulse_period: Duration::from_millis(u64::from(pulse_period_ms)),
pulse_width: Duration::from_millis(u64::from(pulse_width_ms)),
event_width_codes,
audio_delay: positive_delay(request.audio_delay_us, "audio_delay_us")?,
video_delay: positive_delay(request.video_delay_us, "video_delay_us")?,
})
}
fn event_code_at(&self, pts: Duration) -> Option<u32> {
self.event_slot_at(pts).map(|slot| slot.code)
}
fn event_slot_at(&self, pts: Duration) -> Option<ProbeEventSlot> {
if pts < self.warmup {
return None;
}
let since_warmup = pts.saturating_sub(self.warmup);
let period_ns = self.pulse_period.as_nanos().max(1);
let pulse_index = (since_warmup.as_nanos() / period_ns) as usize;
let pulse_offset_ns = since_warmup.as_nanos() % period_ns;
let active_ns = self.pulse_width.as_nanos();
(pulse_offset_ns < active_ns).then(|| self.event_slot_by_id(pulse_index))
}
fn event_slot_by_id(&self, event_id: usize) -> ProbeEventSlot {
let code = self.event_width_codes[event_id % self.event_width_codes.len()];
let planned_start = self
.warmup
.saturating_add(duration_mul(self.pulse_period, event_id as u64));
let planned_end = planned_start.saturating_add(self.pulse_width);
ProbeEventSlot {
event_id,
code,
planned_start_us: duration_us(planned_start),
planned_end_us: duration_us(planned_end),
}
}
fn event_count(&self) -> u64 {
if self.duration <= self.warmup {
return 0;
}
let active = self.duration - self.warmup;
let count = active.as_nanos() / self.pulse_period.as_nanos().max(1);
count.try_into().unwrap_or(u64::MAX)
}
}
fn non_zero_or_default(value: u32, default: u32, name: &str) -> Result<u32> {
if value == 0 {
return Ok(default);
}
if value == u32::MAX {
bail!("{name} is too large");
}
Ok(value)
}
fn positive_delay(value_us: i64, name: &str) -> Result<Duration> {
if value_us < 0 {
bail!("{name} must be zero or positive for the direct output-delay probe");
}
Ok(Duration::from_micros(value_us as u64))
}
fn parse_event_width_codes(raw: &str) -> Result<Vec<u32>> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Ok(DEFAULT_EVENT_WIDTH_CODES.to_vec());
}
let codes = trimmed
.split(',')
.filter_map(|part| {
let part = part.trim();
(!part.is_empty()).then_some(part)
})
.map(|part| {
let code = part
.parse::<u32>()
.with_context(|| format!("parsing event width code `{part}`"))?;
if !(1..=16).contains(&code) {
bail!("event signature code {code} is unsupported; use values 1..16");
}
Ok(code)
})
.collect::<Result<Vec<_>>>()?;
if codes.is_empty() {
bail!("event_width_codes must contain at least one code");
}
Ok(codes)
}