Skip to main content

max / audiofiles

27.2 KB · 702 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 // Surface the Multi-sample precondition inline (not hover-only) while in
202 // Chromatic mode, so the permanently-disabled radio doesn't read as a
203 // dead-end. The mode flips automatically once a second sample is dropped.
204 if state.shared.instrument.lock().config.mode == InstrumentMode::Chromatic {
205 ui.label(
206 egui::RichText::new("Multi-sample activates after you drop a second sample onto the keyboard.")
207 .small()
208 .color(theme::text_muted()),
209 );
210 }
211 }
212
213 /// Draw a 3-octave piano keyboard with click-to-play, active voice highlighting, and zone overlays.
214 fn draw_piano_keyboard(ui: &mut egui::Ui, state: &mut BrowserState) {
215 let base_octave = (state.instrument_root_note / 12).saturating_sub(1) as i32;
216 let num_octaves = 3;
217
218 // Octave navigation: regular ui.button (≈28px) instead of small_button
219 // (≈16px) so the targets clear the Fitts floor for a control users hit
220 // repeatedly. `[` / `]` shortcuts match DAW convention.
221 let mut octave_down = false;
222 let mut octave_up = false;
223 ui.horizontal(|ui| {
224 if ui
225 .button("-")
226 .on_hover_text("Octave down ([)")
227 .clicked()
228 {
229 octave_down = true;
230 }
231 ui.label(
232 egui::RichText::new(format!("Oct {}", base_octave))
233 .small()
234 .color(theme::text_secondary()),
235 );
236 if ui
237 .button("+")
238 .on_hover_text("Octave up (])")
239 .clicked()
240 {
241 octave_up = true;
242 }
243 });
244 // Keyboard shortcuts. Only consume keys when no text input is focused,
245 // otherwise typing `[` into a tag field would scroll the octave.
246 let no_focus = ui.ctx().memory(|m| m.focused().is_none());
247 if no_focus {
248 if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::OpenBracket)) {
249 octave_down = true;
250 }
251 if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::CloseBracket)) {
252 octave_up = true;
253 }
254 }
255 if octave_down && state.instrument_root_note >= 12 {
256 state.instrument_root_note -= 12;
257 if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() {
258 zone.root_note = state.instrument_root_note;
259 }
260 }
261 if octave_up && state.instrument_root_note <= 115 {
262 state.instrument_root_note += 12;
263 if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() {
264 zone.root_note = state.instrument_root_note;
265 }
266 }
267
268 // Piano keys
269 let key_width = 16.0_f32;
270 let white_height = 70.0_f32;
271 let black_height = 42.0_f32;
272 let black_width = 10.0_f32;
273 let zone_bar_height = 8.0_f32;
274
275 let start_note = (base_octave * 12 + 12) as u8;
276 let end_note = start_note + (num_octaves * 12) as u8;
277 let white_notes = white_keys_in_range(start_note, end_note);
278 let total_width = white_notes.len() as f32 * key_width;
279
280 // Snapshot zone info
281 let zone_ranges: Vec<(u8, u8, u8)> = state
282 .shared
283 .instrument
284 .try_lock()
285 .map(|guard| {
286 guard
287 .zone_buffers
288 .iter()
289 .map(|z| (z.low_note, z.high_note, z.root_note))
290 .collect()
291 })
292 .unwrap_or_default();
293 let num_zones = zone_ranges.len();
294
295 let total_height = white_height + if num_zones > 1 { zone_bar_height * num_zones as f32 + 2.0 } else { 0.0 };
296
297 let (response, painter) = ui.allocate_painter(
298 egui::vec2(total_width, total_height),
299 egui::Sense::click_and_drag(),
300 );
301 let rect = response.rect;
302
303 // Get active voices for highlighting
304 let active_notes: Vec<u8> = state
305 .shared
306 .instrument
307 .try_lock()
308 .map(|guard| {
309 guard
310 .voices
311 .iter()
312 .filter(|v| v.active)
313 .map(|v| v.note)
314 .collect()
315 })
316 .unwrap_or_default();
317
318 let note_to_x = |note: u8| -> f32 {
319 let white_count = white_keys_in_range(start_note, note).len() as f32;
320 rect.min.x + white_count * key_width
321 };
322
323 // Build a list of all black key rects for hit testing (black keys take priority)
324 let mut black_key_rects: Vec<(egui::Rect, u8)> = Vec::new();
325 {
326 let mut white_x = 0.0_f32;
327 for &note in &white_notes {
328 let pitch_class = note % 12;
329 if matches!(pitch_class, 0 | 2 | 5 | 7 | 9) {
330 let black_note = note + 1;
331 if black_note < end_note {
332 let bx = rect.min.x + white_x + key_width - black_width / 2.0;
333 let kr = egui::Rect::from_min_size(
334 egui::pos2(bx, rect.min.y),
335 egui::vec2(black_width, black_height),
336 );
337 black_key_rects.push((kr, black_note));
338 }
339 }
340 white_x += key_width;
341 }
342 }
343
344 // Precompute zone-removal chip rects. M-8 moves zone removal off of the
345 // shared secondary-click (which previously did two different things on the
346 // same response) onto an explicit chip per bar; right-click is now
347 // reserved for "set root note" on keys.
348 let chip_size = (zone_bar_height - 2.0).max(6.0);
349 let chip_rects: Vec<egui::Rect> = if num_zones > 1 {
350 zone_ranges
351 .iter()
352 .enumerate()
353 .map(|(i, (_, high, _))| {
354 let x_end = note_to_x((*high).min(end_note.saturating_sub(1)) + 1);
355 let y = rect.min.y + white_height + 2.0 + i as f32 * zone_bar_height;
356 let chip_x = (x_end - chip_size).max(rect.min.x);
357 egui::Rect::from_min_size(
358 egui::pos2(chip_x, y),
359 egui::vec2(chip_size, chip_size),
360 )
361 })
362 .collect()
363 } else {
364 Vec::new()
365 };
366
367 // Determine which note the pointer is over (for click-to-play). Suppress
368 // the lookup when the pointer is below the keys (zone-bar area) or over a
369 // removal chip — previously a click in the zone-bar area silently played
370 // whichever white key sat directly above.
371 let pointer_note: Option<u8> = response.interact_pointer_pos().and_then(|pos| {
372 if chip_rects.iter().any(|r| r.contains(pos)) {
373 return None;
374 }
375 if pos.y >= rect.min.y + white_height {
376 return None;
377 }
378 // Check black keys first (they overlay white keys)
379 for &(kr, note) in &black_key_rects {
380 if kr.contains(pos) {
381 return Some(note);
382 }
383 }
384 // Then white keys
385 let rel_x = pos.x - rect.min.x;
386 let white_idx = (rel_x / key_width) as usize;
387 white_notes.get(white_idx).copied()
388 });
389
390 // Primary-click on a chip removes that zone. Hit-tested here (before key
391 // play handling has run for the click) so the click doesn't double as a
392 // note play.
393 let chip_clicked_index: Option<usize> = if response.clicked() {
394 response.interact_pointer_pos().and_then(|pos| {
395 chip_rects.iter().position(|r| r.contains(pos))
396 })
397 } else {
398 None
399 };
400
401 // Handle pointer-down: note_on for newly pressed notes (left-click only)
402 if response.is_pointer_button_down_on() && !ui.input(|i| i.pointer.secondary_down())
403 && let Some(note) = pointer_note {
404 if !state.piano_held_notes.contains(&note) {
405 state.shared.instrument.lock().note_on(note, 100);
406 state.piano_held_notes.push(note);
407 }
408 // Release notes that are no longer under the pointer (drag across keys)
409 let to_release: Vec<u8> = state.piano_held_notes.iter()
410 .filter(|&&n| n != note)
411 .copied()
412 .collect();
413 for n in to_release {
414 state.shared.instrument.lock().note_off(n);
415 state.piano_held_notes.retain(|&held| held != n);
416 }
417 }
418
419 // Handle pointer-up: release all held notes
420 if response.drag_stopped() || (!response.is_pointer_button_down_on() && !state.piano_held_notes.is_empty()) {
421 let held = std::mem::take(&mut state.piano_held_notes);
422 for n in held {
423 state.shared.instrument.lock().note_off(n);
424 }
425 }
426
427 // Tooltip: right-click hint
428 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");
429
430 // Right-click to set root note
431 if response.secondary_clicked()
432 && let Some(note) = pointer_note {
433 state.instrument_root_note = note;
434 if let Some(zone) = state.shared.instrument.lock().zone_buffers.first_mut() {
435 zone.root_note = note;
436 }
437 }
438
439 // Draw white keys
440 let mut white_x = 0.0_f32;
441 for &note in &white_notes {
442 let key_rect = egui::Rect::from_min_size(
443 rect.min + egui::vec2(white_x, 0.0),
444 egui::vec2(key_width - 1.0, white_height),
445 );
446
447 let is_active = active_notes.contains(&note) || state.piano_held_notes.contains(&note);
448 let is_root = note == state.instrument_root_note;
449
450 let fill = if is_active {
451 theme::accent_blue()
452 } else {
453 theme::piano_white_key()
454 };
455
456 painter.rect_filled(key_rect, 2.0, fill);
457 painter.rect_stroke(key_rect, 2.0, egui::Stroke::new(1.0, theme::border_default()), egui::StrokeKind::Outside);
458
459 if is_root {
460 let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 8.0);
461 painter.circle_filled(dot_center, 3.0, theme::accent_purple());
462 }
463
464 white_x += key_width;
465 }
466
467 // Draw black keys on top
468 for &(key_rect, black_note) in &black_key_rects {
469 let is_active = active_notes.contains(&black_note) || state.piano_held_notes.contains(&black_note);
470 let is_root = black_note == state.instrument_root_note;
471
472 let fill = if is_active {
473 theme::accent_blue()
474 } else {
475 theme::piano_black_key()
476 };
477
478 painter.rect_filled(key_rect, 2.0, fill);
479
480 if is_root {
481 let dot_center = key_rect.center_bottom() - egui::vec2(0.0, 5.0);
482 painter.circle_filled(dot_center, 3.0, theme::accent_purple());
483 }
484 }
485
486 // Draw zone bars (multi-sample mode). Each bar gets a small X chip at its
487 // right edge for removal — see chip_rects above for hit-testing and
488 // chip_clicked_index for the click consumption.
489 if num_zones > 1 {
490 let zone_colors = [
491 theme::accent_blue(),
492 theme::accent_green(),
493 theme::accent_yellow(),
494 theme::accent_purple(),
495 theme::accent_cyan(),
496 theme::accent_red(),
497 ];
498
499 for (i, (low, high, _root)) in zone_ranges.iter().enumerate() {
500 let x_start = note_to_x(*low);
501 let x_end = note_to_x((*high).min(end_note.saturating_sub(1)) + 1);
502 let y = rect.min.y + white_height + 2.0 + i as f32 * zone_bar_height;
503 let bar_rect = egui::Rect::from_min_max(
504 egui::pos2(x_start, y),
505 egui::pos2(x_end, y + zone_bar_height - 1.0),
506 );
507 let color = zone_colors[i % zone_colors.len()];
508 painter.rect_filled(bar_rect, 2.0, color.linear_multiply(0.6));
509
510 // Paint the X chip at the right end of the bar. Background is a
511 // slightly darker overlay so the X reads against the bar fill.
512 if let Some(chip_rect) = chip_rects.get(i) {
513 painter.rect_filled(*chip_rect, 1.0, color.linear_multiply(0.3));
514 let pad = 2.0;
515 let p1 = chip_rect.min + egui::vec2(pad, pad);
516 let p2 = chip_rect.max - egui::vec2(pad, pad);
517 let p3 = egui::pos2(chip_rect.min.x + pad, chip_rect.max.y - pad);
518 let p4 = egui::pos2(chip_rect.max.x - pad, chip_rect.min.y + pad);
519 let stroke = egui::Stroke::new(1.2, theme::text_primary());
520 painter.line_segment([p1, p2], stroke);
521 painter.line_segment([p3, p4], stroke);
522 }
523 }
524
525 if let Some(idx) = chip_clicked_index {
526 state.remove_instrument_zone(idx);
527 }
528 }
529
530 // Drop-hover feedback: while a sample is being dragged over the keyboard,
531 // paint a translucent accent overlay on the white key under the cursor and
532 // show a tooltip naming the target note (m-6). Without this the drop
533 // interaction is invisible until the user commits.
534 let dragged_payload = egui::DragAndDrop::payload::<DragPayload>(ui.ctx());
535 if dragged_payload.is_some() && response.hovered()
536 && let Some(pos) = ui.input(|i| i.pointer.latest_pos())
537 && pos.y < rect.min.y + white_height {
538 let rel_x = (pos.x - rect.min.x).max(0.0);
539 let white_idx = (rel_x / key_width) as usize;
540 if let Some(&root_note) = white_notes.get(white_idx) {
541 let key_x = rect.min.x + white_idx as f32 * key_width;
542 let hover_rect = egui::Rect::from_min_size(
543 egui::pos2(key_x, rect.min.y),
544 egui::vec2(key_width - 1.0, white_height),
545 );
546 painter.rect_filled(
547 hover_rect,
548 2.0,
549 theme::accent_blue().linear_multiply(0.3),
550 );
551 egui::Tooltip::always_open(
552 ui.ctx().clone(),
553 ui.layer_id(),
554 egui::Id::new("piano_drop_hint"),
555 egui::PopupAnchor::Pointer,
556 )
557 .show(|ui| {
558 ui.label(format!(
559 "Drop to create a zone centered on {}",
560 note_name(root_note),
561 ));
562 });
563 }
564 }
565
566 // Handle drop: create zone at the dropped note
567 if let Some(payload) = response.dnd_release_payload::<DragPayload>()
568 && let Some(pos) = ui.input(|i| i.pointer.latest_pos()) {
569 let rel_x = pos.x - rect.min.x;
570 let white_idx = (rel_x / key_width) as usize;
571 if let Some(&root_note) = white_notes.get(white_idx) {
572 let low = root_note.saturating_sub(6);
573 let high = (root_note + 6).min(127);
574 state.add_instrument_zone(&payload.hash, &payload.name, low, high, root_note);
575 }
576 }
577 }
578
579 /// Return the white key MIDI note numbers in a range.
580 fn white_keys_in_range(start: u8, end: u8) -> Vec<u8> {
581 (start..end)
582 .filter(|n| matches!(n % 12, 0 | 2 | 4 | 5 | 7 | 9 | 11))
583 .collect()
584 }
585
586 /// Draw ADSR envelope sliders, with a live envelope-shape preview above so the
587 /// effect of each parameter is visible before the user releases the slider.
588 fn draw_adsr_controls(ui: &mut egui::Ui, state: &mut BrowserState) {
589 let mut envelope = state.shared.instrument.lock().config.envelope;
590
591 // Presets row. Each tuple is (label, A, D, S, R). Loaded values are
592 // playback-tested and meant as shortcuts, not authoritative — the sliders
593 // remain editable after a preset click (p-3).
594 let presets: &[(&str, f32, f32, f32, f32)] = &[
595 ("Default", 0.005, 0.05, 0.8, 0.10),
596 ("Pluck", 0.001, 0.20, 0.0, 0.20),
597 ("Pad", 0.80, 0.30, 0.8, 1.50),
598 ("Stab", 0.001, 0.05, 0.0, 0.05),
599 ];
600 ui.horizontal(|ui| {
601 ui.label(egui::RichText::new("Preset").small().color(theme::text_secondary()));
602 for (label, a, d, s, r) in presets {
603 // Highlight a preset when the envelope matches it exactly. Float
604 // compare is intentional: any user-driven slider edit drops the
605 // highlight, which is the correct signal.
606 let active = (envelope.attack - *a).abs() < 1e-4
607 && (envelope.decay - *d).abs() < 1e-4
608 && (envelope.sustain - *s).abs() < 1e-4
609 && (envelope.release - *r).abs() < 1e-4;
610 if ui.selectable_label(active, *label).clicked() {
611 envelope.attack = *a;
612 envelope.decay = *d;
613 envelope.sustain = *s;
614 envelope.release = *r;
615 }
616 }
617 });
618
619 draw_adsr_envelope_shape(ui, envelope.attack, envelope.decay, envelope.sustain, envelope.release);
620
621 ui.horizontal(|ui| {
622 ui.label(egui::RichText::new("A").small().color(theme::text_secondary()))
623 .on_hover_text("Attack — time to reach full volume after key press");
624 let slider = egui::Slider::new(&mut envelope.attack, 0.001..=5.0)
625 .logarithmic(true)
626 .max_decimals(3)
627 .suffix("s");
628 ui.add(slider);
629
630 ui.label(egui::RichText::new("D").small().color(theme::text_secondary()))
631 .on_hover_text("Decay — time to fall from peak to sustain level");
632 let slider = egui::Slider::new(&mut envelope.decay, 0.001..=5.0)
633 .logarithmic(true)
634 .max_decimals(3)
635 .suffix("s");
636 ui.add(slider);
637 });
638
639 ui.horizontal(|ui| {
640 ui.label(egui::RichText::new("S").small().color(theme::text_secondary()))
641 .on_hover_text("Sustain — held volume level while the key is down (0 to 1)");
642 let slider = egui::Slider::new(&mut envelope.sustain, 0.0..=1.0)
643 .max_decimals(2);
644 ui.add(slider);
645
646 ui.label(egui::RichText::new("R").small().color(theme::text_secondary()))
647 .on_hover_text("Release — time to fade to silence after key release");
648 let slider = egui::Slider::new(&mut envelope.release, 0.001..=10.0)
649 .logarithmic(true)
650 .max_decimals(3)
651 .suffix("s");
652 ui.add(slider);
653 });
654
655 state.shared.instrument.lock().config.envelope = envelope;
656 }
657
658 /// Paint a tiny ADSR envelope diagram (~40px tall) above the sliders so the
659 /// shape changes are visible live. Times are mapped log-ish so very short
660 /// attacks/releases still register visually.
661 fn draw_adsr_envelope_shape(ui: &mut egui::Ui, attack: f32, decay: f32, sustain: f32, release: f32) {
662 let avail = ui.available_width().min(240.0);
663 let (rect, _) = ui.allocate_exact_size(egui::vec2(avail, 40.0), egui::Sense::hover());
664 let painter = ui.painter_at(rect);
665
666 // Background frame
667 painter.rect_filled(rect, 2.0, theme::bg_tertiary());
668
669 // Map each phase to a horizontal slice. Use a soft log so 0.001s isn't a
670 // single pixel: weight = (1 + t).ln() with t in seconds, clamped.
671 let w = |t: f32| (1.0 + t.max(0.0)).ln();
672 let wa = w(attack);
673 let wd = w(decay);
674 // Treat sustain as a fixed visual width so the user can always see the
675 // hold segment — it's volume-axis, not time-axis.
676 let ws: f32 = 0.6;
677 let wr = w(release);
678 let total = (wa + wd + ws + wr).max(0.001);
679 let usable = rect.width() - 4.0;
680 let x0 = rect.left() + 2.0;
681 let x_a = x0 + (wa / total) * usable;
682 let x_d = x_a + (wd / total) * usable;
683 let x_s = x_d + (ws / total) * usable;
684 let x_r = x_s + (wr / total) * usable;
685
686 let y_peak = rect.top() + 4.0;
687 let y_base = rect.bottom() - 4.0;
688 let y_sustain = y_base + (y_peak - y_base) * sustain.clamp(0.0, 1.0);
689
690 let stroke = egui::Stroke::new(1.5, theme::accent_blue());
691 let p0 = egui::pos2(x0, y_base);
692 let p_a = egui::pos2(x_a, y_peak);
693 let p_d = egui::pos2(x_d, y_sustain);
694 let p_s = egui::pos2(x_s, y_sustain);
695 let p_r = egui::pos2(x_r, y_base);
696
697 painter.line_segment([p0, p_a], stroke);
698 painter.line_segment([p_a, p_d], stroke);
699 painter.line_segment([p_d, p_s], stroke);
700 painter.line_segment([p_s, p_r], stroke);
701 }
702