Add piano roll note snapping
This commit is contained in:
parent
123fe3f21a
commit
65a550d8f4
|
|
@ -1673,6 +1673,7 @@ impl Engine {
|
||||||
Ok(json) => {
|
Ok(json) => {
|
||||||
match crate::audio::node_graph::preset::GraphPreset::from_json(&json) {
|
match crate::audio::node_graph::preset::GraphPreset::from_json(&json) {
|
||||||
Ok(preset) => {
|
Ok(preset) => {
|
||||||
|
let preset_name = preset.metadata.name.clone();
|
||||||
// Extract the directory path from the preset path for resolving relative sample paths
|
// Extract the directory path from the preset path for resolving relative sample paths
|
||||||
let preset_base_path = std::path::Path::new(&preset_path).parent();
|
let preset_base_path = std::path::Path::new(&preset_path).parent();
|
||||||
|
|
||||||
|
|
@ -1684,19 +1685,19 @@ impl Engine {
|
||||||
track.instrument_graph = graph;
|
track.instrument_graph = graph;
|
||||||
track.graph_is_default = true;
|
track.graph_is_default = true;
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
|
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)) => {
|
Some(TrackNode::Audio(track)) => {
|
||||||
track.effects_graph = graph;
|
track.effects_graph = graph;
|
||||||
track.graph_is_default = true;
|
track.graph_is_default = true;
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
|
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)) => {
|
Some(TrackNode::Group(track)) => {
|
||||||
track.audio_graph = graph;
|
track.audio_graph = graph;
|
||||||
track.graph_is_default = true;
|
track.graph_is_default = true;
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
|
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) => {
|
Command::GraphLoadLbins(track_id, path) => {
|
||||||
match crate::audio::node_graph::lbins::load_lbins(&path) {
|
match crate::audio::node_graph::lbins::load_lbins(&path) {
|
||||||
Ok((preset, assets)) => {
|
Ok((preset, assets)) => {
|
||||||
|
let preset_name = preset.metadata.name.clone();
|
||||||
match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) {
|
match AudioGraph::from_preset(&preset, self.sample_rate, 8192, None, Some(&assets)) {
|
||||||
Ok(graph) => {
|
Ok(graph) => {
|
||||||
match self.project.get_track_mut(track_id) {
|
match self.project.get_track_mut(track_id) {
|
||||||
|
|
@ -1736,19 +1738,19 @@ impl Engine {
|
||||||
track.instrument_graph = graph;
|
track.instrument_graph = graph;
|
||||||
track.graph_is_default = true;
|
track.graph_is_default = true;
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
|
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)) => {
|
Some(TrackNode::Audio(track)) => {
|
||||||
track.effects_graph = graph;
|
track.effects_graph = graph;
|
||||||
track.graph_is_default = true;
|
track.graph_is_default = true;
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
|
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)) => {
|
Some(TrackNode::Group(track)) => {
|
||||||
track.audio_graph = graph;
|
track.audio_graph = graph;
|
||||||
track.graph_is_default = true;
|
track.graph_is_default = true;
|
||||||
let _ = self.event_tx.push(AudioEvent::GraphStateChanged(track_id));
|
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));
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -310,8 +310,8 @@ pub enum AudioEvent {
|
||||||
GraphConnectionError(TrackId, String),
|
GraphConnectionError(TrackId, String),
|
||||||
/// Graph state changed (for full UI sync)
|
/// Graph state changed (for full UI sync)
|
||||||
GraphStateChanged(TrackId),
|
GraphStateChanged(TrackId),
|
||||||
/// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded
|
/// Preset fully loaded (track_id, preset_name) - emitted after all nodes and samples are loaded
|
||||||
GraphPresetLoaded(TrackId),
|
GraphPresetLoaded(TrackId, String),
|
||||||
/// Preset has been saved to file (track_id, preset_path)
|
/// Preset has been saved to file (track_id, preset_path)
|
||||||
GraphPresetSaved(TrackId, String),
|
GraphPresetSaved(TrackId, String),
|
||||||
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source)
|
/// Script compilation result (track_id, node_id, success, error, ui_declaration, source)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ pub enum LayerProperty {
|
||||||
Visible(bool),
|
Visible(bool),
|
||||||
/// Video layer only: toggle live webcam preview
|
/// Video layer only: toggle live webcam preview
|
||||||
CameraEnabled(bool),
|
CameraEnabled(bool),
|
||||||
|
/// Rename the layer; sets has_custom_name = true
|
||||||
|
Name(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stored old value for rollback
|
/// Stored old value for rollback
|
||||||
|
|
@ -33,6 +35,7 @@ enum OldValue {
|
||||||
Opacity(f64),
|
Opacity(f64),
|
||||||
Visible(bool),
|
Visible(bool),
|
||||||
CameraEnabled(bool),
|
CameraEnabled(bool),
|
||||||
|
Name(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Action that sets a property on one or more layers
|
/// Action that sets a property on one or more layers
|
||||||
|
|
@ -101,6 +104,7 @@ impl Action for SetLayerPropertiesAction {
|
||||||
};
|
};
|
||||||
OldValue::CameraEnabled(val)
|
OldValue::CameraEnabled(val)
|
||||||
}
|
}
|
||||||
|
LayerProperty::Name(_) => OldValue::Name(layer.name().to_string()),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +122,10 @@ impl Action for SetLayerPropertiesAction {
|
||||||
v.camera_enabled = *c;
|
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;
|
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::InputGain(_) => "input gain",
|
||||||
LayerProperty::Muted(_) => "mute",
|
LayerProperty::Muted(_) => "mute",
|
||||||
LayerProperty::Soloed(_) => "solo",
|
LayerProperty::Soloed(_) => "solo",
|
||||||
|
LayerProperty::Name(_) => "name",
|
||||||
LayerProperty::Locked(_) => "lock",
|
LayerProperty::Locked(_) => "lock",
|
||||||
LayerProperty::Opacity(_) => "opacity",
|
LayerProperty::Opacity(_) => "opacity",
|
||||||
LayerProperty::Visible(_) => "visibility",
|
LayerProperty::Visible(_) => "visibility",
|
||||||
|
|
|
||||||
|
|
@ -5267,7 +5267,7 @@ impl eframe::App for EditorApp {
|
||||||
self.waveform_gpu_dirty.insert(pool_index);
|
self.waveform_gpu_dirty.insert(pool_index);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::GraphPresetLoaded(_track_id) => {
|
AudioEvent::GraphPresetLoaded(track_id, preset_name) => {
|
||||||
// Preset was loaded on the audio thread — bump generation
|
// Preset was loaded on the audio thread — bump generation
|
||||||
// so the node graph pane reloads from backend
|
// so the node graph pane reloads from backend
|
||||||
self.project_generation += 1;
|
self.project_generation += 1;
|
||||||
|
|
@ -5278,6 +5278,17 @@ impl eframe::App for EditorApp {
|
||||||
std::sync::atomic::Ordering::Relaxed,
|
std::sync::atomic::Ordering::Relaxed,
|
||||||
|v| if v > 0 { Some(v - 1) } else { Some(0) },
|
|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();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
AudioEvent::InputLevel(peak) => {
|
AudioEvent::InputLevel(peak) => {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,48 @@ enum PitchBendZone {
|
||||||
End, // Last 30%: ramp from 0 → bend
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
enum DragMode {
|
enum DragMode {
|
||||||
MoveNotes { start_time_offset: f64, start_note_offset: i32 },
|
MoveNotes { start_time_offset: f64, start_note_offset: i32 },
|
||||||
|
|
@ -122,6 +164,11 @@ pub struct PianoRollPane {
|
||||||
pitch_bend_range: f32,
|
pitch_bend_range: f32,
|
||||||
// Layer ID for which pitch_bend_range was last queried
|
// Layer ID for which pitch_bend_range was last queried
|
||||||
pitch_bend_range_layer: Option<uuid::Uuid>,
|
pitch_bend_range_layer: Option<uuid::Uuid>,
|
||||||
|
|
||||||
|
// Snap / quantize
|
||||||
|
snap_value: SnapValue,
|
||||||
|
last_snap_selection: HashSet<usize>,
|
||||||
|
snap_user_changed: bool, // set in render_header, consumed before handle_input
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PianoRollPane {
|
impl PianoRollPane {
|
||||||
|
|
@ -155,6 +202,9 @@ impl PianoRollPane {
|
||||||
header_mod: 0.0,
|
header_mod: 0.0,
|
||||||
pitch_bend_range: 2.0,
|
pitch_bend_range: 2.0,
|
||||||
pitch_bend_range_layer: None,
|
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 ──────────────────────────────────────────────
|
// ── 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(
|
fn render_midi_mode(
|
||||||
&mut self,
|
&mut self,
|
||||||
ui: &mut egui::Ui,
|
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
|
// Handle input before rendering
|
||||||
self.handle_input(ui, grid_rect, keyboard_rect, shared, &clip_data);
|
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).
|
// 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.
|
// 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 pointer_just_pressed {
|
||||||
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
||||||
if full_rect.contains(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) {
|
if let Some(selected_clip) = clip_data.iter().find(|c| Some(c.0) == self.selected_clip_id) {
|
||||||
let clip_start = selected_clip.1;
|
let clip_start = selected_clip.1;
|
||||||
let trim_start = selected_clip.2;
|
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 {
|
self.creating_note = Some(TempNote {
|
||||||
note,
|
note,
|
||||||
start_time: clip_local_time,
|
start_time: clip_local_time,
|
||||||
|
|
@ -1266,11 +1389,18 @@ impl PianoRollPane {
|
||||||
self.preview_note_on(note, DEFAULT_VELOCITY, None, now, shared);
|
self.preview_note_on(note, DEFAULT_VELOCITY, None, now, shared);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Start selection rectangle
|
// Start selection rectangle and seek playhead to clicked time
|
||||||
self.selected_note_indices.clear();
|
self.selected_note_indices.clear();
|
||||||
self.update_focus(shared);
|
self.update_focus(shared);
|
||||||
self.selection_rect = Some((pos, pos));
|
self.selection_rect = Some((pos, pos));
|
||||||
self.drag_mode = Some(DragMode::SelectRect);
|
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 resolved = Self::resolve_notes(events);
|
||||||
let old_notes = Self::notes_to_backend_format(&resolved);
|
let old_notes = Self::notes_to_backend_format(&resolved);
|
||||||
|
|
||||||
|
let bpm = shared.action_executor.document().bpm;
|
||||||
let mut new_resolved = resolved.clone();
|
let mut new_resolved = resolved.clone();
|
||||||
for &idx in &self.selected_note_indices {
|
for &idx in &self.selected_note_indices {
|
||||||
if idx < new_resolved.len() {
|
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;
|
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));
|
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(
|
fn push_events_action(
|
||||||
&self,
|
&self,
|
||||||
description: &str,
|
description: &str,
|
||||||
|
|
@ -2256,6 +2405,46 @@ impl PaneRenderer for PianoRollPane {
|
||||||
.max_decimals(1),
|
.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
|
true
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,9 @@ pub struct TimelinePane {
|
||||||
metronome_icon: Option<egui::TextureHandle>,
|
metronome_icon: Option<egui::TextureHandle>,
|
||||||
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires
|
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires
|
||||||
pending_recording_start: Option<PendingRecordingStart>,
|
pending_recording_start: Option<PendingRecordingStart>,
|
||||||
|
|
||||||
|
/// 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
|
/// Deferred recording start created during count-in pre-roll
|
||||||
|
|
@ -680,6 +683,7 @@ impl TimelinePane {
|
||||||
automation_topology_generation: u64::MAX,
|
automation_topology_generation: u64::MAX,
|
||||||
metronome_icon: None,
|
metronome_icon: None,
|
||||||
pending_recording_start: None,
|
pending_recording_start: None,
|
||||||
|
renaming_layer: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1924,14 +1928,51 @@ impl TimelinePane {
|
||||||
name_x_offset = 10.0 + indent + 18.0;
|
name_x_offset = 10.0 + indent + 18.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layer name
|
// Layer name — double-click to rename inline
|
||||||
ui.painter().text(
|
let name_pos = header_rect.min + egui::vec2(name_x_offset, 4.0);
|
||||||
header_rect.min + egui::vec2(name_x_offset, 10.0),
|
let name_rect = egui::Rect::from_min_size(
|
||||||
egui::Align2::LEFT_TOP,
|
name_pos,
|
||||||
&layer_name,
|
egui::vec2(header_rect.max.x - name_pos.x - 8.0, 22.0),
|
||||||
egui::FontId::proportional(14.0),
|
|
||||||
text_color,
|
|
||||||
);
|
);
|
||||||
|
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)
|
// Layer type (smaller text below name with colored background)
|
||||||
let type_text_pos = header_rect.min + egui::vec2(name_x_offset, 28.0);
|
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; };
|
let Some(layer_for_controls) = any_layer_for_controls else { continue; };
|
||||||
|
|
||||||
// Layer controls (mute, solo, lock, volume)
|
// Layer controls: volume slider top-right, buttons below it
|
||||||
let controls_top = header_rect.min.y + 4.0;
|
|
||||||
let controls_right = header_rect.max.x - 8.0;
|
let controls_right = header_rect.max.x - 8.0;
|
||||||
let button_size = egui::vec2(20.0, 20.0);
|
let button_size = egui::vec2(20.0, 20.0);
|
||||||
let slider_width = 60.0;
|
let slider_width = 60.0;
|
||||||
|
|
||||||
// Position controls from right to left
|
|
||||||
let volume_slider_rect = egui::Rect::from_min_size(
|
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),
|
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(
|
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,
|
button_size,
|
||||||
);
|
);
|
||||||
|
|
||||||
let solo_button_rect = egui::Rect::from_min_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,
|
button_size,
|
||||||
);
|
);
|
||||||
|
|
||||||
let mute_button_rect = egui::Rect::from_min_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,
|
button_size,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -2123,6 +2164,7 @@ impl TimelinePane {
|
||||||
// Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume)
|
// 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
|
// 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| {
|
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)
|
// Map volume (0.0-2.0) to slider position (0.0-1.0)
|
||||||
let slider_value = if current_volume <= 1.0 {
|
let slider_value = if current_volume <= 1.0 {
|
||||||
// 0.0-1.0 volume maps to 0.0-0.7 slider (70%)
|
// 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 let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls {
|
||||||
if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
|
if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
|
||||||
let gain_slider_rect = egui::Rect::from_min_size(
|
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),
|
egui::vec2(slider_width, 16.0),
|
||||||
);
|
);
|
||||||
let current_gain = audio_layer.layer.input_gain;
|
let current_gain = audio_layer.layer.input_gain;
|
||||||
|
|
@ -2215,7 +2257,7 @@ impl TimelinePane {
|
||||||
|
|
||||||
// Label
|
// Label
|
||||||
let label_rect = egui::Rect::from_min_size(
|
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),
|
egui::vec2(24.0, 16.0),
|
||||||
);
|
);
|
||||||
ui.painter().text(
|
ui.painter().text(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue