Files
soundcraft/crates/ui-egui/src/windows.rs
T
aiongg ee050db59c ui: move panel docking onto craft-ui's shared Workspace and DockCommand (#1035)
* ui: move panel docking onto craft-ui's shared Workspace and DockCommand

Pin every craft-libs crate at 16fb3d4 (craft-libs main after #285).

The Edit and Mix workspaces are craft_ui::docking::Workspace<Panel>.
ui.dock and ui.dock.reset parse into DockCommand; the app keeps its
panels, permissions, defaults, the document-view and Mix rules, the
beside-the-canvas fallback for panels without a recorded place, and
Arrange. The canvas accepts splits so panels still open beside it.

Saved v1 workspaces (no version, floating windows as
{panels, active, rect}) load unchanged. Tests cover loading one and
dragging a group by its drag bar.

* ui: test that activating a hidden panel shows it

* ui: name the field in ui.dock operation errors; document the shared panel menu and zones
2026-10-24 16:18:19 -04:00

769 lines
36 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.
//! Secondary floating windows (Window menu).
use crate::SoundApp;
use crate::theme::{Tokens, mono, rgb};
use egui::{Color32, Sense, vec2};
use serde_json::json;
use soundcraft_model::{AutomationMode, ClipContent};
use soundcraft_playback::AudioConfig;
use soundcraft_playback::devices::{AudioSystem, DeviceInfo};
use std::sync::Arc;
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
audio_health(app, ctx);
automation(app, ctx);
color_palette(app, ctx);
disk_usage(app, ctx);
system_usage(app, ctx);
task_manager(app, ctx);
metadata(app, ctx);
event_list(app, ctx);
midi_keyboard(app, ctx);
workspace(app, ctx);
configurations(app, ctx);
playback_engine(app, ctx);
io_setup(app, ctx);
shortcuts_window(app, ctx);
clip_effects(app, ctx);
}
fn audio_health(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_audio_health;
win(ctx, &mut open, "Session Audio Health", vec2(620.0, 360.0), |ui| {
let refresh = ui.button("Refresh Report").clicked();
if app.audio_health_report.is_none() || refresh {
app.audio_health_report = Some(app.engine.execute("session.audio_health", &json!({})).map_err(|error| error.to_string()));
}
let Some(result) = &app.audio_health_report else { return };
match result {
Ok(report) => {
ui.label(format!("{} audio clips checked · {} issues", report["audio_clips"], report["issue_count"]));
ui.label(match report["active_audio_end_seconds"].as_f64() {
Some(seconds) => format!("Active audio ends at {seconds:.2} seconds"),
None => "Active audio end time is unknown (invalid session sample rate)".into(),
});
ui.label("Includes alternate takes and muted clips. Refresh after editing.");
ui.separator();
if report["healthy"] == true {
ui.label("All audio clips have loaded media within source bounds.");
}
egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| {
if let Some(issues) = report["issues"].as_array() {
for issue in issues {
ui.group(|ui| {
let text = |key: &str| issue[key].as_str().unwrap_or("");
ui.label(format!(
"{} / {} / {}{}",
text("track"),
text("playlist"),
text("clip"),
if issue["active"] == true { "" } else { " (alternate playlist)" }
));
ui.label(text("message"));
if let Some(path) = issue["path"].as_str() {
ui.label(path);
}
});
}
}
});
ui.separator();
ui.label("Checks loaded media only, not files on disk, signal levels, plugins or routing.");
}
Err(error) => {
ui.label(error.to_string());
}
}
});
app.ui.show_audio_health = open;
if !open {
app.audio_health_report = None;
}
}
fn win(ctx: &egui::Context, open: &mut bool, title: &str, size: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) {
if !*open {
return;
}
egui::Window::new(title).open(open).default_size(size).show(ctx, body);
}
fn automation(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_automation;
win(ctx, &mut open, "Automation", vec2(260.0, 260.0), |ui| {
ui.label("Write automation for the selected tracks:");
let tracks = app.engine.session().edit.selected_tracks.clone();
ui.horizontal_wrapped(|ui| {
for m in [
AutomationMode::Off,
AutomationMode::Read,
AutomationMode::Touch,
AutomationMode::Latch,
AutomationMode::TouchLatch,
AutomationMode::Write,
AutomationMode::Trim,
] {
if ui.button(m.label()).clicked() {
let ids: Vec<u64> = tracks.iter().map(|t| t.0).collect();
let _ = app.run("mix.automation_mode", json!({"tracks": ids, "mode": m.label()}));
}
}
});
ui.separator();
ui.label("Enabled parameters");
for f in ["volume", "pan", "mute", "send level", "send pan", "send mute", "plugin"] {
let id = format!("automation.enable.{}", f.replace(' ', "_"));
let mut on = !app.engine.session().edit.flag(&format!("{id}.off"));
if crate::widgets::checkbox(ui, &mut on, f).changed() {
app.engine.session_mut().edit.set_flag(&format!("{id}.off"), !on);
}
}
ui.separator();
if ui.button("Write to Current").clicked() {
let _ = app.run("automation.write_to_current", json!({}));
}
if ui.button("Thin All").clicked() {
let _ = app.run("automation.thin_all", json!({}));
}
});
app.ui.show_automation = open;
}
fn color_palette(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_color_palette;
win(ctx, &mut open, "Color Palette", vec2(300.0, 140.0), |ui| {
ui.label("Apply to selected tracks (Shift: selected clips)");
ui.horizontal_wrapped(|ui| {
for (i, c) in soundcraft_model::TRACK_COLORS.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::click());
ui.painter().rect_filled(r, 3.0, rgb(*c));
if resp.clicked() {
if ui.input(|x| x.modifiers.shift) {
let _ = app.run("clip.color", json!({"color": c}));
} else {
let _ = app.run("track.color", json!({"color": i}));
}
}
}
});
ui.separator();
ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window"));
});
app.ui.show_color_palette = open;
}
fn disk_usage(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_disk_usage;
win(ctx, &mut open, "Disk Usage", vec2(360.0, 160.0), |ui| {
let s = app.engine.session();
let bytes: u64 = s.sources.iter().map(|x| x.frames * u64::from(x.channels) * 4).sum();
ui.label(format!("Session media: {} files, {:.1} MB in memory (32-bit float)", s.sources.len(), bytes as f64 / 1_048_576.0));
let per_min = f64::from(s.sample_rate.hz()) * 60.0 * 3.0 / 1_048_576.0;
ui.label(format!("Recording at 24-bit uses {per_min:.1} MB per mono track-minute"));
if let Some(p) = &app.engine.path {
ui.label(format!("Session file: {p}"));
}
});
app.ui.show_disk_usage = open;
}
fn system_usage(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_system_usage;
win(ctx, &mut open, "System Usage", vec2(320.0, 140.0), |ui| {
ui.label(format!("UI frame time: {:.1} ms", app.frame_ms));
ui.label(format!("Audio: {}", app.player.as_ref().map_or("no engine".to_string(), |p| format!("{} @ {} Hz", p.device_name, p.device_rate))));
let plugins: usize = app.engine.session().tracks.iter().map(|t| t.mixer.inserts.iter().flatten().count()).sum();
ui.label(format!("Active plugin instances: {plugins}"));
ui.label(format!("Tracks: {}", app.engine.session().tracks.len()));
ui.label(format!("Undo steps: {}", app.engine.undo_history().len()));
});
app.ui.show_system_usage = open;
}
fn task_manager(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_task_manager;
win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| {
match &app.bounce {
Some(b) => {
ui.label(format!("Bouncing {} ({:.0}%)", b.path().display(), b.progress() * 100.0));
ui.add(egui::ProgressBar::new(b.progress()));
}
None => {
ui.label("No background tasks are running.");
}
}
ui.label(
egui::RichText::new("Renders and AudioSuite processes run to completion before returning.").small().color(Tokens::current().text_dim),
);
});
app.ui.show_task_manager = open;
}
fn metadata(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_metadata;
win(ctx, &mut open, "Metadata Inspector", vec2(380.0, 260.0), |ui| {
let s = app.engine.session();
ui.label(egui::RichText::new(&s.name).strong());
egui::Grid::new("meta").num_columns(2).striped(true).show(ui, |ui| {
for (k, v) in [
("Sample rate", format!("{} Hz", s.sample_rate.hz())),
("Bit depth", format!("{:?}", s.bit_depth)),
("Timecode rate", s.frame_rate.label().to_string()),
("Tracks", s.tracks.len().to_string()),
("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::<usize>().to_string()),
("Audio files", s.sources.len().to_string()),
("Markers", s.markers.len().to_string()),
("Length", s.format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs)),
] {
ui.label(k);
ui.label(v);
ui.end_row();
}
});
ui.separator();
let mut c = s.comments.clone();
ui.label("Session comments");
if ui.text_edit_multiline(&mut c).changed() {
app.engine.session_mut().comments = c;
}
});
app.ui.show_metadata = open;
}
fn event_list(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_event_list;
win(ctx, &mut open, "MIDI Event List", vec2(420.0, 360.0), |ui| {
let Some(cid) = crate::midi_editor::target_clip(app) else {
ui.label("Select a MIDI clip.");
return;
};
let notes = match app.engine.session().find_clip(cid).map(|(_, c)| c.content.clone()) {
Some(ClipContent::Midi { sequence }) => sequence.notes,
_ => Vec::new(),
};
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("evl").num_columns(5).striped(true).show(ui, |ui| {
for h in ["Start (ticks)", "Note", "Vel", "Length", ""] {
ui.label(egui::RichText::new(h).strong());
}
ui.end_row();
for (i, n) in notes.iter().enumerate() {
ui.label(egui::RichText::new(n.start.to_string()).font(mono(11.0)));
ui.label(soundcraft_midi::note_name(n.pitch));
let mut v = i64::from(n.velocity);
if ui.add(egui::DragValue::new(&mut v).range(1..=127)).changed() {
let _ = app.engine.execute("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}));
}
ui.label(n.length.to_string());
if ui.small_button("✕").clicked() {
let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": [i]}));
}
ui.end_row();
}
});
});
});
app.ui.show_event_list = open;
}
fn midi_keyboard(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_midi_keyboard;
win(ctx, &mut open, "MIDI Keyboard", vec2(640.0, 110.0), |ui| {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().max(400.0), 80.0), Sense::click());
let whites = 28;
let w = r.width() / whites as f32;
let mut wi = 0;
let base = 48u8;
let mut clicked: Option<u8> = None;
let pos = resp.interact_pointer_pos();
for p in base..base + 48 {
if !matches!(p % 12, 1 | 3 | 6 | 8 | 10) {
let kr = egui::Rect::from_min_size(egui::pos2(r.min.x + wi as f32 * w, r.min.y), vec2(w - 1.0, r.height()));
ui.painter().rect_filled(kr, 2.0, Color32::from_rgb(235, 235, 235));
if resp.clicked() && pos.is_some_and(|q| kr.contains(q)) {
clicked = Some(p);
}
wi += 1;
}
}
wi = 0;
for p in base..base + 48 {
if matches!(p % 12, 1 | 3 | 6 | 8 | 10) {
let kr = egui::Rect::from_min_size(egui::pos2(r.min.x + wi as f32 * w - w * 0.3, r.min.y), vec2(w * 0.6, r.height() * 0.6));
ui.painter().rect_filled(kr, 2.0, Color32::from_rgb(20, 20, 20));
if resp.clicked() && pos.is_some_and(|q| kr.contains(q)) {
clicked = Some(p);
}
} else {
wi += 1;
}
}
if let Some(p) = clicked
&& let Some(cid) = crate::midi_editor::target_clip(app)
{
// Step input: add the note at the insertion point (relative to the clip) and advance.
let s = app.engine.session();
let (clip_start, at) = (s.find_clip(cid).map_or(0, |(_, c)| c.start), s.edit.selection.start);
let rel = s.tempo.samples_to_ticks(at, s.sample_rate) - s.tempo.samples_to_ticks(clip_start, s.sample_rate);
let len = soundcraft_time::TICKS_PER_QUARTER / 2;
let _ = app.run("midi.note_add", json!({"clip": cid.0, "pitch": p, "start_ticks": rel.max(0), "length_ticks": len}));
let s = app.engine.session();
let next = s.tempo.tick_to_samples(s.tempo.samples_to_ticks(at, s.sample_rate) + len, s.sample_rate);
let _ = app.run("transport.locate", json!({"at": next}));
}
ui.label(egui::RichText::new("Click keys to step-enter notes into the selected MIDI clip at the insertion point.").small());
});
app.ui.show_midi_keyboard = open;
}
fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_workspace;
if !open {
return;
}
egui::Window::new("Workspace").open(&mut open).default_size(vec2(460.0, 420.0)).show(ctx, |ui| {
let dir = if app.ui.workspace_dir.is_empty() { std::env::var("HOME").unwrap_or_else(|_| ".".into()) } else { app.ui.workspace_dir.clone() };
ui.horizontal(|ui| {
if ui.button("⬆").on_hover_text("Parent folder").clicked()
&& let Some(p) = std::path::Path::new(&dir).parent()
{
app.ui.workspace_dir = p.to_string_lossy().into_owned();
}
ui.label(egui::RichText::new(&dir).small());
});
ui.separator();
let mut entries: Vec<(String, bool)> = std::fs::read_dir(&dir)
.map(|rd| {
rd.flatten()
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
return None;
}
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
let ext = std::path::Path::new(&name).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase();
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
.then_some((name, is_dir))
})
.collect()
})
.unwrap_or_default();
entries.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.to_lowercase().cmp(&b.0.to_lowercase())));
egui::ScrollArea::vertical().show(ui, |ui| {
for (name, is_dir) in entries.into_iter().take(2000) {
let path = std::path::Path::new(&dir).join(&name).to_string_lossy().into_owned();
let label = if is_dir { format!("📁 {name}") } else { format!("🔊 {name}") };
let r = ui.selectable_label(false, label);
if r.double_clicked() {
if is_dir {
app.ui.workspace_dir = path;
} else if name.ends_with(".scraft") {
let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path}));
} else if name.ends_with(".mid") || name.ends_with(".midi") {
let _ = app.run("file.import_midi", json!({"path": path}));
} else {
let at = app.engine.session().edit.selection.start;
crate::import::start(app, json!({"path": path, "at": at}));
}
}
}
});
ui.label(egui::RichText::new("Double-click to import audio/MIDI at the insertion point or open a session.").small());
});
app.ui.show_workspace = open;
}
fn configurations(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_configurations;
win(ctx, &mut open, "Window Configurations", vec2(320.0, 240.0), |ui| {
if ui.button("New Configuration…").clicked() {
let n = app.ui.configurations.len() + 1;
let mut snap = app.ui.clone();
snap.configurations.clear();
if let Ok(v) = serde_json::to_value(&snap) {
app.ui.configurations.push((format!("Configuration {n}"), v));
}
}
ui.separator();
let list = app.ui.configurations.clone();
for (i, (name, v)) in list.iter().enumerate() {
ui.horizontal(|ui| {
if ui.button(format!("{} {name}", i + 1)).clicked()
&& let Ok(mut st) = serde_json::from_value::<crate::UiState>(v.clone())
{
if let Err(error) = crate::docking::validate(&st.docking).and_then(|()| crate::docking::validate_mix(&st.mix_docking)) {
app.ui.status = format!("Window configuration could not be restored: {error}");
} else {
st.configurations = app.ui.configurations.clone();
st.theme = app.ui.theme;
st.show_configurations = true;
crate::docking::cancel_replaced_state(app, &st);
app.ui = st;
}
}
if ui.small_button("✕").clicked() {
app.ui.configurations.remove(i);
}
});
}
});
app.ui.show_configurations = open;
}
/// The audio systems as last enumerated (enumerating probes devices: done when the window opens
/// and on Refresh, not every frame).
fn audio_systems(ctx: &egui::Context, refresh: bool) -> Arc<Vec<AudioSystem>> {
let id = egui::Id::new("audio_systems");
if !refresh && let Some(s) = ctx.data(|d| d.get_temp::<Arc<Vec<AudioSystem>>>(id)) {
return s;
}
let s = Arc::new(soundcraft_playback::devices::audio_systems());
ctx.data_mut(|d| d.insert_temp(id, Arc::clone(&s)));
s
}
/// The system a config means (its own, else the default one) in an enumeration.
fn chosen_system<'a>(systems: &'a [AudioSystem], cfg: &AudioConfig) -> Option<&'a AudioSystem> {
cfg.system
.as_deref()
.and_then(|n| systems.iter().find(|s| s.name.eq_ignore_ascii_case(n)))
.or_else(|| systems.iter().find(|s| s.is_default))
.or(systems.first())
}
/// The device a config means (by name, else the system default).
fn chosen_device<'a>(devices: &'a [DeviceInfo], name: Option<&str>) -> Option<&'a DeviceInfo> {
name.and_then(|n| devices.iter().find(|d| d.name == n)).or_else(|| devices.iter().find(|d| d.is_default))
}
fn device_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<String>, devices: &[DeviceInfo]) {
let default = devices.iter().find(|d| d.is_default).map_or("none".to_string(), |d| d.name.clone());
let shown = value.clone().unwrap_or_else(|| format!("Default ({default})"));
egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
ui.selectable_value(value, None, format!("Default ({default})"));
for d in devices {
ui.selectable_value(value, Some(d.name.clone()), format!("{} ({} ch)", d.name, d.max_channels));
}
});
}
fn number_combo(ui: &mut egui::Ui, id: &str, value: &mut Option<u32>, options: &[u32], unit: &str) {
let shown = value.map_or("Device default".to_string(), |v| format!("{v} {unit}"));
egui::ComboBox::from_id_salt(id).width(300.0).selected_text(shown).show_ui(ui, |ui| {
ui.selectable_value(value, None, "Device default");
for v in options {
ui.selectable_value(value, Some(*v), format!("{v} {unit}"));
}
});
}
/// Setup › Playback Engine / Hardware: choose the audio system, devices, rate and buffer size.
pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_playback_engine;
if !open {
return;
}
let draft_id = egui::Id::new("audio_draft");
let mut draft = ctx.data(|d| d.get_temp::<AudioConfig>(draft_id)).unwrap_or_else(|| app.ui.audio.clone());
let mut refresh = false;
let mut apply = false;
let systems = audio_systems(ctx, false);
win(ctx, &mut open, "Playback Engine", vec2(460.0, 360.0), |ui| {
let t = Tokens::current();
egui::Grid::new("pe").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
ui.label("Audio system");
let def = systems.iter().find(|s| s.is_default).map_or("none".to_string(), |s| s.name.clone());
let shown = draft.system.clone().unwrap_or_else(|| format!("Default ({def})"));
egui::ComboBox::from_id_salt("pe-system").width(300.0).selected_text(shown).show_ui(ui, |ui| {
let before = draft.system.clone();
ui.selectable_value(&mut draft.system, None, format!("Default ({def})"));
for s in systems.iter() {
ui.selectable_value(&mut draft.system, Some(s.name.clone()), &s.name);
}
if draft.system != before {
// Device names belong to a system.
draft.output = None;
draft.input = None;
}
});
ui.end_row();
let sys = chosen_system(&systems, &draft);
ui.label("Output device");
device_combo(ui, "pe-out", &mut draft.output, sys.map_or(&[][..], |s| &s.outputs));
ui.end_row();
ui.label("Input device");
device_combo(ui, "pe-in", &mut draft.input, sys.map_or(&[][..], |s| &s.inputs));
ui.end_row();
let out = sys.and_then(|s| chosen_device(&s.outputs, draft.output.as_deref()));
ui.label("Sample rate");
number_combo(ui, "pe-rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
ui.end_row();
ui.label("Buffer size");
let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec());
number_combo(ui, "pe-buffer", &mut draft.buffer_size, &buffers, "samples");
ui.end_row();
});
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Refresh devices").clicked() {
refresh = true;
}
let changed = draft != app.ui.audio;
if ui.add_enabled(changed, egui::Button::new("Apply")).on_hover_text("Reopen the audio devices with these settings").clicked() {
apply = true;
}
if changed && ui.button("Revert").clicked() {
draft = app.ui.audio.clone();
}
});
ui.separator();
egui::Grid::new("pe-state").num_columns(2).show(ui, |ui| {
ui.label("Output");
ui.label(app.player.as_ref().map_or("none".to_string(), |p| {
if p.silent {
"no device (silent clock)".to_string()
} else {
let buf = p.device_buffer.map_or("default buffer".to_string(), |b| format!("{b} samples"));
format!("{} · {} Hz · {} ch · {buf}", p.device_name, p.device_rate, p.device_channels)
}
}));
ui.end_row();
ui.label("Input");
ui.label(app.recorder.as_ref().map_or("opened on first record or monitor".to_string(), |r| {
format!("{} · {} Hz · {} ch", r.device_name, r.sample_rate, r.channels)
}));
ui.end_row();
ui.label("Session rate");
let sr = app.engine.session().sample_rate.hz();
let note = app
.player
.as_ref()
.filter(|p| !p.silent && p.device_rate != sr)
.map_or(String::new(), |_| " (resampled to the device rate)".to_string());
ui.label(format!("{sr} Hz{note}"));
ui.end_row();
});
ui.label(egui::RichText::new("The mix runs in 512-sample blocks; the buffer size sets the device's own latency.").small().color(t.text_dim));
let mut dc = app.engine.session().edit.delay_compensation;
if crate::widgets::checkbox(ui, &mut dc, "Delay compensation").changed() {
let _ = app.run("options.delay_compensation", json!({"value": dc}));
}
if ui.button("Reconnect audio device").clicked() {
app.reconnect_audio();
}
});
if refresh {
audio_systems(ctx, true);
}
if apply && let Err(e) = app.apply_audio_config(draft.clone()) {
app.ui.status = e;
}
if open {
ctx.data_mut(|d| d.insert_temp(draft_id, draft));
} else {
// Closing discards unapplied edits and the device list (re-read on the next open).
ctx.data_mut(|d| {
d.remove_temp::<AudioConfig>(draft_id);
d.remove_temp::<Arc<Vec<AudioSystem>>>(egui::Id::new("audio_systems"));
});
}
app.ui.show_playback_engine = open;
}
/// `ui.audio_device`: `{list?: bool}` returns the audio systems and their devices;
/// `{system?, output?, input?, sample_rate?, buffer_size?}` (a `null` value means "default") sets
/// the choice and reopens the devices. Always returns the choice and the open device.
pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result<serde_json::Value, String> {
use serde_json::Value;
let mut changed = false;
let mut cfg = app.ui.audio.clone();
for (key, slot) in [("system", &mut cfg.system), ("output", &mut cfg.output), ("input", &mut cfg.input)] {
if let Some(v) = p.get(key) {
*slot = v.as_str().map(str::to_string).filter(|s| !s.is_empty());
changed = true;
}
}
for (key, slot, range) in [("sample_rate", &mut cfg.sample_rate, 8_000..=768_000u64), ("buffer_size", &mut cfg.buffer_size, 16..=8192u64)] {
if let Some(v) = p.get(key) {
*slot = match v {
Value::Null => None,
v => Some(v.as_u64().filter(|n| range.contains(n)).ok_or_else(|| format!("`{key}` must be a number in {range:?} or null"))? as u32),
};
changed = true;
}
}
if changed {
app.apply_audio_config(cfg)?;
}
let device = app.player.as_ref().map(|pl| {
json!({"name": pl.device_name, "sample_rate": pl.device_rate, "channels": pl.device_channels, "buffer_size": pl.device_buffer, "silent": pl.silent})
});
let mut out = json!({"config": app.ui.audio, "device": device});
if p.get("list").and_then(Value::as_bool).unwrap_or(false) {
out["systems"] = json!(soundcraft_playback::devices::audio_systems());
}
Ok(out)
}
/// Setup › I/O: busses and output paths.
pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_io_setup;
win(ctx, &mut open, "I/O Setup", vec2(420.0, 300.0), |ui| {
ui.label(egui::RichText::new("Busses").strong());
let busses: Vec<(String, String)> = app.engine.session().busses.iter().map(|b| (b.name.clone(), b.format.label().to_string())).collect();
egui::Grid::new("busses").num_columns(3).striped(true).show(ui, |ui| {
for (name, fmt) in busses {
let key = egui::Id::new(("bus_name", name.clone()));
let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone());
let r = ui.text_edit_singleline(&mut edit);
ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone()));
if r.lost_focus() && edit != name {
let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit}));
}
ui.label(fmt);
if ui.small_button("Delete").clicked() {
let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name}));
}
ui.end_row();
}
});
if ui.button("New Bus").clicked() {
let n = app.engine.session().busses.len() + 1;
let _ = app.run("mix.new_bus", json!({"name": format!("Bus {n}")}));
}
ui.separator();
ui.label(egui::RichText::new("Outputs").strong());
// Main output format: stereo, or a surround format (the mix and master faders follow).
let main = app.engine.session().main_format();
ui.horizontal(|ui| {
ui.label("Main output");
egui::ComboBox::from_id_salt("main_output_format").selected_text(main.label()).show_ui(ui, |ui| {
for f in soundcraft_model::ChannelFormat::ALL.into_iter().filter(|f| (2..=16).contains(&f.channels())) {
if ui.selectable_label(f == main, f.label()).clicked() && f != main {
let _ = app.run("setup.main_format", json!({"format": f.label()}));
}
}
});
ui.label(egui::RichText::new(format!("{} ch", main.channels())).small());
});
for o in app.engine.session().outputs.clone() {
ui.label(format!("{} — {} from channel {}", o.name, o.format.label(), o.first_channel + 1));
}
});
app.ui.show_io_setup = open;
}
/// Setup › Keyboard Shortcuts.
pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_shortcuts;
let mac = ctx.os().is_mac();
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › ")));
let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › ")));
for (shortcut, label, menu) in engine.chain(windows) {
let Some(shortcut) = shortcut else { continue };
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
ui.label(label);
ui.label(egui::RichText::new(menu).small());
ui.end_row();
}
});
});
});
app.ui.show_shortcuts = open;
}
/// Clip Effects: per-clip EQ, dynamics and gain (stored as `clip_fx.<clip>.*` values).
pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_clip_effects;
win(ctx, &mut open, "Clip Effects", vec2(560.0, 420.0), |ui| {
let clip = app.engine.session().edit.selected_clips.first().copied().or_else(|| {
let s = app.engine.session();
s.tracks
.iter()
.filter(|t| s.edit.selected_tracks.contains(&t.id))
.flat_map(|t| t.clips().iter())
.find(|c| c.range().contains(s.edit.selection.start) && c.is_audio())
.map(|c| c.id)
});
let Some(cid) = clip else {
ui.label("Select an audio clip.");
return;
};
let name = app.engine.session().find_clip(cid).map(|(_, c)| c.name.clone()).unwrap_or_default();
let bypass = app.engine.session().edit.flag(&format!("clip_fx.bypass.{}", cid.0));
ui.horizontal(|ui| {
ui.label(egui::RichText::new(&name).strong());
if ui.selectable_label(bypass, "Bypass").clicked() {
let _ = app.run("clip.effects_bypass", json!({"clips": [cid.0]}));
}
if ui.button("Render").clicked() {
let _ = app.run("clip.effects_render", json!({"clips": [cid.0]}));
}
if ui.button("Clear").clicked() {
let _ = app.run("edit.clear_clip_effects", json!({"clips": [cid.0]}));
}
});
let get = |app: &SoundApp, k: &str, d: f32| app.engine.session().edit.values.get(&format!("clip_fx.{}.{k}", cid.0)).map_or(d, |v| *v as f32);
egui::ScrollArea::vertical().show(ui, |ui| {
ui.columns(2, |cols| {
for (col, (title, plugin, prefix)) in cols.iter_mut().zip([("EQ", "eq_7band", "eq."), ("Dynamics", "compressor", "comp.")]) {
col.label(egui::RichText::new(title).strong());
let Some(info) = soundcraft_dsp::plugin_info(plugin) else { continue };
for p in info.params {
let key = format!("{prefix}{}", p.id);
let mut v = get(app, &key, p.default);
let before = v;
col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name));
if (v - before).abs() > f32::EPSILON {
let mut m = serde_json::Map::new();
m.insert(key.clone(), json!(v));
let _ = app.engine.execute_merged(
"clip.effects_set",
&json!({"clips": [cid.0], "params": m}),
&format!("cfx:{}:{key}", cid.0),
);
}
}
}
});
let mut g = get(app, "gain", 0.0);
let before = g;
ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text("Clip effects gain (dB)"));
if (g - before).abs() > f32::EPSILON {
let _ =
app.engine.execute_merged("clip.effects_set", &json!({"clips": [cid.0], "params": {"gain": g}}), &format!("cfx:{}:gain", cid.0));
}
});
});
app.ui.show_clip_effects = open;
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod audio_health_tests {
use super::*;
#[test]
fn window_command_renders_report_and_close_clears_snapshot() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
app.run("window.audio_health", json!({"value": true})).unwrap();
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| audio_health(&mut app, ui.ctx()));
output.textures_delta.clear();
assert_eq!(app.audio_health_report.as_ref().unwrap().as_ref().unwrap()["healthy"], true);
assert!(!app.engine.is_dirty());
app.run("window.hide_floating", json!({})).unwrap();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| audio_health(&mut app, ui.ctx()));
output.textures_delta.clear();
assert!(!app.ui.show_audio_health);
assert!(app.audio_health_report.is_none());
app.run("window.audio_health", json!({"value": true})).unwrap();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| audio_health(&mut app, ui.ctx()));
output.textures_delta.clear();
assert!(app.audio_health_report.is_some());
}
}