17 Commits

Author SHA1 Message Date
260bc9dbdf Feat: fix sequencer precision regression
Some checks failed
Deploy Website / deploy (push) Failing after 4s
2026-03-18 03:41:51 +01:00
68bd62f57f Fix: clean ratatui > egui interaction 2026-03-18 02:28:57 +01:00
f1c83c66a0 Fix: MIDI precision 2026-03-18 02:18:21 +01:00
30dfe7372d Fix: MIDI precision 2026-03-18 02:16:05 +01:00
faf541e536 Feat: try again 2026-03-17 13:51:47 +01:00
85cacfe53e Feat: build script UI/UX 2026-03-17 13:26:48 +01:00
c507552b7c Feat: build script UI/UX 2026-03-17 13:21:46 +01:00
d0b2076bf6 Feat: build script UI/UX 2026-03-17 12:58:52 +01:00
ab93acd17f Feat: build script UI/UX 2026-03-17 12:54:57 +01:00
d72b36b8f1 Feat: build script UI/UX 2026-03-17 12:49:01 +01:00
3d9d2ad759 Feat: improve script 2026-03-17 12:41:56 +01:00
5b1353f7e7 Feat: improve script 2026-03-17 12:39:13 +01:00
f78b4374b6 Feat: improve local build script 2026-03-17 12:31:50 +01:00
dacc9bd6be Fix: update documentation with sync mode removal 2026-03-17 02:49:23 +01:00
bfd52c0053 Fix: sync mode is not required 2026-03-17 02:45:41 +01:00
12172ce1e8 Revert "Fix: try to fix the non working sync"
This reverts commit 1513d80a8d.
2026-03-16 22:10:14 +01:00
1513d80a8d Fix: try to fix the non working sync 2026-03-16 22:07:15 +01:00
54 changed files with 1642 additions and 1539 deletions

View File

@@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file.
### Engine
- Audio input channel selection support.
- Audio buffer sizing improved for multi-channel input.
- MIDI output sends directly from dispatcher thread, bypassing UI-thread polling (~30x less jitter).
### Packaging
- CI migrated from GitHub Actions to Gitea Actions.

24
Cargo.lock generated
View File

@@ -873,7 +873,7 @@ dependencies = [
"cpal 0.17.1",
"crossbeam-channel",
"crossterm",
"doux 0.0.14",
"doux",
"eframe",
"egui",
"egui_ratatui",
@@ -925,7 +925,7 @@ dependencies = [
"cagire-ratatui",
"crossbeam-channel",
"crossterm",
"doux 0.0.13",
"doux",
"egui_ratatui",
"nih_plug",
"nih_plug_egui",
@@ -1824,24 +1824,8 @@ dependencies = [
[[package]]
name = "doux"
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",
"ringbuf",
"rosc",
"rustyline",
"soundfont",
"symphonia",
]
[[package]]
name = "doux"
version = "0.0.14"
source = "git+https://github.com/sova-org/doux?tag=v0.0.14#f0de4f4047adfced8fb2116edd3b33d260ba75c8"
version = "0.0.15"
source = "git+https://github.com/sova-org/doux?tag=v0.0.15#29d8f055612f6141d7546d72b91e60026937b0fd"
dependencies = [
"arc-swap",
"clap",

View File

@@ -36,13 +36,12 @@ required-features = ["desktop"]
[features]
default = ["cli"]
cli = ["dep:cpal", "dep:midir", "dep:confy", "dep:clap", "dep:thread-priority"]
block-renderer = ["dep:soft_ratatui", "dep:rustc-hash", "dep:egui"]
block-renderer = ["dep:soft_ratatui", "dep:rustc-hash", "dep:egui", "dep:egui_ratatui"]
desktop = [
"cli",
"block-renderer",
"cagire-forth/desktop",
"dep:eframe",
"dep:egui_ratatui",
"dep:image",
]
asio = ["doux/asio", "cpal/asio"]
@@ -52,7 +51,7 @@ 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.14", features = ["native", "soundfont"] }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.15", features = ["native", "soundfont"] }
rusty_link = "0.4"
ratatui = "0.30"
crossterm = "0.29"
@@ -92,7 +91,7 @@ winres = "0.1"
[profile.release]
opt-level = 3
lto = "fat"
lto = "thin"
codegen-units = 1
panic = "abort"
strip = true

View File

@@ -1187,7 +1187,7 @@ impl Forth {
}
let dur = steps * ctx.step_duration();
cmd.set_param("fit", Value::Float(dur, None));
cmd.set_param("dur", Value::Float(dur, None));
cmd.set_param("gate", Value::Float(steps, None));
}
Op::At => {
@@ -1753,7 +1753,7 @@ fn cmd_param_float(cmd: &CmdRegister, name: &str) -> Option<f64> {
fn is_tempo_scaled_param(name: &str) -> bool {
matches!(
name,
"attack" | "decay" | "release" | "envdelay" | "hold" | "chorusdelay"
"attack" | "decay" | "release" | "envdelay" | "hold" | "chorusdelay" | "gate"
)
}
@@ -1769,7 +1769,7 @@ fn emit_output(
let mut out = String::with_capacity(128);
out.push('/');
let has_dur = params.iter().any(|(k, _)| *k == "dur");
let has_gate = params.iter().any(|(k, _)| *k == "gate");
let has_release = params.iter().any(|(k, _)| *k == "release");
let delaytime_idx = params.iter().position(|(k, _)| *k == "delaytime");
@@ -1806,11 +1806,11 @@ fn emit_output(
let _ = write!(&mut out, "delta/{delta_ticks}");
}
if !has_dur {
if !has_gate {
if !out.ends_with('/') {
out.push('/');
}
let _ = write!(&mut out, "dur/{}", step_duration * 4.0);
let _ = write!(&mut out, "gate/{}", step_duration * 4.0);
}
if !has_release {

View File

@@ -131,7 +131,7 @@ pub(super) const WORDS: &[Word] = &[
aliases: &[],
category: "Sample",
stack: "(v.. --)",
desc: "Set duration",
desc: "Set MIDI note duration (for audio, use gate)",
example: "0.5 dur",
compile: Param,
varargs: true,

View File

@@ -14,4 +14,4 @@ pub const MAX_STEPS: usize = 1024;
pub const DEFAULT_LENGTH: usize = 16;
pub use file::{load, load_str, save, FileError};
pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step, SyncMode};
pub use project::{Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, Step};

View File

@@ -206,39 +206,6 @@ impl LaunchQuantization {
}
}
/// How a pattern synchronizes when launched: restart or phase-lock.
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum SyncMode {
#[default]
Reset,
PhaseLock,
}
impl SyncMode {
/// Human-readable label for display.
pub fn label(&self) -> &'static str {
match self {
Self::Reset => "Reset",
Self::PhaseLock => "Phase-Lock",
}
}
pub fn short_label(&self) -> &'static str {
match self {
Self::Reset => "Rst",
Self::PhaseLock => "Plk",
}
}
/// Toggle between Reset and PhaseLock.
pub fn toggle(&self) -> Self {
match self {
Self::Reset => Self::PhaseLock,
Self::PhaseLock => Self::Reset,
}
}
}
/// What happens when a pattern finishes: loop, stop, or chain to another.
#[derive(Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
pub enum FollowUp {
@@ -315,7 +282,7 @@ impl Default for Step {
}
}
/// Sequence of steps with playback settings (speed, quantization, sync, follow-up).
/// Sequence of steps with playback settings (speed, quantization, follow-up).
#[derive(Clone)]
pub struct Pattern {
pub steps: Vec<Step>,
@@ -324,7 +291,6 @@ pub struct Pattern {
pub name: Option<String>,
pub description: Option<String>,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
pub follow_up: FollowUp,
}
@@ -361,8 +327,6 @@ struct SparsePattern {
description: Option<String>,
#[serde(default, skip_serializing_if = "is_default_quantization")]
quantization: LaunchQuantization,
#[serde(default, skip_serializing_if = "is_default_sync_mode")]
sync_mode: SyncMode,
#[serde(default, skip_serializing_if = "is_default_follow_up")]
follow_up: FollowUp,
}
@@ -371,10 +335,6 @@ fn is_default_quantization(q: &LaunchQuantization) -> bool {
*q == LaunchQuantization::default()
}
fn is_default_sync_mode(s: &SyncMode) -> bool {
*s == SyncMode::default()
}
#[derive(Deserialize)]
struct LegacyPattern {
steps: Vec<Step>,
@@ -388,8 +348,6 @@ struct LegacyPattern {
#[serde(default)]
quantization: LaunchQuantization,
#[serde(default)]
sync_mode: SyncMode,
#[serde(default)]
follow_up: FollowUp,
}
@@ -416,7 +374,6 @@ impl Serialize for Pattern {
name: self.name.clone(),
description: self.description.clone(),
quantization: self.quantization,
sync_mode: self.sync_mode,
follow_up: self.follow_up,
};
sparse.serialize(serializer)
@@ -452,7 +409,6 @@ impl<'de> Deserialize<'de> for Pattern {
name: sparse.name,
description: sparse.description,
quantization: sparse.quantization,
sync_mode: sparse.sync_mode,
follow_up: sparse.follow_up,
})
}
@@ -463,7 +419,6 @@ impl<'de> Deserialize<'de> for Pattern {
name: legacy.name,
description: legacy.description,
quantization: legacy.quantization,
sync_mode: legacy.sync_mode,
follow_up: legacy.follow_up,
}),
}
@@ -479,7 +434,6 @@ impl Default for Pattern {
name: None,
description: None,
quantization: LaunchQuantization::default(),
sync_mode: SyncMode::default(),
follow_up: FollowUp::default(),
}
}

View File

@@ -164,7 +164,7 @@ mod tests {
for i in 0..16 {
pattern.steps[i] = Step {
active: true,
script: format!("kick {i} note 0.5 dur"),
script: format!("kick {i} note 0.5 gate"),
source: None,
name: Some(format!("step_{i}")),
};

View File

@@ -28,7 +28,6 @@ Each pattern is an independent sequence of steps with its own properties:
| Length | Steps before the pattern loops (`1`-`1024`) | `16` |
| Speed | Playback rate (`1/8x` to `8x`) | `1x` |
| Quantization | When the pattern launches | `Bar` |
| Sync Mode | Reset or Phase-Lock on re-trigger | `Reset` |
| Follow Up | What happens when the pattern finishes an iteration | `Loop` |
Press `e` in the patterns view to edit these settings. After editing properties, you will have to hit the `c` key to _launch_ these changes. More about that later!

View File

@@ -51,7 +51,7 @@ Cagire includes a complete synthesis and sampling engine. No external software i
```forth
;; sawtooth wave + lowpass filter with envelope + chorus + reverb
100 199 freq saw sound 250 8000 0.01 0.3 0.5 0.3 env lpf 0.2 chorus 0.8 verb 2 dur .
100 199 freq saw sound 250 8000 0.01 0.3 0.5 0.3 env lpf 0.2 chorus 0.8 verb 2 gate .
```
```forth
@@ -61,7 +61,7 @@ Cagire includes a complete synthesis and sampling engine. No external software i
```forth
;; white noise + sine wave + envelope = percussion
white sine sound 100 freq 0.5 decay 2 dur .
white sine sound 100 freq 0.5 decay 2 gate .
```
```forth

View File

@@ -33,7 +33,7 @@ You can also arm mute/solo changes:
- Press `Shift+m` to clear all mutes
- Press `Shift+x` to clear all solos
A pattern might not start immediately depending on the sync mode you have chosen.
A pattern might not start immediately depending on its quantization setting.
It might wait for the next beat/bar boundary.
## Status Indicators
@@ -63,9 +63,4 @@ Launched changes don't execute immediately. They wait for a quantization boundar
Edit quantization in pattern properties (press `e` on a pattern).
## Sync Mode
When a pattern starts, its playback position depends on sync mode:
- **Reset**: Always start at step 0
- **Phase-Lock**: Start at the current beat-aligned position (stays in sync with other patterns)
Patterns always start at a beat-aligned position (phase-lock), staying in sync with other running patterns.

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", tag = "v0.0.13", features = ["native", "soundfont"] }
doux = { git = "https://github.com/sova-org/doux", tag = "v0.0.15", 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

@@ -6,128 +6,22 @@ use std::time::Instant;
use arc_swap::ArcSwap;
use crossbeam_channel::Sender;
use egui_ratatui::RataguiBackend;
use nih_plug::prelude::*;
use nih_plug_egui::egui;
use nih_plug_egui::{create_egui_editor, EguiState};
use ratatui::Terminal;
use soft_ratatui::embedded_graphics_unicodefonts::{
mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas,
mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas,
mono_9x18_atlas, mono_9x18_bold_atlas,
};
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::block_renderer::BlockCharBackend;
use cagire::app::App;
use cagire::engine::{AudioCommand, LinkState, SequencerSnapshot};
use cagire::input::{handle_key, handle_mouse, InputContext};
use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState};
use cagire::model::{Dictionary, Rng, Variables};
use cagire::terminal::{create_terminal, FontChoice, TerminalType};
use cagire::theme;
use cagire::views;
use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState};
use crate::params::CagireParams;
use crate::PluginBridge;
type TerminalType = Terminal<RataguiBackend<BlockCharBackend>>;
#[derive(Clone, Copy, PartialEq)]
enum FontChoice {
Size6x13,
Size7x13,
Size8x13,
Size9x15,
Size9x18,
Size10x20,
}
impl FontChoice {
fn from_setting(s: &str) -> Self {
match s {
"6x13" => Self::Size6x13,
"7x13" => Self::Size7x13,
"9x15" => Self::Size9x15,
"9x18" => Self::Size9x18,
"10x20" => Self::Size10x20,
_ => Self::Size8x13,
}
}
fn to_setting(self) -> &'static str {
match self {
Self::Size6x13 => "6x13",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20",
}
}
fn label(self) -> &'static str {
match self {
Self::Size6x13 => "6x13 (Compact)",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13 (Default)",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20 (Large)",
}
}
const ALL: [Self; 6] = [
Self::Size6x13,
Self::Size7x13,
Self::Size8x13,
Self::Size9x15,
Self::Size9x18,
Self::Size10x20,
];
}
fn create_terminal(font: FontChoice) -> TerminalType {
let (regular, bold, italic) = match font {
FontChoice::Size6x13 => (
mono_6x13_atlas(),
Some(mono_6x13_bold_atlas()),
Some(mono_6x13_italic_atlas()),
),
FontChoice::Size7x13 => (
mono_7x13_atlas(),
Some(mono_7x13_bold_atlas()),
Some(mono_7x13_italic_atlas()),
),
FontChoice::Size8x13 => (
mono_8x13_atlas(),
Some(mono_8x13_bold_atlas()),
Some(mono_8x13_italic_atlas()),
),
FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None),
FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None),
FontChoice::Size10x20 => (mono_10x20_atlas(), None, None),
};
let eg = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
let soft = SoftBackend {
buffer: eg.buffer,
cursor: eg.cursor,
cursor_pos: eg.cursor_pos,
char_width: eg.char_width,
char_height: eg.char_height,
blink_counter: eg.blink_counter,
blinking_fast: eg.blinking_fast,
blinking_slow: eg.blinking_slow,
rgb_pixmap: eg.rgb_pixmap,
always_redraw_list: eg.always_redraw_list,
raster_backend: BlockCharBackend {
inner: eg.raster_backend,
},
};
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
}
struct EditorState {
app: App,
terminal: TerminalType,
@@ -234,7 +128,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;
editor.app.playback.playing = editor.playing.load(std::sync::atomic::Ordering::Relaxed);
// Sync host tempo into LinkState so title bar shows real tempo
if shared.tempo > 0.0 {

View File

@@ -185,6 +185,7 @@ impl Plugin for CagirePlugin {
self.sample_rate,
self.output_channels,
64,
buffer_config.max_buffer_size as usize,
);
self.bridge
.sample_registry
@@ -219,7 +220,6 @@ impl Plugin for CagirePlugin {
source: s.source,
})
.collect(),
sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
};
let _ = self.bridge.cmd_tx.send(SeqCommand::PatternUpdate {
@@ -292,7 +292,7 @@ impl Plugin for CagirePlugin {
fill: false,
nudge_secs: 0.0,
current_time_us: 0,
audio_sample_pos: self.sample_pos,
corrected_audio_pos: self.sample_pos as f64,
sr: self.sample_rate as f64,
mouse_x: 0.5,
mouse_y: 0.5,

Binary file not shown.

Binary file not shown.

View File

@@ -1,473 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
export MACOSX_DEPLOYMENT_TARGET="12.0"
cd "$(git rev-parse --show-toplevel)"
PLUGIN_NAME="cagire-plugins"
LIB_NAME="cagire_plugins" # cargo converts hyphens to underscores
OUT="releases"
PLATFORMS=(
"aarch64-apple-darwin"
"x86_64-apple-darwin"
"x86_64-unknown-linux-gnu"
"aarch64-unknown-linux-gnu"
"x86_64-pc-windows-gnu"
)
PLATFORM_LABELS=(
"macOS aarch64 (native)"
"macOS x86_64 (native)"
"Linux x86_64 (cross)"
"Linux aarch64 (cross)"
"Windows x86_64 (cross)"
)
PLATFORM_ALIASES=(
"macos-arm64"
"macos-x86_64"
"linux-x86_64"
"linux-aarch64"
"windows-x86_64"
)
# --- CLI argument parsing ---
cli_platforms=""
cli_targets=""
cli_yes=false
cli_all=false
while [[ $# -gt 0 ]]; do
case "$1" in
--platforms) cli_platforms="$2"; shift 2 ;;
--targets) cli_targets="$2"; shift 2 ;;
--yes) cli_yes=true; shift ;;
--all) cli_all=true; shift ;;
-h|--help)
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " --platforms <list> Comma-separated: macos-arm64,macos-x86_64,linux-x86_64,linux-aarch64,windows-x86_64"
echo " --targets <list> Comma-separated: cli,desktop,plugins"
echo " --all Build all platforms and targets"
echo " --yes Skip confirmation prompt"
echo ""
echo "Without options, runs interactively."
exit 0
;;
*) echo "Unknown option: $1"; exit 1 ;;
esac
done
resolve_platform_alias() {
local alias="$1"
for i in "${!PLATFORM_ALIASES[@]}"; do
if [[ "${PLATFORM_ALIASES[$i]}" == "$alias" ]]; then
echo "$i"
return
fi
done
echo "Unknown platform: $alias" >&2
exit 1
}
# --- Helpers ---
prompt_platforms() {
echo "Select platform (0=all, comma-separated):"
echo " 0) All"
for i in "${!PLATFORMS[@]}"; do
echo " $((i+1))) ${PLATFORM_LABELS[$i]}"
done
read -rp "> " choice
if [[ "$choice" == "0" || -z "$choice" ]]; then
selected_platforms=("${PLATFORMS[@]}")
selected_labels=("${PLATFORM_LABELS[@]}")
else
IFS=',' read -ra indices <<< "$choice"
selected_platforms=()
selected_labels=()
for idx in "${indices[@]}"; do
idx="${idx// /}"
idx=$((idx - 1))
if (( idx < 0 || idx >= ${#PLATFORMS[@]} )); then
echo "Invalid platform index: $((idx+1))"
exit 1
fi
selected_platforms+=("${PLATFORMS[$idx]}")
selected_labels+=("${PLATFORM_LABELS[$idx]}")
done
fi
}
prompt_targets() {
echo ""
echo "Select targets (0=all, comma-separated):"
echo " 0) All"
echo " 1) cagire"
echo " 2) cagire-desktop"
echo " 3) cagire-plugins (CLAP/VST3)"
read -rp "> " choice
build_cagire=false
build_desktop=false
build_plugins=false
if [[ "$choice" == "0" || -z "$choice" ]]; then
build_cagire=true
build_desktop=true
build_plugins=true
else
IFS=',' read -ra targets <<< "$choice"
for t in "${targets[@]}"; do
t="${t// /}"
case "$t" in
1) build_cagire=true ;;
2) build_desktop=true ;;
3) build_plugins=true ;;
*) echo "Invalid target: $t"; exit 1 ;;
esac
done
fi
}
confirm_summary() {
echo ""
echo "=== Build Summary ==="
echo ""
echo "Platforms:"
for label in "${selected_labels[@]}"; do
echo " - $label"
done
echo ""
echo "Targets:"
$build_cagire && echo " - cagire"
$build_desktop && echo " - cagire-desktop"
$build_plugins && echo " - cagire-plugins (CLAP/VST3)"
echo ""
read -rp "Proceed? [Y/n] " yn
case "${yn,,}" in
n|no) echo "Aborted."; exit 0 ;;
esac
}
platform_os() {
case "$1" in
*windows*) echo "windows" ;;
*linux*) echo "linux" ;;
*apple*) echo "macos" ;;
esac
}
platform_arch() {
case "$1" in
aarch64*) echo "aarch64" ;;
x86_64*) echo "x86_64" ;;
esac
}
platform_suffix() {
case "$1" in
*windows*) echo ".exe" ;;
*) echo "" ;;
esac
}
is_cross_target() {
case "$1" in
*linux*|*windows*) return 0 ;;
*) return 1 ;;
esac
}
native_target() {
[[ "$1" == "aarch64-apple-darwin" ]]
}
release_dir() {
if native_target "$1"; then
echo "target/release"
else
echo "target/$1/release"
fi
}
target_flag() {
if native_target "$1"; then
echo ""
else
echo "--target $1"
fi
}
builder_for() {
if is_cross_target "$1"; then
echo "cross"
else
echo "cargo"
fi
}
build_binary() {
local platform="$1"
shift
local builder
builder=$(builder_for "$platform")
local tf
tf=$(target_flag "$platform")
# shellcheck disable=SC2086
$builder build --release $tf "$@"
}
bundle_plugins_native() {
local platform="$1"
local tf
tf=$(target_flag "$platform")
# shellcheck disable=SC2086
cargo xtask bundle "$PLUGIN_NAME" --release $tf
}
bundle_desktop_native() {
local platform="$1"
local tf
tf=$(target_flag "$platform")
# shellcheck disable=SC2086
cargo bundle --release --features desktop --bin cagire-desktop $tf
}
bundle_plugins_cross() {
local platform="$1"
local rd
rd=$(release_dir "$platform")
local os
os=$(platform_os "$platform")
local arch
arch=$(platform_arch "$platform")
# Build the cdylib with cross
# shellcheck disable=SC2046
build_binary "$platform" -p "$PLUGIN_NAME"
# Determine source library file
local src_lib
case "$os" in
linux) src_lib="$rd/lib${LIB_NAME}.so" ;;
windows) src_lib="$rd/${LIB_NAME}.dll" ;;
esac
if [[ ! -f "$src_lib" ]]; then
echo " ERROR: Expected library not found: $src_lib"
return 1
fi
# Assemble CLAP bundle (flat file)
local clap_out="$OUT/${PLUGIN_NAME}-${os}-${arch}.clap"
cp "$src_lib" "$clap_out"
echo " CLAP -> $clap_out"
# Assemble VST3 bundle (directory tree)
local vst3_dir="$OUT/${PLUGIN_NAME}-${os}-${arch}.vst3"
local vst3_contents
case "$os" in
linux)
vst3_contents="$vst3_dir/Contents/${arch}-linux"
mkdir -p "$vst3_contents"
cp "$src_lib" "$vst3_contents/${PLUGIN_NAME}.so"
;;
windows)
vst3_contents="$vst3_dir/Contents/${arch}-win"
mkdir -p "$vst3_contents"
cp "$src_lib" "$vst3_contents/${PLUGIN_NAME}.vst3"
;;
esac
echo " VST3 -> $vst3_dir/"
}
copy_artifacts() {
local platform="$1"
local rd
rd=$(release_dir "$platform")
local os
os=$(platform_os "$platform")
local arch
arch=$(platform_arch "$platform")
local suffix
suffix=$(platform_suffix "$platform")
if $build_cagire; then
local src="$rd/cagire${suffix}"
local dst="$OUT/cagire-${os}-${arch}${suffix}"
cp "$src" "$dst"
echo " cagire -> $dst"
fi
if $build_desktop; then
local src="$rd/cagire-desktop${suffix}"
local dst="$OUT/cagire-desktop-${os}-${arch}${suffix}"
cp "$src" "$dst"
echo " cagire-desktop -> $dst"
# macOS .app bundle
if [[ "$os" == "macos" ]]; then
local app_src="$rd/bundle/osx/Cagire.app"
if [[ ! -d "$app_src" ]]; then
echo " ERROR: .app bundle not found at $app_src"
echo " Did 'cargo bundle' succeed?"
return 1
fi
local app_dst="$OUT/Cagire-${arch}.app"
rm -rf "$app_dst"
cp -R "$app_src" "$app_dst"
echo " Cagire.app -> $app_dst"
scripts/make-dmg.sh "$app_dst" "$OUT"
fi
fi
# NSIS installer for Windows targets
if [[ "$os" == "windows" ]] && command -v makensis &>/dev/null; then
echo " Building NSIS installer..."
local version
version=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
local abs_root
abs_root=$(pwd)
makensis -DVERSION="$version" \
-DCLI_EXE="$abs_root/$rd/cagire.exe" \
-DDESKTOP_EXE="$abs_root/$rd/cagire-desktop.exe" \
-DICON="$abs_root/assets/Cagire.ico" \
-DOUTDIR="$abs_root/$OUT" \
nsis/cagire.nsi
echo " Installer -> $OUT/cagire-${version}-windows-x86_64-setup.exe"
fi
# AppImage for Linux targets
if [[ "$os" == "linux" ]]; then
if $build_cagire; then
scripts/make-appimage.sh "$rd/cagire" "$arch" "$OUT"
fi
if $build_desktop; then
scripts/make-appimage.sh "$rd/cagire-desktop" "$arch" "$OUT"
fi
fi
# Plugin artifacts for native targets (cross handled in bundle_plugins_cross)
if $build_plugins && ! is_cross_target "$platform"; then
local bundle_dir="target/bundled"
# CLAP
local clap_src="$bundle_dir/${PLUGIN_NAME}.clap"
if [[ -e "$clap_src" ]]; then
local clap_dst="$OUT/${PLUGIN_NAME}-${os}-${arch}.clap"
cp -r "$clap_src" "$clap_dst"
echo " CLAP -> $clap_dst"
fi
# VST3
local vst3_src="$bundle_dir/${PLUGIN_NAME}.vst3"
if [[ -d "$vst3_src" ]]; then
local vst3_dst="$OUT/${PLUGIN_NAME}-${os}-${arch}.vst3"
rm -rf "$vst3_dst"
cp -r "$vst3_src" "$vst3_dst"
echo " VST3 -> $vst3_dst/"
fi
fi
}
# --- Main ---
if $cli_all; then
selected_platforms=("${PLATFORMS[@]}")
selected_labels=("${PLATFORM_LABELS[@]}")
build_cagire=true
build_desktop=true
build_plugins=true
elif [[ -n "$cli_platforms" || -n "$cli_targets" ]]; then
# Resolve platforms from CLI
if [[ -n "$cli_platforms" ]]; then
selected_platforms=()
selected_labels=()
IFS=',' read -ra aliases <<< "$cli_platforms"
for alias in "${aliases[@]}"; do
alias="${alias// /}"
idx=$(resolve_platform_alias "$alias")
selected_platforms+=("${PLATFORMS[$idx]}")
selected_labels+=("${PLATFORM_LABELS[$idx]}")
done
else
selected_platforms=("${PLATFORMS[@]}")
selected_labels=("${PLATFORM_LABELS[@]}")
fi
# Resolve targets from CLI
build_cagire=false
build_desktop=false
build_plugins=false
if [[ -n "$cli_targets" ]]; then
IFS=',' read -ra tgts <<< "$cli_targets"
for t in "${tgts[@]}"; do
t="${t// /}"
case "$t" in
cli) build_cagire=true ;;
desktop) build_desktop=true ;;
plugins) build_plugins=true ;;
*) echo "Unknown target: $t (expected: cli, desktop, plugins)"; exit 1 ;;
esac
done
else
build_cagire=true
build_desktop=true
build_plugins=true
fi
else
prompt_platforms
prompt_targets
fi
if ! $cli_yes && [[ -z "$cli_platforms" ]] && ! $cli_all; then
confirm_summary
fi
mkdir -p "$OUT"
step=0
total=${#selected_platforms[@]}
for platform in "${selected_platforms[@]}"; do
step=$((step + 1))
echo ""
echo "=== [$step/$total] $platform ==="
if $build_cagire; then
echo " -> cagire"
build_binary "$platform"
fi
if $build_desktop; then
echo " -> cagire-desktop"
build_binary "$platform" --features desktop --bin cagire-desktop
if ! is_cross_target "$platform"; then
echo " -> bundling cagire-desktop .app"
bundle_desktop_native "$platform"
fi
fi
if $build_plugins; then
echo " -> cagire-plugins"
if is_cross_target "$platform"; then
bundle_plugins_cross "$platform"
else
bundle_plugins_native "$platform"
fi
fi
echo " Copying artifacts..."
copy_artifacts "$platform"
done
echo ""
echo "=== Done ==="
echo ""
ls -lhR "$OUT/"

1029
scripts/build.py Executable file

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: scripts/make-appimage.sh <binary-path> <arch> <output-dir>
# Produces an AppImage from a Linux binary.
# On native Linux with matching arch: uses linuxdeploy.
# Otherwise (cross-compilation): builds AppImage via mksquashfs in Docker.
if [[ $# -ne 3 ]]; then
echo "Usage: $0 <binary-path> <arch> <output-dir>"
exit 1
fi
BINARY="$1"
ARCH="$2"
OUTDIR="$3"
REPO_ROOT="$(git rev-parse --show-toplevel)"
CACHE_DIR="$REPO_ROOT/.cache"
APP_NAME="$(basename "$BINARY")"
RUNTIME_URL="https://github.com/AppImage/type2-runtime/releases/download/continuous/runtime-${ARCH}"
RUNTIME="$CACHE_DIR/runtime-${ARCH}"
build_appdir() {
local appdir="$1"
mkdir -p "$appdir/usr/bin"
cp "$BINARY" "$appdir/usr/bin/cagire"
chmod +x "$appdir/usr/bin/cagire"
mkdir -p "$appdir/usr/share/icons/hicolor/512x512/apps"
cp "$REPO_ROOT/assets/Cagire.png" "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png"
cp "$REPO_ROOT/assets/cagire.desktop" "$appdir/cagire.desktop"
# AppRun entry point
cat > "$appdir/AppRun" <<'APPRUN'
#!/bin/sh
SELF="$(readlink -f "$0")"
HERE="$(dirname "$SELF")"
exec "$HERE/usr/bin/cagire" "$@"
APPRUN
chmod +x "$appdir/AppRun"
# Symlink icon at root for AppImage spec
ln -sf usr/share/icons/hicolor/512x512/apps/cagire.png "$appdir/cagire.png"
ln -sf cagire.desktop "$appdir/.DirIcon" 2>/dev/null || true
}
download_runtime() {
mkdir -p "$CACHE_DIR"
if [[ ! -f "$RUNTIME" ]]; then
echo " Downloading AppImage runtime for $ARCH..."
curl -fSL "$RUNTIME_URL" -o "$RUNTIME"
fi
}
run_native() {
local linuxdeploy_url="https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
local linuxdeploy="$CACHE_DIR/linuxdeploy-${ARCH}.AppImage"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$linuxdeploy" ]]; then
echo " Downloading linuxdeploy for $ARCH..."
curl -fSL "$linuxdeploy_url" -o "$linuxdeploy"
chmod +x "$linuxdeploy"
fi
local appdir
appdir="$(mktemp -d)/AppDir"
build_appdir "$appdir"
export ARCH
export LDAI_RUNTIME_FILE="$RUNTIME"
"$linuxdeploy" \
--appimage-extract-and-run \
--appdir "$appdir" \
--desktop-file "$appdir/cagire.desktop" \
--icon-file "$appdir/usr/share/icons/hicolor/512x512/apps/cagire.png" \
--output appimage
local appimage
appimage=$(ls -1t ./*.AppImage 2>/dev/null | head -1 || true)
if [[ -z "$appimage" ]]; then
echo " ERROR: No AppImage produced"
exit 1
fi
mkdir -p "$OUTDIR"
mv "$appimage" "$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
echo " AppImage -> $OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
}
run_docker() {
local platform
case "$ARCH" in
x86_64) platform="linux/amd64" ;;
aarch64) platform="linux/arm64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
local appdir
appdir="$(mktemp -d)/AppDir"
build_appdir "$appdir"
local image_tag="cagire-appimage-${ARCH}"
echo " Building Docker image $image_tag ($platform)..."
docker build --platform "$platform" -q -t "$image_tag" - <<'DOCKERFILE'
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends \
squashfs-tools \
&& rm -rf /var/lib/apt/lists/*
DOCKERFILE
echo " Creating squashfs via Docker ($image_tag)..."
docker run --rm --platform "$platform" \
-v "$appdir:/appdir:ro" \
-v "$CACHE_DIR:/cache" \
"$image_tag" \
mksquashfs /appdir /cache/appimage-${ARCH}.squashfs \
-root-owned -noappend -comp gzip -no-progress
mkdir -p "$OUTDIR"
local final="$OUTDIR/${APP_NAME}-linux-${ARCH}.AppImage"
cat "$RUNTIME" "$CACHE_DIR/appimage-${ARCH}.squashfs" > "$final"
chmod +x "$final"
rm -f "$CACHE_DIR/appimage-${ARCH}.squashfs"
echo " AppImage -> $final"
}
HOST_ARCH="$(uname -m)"
download_runtime
echo " Building AppImage for ${APP_NAME} ($ARCH)..."
if [[ "$HOST_ARCH" == "$ARCH" ]] && [[ "$(uname -s)" == "Linux" ]]; then
run_native
else
run_docker
fi

View File

@@ -1,52 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Usage: scripts/make-dmg.sh <app-path> <output-dir>
# Produces a .dmg from a macOS .app bundle using only hdiutil.
if [[ $# -ne 2 ]]; then
echo "Usage: $0 <app-path> <output-dir>"
exit 1
fi
APP_PATH="$1"
OUTDIR="$2"
REPO_ROOT="$(git rev-parse --show-toplevel)"
if [[ ! -d "$APP_PATH" ]]; then
echo "ERROR: $APP_PATH is not a directory"
exit 1
fi
LIPO_OUTPUT=$(lipo -info "$APP_PATH/Contents/MacOS/cagire-desktop" 2>/dev/null)
if [[ -z "$LIPO_OUTPUT" ]]; then
echo "ERROR: could not determine architecture from $APP_PATH"
exit 1
fi
if echo "$LIPO_OUTPUT" | grep -q "Architectures in the fat file"; then
ARCH="universal"
else
ARCH=$(echo "$LIPO_OUTPUT" | awk '{print $NF}')
case "$ARCH" in
arm64) ARCH="aarch64" ;;
esac
fi
STAGING="$(mktemp -d)"
trap 'rm -rf "$STAGING"' EXIT
cp -R "$APP_PATH" "$STAGING/Cagire.app"
ln -s /Applications "$STAGING/Applications"
cp "$REPO_ROOT/assets/DMG-README.txt" "$STAGING/README.txt"
DMG_NAME="Cagire-${ARCH}.dmg"
mkdir -p "$OUTDIR"
hdiutil create -volname "Cagire" \
-srcfolder "$STAGING" \
-ov -format UDZO \
"$OUTDIR/$DMG_NAME"
echo " DMG -> $OUTDIR/$DMG_NAME"

9
scripts/platforms.toml Normal file
View File

@@ -0,0 +1,9 @@
# Cagire build targets — each triple defines a compilation platform.
# Everything else (os, arch, cross, alias, label) is derived by build.py.
triples = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-pc-windows-gnu",
]

View File

@@ -199,7 +199,6 @@ impl App {
length,
speed,
quantization,
sync_mode,
follow_up,
} => {
self.playback.staged_prop_changes.insert(
@@ -210,7 +209,6 @@ impl App {
length,
speed,
quantization,
sync_mode,
follow_up,
},
);

View File

@@ -203,7 +203,6 @@ impl App {
length: pat.length.to_string(),
speed: pat.speed,
quantization: pat.quantization,
sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
};
}

View File

@@ -52,7 +52,7 @@ impl App {
output_devices: {
let outputs = crate::midi::list_midi_outputs();
self.midi
.selected_outputs
.selected_outputs()
.iter()
.map(|opt| {
opt.and_then(|idx| outputs.get(idx).map(|d| d.name.clone()))
@@ -138,7 +138,6 @@ impl App {
self.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: crate::model::LaunchQuantization::Immediate,
sync_mode: crate::model::SyncMode::PhaseLock,
});
}

View File

@@ -16,7 +16,6 @@ impl App {
bank,
pattern,
quantization: staged.quantization,
sync_mode: staged.sync_mode,
});
}
PatternChange::Stop { bank, pattern } => {
@@ -68,7 +67,6 @@ impl App {
source: s.source,
})
.collect(),
sync_mode: pat.sync_mode,
follow_up: pat.follow_up,
};
let _ = cmd_tx.send(SeqCommand::PatternUpdate {

View File

@@ -29,7 +29,6 @@ impl App {
self.playback.staged_changes.push(StagedChange {
change: PatternChange::Stop { bank, pattern },
quantization: pattern_data.quantization,
sync_mode: pattern_data.sync_mode,
});
self.ui
.set_status(format!("{} armed to stop", bp_label(bank, pattern)));
@@ -37,7 +36,6 @@ impl App {
self.playback.staged_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: pattern_data.quantization,
sync_mode: pattern_data.sync_mode,
});
self.ui
.set_status(format!("{} armed to play", bp_label(bank, pattern)));
@@ -84,7 +82,6 @@ impl App {
}
pat.speed = props.speed;
pat.quantization = props.quantization;
pat.sync_mode = props.sync_mode;
pat.follow_up = props.follow_up;
self.project_state.mark_dirty(bank, pattern);
}

View File

@@ -1,33 +1,24 @@
#![windows_subsystem = "windows"]
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Duration;
use cagire::block_renderer::BlockCharBackend;
use arc_swap::ArcSwap;
use clap::Parser;
use doux::EngineMetrics;
use eframe::NativeOptions;
use egui_ratatui::RataguiBackend;
use ratatui::Terminal;
use soft_ratatui::embedded_graphics_unicodefonts::{
mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas,
mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas,
mono_9x18_atlas, mono_9x18_bold_atlas,
};
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use cagire::engine::{
build_stream, AnalysisHandle, AudioStreamConfig, LinkState, MidiCommand, ScopeBuffer,
build_stream, AnalysisHandle, AudioRef, AudioStreamConfig, LinkState, ScopeBuffer,
SequencerHandle, SpectrumBuffer,
};
use cagire::terminal::{create_terminal, FontChoice, TerminalType};
use cagire::init::{init, InitArgs};
use cagire::input::{handle_key, handle_mouse, InputContext, InputResult};
use cagire::input_egui::{convert_egui_events, convert_egui_mouse, EguiMouseState};
use cagire::settings::Settings;
use cagire::views;
use crossbeam_channel::Receiver;
#[derive(Parser)]
#[command(name = "cagire-desktop", about = "Cagire desktop application")]
@@ -48,103 +39,6 @@ struct Args {
buffer: Option<u32>,
}
#[derive(Clone, Copy, PartialEq)]
enum FontChoice {
Size6x13,
Size7x13,
Size8x13,
Size9x15,
Size9x18,
Size10x20,
}
impl FontChoice {
fn from_setting(s: &str) -> Self {
match s {
"6x13" => Self::Size6x13,
"7x13" => Self::Size7x13,
"9x15" => Self::Size9x15,
"9x18" => Self::Size9x18,
"10x20" => Self::Size10x20,
_ => Self::Size8x13,
}
}
fn to_setting(self) -> &'static str {
match self {
Self::Size6x13 => "6x13",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20",
}
}
fn label(self) -> &'static str {
match self {
Self::Size6x13 => "6x13 (Compact)",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13 (Default)",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20 (Large)",
}
}
const ALL: [Self; 6] = [
Self::Size6x13,
Self::Size7x13,
Self::Size8x13,
Self::Size9x15,
Self::Size9x18,
Self::Size10x20,
];
}
type TerminalType = Terminal<RataguiBackend<BlockCharBackend>>;
fn create_terminal(font: FontChoice) -> TerminalType {
let (regular, bold, italic) = match font {
FontChoice::Size6x13 => (
mono_6x13_atlas(),
Some(mono_6x13_bold_atlas()),
Some(mono_6x13_italic_atlas()),
),
FontChoice::Size7x13 => (
mono_7x13_atlas(),
Some(mono_7x13_bold_atlas()),
Some(mono_7x13_italic_atlas()),
),
FontChoice::Size8x13 => (
mono_8x13_atlas(),
Some(mono_8x13_bold_atlas()),
Some(mono_8x13_italic_atlas()),
),
FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None),
FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None),
FontChoice::Size10x20 => (mono_10x20_atlas(), None, None),
};
let eg = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
let soft = SoftBackend {
buffer: eg.buffer,
cursor: eg.cursor,
cursor_pos: eg.cursor_pos,
char_width: eg.char_width,
char_height: eg.char_height,
blink_counter: eg.blink_counter,
blinking_fast: eg.blinking_fast,
blinking_slow: eg.blinking_slow,
rgb_pixmap: eg.rgb_pixmap,
always_redraw_list: eg.always_redraw_list,
raster_backend: BlockCharBackend {
inner: eg.raster_backend,
},
};
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
}
struct CagireDesktop {
app: cagire::app::App,
terminal: TerminalType,
@@ -155,12 +49,11 @@ struct CagireDesktop {
metrics: Arc<EngineMetrics>,
scope_buffer: Arc<ScopeBuffer>,
spectrum_buffer: Arc<SpectrumBuffer>,
audio_sample_pos: Arc<AtomicU64>,
audio_ref: Arc<ArcSwap<AudioRef>>,
sample_rate_shared: Arc<AtomicU32>,
_stream: Option<cpal::Stream>,
_input_stream: Option<cpal::Stream>,
_analysis_handle: Option<AnalysisHandle>,
midi_rx: Receiver<MidiCommand>,
device_lost: Arc<AtomicBool>,
stream_error_rx: crossbeam_channel::Receiver<String>,
current_font: FontChoice,
@@ -202,12 +95,11 @@ impl CagireDesktop {
metrics: b.metrics,
scope_buffer: b.scope_buffer,
spectrum_buffer: b.spectrum_buffer,
audio_sample_pos: b.audio_sample_pos,
audio_ref: b.audio_ref,
sample_rate_shared: b.sample_rate_shared,
_stream: b.stream,
_input_stream: b.input_stream,
_analysis_handle: b.analysis_handle,
midi_rx: b.midi_rx,
device_lost: b.device_lost,
stream_error_rx: b.stream_error_rx,
current_font,
@@ -237,7 +129,6 @@ impl CagireDesktop {
return;
};
let new_audio_rx = sequencer.swap_audio_channel();
self.midi_rx = sequencer.swap_midi_channel();
let new_config = AudioStreamConfig {
output_device: self.app.audio.config.output_device.clone(),
@@ -262,7 +153,11 @@ impl CagireDesktop {
}
}
self.audio_sample_pos.store(0, Ordering::Release);
self.audio_ref.store(Arc::new(AudioRef {
sample_pos: 0,
timestamp: std::time::Instant::now(),
sample_rate: 44100.0,
}));
let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples
.iter()
@@ -276,7 +171,7 @@ impl CagireDesktop {
Arc::clone(&self.spectrum_buffer),
Arc::clone(&self.metrics),
restart_samples,
Arc::clone(&self.audio_sample_pos),
Arc::clone(&self.audio_ref),
new_error_tx,
&self.app.audio.config.sample_paths,
Arc::clone(&self.device_lost),
@@ -288,6 +183,7 @@ impl CagireDesktop {
self.app.audio.config.sample_rate = info.sample_rate;
self.app.audio.config.host_name = info.host_name;
self.app.audio.config.channels = info.channels;
self.app.audio.config.input_sample_rate = info.input_sample_rate;
self.sample_rate_shared
.store(info.sample_rate as u32, Ordering::Relaxed);
self.app.audio.error = None;
@@ -339,30 +235,23 @@ impl CagireDesktop {
let term = self.terminal.get_frame().area();
let widget_rect = ctx.content_rect();
for mouse in convert_egui_mouse(ctx, widget_rect, term, &mut self.egui_mouse) {
let mut input_ctx = InputContext {
app: &mut self.app,
link: &self.link,
snapshot: &seq_snapshot,
playing: &self.playing,
audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &self.nudge_us,
};
let mouse_events = convert_egui_mouse(ctx, widget_rect, term, &mut self.egui_mouse);
let key_events = convert_egui_events(ctx);
let mut input_ctx = InputContext {
app: &mut self.app,
link: &self.link,
snapshot: &seq_snapshot,
playing: &self.playing,
audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &self.nudge_us,
};
for mouse in mouse_events {
handle_mouse(&mut input_ctx, mouse, term);
}
for key in convert_egui_events(ctx) {
let mut input_ctx = InputContext {
app: &mut self.app,
link: &self.link,
snapshot: &seq_snapshot,
playing: &self.playing,
audio_tx: &sequencer.audio_tx,
seq_cmd_tx: &sequencer.cmd_tx,
nudge_us: &self.nudge_us,
};
for key in key_events {
if let InputResult::Quit = handle_key(&mut input_ctx, key) {
return true;
}
@@ -414,59 +303,6 @@ impl eframe::App for CagireDesktop {
self.app.flush_dirty_patterns(&sequencer.cmd_tx);
self.app.flush_dirty_script(&sequencer.cmd_tx);
while let Ok(midi_cmd) = self.midi_rx.try_recv() {
match midi_cmd {
MidiCommand::NoteOn {
device,
channel,
note,
velocity,
} => {
self.app.midi.send_note_on(device, channel, note, velocity);
}
MidiCommand::NoteOff {
device,
channel,
note,
} => {
self.app.midi.send_note_off(device, channel, note);
}
MidiCommand::CC {
device,
channel,
cc,
value,
} => {
self.app.midi.send_cc(device, channel, cc, value);
}
MidiCommand::PitchBend {
device,
channel,
value,
} => {
self.app.midi.send_pitch_bend(device, channel, value);
}
MidiCommand::Pressure {
device,
channel,
value,
} => {
self.app.midi.send_pressure(device, channel, value);
}
MidiCommand::ProgramChange {
device,
channel,
program,
} => {
self.app.midi.send_program_change(device, channel, program);
}
MidiCommand::Clock { device } => self.app.midi.send_realtime(device, 0xF8),
MidiCommand::Start { device } => self.app.midi.send_realtime(device, 0xFA),
MidiCommand::Stop { device } => self.app.midi.send_realtime(device, 0xFC),
MidiCommand::Continue { device } => self.app.midi.send_realtime(device, 0xFB),
}
}
let should_quit = self.handle_input(ctx);
if should_quit {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);

View File

@@ -242,7 +242,7 @@ fn color_to_rgb(color: &Color, is_fg: bool) -> [u8; 3] {
Color::Yellow => [255, 215, 0],
Color::Blue => [0, 0, 139],
Color::Magenta => [255, 0, 255],
Color::Cyan => [0, 0, 255],
Color::Cyan => [0, 255, 255],
Color::Gray => [128, 128, 128],
Color::DarkGray => [64, 64, 64],
Color::LightRed => [255, 0, 0],

View File

@@ -2,7 +2,7 @@
use std::path::PathBuf;
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed};
use crate::page::Page;
use crate::state::{ColorScheme, DeviceKind, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
@@ -169,7 +169,6 @@ pub enum AppCommand {
length: Option<usize>,
speed: PatternSpeed,
quantization: LaunchQuantization,
sync_mode: SyncMode,
follow_up: FollowUp,
},

View File

@@ -1,13 +1,22 @@
//! Audio output stream (cpal) and FFT spectrum analysis.
use arc_swap::ArcSwap;
use ringbuf::{traits::*, HeapRb};
use rustfft::{num_complex::Complex, FftPlanner};
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use std::time::Instant;
#[cfg(feature = "cli")]
use std::sync::atomic::AtomicU64;
/// Timestamped audio position reference for jitter-free tick interpolation.
/// Published by the audio callback after each `process_block`, read by the
/// sequencer to compute the correct sample position at any instant.
#[derive(Clone)]
pub struct AudioRef {
pub sample_pos: u64,
pub timestamp: Instant,
pub sample_rate: f64,
}
pub struct ScopeBuffer {
pub samples: [AtomicU32; 256],
@@ -282,6 +291,7 @@ pub struct AudioStreamInfo {
pub sample_rate: f32,
pub host_name: String,
pub channels: u16,
pub input_sample_rate: Option<f32>,
}
#[cfg(feature = "cli")]
@@ -302,7 +312,7 @@ pub fn build_stream(
spectrum_buffer: Arc<SpectrumBuffer>,
metrics: Arc<EngineMetrics>,
initial_samples: Vec<doux::sampling::SampleEntry>,
audio_sample_pos: Arc<AtomicU64>,
audio_ref: Arc<ArcSwap<AudioRef>>,
error_tx: Sender<String>,
sample_paths: &[std::path::PathBuf],
device_lost: Arc<AtomicBool>,
@@ -367,10 +377,16 @@ pub fn build_stream(
dev
});
let input_channels: usize = input_device
let input_config = input_device
.as_ref()
.and_then(|dev| dev.default_input_config().ok());
let input_channels: usize = input_config
.as_ref()
.and_then(|dev| dev.default_input_config().ok())
.map_or(0, |cfg| cfg.channels() as usize);
let input_sample_rate = input_config.and_then(|cfg| {
let rate = cfg.sample_rate() as f32;
(rate != sample_rate).then_some(rate)
});
engine.input_channels = input_channels;
@@ -431,6 +447,7 @@ pub fn build_stream(
let mut rt_set = false;
let mut live_scratch = vec![0.0f32; 4096];
let mut input_consumer = input_consumer;
let mut current_pos: u64 = 0;
let stream = device
.build_output_stream(
@@ -447,8 +464,6 @@ pub fn build_stream(
let buffer_samples = data.len() / channels;
let buffer_time_ns = (buffer_samples as f64 / sr as f64 * 1e9) as u64;
audio_sample_pos.fetch_add(buffer_samples as u64, Ordering::Release);
while let Ok(cmd) = audio_rx.try_recv() {
match cmd {
AudioCommand::Evaluate { cmd, tick } => {
@@ -490,6 +505,16 @@ pub fn build_stream(
engine.metrics.load.set_buffer_time(buffer_time_ns);
engine.process_block(data, &[], &live_scratch[..raw_len]);
// Publish accurate audio reference AFTER process_block
// so sample_pos matches doux's internal tick exactly.
current_pos += buffer_samples as u64;
audio_ref.store(Arc::new(AudioRef {
sample_pos: current_pos,
timestamp: Instant::now(),
sample_rate: sr as f64,
}));
scope_buffer.write(data);
// Feed mono mix to analysis thread via ring buffer (non-blocking)
@@ -519,6 +544,7 @@ pub fn build_stream(
sample_rate,
host_name,
channels: effective_channels,
input_sample_rate,
};
Ok((stream, input_stream, info, analysis_handle, registry))
}

View File

@@ -1,14 +1,13 @@
use arc_swap::ArcSwap;
use crossbeam_channel::{Receiver, RecvTimeoutError, Sender};
use crossbeam_channel::{Receiver, RecvTimeoutError};
use std::cmp::Ordering;
use std::collections::BinaryHeap;
use std::sync::Arc;
use std::time::Duration;
use super::link::LinkState;
use super::realtime::{precise_sleep_us, set_realtime_priority, warn_no_rt};
use super::sequencer::MidiCommand;
use super::timing::SyncTime;
use crate::midi::{MidiOutputPorts, MAX_MIDI_OUTPUTS};
/// A MIDI command scheduled for dispatch at a specific time.
#[derive(Clone)]
@@ -46,13 +45,13 @@ impl Eq for TimedMidiCommand {}
const SPIN_THRESHOLD_US: SyncTime = 100;
/// Dispatcher loop — handles MIDI timing only.
/// Dispatcher loop — handles MIDI timing and sends directly to MIDI ports.
/// Audio commands bypass the dispatcher entirely and go straight to doux's
/// sample-accurate scheduler via the audio thread channel.
pub fn dispatcher_loop(
cmd_rx: Receiver<TimedMidiCommand>,
midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
link: Arc<LinkState>,
ports: MidiOutputPorts,
link: &LinkState,
) {
let has_rt = set_realtime_priority();
if !has_rt {
@@ -84,8 +83,8 @@ pub fn dispatcher_loop(
while let Some(cmd) = queue.peek() {
if cmd.target_time_us <= current_us + SPIN_THRESHOLD_US {
let cmd = queue.pop().expect("pop after peek");
wait_until_dispatch(cmd.target_time_us, &link, has_rt);
dispatch_midi(cmd.command, &midi_tx);
wait_until_dispatch(cmd.target_time_us, link, has_rt);
dispatch_midi(cmd.command, &ports);
} else {
break;
}
@@ -106,15 +105,15 @@ fn wait_until_dispatch(target_us: SyncTime, link: &LinkState, has_rt: bool) {
}
}
fn dispatch_midi(cmd: MidiDispatch, midi_tx: &Arc<ArcSwap<Sender<MidiCommand>>>) {
fn dispatch_midi(cmd: MidiDispatch, ports: &MidiOutputPorts) {
match cmd {
MidiDispatch::Send(midi_cmd) => {
let _ = midi_tx.load().try_send(midi_cmd);
ports.send_command(&midi_cmd);
}
MidiDispatch::FlushAll => {
for dev in 0..4u8 {
for dev in 0..MAX_MIDI_OUTPUTS as u8 {
for chan in 0..16u8 {
let _ = midi_tx.load().try_send(MidiCommand::CC {
ports.send_command(&MidiCommand::CC {
device: dev,
channel: chan,
cc: 123,

View File

@@ -81,6 +81,20 @@ impl LinkState {
self.link.commit_app_session_state(&state);
}
pub fn start_playing(&self, beat: f64, time: i64, quantum: f64) {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);
state.set_is_playing_and_request_beat_at_time(true, time, beat, quantum);
self.link.commit_app_session_state(&state);
}
pub fn stop_playing(&self, time: i64) {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);
state.set_is_playing(false, time);
self.link.commit_app_session_state(&state);
}
pub fn capture_app_state(&self) -> SessionState {
let mut state = SessionState::new();
self.link.capture_app_session_state(&mut state);

View File

@@ -5,9 +5,9 @@ pub mod realtime;
pub mod sequencer;
mod timing;
pub use timing::{substeps_in_window, StepTiming, SyncTime};
pub use timing::{next_boundary, substeps_in_window, SyncTime};
pub use audio::{preload_sample_heads, AnalysisHandle, ScopeBuffer, SpectrumBuffer};
pub use audio::{preload_sample_heads, AnalysisHandle, AudioRef, ScopeBuffer, SpectrumBuffer};
// Re-exported for the plugin crate (not used by the terminal binary).
#[allow(unused_imports)]
@@ -21,13 +21,13 @@ pub use audio::AudioStreamInfo;
pub use link::LinkState;
pub use sequencer::{
spawn_sequencer, AudioCommand, MidiCommand, PatternChange, PatternSnapshot, SeqCommand,
spawn_sequencer, AudioCommand, PatternChange, PatternSnapshot, SeqCommand,
SequencerConfig, SequencerHandle, SequencerSnapshot, StepSnapshot,
};
// Re-exported for the plugin crate (not used by the terminal binary).
#[allow(unused_imports)]
pub use sequencer::{
parse_midi_command, SequencerState, SharedSequencerState, TickInput, TickOutput,
parse_midi_command, MidiCommand, SequencerState, SharedSequencerState, TickInput, TickOutput,
TimestampedCommand,
};

View File

@@ -8,17 +8,25 @@ use rand::SeedableRng;
use std::collections::HashMap;
#[cfg(feature = "desktop")]
use std::sync::atomic::AtomicU32;
use std::sync::atomic::{AtomicI64, AtomicU64};
use std::sync::atomic::AtomicI64;
use std::sync::Arc;
use std::thread::{self, JoinHandle};
use super::audio::AudioRef;
use super::dispatcher::{dispatcher_loop, MidiDispatch, TimedMidiCommand};
use super::realtime::set_realtime_priority;
use super::{substeps_in_window, LinkState, StepTiming, SyncTime};
use super::{next_boundary, substeps_in_window, LinkState, SyncTime};
use crate::model::{
CcAccess, Dictionary, ExecutionTrace, Rng, ScriptEngine, StepContext, Value, Variables,
};
use crate::model::{FollowUp, LaunchQuantization, SyncMode, MAX_BANKS, MAX_PATTERNS};
use crate::model::{FollowUp, LaunchQuantization, MAX_BANKS, MAX_PATTERNS};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum SyncMode {
Reset,
PhaseLock,
}
use crate::state::LiveKeyState;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
@@ -114,7 +122,6 @@ pub enum SeqCommand {
bank: usize,
pattern: usize,
quantization: LaunchQuantization,
sync_mode: SyncMode,
},
PatternStop {
bank: usize,
@@ -141,7 +148,6 @@ pub struct PatternSnapshot {
pub speed: crate::model::PatternSpeed,
pub length: usize,
pub steps: Vec<StepSnapshot>,
pub sync_mode: SyncMode,
pub follow_up: FollowUp,
}
@@ -170,7 +176,6 @@ 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>,
}
@@ -181,7 +186,6 @@ 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>,
}
@@ -194,7 +198,6 @@ 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(),
}
@@ -210,7 +213,6 @@ impl SequencerSnapshot {
event_count: 0,
tempo: 0.0,
beat: 0.0,
playing: false,
script_trace: None,
print_output: None,
}
@@ -261,7 +263,6 @@ impl SequencerSnapshot {
pub struct SequencerHandle {
pub cmd_tx: Sender<SeqCommand>,
pub audio_tx: Arc<ArcSwap<Sender<AudioCommand>>>,
pub midi_tx: Arc<ArcSwap<Sender<MidiCommand>>>,
shared_state: Arc<ArcSwap<SharedSequencerState>>,
thread: JoinHandle<()>,
}
@@ -278,12 +279,6 @@ impl SequencerHandle {
new_rx
}
pub fn swap_midi_channel(&self) -> Receiver<MidiCommand> {
let (new_tx, new_rx) = bounded::<MidiCommand>(256);
self.midi_tx.store(Arc::new(new_tx));
new_rx
}
pub fn shutdown(self) {
let _ = self.cmd_tx.send(SeqCommand::Shutdown);
if let Err(e) = self.thread.join() {
@@ -299,18 +294,24 @@ struct ActivePattern {
step_index: usize,
iter: usize,
last_step_beat: f64,
origin_beat: f64,
}
#[derive(Clone, Copy)]
struct PendingPattern {
id: PatternId,
quantization: LaunchQuantization,
target_beat: Option<f64>,
sync_mode: SyncMode,
}
#[derive(Clone, Copy)]
enum PlayState {
Idle { pause_beat: Option<f64> },
Playing { frontier: f64 },
}
struct AudioState {
prev_beat: f64,
pause_beat: Option<f64>,
play_state: PlayState,
active_patterns: HashMap<PatternId, ActivePattern>,
pending_starts: Vec<PendingPattern>,
pending_stops: Vec<PendingPattern>,
@@ -320,8 +321,7 @@ struct AudioState {
impl AudioState {
fn new() -> Self {
Self {
prev_beat: -1.0,
pause_beat: None,
play_state: PlayState::Idle { pause_beat: None },
active_patterns: HashMap::new(),
pending_starts: Vec::new(),
pending_stops: Vec::new(),
@@ -331,7 +331,7 @@ impl AudioState {
}
pub struct SequencerConfig {
pub audio_sample_pos: Arc<AtomicU64>,
pub audio_ref: Arc<ArcSwap<AudioRef>>,
pub sample_rate: Arc<std::sync::atomic::AtomicU32>,
pub cc_access: Option<Arc<dyn CcAccess>>,
pub variables: Variables,
@@ -351,16 +351,11 @@ pub fn spawn_sequencer(
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
config: SequencerConfig,
) -> (
SequencerHandle,
Receiver<AudioCommand>,
Receiver<MidiCommand>,
) {
midi_ports: crate::midi::MidiOutputPorts,
) -> (SequencerHandle, Receiver<AudioCommand>) {
let (cmd_tx, cmd_rx) = bounded::<SeqCommand>(64);
let (audio_tx, audio_rx) = unbounded::<AudioCommand>();
let (midi_tx, midi_rx) = bounded::<MidiCommand>(256);
let audio_tx = Arc::new(ArcSwap::from_pointee(audio_tx));
let midi_tx = Arc::new(ArcSwap::from_pointee(midi_tx));
// Dispatcher channel — MIDI only (unbounded to avoid blocking the scheduler)
let (dispatch_tx, dispatch_rx) = unbounded::<TimedMidiCommand>();
@@ -377,13 +372,12 @@ pub fn spawn_sequencer(
#[cfg(feature = "desktop")]
let mouse_down = config.mouse_down;
// Spawn dispatcher thread (MIDI only — audio goes direct to doux)
// Spawn dispatcher thread — sends MIDI directly to ports (no UI-thread hop)
let dispatcher_link = Arc::clone(&link);
let dispatcher_midi_tx = Arc::clone(&midi_tx);
thread::Builder::new()
.name("cagire-dispatcher".into())
.spawn(move || {
dispatcher_loop(dispatch_rx, dispatcher_midi_tx, dispatcher_link);
dispatcher_loop(dispatch_rx, midi_ports, &dispatcher_link);
})
.expect("Failed to spawn dispatcher thread");
@@ -401,7 +395,7 @@ pub fn spawn_sequencer(
shared_state_clone,
live_keys,
nudge_us,
config.audio_sample_pos,
config.audio_ref,
config.sample_rate,
config.cc_access,
variables,
@@ -419,11 +413,10 @@ pub fn spawn_sequencer(
let handle = SequencerHandle {
cmd_tx,
audio_tx,
midi_tx,
shared_state,
thread,
};
(handle, audio_rx, midi_rx)
(handle, audio_rx)
}
struct PatternCache {
@@ -474,22 +467,6 @@ impl PatternSnapshot {
}
}
fn check_quantization_boundary(
quantization: LaunchQuantization,
beat: f64,
prev_beat: f64,
quantum: f64,
) -> bool {
match quantization {
LaunchQuantization::Immediate => prev_beat >= 0.0,
LaunchQuantization::Beat => StepTiming::NextBeat.crossed(prev_beat, beat, quantum),
LaunchQuantization::Bar => StepTiming::NextBar.crossed(prev_beat, beat, quantum),
LaunchQuantization::Bars2 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 2.0),
LaunchQuantization::Bars4 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 4.0),
LaunchQuantization::Bars8 => StepTiming::NextBar.crossed(prev_beat, beat, quantum * 8.0),
}
}
type StepKey = (usize, usize, usize);
struct RunsCounter {
@@ -527,7 +504,7 @@ pub struct TickInput {
pub fill: bool,
pub nudge_secs: f64,
pub current_time_us: SyncTime,
pub audio_sample_pos: u64,
pub corrected_audio_pos: f64,
pub sr: f64,
pub mouse_x: f64,
pub mouse_y: f64,
@@ -582,7 +559,7 @@ pub struct SequencerState {
script_text: String,
script_speed: crate::model::PatternSpeed,
script_length: usize,
script_frontier: f64,
script_frontier: Option<f64>,
script_step: usize,
script_trace: Option<ExecutionTrace>,
print_output: Option<String>,
@@ -621,7 +598,7 @@ impl SequencerState {
script_text: String::new(),
script_speed: crate::model::PatternSpeed::default(),
script_length: 16,
script_frontier: -1.0,
script_frontier: None,
script_step: 0,
script_trace: None,
print_output: None,
@@ -639,7 +616,7 @@ impl SequencerState {
false
}
fn process_commands(&mut self, commands: Vec<SeqCommand>) {
fn process_commands(&mut self, commands: Vec<SeqCommand>, quantum: f64) {
for cmd in commands {
match cmd {
SeqCommand::PatternUpdate {
@@ -660,15 +637,15 @@ impl SequencerState {
bank,
pattern,
quantization,
sync_mode,
} => {
let id = PatternId { bank, pattern };
self.audio_state.pending_stops.retain(|p| p.id != id);
if !self.audio_state.pending_starts.iter().any(|p| p.id == id) {
let target_beat = next_boundary(self.last_beat, quantization, quantum);
self.audio_state.pending_starts.push(PendingPattern {
id,
quantization,
sync_mode,
target_beat,
sync_mode: SyncMode::PhaseLock,
});
}
}
@@ -680,9 +657,10 @@ impl SequencerState {
let id = PatternId { bank, pattern };
self.audio_state.pending_starts.retain(|p| p.id != id);
if !self.audio_state.pending_stops.iter().any(|p| p.id == id) {
let target_beat = next_boundary(self.last_beat, quantization, quantum);
self.audio_state.pending_stops.push(PendingPattern {
id,
quantization,
target_beat,
sync_mode: SyncMode::Reset,
});
}
@@ -722,7 +700,8 @@ 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.audio_state.play_state = PlayState::Idle { pause_beat: None };
self.script_frontier = None;
self.step_traces = Arc::new(HashMap::new());
self.runs_counter.counts.clear();
self.audio_state.flush_midi_notes = true;
@@ -732,9 +711,8 @@ impl SequencerState {
active.step_index = 0;
active.iter = 0;
}
self.audio_state.prev_beat = -1.0;
self.audio_state.pause_beat = None;
self.script_frontier = -1.0;
self.audio_state.play_state = PlayState::Idle { pause_beat: None };
self.script_frontier = None;
self.script_step = 0;
self.script_trace = None;
self.variables.store(Arc::new(HashMap::new()));
@@ -758,30 +736,26 @@ impl SequencerState {
}
pub fn tick(&mut self, input: TickInput) -> TickOutput {
self.process_commands(input.commands);
self.last_tempo = input.tempo;
self.last_beat = input.beat;
self.last_playing = input.playing;
self.process_commands(input.commands, input.quantum);
if !input.playing {
return self.tick_paused();
}
let frontier = self.audio_state.prev_beat;
let lookahead_end = input.lookahead_end;
let resuming = frontier < 0.0;
let boundary_frontier = if resuming {
self.audio_state.pause_beat.take().unwrap_or(input.beat)
} else {
frontier
let (frontier, resuming) = match self.audio_state.play_state {
PlayState::Playing { frontier } => (frontier, false),
PlayState::Idle { pause_beat } => (pause_beat.unwrap_or(input.beat), true),
};
let lookahead_end = input.lookahead_end;
self.activate_pending(lookahead_end, boundary_frontier, input.quantum);
self.deactivate_pending(lookahead_end, boundary_frontier, input.quantum);
self.activate_pending(frontier, lookahead_end);
self.deactivate_pending(frontier, lookahead_end);
if resuming {
self.realign_phaselock_patterns(lookahead_end);
self.reset_origins_on_resume(lookahead_end);
}
let steps = self.execute_steps(
@@ -793,7 +767,7 @@ impl SequencerState {
input.fill,
input.nudge_secs,
input.current_time_us,
input.audio_sample_pos,
input.corrected_audio_pos,
input.sr,
input.mouse_x,
input.mouse_y,
@@ -808,7 +782,7 @@ impl SequencerState {
input.quantum,
input.fill,
input.nudge_secs,
input.audio_sample_pos,
input.corrected_audio_pos,
input.sr,
input.mouse_x,
input.mouse_y,
@@ -818,7 +792,7 @@ impl SequencerState {
let new_tempo = self.read_tempo_variable(steps.any_step_fired);
self.apply_follow_ups();
self.audio_state.prev_beat = lookahead_end;
self.audio_state.play_state = PlayState::Playing { frontier: lookahead_end };
let flush = std::mem::take(&mut self.audio_state.flush_midi_notes);
TickOutput {
@@ -840,11 +814,12 @@ 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;
let pause_beat = match self.audio_state.play_state {
PlayState::Playing { frontier } => Some(frontier),
PlayState::Idle { pause_beat } => pause_beat,
};
self.audio_state.play_state = PlayState::Idle { pause_beat };
self.script_frontier = None;
self.script_step = 0;
self.script_trace = None;
self.print_output = None;
@@ -858,35 +833,46 @@ impl SequencerState {
}
}
fn realign_phaselock_patterns(&mut self, beat: f64) {
for (id, active) in &mut self.audio_state.active_patterns {
let Some(pattern) = self.pattern_cache.get(id.bank, id.pattern) else {
continue;
};
if pattern.sync_mode != SyncMode::PhaseLock {
continue;
}
let speed_mult = pattern.speed.multiplier();
let subs_per_beat = 4.0 * speed_mult;
let step = (beat * subs_per_beat).floor() as usize + 1;
active.step_index = step % pattern.length;
fn pause_beat(&self) -> Option<f64> {
match self.audio_state.play_state {
PlayState::Idle { pause_beat } => pause_beat,
PlayState::Playing { .. } => None,
}
}
fn activate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) {
fn reset_origins_on_resume(&mut self, lookahead_end: f64) {
for (id, active) in &mut self.audio_state.active_patterns {
active.origin_beat = lookahead_end;
let Some(pattern) = self.pattern_cache.get(id.bank, id.pattern) else {
continue;
};
let subs_per_beat = 4.0 * pattern.speed.multiplier();
let step = (lookahead_end * subs_per_beat).floor() as usize + 1;
active.step_index = step % pattern.length;
}
self.script_frontier = Some(lookahead_end);
}
fn activate_pending(&mut self, frontier: f64, lookahead_end: f64) {
self.buf_activated.clear();
for pending in &self.audio_state.pending_starts {
if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) {
let should_activate = match pending.target_beat {
None => true,
Some(target) => target <= lookahead_end,
};
if should_activate {
let origin_beat = match pending.target_beat {
Some(t) if t > frontier => t - 1e-9,
_ => frontier,
};
let start_step = match pending.sync_mode {
SyncMode::Reset => 0,
SyncMode::PhaseLock => {
if let Some(pat) =
self.pattern_cache.get(pending.id.bank, pending.id.pattern)
{
let speed_mult = pat.speed.multiplier();
let subs_per_beat = 4.0 * speed_mult;
let first_sub = (prev_beat * subs_per_beat).floor() as usize + 1;
first_sub % pat.length
let subs_per_beat = 4.0 * pat.speed.multiplier();
(origin_beat * subs_per_beat).floor() as usize % pat.length
} else {
0
}
@@ -901,7 +887,8 @@ impl SequencerState {
pattern: pending.id.pattern,
step_index: start_step,
iter: 0,
last_step_beat: beat,
last_step_beat: lookahead_end,
origin_beat,
},
);
self.buf_activated.push(pending.id);
@@ -913,15 +900,18 @@ impl SequencerState {
.retain(|p| !activated.contains(&p.id));
}
fn deactivate_pending(&mut self, beat: f64, prev_beat: f64, quantum: f64) {
fn deactivate_pending(&mut self, _frontier: f64, lookahead_end: f64) {
self.buf_stopped.clear();
for pending in &self.audio_state.pending_stops {
if check_quantization_boundary(pending.quantization, beat, prev_beat, quantum) {
let should_deactivate = match pending.target_beat {
None => true,
Some(target) => target <= lookahead_end,
};
if should_deactivate {
self.audio_state.active_patterns.remove(&pending.id);
Arc::make_mut(&mut self.step_traces).retain(|&(bank, pattern, _), _| {
bank != pending.id.bank || pattern != pending.id.pattern
});
// Flush pending update so cache stays current for future launches
let key = (pending.id.bank, pending.id.pattern);
if let Some(snapshot) = self.pending_updates.remove(&key) {
self.pattern_cache.set(key.0, key.1, snapshot);
@@ -946,7 +936,7 @@ impl SequencerState {
fill: bool,
nudge_secs: f64,
_current_time_us: SyncTime,
audio_sample_pos: u64,
corrected_audio_pos: f64,
sr: f64,
mouse_x: f64,
mouse_y: f64,
@@ -981,7 +971,8 @@ impl SequencerState {
.copied()
.unwrap_or_else(|| pattern.speed.multiplier());
let step_beats = substeps_in_window(frontier, lookahead_end, speed_mult);
let pattern_frontier = frontier.max(active.origin_beat);
let step_beats = substeps_in_window(pattern_frontier, lookahead_end, speed_mult);
for step_beat in step_beats {
result.any_step_fired = true;
@@ -994,7 +985,7 @@ impl SequencerState {
} else {
0.0
};
let event_tick = Some(audio_sample_pos + (time_delta * sr).round() as u64);
let event_tick = Some((corrected_audio_pos + time_delta * sr).round() as u64);
if let Some(step) = pattern.steps.get(step_idx) {
let resolved_script = pattern.resolve_script(step_idx);
@@ -1106,7 +1097,7 @@ impl SequencerState {
quantum: f64,
fill: bool,
nudge_secs: f64,
audio_sample_pos: u64,
corrected_audio_pos: f64,
sr: f64,
mouse_x: f64,
mouse_y: f64,
@@ -1116,11 +1107,7 @@ impl SequencerState {
return;
}
let script_frontier = if self.script_frontier < 0.0 {
frontier
} else {
self.script_frontier
};
let script_frontier = self.script_frontier.unwrap_or(frontier);
let speed_mult = self.script_speed.multiplier();
let fire_beats = substeps_in_window(script_frontier, lookahead_end, speed_mult);
@@ -1132,7 +1119,7 @@ impl SequencerState {
} else {
0.0
};
let event_tick = Some(audio_sample_pos + (time_delta * sr).round() as u64);
let event_tick = Some((corrected_audio_pos + time_delta * sr).round() as u64);
let step_in_cycle = self.script_step % self.script_length;
@@ -1187,7 +1174,7 @@ impl SequencerState {
self.script_step += 1;
}
self.script_frontier = lookahead_end;
self.script_frontier = Some(lookahead_end);
}
fn read_tempo_variable(&self, any_step_fired: bool) -> Option<f64> {
@@ -1220,21 +1207,21 @@ impl SequencerState {
FollowUp::Stop => {
self.audio_state.pending_stops.push(PendingPattern {
id: *completed_id,
quantization: LaunchQuantization::Immediate,
target_beat: None,
sync_mode: SyncMode::Reset,
});
}
FollowUp::Chain { bank, pattern } => {
self.audio_state.pending_stops.push(PendingPattern {
id: *completed_id,
quantization: LaunchQuantization::Immediate,
target_beat: None,
sync_mode: SyncMode::Reset,
});
let target = PatternId { bank, pattern };
if !self.audio_state.pending_starts.iter().any(|p| p.id == target) {
self.audio_state.pending_starts.push(PendingPattern {
id: target,
quantization: LaunchQuantization::Immediate,
target_beat: None,
sync_mode: SyncMode::Reset,
});
}
@@ -1261,7 +1248,6 @@ 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(),
}
@@ -1279,7 +1265,7 @@ fn sequencer_loop(
shared_state: Arc<ArcSwap<SharedSequencerState>>,
live_keys: Arc<LiveKeyState>,
nudge_us: Arc<AtomicI64>,
audio_sample_pos: Arc<AtomicU64>,
audio_ref: Arc<ArcSwap<AudioRef>>,
sample_rate: Arc<std::sync::atomic::AtomicU32>,
cc_access: Option<Arc<dyn CcAccess>>,
variables: Variables,
@@ -1325,9 +1311,19 @@ fn sequencer_loop(
let state = link.capture_app_state();
let current_time_us = link.clock_micros() as SyncTime;
let beat = state.beat_at_time(current_time_us as i64, quantum);
let mut beat = state.beat_at_time(current_time_us as i64, quantum);
let tempo = state.tempo();
let is_playing = playing.load(Ordering::Relaxed);
if is_playing && !seq_state.last_playing {
let anchor_beat = seq_state.pause_beat().unwrap_or(0.0);
link.start_playing(anchor_beat, current_time_us as i64, quantum);
let state = link.capture_app_state();
beat = state.beat_at_time(current_time_us as i64, quantum);
} else if !is_playing && seq_state.last_playing {
link.stop_playing(current_time_us as i64);
}
let lookahead_beats = if tempo > 0.0 {
lookahead_secs * tempo / 60.0
} else {
@@ -1336,10 +1332,12 @@ fn sequencer_loop(
let lookahead_end = beat + lookahead_beats;
let sr = sample_rate.load(Ordering::Relaxed) as f64;
let audio_samples = audio_sample_pos.load(Ordering::Acquire);
let ref_snapshot = audio_ref.load();
let elapsed_secs = ref_snapshot.timestamp.elapsed().as_secs_f64();
let corrected_pos = ref_snapshot.sample_pos as f64 + elapsed_secs * ref_snapshot.sample_rate;
let input = TickInput {
commands,
playing: playing.load(Ordering::Relaxed),
playing: is_playing,
beat,
lookahead_end,
tempo,
@@ -1347,7 +1345,7 @@ fn sequencer_loop(
fill: live_keys.fill(),
nudge_secs: nudge_us.load(Ordering::Relaxed) as f64 / 1_000_000.0,
current_time_us,
audio_sample_pos: audio_samples,
corrected_audio_pos: corrected_pos,
sr,
#[cfg(feature = "desktop")]
mouse_x: f32::from_bits(mouse_x.load(Ordering::Relaxed)) as f64,
@@ -1550,7 +1548,6 @@ mod tests {
source: None,
})
.collect(),
sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
}
}
@@ -1570,7 +1567,7 @@ mod tests {
fill: false,
nudge_secs: 0.0,
current_time_us: 0,
audio_sample_pos: 0,
corrected_audio_pos: 0.0,
sr: 48000.0,
mouse_x: 0.5,
mouse_y: 0.5,
@@ -1589,7 +1586,7 @@ mod tests {
fill: false,
nudge_secs: 0.0,
current_time_us: 0,
audio_sample_pos: 0,
corrected_audio_pos: 0.0,
sr: 48000.0,
mouse_x: 0.5,
mouse_y: 0.5,
@@ -1621,7 +1618,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
},
],
1.0,
@@ -1649,7 +1645,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
1.0,
));
@@ -1697,7 +1692,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
@@ -1706,43 +1700,32 @@ mod tests {
}
#[test]
fn test_quantization_boundaries() {
assert!(check_quantization_boundary(
LaunchQuantization::Immediate,
1.5,
1.0,
4.0
));
assert!(check_quantization_boundary(
LaunchQuantization::Beat,
2.0,
1.9,
4.0
));
assert!(!check_quantization_boundary(
LaunchQuantization::Beat,
1.5,
1.2,
4.0
));
assert!(check_quantization_boundary(
LaunchQuantization::Bar,
4.0,
3.9,
4.0
));
assert!(!check_quantization_boundary(
LaunchQuantization::Bar,
3.5,
3.2,
4.0
));
assert!(!check_quantization_boundary(
LaunchQuantization::Immediate,
1.0,
-1.0,
4.0
));
fn test_next_boundary() {
use super::super::next_boundary;
// Immediate returns None
assert_eq!(next_boundary(1.5, LaunchQuantization::Immediate, 4.0), None);
// Beat: next integer beat
assert_eq!(next_boundary(1.5, LaunchQuantization::Beat, 4.0), Some(2.0));
assert_eq!(next_boundary(1.9, LaunchQuantization::Beat, 4.0), Some(2.0));
// On exact beat boundary, targets next beat
assert_eq!(next_boundary(2.0, LaunchQuantization::Beat, 4.0), Some(3.0));
// Bar (quantum=4): next multiple of 4
assert_eq!(next_boundary(3.5, LaunchQuantization::Bar, 4.0), Some(4.0));
assert_eq!(next_boundary(3.9, LaunchQuantization::Bar, 4.0), Some(4.0));
// On exact bar boundary, targets next bar
assert_eq!(next_boundary(4.0, LaunchQuantization::Bar, 4.0), Some(8.0));
// Bars2 (quantum=4): next multiple of 8
assert_eq!(next_boundary(3.5, LaunchQuantization::Bars2, 4.0), Some(8.0));
// Bars4 (quantum=4): next multiple of 16
assert_eq!(next_boundary(3.5, LaunchQuantization::Bars4, 4.0), Some(16.0));
// Bars8 (quantum=4): next multiple of 32
assert_eq!(next_boundary(3.5, LaunchQuantization::Bars8, 4.0), Some(32.0));
}
#[test]
@@ -1763,7 +1746,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
@@ -1808,7 +1790,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
@@ -1844,7 +1825,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
},
SeqCommand::PatternStop {
bank: 0,
@@ -1879,13 +1859,11 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
},
SeqCommand::PatternStart {
bank: 0,
pattern: 1,
quantization: LaunchQuantization::Beat,
sync_mode: SyncMode::Reset,
},
],
0.0,
@@ -1921,7 +1899,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
@@ -1990,7 +1967,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
..tick_at(1.0, false)
});
@@ -2019,7 +1995,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
1.0,
));
@@ -2049,13 +2024,11 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
},
SeqCommand::PatternStart {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
},
],
0.0,
@@ -2088,7 +2061,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
@@ -2115,7 +2087,6 @@ mod tests {
source: None,
})
.collect(),
sync_mode: SyncMode::Reset,
follow_up: FollowUp::default(),
}
}
@@ -2138,7 +2109,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
0.5,
));
@@ -2186,13 +2156,11 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
},
SeqCommand::PatternStart {
bank: 0,
pattern: 1,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
},
],
0.5,
@@ -2227,7 +2195,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
}],
3.5,
));
@@ -2266,7 +2233,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Immediate,
sync_mode: SyncMode::Reset,
}],
..tick_at(2.0, false)
});
@@ -2306,13 +2272,11 @@ mod tests {
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,
@@ -2344,7 +2308,6 @@ mod tests {
source: None,
})
.collect(),
sync_mode: SyncMode::PhaseLock,
follow_up: FollowUp::default(),
}
}
@@ -2368,7 +2331,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::PhaseLock,
}],
3.5,
));
@@ -2410,7 +2372,6 @@ mod tests {
bank: 0,
pattern: 0,
quantization: LaunchQuantization::Bar,
sync_mode: SyncMode::Reset,
}],
1.0,
));

View File

@@ -1,26 +1,28 @@
use crate::model::LaunchQuantization;
/// Microsecond-precision timestamp for audio synchronization.
pub type SyncTime = u64;
/// Timing boundary types for step and pattern scheduling.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepTiming {
/// Fire when a beat boundary is crossed.
NextBeat,
/// Fire when a bar/quantum boundary is crossed.
NextBar,
}
impl StepTiming {
/// Returns true if the boundary was crossed between prev_beat and curr_beat.
pub fn crossed(&self, prev_beat: f64, curr_beat: f64, quantum: f64) -> bool {
if prev_beat < 0.0 {
return false;
/// Compute the exact next quantization boundary beat after `current_beat`.
/// Returns `None` for Immediate (activate now), `Some(beat)` for all others.
pub fn next_boundary(current_beat: f64, quantization: LaunchQuantization, quantum: f64) -> Option<f64> {
match quantization {
LaunchQuantization::Immediate => None,
LaunchQuantization::Beat => Some(current_beat.floor() + 1.0),
LaunchQuantization::Bar => {
Some((current_beat / quantum).floor() * quantum + quantum)
}
match self {
Self::NextBeat => prev_beat.floor() as i64 != curr_beat.floor() as i64,
Self::NextBar => {
(prev_beat / quantum).floor() as i64 != (curr_beat / quantum).floor() as i64
}
LaunchQuantization::Bars2 => {
let p = quantum * 2.0;
Some((current_beat / p).floor() * p + p)
}
LaunchQuantization::Bars4 => {
let p = quantum * 4.0;
Some((current_beat / p).floor() * p + p)
}
LaunchQuantization::Bars8 => {
let p = quantum * 8.0;
Some((current_beat / p).floor() * p + p)
}
}
}
@@ -29,7 +31,7 @@ impl StepTiming {
/// Each entry is the exact beat at which that substep fires.
/// Clamped to 64 results max to prevent runaway.
pub fn substeps_in_window(frontier: f64, end: f64, speed: f64) -> Vec<f64> {
if frontier < 0.0 || end <= frontier || speed <= 0.0 {
if end <= frontier || speed <= 0.0 {
return Vec::new();
}
let substeps_per_beat = 4.0 * speed;
@@ -55,9 +57,6 @@ mod tests {
}
fn substeps_crossed(prev_beat: f64, curr_beat: f64, speed: f64) -> usize {
if prev_beat < 0.0 {
return 0;
}
let prev_substep = (prev_beat * 4.0 * speed).floor() as i64;
let curr_substep = (curr_beat * 4.0 * speed).floor() as i64;
(curr_substep - prev_substep).clamp(0, 16) as usize
@@ -88,23 +87,15 @@ mod tests {
}
#[test]
fn test_step_timing_beat_crossed() {
// Crossing from beat 0 to beat 1
assert!(StepTiming::NextBeat.crossed(0.9, 1.1, 4.0));
// Not crossing (both in same beat)
assert!(!StepTiming::NextBeat.crossed(0.5, 0.9, 4.0));
// Negative prev_beat returns false
assert!(!StepTiming::NextBeat.crossed(-1.0, 1.0, 4.0));
}
#[test]
fn test_step_timing_bar_crossed() {
// Crossing from bar 0 to bar 1 (quantum=4)
assert!(StepTiming::NextBar.crossed(3.9, 4.1, 4.0));
// Not crossing (both in same bar)
assert!(!StepTiming::NextBar.crossed(2.0, 3.0, 4.0));
// Crossing with different quantum
assert!(StepTiming::NextBar.crossed(7.9, 8.1, 8.0));
fn test_next_boundary() {
assert_eq!(next_boundary(1.5, LaunchQuantization::Immediate, 4.0), None);
assert_eq!(next_boundary(1.5, LaunchQuantization::Beat, 4.0), Some(2.0));
assert_eq!(next_boundary(2.0, LaunchQuantization::Beat, 4.0), Some(3.0));
assert_eq!(next_boundary(3.5, LaunchQuantization::Bar, 4.0), Some(4.0));
assert_eq!(next_boundary(4.0, LaunchQuantization::Bar, 4.0), Some(8.0));
assert_eq!(next_boundary(3.5, LaunchQuantization::Bars2, 4.0), Some(8.0));
assert_eq!(next_boundary(3.5, LaunchQuantization::Bars4, 4.0), Some(16.0));
assert_eq!(next_boundary(3.5, LaunchQuantization::Bars8, 4.0), Some(32.0));
}
#[test]
@@ -126,9 +117,9 @@ mod tests {
}
#[test]
fn test_substeps_crossed_negative_prev() {
// Negative prev_beat returns 0
assert_eq!(substeps_crossed(-1.0, 0.5, 1.0), 0);
fn test_substeps_crossed_same_position() {
// Same position returns 0
assert_eq!(substeps_crossed(0.5, 0.5, 1.0), 0);
}
#[test]
@@ -205,8 +196,9 @@ mod tests {
}
#[test]
fn test_substeps_in_window_negative_frontier() {
let result = substeps_in_window(-1.0, 0.5, 1.0);
fn test_substeps_in_window_reversed() {
// end <= frontier returns empty
let result = substeps_in_window(0.5, 0.3, 1.0);
assert!(result.is_empty());
}
}

View File

@@ -1,15 +1,16 @@
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::Arc;
use std::time::Instant;
use crossbeam_channel::Receiver;
use arc_swap::ArcSwap;
use doux::EngineMetrics;
use crate::app::App;
use crate::engine::{
build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioStreamConfig,
LinkState, MidiCommand, PatternChange, ScopeBuffer, SequencerConfig, SequencerHandle,
SpectrumBuffer,
build_stream, preload_sample_heads, spawn_sequencer, AnalysisHandle, AudioRef,
AudioStreamConfig, LinkState, PatternChange, ScopeBuffer, SequencerConfig,
SequencerHandle, SpectrumBuffer,
};
use crate::midi;
use crate::model;
@@ -37,12 +38,11 @@ pub struct Init {
pub metrics: Arc<EngineMetrics>,
pub scope_buffer: Arc<ScopeBuffer>,
pub spectrum_buffer: Arc<SpectrumBuffer>,
pub audio_sample_pos: Arc<AtomicU64>,
pub audio_ref: Arc<ArcSwap<AudioRef>>,
pub sample_rate_shared: Arc<AtomicU32>,
pub stream: Option<cpal::Stream>,
pub input_stream: Option<cpal::Stream>,
pub analysis_handle: Option<AnalysisHandle>,
pub midi_rx: Receiver<MidiCommand>,
pub device_lost: Arc<AtomicBool>,
pub stream_error_rx: crossbeam_channel::Receiver<String>,
#[cfg(feature = "desktop")]
@@ -83,8 +83,7 @@ pub fn init(args: InitArgs) -> Init {
for (bank, pattern) in playing {
app.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: model::LaunchQuantization::Immediate,
sync_mode: model::SyncMode::PhaseLock,
quantization: model::LaunchQuantization::Bar,
});
}
app.ui.set_status(format!("Demo: {}", demo.name));
@@ -96,8 +95,7 @@ pub fn init(args: InitArgs) -> Init {
bank: 0,
pattern: 0,
},
quantization: model::LaunchQuantization::Immediate,
sync_mode: model::SyncMode::PhaseLock,
quantization: model::LaunchQuantization::Bar,
});
}
@@ -155,7 +153,11 @@ pub fn init(args: InitArgs) -> Init {
let scope_buffer = Arc::new(ScopeBuffer::new());
let spectrum_buffer = Arc::new(SpectrumBuffer::new());
let audio_sample_pos = Arc::new(AtomicU64::new(0));
let audio_ref = Arc::new(ArcSwap::from_pointee(AudioRef {
sample_pos: 0,
timestamp: Instant::now(),
sample_rate: 44100.0,
}));
let sample_rate_shared = Arc::new(AtomicU32::new(44100));
let mut initial_samples = Vec::new();
for path in &app.audio.config.sample_paths {
@@ -181,7 +183,7 @@ pub fn init(args: InitArgs) -> Init {
let mouse_down = Arc::new(AtomicU32::new(0.0_f32.to_bits()));
let seq_config = SequencerConfig {
audio_sample_pos: Arc::clone(&audio_sample_pos),
audio_ref: Arc::clone(&audio_ref),
sample_rate: Arc::clone(&sample_rate_shared),
cc_access: Some(Arc::new(app.midi.cc_memory.clone()) as Arc<dyn model::CcAccess>),
variables: Arc::clone(&app.variables),
@@ -194,13 +196,14 @@ pub fn init(args: InitArgs) -> Init {
mouse_down: Arc::clone(&mouse_down),
};
let (sequencer, initial_audio_rx, midi_rx) = spawn_sequencer(
let (sequencer, initial_audio_rx) = spawn_sequencer(
Arc::clone(&link),
Arc::clone(&playing),
settings.link.quantum,
Arc::clone(&app.live_keys),
Arc::clone(&nudge_us),
seq_config,
app.midi.output_ports.clone(),
);
let device_lost = Arc::new(AtomicBool::new(false));
@@ -221,7 +224,7 @@ pub fn init(args: InitArgs) -> Init {
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
initial_samples,
Arc::clone(&audio_sample_pos),
Arc::clone(&audio_ref),
stream_error_tx,
&app.audio.config.sample_paths,
Arc::clone(&device_lost),
@@ -230,6 +233,7 @@ pub fn init(args: InitArgs) -> Init {
app.audio.config.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels;
app.audio.config.input_sample_rate = info.input_sample_rate;
sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.sample_registry = Some(Arc::clone(&registry));
@@ -264,12 +268,11 @@ pub fn init(args: InitArgs) -> Init {
metrics,
scope_buffer,
spectrum_buffer,
audio_sample_pos,
audio_ref,
sample_rate_shared,
stream,
input_stream,
analysis_handle,
midi_rx,
device_lost,
stream_error_rx,
#[cfg(feature = "desktop")]

View File

@@ -48,22 +48,20 @@ pub(crate) fn cycle_link_setting(ctx: &mut InputContext, right: bool) {
pub(crate) fn cycle_midi_output(ctx: &mut InputContext, right: bool) {
let slot = ctx.app.audio.midi_output_slot;
let all_devices = crate::midi::list_midi_outputs();
let selected = ctx.app.midi.selected_outputs();
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
.iter()
.enumerate()
.filter(|(idx, _)| {
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|| !ctx
.app
.midi
.selected_outputs
selected[slot] == Some(*idx)
|| !selected
.iter()
.enumerate()
.any(|(s, sel)| s != slot && *sel == Some(*idx))
})
.collect();
let total_options = available.len() + 1;
let current_pos = ctx.app.midi.selected_outputs[slot]
let current_pos = selected[slot]
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
.map(|p| p + 1)
.unwrap_or(0);
@@ -299,7 +297,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Char('r') => ctx.dispatch(AppCommand::ResetPeakVoices),
KeyCode::Char('t') if !ctx.app.plugin_mode => {
let _ = ctx.audio_tx.load().send(AudioCommand::Evaluate {
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
cmd: "/sound/sine/gate/0.5/decay/0.2".into(),
tick: None,
});
}

View File

@@ -461,7 +461,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
length,
speed,
quantization,
sync_mode,
follow_up,
} => {
let (bank, pattern) = (*bank, *pattern);
@@ -472,7 +471,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Left => match field {
PatternPropsField::Speed => *speed = speed.prev(),
PatternPropsField::Quantization => *quantization = quantization.prev(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
PatternPropsField::FollowUp => *follow_up = follow_up.prev_mode(),
PatternPropsField::ChainBank => {
if let FollowUp::Chain { bank: b, .. } = follow_up {
@@ -489,7 +487,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
KeyCode::Right => match field {
PatternPropsField::Speed => *speed = speed.next(),
PatternPropsField::Quantization => *quantization = quantization.next(),
PatternPropsField::SyncMode => *sync_mode = sync_mode.toggle(),
PatternPropsField::FollowUp => *follow_up = follow_up.next_mode(),
PatternPropsField::ChainBank => {
if let FollowUp::Chain { bank: b, .. } = follow_up {
@@ -535,7 +532,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
let length_val = length.parse().ok();
let speed_val = *speed;
let quant_val = *quantization;
let sync_val = *sync_mode;
let follow_up_val = *follow_up;
ctx.dispatch(AppCommand::StagePatternProps {
bank,
@@ -545,7 +541,6 @@ pub(super) fn handle_modal_input(ctx: &mut InputContext, key: KeyEvent) -> Input
length: length_val,
speed: speed_val,
quantization: quant_val,
sync_mode: sync_val,
follow_up: follow_up_val,
});
ctx.dispatch(AppCommand::CloseModal);
@@ -795,7 +790,7 @@ fn execute_palette_entry(
.audio_tx
.load()
.send(crate::engine::AudioCommand::Evaluate {
cmd: "/sound/sine/dur/0.5/decay/0.2".into(),
cmd: "/sound/sine/gate/0.5/decay/0.2".into(),
tick: None,
});
}

View File

@@ -66,7 +66,7 @@ pub(super) fn handle_sample_explorer(ctx: &mut InputContext, key: KeyEvent) -> I
TreeLineKind::File => {
let folder = &entry.folder;
let idx = entry.index;
let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/dur/1");
let cmd = format!("/sound/{folder}/n/{idx}/gain/1.00/gate/1");
let _ = ctx
.audio_tx
.load()

View File

@@ -21,3 +21,6 @@ pub mod block_renderer;
#[cfg(feature = "block-renderer")]
pub mod input_egui;
#[cfg(feature = "block-renderer")]
pub mod terminal;

View File

@@ -96,12 +96,11 @@ fn main() -> io::Result<()> {
let metrics = b.metrics;
let scope_buffer = b.scope_buffer;
let spectrum_buffer = b.spectrum_buffer;
let audio_sample_pos = b.audio_sample_pos;
let audio_ref = b.audio_ref;
let sample_rate_shared = b.sample_rate_shared;
let mut _stream = b.stream;
let mut _input_stream = b.input_stream;
let mut _analysis_handle = b.analysis_handle;
let mut midi_rx = b.midi_rx;
let device_lost = b.device_lost;
let mut stream_error_rx = b.stream_error_rx;
@@ -125,7 +124,6 @@ fn main() -> io::Result<()> {
_analysis_handle = None;
let new_audio_rx = sequencer.swap_audio_channel();
midi_rx = sequencer.swap_midi_channel();
let new_config = AudioStreamConfig {
output_device: app.audio.config.output_device.clone(),
@@ -150,7 +148,11 @@ fn main() -> io::Result<()> {
}
}
audio_sample_pos.store(0, Ordering::Relaxed);
audio_ref.store(Arc::new(engine::AudioRef {
sample_pos: 0,
timestamp: std::time::Instant::now(),
sample_rate: 44100.0,
}));
let preload_entries: Vec<(String, std::path::PathBuf)> = restart_samples
.iter()
@@ -164,7 +166,7 @@ fn main() -> io::Result<()> {
Arc::clone(&spectrum_buffer),
Arc::clone(&metrics),
restart_samples,
Arc::clone(&audio_sample_pos),
Arc::clone(&audio_ref),
new_error_tx,
&app.audio.config.sample_paths,
Arc::clone(&device_lost),
@@ -176,6 +178,7 @@ fn main() -> io::Result<()> {
app.audio.config.sample_rate = info.sample_rate;
app.audio.config.host_name = info.host_name;
app.audio.config.channels = info.channels;
app.audio.config.input_sample_rate = info.input_sample_rate;
sample_rate_shared.store(info.sample_rate as u32, Ordering::Relaxed);
app.audio.error = None;
app.audio.sample_registry = Some(Arc::clone(&registry));
@@ -233,59 +236,6 @@ fn main() -> io::Result<()> {
}
was_playing = app.playback.playing;
while let Ok(midi_cmd) = midi_rx.try_recv() {
match midi_cmd {
engine::MidiCommand::NoteOn {
device,
channel,
note,
velocity,
} => {
app.midi.send_note_on(device, channel, note, velocity);
}
engine::MidiCommand::NoteOff {
device,
channel,
note,
} => {
app.midi.send_note_off(device, channel, note);
}
engine::MidiCommand::CC {
device,
channel,
cc,
value,
} => {
app.midi.send_cc(device, channel, cc, value);
}
engine::MidiCommand::PitchBend {
device,
channel,
value,
} => {
app.midi.send_pitch_bend(device, channel, value);
}
engine::MidiCommand::Pressure {
device,
channel,
value,
} => {
app.midi.send_pressure(device, channel, value);
}
engine::MidiCommand::ProgramChange {
device,
channel,
program,
} => {
app.midi.send_program_change(device, channel, program);
}
engine::MidiCommand::Clock { device } => app.midi.send_realtime(device, 0xF8),
engine::MidiCommand::Start { device } => app.midi.send_realtime(device, 0xFA),
engine::MidiCommand::Stop { device } => app.midi.send_realtime(device, 0xFC),
engine::MidiCommand::Continue { device } => app.midi.send_realtime(device, 0xFB),
}
}
{
app.metrics.active_voices = metrics.active_voices.load(Ordering::Relaxed) as usize;
app.metrics.peak_voices = app.metrics.peak_voices.max(app.metrics.active_voices);

View File

@@ -1,12 +1,124 @@
use parking_lot::Mutex;
use std::sync::Arc;
use crate::engine::sequencer::MidiCommand;
use crate::model::CcAccess;
pub const MAX_MIDI_OUTPUTS: usize = 4;
pub const MAX_MIDI_INPUTS: usize = 4;
pub const MAX_MIDI_DEVICES: usize = 4;
/// Thread-safe MIDI output connections shared between the dispatcher (sending)
/// and the UI thread (connect/disconnect). The dispatcher calls `send_command`
/// directly after precise timing, eliminating the ~16ms jitter from UI polling.
#[derive(Clone)]
pub struct MidiOutputPorts {
#[cfg(feature = "cli")]
conns: Arc<Mutex<[Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS]>>,
pub selected: Arc<Mutex<[Option<usize>; MAX_MIDI_OUTPUTS]>>,
}
impl MidiOutputPorts {
pub fn new() -> Self {
Self {
#[cfg(feature = "cli")]
conns: Arc::new(Mutex::new([None, None, None, None])),
selected: Arc::new(Mutex::new([None; MAX_MIDI_OUTPUTS])),
}
}
#[cfg(feature = "cli")]
pub fn connect(&self, slot: usize, port_index: usize) -> Result<(), String> {
if slot >= MAX_MIDI_OUTPUTS {
return Err("Invalid output slot".to_string());
}
let midi_out =
midir::MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?;
let ports = midi_out.ports();
let port = ports.get(port_index).ok_or("MIDI output port not found")?;
let conn = midi_out
.connect(port, &format!("cagire-midi-out-{slot}"))
.map_err(|e| e.to_string())?;
self.conns.lock()[slot] = Some(conn);
self.selected.lock()[slot] = Some(port_index);
Ok(())
}
#[cfg(not(feature = "cli"))]
pub fn connect(&self, _slot: usize, _port_index: usize) -> Result<(), String> {
Ok(())
}
#[cfg(feature = "cli")]
pub fn disconnect(&self, slot: usize) {
if slot < MAX_MIDI_OUTPUTS {
self.conns.lock()[slot] = None;
self.selected.lock()[slot] = None;
}
}
#[cfg(not(feature = "cli"))]
pub fn disconnect(&self, slot: usize) {
if slot < MAX_MIDI_OUTPUTS {
self.selected.lock()[slot] = None;
}
}
#[cfg(feature = "cli")]
fn send_message(&self, device: u8, message: &[u8]) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
let mut conns = self.conns.lock();
if let Some(conn) = &mut conns[slot] {
let _ = conn.send(message);
}
}
#[cfg(not(feature = "cli"))]
fn send_message(&self, _device: u8, _message: &[u8]) {}
/// Send a MidiCommand directly — called by the dispatcher thread.
pub fn send_command(&self, cmd: &MidiCommand) {
match cmd {
MidiCommand::NoteOn { device, channel, note, velocity } => {
let status = 0x90 | (channel & 0x0F);
self.send_message(*device, &[status, note & 0x7F, velocity & 0x7F]);
}
MidiCommand::NoteOff { device, channel, note } => {
let status = 0x80 | (channel & 0x0F);
self.send_message(*device, &[status, note & 0x7F, 0]);
}
MidiCommand::CC { device, channel, cc, value } => {
let status = 0xB0 | (channel & 0x0F);
self.send_message(*device, &[status, cc & 0x7F, value & 0x7F]);
}
MidiCommand::PitchBend { device, channel, value } => {
let status = 0xE0 | (channel & 0x0F);
let lsb = (value & 0x7F) as u8;
let msb = ((value >> 7) & 0x7F) as u8;
self.send_message(*device, &[status, lsb, msb]);
}
MidiCommand::Pressure { device, channel, value } => {
let status = 0xD0 | (channel & 0x0F);
self.send_message(*device, &[status, value & 0x7F]);
}
MidiCommand::ProgramChange { device, channel, program } => {
let status = 0xC0 | (channel & 0x0F);
self.send_message(*device, &[status, program & 0x7F]);
}
MidiCommand::Clock { device } => self.send_message(*device, &[0xF8]),
MidiCommand::Start { device } => self.send_message(*device, &[0xFA]),
MidiCommand::Stop { device } => self.send_message(*device, &[0xFC]),
MidiCommand::Continue { device } => self.send_message(*device, &[0xFB]),
}
}
}
impl Default for MidiOutputPorts {
fn default() -> Self {
Self::new()
}
}
/// Raw CC memory storage type
type CcMemoryInner = Arc<Mutex<[[[u8; 128]; 16]; MAX_MIDI_DEVICES]>>;
@@ -95,11 +207,9 @@ pub fn list_midi_inputs() -> Vec<MidiDeviceInfo> {
}
pub struct MidiState {
#[cfg(feature = "cli")]
output_conns: [Option<midir::MidiOutputConnection>; MAX_MIDI_OUTPUTS],
#[cfg(feature = "cli")]
input_conns: [Option<midir::MidiInputConnection<(CcMemoryInner, usize)>>; MAX_MIDI_INPUTS],
pub selected_outputs: [Option<usize>; MAX_MIDI_OUTPUTS],
pub output_ports: MidiOutputPorts,
pub selected_inputs: [Option<usize>; MAX_MIDI_INPUTS],
pub cc_memory: CcMemory,
}
@@ -113,51 +223,24 @@ impl Default for MidiState {
impl MidiState {
pub fn new() -> Self {
Self {
#[cfg(feature = "cli")]
output_conns: [None, None, None, None],
#[cfg(feature = "cli")]
input_conns: [None, None, None, None],
selected_outputs: [None; MAX_MIDI_OUTPUTS],
output_ports: MidiOutputPorts::new(),
selected_inputs: [None; MAX_MIDI_INPUTS],
cc_memory: CcMemory::new(),
}
}
#[cfg(feature = "cli")]
pub fn connect_output(&mut self, slot: usize, port_index: usize) -> Result<(), String> {
if slot >= MAX_MIDI_OUTPUTS {
return Err("Invalid output slot".to_string());
}
let midi_out =
midir::MidiOutput::new(&format!("cagire-out-{slot}")).map_err(|e| e.to_string())?;
let ports = midi_out.ports();
let port = ports.get(port_index).ok_or("MIDI output port not found")?;
let conn = midi_out
.connect(port, &format!("cagire-midi-out-{slot}"))
.map_err(|e| e.to_string())?;
self.output_conns[slot] = Some(conn);
self.selected_outputs[slot] = Some(port_index);
Ok(())
pub fn connect_output(&self, slot: usize, port_index: usize) -> Result<(), String> {
self.output_ports.connect(slot, port_index)
}
#[cfg(not(feature = "cli"))]
pub fn connect_output(&mut self, _slot: usize, _port_index: usize) -> Result<(), String> {
Ok(())
pub fn disconnect_output(&self, slot: usize) {
self.output_ports.disconnect(slot);
}
#[cfg(feature = "cli")]
pub fn disconnect_output(&mut self, slot: usize) {
if slot < MAX_MIDI_OUTPUTS {
self.output_conns[slot] = None;
self.selected_outputs[slot] = None;
}
}
#[cfg(not(feature = "cli"))]
pub fn disconnect_output(&mut self, slot: usize) {
if slot < MAX_MIDI_OUTPUTS {
self.selected_outputs[slot] = None;
}
pub fn selected_outputs(&self) -> [Option<usize>; MAX_MIDI_OUTPUTS] {
*self.output_ports.selected.lock()
}
#[cfg(feature = "cli")]
@@ -215,51 +298,4 @@ impl MidiState {
self.selected_inputs[slot] = None;
}
}
#[cfg(feature = "cli")]
fn send_message(&mut self, device: u8, message: &[u8]) {
let slot = (device as usize).min(MAX_MIDI_OUTPUTS - 1);
if let Some(conn) = &mut self.output_conns[slot] {
let _ = conn.send(message);
}
}
#[cfg(not(feature = "cli"))]
fn send_message(&mut self, _device: u8, _message: &[u8]) {}
pub fn send_note_on(&mut self, device: u8, channel: u8, note: u8, velocity: u8) {
let status = 0x90 | (channel & 0x0F);
self.send_message(device, &[status, note & 0x7F, velocity & 0x7F]);
}
pub fn send_note_off(&mut self, device: u8, channel: u8, note: u8) {
let status = 0x80 | (channel & 0x0F);
self.send_message(device, &[status, note & 0x7F, 0]);
}
pub fn send_cc(&mut self, device: u8, channel: u8, cc: u8, value: u8) {
let status = 0xB0 | (channel & 0x0F);
self.send_message(device, &[status, cc & 0x7F, value & 0x7F]);
}
pub fn send_pitch_bend(&mut self, device: u8, channel: u8, value: u16) {
let status = 0xE0 | (channel & 0x0F);
let lsb = (value & 0x7F) as u8;
let msb = ((value >> 7) & 0x7F) as u8;
self.send_message(device, &[status, lsb, msb]);
}
pub fn send_pressure(&mut self, device: u8, channel: u8, value: u8) {
let status = 0xD0 | (channel & 0x0F);
self.send_message(device, &[status, value & 0x7F]);
}
pub fn send_program_change(&mut self, device: u8, channel: u8, program: u8) {
let status = 0xC0 | (channel & 0x0F);
self.send_message(device, &[status, program & 0x7F]);
}
pub fn send_realtime(&mut self, device: u8, msg: u8) {
self.send_message(device, &[msg]);
}
}

View File

@@ -11,7 +11,7 @@ pub use cagire_forth::{
};
pub use cagire_project::{
load, load_str, save, share, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed,
Project, SyncMode, MAX_BANKS, MAX_PATTERNS,
Project, MAX_BANKS, MAX_PATTERNS,
};
pub use script::ScriptEngine;

View File

@@ -127,6 +127,7 @@ pub struct AudioConfig {
pub lissajous_trails: bool,
pub spectrum_mode: SpectrumMode,
pub spectrum_peaks: bool,
pub input_sample_rate: Option<f32>,
}
impl Default for AudioConfig {
@@ -154,6 +155,7 @@ impl Default for AudioConfig {
lissajous_trails: false,
spectrum_mode: SpectrumMode::default(),
spectrum_peaks: false,
input_sample_rate: None,
}
}
}

View File

@@ -31,7 +31,6 @@ pub enum PatternPropsField {
Length,
Speed,
Quantization,
SyncMode,
FollowUp,
ChainBank,
ChainPattern,
@@ -44,8 +43,7 @@ impl PatternPropsField {
Self::Description => Self::Length,
Self::Length => Self::Speed,
Self::Speed => Self::Quantization,
Self::Quantization => Self::SyncMode,
Self::SyncMode => Self::FollowUp,
Self::Quantization => Self::FollowUp,
Self::FollowUp if follow_up_is_chain => Self::ChainBank,
Self::FollowUp => Self::FollowUp,
Self::ChainBank => Self::ChainPattern,
@@ -60,8 +58,7 @@ impl PatternPropsField {
Self::Length => Self::Description,
Self::Speed => Self::Length,
Self::Quantization => Self::Speed,
Self::SyncMode => Self::Quantization,
Self::FollowUp => Self::SyncMode,
Self::FollowUp => Self::Quantization,
Self::ChainBank => Self::FollowUp,
Self::ChainPattern if follow_up_is_chain => Self::ChainBank,
Self::ChainPattern => Self::FollowUp,

View File

@@ -1,4 +1,4 @@
use crate::model::{self, FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::model::{self, FollowUp, LaunchQuantization, PatternSpeed};
use crate::state::editor::{EuclideanField, PatternField, PatternPropsField, ScriptField};
use crate::state::file_browser::FileBrowserState;
@@ -85,7 +85,6 @@ pub enum Modal {
length: String,
speed: PatternSpeed,
quantization: LaunchQuantization,
sync_mode: SyncMode,
follow_up: FollowUp,
},
KeybindingsHelp {

View File

@@ -1,12 +1,11 @@
use crate::engine::PatternChange;
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed};
use std::collections::{HashMap, HashSet};
#[derive(Clone)]
pub struct StagedChange {
pub change: PatternChange,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
@@ -21,7 +20,6 @@ pub struct StagedPropChange {
pub length: Option<usize>,
pub speed: PatternSpeed,
pub quantization: LaunchQuantization,
pub sync_mode: SyncMode,
pub follow_up: FollowUp,
}

110
src/terminal.rs Normal file
View File

@@ -0,0 +1,110 @@
use egui_ratatui::RataguiBackend;
use ratatui::Terminal;
use soft_ratatui::embedded_graphics_unicodefonts::{
mono_10x20_atlas, mono_6x13_atlas, mono_6x13_bold_atlas, mono_6x13_italic_atlas,
mono_7x13_atlas, mono_7x13_bold_atlas, mono_7x13_italic_atlas, mono_8x13_atlas,
mono_8x13_bold_atlas, mono_8x13_italic_atlas, mono_9x15_atlas, mono_9x15_bold_atlas,
mono_9x18_atlas, mono_9x18_bold_atlas,
};
use soft_ratatui::{EmbeddedGraphics, SoftBackend};
use crate::block_renderer::BlockCharBackend;
pub type TerminalType = Terminal<RataguiBackend<BlockCharBackend>>;
#[derive(Clone, Copy, PartialEq)]
pub enum FontChoice {
Size6x13,
Size7x13,
Size8x13,
Size9x15,
Size9x18,
Size10x20,
}
impl FontChoice {
pub fn from_setting(s: &str) -> Self {
match s {
"6x13" => Self::Size6x13,
"7x13" => Self::Size7x13,
"9x15" => Self::Size9x15,
"9x18" => Self::Size9x18,
"10x20" => Self::Size10x20,
_ => Self::Size8x13,
}
}
pub fn to_setting(self) -> &'static str {
match self {
Self::Size6x13 => "6x13",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Size6x13 => "6x13 (Compact)",
Self::Size7x13 => "7x13",
Self::Size8x13 => "8x13 (Default)",
Self::Size9x15 => "9x15",
Self::Size9x18 => "9x18",
Self::Size10x20 => "10x20 (Large)",
}
}
pub const ALL: [Self; 6] = [
Self::Size6x13,
Self::Size7x13,
Self::Size8x13,
Self::Size9x15,
Self::Size9x18,
Self::Size10x20,
];
}
pub fn create_terminal(font: FontChoice) -> TerminalType {
let (regular, bold, italic) = match font {
FontChoice::Size6x13 => (
mono_6x13_atlas(),
Some(mono_6x13_bold_atlas()),
Some(mono_6x13_italic_atlas()),
),
FontChoice::Size7x13 => (
mono_7x13_atlas(),
Some(mono_7x13_bold_atlas()),
Some(mono_7x13_italic_atlas()),
),
FontChoice::Size8x13 => (
mono_8x13_atlas(),
Some(mono_8x13_bold_atlas()),
Some(mono_8x13_italic_atlas()),
),
FontChoice::Size9x15 => (mono_9x15_atlas(), Some(mono_9x15_bold_atlas()), None),
FontChoice::Size9x18 => (mono_9x18_atlas(), Some(mono_9x18_bold_atlas()), None),
FontChoice::Size10x20 => (mono_10x20_atlas(), None, None),
};
// SoftBackend fields are copied individually to wrap raster_backend in BlockCharBackend.
// If soft_ratatui changes its field layout, this will fail to compile — that's intentional.
let eg = SoftBackend::<EmbeddedGraphics>::new(80, 24, regular, bold, italic);
let soft = SoftBackend {
buffer: eg.buffer,
cursor: eg.cursor,
cursor_pos: eg.cursor_pos,
char_width: eg.char_width,
char_height: eg.char_height,
blink_counter: eg.blink_counter,
blinking_fast: eg.blinking_fast,
blinking_slow: eg.blinking_slow,
rgb_pixmap: eg.rgb_pixmap,
always_redraw_list: eg.always_redraw_list,
raster_backend: BlockCharBackend {
inner: eg.raster_backend,
},
};
Terminal::new(RataguiBackend::new("cagire", soft)).expect("terminal")
}

View File

@@ -519,6 +519,14 @@ fn render_status(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
label_style,
)));
if let Some(input_sr) = app.audio.config.input_sample_rate {
let warn_style = Style::new().fg(theme.flash.error_fg);
lines.push(Line::from(Span::styled(
format!(" Input {input_sr:.0} Hz !!"),
warn_style,
)));
}
if !app.plugin_mode {
// Host
lines.push(Line::from(Span::styled(
@@ -946,7 +954,7 @@ fn render_midi_output(frame: &mut Frame, app: &App, area: Rect) {
let mut lines: Vec<Line> = Vec::new();
for slot in 0..4 {
let is_focused = section_focused && app.audio.midi_output_slot == slot;
let display = midi_display_name(&midi_outputs, app.midi.selected_outputs[slot]);
let display = midi_display_name(&midi_outputs, app.midi.selected_outputs()[slot]);
let prefix = if is_focused { "> " } else { " " };
let style = if is_focused { highlight } else { label_style };
let val_style = if is_focused { highlight } else { value_style };

View File

@@ -507,11 +507,7 @@ fn render_patterns(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, a
};
let props_indicator = if has_staged_props { "~" } else { "" };
let quant_sync = if is_selected {
format!(
"{}:{} ",
pattern.quantization.short_label(),
pattern.sync_mode.short_label()
)
format!("{} ", pattern.quantization.short_label())
} else {
String::new()
};
@@ -755,8 +751,6 @@ fn render_properties(
let steps_label = format!("{}/{}", content_count, pattern.length);
let speed_label = pattern.speed.label();
let quant_label = pattern.quantization.label();
let sync_label = pattern.sync_mode.label();
let label_style = Style::new().fg(theme.ui.text_muted);
let value_style = Style::new().fg(theme.ui.text_primary);
@@ -781,10 +775,6 @@ fn render_properties(
Span::styled(" Quant ", label_style),
Span::styled(quant_label, value_style),
]),
Line::from(vec![
Span::styled(" Sync ", label_style),
Span::styled(sync_label, value_style),
]),
];
if pattern.follow_up != FollowUp::Loop {

View File

@@ -737,7 +737,6 @@ fn render_modal(
length,
speed,
quantization,
sync_mode,
follow_up,
} => {
use crate::model::FollowUp;
@@ -766,7 +765,6 @@ fn render_modal(
("Length", length.clone(), *field == PatternPropsField::Length),
("Speed", speed_label, *field == PatternPropsField::Speed),
("Quantization", quantization.label().to_string(), *field == PatternPropsField::Quantization),
("Sync Mode", sync_mode.label().to_string(), *field == PatternPropsField::SyncMode),
("Follow Up", follow_up_label, *field == PatternPropsField::FollowUp),
];
if is_chain {

View File

@@ -21,9 +21,9 @@ fn with_params() {
}
#[test]
fn auto_dur() {
fn auto_gate() {
let outputs = expect_outputs(r#""kick" snd ."#, 1);
assert!(outputs[0].contains("dur/"));
assert!(outputs[0].contains("gate/"));
}
#[test]
@@ -94,7 +94,7 @@ fn param_only_emit() {
assert!(outputs[0].contains("voice/0"));
assert!(outputs[0].contains("freq/880"));
assert!(!outputs[0].contains("sound/"));
assert!(outputs[0].contains("dur/"));
assert!(outputs[0].contains("gate/"));
assert!(!outputs[0].contains("delaytime/"));
}
@@ -141,8 +141,8 @@ fn polyphonic_with_at() {
#[test]
fn explicit_dur_zero_is_infinite() {
let outputs = expect_outputs("880 freq 0 dur .", 1);
assert!(outputs[0].contains("dur/0"));
let outputs = expect_outputs("880 freq 0 gate .", 1);
assert!(outputs[0].contains("gate/0"));
}
#[test]

View File

@@ -21,10 +21,10 @@ fn get_deltas(outputs: &[String]) -> Vec<f64> {
.collect()
}
fn get_durs(outputs: &[String]) -> Vec<f64> {
fn get_gates(outputs: &[String]) -> Vec<f64> {
outputs
.iter()
.map(|o| parse_params(o).get("dur").copied().unwrap_or(0.0))
.map(|o| parse_params(o).get("gate").copied().unwrap_or(0.0))
.collect()
}
@@ -88,10 +88,10 @@ fn alternating_sounds() {
}
#[test]
fn dur_is_step_duration() {
fn gate_is_step_duration() {
let outputs = expect_outputs(r#""kick" snd ."#, 1);
let durs = get_durs(&outputs);
assert!(approx_eq(durs[0], 0.5), "dur should be 4 * step_duration (0.5), got {}", durs[0]);
let gates = get_gates(&outputs);
assert!(approx_eq(gates[0], 0.5), "gate should be 4 * step_duration (0.5), got {}", gates[0]);
}
#[test]

View File

@@ -13,7 +13,7 @@ const SOUNDS = new Set([
const PARAMS = new Set([
'freq', 'note', 'gain', 'decay', 'attack', 'release', 'lpf', 'hpf', 'bpf',
'verb', 'delay', 'pan', 'orbit', 'harmonics', 'distort', 'speed', 'voice',
'dur', 'sustain', 'delaytime', 'delayfb', 'chorus', 'phaser', 'flanger',
'dur', 'gate', 'sustain', 'delaytime', 'delayfb', 'chorus', 'phaser', 'flanger',
'crush', 'fold', 'wrap', 'resonance', 'begin', 'end', 'velocity', 'chan',
'dev', 'ccnum', 'ccout', 'bend', 'pressure', 'program', 'tilt', 'slope',
'sub_gain', 'sub_oct', 'feedback', 'depth', 'sweep', 'comb', 'damping',