diff --git a/native-bridge/src/audio/engine.rs b/native-bridge/src/audio/engine.rs index 66bcd83b..3f04769c 100644 --- a/native-bridge/src/audio/engine.rs +++ b/native-bridge/src/audio/engine.rs @@ -67,13 +67,17 @@ impl Default for EngineConfig { } } -/// Real-time audio levels +/// Real-time audio levels (stereo) #[derive(Debug, Clone, Default)] pub struct AudioLevels { - pub input_level: f32, - pub output_level: f32, - pub input_peak: f32, - pub output_peak: f32, + pub input_level_l: f32, + pub input_level_r: f32, + pub output_level_l: f32, + pub output_level_r: f32, + pub input_peak_l: f32, + pub input_peak_r: f32, + pub output_peak_l: f32, + pub output_peak_r: f32, pub remote_levels: Vec<(String, f32)>, pub backing_level: f32, } @@ -944,14 +948,17 @@ impl AudioEngine { stereo_buffer.push(right_sample); } - // Calculate input level - let level = stereo_buffer - .iter() - .map(|s| s.abs()) - .fold(0.0_f32, f32::max); + // Calculate input levels (stereo) - interleaved L/R samples + let (level_l, level_r) = stereo_buffer + .chunks_exact(2) + .fold((0.0_f32, 0.0_f32), |(max_l, max_r), chunk| { + (max_l.max(chunk[0].abs()), max_r.max(chunk[1].abs())) + }); if let Ok(mut lvl) = levels.try_write() { - lvl.input_level = level; - lvl.input_peak = lvl.input_peak.max(level); + lvl.input_level_l = level_l; + lvl.input_level_r = level_r; + lvl.input_peak_l = lvl.input_peak_l.max(level_l); + lvl.input_peak_r = lvl.input_peak_r.max(level_r); } // Stream audio to browser (browser applies effects via Web Audio) @@ -1084,11 +1091,17 @@ impl AudioEngine { *sample = sample.tanh(); } - // Update output level for metering - let level = data.iter().map(|s| s.abs()).fold(0.0_f32, f32::max); + // Update output levels for metering (stereo) - interleaved L/R samples + let (level_l, level_r) = data + .chunks_exact(2) + .fold((0.0_f32, 0.0_f32), |(max_l, max_r), chunk| { + (max_l.max(chunk[0].abs()), max_r.max(chunk[1].abs())) + }); if let Ok(mut lvl) = levels.try_write() { - lvl.output_level = level; - lvl.output_peak = lvl.output_peak.max(level); + lvl.output_level_l = level_l; + lvl.output_level_r = level_r; + lvl.output_peak_l = lvl.output_peak_l.max(level_l); + lvl.output_peak_r = lvl.output_peak_r.max(level_r); lvl.backing_level = backing_level; } } diff --git a/native-bridge/src/protocol/server.rs b/native-bridge/src/protocol/server.rs index b390dfe4..cbe8676b 100644 --- a/native-bridge/src/protocol/server.rs +++ b/native-bridge/src/protocol/server.rs @@ -30,11 +30,48 @@ impl BridgeServer { while let Ok((stream, peer)) = listener.accept().await { info!("Browser connected from {}", peer); + // Notify TUI of browser connection + { + let app = self.state.lock().await; + if let Some(ref tx) = app.tui_tx { + let _ = tx.try_send(AppEvent::ConnectionEvent { + event_type: crate::tui::ConnectionEventType::BrowserConnected, + peer_id: Some(peer.to_string()), + }); + // Mark as connected (browser connection = basic connectivity) + let _ = tx.try_send(AppEvent::NetworkState { + connected: true, + mode: crate::tui::NetworkMode::Disconnected, // No P2P room yet + peer_count: 0, + latency_ms: 0.0, + packet_loss: 0.0, + }); + } + } + if let Err(e) = self.handle_connection(stream).await { error!("Connection error: {}", e); } info!("Browser disconnected"); + + // Notify TUI of browser disconnection + { + let app = self.state.lock().await; + if let Some(ref tx) = app.tui_tx { + let _ = tx.try_send(AppEvent::ConnectionEvent { + event_type: crate::tui::ConnectionEventType::BrowserDisconnected, + peer_id: None, + }); + let _ = tx.try_send(AppEvent::NetworkState { + connected: false, + mode: crate::tui::NetworkMode::Disconnected, + peer_count: 0, + latency_ms: 0.0, + packet_loss: 0.0, + }); + } + } } Ok(()) @@ -80,12 +117,19 @@ impl BridgeServer { // Send to TUI if channel exists if let Some(ref tx) = app.tui_tx { - // Audio levels + // Convert linear amplitude to dB for TUI display + // dB = 20 * log10(linear), clamped to -60dB minimum + let to_db = |linear: f32| -> f32 { + if linear <= 0.000001 { -60.0 } + else { (20.0 * linear.log10()).clamp(-60.0, 6.0) } + }; + + // Audio levels (true stereo from audio engine) let _ = tx.try_send(AppEvent::AudioLevels { - input_l: audio_levels.input_level, - input_r: audio_levels.input_level, // Mono for now - output_l: audio_levels.output_level, - output_r: audio_levels.output_level, + input_l: to_db(audio_levels.input_level_l), + input_r: to_db(audio_levels.input_level_r), + output_l: to_db(audio_levels.output_level_l), + output_r: to_db(audio_levels.output_level_r), }); // Effects metering (dynamics) @@ -94,13 +138,25 @@ impl BridgeServer { compressor_reduction: effects_metering.compressor_reduction, limiter_reduction: effects_metering.limiter_reduction, }); + + // Remote user levels + if !audio_levels.remote_levels.is_empty() { + let _ = tx.try_send(AppEvent::RemoteLevels { + levels: audio_levels.remote_levels.clone(), + }); + } + + // Backing track level + let _ = tx.try_send(AppEvent::BackingLevel { + level: audio_levels.backing_level, + }); } let msg = NativeMessage::Levels { - input_level: audio_levels.input_level, - input_peak: audio_levels.input_peak, - output_level: audio_levels.output_level, - output_peak: audio_levels.output_peak, + input_level: audio_levels.input_level_l.max(audio_levels.input_level_r), + input_peak: audio_levels.input_peak_l.max(audio_levels.input_peak_r), + output_level: audio_levels.output_level_l.max(audio_levels.output_level_r), + output_peak: audio_levels.output_peak_l.max(audio_levels.output_peak_r), remote_levels: audio_levels.remote_levels, }; @@ -123,6 +179,40 @@ impl BridgeServer { app.audio_engine.get_browser_stream_occupancy(); let is_healthy = app.audio_engine.is_browser_stream_healthy(); let ms_since_last_read = app.audio_engine.ms_since_last_browser_read(); + + // Send stream health to TUI + if let Some(ref tx) = app.tui_tx { + let buffer_occupancy = buffer_used as f32 / buffer_capacity as f32; + let _ = tx.try_send(AppEvent::StreamHealth { + buffer_occupancy, + overflow_count, + is_healthy, + }); + + // Send network stats if we're in a room + if let Some(ref network) = app.network { + let stats = network.stats(); + let _ = tx.try_send(AppEvent::NetworkState { + connected: true, + mode: match stats.mode { + crate::network::NetworkMode::P2P => crate::tui::NetworkMode::P2P, + crate::network::NetworkMode::Relay => crate::tui::NetworkMode::Relay, + crate::network::NetworkMode::Hybrid => crate::tui::NetworkMode::Hybrid, + crate::network::NetworkMode::Disconnected => crate::tui::NetworkMode::Disconnected, + }, + peer_count: stats.peer_count, + latency_ms: stats.rtt_ms, + packet_loss: stats.packet_loss_pct, + }); + let _ = tx.try_send(AppEvent::NetworkStats { + jitter_ms: stats.jitter_ms, + clock_offset_ms: stats.clock_offset_ms, + bytes_sent_per_sec: stats.bytes_sent_per_sec, + bytes_recv_per_sec: stats.bytes_recv_per_sec, + }); + } + } + drop(app); let health = NativeMessage::StreamHealth { @@ -452,7 +542,57 @@ impl BridgeServer { BrowserMessage::UpdateEffects { effects, .. } => { let app = self.state.lock().await; - app.audio_engine.update_effects(effects); + app.audio_engine.update_effects(effects.clone()); + + // Send EffectToggled events to TUI + if let Some(ref tx) = app.tui_tx { + // Map effect settings to TUI effect names + let effect_states = [ + ("Wah", effects.wah.enabled), + ("Overdrive", effects.overdrive.enabled), + ("Distortion", effects.distortion.enabled), + ("Amp Sim", effects.amp.enabled), + ("Cabinet", effects.cabinet.enabled), + ("Noise Gate", effects.noise_gate.enabled), + ("Compressor", effects.compressor.enabled), + ("De-Esser", effects.de_esser.enabled), + ("Transient", effects.transient_shaper.enabled), + ("Multiband", effects.multiband_compressor.enabled), + ("Exciter", effects.exciter.enabled), + ("Limiter", effects.limiter.enabled), + ("Chorus", effects.chorus.enabled), + ("Flanger", effects.flanger.enabled), + ("Phaser", effects.phaser.enabled), + ("Tremolo", effects.tremolo.enabled), + ("Vibrato", effects.vibrato.enabled), + ("Auto Pan", effects.auto_pan.enabled), + ("Rotary", effects.rotary_speaker.enabled), + ("Ring Mod", effects.ring_modulator.enabled), + ("Delay", effects.delay.enabled), + ("Stereo Delay", effects.stereo_delay.enabled), + ("Granular", effects.granular_delay.enabled), + ("Pitch Correct", effects.pitch_correction.enabled), + ("Harmonizer", effects.harmonizer.enabled), + ("Formant", effects.formant_shifter.enabled), + ("Freq Shift", effects.frequency_shifter.enabled), + ("Vocal Double", effects.vocal_doubler.enabled), + ("EQ", effects.eq.enabled), + ("Reverb", effects.reverb.enabled), + ("Room Sim", effects.room_simulator.enabled), + ("Shimmer", effects.shimmer_reverb.enabled), + ("Stereo Img", effects.stereo_imager.enabled), + ("Multi Filter", effects.multi_filter.enabled), + ("Bitcrusher", effects.bitcrusher.enabled), + ]; + + for (name, enabled) in effect_states { + let _ = tx.try_send(AppEvent::EffectToggled { + name: name.to_string(), + enabled, + }); + } + } + None } @@ -478,6 +618,26 @@ impl BridgeServer { info!("AddRemoteUser: {} ({})", user_name, user_id); let app = self.state.lock().await; app.audio_engine.add_remote_user(&user_id, &user_name); + + // Notify TUI of peer join + if let Some(ref tx) = app.tui_tx { + let _ = tx.try_send(AppEvent::ConnectionEvent { + event_type: crate::tui::ConnectionEventType::PeerJoined, + peer_id: Some(user_name.clone()), + }); + // Update user count (get from network if available) + if let Some(ref network) = app.network { + let stats = network.stats(); + let _ = tx.try_send(AppEvent::RoomContext { + room_id: app.connected_room.clone(), + user_count: stats.peer_count + 1, // peers + self + key: None, + scale: None, + bpm: None, + }); + } + } + None } @@ -485,6 +645,26 @@ impl BridgeServer { info!("RemoveRemoteUser: {}", user_id); let app = self.state.lock().await; app.audio_engine.remove_remote_user(&user_id); + + // Notify TUI of peer leave + if let Some(ref tx) = app.tui_tx { + let _ = tx.try_send(AppEvent::ConnectionEvent { + event_type: crate::tui::ConnectionEventType::PeerLeft, + peer_id: Some(user_id.clone()), + }); + // Update user count + if let Some(ref network) = app.network { + let stats = network.stats(); + let _ = tx.try_send(AppEvent::RoomContext { + room_id: app.connected_room.clone(), + user_count: stats.peer_count + 1, + key: None, + scale: None, + bpm: None, + }); + } + } + None } @@ -628,6 +808,34 @@ impl BridgeServer { let mode = network.mode(); let is_master = network.is_master(); info!("Audio-network bridge started for room {}", room_id); + + // Notify TUI of room join + if let Some(ref tx) = app.tui_tx { + let tui_mode = match mode { + crate::network::NetworkMode::P2P => crate::tui::NetworkMode::P2P, + crate::network::NetworkMode::Relay => crate::tui::NetworkMode::Relay, + crate::network::NetworkMode::Hybrid => crate::tui::NetworkMode::Hybrid, + }; + let _ = tx.try_send(AppEvent::ConnectionEvent { + event_type: crate::tui::ConnectionEventType::RoomJoined, + peer_id: None, + }); + let _ = tx.try_send(AppEvent::NetworkState { + connected: true, + mode: tui_mode, + peer_count: 1, // Just us initially + latency_ms: 0.0, + packet_loss: 0.0, + }); + let _ = tx.try_send(AppEvent::RoomContext { + room_id: Some(room_id.clone()), + user_count: 1, + key: None, + scale: None, + bpm: None, + }); + } + Some(NativeMessage::RoomJoined { room_id, network_mode: format!("{:?}", mode), @@ -656,6 +864,29 @@ impl BridgeServer { network.disconnect().await; } + // Notify TUI of room leave + if let Some(ref tx) = app.tui_tx { + let _ = tx.try_send(AppEvent::ConnectionEvent { + event_type: crate::tui::ConnectionEventType::RoomLeft, + peer_id: None, + }); + // Keep browser connected, just not in a room + let _ = tx.try_send(AppEvent::NetworkState { + connected: true, + mode: crate::tui::NetworkMode::Disconnected, + peer_count: 0, + latency_ms: 0.0, + packet_loss: 0.0, + }); + let _ = tx.try_send(AppEvent::RoomContext { + room_id: None, + user_count: 0, + key: None, + scale: None, + bpm: None, + }); + } + Some(NativeMessage::RoomLeft) } @@ -708,7 +939,19 @@ impl BridgeServer { // Update effects chain with room context let app = self.state.lock().await; app.audio_engine - .set_room_context(key, scale, bpm, time_sig_num, time_sig_denom); + .set_room_context(key.clone(), scale.clone(), bpm, time_sig_num, time_sig_denom); + + // Send room context to TUI + if let Some(ref tx) = app.tui_tx { + let _ = tx.try_send(AppEvent::RoomContext { + room_id: app.connected_room.clone(), + user_count: app.network.as_ref().map(|n| n.stats().peer_count + 1).unwrap_or(1), + key, + scale, + bpm, + }); + } + None } diff --git a/native-bridge/src/tui/app.rs b/native-bridge/src/tui/app.rs index e11c83a4..8085b060 100644 --- a/native-bridge/src/tui/app.rs +++ b/native-bridge/src/tui/app.rs @@ -57,6 +57,27 @@ pub enum AppEvent { event_type: ConnectionEventType, peer_id: Option, }, + /// Remote user audio levels + RemoteLevels { + levels: Vec<(String, f32)>, // (user_id, level_db) + }, + /// Backing track audio level + BackingLevel { + level: f32, + }, + /// Browser stream health metrics + StreamHealth { + buffer_occupancy: f32, // 0.0-1.0 + overflow_count: u64, + is_healthy: bool, + }, + /// Extended network stats (jitter, clock sync) + NetworkStats { + jitter_ms: f32, + clock_offset_ms: f32, + bytes_sent_per_sec: u64, + bytes_recv_per_sec: u64, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -184,6 +205,10 @@ pub struct App { pub latency_ms: f32, pub packet_loss: f32, pub latency_history: VecDeque, + pub jitter_ms: f32, + pub clock_offset_ms: f32, + pub bytes_sent_per_sec: u64, + pub bytes_recv_per_sec: u64, // Room context pub room_id: Option, @@ -198,6 +223,17 @@ pub struct App { pub sample_rate: u32, pub buffer_size: u32, + // Remote users + pub remote_levels: Vec<(String, f32)>, + + // Backing track + pub backing_level: f32, + + // Browser stream health + pub stream_buffer_occupancy: f32, + pub stream_overflow_count: u64, + pub stream_healthy: bool, + // Logs pub logs: VecDeque, max_logs: usize, @@ -240,6 +276,10 @@ impl App { latency_ms: 0.0, packet_loss: 0.0, latency_history: VecDeque::with_capacity(60), + jitter_ms: 0.0, + clock_offset_ms: 0.0, + bytes_sent_per_sec: 0, + bytes_recv_per_sec: 0, room_id: None, user_count: 0, @@ -252,6 +292,13 @@ impl App { sample_rate: 48000, buffer_size: 256, + remote_levels: Vec::new(), + backing_level: -60.0, + + stream_buffer_occupancy: 0.0, + stream_overflow_count: 0, + stream_healthy: true, + logs: VecDeque::with_capacity(100), max_logs: 100, @@ -400,6 +447,23 @@ impl App { message: msg, }); } + AppEvent::RemoteLevels { levels } => { + self.remote_levels = levels; + } + AppEvent::BackingLevel { level } => { + self.backing_level = level; + } + AppEvent::StreamHealth { buffer_occupancy, overflow_count, is_healthy } => { + self.stream_buffer_occupancy = buffer_occupancy; + self.stream_overflow_count = overflow_count; + self.stream_healthy = is_healthy; + } + AppEvent::NetworkStats { jitter_ms, clock_offset_ms, bytes_sent_per_sec, bytes_recv_per_sec } => { + self.jitter_ms = jitter_ms; + self.clock_offset_ms = clock_offset_ms; + self.bytes_sent_per_sec = bytes_sent_per_sec; + self.bytes_recv_per_sec = bytes_recv_per_sec; + } } } diff --git a/native-bridge/src/tui/ui.rs b/native-bridge/src/tui/ui.rs index bb0c9c15..2cfab152 100644 --- a/native-bridge/src/tui/ui.rs +++ b/native-bridge/src/tui/ui.rs @@ -109,15 +109,30 @@ fn draw_audio_panel(f: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); f.render_widget(block, area); - // Split into sections - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ + // Dynamic layout based on whether we have remote users + let has_remotes = !app.remote_levels.is_empty(); + let has_backing = app.backing_level > -59.0; + + let constraints = if has_remotes || has_backing { + vec![ + Constraint::Length(4), // Input levels + Constraint::Length(4), // Output levels + Constraint::Length(4), // Device info + Constraint::Length(3), // Dynamics metering + Constraint::Min(3), // Remote/Backing levels + ] + } else { + vec![ Constraint::Length(4), // Input levels Constraint::Length(4), // Output levels Constraint::Length(4), // Device info Constraint::Min(3), // Dynamics metering - ]) + ] + }; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) .split(inner); // Input levels @@ -165,6 +180,34 @@ fn draw_audio_panel(f: &mut Frame, app: &App, area: Rect) { ]) .block(Block::default().title(" Dynamics ").borders(Borders::ALL)); f.render_widget(dynamics, chunks[3]); + + // Remote/Backing levels if present + if chunks.len() > 4 { + let mut remote_lines: Vec = Vec::new(); + + // Backing track + if has_backing { + remote_lines.push(Line::from(vec![ + Span::raw("♫ Track: "), + level_bar(app.backing_level, 15), + Span::styled(format!(" {:.0}dB", app.backing_level), Style::default().fg(Color::DarkGray)), + ])); + } + + // Remote users + for (user_id, level) in &app.remote_levels { + let short_id = if user_id.len() > 8 { &user_id[..8] } else { user_id }; + remote_lines.push(Line::from(vec![ + Span::styled(format!("{}: ", short_id), Style::default().fg(Color::Cyan)), + level_bar(*level, 15), + Span::styled(format!(" {:.0}dB", level), Style::default().fg(Color::DarkGray)), + ])); + } + + let remote_panel = Paragraph::new(remote_lines) + .block(Block::default().title(" Remote/Backing ").borders(Borders::ALL)); + f.render_widget(remote_panel, chunks[4]); + } } fn draw_stereo_meter(f: &mut Frame, label: &str, level_l: f32, level_r: f32, @@ -338,6 +381,11 @@ fn draw_network_panel(f: &mut Frame, app: &App, area: Rect) { format!("{}", app.peer_count), Style::default().fg(Color::Yellow), ), + Span::raw(" Users: "), + Span::styled( + format!("{}", app.user_count), + Style::default().fg(Color::Cyan), + ), ]), Line::from(vec![ Span::raw("Latency: "), @@ -349,14 +397,32 @@ fn draw_network_panel(f: &mut Frame, app: &App, area: Rect) { else { Color::Red } ), ), + Span::raw(" Jitter: "), + Span::styled( + format!("{:.1}ms", app.jitter_ms), + Style::default().fg( + if app.jitter_ms < 5.0 { Color::Green } + else if app.jitter_ms < 15.0 { Color::Yellow } + else { Color::Red } + ), + ), ]), Line::from(vec![ Span::raw("Loss: "), Span::styled( - format!("{:.2}%", app.packet_loss * 100.0), + format!("{:.2}%", app.packet_loss), Style::default().fg( - if app.packet_loss < 0.01 { Color::Green } - else if app.packet_loss < 0.05 { Color::Yellow } + if app.packet_loss < 1.0 { Color::Green } + else if app.packet_loss < 5.0 { Color::Yellow } + else { Color::Red } + ), + ), + Span::raw(" Sync: "), + Span::styled( + format!("{:+.1}ms", app.clock_offset_ms), + Style::default().fg( + if app.clock_offset_ms.abs() < 5.0 { Color::Green } + else if app.clock_offset_ms.abs() < 20.0 { Color::Yellow } else { Color::Red } ), ), @@ -458,14 +524,29 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(5), // Levels mini + Constraint::Length(6), // Levels mini (with stream health) Constraint::Length(4), // Network mini Constraint::Length(4), // Room mini Constraint::Min(3), // Active effects ]) .split(inner); - // Mini levels + // Mini levels with stream health + let stream_bar = { + let filled = (app.stream_buffer_occupancy * 10.0) as usize; + let bar: String = (0..10) + .map(|i| if i < filled { '▓' } else { '░' }) + .collect(); + let color = if !app.stream_healthy { + Color::Red + } else if app.stream_buffer_occupancy > 0.8 { + Color::Yellow + } else { + Color::Green + }; + Span::styled(bar, Style::default().fg(color)) + }; + let levels = Paragraph::new(vec![ Line::from(vec![ Span::raw("In: "), @@ -476,8 +557,15 @@ fn draw_sidebar(f: &mut Frame, app: &App, area: Rect) { level_bar(app.output_level_l.max(app.output_level_r), 10), ]), Line::from(vec![ - Span::raw("CPU: "), - Span::styled("▓▓▓░░░░░░░", Style::default().fg(Color::Green)), // Placeholder + Span::raw("Buf: "), + stream_bar, + ]), + Line::from(vec![ + Span::raw("Ovf: "), + Span::styled( + format!("{}", app.stream_overflow_count), + Style::default().fg(if app.stream_overflow_count == 0 { Color::Green } else { Color::Red }), + ), ]), ]) .block(Block::default().title("Meters").borders(Borders::TOP));