Feat: demo songs
This commit is contained in:
@@ -95,6 +95,8 @@ pub enum Op {
|
||||
Ramp,
|
||||
Triangle,
|
||||
Range,
|
||||
LinMap,
|
||||
ExpMap,
|
||||
Perlin,
|
||||
Loop,
|
||||
Degree(&'static [i64]),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "=",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
8370
demos/01.cagire
Normal file
File diff suppressed because it is too large
Load Diff
8370
demos/02.cagire
Normal file
8370
demos/02.cagire
Normal file
File diff suppressed because it is too large
Load Diff
8370
demos/03.cagire
Normal file
8370
demos/03.cagire
Normal file
File diff suppressed because it is too large
Load Diff
1
demos/04.cagire
Normal file
1
demos/04.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
1
demos/05.cagire
Normal file
1
demos/05.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
1
demos/06.cagire
Normal file
1
demos/06.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
1
demos/07.cagire
Normal file
1
demos/07.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
1
demos/08.cagire
Normal file
1
demos/08.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
1
demos/09.cagire
Normal file
1
demos/09.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
1
demos/10.cagire
Normal file
1
demos/10.cagire
Normal file
@@ -0,0 +1 @@
|
||||
{"version":1,"banks":[],"tempo":120.0,"playing_patterns":[[0,0]],"prelude":""}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
40
src/init.rs
40
src/init.rs
@@ -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());
|
||||
|
||||
@@ -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
17
src/model/demos.rs
Normal 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") },
|
||||
];
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user