diff --git a/CHANGELOG.md b/CHANGELOG.md index 59eabba..a22d5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to this project will be documented in this file. ### Fixed - PatternProps and EuclideanDistribution modals now use the global theme background instead of the terminal default. +- Changing pattern properties is now a stage/commit operation. +- Changing pattern speed only happens at pattern boundaries. +- `mlockall` warning no longer appears on macOS; memory locking is now Linux-only. ## [0.0.5] - Unreleased diff --git a/src/app.rs b/src/app.rs index 08dea1e..9be8036 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,7 +20,7 @@ use crate::settings::Settings; use crate::state::{ AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal, MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, - PlaybackState, ProjectState, StagedChange, UiState, + PlaybackState, ProjectState, StagedChange, StagedPropChange, UiState, }; use crate::views::{dict_view, help_view}; @@ -499,13 +499,14 @@ impl App { } } - /// Commits staged pattern and mute/solo changes. + /// Commits staged pattern, mute/solo, and prop changes. /// Returns true if mute state changed (caller should send to sequencer). pub fn commit_staged_changes(&mut self) -> bool { let pattern_count = self.playback.staged_changes.len(); let mute_count = self.playback.staged_mute_changes.len(); + let prop_count = self.playback.staged_prop_changes.len(); - if pattern_count == 0 && mute_count == 0 { + if pattern_count == 0 && mute_count == 0 && prop_count == 0 { self.ui.set_status("No changes to commit".to_string()); return false; } @@ -530,11 +531,21 @@ impl App { } } - let status = match (pattern_count, mute_count) { - (0, m) => format!("Applied {m} mute/solo changes"), - (p, 0) => format!("Committed {p} pattern changes"), - (p, m) => format!("Committed {p} pattern + {m} mute/solo changes"), - }; + // Apply staged prop changes + for ((bank, pattern), props) in self.playback.staged_prop_changes.drain() { + let pat = self.project_state.project.pattern_at_mut(bank, pattern); + pat.name = props.name; + if let Some(len) = props.length { + pat.set_length(len); + } + pat.speed = props.speed; + pat.quantization = props.quantization; + pat.sync_mode = props.sync_mode; + self.project_state.mark_dirty(bank, pattern); + } + + let total = pattern_count + mute_count + prop_count; + let status = format!("Committed {total} changes"); self.ui.set_status(status); mute_changed @@ -543,19 +554,18 @@ impl App { pub fn clear_staged_changes(&mut self) { let pattern_count = self.playback.staged_changes.len(); let mute_count = self.playback.staged_mute_changes.len(); + let prop_count = self.playback.staged_prop_changes.len(); - if pattern_count == 0 && mute_count == 0 { + if pattern_count == 0 && mute_count == 0 && prop_count == 0 { return; } self.playback.staged_changes.clear(); self.playback.staged_mute_changes.clear(); + self.playback.staged_prop_changes.clear(); - let status = match (pattern_count, mute_count) { - (0, m) => format!("Cleared {m} staged mute/solo"), - (p, 0) => format!("Cleared {p} staged patterns"), - (p, m) => format!("Cleared {p} patterns + {m} mute/solo"), - }; + let total = pattern_count + mute_count + prop_count; + let status = format!("Cleared {total} staged changes"); self.ui.set_status(status); } @@ -1189,7 +1199,7 @@ impl App { AppCommand::OpenPatternPropsModal { bank, pattern } => { self.open_pattern_props_modal(bank, pattern); } - AppCommand::SetPatternProps { + AppCommand::StagePatternProps { bank, pattern, name, @@ -1198,15 +1208,21 @@ impl App { quantization, sync_mode, } => { - let pat = self.project_state.project.pattern_at_mut(bank, pattern); - pat.name = name; - if let Some(len) = length { - pat.set_length(len); - } - pat.speed = speed; - pat.quantization = quantization; - pat.sync_mode = sync_mode; - self.project_state.mark_dirty(bank, pattern); + self.playback.staged_prop_changes.insert( + (bank, pattern), + StagedPropChange { + name, + length, + speed, + quantization, + sync_mode, + }, + ); + self.ui.set_status(format!( + "B{:02}:P{:02} props staged", + bank + 1, + pattern + 1 + )); } // Page navigation diff --git a/src/commands.rs b/src/commands.rs index d8bdb26..c970a7d 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -106,7 +106,7 @@ pub enum AppCommand { bank: usize, pattern: usize, }, - SetPatternProps { + StagePatternProps { bank: usize, pattern: usize, name: Option, diff --git a/src/engine/realtime.rs b/src/engine/realtime.rs index 983102c..f2f8222 100644 --- a/src/engine/realtime.rs +++ b/src/engine/realtime.rs @@ -1,45 +1,52 @@ -use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(target_os = "linux")] +mod memory { + use std::sync::atomic::{AtomicBool, Ordering}; -static MLOCKALL_CALLED: AtomicBool = AtomicBool::new(false); -static MLOCKALL_SUCCESS: AtomicBool = AtomicBool::new(false); + static MLOCKALL_CALLED: AtomicBool = AtomicBool::new(false); + static MLOCKALL_SUCCESS: AtomicBool = AtomicBool::new(false); -/// Locks all current and future memory pages to prevent page faults during RT execution. -/// Must be called BEFORE spawning any threads for maximum effectiveness. -/// Returns true if mlockall succeeded, false otherwise (which is common without rtprio). -#[cfg(unix)] -pub fn lock_memory() -> bool { - if MLOCKALL_CALLED.swap(true, Ordering::SeqCst) { - return MLOCKALL_SUCCESS.load(Ordering::SeqCst); + /// Locks all current and future memory pages to prevent page faults during RT execution. + /// Must be called BEFORE spawning any threads for maximum effectiveness. + pub fn lock_memory() -> bool { + if MLOCKALL_CALLED.swap(true, Ordering::SeqCst) { + return MLOCKALL_SUCCESS.load(Ordering::SeqCst); + } + + let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) }; + + if result == 0 { + MLOCKALL_SUCCESS.store(true, Ordering::SeqCst); + true + } else { + let errno = std::io::Error::last_os_error(); + eprintln!("[cagire] mlockall failed: {errno}"); + eprintln!("[cagire] Memory locking disabled. For best RT performance on Linux:"); + eprintln!("[cagire] 1. Add user to 'audio' group: sudo usermod -aG audio $USER"); + eprintln!("[cagire] 2. Add to /etc/security/limits.conf:"); + eprintln!("[cagire] @audio - memlock unlimited"); + eprintln!("[cagire] 3. Log out and back in"); + false + } } - let result = unsafe { libc::mlockall(libc::MCL_CURRENT | libc::MCL_FUTURE) }; - - if result == 0 { - MLOCKALL_SUCCESS.store(true, Ordering::SeqCst); - true - } else { - // Get the actual error for better diagnostics - let errno = std::io::Error::last_os_error(); - eprintln!("[cagire] mlockall failed: {errno}"); - eprintln!("[cagire] Memory locking disabled. For best RT performance on Linux:"); - eprintln!("[cagire] 1. Add user to 'audio' group: sudo usermod -aG audio $USER"); - eprintln!("[cagire] 2. Add to /etc/security/limits.conf:"); - eprintln!("[cagire] @audio - memlock unlimited"); - eprintln!("[cagire] 3. Log out and back in"); - false + #[allow(dead_code)] + pub fn is_memory_locked() -> bool { + MLOCKALL_SUCCESS.load(Ordering::Relaxed) } } -#[cfg(not(unix))] +#[cfg(target_os = "linux")] +pub use memory::{is_memory_locked, lock_memory}; + +#[cfg(not(target_os = "linux"))] pub fn lock_memory() -> bool { - // Windows: VirtualLock exists but isn't typically needed for audio true } -/// Check if memory locking is active. +#[cfg(not(target_os = "linux"))] #[allow(dead_code)] pub fn is_memory_locked() -> bool { - MLOCKALL_SUCCESS.load(Ordering::Relaxed) + false } /// Attempts to set realtime scheduling priority for the current thread. diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 174316f..f61a372 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -531,6 +531,7 @@ impl KeyCache { pub(crate) struct SequencerState { audio_state: AudioState, pattern_cache: PatternCache, + pending_updates: HashMap<(usize, usize), PatternSnapshot>, runs_counter: RunsCounter, step_traces: Arc, event_count: usize, @@ -559,6 +560,7 @@ impl SequencerState { Self { audio_state: AudioState::new(), pattern_cache: PatternCache::new(), + pending_updates: HashMap::new(), runs_counter: RunsCounter::new(), step_traces: Arc::new(HashMap::new()), event_count: 0, @@ -596,7 +598,14 @@ impl SequencerState { pattern, data, } => { - self.pattern_cache.set(bank, pattern, data); + let id = PatternId { bank, pattern }; + let is_active = self.audio_state.active_patterns.contains_key(&id); + let has_cache = self.pattern_cache.get(bank, pattern).is_some(); + if is_active && has_cache { + self.pending_updates.insert((bank, pattern), data); + } else { + self.pattern_cache.set(bank, pattern, data); + } } SeqCommand::PatternStart { bank, @@ -652,6 +661,10 @@ impl SequencerState { } } SeqCommand::StopAll => { + // Flush pending updates so cache stays current for future launches + for ((bank, pattern), snapshot) in self.pending_updates.drain() { + self.pattern_cache.set(bank, pattern, snapshot); + } self.audio_state.active_patterns.clear(); self.audio_state.pending_starts.clear(); self.audio_state.pending_stops.clear(); @@ -715,6 +728,10 @@ impl SequencerState { Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); + let key = (pending.id.bank, pending.id.pattern); + if let Some(snapshot) = self.pending_updates.remove(&key) { + self.pattern_cache.set(key.0, key.1, snapshot); + } } self.audio_state.pending_starts.clear(); self.audio_state.prev_beat = -1.0; @@ -773,6 +790,11 @@ impl SequencerState { Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| { bank != pending.id.bank || pattern != pending.id.pattern }); + // Flush pending update so cache stays current for future launches + let key = (pending.id.bank, pending.id.pattern); + if let Some(snapshot) = self.pending_updates.remove(&key) { + self.pattern_cache.set(key.0, key.1, snapshot); + } self.buf_stopped.push(pending.id); } } @@ -921,6 +943,14 @@ impl SequencerState { } } + // Apply deferred updates for patterns that just completed an iteration + for completed_id in &self.buf_completed_iterations { + let key = (completed_id.bank, completed_id.pattern); + if let Some(snapshot) = self.pending_updates.remove(&key) { + self.pattern_cache.set(key.0, key.1, snapshot); + } + } + result } @@ -1872,8 +1902,9 @@ mod tests { let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); - // Update pattern to length 2 while running — step_index wraps via modulo - // beat=1.25: beat_int=5, prev=4, fires 1 step. step_index=0%2=0 fires, advances to 1 + // Update pattern to length 2 while running — deferred until iteration boundary + // beat=1.25: update is deferred (pattern active), still length 4 + // step_index=0 fires, advances to 1 state.tick(tick_with( vec![SeqCommand::PatternUpdate { bank: 0, @@ -1883,10 +1914,21 @@ mod tests { 1.25, )); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); + assert_eq!(ap.step_index, 1); // still length 4 + + // Advance through remaining steps of original length-4 pattern + state.tick(tick_at(1.5, true)); // step 1→2 + state.tick(tick_at(1.75, true)); // step 2→3 + state.tick(tick_at(2.0, true)); // step 3→wraps to 0, iteration completes, update applies + + // Now length=2 is applied. Next tick uses new length. + // beat=2.25: step 0 fires, advances to 1 + state.tick(tick_at(2.25, true)); + let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 1); - // beat=1.5: beat_int=6, prev=5, step fires. step_index=1 fires, wraps to 0 - state.tick(tick_at(1.5, true)); + // beat=2.5: step 1 fires, wraps to 0 (length 2) + state.tick(tick_at(2.5, true)); let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap(); assert_eq!(ap.step_index, 0); } diff --git a/src/input.rs b/src/input.rs index d2aff3a..3b3f58b 100644 --- a/src/input.rs +++ b/src/input.rs @@ -615,7 +615,7 @@ fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { let speed_val = *speed; let quant_val = *quantization; let sync_val = *sync_mode; - ctx.dispatch(AppCommand::SetPatternProps { + ctx.dispatch(AppCommand::StagePatternProps { bank, pattern, name: name_val, @@ -1096,6 +1096,7 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { KeyCode::Esc => { if !ctx.app.playback.staged_changes.is_empty() || !ctx.app.playback.staged_mute_changes.is_empty() + || !ctx.app.playback.staged_prop_changes.is_empty() { ctx.dispatch(AppCommand::ClearStagedChanges); } else { diff --git a/src/state/mod.rs b/src/state/mod.rs index 52b4afa..884e1d5 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -41,7 +41,7 @@ pub use options::{OptionsFocus, OptionsState}; pub use panel::{PanelFocus, PanelState, SidePanel}; pub use patterns_nav::{PatternsColumn, PatternsNav}; pub use mute::MuteState; -pub use playback::{PlaybackState, StagedChange, StagedMuteChange}; +pub use playback::{PlaybackState, StagedChange, StagedMuteChange, StagedPropChange}; pub use project::ProjectState; pub use sample_browser::SampleBrowserState; pub use ui::{DictFocus, FlashKind, HelpFocus, UiState}; diff --git a/src/state/playback.rs b/src/state/playback.rs index 651b8b9..880a608 100644 --- a/src/state/playback.rs +++ b/src/state/playback.rs @@ -1,6 +1,6 @@ use crate::engine::PatternChange; -use crate::model::{LaunchQuantization, SyncMode}; -use std::collections::HashSet; +use crate::model::{LaunchQuantization, PatternSpeed, SyncMode}; +use std::collections::{HashMap, HashSet}; #[derive(Clone)] pub struct StagedChange { @@ -15,11 +15,20 @@ pub enum StagedMuteChange { ToggleSolo { bank: usize, pattern: usize }, } +pub struct StagedPropChange { + pub name: Option, + pub length: Option, + pub speed: PatternSpeed, + pub quantization: LaunchQuantization, + pub sync_mode: SyncMode, +} + pub struct PlaybackState { pub playing: bool, pub staged_changes: Vec, pub queued_changes: Vec, pub staged_mute_changes: HashSet, + pub staged_prop_changes: HashMap<(usize, usize), StagedPropChange>, } impl Default for PlaybackState { @@ -29,6 +38,7 @@ impl Default for PlaybackState { staged_changes: Vec::new(), queued_changes: Vec::new(), staged_mute_changes: HashSet::new(), + staged_prop_changes: HashMap::new(), } } } @@ -41,6 +51,11 @@ impl PlaybackState { pub fn clear_queues(&mut self) { self.staged_changes.clear(); self.queued_changes.clear(); + self.staged_prop_changes.clear(); + } + + pub fn has_staged_props(&self, bank: usize, pattern: usize) -> bool { + self.staged_prop_changes.contains_key(&(bank, pattern)) } pub fn stage_mute(&mut self, bank: usize, pattern: usize) { diff --git a/src/views/patterns_view.rs b/src/views/patterns_view.rs index 4c07585..0879f29 100644 --- a/src/views/patterns_view.rs +++ b/src/views/patterns_view.rs @@ -285,6 +285,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a // Staged mute/solo (will toggle on commit) let has_staged_mute = app.playback.has_staged_mute(bank, idx); let has_staged_solo = app.playback.has_staged_solo(bank, idx); + let has_staged_props = app.playback.has_staged_props(bank, idx); // Preview state (what it will be after commit) let preview_muted = is_muted ^ has_staged_mute; @@ -386,13 +387,14 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a spans.push(Span::styled(format!(" {name}"), name_style)); } - // Right-aligned info: length and speed + // Right-aligned info: length, speed, and staged props indicator let speed_str = if speed != PatternSpeed::NORMAL { format!(" {}", speed.label()) } else { String::new() }; - let right_info = format!("{length}{speed_str}"); + let props_indicator = if has_staged_props { "~" } else { "" }; + let right_info = format!("{props_indicator}{length}{speed_str}"); let left_width: usize = spans.iter().map(|s| s.content.chars().count()).sum(); let right_width = right_info.chars().count(); let padding = (text_area.width as usize).saturating_sub(left_width + right_width + 1);