Skip to main content

max / audiofiles

26.7 KB · 695 lines History Blame Raw
1 //! Floating MIDI/instrument window: device picker, activity display, mode selector,
2 //! clickable piano keyboard, and ADSR envelope controls.
3
4 use std::time::Instant;
5
6 use egui;
7 use audiofiles_core::instrument::{InstrumentMode, note_name};
8
9 use crate::state::{BrowserState, MidiAction};
10 use super::theme;
11 use super::widgets;
12
13 /// Drag payload for dropping samples onto the keyboard to create zones.
14 #[derive(Clone)]
15 pub struct DragPayload {
16 pub hash: String,
17 pub name: String,
18 }
19
20 /// Draw the floating MIDI/instrument window. Call from the overlay layer.
21 pub fn draw_midi_window(ctx: &egui::Context, state: &mut BrowserState) {
22 let mut open = state.show_midi_window;
23 widgets::tool_window(ctx, "MIDI / Instrument", &mut open, 420.0, 340.0, |ui| {
24 // Empty state hint when no sample is loaded
25 let has_sample = state.shared.instrument.try_lock()
26 .map(|g| !g.zone_buffers.is_empty())
27 .unwrap_or(false);
28 if !has_sample {
29 ui.vertical_centered(|ui| {
30 ui.add_space(theme::space::MD);
31 ui.label(
32 egui::RichText::new("No sample loaded")
33 .color(theme::text_secondary()),
34 );
35 ui.label(
36 egui::RichText::new("Right-click a sample \u{2192} \"Play as Instrument\", or drag samples onto the keyboard below")
37 .small()
38 .color(theme::text_muted()),
39 );
40 ui.add_space(theme::space::MD);
41 });
42 ui.separator();
43 }
44
45 // Section 1: MIDI device picker
46 draw_midi_device_picker(ui, state);
47
48 ui.separator();
49
50 // Section 2: Activity display
51 draw_activity_display(ui, state);
52
53 ui.separator();
54
55 // Section 3: Mode + root + lock
56 draw_mode_controls(ui, state);
57
58 ui.separator();
59
60 // Section 4: Piano keyboard (clickable)
61 draw_piano_keyboard(ui, state);
62
63 ui.separator();
64
65 // Section 5: ADSR controls
66 draw_adsr_controls(ui, state);
67 });
68 state.show_midi_window = open;
69 }
70
71 /// MIDI device picker: port dropdown, refresh, disconnect.
72 /// Only shown when ports are available or a device is connected.
73 fn draw_midi_device_picker(ui: &mut egui::Ui, state: &mut BrowserState) {
74 // Auto-scan on first display so the user doesn't see an empty picker
75 if state.midi_state.available_ports.is_empty()
76 && state.midi_state.connected_port.is_none()
77 && state.midi_pending_action.is_none()
78 {
79 state.midi_pending_action = Some(MidiAction::RefreshPorts);
80 }
81
82 // Empty state: instead of hiding the picker entirely (which leaves the user
83 // with no way to ask the app to look again from inside the panel), render a
84 // muted line + Refresh button so plugging a controller mid-session is
85 // recoverable without closing the window.
86 if state.midi_state.available_ports.is_empty() && state.midi_state.connected_port.is_none() {
87 ui.horizontal(|ui| {
88 ui.label(
89 egui::RichText::new("No MIDI inputs detected")
90 .color(theme::text_muted()),
91 );
92 if ui.small_button("Refresh").clicked() {
93 state.midi_pending_action = Some(MidiAction::RefreshPorts);
94 }
95 });
96 return;
97 }
98
99 ui.horizontal(|ui| {
100 ui.label(egui::RichText::new("MIDI Input").color(theme::text_primary()));
101
102 if ui.small_button("Refresh").clicked() {
103 state.midi_pending_action = Some(MidiAction::RefreshPorts);
104 }
105 });
106
107 ui.horizontal(|ui| {
108 let ports = &state.midi_state.available_ports;
109 let selected_text = state
110 .midi_state
111 .connected_port_name
112 .as_deref()
113 .unwrap_or("(none)");
114
115 egui::ComboBox::from_id_salt("midi_port")
116 .selected_text(selected_text)
117 .width(220.0)
118 .show_ui(ui, |ui| {
119 for (i, name) in ports.iter().enumerate() {
120 let is_current = state.midi_state.connected_port == Some(i);
121 if ui.selectable_label(is_current, name).clicked() && !is_current {
122 state.midi_pending_action = Some(MidiAction::Connect(i));
123 }
124 }
125 });
126
127 if state.midi_state.connected_port.is_some() && ui.small_button("Disconnect").clicked() {
128 state.midi_pending_action = Some(MidiAction::Disconnect);
129 }
130 });
131 }
132
133 /// Recent note activity with fading alpha.
134 fn draw_activity_display(ui: &mut egui::Ui, state: &mut BrowserState) {
135 let now = Instant::now();
136 // Expire notes older than 2 seconds
137 state.midi_state.recent_notes.retain(|n| now.duration_since(n.timestamp).as_secs_f32() < 2.0);
138
139 ui.horizontal(|ui| {
140 if state.midi_state.recent_notes.is_empty() {
141 // Idle copy depends on connection state so the user always has a
142 // ground truth on whether MIDI is wired up (m-7). The dash was
143 // ambiguous against the M-10 empty picker.
144 let idle_text = match state.midi_state.connected_port_name.as_deref() {
145 Some(port) => format!("Connected to {port} \u{00B7} listening"),
146 None => "Not connected".to_string(),
147 };
148 ui.label(
149 egui::RichText::new(idle_text)
150 .small()
151 .color(theme::text_muted()),
152 );
153 } else {
154 for note in state.midi_state.recent_notes.iter().rev().take(8) {
155 let age = now.duration_since(note.timestamp).as_secs_f32();
156 let alpha = ((2.0 - age) / 2.0).clamp(0.0, 1.0);
157 let color = theme::text_primary().linear_multiply(alpha);
158 ui.label(
159 egui::RichText::new(format!("{} v{}", note.note_name, note.velocity))
160 .small()
161 .color(color),
162 );
163 }
164 }
165 });
166 // Request repaint while notes are fading
167 if !state.midi_state.recent_notes.is_empty() {
168 ui.ctx().request_repaint();
169 }
170 }
171
172 /// Mode selector, root note label, lock checkbox.
173 fn draw_mode_controls(ui: &mut egui::Ui, state: &mut BrowserState) {
174 ui.horizontal(|ui| {
175 let mut mode = state.shared.instrument.lock().config.mode;
176 let was_chromatic = mode == InstrumentMode::Chromatic;
177 ui.radio_value(&mut mode, InstrumentMode::Chromatic, "Chromatic")
178 .on_hover_text("Pitch one sample up and down across the keyboard");
179 ui.add_enabled(false, egui::RadioButton::new(
180 mode == InstrumentMode::MultiSample,
181 "Multi-sample",
182 ))
183 .on_hover_text("Drop two or more samples onto the keyboard to enable multi-sample mode");
184 if (mode == InstrumentMode::Chromatic) != was_chromatic {
185 state.shared.instrument.lock().config.mode = mode;
186 }
187
188 ui.separator();
189
190 ui.label(
191 egui::RichText::new(format!("Root: {}", note_name(state.instrument_root_note)))
192 .color(theme::text_secondary()),
193 );
194
195 ui.separator();
196
197 ui.checkbox(&mut state.instrument_locked, "Lock sample")
198 .on_hover_text("Keep the current sample loaded as the table selection changes");
199 });
200 }
201
202 /// Draw a 3-octave piano keyboard with click-to-play, active voice highlighting, and zone overlays.
203 fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) {
204 let base_octave = (state.instrument_root_note / 12).saturating_sub(1) as i32;
205 let num_octaves = 3;
206
207 // Octave navigation: regular ui.button (≈28px) instead of small_button
208 // (≈16px) so the targets clear the Fitts floor for a control users hit
209 // repeatedly. `[` / `]` shortcuts match DAW convention.
210 let mut octave_down = false;
211 let mut octave_up = false;
212 ui.horizontal(|ui| {
213 if ui
214 .button("-")
215 .on_hover_text("Octave down ([)")
216 .clicked()
217 {
218 octave_down = true;
219 }
220 ui.label(
221 egui::RichText::new(format!("Oct {}", base_octave))
222 .small()
223 .color(theme::text_secondary()),
224 );
225 if ui
226 .button("+")
227 .on_hover_text("Octave up (])")
228 .clicked()
229 {
230 octave_up = true;
231 }
232 });
233 // Keyboard shortcuts. Only consume keys when no text input is focused,
234 // otherwise typing `[` into a tag field would scroll the octave.
235 let no_focus = ui.ctx().memory(|m| m.focused().is_none());
236 if no_focus {
237 if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) {
238 octave_down = true;
239 }
240 if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) {
241 octave_up = true;
242 }
243 }
244 if octave_down && state.instrument_root_note >= 12 {
245 state.instrument_root_note -= 12;
246 if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() {
247 zone.root_note = state.instrument_root_note;
248 }
249 }
250 if octave_up && state.instrument_root_note <= 115 {
251 state.instrument_root_note += 12;
252 if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() {
253 zone.root_note = state.instrument_root_note;
254 }
255 }
256
257 // Piano keys
258 let key_width = 16.0_f32;
259 let white_height = 70.0_f32;
260 let black_height = 42.0_f32;
261 let black_width = 10.0_f32;
262 let zone_bar_height = 8.0_f32;
263
264 let start_note = (base_octave * 12 + 12) as u8;
265 let end_note = start_note + (num_octaves * 12) as u8;
266 let white_notes = white_keys_in_range(start_note, end_note);
267 let total_width = white_notes.len() as f32 * key_width;
268
269 // Snapshot zone info
270 let zone_ranges: Vec<(u8, u8, u8)> = state
271 .shared
272 .instrument
273 .try_lock()
274 .map(|guard| {
275 guard
276 .zone_buffers
277 .iter()
278 .map(|z| (z.low_note, z.high_note, z.root_note))
279 .collect()
280 })
281 .unwrap_or_default();
282 let num_zones = zone_ranges.len();
283
284 let total_height = white_height + if num_zones > 1 { zone_bar_height * num_zones as f32 + 2.0 } else { 0.0 };
285
286 let (response, painter) = ui.allocate_painter(
287 egui::vec2(total_width, total_height),
288 egui::Sense::click_and_drag(),
289 );
290 let rect = response.rect;
291
292 // Get active voices for highlighting
293 let active_notes: Vec<u8> = state
294 .shared
295 .instrument
296 .try_lock()
297 .map(|guard| {
298 guard
299 .voices
300 .iter()
301 .filter(|v| v.active)
302 .map(|v| v.note)
303 .collect()
304 })
305 .unwrap_or_default();
306
307 let note_to_x = |note: u8| -> f32 {
308 let white_count = white_keys_in_range(start_note, note).len() as f32;
309 rect.min.x + white_count * key_width
310 };
311
312 // Build a list of all black key rects for hit testing (black keys take priority)
313 let mut black_key_rects: Vec<(egui::Rect, u8)> = Vec::new();
314 {
315 let mut white_x = 0.0_f32;
316 for &note in &white_notes {
317 let pitch_class = note % 12;
318 if matches!(pitch_class, 0 | 2 | 5 | 7 | 9) {
319 let black_note = note + 1;
320 if black_note < end_note {
321 let bx = rect.min.x + white_x + key_width - black_width / 2.0;
322 let kr = egui::Rect::from_min_size(
323 egui::pos2(bx, rect.min.y),
324 egui::vec2(black_width, black_height),
325 );
326 black_key_rects.push((kr, black_note));
327 }
328 }
329 white_x += key_width;
330 }
331 }
332
333 // Precompute zone-removal chip rects. M-8 moves zone removal off of the
334 // shared secondary-click (which previously did two different things on the
335 // same response) onto an explicit chip per bar; right-click is now
336 // reserved for "set root note" on keys.
337 let chip_size = (zone_bar_height - 2.0).max(6.0);
338 let chip_rects: Vec<egui::Rect> = if num_zones > 1 {
339 zone_ranges
340 .iter()
341 .enumerate()
342 .map(|(i, (_, high, _))| {
343 let x_end = note_to_x((*high).min(end_note.saturating_sub(1)) + 1);
344 let y = rect.min.y + white_height + 2.0 + i as f32 * zone_bar_height;
345 let chip_x = (x_end - chip_size).max(rect.min.x);
346 egui::Rect::from_min_size(
347 egui::pos2(chip_x, y),
348 egui::vec2(chip_size, chip_size),
349 )
350 })
351 .collect()
352 } else {
353 Vec::new()
354 };
355
356 // Determine which note the pointer is over (for click-to-play). Suppress
357 // the lookup when the pointer is below the keys (zone-bar area) or over a
358 // removal chip — previously a click in the zone-bar area silently played
359 // whichever white key sat directly above.
360 let pointer_note: Option<u8> = response.interact_pointer_pos().and_then(|pos| {
361 if chip_rects.iter().any(|r| r.contains(pos)) {
362 return None;
363 }
364 if pos.y >= rect.min.y + white_height {
365 return None;
366 }
367 // Check black keys first (they overlay white keys)
368 for &(kr, note) in &black_key_rects {
369 if kr.contains(pos) {
370 return Some(note);
371 }
372 }
373 // Then white keys
374 let rel_x = pos.x - rect.min.x;
375 let white_idx = (rel_x / key_width) as usize;
376 white_notes.get(white_idx).copied()
377 });
378
379 // Primary-click on a chip removes that zone. Hit-tested here (before key
380 // play handling has run for the click) so the click doesn't double as a
381 // note play.
382 let chip_clicked_index: Option<usize> = if response.clicked() {
383 response.interact_pointer_pos().and_then(|pos| {
384 chip_rects.iter().position(|r| r.contains(pos))
385 })
386 } else {
387 None
388 };
389
390 // Handle pointer-down: note_on for newly pressed notes (left-click only)
391 if response.is_pointer_button_down_on() && !ui.input(|i| i.pointer.secondary_down()) {
392 if let Some(note) = pointer_note {
393 if !state.piano_held_notes.contains(&note) {
394 state.shared.instrument.lock().note_on(note, 100);
395 state.piano_held_notes.push(note);
396 }
397 // Release notes that are no longer under the pointer (drag across keys)
398 let to_release: Vec<u8> = state.piano_held_notes.iter()
399 .filter(|&&n| n != note)
400 .copied()
401 .collect();
402 for n in to_release {
403 state.shared.instrument.lock().note_off(n);
404 state.piano_held_notes.retain(|&held| held != n);
405 }
406 }
407 }
408
409 // Handle pointer-up: release all held notes
410 if response.drag_stopped() || (!response.is_pointer_button_down_on() && !state.piano_held_notes.is_empty()) {
411 let held = std::mem::take(&mut state.piano_held_notes);
412 for n in held {
413 state.shared.instrument.lock().note_off(n);
414 }
415 }
416
417 // Tooltip: right-click hint
418 response.clone().on_hover_text("Click to play \u{2022} Right-click to set root note \u{2022} Drag samples here to load \u{2022} Click the X on a zone bar to remove it");
419
420 // Right-click to set root note
421 if response.secondary_clicked() {
422 if let Some(note) = pointer_note {
423 state.instrument_root_note = note;
424 if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() {
425 zone.root_note = note;
426 }
427 }
428 }
429
430 // Draw white keys
431 let mut white_x = 0.0_f32;
432 for &note in &white_notes {
433 let key_rect = egui::Rect::from_min_size(
434 rect.min + egui::vec2(white_x, 0.0),
435 egui::vec2(key_width - 1.0, white_height),
436 );
437
438 let is_active = active_notes.contains(&note) || state.piano_held_notes.contains(&note);
439 let is_root = note == state.instrument_root_note;
440
441 let fill = if is_active {
442 theme::accent_blue()
443 } else {
444 theme::piano_white_key()
445 };
446
447 painter.rect_filled(key_rect, 2.0, fill);
448 painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, theme::border_default()), egui::StrokeKind::Outside);
449
450 if is_root {
451 let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 8.0);
452 painter.circle_filled(dot_center, 3.0, theme::accent_purple());
453 }
454
455 white_x += key_width;
456 }
457
458 // Draw black keys on top
459 for &(key_rect, black_note) in &black_key_rects {
460 let is_active = active_notes.contains(&black_note) || state.piano_held_notes.contains(&black_note);
461 let is_root = black_note == state.instrument_root_note;
462
463 let fill = if is_active {
464 theme::accent_blue()
465 } else {
466 theme::piano_black_key()
467 };
468
469 painter.rect_filled(key_rect, 2.0, fill);
470
471 if is_root {
472 let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 5.0);
473 painter.circle_filled(dot_center, 3.0, theme::accent_purple());
474 }
475 }
476
477 // Draw zone bars (multi-sample mode). Each bar gets a small X chip at its
478 // right edge for removal — see chip_rects above for hit-testing and
479 // chip_clicked_index for the click consumption.
480 if num_zones > 1 {
481 let zone_colors = [
482 theme::accent_blue(),
483 theme::accent_green(),
484 theme::accent_yellow(),
485 theme::accent_purple(),
486 theme::accent_cyan(),
487 theme::accent_red(),
488 ];
489
490 for (i, (low, high, _root)) in zone_ranges.iter().enumerate() {
491 let x_start = note_to_x(*low);
492 let x_end = note_to_x((*high).min(end_note.saturating_sub(1)) + 1);
493 let y = rect.min.y + white_height + 2.0 + i as f32 * zone_bar_height;
494 let bar_rect = egui::Rect::from_min_max(
495 egui::pos2(x_start, y),
496 egui::pos2(x_end, y + zone_bar_height - 1.0),
497 );
498 let color = zone_colors[i % zone_colors.len()];
499 painter.rect_filled(bar_rect, 2.0, color.linear_multiply(0.6));
500
501 // Paint the X chip at the right end of the bar. Background is a
502 // slightly darker overlay so the X reads against the bar fill.
503 if let Some(chip_rect) = chip_rects.get(i) {
504 painter.rect_filled(*chip_rect, 1.0, color.linear_multiply(0.3));
505 let pad = 2.0;
506 let p1 = chip_rect.min + egui::vec2(pad, pad);
507 let p2 = chip_rect.max - egui::vec2(pad, pad);
508 let p3 = egui::pos2(chip_rect.min.x + pad, chip_rect.max.y - pad);
509 let p4 = egui::pos2(chip_rect.max.x - pad, chip_rect.min.y + pad);
510 let stroke = egui::Stroke::new(1.2, theme::text_primary());
511 painter.line_segment([p1, p2], stroke);
512 painter.line_segment([p3, p4], stroke);
513 }
514 }
515
516 if let Some(idx) = chip_clicked_index {
517 state.remove_instrument_zone(idx);
518 }
519 }
520
521 // Drop-hover feedback: while a sample is being dragged over the keyboard,
522 // paint a translucent accent overlay on the white key under the cursor and
523 // show a tooltip naming the target note (m-6). Without this the drop
524 // interaction is invisible until the user commits.
525 let dragged_payload = egui::DragAndDrop::payload::<DragPayload>(ui.ctx());
526 if dragged_payload.is_some() && response.hovered() {
527 if let Some(pos) = ui.input(|i| i.pointer.latest_pos()) {
528 if pos.y < rect.min.y + white_height {
529 let rel_x = (pos.x - rect.min.x).max(0.0);
530 let white_idx = (rel_x / key_width) as usize;
531 if let Some(&root_note) = white_notes.get(white_idx) {
532 let key_x = rect.min.x + white_idx as f32 * key_width;
533 let hover_rect = egui::Rect::from_min_size(
534 egui::pos2(key_x, rect.min.y),
535 egui::vec2(key_width - 1.0, white_height),
536 );
537 painter.rect_filled(
538 hover_rect,
539 2.0,
540 theme::accent_blue().linear_multiply(0.3),
541 );
542 egui::show_tooltip_at_pointer(
543 ui.ctx(),
544 ui.layer_id(),
545 egui::Id::new("piano_drop_hint"),
546 |ui| {
547 ui.label(format!(
548 "Drop to create a zone centered on {}",
549 note_name(root_note),
550 ));
551 },
552 );
553 }
554 }
555 }
556 }
557
558 // Handle drop: create zone at the dropped note
559 if let Some(payload) = response.dnd_release_payload::<DragPayload>() {
560 if let Some(pos) = ui.input(|i| i.pointer.latest_pos()) {
561 let rel_x = pos.x - rect.min.x;
562 let white_idx = (rel_x / key_width) as usize;
563 if let Some(&root_note) = white_notes.get(white_idx) {
564 let low = root_note.saturating_sub(6);
565 let high = (root_note + 6).min(127);
566 state.add_instrument_zone(&payload.hash, &payload.name, low, high, root_note);
567 }
568 }
569 }
570 }
571
572 /// Return the white key MIDI note numbers in a range.
573 fn white_keys_in_range(start: u8, end: u8) -> Vec<u8> {
574 (start..end)
575 .filter(|n| matches!(n % 12, 0 | 2 | 4 | 5 | 7 | 9 | 11))
576 .collect()
577 }
578
579 /// Draw ADSR envelope sliders, with a live envelope-shape preview above so the
580 /// effect of each parameter is visible before the user releases the slider.
581 fn draw_adsr_controls(ui: &mut egui::Ui, state: &mut BrowserState) {
582 let mut envelope = state.shared.instrument.lock().config.envelope;
583
584 // Presets row. Each tuple is (label, A, D, S, R). Loaded values are
585 // playback-tested and meant as shortcuts, not authoritative — the sliders
586 // remain editable after a preset click (p-3).
587 let presets: &[(&str, f32, f32, f32, f32)] = &[
588 ("Default", 0.005, 0.05, 0.8, 0.10),
589 ("Pluck", 0.001, 0.20, 0.0, 0.20),
590 ("Pad", 0.80, 0.30, 0.8, 1.50),
591 ("Stab", 0.001, 0.05, 0.0, 0.05),
592 ];
593 ui.horizontal(|ui| {
594 ui.label(egui::RichText::new("Preset").small().color(theme::text_secondary()));
595 for (label, a, d, s, r) in presets {
596 // Highlight a preset when the envelope matches it exactly. Float
597 // compare is intentional: any user-driven slider edit drops the
598 // highlight, which is the correct signal.
599 let active = (envelope.attack - *a).abs() < 1e-4
600 && (envelope.decay - *d).abs() < 1e-4
601 && (envelope.sustain - *s).abs() < 1e-4
602 && (envelope.release - *r).abs() < 1e-4;
603 if ui.selectable_label(active, *label).clicked() {
604 envelope.attack = *a;
605 envelope.decay = *d;
606 envelope.sustain = *s;
607 envelope.release = *r;
608 }
609 }
610 });
611
612 draw_adsr_envelope_shape(ui, envelope.attack, envelope.decay, envelope.sustain, envelope.release);
613
614 ui.horizontal(|ui| {
615 ui.label(egui::RichText::new("A").small().color(theme::text_secondary()))
616 .on_hover_text("Attack — time to reach full volume after key press");
617 let slider = egui::Slider::new(&mut envelope.attack, 0.001..=5.0)
618 .logarithmic(true)
619 .max_decimals(3)
620 .suffix("s");
621 ui.add(slider);
622
623 ui.label(egui::RichText::new("D").small().color(theme::text_secondary()))
624 .on_hover_text("Decay — time to fall from peak to sustain level");
625 let slider = egui::Slider::new(&mut envelope.decay, 0.001..=5.0)
626 .logarithmic(true)
627 .max_decimals(3)
628 .suffix("s");
629 ui.add(slider);
630 });
631
632 ui.horizontal(|ui| {
633 ui.label(egui::RichText::new("S").small().color(theme::text_secondary()))
634 .on_hover_text("Sustain — held volume level while the key is down (0 to 1)");
635 let slider = egui::Slider::new(&mut envelope.sustain, 0.0..=1.0)
636 .max_decimals(2);
637 ui.add(slider);
638
639 ui.label(egui::RichText::new("R").small().color(theme::text_secondary()))
640 .on_hover_text("Release — time to fade to silence after key release");
641 let slider = egui::Slider::new(&mut envelope.release, 0.001..=10.0)
642 .logarithmic(true)
643 .max_decimals(3)
644 .suffix("s");
645 ui.add(slider);
646 });
647
648 state.shared.instrument.lock().config.envelope = envelope;
649 }
650
651 /// Paint a tiny ADSR envelope diagram (~40px tall) above the sliders so the
652 /// shape changes are visible live. Times are mapped log-ish so very short
653 /// attacks/releases still register visually.
654 fn draw_adsr_envelope_shape(ui: &mut egui::Ui, attack: f32, decay: f32, sustain: f32, release: f32) {
655 let avail = ui.available_width().min(240.0);
656 let (rect, _) = ui.allocate_exact_size(egui::vec2(avail, 40.0), egui::Sense::hover());
657 let painter = ui.painter_at(rect);
658
659 // Background frame
660 painter.rect_filled(rect, 2.0, theme::bg_tertiary());
661
662 // Map each phase to a horizontal slice. Use a soft log so 0.001s isn't a
663 // single pixel: weight = (1 + t).ln() with t in seconds, clamped.
664 let w = |t: f32| (1.0 + t.max(0.0)).ln();
665 let wa = w(attack);
666 let wd = w(decay);
667 // Treat sustain as a fixed visual width so the user can always see the
668 // hold segment — it's volume-axis, not time-axis.
669 let ws: f32 = 0.6;
670 let wr = w(release);
671 let total = (wa + wd + ws + wr).max(0.001);
672 let usable = rect.width() - 4.0;
673 let x0 = rect.left() + 2.0;
674 let x_a = x0 + (wa / total) * usable;
675 let x_d = x_a + (wd / total) * usable;
676 let x_s = x_d + (ws / total) * usable;
677 let x_r = x_s + (wr / total) * usable;
678
679 let y_peak = rect.top() + 4.0;
680 let y_base = rect.bottom() - 4.0;
681 let y_sustain = y_base + (y_peak - y_base) * sustain.clamp(0.0, 1.0);
682
683 let stroke = egui::Stroke::new(1.5, theme::accent_blue());
684 let p0 = egui::pos2(x0, y_base);
685 let p_a = egui::pos2(x_a, y_peak);
686 let p_d = egui::pos2(x_d, y_sustain);
687 let p_s = egui::pos2(x_s, y_sustain);
688 let p_r = egui::pos2(x_r, y_base);
689
690 painter.line_segment([p0, p_a], stroke);
691 painter.line_segment([p_a, p_d], stroke);
692 painter.line_segment([p_d, p_s], stroke);
693 painter.line_segment([p_s, p_r], stroke);
694 }
695