diff --git a/AGENTS.md b/AGENTS.md index 4e65056..b73ac71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -120,6 +120,9 @@ path. - [x] Split freshness reporting into fixed sync delay, last-hop device overhead, and total RC target event age so freshness work cannot hide the sync cost. +- [x] Add dense server-generated smoothness evidence on the normal UVC/UAC + path: per-frame video continuity watermark, quiet audio pilot, cadence + jitter, duplicate/missing frame estimates, and low-RMS audio gap counts. - [ ] Keep UI/profile controls authoritative for UVC output profiles beyond `640x480@20`; validate `1280x720@30` and `1920x1080@20/30` after sync is locked. diff --git a/Cargo.lock b/Cargo.lock index 4da5257..7ad72b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.19.9" +version = "0.19.10" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.19.9" +version = "0.19.10" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.19.9" +version = "0.19.10" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index c518efb..60e1a0a 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.19.9" +version = "0.19.10" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 5b2a1ee..d141b45 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.19.9" +version = "0.19.10" edition = "2024" build = "build.rs" diff --git a/scripts/manual/run_upstream_av_sync.sh b/scripts/manual/run_upstream_av_sync.sh index e9bbd0d..87227c0 100755 --- a/scripts/manual/run_upstream_av_sync.sh +++ b/scripts/manual/run_upstream_av_sync.sh @@ -821,6 +821,7 @@ write_output_delay_correlation() { "${LOCAL_OUTPUT_DELAY_CORRELATION_JSON}" \ "${LOCAL_OUTPUT_DELAY_CORRELATION_CSV}" \ "${LOCAL_OUTPUT_DELAY_CORRELATION_TXT}" \ + "${LOCAL_CAPTURE}" \ "${LOCAL_CAPTURE_LOG}" \ "${LOCAL_CLOCK_ALIGNMENT_JSON}" \ "${LESAVKA_OUTPUT_FRESHNESS_MAX_AGE_MS}" \ @@ -830,6 +831,9 @@ import csv import json import math import pathlib +import statistics +import struct +import subprocess import sys ( @@ -838,6 +842,7 @@ import sys output_json_path, output_csv_path, output_txt_path, + capture_path, capture_log_path, clock_alignment_path, max_freshness_age_raw, @@ -879,6 +884,24 @@ def load_json_or_empty(path): return {} +def run_json(command, description): + try: + output = subprocess.check_output(command, stderr=subprocess.PIPE) + except Exception as error: + return {"available": False, "error": f"{description} failed: {error}"} + try: + return json.loads(output) + except Exception as error: + return {"available": False, "error": f"{description} JSON parse failed: {error}"} + + +def run_bytes(command, description): + try: + return subprocess.check_output(command, stderr=subprocess.PIPE) + except Exception as error: + raise RuntimeError(f"{description} failed: {error}") from error + + def parse_capture_start_unix_ns(path): try: lines = pathlib.Path(path).read_text(errors="replace").splitlines() @@ -905,6 +928,25 @@ def percentile(values, pct): return values[lower] * (1.0 - fraction) + values[upper] * fraction +def numeric_stats(values, suffix=""): + values = [value for value in values if value is not None and math.isfinite(value)] + if not values: + return { + "available": False, + "count": 0, + f"median{suffix}": None, + f"p95{suffix}": None, + f"max{suffix}": None, + } + return { + "available": True, + "count": len(values), + f"median{suffix}": percentile(values, 50.0), + f"p95{suffix}": percentile(values, 95.0), + f"max{suffix}": max(values), + } + + def stats(rows, key): values = [row.get(key) for row in rows if row.get(key) is not None] values = [value for value in values if math.isfinite(value)] @@ -994,6 +1036,294 @@ def correlation(rows, left_key, right_key): return sum((x - mean_x) * (y - mean_y) for x, y in pairs) / math.sqrt(denom_x * denom_y) +def ffprobe_frame_timestamps(path): + data = run_json( + [ + "ffprobe", + "-hide_banner", + "-loglevel", + "error", + "-select_streams", + "v:0", + "-show_frames", + "-show_entries", + "frame=best_effort_timestamp_time", + "-of", + "json", + path, + ], + "ffprobe video frames", + ) + return [ + float(frame["best_effort_timestamp_time"]) + for frame in data.get("frames", []) + if frame.get("best_effort_timestamp_time") not in (None, "N/A") + ] + + +def ffprobe_packet_times(path, stream): + data = run_json( + [ + "ffprobe", + "-hide_banner", + "-loglevel", + "error", + "-select_streams", + stream, + "-show_packets", + "-show_entries", + "packet=pts_time,duration_time", + "-of", + "json", + path, + ], + f"ffprobe {stream} packets", + ) + packets = [] + for packet in data.get("packets", []): + try: + pts = float(packet["pts_time"]) + except Exception: + continue + duration = None + try: + duration = float(packet.get("duration_time")) + except Exception: + pass + packets.append((pts, duration)) + return packets + + +def continuity_frame_ids(path): + width = 320 + height = 32 + blocks = 20 + try: + raw = run_bytes( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-i", + path, + "-map", + "0:v:0", + "-vf", + f"crop=iw:32:0:ih-32,scale={width}:{height}:flags=area,format=gray", + "-f", + "rawvideo", + "-pix_fmt", + "gray", + "-", + ], + "ffmpeg video continuity extraction", + ) + except RuntimeError: + return [] + frame_pixels = width * height + if not raw or len(raw) % frame_pixels != 0: + return [] + block_width = width // blocks + ids = [] + for offset in range(0, len(raw), frame_pixels): + frame = raw[offset : offset + frame_pixels] + averages = [] + for block in range(blocks): + x_start = block * block_width + x_end = width if block + 1 == blocks else (block + 1) * block_width + total = 0 + count = 0 + for y in range(height): + row = y * width + for x in range(x_start, x_end): + total += frame[row + x] + count += 1 + averages.append(total / max(1, count)) + white = averages[0] + black = averages[1] + if white < 150 or black > 105 or white - black < 70: + ids.append(None) + continue + threshold = (white + black) / 2.0 + value = 0 + for block in range(2, 18): + value = (value << 1) | int(averages[block] > threshold) + parity = bool(averages[18] > threshold) + inverse = bool(averages[19] > threshold) + if parity == inverse or parity != bool(value.bit_count() & 1): + ids.append(None) + continue + ids.append(value) + return ids + + +def sequence_smoothness(ids): + decoded = [value for value in ids if value is not None] + duplicates = 0 + missing = 0 + regressions = 0 + jumps = [] + previous = None + for value in decoded: + if previous is None: + previous = value + continue + diff = (value - previous) & 0xFFFF + jumps.append(diff) + if diff == 0: + duplicates += 1 + elif 1 < diff < 32768: + missing += diff - 1 + elif diff >= 32768: + regressions += 1 + previous = value + return { + "decoded_frames": len(decoded), + "undecodable_frames": len(ids) - len(decoded), + "unique_frames": len(set(decoded)), + "duplicate_frames": duplicates, + "estimated_missing_frames": missing, + "sequence_regressions": regressions, + "largest_forward_jump": max(jumps) if jumps else 0, + } + + +def interval_smoothness(timestamps, expected_ms, window_start_s=None, window_end_s=None): + if window_start_s is not None and window_end_s is not None: + timestamps = [ts for ts in timestamps if window_start_s <= ts <= window_end_s] + intervals = [ + (right - left) * 1000.0 + for left, right in zip(timestamps, timestamps[1:]) + if right > left + ] + if (expected_ms is None or expected_ms <= 0.0) and intervals: + expected_ms = statistics.median(intervals) + jitter = [abs(value - expected_ms) for value in intervals] if expected_ms else [] + hiccup_threshold = expected_ms * 1.5 if expected_ms else float("inf") + short_threshold = expected_ms * 0.5 if expected_ms else -1.0 + return { + "timestamps": len(timestamps), + "expected_interval_ms": expected_ms, + "interval_stats": numeric_stats(intervals, "_interval_ms"), + "jitter_stats": numeric_stats(jitter, "_jitter_ms"), + "hiccup_count": sum(1 for value in intervals if value > hiccup_threshold), + "short_interval_count": sum(1 for value in intervals if value < short_threshold), + "estimated_missing_by_timestamp": sum( + max(0, round(value / expected_ms) - 1) + for value in intervals + if expected_ms and value > hiccup_threshold + ), + } + + +def audio_rms_smoothness(path, window_start_s, window_end_s): + sample_rate = 48_000 + window_ms = 10 + samples_per_window = sample_rate * window_ms // 1000 + try: + raw = run_bytes( + [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-i", + path, + "-map", + "0:a:0", + "-ac", + "1", + "-ar", + str(sample_rate), + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-", + ], + "ffmpeg audio continuity extraction", + ) + except RuntimeError as error: + return {"available": False, "error": str(error)} + start_sample = max(0, int(window_start_s * sample_rate)) + end_sample = min(len(raw) // 2, int(window_end_s * sample_rate)) + if end_sample <= start_sample: + return {"available": False, "error": "empty audio smoothness window"} + sample_count = end_sample - start_sample + samples = struct.unpack( + f"<{sample_count}h", + raw[start_sample * 2 : end_sample * 2], + ) + rms_values = [] + for index in range(0, len(samples) - samples_per_window + 1, samples_per_window): + chunk = samples[index : index + samples_per_window] + rms_values.append(math.sqrt(sum(value * value for value in chunk) / len(chunk))) + low_threshold = 90.0 + return { + "available": bool(rms_values), + "window_ms": window_ms, + "rms_stats": numeric_stats(rms_values, "_rms"), + "low_rms_window_count": sum(1 for value in rms_values if value < low_threshold), + "low_rms_threshold": low_threshold, + } + + +def analyze_smoothness(path, report, timeline): + camera_fps = as_float(timeline.get("camera_fps"), 0.0) + duration_s = as_float(timeline.get("duration_us"), 0.0) / 1_000_000.0 + warmup_s = as_float(timeline.get("warmup_us"), 0.0) / 1_000_000.0 + paired_times = [ + value + for event in report.get("paired_events", []) + for value in [finite(event.get("video_time_s")), finite(event.get("audio_time_s"))] + if value is not None + ] + if paired_times: + window_start_s = max(0.0, min(paired_times) - warmup_s) + window_end_s = window_start_s + duration_s + else: + window_start_s = None + window_end_s = None + video_timestamps = ffprobe_frame_timestamps(path) + audio_packets = ffprobe_packet_times(path, "a:0") + audio_packet_timestamps = [pts for pts, _duration in audio_packets] + video_ids = continuity_frame_ids(path) + if window_start_s is not None and window_end_s is not None and len(video_ids) == len(video_timestamps): + video_ids = [ + frame_id + for frame_id, timestamp in zip(video_ids, video_timestamps) + if window_start_s <= timestamp <= window_end_s + ] + expected_video_ms = 1000.0 / camera_fps if camera_fps > 0.0 else None + audio_durations = [ + duration * 1000.0 + for _pts, duration in audio_packets + if duration is not None and math.isfinite(duration) and duration > 0.0 + ] + expected_audio_ms = statistics.median(audio_durations) if audio_durations else None + audio_window_end = window_end_s if window_end_s is not None else max(audio_packet_timestamps or [0.0]) + return { + "schema": "lesavka.output-smoothness-summary.v1", + "scope": "captured RC target media cadence and continuity over the server-generated probe window", + "window_start_s": window_start_s, + "window_end_s": window_end_s, + "video": { + **interval_smoothness(video_timestamps, expected_video_ms, window_start_s, window_end_s), + **sequence_smoothness(video_ids), + }, + "audio": { + "packet_cadence": interval_smoothness( + audio_packet_timestamps, + expected_audio_ms, + window_start_s, + window_end_s, + ), + "rms_continuity": audio_rms_smoothness(path, window_start_s or 0.0, audio_window_end), + }, + } + + capture_start_unix_ns = parse_capture_start_unix_ns(capture_log_path) clock_alignment = load_json_or_empty(clock_alignment_path) clock_alignment_available = bool(clock_alignment.get("available")) @@ -1197,6 +1527,8 @@ else: f"(limit {max_freshness_drift_ms:.1f} ms)" ) +smoothness = analyze_smoothness(capture_path, report, timeline) + artifact = { "schema": "lesavka.output-delay-correlation.v1", "report_json": report_path, @@ -1233,6 +1565,7 @@ artifact = { "video_event_age_model": video_event_age_model, "audio_event_age_model": audio_event_age_model, }, + "smoothness": smoothness, "server_observed_correlation": server_observed_correlation, "server_drift_share_of_observed": server_share, "dominant_layer": dominant_layer, @@ -1299,11 +1632,18 @@ lines = [ f"- freshness status: {freshness_status} ({freshness_reason})", f"- clock uncertainty: +/-{clock_uncertainty_ms:.1f} ms", f"- intentional sync delays: audio {audio_delay_ms:+.1f} ms, video {video_delay_ms:+.1f} ms", - f"- device path overhead: video median {video_freshness_stats.get('median_ms') or 0.0:.1f} ms / p95 {video_freshness_stats.get('p95_ms') or 0.0:.1f} ms / max {video_freshness_stats.get('max_ms') or 0.0:.1f} ms; audio median {audio_freshness_stats.get('median_ms') or 0.0:.1f} ms / p95 {audio_freshness_stats.get('p95_ms') or 0.0:.1f} ms / max {audio_freshness_stats.get('max_ms') or 0.0:.1f} ms", + f"- media timestamp path offset: video median {video_freshness_stats.get('median_ms') or 0.0:.1f} ms / p95 {video_freshness_stats.get('p95_ms') or 0.0:.1f} ms / max {video_freshness_stats.get('max_ms') or 0.0:.1f} ms; audio median {audio_freshness_stats.get('median_ms') or 0.0:.1f} ms / p95 {audio_freshness_stats.get('p95_ms') or 0.0:.1f} ms / max {audio_freshness_stats.get('max_ms') or 0.0:.1f} ms", f"- RC target event age: video median {video_event_age_stats.get('median_ms') or 0.0:.1f} ms / p95 {video_event_age_stats.get('p95_ms') or 0.0:.1f} ms / max {video_event_age_stats.get('max_ms') or 0.0:.1f} ms; audio median {audio_event_age_stats.get('median_ms') or 0.0:.1f} ms / p95 {audio_event_age_stats.get('p95_ms') or 0.0:.1f} ms / max {audio_event_age_stats.get('max_ms') or 0.0:.1f} ms", f"- freshness budget: worst RC event-age p95 {(freshness_worst_event_p95_ms or 0.0):.1f} ms + clock uncertainty {clock_uncertainty_ms:.1f} ms = {(freshness_worst_event_with_uncertainty_ms or 0.0):.1f} ms vs limit {max_freshness_age_ms:.1f} ms", f"- video event-age drift: {video_event_age_model.get('drift_ms', 0.0):+.1f} ms over paired events ({video_event_age_model.get('slope_ms_per_s', 0.0):+.3f} ms/s)", f"- audio event-age drift: {audio_event_age_model.get('drift_ms', 0.0):+.1f} ms over paired events ({audio_event_age_model.get('slope_ms_per_s', 0.0):+.3f} ms/s)", + "", + "Output smoothness", + f"- window: {smoothness.get('window_start_s') or 0.0:.3f}s to {smoothness.get('window_end_s') or 0.0:.3f}s", + f"- video cadence: frames {smoothness.get('video', {}).get('timestamps', 0)}, expected interval {(smoothness.get('video', {}).get('expected_interval_ms') or 0.0):.1f} ms, p95 jitter {(smoothness.get('video', {}).get('jitter_stats', {}).get('p95_jitter_ms') or 0.0):.1f} ms, max interval {(smoothness.get('video', {}).get('interval_stats', {}).get('max_interval_ms') or 0.0):.1f} ms, hiccups {smoothness.get('video', {}).get('hiccup_count', 0)}", + f"- video continuity: decoded {smoothness.get('video', {}).get('decoded_frames', 0)}, duplicates {smoothness.get('video', {}).get('duplicate_frames', 0)}, estimated missing {smoothness.get('video', {}).get('estimated_missing_frames', 0)}, undecodable {smoothness.get('video', {}).get('undecodable_frames', 0)}", + f"- audio packet cadence: packets {smoothness.get('audio', {}).get('packet_cadence', {}).get('timestamps', 0)}, p95 jitter {(smoothness.get('audio', {}).get('packet_cadence', {}).get('jitter_stats', {}).get('p95_jitter_ms') or 0.0):.1f} ms, max interval {(smoothness.get('audio', {}).get('packet_cadence', {}).get('interval_stats', {}).get('max_interval_ms') or 0.0):.1f} ms, hiccups {smoothness.get('audio', {}).get('packet_cadence', {}).get('hiccup_count', 0)}", + f"- audio pilot continuity: low-RMS windows {smoothness.get('audio', {}).get('rms_continuity', {}).get('low_rms_window_count', 0)}, median RMS {(smoothness.get('audio', {}).get('rms_continuity', {}).get('rms_stats', {}).get('median_rms') or 0.0):.1f}", ] summary = "\n".join(lines) + "\n" pathlib.Path(output_txt_path).write_text(summary) diff --git a/server/Cargo.toml b/server/Cargo.toml index f0d7221..df461cf 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.19.9" +version = "0.19.10" edition = "2024" autobins = false diff --git a/server/src/output_delay_probe.rs b/server/src/output_delay_probe.rs index 5102ba2..51fa4dc 100644 --- a/server/src/output_delay_probe.rs +++ b/server/src/output_delay_probe.rs @@ -23,7 +23,11 @@ const AUDIO_SAMPLE_RATE: u32 = 48_000; const AUDIO_CHANNELS: usize = 2; const AUDIO_CHUNK_MS: u64 = 10; const AUDIO_AMPLITUDE: f64 = 24_000.0; +const AUDIO_PILOT_AMPLITUDE: f64 = 700.0; +const AUDIO_PILOT_FREQUENCY_HZ: f64 = 180.0; const DARK_FRAME_RGB: Rgb = Rgb { r: 4, g: 8, b: 12 }; +const VIDEO_CONTINUITY_BLOCKS: usize = 20; +const VIDEO_CONTINUITY_DATA_BITS: usize = 16; const EVENT_COLORS: [Rgb; 4] = [ Rgb { r: 255, @@ -342,7 +346,7 @@ pub async fn run_server_output_delay_probe( let frame_step = Duration::from_nanos(1_000_000_000u64 / u64::from(camera.fps.max(1))); let audio_chunk = Duration::from_millis(AUDIO_CHUNK_MS); let samples_per_chunk = ((u64::from(AUDIO_SAMPLE_RATE) * AUDIO_CHUNK_MS) / 1_000) as usize; - let frames = EncodedProbeFrames::new(camera)?; + let frames = EncodedProbeFrames::new(camera, &config, frame_step)?; let server_start_unix_ns = unix_ns_now(); let start = tokio::time::Instant::now(); let mut timeline = OutputDelayProbeTimeline::new(&config, camera, server_start_unix_ns); @@ -402,12 +406,11 @@ pub async fn run_server_output_delay_probe( if frame_active && next_frame_due <= next_audio_due { let pts_us = duration_us(next_frame_pts); let event_slot = config.event_slot_at(next_frame_pts); - let code = event_slot.map(|slot| slot.code); let seq = frame_index.saturating_add(1); relay.feed(VideoPacket { id: 0, pts: pts_us, - data: frames.packet_for_code(code)?.to_vec(), + data: frames.packet_for_frame(frame_index)?.to_vec(), seq, effective_fps: camera.fps, client_capture_pts_us: pts_us, @@ -463,13 +466,12 @@ pub async fn run_server_output_delay_probe( #[cfg(not(coverage))] struct EncodedProbeFrames { - dark: Vec, - events: [Vec; 4], + frames: Vec>, } #[cfg(not(coverage))] impl EncodedProbeFrames { - fn new(camera: &CameraConfig) -> Result { + fn new(camera: &CameraConfig, config: &ProbeConfig, frame_step: Duration) -> Result { if !matches!(camera.codec, CameraCodec::Mjpeg) { bail!( "server-generated output-delay probe currently requires MJPEG UVC output, got {}", @@ -478,28 +480,33 @@ impl EncodedProbeFrames { } let mut encoder = MjpegFrameEncoder::new(camera)?; - let dark = encoder.encode_solid(DARK_FRAME_RGB, 0)?; - let events = [ - encoder.encode_solid(EVENT_COLORS[0], 1)?, - encoder.encode_solid(EVENT_COLORS[1], 2)?, - encoder.encode_solid(EVENT_COLORS[2], 3)?, - encoder.encode_solid(EVENT_COLORS[3], 4)?, - ]; - Ok(Self { dark, events }) + let mut frames = Vec::new(); + let mut frame_index = 0u64; + loop { + let pts = duration_mul(frame_step, frame_index); + if pts > config.duration { + break; + } + let code = config.event_code_at(pts); + frames.push(encoder.encode_probe_frame(probe_color_for_code(code), frame_index)?); + frame_index = frame_index.saturating_add(1); + } + Ok(Self { frames }) } - fn packet_for_code(&self, code: Option) -> Result<&[u8]> { - let Some(code) = code else { - return Ok(&self.dark); - }; - let index = usize::try_from(code.saturating_sub(1)).unwrap_or(usize::MAX); - self.events - .get(index) + fn packet_for_frame(&self, frame_index: u64) -> Result<&[u8]> { + self.frames + .get(usize::try_from(frame_index).unwrap_or(usize::MAX)) .map(Vec::as_slice) - .with_context(|| format!("unsupported event code {code}")) + .with_context(|| format!("missing pre-encoded probe frame {frame_index}")) } } +fn probe_color_for_code(code: Option) -> Rgb { + code.and_then(|code| EVENT_COLORS.get(code.checked_sub(1)? as usize).copied()) + .unwrap_or(DARK_FRAME_RGB) +} + #[cfg(not(coverage))] struct MjpegFrameEncoder { src: gst_app::AppSrc, @@ -582,9 +589,9 @@ impl MjpegFrameEncoder { }) } - fn encode_solid(&mut self, color: Rgb, sequence: u64) -> Result> { + fn encode_probe_frame(&mut self, color: Rgb, sequence: u64) -> Result> { let pts_us = sequence.saturating_mul(self.frame_step_us); - let frame = solid_rgb_frame(self.width, self.height, color); + let frame = probe_rgb_frame(self.width, self.height, color, sequence); let mut buffer = gst::Buffer::from_slice(frame); if let Some(meta) = buffer.get_mut() { let pts = gst::ClockTime::from_useconds(pts_us); @@ -616,16 +623,57 @@ impl Drop for MjpegFrameEncoder { } #[cfg(not(coverage))] -fn solid_rgb_frame(width: usize, height: usize, color: Rgb) -> Vec { +fn probe_rgb_frame(width: usize, height: usize, color: Rgb, sequence: u64) -> Vec { let mut frame = vec![0u8; width.saturating_mul(height).saturating_mul(3)]; for pixel in frame.chunks_exact_mut(3) { pixel[0] = color.r; pixel[1] = color.g; pixel[2] = color.b; } + draw_frame_continuity_watermark(&mut frame, width, height, sequence); frame } +fn draw_frame_continuity_watermark(frame: &mut [u8], width: usize, height: usize, sequence: u64) { + if width < VIDEO_CONTINUITY_BLOCKS || height < 8 { + return; + } + let stripe_height = (height / 18).clamp(8, 48); + let stripe_top = height.saturating_sub(stripe_height); + let block_width = (width / VIDEO_CONTINUITY_BLOCKS).max(1); + let seq = (sequence & 0xffff) as u16; + let parity = (seq.count_ones() & 1) != 0; + for block in 0..VIDEO_CONTINUITY_BLOCKS { + let white = match block { + 0 => true, + 1 => false, + 2..=17 => { + let bit = VIDEO_CONTINUITY_DATA_BITS - 1 - (block - 2); + ((seq >> bit) & 1) != 0 + } + 18 => parity, + _ => !parity, + }; + let value = if white { 255 } else { 0 }; + let x_start = block * block_width; + let x_end = if block + 1 == VIDEO_CONTINUITY_BLOCKS { + width + } else { + ((block + 1) * block_width).min(width) + }; + for y in stripe_top..height { + for x in x_start..x_end { + let offset = (y * width + x) * 3; + if let Some(pixel) = frame.get_mut(offset..offset + 3) { + pixel[0] = value; + pixel[1] = value; + pixel[2] = value; + } + } + } + } +} + fn render_audio_chunk( config: &ProbeConfig, chunk_pts: Duration, @@ -636,14 +684,17 @@ fn render_audio_chunk( Vec::with_capacity(samples_per_chunk * AUDIO_CHANNELS * std::mem::size_of::()); for sample_index in 0..samples_per_chunk { let sample_pts = chunk_pts + duration_mul(sample_step, sample_index as u64); - let sample = config + let pilot_phase = TAU * AUDIO_PILOT_FREQUENCY_HZ * sample_pts.as_secs_f64(); + let pilot = pilot_phase.sin() * AUDIO_PILOT_AMPLITUDE; + let event = config .event_code_at(sample_pts) .and_then(event_frequency_hz) .map(|frequency| { let phase = TAU * frequency * sample_pts.as_secs_f64(); - (phase.sin() * AUDIO_AMPLITUDE) as i16 + phase.sin() * AUDIO_AMPLITUDE }) - .unwrap_or(0); + .unwrap_or(0.0); + let sample = (pilot + event).clamp(f64::from(i16::MIN), f64::from(i16::MAX)) as i16; for _ in 0..AUDIO_CHANNELS { pcm.extend_from_slice(&sample.to_le_bytes()); } @@ -732,7 +783,8 @@ mod tests { let idle = render_audio_chunk(&config, Duration::from_millis(500), 480); assert!(active.iter().any(|byte| *byte != 0)); - assert!(idle.iter().all(|byte| *byte == 0)); + assert!(idle.iter().any(|byte| *byte != 0)); + assert!(rms_i16_le(&active) > rms_i16_le(&idle) * 10.0); } #[test] @@ -792,4 +844,14 @@ mod tests { Some(0.5) ); } + + fn rms_i16_le(bytes: &[u8]) -> f64 { + let samples = bytes + .chunks_exact(2) + .map(|chunk| f64::from(i16::from_le_bytes([chunk[0], chunk[1]]))) + .collect::>(); + let mean_square = + samples.iter().map(|sample| sample * sample).sum::() / samples.len().max(1) as f64; + mean_square.sqrt() + } } diff --git a/testing/tests/client_manual_sync_script_contract.rs b/testing/tests/client_manual_sync_script_contract.rs index 8858aa5..3ac61e3 100644 --- a/testing/tests/client_manual_sync_script_contract.rs +++ b/testing/tests/client_manual_sync_script_contract.rs @@ -80,9 +80,13 @@ fn upstream_sync_script_tunnels_auto_server_addr_through_ssh() { "freshness status", "clock-corrected server feed to Tethys capture event", "intentional sync delays", - "device path overhead", + "media timestamp path offset", "RC target event age", "freshness budget", + "Output smoothness", + "video continuity", + "audio pilot continuity", + "schema\": \"lesavka.output-smoothness-summary.v1\"", "video_freshness_ms", "audio_freshness_ms", "video_event_age_ms",