Feat: UI/UX and ducking compressor

This commit is contained in:
2026-02-24 02:57:27 +01:00
parent 848d0e773f
commit 2de49bdeba
24 changed files with 402 additions and 71 deletions

View File

@@ -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

View File

@@ -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(),

View File

@@ -255,6 +255,8 @@ pub enum AppCommand {
ToggleSpectrum,
ToggleLissajous,
TogglePreview,
SetGainBoost(f32),
ToggleNormalizeViz,
TogglePerformanceMode,
// 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_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;

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)));
}
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),

View File

@@ -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),

View File

@@ -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,
}
}
}

View File

@@ -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(),
}
}

View File

@@ -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 {

View File

@@ -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);
}

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) {
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);
}

View File

@@ -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;