Feat: mixed bag

This commit is contained in:
2026-02-25 20:31:36 +01:00
parent a6ff19bb08
commit 0119988d7c
25 changed files with 246 additions and 10 deletions

View File

@@ -51,7 +51,7 @@ cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" } cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" } cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" } cagire-ratatui = { path = "crates/ratatui" }
doux = { path = "/Users/bubo/doux", features = ["native"] } doux = { path = "/Users/bubo/doux", features = ["native", "soundfont"] }
rusty_link = "0.4" rusty_link = "0.4"
ratatui = "0.30" ratatui = "0.30"
crossterm = "0.29" crossterm = "0.29"

View File

@@ -283,6 +283,7 @@ pub struct Pattern {
pub length: usize, pub length: usize,
pub speed: PatternSpeed, pub speed: PatternSpeed,
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>,
pub quantization: LaunchQuantization, pub quantization: LaunchQuantization,
pub sync_mode: SyncMode, pub sync_mode: SyncMode,
pub follow_up: FollowUp, pub follow_up: FollowUp,
@@ -317,6 +318,8 @@ struct SparsePattern {
speed: PatternSpeed, speed: PatternSpeed,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>, name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(default, skip_serializing_if = "is_default_quantization")] #[serde(default, skip_serializing_if = "is_default_quantization")]
quantization: LaunchQuantization, quantization: LaunchQuantization,
#[serde(default, skip_serializing_if = "is_default_sync_mode")] #[serde(default, skip_serializing_if = "is_default_sync_mode")]
@@ -342,6 +345,8 @@ struct LegacyPattern {
#[serde(default)] #[serde(default)]
name: Option<String>, name: Option<String>,
#[serde(default)] #[serde(default)]
description: Option<String>,
#[serde(default)]
quantization: LaunchQuantization, quantization: LaunchQuantization,
#[serde(default)] #[serde(default)]
sync_mode: SyncMode, sync_mode: SyncMode,
@@ -370,6 +375,7 @@ impl Serialize for Pattern {
length: self.length, length: self.length,
speed: self.speed, speed: self.speed,
name: self.name.clone(), name: self.name.clone(),
description: self.description.clone(),
quantization: self.quantization, quantization: self.quantization,
sync_mode: self.sync_mode, sync_mode: self.sync_mode,
follow_up: self.follow_up, follow_up: self.follow_up,
@@ -405,6 +411,7 @@ impl<'de> Deserialize<'de> for Pattern {
length: sparse.length, length: sparse.length,
speed: sparse.speed, speed: sparse.speed,
name: sparse.name, name: sparse.name,
description: sparse.description,
quantization: sparse.quantization, quantization: sparse.quantization,
sync_mode: sparse.sync_mode, sync_mode: sparse.sync_mode,
follow_up: sparse.follow_up, follow_up: sparse.follow_up,
@@ -415,6 +422,7 @@ impl<'de> Deserialize<'de> for Pattern {
length: legacy.length, length: legacy.length,
speed: legacy.speed, speed: legacy.speed,
name: legacy.name, name: legacy.name,
description: legacy.description,
quantization: legacy.quantization, quantization: legacy.quantization,
sync_mode: legacy.sync_mode, sync_mode: legacy.sync_mode,
follow_up: legacy.follow_up, follow_up: legacy.follow_up,
@@ -430,6 +438,7 @@ impl Default for Pattern {
length: DEFAULT_LENGTH, length: DEFAULT_LENGTH,
speed: PatternSpeed::default(), speed: PatternSpeed::default(),
name: None, name: None,
description: None,
quantization: LaunchQuantization::default(), quantization: LaunchQuantization::default(),
sync_mode: SyncMode::default(), sync_mode: SyncMode::default(),
follow_up: FollowUp::default(), follow_up: FollowUp::default(),

111
docs/tutorials/soundfont.md Normal file
View File

@@ -0,0 +1,111 @@
# Soundfonts
General MIDI playback via SF2 soundfont files. Native only -- not available in the WASM build.
## Setup
Drop an `.sf2` file into one of your samples directories. The engine finds and loads it automatically when Cagire starts. Only one soundfont is active at a time.
## Playing
Use `gm` as the sound source. The `n` parameter selects a program by name or number (0-127):
```forth
gm s piano n . ;; acoustic piano
gm s strings n c4 note . ;; strings playing middle C
gm s 0 n e4 note . ;; program 0 (piano) playing E4
```
## Drums
Drums live on a separate bank. Use `drums` or `percussion` as the `n` value. Each MIDI note triggers a different instrument:
```forth
gm s drums n 36 note . ;; kick
gm s drums n 38 note . ;; snare
gm s drums n 42 note . ;; closed hi-hat
gm s percussion n 49 note . ;; crash cymbal
```
## Envelope
The soundfont embeds ADSR envelope data per preset. The engine applies it automatically. Override any parameter explicitly:
```forth
gm s piano n 0.01 attack 0.3 decay .
gm s strings n 0.5 attack 2.0 release .
```
If you set `attack`, `decay`, `sustain`, or `release`, your value wins. Unspecified parameters keep the soundfont default.
## Effects
All standard engine parameters work on GM voices. Filter, distort, spatialize:
```forth
gm s bass n 800 lpf 0.3 verb .
gm s epiano n 0.5 delay 1.5 distort .
gm s choir n 0.8 pan 2000 hpf .
```
## Preset Names
| # | Name | # | Name | # | Name | # | Name |
|---|------|---|------|---|------|---|------|
| 0 | piano | 24 | guitar | 48 | strings | 72 | piccolo |
| 1 | brightpiano | 25 | steelguitar | 49 | slowstrings | 73 | flute |
| 4 | epiano | 26 | jazzguitar | 52 | choir | 74 | recorder |
| 6 | harpsichord | 27 | cleangt | 56 | trumpet | 75 | panflute |
| 7 | clavinet | 29 | overdrive | 57 | trombone | 79 | whistle |
| 8 | celesta | 30 | distgt | 58 | tuba | 80 | ocarina |
| 9 | glockenspiel | 33 | bass | 60 | horn | 81 | lead |
| 10 | musicbox | 34 | pickbass | 61 | brass | 82 | sawlead |
| 11 | vibraphone | 35 | fretless | 64 | sopranosax | 89 | pad |
| 12 | marimba | 36 | slapbass | 65 | altosax | 90 | warmpad |
| 13 | xylophone | 38 | synthbass | 66 | tenorsax | 91 | polysynth |
| 14 | bells | 40 | violin | 67 | barisax | 104 | sitar |
| 16 | organ | 41 | viola | 68 | oboe | 105 | banjo |
| 19 | churchorgan | 42 | cello | 70 | bassoon | 108 | kalimba |
| 21 | accordion | 43 | contrabass | 71 | clarinet | 114 | steeldrum |
| 22 | harmonica | 45 | pizzicato | | | | |
| | | 46 | harp | | | | |
| | | 47 | timpani | | | | |
Drums are on a separate bank: use `drums` or `percussion` as the `n` value.
## Examples
A simple GM drum pattern across four steps:
```forth
;; step 1: kick
gm s drums n 36 note .
;; step 2: closed hat
gm s drums n 42 note 0.6 gain .
;; step 3: snare
gm s drums n 38 note .
;; step 4: closed hat
gm s drums n 42 note 0.6 gain .
```
Layer piano chords with randomness:
```forth
gm s piano n
c4 e4 g4 3 choose note
0.3 0.8 rand gain
0.1 0.4 rand verb
.
```
A bass line with envelope override:
```forth
gm s bass n
c2 e2 g2 a2 4 cycle note
0.01 attack 0.2 decay 0.0 sustain
.
```

View File

@@ -14,7 +14,7 @@ cagire = { path = "../..", default-features = false, features = ["block-renderer
cagire-forth = { path = "../../crates/forth" } cagire-forth = { path = "../../crates/forth" }
cagire-project = { path = "../../crates/project" } cagire-project = { path = "../../crates/project" }
cagire-ratatui = { path = "../../crates/ratatui" } cagire-ratatui = { path = "../../crates/ratatui" }
doux = { path = "/Users/bubo/doux", features = ["native"] } doux = { path = "/Users/bubo/doux", features = ["native", "soundfont"] }
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] } nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", features = ["standalone"] }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" } nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug" }
egui_ratatui = "2.1" egui_ratatui = "2.1"

View File

@@ -139,6 +139,13 @@ impl App {
} => { } => {
self.project_state.project.banks[bank].patterns[pattern].name = name; self.project_state.project.banks[bank].patterns[pattern].name = name;
} }
AppCommand::DescribePattern {
bank,
pattern,
description,
} => {
self.project_state.project.banks[bank].patterns[pattern].description = description;
}
AppCommand::RenameStep { AppCommand::RenameStep {
bank, bank,
pattern, pattern,
@@ -179,6 +186,7 @@ impl App {
bank, bank,
pattern, pattern,
name, name,
description,
length, length,
speed, speed,
quantization, quantization,
@@ -189,6 +197,7 @@ impl App {
(bank, pattern), (bank, pattern),
StagedPropChange { StagedPropChange {
name, name,
description,
length, length,
speed, speed,
quantization, quantization,

View File

@@ -187,6 +187,7 @@ impl App {
pattern, pattern,
field: PatternPropsField::default(), field: PatternPropsField::default(),
name: pat.name.clone().unwrap_or_default(), name: pat.name.clone().unwrap_or_default(),
description: pat.description.clone().unwrap_or_default(),
length: pat.length.to_string(), length: pat.length.to_string(),
speed: pat.speed, speed: pat.speed,
quantization: pat.quantization, quantization: pat.quantization,

View File

@@ -74,6 +74,7 @@ impl App {
for ((bank, pattern), props) in self.playback.staged_prop_changes.drain() { for ((bank, pattern), props) in self.playback.staged_prop_changes.drain() {
let pat = self.project_state.project.pattern_at_mut(bank, pattern); let pat = self.project_state.project.pattern_at_mut(bank, pattern);
pat.name = props.name; pat.name = props.name;
pat.description = props.description;
if let Some(len) = props.length { if let Some(len) = props.length {
pat.set_length(len); pat.set_length(len);
} }

View File

@@ -32,7 +32,8 @@ impl App {
let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
} }
AppCommand::RenameStep { bank, pattern, .. } => { AppCommand::RenameStep { bank, pattern, .. }
| AppCommand::DescribePattern { bank, pattern, .. } => {
let data = self.project_state.project.pattern_at(*bank, *pattern).clone(); let data = self.project_state.project.pattern_at(*bank, *pattern).clone();
Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data }) Some(UndoScope::Pattern { bank: *bank, pattern: *pattern, data })
} }

View File

@@ -263,6 +263,7 @@ impl CagireDesktop {
restart_samples, restart_samples,
Arc::clone(&self.audio_sample_pos), Arc::clone(&self.audio_sample_pos),
new_error_tx, new_error_tx,
&self.app.audio.config.sample_paths,
) { ) {
Ok((new_stream, info, new_analysis, registry)) => { Ok((new_stream, info, new_analysis, registry)) => {
self._stream = Some(new_stream); self._stream = Some(new_stream);

View File

@@ -125,6 +125,11 @@ pub enum AppCommand {
step: usize, step: usize,
name: Option<String>, name: Option<String>,
}, },
DescribePattern {
bank: usize,
pattern: usize,
description: Option<String>,
},
Save(PathBuf), Save(PathBuf),
Load(PathBuf), Load(PathBuf),
@@ -142,6 +147,7 @@ pub enum AppCommand {
bank: usize, bank: usize,
pattern: usize, pattern: usize,
name: Option<String>, name: Option<String>,
description: Option<String>,
length: Option<usize>, length: Option<usize>,
speed: PatternSpeed, speed: PatternSpeed,
quantization: LaunchQuantization, quantization: LaunchQuantization,

View File

@@ -294,6 +294,7 @@ pub fn build_stream(
initial_samples: Vec<doux::sampling::SampleEntry>, initial_samples: Vec<doux::sampling::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>, audio_sample_pos: Arc<AtomicU64>,
error_tx: Sender<String>, error_tx: Sender<String>,
sample_paths: &[std::path::PathBuf],
) -> Result< ) -> Result<
( (
Stream, Stream,
@@ -338,6 +339,17 @@ pub fn build_stream(
let mut engine = let mut engine =
Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics)); Engine::new_with_metrics(sample_rate, channels, max_voices, Arc::clone(&metrics));
engine.sample_index = initial_samples; engine.sample_index = initial_samples;
for path in sample_paths {
if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) {
if let Err(e) = engine.load_soundfont(&sf2_path) {
eprintln!("Failed to load soundfont: {e}");
} else {
break;
}
}
}
let registry = Arc::clone(&engine.sample_registry); let registry = Arc::clone(&engine.sample_registry);
let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer); let (mut fft_producer, analysis_handle) = spawn_analysis_thread(sample_rate, spectrum_buffer);
@@ -382,6 +394,11 @@ pub fn build_stream(
AudioCommand::LoadSamples(samples) => { AudioCommand::LoadSamples(samples) => {
engine.sample_index.extend(samples); engine.sample_index.extend(samples);
} }
AudioCommand::LoadSoundfont(path) => {
if let Err(e) = engine.load_soundfont(&path) {
eprintln!("Failed to load soundfont: {e}");
}
}
} }
} }

View File

@@ -53,6 +53,7 @@ pub enum AudioCommand {
Hush, Hush,
Panic, Panic,
LoadSamples(Vec<doux::sampling::SampleEntry>), LoadSamples(Vec<doux::sampling::SampleEntry>),
LoadSoundfont(std::path::PathBuf),
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]

View File

@@ -210,6 +210,7 @@ pub fn init(args: InitArgs) -> Init {
initial_samples, initial_samples,
Arc::clone(&audio_sample_pos), Arc::clone(&audio_sample_pos),
stream_error_tx, stream_error_tx,
&app.audio.config.sample_paths,
) { ) {
Ok((s, info, analysis, registry)) => { Ok((s, info, analysis, registry)) => {
app.audio.config.sample_rate = info.sample_rate; app.audio.config.sample_rate = info.sample_rate;

View File

@@ -214,11 +214,13 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
} }
KeyCode::Char('m') => { KeyCode::Char('m') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageMute { bank, pattern }); ctx.app.mute.toggle_mute(bank, pattern);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
} }
KeyCode::Char('x') => { KeyCode::Char('x') => {
let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern); let (bank, pattern) = (ctx.app.editor_ctx.bank, ctx.app.editor_ctx.pattern);
ctx.dispatch(AppCommand::StageSolo { bank, pattern }); ctx.app.mute.toggle_solo(bank, pattern);
ctx.app.send_mute_state(ctx.seq_cmd_tx);
} }
KeyCode::Char('M') => { KeyCode::Char('M') => {
ctx.dispatch(AppCommand::ClearMutes); ctx.dispatch(AppCommand::ClearMutes);

View File

@@ -207,6 +207,13 @@ fn load_project_samples(ctx: &mut InputContext) {
ctx.app.audio.config.sample_paths = paths; ctx.app.audio.config.sample_paths = paths;
ctx.app.audio.config.sample_count = total_count; ctx.app.audio.config.sample_count = total_count;
for path in &ctx.app.audio.config.sample_paths {
if let Some(sf2_path) = doux::soundfont::find_sf2_file(path) {
let _ = ctx.audio_tx.load().send(AudioCommand::LoadSoundfont(sf2_path));
break;
}
}
if total_count > 0 { if total_count > 0 {
if let Some(registry) = ctx.app.audio.sample_registry.clone() { if let Some(registry) = ctx.app.audio.sample_registry.clone() {
let sr = ctx.app.audio.config.sample_rate; let sr = ctx.app.audio.config.sample_rate;

View File

@@ -204,6 +204,9 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
.map(|e| (e.name.clone(), e.path.clone())) .map(|e| (e.name.clone(), e.path.clone()))
.collect(); .collect();
let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSamples(index)); let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSamples(index));
if let Some(sf2_path) = doux::soundfont::find_sf2_file(&path) {
let _ = ctx.audio_tx.load().send(crate::engine::AudioCommand::LoadSoundfont(sf2_path));
}
ctx.app.audio.config.sample_count += count; ctx.app.audio.config.sample_count += count;
ctx.app.audio.add_sample_path(path); ctx.app.audio.add_sample_path(path);
if let Some(registry) = ctx.app.audio.sample_registry.clone() { if let Some(registry) = ctx.app.audio.sample_registry.clone() {
@@ -376,6 +379,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
pattern, pattern,
field, field,
name, name,
description,
length, length,
speed, speed,
quantization, quantization,
@@ -423,6 +427,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
}, },
KeyCode::Char(c) => match field { KeyCode::Char(c) => match field {
PatternPropsField::Name => name.push(c), PatternPropsField::Name => name.push(c),
PatternPropsField::Description => description.push(c),
PatternPropsField::Length if c.is_ascii_digit() => length.push(c), PatternPropsField::Length if c.is_ascii_digit() => length.push(c),
_ => {} _ => {}
}, },
@@ -430,6 +435,9 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
PatternPropsField::Name => { PatternPropsField::Name => {
name.pop(); name.pop();
} }
PatternPropsField::Description => {
description.pop();
}
PatternPropsField::Length => { PatternPropsField::Length => {
length.pop(); length.pop();
} }
@@ -441,6 +449,11 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
} else { } else {
Some(name.clone()) Some(name.clone())
}; };
let desc_val = if description.is_empty() {
None
} else {
Some(description.clone())
};
let length_val = length.parse().ok(); let length_val = length.parse().ok();
let speed_val = *speed; let speed_val = *speed;
let quant_val = *quantization; let quant_val = *quantization;
@@ -450,6 +463,7 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
bank, bank,
pattern, pattern,
name: name_val, name: name_val,
description: desc_val,
length: length_val, length: length_val,
speed: speed_val, speed: speed_val,
quantization: quant_val, quantization: quant_val,
@@ -625,5 +639,8 @@ fn rename_command(target: &RenameTarget, name: Option<String>) -> AppCommand {
RenameTarget::Step { bank, pattern, step } => AppCommand::RenameStep { RenameTarget::Step { bank, pattern, step } => AppCommand::RenameStep {
bank: *bank, pattern: *pattern, step: *step, name, bank: *bank, pattern: *pattern, step: *step, name,
}, },
RenameTarget::DescribePattern { bank, pattern } => AppCommand::DescribePattern {
bank: *bank, pattern: *pattern, description: name,
},
} }
} }

View File

@@ -229,6 +229,22 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp
ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern }); ctx.dispatch(AppCommand::OpenPatternPropsModal { bank, pattern });
} }
} }
KeyCode::Char('d') => {
if ctx.app.patterns_nav.column == PatternsColumn::Patterns
&& !ctx.app.patterns_nav.has_selection()
{
let bank = ctx.app.patterns_nav.bank_cursor;
let pattern = ctx.app.patterns_nav.pattern_cursor;
let current_desc = ctx.app.project_state.project.banks[bank].patterns[pattern]
.description
.clone()
.unwrap_or_default();
ctx.dispatch(AppCommand::OpenModal(Modal::Rename {
target: RenameTarget::DescribePattern { bank, pattern },
name: current_desc,
}));
}
}
KeyCode::Char('m') => { KeyCode::Char('m') => {
let bank = ctx.app.patterns_nav.bank_cursor; let bank = ctx.app.patterns_nav.bank_cursor;
for pattern in ctx.app.patterns_nav.selected_patterns() { for pattern in ctx.app.patterns_nav.selected_patterns() {

View File

@@ -137,6 +137,7 @@ fn main() -> io::Result<()> {
restart_samples, restart_samples,
Arc::clone(&audio_sample_pos), Arc::clone(&audio_sample_pos),
new_error_tx, new_error_tx,
&app.audio.config.sample_paths,
) { ) {
Ok((new_stream, info, new_analysis, registry)) => { Ok((new_stream, info, new_analysis, registry)) => {
_stream = Some(new_stream); _stream = Some(new_stream);

View File

@@ -123,6 +123,10 @@ pub const DOCS: &[DocEntry] = &[
"Recording", "Recording",
include_str!("../../docs/tutorials/recording.md"), include_str!("../../docs/tutorials/recording.md"),
), ),
Topic(
"Soundfonts",
include_str!("../../docs/tutorials/soundfont.md"),
),
]; ];
pub fn topic_count() -> usize { pub fn topic_count() -> usize {

View File

@@ -20,6 +20,7 @@ pub enum PatternField {
pub enum PatternPropsField { pub enum PatternPropsField {
#[default] #[default]
Name, Name,
Description,
Length, Length,
Speed, Speed,
Quantization, Quantization,
@@ -32,7 +33,8 @@ pub enum PatternPropsField {
impl PatternPropsField { impl PatternPropsField {
pub fn next(&self, follow_up_is_chain: bool) -> Self { pub fn next(&self, follow_up_is_chain: bool) -> Self {
match self { match self {
Self::Name => Self::Length, Self::Name => Self::Description,
Self::Description => Self::Length,
Self::Length => Self::Speed, Self::Length => Self::Speed,
Self::Speed => Self::Quantization, Self::Speed => Self::Quantization,
Self::Quantization => Self::SyncMode, Self::Quantization => Self::SyncMode,
@@ -47,7 +49,8 @@ impl PatternPropsField {
pub fn prev(&self, follow_up_is_chain: bool) -> Self { pub fn prev(&self, follow_up_is_chain: bool) -> Self {
match self { match self {
Self::Name => Self::Name, Self::Name => Self::Name,
Self::Length => Self::Name, Self::Description => Self::Name,
Self::Length => Self::Description,
Self::Speed => Self::Length, Self::Speed => Self::Length,
Self::Quantization => Self::Speed, Self::Quantization => Self::Speed,
Self::SyncMode => Self::Quantization, Self::SyncMode => Self::Quantization,

View File

@@ -35,6 +35,7 @@ pub enum RenameTarget {
Bank { bank: usize }, Bank { bank: usize },
Pattern { bank: usize, pattern: usize }, Pattern { bank: usize, pattern: usize },
Step { bank: usize, pattern: usize, step: usize }, Step { bank: usize, pattern: usize, step: usize },
DescribePattern { bank: usize, pattern: usize },
} }
impl RenameTarget { impl RenameTarget {
@@ -43,6 +44,9 @@ impl RenameTarget {
Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1), Self::Bank { bank } => format!("Rename Bank {:02}", bank + 1),
Self::Pattern { bank, pattern } => format!("Rename {}", model::bp_label(*bank, *pattern)), Self::Pattern { bank, pattern } => format!("Rename {}", model::bp_label(*bank, *pattern)),
Self::Step { step, .. } => format!("Name Step {:02}", step + 1), Self::Step { step, .. } => format!("Name Step {:02}", step + 1),
Self::DescribePattern { bank, pattern } => {
format!("Describe {}", model::bp_label(*bank, *pattern))
}
} }
} }
} }
@@ -73,6 +77,7 @@ pub enum Modal {
pattern: usize, pattern: usize,
field: PatternPropsField, field: PatternPropsField,
name: String, name: String,
description: String,
length: String, length: String,
speed: PatternSpeed, speed: PatternSpeed,
quantization: LaunchQuantization, quantization: LaunchQuantization,

View File

@@ -17,6 +17,7 @@ pub enum StagedMuteChange {
pub struct StagedPropChange { pub struct StagedPropChange {
pub name: Option<String>, pub name: Option<String>,
pub description: Option<String>,
pub length: Option<usize>, pub length: Option<usize>,
pub speed: PatternSpeed, pub speed: PatternSpeed,
pub quantization: LaunchQuantization, pub quantization: LaunchQuantization,

View File

@@ -55,13 +55,17 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
} }
Page::Patterns => { Page::Patterns => {
bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns"));
bindings.push(("Shift+↑↓", "Select", "Extend selection"));
bindings.push(("Alt+↑↓", "Shift", "Move patterns up/down"));
bindings.push(("Enter", "Select", "Select pattern for editing")); bindings.push(("Enter", "Select", "Select pattern for editing"));
if !plugin_mode { if !plugin_mode {
bindings.push(("Space", "Play", "Toggle pattern playback")); bindings.push(("Space", "Play", "Toggle pattern playback"));
} }
bindings.push(("Esc", "Back", "Clear staged or go back")); bindings.push(("Esc", "Back", "Clear staged or go back"));
bindings.push(("c", "Commit", "Commit staged changes")); bindings.push(("c", "Commit", "Commit staged changes"));
bindings.push(("p", "Stage play", "Stage pattern play toggle"));
bindings.push(("r", "Rename", "Rename bank/pattern")); bindings.push(("r", "Rename", "Rename bank/pattern"));
bindings.push(("d", "Describe", "Add description to pattern"));
bindings.push(("e", "Properties", "Edit pattern properties")); bindings.push(("e", "Properties", "Edit pattern properties"));
bindings.push(("m", "Mute", "Stage mute for pattern")); bindings.push(("m", "Mute", "Stage mute for pattern"));
bindings.push(("x", "Solo", "Stage solo for pattern")); bindings.push(("x", "Solo", "Stage solo for pattern"));
@@ -70,6 +74,8 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati
bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern")); bindings.push(("Ctrl+C", "Copy", "Copy bank/pattern"));
bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern")); bindings.push(("Ctrl+V", "Paste", "Paste bank/pattern"));
bindings.push(("Del", "Reset", "Reset bank/pattern")); bindings.push(("Del", "Reset", "Reset bank/pattern"));
bindings.push(("Ctrl+Z", "Undo", "Undo last action"));
bindings.push(("Ctrl+Shift+Z", "Redo", "Redo last action"));
} }
Page::Engine => { Page::Engine => {
bindings.push(("Tab", "Section", "Next section")); bindings.push(("Tab", "Section", "Next section"));

View File

@@ -496,13 +496,22 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
height: 1, height: 1,
}; };
let label = format!( let right_label = format!(
"{} · {}", "{} · {}",
pattern.quantization.label(), pattern.quantization.label(),
pattern.sync_mode.label() pattern.sync_mode.label()
); );
let w = detail_area.width as usize; let w = detail_area.width as usize;
let padded_label = format!("{label:>w$}"); let label = if let Some(desc) = &pattern.description {
let right_len = right_label.chars().count();
let max_desc = w.saturating_sub(right_len + 1);
let truncated: String = desc.chars().take(max_desc).collect();
let pad = w.saturating_sub(truncated.chars().count() + right_len);
format!("{truncated}{}{right_label}", " ".repeat(pad))
} else {
format!("{right_label:>w$}")
};
let padded_label = label;
let filled_width = if is_playing { let filled_width = if is_playing {
let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0); let ratio = snapshot.get_smooth_progress(bank, idx, length, speed.multiplier()).unwrap_or(0.0);
@@ -725,6 +734,7 @@ fn render_properties(
let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx]; let pattern = &app.project_state.project.banks[bank].patterns[pattern_idx];
let name = pattern.name.as_deref().unwrap_or("-"); let name = pattern.name.as_deref().unwrap_or("-");
let desc = pattern.description.as_deref().unwrap_or("-");
let content_count = pattern.content_step_count(); let content_count = pattern.content_step_count();
let steps_label = format!("{}/{}", content_count, pattern.length); let steps_label = format!("{}/{}", content_count, pattern.length);
let speed_label = pattern.speed.label(); let speed_label = pattern.speed.label();
@@ -739,6 +749,10 @@ fn render_properties(
Span::styled(" Name ", label_style), Span::styled(" Name ", label_style),
Span::styled(name, value_style), Span::styled(name, value_style),
]), ]),
Line::from(vec![
Span::styled(" Desc ", label_style),
Span::styled(desc, value_style),
]),
Line::from(vec![ Line::from(vec![
Span::styled(" Steps ", label_style), Span::styled(" Steps ", label_style),
Span::styled(steps_label, value_style), Span::styled(steps_label, value_style),

View File

@@ -650,6 +650,7 @@ fn render_modal(
pattern, pattern,
field, field,
name, name,
description,
length, length,
speed, speed,
quantization, quantization,
@@ -660,7 +661,7 @@ fn render_modal(
use crate::state::PatternPropsField; use crate::state::PatternPropsField;
let is_chain = matches!(follow_up, FollowUp::Chain { .. }); let is_chain = matches!(follow_up, FollowUp::Chain { .. });
let modal_height = if is_chain { 16 } else { 14 }; let modal_height = if is_chain { 18 } else { 16 };
let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1)) let inner = ModalFrame::new(&format!(" Pattern B{:02}:P{:02} ", bank + 1, pattern + 1))
.width(50) .width(50)
@@ -678,6 +679,7 @@ fn render_modal(
}; };
let mut fields: Vec<(&str, String, bool)> = vec![ let mut fields: Vec<(&str, String, bool)> = vec![
("Name", name.clone(), *field == PatternPropsField::Name), ("Name", name.clone(), *field == PatternPropsField::Name),
("Desc", description.clone(), *field == PatternPropsField::Description),
("Length", length.clone(), *field == PatternPropsField::Length), ("Length", length.clone(), *field == PatternPropsField::Length),
("Speed", speed_label, *field == PatternPropsField::Speed), ("Speed", speed_label, *field == PatternPropsField::Speed),
("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization), ("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization),