Feat: demo songs

This commit is contained in:
2026-02-22 23:50:35 +01:00
parent 81f475a75b
commit f47285385c
27 changed files with 25324 additions and 38 deletions

View File

@@ -95,6 +95,8 @@ pub enum Op {
Ramp, Ramp,
Triangle, Triangle,
Range, Range,
LinMap,
ExpMap,
Perlin, Perlin,
Loop, Loop,
Degree(&'static [i64]), Degree(&'static [i64]),

View File

@@ -1083,6 +1083,28 @@ impl Forth {
let val = pop_float(stack)?; let val = pop_float(stack)?;
stack.push(Value::Float(min + val * (max - min), None)); stack.push(Value::Float(min + val * (max - min), None));
} }
Op::LinMap => {
let out_hi = pop_float(stack)?;
let out_lo = pop_float(stack)?;
let in_hi = pop_float(stack)?;
let in_lo = pop_float(stack)?;
let val = pop_float(stack)?;
let t = if (in_hi - in_lo).abs() < f64::EPSILON {
0.0
} else {
(val - in_lo) / (in_hi - in_lo)
};
stack.push(Value::Float(out_lo + t * (out_hi - out_lo), None));
}
Op::ExpMap => {
let hi = pop_float(stack)?;
let lo = pop_float(stack)?;
let val = pop_float(stack)?;
if lo <= 0.0 || hi <= 0.0 {
return Err("expmap requires positive bounds".into());
}
stack.push(Value::Float(lo * (hi / lo).powf(val), None));
}
Op::Perlin => { Op::Perlin => {
let freq = pop_float(stack)?; let freq = pop_float(stack)?;
let val = perlin_noise_1d(freq * ctx.beat); let val = perlin_noise_1d(freq * ctx.beat);

View File

@@ -88,6 +88,8 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"ramp" => Op::Ramp, "ramp" => Op::Ramp,
"triangle" => Op::Triangle, "triangle" => Op::Triangle,
"range" => Op::Range, "range" => Op::Range,
"linmap" => Op::LinMap,
"expmap" => Op::ExpMap,
"perlin" => Op::Perlin, "perlin" => Op::Perlin,
"loop" => Op::Loop, "loop" => Op::Loop,
"oct" => Op::Oct, "oct" => Op::Oct,

View File

@@ -354,6 +354,26 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple, compile: Simple,
varargs: false, varargs: false,
}, },
Word {
name: "linmap",
aliases: &[],
category: "Arithmetic",
stack: "(val inlo inhi outlo outhi -- mapped)",
desc: "Linear map from [inlo,inhi] to [outlo,outhi]",
example: "64 0 127 200 2000 linmap => 1007.87",
compile: Simple,
varargs: false,
},
Word {
name: "expmap",
aliases: &[],
category: "Arithmetic",
stack: "(val lo hi -- mapped)",
desc: "Exponential map from [0,1] to [lo,hi]",
example: "0.5 200 8000 expmap => 1264.91",
compile: Simple,
varargs: false,
},
// Comparison // Comparison
Word { Word {
name: "=", name: "=",

View File

@@ -101,7 +101,11 @@ pub fn save(project: &Project, path: &Path) -> Result<PathBuf, FileError> {
pub fn load(path: &Path) -> Result<Project, FileError> { pub fn load(path: &Path) -> Result<Project, FileError> {
let json = fs::read_to_string(path)?; let json = fs::read_to_string(path)?;
let file: ProjectFile = serde_json::from_str(&json)?; load_str(&json)
}
pub fn load_str(json: &str) -> Result<Project, FileError> {
let file: ProjectFile = serde_json::from_str(json)?;
if file.version > VERSION { if file.version > VERSION {
return Err(FileError::Version(file.version)); return Err(FileError::Version(file.version));
} }

View File

@@ -8,5 +8,5 @@ pub const MAX_PATTERNS: usize = 32;
pub const MAX_STEPS: usize = 1024; pub const MAX_STEPS: usize = 1024;
pub const DEFAULT_LENGTH: usize = 16; pub const DEFAULT_LENGTH: usize = 16;
pub use file::{load, save, FileError}; 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, SyncMode};

8370
demos/01.cagire Normal file

File diff suppressed because it is too large Load Diff

8370
demos/02.cagire Normal file

File diff suppressed because it is too large Load Diff

8370
demos/03.cagire Normal file

File diff suppressed because it is too large Load Diff

1
demos/04.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/05.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/06.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/07.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/08.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/09.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

1
demos/10.cagire Normal file
View File

@@ -0,0 +1 @@
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}

View File

@@ -31,6 +31,8 @@ impl App {
layout: self.audio.config.layout, layout: self.audio.config.layout,
hue_rotation: self.ui.hue_rotation, hue_rotation: self.ui.hue_rotation,
onboarding_dismissed: self.ui.onboarding_dismissed.clone(), onboarding_dismissed: self.ui.onboarding_dismissed.clone(),
load_demo_on_startup: self.ui.load_demo_on_startup,
demo_index: self.ui.demo_index,
font: self.ui.font.clone(), font: self.ui.font.clone(),
zoom_factor: self.ui.zoom_factor, zoom_factor: self.ui.zoom_factor,
}, },
@@ -91,6 +93,16 @@ impl App {
pub fn load(&mut self, path: PathBuf, link: &LinkState) { pub fn load(&mut self, path: PathBuf, link: &LinkState) {
match model::load(&path) { match model::load(&path) {
Ok(project) => { Ok(project) => {
self.apply_project(project, format!("Loaded: {}", path.display()), link);
self.project_state.file_path = Some(path);
}
Err(e) => {
self.ui.set_status(format!("Load error: {e}"));
}
}
}
fn apply_project(&mut self, project: model::Project, label: String, link: &LinkState) {
let tempo = project.tempo; let tempo = project.tempo;
let playing = project.playing_patterns.clone(); let playing = project.playing_patterns.clone();
@@ -116,12 +128,6 @@ impl App {
}); });
} }
self.ui.set_status(format!("Loaded: {}", path.display())); self.ui.set_status(label);
self.project_state.file_path = Some(path);
}
Err(e) => {
self.ui.set_status(format!("Load error: {e}"));
}
}
} }
} }

View File

@@ -65,6 +65,29 @@ pub fn init(args: InitArgs) -> Init {
let mut app = App::new(); let mut app = App::new();
app.ui.load_demo_on_startup = settings.display.load_demo_on_startup;
app.ui.demo_index = settings.display.demo_index;
if app.ui.load_demo_on_startup && !model::demos::DEMOS.is_empty() {
let count = model::demos::DEMOS.len();
let index = settings.display.demo_index % count;
let demo = &model::demos::DEMOS[index];
if let Ok(project) = model::load_str(demo.json) {
let playing = project.playing_patterns.clone();
let tempo = project.tempo;
app.project_state.project = project;
link.set_tempo(tempo);
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,
});
}
app.ui.set_status(format!("Demo: {}", demo.name));
}
app.ui.demo_index = (index + 1) % count;
} else {
app.playback.queued_changes.push(StagedChange { app.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { change: PatternChange::Start {
bank: 0, bank: 0,
@@ -73,6 +96,7 @@ pub fn init(args: InitArgs) -> Init {
quantization: model::LaunchQuantization::Immediate, quantization: model::LaunchQuantization::Immediate,
sync_mode: model::SyncMode::PhaseLock, sync_mode: model::SyncMode::PhaseLock,
}); });
}
app.audio.config.output_device = args.output.or(settings.audio.output_device.clone()); app.audio.config.output_device = args.output.or(settings.audio.output_device.clone());
app.audio.config.input_device = args.input.or(settings.audio.input_device.clone()); app.audio.config.input_device = args.input.or(settings.audio.input_device.clone());

View File

@@ -141,6 +141,9 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
OptionsFocus::ResetOnboarding => { OptionsFocus::ResetOnboarding => {
ctx.dispatch(AppCommand::ResetOnboarding); ctx.dispatch(AppCommand::ResetOnboarding);
} }
OptionsFocus::LoadDemoOnStartup => {
ctx.app.ui.load_demo_on_startup = !ctx.app.ui.load_demo_on_startup;
}
OptionsFocus::MidiInput0 OptionsFocus::MidiInput0
| OptionsFocus::MidiInput1 | OptionsFocus::MidiInput1
| OptionsFocus::MidiInput2 | OptionsFocus::MidiInput2

17
src/model/demos.rs Normal file
View File

@@ -0,0 +1,17 @@
pub struct DemoEntry {
pub name: &'static str,
pub json: &'static str,
}
pub const DEMOS: &[DemoEntry] = &[
DemoEntry { name: "Demo 01", json: include_str!("../../demos/01.cagire") },
DemoEntry { name: "Demo 02", json: include_str!("../../demos/02.cagire") },
DemoEntry { name: "Demo 03", json: include_str!("../../demos/03.cagire") },
DemoEntry { name: "Demo 04", json: include_str!("../../demos/04.cagire") },
DemoEntry { name: "Demo 05", json: include_str!("../../demos/05.cagire") },
DemoEntry { name: "Demo 06", json: include_str!("../../demos/06.cagire") },
DemoEntry { name: "Demo 07", json: include_str!("../../demos/07.cagire") },
DemoEntry { name: "Demo 08", json: include_str!("../../demos/08.cagire") },
DemoEntry { name: "Demo 09", json: include_str!("../../demos/09.cagire") },
DemoEntry { name: "Demo 10", json: include_str!("../../demos/10.cagire") },
];

View File

@@ -1,4 +1,5 @@
pub mod categories; pub mod categories;
pub mod demos;
pub mod docs; pub mod docs;
pub mod onboarding; pub mod onboarding;
mod script; mod script;
@@ -8,8 +9,8 @@ pub use cagire_forth::{
Variables, Word, WordCompile, WORDS, Variables, Word, WordCompile, WORDS,
}; };
pub use cagire_project::{ pub use cagire_project::{
load, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode, load, load_str, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project,
MAX_BANKS, MAX_PATTERNS, SyncMode, MAX_BANKS, MAX_PATTERNS,
}; };
pub use script::ScriptEngine; pub use script::ScriptEngine;

View File

@@ -58,6 +58,10 @@ pub struct DisplaySettings {
pub hue_rotation: f32, pub hue_rotation: f32,
#[serde(default)] #[serde(default)]
pub onboarding_dismissed: Vec<String>, pub onboarding_dismissed: Vec<String>,
#[serde(default = "default_true")]
pub load_demo_on_startup: bool,
#[serde(default)]
pub demo_index: usize,
} }
fn default_font() -> String { fn default_font() -> String {
@@ -105,6 +109,8 @@ impl Default for DisplaySettings {
layout: MainLayout::default(), layout: MainLayout::default(),
hue_rotation: 0.0, hue_rotation: 0.0,
onboarding_dismissed: Vec::new(), onboarding_dismissed: Vec::new(),
load_demo_on_startup: true,
demo_index: 0,
} }
} }
} }

View File

@@ -27,6 +27,7 @@ pub enum OptionsFocus {
MidiInput2, MidiInput2,
MidiInput3, MidiInput3,
ResetOnboarding, ResetOnboarding,
LoadDemoOnStartup,
} }
impl CyclicEnum for OptionsFocus { impl CyclicEnum for OptionsFocus {
@@ -55,6 +56,7 @@ impl CyclicEnum for OptionsFocus {
Self::MidiInput2, Self::MidiInput2,
Self::MidiInput3, Self::MidiInput3,
Self::ResetOnboarding, Self::ResetOnboarding,
Self::LoadDemoOnStartup,
]; ];
} }
@@ -77,6 +79,7 @@ const STANDALONE_ONLY: &[OptionsFocus] = &[
OptionsFocus::MidiInput2, OptionsFocus::MidiInput2,
OptionsFocus::MidiInput3, OptionsFocus::MidiInput3,
OptionsFocus::ResetOnboarding, OptionsFocus::ResetOnboarding,
OptionsFocus::LoadDemoOnStartup,
]; ];
/// Section layout: header line, divider line, then option lines. /// Section layout: header line, divider line, then option lines.
@@ -113,6 +116,7 @@ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[
(OptionsFocus::MidiInput3, 39), (OptionsFocus::MidiInput3, 39),
// blank=40, ONBOARDING header=41, divider=42 // blank=40, ONBOARDING header=41, divider=42
(OptionsFocus::ResetOnboarding, 43), (OptionsFocus::ResetOnboarding, 43),
(OptionsFocus::LoadDemoOnStartup, 44),
]; ];
impl OptionsFocus { impl OptionsFocus {

View File

@@ -79,6 +79,8 @@ pub struct UiState {
pub zoom_factor: f32, pub zoom_factor: f32,
pub window_width: u32, pub window_width: u32,
pub window_height: u32, pub window_height: u32,
pub load_demo_on_startup: bool,
pub demo_index: usize,
} }
impl Default for UiState { impl Default for UiState {
@@ -127,6 +129,8 @@ impl Default for UiState {
zoom_factor: 1.5, zoom_factor: 1.5,
window_width: 1200, window_width: 1200,
window_height: 800, window_height: 800,
load_demo_on_startup: true,
demo_index: 0,
} }
} }
} }

View File

@@ -249,6 +249,12 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
focus == OptionsFocus::ResetOnboarding, focus == OptionsFocus::ResetOnboarding,
&theme, &theme,
), ),
render_option_line(
"Demo on startup",
if app.ui.load_demo_on_startup { "On" } else { "Off" },
focus == OptionsFocus::LoadDemoOnStartup,
&theme,
),
]); ]);
} }
@@ -358,6 +364,7 @@ fn option_description(focus: OptionsFocus) -> Option<&'static str> {
OptionsFocus::MidiInput2 => Some("MIDI input device for channel group 3"), OptionsFocus::MidiInput2 => Some("MIDI input device for channel group 3"),
OptionsFocus::MidiInput3 => Some("MIDI input device for channel group 4"), OptionsFocus::MidiInput3 => Some("MIDI input device for channel group 4"),
OptionsFocus::ResetOnboarding => Some("Re-enable all dismissed guide popups"), OptionsFocus::ResetOnboarding => Some("Re-enable all dismissed guide popups"),
OptionsFocus::LoadDemoOnStartup => Some("Load a rotating demo song on fresh startup"),
} }
} }

View File

@@ -559,6 +559,9 @@ fn render_mini_tile_grid(
let row_gap: u16 = 1; let row_gap: u16 = 1;
let max_tile_height: u16 = 4; let max_tile_height: u16 = 4;
let max_rows = (area.height as usize + row_gap as usize) / (1 + row_gap as usize);
let num_rows = num_rows.min(max_rows.max(1));
let available_for_rows = let available_for_rows =
area.height.saturating_sub((num_rows.saturating_sub(1) as u16) * row_gap); area.height.saturating_sub((num_rows.saturating_sub(1) as u16) * row_gap);
let tile_height = (available_for_rows / num_rows as u16).min(max_tile_height).max(1); let tile_height = (available_for_rows / num_rows as u16).min(max_tile_height).max(1);

View File

@@ -210,3 +210,47 @@ fn shorthand_float() {
fn shorthand_float_negative() { fn shorthand_float_negative() {
expect_float("-.5 1 +", 0.5); expect_float("-.5 1 +", 0.5);
} }
// linmap
#[test]
fn linmap_basic() {
expect_float("64 0 127 200 2000 linmap", 200.0 + (64.0 / 127.0) * 1800.0);
}
#[test]
fn linmap_at_bounds() {
expect_float("0 0 127 200 2000 linmap", 200.0);
}
#[test]
fn linmap_at_upper() {
expect_float("127 0 127 200 2000 linmap", 2000.0);
}
#[test]
fn linmap_equal_input_range() {
expect_float("5 5 5 100 200 linmap", 100.0);
}
// expmap
#[test]
fn expmap_at_zero() {
expect_float("0 200 8000 expmap", 200.0);
}
#[test]
fn expmap_at_one() {
expect_float("1 200 8000 expmap", 8000.0);
}
#[test]
fn expmap_midpoint() {
expect_float("0.5 100 10000 expmap", 1000.0);
}
#[test]
fn expmap_negative_bound() {
expect_error("0.5 -100 8000 expmap", "expmap requires positive bounds");
}