From a6e04ae89befc8dcfbe9d06e5bbfd95fd1ac27fa Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Sun, 1 Mar 2026 14:49:49 -0500 Subject: [PATCH] Add VU meters --- daw-backend/src/audio/engine.rs | 163 ++++++++++++------ daw-backend/src/audio/project.rs | 50 +++++- daw-backend/src/audio/track.rs | 14 +- daw-backend/src/command/types.rs | 13 ++ .../src/actions/set_layer_properties.rs | 8 + .../lightningbeam-core/src/layer.rs | 8 + .../lightningbeam-editor/src/main.rs | 77 +++++++++ .../lightningbeam-editor/src/panes/mod.rs | 7 + .../src/panes/timeline.rs | 96 ++++++++++- 9 files changed, 380 insertions(+), 56 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index b73adcb..d6f4713 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -71,6 +71,15 @@ pub struct Engine { // Disk reader for streaming playback of compressed files disk_reader: Option, + // Input monitoring and metering + input_monitoring: bool, + input_gain: f32, + input_level_peak: f32, + input_level_counter: usize, + output_level_peak: f32, + output_level_counter: usize, + track_level_counter: usize, + // Callback timing diagnostics (enabled by DAW_AUDIO_DEBUG=1) debug_audio: bool, callback_count: u64, @@ -138,6 +147,13 @@ impl Engine { metronome: Metronome::new(sample_rate), recording_sample_buffer: Vec::with_capacity(4096), disk_reader: Some(disk_reader), + input_monitoring: false, + input_gain: 1.0, + input_level_peak: 0.0, + input_level_counter: 0, + output_level_peak: 0.0, + output_level_counter: 0, + track_level_counter: 0, debug_audio: std::env::var("DAW_AUDIO_DEBUG").map_or(false, |v| v == "1"), callback_count: 0, timing_worst_total_us: 0, @@ -345,6 +361,25 @@ impl Engine { self.channels, ); + // Compute output peak for master VU meter + let output_peak = output.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + self.output_level_peak = self.output_level_peak.max(output_peak); + self.output_level_counter += output.len(); + let meter_interval = self.sample_rate as usize / 20; // ~50ms + if self.output_level_counter >= meter_interval { + let _ = self.event_tx.push(AudioEvent::OutputLevel(self.output_level_peak)); + self.output_level_peak = 0.0; + self.output_level_counter = 0; + } + + // Send per-track peak levels periodically (~50ms) + self.track_level_counter += output.len(); + if self.track_level_counter >= meter_interval { + let levels = self.project.collect_track_peaks(); + let _ = self.event_tx.push(AudioEvent::TrackLevels(levels)); + self.track_level_counter = 0; + } + // Update playhead (convert total samples to frames) self.playhead += (output.len() / self.channels as usize) as u64; @@ -380,73 +415,85 @@ impl Engine { self.process_live_midi(output); } - // Process recording if active (independent of playback state) - if let Some(recording) = &mut self.recording_state { + // Process input monitoring and/or recording (independent of playback state) + let is_recording = self.recording_state.is_some(); + if is_recording || self.input_monitoring { if let Some(input_rx) = &mut self.input_rx { - // Phase 1: Discard stale samples by popping without storing - // (fast — no Vec push, no add_samples overhead) - while recording.samples_to_skip > 0 { - match input_rx.pop() { - Ok(_) => recording.samples_to_skip -= 1, - Err(_) => break, + // Phase 1: Discard stale samples during recording skip phase + if let Some(recording) = &mut self.recording_state { + while recording.samples_to_skip > 0 { + match input_rx.pop() { + Ok(_) => recording.samples_to_skip -= 1, + Err(_) => break, + } } } - // Phase 2: Pull fresh samples for actual recording + // Phase 2: Pull fresh samples self.recording_sample_buffer.clear(); while let Ok(sample) = input_rx.pop() { - self.recording_sample_buffer.push(sample); + // Apply input gain + self.recording_sample_buffer.push(sample * self.input_gain); } - // Add samples to recording if !self.recording_sample_buffer.is_empty() { - // Calculate how many samples will be skipped (stale buffer data) - let skip = if recording.paused { - self.recording_sample_buffer.len() - } else { - recording.samples_to_skip.min(self.recording_sample_buffer.len()) - }; + // Compute input peak for VU metering + let input_peak = self.recording_sample_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + self.input_level_peak = self.input_level_peak.max(input_peak); + self.input_level_counter += self.recording_sample_buffer.len(); + let meter_interval = self.sample_rate as usize / 20; // ~50ms + if self.input_level_counter >= meter_interval { + let _ = self.event_tx.push(AudioEvent::InputLevel(self.input_level_peak)); + self.input_level_peak = 0.0; + self.input_level_counter = 0; + } - match recording.add_samples(&self.recording_sample_buffer) { - Ok(_flushed) => { - // Mirror non-skipped samples to UI for live waveform display - if skip < self.recording_sample_buffer.len() { - if let Some(ref mut mirror_tx) = self.recording_mirror_tx { - for &sample in &self.recording_sample_buffer[skip..] { - let _ = mirror_tx.push(sample); + // Feed samples to recording if active + if let Some(recording) = &mut self.recording_state { + let skip = if recording.paused { + self.recording_sample_buffer.len() + } else { + recording.samples_to_skip.min(self.recording_sample_buffer.len()) + }; + + match recording.add_samples(&self.recording_sample_buffer) { + Ok(_flushed) => { + // Mirror non-skipped samples to UI for live waveform display + if skip < self.recording_sample_buffer.len() { + if let Some(ref mut mirror_tx) = self.recording_mirror_tx { + for &sample in &self.recording_sample_buffer[skip..] { + let _ = mirror_tx.push(sample); + } } } - } - // Update clip duration every callback for sample-accurate timing - let duration = recording.duration(); - let clip_id = recording.clip_id; - let track_id = recording.track_id; + // Update clip duration every callback for sample-accurate timing + let duration = recording.duration(); + let clip_id = recording.clip_id; + let track_id = recording.track_id; - // Update clip duration in project as recording progresses - if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { - if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - // Update both internal_end and external_duration as recording progresses - clip.internal_end = clip.internal_start + duration; - clip.external_duration = duration; + // Update clip duration in project as recording progresses + if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { + if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { + clip.internal_end = clip.internal_start + duration; + clip.external_duration = duration; + } + } + + // Send progress event periodically (every ~0.1 seconds) + self.recording_progress_counter += self.recording_sample_buffer.len(); + if self.recording_progress_counter >= (self.sample_rate as usize / 10) { + let _ = self.event_tx.push(AudioEvent::RecordingProgress(clip_id, duration)); + self.recording_progress_counter = 0; } } - - // Send progress event periodically (every ~0.1 seconds) - self.recording_progress_counter += self.recording_sample_buffer.len(); - if self.recording_progress_counter >= (self.sample_rate as usize / 10) { - let _ = self.event_tx.push(AudioEvent::RecordingProgress(clip_id, duration)); - self.recording_progress_counter = 0; + Err(e) => { + let _ = self.event_tx.push(AudioEvent::RecordingError( + format!("Recording write error: {}", e) + )); + self.recording_state = None; } } - Err(e) => { - // Recording error occurred - let _ = self.event_tx.push(AudioEvent::RecordingError( - format!("Recording write error: {}", e) - )); - // Stop recording on error - self.recording_state = None; - } } } } @@ -1136,6 +1183,14 @@ impl Engine { self.metronome.set_enabled(enabled); } + Command::SetInputMonitoring(enabled) => { + self.input_monitoring = enabled; + } + + Command::SetInputGain(gain) => { + self.input_gain = gain; + } + Command::SetTempo(bpm, time_sig) => { self.metronome.update_timing(bpm, time_sig); self.project.set_tempo(bpm, time_sig.0); @@ -2851,6 +2906,16 @@ impl EngineController { let _ = self.command_tx.push(Command::SetTrackSolo(track_id, solo)); } + /// Enable or disable input monitoring (mic level metering) + pub fn set_input_monitoring(&mut self, enabled: bool) { + let _ = self.command_tx.push(Command::SetInputMonitoring(enabled)); + } + + /// Set the input gain multiplier (applied before recording) + pub fn set_input_gain(&mut self, gain: f32) { + let _ = self.command_tx.push(Command::SetInputGain(gain)); + } + /// Move a clip to a new timeline position (changes external_start) pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64) { let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index f838c64..8fd580e 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -441,13 +441,34 @@ impl Project { // Handle audio track vs MIDI track vs group track match self.tracks.get_mut(&track_id) { Some(TrackNode::Audio(track)) => { - // Render audio track directly into output - track.render(output, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + // Render audio track into a temp buffer for peak measurement + let mut track_buffer = buffer_pool.acquire(); + track_buffer.resize(output.len(), 0.0); + track_buffer.fill(0.0); + track.render(&mut track_buffer, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + // Accumulate peak level for VU metering (max over meter interval) + let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + track.peak_level = track.peak_level.max(buffer_peak); + // Mix into output + for (out, src) in output.iter_mut().zip(track_buffer.iter()) { + *out += src; + } + buffer_pool.release(track_buffer); } Some(TrackNode::Midi(track)) => { - // Render MIDI track directly into output - // Access midi_clip_pool from self - safe because we only need immutable access - track.render(output, &self.midi_clip_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + // Render MIDI track into a temp buffer for peak measurement + let mut track_buffer = buffer_pool.acquire(); + track_buffer.resize(output.len(), 0.0); + track_buffer.fill(0.0); + track.render(&mut track_buffer, &self.midi_clip_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + // Accumulate peak level for VU metering (max over meter interval) + let buffer_peak = track_buffer.iter().map(|s| s.abs()).fold(0.0f32, f32::max); + track.peak_level = track.peak_level.max(buffer_peak); + // Mix into output + for (out, src) in output.iter_mut().zip(track_buffer.iter()) { + *out += src; + } + buffer_pool.release(track_buffer); } Some(TrackNode::Group(group)) => { // Skip rendering if playhead is outside the metatrack's trim window @@ -534,6 +555,25 @@ impl Project { } } + /// Collect per-track peak levels for VU metering and reset accumulators + pub fn collect_track_peaks(&mut self) -> Vec<(TrackId, f32)> { + let mut levels = Vec::new(); + for (id, track) in &mut self.tracks { + match track { + TrackNode::Audio(t) => { + levels.push((*id, t.peak_level)); + t.peak_level = 0.0; + } + TrackNode::Midi(t) => { + levels.push((*id, t.peak_level)); + t.peak_level = 0.0; + } + TrackNode::Group(_) => {} + } + } + levels + } + /// Stop all notes on all MIDI tracks pub fn stop_all_notes(&mut self) { for track in self.tracks.values_mut() { diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index bedaa13..4f9aa5f 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -435,6 +435,10 @@ pub struct MidiTrack { /// Used to detect when the playhead exits a clip, so we can send all-notes-off. #[serde(skip)] prev_active_instances: HashSet, + + /// Peak level of last render() call (for VU metering) + #[serde(skip, default)] + pub peak_level: f32, } impl Clone for MidiTrack { @@ -452,6 +456,7 @@ impl Clone for MidiTrack { next_automation_id: self.next_automation_id, live_midi_queue: Vec::new(), // Don't clone live MIDI queue prev_active_instances: HashSet::new(), + peak_level: 0.0, } } } @@ -479,6 +484,7 @@ impl MidiTrack { next_automation_id: 0, live_midi_queue: Vec::new(), prev_active_instances: HashSet::new(), + peak_level: 0.0, } } @@ -705,6 +711,10 @@ pub struct AudioTrack { /// Pre-allocated buffer for clip rendering (avoids heap allocation per callback) #[serde(skip, default)] clip_render_buffer: Vec, + + /// Peak level of last render() call (for VU metering) + #[serde(skip, default)] + pub peak_level: f32, } impl Clone for AudioTrack { @@ -721,6 +731,7 @@ impl Clone for AudioTrack { effects_graph_preset: self.effects_graph_preset.clone(), effects_graph: default_audio_graph(), // Create fresh graph, not cloned clip_render_buffer: Vec::new(), + peak_level: 0.0, } } } @@ -764,6 +775,7 @@ impl AudioTrack { effects_graph_preset: None, effects_graph, clip_render_buffer: Vec::new(), + peak_level: 0.0, } } @@ -987,7 +999,7 @@ impl AudioTrack { } // Calculate combined gain - let combined_gain = clip.gain * self.volume; + let combined_gain = clip.gain; let mut total_rendered = 0; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 855f0bb..75a1560 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -226,6 +226,12 @@ pub enum Command { priority: u8, // 0=Low, 1=Medium, 2=High }, + // Input monitoring/gain commands + /// Enable or disable input monitoring (mic level metering) + SetInputMonitoring(bool), + /// Set the input gain multiplier (applied before recording) + SetInputGain(f32), + // Async audio import /// Import an audio file asynchronously. The engine probes the file format /// and either memory-maps it (WAV/AIFF) or sets up stream decode @@ -333,6 +339,13 @@ pub enum AudioEvent { channels: u32, }, + /// Peak amplitude of mic input (for input monitoring meter) + InputLevel(f32), + /// Peak amplitude of mix output (for master meter) + OutputLevel(f32), + /// Per-track playback peak levels + TrackLevels(Vec<(TrackId, f32)>), + /// Background waveform decode progress/completion for a compressed audio file. /// Internal event — consumed by the engine to update the pool, not forwarded to UI. /// `decoded_frames` < `total_frames` means partial; equal means complete. diff --git a/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs b/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs index dd8a251..c4a8c00 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs @@ -12,6 +12,7 @@ use uuid::Uuid; #[derive(Clone, Debug)] pub enum LayerProperty { Volume(f64), + InputGain(f64), Muted(bool), Soloed(bool), Locked(bool), @@ -25,6 +26,7 @@ pub enum LayerProperty { #[derive(Clone, Debug)] enum OldValue { Volume(f64), + InputGain(f64), Muted(bool), Soloed(bool), Locked(bool), @@ -85,6 +87,7 @@ impl Action for SetLayerPropertiesAction { if self.old_values[i].is_none() { self.old_values[i] = Some(match &self.property { LayerProperty::Volume(_) => OldValue::Volume(layer.volume()), + LayerProperty::InputGain(_) => OldValue::InputGain(layer.layer().input_gain), LayerProperty::Muted(_) => OldValue::Muted(layer.muted()), LayerProperty::Soloed(_) => OldValue::Soloed(layer.soloed()), LayerProperty::Locked(_) => OldValue::Locked(layer.locked()), @@ -104,6 +107,7 @@ impl Action for SetLayerPropertiesAction { // Set new value match &self.property { LayerProperty::Volume(v) => layer.set_volume(*v), + LayerProperty::InputGain(g) => layer.layer_mut().input_gain = *g, LayerProperty::Muted(m) => layer.set_muted(*m), LayerProperty::Soloed(s) => layer.set_soloed(*s), LayerProperty::Locked(l) => layer.set_locked(*l), @@ -128,6 +132,7 @@ impl Action for SetLayerPropertiesAction { if let Some(old_value) = &self.old_values[i] { match old_value { OldValue::Volume(v) => layer.set_volume(*v), + OldValue::InputGain(g) => layer.layer_mut().input_gain = *g, OldValue::Muted(m) => layer.set_muted(*m), OldValue::Soloed(s) => layer.set_soloed(*s), OldValue::Locked(l) => layer.set_locked(*l), @@ -159,6 +164,7 @@ impl Action for SetLayerPropertiesAction { if let Some(&track_id) = backend.layer_to_track_map.get(&layer_id) { match &self.property { LayerProperty::Volume(v) => controller.set_track_volume(track_id, *v as f32), + LayerProperty::InputGain(g) => controller.set_input_gain(*g as f32), LayerProperty::Muted(m) => controller.set_track_mute(track_id, *m), LayerProperty::Soloed(s) => controller.set_track_solo(track_id, *s), _ => {} // Locked/Opacity/Visible/CameraEnabled are UI-only @@ -183,6 +189,7 @@ impl Action for SetLayerPropertiesAction { if let Some(old_value) = &self.old_values[i] { match old_value { OldValue::Volume(v) => controller.set_track_volume(track_id, *v as f32), + OldValue::InputGain(g) => controller.set_input_gain(*g as f32), OldValue::Muted(m) => controller.set_track_mute(track_id, *m), OldValue::Soloed(s) => controller.set_track_solo(track_id, *s), _ => {} // Locked/Opacity/Visible are UI-only @@ -196,6 +203,7 @@ impl Action for SetLayerPropertiesAction { fn description(&self) -> String { let property_name = match &self.property { LayerProperty::Volume(_) => "volume", + LayerProperty::InputGain(_) => "input gain", LayerProperty::Muted(_) => "mute", LayerProperty::Soloed(_) => "solo", LayerProperty::Locked(_) => "lock", diff --git a/lightningbeam-ui/lightningbeam-core/src/layer.rs b/lightningbeam-ui/lightningbeam-core/src/layer.rs index 157132f..3ecd6ef 100644 --- a/lightningbeam-ui/lightningbeam-core/src/layer.rs +++ b/lightningbeam-ui/lightningbeam-core/src/layer.rs @@ -60,6 +60,8 @@ pub trait LayerTrait { fn set_locked(&mut self, locked: bool); } +fn default_input_gain() -> f64 { 1.0 } + /// Base layer structure #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Layer { @@ -84,6 +86,10 @@ pub struct Layer { /// Audio volume (1.0 = 100%, affects nested audio layers/clips) pub volume: f64, + /// Input gain for recording (1.0 = unity, range 0.0–4.0) + #[serde(default = "default_input_gain")] + pub input_gain: f64, + /// Audio mute state pub muted: bool, @@ -108,6 +114,7 @@ impl Layer { visible: true, opacity: 1.0, volume: 1.0, // 100% volume + input_gain: 1.0, muted: false, soloed: false, locked: false, @@ -125,6 +132,7 @@ impl Layer { visible: true, opacity: 1.0, volume: 1.0, + input_gain: 1.0, muted: false, soloed: false, locked: false, diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index b027a3f..a418258 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -814,6 +814,11 @@ struct EditorApp { region_selection: Option, region_select_mode: lightningbeam_core::tool::RegionSelectMode, + // VU meter levels + input_level: f32, + output_level: f32, + track_levels: HashMap, + /// Cache for MIDI event data (keyed by backend midi_clip_id) /// Prevents repeated backend queries for the same MIDI clip /// Format: (timestamp, note_number, velocity, is_note_on) @@ -1057,6 +1062,9 @@ impl EditorApp { polygon_sides: 5, // Default to pentagon region_selection: None, region_select_mode: lightningbeam_core::tool::RegionSelectMode::default(), + input_level: 0.0, + output_level: 0.0, + track_levels: HashMap::new(), midi_event_cache: HashMap::new(), // Initialize empty MIDI event cache audio_duration_cache: HashMap::new(), // Initialize empty audio duration cache audio_pools_with_new_waveforms: HashSet::new(), // Track pool indices with new raw audio @@ -4672,6 +4680,18 @@ impl eframe::App for EditorApp { ); ctx.request_repaint(); } + AudioEvent::InputLevel(peak) => { + self.input_level = self.input_level.max(peak); + } + AudioEvent::OutputLevel(peak) => { + self.output_level = self.output_level.max(peak); + } + AudioEvent::TrackLevels(levels) => { + for (track_id, peak) in levels { + let entry = self.track_levels.entry(track_id).or_insert(0.0); + *entry = entry.max(peak); + } + } _ => {} // Ignore other events for now } } @@ -4686,6 +4706,38 @@ impl eframe::App for EditorApp { } } + // Update input monitoring based on active layer + if let Some(controller) = &self.audio_controller { + let should_monitor = self.active_layer_id.map_or(false, |layer_id| { + let doc = self.action_executor.document(); + if let Some(layer) = doc.get_layer(&layer_id) { + matches!(layer, lightningbeam_core::layer::AnyLayer::Audio(a) if a.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled) + } else { + false + } + }); + if let Ok(mut ctrl) = controller.try_lock() { + ctrl.set_input_monitoring(should_monitor); + } + } + + // Decay VU meter levels (~1.5s full fall at 60fps) + { + let decay = 0.97f32; + self.input_level *= decay; + self.output_level *= decay; + for level in self.track_levels.values_mut() { + *level *= decay; + } + // Request repaint while any level is visible + let any_active = self.input_level > 0.001 + || self.output_level > 0.001 + || self.track_levels.values().any(|&v| v > 0.001); + if any_active { + ctx.request_repaint(); + } + } + let _post_events_ms = _frame_start.elapsed().as_secs_f64() * 1000.0; // Request continuous repaints when playing to update time display @@ -4925,6 +4977,27 @@ impl eframe::App for EditorApp { } }); + // Mix output VU meter (thin bar below menu) + if self.app_mode != AppMode::StartScreen && self.output_level > 0.001 { + egui::TopBottomPanel::top("mix_meter").exact_height(4.0).show(ctx, |ui| { + let rect = ui.available_rect_before_wrap(); + let level = self.output_level.min(1.0); + let filled_width = rect.width() * level; + let color = if level > 0.9 { + egui::Color32::from_rgb(220, 50, 50) + } else if level > 0.7 { + egui::Color32::from_rgb(220, 200, 50) + } else { + egui::Color32::from_rgb(50, 200, 80) + }; + let filled_rect = egui::Rect::from_min_size( + rect.left_top(), + egui::vec2(filled_width, rect.height()), + ); + ui.painter().rect_filled(filled_rect, 0.0, color); + }); + } + // Render start screen or editor based on app mode if self.app_mode == AppMode::StartScreen { self.render_start_screen(ctx); @@ -5075,6 +5148,10 @@ impl eframe::App for EditorApp { target_format: self.target_format, pending_menu_actions: &mut pending_menu_actions, clipboard_manager: &mut self.clipboard_manager, + input_level: self.input_level, + output_level: self.output_level, + track_levels: &self.track_levels, + track_to_layer_map: &self.track_to_layer_map, waveform_stereo: self.config.waveform_stereo, project_generation: &mut self.project_generation, script_to_edit: &mut self.script_to_edit, diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index 81d3910..5983c55 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -244,6 +244,13 @@ pub struct SharedPaneState<'a> { pub pending_menu_actions: &'a mut Vec, /// Clipboard manager for cut/copy/paste operations pub clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager, + // VU meter levels + pub input_level: f32, + #[allow(dead_code)] // Used by mix meter in main.rs, available to panes + pub output_level: f32, + pub track_levels: &'a std::collections::HashMap, + #[allow(dead_code)] // Available for panes that need reverse track->layer lookup + pub track_to_layer_map: &'a std::collections::HashMap, /// Whether to show waveforms as stacked stereo (true) or combined mono (false) pub waveform_stereo: bool, /// Generation counter - incremented on project load to force reloads diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 3ed9fc0..dd4b44a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -1260,6 +1260,9 @@ impl TimelinePane { pending_actions: &mut Vec>, _document: &lightningbeam_core::document::Document, context_layers: &[&lightningbeam_core::layer::AnyLayer], + layer_to_track_map: &std::collections::HashMap, + track_levels: &std::collections::HashMap, + input_level: f32, ) { // Background for header column let header_style = theme.style(".timeline-header", ui.ctx()); @@ -1659,6 +1662,10 @@ impl TimelinePane { (response, temp_slider_value) }).inner; + // Block layer drag while interacting with the slider + if volume_response.0.dragged() || volume_response.0.has_focus() { + self.layer_control_clicked = true; + } if volume_response.0.changed() { self.layer_control_clicked = true; // Map slider position (0.0-1.0) back to volume (0.0-2.0) @@ -1678,6 +1685,93 @@ impl TimelinePane { )); } + // Input gain slider for sampled audio layers (below volume slider) + if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls { + if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled { + let gain_slider_rect = egui::Rect::from_min_size( + egui::pos2(controls_right - slider_width, controls_top + 22.0), + egui::vec2(slider_width, 16.0), + ); + let current_gain = audio_layer.layer.input_gain; + + // Map gain (0.0-4.0) to slider (0.0-1.0): linear + let mut slider_val = (current_gain / 4.0) as f32; + let gain_response = ui.scope_builder(egui::UiBuilder::new().max_rect(gain_slider_rect), |ui| { + let slider = egui::Slider::new(&mut slider_val, 0.0..=1.0f32) + .show_value(false); + ui.add(slider) + }).inner; + + // Block layer drag while interacting with the slider + if gain_response.dragged() || gain_response.has_focus() { + self.layer_control_clicked = true; + } + if gain_response.changed() { + self.layer_control_clicked = true; + let new_gain = (slider_val * 4.0) as f64; + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::InputGain(new_gain), + ) + )); + } + + // Label + let label_rect = egui::Rect::from_min_size( + egui::pos2(gain_slider_rect.min.x - 26.0, controls_top + 22.0), + egui::vec2(24.0, 16.0), + ); + ui.painter().text( + label_rect.center(), + egui::Align2::CENTER_CENTER, + "Gain", + egui::FontId::proportional(9.0), + egui::Color32::from_gray(140), + ); + } + } + + // Per-layer VU meter bar (4px tall at bottom of header) + { + // Look up the track level for this layer + let mut level = 0.0f32; + if let Some(&track_id) = layer_to_track_map.get(&layer_id) { + if let Some(&track_level) = track_levels.get(&track_id) { + level = track_level; + } + } + + // For active sampled audio layer, show max of track level and input level + let is_active_sampled_audio = active_layer_id.map_or(false, |id| id == layer_id) + && matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Audio(a) if a.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled); + if is_active_sampled_audio { + level = level.max(input_level); + } + + if level > 0.001 { + let meter_height = 4.0; + let meter_rect = egui::Rect::from_min_size( + egui::pos2(header_rect.min.x, header_rect.max.y - meter_height - 1.0), + egui::vec2(header_rect.width(), meter_height), + ); + let clamped = level.min(1.0); + let filled_width = meter_rect.width() * clamped; + let color = if clamped > 0.9 { + egui::Color32::from_rgb(220, 50, 50) + } else if clamped > 0.7 { + egui::Color32::from_rgb(220, 200, 50) + } else { + egui::Color32::from_rgb(50, 200, 80) + }; + let filled = egui::Rect::from_min_size( + meter_rect.left_top(), + egui::vec2(filled_width, meter_rect.height()), + ); + ui.painter().rect_filled(filled, 0.0, color); + } + } + // Separator line at bottom ui.painter().line_segment( [ @@ -4278,7 +4372,7 @@ impl PaneRenderer for TimelinePane { // Render layer header column with clipping ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect)); - self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers); + self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level); // Render time ruler (clip to ruler rect) ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));