5 Commits

9 changed files with 354 additions and 53 deletions

View File

@@ -54,22 +54,22 @@ jobs:
echo "C:\Program Files\CMake\bin" >> $env:GITHUB_PATH
- name: Build
run: cargo build --release --target x86_64-pc-windows-msvc
run: cargo build --release --features asio --target x86_64-pc-windows-msvc
- name: Build desktop
run: cargo build --release --features desktop --bin cagire-desktop --target x86_64-pc-windows-msvc
run: cargo build --release --features desktop,asio --bin cagire-desktop --target x86_64-pc-windows-msvc
- name: Test
if: inputs.run-tests
run: cargo test --target x86_64-pc-windows-msvc
run: cargo test --features asio --target x86_64-pc-windows-msvc
- name: Clippy
if: inputs.run-clippy
run: cargo clippy --target x86_64-pc-windows-msvc -- -D warnings
run: cargo clippy --features asio --target x86_64-pc-windows-msvc -- -D warnings
- name: Bundle CLAP plugin
if: inputs.build-packages
run: cargo xtask bundle cagire-plugins --release --target x86_64-pc-windows-msvc
run: cargo xtask bundle cagire-plugins --release --features asio --target x86_64-pc-windows-msvc
- name: Install NSIS
if: inputs.build-packages

View File

@@ -2,6 +2,56 @@
All notable changes to this project will be documented in this file.
## [0.1.4]
### Breaking
- **Doux v0.0.12**: removed Mutable Instruments Plaits modes (`modal`, `va`, `analog`, `waveshape`, `grain`, `chord`, `swarm`, `pnoise`, etc.). Native percussion models retained; new models added: `tom`, `cowbell`, `cymbal`.
- Simplified effects/filter API: removed per-filter envelope parameters in favor of the universal `env` word.
- Recording commands simplified: removed `/sound/` path segment from `rec`, `overdub`, `orec`, `odub`.
### Forth Language
- New modulation transition words: `islide` (swell), `oslide` (pluck), `pslide` (stair/stepped).
- New `lpg` word (Low Pass Gate): pairs amplitude envelope with lowpass filter modulation.
- New `inchan` word: select audio input channel by index.
- New EQ frequency words: `eqlofreq`, `eqmidfreq`, `eqhifreq`.
### UI / UX
- Redesigned top bar: consolidated transport, tempo, bar:beat display with visual beat segments.
- CPU meter with color-coded fill bar (green/yellow/red).
### Engine
- Audio input channel selection support.
- Audio buffer sizing improved for multi-channel input.
### Packaging
- CI migrated from GitHub Actions to Gitea Actions.
- Removed WIX installer; Windows now distributed via zip and NSIS only.
- Gitea Actions workflow for automatic website deployment.
- Added LICENSE file.
### Documentation
- Extensive documentation updates reflecting doux v0.0.12 API changes across sources, filters, modulation, wavetable, and audio modulation docs.
## [0.1.3]
### Forth Language
- New `stretch` word: pitch-independent time stretching via phase vocoder (e.g., `kick sound 2 stretch .` plays at half speed, same pitch).
- Automatic default release time on sounds when none is explicitly set.
### Engine
- Sample-accurate timing: delta computation switched from float seconds to integer sample ticks, fixing precision issues.
- Lock-free audio input buffer: replaced `Arc<Mutex<VecDeque>>` with `HeapRb` ring buffer.
- Theme access optimized: `Rc<ThemeColors>` replaces deep cloning on every `get()`.
- Dictionary keys cached in `App` to avoid repeated lock acquisitions during rendering.
### Fixed
- Realtime priority diagnostics: dedicated `warn_no_rt()` on Linux, lookahead widened from 20ms to 40ms when RT priority unavailable.
- Float epsilon precision in delta/nudge zero-comparisons.
- Windows build fixes for standalone and plugin targets.
### Documentation
- Time stretching usage guide added to `docs/engine/samples.md`.
## [0.1.2]
### Forth Language

74
Cargo.lock generated
View File

@@ -372,6 +372,20 @@ dependencies = [
"libloading 0.8.9",
]
[[package]]
name = "asio-sys"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "826194e1612938c9be09b78b58323fbb2e326de3d491b4230186cf6e832d8ded"
dependencies = [
"bindgen",
"cc",
"num-derive",
"num-traits",
"parse_cfg",
"walkdir",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -846,7 +860,7 @@ checksum = "981520c98f422fcc584dc1a95c334e6953900b9106bc47a9839b81790009eb21"
[[package]]
name = "cagire"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"arboard",
"arc-swap",
@@ -859,7 +873,7 @@ dependencies = [
"cpal 0.17.1",
"crossbeam-channel",
"crossterm",
"doux 0.0.12",
"doux 0.0.14",
"eframe",
"egui",
"egui_ratatui",
@@ -885,7 +899,7 @@ dependencies = [
[[package]]
name = "cagire-forth"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"arc-swap",
"parking_lot",
@@ -894,7 +908,7 @@ dependencies = [
[[package]]
name = "cagire-markdown"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"minimad",
"ratatui",
@@ -902,7 +916,7 @@ dependencies = [
[[package]]
name = "cagire-plugins"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"arc-swap",
"cagire",
@@ -911,7 +925,7 @@ dependencies = [
"cagire-ratatui",
"crossbeam-channel",
"crossterm",
"doux 0.0.10",
"doux 0.0.13",
"egui_ratatui",
"nih_plug",
"nih_plug_egui",
@@ -926,7 +940,7 @@ dependencies = [
[[package]]
name = "cagire-project"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"base64",
"brotli",
@@ -938,7 +952,7 @@ dependencies = [
[[package]]
name = "cagire-ratatui"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"rand",
"ratatui",
@@ -1452,6 +1466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
dependencies = [
"alsa 0.10.0",
"asio-sys",
"coreaudio-rs 0.13.0",
"dasp_sample",
"jack 0.13.5",
@@ -1809,14 +1824,13 @@ dependencies = [
[[package]]
name = "doux"
version = "0.0.10"
source = "git+https://github.com/sova-org/doux#7f4e548ae3a917e62cf4c9acb7540496684f0d8f"
version = "0.0.13"
source = "git+https://github.com/sova-org/doux?tag=v0.0.13#b8150d907e4cc2764e82fdaa424df41ceef9b0d2"
dependencies = [
"arc-swap",
"clap",
"cpal 0.17.1",
"crossbeam-channel",
"mi-plaits-dsp",
"ringbuf",
"rosc",
"rustyline",
@@ -1826,8 +1840,8 @@ dependencies = [
[[package]]
name = "doux"
version = "0.0.12"
source = "git+https://github.com/sova-org/doux?tag=v0.0.12#5b62d6634df217a00ced5e711fe98b77c9d3f79c"
version = "0.0.14"
source = "git+https://github.com/sova-org/doux?tag=v0.0.14#f0de4f4047adfced8fb2116edd3b33d260ba75c8"
dependencies = [
"arc-swap",
"clap",
@@ -1852,12 +1866,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ecolor"
version = "0.33.3"
@@ -3255,16 +3263,6 @@ dependencies = [
"paste",
]
[[package]]
name = "mi-plaits-dsp"
version = "0.1.0"
source = "git+https://github.com/sourcebox/mi-plaits-dsp-rs?rev=dc55bd55e73bd6f86fbbb4f8adc3b598d659fdb4#dc55bd55e73bd6f86fbbb4f8adc3b598d659fdb4"
dependencies = [
"dyn-clone",
"num-traits",
"spin",
]
[[package]]
name = "micromath"
version = "2.1.0"
@@ -4135,6 +4133,15 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "parse_cfg"
version = "4.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "905787a434a2c721408e7c9a252e85f3d93ca0f118a5283022636c0e05a7ea49"
dependencies = [
"nom",
]
[[package]]
name = "paste"
version = "1.0.15"
@@ -5145,15 +5152,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a7f4cb358863e55f8f1a3882f68601360cf6c42fc53ff2fe9aea41c33e24489"
[[package]]
name = "spin"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591"
dependencies = [
"lock_api",
]
[[package]]
name = "spirv"
version = "0.3.0+sdk-1.3.268.0"

View File

@@ -2,7 +2,7 @@
members = ["crates/forth", "crates/markdown", "crates/project", "crates/ratatui", "plugins/cagire-plugins", "plugins/baseview", "plugins/egui-baseview", "plugins/nih-plug-egui", "xtask"]
[workspace.package]
version = "0.1.3"
version = "0.1.4"
edition = "2021"
authors = ["Raphaël Forment <raphael.forment@gmail.com>"]
license = "AGPL-3.0"
@@ -45,13 +45,14 @@ desktop = [
"dep:egui_ratatui",
"dep:image",
]
asio = ["doux/asio", "cpal/asio"]
[dependencies]
cagire-forth = { path = "crates/forth" }
cagire-markdown = { path = "crates/markdown" }
cagire-project = { path = "crates/project" }
cagire-ratatui = { path = "crates/ratatui" }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.12", features = ["native", "soundfont"] }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.14", features = ["native", "soundfont"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"

View File

@@ -1778,6 +1778,9 @@ fn emit_output(
}
for (i, (k, v)) in params.iter().enumerate() {
if v.is_empty() {
continue;
}
if !out.ends_with('/') {
out.push('/');
}

View File

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

View File

@@ -234,6 +234,7 @@ pub fn create_editor(
// Read live snapshot from the audio thread
let shared = editor.bridge.shared_state.load();
editor.snapshot = SequencerSnapshot::from(shared.as_ref());
editor.app.playback.playing = editor.snapshot.playing;
// Sync host tempo into LinkState so title bar shows real tempo
if shared.tempo > 0.0 {
@@ -298,6 +299,11 @@ pub fn create_editor(
let elapsed = editor.last_frame.elapsed();
editor.last_frame = Instant::now();
if editor.app.playback.has_armed() {
let rate = std::f32::consts::TAU;
editor.app.ui.pulse_phase = (editor.app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU;
}
let link = &editor.link;
let app = &editor.app;
let snapshot = &editor.snapshot;

View File

@@ -496,6 +496,11 @@ impl eframe::App for CagireDesktop {
let elapsed = self.last_frame.elapsed();
self.last_frame = std::time::Instant::now();
if self.app.playback.has_armed() {
let rate = std::f32::consts::TAU;
self.app.ui.pulse_phase = (self.app.ui.pulse_phase + elapsed.as_secs_f32() * rate) % std::f32::consts::TAU;
}
let link = &self.link;
let app = &self.app;
self.terminal

View File

@@ -170,6 +170,7 @@ pub struct SharedSequencerState {
pub event_count: usize,
pub tempo: f64,
pub beat: f64,
pub playing: bool,
pub script_trace: Option<ExecutionTrace>,
pub print_output: Option<String>,
}
@@ -180,6 +181,7 @@ pub struct SequencerSnapshot {
pub event_count: usize,
pub tempo: f64,
pub beat: f64,
pub playing: bool,
script_trace: Option<ExecutionTrace>,
pub print_output: Option<String>,
}
@@ -192,6 +194,7 @@ impl From<&SharedSequencerState> for SequencerSnapshot {
event_count: s.event_count,
tempo: s.tempo,
beat: s.beat,
playing: s.playing,
script_trace: s.script_trace.clone(),
print_output: s.print_output.clone(),
}
@@ -207,6 +210,7 @@ impl SequencerSnapshot {
event_count: 0,
tempo: 0.0,
beat: 0.0,
playing: false,
script_trace: None,
print_output: None,
}
@@ -306,6 +310,7 @@ struct PendingPattern {
struct AudioState {
prev_beat: f64,
pause_beat: Option<f64>,
active_patterns: HashMap<PatternId, ActivePattern>,
pending_starts: Vec<PendingPattern>,
pending_stops: Vec<PendingPattern>,
@@ -316,6 +321,7 @@ impl AudioState {
fn new() -> Self {
Self {
prev_beat: -1.0,
pause_beat: None,
active_patterns: HashMap::new(),
pending_starts: Vec::new(),
pending_stops: Vec::new(),
@@ -572,6 +578,7 @@ pub struct SequencerState {
soloed: std::collections::HashSet<(usize, usize)>,
last_tempo: f64,
last_beat: f64,
last_playing: bool,
script_text: String,
script_speed: crate::model::PatternSpeed,
script_length: usize,
@@ -610,6 +617,7 @@ impl SequencerState {
soloed: std::collections::HashSet::new(),
last_tempo: 0.0,
last_beat: 0.0,
last_playing: false,
script_text: String::new(),
script_speed: crate::model::PatternSpeed::default(),
script_length: 16,
@@ -714,6 +722,7 @@ impl SequencerState {
self.audio_state.active_patterns.clear();
self.audio_state.pending_starts.clear();
self.audio_state.pending_stops.clear();
self.audio_state.pause_beat = None;
self.step_traces = Arc::new(HashMap::new());
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
@@ -724,6 +733,7 @@ impl SequencerState {
active.iter = 0;
}
self.audio_state.prev_beat = -1.0;
self.audio_state.pause_beat = None;
self.script_frontier = -1.0;
self.script_step = 0;
self.script_trace = None;
@@ -751,6 +761,7 @@ impl SequencerState {
self.process_commands(input.commands);
self.last_tempo = input.tempo;
self.last_beat = input.beat;
self.last_playing = input.playing;
if !input.playing {
return self.tick_paused();
@@ -758,14 +769,21 @@ impl SequencerState {
let frontier = self.audio_state.prev_beat;
let lookahead_end = input.lookahead_end;
let resuming = frontier < 0.0;
if frontier < 0.0 {
let boundary_frontier = if resuming {
self.audio_state.pause_beat.take().unwrap_or(input.beat)
} else {
frontier
};
self.activate_pending(lookahead_end, boundary_frontier, input.quantum);
self.deactivate_pending(lookahead_end, boundary_frontier, input.quantum);
if resuming {
self.realign_phaselock_patterns(lookahead_end);
}
self.activate_pending(lookahead_end, frontier, input.quantum);
self.deactivate_pending(lookahead_end, frontier, input.quantum);
let steps = self.execute_steps(
input.beat,
frontier,
@@ -822,6 +840,9 @@ impl SequencerState {
self.pattern_cache.set(key.0, key.1, snapshot);
}
}
if self.audio_state.prev_beat >= 0.0 {
self.audio_state.pause_beat = Some(self.audio_state.prev_beat);
}
self.audio_state.prev_beat = -1.0;
self.script_frontier = -1.0;
self.script_step = 0;
@@ -1240,6 +1261,7 @@ impl SequencerState {
event_count: self.event_count,
tempo: self.last_tempo,
beat: self.last_beat,
playing: self.last_playing,
script_trace: self.script_trace.clone(),
print_output: self.print_output.clone(),
}
@@ -1975,10 +1997,8 @@ mod tests {
assert!(!state.audio_state.pending_starts.is_empty());
// Resume playing — first tick resets prev_beat from -1 to 2.0
// Resume playing — Immediate fires on first tick
state.tick(tick_at(2.0, true));
// Second tick: prev_beat is now >= 0, so Immediate fires
state.tick(tick_at(2.25, true));
assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0)));
}
@@ -2187,4 +2207,222 @@ mod tests {
// Should have commands from both patterns (2 patterns * 1 command each)
assert!(output.audio_commands.len() >= 2);
}
#[test]
fn test_bar_boundary_crossed_during_pause() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: simple_pattern(4),
}],
0.0,
));
// Queue Bar-quantized start at beat 3.5 (before bar at beat 4.0)
state.tick(tick_with(
vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
}],
3.5,
));
assert!(!state.audio_state.active_patterns.contains_key(&pid(0, 0)));
// Pause — saves prev_beat=3.5 as pause_beat
state.tick(tick_at(3.75, false));
// Resume after bar boundary (beat 4.0 crossed)
state.tick(tick_at(4.5, true));
assert!(
state.audio_state.active_patterns.contains_key(&pid(0, 0)),
"Bar-quantized pattern should activate on resume after bar boundary"
);
}
#[test]
fn test_immediate_activates_on_first_resume_tick() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: simple_pattern(4),
}],
0.0,
));
// Pause
state.tick(tick_at(1.0, false));
// Queue Immediate start while paused
state.tick(TickInput {
commands: vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
..tick_at(2.0, false)
});
// Resume — Immediate should fire on this single tick
state.tick(tick_at(3.0, true));
assert!(
state.audio_state.active_patterns.contains_key(&pid(0, 0)),
"Immediate should activate on first resume tick"
);
}
#[test]
fn test_multiple_patterns_sync_after_pause() {
let mut state = make_state();
state.tick(tick_with(
vec![
SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: simple_pattern(4),
},
SeqCommand::PatternUpdate {
bank: 0,
pattern: 1,
data: simple_pattern(8),
},
],
0.0,
));
// Queue two Bar-quantized starts
state.tick(tick_with(
vec![
SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
},
SeqCommand::PatternStart {
bank: 0,
pattern: 1,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
},
],
3.5,
));
// Pause before bar
state.tick(tick_at(3.75, false));
// Resume after bar boundary
state.tick(tick_at(4.5, true));
assert!(
state.audio_state.active_patterns.contains_key(&pid(0, 0)),
"First pattern should activate"
);
assert!(
state.audio_state.active_patterns.contains_key(&pid(0, 1)),
"Second pattern should activate together"
);
}
fn phaselock_pattern(length: usize) -> PatternSnapshot {
PatternSnapshot {
speed: Default::default(),
length,
steps: (0..length)
.map(|_| StepSnapshot {
active: true,
script: "test".into(),
source: None,
})
.collect(),
sync_mode: SyncMode::PhaseLock,
follow_up: FollowUp::default(),
}
}
#[test]
fn test_phaselock_position_correct_after_resume() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: phaselock_pattern(16),
}],
0.0,
));
// Queue PhaseLock pattern
state.tick(tick_with(
vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::PhaseLock,
}],
3.5,
));
// Pause before bar
state.tick(tick_at(3.75, false));
// Resume at beat 5.0 (after bar boundary at 4.0)
let resume_beat = 5.0;
state.tick(tick_at(resume_beat, true));
assert!(state.audio_state.active_patterns.contains_key(&pid(0, 0)));
// realign_phaselock_patterns uses: (beat * 4.0).floor() + 1 % length
// At beat 5.0: (5.0 * 4.0).floor() = 20, +1 = 21, 21 % 16 = 5
let ap = state.audio_state.active_patterns.get(&pid(0, 0)).unwrap();
let expected = ((resume_beat * 4.0).floor() as usize + 1) % 16;
assert_eq!(
ap.step_index, expected,
"PhaseLock step should be based on resume beat, not pause beat"
);
}
#[test]
fn test_no_false_boundary_after_pause_within_same_bar() {
let mut state = make_state();
state.tick(tick_with(
vec![SeqCommand::PatternUpdate {
bank: 0,
pattern: 0,
data: simple_pattern(4),
}],
0.0,
));
// Queue Bar-quantized start at beat 1.0
state.tick(tick_with(
vec![SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
}],
1.0,
));
// Pause at beat 1.5 (within same bar)
state.tick(tick_at(1.5, false));
// Resume at beat 2.5 (still within same bar, quantum=4)
state.tick(tick_at(2.5, true));
assert!(
!state.audio_state.active_patterns.contains_key(&pid(0, 0)),
"Bar-quantized pattern should NOT activate when no bar boundary was crossed"
);
}
}