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,
Triangle,
Range,
LinMap,
ExpMap,
Perlin,
Loop,
Degree(&'static [i64]),

View File

@@ -1083,6 +1083,28 @@ impl Forth {
let val = pop_float(stack)?;
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 => {
let freq = pop_float(stack)?;
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,
"triangle" => Op::Triangle,
"range" => Op::Range,
"linmap" => Op::LinMap,
"expmap" => Op::ExpMap,
"perlin" => Op::Perlin,
"loop" => Op::Loop,
"oct" => Op::Oct,

View File

@@ -354,6 +354,26 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple,
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
Word {
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> {
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 {
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 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};

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,
hue_rotation: self.ui.hue_rotation,
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(),
zoom_factor: self.ui.zoom_factor,
},
@@ -91,32 +93,7 @@ impl App {
pub fn load(&mut self, path: PathBuf, link: &LinkState) {
match model::load(&path) {
Ok(project) => {
let tempo = project.tempo;
let playing = project.playing_patterns.clone();
self.project_state.project = project;
self.editor_ctx.step = 0;
self.load_step_to_editor();
self.compile_all_steps(link);
self.mark_all_patterns_dirty();
link.set_tempo(tempo);
self.playback.clear_queues();
self.undo.clear();
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
self.evaluate_prelude(link);
for (bank, pattern) in playing {
self.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: crate::model::LaunchQuantization::Immediate,
sync_mode: crate::model::SyncMode::PhaseLock,
});
}
self.ui.set_status(format!("Loaded: {}", path.display()));
self.apply_project(project, format!("Loaded: {}", path.display()), link);
self.project_state.file_path = Some(path);
}
Err(e) => {
@@ -124,4 +101,33 @@ impl App {
}
}
}
fn apply_project(&mut self, project: model::Project, label: String, link: &LinkState) {
let tempo = project.tempo;
let playing = project.playing_patterns.clone();
self.project_state.project = project;
self.editor_ctx.step = 0;
self.load_step_to_editor();
self.compile_all_steps(link);
self.mark_all_patterns_dirty();
link.set_tempo(tempo);
self.playback.clear_queues();
self.undo.clear();
self.variables.store(Arc::new(HashMap::new()));
self.dict.lock().clear();
self.evaluate_prelude(link);
for (bank, pattern) in playing {
self.playback.queued_changes.push(StagedChange {
change: PatternChange::Start { bank, pattern },
quantization: crate::model::LaunchQuantization::Immediate,
sync_mode: crate::model::SyncMode::PhaseLock,
});
}
self.ui.set_status(label);
}
}

View File

@@ -65,14 +65,38 @@ pub fn init(args: InitArgs) -> Init {
let mut app = App::new();
app.playback.queued_changes.push(StagedChange {
change: PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: model::LaunchQuantization::Immediate,
sync_mode: model::SyncMode::PhaseLock,
});
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 {
change: PatternChange::Start {
bank: 0,
pattern: 0,
},
quantization: model::LaunchQuantization::Immediate,
sync_mode: model::SyncMode::PhaseLock,
});
}
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());

View File

@@ -141,6 +141,9 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
OptionsFocus::ResetOnboarding => {
ctx.dispatch(AppCommand::ResetOnboarding);
}
OptionsFocus::LoadDemoOnStartup => {
ctx.app.ui.load_demo_on_startup = !ctx.app.ui.load_demo_on_startup;
}
OptionsFocus::MidiInput0
| OptionsFocus::MidiInput1
| 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 demos;
pub mod docs;
pub mod onboarding;
mod script;
@@ -8,8 +9,8 @@ pub use cagire_forth::{
Variables, Word, WordCompile, WORDS,
};
pub use cagire_project::{
load, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project, SyncMode,
MAX_BANKS, MAX_PATTERNS,
load, load_str, save, Bank, FollowUp, LaunchQuantization, Pattern, PatternSpeed, Project,
SyncMode, MAX_BANKS, MAX_PATTERNS,
};
pub use script::ScriptEngine;

View File

@@ -58,6 +58,10 @@ pub struct DisplaySettings {
pub hue_rotation: f32,
#[serde(default)]
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 {
@@ -105,6 +109,8 @@ impl Default for DisplaySettings {
layout: MainLayout::default(),
hue_rotation: 0.0,
onboarding_dismissed: Vec::new(),
load_demo_on_startup: true,
demo_index: 0,
}
}
}

View File

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

View File

@@ -79,6 +79,8 @@ pub struct UiState {
pub zoom_factor: f32,
pub window_width: u32,
pub window_height: u32,
pub load_demo_on_startup: bool,
pub demo_index: usize,
}
impl Default for UiState {
@@ -127,6 +129,8 @@ impl Default for UiState {
zoom_factor: 1.5,
window_width: 1200,
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,
&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::MidiInput3 => Some("MIDI input device for channel group 4"),
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 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 =
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);

View File

@@ -210,3 +210,47 @@ fn shorthand_float() {
fn shorthand_float_negative() {
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");
}