@@ -13,7 +13,7 @@ use livekit_api::access_token;
1313use log:: { debug, info} ;
1414use parking_lot:: Mutex ;
1515use std:: {
16- collections:: HashMap ,
16+ collections:: { HashMap , VecDeque } ,
1717 env,
1818 sync:: OnceLock ,
1919 sync:: {
@@ -24,6 +24,7 @@ use std::{
2424} ;
2525
2626mod codec_display;
27+ mod user_data;
2728mod subscriber_timing;
2829mod viewport_aspect;
2930
@@ -946,7 +947,7 @@ async fn handle_track_subscribed(
946947 if drained_frames > 0 {
947948 debug ! ( "Dropped {drained_frames} stale decoded frames before render upload" ) ;
948949 }
949- if let Some ( metadata) = frame. frame_metadata {
950+ if let Some ( metadata) = & frame. frame_metadata {
950951 if let Some ( capture_timestamp_us) = metadata. user_timestamp {
951952 subscriber_timing_sink. record_frame_received_by_sink (
952953 capture_timestamp_us,
@@ -1120,6 +1121,81 @@ fn subscriber_overlay_lines(
11201121 Some ( lines)
11211122}
11221123
1124+ /// Render a live line graph of the six decoded channel values (top-right overlay).
1125+ /// Each trace is normalized so ±`VALUE_RANGE` spans the plot height.
1126+ fn paint_channel_graph ( ctx : & egui:: Context , history : & VecDeque < [ f32 ; user_data:: NUM_CHANNELS ] > ) {
1127+ if history. is_empty ( ) {
1128+ return ;
1129+ }
1130+ let latest = * history. back ( ) . unwrap ( ) ;
1131+
1132+ egui:: Area :: new ( "channel_graph" . into ( ) )
1133+ . anchor ( egui:: Align2 :: RIGHT_TOP , egui:: vec2 ( -10.0 , 10.0 ) )
1134+ . interactable ( false )
1135+ . show ( ctx, |ui| {
1136+ egui:: Frame :: NONE
1137+ . fill ( egui:: Color32 :: from_black_alpha ( 180 ) )
1138+ . corner_radius ( egui:: CornerRadius :: same ( 4 ) )
1139+ . inner_margin ( egui:: Margin :: same ( 8 ) )
1140+ . show ( ui, |ui| {
1141+ let plot_size = egui:: vec2 ( 360.0 , 160.0 ) ;
1142+ // Pin the panel to the plot width so the legend wraps within it
1143+ // instead of stretching the frame wider than the graph.
1144+ ui. set_max_width ( plot_size. x ) ;
1145+
1146+ ui. label (
1147+ egui:: RichText :: new ( "user_data channels" )
1148+ . monospace ( )
1149+ . size ( 12.0 )
1150+ . color ( egui:: Color32 :: WHITE ) ,
1151+ ) ;
1152+
1153+ let ( rect, _) = ui. allocate_exact_size ( plot_size, egui:: Sense :: hover ( ) ) ;
1154+ let painter = ui. painter_at ( rect) ;
1155+
1156+ // Zero axis.
1157+ painter. hline (
1158+ rect. x_range ( ) ,
1159+ rect. center ( ) . y ,
1160+ egui:: Stroke :: new ( 1.0 , egui:: Color32 :: from_gray ( 90 ) ) ,
1161+ ) ;
1162+
1163+ let n = history. len ( ) ;
1164+ let denom = ( n. saturating_sub ( 1 ) ) . max ( 1 ) as f32 ;
1165+ let half_h = rect. height ( ) / 2.0 - 2.0 ;
1166+ for j in 0 ..user_data:: NUM_CHANNELS {
1167+ let points: Vec < egui:: Pos2 > = history
1168+ . iter ( )
1169+ . enumerate ( )
1170+ . map ( |( i, sample) | {
1171+ let x = rect. left ( ) + ( i as f32 / denom) * rect. width ( ) ;
1172+ let norm = ( sample[ j] / user_data:: VALUE_RANGE )
1173+ . clamp ( -1.0 , 1.0 ) ;
1174+ let y = rect. center ( ) . y - norm * half_h;
1175+ egui:: pos2 ( x, y)
1176+ } )
1177+ . collect ( ) ;
1178+ painter. add ( egui:: Shape :: line (
1179+ points,
1180+ egui:: Stroke :: new ( 1.5 , CHANNEL_COLORS [ j] ) ,
1181+ ) ) ;
1182+ }
1183+
1184+ // Legend: current value per channel.
1185+ ui. horizontal_wrapped ( |ui| {
1186+ for ( j, value) in latest. iter ( ) . enumerate ( ) {
1187+ ui. label (
1188+ egui:: RichText :: new ( format ! ( "CH{}: {:>+6.2}" , j + 1 , value) )
1189+ . monospace ( )
1190+ . size ( 11.0 )
1191+ . color ( CHANNEL_COLORS [ j] ) ,
1192+ ) ;
1193+ }
1194+ } ) ;
1195+ } ) ;
1196+ } ) ;
1197+ }
1198+
11231199fn paint_subscriber_overlay ( ctx : & egui:: Context , lines : & [ String ] ) {
11241200 egui:: Area :: new ( "subscriber_overlay" . into ( ) )
11251201 . anchor ( egui:: Align2 :: LEFT_TOP , egui:: vec2 ( 10.0 , 10.0 ) )
@@ -1182,6 +1258,19 @@ fn handle_track_unpublished(
11821258 clear_hud_and_simulcast ( shared, frame_slot, video_size, simulcast, subscriber_timing) ;
11831259}
11841260
1261+ /// Number of channel samples retained for the live graph (~10s at 30fps).
1262+ const CHANNEL_HISTORY_LEN : usize = 300 ;
1263+
1264+ /// Distinct colors for the six channel traces.
1265+ const CHANNEL_COLORS : [ egui:: Color32 ; user_data:: NUM_CHANNELS ] = [
1266+ egui:: Color32 :: from_rgb ( 0xef , 0x53 , 0x50 ) , // red
1267+ egui:: Color32 :: from_rgb ( 0xff , 0xa7 , 0x26 ) , // orange
1268+ egui:: Color32 :: from_rgb ( 0xff , 0xee , 0x58 ) , // yellow
1269+ egui:: Color32 :: from_rgb ( 0x66 , 0xbb , 0x6a ) , // green
1270+ egui:: Color32 :: from_rgb ( 0x42 , 0xa5 , 0xf5 ) , // blue
1271+ egui:: Color32 :: from_rgb ( 0xab , 0x47 , 0xbc ) , // purple
1272+ ] ;
1273+
11851274struct VideoApp {
11861275 shared : Arc < Mutex < SharedYuv > > ,
11871276 frame_slot : Arc < LatestRenderFrameSlot > ,
@@ -1192,6 +1281,8 @@ struct VideoApp {
11921281 ctrl_c_received : Arc < AtomicBool > ,
11931282 viewport : AspectConstrainedViewport ,
11941283 display_timestamp : bool ,
1284+ /// Rolling history of decoded channel values from the user_data trailer.
1285+ channel_history : VecDeque < [ f32 ; user_data:: NUM_CHANNELS ] > ,
11951286}
11961287
11971288impl eframe:: App for VideoApp {
@@ -1208,14 +1299,21 @@ impl eframe::App for VideoApp {
12081299
12091300 let render_frame = self . frame_slot . take ( ) ;
12101301 if let Some ( frame) = render_frame. as_ref ( ) {
1211- if let Some ( metadata) = frame. frame_metadata {
1302+ if let Some ( metadata) = & frame. frame_metadata {
12121303 if let Some ( capture_timestamp_us) = metadata. user_timestamp {
12131304 self . subscriber_timing . record_frame_selected_for_render (
12141305 capture_timestamp_us,
12151306 metadata. frame_id ,
12161307 current_timestamp_us ( ) ,
12171308 ) ;
12181309 }
1310+ // Decode the 6 user_data channel values for the live graph.
1311+ if let Some ( values) = metadata. user_data . as_deref ( ) . and_then ( user_data:: decode) {
1312+ if self . channel_history . len ( ) >= CHANNEL_HISTORY_LEN {
1313+ self . channel_history . pop_front ( ) ;
1314+ }
1315+ self . channel_history . push_back ( values) ;
1316+ }
12191317 }
12201318 }
12211319
@@ -1255,6 +1353,8 @@ impl eframe::App for VideoApp {
12551353 paint_subscriber_overlay ( ctx, lines) ;
12561354 }
12571355
1356+ paint_channel_graph ( ctx, & self . channel_history ) ;
1357+
12581358 // Simulcast layer controls: bottom-left overlay
12591359 egui:: Area :: new ( "simulcast_controls" . into ( ) )
12601360 . anchor ( egui:: Align2 :: LEFT_BOTTOM , egui:: vec2 ( 10.0 , -10.0 ) )
@@ -1450,6 +1550,7 @@ async fn run(args: Args, ctrl_c_received: Arc<AtomicBool>) -> Result<()> {
14501550 ctrl_c_received : ctrl_c_received. clone ( ) ,
14511551 viewport,
14521552 display_timestamp : args. display_timestamp ,
1553+ channel_history : VecDeque :: with_capacity ( CHANNEL_HISTORY_LEN ) ,
14531554 } ;
14541555 let native_options = viewport_aspect:: native_options ( None ) ;
14551556 eframe:: run_native (
@@ -1797,8 +1898,8 @@ impl CallbackTrait for YuvPaintCallback {
17971898
17981899 let frame_for_upload = self . render_frame . lock ( ) . take ( ) . map ( |frame| {
17991900 let prepare_timestamp_us = current_timestamp_us ( ) ;
1800- let frame_id = frame. frame_metadata . and_then ( |m| m. frame_id ) ;
1801- let sample = frame. frame_metadata . and_then ( |metadata| {
1901+ let frame_id = frame. frame_metadata . as_ref ( ) . and_then ( |m| m. frame_id ) ;
1902+ let sample = frame. frame_metadata . as_ref ( ) . and_then ( |metadata| {
18021903 metadata. user_timestamp . map ( |capture_timestamp_us| PendingPaintSample {
18031904 frame_id,
18041905 capture_timestamp_us,
0 commit comments