Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
350 changes: 253 additions & 97 deletions src/app.rs

Large diffs are not rendered by default.

344 changes: 232 additions & 112 deletions src/audio/engine.rs

Large diffs are not rendered by default.

363 changes: 239 additions & 124 deletions src/keys.rs

Large diffs are not rendered by default.

18 changes: 13 additions & 5 deletions src/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,22 @@ use crate::sequencer::drum_pattern::{DrumPattern, DrumTrackId};
use crate::sequencer::synth_pattern::SynthPattern;
use crate::sequencer::transport::Transport;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SynthId {
A,
B,
}

/// Commands sent from the UI thread to the audio thread.
/// Sent via a bounded crossbeam channel (capacity 64).
pub enum UiToAudio {
SetTransport(Transport),
SetDrumPattern(DrumPattern),
SetSynthPattern(SynthPattern),
SetSynthPattern(SynthId, SynthPattern),
SetEffectParams(EffectParams),
TriggerDrum(DrumTrackId), // fire the voice immediately
TriggerSynth(u8), // MIDI note number — fire synth immediately
ReleaseSynth, // release synth envelopes
TriggerSynth(SynthId, u8), // MIDI note number — fire synth immediately
ReleaseSynth(SynthId), // release synth envelopes
}

/// Notifications sent from the audio thread back to the UI.
Expand All @@ -25,8 +31,10 @@ pub enum AudioToUi {
beat: u8,
is_bar_start: bool,
triggered: u8, // bitmask: which drum tracks triggered on this step
synth_triggered: bool, // whether synth was triggered on this step
synth_a_triggered: bool, // whether synth A was triggered on this step
drum_step: usize, // drum pattern step (global_step % drum_length)
synth_step: usize, // synth pattern step (global_step % synth_length)
synth_a_step: usize, // synth A pattern step (global_step % synth_length)
synth_b_step: usize, // synth B pattern step (global_step % synth_length)
synth_b_triggered: bool, // whether synth B was triggered on this step
},
}
293 changes: 198 additions & 95 deletions src/mouse.rs

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions src/presets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub enum PatternMergeMode {
#[derive(Clone, Debug, PartialEq)]
pub struct PresetBrowserState {
pub target: PresetTarget,
pub target_synth: crate::messages::SynthId, // which synth to apply synth presets to
pub categories: Vec<&'static str>,
pub category_idx: usize,
pub preset_names: Vec<&'static str>,
Expand All @@ -64,6 +65,7 @@ impl PresetBrowserState {
.collect();
Self {
target: PresetTarget::DrumSound(track),
target_synth: crate::messages::SynthId::A, // default, not used for drum presets
categories,
category_idx: 0,
preset_names: names,
Expand All @@ -80,6 +82,7 @@ impl PresetBrowserState {
.collect();
Self {
target: PresetTarget::SynthSound,
target_synth: crate::messages::SynthId::A, // default, will be set when browser opens
categories,
category_idx: 0,
preset_names: names,
Expand Down Expand Up @@ -150,6 +153,7 @@ impl PresetBrowserState {
.collect();
Self {
target: PresetTarget::Pattern,
target_synth: crate::messages::SynthId::A, // default, not used for drum patterns
categories,
category_idx: 0,
preset_names: names,
Expand All @@ -175,6 +179,7 @@ impl PresetBrowserState {
.collect();
Self {
target: PresetTarget::SynthPattern,
target_synth: crate::messages::SynthId::A, // default, will be set when browser opens
categories,
category_idx: 0,
preset_names: names,
Expand Down
177 changes: 177 additions & 0 deletions src/sequencer/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,14 @@ pub struct ProjectFile {
pub synth_patterns: Vec<SynthPatternData>,
#[serde(default)]
pub active_synth_pattern: usize,
#[serde(default)]
pub synth_b_kits: Vec<SynthKitData>,
#[serde(default)]
pub active_synth_b_kit: usize,
#[serde(default)]
pub synth_b_patterns: Vec<SynthPatternData>,
#[serde(default)]
pub active_synth_b_pattern: usize,
}

fn default_bpm() -> f64 { 120.0 }
Expand Down Expand Up @@ -452,6 +460,20 @@ impl Default for ProjectFile {
..Default::default()
});
}
let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS);
for i in 0..NUM_PATTERNS {
synth_b_patterns.push(SynthPatternData {
name: format!("Synth B {}", i + 1),
..Default::default()
});
}
let mut synth_b_kits = Vec::with_capacity(NUM_KITS);
for i in 0..NUM_KITS {
synth_b_kits.push(SynthKitData {
name: format!("Synth B Kit {}", i + 1),
..Default::default()
});
}
Self {
textstep: FileHeader::default(),
metadata: ProjectMetadata {
Expand All @@ -471,6 +493,10 @@ impl Default for ProjectFile {
active_synth_kit: 0,
synth_patterns,
active_synth_pattern: 0,
synth_b_kits,
active_synth_b_kit: 0,
synth_b_patterns,
active_synth_b_pattern: 0,
}
}
}
Expand Down Expand Up @@ -657,6 +683,20 @@ pub fn demo_project() -> ProjectFile {
..Default::default()
});
}
let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS);
for i in 0..NUM_PATTERNS {
synth_b_patterns.push(SynthPatternData {
name: format!("Synth B {}", i + 1),
..Default::default()
});
}
let mut synth_b_kits = Vec::with_capacity(NUM_KITS);
for i in 0..NUM_KITS {
synth_b_kits.push(SynthKitData {
name: format!("Synth B Kit {}", i + 1),
..Default::default()
});
}

ProjectFile {
textstep: FileHeader::default(),
Expand All @@ -677,6 +717,10 @@ pub fn demo_project() -> ProjectFile {
active_synth_kit: 0,
synth_patterns,
active_synth_pattern: 0,
synth_b_kits,
active_synth_b_kit: 0,
synth_b_patterns,
active_synth_b_pattern: 0,
}
}

Expand Down Expand Up @@ -792,6 +836,34 @@ impl ProjectFile {
}
}

/// Save synth B pattern steps to project.
pub fn save_synth_b_pattern(&mut self, index: usize, pattern: &SynthPattern) {
if index < self.synth_b_patterns.len() {
self.synth_b_patterns[index] = SynthPatternData::from_synth_pattern(pattern);
}
}

/// Load synth B pattern steps from project.
pub fn load_synth_b_pattern(&self, index: usize, pattern: &mut SynthPattern) {
if let Some(pat_data) = self.synth_b_patterns.get(index) {
pat_data.apply_to(pattern);
}
}

/// Save synth B kit params to project.
pub fn save_synth_b_kit(&mut self, index: usize, params: &SynthParams) {
if index < self.synth_b_kits.len() {
self.synth_b_kits[index].params = *params;
}
}

/// Load synth B kit params into pattern.
pub fn load_synth_b_kit(&self, index: usize, pattern: &mut SynthPattern) {
if let Some(kit_data) = self.synth_b_kits.get(index) {
kit_data.apply_to(pattern);
}
}

/// Ensure we always have NUM_PATTERNS patterns and NUM_KITS kits.
pub fn normalize(&mut self) {
// Migrate old single-kit format: if kits is empty, seed from legacy kit field
Expand Down Expand Up @@ -841,6 +913,28 @@ impl ProjectFile {
if self.active_synth_pattern >= self.synth_patterns.len() {
self.active_synth_pattern = 0;
}

while self.synth_b_kits.len() < NUM_KITS {
let idx = self.synth_b_kits.len();
self.synth_b_kits.push(SynthKitData {
name: format!("Synth B Kit {}", idx + 1),
..Default::default()
});
}
if self.active_synth_b_kit >= self.synth_b_kits.len() {
self.active_synth_b_kit = 0;
}

while self.synth_b_patterns.len() < NUM_PATTERNS {
let idx = self.synth_b_patterns.len();
self.synth_b_patterns.push(SynthPatternData {
name: format!("Synth B {}", idx + 1),
..Default::default()
});
}
if self.active_synth_b_pattern >= self.synth_b_patterns.len() {
self.active_synth_b_pattern = 0;
}
}
}

Expand Down Expand Up @@ -1037,6 +1131,89 @@ mod tests {
// Remaining kits are defaults
assert_eq!(proj.kits[1].name, "Kit 2");
}

#[test]
fn test_project_roundtrip_dual_synth() {
// Create a project with synth B data
let mut project = ProjectFile::default();

// Set synth B pattern data
project.synth_b_patterns[0].name = "Test Synth B Pattern".to_string();
project.synth_b_patterns[1].name = "Custom Pattern".to_string();
project.synth_b_patterns[1].steps[5] = SynthStepData {
active: true,
note: 60,
velocity: 0.8,
gate: 0.9,
length: 4,
};

// Set synth B kit data
project.synth_b_kits[0].name = "Test Synth B Kit".to_string();
project.synth_b_kits[0].params.osc1_level = 0.75;

project.active_synth_b_kit = 2;
project.active_synth_b_pattern = 3;

// Serialize
let json = serde_json::to_string(&project).unwrap();

// Deserialize
let mut loaded: ProjectFile = serde_json::from_str(&json).unwrap();
loaded.normalize();

// Verify synth B data survived
assert_eq!(loaded.synth_b_patterns[0].name, "Test Synth B Pattern");
assert_eq!(loaded.synth_b_patterns[1].name, "Custom Pattern");
assert_eq!(loaded.synth_b_patterns[1].steps[5].active, true);
assert_eq!(loaded.synth_b_patterns[1].steps[5].note, 60);
assert_eq!(loaded.synth_b_patterns[1].steps[5].velocity, 0.8);
assert_eq!(loaded.synth_b_kits[0].name, "Test Synth B Kit");
assert_eq!(loaded.synth_b_kits[0].params.osc1_level, 0.75);
assert_eq!(loaded.active_synth_b_kit, 2);
assert_eq!(loaded.active_synth_b_pattern, 3);

// Verify arrays are properly sized
assert_eq!(loaded.synth_b_patterns.len(), NUM_PATTERNS);
assert_eq!(loaded.synth_b_kits.len(), NUM_KITS);
}

#[test]
fn test_old_project_loads_with_synth_b_defaults() {
// Simulate an old project file without synth_b fields
let json = r#"{
"textstep": {"format_version": 1, "app_version": "0.1.0"},
"metadata": {"name": "Old Project"},
"kits": [{"name": "Kit 1", "tracks": []}],
"active_kit": 0,
"patterns": [{"name": "P1", "steps": []}],
"active_pattern": 0,
"bpm": 120.0,
"loop_length": 32,
"swing": 0.5,
"synth_kits": [{"name": "Synth Kit 1", "params": {}}],
"active_synth_kit": 0,
"synth_patterns": [{"name": "Synth 1", "steps": []}],
"active_synth_pattern": 0
}"#;

let mut project: ProjectFile = serde_json::from_str(json).unwrap();
project.normalize();

// Verify synth_b fields get defaults
assert_eq!(project.synth_b_patterns.len(), NUM_PATTERNS);
assert_eq!(project.synth_b_kits.len(), NUM_KITS);
assert_eq!(project.active_synth_b_kit, 0);
assert_eq!(project.active_synth_b_pattern, 0);

// Default names should be present
assert_eq!(project.synth_b_patterns[0].name, "Synth B 1");
assert_eq!(project.synth_b_kits[0].name, "Synth B Kit 1");

// Old data should be intact
assert_eq!(project.metadata.name, "Old Project");
assert_eq!(project.synth_patterns[0].name, "Synth 1");
}
}

#[cfg(test)]
Expand Down
14 changes: 11 additions & 3 deletions src/sequencer/transport.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
//! Transport state: play/pause/stop, BPM, loop configuration, swing amount.

use serde::{Deserialize, Serialize};

/// Sequencer playback state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlayState {
Expand All @@ -15,19 +17,25 @@ pub enum RecordMode {
}

/// Per-section loop length settings (8/16/24/32 steps for drum and synth independently).
#[derive(Clone, Copy, Debug)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct LoopConfig {
pub enabled: bool,
pub drum_length: u8, // 8, 16, 24, or 32
pub synth_length: u8, // 8, 16, 24, or 32
#[serde(alias = "synth_length")]
pub synth_a_length: u8, // 8, 16, 24, or 32 (was: synth_length)
#[serde(default = "default_synth_b_length")]
pub synth_b_length: u8, // 8, 16, 24, or 32
}

fn default_synth_b_length() -> u8 { 16 }

impl Default for LoopConfig {
fn default() -> Self {
Self {
enabled: false,
drum_length: 32,
synth_length: 32,
synth_a_length: 32,
synth_b_length: 16,
}
}
}
Expand Down
Loading
Loading