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
546 lines
16 KiB
Rust
546 lines
16 KiB
Rust
//! 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
|
||
}
|