Compare commits
6 Commits
772d21a8ed
...
e4799c1f42
| Author | SHA1 | Date | |
|---|---|---|---|
| e4799c1f42 | |||
| 636129688d | |||
| a2ee0e5a50 | |||
| 96ed74c6fe | |||
| a67d982fcd | |||
| c9ab7a4f0b |
@@ -2,6 +2,12 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.0.5] - Unreleased
|
||||
|
||||
### Added
|
||||
- Mute/solo for patterns: stage with `m`/`x`, commit with `c`. Solo mutes all other patterns. Clear with `M`/`X`.
|
||||
- Lookahead scheduling: scripts are pre-evaluated ahead of time and audio commands are scheduled at precise beat positions, improving timing accuracy under CPU load.
|
||||
|
||||
## [0.0.4] - 2026-02-02
|
||||
|
||||
### Added
|
||||
@@ -19,6 +25,8 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
### Fixed
|
||||
- Scope/spectrum ratio asymmetry in Left/Right layout modes.
|
||||
- Updated `cpal` dependency from 0.15 to 0.17 to fix type mismatch with `doux` audio backend.
|
||||
- Copy/paste (Ctrl+C/V/X) not working in desktop version due to egui intercepting clipboard shortcuts.
|
||||
|
||||
## [0.0.3] - 2026-02-02
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.0.3"
|
||||
version = "0.0.4"
|
||||
edition = "2021"
|
||||
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
|
||||
license = "AGPL-3.0"
|
||||
@@ -53,7 +53,7 @@ doux = { git = "https://github.com/sova-org/doux", features = ["native"] }
|
||||
rusty_link = "0.4"
|
||||
ratatui = "0.30"
|
||||
crossterm = "0.29"
|
||||
cpal = "0.15"
|
||||
cpal = "0.17"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
rand = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -3,8 +3,17 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MuteStatus {
|
||||
Normal,
|
||||
Muted,
|
||||
Soloed,
|
||||
EffectivelyMuted, // Solo active on another pattern
|
||||
}
|
||||
|
||||
pub struct ActivePatterns<'a> {
|
||||
patterns: &'a [(usize, usize, usize)], // (bank, pattern, iter)
|
||||
mute_status: Option<&'a [MuteStatus]>,
|
||||
current_step: Option<(usize, usize)>, // (current_step, total_steps)
|
||||
}
|
||||
|
||||
@@ -12,6 +21,7 @@ impl<'a> ActivePatterns<'a> {
|
||||
pub fn new(patterns: &'a [(usize, usize, usize)]) -> Self {
|
||||
Self {
|
||||
patterns,
|
||||
mute_status: None,
|
||||
current_step: None,
|
||||
}
|
||||
}
|
||||
@@ -20,6 +30,11 @@ impl<'a> ActivePatterns<'a> {
|
||||
self.current_step = Some((current, total));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_mute_status(mut self, status: &'a [MuteStatus]) -> Self {
|
||||
self.mute_status = Some(status);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for ActivePatterns<'_> {
|
||||
@@ -39,20 +54,36 @@ impl Widget for ActivePatterns<'_> {
|
||||
if row >= max_pattern_rows {
|
||||
break;
|
||||
}
|
||||
let text = format!("B{:02}:{:02} ({:02})", bank + 1, pattern + 1, iter.min(99));
|
||||
let y = area.y + row as u16;
|
||||
let bg = if row % 2 == 0 {
|
||||
theme.table.row_even
|
||||
} else {
|
||||
theme.table.row_odd
|
||||
|
||||
let mute_status = self
|
||||
.mute_status
|
||||
.and_then(|s| s.get(row))
|
||||
.copied()
|
||||
.unwrap_or(MuteStatus::Normal);
|
||||
|
||||
let (prefix, fg, bg) = match mute_status {
|
||||
MuteStatus::Soloed => ("S", theme.list.soloed_fg, theme.list.soloed_bg),
|
||||
MuteStatus::Muted => ("M", theme.list.muted_fg, theme.list.muted_bg),
|
||||
MuteStatus::EffectivelyMuted => (" ", theme.list.muted_fg, theme.list.muted_bg),
|
||||
MuteStatus::Normal => {
|
||||
let bg = if row % 2 == 0 {
|
||||
theme.table.row_even
|
||||
} else {
|
||||
theme.table.row_odd
|
||||
};
|
||||
(" ", theme.ui.text_primary, bg)
|
||||
}
|
||||
};
|
||||
|
||||
let text = format!("{}B{:02}:{:02}({:02})", prefix, bank + 1, pattern + 1, iter.min(99));
|
||||
let y = area.y + row as u16;
|
||||
|
||||
let mut chars = text.chars();
|
||||
for col in 0..area.width as usize {
|
||||
let ch = chars.next().unwrap_or(' ');
|
||||
buf[(area.x + col as u16, y)]
|
||||
.set_char(ch)
|
||||
.set_fg(theme.ui.text_primary)
|
||||
.set_fg(fg)
|
||||
.set_bg(bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ mod text_input;
|
||||
pub mod theme;
|
||||
mod vu_meter;
|
||||
|
||||
pub use active_patterns::ActivePatterns;
|
||||
pub use active_patterns::{ActivePatterns, MuteStatus};
|
||||
pub use confirm::ConfirmModal;
|
||||
pub use editor::{CompletionCandidate, Editor};
|
||||
pub use file_browser::FileBrowserModal;
|
||||
|
||||
@@ -123,6 +123,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: teal,
|
||||
hover_bg: surface1,
|
||||
hover_fg: text,
|
||||
muted_bg: Color::Rgb(215, 215, 225),
|
||||
muted_fg: overlay0,
|
||||
soloed_bg: Color::Rgb(250, 235, 200),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
|
||||
@@ -123,6 +123,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: teal,
|
||||
hover_bg: surface1,
|
||||
hover_fg: text,
|
||||
muted_bg: Color::Rgb(40, 40, 50),
|
||||
muted_fg: overlay0,
|
||||
soloed_bg: Color::Rgb(60, 55, 35),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
|
||||
@@ -117,6 +117,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: cyan,
|
||||
hover_bg: lighter_bg,
|
||||
hover_fg: foreground,
|
||||
muted_bg: Color::Rgb(50, 52, 65),
|
||||
muted_fg: comment,
|
||||
soloed_bg: Color::Rgb(70, 70, 50),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
|
||||
@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: mint,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(75, 70, 95),
|
||||
muted_fg: muted,
|
||||
soloed_bg: Color::Rgb(100, 95, 65),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: coral,
|
||||
|
||||
@@ -119,6 +119,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: aqua,
|
||||
hover_bg: bg2,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(50, 50, 55),
|
||||
muted_fg: fg4,
|
||||
soloed_bg: Color::Rgb(70, 65, 40),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
|
||||
@@ -114,6 +114,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: yellow,
|
||||
hover_bg: Color::Rgb(230, 50, 50),
|
||||
hover_fg: yellow,
|
||||
muted_bg: darker_red,
|
||||
muted_fg: dim_yellow,
|
||||
soloed_bg: Color::Rgb(200, 200, 0),
|
||||
soloed_fg: black,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: white,
|
||||
|
||||
@@ -119,6 +119,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: crystal_blue,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(38, 38, 48),
|
||||
muted_fg: comment,
|
||||
soloed_bg: Color::Rgb(60, 55, 45),
|
||||
soloed_fg: carp_yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: autumn_red,
|
||||
|
||||
@@ -187,6 +187,10 @@ pub struct ListColors {
|
||||
pub edit_fg: Color,
|
||||
pub hover_bg: Color,
|
||||
pub hover_fg: Color,
|
||||
pub muted_bg: Color,
|
||||
pub muted_fg: Color,
|
||||
pub soloed_bg: Color,
|
||||
pub soloed_fg: Color,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -116,6 +116,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: bright,
|
||||
hover_bg: surface2,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(22, 22, 22),
|
||||
muted_fg: dark,
|
||||
soloed_bg: Color::Rgb(60, 60, 60),
|
||||
soloed_fg: bright,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: dim,
|
||||
|
||||
@@ -116,6 +116,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: dark,
|
||||
hover_bg: surface2,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(235, 235, 235),
|
||||
muted_fg: light,
|
||||
soloed_bg: Color::Rgb(190, 190, 190),
|
||||
soloed_fg: dark,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: dim,
|
||||
|
||||
@@ -117,6 +117,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: blue,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(48, 50, 45),
|
||||
muted_fg: comment,
|
||||
soloed_bg: Color::Rgb(70, 65, 45),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: pink,
|
||||
|
||||
@@ -117,6 +117,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: frost0,
|
||||
hover_bg: polar_night2,
|
||||
hover_fg: snow_storm2,
|
||||
muted_bg: Color::Rgb(55, 60, 70),
|
||||
muted_fg: polar_night3,
|
||||
soloed_bg: Color::Rgb(70, 65, 50),
|
||||
soloed_fg: aurora_yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: aurora_red,
|
||||
|
||||
@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: cyan,
|
||||
hover_bg: surface2,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(15, 15, 15),
|
||||
muted_fg: fg_muted,
|
||||
soloed_bg: Color::Rgb(45, 40, 15),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
|
||||
@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: foam,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(32, 30, 42),
|
||||
muted_fg: muted,
|
||||
soloed_bg: Color::Rgb(60, 50, 40),
|
||||
soloed_fg: gold,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: love,
|
||||
|
||||
@@ -118,6 +118,10 @@ pub fn theme() -> ThemeColors {
|
||||
edit_fg: blue,
|
||||
hover_bg: bg_lighter,
|
||||
hover_fg: fg,
|
||||
muted_bg: Color::Rgb(35, 38, 50),
|
||||
muted_fg: comment,
|
||||
soloed_bg: Color::Rgb(60, 55, 40),
|
||||
soloed_fg: yellow,
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: red,
|
||||
|
||||
@@ -183,6 +183,10 @@ pub fn rotate_theme(theme: ThemeColors, degrees: f32) -> ThemeColors {
|
||||
edit_fg: rotate_color(theme.list.edit_fg, degrees),
|
||||
hover_bg: rotate_color(theme.list.hover_bg, degrees),
|
||||
hover_fg: rotate_color(theme.list.hover_fg, degrees),
|
||||
muted_bg: rotate_color(theme.list.muted_bg, degrees),
|
||||
muted_fg: rotate_color(theme.list.muted_fg, degrees),
|
||||
soloed_bg: rotate_color(theme.list.soloed_bg, degrees),
|
||||
soloed_fg: rotate_color(theme.list.soloed_fg, degrees),
|
||||
},
|
||||
link_status: LinkStatusColors {
|
||||
disabled: rotate_color(theme.link_status.disabled, degrees),
|
||||
|
||||
94
src/app.rs
94
src/app.rs
@@ -17,8 +17,8 @@ use crate::services::pattern_editor;
|
||||
use crate::settings::Settings;
|
||||
use crate::state::{
|
||||
AudioSettings, CyclicEnum, DictFocus, EditorContext, FlashKind, LiveKeyState, Metrics, Modal,
|
||||
OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav, PlaybackState,
|
||||
ProjectState, StagedChange, UiState,
|
||||
MuteState, OptionsState, PanelState, PatternField, PatternPropsField, PatternsNav,
|
||||
PlaybackState, ProjectState, StagedChange, UiState,
|
||||
};
|
||||
use crate::views::{dict_view, help_view};
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct App {
|
||||
pub project_state: ProjectState,
|
||||
pub ui: UiState,
|
||||
pub playback: PlaybackState,
|
||||
pub mute: MuteState,
|
||||
|
||||
pub page: Page,
|
||||
pub editor_ctx: EditorContext,
|
||||
@@ -69,6 +70,7 @@ impl App {
|
||||
project_state: ProjectState::default(),
|
||||
ui: UiState::default(),
|
||||
playback: PlaybackState::default(),
|
||||
mute: MuteState::default(),
|
||||
|
||||
page: Page::default(),
|
||||
editor_ctx: EditorContext::default(),
|
||||
@@ -494,26 +496,64 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commit_staged_changes(&mut self) {
|
||||
if self.playback.staged_changes.is_empty() {
|
||||
/// Commits staged pattern and mute/solo 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();
|
||||
|
||||
if pattern_count == 0 && mute_count == 0 {
|
||||
self.ui.set_status("No changes to commit".to_string());
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
let count = self.playback.staged_changes.len();
|
||||
self.playback
|
||||
.queued_changes
|
||||
.append(&mut self.playback.staged_changes);
|
||||
self.ui.set_status(format!("Committed {count} changes"));
|
||||
|
||||
// Commit pattern changes (queued for quantization)
|
||||
if pattern_count > 0 {
|
||||
self.playback
|
||||
.queued_changes
|
||||
.append(&mut self.playback.staged_changes);
|
||||
}
|
||||
|
||||
// Apply mute/solo changes immediately
|
||||
let mute_changed = mute_count > 0;
|
||||
for change in self.playback.staged_mute_changes.drain() {
|
||||
match change {
|
||||
crate::state::StagedMuteChange::ToggleMute { bank, pattern } => {
|
||||
self.mute.toggle_mute(bank, pattern);
|
||||
}
|
||||
crate::state::StagedMuteChange::ToggleSolo { bank, pattern } => {
|
||||
self.mute.toggle_solo(bank, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
};
|
||||
self.ui.set_status(status);
|
||||
|
||||
mute_changed
|
||||
}
|
||||
|
||||
pub fn clear_staged_changes(&mut self) {
|
||||
if self.playback.staged_changes.is_empty() {
|
||||
let pattern_count = self.playback.staged_changes.len();
|
||||
let mute_count = self.playback.staged_mute_changes.len();
|
||||
|
||||
if pattern_count == 0 && mute_count == 0 {
|
||||
return;
|
||||
}
|
||||
let count = self.playback.staged_changes.len();
|
||||
|
||||
self.playback.staged_changes.clear();
|
||||
self.ui
|
||||
.set_status(format!("Cleared {count} staged changes"));
|
||||
self.playback.staged_mute_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"),
|
||||
};
|
||||
self.ui.set_status(status);
|
||||
}
|
||||
|
||||
pub fn select_edit_pattern(&mut self, pattern: usize) {
|
||||
@@ -1094,9 +1134,6 @@ impl App {
|
||||
AppCommand::DuplicateSteps => self.duplicate_steps(link),
|
||||
|
||||
// Pattern playback (staging)
|
||||
AppCommand::CommitStagedChanges => {
|
||||
self.commit_staged_changes();
|
||||
}
|
||||
AppCommand::ClearStagedChanges => {
|
||||
self.clear_staged_changes();
|
||||
}
|
||||
@@ -1300,6 +1337,22 @@ impl App {
|
||||
self.stage_pattern_toggle(bank, pattern, snapshot);
|
||||
}
|
||||
|
||||
// Mute/Solo (staged)
|
||||
AppCommand::StageMute { bank, pattern } => {
|
||||
self.playback.stage_mute(bank, pattern);
|
||||
}
|
||||
AppCommand::StageSolo { bank, pattern } => {
|
||||
self.playback.stage_solo(bank, pattern);
|
||||
}
|
||||
AppCommand::ClearMutes => {
|
||||
self.playback.clear_staged_mutes();
|
||||
self.mute.clear_mute();
|
||||
}
|
||||
AppCommand::ClearSolos => {
|
||||
self.playback.clear_staged_solos();
|
||||
self.mute.clear_solo();
|
||||
}
|
||||
|
||||
// UI state
|
||||
AppCommand::ClearMinimap => {
|
||||
self.ui.minimap_until = None;
|
||||
@@ -1522,6 +1575,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_mute_state(&self, cmd_tx: &Sender<SeqCommand>) {
|
||||
let _ = cmd_tx.send(SeqCommand::SetMuteState {
|
||||
muted: self.mute.muted.clone(),
|
||||
soloed: self.mute.soloed.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn flush_dirty_patterns(&mut self, cmd_tx: &Sender<SeqCommand>) {
|
||||
for (bank, pattern) in self.project_state.take_dirty() {
|
||||
let pat = self.project_state.project.pattern_at(bank, pattern);
|
||||
|
||||
@@ -75,7 +75,6 @@ pub enum AppCommand {
|
||||
DuplicateSteps,
|
||||
|
||||
// Pattern playback (staging)
|
||||
CommitStagedChanges,
|
||||
ClearStagedChanges,
|
||||
|
||||
// Project
|
||||
@@ -156,6 +155,12 @@ pub enum AppCommand {
|
||||
PatternsBack,
|
||||
PatternsTogglePlay,
|
||||
|
||||
// Mute/Solo (staged)
|
||||
StageMute { bank: usize, pattern: usize },
|
||||
StageSolo { bank: usize, pattern: usize },
|
||||
ClearMutes, // Clears both staged and applied mutes
|
||||
ClearSolos, // Clears both staged and applied solos
|
||||
|
||||
// UI state
|
||||
ClearMinimap,
|
||||
HideTitle,
|
||||
|
||||
@@ -257,7 +257,7 @@ pub fn build_stream(
|
||||
};
|
||||
|
||||
let default_config = device.default_output_config().map_err(|e| e.to_string())?;
|
||||
let sample_rate = default_config.sample_rate().0 as f32;
|
||||
let sample_rate = default_config.sample_rate() as f32;
|
||||
|
||||
let buffer_size = if config.buffer_size > 0 {
|
||||
cpal::BufferSize::Fixed(config.buffer_size)
|
||||
|
||||
@@ -115,6 +115,10 @@ pub enum SeqCommand {
|
||||
pattern: usize,
|
||||
quantization: LaunchQuantization,
|
||||
},
|
||||
SetMuteState {
|
||||
muted: std::collections::HashSet<(usize, usize)>,
|
||||
soloed: std::collections::HashSet<(usize, usize)>,
|
||||
},
|
||||
StopAll,
|
||||
Shutdown,
|
||||
}
|
||||
@@ -432,6 +436,25 @@ fn check_quantization_boundary(
|
||||
|
||||
type StepKey = (usize, usize, usize);
|
||||
|
||||
/// Tracks a step that has been pre-evaluated via lookahead scheduling.
|
||||
/// Used to prevent duplicate evaluation when the step's actual fire time arrives.
|
||||
struct ScheduledStep {
|
||||
target_beat: f64,
|
||||
tempo_at_schedule: f64,
|
||||
}
|
||||
|
||||
/// An audio command scheduled for future emission.
|
||||
/// Commands are held here until their target_beat passes, then emitted to the audio engine.
|
||||
struct PendingCommand {
|
||||
cmd: TimestampedCommand,
|
||||
target_beat: f64,
|
||||
bank: usize,
|
||||
pattern: usize,
|
||||
}
|
||||
|
||||
/// Key for tracking scheduled steps: (bank, pattern, step_index, beat_int)
|
||||
type ScheduledStepKey = (usize, usize, usize, i64);
|
||||
|
||||
struct RunsCounter {
|
||||
counts: HashMap<StepKey, usize>,
|
||||
}
|
||||
@@ -551,6 +574,12 @@ pub(crate) struct SequencerState {
|
||||
buf_audio_commands: Vec<TimestampedCommand>,
|
||||
cc_access: Option<Arc<dyn CcAccess>>,
|
||||
active_notes: HashMap<(u8, u8, u8), ActiveNote>,
|
||||
muted: std::collections::HashSet<(usize, usize)>,
|
||||
soloed: std::collections::HashSet<(usize, usize)>,
|
||||
// Lookahead scheduling state
|
||||
scheduled_steps: HashMap<(usize, usize, usize, i64), ScheduledStep>,
|
||||
pending_commands: Vec<PendingCommand>,
|
||||
last_tempo: f64,
|
||||
}
|
||||
|
||||
impl SequencerState {
|
||||
@@ -575,9 +604,25 @@ impl SequencerState {
|
||||
buf_audio_commands: Vec::new(),
|
||||
cc_access,
|
||||
active_notes: HashMap::new(),
|
||||
muted: std::collections::HashSet::new(),
|
||||
soloed: std::collections::HashSet::new(),
|
||||
scheduled_steps: HashMap::new(),
|
||||
pending_commands: Vec::new(),
|
||||
last_tempo: 120.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||
let key = (bank, pattern);
|
||||
if self.muted.contains(&key) {
|
||||
return true;
|
||||
}
|
||||
if !self.soloed.is_empty() && !self.soloed.contains(&key) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn process_commands(&mut self, commands: Vec<SeqCommand>) {
|
||||
for cmd in commands {
|
||||
match cmd {
|
||||
@@ -619,6 +664,28 @@ impl SequencerState {
|
||||
});
|
||||
}
|
||||
}
|
||||
SeqCommand::SetMuteState { muted, soloed } => {
|
||||
let newly_muted: Vec<(usize, usize)> = self
|
||||
.audio_state
|
||||
.active_patterns
|
||||
.keys()
|
||||
.filter(|id| {
|
||||
let key = (id.bank, id.pattern);
|
||||
let was_muted = self.is_effectively_muted(id.bank, id.pattern);
|
||||
let now_muted = muted.contains(&key)
|
||||
|| (!soloed.is_empty() && !soloed.contains(&key));
|
||||
!was_muted && now_muted
|
||||
})
|
||||
.map(|id| (id.bank, id.pattern))
|
||||
.collect();
|
||||
|
||||
self.muted = muted;
|
||||
self.soloed = soloed;
|
||||
|
||||
if !newly_muted.is_empty() {
|
||||
self.audio_state.flush_midi_notes = true;
|
||||
}
|
||||
}
|
||||
SeqCommand::StopAll => {
|
||||
self.audio_state.active_patterns.clear();
|
||||
self.audio_state.pending_starts.clear();
|
||||
@@ -626,6 +693,8 @@ impl SequencerState {
|
||||
Arc::make_mut(&mut self.step_traces).clear();
|
||||
self.runs_counter.counts.clear();
|
||||
self.audio_state.flush_midi_notes = true;
|
||||
self.scheduled_steps.clear();
|
||||
self.pending_commands.clear();
|
||||
}
|
||||
SeqCommand::Shutdown => {}
|
||||
}
|
||||
@@ -744,12 +813,65 @@ impl SequencerState {
|
||||
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
|
||||
bank != pending.id.bank || pattern != pending.id.pattern
|
||||
});
|
||||
// Clear scheduled steps and pending commands for this pattern
|
||||
let (b, p) = (pending.id.bank, pending.id.pattern);
|
||||
self.scheduled_steps
|
||||
.retain(|&(bank, pattern, _, _), _| bank != b || pattern != p);
|
||||
self.pending_commands
|
||||
.retain(|cmd| cmd.bank != b || cmd.pattern != p);
|
||||
stopped.push(pending.id);
|
||||
}
|
||||
}
|
||||
stopped
|
||||
}
|
||||
|
||||
/// Convert a logical beat position to engine time for audio scheduling.
|
||||
fn beat_to_engine_time(
|
||||
target_beat: f64,
|
||||
current_beat: f64,
|
||||
engine_time: f64,
|
||||
tempo: f64,
|
||||
) -> f64 {
|
||||
let beats_ahead = target_beat - current_beat;
|
||||
let secs_ahead = beats_ahead * 60.0 / tempo;
|
||||
engine_time + secs_ahead
|
||||
}
|
||||
|
||||
/// Reschedule all pending commands when tempo changes.
|
||||
fn reschedule_for_tempo_change(
|
||||
&mut self,
|
||||
new_tempo: f64,
|
||||
current_beat: f64,
|
||||
engine_time: f64,
|
||||
) {
|
||||
for pending in &mut self.pending_commands {
|
||||
if pending.cmd.time.is_some() {
|
||||
pending.cmd.time = Some(Self::beat_to_engine_time(
|
||||
pending.target_beat,
|
||||
current_beat,
|
||||
engine_time,
|
||||
new_tempo,
|
||||
));
|
||||
}
|
||||
}
|
||||
for step in self.scheduled_steps.values_mut() {
|
||||
step.tempo_at_schedule = new_tempo;
|
||||
}
|
||||
self.last_tempo = new_tempo;
|
||||
}
|
||||
|
||||
/// Main step execution with lookahead scheduling support.
|
||||
///
|
||||
/// This function handles two timing modes:
|
||||
/// 1. **Immediate firing**: When a beat boundary is crossed (`beat_int != prev_beat_int`),
|
||||
/// the current step fires. If already pre-evaluated via lookahead, we skip evaluation.
|
||||
/// 2. **Lookahead pre-evaluation**: When `lookahead_secs > 0`, we pre-evaluate future steps
|
||||
/// and queue their commands with precise timestamps for later emission.
|
||||
///
|
||||
/// The lookahead scheduling improves timing accuracy by:
|
||||
/// - Evaluating scripts BEFORE their logical fire time
|
||||
/// - Scheduling audio commands at exact beat positions using engine time
|
||||
/// - Allowing the audio engine to play sounds at the precise moment
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn execute_steps(
|
||||
&mut self,
|
||||
@@ -772,6 +894,12 @@ impl SequencerState {
|
||||
any_step_fired: false,
|
||||
};
|
||||
|
||||
// Reschedule pending commands if tempo changed
|
||||
if (tempo - self.last_tempo).abs() > 0.001 {
|
||||
self.reschedule_for_tempo_change(tempo, beat, engine_time);
|
||||
}
|
||||
|
||||
// Load speed overrides from variables
|
||||
self.speed_overrides.clear();
|
||||
{
|
||||
let vars = self.variables.lock().unwrap();
|
||||
@@ -783,6 +911,15 @@ impl SequencerState {
|
||||
}
|
||||
}
|
||||
|
||||
let muted_snapshot = self.muted.clone();
|
||||
let soloed_snapshot = self.soloed.clone();
|
||||
let lookahead_beats = if tempo > 0.0 {
|
||||
lookahead_secs * tempo / 60.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Process each active pattern
|
||||
for (_id, active) in self.audio_state.active_patterns.iter_mut() {
|
||||
let Some(pattern) = self.pattern_cache.get(active.bank, active.pattern) else {
|
||||
continue;
|
||||
@@ -793,76 +930,94 @@ impl SequencerState {
|
||||
.get(&(active.bank, active.pattern))
|
||||
.copied()
|
||||
.unwrap_or_else(|| pattern.speed.multiplier());
|
||||
|
||||
let beat_int = (beat * 4.0 * speed_mult).floor() as i64;
|
||||
let prev_beat_int = (prev_beat * 4.0 * speed_mult).floor() as i64;
|
||||
let step_fires = beat_int != prev_beat_int && prev_beat >= 0.0;
|
||||
|
||||
if beat_int != prev_beat_int && prev_beat >= 0.0 {
|
||||
// === IMMEDIATE STEP EXECUTION ===
|
||||
// Fire the current step if a beat boundary was crossed
|
||||
if step_fires {
|
||||
result.any_step_fired = true;
|
||||
let step_idx = active.step_index % pattern.length;
|
||||
let sched_key: ScheduledStepKey =
|
||||
(active.bank, active.pattern, step_idx, beat_int);
|
||||
|
||||
if let Some(step) = pattern.steps.get(step_idx) {
|
||||
let resolved_script = pattern.resolve_script(step_idx);
|
||||
let has_script = resolved_script
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
// Skip evaluation if already done via lookahead
|
||||
if !self.scheduled_steps.contains_key(&sched_key) {
|
||||
if let Some(step) = pattern.steps.get(step_idx) {
|
||||
let resolved_script = pattern.resolve_script(step_idx);
|
||||
let has_script = resolved_script
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if step.active && has_script {
|
||||
let source_idx = pattern.resolve_source(step_idx);
|
||||
let runs = self.runs_counter.get_and_increment(
|
||||
active.bank,
|
||||
active.pattern,
|
||||
source_idx,
|
||||
);
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat,
|
||||
bank: active.bank,
|
||||
pattern: active.pattern,
|
||||
tempo,
|
||||
phase: beat % quantum,
|
||||
slot: 0,
|
||||
runs,
|
||||
iter: active.iter,
|
||||
speed: speed_mult,
|
||||
fill,
|
||||
nudge_secs,
|
||||
cc_access: self.cc_access.clone(),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down,
|
||||
};
|
||||
if let Some(script) = resolved_script {
|
||||
let mut trace = ExecutionTrace::default();
|
||||
if let Ok(cmds) = self
|
||||
.script_engine
|
||||
.evaluate_with_trace(script, &ctx, &mut trace)
|
||||
{
|
||||
Arc::make_mut(&mut self.step_traces).insert(
|
||||
(active.bank, active.pattern, source_idx),
|
||||
std::mem::take(&mut trace),
|
||||
if step.active && has_script {
|
||||
let pattern_key = (active.bank, active.pattern);
|
||||
let is_muted = muted_snapshot.contains(&pattern_key)
|
||||
|| (!soloed_snapshot.is_empty()
|
||||
&& !soloed_snapshot.contains(&pattern_key));
|
||||
|
||||
if !is_muted {
|
||||
let source_idx = pattern.resolve_source(step_idx);
|
||||
let runs = self.runs_counter.get_and_increment(
|
||||
active.bank,
|
||||
active.pattern,
|
||||
source_idx,
|
||||
);
|
||||
|
||||
let event_time = if lookahead_secs > 0.0 {
|
||||
Some(engine_time + lookahead_secs)
|
||||
} else {
|
||||
None
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat,
|
||||
bank: active.bank,
|
||||
pattern: active.pattern,
|
||||
tempo,
|
||||
phase: beat % quantum,
|
||||
slot: 0,
|
||||
runs,
|
||||
iter: active.iter,
|
||||
speed: speed_mult,
|
||||
fill,
|
||||
nudge_secs,
|
||||
cc_access: self.cc_access.clone(),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down,
|
||||
};
|
||||
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
if let Some(script) = resolved_script {
|
||||
let mut trace = ExecutionTrace::default();
|
||||
if let Ok(cmds) = self
|
||||
.script_engine
|
||||
.evaluate_with_trace(script, &ctx, &mut trace)
|
||||
{
|
||||
Arc::make_mut(&mut self.step_traces).insert(
|
||||
(active.bank, active.pattern, source_idx),
|
||||
std::mem::take(&mut trace),
|
||||
);
|
||||
|
||||
let event_time = if lookahead_secs > 0.0 {
|
||||
Some(engine_time + lookahead_secs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.buf_audio_commands.push(TimestampedCommand {
|
||||
cmd,
|
||||
time: event_time,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance step index
|
||||
let next_step = active.step_index + 1;
|
||||
if next_step >= pattern.length {
|
||||
active.iter += 1;
|
||||
@@ -873,8 +1028,144 @@ impl SequencerState {
|
||||
}
|
||||
active.step_index = next_step % pattern.length;
|
||||
}
|
||||
|
||||
// === LOOKAHEAD PRE-EVALUATION ===
|
||||
// Pre-evaluate future steps within the lookahead window
|
||||
if lookahead_secs > 0.0 {
|
||||
let future_beat = beat + lookahead_beats;
|
||||
let future_beat_int = (future_beat * 4.0 * speed_mult).floor() as i64;
|
||||
let start_beat_int = beat_int + 1;
|
||||
|
||||
let mut lookahead_step = active.step_index;
|
||||
let mut lookahead_iter = active.iter;
|
||||
|
||||
for target_beat_int in start_beat_int..=future_beat_int {
|
||||
let step_idx = lookahead_step % pattern.length;
|
||||
let sched_key: ScheduledStepKey =
|
||||
(active.bank, active.pattern, step_idx, target_beat_int);
|
||||
|
||||
// Skip if already scheduled
|
||||
if self.scheduled_steps.contains_key(&sched_key) {
|
||||
let next = lookahead_step + 1;
|
||||
if next >= pattern.length {
|
||||
lookahead_iter += 1;
|
||||
}
|
||||
lookahead_step = next % pattern.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate the logical beat time for this step
|
||||
let target_beat = target_beat_int as f64 / (4.0 * speed_mult);
|
||||
|
||||
if let Some(step) = pattern.steps.get(step_idx) {
|
||||
let resolved_script = pattern.resolve_script(step_idx);
|
||||
let has_script = resolved_script
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
|
||||
if step.active && has_script {
|
||||
let pattern_key = (active.bank, active.pattern);
|
||||
let is_muted = muted_snapshot.contains(&pattern_key)
|
||||
|| (!soloed_snapshot.is_empty()
|
||||
&& !soloed_snapshot.contains(&pattern_key));
|
||||
|
||||
if !is_muted {
|
||||
let source_idx = pattern.resolve_source(step_idx);
|
||||
let runs = self.runs_counter.get_and_increment(
|
||||
active.bank,
|
||||
active.pattern,
|
||||
source_idx,
|
||||
);
|
||||
|
||||
let ctx = StepContext {
|
||||
step: step_idx,
|
||||
beat: target_beat,
|
||||
bank: active.bank,
|
||||
pattern: active.pattern,
|
||||
tempo,
|
||||
phase: target_beat % quantum,
|
||||
slot: 0,
|
||||
runs,
|
||||
iter: lookahead_iter,
|
||||
speed: speed_mult,
|
||||
fill,
|
||||
nudge_secs,
|
||||
cc_access: self.cc_access.clone(),
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down,
|
||||
};
|
||||
|
||||
if let Some(script) = resolved_script {
|
||||
let mut trace = ExecutionTrace::default();
|
||||
if let Ok(cmds) = self
|
||||
.script_engine
|
||||
.evaluate_with_trace(script, &ctx, &mut trace)
|
||||
{
|
||||
Arc::make_mut(&mut self.step_traces).insert(
|
||||
(active.bank, active.pattern, source_idx),
|
||||
std::mem::take(&mut trace),
|
||||
);
|
||||
|
||||
let event_time = Some(Self::beat_to_engine_time(
|
||||
target_beat,
|
||||
beat,
|
||||
engine_time,
|
||||
tempo,
|
||||
));
|
||||
|
||||
for cmd in cmds {
|
||||
self.event_count += 1;
|
||||
self.pending_commands.push(PendingCommand {
|
||||
cmd: TimestampedCommand { cmd, time: event_time },
|
||||
target_beat,
|
||||
bank: active.bank,
|
||||
pattern: active.pattern,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark step as scheduled
|
||||
self.scheduled_steps.insert(
|
||||
sched_key,
|
||||
ScheduledStep {
|
||||
target_beat,
|
||||
tempo_at_schedule: tempo,
|
||||
},
|
||||
);
|
||||
|
||||
// Advance for next iteration
|
||||
let next = lookahead_step + 1;
|
||||
if next >= pattern.length {
|
||||
lookahead_iter += 1;
|
||||
}
|
||||
lookahead_step = next % pattern.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === EMIT READY COMMANDS ===
|
||||
// Move commands whose target_beat has passed from pending to output
|
||||
let (ready, still_pending): (Vec<_>, Vec<_>) = std::mem::take(&mut self.pending_commands)
|
||||
.into_iter()
|
||||
.partition(|p| p.target_beat <= beat);
|
||||
self.pending_commands = still_pending;
|
||||
|
||||
for pending in ready {
|
||||
self.buf_audio_commands.push(pending.cmd);
|
||||
}
|
||||
|
||||
// Cleanup stale scheduled_steps (more than 1 beat in the past)
|
||||
self.scheduled_steps
|
||||
.retain(|_, s| s.target_beat > beat - 1.0);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
@@ -1905,4 +2196,178 @@ mod tests {
|
||||
let output = state.tick(tick_at(1.0, true));
|
||||
assert_eq!(output.new_tempo, Some(140.0));
|
||||
}
|
||||
|
||||
fn tick_with_lookahead(beat: f64, lookahead_secs: f64) -> TickInput {
|
||||
TickInput {
|
||||
commands: Vec::new(),
|
||||
playing: true,
|
||||
beat,
|
||||
tempo: 120.0,
|
||||
quantum: 4.0,
|
||||
fill: false,
|
||||
nudge_secs: 0.0,
|
||||
current_time_us: 0,
|
||||
engine_time: beat * 0.5, // At 120 BPM, 1 beat = 0.5 seconds
|
||||
lookahead_secs,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_x: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_y: 0.5,
|
||||
#[cfg(feature = "desktop")]
|
||||
mouse_down: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn pattern_with_sound(length: usize) -> PatternSnapshot {
|
||||
PatternSnapshot {
|
||||
speed: Default::default(),
|
||||
length,
|
||||
steps: (0..length)
|
||||
.map(|_| StepSnapshot {
|
||||
active: true,
|
||||
script: "sine sound 500 freq .".into(),
|
||||
source: None,
|
||||
})
|
||||
.collect(),
|
||||
quantization: LaunchQuantization::Immediate,
|
||||
sync_mode: SyncMode::Reset,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookahead_pre_evaluates_future_steps() {
|
||||
let mut state = make_state();
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternUpdate {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
data: pattern_with_sound(4),
|
||||
}],
|
||||
0.0,
|
||||
));
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternStart {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
quantization: LaunchQuantization::Immediate,
|
||||
sync_mode: SyncMode::Reset,
|
||||
}],
|
||||
0.5,
|
||||
));
|
||||
|
||||
// With 100ms lookahead at 120 BPM = 0.2 beats lookahead
|
||||
// At beat 0.75, future_beat = 0.95
|
||||
// beat_int = 3, future_beat_int = 3
|
||||
// next_beat_int = 4 > future_beat_int, so no lookahead yet
|
||||
let output = state.tick(tick_with_lookahead(0.75, 0.1));
|
||||
// Step fired (step 1), commands emitted immediately
|
||||
assert!(output.shared_state.active_patterns.iter().any(|p| p.step_index == 2));
|
||||
|
||||
// With 500ms lookahead = 1 beat lookahead
|
||||
// At beat 1.0, future_beat = 2.0
|
||||
// beat_int = 4, future_beat_int = 8
|
||||
// Should pre-evaluate steps at beat_ints 5, 6, 7, 8
|
||||
let _output = state.tick(tick_with_lookahead(1.0, 0.5));
|
||||
|
||||
// Check that scheduled_steps contains the pre-evaluated steps
|
||||
// At beat 1.0, step_index is 3 (step 2 just fired)
|
||||
// Lookahead will schedule steps: 3@5, 0@6, 1@7, 2@8
|
||||
assert!(state.scheduled_steps.contains_key(&(0, 0, 3, 5)));
|
||||
assert!(state.scheduled_steps.contains_key(&(0, 0, 0, 6)));
|
||||
assert!(state.scheduled_steps.contains_key(&(0, 0, 1, 7)));
|
||||
assert!(state.scheduled_steps.contains_key(&(0, 0, 2, 8)));
|
||||
|
||||
// Pending commands should exist for future steps
|
||||
assert!(!state.pending_commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookahead_commands_emit_at_correct_time() {
|
||||
let mut state = make_state();
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternUpdate {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
data: pattern_with_sound(4),
|
||||
}],
|
||||
0.0,
|
||||
));
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternStart {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
quantization: LaunchQuantization::Immediate,
|
||||
sync_mode: SyncMode::Reset,
|
||||
}],
|
||||
0.5,
|
||||
));
|
||||
|
||||
// Pre-evaluate with 1 beat lookahead
|
||||
state.tick(tick_with_lookahead(0.75, 0.5));
|
||||
|
||||
// Commands for step 2 (at beat 1.0) should be in pending_commands
|
||||
let pending_for_step2: Vec<_> = state
|
||||
.pending_commands
|
||||
.iter()
|
||||
.filter(|p| (p.target_beat - 1.0).abs() < 0.01)
|
||||
.collect();
|
||||
assert!(!pending_for_step2.is_empty());
|
||||
|
||||
// Advance to beat 1.0 - pending commands should be emitted
|
||||
let output = state.tick(tick_with_lookahead(1.0, 0.5));
|
||||
// The commands should have been moved to buf_audio_commands
|
||||
assert!(!output.audio_commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_lookahead_tempo_change_reschedules() {
|
||||
let mut state = make_state();
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternUpdate {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
data: pattern_with_sound(4),
|
||||
}],
|
||||
0.0,
|
||||
));
|
||||
|
||||
state.tick(tick_with(
|
||||
vec![SeqCommand::PatternStart {
|
||||
bank: 0,
|
||||
pattern: 0,
|
||||
quantization: LaunchQuantization::Immediate,
|
||||
sync_mode: SyncMode::Reset,
|
||||
}],
|
||||
0.5,
|
||||
));
|
||||
|
||||
// Pre-evaluate with lookahead
|
||||
state.tick(tick_with_lookahead(0.75, 0.5));
|
||||
|
||||
// Record original event times
|
||||
let original_times: Vec<_> = state
|
||||
.pending_commands
|
||||
.iter()
|
||||
.map(|p| p.cmd.time)
|
||||
.collect();
|
||||
assert!(!original_times.is_empty());
|
||||
|
||||
// Simulate tempo change by ticking with different tempo
|
||||
let mut input = tick_with_lookahead(0.8, 0.5);
|
||||
input.tempo = 140.0; // Changed from 120
|
||||
state.tick(input);
|
||||
|
||||
// Event times should have been rescheduled
|
||||
// (The exact times depend on the reschedule algorithm)
|
||||
// At minimum, scheduled_steps should have updated tempo
|
||||
for step in state.scheduled_steps.values() {
|
||||
// Tempo should be updated for all scheduled steps
|
||||
assert!((step.tempo_at_schedule - 140.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
45
src/input.rs
45
src/input.rs
@@ -1054,6 +1054,22 @@ fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool) -> InputR
|
||||
rotation: "0".to_string(),
|
||||
}));
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||
ctx.dispatch(AppCommand::StageMute { bank, pattern });
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
|
||||
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
|
||||
}
|
||||
KeyCode::Char('M') => {
|
||||
ctx.dispatch(AppCommand::ClearMutes);
|
||||
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||
}
|
||||
KeyCode::Char('X') => {
|
||||
ctx.dispatch(AppCommand::ClearSolos);
|
||||
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
InputResult::Continue
|
||||
@@ -1070,7 +1086,9 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
KeyCode::Up => ctx.dispatch(AppCommand::PatternsCursorUp),
|
||||
KeyCode::Down => ctx.dispatch(AppCommand::PatternsCursorDown),
|
||||
KeyCode::Esc => {
|
||||
if !ctx.app.playback.staged_changes.is_empty() {
|
||||
if !ctx.app.playback.staged_changes.is_empty()
|
||||
|| !ctx.app.playback.staged_mute_changes.is_empty()
|
||||
{
|
||||
ctx.dispatch(AppCommand::ClearStagedChanges);
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::PatternsBack);
|
||||
@@ -1082,7 +1100,12 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.dispatch(AppCommand::PatternsTogglePlay);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('c') if !ctrl => ctx.dispatch(AppCommand::CommitStagedChanges),
|
||||
KeyCode::Char('c') if !ctrl => {
|
||||
let mute_changed = ctx.app.commit_staged_changes();
|
||||
if mute_changed {
|
||||
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||
}
|
||||
}
|
||||
KeyCode::Char('q') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit {
|
||||
selected: false,
|
||||
@@ -1165,6 +1188,24 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
|
||||
}
|
||||
}
|
||||
KeyCode::Char('m') => {
|
||||
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||
ctx.dispatch(AppCommand::StageMute { bank, pattern });
|
||||
}
|
||||
KeyCode::Char('x') => {
|
||||
let bank = ctx.app.patterns_nav.bank_cursor;
|
||||
let pattern = ctx.app.patterns_nav.pattern_cursor;
|
||||
ctx.dispatch(AppCommand::StageSolo { bank, pattern });
|
||||
}
|
||||
KeyCode::Char('M') => {
|
||||
ctx.dispatch(AppCommand::ClearMutes);
|
||||
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||
}
|
||||
KeyCode::Char('X') => {
|
||||
ctx.dispatch(AppCommand::ClearSolos);
|
||||
ctx.app.send_mute_state(ctx.seq_cmd_tx);
|
||||
}
|
||||
KeyCode::Char('?') => {
|
||||
ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 }));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ fn convert_event(event: &egui::Event) -> Option<KeyEvent> {
|
||||
}
|
||||
None
|
||||
}
|
||||
// egui intercepts Ctrl+C/V/X and converts them to these high-level events
|
||||
// instead of passing through raw Key events (see egui issue #4065).
|
||||
// Synthesize the equivalent KeyEvent so the application's input handler receives them.
|
||||
egui::Event::Copy => Some(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
||||
egui::Event::Cut => Some(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
|
||||
egui::Event::Paste(_) => Some(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ pub mod editor;
|
||||
pub mod file_browser;
|
||||
pub mod live_keys;
|
||||
pub mod modal;
|
||||
pub mod mute;
|
||||
pub mod options;
|
||||
pub mod panel;
|
||||
pub mod patterns_nav;
|
||||
@@ -38,7 +39,8 @@ pub use modal::Modal;
|
||||
pub use options::{OptionsFocus, OptionsState};
|
||||
pub use panel::{PanelFocus, PanelState, SidePanel};
|
||||
pub use patterns_nav::{PatternsColumn, PatternsNav};
|
||||
pub use playback::{PlaybackState, StagedChange};
|
||||
pub use mute::MuteState;
|
||||
pub use playback::{PlaybackState, StagedChange, StagedMuteChange};
|
||||
pub use project::ProjectState;
|
||||
pub use sample_browser::SampleBrowserState;
|
||||
pub use ui::{DictFocus, FlashKind, HelpFocus, UiState};
|
||||
|
||||
53
src/state/mute.rs
Normal file
53
src/state/mute.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct MuteState {
|
||||
pub muted: HashSet<(usize, usize)>,
|
||||
pub soloed: HashSet<(usize, usize)>,
|
||||
}
|
||||
|
||||
impl MuteState {
|
||||
pub fn toggle_mute(&mut self, bank: usize, pattern: usize) {
|
||||
let key = (bank, pattern);
|
||||
if self.muted.contains(&key) {
|
||||
self.muted.remove(&key);
|
||||
} else {
|
||||
self.muted.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_solo(&mut self, bank: usize, pattern: usize) {
|
||||
let key = (bank, pattern);
|
||||
if self.soloed.contains(&key) {
|
||||
self.soloed.remove(&key);
|
||||
} else {
|
||||
self.soloed.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_mute(&mut self) {
|
||||
self.muted.clear();
|
||||
}
|
||||
|
||||
pub fn clear_solo(&mut self) {
|
||||
self.soloed.clear();
|
||||
}
|
||||
|
||||
pub fn is_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||
self.muted.contains(&(bank, pattern))
|
||||
}
|
||||
|
||||
pub fn is_soloed(&self, bank: usize, pattern: usize) -> bool {
|
||||
self.soloed.contains(&(bank, pattern))
|
||||
}
|
||||
|
||||
pub fn is_effectively_muted(&self, bank: usize, pattern: usize) -> bool {
|
||||
if self.muted.contains(&(bank, pattern)) {
|
||||
return true;
|
||||
}
|
||||
if !self.soloed.is_empty() && !self.soloed.contains(&(bank, pattern)) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::engine::PatternChange;
|
||||
use crate::model::{LaunchQuantization, SyncMode};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct StagedChange {
|
||||
@@ -8,10 +9,17 @@ pub struct StagedChange {
|
||||
pub sync_mode: SyncMode,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum StagedMuteChange {
|
||||
ToggleMute { bank: usize, pattern: usize },
|
||||
ToggleSolo { bank: usize, pattern: usize },
|
||||
}
|
||||
|
||||
pub struct PlaybackState {
|
||||
pub playing: bool,
|
||||
pub staged_changes: Vec<StagedChange>,
|
||||
pub queued_changes: Vec<StagedChange>,
|
||||
pub staged_mute_changes: HashSet<StagedMuteChange>,
|
||||
}
|
||||
|
||||
impl Default for PlaybackState {
|
||||
@@ -20,6 +28,7 @@ impl Default for PlaybackState {
|
||||
playing: true,
|
||||
staged_changes: Vec::new(),
|
||||
queued_changes: Vec::new(),
|
||||
staged_mute_changes: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,4 +42,38 @@ impl PlaybackState {
|
||||
self.staged_changes.clear();
|
||||
self.queued_changes.clear();
|
||||
}
|
||||
|
||||
pub fn stage_mute(&mut self, bank: usize, pattern: usize) {
|
||||
let change = StagedMuteChange::ToggleMute { bank, pattern };
|
||||
if self.staged_mute_changes.contains(&change) {
|
||||
self.staged_mute_changes.remove(&change);
|
||||
} else {
|
||||
self.staged_mute_changes.insert(change);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stage_solo(&mut self, bank: usize, pattern: usize) {
|
||||
let change = StagedMuteChange::ToggleSolo { bank, pattern };
|
||||
if self.staged_mute_changes.contains(&change) {
|
||||
self.staged_mute_changes.remove(&change);
|
||||
} else {
|
||||
self.staged_mute_changes.insert(change);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_staged_mutes(&mut self) {
|
||||
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleMute { .. }));
|
||||
}
|
||||
|
||||
pub fn clear_staged_solos(&mut self) {
|
||||
self.staged_mute_changes.retain(|c| !matches!(c, StagedMuteChange::ToggleSolo { .. }));
|
||||
}
|
||||
|
||||
pub fn has_staged_mute(&self, bank: usize, pattern: usize) -> bool {
|
||||
self.staged_mute_changes.contains(&StagedMuteChange::ToggleMute { bank, pattern })
|
||||
}
|
||||
|
||||
pub fn has_staged_solo(&self, bank: usize, pattern: usize) -> bool {
|
||||
self.staged_mute_changes.contains(&StagedMuteChange::ToggleSolo { bank, pattern })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +323,8 @@ fn render_vu_meter(frame: &mut Frame, app: &App, area: Rect) {
|
||||
}
|
||||
|
||||
fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area: Rect) {
|
||||
use crate::widgets::MuteStatus;
|
||||
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -336,11 +338,27 @@ fn render_active_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnap
|
||||
.map(|p| (p.bank, p.pattern, p.iter))
|
||||
.collect();
|
||||
|
||||
let mute_status: Vec<MuteStatus> = snapshot
|
||||
.active_patterns
|
||||
.iter()
|
||||
.map(|p| {
|
||||
if app.mute.is_soloed(p.bank, p.pattern) {
|
||||
MuteStatus::Soloed
|
||||
} else if app.mute.is_muted(p.bank, p.pattern) {
|
||||
MuteStatus::Muted
|
||||
} else if app.mute.is_effectively_muted(p.bank, p.pattern) {
|
||||
MuteStatus::EffectivelyMuted
|
||||
} else {
|
||||
MuteStatus::Normal
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let step_info = snapshot
|
||||
.get_step(app.editor_ctx.bank, app.editor_ctx.pattern)
|
||||
.map(|step| (step, app.current_edit_pattern().length));
|
||||
|
||||
let mut widget = ActivePatterns::new(&patterns);
|
||||
let mut widget = ActivePatterns::new(&patterns).with_mute_status(&mute_status);
|
||||
if let Some((step, total)) = step_info {
|
||||
widget = widget.with_step(step, total);
|
||||
}
|
||||
|
||||
@@ -91,13 +91,41 @@ fn render_banks(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, area
|
||||
let is_playing = banks_with_playback.contains(&idx);
|
||||
let is_staged = banks_with_staged.contains(&idx);
|
||||
|
||||
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged) {
|
||||
(true, _, _) => (theme.selection.cursor, theme.selection.cursor_fg, ""),
|
||||
(false, true, _) => (theme.list.playing_bg, theme.list.playing_fg, "> "),
|
||||
(false, false, true) => (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ "),
|
||||
(false, false, false) if is_selected => (theme.list.hover_bg, theme.list.hover_fg, ""),
|
||||
(false, false, false) if is_edit => (theme.list.edit_bg, theme.list.edit_fg, ""),
|
||||
(false, false, false) => (theme.ui.bg, theme.ui.text_muted, ""),
|
||||
// Check if any pattern in this bank is muted/soloed (applied)
|
||||
let has_muted = (0..MAX_PATTERNS).any(|p| app.mute.is_muted(idx, p));
|
||||
let has_soloed = (0..MAX_PATTERNS).any(|p| app.mute.is_soloed(idx, p));
|
||||
|
||||
// Check if any pattern in this bank has staged mute/solo
|
||||
let has_staged_mute = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_mute(idx, p));
|
||||
let has_staged_solo = (0..MAX_PATTERNS).any(|p| app.playback.has_staged_solo(idx, p));
|
||||
let has_staged_mute_solo = has_staged_mute || has_staged_solo;
|
||||
|
||||
let (bg, fg, prefix) = if is_cursor {
|
||||
(theme.selection.cursor, theme.selection.cursor_fg, "")
|
||||
} else if is_playing {
|
||||
if has_staged_mute_solo {
|
||||
(theme.list.staged_play_bg, theme.list.staged_play_fg, ">*")
|
||||
} else if has_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, ">S")
|
||||
} else if has_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, ">M")
|
||||
} else {
|
||||
(theme.list.playing_bg, theme.list.playing_fg, "> ")
|
||||
}
|
||||
} else if is_staged {
|
||||
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
||||
} else if has_staged_mute_solo {
|
||||
(theme.list.staged_play_bg, theme.list.staged_play_fg, " *")
|
||||
} else if has_soloed && is_selected {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, " S")
|
||||
} else if has_muted && is_selected {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, " M")
|
||||
} else if is_selected {
|
||||
(theme.list.hover_bg, theme.list.hover_fg, "")
|
||||
} else if is_edit {
|
||||
(theme.list.edit_bg, theme.list.edit_fg, "")
|
||||
} else {
|
||||
(theme.ui.bg, theme.ui.text_muted, "")
|
||||
};
|
||||
|
||||
let name = app.project_state.project.banks[idx]
|
||||
@@ -250,14 +278,74 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
|
||||
let is_staged_play = staged_to_play.contains(&idx);
|
||||
let is_staged_stop = staged_to_stop.contains(&idx);
|
||||
|
||||
let (bg, fg, prefix) = match (is_cursor, is_playing, is_staged_play, is_staged_stop) {
|
||||
(true, _, _, _) => (theme.selection.cursor, theme.selection.cursor_fg, ""),
|
||||
(false, true, _, true) => (theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- "),
|
||||
(false, true, _, false) => (theme.list.playing_bg, theme.list.playing_fg, "> "),
|
||||
(false, false, true, _) => (theme.list.staged_play_bg, theme.list.staged_play_fg, "+ "),
|
||||
(false, false, false, _) if is_selected => (theme.list.hover_bg, theme.list.hover_fg, ""),
|
||||
(false, false, false, _) if is_edit => (theme.list.edit_bg, theme.list.edit_fg, ""),
|
||||
(false, false, false, _) => (theme.ui.bg, theme.ui.text_muted, ""),
|
||||
// Current applied mute/solo state
|
||||
let is_muted = app.mute.is_muted(bank, idx);
|
||||
let is_soloed = app.mute.is_soloed(bank, idx);
|
||||
|
||||
// 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);
|
||||
|
||||
// Preview state (what it will be after commit)
|
||||
let preview_muted = is_muted ^ has_staged_mute;
|
||||
let preview_soloed = is_soloed ^ has_staged_solo;
|
||||
let is_effectively_muted = app.mute.is_effectively_muted(bank, idx);
|
||||
|
||||
let (bg, fg, prefix) = if is_cursor {
|
||||
(theme.selection.cursor, theme.selection.cursor_fg, "")
|
||||
} else if is_playing {
|
||||
// Playing patterns
|
||||
if is_staged_stop {
|
||||
(theme.list.staged_stop_bg, theme.list.staged_stop_fg, "- ")
|
||||
} else if has_staged_solo {
|
||||
// Staged solo toggle on playing pattern
|
||||
if preview_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||
} else {
|
||||
(theme.list.playing_bg, theme.list.playing_fg, "-S")
|
||||
}
|
||||
} else if has_staged_mute {
|
||||
// Staged mute toggle on playing pattern
|
||||
if preview_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||
} else {
|
||||
(theme.list.playing_bg, theme.list.playing_fg, "-M")
|
||||
}
|
||||
} else if is_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, ">S")
|
||||
} else if is_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, ">M")
|
||||
} else if is_effectively_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, "> ")
|
||||
} else {
|
||||
(theme.list.playing_bg, theme.list.playing_fg, "> ")
|
||||
}
|
||||
} else if is_staged_play {
|
||||
(theme.list.staged_play_bg, theme.list.staged_play_fg, "+ ")
|
||||
} else if has_staged_solo {
|
||||
// Staged solo on non-playing pattern
|
||||
if preview_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, "+S")
|
||||
} else {
|
||||
(theme.ui.bg, theme.ui.text_muted, "-S")
|
||||
}
|
||||
} else if has_staged_mute {
|
||||
// Staged mute on non-playing pattern
|
||||
if preview_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, "+M")
|
||||
} else {
|
||||
(theme.ui.bg, theme.ui.text_muted, "-M")
|
||||
}
|
||||
} else if is_soloed {
|
||||
(theme.list.soloed_bg, theme.list.soloed_fg, " S")
|
||||
} else if is_muted {
|
||||
(theme.list.muted_bg, theme.list.muted_fg, " M")
|
||||
} else if is_selected {
|
||||
(theme.list.hover_bg, theme.list.hover_fg, "")
|
||||
} else if is_edit {
|
||||
(theme.list.edit_bg, theme.list.edit_fg, "")
|
||||
} else {
|
||||
(theme.ui.bg, theme.ui.text_muted, "")
|
||||
};
|
||||
|
||||
let pattern = &app.project_state.project.banks[bank].patterns[idx];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pub use cagire_ratatui::{
|
||||
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, NavMinimap, NavTile, Orientation,
|
||||
SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
|
||||
ActivePatterns, ConfirmModal, FileBrowserModal, ModalFrame, MuteStatus, NavMinimap, NavTile,
|
||||
Orientation, SampleBrowser, Scope, Spectrum, TextInputModal, VuMeter,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user