test(server-rc): harden coded output proof
This commit is contained in:
parent
bbc70f088f
commit
1e343057ac
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.20"
|
version = "0.19.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@ -1686,7 +1686,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.19.20"
|
version = "0.19.21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"base64",
|
"base64",
|
||||||
@ -1698,7 +1698,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.19.20"
|
version = "0.19.21"
|
||||||
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.20"
|
version = "0.19.21"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
use std::collections::BTreeSet;
|
||||||
#[cfg(any(not(coverage), test))]
|
#[cfg(any(not(coverage), test))]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@ -16,10 +18,25 @@ use lesavka_client::sync_probe::analyze::{
|
|||||||
struct SyncAnalyzeOutput<'a> {
|
struct SyncAnalyzeOutput<'a> {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
report: &'a SyncAnalysisReport,
|
report: &'a SyncAnalysisReport,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
signature_coverage: Option<SignatureCoverage>,
|
||||||
calibration: SyncCalibrationRecommendation,
|
calibration: SyncCalibrationRecommendation,
|
||||||
verdict: SyncAnalysisVerdict,
|
verdict: SyncAnalysisVerdict,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct SignatureCoverage {
|
||||||
|
expected_event_count: usize,
|
||||||
|
expected_codes: Vec<u32>,
|
||||||
|
paired_event_count: usize,
|
||||||
|
paired_server_event_ids: Vec<usize>,
|
||||||
|
paired_codes: Vec<u32>,
|
||||||
|
missing_event_ids: Vec<usize>,
|
||||||
|
missing_codes: Vec<u32>,
|
||||||
|
unknown_pair_identity_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn main() -> Result<()> {
|
fn main() -> Result<()> {
|
||||||
let args = parse_args(std::env::args().skip(1))?;
|
let args = parse_args(std::env::args().skip(1))?;
|
||||||
@ -27,9 +44,17 @@ fn main() -> Result<()> {
|
|||||||
.with_context(|| format!("analyzing sync capture {}", args.capture_path.display()))?;
|
.with_context(|| format!("analyzing sync capture {}", args.capture_path.display()))?;
|
||||||
let calibration = report.calibration_recommendation();
|
let calibration = report.calibration_recommendation();
|
||||||
let verdict = report.verdict();
|
let verdict = report.verdict();
|
||||||
let human_report = format_human_report(&args.capture_path, &report, &calibration, &verdict);
|
let signature_coverage = signature_coverage(&args.options.event_width_codes, &report);
|
||||||
|
let human_report = format_human_report(
|
||||||
|
&args.capture_path,
|
||||||
|
&report,
|
||||||
|
signature_coverage.as_ref(),
|
||||||
|
&calibration,
|
||||||
|
&verdict,
|
||||||
|
);
|
||||||
let output = SyncAnalyzeOutput {
|
let output = SyncAnalyzeOutput {
|
||||||
report: &report,
|
report: &report,
|
||||||
|
signature_coverage,
|
||||||
calibration,
|
calibration,
|
||||||
verdict,
|
verdict,
|
||||||
};
|
};
|
||||||
@ -226,6 +251,7 @@ fn parse_analysis_seconds(raw: &str, label: &str) -> Result<f64> {
|
|||||||
fn format_human_report(
|
fn format_human_report(
|
||||||
capture_path: &std::path::Path,
|
capture_path: &std::path::Path,
|
||||||
report: &SyncAnalysisReport,
|
report: &SyncAnalysisReport,
|
||||||
|
signature_coverage: Option<&SignatureCoverage>,
|
||||||
calibration: &SyncCalibrationRecommendation,
|
calibration: &SyncCalibrationRecommendation,
|
||||||
verdict: &SyncAnalysisVerdict,
|
verdict: &SyncAnalysisVerdict,
|
||||||
) -> String {
|
) -> String {
|
||||||
@ -246,6 +272,7 @@ fn format_human_report(
|
|||||||
} else {
|
} else {
|
||||||
"reported only; ignored for verdict/calibration because it disagrees with paired pulses"
|
"reported only; ignored for verdict/calibration because it disagrees with paired pulses"
|
||||||
};
|
};
|
||||||
|
let signature_coverage = format_signature_coverage(signature_coverage);
|
||||||
format!(
|
format!(
|
||||||
"\
|
"\
|
||||||
A/V sync report for {capture}
|
A/V sync report for {capture}
|
||||||
@ -264,6 +291,7 @@ A/V sync report for {capture}
|
|||||||
- paired window first video/audio: {paired_video:.3} s / {paired_audio:.3} s
|
- paired window first video/audio: {paired_video:.3} s / {paired_audio:.3} s
|
||||||
- unpaired video onsets: {unpaired_video}
|
- unpaired video onsets: {unpaired_video}
|
||||||
- unpaired audio onsets: {unpaired_audio}
|
- unpaired audio onsets: {unpaired_audio}
|
||||||
|
{signature_coverage}\
|
||||||
- first skew: {first_skew:+.1} ms (audio after video is positive)
|
- first skew: {first_skew:+.1} ms (audio after video is positive)
|
||||||
- last skew: {last_skew:+.1} ms
|
- last skew: {last_skew:+.1} ms
|
||||||
- mean skew: {mean_skew:+.1} ms
|
- mean skew: {mean_skew:+.1} ms
|
||||||
@ -297,6 +325,7 @@ A/V sync report for {capture}
|
|||||||
paired_audio = first_paired_audio,
|
paired_audio = first_paired_audio,
|
||||||
unpaired_video = unpaired_video,
|
unpaired_video = unpaired_video,
|
||||||
unpaired_audio = unpaired_audio,
|
unpaired_audio = unpaired_audio,
|
||||||
|
signature_coverage = signature_coverage,
|
||||||
first_skew = report.first_skew_ms,
|
first_skew = report.first_skew_ms,
|
||||||
last_skew = report.last_skew_ms,
|
last_skew = report.last_skew_ms,
|
||||||
mean_skew = report.mean_skew_ms,
|
mean_skew = report.mean_skew_ms,
|
||||||
@ -310,6 +339,69 @@ A/V sync report for {capture}
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn signature_coverage(
|
||||||
|
expected_codes: &[u32],
|
||||||
|
report: &SyncAnalysisReport,
|
||||||
|
) -> Option<SignatureCoverage> {
|
||||||
|
if expected_codes.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let paired_server_event_ids = report
|
||||||
|
.paired_events
|
||||||
|
.iter()
|
||||||
|
.filter_map(|event| event.server_event_id)
|
||||||
|
.collect::<BTreeSet<_>>()
|
||||||
|
.into_iter()
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let paired_codes = paired_server_event_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|index| expected_codes.get(*index).copied())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let missing_event_ids = expected_codes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, _)| (!paired_server_event_ids.contains(&index)).then_some(index))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let missing_codes = missing_event_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|index| expected_codes.get(*index).copied())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let unknown_pair_identity_count = report
|
||||||
|
.paired_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| event.server_event_id.is_none() || event.event_code.is_none())
|
||||||
|
.count();
|
||||||
|
Some(SignatureCoverage {
|
||||||
|
expected_event_count: expected_codes.len(),
|
||||||
|
expected_codes: expected_codes.to_vec(),
|
||||||
|
paired_event_count: paired_server_event_ids.len(),
|
||||||
|
paired_server_event_ids,
|
||||||
|
paired_codes,
|
||||||
|
missing_event_ids,
|
||||||
|
missing_codes,
|
||||||
|
unknown_pair_identity_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn format_signature_coverage(coverage: Option<&SignatureCoverage>) -> String {
|
||||||
|
let Some(coverage) = coverage else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let missing_ids = format_usize_list(&coverage.missing_event_ids);
|
||||||
|
let missing_codes = format_u32_list(&coverage.missing_codes);
|
||||||
|
format!(
|
||||||
|
"- expected coded signatures: {}\n- paired coded signatures: {}/{}\n- missing paired signature ids: {}\n- missing paired signature codes: {}\n- paired signatures without identity: {}\n",
|
||||||
|
coverage.expected_event_count,
|
||||||
|
coverage.paired_event_count,
|
||||||
|
coverage.expected_event_count,
|
||||||
|
missing_ids,
|
||||||
|
missing_codes,
|
||||||
|
coverage.unknown_pair_identity_count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn unpaired_video_onsets(report: &SyncAnalysisReport) -> Vec<f64> {
|
fn unpaired_video_onsets(report: &SyncAnalysisReport) -> Vec<f64> {
|
||||||
unpaired_onsets(
|
unpaired_onsets(
|
||||||
@ -365,6 +457,30 @@ fn format_onset_list(onsets: &[f64]) -> String {
|
|||||||
formatted.join(", ")
|
formatted.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn format_usize_list(values: &[usize]) -> String {
|
||||||
|
if values.is_empty() {
|
||||||
|
return "none".to_string();
|
||||||
|
}
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn format_u32_list(values: &[u32]) -> String {
|
||||||
|
if values.is_empty() {
|
||||||
|
return "none".to_string();
|
||||||
|
}
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.map(|value| value.to_string())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn write_report_dir(
|
fn write_report_dir(
|
||||||
report_dir: &std::path::Path,
|
report_dir: &std::path::Path,
|
||||||
@ -386,16 +502,34 @@ fn write_report_dir(
|
|||||||
|
|
||||||
#[cfg(not(coverage))]
|
#[cfg(not(coverage))]
|
||||||
fn write_events_csv(path: &std::path::Path, report: &SyncAnalysisReport) -> Result<()> {
|
fn write_events_csv(path: &std::path::Path, report: &SyncAnalysisReport) -> Result<()> {
|
||||||
let mut csv = String::from("event_id,video_time_s,audio_time_s,skew_ms,confidence\n");
|
let mut csv = String::from(
|
||||||
|
"event_id,server_event_id,event_code,video_time_s,audio_time_s,skew_ms,confidence\n",
|
||||||
|
);
|
||||||
for event in &report.paired_events {
|
for event in &report.paired_events {
|
||||||
csv.push_str(&format!(
|
csv.push_str(&format!(
|
||||||
"{},{:.9},{:.9},{:.6},{:.6}\n",
|
"{},{},{},{:.9},{:.9},{:.6},{:.6}\n",
|
||||||
event.event_id, event.video_time_s, event.audio_time_s, event.skew_ms, event.confidence
|
event.event_id,
|
||||||
|
optional_usize(event.server_event_id),
|
||||||
|
optional_u32(event.event_code),
|
||||||
|
event.video_time_s,
|
||||||
|
event.audio_time_s,
|
||||||
|
event.skew_ms,
|
||||||
|
event.confidence
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
std::fs::write(path, csv).with_context(|| format!("writing {}", path.display()))
|
std::fs::write(path, csv).with_context(|| format!("writing {}", path.display()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn optional_usize(value: Option<usize>) -> String {
|
||||||
|
value.map(|value| value.to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(coverage))]
|
||||||
|
fn optional_u32(value: Option<u32>) -> String {
|
||||||
|
value.map(|value| value.to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(coverage)]
|
#[cfg(coverage)]
|
||||||
fn main() {}
|
fn main() {}
|
||||||
|
|
||||||
@ -510,6 +644,7 @@ mod tests {
|
|||||||
let text = super::format_human_report(
|
let text = super::format_human_report(
|
||||||
std::path::Path::new("/tmp/capture.webm"),
|
std::path::Path::new("/tmp/capture.webm"),
|
||||||
&report,
|
&report,
|
||||||
|
None,
|
||||||
&calibration,
|
&calibration,
|
||||||
&verdict,
|
&verdict,
|
||||||
);
|
);
|
||||||
@ -520,4 +655,77 @@ mod tests {
|
|||||||
assert!(text.contains("- unpaired video onsets: 9.461s, 13.367s"));
|
assert!(text.contains("- unpaired video onsets: 9.461s, 13.367s"));
|
||||||
assert!(text.contains("- unpaired audio onsets: 9.135s, 13.135s"));
|
assert!(text.contains("- unpaired audio onsets: 9.135s, 13.135s"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signature_coverage_reports_missing_and_unknown_coded_pairs() {
|
||||||
|
let report = SyncAnalysisReport {
|
||||||
|
video_event_count: 3,
|
||||||
|
audio_event_count: 3,
|
||||||
|
paired_event_count: 2,
|
||||||
|
coded_events: true,
|
||||||
|
activity_start_delta_ms: 0.0,
|
||||||
|
raw_first_video_activity_s: 1.0,
|
||||||
|
raw_first_audio_activity_s: 1.0,
|
||||||
|
first_skew_ms: 0.0,
|
||||||
|
last_skew_ms: 0.0,
|
||||||
|
mean_skew_ms: 0.0,
|
||||||
|
median_skew_ms: 0.0,
|
||||||
|
max_abs_skew_ms: 0.0,
|
||||||
|
drift_ms: 0.0,
|
||||||
|
skews_ms: vec![0.0, 0.0],
|
||||||
|
video_onsets_s: vec![1.0, 2.0, 3.0],
|
||||||
|
audio_onsets_s: vec![1.0, 2.0, 3.0],
|
||||||
|
paired_events: vec![
|
||||||
|
SyncEventPair {
|
||||||
|
event_id: 0,
|
||||||
|
server_event_id: Some(0),
|
||||||
|
event_code: Some(1),
|
||||||
|
video_time_s: 1.0,
|
||||||
|
audio_time_s: 1.0,
|
||||||
|
skew_ms: 0.0,
|
||||||
|
confidence: 1.0,
|
||||||
|
},
|
||||||
|
SyncEventPair {
|
||||||
|
event_id: 1,
|
||||||
|
server_event_id: None,
|
||||||
|
event_code: None,
|
||||||
|
video_time_s: 2.0,
|
||||||
|
audio_time_s: 2.0,
|
||||||
|
skew_ms: 0.0,
|
||||||
|
confidence: 0.4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let calibration = SyncCalibrationRecommendation {
|
||||||
|
ready: false,
|
||||||
|
recommended_audio_offset_adjust_us: 0,
|
||||||
|
recommended_video_offset_adjust_us: 0,
|
||||||
|
note: "need more pairs".to_string(),
|
||||||
|
};
|
||||||
|
let verdict = SyncAnalysisVerdict {
|
||||||
|
status: "insufficient_data".to_string(),
|
||||||
|
passed: false,
|
||||||
|
p95_abs_skew_ms: 0.0,
|
||||||
|
max_abs_skew_ms: 0.0,
|
||||||
|
preferred_p95_abs_skew_ms: 35.0,
|
||||||
|
acceptable_p95_abs_skew_ms: 80.0,
|
||||||
|
gross_failure_p95_abs_skew_ms: 250.0,
|
||||||
|
catastrophic_max_abs_skew_ms: 1_000.0,
|
||||||
|
reason: "need more pairs".to_string(),
|
||||||
|
};
|
||||||
|
let coverage = super::signature_coverage(&[1, 2, 3], &report);
|
||||||
|
let text = super::format_human_report(
|
||||||
|
std::path::Path::new("/tmp/capture.webm"),
|
||||||
|
&report,
|
||||||
|
coverage.as_ref(),
|
||||||
|
&calibration,
|
||||||
|
&verdict,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(text.contains("- expected coded signatures: 3"));
|
||||||
|
assert!(text.contains("- paired coded signatures: 1/3"));
|
||||||
|
assert!(text.contains("- missing paired signature ids: 1, 2"));
|
||||||
|
assert!(text.contains("- missing paired signature codes: 2, 3"));
|
||||||
|
assert!(text.contains("- paired signatures without identity: 1"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,24 +38,14 @@ pub fn analyze_capture(
|
|||||||
} else {
|
} else {
|
||||||
let colors = extract_video_colors(capture_path)?;
|
let colors = extract_video_colors(capture_path)?;
|
||||||
let timestamps = reconcile_video_timestamps(raw_timestamps.clone(), colors.len())?;
|
let timestamps = reconcile_video_timestamps(raw_timestamps.clone(), colors.len())?;
|
||||||
match detect_color_coded_video_segments(
|
let segments = detect_color_coded_video_segments(
|
||||||
×tamps,
|
×tamps,
|
||||||
&colors,
|
&colors,
|
||||||
&options.event_width_codes,
|
&options.event_width_codes,
|
||||||
options.pulse_width_s,
|
options.pulse_width_s,
|
||||||
) {
|
)
|
||||||
Ok(segments) => (segments, true),
|
.context("color-coded video pulse detection failed")?;
|
||||||
Err(color_error) => {
|
(segments, true)
|
||||||
let brightness = extract_video_brightness(capture_path)?;
|
|
||||||
let timestamps = reconcile_video_timestamps(raw_timestamps, brightness.len())?;
|
|
||||||
(
|
|
||||||
detect_video_segments(×tamps, &brightness).with_context(|| {
|
|
||||||
format!("color-coded video pulse detection failed: {color_error}")
|
|
||||||
})?,
|
|
||||||
false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let audio_samples = extract_audio_samples(capture_path)?;
|
let audio_samples = extract_audio_samples(capture_path)?;
|
||||||
|
|||||||
@ -441,8 +441,24 @@ fn palette_match_score(r: u8, g: u8, b: u8) -> f64 {
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PALETTE: [(u8, u8, u8); 4] =
|
const PALETTE: [(u8, u8, u8); 16] = [
|
||||||
[(255, 45, 45), (0, 230, 118), (41, 121, 255), (255, 179, 0)];
|
(255, 45, 45),
|
||||||
|
(0, 230, 118),
|
||||||
|
(41, 121, 255),
|
||||||
|
(255, 179, 0),
|
||||||
|
(216, 27, 96),
|
||||||
|
(0, 188, 212),
|
||||||
|
(205, 220, 57),
|
||||||
|
(126, 87, 194),
|
||||||
|
(255, 112, 67),
|
||||||
|
(38, 166, 154),
|
||||||
|
(255, 64, 129),
|
||||||
|
(92, 107, 192),
|
||||||
|
(255, 235, 59),
|
||||||
|
(105, 240, 174),
|
||||||
|
(171, 71, 188),
|
||||||
|
(3, 169, 244),
|
||||||
|
];
|
||||||
let best_distance = PALETTE
|
let best_distance = PALETTE
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(pr, pg, pb)| {
|
.map(|(pr, pg, pb)| {
|
||||||
|
|||||||
@ -257,13 +257,10 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
let filtered_video_segments = filter_segments_to_window(&video_segments, common_window);
|
let filtered_video_segments = filter_segments_to_window(&video_segments, common_window);
|
||||||
let filtered_audio_segments = filter_segments_to_window(&audio_segments, common_window);
|
let filtered_audio_segments = filter_segments_to_window(&audio_segments, common_window);
|
||||||
if filtered_video_segments.is_empty() || filtered_audio_segments.is_empty() {
|
if filtered_video_segments.is_empty() || filtered_audio_segments.is_empty() {
|
||||||
return correlate_segments(
|
bail!(
|
||||||
&video_segments,
|
"coded pulse common window removed one stream entirely; refusing cadence-only fallback for coded proof (video={} audio={} raw activity delta {activity_start_delta_ms:+.1} ms)",
|
||||||
&audio_segments,
|
video_segments.len(),
|
||||||
pulse_period_s,
|
audio_segments.len(),
|
||||||
pulse_width_s,
|
|
||||||
1,
|
|
||||||
max_pair_gap_s,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -389,16 +386,6 @@ pub(crate) fn correlate_coded_segments(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if activity_start_delta_ms.abs() >= 1_000.0 {
|
|
||||||
return correlate_segments(
|
|
||||||
&video_segments,
|
|
||||||
&audio_segments,
|
|
||||||
pulse_period_s,
|
|
||||||
pulse_width_s,
|
|
||||||
1,
|
|
||||||
max_pair_gap_s,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bail!(
|
bail!(
|
||||||
"need at least {MIN_CODED_PAIRS} matching coded pulse pairs; saw {}; raw activity delta was {activity_start_delta_ms:+.1} ms (video={raw_first_video_activity_s:.3}s audio={raw_first_audio_activity_s:.3}s)",
|
"need at least {MIN_CODED_PAIRS} matching coded pulse pairs; saw {}; raw activity delta was {activity_start_delta_ms:+.1} ms (video={raw_first_video_activity_s:.3}s audio={raw_first_audio_activity_s:.3}s)",
|
||||||
pairs.len()
|
pairs.len()
|
||||||
|
|||||||
@ -625,6 +625,38 @@ fn correlate_coded_segments_rejects_nearby_wrong_width_codes() {
|
|||||||
assert!(correlate_coded_segments(&video, &audio, 1.0, 0.12, &codes, 0.2).is_err());
|
assert!(correlate_coded_segments(&video, &audio, 1.0, 0.12, &codes, 0.2).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn correlate_coded_segments_refuses_cadence_fallback_when_windows_do_not_overlap() {
|
||||||
|
fn segment(start_s: f64, code: u32) -> PulseSegment {
|
||||||
|
let duration_s = 0.12 * f64::from(code);
|
||||||
|
PulseSegment {
|
||||||
|
start_s,
|
||||||
|
end_s: start_s + duration_s,
|
||||||
|
duration_s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let codes = [1, 2, 3, 4, 5, 6];
|
||||||
|
let video = codes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(tick, code)| segment(100.0 + tick as f64, *code))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let audio = codes
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(tick, code)| segment(tick as f64, *code))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
let error = correlate_coded_segments(&video, &audio, 1.0, 0.12, &codes, 0.5)
|
||||||
|
.expect_err("coded proof should not fall back to cadence-only pairing");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("refusing cadence-only fallback"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn assert_sync_report_shape(report: &SyncAnalysisReport, paired_events: usize) {
|
fn assert_sync_report_shape(report: &SyncAnalysisReport, paired_events: usize) {
|
||||||
assert_eq!(report.video_event_count, paired_events);
|
assert_eq!(report.video_event_count, paired_events);
|
||||||
assert_eq!(report.audio_event_count, paired_events);
|
assert_eq!(report.audio_event_count, paired_events);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "lesavka_common"
|
name = "lesavka_common"
|
||||||
version = "0.19.20"
|
version = "0.19.21"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@ -89,6 +89,7 @@ REMOTE_PULSE_VIDEO_MODE=${REMOTE_PULSE_VIDEO_MODE:-cfr}
|
|||||||
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
REMOTE_CAPTURE_STACK=${REMOTE_CAPTURE_STACK:-pulse}
|
||||||
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
||||||
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK:-0}
|
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK:-0}
|
||||||
|
REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1}
|
||||||
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}
|
||||||
|
|
||||||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
@ -175,6 +176,7 @@ run_mode_probe() {
|
|||||||
REMOTE_AUDIO_SOURCE="${REMOTE_AUDIO_SOURCE}" \
|
REMOTE_AUDIO_SOURCE="${REMOTE_AUDIO_SOURCE}" \
|
||||||
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK="${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}" \
|
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK="${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}" \
|
||||||
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS="${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}" \
|
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS="${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}" \
|
||||||
|
REMOTE_CAPTURE_READY_SETTLE_SECONDS="${REMOTE_CAPTURE_READY_SETTLE_SECONDS}" \
|
||||||
PROBE_PREBUILD=0 \
|
PROBE_PREBUILD=0 \
|
||||||
VIDEO_SIZE="${width}x${height}" \
|
VIDEO_SIZE="${width}x${height}" \
|
||||||
VIDEO_FPS="${fps}" \
|
VIDEO_FPS="${fps}" \
|
||||||
@ -915,6 +917,7 @@ audio = smoothness.get("audio") or {}
|
|||||||
audio_cadence = audio.get("packet_cadence") or {}
|
audio_cadence = audio.get("packet_cadence") or {}
|
||||||
audio_rms = audio.get("rms_continuity") or {}
|
audio_rms = audio.get("rms_continuity") or {}
|
||||||
verdict = report.get("verdict") or {}
|
verdict = report.get("verdict") or {}
|
||||||
|
signature_coverage = report.get("signature_coverage") or {}
|
||||||
|
|
||||||
sync_pass = verdict.get("passed") is True
|
sync_pass = verdict.get("passed") is True
|
||||||
freshness_status = freshness.get("status", "unknown")
|
freshness_status = freshness.get("status", "unknown")
|
||||||
@ -929,6 +932,9 @@ missing = as_int(video.get("estimated_missing_frames"), 0)
|
|||||||
undecodable = as_int(video.get("undecodable_frames"), 0)
|
undecodable = as_int(video.get("undecodable_frames"), 0)
|
||||||
duplicates = as_int(video.get("duplicate_frames"), 0)
|
duplicates = as_int(video.get("duplicate_frames"), 0)
|
||||||
low_rms = as_int(audio_rms.get("low_rms_window_count"), 0)
|
low_rms = as_int(audio_rms.get("low_rms_window_count"), 0)
|
||||||
|
signature_expected = as_int(signature_coverage.get("expected_event_count"), 0)
|
||||||
|
signature_paired = as_int(signature_coverage.get("paired_event_count"), 0)
|
||||||
|
signature_unknown = as_int(signature_coverage.get("unknown_pair_identity_count"), 0)
|
||||||
pair_confidences = []
|
pair_confidences = []
|
||||||
for event in report.get("paired_events") or []:
|
for event in report.get("paired_events") or []:
|
||||||
if not isinstance(event, dict):
|
if not isinstance(event, dict):
|
||||||
@ -966,6 +972,13 @@ if as_bool(require_sync_raw) and not sync_pass:
|
|||||||
reasons.append(f"sync did not pass: {verdict.get('status', 'unknown')}")
|
reasons.append(f"sync did not pass: {verdict.get('status', 'unknown')}")
|
||||||
if as_bool(require_freshness_raw) and not freshness_pass:
|
if as_bool(require_freshness_raw) and not freshness_pass:
|
||||||
reasons.append(f"freshness did not pass: {freshness_status}")
|
reasons.append(f"freshness did not pass: {freshness_status}")
|
||||||
|
if signature_expected > 0:
|
||||||
|
if signature_paired < signature_expected:
|
||||||
|
reasons.append(f"paired coded signatures {signature_paired} < expected {signature_expected}")
|
||||||
|
if signature_unknown > 0:
|
||||||
|
reasons.append(f"paired signatures without coded identity {signature_unknown} > 0")
|
||||||
|
elif report.get("coded_events") is True:
|
||||||
|
reasons.append("coded signature coverage unavailable")
|
||||||
if video_hiccups > as_int(max_video_hiccups_raw):
|
if video_hiccups > as_int(max_video_hiccups_raw):
|
||||||
reasons.append(f"video hiccups {video_hiccups} > {max_video_hiccups_raw}")
|
reasons.append(f"video hiccups {video_hiccups} > {max_video_hiccups_raw}")
|
||||||
if audio_hiccups > as_int(max_audio_hiccups_raw):
|
if audio_hiccups > as_int(max_audio_hiccups_raw):
|
||||||
@ -1012,6 +1025,11 @@ artifact = {
|
|||||||
"video_event_count": as_int(report.get("video_event_count"), 0),
|
"video_event_count": as_int(report.get("video_event_count"), 0),
|
||||||
"audio_event_count": as_int(report.get("audio_event_count"), 0),
|
"audio_event_count": as_int(report.get("audio_event_count"), 0),
|
||||||
"paired_event_count": as_int(report.get("paired_event_count"), 0),
|
"paired_event_count": as_int(report.get("paired_event_count"), 0),
|
||||||
|
"signature_expected_event_count": signature_expected,
|
||||||
|
"signature_paired_event_count": signature_paired,
|
||||||
|
"signature_missing_event_ids": signature_coverage.get("missing_event_ids") or [],
|
||||||
|
"signature_missing_codes": signature_coverage.get("missing_codes") or [],
|
||||||
|
"signature_unknown_pair_identity_count": signature_unknown,
|
||||||
"paired_confidence_min": min(pair_confidences) if pair_confidences else 0.0,
|
"paired_confidence_min": min(pair_confidences) if pair_confidences else 0.0,
|
||||||
"paired_confidence_median": median(pair_confidences),
|
"paired_confidence_median": median(pair_confidences),
|
||||||
},
|
},
|
||||||
@ -1277,6 +1295,9 @@ for result in results:
|
|||||||
" sync evidence: "
|
" sync evidence: "
|
||||||
f"video_onsets={sync.get('video_event_count', 0)} audio_onsets={sync.get('audio_event_count', 0)} "
|
f"video_onsets={sync.get('video_event_count', 0)} audio_onsets={sync.get('audio_event_count', 0)} "
|
||||||
f"pairs={sync.get('paired_event_count', 0)} "
|
f"pairs={sync.get('paired_event_count', 0)} "
|
||||||
|
f"coded_pairs={sync.get('signature_paired_event_count', 0)}/{sync.get('signature_expected_event_count', 0)} "
|
||||||
|
f"missing_codes={sync.get('signature_missing_codes') or []} "
|
||||||
|
f"unknown_identity={sync.get('signature_unknown_pair_identity_count', 0)} "
|
||||||
f"pair_conf_median={sync.get('paired_confidence_median', 0.0):.3f} "
|
f"pair_conf_median={sync.get('paired_confidence_median', 0.0):.3f} "
|
||||||
f"raw_pair_disagreement={sync.get('activity_pair_disagreement_ms', 0.0):+.1f}ms"
|
f"raw_pair_disagreement={sync.get('activity_pair_disagreement_ms', 0.0):+.1f}ms"
|
||||||
)
|
)
|
||||||
|
|||||||
@ -44,9 +44,10 @@ REMOTE_AUDIO_SOURCE=${REMOTE_AUDIO_SOURCE:-auto}
|
|||||||
REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto}
|
REMOTE_AUDIO_QUIESCE_USER_AUDIO=${REMOTE_AUDIO_QUIESCE_USER_AUDIO:-auto}
|
||||||
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK:-0}
|
REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK:-0}
|
||||||
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}
|
REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}
|
||||||
|
REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1}
|
||||||
ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-0}
|
ANALYSIS_NORMALIZE=${ANALYSIS_NORMALIZE:-0}
|
||||||
ANALYSIS_SCALE_WIDTH=${ANALYSIS_SCALE_WIDTH:-1280}
|
ANALYSIS_SCALE_WIDTH=${ANALYSIS_SCALE_WIDTH:-1280}
|
||||||
ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1}
|
ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-0}
|
||||||
ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS=${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS:-1.0}
|
ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS=${ANALYSIS_TIMELINE_WINDOW_PADDING_SECONDS:-1.0}
|
||||||
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
SSH_OPTS=${SSH_OPTS:-"-o BatchMode=yes -o ConnectTimeout=30"}
|
||||||
PROBE_PREBUILD=${PROBE_PREBUILD:-1}
|
PROBE_PREBUILD=${PROBE_PREBUILD:-1}
|
||||||
@ -1728,12 +1729,6 @@ freshness_min_event_age_ms = min(event_age_min_values) if event_age_min_values e
|
|||||||
if not event_age_p95_values:
|
if not event_age_p95_values:
|
||||||
freshness_status = "unknown"
|
freshness_status = "unknown"
|
||||||
freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available"
|
freshness_reason = "clock-aligned server feed and Tethys capture timestamps were not available"
|
||||||
elif not sync_passed:
|
|
||||||
freshness_status = "unknown"
|
|
||||||
freshness_reason = (
|
|
||||||
"sync did not pass, so freshness from paired signatures is not trustworthy: "
|
|
||||||
f"{sync_verdict.get('status', 'unknown')} - {sync_verdict.get('reason', '')}"
|
|
||||||
)
|
|
||||||
elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms:
|
elif not clock_alignment_available or clock_uncertainty_ms > max_clock_uncertainty_ms:
|
||||||
freshness_status = "unknown"
|
freshness_status = "unknown"
|
||||||
freshness_reason = (
|
freshness_reason = (
|
||||||
@ -1754,6 +1749,12 @@ elif freshness_worst_event_p95_ms < -clock_uncertainty_ms:
|
|||||||
f"worst RC event-age p95 {freshness_worst_event_p95_ms:.1f} ms, uncertainty "
|
f"worst RC event-age p95 {freshness_worst_event_p95_ms:.1f} ms, uncertainty "
|
||||||
f"{clock_uncertainty_ms:.1f} ms"
|
f"{clock_uncertainty_ms:.1f} ms"
|
||||||
)
|
)
|
||||||
|
elif not sync_passed:
|
||||||
|
freshness_status = "unknown"
|
||||||
|
freshness_reason = (
|
||||||
|
"sync did not pass, so freshness from paired signatures is not trustworthy: "
|
||||||
|
f"{sync_verdict.get('status', 'unknown')} - {sync_verdict.get('reason', '')}"
|
||||||
|
)
|
||||||
elif freshness_worst_event_with_uncertainty_ms <= max_freshness_age_ms and (
|
elif freshness_worst_event_with_uncertainty_ms <= max_freshness_age_ms and (
|
||||||
freshness_worst_drift_ms is None or freshness_worst_drift_ms <= max_freshness_drift_ms
|
freshness_worst_drift_ms is None or freshness_worst_drift_ms <= max_freshness_drift_ms
|
||||||
):
|
):
|
||||||
@ -2084,6 +2085,7 @@ ssh ${SSH_OPTS} "${TETHYS_HOST}" bash -s -- \
|
|||||||
"${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" \
|
"${REMOTE_AUDIO_QUIESCE_USER_AUDIO}" \
|
||||||
"${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}" \
|
"${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}" \
|
||||||
"${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \
|
"${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS}" \
|
||||||
|
"${REMOTE_CAPTURE_READY_SETTLE_SECONDS}" \
|
||||||
> >(tee "${LOCAL_CAPTURE_LOG}") \
|
> >(tee "${LOCAL_CAPTURE_LOG}") \
|
||||||
2> >(tee -a "${LOCAL_CAPTURE_LOG}" >&2) <<'REMOTE_CAPTURE_SCRIPT' &
|
2> >(tee -a "${LOCAL_CAPTURE_LOG}" >&2) <<'REMOTE_CAPTURE_SCRIPT' &
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -2100,6 +2102,7 @@ remote_audio_source=${10}
|
|||||||
remote_audio_quiesce_user_audio=${11}
|
remote_audio_quiesce_user_audio=${11}
|
||||||
remote_capture_allow_alsa_fallback=${12}
|
remote_capture_allow_alsa_fallback=${12}
|
||||||
remote_capture_preroll_discard_seconds=${13}
|
remote_capture_preroll_discard_seconds=${13}
|
||||||
|
remote_capture_ready_settle_seconds=${14}
|
||||||
|
|
||||||
rm -f "${remote_capture}"
|
rm -f "${remote_capture}"
|
||||||
|
|
||||||
@ -2443,7 +2446,11 @@ gst_decode_chain="$(gst_video_decode_chain)"
|
|||||||
|
|
||||||
run_ffmpeg_capture() {
|
run_ffmpeg_capture() {
|
||||||
local rc=0
|
local rc=0
|
||||||
timeout --kill-after=5 --signal=INT "$((capture_seconds + 5))" "$@" </dev/null || rc=$?
|
announce_capture_start
|
||||||
|
timeout --kill-after=5 --signal=INT "$((capture_seconds + 5))" "$@" </dev/null &
|
||||||
|
local capture_pid=$!
|
||||||
|
signal_capture_ready
|
||||||
|
wait "${capture_pid}" || rc=$?
|
||||||
case "${rc}" in
|
case "${rc}" in
|
||||||
0|124|130)
|
0|124|130)
|
||||||
return 0
|
return 0
|
||||||
@ -2454,6 +2461,25 @@ run_ffmpeg_capture() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
announce_capture_start() {
|
||||||
|
printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
signal_capture_ready() {
|
||||||
|
if [[ "${remote_capture_ready_settle_seconds}" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
|
||||||
|
sleep "${remote_capture_ready_settle_seconds}"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "__LESAVKA_CAPTURE_READY__"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tolerant_capture() {
|
||||||
|
announce_capture_start
|
||||||
|
"$@" &
|
||||||
|
local capture_pid=$!
|
||||||
|
signal_capture_ready
|
||||||
|
wait "${capture_pid}" || true
|
||||||
|
}
|
||||||
|
|
||||||
quiesce_for_alsa=0
|
quiesce_for_alsa=0
|
||||||
case "${remote_audio_quiesce_user_audio}" in
|
case "${remote_audio_quiesce_user_audio}" in
|
||||||
1|true|yes)
|
1|true|yes)
|
||||||
@ -2525,28 +2551,31 @@ discard_preroll_capture() {
|
|||||||
|
|
||||||
discard_preroll_capture "${remote_capture_preroll_discard_seconds}"
|
discard_preroll_capture "${remote_capture_preroll_discard_seconds}"
|
||||||
|
|
||||||
printf '%s\n' "__LESAVKA_CAPTURE_READY__"
|
|
||||||
printf 'capture_start_unix_ns=%s\n' "$(date +%s%N)" >&2
|
|
||||||
|
|
||||||
if [[ "${capture_mode}" == "pwpipe" ]]; then
|
if [[ "${capture_mode}" == "pwpipe" ]]; then
|
||||||
printf 'using PipeWire-native mux capture target serial: %s\n' "${pw_audio_target}" >&2
|
printf 'using PipeWire-native mux capture target serial: %s\n' "${pw_audio_target}" >&2
|
||||||
timeout "${capture_seconds}" pw-record \
|
announce_capture_start
|
||||||
--target "${pw_audio_target}" \
|
(
|
||||||
--rate 48000 \
|
timeout "${capture_seconds}" pw-record \
|
||||||
--channels 2 \
|
--target "${pw_audio_target}" \
|
||||||
--format s16 \
|
--rate 48000 \
|
||||||
--raw - \
|
--channels 2 \
|
||||||
| pw-v4l2 ffmpeg -hide_banner -loglevel error -y \
|
--format s16 \
|
||||||
-thread_queue_size 1024 \
|
--raw - \
|
||||||
"${video_args[@]}" \
|
| pw-v4l2 ffmpeg -hide_banner -loglevel error -y \
|
||||||
-i "${resolved_video_device}" \
|
-thread_queue_size 1024 \
|
||||||
-thread_queue_size 1024 \
|
"${video_args[@]}" \
|
||||||
-f s16le -ar 48000 -ac 2 \
|
-i "${resolved_video_device}" \
|
||||||
-i pipe:0 \
|
-thread_queue_size 1024 \
|
||||||
-t "${capture_seconds}" \
|
-f s16le -ar 48000 -ac 2 \
|
||||||
-c:v copy \
|
-i pipe:0 \
|
||||||
-c:a pcm_s16le \
|
-t "${capture_seconds}" \
|
||||||
"${remote_capture}"
|
-c:v copy \
|
||||||
|
-c:a pcm_s16le \
|
||||||
|
"${remote_capture}"
|
||||||
|
) &
|
||||||
|
capture_pid=$!
|
||||||
|
signal_capture_ready
|
||||||
|
wait "${capture_pid}"
|
||||||
elif [[ "${capture_mode}" == "pulse" ]]; then
|
elif [[ "${capture_mode}" == "pulse" ]]; then
|
||||||
printf 'using Pulse source: %s\n' "${pulse_source}" >&2
|
printf 'using Pulse source: %s\n' "${pulse_source}" >&2
|
||||||
case "${remote_pulse_capture_tool}" in
|
case "${remote_pulse_capture_tool}" in
|
||||||
@ -2596,7 +2625,7 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2
|
printf 'gst copy mode only supports mjpeg input, got %s\n' "${video_format}" >&2
|
||||||
exit 64
|
exit 64
|
||||||
fi
|
fi
|
||||||
timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
run_tolerant_capture timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
||||||
gst-launch-1.0 -q -e \
|
gst-launch-1.0 -q -e \
|
||||||
matroskamux name=mux ! filesink location="${remote_capture}" \
|
matroskamux name=mux ! filesink location="${remote_capture}" \
|
||||||
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
||||||
@ -2605,10 +2634,10 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
||||||
audio/x-raw,rate=48000,channels=2 ! \
|
audio/x-raw,rate=48000,channels=2 ! \
|
||||||
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
||||||
queue ! mux. || true
|
queue ! mux.
|
||||||
;;
|
;;
|
||||||
cfr)
|
cfr)
|
||||||
timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
run_tolerant_capture timeout --kill-after=5 --signal=INT "$((capture_seconds + 3))" \
|
||||||
gst-launch-1.0 -q -e \
|
gst-launch-1.0 -q -e \
|
||||||
matroskamux name=mux ! filesink location="${remote_capture}" \
|
matroskamux name=mux ! filesink location="${remote_capture}" \
|
||||||
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
v4l2src device="${resolved_video_device}" do-timestamp=true ! \
|
||||||
@ -2621,7 +2650,7 @@ elif [[ "${capture_mode}" == "pulse" ]]; then
|
|||||||
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
pulsesrc device="${pulse_source}" do-timestamp=true ! \
|
||||||
audio/x-raw,rate=48000,channels=2 ! \
|
audio/x-raw,rate=48000,channels=2 ! \
|
||||||
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! \
|
||||||
queue ! mux. || true
|
queue ! mux.
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
printf 'unsupported REMOTE_PULSE_VIDEO_MODE=%s\n' "${remote_pulse_video_mode}" >&2
|
printf 'unsupported REMOTE_PULSE_VIDEO_MODE=%s\n' "${remote_pulse_video_mode}" >&2
|
||||||
|
|||||||
@ -10,7 +10,7 @@ bench = false
|
|||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "lesavka_server"
|
name = "lesavka_server"
|
||||||
version = "0.19.20"
|
version = "0.19.21"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
autobins = false
|
autobins = false
|
||||||
|
|
||||||
|
|||||||
@ -49,7 +49,7 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"LESAVKA_OUTPUT_DELAY_SAVE=${LESAVKA_OUTPUT_DELAY_SAVE:-0}",
|
"LESAVKA_OUTPUT_DELAY_SAVE=${LESAVKA_OUTPUT_DELAY_SAVE:-0}",
|
||||||
"LESAVKA_OUTPUT_REQUIRE_SYNC_PASS=${LESAVKA_OUTPUT_REQUIRE_SYNC_PASS:-0}",
|
"LESAVKA_OUTPUT_REQUIRE_SYNC_PASS=${LESAVKA_OUTPUT_REQUIRE_SYNC_PASS:-0}",
|
||||||
"LESAVKA_OUTPUT_DELAY_TARGET=${LESAVKA_OUTPUT_DELAY_TARGET:-video}",
|
"LESAVKA_OUTPUT_DELAY_TARGET=${LESAVKA_OUTPUT_DELAY_TARGET:-video}",
|
||||||
"LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${LESAVKA_OUTPUT_DELAY_MIN_PAIRS:-8}",
|
"LESAVKA_OUTPUT_DELAY_MIN_PAIRS=${LESAVKA_OUTPUT_DELAY_MIN_PAIRS:-13}",
|
||||||
"LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS=${LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS:-5000}",
|
"LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS=${LESAVKA_OUTPUT_DELAY_MAX_ABS_SKEW_MS:-5000}",
|
||||||
"LESAVKA_OUTPUT_DELAY_MAX_DRIFT_MS=${LESAVKA_OUTPUT_DELAY_MAX_DRIFT_MS:-80}",
|
"LESAVKA_OUTPUT_DELAY_MAX_DRIFT_MS=${LESAVKA_OUTPUT_DELAY_MAX_DRIFT_MS:-80}",
|
||||||
"LESAVKA_OUTPUT_DELAY_MAX_STEP_US=${LESAVKA_OUTPUT_DELAY_MAX_STEP_US:-1500000}",
|
"LESAVKA_OUTPUT_DELAY_MAX_STEP_US=${LESAVKA_OUTPUT_DELAY_MAX_STEP_US:-1500000}",
|
||||||
@ -57,6 +57,11 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}",
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_DRIFT_MS:-100}",
|
||||||
"LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}",
|
"LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS=${LESAVKA_OUTPUT_FRESHNESS_MAX_CLOCK_UNCERTAINTY_MS:-250}",
|
||||||
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}",
|
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=${REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS:-0}",
|
||||||
|
"REMOTE_CAPTURE_READY_SETTLE_SECONDS=${REMOTE_CAPTURE_READY_SETTLE_SECONDS:-1}",
|
||||||
|
"remote_capture_ready_settle_seconds=${14}",
|
||||||
|
"announce_capture_start",
|
||||||
|
"signal_capture_ready",
|
||||||
|
"run_tolerant_capture",
|
||||||
"discarding %ss of post-enumeration capture before probe",
|
"discarding %ss of post-enumeration capture before probe",
|
||||||
"ffmpeg -nostdin -hide_banner",
|
"ffmpeg -nostdin -hide_banner",
|
||||||
"\"$@\" </dev/null",
|
"\"$@\" </dev/null",
|
||||||
@ -141,7 +146,7 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"calibration_active_video_offset_us",
|
"calibration_active_video_offset_us",
|
||||||
"absolute_target_video_offset_us",
|
"absolute_target_video_offset_us",
|
||||||
"calibration_apply_video_delta_us",
|
"calibration_apply_video_delta_us",
|
||||||
"PROBE_EVENT_WIDTH_CODES=${PROBE_EVENT_WIDTH_CODES:-1,2,1,3",
|
"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}\"",
|
||||||
"\"${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US}\"",
|
"\"${LESAVKA_OUTPUT_DELAY_PROBE_VIDEO_DELAY_US}\"",
|
||||||
"REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}",
|
"REMOTE_PULSE_CAPTURE_TOOL=${REMOTE_PULSE_CAPTURE_TOOL:-gst}",
|
||||||
@ -153,7 +158,7 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() {
|
|||||||
"PROBE_START_GRACE_SECONDS=${PROBE_START_GRACE_SECONDS:-20}",
|
"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))}",
|
||||||
"CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))}",
|
"CAPTURE_SECONDS=${CAPTURE_SECONDS:-$((PROBE_DURATION_SECONDS + PROBE_WARMUP_SECONDS + PROBE_START_GRACE_SECONDS + LEAD_IN_SECONDS + TAIL_SECONDS))}",
|
||||||
"ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-1}",
|
"ANALYSIS_TIMELINE_WINDOW=${ANALYSIS_TIMELINE_WINDOW:-0}",
|
||||||
"compute_analysis_window_arg",
|
"compute_analysis_window_arg",
|
||||||
"analyzer timeline window:",
|
"analyzer timeline window:",
|
||||||
"output-delay-probe",
|
"output-delay-probe",
|
||||||
@ -239,7 +244,7 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
|||||||
"LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}",
|
"LESAVKA_SERVER_RC_PROBE_PREBUILD=${LESAVKA_SERVER_RC_PROBE_PREBUILD:-1}",
|
||||||
"LESAVKA_SERVER_RC_TUNE_DELAYS=${LESAVKA_SERVER_RC_TUNE_DELAYS:-1}",
|
"LESAVKA_SERVER_RC_TUNE_DELAYS=${LESAVKA_SERVER_RC_TUNE_DELAYS:-1}",
|
||||||
"LESAVKA_SERVER_RC_TUNE_CONFIRM=${LESAVKA_SERVER_RC_TUNE_CONFIRM:-1}",
|
"LESAVKA_SERVER_RC_TUNE_CONFIRM=${LESAVKA_SERVER_RC_TUNE_CONFIRM:-1}",
|
||||||
"LESAVKA_SERVER_RC_TUNE_MIN_PAIRS=${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS:-8}",
|
"LESAVKA_SERVER_RC_TUNE_MIN_PAIRS=${LESAVKA_SERVER_RC_TUNE_MIN_PAIRS:-13}",
|
||||||
"LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS=${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS:-1000}",
|
"LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS=${LESAVKA_SERVER_RC_TUNE_MAX_ABS_SKEW_MS:-1000}",
|
||||||
"LESAVKA_SERVER_RC_TUNE_MAX_STEP_US=${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US:-500000}",
|
"LESAVKA_SERVER_RC_TUNE_MAX_STEP_US=${LESAVKA_SERVER_RC_TUNE_MAX_STEP_US:-500000}",
|
||||||
"LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US=${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US:-5000}",
|
"LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US=${LESAVKA_SERVER_RC_TUNE_MIN_CHANGE_US:-5000}",
|
||||||
@ -283,11 +288,15 @@ fn server_rc_mode_matrix_validates_advertised_uvc_profiles() {
|
|||||||
"calibration_video_target_offset_us",
|
"calibration_video_target_offset_us",
|
||||||
"calibration_audio_target_offset_us",
|
"calibration_audio_target_offset_us",
|
||||||
"calibration:",
|
"calibration:",
|
||||||
|
"signature_coverage",
|
||||||
|
"paired coded signatures",
|
||||||
|
"signature_missing_codes",
|
||||||
"REMOTE_PULSE_CAPTURE_TOOL=\"${REMOTE_PULSE_CAPTURE_TOOL}\"",
|
"REMOTE_PULSE_CAPTURE_TOOL=\"${REMOTE_PULSE_CAPTURE_TOOL}\"",
|
||||||
"REMOTE_PULSE_VIDEO_MODE=\"${REMOTE_PULSE_VIDEO_MODE}\"",
|
"REMOTE_PULSE_VIDEO_MODE=\"${REMOTE_PULSE_VIDEO_MODE}\"",
|
||||||
"REMOTE_CAPTURE_STACK=\"${REMOTE_CAPTURE_STACK}\"",
|
"REMOTE_CAPTURE_STACK=\"${REMOTE_CAPTURE_STACK}\"",
|
||||||
"REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=\"${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}\"",
|
"REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK=\"${REMOTE_CAPTURE_ALLOW_ALSA_FALLBACK}\"",
|
||||||
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=\"${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}\"",
|
"REMOTE_CAPTURE_PREROLL_DISCARD_SECONDS=\"${LESAVKA_SERVER_RC_PREROLL_DISCARD_SECONDS}\"",
|
||||||
|
"REMOTE_CAPTURE_READY_SETTLE_SECONDS=\"${REMOTE_CAPTURE_READY_SETTLE_SECONDS}\"",
|
||||||
"PROBE_PREBUILD=0",
|
"PROBE_PREBUILD=0",
|
||||||
"VIDEO_SIZE=\"${width}x${height}\"",
|
"VIDEO_SIZE=\"${width}x${height}\"",
|
||||||
"VIDEO_FPS=\"${fps}\"",
|
"VIDEO_FPS=\"${fps}\"",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user