From 65a550d8f4af5e84cb08a7d2c45fcf6be2a46b2e Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Tue, 24 Mar 2026 19:24:24 -0400 Subject: [PATCH] Add piano roll note snapping --- daw-backend/src/audio/engine.rs | 14 +- daw-backend/src/command/types.rs | 4 +- .../src/actions/set_layer_properties.rs | 13 ++ .../lightningbeam-editor/src/main.rs | 13 +- .../src/panes/piano_roll.rs | 197 +++++++++++++++++- .../src/panes/timeline.rs | 74 +++++-- 6 files changed, 286 insertions(+), 29 deletions(-) diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 810c103..6e1bb7d 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1673,6 +1673,7 @@ impl Engine { Ok(json) => { match crate::audio::node_graph::preset::GraphPreset::from_json(&json) { Ok(preset) => { + let preset_name = preset.metadata.name.clone(); // Extract the directory path from the preset path for resolving relative sample paths let preset_base_path = std::path::Path::new(&preset_path).parent(); @@ -1684,19 +1685,19 @@ impl Engine { track.instrument_graph = graph; track.graph_is_default = true; let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); - let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); } Some(TrackNode::Audio(track)) => { track.effects_graph = graph; track.graph_is_default = true; let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); - let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); } Some(TrackNode::Group(track)) => { track.audio_graph = graph; track.graph_is_default = true; let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); - let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); } _ => {} } @@ -1729,6 +1730,7 @@ impl Engine { Command::GraphLoadLbins(track_id, path) => { match crate::audio::node_graph::lbins::load_lbins(&path) { Ok((preset, assets)) => { + let preset_name = preset.metadata.name.clone(); match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) { Ok(graph) => { match self.project.get_track_mut(track_id) { @@ -1736,19 +1738,19 @@ impl Engine { track.instrument_graph = graph; track.graph_is_default = true; let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); - let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); } Some(TrackNode::Audio(track)) => { track.effects_graph = graph; track.graph_is_default = true; let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); - let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); } Some(TrackNode::Group(track)) => { track.audio_graph = graph; track.graph_is_default = true; let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id)); - let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id)); + let _ = self.event_tx.push(AudioEvent::GraphPresetLoaded(track_id, preset_name)); } _ => {} } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 2baf944..b3a3b85 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -310,8 +310,8 @@ pub enum AudioEvent { GraphConnectionError(TrackId, String), /// Graph state changed (for full UI sync) GraphStateChanged(TrackId), - /// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded - GraphPresetLoaded(TrackId), + /// Preset fully loaded (track_id, preset_name) - emitted after all nodes and samples are loaded + GraphPresetLoaded(TrackId, String), /// Preset has been saved to file (track_id, preset_path) GraphPresetSaved(TrackId, String), /// Script compilation result (track_id, node_id, success, error, ui_declaration, source) 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 c4a8c00..6596493 100644 --- a/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs +++ b/lightningbeam-ui/lightningbeam-core/src/actions/set_layer_properties.rs @@ -20,6 +20,8 @@ pub enum LayerProperty { Visible(bool), /// Video layer only: toggle live webcam preview CameraEnabled(bool), + /// Rename the layer; sets has_custom_name = true + Name(String), } /// Stored old value for rollback @@ -33,6 +35,7 @@ enum OldValue { Opacity(f64), Visible(bool), CameraEnabled(bool), + Name(String), } /// Action that sets a property on one or more layers @@ -101,6 +104,7 @@ impl Action for SetLayerPropertiesAction { }; OldValue::CameraEnabled(val) } + LayerProperty::Name(_) => OldValue::Name(layer.name().to_string()), }); } @@ -118,6 +122,10 @@ impl Action for SetLayerPropertiesAction { v.camera_enabled = *c; } } + LayerProperty::Name(n) => { + layer.set_name(n.clone()); + layer.set_has_custom_name(true); + } } } } @@ -143,6 +151,10 @@ impl Action for SetLayerPropertiesAction { v.camera_enabled = *c; } } + OldValue::Name(n) => { + layer.set_name(n.clone()); + layer.set_has_custom_name(false); + } } } } @@ -206,6 +218,7 @@ impl Action for SetLayerPropertiesAction { LayerProperty::InputGain(_) => "input gain", LayerProperty::Muted(_) => "mute", LayerProperty::Soloed(_) => "solo", + LayerProperty::Name(_) => "name", LayerProperty::Locked(_) => "lock", LayerProperty::Opacity(_) => "opacity", LayerProperty::Visible(_) => "visibility", diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 1119344..e827cb5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -5267,7 +5267,7 @@ impl eframe::App for EditorApp { self.waveform_gpu_dirty.insert(pool_index); ctx.request_repaint(); } - AudioEvent::GraphPresetLoaded(_track_id) => { + AudioEvent::GraphPresetLoaded(track_id, preset_name) => { // Preset was loaded on the audio thread — bump generation // so the node graph pane reloads from backend self.project_generation += 1; @@ -5278,6 +5278,17 @@ impl eframe::App for EditorApp { std::sync::atomic::Ordering::Relaxed, |v| if v > 0 { Some(v - 1) } else { Some(0) }, ); + // Auto-rename the MIDI layer to match the preset name, unless + // the user has already given it a custom name + if let Some(&layer_uuid) = self.track_to_layer_map.get(&track_id) { + let doc = self.action_executor.document_mut(); + if let Some(layer) = doc.get_layer_mut(&layer_uuid) { + use lightningbeam_core::layer::LayerTrait; + if !layer.has_custom_name() { + layer.set_name(preset_name); + } + } + } ctx.request_repaint(); } AudioEvent::InputLevel(peak) => { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs index d3c038d..00fea9b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/piano_roll.rs @@ -34,6 +34,48 @@ enum PitchBendZone { End, // Last 30%: ramp from 0 → bend } +#[derive(Clone, Copy, Debug, PartialEq, Default)] +enum SnapValue { + #[default] None, + Whole, Half, Quarter, Eighth, Sixteenth, ThirtySecond, + QuarterTriplet, EighthTriplet, SixteenthTriplet, ThirtySecondTriplet, + EighthSwingLight, SixteenthSwingLight, + EighthSwingHeavy, SixteenthSwingHeavy, +} + +impl SnapValue { + fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Whole => "1/1", + Self::Half => "1/2", + Self::Quarter => "1/4", + Self::Eighth => "1/8", + Self::Sixteenth => "1/16", + Self::ThirtySecond => "1/32", + Self::QuarterTriplet => "1/4T", + Self::EighthTriplet => "1/8T", + Self::SixteenthTriplet => "1/16T", + Self::ThirtySecondTriplet => "1/32T", + Self::EighthSwingLight => "1/8 swing light", + Self::SixteenthSwingLight => "1/16 swing light", + Self::EighthSwingHeavy => "1/8 swing heavy", + Self::SixteenthSwingHeavy => "1/16 swing heavy", + } + } + + fn all() -> &'static [SnapValue] { + &[ + Self::None, Self::Whole, Self::Half, Self::Quarter, + Self::Eighth, Self::Sixteenth, Self::ThirtySecond, + Self::QuarterTriplet, Self::EighthTriplet, + Self::SixteenthTriplet, Self::ThirtySecondTriplet, + Self::EighthSwingLight, Self::SixteenthSwingLight, + Self::EighthSwingHeavy, Self::SixteenthSwingHeavy, + ] + } +} + #[derive(Debug, Clone, Copy, PartialEq)] enum DragMode { MoveNotes { start_time_offset: f64, start_note_offset: i32 }, @@ -122,6 +164,11 @@ pub struct PianoRollPane { pitch_bend_range: f32, // Layer ID for which pitch_bend_range was last queried pitch_bend_range_layer: Option, + + // Snap / quantize + snap_value: SnapValue, + last_snap_selection: HashSet, + snap_user_changed: bool, // set in render_header, consumed before handle_input } impl PianoRollPane { @@ -155,6 +202,9 @@ impl PianoRollPane { header_mod: 0.0, pitch_bend_range: 2.0, pitch_bend_range_layer: None, + snap_value: SnapValue::None, + last_snap_selection: HashSet::new(), + snap_user_changed: false, } } @@ -263,6 +313,61 @@ impl PianoRollPane { // ── MIDI mode rendering ────────────────────────────────────────────── +} // end impl PianoRollPane (snap helpers follow as free functions) + +fn snap_to_value(t: f64, snap: SnapValue, bpm: f64) -> f64 { + let beat = 60.0 / bpm; + match snap { + SnapValue::None => t, + SnapValue::Whole => round_to_grid(t, beat * 4.0), + SnapValue::Half => round_to_grid(t, beat * 2.0), + SnapValue::Quarter => round_to_grid(t, beat), + SnapValue::Eighth => round_to_grid(t, beat * 0.5), + SnapValue::Sixteenth => round_to_grid(t, beat * 0.25), + SnapValue::ThirtySecond => round_to_grid(t, beat * 0.125), + SnapValue::QuarterTriplet => round_to_grid(t, beat * 2.0 / 3.0), + SnapValue::EighthTriplet => round_to_grid(t, beat / 3.0), + SnapValue::SixteenthTriplet => round_to_grid(t, beat / 6.0), + SnapValue::ThirtySecondTriplet => round_to_grid(t, beat / 12.0), + SnapValue::EighthSwingLight => snap_swing(t, beat, 2.0 / 3.0), + SnapValue::SixteenthSwingLight => snap_swing(t, beat * 0.5, 2.0 / 3.0), + SnapValue::EighthSwingHeavy => snap_swing(t, beat, 3.0 / 4.0), + SnapValue::SixteenthSwingHeavy => snap_swing(t, beat * 0.5, 3.0 / 4.0), + } +} + +fn round_to_grid(t: f64, interval: f64) -> f64 { + (t / interval).round() * interval +} + +fn snap_swing(t: f64, cell: f64, ratio: f64) -> f64 { + let cell_n = (t / cell).floor() as i64; + let cell_start = cell_n as f64 * cell; + let cands = [cell_start, cell_start + ratio * cell, cell_start + cell]; + *cands.iter().min_by(|&&a, &&b| (a - t).abs().partial_cmp(&(b - t).abs()).unwrap()).unwrap() +} + +fn detect_snap(notes: &[&ResolvedNote], bpm: f64) -> SnapValue { + const EPS: f64 = 0.002; + if notes.is_empty() { return SnapValue::None; } + let order = [ + SnapValue::Whole, SnapValue::Half, SnapValue::Quarter, + SnapValue::EighthSwingHeavy, SnapValue::EighthSwingLight, SnapValue::Eighth, + SnapValue::SixteenthSwingHeavy, SnapValue::SixteenthSwingLight, + SnapValue::QuarterTriplet, SnapValue::Sixteenth, + SnapValue::EighthTriplet, SnapValue::ThirtySecond, + SnapValue::SixteenthTriplet, SnapValue::ThirtySecondTriplet, + ]; + for &sv in &order { + if notes.iter().all(|n| (snap_to_value(n.start_time, sv, bpm) - n.start_time).abs() < EPS) { + return sv; + } + } + SnapValue::None +} + +impl PianoRollPane { + fn render_midi_mode( &mut self, ui: &mut egui::Ui, @@ -345,6 +450,18 @@ impl PianoRollPane { } } + // Apply quantize if the user changed the snap dropdown (must happen before handle_input + // which may clear the selection when the ComboBox click propagates to the grid). + if self.snap_user_changed { + self.snap_user_changed = false; + if self.snap_value != SnapValue::None && !self.selected_note_indices.is_empty() { + if let Some(clip_id) = self.selected_clip_id { + let bpm = shared.action_executor.document().bpm; + self.quantize_selected_notes(clip_id, bpm, shared); + } + } + } + // Handle input before rendering self.handle_input(ui, grid_rect, keyboard_rect, shared, &clip_data); @@ -1095,7 +1212,9 @@ impl PianoRollPane { // Immediate press detection (fires on the actual press frame, before egui's drag threshold). // This ensures note preview and hit testing use the real press position. - let pointer_just_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); + // Skip when any popup (e.g. ComboBox dropdown) is open so clicks there don't pass through. + let pointer_just_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) + && !ui.ctx().is_popup_open(); if pointer_just_pressed { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if full_rect.contains(pos) { @@ -1255,7 +1374,11 @@ impl PianoRollPane { if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) { let clip_start = selected_clip.1; let trim_start = selected_clip.2; - let clip_local_time = (time - clip_start).max(0.0) + trim_start; + let bpm = shared.action_executor.document().bpm; + let clip_local_time = snap_to_value( + (time - clip_start).max(0.0) + trim_start, + self.snap_value, bpm, + ); self.creating_note = Some(TempNote { note, start_time: clip_local_time, @@ -1266,11 +1389,18 @@ impl PianoRollPane { self.preview_note_on(note, DEFAULT_VELOCITY, None, now, shared); } } else { - // Start selection rectangle + // Start selection rectangle and seek playhead to clicked time self.selected_note_indices.clear(); self.update_focus(shared); self.selection_rect = Some((pos, pos)); self.drag_mode = Some(DragMode::SelectRect); + + let bpm = shared.action_executor.document().bpm; + let seek_time = snap_to_value(time.max(0.0), self.snap_value, bpm); + *shared.playback_time = seek_time; + if let Some(ctrl) = shared.audio_controller.as_ref() { + if let Ok(mut c) = ctrl.lock() { c.seek(seek_time); } + } } } @@ -1652,10 +1782,12 @@ impl PianoRollPane { let resolved = Self::resolve_notes(events); let old_notes = Self::notes_to_backend_format(&resolved); + let bpm = shared.action_executor.document().bpm; let mut new_resolved = resolved.clone(); for &idx in &self.selected_note_indices { if idx < new_resolved.len() { - new_resolved[idx].start_time = (new_resolved[idx].start_time + dt).max(0.0); + let raw_time = (new_resolved[idx].start_time + dt).max(0.0); + new_resolved[idx].start_time = snap_to_value(raw_time, self.snap_value, bpm); new_resolved[idx].note = (new_resolved[idx].note as i32 + dn).clamp(0, 127) as u8; } } @@ -1831,6 +1963,23 @@ impl PianoRollPane { shared.pending_actions.push(Box::new(action)); } + fn quantize_selected_notes(&mut self, clip_id: u32, bpm: f64, shared: &mut SharedPaneState) { + let events = match shared.midi_event_cache.get(&clip_id) { Some(e) => e, None => return }; + let resolved = Self::resolve_notes(events); + let old_notes = Self::notes_to_backend_format(&resolved); + let mut new_resolved = resolved.clone(); + for &idx in &self.selected_note_indices { + if idx < new_resolved.len() { + new_resolved[idx].start_time = + snap_to_value(new_resolved[idx].start_time, self.snap_value, bpm).max(0.0); + } + } + let new_notes = Self::notes_to_backend_format(&new_resolved); + Self::update_cache_from_resolved(clip_id, &new_resolved, shared); + self.push_update_action("Quantize notes", clip_id, old_notes, new_notes, shared, &[]); + self.cached_clip_id = None; + } + fn push_events_action( &self, description: &str, @@ -2256,6 +2405,46 @@ impl PaneRenderer for PianoRollPane { .max_decimals(1), ); } + + // Snap-to dropdown — only in Measures mode + let doc = shared.action_executor.document(); + let is_measures = doc.timeline_mode == lightningbeam_core::document::TimelineMode::Measures; + let bpm = doc.bpm; + drop(doc); + + if is_measures { + // Auto-detect grid when selection changes + if self.selected_note_indices != self.last_snap_selection { + if !self.selected_note_indices.is_empty() { + if let Some(clip_id) = self.selected_clip_id { + if let Some(events) = shared.midi_event_cache.get(&clip_id) { + let resolved = Self::resolve_notes(events); + let sel: Vec<&ResolvedNote> = self.selected_note_indices.iter() + .filter_map(|&i| resolved.get(i)) + .collect(); + self.snap_value = detect_snap(&sel, bpm); + } + } + } + self.last_snap_selection = self.selected_note_indices.clone(); + } + + ui.separator(); + ui.label(egui::RichText::new("Snap to:").color(header_secondary).size(10.0)); + let old_snap = self.snap_value; + egui::ComboBox::from_id_salt("piano_roll_snap") + .selected_text(self.snap_value.label()) + .width(110.0) + .show_ui(ui, |ui| { + for &sv in SnapValue::all() { + ui.selectable_value(&mut self.snap_value, sv, sv.label()); + } + }); + + if self.snap_value != old_snap { + self.snap_user_changed = true; + } + } }); true } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 6eb5f6f..ad3d804 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -219,6 +219,9 @@ pub struct TimelinePane { metronome_icon: Option, /// Count-in pre-roll state: set when count-in is active, cleared when recording fires pending_recording_start: Option, + + /// Layer currently being renamed via inline text edit (layer_id, buffer) + renaming_layer: Option<(uuid::Uuid, String)>, } /// Deferred recording start created during count-in pre-roll @@ -680,6 +683,7 @@ impl TimelinePane { automation_topology_generation: u64::MAX, metronome_icon: None, pending_recording_start: None, + renaming_layer: None, } } @@ -1924,14 +1928,51 @@ impl TimelinePane { name_x_offset = 10.0 + indent + 18.0; } - // Layer name - ui.painter().text( - header_rect.min + egui::vec2(name_x_offset, 10.0), - egui::Align2::LEFT_TOP, - &layer_name, - egui::FontId::proportional(14.0), - text_color, + // Layer name — double-click to rename inline + let name_pos = header_rect.min + egui::vec2(name_x_offset, 4.0); + let name_rect = egui::Rect::from_min_size( + name_pos, + egui::vec2(header_rect.max.x - name_pos.x - 8.0, 22.0), ); + let is_renaming = self.renaming_layer.as_ref().map_or(false, |(id, _)| *id == layer_id); + if is_renaming { + let buf = &mut self.renaming_layer.as_mut().unwrap().1; + let te = egui::TextEdit::singleline(buf) + .font(egui::FontId::proportional(14.0)) + .text_color(text_color) + .frame(false) + .desired_width(name_rect.width()); + let te_resp = ui.put(name_rect, te); + te_resp.request_focus(); + let done = te_resp.lost_focus() + || ui.input(|i| i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Escape)); + if done { + let new_name = self.renaming_layer.take().unwrap().1; + let new_name = new_name.trim().to_string(); + if !new_name.is_empty() && new_name != layer_name { + pending_actions.push(Box::new( + lightningbeam_core::actions::SetLayerPropertiesAction::new( + layer_id, + lightningbeam_core::actions::LayerProperty::Name(new_name), + ) + )); + } + self.layer_control_clicked = true; + } + } else { + let name_resp = ui.allocate_rect(name_rect, egui::Sense::click()); + ui.painter().text( + name_pos + egui::vec2(0.0, 6.0), + egui::Align2::LEFT_TOP, + &layer_name, + egui::FontId::proportional(14.0), + text_color, + ); + if name_resp.double_clicked() { + self.renaming_layer = Some((layer_id, layer_name.clone())); + self.layer_control_clicked = true; + } + } // Layer type (smaller text below name with colored background) let type_text_pos = header_rect.min + egui::vec2(name_x_offset, 28.0); @@ -1972,30 +2013,30 @@ impl TimelinePane { let Some(layer_for_controls) = any_layer_for_controls else { continue; }; - // Layer controls (mute, solo, lock, volume) - let controls_top = header_rect.min.y + 4.0; + // Layer controls: volume slider top-right, buttons below it let controls_right = header_rect.max.x - 8.0; let button_size = egui::vec2(20.0, 20.0); let slider_width = 60.0; - // Position controls from right to left let volume_slider_rect = egui::Rect::from_min_size( - egui::pos2(controls_right - slider_width, controls_top), + egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0), egui::vec2(slider_width, 20.0), ); + // Buttons sit below the slider, right-aligned to match it + let buttons_top = volume_slider_rect.max.y + 4.0; let lock_button_rect = egui::Rect::from_min_size( - egui::pos2(volume_slider_rect.min.x - button_size.x - 4.0, controls_top), + egui::pos2(controls_right - button_size.x, buttons_top), button_size, ); let solo_button_rect = egui::Rect::from_min_size( - egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, controls_top), + egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top), button_size, ); let mute_button_rect = egui::Rect::from_min_size( - egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, controls_top), + egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top), button_size, ); @@ -2123,6 +2164,7 @@ impl TimelinePane { // Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume) // Disabled when the user has edited the Volume automation curve beyond the default single keyframe let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| { + ui.spacing_mut().slider_width = slider_width; // Map volume (0.0-2.0) to slider position (0.0-1.0) let slider_value = if current_volume <= 1.0 { // 0.0-1.0 volume maps to 0.0-0.7 slider (70%) @@ -2185,7 +2227,7 @@ impl TimelinePane { 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::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0), egui::vec2(slider_width, 16.0), ); let current_gain = audio_layer.layer.input_gain; @@ -2215,7 +2257,7 @@ impl TimelinePane { // Label let label_rect = egui::Rect::from_min_size( - egui::pos2(gain_slider_rect.min.x - 26.0, controls_top + 22.0), + egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0), egui::vec2(24.0, 16.0), ); ui.painter().text(