test(sync): use unique server RC probe signatures
This commit is contained in:
parent
db72307eda
commit
8bb157891f
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.19.18"
|
version = "0.19.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.19.18"
|
version = "0.19.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.19.18"
|
version = "0.19.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
|
|||||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_client"
|
name = "lesavka_client"
|
||||||
version = "0.19.18"
|
version = "0.19.19"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -481,6 +481,8 @@ mod tests {
|
|||||||
audio_onsets_s: vec![9.135, 11.146, 13.135],
|
audio_onsets_s: vec![9.135, 11.146, 13.135],
|
||||||
paired_events: vec![SyncEventPair {
|
paired_events: vec![SyncEventPair {
|
||||||
event_id: 0,
|
event_id: 0,
|
||||||
|
server_event_id: None,
|
||||||
|
event_code: None,
|
||||||
video_time_s: 11.420,
|
video_time_s: 11.420,
|
||||||
audio_time_s: 11.146,
|
audio_time_s: 11.146,
|
||||||
skew_ms: -188.4,
|
skew_ms: -188.4,
|
||||||
|
|||||||
@ -249,17 +249,14 @@ mod tests {
|
|||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(index, _)| match index {
|
.map(|(index, _)| match index {
|
||||||
10 | 11 => (255, 45, 45),
|
10 | 11 => (255, 45, 45),
|
||||||
20 | 21 | 22 | 23 => (0, 230, 118),
|
20 | 21 => (0, 230, 118),
|
||||||
30 | 31 | 32 => (41, 121, 255),
|
30 | 31 => (41, 121, 255),
|
||||||
_ => (0, 0, 0),
|
_ => (0, 0, 0),
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let mut audio = vec![0i16; 220_000];
|
let mut audio = vec![0i16; 220_000];
|
||||||
for (start_s, code) in [(1.05, 1usize), (2.05, 2usize), (3.05, 3usize)] {
|
for (start_s, frequency_hz) in [(1.05, 620.0), (2.05, 780.0), (3.05, 940.0)] {
|
||||||
let start = (start_s * 48_000.0) as usize;
|
add_sine(&mut audio, 48_000, start_s, 0.12, frequency_hz, 18_000.0);
|
||||||
for sample in audio.iter_mut().skip(start).take(5_760 * code) {
|
|
||||||
*sample = 18_000;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with_fake_media_tools(
|
with_fake_media_tools(
|
||||||
@ -289,4 +286,23 @@ mod tests {
|
|||||||
.expect("reconciled timestamps");
|
.expect("reconciled timestamps");
|
||||||
assert_eq!(reconciled, vec![0.0, 0.5, 1.0]);
|
assert_eq!(reconciled, vec![0.0, 0.5, 1.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_sine(
|
||||||
|
samples: &mut [i16],
|
||||||
|
sample_rate_hz: u32,
|
||||||
|
start_s: f64,
|
||||||
|
duration_s: f64,
|
||||||
|
frequency_hz: f64,
|
||||||
|
amplitude: f64,
|
||||||
|
) {
|
||||||
|
let start = (start_s * f64::from(sample_rate_hz)).round() as usize;
|
||||||
|
let len = (duration_s * f64::from(sample_rate_hz)).round() as usize;
|
||||||
|
for (offset, sample) in samples.iter_mut().skip(start).take(len).enumerate() {
|
||||||
|
let t = offset as f64 / f64::from(sample_rate_hz);
|
||||||
|
let value = amplitude * (2.0 * std::f64::consts::PI * frequency_hz * t).sin();
|
||||||
|
*sample = value
|
||||||
|
.round()
|
||||||
|
.clamp(f64::from(i16::MIN), f64::from(i16::MAX)) as i16;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,7 +23,10 @@ const MAX_AUDIO_PULSE_INTERNAL_GAP_S: f64 = 0.16;
|
|||||||
const MIN_AUDIO_PROBE_PEAK: f64 = 25.0;
|
const MIN_AUDIO_PROBE_PEAK: f64 = 25.0;
|
||||||
const AUDIO_ENVELOPE_THRESHOLD_FRACTION: f64 = 0.30;
|
const AUDIO_ENVELOPE_THRESHOLD_FRACTION: f64 = 0.30;
|
||||||
const AUDIO_SAMPLE_THRESHOLD_FRACTION: f64 = 0.22;
|
const AUDIO_SAMPLE_THRESHOLD_FRACTION: f64 = 0.22;
|
||||||
const AUDIO_TONE_FREQUENCIES_HZ: [f64; 4] = [660.0, 880.0, 1100.0, 1320.0];
|
const AUDIO_TONE_FREQUENCIES_HZ: [f64; 16] = [
|
||||||
|
620.0, 780.0, 940.0, 1120.0, 1320.0, 1540.0, 1780.0, 2040.0, 2320.0, 2620.0, 2960.0, 3340.0,
|
||||||
|
3760.0, 4220.0, 4740.0, 5320.0,
|
||||||
|
];
|
||||||
const MIN_TONE_ENVELOPE_PEAK: f64 = 18.0;
|
const MIN_TONE_ENVELOPE_PEAK: f64 = 18.0;
|
||||||
const MIN_TONE_CONTRAST_FRACTION_OF_AMPLITUDE: f64 = 0.12;
|
const MIN_TONE_CONTRAST_FRACTION_OF_AMPLITUDE: f64 = 0.12;
|
||||||
const MIN_TONE_CODE_DOMINANCE_RATIO: f64 = 1.35;
|
const MIN_TONE_CODE_DOMINANCE_RATIO: f64 = 1.35;
|
||||||
@ -147,7 +150,6 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let frame_step_s = median_frame_step_seconds(×tamps_s[..frame_count]).max(1.0 / 120.0);
|
let frame_step_s = median_frame_step_seconds(×tamps_s[..frame_count]).max(1.0 / 120.0);
|
||||||
let max_event_code = event_codes.iter().copied().max().unwrap_or(1);
|
|
||||||
let mut segments = Vec::new();
|
let mut segments = Vec::new();
|
||||||
let mut previous_code = None::<u32>;
|
let mut previous_code = None::<u32>;
|
||||||
let mut segment_start = 0.0_f64;
|
let mut segment_start = 0.0_f64;
|
||||||
@ -156,7 +158,7 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
let mut segment_codes = Vec::<u32>::new();
|
let mut segment_codes = Vec::<u32>::new();
|
||||||
|
|
||||||
for (timestamp, frame) in timestamps_s.iter().copied().zip(frames.iter().copied()) {
|
for (timestamp, frame) in timestamps_s.iter().copied().zip(frames.iter().copied()) {
|
||||||
let code = color_event_code(frame).filter(|code| event_codes.contains(code));
|
let code = color_event_code_for_codes(frame, event_codes);
|
||||||
if code.is_some() && previous_code.is_none() {
|
if code.is_some() && previous_code.is_none() {
|
||||||
segment_start = previous_timestamp
|
segment_start = previous_timestamp
|
||||||
.map(|prior| edge_midpoint(prior, timestamp))
|
.map(|prior| edge_midpoint(prior, timestamp))
|
||||||
@ -176,7 +178,6 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
timestamp,
|
timestamp,
|
||||||
),
|
),
|
||||||
pulse_width_s,
|
pulse_width_s,
|
||||||
max_event_code,
|
|
||||||
&segment_codes,
|
&segment_codes,
|
||||||
frame_step_s,
|
frame_step_s,
|
||||||
);
|
);
|
||||||
@ -192,7 +193,6 @@ pub(crate) fn detect_color_coded_video_segments(
|
|||||||
segment_start,
|
segment_start,
|
||||||
last_timestamp + frame_step_s / 2.0,
|
last_timestamp + frame_step_s / 2.0,
|
||||||
pulse_width_s,
|
pulse_width_s,
|
||||||
max_event_code,
|
|
||||||
&segment_codes,
|
&segment_codes,
|
||||||
frame_step_s,
|
frame_step_s,
|
||||||
);
|
);
|
||||||
@ -224,7 +224,6 @@ fn push_color_segment(
|
|||||||
start_s: f64,
|
start_s: f64,
|
||||||
observed_end_s: f64,
|
observed_end_s: f64,
|
||||||
pulse_width_s: f64,
|
pulse_width_s: f64,
|
||||||
max_event_code: u32,
|
|
||||||
codes: &[u32],
|
codes: &[u32],
|
||||||
frame_step_s: f64,
|
frame_step_s: f64,
|
||||||
) {
|
) {
|
||||||
@ -232,9 +231,8 @@ fn push_color_segment(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let observed_duration_s = observed_end_s - start_s;
|
let observed_duration_s = observed_end_s - start_s;
|
||||||
let max_observed_duration_s =
|
let max_observed_duration_s = (pulse_width_s * MAX_COLOR_OBSERVED_DURATION_MULTIPLIER)
|
||||||
(pulse_width_s * f64::from(max_event_code) * MAX_COLOR_OBSERVED_DURATION_MULTIPLIER)
|
+ MAX_COLOR_OBSERVED_DURATION_SLACK_S;
|
||||||
+ MAX_COLOR_OBSERVED_DURATION_SLACK_S;
|
|
||||||
if observed_duration_s > max_observed_duration_s {
|
if observed_duration_s > max_observed_duration_s {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -261,19 +259,22 @@ fn dominant_event_code(codes: &[u32]) -> Option<u32> {
|
|||||||
.map(|(code, _)| code)
|
.map(|(code, _)| code)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn color_event_code(frame: VideoColorFrame) -> Option<u32> {
|
fn color_event_code_for_codes(frame: VideoColorFrame, event_codes: &[u32]) -> Option<u32> {
|
||||||
let max = frame.r.max(frame.g).max(frame.b);
|
let max = frame.r.max(frame.g).max(frame.b);
|
||||||
let min = frame.r.min(frame.g).min(frame.b);
|
let min = frame.r.min(frame.g).min(frame.b);
|
||||||
if max < MIN_COLOR_PULSE_VALUE || max.saturating_sub(min) < MIN_COLOR_PULSE_SATURATION {
|
if max < MIN_COLOR_PULSE_VALUE || max.saturating_sub(min) < MIN_COLOR_PULSE_SATURATION {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
color_palette()
|
event_codes
|
||||||
.into_iter()
|
.iter()
|
||||||
.map(|(code, color)| (code, color_distance_squared(frame, color)))
|
.copied()
|
||||||
|
.filter_map(|code| {
|
||||||
|
color_for_event_code(code).map(|color| (code, color_distance_squared(frame, color)))
|
||||||
|
})
|
||||||
.min_by_key(|(_, distance)| *distance)
|
.min_by_key(|(_, distance)| *distance)
|
||||||
.and_then(|(code, distance)| (distance <= MAX_COLOR_DISTANCE_SQUARED).then_some(code))
|
.and_then(|(code, distance)| (distance <= MAX_COLOR_DISTANCE_SQUARED).then_some(code))
|
||||||
.or_else(|| dominant_color_event_code(frame))
|
.or_else(|| dominant_color_event_code(frame).filter(|code| event_codes.contains(code)))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn dominant_color_event_code(frame: VideoColorFrame) -> Option<u32> {
|
fn dominant_color_event_code(frame: VideoColorFrame) -> Option<u32> {
|
||||||
@ -305,7 +306,7 @@ fn color_for_event_code(code: u32) -> Option<VideoColorFrame> {
|
|||||||
.find_map(|(palette_code, color)| (palette_code == code).then_some(color))
|
.find_map(|(palette_code, color)| (palette_code == code).then_some(color))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn color_palette() -> [(u32, VideoColorFrame); 4] {
|
fn color_palette() -> [(u32, VideoColorFrame); 16] {
|
||||||
[
|
[
|
||||||
(
|
(
|
||||||
1,
|
1,
|
||||||
@ -339,6 +340,102 @@ fn color_palette() -> [(u32, VideoColorFrame); 4] {
|
|||||||
b: 0,
|
b: 0,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
5,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 216,
|
||||||
|
g: 27,
|
||||||
|
b: 96,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
6,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 0,
|
||||||
|
g: 188,
|
||||||
|
b: 212,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
7,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 205,
|
||||||
|
g: 220,
|
||||||
|
b: 57,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
8,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 126,
|
||||||
|
g: 87,
|
||||||
|
b: 194,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
9,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 255,
|
||||||
|
g: 112,
|
||||||
|
b: 67,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
10,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 38,
|
||||||
|
g: 166,
|
||||||
|
b: 154,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
11,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 255,
|
||||||
|
g: 64,
|
||||||
|
b: 129,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
12,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 92,
|
||||||
|
g: 107,
|
||||||
|
b: 192,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
13,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 255,
|
||||||
|
g: 235,
|
||||||
|
b: 59,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
14,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 105,
|
||||||
|
g: 240,
|
||||||
|
b: 174,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
15,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 171,
|
||||||
|
g: 71,
|
||||||
|
b: 188,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
16,
|
||||||
|
VideoColorFrame {
|
||||||
|
r: 3,
|
||||||
|
g: 169,
|
||||||
|
b: 244,
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -286,6 +286,7 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
&video_indexed,
|
&video_indexed,
|
||||||
&audio_indexed,
|
&audio_indexed,
|
||||||
&offset_candidates,
|
&offset_candidates,
|
||||||
|
event_width_codes,
|
||||||
max_pair_gap_s,
|
max_pair_gap_s,
|
||||||
expected_start_skew_ms,
|
expected_start_skew_ms,
|
||||||
);
|
);
|
||||||
@ -364,6 +365,7 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
&raw_full_video_indexed,
|
&raw_full_video_indexed,
|
||||||
&raw_full_audio_indexed,
|
&raw_full_audio_indexed,
|
||||||
&candidate_coded_index_offsets(&raw_full_video_indexed, &raw_full_audio_indexed),
|
&candidate_coded_index_offsets(&raw_full_video_indexed, &raw_full_audio_indexed),
|
||||||
|
event_width_codes,
|
||||||
DIAGNOSTIC_CODED_MAX_PAIR_GAP_S,
|
DIAGNOSTIC_CODED_MAX_PAIR_GAP_S,
|
||||||
activity_start_delta_ms,
|
activity_start_delta_ms,
|
||||||
);
|
);
|
||||||
@ -468,6 +470,7 @@ fn best_coded_pairs_for_raw_segments(
|
|||||||
&video_indexed,
|
&video_indexed,
|
||||||
&audio_indexed,
|
&audio_indexed,
|
||||||
&candidate_coded_index_offsets(&video_indexed, &audio_indexed),
|
&candidate_coded_index_offsets(&video_indexed, &audio_indexed),
|
||||||
|
event_width_codes,
|
||||||
max_pair_gap_s,
|
max_pair_gap_s,
|
||||||
expected_start_skew_ms,
|
expected_start_skew_ms,
|
||||||
);
|
);
|
||||||
@ -756,6 +759,8 @@ struct MatchedOnsetPair {
|
|||||||
audio_time_s: f64,
|
audio_time_s: f64,
|
||||||
skew_ms: f64,
|
skew_ms: f64,
|
||||||
confidence: f64,
|
confidence: f64,
|
||||||
|
server_event_id: Option<usize>,
|
||||||
|
event_code: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MatchedOnsetPair {
|
impl MatchedOnsetPair {
|
||||||
@ -771,8 +776,16 @@ impl MatchedOnsetPair {
|
|||||||
audio_time_s,
|
audio_time_s,
|
||||||
skew_ms,
|
skew_ms,
|
||||||
confidence,
|
confidence,
|
||||||
|
server_event_id: None,
|
||||||
|
event_code: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_identity(mut self, event_width_codes: &[u32], event_code: u32) -> Self {
|
||||||
|
self.server_event_id = unique_event_id_for_code(event_width_codes, event_code);
|
||||||
|
self.event_code = Some(event_code);
|
||||||
|
self
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn best_pairs_for_index_offsets(
|
fn best_pairs_for_index_offsets(
|
||||||
@ -842,6 +855,7 @@ fn best_coded_pairs_for_index_offsets(
|
|||||||
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||||
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||||
offset_candidates: &[i64],
|
offset_candidates: &[i64],
|
||||||
|
event_width_codes: &[u32],
|
||||||
max_pair_gap_s: f64,
|
max_pair_gap_s: f64,
|
||||||
expected_start_skew_ms: f64,
|
expected_start_skew_ms: f64,
|
||||||
) -> Vec<MatchedOnsetPair> {
|
) -> Vec<MatchedOnsetPair> {
|
||||||
@ -860,6 +874,7 @@ fn best_coded_pairs_for_index_offsets(
|
|||||||
.map(|audio| {
|
.map(|audio| {
|
||||||
let skew_ms = (audio.start_s - video.start_s) * 1000.0;
|
let skew_ms = (audio.start_s - video.start_s) * 1000.0;
|
||||||
MatchedOnsetPair::new(video.start_s, audio.start_s, skew_ms, max_pair_gap_s)
|
MatchedOnsetPair::new(video.start_s, audio.start_s, skew_ms, max_pair_gap_s)
|
||||||
|
.with_identity(event_width_codes, video.code)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.filter(|pair| pair.skew_ms.abs() <= max_pair_gap_ms)
|
.filter(|pair| pair.skew_ms.abs() <= max_pair_gap_ms)
|
||||||
@ -935,12 +950,10 @@ fn best_coded_pairs_by_time(
|
|||||||
|
|
||||||
if let Some((audio_index, audio, skew_ms)) = best_audio {
|
if let Some((audio_index, audio, skew_ms)) = best_audio {
|
||||||
used_audio[audio_index] = true;
|
used_audio[audio_index] = true;
|
||||||
pairs.push(MatchedOnsetPair::new(
|
pairs.push(
|
||||||
video.start_s,
|
MatchedOnsetPair::new(video.start_s, audio.start_s, skew_ms, max_pair_gap_s)
|
||||||
audio.start_s,
|
.with_identity(event_width_codes, video_code),
|
||||||
skew_ms,
|
);
|
||||||
max_pair_gap_s,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -951,6 +964,7 @@ fn diagnostic_coded_pairs_for_index_offsets(
|
|||||||
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
video_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||||
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
audio_indexed: &BTreeMap<i64, CodedPulseSegment>,
|
||||||
offset_candidates: &[i64],
|
offset_candidates: &[i64],
|
||||||
|
event_width_codes: &[u32],
|
||||||
max_pair_gap_s: f64,
|
max_pair_gap_s: f64,
|
||||||
expected_start_skew_ms: f64,
|
expected_start_skew_ms: f64,
|
||||||
) -> Vec<MatchedOnsetPair> {
|
) -> Vec<MatchedOnsetPair> {
|
||||||
@ -967,6 +981,7 @@ fn diagnostic_coded_pairs_for_index_offsets(
|
|||||||
.map(|audio| {
|
.map(|audio| {
|
||||||
let skew_ms = (audio.start_s - video.start_s) * 1000.0;
|
let skew_ms = (audio.start_s - video.start_s) * 1000.0;
|
||||||
MatchedOnsetPair::new(video.start_s, audio.start_s, skew_ms, max_pair_gap_s)
|
MatchedOnsetPair::new(video.start_s, audio.start_s, skew_ms, max_pair_gap_s)
|
||||||
|
.with_identity(event_width_codes, video.code)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.filter(|pair| pair.skew_ms.abs() <= max_pair_gap_ms)
|
.filter(|pair| pair.skew_ms.abs() <= max_pair_gap_ms)
|
||||||
@ -1019,7 +1034,9 @@ fn sync_report_from_pairs(
|
|||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(event_id, pair)| SyncEventPair {
|
.map(|(event_id, pair)| SyncEventPair {
|
||||||
event_id,
|
event_id: pair.server_event_id.unwrap_or(event_id),
|
||||||
|
server_event_id: pair.server_event_id,
|
||||||
|
event_code: pair.event_code,
|
||||||
video_time_s: pair.video_time_s,
|
video_time_s: pair.video_time_s,
|
||||||
audio_time_s: pair.audio_time_s,
|
audio_time_s: pair.audio_time_s,
|
||||||
skew_ms: pair.skew_ms,
|
skew_ms: pair.skew_ms,
|
||||||
@ -1062,3 +1079,13 @@ fn sync_report_from_pairs(
|
|||||||
paired_events,
|
paired_events,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn unique_event_id_for_code(event_width_codes: &[u32], event_code: u32) -> Option<usize> {
|
||||||
|
let mut matches = event_width_codes
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, code)| *code == event_code);
|
||||||
|
let (index, _) = matches.next()?;
|
||||||
|
matches.next().is_none().then_some(index)
|
||||||
|
}
|
||||||
|
|||||||
@ -96,7 +96,7 @@ fn detect_color_coded_video_segments_ignores_generic_bright_changes() {
|
|||||||
g: 45,
|
g: 45,
|
||||||
b: 45,
|
b: 45,
|
||||||
},
|
},
|
||||||
40..=45 => VideoColorFrame {
|
40..=42 => VideoColorFrame {
|
||||||
r: 0,
|
r: 0,
|
||||||
g: 230,
|
g: 230,
|
||||||
b: 118,
|
b: 118,
|
||||||
@ -224,8 +224,8 @@ fn detect_audio_segments_locks_onto_probe_tone_over_background_hum() {
|
|||||||
fn detect_coded_audio_segments_uses_probe_tone_frequency_for_event_code() {
|
fn detect_coded_audio_segments_uses_probe_tone_frequency_for_event_code() {
|
||||||
let mut samples = vec![0i16; 96_000];
|
let mut samples = vec![0i16; 96_000];
|
||||||
add_sine(&mut samples, 48_000, 0.0, 2.0, 120.0, 7_000.0);
|
add_sine(&mut samples, 48_000, 0.0, 2.0, 120.0, 7_000.0);
|
||||||
add_sine(&mut samples, 48_000, 0.25, 0.07, 660.0, 2_000.0);
|
add_sine(&mut samples, 48_000, 0.25, 0.07, 620.0, 2_000.0);
|
||||||
add_sine(&mut samples, 48_000, 1.25, 0.07, 1320.0, 2_000.0);
|
add_sine(&mut samples, 48_000, 1.25, 0.07, 1120.0, 2_000.0);
|
||||||
|
|
||||||
let segments =
|
let segments =
|
||||||
detect_coded_audio_segments(&samples, 48_000, 10, &[1, 2, 3, 4], 0.12).expect("segments");
|
detect_coded_audio_segments(&samples, 48_000, 10, &[1, 2, 3, 4], 0.12).expect("segments");
|
||||||
|
|||||||
@ -40,6 +40,10 @@ pub struct SyncAnalysisReport {
|
|||||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||||
pub struct SyncEventPair {
|
pub struct SyncEventPair {
|
||||||
pub event_id: usize,
|
pub event_id: usize,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub server_event_id: Option<usize>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub event_code: Option<u32>,
|
||||||
pub video_time_s: f64,
|
pub video_time_s: f64,
|
||||||
pub audio_time_s: f64,
|
pub audio_time_s: f64,
|
||||||
pub skew_ms: f64,
|
pub skew_ms: f64,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.19.18"
|
version = "0.19.19"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import time
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DEFAULT_EVENT_WIDTH_CODES = "1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2"
|
DEFAULT_EVENT_WIDTH_CODES = "1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16"
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
@ -165,13 +165,37 @@ const pulseColors = {
|
|||||||
1: '#b81d24',
|
1: '#b81d24',
|
||||||
2: '#007a3d',
|
2: '#007a3d',
|
||||||
3: '#1456b8',
|
3: '#1456b8',
|
||||||
4: '#b56b00'
|
4: '#b56b00',
|
||||||
|
5: '#d81b60',
|
||||||
|
6: '#00bcd4',
|
||||||
|
7: '#cddc39',
|
||||||
|
8: '#7e57c2',
|
||||||
|
9: '#ff7043',
|
||||||
|
10: '#26a69a',
|
||||||
|
11: '#ff4081',
|
||||||
|
12: '#5c6bc0',
|
||||||
|
13: '#ffeb3b',
|
||||||
|
14: '#69f0ae',
|
||||||
|
15: '#ab47bc',
|
||||||
|
16: '#03a9f4'
|
||||||
};
|
};
|
||||||
const pulseFrequencies = {
|
const pulseFrequencies = {
|
||||||
1: 660,
|
1: 620,
|
||||||
2: 880,
|
2: 780,
|
||||||
3: 1100,
|
3: 940,
|
||||||
4: 1320
|
4: 1120,
|
||||||
|
5: 1320,
|
||||||
|
6: 1540,
|
||||||
|
7: 1780,
|
||||||
|
8: 2040,
|
||||||
|
9: 2320,
|
||||||
|
10: 2620,
|
||||||
|
11: 2960,
|
||||||
|
12: 3340,
|
||||||
|
13: 3760,
|
||||||
|
14: 4220,
|
||||||
|
15: 4740,
|
||||||
|
16: 5320
|
||||||
};
|
};
|
||||||
|
|
||||||
function setStatus(message) { statusEl.textContent = message; }
|
function setStatus(message) { statusEl.textContent = message; }
|
||||||
@ -197,7 +221,7 @@ function eventAt(elapsedMs, command) {
|
|||||||
const offset = sinceWarmup % command.pulse_period_ms;
|
const offset = sinceWarmup % command.pulse_period_ms;
|
||||||
const codes = command.event_width_codes && command.event_width_codes.length ? command.event_width_codes : [1];
|
const codes = command.event_width_codes && command.event_width_codes.length ? command.event_width_codes : [1];
|
||||||
const widthCode = codes[pulseIndex % codes.length];
|
const widthCode = codes[pulseIndex % codes.length];
|
||||||
const width = Math.min(command.pulse_period_ms - 1, command.pulse_width_ms * widthCode);
|
const width = Math.min(command.pulse_period_ms - 1, command.pulse_width_ms);
|
||||||
return { active: offset < width, pulseIndex, widthCode };
|
return { active: offset < width, pulseIndex, widthCode };
|
||||||
}
|
}
|
||||||
async function runStimulus(command, mode, token) {
|
async function runStimulus(command, mode, token) {
|
||||||
|
|||||||
@ -83,7 +83,7 @@ LESAVKA_SERVER_RC_MAX_AUDIO_LOW_RMS_WINDOWS=${LESAVKA_SERVER_RC_MAX_AUDIO_LOW_RM
|
|||||||
|
|
||||||
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-20}
|
PROBE_DURATION_SECONDS=${PROBE_DURATION_SECONDS:-20}
|
||||||
PROBE_WARMUP_SECONDS=${PROBE_WARMUP_SECONDS:-4}
|
PROBE_WARMUP_SECONDS=${PROBE_WARMUP_SECONDS:-4}
|
||||||
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2}
|
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}
|
||||||
REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}
|
||||||
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}
|
REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}
|
||||||
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
||||||
|
|||||||
@ -23,7 +23,7 @@ PROBE_START_GRACE_SECONDS=${PROBE_START_GRACE_SECONDS:-20}
|
|||||||
PROBE_TIMEOUT_SECONDS=${PROBE_TIMEOUT_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS))}
|
PROBE_TIMEOUT_SECONDS=${PROBE_TIMEOUT_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS))}
|
||||||
PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000}
|
PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000}
|
||||||
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
||||||
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2}
|
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}
|
||||||
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}
|
LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US=${LESAVKA_OUTPUT_DELAY_PROBE_AUDIO_DELAY_US:-0}
|
||||||
LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US:-0}
|
LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US=${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US:-0}
|
||||||
# Do not open the UVC host capture far ahead of the probe. The gadget side only
|
# Do not open the UVC host capture far ahead of the probe. The gadget side only
|
||||||
@ -992,7 +992,8 @@ import sys
|
|||||||
) = sys.argv[1:]
|
) = sys.argv[1:]
|
||||||
report = json.loads(pathlib.Path(report_path).read_text())
|
report = json.loads(pathlib.Path(report_path).read_text())
|
||||||
timeline = json.loads(pathlib.Path(timeline_path).read_text())
|
timeline = json.loads(pathlib.Path(timeline_path).read_text())
|
||||||
server_events = {int(event["event_id"]): event for event in timeline.get("events", [])}
|
server_event_list = timeline.get("events", [])
|
||||||
|
server_events = {int(event["event_id"]): event for event in server_event_list}
|
||||||
|
|
||||||
|
|
||||||
def finite(value):
|
def finite(value):
|
||||||
@ -1018,6 +1019,17 @@ def as_int_or_none(value):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
server_events_by_code = {}
|
||||||
|
for event in server_event_list:
|
||||||
|
code = as_int_or_none(event.get("code"))
|
||||||
|
if code is None:
|
||||||
|
continue
|
||||||
|
server_events_by_code.setdefault(code, []).append(event)
|
||||||
|
unique_server_events_by_code = {
|
||||||
|
code: events[0] for code, events in server_events_by_code.items() if len(events) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def load_json_or_empty(path):
|
def load_json_or_empty(path):
|
||||||
try:
|
try:
|
||||||
return json.loads(pathlib.Path(path).read_text())
|
return json.loads(pathlib.Path(path).read_text())
|
||||||
@ -1479,12 +1491,75 @@ clock_uncertainty_ms = as_float(clock_alignment.get("uncertainty_ms"), 0.0)
|
|||||||
audio_delay_ms = as_float(timeline.get("audio_delay_us"), 0.0) / 1000.0
|
audio_delay_ms = as_float(timeline.get("audio_delay_us"), 0.0) / 1000.0
|
||||||
video_delay_ms = as_float(timeline.get("video_delay_us"), 0.0) / 1000.0
|
video_delay_ms = as_float(timeline.get("video_delay_us"), 0.0) / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def corrected_server_capture_time_s(server, key):
|
||||||
|
server_unix_ns = as_int_or_none(server.get(key))
|
||||||
|
if (
|
||||||
|
server_unix_ns is None
|
||||||
|
or theia_to_tethys_offset_ns is None
|
||||||
|
or capture_start_unix_ns is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return (server_unix_ns + theia_to_tethys_offset_ns - capture_start_unix_ns) / 1_000_000_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def nearest_server_event_for_observation(observed, used_event_ids):
|
||||||
|
observed_video_s = finite(observed.get("video_time_s"))
|
||||||
|
observed_audio_s = finite(observed.get("audio_time_s"))
|
||||||
|
if observed_video_s is None and observed_audio_s is None:
|
||||||
|
return None, None, None
|
||||||
|
best = None
|
||||||
|
for server in server_event_list:
|
||||||
|
event_id = as_int_or_none(server.get("event_id"))
|
||||||
|
if event_id in used_event_ids:
|
||||||
|
continue
|
||||||
|
expected_video_s = corrected_server_capture_time_s(server, "video_feed_unix_ns")
|
||||||
|
expected_audio_s = corrected_server_capture_time_s(server, "audio_push_unix_ns")
|
||||||
|
score = 0.0
|
||||||
|
parts = 0
|
||||||
|
if observed_video_s is not None and expected_video_s is not None:
|
||||||
|
score += abs(observed_video_s - expected_video_s)
|
||||||
|
parts += 1
|
||||||
|
if observed_audio_s is not None and expected_audio_s is not None:
|
||||||
|
score += abs(observed_audio_s - expected_audio_s)
|
||||||
|
parts += 1
|
||||||
|
if parts == 0:
|
||||||
|
continue
|
||||||
|
normalized_score = score / parts
|
||||||
|
if best is None or normalized_score < best[0]:
|
||||||
|
best = (normalized_score, event_id, server)
|
||||||
|
if best is None:
|
||||||
|
return None, None, None
|
||||||
|
return best[2], "nearest_clocked_time", best[0] * 1000.0
|
||||||
|
|
||||||
|
|
||||||
joined = []
|
joined = []
|
||||||
|
used_server_event_ids = set()
|
||||||
for observed in report.get("paired_events", []):
|
for observed in report.get("paired_events", []):
|
||||||
event_id = int(observed.get("event_id", -1))
|
observed_event_id = as_int_or_none(observed.get("event_id"))
|
||||||
server = server_events.get(event_id)
|
observed_server_event_id = as_int_or_none(observed.get("server_event_id"))
|
||||||
|
observed_event_code = as_int_or_none(observed.get("event_code"))
|
||||||
|
server = None
|
||||||
|
match_method = None
|
||||||
|
match_score_ms = None
|
||||||
|
if observed_server_event_id is not None:
|
||||||
|
server = server_events.get(observed_server_event_id)
|
||||||
|
match_method = "analyzer_server_event_id" if server else None
|
||||||
|
if server is None and observed_event_code is not None:
|
||||||
|
server = unique_server_events_by_code.get(observed_event_code)
|
||||||
|
match_method = "unique_event_code" if server else None
|
||||||
|
if server is None:
|
||||||
|
server, match_method, match_score_ms = nearest_server_event_for_observation(
|
||||||
|
observed,
|
||||||
|
used_server_event_ids,
|
||||||
|
)
|
||||||
|
if server is None and observed_event_code is None and observed_event_id is not None:
|
||||||
|
server = server_events.get(observed_event_id)
|
||||||
|
match_method = "legacy_pair_event_id" if server else None
|
||||||
if not server:
|
if not server:
|
||||||
continue
|
continue
|
||||||
|
event_id = int(server.get("event_id", -1))
|
||||||
|
used_server_event_ids.add(event_id)
|
||||||
observed_skew_ms = finite(observed.get("skew_ms"))
|
observed_skew_ms = finite(observed.get("skew_ms"))
|
||||||
server_feed_delta_ms = finite(server.get("server_feed_delta_ms"))
|
server_feed_delta_ms = finite(server.get("server_feed_delta_ms"))
|
||||||
tethys_video_time_s = finite(observed.get("video_time_s"))
|
tethys_video_time_s = finite(observed.get("video_time_s"))
|
||||||
@ -1541,6 +1616,11 @@ for observed in report.get("paired_events", []):
|
|||||||
planned_start_us = int(server.get("planned_start_us", 0))
|
planned_start_us = int(server.get("planned_start_us", 0))
|
||||||
joined.append({
|
joined.append({
|
||||||
"event_id": event_id,
|
"event_id": event_id,
|
||||||
|
"observed_event_id": observed_event_id,
|
||||||
|
"observed_server_event_id": observed_server_event_id,
|
||||||
|
"observed_event_code": observed_event_code,
|
||||||
|
"match_method": match_method,
|
||||||
|
"match_score_ms": match_score_ms,
|
||||||
"code": int(server.get("code", 0)),
|
"code": int(server.get("code", 0)),
|
||||||
"event_time_s": planned_start_us / 1_000_000.0,
|
"event_time_s": planned_start_us / 1_000_000.0,
|
||||||
"planned_start_us": planned_start_us,
|
"planned_start_us": planned_start_us,
|
||||||
@ -1759,6 +1839,11 @@ pathlib.Path(output_json_path).write_text(json.dumps(artifact, indent=2, sort_ke
|
|||||||
with pathlib.Path(output_csv_path).open("w", newline="", encoding="utf-8") as handle:
|
with pathlib.Path(output_csv_path).open("w", newline="", encoding="utf-8") as handle:
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
"event_id",
|
"event_id",
|
||||||
|
"observed_event_id",
|
||||||
|
"observed_server_event_id",
|
||||||
|
"observed_event_code",
|
||||||
|
"match_method",
|
||||||
|
"match_score_ms",
|
||||||
"code",
|
"code",
|
||||||
"event_time_s",
|
"event_time_s",
|
||||||
"tethys_video_time_s",
|
"tethys_video_time_s",
|
||||||
@ -2653,6 +2738,7 @@ fi
|
|||||||
|
|
||||||
if ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_CAPTURE}'"; then
|
if ssh ${SSH_OPTS} "${TETHYS_HOST}" "test -f '${REMOTE_CAPTURE}'"; then
|
||||||
remote_fetch_capture="${REMOTE_CAPTURE}"
|
remote_fetch_capture="${REMOTE_CAPTURE}"
|
||||||
|
analysis_status=0
|
||||||
if [[ "${ANALYSIS_NORMALIZE}" != "0" ]]; then
|
if [[ "${ANALYSIS_NORMALIZE}" != "0" ]]; then
|
||||||
remote_fetch_capture="${REMOTE_CAPTURE%.mkv}-analysis.mkv"
|
remote_fetch_capture="${REMOTE_CAPTURE%.mkv}-analysis.mkv"
|
||||||
echo "==> normalizing remote capture to CFR for analysis"
|
echo "==> normalizing remote capture to CFR for analysis"
|
||||||
@ -2690,15 +2776,22 @@ REMOTE_NORMALIZE_SCRIPT
|
|||||||
echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }"
|
echo " ↪ analyzer timeline window: ${analysis_window_arg#--analysis-window-s }"
|
||||||
fi
|
fi
|
||||||
echo "==> analyzing capture on ${TETHYS_HOST}"
|
echo "==> analyzing capture on ${TETHYS_HOST}"
|
||||||
|
set +e
|
||||||
ssh ${SSH_OPTS} "${TETHYS_HOST}" \
|
ssh ${SSH_OPTS} "${TETHYS_HOST}" \
|
||||||
"chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}' ${analysis_window_arg}" \
|
"chmod +x '${REMOTE_ANALYZE_BIN}' && '${REMOTE_ANALYZE_BIN}' '${remote_fetch_capture}' --json --event-width-codes '${PROBE_EVENT_WIDTH_CODES}' ${analysis_window_arg}" \
|
||||||
> "${LOCAL_ANALYSIS_JSON}"
|
> "${LOCAL_ANALYSIS_JSON}"
|
||||||
|
analysis_status=$?
|
||||||
|
set -e
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${FETCH_CAPTURE}" != "0" ]]; then
|
if [[ "${FETCH_CAPTURE}" != "0" ]]; then
|
||||||
echo "==> fetching capture back to ${LOCAL_CAPTURE}"
|
echo "==> fetching capture back to ${LOCAL_CAPTURE}"
|
||||||
scp ${SSH_OPTS} "${TETHYS_HOST}:${remote_fetch_capture}" "${LOCAL_CAPTURE}"
|
scp ${SSH_OPTS} "${TETHYS_HOST}:${remote_fetch_capture}" "${LOCAL_CAPTURE}"
|
||||||
fi
|
fi
|
||||||
|
if [[ "${analysis_status}" -ne 0 ]]; then
|
||||||
|
echo "remote analysis failed with status ${analysis_status}; capture preserved at ${LOCAL_CAPTURE}" >&2
|
||||||
|
exit "${analysis_status}"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${probe_status}" -ne 0 ]]; then
|
if [[ "${probe_status}" -ne 0 ]]; then
|
||||||
@ -2765,12 +2858,22 @@ pathlib.Path(sys.argv[2]).write_text(summary)
|
|||||||
with pathlib.Path(sys.argv[3]).open("w", newline="") as handle:
|
with pathlib.Path(sys.argv[3]).open("w", newline="") as handle:
|
||||||
writer = csv.DictWriter(
|
writer = csv.DictWriter(
|
||||||
handle,
|
handle,
|
||||||
fieldnames=["event_id", "video_time_s", "audio_time_s", "skew_ms", "confidence"],
|
fieldnames=[
|
||||||
|
"event_id",
|
||||||
|
"server_event_id",
|
||||||
|
"event_code",
|
||||||
|
"video_time_s",
|
||||||
|
"audio_time_s",
|
||||||
|
"skew_ms",
|
||||||
|
"confidence",
|
||||||
|
],
|
||||||
)
|
)
|
||||||
writer.writeheader()
|
writer.writeheader()
|
||||||
for event in report.get("paired_events", []):
|
for event in report.get("paired_events", []):
|
||||||
writer.writerow({
|
writer.writerow({
|
||||||
"event_id": event.get("event_id"),
|
"event_id": event.get("event_id"),
|
||||||
|
"server_event_id": event.get("server_event_id"),
|
||||||
|
"event_code": event.get("event_code"),
|
||||||
"video_time_s": event.get("video_time_s"),
|
"video_time_s": event.get("video_time_s"),
|
||||||
"audio_time_s": event.get("audio_time_s"),
|
"audio_time_s": event.get("audio_time_s"),
|
||||||
"skew_ms": event.get("skew_ms"),
|
"skew_ms": event.get("skew_ms"),
|
||||||
|
|||||||
@ -22,7 +22,7 @@ PROBE_WARMUP_SECONDS=${PROBE_WARMUP_SECONDS:-4}
|
|||||||
PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000}
|
PROBE_PULSE_PERIOD_MS=${PROBE_PULSE_PERIOD_MS:-1000}
|
||||||
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
PROBE_PULSE_WIDTH_MS=${PROBE_PULSE_WIDTH_MS:-120}
|
||||||
PROBE_MARKER_TICK_PERIOD=${PROBE_MARKER_TICK_PERIOD:-5}
|
PROBE_MARKER_TICK_PERIOD=${PROBE_MARKER_TICK_PERIOD:-5}
|
||||||
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3,2,4,1,1,3,1,4,2,1,2,3,4,1,3,2,2,4,1,2,4,3,1,1,4,2,3,1,2}
|
PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16}
|
||||||
PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}
|
PROBE_AUDIO_GAIN=${PROBE_AUDIO_GAIN:-0.55}
|
||||||
LESAVKA_SYNC_CALIBRATION_SEGMENTS_SET=${LESAVKA_SYNC_CALIBRATION_SEGMENTS+x}
|
LESAVKA_SYNC_CALIBRATION_SEGMENTS_SET=${LESAVKA_SYNC_CALIBRATION_SEGMENTS+x}
|
||||||
LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}
|
LESAVKA_SYNC_ADAPTIVE_CALIBRATION=${LESAVKA_SYNC_ADAPTIVE_CALIBRATION:-0}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.19.18"
|
version = "0.19.19"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -16,9 +16,7 @@ const DEFAULT_DURATION_SECONDS: u32 = 20;
|
|||||||
const DEFAULT_WARMUP_SECONDS: u32 = 4;
|
const DEFAULT_WARMUP_SECONDS: u32 = 4;
|
||||||
const DEFAULT_PULSE_PERIOD_MS: u32 = 1_000;
|
const DEFAULT_PULSE_PERIOD_MS: u32 = 1_000;
|
||||||
const DEFAULT_PULSE_WIDTH_MS: u32 = 120;
|
const DEFAULT_PULSE_WIDTH_MS: u32 = 120;
|
||||||
const DEFAULT_EVENT_WIDTH_CODES: &[u32] = &[
|
const DEFAULT_EVENT_WIDTH_CODES: &[u32] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16];
|
||||||
1, 2, 1, 3, 2, 4, 1, 1, 3, 1, 4, 2, 1, 2, 3, 4, 1, 3, 2, 2, 4, 1, 2, 4, 3, 1, 1, 4, 2, 3, 1, 2,
|
|
||||||
];
|
|
||||||
const AUDIO_SAMPLE_RATE: u32 = 48_000;
|
const AUDIO_SAMPLE_RATE: u32 = 48_000;
|
||||||
const AUDIO_CHANNELS: usize = 2;
|
const AUDIO_CHANNELS: usize = 2;
|
||||||
const AUDIO_CHUNK_MS: u64 = 10;
|
const AUDIO_CHUNK_MS: u64 = 10;
|
||||||
@ -28,7 +26,7 @@ const AUDIO_PILOT_FREQUENCY_HZ: f64 = 180.0;
|
|||||||
const DARK_FRAME_RGB: Rgb = Rgb { r: 4, g: 8, b: 12 };
|
const DARK_FRAME_RGB: Rgb = Rgb { r: 4, g: 8, b: 12 };
|
||||||
const VIDEO_CONTINUITY_BLOCKS: usize = 20;
|
const VIDEO_CONTINUITY_BLOCKS: usize = 20;
|
||||||
const VIDEO_CONTINUITY_DATA_BITS: usize = 16;
|
const VIDEO_CONTINUITY_DATA_BITS: usize = 16;
|
||||||
const EVENT_COLORS: [Rgb; 4] = [
|
const EVENT_COLORS: [Rgb; 16] = [
|
||||||
Rgb {
|
Rgb {
|
||||||
r: 255,
|
r: 255,
|
||||||
g: 45,
|
g: 45,
|
||||||
@ -49,8 +47,71 @@ const EVENT_COLORS: [Rgb; 4] = [
|
|||||||
g: 179,
|
g: 179,
|
||||||
b: 0,
|
b: 0,
|
||||||
},
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 216,
|
||||||
|
g: 27,
|
||||||
|
b: 96,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 0,
|
||||||
|
g: 188,
|
||||||
|
b: 212,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 205,
|
||||||
|
g: 220,
|
||||||
|
b: 57,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 126,
|
||||||
|
g: 87,
|
||||||
|
b: 194,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 255,
|
||||||
|
g: 112,
|
||||||
|
b: 67,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 38,
|
||||||
|
g: 166,
|
||||||
|
b: 154,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 255,
|
||||||
|
g: 64,
|
||||||
|
b: 129,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 92,
|
||||||
|
g: 107,
|
||||||
|
b: 192,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 255,
|
||||||
|
g: 235,
|
||||||
|
b: 59,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 105,
|
||||||
|
g: 240,
|
||||||
|
b: 174,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 171,
|
||||||
|
g: 71,
|
||||||
|
b: 188,
|
||||||
|
},
|
||||||
|
Rgb {
|
||||||
|
r: 3,
|
||||||
|
g: 169,
|
||||||
|
b: 244,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const EVENT_FREQUENCIES_HZ: [f64; 16] = [
|
||||||
|
620.0, 780.0, 940.0, 1_120.0, 1_320.0, 1_540.0, 1_780.0, 2_040.0, 2_320.0, 2_620.0, 2_960.0,
|
||||||
|
3_340.0, 3_760.0, 4_220.0, 4_740.0, 5_320.0,
|
||||||
];
|
];
|
||||||
const EVENT_FREQUENCIES_HZ: [f64; 4] = [660.0, 880.0, 1_100.0, 1_320.0];
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug)]
|
#[derive(Clone, Copy, Debug)]
|
||||||
struct Rgb {
|
struct Rgb {
|
||||||
@ -250,8 +311,7 @@ impl ProbeConfig {
|
|||||||
let period_ns = self.pulse_period.as_nanos().max(1);
|
let period_ns = self.pulse_period.as_nanos().max(1);
|
||||||
let pulse_index = (since_warmup.as_nanos() / period_ns) as usize;
|
let pulse_index = (since_warmup.as_nanos() / period_ns) as usize;
|
||||||
let pulse_offset_ns = since_warmup.as_nanos() % period_ns;
|
let pulse_offset_ns = since_warmup.as_nanos() % period_ns;
|
||||||
let code = self.event_width_codes[pulse_index % self.event_width_codes.len()];
|
let active_ns = self.pulse_width.as_nanos();
|
||||||
let active_ns = self.pulse_width.as_nanos().saturating_mul(u128::from(code));
|
|
||||||
(pulse_offset_ns < active_ns).then(|| self.event_slot_by_id(pulse_index))
|
(pulse_offset_ns < active_ns).then(|| self.event_slot_by_id(pulse_index))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,8 +320,7 @@ impl ProbeConfig {
|
|||||||
let planned_start = self
|
let planned_start = self
|
||||||
.warmup
|
.warmup
|
||||||
.saturating_add(duration_mul(self.pulse_period, event_id as u64));
|
.saturating_add(duration_mul(self.pulse_period, event_id as u64));
|
||||||
let planned_end =
|
let planned_end = planned_start.saturating_add(self.pulse_width);
|
||||||
planned_start.saturating_add(duration_mul(self.pulse_width, u64::from(code)));
|
|
||||||
ProbeEventSlot {
|
ProbeEventSlot {
|
||||||
event_id,
|
event_id,
|
||||||
code,
|
code,
|
||||||
@ -312,8 +371,8 @@ fn parse_event_width_codes(raw: &str) -> Result<Vec<u32>> {
|
|||||||
let code = part
|
let code = part
|
||||||
.parse::<u32>()
|
.parse::<u32>()
|
||||||
.with_context(|| format!("parsing event width code `{part}`"))?;
|
.with_context(|| format!("parsing event width code `{part}`"))?;
|
||||||
if !(1..=4).contains(&code) {
|
if !(1..=16).contains(&code) {
|
||||||
bail!("event width code {code} is unsupported; use values 1..4");
|
bail!("event signature code {code} is unsupported; use values 1..16");
|
||||||
}
|
}
|
||||||
Ok(code)
|
Ok(code)
|
||||||
})
|
})
|
||||||
@ -760,7 +819,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn event_codes_reject_unsupported_signatures() {
|
fn event_codes_reject_unsupported_signatures() {
|
||||||
let request = OutputDelayProbeRequest {
|
let request = OutputDelayProbeRequest {
|
||||||
event_width_codes: "1,5".to_string(),
|
event_width_codes: "1,17".to_string(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user