Files
Soundgen/crates/soundgen-gui/src/channel_panel.rs
T
Emil 58c47fe286 Add vibrato support + fix feedback loop + GUI improvements
Vibrato (LFO frequency modulation):
- New VibratoSpec in SoundSpec: rate (Hz), depth (cents), delay (s)
- ChannelRenderer applies vibrato to frequency each tick
- Supported on Pulse and Triangle channels
- GUI: vibrato controls (enable, rate, depth) in channel panel
- Enables bird chirps, sirens, wobbles

Feedback loop fixes:
- generate_batch now accepts full SoundSpec array (not just names)
  so each sound is unique
- render_sound returns reference examples from feedback DB
- Fixed GUI bug: clicking stars no longer erases feedback text
- Feedback text auto-saves on Enter / focus loss
- Replaced emoji buttons (✓/🗑) with text (Save/Del)
- Green saved indicator when feedback exists in DB

99 tests passing, 0 warnings
2026-06-21 23:54:25 +03:00

546 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Channel panel — controls for duty cycle, frequency, envelope, filter, volume, pan.
//!
//! Features:
//! - Collapsible channels with color-coded headers
//! - Compact ADSR with mini visual shape
//! - Filter controls (type, cutoff, Q, sweep)
//! - Move up/down, duplicate, delete buttons
use egui::{Color32, Ui};
use soundgen_core::{FrequencyAutomation, SweepCurve};
use soundgen_fmt::{ChannelSpec, CutoffAutomation, EnvelopeSpec, FilterKind, FilterSpec};
/// Channel type color for headers.
pub fn channel_type_color(channel: &ChannelSpec) -> Color32 {
match channel {
ChannelSpec::Pulse { .. } => Color32::from_rgb(255, 200, 80), // amber
ChannelSpec::Triangle { .. } => Color32::from_rgb(100, 255, 150), // green
ChannelSpec::Noise { .. } => Color32::from_rgb(180, 180, 190), // gray
}
}
/// Channel type name.
pub fn channel_type_name(channel: &ChannelSpec) -> &'static str {
match channel {
ChannelSpec::Pulse { .. } => "Pulse",
ChannelSpec::Triangle { .. } => "Triangle",
ChannelSpec::Noise { .. } => "Noise",
}
}
/// Draw controls for a channel spec inside a collapsing header.
/// Returns true if the spec was modified.
pub fn channel_panel(
ui: &mut Ui,
channel: &mut ChannelSpec,
index: usize,
total: usize,
id_source: &str,
) -> ChannelEdit {
let mut edit = ChannelEdit::default();
let color = channel_type_color(channel);
let name = channel_type_name(channel);
// Header with color bar + buttons
ui.horizontal(|ui| {
// Color indicator
let (bar_rect, _) =
ui.allocate_exact_size(egui::Vec2::new(4.0, 20.0), egui::Sense::hover());
ui.painter().rect_filled(bar_rect, 2.0, color);
// Collapsing header
let header_text = format!("Ch {} · {}", index + 1, name);
let id = format!("{}_{}", id_source, index);
egui::CollapsingHeader::new(header_text)
.id_source(id)
.default_open(true)
.show(ui, |ui| {
edit.changed = channel_controls(ui, channel, index);
});
// Move/dup/delete buttons
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("×").on_hover_text("Delete channel").clicked() {
edit.deleted = true;
}
if ui.button("⧉").on_hover_text("Duplicate channel").clicked() {
edit.duplicated = true;
}
ui.add_enabled_ui(index > 0, |ui| {
if ui.button("▲").on_hover_text("Move up").clicked() {
edit.moved_up = true;
}
});
ui.add_enabled_ui(index < total - 1, |ui| {
if ui.button("▼").on_hover_text("Move down").clicked() {
edit.moved_down = true;
}
});
});
});
edit
}
/// Actions from channel editing.
#[derive(Default)]
pub struct ChannelEdit {
pub changed: bool,
pub deleted: bool,
pub duplicated: bool,
pub moved_up: bool,
pub moved_down: bool,
}
fn channel_controls(ui: &mut Ui, channel: &mut ChannelSpec, _index: usize) -> bool {
let mut changed = false;
match channel {
ChannelSpec::Pulse {
duty,
frequency,
envelope,
filter,
vibrato,
volume,
pan,
} => {
// Duty cycle dropdown
ui.horizontal(|ui| {
ui.label("Duty:");
let mut duty_val = *duty;
let resp = egui::ComboBox::from_id_source("duty")
.selected_text(format!("{}%", duty_val))
.show_ui(ui, |ui| {
ui.selectable_value(&mut duty_val, 12, "12.5%");
ui.selectable_value(&mut duty_val, 25, "25%");
ui.selectable_value(&mut duty_val, 50, "50%");
ui.selectable_value(&mut duty_val, 75, "75%");
});
if resp.response.changed() {
*duty = duty_val;
changed = true;
}
});
changed |= freq_controls(ui, frequency);
changed |= envelope_controls(ui, envelope);
changed |= filter_controls(ui, filter);
changed |= vol_pan_controls(ui, volume, pan);
}
ChannelSpec::Triangle {
frequency,
envelope,
filter,
vibrato,
volume,
pan,
} => {
changed |= freq_controls(ui, frequency);
changed |= envelope_controls(ui, envelope);
changed |= filter_controls(ui, filter);
changed |= vibrato_controls(ui, vibrato);
changed |= vol_pan_controls(ui, volume, pan);
}
ChannelSpec::Noise {
mode,
frequency,
envelope,
filter,
vibrato: _,
volume,
pan,
} => {
ui.horizontal(|ui| {
ui.label("Mode:");
let mut is_white = *mode == "white";
if ui.radio_value(&mut is_white, true, "White").changed() {
*mode = "white".to_string();
changed = true;
}
if ui.radio_value(&mut is_white, false, "Periodic").changed() {
*mode = "periodic".to_string();
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Clock:");
if ui
.add(
egui::Slider::new(frequency, 100.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.changed()
{
changed = true;
}
});
changed |= envelope_controls(ui, envelope);
changed |= filter_controls(ui, filter);
changed |= vol_pan_controls(ui, volume, pan);
}
}
changed
}
fn freq_controls(ui: &mut Ui, freq: &mut FrequencyAutomation) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("Freq:");
if ui
.add(
egui::Slider::new(&mut freq.start, 20.0..=8000.0)
.logarithmic(true)
.suffix(" Hz")
.text("start"),
)
.on_hover_text("Starting frequency")
.changed()
{
changed = true;
}
if ui
.add(
egui::Slider::new(&mut freq.end, 20.0..=8000.0)
.logarithmic(true)
.suffix(" Hz")
.text("end"),
)
.on_hover_text("Ending frequency (sweep target)")
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Curve:");
if ui
.radio_value(&mut freq.curve, SweepCurve::Linear, "Linear")
.changed()
{
changed = true;
}
if ui
.radio_value(&mut freq.curve, SweepCurve::Exponential, "Exponential")
.changed()
{
changed = true;
}
});
changed
}
fn envelope_controls(ui: &mut Ui, envelope: &mut Option<EnvelopeSpec>) -> bool {
let mut changed = false;
if envelope.is_none() {
*envelope = Some(EnvelopeSpec::default());
}
let env = envelope.as_mut().unwrap();
// ADSR visual + sliders in compact layout
ui.horizontal(|ui| {
// Mini ADSR visual
crate::waveform::adsr_visual(ui, env.attack, env.decay, env.sustain, env.release, 1.0);
// Compact sliders in a grid
ui.vertical(|ui| {
ui.horizontal(|ui| {
ui.label("A:");
if ui
.add(
egui::Slider::new(&mut env.attack, 0.0..=2.0)
.suffix("s")
.clamp_to_range(true)
.fixed_decimals(3),
)
.on_hover_text("Attack time (seconds)")
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("D:");
if ui
.add(
egui::Slider::new(&mut env.decay, 0.0..=2.0)
.suffix("s")
.clamp_to_range(true)
.fixed_decimals(3),
)
.on_hover_text("Decay time (seconds)")
.changed()
{
changed = true;
}
});
});
});
ui.horizontal(|ui| {
ui.label("S:");
if ui
.add(
egui::Slider::new(&mut env.sustain, 0.0..=1.0)
.clamp_to_range(true)
.fixed_decimals(2),
)
.on_hover_text("Sustain level (0-1)")
.changed()
{
changed = true;
}
ui.label("R:");
if ui
.add(
egui::Slider::new(&mut env.release, 0.0..=2.0)
.suffix("s")
.clamp_to_range(true)
.fixed_decimals(3),
)
.on_hover_text("Release time (seconds)")
.changed()
{
changed = true;
}
});
changed
}
fn filter_controls(ui: &mut Ui, filter: &mut Option<FilterSpec>) -> bool {
let mut changed = false;
let has_filter = filter.is_some();
ui.horizontal(|ui| {
let mut enable = has_filter;
if ui
.checkbox(&mut enable, "Filter")
.on_hover_text("Enable biquad filter")
.changed()
{
if enable && filter.is_none() {
*filter = Some(FilterSpec {
kind: FilterKind::Lowpass,
cutoff: 2000.0,
cutoff_sweep: None,
q: 0.707,
});
changed = true;
} else if !enable && filter.is_some() {
*filter = None;
changed = true;
}
}
});
if let Some(filt) = filter {
ui.horizontal(|ui| {
ui.label("Type:");
let mut kind = filt.kind;
let resp = egui::ComboBox::from_id_source("filter_type")
.selected_text(match kind {
FilterKind::Lowpass => "Lowpass",
FilterKind::Highpass => "Highpass",
})
.show_ui(ui, |ui| {
ui.selectable_value(&mut kind, FilterKind::Lowpass, "Lowpass");
ui.selectable_value(&mut kind, FilterKind::Highpass, "Highpass");
});
if resp.response.changed() {
filt.kind = kind;
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Cutoff:");
if ui
.add(
egui::Slider::new(&mut filt.cutoff, 20.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.on_hover_text("Filter cutoff frequency")
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label("Q:");
if ui
.add(
egui::Slider::new(&mut filt.q, 0.1..=10.0)
.fixed_decimals(2)
.text("resonance"),
)
.on_hover_text("Filter resonance (higher = sharper)")
.changed()
{
changed = true;
}
});
// Cutoff sweep
ui.horizontal(|ui| {
let mut has_sweep = filt.cutoff_sweep.is_some();
if ui
.checkbox(&mut has_sweep, "Cutoff sweep")
.on_hover_text("Automate cutoff over time")
.changed()
{
if has_sweep {
filt.cutoff_sweep = Some(CutoffAutomation {
start: filt.cutoff,
end: filt.cutoff * 0.1,
curve: SweepCurve::Exponential,
});
changed = true;
} else {
filt.cutoff_sweep = None;
changed = true;
}
}
});
if let Some(sweep) = &mut filt.cutoff_sweep {
ui.horizontal(|ui| {
ui.label(" Start:");
if ui
.add(
egui::Slider::new(&mut sweep.start, 20.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.changed()
{
changed = true;
}
ui.label("End:");
if ui
.add(
egui::Slider::new(&mut sweep.end, 20.0..=20000.0)
.logarithmic(true)
.suffix(" Hz"),
)
.changed()
{
changed = true;
}
});
ui.horizontal(|ui| {
ui.label(" Curve:");
if ui
.radio_value(&mut sweep.curve, SweepCurve::Linear, "Linear")
.changed()
{
changed = true;
}
if ui
.radio_value(&mut sweep.curve, SweepCurve::Exponential, "Exponential")
.changed()
{
changed = true;
}
});
}
}
changed
}
fn vol_pan_controls(ui: &mut Ui, volume: &mut f32, pan: &mut f32) -> bool {
let mut changed = false;
ui.horizontal(|ui| {
ui.label("Vol:");
if ui
.add(egui::Slider::new(volume, 0.0..=1.0).fixed_decimals(2))
.on_hover_text("Channel volume (0-1)")
.changed()
{
changed = true;
}
ui.label("Pan:");
if ui
.add(
egui::Slider::new(pan, -1.0..=1.0)
.fixed_decimals(2)
.text("L/R"),
)
.on_hover_text("Pan (-1=left, 0=center, 1=right)")
.changed()
{
changed = true;
}
});
changed
}
fn vibrato_controls(ui: &mut Ui, vibrato: &mut Option<soundgen_fmt::VibratoSpec>) -> bool {
use soundgen_fmt::VibratoSpec;
let mut changed = false;
let has_vibrato = vibrato.is_some();
ui.horizontal(|ui| {
let mut enable = has_vibrato;
if ui
.checkbox(&mut enable, "Vibrato")
.on_hover_text("LFO frequency modulation — for bird chirps, sirens, wobbles")
.changed()
{
if enable && vibrato.is_none() {
*vibrato = Some(VibratoSpec {
rate: 10.0,
depth: 200.0,
delay: 0.0,
});
changed = true;
} else if !enable && vibrato.is_some() {
*vibrato = None;
changed = true;
}
}
});
if let Some(vib) = vibrato {
ui.horizontal(|ui| {
ui.label("Rate:");
if ui
.add(
egui::Slider::new(&mut vib.rate, 0.5..=50.0)
.suffix(" Hz")
.fixed_decimals(1),
)
.on_hover_text("LFO speed (5-15 Hz = bird chirps, 0.5-2 = subtle vibrato)")
.changed()
{
changed = true;
}
ui.label("Depth:");
if ui
.add(
egui::Slider::new(&mut vib.depth, 0.0..=1200.0)
.suffix(" cents")
.fixed_decimals(0),
)
.on_hover_text(
"Pitch variation (100 = subtle, 600 = wide chirp, 1200 = full octave)",
)
.changed()
{
changed = true;
}
});
}
changed
}