diff --git a/src/host/audio_plan.rs b/src/host/audio_plan.rs index e45d4a1..faf413c 100644 --- a/src/host/audio_plan.rs +++ b/src/host/audio_plan.rs @@ -1020,7 +1020,8 @@ mod tests { const PN_CHIP_FRAMES: usize = 24; const PN_SEQUENCE_CHIPS: usize = 4_096; const PN_WINDOW_CHIPS: usize = 1_024; - const PN_FILE_SECONDS: usize = 8; + const PN_FILE_SECONDS: usize = 12; + const PN_CAPTURE_SECONDS: u64 = 6; const PN_SKIP_FRAMES: usize = PN_SAMPLE_RATE / 2; const DESKTOP_PN_SEED: u64 = 0x1357_9bdf; const REMOTE_PN_SEED: u64 = 0x2468_ace1; @@ -1630,30 +1631,36 @@ mod tests { Ok(errors) } - fn node_ids_named(names: &[&str]) -> Result> { - let objects = pw_dump_objects("the Phase-9 xrun node set")?; + fn matching_node_ids(objects: &[serde_json::Value], names: &[&str]) -> Result> { let wanted = names.iter().copied().collect::>(); + let mut seen_names = HashSet::new(); let found = objects .iter() .filter_map(|object| { let name = object .pointer("/info/props/node.name") .and_then(serde_json::Value::as_str)?; - wanted - .contains(name) - .then(|| object.get("id").and_then(value_u64))? + if !wanted.contains(name) { + return None; + } + let id = object.get("id").and_then(value_u64)?; + seen_names.insert(name); + Some(id) }) .collect::>(); - if found.len() != wanted.len() { - bail!( - "Phase-9 xrun snapshot resolved {} of {} required nodes ({names:?})", - found.len(), - wanted.len() - ); + let mut missing = wanted.difference(&seen_names).copied().collect::>(); + if !missing.is_empty() { + missing.sort_unstable(); + bail!("Phase-9 xrun snapshot did not resolve required node names: {missing:?}"); } Ok(found) } + fn node_ids_named(names: &[&str]) -> Result> { + let objects = pw_dump_objects("the Phase-9 xrun node set")?; + matching_node_ids(&objects, names) + } + fn xrun_delta( before: &HashMap, after: &HashMap, @@ -1671,7 +1678,11 @@ mod tests { .sum() } - async fn record_capture(monitor_name: &str, label: &str) -> Result> { + async fn record_capture_for( + monitor_name: &str, + label: &str, + duration: Duration, + ) -> Result> { let raw = RawCaptureFile::new(label); let output_file = std::fs::File::create(&raw.0).with_context(|| format!("create {}", raw.0.display()))?; @@ -1690,12 +1701,16 @@ mod tests { .stdout(Stdio::from(output_file)) .stderr(Stdio::piped()); let mut recorder = ContainedProcess::spawn(&mut command, "Phase-6 parec recorder")?; - tokio::time::sleep(Duration::from_secs(3)).await; + tokio::time::sleep(duration).await; recorder.ensure_running()?; recorder.stop()?; std::fs::read(&raw.0).with_context(|| format!("read {}", raw.0.display())) } + async fn record_capture(monitor_name: &str, label: &str) -> Result> { + record_capture_for(monitor_name, label, Duration::from_secs(3)).await + } + fn tone_dbfs(raw: &[u8], frequency: f64) -> Result { const SAMPLE_RATE: f64 = 48_000.0; const CHANNELS: usize = 2; @@ -1935,7 +1950,12 @@ mod tests { } let relevant_xrun_nodes = node_ids_named(&xrun_names)?; let xruns_before = pw_top_errors()?; - let raw = record_capture(&monitor_name, &format!("phase9-{}", arm.label)).await?; + let raw = record_capture_for( + &monitor_name, + &format!("phase9-{}", arm.label), + Duration::from_secs(PN_CAPTURE_SECONDS), + ) + .await?; let xruns_after = pw_top_errors()?; // Original routes must still be intact after the measurement, not only @@ -2014,6 +2034,28 @@ mod tests { assert!(max_cyclic_correlation(&vec![0.0; PN_WINDOW_CHIPS], &desktop) == 0.0); } + #[test] + fn phase9_xrun_node_resolution_accepts_duplicate_names_but_not_missing_names() { + let objects = serde_json::json!([ + {"id": 11, "info": {"props": {"node.name": "controlled"}}}, + {"id": 12, "info": {"props": {"node.name": "controlled"}}}, + {"id": 13, "info": {"props": {"node.name": "playback"}}}, + {"id": 14, "info": {"props": {"node.name": "unrelated"}}} + ]); + let objects = objects.as_array().expect("fixture is an array"); + assert_eq!( + matching_node_ids(objects, &["controlled", "playback"]) + .expect("all required names are represented"), + HashSet::from([11, 12, 13]) + ); + assert!( + matching_node_ids(objects, &["controlled", "missing"]) + .unwrap_err() + .to_string() + .contains("missing") + ); + } + /// Phase-9 rig upgrade: the Phase-6 production-path topology is driven by /// two pinned, independently generated PN probes. Every non-overlapping /// half-second window is normalized and correlated separately on left and