Before going crazy

This commit is contained in:
2026-01-28 18:05:50 +01:00
parent 4c633a895f
commit db5237480a
11 changed files with 459 additions and 299 deletions

4
.gitignore vendored
View File

@@ -2,3 +2,7 @@
Cargo.lock
*.prof
.DS_Store
# Claude
.claude/
CLAUDE.md

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,15 @@ use serde::{Deserialize, Serialize};
use crate::project::{Bank, Project};
const VERSION: u8 = 1;
pub const EXTENSION: &str = "cagire";
pub fn ensure_extension(path: &Path) -> PathBuf {
if path.extension().map(|e| e == EXTENSION).unwrap_or(false) {
path.to_path_buf()
} else {
path.with_extension(EXTENSION)
}
}
#[derive(Serialize, Deserialize)]
struct ProjectFile {
@@ -74,11 +83,12 @@ impl From<serde_json::Error> for FileError {
}
}
pub fn save(project: &Project, path: &Path) -> Result<(), FileError> {
pub fn save(project: &Project, path: &Path) -> Result<PathBuf, FileError> {
let path = ensure_extension(path);
let file = ProjectFile::from(project);
let json = serde_json::to_string_pretty(&file)?;
fs::write(path, json)?;
Ok(())
fs::write(&path, json)?;
Ok(path)
}
pub fn load(path: &Path) -> Result<Project, FileError> {

View File

@@ -216,6 +216,12 @@ pub struct Step {
pub source: Option<usize>,
}
impl Step {
pub fn is_default(&self) -> bool {
self.active && self.script.is_empty() && self.source.is_none()
}
}
impl Default for Step {
fn default() -> Self {
Self {
@@ -227,20 +233,141 @@ impl Default for Step {
}
}
#[derive(Clone, Serialize, Deserialize)]
#[derive(Clone)]
pub struct Pattern {
pub steps: Vec<Step>,
pub length: usize,
#[serde(default)]
pub speed: PatternSpeed,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub quantization: LaunchQuantization,
#[serde(default)]
pub sync_mode: SyncMode,
}
#[derive(Serialize, Deserialize)]
struct SparseStep {
i: usize,
#[serde(default = "default_active", skip_serializing_if = "is_true")]
active: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<usize>,
}
fn default_active() -> bool {
true
}
fn is_true(v: &bool) -> bool {
*v
}
#[derive(Serialize, Deserialize)]
struct SparsePattern {
steps: Vec<SparseStep>,
length: usize,
#[serde(default)]
speed: PatternSpeed,
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
#[serde(default, skip_serializing_if = "is_default_quantization")]
quantization: LaunchQuantization,
#[serde(default, skip_serializing_if = "is_default_sync_mode")]
sync_mode: SyncMode,
}
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>,
length: usize,
#[serde(default)]
speed: PatternSpeed,
#[serde(default)]
name: Option<String>,
#[serde(default)]
quantization: LaunchQuantization,
#[serde(default)]
sync_mode: SyncMode,
}
impl Serialize for Pattern {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let sparse_steps: Vec<SparseStep> = self
.steps
.iter()
.enumerate()
.filter(|(_, step)| !step.is_default())
.map(|(i, step)| SparseStep {
i,
active: step.active,
script: step.script.clone(),
source: step.source,
})
.collect();
let sparse = SparsePattern {
steps: sparse_steps,
length: self.length,
speed: self.speed,
name: self.name.clone(),
quantization: self.quantization,
sync_mode: self.sync_mode,
};
sparse.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Pattern {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum PatternFormat {
Sparse(SparsePattern),
Legacy(LegacyPattern),
}
match PatternFormat::deserialize(deserializer)? {
PatternFormat::Sparse(sparse) => {
let mut steps: Vec<Step> = (0..MAX_STEPS).map(|_| Step::default()).collect();
for ss in sparse.steps {
if ss.i < MAX_STEPS {
steps[ss.i] = Step {
active: ss.active,
script: ss.script,
command: None,
source: ss.source,
};
}
}
Ok(Pattern {
steps,
length: sparse.length,
speed: sparse.speed,
name: sparse.name,
quantization: sparse.quantization,
sync_mode: sparse.sync_mode,
})
}
PatternFormat::Legacy(legacy) => Ok(Pattern {
steps: legacy.steps,
length: legacy.length,
speed: legacy.speed,
name: legacy.name,
quantization: legacy.quantization,
sync_mode: legacy.sync_mode,
}),
}
}
}
impl Default for Pattern {
fn default() -> Self {
Self {

View File

@@ -9,7 +9,7 @@ use super::ModalFrame;
pub struct FileBrowserModal<'a> {
title: &'a str,
input: &'a str,
entries: &'a [(String, bool)],
entries: &'a [(String, bool, bool)],
selected: usize,
scroll_offset: usize,
border_color: Color,
@@ -18,7 +18,7 @@ pub struct FileBrowserModal<'a> {
}
impl<'a> FileBrowserModal<'a> {
pub fn new(title: &'a str, input: &'a str, entries: &'a [(String, bool)]) -> Self {
pub fn new(title: &'a str, input: &'a str, entries: &'a [(String, bool, bool)]) -> Self {
Self {
title,
input,
@@ -85,7 +85,7 @@ impl<'a> FileBrowserModal<'a> {
let lines: Vec<Line> = visible_entries
.enumerate()
.map(|(i, (name, is_dir))| {
.map(|(i, (name, is_dir, is_cagire))| {
let abs_idx = i + self.scroll_offset;
let is_selected = abs_idx == self.selected;
let prefix = if is_selected { "> " } else { " " };
@@ -98,6 +98,8 @@ impl<'a> FileBrowserModal<'a> {
Color::Yellow
} else if *is_dir {
Color::Blue
} else if *is_cagire {
Color::Magenta
} else {
Color::White
};

View File

@@ -495,9 +495,9 @@ impl App {
self.project_state.project.sample_paths = self.audio.config.sample_paths.clone();
self.project_state.project.tempo = link.tempo();
match model::save(&self.project_state.project, &path) {
Ok(()) => {
self.ui.set_status(format!("Saved: {}", path.display()));
self.project_state.file_path = Some(path);
Ok(final_path) => {
self.ui.set_status(format!("Saved: {}", final_path.display()));
self.project_state.file_path = Some(final_path);
}
Err(e) => {
self.ui.set_status(format!("Save error: {e}"));

View File

@@ -1,6 +1,6 @@
mod script;
pub use cagire_forth::{Word, WORDS};
pub use cagire_forth::{Word, WordCompile, WORDS};
pub use cagire_project::{
load, save, Bank, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, MAX_BANKS,
MAX_PATTERNS,

View File

@@ -14,6 +14,12 @@ pub struct DirEntry {
pub is_dir: bool,
}
impl DirEntry {
pub fn is_cagire(&self) -> bool {
!self.is_dir && self.name.ends_with(".cagire")
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct FileBrowserState {
pub mode: FileBrowserMode,

View File

@@ -109,7 +109,10 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
let query = app.ui.dict_search_query.to_lowercase();
WORDS
.iter()
.filter(|w| w.name.to_lowercase().contains(&query))
.filter(|w| {
w.name.to_lowercase().contains(&query)
|| w.aliases.iter().any(|a| a.to_lowercase().contains(&query))
})
.collect()
} else {
let category = CATEGORIES[app.ui.dict_category];
@@ -144,12 +147,25 @@ fn render_words(frame: &mut Frame, app: &App, area: Rect, is_searching: bool) {
.fg(Color::Green)
.bg(name_bg)
.add_modifier(Modifier::BOLD);
let name_line = format!(" {}", word.name);
let padding = " ".repeat(content_width.saturating_sub(name_line.chars().count()));
let alias_style = Style::new().fg(Color::DarkGray).bg(name_bg);
let name_text = if word.aliases.is_empty() {
format!(" {}", word.name)
} else {
format!(" {} ({})", word.name, word.aliases.join(", "))
};
let padding = " ".repeat(content_width.saturating_sub(name_text.chars().count()));
if word.aliases.is_empty() {
lines.push(RLine::from(Span::styled(
format!("{name_line}{padding}"),
format!("{name_text}{padding}"),
name_style,
)));
} else {
lines.push(RLine::from(vec![
Span::styled(format!(" {}", word.name), name_style),
Span::styled(format!(" ({})", word.aliases.join(", ")), alias_style),
Span::styled(padding, name_style),
]));
}
let stack_style = Style::new().fg(Color::Magenta);
lines.push(RLine::from(vec![

View File

@@ -1,6 +1,6 @@
use ratatui::style::{Color, Modifier, Style};
use crate::model::SourceSpan;
use crate::model::{SourceSpan, WordCompile, WORDS};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum TokenKind {
@@ -45,223 +45,49 @@ pub struct Token {
pub kind: TokenKind,
}
const STACK_OPS: &[&str] = &["dup", "dupn", "drop", "swap", "over", "rot", "nip", "tuck"];
const OPERATORS: &[&str] = &[
"+", "-", "*", "/", "mod", "neg", "abs", "min", "max", "=", "<>", "<", ">", "<=", ">=", "and",
"or", "not", "ceil", "floor", "round", "mtof", "ftom",
];
const KEYWORDS: &[&str] = &[
"if",
"else",
"then",
"emit",
"rand",
"rrand",
"seed",
"cycle",
"choose",
"chance",
"[",
"]",
"zoom",
"scale!",
"stack",
"echo",
"necho",
"for",
"div",
"each",
"at",
"pop",
"adsr",
"ad",
"?",
"!?",
"<<",
">>",
"|",
"@",
"!",
"pcycle",
"tempo!",
"prob",
"sometimes",
"often",
"rarely",
"almostAlways",
"almostNever",
"always",
"never",
"coin",
"fill",
"iter",
"every",
"gt",
"lt",
":",
";",
"apply",
"clear",
];
const SOUND: &[&str] = &["sound", "s"];
const CONTEXT: &[&str] = &[
"step", "beat", "bank", "pattern", "tempo", "phase", "slot", "runs", "stepdur",
];
const PARAMS: &[&str] = &[
"time",
"repeat",
"dur",
"gate",
"freq",
"detune",
"speed",
"glide",
"pw",
"spread",
"mult",
"warp",
"mirror",
"harmonics",
"timbre",
"morph",
"begin",
"end",
"gain",
"postgain",
"velocity",
"pan",
"attack",
"decay",
"sustain",
"release",
"lpf",
"lpq",
"lpe",
"lpa",
"lpd",
"lps",
"lpr",
"hpf",
"hpq",
"hpe",
"hpa",
"hpd",
"hps",
"hpr",
"bpf",
"bpq",
"bpe",
"bpa",
"bpd",
"bps",
"bpr",
"ftype",
"penv",
"patt",
"pdec",
"psus",
"prel",
"vib",
"vibmod",
"vibshape",
"fm",
"fmh",
"fmshape",
"fme",
"fma",
"fmd",
"fms",
"fmr",
"am",
"amdepth",
"amshape",
"rm",
"rmdepth",
"rmshape",
"phaser",
"phaserdepth",
"phasersweep",
"phasercenter",
"flanger",
"flangerdepth",
"flangerfeedback",
"chorus",
"chorusdepth",
"chorusdelay",
"comb",
"combfreq",
"combfeedback",
"combdamp",
"coarse",
"crush",
"fold",
"wrap",
"distort",
"distortvol",
"delay",
"delaytime",
"delayfeedback",
"delaytype",
"verb",
"verbdecay",
"verbdamp",
"verbpredelay",
"verbdiff",
"voice",
"orbit",
"note",
"size",
"n",
"cut",
"reset",
"eqlo",
"eqmid",
"eqhi",
"tilt",
"width",
"haas",
"llpf",
"llpq",
"lhpf",
"lhpq",
"lbpf",
"lbpq",
"sub",
"suboct",
"subwave",
"bank",
"loop",
];
fn lookup_word_kind(word: &str) -> Option<TokenKind> {
for w in WORDS {
if w.name == word || w.aliases.contains(&word) {
return Some(match &w.compile {
WordCompile::Param => TokenKind::Param,
WordCompile::Context(_) => TokenKind::Context,
_ => match w.category {
"Stack" => TokenKind::StackOp,
"Arithmetic" | "Comparison" | "Music" => TokenKind::Operator,
"Logic" if matches!(w.name, "and" | "or" | "not" | "xor" | "nand" | "nor") => {
TokenKind::Operator
}
"Sound" => TokenKind::Sound,
_ => TokenKind::Keyword,
},
});
}
}
None
}
fn is_note(word: &str) -> bool {
let bytes = word.as_bytes();
if bytes.len() < 2 {
return false;
}
let base = matches!(bytes[0], b'c' | b'd' | b'e' | b'f' | b'g' | b'a' | b'b');
if !base {
return false;
}
match bytes[1] {
b'#' | b's' | b'b' => bytes.len() > 2 && bytes[2..].iter().all(|b| b.is_ascii_digit()),
b'0'..=b'9' => bytes[1..].iter().all(|b| b.is_ascii_digit()),
_ => false,
}
}
const INTERVALS: &[&str] = &[
"P1", "unison", "m2", "M2", "m3", "M3", "P4", "aug4", "dim5", "tritone", "P5", "m6", "M6",
"m7", "M7", "P8", "oct", "m9", "M9", "m10", "M10", "P11", "aug11", "P12", "m13", "M13", "m14",
"M14", "P15",
];
const NOTES: &[&str] = &[
"c0", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "c9", "d0", "d1", "d2", "d3", "d4", "d5",
"d6", "d7", "d8", "d9", "e0", "e1", "e2", "e3", "e4", "e5", "e6", "e7", "e8", "e9", "f0", "f1",
"f2", "f3", "f4", "f5", "f6", "f7", "f8", "f9", "g0", "g1", "g2", "g3", "g4", "g5", "g6", "g7",
"g8", "g9", "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "b0", "b1", "b2", "b3",
"b4", "b5", "b6", "b7", "b8", "b9", "cs0", "cs1", "cs2", "cs3", "cs4", "cs5", "cs6", "cs7",
"cs8", "cs9", "ds0", "ds1", "ds2", "ds3", "ds4", "ds5", "ds6", "ds7", "ds8", "ds9", "es0",
"es1", "es2", "es3", "es4", "es5", "es6", "es7", "es8", "es9", "fs0", "fs1", "fs2", "fs3",
"fs4", "fs5", "fs6", "fs7", "fs8", "fs9", "gs0", "gs1", "gs2", "gs3", "gs4", "gs5", "gs6",
"gs7", "gs8", "gs9", "as0", "as1", "as2", "as3", "as4", "as5", "as6", "as7", "as8", "as9",
"bs0", "bs1", "bs2", "bs3", "bs4", "bs5", "bs6", "bs7", "bs8", "bs9", "cb0", "cb1", "cb2",
"cb3", "cb4", "cb5", "cb6", "cb7", "cb8", "cb9", "db0", "db1", "db2", "db3", "db4", "db5",
"db6", "db7", "db8", "db9", "eb0", "eb1", "eb2", "eb3", "eb4", "eb5", "eb6", "eb7", "eb8",
"eb9", "fb0", "fb1", "fb2", "fb3", "fb4", "fb5", "fb6", "fb7", "fb8", "fb9", "gb0", "gb1",
"gb2", "gb3", "gb4", "gb5", "gb6", "gb7", "gb8", "gb9", "ab0", "ab1", "ab2", "ab3", "ab4",
"ab5", "ab6", "ab7", "ab8", "ab9", "bb0", "bb1", "bb2", "bb3", "bb4", "bb5", "bb6", "bb7",
"bb8", "bb9", "c#0", "c#1", "c#2", "c#3", "c#4", "c#5", "c#6", "c#7", "c#8", "c#9", "d#0",
"d#1", "d#2", "d#3", "d#4", "d#5", "d#6", "d#7", "d#8", "d#9", "e#0", "e#1", "e#2", "e#3",
"e#4", "e#5", "e#6", "e#7", "e#8", "e#9", "f#0", "f#1", "f#2", "f#3", "f#4", "f#5", "f#6",
"f#7", "f#8", "f#9", "g#0", "g#1", "g#2", "g#3", "g#4", "g#5", "g#6", "g#7", "g#8", "g#9",
"a#0", "a#1", "a#2", "a#3", "a#4", "a#5", "a#6", "a#7", "a#8", "a#9", "b#0", "b#1", "b#2",
"b#3", "b#4", "b#5", "b#6", "b#7", "b#8", "b#9",
];
pub fn tokenize_line(line: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = line.char_indices().peekable();
@@ -329,36 +155,15 @@ fn classify_word(word: &str) -> TokenKind {
return TokenKind::Number;
}
if STACK_OPS.contains(&word) {
return TokenKind::StackOp;
}
if OPERATORS.contains(&word) {
return TokenKind::Operator;
}
if KEYWORDS.contains(&word) {
return TokenKind::Keyword;
}
if SOUND.contains(&word) {
return TokenKind::Sound;
}
if CONTEXT.contains(&word) {
return TokenKind::Context;
}
if PARAMS.contains(&word) {
return TokenKind::Param;
if let Some(kind) = lookup_word_kind(word) {
return kind;
}
if INTERVALS.contains(&word) {
return TokenKind::Interval;
}
let lower = word.to_ascii_lowercase();
if NOTES.contains(&lower.as_str()) {
if is_note(&word.to_ascii_lowercase()) {
return TokenKind::Note;
}

View File

@@ -431,10 +431,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
FileBrowserMode::Save => ("Save As", Color::Green),
FileBrowserMode::Load => ("Load From", Color::Blue),
};
let entries: Vec<(String, bool)> = state
let entries: Vec<(String, bool, bool)> = state
.entries
.iter()
.map(|e| (e.name.clone(), e.is_dir))
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
.collect();
FileBrowserModal::new(title, &state.input, &entries)
.selected(state.selected)
@@ -483,10 +483,10 @@ fn render_modal(frame: &mut Frame, app: &App, snapshot: &SequencerSnapshot, term
}
Modal::AddSamplePath(state) => {
use crate::widgets::FileBrowserModal;
let entries: Vec<(String, bool)> = state
let entries: Vec<(String, bool, bool)> = state
.entries
.iter()
.map(|e| (e.name.clone(), e.is_dir))
.map(|e| (e.name.clone(), e.is_dir, e.is_cagire()))
.collect();
FileBrowserModal::new("Add Sample Path", &state.input, &entries)
.selected(state.selected)