//! 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) -> 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) -> 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) -> 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 }