Feat: UI/UX and ducking compressor
Some checks failed
Deploy Website / deploy (push) Failing after 4m52s

This commit is contained in:
2026-02-24 02:57:27 +01:00
parent 7632bc76f7
commit f0de312d6b
24 changed files with 402 additions and 71 deletions

View File

@@ -76,6 +76,7 @@ pub enum Op {
PCycle(Option<SourceSpan>), PCycle(Option<SourceSpan>),
Choose(Option<SourceSpan>), Choose(Option<SourceSpan>),
Bounce(Option<SourceSpan>), Bounce(Option<SourceSpan>),
PBounce(Option<SourceSpan>),
WChoose(Option<SourceSpan>), WChoose(Option<SourceSpan>),
ChanceExec(Option<SourceSpan>), ChanceExec(Option<SourceSpan>),
ProbExec(Option<SourceSpan>), ProbExec(Option<SourceSpan>),
@@ -84,6 +85,9 @@ pub enum Op {
Ftom, Ftom,
SetTempo, SetTempo,
Every(Option<SourceSpan>), Every(Option<SourceSpan>),
Except(Option<SourceSpan>),
EveryOffset(Option<SourceSpan>),
ExceptOffset(Option<SourceSpan>),
Bjork(Option<SourceSpan>), Bjork(Option<SourceSpan>),
PBjork(Option<SourceSpan>), PBjork(Option<SourceSpan>),
Quotation(Arc<[Op]>, Option<SourceSpan>), Quotation(Arc<[Op]>, Option<SourceSpan>),

View File

@@ -799,16 +799,20 @@ impl Forth {
drain_select_run(count, idx, stack, outputs, cmd)?; 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; let count = pop_int(stack)? as usize;
if count == 0 { if count == 0 {
return Err("bounce count must be > 0".into()); return Err("bounce count must be > 0".into());
} }
let counter = match &ops[pc] {
Op::Bounce(_) => ctx.runs,
_ => ctx.iter,
};
let idx = if count == 1 { let idx = if count == 1 {
0 0
} else { } else {
let period = 2 * (count - 1); let period = 2 * (count - 1);
let raw = ctx.runs % period; let raw = counter % period;
if raw < count { raw } else { period - raw } if raw < count { raw } else { period - raw }
}; };
if let Some(span) = word_span { 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) => { Op::Bjork(word_span) | Op::PBjork(word_span) => {
let n = pop_int(stack)?; let n = pop_int(stack)?;
let k = pop_int(stack)?; let k = pop_int(stack)?;

View File

@@ -67,8 +67,12 @@ pub(super) fn simple_op(name: &str) -> Option<Op> {
"pcycle" => Op::PCycle(None), "pcycle" => Op::PCycle(None),
"choose" => Op::Choose(None), "choose" => Op::Choose(None),
"bounce" => Op::Bounce(None), "bounce" => Op::Bounce(None),
"pbounce" => Op::PBounce(None),
"wchoose" => Op::WChoose(None), "wchoose" => Op::WChoose(None),
"every" => Op::Every(None), "every" => Op::Every(None),
"except" => Op::Except(None),
"every+" => Op::EveryOffset(None),
"except+" => Op::ExceptOffset(None),
"bjork" => Op::Bjork(None), "bjork" => Op::Bjork(None),
"pbjork" => Op::PBjork(None), "pbjork" => Op::PBjork(None),
"chance" => Op::ChanceExec(None), "chance" => Op::ChanceExec(None),
@@ -204,8 +208,8 @@ fn attach_span(op: &mut Op, span: SourceSpan) {
match op { match op {
Op::Rand(s) | Op::ExpRand(s) | Op::LogRand(s) | Op::Coin(s) 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::Choose(s) | Op::WChoose(s) | Op::Cycle(s) | Op::PCycle(s)
| Op::Bounce(s) | Op::ChanceExec(s) | Op::ProbExec(s) | Op::Bounce(s) | Op::PBounce(s) | Op::ChanceExec(s) | Op::ProbExec(s)
| Op::Every(s) | Op::Every(s) | Op::Except(s) | Op::EveryOffset(s) | Op::ExceptOffset(s)
| Op::Bjork(s) | Op::PBjork(s) | Op::Bjork(s) | Op::PBjork(s)
| Op::Count(s) | Op::Index(s) => *s = Some(span), | Op::Count(s) | Op::Index(s) => *s = Some(span),
_ => {} _ => {}

View File

@@ -959,4 +959,45 @@ pub(super) const WORDS: &[Word] = &[
compile: Param, compile: Param,
varargs: true, 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,
},
]; ];

View File

@@ -113,6 +113,16 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple, compile: Simple,
varargs: true, 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 { Word {
name: "index", name: "index",
aliases: &[], aliases: &[],
@@ -214,6 +224,36 @@ pub(super) const WORDS: &[Word] = &[
compile: Simple, compile: Simple,
varargs: false, 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 { Word {
name: "bjork", name: "bjork",
aliases: &[], aliases: &[],

View File

@@ -13,6 +13,7 @@ pub struct Lissajous<'a> {
left: &'a [f32], left: &'a [f32],
right: &'a [f32], right: &'a [f32],
color: Option<Color>, color: Option<Color>,
gain: f32,
} }
impl<'a> Lissajous<'a> { impl<'a> Lissajous<'a> {
@@ -21,6 +22,7 @@ impl<'a> Lissajous<'a> {
left, left,
right, right,
color: None, color: None,
gain: 1.0,
} }
} }
@@ -28,6 +30,11 @@ impl<'a> Lissajous<'a> {
self.color = Some(c); self.color = Some(c);
self self
} }
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
} }
impl Widget for Lissajous<'_> { impl Widget for Lissajous<'_> {
@@ -43,14 +50,6 @@ impl Widget for Lissajous<'_> {
let fine_height = height * 4; let fine_height = height * 4;
let len = self.left.len().min(self.right.len()); 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| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
let size = width * height; let size = width * height;
@@ -58,8 +57,8 @@ impl Widget for Lissajous<'_> {
patterns.resize(size, 0); patterns.resize(size, 0);
for i in 0..len { for i in 0..len {
let l = (self.left[i] * gain).clamp(-1.0, 1.0); let l = (self.left[i] * self.gain).clamp(-1.0, 1.0);
let r = (self.right[i] * 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) // 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; let fine_x = ((r + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;

View File

@@ -41,6 +41,11 @@ impl<'a> Scope<'a> {
self.color = Some(c); self.color = Some(c);
self self
} }
pub fn gain(mut self, g: f32) -> Self {
self.gain = g;
self
}
} }
impl Widget for Scope<'_> { 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_width = width * 2;
let fine_height = height * 4; 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| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
let size = width * height; 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 { for fine_x in 0..fine_width {
let sample_idx = (fine_x * data.len()) / 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 = ((1.0 - sample) * 0.5 * (fine_height - 1) as f32).round() as usize;
let fine_y = fine_y.min(fine_height - 1); 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_width = width * 2;
let fine_height = height * 4; 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| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
let size = width * height; 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 { for fine_y in 0..fine_height {
let sample_idx = (fine_y * data.len()) / 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 = ((sample + 1.0) * 0.5 * (fine_width - 1) as f32).round() as usize;
let fine_x = fine_x.min(fine_width - 1); let fine_x = fine_x.min(fine_width - 1);

View File

@@ -8,11 +8,17 @@ const BLOCKS: [char; 8] = ['\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2
pub struct Spectrum<'a> { pub struct Spectrum<'a> {
data: &'a [f32; 32], data: &'a [f32; 32],
gain: f32,
} }
impl<'a> Spectrum<'a> { impl<'a> Spectrum<'a> {
pub fn new(data: &'a [f32; 32]) -> Self { 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 { if w == 0 {
continue; continue;
} }
let bar_height = mag * height; let bar_height = (mag * self.gain).min(1.0) * height;
let full_cells = bar_height as usize; let full_cells = bar_height as usize;
let frac = bar_height - full_cells as f32; let frac = bar_height - full_cells as f32;
let frac_idx = (frac * 8.0) as usize; let frac_idx = (frac * 8.0) as usize;

View File

@@ -81,9 +81,6 @@ fn render_horizontal(data: &[f32], area: Rect, buf: &mut Buffer, color: Color, g
let fine_height = height * 4; let fine_height = height * 4;
let len = data.len(); 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| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
patterns.clear(); 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 min_s = f32::MAX;
let mut max_s = f32::MIN; let mut max_s = f32::MIN;
for &s in slice { 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 { if s < min_s {
min_s = 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 fine_height = height * 4;
let len = data.len(); 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| { PATTERNS.with(|p| {
let mut patterns = p.borrow_mut(); let mut patterns = p.borrow_mut();
patterns.clear(); 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 min_s = f32::MAX;
let mut max_s = f32::MIN; let mut max_s = f32::MIN;
for &s in slice { 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 { if s < min_s {
min_s = s; min_s = s;
} }

View File

@@ -133,6 +133,12 @@ sine s .
{ crash s . } 4 every ;; crash cymbal every 4th iteration { 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: `bjork` and `pbjork` use Bjorklund's algorithm to distribute k hits across n positions as evenly as possible. Classic Euclidean rhythms:
```forth ```forth

View File

@@ -391,6 +391,8 @@ impl App {
AppCommand::ToggleSpectrum => self.audio.config.show_spectrum = !self.audio.config.show_spectrum, 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::ToggleLissajous => self.audio.config.show_lissajous = !self.audio.config.show_lissajous,
AppCommand::TogglePreview => self.audio.config.show_preview = !self.audio.config.show_preview, 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, AppCommand::TogglePerformanceMode => self.ui.performance_mode = !self.ui.performance_mode,
// Metrics // Metrics

View File

@@ -36,6 +36,8 @@ impl App {
demo_index: self.ui.demo_index, 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,
gain_boost: self.audio.config.gain_boost,
normalize_viz: self.audio.config.normalize_viz,
}, },
link: crate::settings::LinkSettings { link: crate::settings::LinkSettings {
enabled: link.is_enabled(), enabled: link.is_enabled(),

View File

@@ -255,6 +255,8 @@ pub enum AppCommand {
ToggleSpectrum, ToggleSpectrum,
ToggleLissajous, ToggleLissajous,
TogglePreview, TogglePreview,
SetGainBoost(f32),
ToggleNormalizeViz,
TogglePerformanceMode, TogglePerformanceMode,
// Metrics // Metrics

View File

@@ -111,6 +111,8 @@ pub fn init(args: InitArgs) -> Init {
app.audio.config.show_spectrum = settings.display.show_spectrum; app.audio.config.show_spectrum = settings.display.show_spectrum;
app.audio.config.show_lissajous = settings.display.show_lissajous; app.audio.config.show_lissajous = settings.display.show_lissajous;
app.audio.config.show_preview = settings.display.show_preview; 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.show_completion = settings.display.show_completion;
app.ui.performance_mode = settings.display.performance_mode; app.ui.performance_mode = settings.display.performance_mode;
app.ui.color_scheme = settings.display.color_scheme; app.ui.color_scheme = settings.display.color_scheme;

View File

@@ -112,8 +112,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool
ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current)));
} }
KeyCode::Char(':') => { KeyCode::Char(':') => {
let current = (ctx.app.editor_ctx.step + 1).to_string(); ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(String::new())));
ctx.dispatch(AppCommand::OpenModal(Modal::JumpToStep(current)));
} }
KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease), KeyCode::Char('<') | KeyCode::Char(',') => ctx.dispatch(AppCommand::LengthDecrease),
KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease), KeyCode::Char('>') | KeyCode::Char('.') => ctx.dispatch(AppCommand::LengthIncrease),

View File

@@ -25,6 +25,17 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope), OptionsFocus::ShowScope => ctx.dispatch(AppCommand::ToggleScope),
OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum), OptionsFocus::ShowSpectrum => ctx.dispatch(AppCommand::ToggleSpectrum),
OptionsFocus::ShowLissajous => ctx.dispatch(AppCommand::ToggleLissajous), 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::ShowCompletion => ctx.dispatch(AppCommand::ToggleCompletion),
OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview), OptionsFocus::ShowPreview => ctx.dispatch(AppCommand::TogglePreview),
OptionsFocus::PerformanceMode => ctx.dispatch(AppCommand::TogglePerformanceMode), OptionsFocus::PerformanceMode => ctx.dispatch(AppCommand::TogglePerformanceMode),

View File

@@ -64,6 +64,10 @@ pub struct DisplaySettings {
pub load_demo_on_startup: bool, pub load_demo_on_startup: bool,
#[serde(default)] #[serde(default)]
pub demo_index: usize, pub demo_index: usize,
#[serde(default = "default_gain_boost")]
pub gain_boost: f32,
#[serde(default)]
pub normalize_viz: bool,
} }
fn default_font() -> String { fn default_font() -> String {
@@ -74,6 +78,10 @@ fn default_zoom() -> f32 {
1.5 1.5
} }
fn default_gain_boost() -> f32 {
1.0
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct LinkSettings { pub struct LinkSettings {
pub enabled: bool, pub enabled: bool,
@@ -114,6 +122,8 @@ impl Default for DisplaySettings {
onboarding_dismissed: Vec::new(), onboarding_dismissed: Vec::new(),
load_demo_on_startup: true, load_demo_on_startup: true,
demo_index: 0, demo_index: 0,
gain_boost: 1.0,
normalize_viz: false,
} }
} }
} }

View File

@@ -85,6 +85,8 @@ pub struct AudioConfig {
pub show_spectrum: bool, pub show_spectrum: bool,
pub show_lissajous: bool, pub show_lissajous: bool,
pub show_preview: bool, pub show_preview: bool,
pub gain_boost: f32,
pub normalize_viz: bool,
pub layout: MainLayout, pub layout: MainLayout,
} }
@@ -105,6 +107,8 @@ impl Default for AudioConfig {
show_spectrum: true, show_spectrum: true,
show_lissajous: true, show_lissajous: true,
show_preview: true, show_preview: true,
gain_boost: 1.0,
normalize_viz: false,
layout: MainLayout::default(), layout: MainLayout::default(),
} }
} }

View File

@@ -10,6 +10,8 @@ pub enum OptionsFocus {
ShowScope, ShowScope,
ShowSpectrum, ShowSpectrum,
ShowLissajous, ShowLissajous,
GainBoost,
NormalizeViz,
ShowCompletion, ShowCompletion,
ShowPreview, ShowPreview,
PerformanceMode, PerformanceMode,
@@ -40,6 +42,8 @@ impl CyclicEnum for OptionsFocus {
Self::ShowScope, Self::ShowScope,
Self::ShowSpectrum, Self::ShowSpectrum,
Self::ShowLissajous, Self::ShowLissajous,
Self::GainBoost,
Self::NormalizeViz,
Self::ShowCompletion, Self::ShowCompletion,
Self::ShowPreview, Self::ShowPreview,
Self::PerformanceMode, Self::PerformanceMode,
@@ -96,30 +100,32 @@ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[
(OptionsFocus::ShowScope, 6), (OptionsFocus::ShowScope, 6),
(OptionsFocus::ShowSpectrum, 7), (OptionsFocus::ShowSpectrum, 7),
(OptionsFocus::ShowLissajous, 8), (OptionsFocus::ShowLissajous, 8),
(OptionsFocus::ShowCompletion, 9), (OptionsFocus::GainBoost, 9),
(OptionsFocus::ShowPreview, 10), (OptionsFocus::NormalizeViz, 10),
(OptionsFocus::PerformanceMode, 11), (OptionsFocus::ShowCompletion, 11),
(OptionsFocus::Font, 12), (OptionsFocus::ShowPreview, 12),
(OptionsFocus::ZoomFactor, 13), (OptionsFocus::PerformanceMode, 13),
(OptionsFocus::WindowSize, 14), (OptionsFocus::Font, 14),
// blank=15, ABLETON LINK header=16, divider=17 (OptionsFocus::ZoomFactor, 15),
(OptionsFocus::LinkEnabled, 18), (OptionsFocus::WindowSize, 16),
(OptionsFocus::StartStopSync, 19), // blank=17, ABLETON LINK header=18, divider=19
(OptionsFocus::Quantum, 20), (OptionsFocus::LinkEnabled, 20),
// blank=21, SESSION header=22, divider=23, Tempo=24, Beat=25, Phase=26 (OptionsFocus::StartStopSync, 21),
// blank=27, MIDI OUTPUTS header=28, divider=29 (OptionsFocus::Quantum, 22),
(OptionsFocus::MidiOutput0, 30), // blank=23, SESSION header=24, divider=25, Tempo=26, Beat=27, Phase=28
(OptionsFocus::MidiOutput1, 31), // blank=29, MIDI OUTPUTS header=30, divider=31
(OptionsFocus::MidiOutput2, 32), (OptionsFocus::MidiOutput0, 32),
(OptionsFocus::MidiOutput3, 33), (OptionsFocus::MidiOutput1, 33),
// blank=34, MIDI INPUTS header=35, divider=36 (OptionsFocus::MidiOutput2, 34),
(OptionsFocus::MidiInput0, 37), (OptionsFocus::MidiOutput3, 35),
(OptionsFocus::MidiInput1, 38), // blank=36, MIDI INPUTS header=37, divider=38
(OptionsFocus::MidiInput2, 39), (OptionsFocus::MidiInput0, 39),
(OptionsFocus::MidiInput3, 40), (OptionsFocus::MidiInput1, 40),
// blank=41, ONBOARDING header=42, divider=43 (OptionsFocus::MidiInput2, 41),
(OptionsFocus::ResetOnboarding, 44), (OptionsFocus::MidiInput3, 42),
(OptionsFocus::LoadDemoOnStartup, 45), // blank=43, ONBOARDING header=44, divider=45
(OptionsFocus::ResetOnboarding, 46),
(OptionsFocus::LoadDemoOnStartup, 47),
]; ];
impl OptionsFocus { impl OptionsFocus {
@@ -175,13 +181,13 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> {
// based on which sections are hidden. // based on which sections are hidden.
let mut offset: usize = 0; 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 { if !plugin_mode {
offset += 3; // 3 lines for Font, ZoomFactor, WindowSize offset += 3; // 3 lines for Font, ZoomFactor, WindowSize
} }
// Link + Session + MIDI sections hidden when plugin_mode // 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 { if plugin_mode {
let link_section_lines = 26; let link_section_lines = 26;
offset += link_section_lines; offset += link_section_lines;
@@ -192,10 +198,10 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> {
if !focus.is_visible(plugin_mode) { if !focus.is_visible(plugin_mode) {
continue; continue;
} }
// Lines at or below index 11 (PerformanceMode) are never shifted // Lines at or below index 13 (PerformanceMode) are never shifted
let adjusted = if raw_line <= 11 { let adjusted = if raw_line <= 13 {
raw_line raw_line
} else if !plugin_mode && raw_line <= 14 { } else if !plugin_mode && raw_line <= 16 {
// Font/Zoom/Window — these are hidden, skip // Font/Zoom/Window — these are hidden, skip
continue; continue;
} else { } else {

View File

@@ -163,6 +163,15 @@ fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) {
render_spectrum(frame, app, spectrum_area); 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) { fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let theme = theme::get(); let theme = theme::get();
let block = Block::default() let block = Block::default()
@@ -173,9 +182,11 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let gain = viz_gain(&app.metrics.scope, &app.audio.config);
let scope = Scope::new(&app.metrics.scope) let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Horizontal) .orientation(Orientation::Horizontal)
.color(theme.meter.low); .color(theme.meter.low)
.gain(gain);
frame.render_widget(scope, inner); frame.render_widget(scope, inner);
} }
@@ -189,8 +200,16 @@ fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, 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) 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); frame.render_widget(lissajous, inner);
} }
@@ -204,7 +223,13 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, 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); frame.render_widget(spectrum, inner);
} }

View File

@@ -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) { fn render_scope(frame: &mut Frame, app: &App, area: Rect, orientation: Orientation) {
let theme = theme::get(); let theme = theme::get();
let block = Block::default() 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); let inner = block.inner(area);
frame.render_widget(block, area); frame.render_widget(block, area);
let gain = viz_gain(&app.metrics.scope, &app.audio.config);
let scope = Scope::new(&app.metrics.scope) let scope = Scope::new(&app.metrics.scope)
.orientation(orientation) .orientation(orientation)
.color(theme.meter.low); .color(theme.meter.low)
.gain(gain);
frame.render_widget(scope, inner); frame.render_widget(scope, inner);
} }
@@ -504,7 +515,13 @@ fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, 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); frame.render_widget(spectrum, inner);
} }
@@ -516,8 +533,16 @@ fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) {
let inner = block.inner(area); let inner = block.inner(area);
frame.render_widget(block, 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) 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); frame.render_widget(lissajous, inner);
} }

View File

@@ -88,6 +88,18 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
focus == OptionsFocus::ShowLissajous, focus == OptionsFocus::ShowLissajous,
&theme, &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( render_option_line(
"Completion", "Completion",
if app.ui.show_completion { "On" } else { "Off" }, 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::HueRotation => Some("Shift all theme colors by a hue angle"),
OptionsFocus::RefreshRate => Some("Lower values reduce CPU usage"), OptionsFocus::RefreshRate => Some("Lower values reduce CPU usage"),
OptionsFocus::RuntimeHighlight => Some("Highlight executed code spans during playback"), OptionsFocus::RuntimeHighlight => Some("Highlight executed code spans during playback"),
OptionsFocus::ShowScope => Some("Oscilloscope on the engine page"), OptionsFocus::ShowScope => Some("Oscilloscope on the main view"),
OptionsFocus::ShowSpectrum => Some("Spectrum analyzer on the engine page"), OptionsFocus::ShowSpectrum => Some("Spectrum analyzer on the main view"),
OptionsFocus::ShowLissajous => Some("XY stereo phase scope (left vs right)"), 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::ShowCompletion => Some("Word completion popup in the editor"),
OptionsFocus::ShowPreview => Some("Step script preview on the sequencer grid"), OptionsFocus::ShowPreview => Some("Step script preview on the sequencer grid"),
OptionsFocus::PerformanceMode => Some("Hide header and footer bars"), 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> { 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_style = Style::new().fg(theme.ui.text_muted);
let label_width = 20; let label_width = 20;

View File

@@ -209,6 +209,37 @@ fn bounce_underflow() {
expect_error("1 2 5 bounce", "stack 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 // wchoose
#[test] #[test]

View File

@@ -338,3 +338,59 @@ fn arp_mixed_cycle_and_arp() {
assert!(approx_eq(notes[4], 67.0)); assert!(approx_eq(notes[4], 67.0));
assert!(approx_eq(notes[5], 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);
}
}