diff --git a/crates/forth/src/ops.rs b/crates/forth/src/ops.rs index 668512d..45cfe29 100644 --- a/crates/forth/src/ops.rs +++ b/crates/forth/src/ops.rs @@ -76,6 +76,7 @@ pub enum Op { PCycle(Option), Choose(Option), Bounce(Option), + PBounce(Option), WChoose(Option), ChanceExec(Option), ProbExec(Option), @@ -84,6 +85,9 @@ pub enum Op { Ftom, SetTempo, Every(Option), + Except(Option), + EveryOffset(Option), + ExceptOffset(Option), Bjork(Option), PBjork(Option), Quotation(Arc<[Op]>, Option), diff --git a/crates/forth/src/vm.rs b/crates/forth/src/vm.rs index 8acf7b4..aed9264 100644 --- a/crates/forth/src/vm.rs +++ b/crates/forth/src/vm.rs @@ -799,16 +799,20 @@ impl Forth { drain_select_run(count, idx, stack, outputs, cmd)?; } - Op::Bounce(word_span) => { + Op::Bounce(word_span) | Op::PBounce(word_span) => { let count = pop_int(stack)? as usize; if count == 0 { return Err("bounce count must be > 0".into()); } + let counter = match &ops[pc] { + Op::Bounce(_) => ctx.runs, + _ => ctx.iter, + }; let idx = if count == 1 { 0 } else { let period = 2 * (count - 1); - let raw = ctx.runs % period; + let raw = counter % period; if raw < count { raw } else { period - raw } }; if let Some(span) = word_span { @@ -895,6 +899,47 @@ impl Forth { } } + Op::Except(word_span) => { + let n = pop_int(stack)?; + let quot = pop(stack)?; + if n <= 0 { + return Err("except count must be > 0".into()); + } + let result = ctx.iter as i64 % n != 0; + record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result)); + if result { + run_quotation(quot, stack, outputs, cmd)?; + } + } + + Op::EveryOffset(word_span) => { + let offset = pop_int(stack)?; + let n = pop_int(stack)?; + let quot = pop(stack)?; + if n <= 0 { + return Err("every+ count must be > 0".into()); + } + let result = ctx.iter as i64 % n == offset.rem_euclid(n); + record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result)); + if result { + run_quotation(quot, stack, outputs, cmd)?; + } + } + + Op::ExceptOffset(word_span) => { + let offset = pop_int(stack)?; + let n = pop_int(stack)?; + let quot = pop(stack)?; + if n <= 0 { + return Err("except+ count must be > 0".into()); + } + let result = ctx.iter as i64 % n != offset.rem_euclid(n); + record_resolved(&trace_cell, *word_span, ResolvedValue::Bool(result)); + if result { + run_quotation(quot, stack, outputs, cmd)?; + } + } + Op::Bjork(word_span) | Op::PBjork(word_span) => { let n = pop_int(stack)?; let k = pop_int(stack)?; diff --git a/crates/forth/src/words/compile.rs b/crates/forth/src/words/compile.rs index 98b88b7..7819a5a 100644 --- a/crates/forth/src/words/compile.rs +++ b/crates/forth/src/words/compile.rs @@ -67,8 +67,12 @@ pub(super) fn simple_op(name: &str) -> Option { "pcycle" => Op::PCycle(None), "choose" => Op::Choose(None), "bounce" => Op::Bounce(None), + "pbounce" => Op::PBounce(None), "wchoose" => Op::WChoose(None), "every" => Op::Every(None), + "except" => Op::Except(None), + "every+" => Op::EveryOffset(None), + "except+" => Op::ExceptOffset(None), "bjork" => Op::Bjork(None), "pbjork" => Op::PBjork(None), "chance" => Op::ChanceExec(None), @@ -204,8 +208,8 @@ fn attach_span(op: &mut Op, span: SourceSpan) { match op { Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s) | Op::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s) - | Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) - | Op::Every(s) + | Op::Bounce(s) | Op::PBounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) + | Op::Every(s) | Op::Except(s) | Op::EveryOffset(s) | Op::ExceptOffset(s) | Op::Bjork(s) | Op::PBjork(s) | Op::Count(s) | Op::Index(s) => *s = Some(span), _ => {} diff --git a/crates/forth/src/words/effects.rs b/crates/forth/src/words/effects.rs index 5de0d00..99802a4 100644 --- a/crates/forth/src/words/effects.rs +++ b/crates/forth/src/words/effects.rs @@ -959,4 +959,45 @@ pub(super) const WORDS: &[Word] = &[ compile: Param, varargs: true, }, + // Compressor + Word { + name: "comp", + aliases: &[], + category: "Compressor", + stack: "(v.. --)", + desc: "Set sidechain duck amount (0-1)", + example: "0.8 comp", + compile: Param, + varargs: true, + }, + Word { + name: "compattack", + aliases: &["cattack"], + category: "Compressor", + stack: "(v.. --)", + desc: "Set compressor attack time in seconds", + example: "0.01 compattack", + compile: Param, + varargs: true, + }, + Word { + name: "comprelease", + aliases: &["crelease"], + category: "Compressor", + stack: "(v.. --)", + desc: "Set compressor release time in seconds", + example: "0.15 comprelease", + compile: Param, + varargs: true, + }, + Word { + name: "comporbit", + aliases: &["corbit"], + category: "Compressor", + stack: "(v.. --)", + desc: "Set sidechain source orbit", + example: "0 comporbit", + compile: Param, + varargs: true, + }, ]; diff --git a/crates/forth/src/words/sequencing.rs b/crates/forth/src/words/sequencing.rs index 6c3325a..584e48b 100644 --- a/crates/forth/src/words/sequencing.rs +++ b/crates/forth/src/words/sequencing.rs @@ -113,6 +113,16 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: true, }, + Word { + name: "pbounce", + aliases: &[], + category: "Probability", + stack: "(v1..vn n -- selected)", + desc: "Ping-pong cycle through n items by pattern iteration", + example: "60 64 67 72 4 pbounce", + compile: Simple, + varargs: true, + }, Word { name: "index", aliases: &[], @@ -214,6 +224,36 @@ pub(super) const WORDS: &[Word] = &[ compile: Simple, varargs: false, }, + Word { + name: "except", + aliases: &[], + category: "Time", + stack: "(quot n --)", + desc: "Execute quotation on all iterations except every nth", + example: "{ 2 distort } 4 except", + compile: Simple, + varargs: false, + }, + Word { + name: "every+", + aliases: &[], + category: "Time", + stack: "(quot n offset --)", + desc: "Execute quotation every nth iteration with phase offset", + example: "{ snare } 4 2 every+ => fires at iter 2, 6, 10...", + compile: Simple, + varargs: false, + }, + Word { + name: "except+", + aliases: &[], + category: "Time", + stack: "(quot n offset --)", + desc: "Skip quotation every nth iteration with phase offset", + example: "{ snare } 4 2 except+ => skips at iter 2, 6, 10...", + compile: Simple, + varargs: false, + }, Word { name: "bjork", aliases: &[], diff --git a/crates/ratatui/src/lissajous.rs b/crates/ratatui/src/lissajous.rs index dd1b906..5a9885d 100644 --- a/crates/ratatui/src/lissajous.rs +++ b/crates/ratatui/src/lissajous.rs @@ -13,6 +13,7 @@ pub struct Lissajous<'a> { left: &'a [f32], right: &'a [f32], color: Option, + gain: f32, } impl<'a> Lissajous<'a> { @@ -21,6 +22,7 @@ impl<'a> Lissajous<'a> { left, right, color: None, + gain: 1.0, } } @@ -28,6 +30,11 @@ impl<'a> Lissajous<'a> { self.color = Some(c); self } + + pub fn gain(mut self, g: f32) -> Self { + self.gain = g; + self + } } impl Widget for Lissajous<'_> { @@ -43,14 +50,6 @@ impl Widget for Lissajous<'_> { let fine_height = height * 4; let len = self.left.len().min(self.right.len()); - let peak = self - .left - .iter() - .chain(self.right.iter()) - .map(|s| s.abs()) - .fold(0.0f32, f32::max); - let gain = if peak > 0.001 { 1.0 / peak } else { 1.0 }; - PATTERNS.with(|p| { let mut patterns = p.borrow_mut(); let size = width * height; @@ -58,8 +57,8 @@ impl Widget for Lissajous<'_> { patterns.resize(size, 0); for i in 0..len { - let l = (self.left[i] * gain).clamp(-1.0, 1.0); - let r = (self.right[i] * gain).clamp(-1.0, 1.0); + let l = (self.left[i] * self.gain).clamp(-1.0, 1.0); + let r = (self.right[i] * self.gain).clamp(-1.0, 1.0); // X = right channel, Y = left channel (inverted so up = positive) let fine_x = ((r + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize; diff --git a/crates/ratatui/src/scope.rs b/crates/ratatui/src/scope.rs index c93e349..ce27abd 100644 --- a/crates/ratatui/src/scope.rs +++ b/crates/ratatui/src/scope.rs @@ -41,6 +41,11 @@ impl<'a> Scope<'a> { self.color = Some(c); self } + + pub fn gain(mut self, g: f32) -> Self { + self.gain = g; + self + } } impl Widget for Scope<'_> { @@ -66,9 +71,6 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g let fine_width = width * 2; let fine_height = height * 4; - let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max); - let auto_gain = if peak > 0.001 { gain / peak } else { gain }; - PATTERNS.with(|p| { let mut patterns = p.borrow_mut(); let size = width * height; @@ -77,7 +79,7 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g for fine_x in 0..fine_width { let sample_idx = (fine_x * data.len()) / fine_width; - let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * auto_gain).clamp(-1.0, 1.0); + let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); let fine_y = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize; let fine_y = fine_y.min(fine_height - 1); @@ -122,9 +124,6 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai let fine_width = width * 2; let fine_height = height * 4; - let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max); - let auto_gain = if peak > 0.001 { gain / peak } else { gain }; - PATTERNS.with(|p| { let mut patterns = p.borrow_mut(); let size = width * height; @@ -133,7 +132,7 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai for fine_y in 0..fine_height { let sample_idx = (fine_y * data.len()) / fine_height; - let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * auto_gain).clamp(-1.0, 1.0); + let sample = (data.get(sample_idx).copied().unwrap_or(0.0) * gain).clamp(-1.0, 1.0); let fine_x = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize; let fine_x = fine_x.min(fine_width - 1); diff --git a/crates/ratatui/src/spectrum.rs b/crates/ratatui/src/spectrum.rs index c066400..dea5ff8 100644 --- a/crates/ratatui/src/spectrum.rs +++ b/crates/ratatui/src/spectrum.rs @@ -8,11 +8,17 @@ const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2 pub struct Spectrum<'a> { data: &'a [f32; 32], + gain: f32, } impl<'a> Spectrum<'a> { pub fn new(data: &'a [f32; 32]) -> Self { - Self { data } + Self { data, gain: 1.0 } + } + + pub fn gain(mut self, g: f32) -> Self { + self.gain = g; + self } } @@ -36,7 +42,7 @@ impl Widget for Spectrum<'_> { if w == 0 { continue; } - let bar_height = mag * height; + let bar_height = (mag * self.gain).min(1.0) * height; let full_cells = bar_height as usize; let frac = bar_height - full_cells as f32; let frac_idx = (frac * 8.0) as usize; diff --git a/crates/ratatui/src/waveform.rs b/crates/ratatui/src/waveform.rs index ec8bd9a..943711d 100644 --- a/crates/ratatui/src/waveform.rs +++ b/crates/ratatui/src/waveform.rs @@ -81,9 +81,6 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g let fine_height = height * 4; let len = data.len(); - let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max); - let auto_gain = if peak > 0.001 { gain / peak } else { gain }; - PATTERNS.with(|p| { let mut patterns = p.borrow_mut(); patterns.clear(); @@ -97,7 +94,7 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g let mut min_s = f32::MAX; let mut max_s = f32::MIN; for &s in slice { - let s = (s * auto_gain).clamp(-1.0, 1.0); + let s = (s * gain).clamp(-1.0, 1.0); if s < min_s { min_s = s; } @@ -142,9 +139,6 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai let fine_height = height * 4; let len = data.len(); - let peak = data.iter().map(|s| s.abs()).fold(0.0f32, f32::max); - let auto_gain = if peak > 0.001 { gain / peak } else { gain }; - PATTERNS.with(|p| { let mut patterns = p.borrow_mut(); patterns.clear(); @@ -158,7 +152,7 @@ fn render_vertical(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, gai let mut min_s = f32::MAX; let mut max_s = f32::MIN; for &s in slice { - let s = (s * auto_gain).clamp(-1.0, 1.0); + let s = (s * gain).clamp(-1.0, 1.0); if s < min_s { min_s = s; } diff --git a/docs/tutorials/randomness.md b/docs/tutorials/randomness.md index e1bcbcb..1a4f600 100644 --- a/docs/tutorials/randomness.md +++ b/docs/tutorials/randomness.md @@ -133,6 +133,12 @@ sine s . { crash s . } 4 every ;; crash cymbal every 4th iteration ``` +`except` is the inverse -- it runs a quotation on all iterations *except* every nth: + +```forth +{ 2 distort } 4 except ;; distort on all iterations except every 4th +``` + `bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits across n positions as evenly as possible. Classic Euclidean rhythms: ```forth diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 04329a9..aaac8ad 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -391,6 +391,8 @@ impl App { AppCommand::ToggleSpectrum => self.audio.config.show_spectrum = !self.audio.config.show_spectrum, AppCommand::ToggleLissajous => self.audio.config.show_lissajous = !self.audio.config.show_lissajous, AppCommand::TogglePreview => self.audio.config.show_preview = !self.audio.config.show_preview, + AppCommand::SetGainBoost(g) => self.audio.config.gain_boost = g, + AppCommand::ToggleNormalizeViz => self.audio.config.normalize_viz = !self.audio.config.normalize_viz, AppCommand::TogglePerformanceMode => self.ui.performance_mode = !self.ui.performance_mode, // Metrics diff --git a/src/app/persistence.rs b/src/app/persistence.rs index a74540a..41d1f39 100644 --- a/src/app/persistence.rs +++ b/src/app/persistence.rs @@ -36,6 +36,8 @@ impl App { demo_index: self.ui.demo_index, font: self.ui.font.clone(), zoom_factor: self.ui.zoom_factor, + gain_boost: self.audio.config.gain_boost, + normalize_viz: self.audio.config.normalize_viz, }, link: crate::settings::LinkSettings { enabled: link.is_enabled(), diff --git a/src/commands.rs b/src/commands.rs index 0493508..58300e5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -255,6 +255,8 @@ pub enum AppCommand { ToggleSpectrum, ToggleLissajous, TogglePreview, + SetGainBoost(f32), + ToggleNormalizeViz, TogglePerformanceMode, // Metrics diff --git a/src/init.rs b/src/init.rs index f62c36e..0fab46b 100644 --- a/src/init.rs +++ b/src/init.rs @@ -111,6 +111,8 @@ pub fn init(args: InitArgs) -> Init { app.audio.config.show_spectrum = settings.display.show_spectrum; app.audio.config.show_lissajous = settings.display.show_lissajous; app.audio.config.show_preview = settings.display.show_preview; + app.audio.config.gain_boost = settings.display.gain_boost; + app.audio.config.normalize_viz = settings.display.normalize_viz; app.ui.show_completion = settings.display.show_completion; app.ui.performance_mode = settings.display.performance_mode; app.ui.color_scheme = settings.display.color_scheme; diff --git a/src/input/main_page.rs b/src/input/main_page.rs index 45bfe22..bd94d75 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -112,8 +112,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); } KeyCode::Char(':') => { - let current = (ctx.app.editor_ctx.step + 1).to_string(); - ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(current))); + ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(String::new()))); } KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), diff --git a/src/input/options_page.rs b/src/input/options_page.rs index 50f0e2b..01cc145 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -25,6 +25,17 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) { OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope), OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum), OptionsFocus::ShowLissajous => ctx.dispatch(AppCommand::ToggleLissajous), + OptionsFocus::GainBoost => { + const GAINS: &[f32] = &[1.0, 2.0, 4.0, 8.0, 16.0]; + let pos = GAINS.iter().position(|g| (*g - ctx.app.audio.config.gain_boost).abs() < 0.01).unwrap_or(0); + let new_pos = if right { + (pos + 1) % GAINS.len() + } else { + (pos + GAINS.len() - 1) % GAINS.len() + }; + ctx.dispatch(AppCommand::SetGainBoost(GAINS[new_pos])); + } + OptionsFocus::NormalizeViz => ctx.dispatch(AppCommand::ToggleNormalizeViz), OptionsFocus::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion), OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview), OptionsFocus::PerformanceMode => ctx.dispatch(AppCommand::TogglePerformanceMode), diff --git a/src/settings.rs b/src/settings.rs index e5c9432..50286bd 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -64,6 +64,10 @@ pub struct DisplaySettings { pub load_demo_on_startup: bool, #[serde(default)] pub demo_index: usize, + #[serde(default = "default_gain_boost")] + pub gain_boost: f32, + #[serde(default)] + pub normalize_viz: bool, } fn default_font() -> String { @@ -74,6 +78,10 @@ fn default_zoom() -> f32 { 1.5 } +fn default_gain_boost() -> f32 { + 1.0 +} + #[derive(Debug, Serialize, Deserialize)] pub struct LinkSettings { pub enabled: bool, @@ -114,6 +122,8 @@ impl Default for DisplaySettings { onboarding_dismissed: Vec::new(), load_demo_on_startup: true, demo_index: 0, + gain_boost: 1.0, + normalize_viz: false, } } } diff --git a/src/state/audio.rs b/src/state/audio.rs index 442b254..51e6f0d 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -85,6 +85,8 @@ pub struct AudioConfig { pub show_spectrum: bool, pub show_lissajous: bool, pub show_preview: bool, + pub gain_boost: f32, + pub normalize_viz: bool, pub layout: MainLayout, } @@ -105,6 +107,8 @@ impl Default for AudioConfig { show_spectrum: true, show_lissajous: true, show_preview: true, + gain_boost: 1.0, + normalize_viz: false, layout: MainLayout::default(), } } diff --git a/src/state/options.rs b/src/state/options.rs index 96c18ba..29dd3f0 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -10,6 +10,8 @@ pub enum OptionsFocus { ShowScope, ShowSpectrum, ShowLissajous, + GainBoost, + NormalizeViz, ShowCompletion, ShowPreview, PerformanceMode, @@ -40,6 +42,8 @@ impl CyclicEnum for OptionsFocus { Self::ShowScope, Self::ShowSpectrum, Self::ShowLissajous, + Self::GainBoost, + Self::NormalizeViz, Self::ShowCompletion, Self::ShowPreview, Self::PerformanceMode, @@ -96,30 +100,32 @@ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[ (OptionsFocus::ShowScope, 6), (OptionsFocus::ShowSpectrum, 7), (OptionsFocus::ShowLissajous, 8), - (OptionsFocus::ShowCompletion, 9), - (OptionsFocus::ShowPreview, 10), - (OptionsFocus::PerformanceMode, 11), - (OptionsFocus::Font, 12), - (OptionsFocus::ZoomFactor, 13), - (OptionsFocus::WindowSize, 14), - // blank=15, ABLETON LINK header=16, divider=17 - (OptionsFocus::LinkEnabled, 18), - (OptionsFocus::StartStopSync, 19), - (OptionsFocus::Quantum, 20), - // blank=21, SESSION header=22, divider=23, Tempo=24, Beat=25, Phase=26 - // blank=27, MIDI OUTPUTS header=28, divider=29 - (OptionsFocus::MidiOutput0, 30), - (OptionsFocus::MidiOutput1, 31), - (OptionsFocus::MidiOutput2, 32), - (OptionsFocus::MidiOutput3, 33), - // blank=34, MIDI INPUTS header=35, divider=36 - (OptionsFocus::MidiInput0, 37), - (OptionsFocus::MidiInput1, 38), - (OptionsFocus::MidiInput2, 39), - (OptionsFocus::MidiInput3, 40), - // blank=41, ONBOARDING header=42, divider=43 - (OptionsFocus::ResetOnboarding, 44), - (OptionsFocus::LoadDemoOnStartup, 45), + (OptionsFocus::GainBoost, 9), + (OptionsFocus::NormalizeViz, 10), + (OptionsFocus::ShowCompletion, 11), + (OptionsFocus::ShowPreview, 12), + (OptionsFocus::PerformanceMode, 13), + (OptionsFocus::Font, 14), + (OptionsFocus::ZoomFactor, 15), + (OptionsFocus::WindowSize, 16), + // blank=17, ABLETON LINK header=18, divider=19 + (OptionsFocus::LinkEnabled, 20), + (OptionsFocus::StartStopSync, 21), + (OptionsFocus::Quantum, 22), + // blank=23, SESSION header=24, divider=25, Tempo=26, Beat=27, Phase=28 + // blank=29, MIDI OUTPUTS header=30, divider=31 + (OptionsFocus::MidiOutput0, 32), + (OptionsFocus::MidiOutput1, 33), + (OptionsFocus::MidiOutput2, 34), + (OptionsFocus::MidiOutput3, 35), + // blank=36, MIDI INPUTS header=37, divider=38 + (OptionsFocus::MidiInput0, 39), + (OptionsFocus::MidiInput1, 40), + (OptionsFocus::MidiInput2, 41), + (OptionsFocus::MidiInput3, 42), + // blank=43, ONBOARDING header=44, divider=45 + (OptionsFocus::ResetOnboarding, 46), + (OptionsFocus::LoadDemoOnStartup, 47), ]; impl OptionsFocus { @@ -175,13 +181,13 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> { // based on which sections are hidden. let mut offset: usize = 0; - // Font/Zoom/Window lines (12,13,14) hidden when !plugin_mode + // Font/Zoom/Window lines (14,15,16) hidden when !plugin_mode if !plugin_mode { offset += 3; // 3 lines for Font, ZoomFactor, WindowSize } // Link + Session + MIDI sections hidden when plugin_mode - // These span from blank(15) through MidiInput3(40) = 26 lines + // These span from blank(17) through MidiInput3(42) = 26 lines if plugin_mode { let link_section_lines = 26; offset += link_section_lines; @@ -192,10 +198,10 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> { if !focus.is_visible(plugin_mode) { continue; } - // Lines at or below index 11 (PerformanceMode) are never shifted - let adjusted = if raw_line <= 11 { + // Lines at or below index 13 (PerformanceMode) are never shifted + let adjusted = if raw_line <= 13 { raw_line - } else if !plugin_mode && raw_line <= 14 { + } else if !plugin_mode && raw_line <= 16 { // Font/Zoom/Window — these are hidden, skip continue; } else { diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 77a387a..09ad606 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -163,6 +163,15 @@ fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { render_spectrum(frame, app, spectrum_area); } +fn viz_gain(data: &[f32], config: &crate::state::audio::AudioConfig) -> f32 { + if config.normalize_viz { + let peak = data.iter().fold(0.0_f32, |m, s| m.max(s.abs())); + if peak > 0.0001 { 1.0 / peak } else { 1.0 } + } else { + config.gain_boost + } +} + fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let block = Block::default() @@ -173,9 +182,11 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); + let gain = viz_gain(&app.metrics.scope, &app.audio.config); let scope = Scope::new(&app.metrics.scope) .orientation(Orientation::Horizontal) - .color(theme.meter.low); + .color(theme.meter.low) + .gain(gain); frame.render_widget(scope, inner); } @@ -189,8 +200,16 @@ fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); + let peak = app.metrics.scope.iter().chain(app.metrics.scope_right.iter()) + .fold(0.0_f32, |m, s| m.max(s.abs())); + let gain = if app.audio.config.normalize_viz { + if peak > 0.0001 { 1.0 / peak } else { 1.0 } + } else { + app.audio.config.gain_boost + }; let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right) - .color(theme.meter.low); + .color(theme.meter.low) + .gain(gain); frame.render_widget(lissajous, inner); } @@ -204,7 +223,13 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); - let spectrum = Spectrum::new(&app.metrics.spectrum); + let gain = if app.audio.config.normalize_viz { + viz_gain(&app.metrics.spectrum, &app.audio.config) + } else { + 1.0 + }; + let spectrum = Spectrum::new(&app.metrics.spectrum) + .gain(gain); frame.render_widget(spectrum, inner); } diff --git a/src/views/main_view.rs b/src/views/main_view.rs index 0e4ae67..eb3d2e3 100644 --- a/src/views/main_view.rs +++ b/src/views/main_view.rs @@ -482,6 +482,15 @@ fn render_tile( } } +fn viz_gain(data: &[f32], config: &crate::state::audio::AudioConfig) -> f32 { + if config.normalize_viz { + let peak = data.iter().fold(0.0_f32, |m, s| m.max(s.abs())); + if peak > 0.0001 { 1.0 / peak } else { 1.0 } + } else { + config.gain_boost + } +} + fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) { let theme = theme::get(); let block = Block::default() @@ -490,9 +499,11 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientati let inner = block.inner(area); frame.render_widget(block, area); + let gain = viz_gain(&app.metrics.scope, &app.audio.config); let scope = Scope::new(&app.metrics.scope) .orientation(orientation) - .color(theme.meter.low); + .color(theme.meter.low) + .gain(gain); frame.render_widget(scope, inner); } @@ -504,7 +515,13 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); - let spectrum = Spectrum::new(&app.metrics.spectrum); + let gain = if app.audio.config.normalize_viz { + viz_gain(&app.metrics.spectrum, &app.audio.config) + } else { + 1.0 + }; + let spectrum = Spectrum::new(&app.metrics.spectrum) + .gain(gain); frame.render_widget(spectrum, inner); } @@ -516,8 +533,16 @@ fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) { let inner = block.inner(area); frame.render_widget(block, area); + let peak = app.metrics.scope.iter().chain(app.metrics.scope_right.iter()) + .fold(0.0_f32, |m, s| m.max(s.abs())); + let gain = if app.audio.config.normalize_viz { + if peak > 0.0001 { 1.0 / peak } else { 1.0 } + } else { + app.audio.config.gain_boost + }; let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right) - .color(theme.meter.low); + .color(theme.meter.low) + .gain(gain); frame.render_widget(lissajous, inner); } diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 87f020d..f03bc72 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -88,6 +88,18 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { focus == OptionsFocus::ShowLissajous, &theme, ), + render_option_line( + "Gain boost", + &gain_boost_label(app.audio.config.gain_boost), + focus == OptionsFocus::GainBoost, + &theme, + ), + render_option_line( + "Normalize", + if app.audio.config.normalize_viz { "On" } else { "Off" }, + focus == OptionsFocus::NormalizeViz, + &theme, + ), render_option_line( "Completion", if app.ui.show_completion { "On" } else { "Off" }, @@ -354,9 +366,11 @@ fn option_description(focus: OptionsFocus) -> Option<&'static str> { OptionsFocus::HueRotation => Some("Shift all theme colors by a hue angle"), OptionsFocus::RefreshRate => Some("Lower values reduce CPU usage"), OptionsFocus::RuntimeHighlight => Some("Highlight executed code spans during playback"), - OptionsFocus::ShowScope => Some("Oscilloscope on the engine page"), - OptionsFocus::ShowSpectrum => Some("Spectrum analyzer on the engine page"), + OptionsFocus::ShowScope => Some("Oscilloscope on the main view"), + OptionsFocus::ShowSpectrum => Some("Spectrum analyzer on the main view"), OptionsFocus::ShowLissajous => Some("XY stereo phase scope (left vs right)"), + OptionsFocus::GainBoost => Some("Amplify scope and lissajous waveforms"), + OptionsFocus::NormalizeViz => Some("Auto-scale visualizations to fill the display"), OptionsFocus::ShowCompletion => Some("Word completion popup in the editor"), OptionsFocus::ShowPreview => Some("Step script preview on the sequencer grid"), OptionsFocus::PerformanceMode => Some("Hide header and footer bars"), @@ -386,6 +400,10 @@ fn render_description_line(desc: &str, theme: &ThemeColors) -> Line<'static> { )) } +fn gain_boost_label(gain: f32) -> String { + format!("{:.0}x", gain) +} + fn render_readonly_line(label: &str, value: &str, value_style: Style, theme: &theme::ThemeColors) -> Line<'static> { let label_style = Style::new().fg(theme.ui.text_muted); let label_width = 20; diff --git a/tests/forth/randomness.rs b/tests/forth/randomness.rs index 46fa6e8..0cf191c 100644 --- a/tests/forth/randomness.rs +++ b/tests/forth/randomness.rs @@ -209,6 +209,37 @@ fn bounce_underflow() { expect_error("1 2 5 bounce", "stack underflow"); } +// pbounce + +#[test] +fn pbounce_by_iter() { + let expected = [60, 64, 67, 72, 67, 64, 60, 64]; + for (iter, &exp) in expected.iter().enumerate() { + let ctx = ctx_with(|c| c.iter = iter); + let f = run_ctx("60 64 67 72 4 pbounce", &ctx); + assert_eq!(stack_int(&f), exp, "pbounce at iter={}", iter); + } +} + +#[test] +fn pbounce_single() { + for iter in 0..5 { + let ctx = ctx_with(|c| c.iter = iter); + let f = run_ctx("42 1 pbounce", &ctx); + assert_eq!(stack_int(&f), 42, "pbounce single at iter={}", iter); + } +} + +#[test] +fn pbounce_with_bracket() { + let expected = [60, 64, 67, 64, 60, 64]; + for (iter, &exp) in expected.iter().enumerate() { + let ctx = ctx_with(|c| c.iter = iter); + let f = run_ctx("[ 60 64 67 ] pbounce", &ctx); + assert_eq!(stack_int(&f), exp, "pbounce bracket at iter={}", iter); + } +} + // wchoose #[test] diff --git a/tests/forth/temporal.rs b/tests/forth/temporal.rs index f960b2e..5b463f9 100644 --- a/tests/forth/temporal.rs +++ b/tests/forth/temporal.rs @@ -338,3 +338,59 @@ fn arp_mixed_cycle_and_arp() { assert!(approx_eq(notes[4], 67.0)); assert!(approx_eq(notes[5], 67.0)); } + +// --- every+ / except+ tests --- + +#[test] +fn every_offset_fires_at_offset() { + for iter in 0..8 { + let ctx = ctx_with(|c| c.iter = iter); + let f = forth(); + let outputs = f.evaluate(r#""kick" s { . } 4 2 every+"#, &ctx).unwrap(); + if iter % 4 == 2 { + assert_eq!(outputs.len(), 1, "iter={}: should fire", iter); + } else { + assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter); + } + } +} + +#[test] +fn every_offset_wraps_large_offset() { + // offset 6 with n=4 → 6 % 4 = 2, same as offset 2 + for iter in 0..8 { + let ctx = ctx_with(|c| c.iter = iter); + let f = forth(); + let outputs = f.evaluate(r#""kick" s { . } 4 6 every+"#, &ctx).unwrap(); + if iter % 4 == 2 { + assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter); + } else { + assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter); + } + } +} + +#[test] +fn except_offset_inverse() { + for iter in 0..8 { + let ctx = ctx_with(|c| c.iter = iter); + let f = forth(); + let outputs = f.evaluate(r#""kick" s { . } 4 2 except+"#, &ctx).unwrap(); + if iter % 4 != 2 { + assert_eq!(outputs.len(), 1, "iter={}: should fire", iter); + } else { + assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter); + } + } +} + +#[test] +fn every_offset_zero_is_same_as_every() { + for iter in 0..8 { + let ctx = ctx_with(|c| c.iter = iter); + let f = forth(); + let a = f.evaluate(r#""kick" s { . } 3 every"#, &ctx).unwrap(); + let b = f.evaluate(r#""kick" s { . } 3 0 every+"#, &ctx).unwrap(); + assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter); + } +}