From 0751dd31f1491cc0f615545eda284fa65453c464 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 01:17:42 -0400 Subject: [PATCH] Score export and print, clip group files, Merge Markers, path dialogs for every file command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - File › Export › Sibelius and File › Send To › Sibelius write MusicXML 4.0 (quantized, chords, ties across bar lines, meter changes, treble or bass clef per part) - File › Print Score engraves the MIDI tracks to a printable SVG page; File › Score Setup sets title, composer, bars per system and track names - File › Import › Clip Groups and Export Clip Groups (.scgrp with its audio) - Edit › Paste Special › Merge Markers: copy carries the markers in the copied range - The UI opens a path prompt for any command that needs a path or folder Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/catalog.rs | 2 +- crates/engine/src/clip_group_file.rs | 209 ++++++++++ crates/engine/src/cmd/edit.rs | 13 + crates/engine/src/cmd/edit_more.rs | 55 +++ crates/engine/src/cmd/file.rs | 69 ++++ crates/engine/src/lib.rs | 4 + crates/engine/src/score.rs | 561 +++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 97 ++++- crates/ui-egui/src/menus.rs | 9 +- docs/parity.md | 17 +- 10 files changed, 1018 insertions(+), 18 deletions(-) create mode 100644 crates/engine/src/clip_group_file.rs create mode 100644 crates/engine/src/score.rs diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 0ee3944..9c54621 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -129,6 +129,6 @@ mod tests { let done = p["implemented"].as_u64().unwrap_or(0); // The floor only ever rises. assert!(pct >= 25.0, "engine parity regressed: {pct}%"); - assert!(done >= 386, "engine parity regressed: {done} menu items"); + assert!(done >= 396, "engine parity regressed: {done} menu items"); } } diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs new file mode 100644 index 0000000..c889c99 --- /dev/null +++ b/crates/engine/src/clip_group_file.rs @@ -0,0 +1,209 @@ +//! Clip group files (`.scgrp`): clips exported from a session (Clip List › Export Clip Groups) +//! and imported into another (File › Import › Clip Groups…). +//! +//! The file is JSON: clips per lane with starts relative to the group, plus the audio they play +//! as 32-bit float WAVs in a ` Audio/` folder next to it. + +use crate::{Engine, EngineError, Result}; +use serde::{Deserialize, Serialize}; +use soundcraft_audio_io::{EncodeOptions, FileFormat}; +use soundcraft_model::{Clip, ClipContent, ClipId, SourceId, TrackId}; +use soundcraft_time::Samples; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +pub const EXTENSION: &str = "scgrp"; +const FORMAT: &str = "soundcraft.clipgroup"; +/// Refuse files that would place absurd numbers of clips. +const MAX_CLIPS: usize = 10_000; + +#[derive(Debug, Serialize, Deserialize)] +struct GroupFile { + format: String, + version: u32, + sample_rate: u32, + length: Samples, + /// Clips per lane (one lane per source track), starts relative to the group start. + lanes: Vec>, + /// Source id (as written) → audio file path relative to the group file. + audio: BTreeMap, +} + +/// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written. +pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { + let s = e.session(); + let mut lanes: Vec<(usize, Vec)> = Vec::new(); + for id in ids { + let Some((t, c)) = s.find_clip(*id) else { continue }; + let ti = s.tracks.iter().position(|x| x.id == t).unwrap_or(0); + match lanes.iter_mut().find(|(i, _)| *i == ti) { + Some((_, v)) => v.push(c.clone()), + None => lanes.push((ti, vec![c.clone()])), + } + } + let clips: Vec<&Clip> = lanes.iter().flat_map(|(_, v)| v.iter()).collect(); + let (Some(start), Some(end)) = (clips.iter().map(|c| c.start).min(), clips.iter().map(|c| c.start.saturating_add(c.length)).max()) else { + return Err(EngineError::BadParams("file.export_clip_groups".into(), "no clips to export".into())); + }; + let n = clips.len(); + lanes.sort_by_key(|(i, _)| *i); + let p = with_extension(path); + let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); + let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); + let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); + let mut audio = BTreeMap::new(); + let mut out_lanes = Vec::new(); + for (_, v) in lanes { + let mut lane = Vec::new(); + for mut c in v { + c.start -= start; + if let ClipContent::Audio { source, .. } = c.content + && let std::collections::btree_map::Entry::Vacant(slot) = audio.entry(source.0) + && let (Some(buf), Some(src)) = (s.pool.get(source), s.sources.iter().find(|x| x.id == source)) + { + let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0); + std::fs::create_dir_all(dir.join(&audio_dir_name)).map_err(|err| EngineError::Io(format!("{audio_dir_name}: {err}")))?; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::write(dir.join(&rel), bytes).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + slot.insert(rel); + } + lane.push(c); + } + out_lanes.push(lane); + } + let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio }; + let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; + Ok(n) +} + +/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks +/// are made when the session runs out). Returns the ids of the placed clips. +pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> { + let id = "file.import_clip_groups"; + let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; + if file.format != FORMAT { + return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into())); + } + if file.lanes.iter().map(Vec::len).sum::() > MAX_CLIPS { + return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into())); + } + let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); + // Decode audio first so a bad file changes nothing. + let mut decoded = BTreeMap::new(); + for (old, rel) in &file.audio { + let full = dir.join(rel); + if !full.starts_with(&dir) || rel.contains("..") { + return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); + } + let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?; + let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + decoded.insert(*old, (rel.clone(), buf)); + } + let src_rate = f64::from(file.sample_rate.max(1)); + let s = e.session_mut(); + let ratio = f64::from(s.sample_rate.hz()) / src_rate; + let scale = |v: Samples| -> Samples { (v as f64 * ratio).round() as Samples }; + let mut map: BTreeMap = BTreeMap::new(); + for (old, (rel, buf)) in decoded { + let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); + map.insert(old, crate::io::add_source(s, &name, buf, None, FileFormat::Wav)); + } + let start_idx = first.and_then(|t| s.tracks.iter().position(|x| x.id == t)); + let mut placed = Vec::new(); + let group_id = s.alloc(); + for (li, lane) in file.lanes.into_iter().enumerate() { + let audio_lane = lane.iter().any(|c| matches!(c.content, ClipContent::Audio { .. })); + let target = start_idx.and_then(|i| s.tracks.get(i + li)).map(|t| t.id); + let target = match target { + Some(t) => t, + None => { + let channels = lane + .iter() + .find_map(|c| match c.content { + ClipContent::Audio { source, .. } => map.get(&source.0).and_then(|n| s.pool.get(*n)).map(|a| a.buffer.num_channels()), + _ => None, + }) + .unwrap_or(2); + let kind = if audio_lane { soundcraft_model::TrackKind::Audio } else { soundcraft_model::TrackKind::Midi }; + let fmt = if channels == 1 { soundcraft_model::ChannelFormat::Mono } else { soundcraft_model::ChannelFormat::Stereo }; + s.add_track(kind, fmt, Some("Clip Group")) + } + }; + for mut c in lane { + if let ClipContent::Audio { source, offset } = &mut c.content { + let Some(n) = map.get(&source.0) else { continue }; + *source = *n; + *offset = scale(*offset); + } else if !matches!(c.content, ClipContent::Midi { .. }) { + continue; + } + c.id = s.new_clip_id(); + c.start = at.saturating_add(scale(c.start.max(0))); + c.length = scale(c.length).max(1); + c.group = Some(group_id); + c.clamp_fades(); + placed.push(c.id); + crate::edit::place_clip(s, target, c); + } + } + Ok(placed) +} + +fn with_extension(path: &str) -> PathBuf { + let p = PathBuf::from(path); + if p.extension().is_none() { p.with_extension(EXTENSION) } else { p } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + #[test] + fn export_then_import_round_trips_audio_clips() { + let dir = std::env::temp_dir().join(format!("sc-grp-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("drums.scgrp"); + let path = path.to_string_lossy().to_string(); + let mut e = crate::demo::demo_engine(); + let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap(); + let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(2).map(|c| c.id).collect(); + let r = e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids.iter().map(|c| c.0).collect::>()})).unwrap(); + assert_eq!(r["clips"], 2); + let before = e.session().tracks.len(); + let at = 30_000_000; + let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": at, "track": "Bass"})).unwrap(); + let placed: Vec<_> = r["clips"].as_array().unwrap().iter().filter_map(serde_json::Value::as_u64).collect(); + assert_eq!(placed.len(), 2); + let bass = e.session().tracks.iter().find(|t| t.name == "Bass").map(|t| t.id).unwrap(); + for id in &placed { + let (t, c) = e.session().find_clip(soundcraft_model::ClipId(*id)).unwrap(); + assert_eq!(t, bass); + assert!(c.start >= at && c.group.is_some()); + assert!(matches!(c.content, soundcraft_model::ClipContent::Audio { source, .. } if e.session().pool.contains(source))); + } + e.undo(); + assert_eq!(e.session().tracks.len(), before); + assert!(e.session().find_clip(soundcraft_model::ClipId(placed[0])).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rejects_foreign_files_and_escaping_paths() { + let dir = std::env::temp_dir().join(format!("sc-grp-bad-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("x.scgrp"); + std::fs::write( + &p, + r#"{"format":"soundcraft.clipgroup","version":1,"sample_rate":48000,"length":1,"lanes":[],"audio":{"1":"../../etc/passwd"}}"#, + ) + .unwrap(); + let mut e = crate::demo::demo_engine(); + assert!(e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).is_err()); + std::fs::write(&p, "{}").unwrap(); + assert!(e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index babf65e..7e4f2d5 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -185,6 +185,19 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { .unwrap_or_default(); cb.automation.push(lanes); } + cb.markers = e + .session() + .markers + .iter() + .filter(|m| m.kind == soundcraft_model::MarkerKind::Marker && range.contains(m.start)) + .map(|m| { + let mut m = m.clone(); + m.end -= m.start; + m.start -= range.start; + m.end += m.start; + m + }) + .collect(); if cut { let sh = shuffle(e.session()); let s = e.session_mut(); diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index e60d06f..d27bb97 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -107,6 +107,15 @@ pub fn specs() -> Vec { has_clipboard, paste_merge_midi ), + cmd!( + "edit.paste_merge_markers", + "Merge Markers", + ["Edit", "Paste Special"], + None, + "{at?} — adds the copied markers at the insertion point, skipping any that already exist there", + has_marker_clipboard, + paste_merge_markers + ), cmd!( "edit.paste_to_current_automation", "To Current Automation Type", @@ -888,11 +897,57 @@ fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) Ok(json!({"lanes": lanes})) } +fn has_marker_clipboard(e: &Engine) -> std::result::Result<(), String> { + if e.clipboard.markers.is_empty() { Err("no markers on the clipboard".into()) } else { Ok(()) } +} + +fn paste_merge_markers(e: &mut Engine, p: &Value) -> Result { + let id = "edit.paste_merge_markers"; + if e.clipboard.markers.is_empty() { + return Err(bad(id, "no markers on the clipboard (copy a range that contains markers)")); + } + let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let copied = e.clipboard.markers.clone(); + let s = e.session_mut(); + let mut added = 0; + for m in copied { + let start = at.saturating_add(m.start); + if s.markers.iter().any(|x| x.start == start && x.name == m.name && x.ruler == m.ruler) { + continue; + } + let new = s.add_marker(&m.name, m.kind, start, start.saturating_add(m.end - m.start)); + if let Some(x) = s.markers.iter_mut().find(|x| x.id == new) { + x.comments = m.comments; + x.color = m.color; + x.ruler = m.ruler; + } + added += 1; + } + Ok(json!({"added": added})) +} + #[cfg(test)] mod tests { use super::*; use soundcraft_model::{ChannelFormat, SourceId, TrackKind}; + #[test] + fn merge_markers_pastes_copied_markers_once() { + let mut e = crate::demo::demo_engine(); + let first = e.session().markers.first().cloned().expect("demo has markers"); + let r = json!({"start": first.start.saturating_sub(10).max(0), "end": first.start + 10}); + e.execute("edit.select", &json!({"tracks": ["Kick"], "start": r["start"], "end": r["end"]})).unwrap(); + e.execute("edit.copy", &json!({})).unwrap(); + let before = e.session().markers.len(); + let at = 5_000_000; + let out = e.execute("edit.paste_merge_markers", &json!({"at": at})).unwrap(); + assert_eq!(out["added"], 1); + assert_eq!(e.session().markers.len(), before + 1); + assert!(e.session().markers.iter().any(|m| m.name == first.name && m.start == at + first.start - r["start"].as_i64().unwrap())); + // Pasting again at the same place adds nothing. + assert_eq!(e.execute("edit.paste_merge_markers", &json!({"at": at})).unwrap()["added"], 0); + } + fn session_with_clips(starts: &[(Samples, Samples)]) -> (Engine, soundcraft_model::TrackId, Vec) { let mut s = Session::default(); let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d9548fa..d6b9738 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -129,6 +129,57 @@ pub fn specs() -> Vec { crate::io::save_session(&mut tmp, &path)?; Ok(json!({"path": path})) }), + cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| { + write_score(e, p, "file.export_sibelius", false) + }), + cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| { + write_score(e, p, "file.send_to_sibelius", false) + }), + cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| { + write_score(e, p, "file.print_score", true) + }), + cmd!( + "file.score_setup", + "Score Setup...", + ["File"], + None, + "{title?, composer?, bars_per_system?: 1..16, show_track_names?: bool} — returns the current setup", + always, + |e, p| { + let mut setup = crate::score::ScoreSetup::from_session(e.session()); + if let Some(t) = str_param(p, "title") { + setup.title = t.to_string(); + } + if let Some(c) = str_param(p, "composer") { + setup.composer = c.to_string(); + } + if let Some(b) = p.get("bars_per_system").and_then(serde_json::Value::as_f64) { + setup.bars_per_system = if b.is_finite() { b.clamp(1.0, 16.0) as u32 } else { 4 }; + } + if let Some(b) = p.get("show_track_names").and_then(serde_json::Value::as_bool) { + setup.show_track_names = b; + } + setup.store(e.session_mut()); + Ok( + json!({"title": setup.title, "composer": setup.composer, "bars_per_system": setup.bars_per_system, "show_track_names": setup.show_track_names}), + ) + } + ), + cmd!(noundo "file.export_clip_groups", "Export Clip Groups...", [], None, "{path, clips?} — writes a .scgrp clip group file with its audio", always, |e, p| { + let path = str_param(p, "path").ok_or_else(|| bad("file.export_clip_groups", "`path` required"))?.to_string(); + let ids = clip_ids_param(e, p); + let n = crate::clip_group_file::export(e, &ids, &path)?; + Ok(json!({"path": path, "clips": n})) + }), + cmd!("file.import_clip_groups", "Clip Groups...", ["File", "Import"], None, "{path, track?: first target track, at?}", always, |e, p| { + let id = "file.import_clip_groups"; + let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string(); + let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let first = tracks_param(e, id, p)?.first().copied(); + let placed = crate::clip_group_file::import(e, &path, first, at)?; + e.session_mut().edit.selected_clips.clone_from(&placed); + Ok(json!({"clips": placed.iter().map(|c| c.0).collect::>()})) + }), cmd!(query "file.get_info", "Get Info...", ["File"], None, "{}", always, |e, _| Ok(crate::inspect::session(e, false))), cmd!(noundo "app.quit", "Quit", [], Some("Cmd+Q"), "{}", always, |_, _| Ok(json!({"quit": true}))), ] @@ -206,3 +257,21 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), ) } + +/// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`). +fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result { + let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { tracks_param(e, id, p)? } else { Vec::new() }; + let parts = crate::score::layout(e.session(), &tracks); + if parts.is_empty() { + return Err(bad(id, "no MIDI or instrument tracks to notate")); + } + let setup = crate::score::ScoreSetup::from_session(e.session()); + let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") }; + let path = match str_param(p, "path") { + Some(p) => p.to_string(), + None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(), + }; + std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0); + Ok(json!({"path": path, "parts": parts.len(), "measures": measures})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index cf9117f..f5c47fd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,11 +7,13 @@ pub mod catalog; pub mod catalog_more; +pub mod clip_group_file; pub mod cmd; pub mod demo; pub mod edit; pub mod inspect; pub mod io; +pub mod score; use serde_json::{Value, json}; use soundcraft_model::{Clip, Session, TrackId}; @@ -74,6 +76,8 @@ pub struct Clipboard { pub clip_gain: Vec>, /// Copy Special › Clip Effects: clip-effect settings (`param` → value) of the first copied clip. pub clip_effects: Vec<(String, f64)>, + /// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers. + pub markers: Vec, } #[derive(Debug, Clone)] diff --git a/crates/engine/src/score.rs b/crates/engine/src/score.rs new file mode 100644 index 0000000..847be67 --- /dev/null +++ b/crates/engine/src/score.rs @@ -0,0 +1,561 @@ +//! Notation: MIDI tracks laid out as measures of notes and rests, written as MusicXML +//! (File › Export › Sibelius, File › Send To › Sibelius) or engraved to SVG (File › Print Score). +//! +//! Notes are quantized to sixteenths. Each part is a single voice: notes that start together form +//! a chord, overlapping notes are cut at the next onset, and anything crossing a bar line is split +//! and tied. + +use soundcraft_model::{ClipContent, Session, TrackId, TrackKind}; +use soundcraft_time::TICKS_PER_QUARTER; +use std::fmt::Write as _; + +/// Ticks per score unit (a sixteenth). +const UNIT: i64 = TICKS_PER_QUARTER / 4; +/// Upper bound on engraved measures, so a hostile session cannot make a huge document. +const MAX_MEASURES: usize = 2000; +/// Note values a single notehead can show, in sixteenths (longest first). +const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1]; + +/// Score Setup (File › Score Setup…). Stored in the session's edit flags/values. +#[derive(Debug, Clone, PartialEq)] +pub struct ScoreSetup { + pub title: String, + pub composer: String, + pub bars_per_system: u32, + pub show_track_names: bool, +} + +const TITLE_KEY: &str = "score.title="; +const COMPOSER_KEY: &str = "score.composer="; + +impl ScoreSetup { + pub fn from_session(s: &Session) -> Self { + let text = |key: &str| s.edit.flags.iter().find_map(|f| f.strip_prefix(key)).map(str::to_string); + let bars = s.edit.value("score.bars_per_system", 4.0); + Self { + title: text(TITLE_KEY).unwrap_or_else(|| s.name.clone()), + composer: text(COMPOSER_KEY).unwrap_or_default(), + bars_per_system: if bars.is_finite() { bars.clamp(1.0, 16.0) as u32 } else { 4 }, + show_track_names: !s.edit.flag("score.hide_names"), + } + } + + pub fn store(&self, s: &mut Session) { + s.edit.flags.retain(|f| !f.starts_with(TITLE_KEY) && !f.starts_with(COMPOSER_KEY)); + let clean = |t: &str| t.chars().filter(|c| !c.is_control()).take(200).collect::(); + s.edit.flags.insert(format!("{TITLE_KEY}{}", clean(&self.title))); + s.edit.flags.insert(format!("{COMPOSER_KEY}{}", clean(&self.composer))); + s.edit.values.insert("score.bars_per_system".into(), f64::from(self.bars_per_system.clamp(1, 16))); + s.edit.set_flag("score.hide_names", !self.show_track_names); + } +} + +/// One measure: its meter and its items (notes, chords or rests), in sixteenths. +#[derive(Debug, Clone, PartialEq)] +pub struct Measure { + pub numerator: u32, + pub denominator: u32, + pub items: Vec, +} + +/// A notehead group (chord) or a rest (no pitches). +#[derive(Debug, Clone, PartialEq)] +pub struct Item { + pub pitches: Vec, + /// Duration in sixteenths; always one of [`VALUES`]. + pub units: u32, + pub tie_start: bool, + pub tie_stop: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Part { + pub name: String, + /// True when the part reads best in bass clef. + pub bass: bool, + pub measures: Vec, +} + +/// Lay out the MIDI (and instrument) tracks `tracks` (all of them when empty). +pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec { + let sr = s.sample_rate; + let mut raw: Vec<(String, Vec<(i64, i64, u8)>)> = Vec::new(); + for t in &s.tracks { + if !matches!(t.kind, TrackKind::Midi | TrackKind::Instrument) || (!tracks.is_empty() && !tracks.contains(&t.id)) { + continue; + } + let mut notes = Vec::new(); + for c in t.clips() { + let ClipContent::Midi { sequence } = &c.content else { continue }; + if c.muted { + continue; + } + let base = s.tempo.samples_to_ticks(c.start, sr); + let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr); + for n in &sequence.notes { + let st = base.saturating_add(n.start); + if st < base || st >= end || n.length <= 0 { + continue; + } + let a = quantize(st); + let b = quantize(st.saturating_add(n.length).min(end)).max(a + 1); + notes.push((a, b, n.pitch)); + } + } + raw.push((t.name.clone(), notes)); + } + let last = raw.iter().flat_map(|(_, n)| n.iter().map(|x| x.1)).max().unwrap_or(0); + let bars = measure_grid(s, last); + raw.into_iter() + .map(|(name, notes)| { + let avg = if notes.is_empty() { 60.0 } else { notes.iter().map(|n| f64::from(n.2)).sum::() / notes.len() as f64 }; + Part { name, bass: avg < 57.0, measures: fill(&bars, ¬es) } + }) + .collect() +} + +fn quantize(tick: i64) -> i64 { + (tick.max(0) + UNIT / 2) / UNIT +} + +/// (start unit, length in units, numerator, denominator) per measure, covering `end_unit`. +fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> { + let mut v = Vec::new(); + let mut bar = 1; + loop { + let a = s.tempo.bar_start_tick(bar); + let b = s.tempo.bar_start_tick(bar + 1); + let m = s.tempo.meter_at_tick(a); + let (ua, ub) = (a / UNIT, b / UNIT); + if ub <= ua { + break; + } + v.push((ua, ub - ua, m.numerator, m.denominator)); + bar += 1; + if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES { + break; + } + } + v +} + +/// Split `units` into note values that a single notehead can show. +fn split_units(mut units: i64) -> Vec { + let mut out = Vec::new(); + while units > 0 { + let v = VALUES.iter().copied().find(|v| i64::from(*v) <= units).unwrap_or(1); + out.push(v); + units -= i64::from(v); + } + out +} + +fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec { + // Onsets → chords, each held until its longest note ends or the next onset. + let mut onsets: std::collections::BTreeMap)> = std::collections::BTreeMap::new(); + for (a, b, p) in notes { + let e = onsets.entry(*a).or_insert((*b, Vec::new())); + e.0 = e.0.max(*b); + if !e.1.contains(p) { + e.1.push(*p); + } + } + let starts: Vec = onsets.keys().copied().collect(); + let mut spans: Vec<(i64, i64, Vec)> = Vec::new(); + for (i, (a, (b, mut ps))) in onsets.into_iter().enumerate() { + let end = starts.get(i + 1).map_or(b, |n| b.min(*n)); + ps.sort_unstable(); + spans.push((a, end, ps)); + } + let mut measures = Vec::new(); + for (ms, ml, num, den) in bars { + let me = ms + ml; + let mut items = Vec::new(); + let mut cursor = *ms; + let emit = |from: i64, to: i64, pitches: &[u8], tied_in: bool, tied_out: bool, items: &mut Vec| { + let parts = split_units(to - from); + let n = parts.len(); + for (k, u) in parts.into_iter().enumerate() { + let rest = pitches.is_empty(); + items.push(Item { + pitches: pitches.to_vec(), + units: u, + tie_stop: !rest && (tied_in || k > 0), + tie_start: !rest && (tied_out || k + 1 < n), + }); + } + }; + for (a, b, ps) in spans.iter().filter(|(a, b, _)| *b > *ms && *a < me) { + let from = (*a).max(*ms).max(cursor); + let to = (*b).min(me); + if to <= from { + continue; + } + if from > cursor { + emit(cursor, from, &[], false, false, &mut items); + } + emit(from, to, ps, *a < *ms, *b > me, &mut items); + cursor = to; + } + if cursor < me { + emit(cursor, me, &[], false, false, &mut items); + } + measures.push(Measure { numerator: *num, denominator: *den, items }); + } + measures +} + +/// (step, alter, octave) spelled with sharps. +fn spell(pitch: u8) -> (char, i32, i32) { + const STEPS: [(char, i32); 12] = + [('C', 0), ('C', 1), ('D', 0), ('D', 1), ('E', 0), ('F', 0), ('F', 1), ('G', 0), ('G', 1), ('A', 0), ('A', 1), ('B', 0)]; + let (step, alter) = STEPS[usize::from(pitch % 12)]; + (step, alter, i32::from(pitch / 12) - 1) +} + +fn xml_escape(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +fn note_type(units: u32) -> (&'static str, bool) { + match units { + 16 => ("whole", false), + 12 => ("half", true), + 8 => ("half", false), + 6 => ("quarter", true), + 4 => ("quarter", false), + 3 => ("eighth", true), + 2 => ("eighth", false), + _ => ("16th", false), + } +} + +/// MusicXML 4.0 (partwise) for `parts`. +pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String { + let mut x = String::new(); + x.push_str("\n"); + x.push_str("\n"); + x.push_str("\n"); + let _ = writeln!(x, " {}", xml_escape(&setup.title)); + x.push_str(" \n"); + if !setup.composer.is_empty() { + let _ = writeln!(x, " {}", xml_escape(&setup.composer)); + } + x.push_str(" SoundCraft\n \n \n"); + for (i, p) in parts.iter().enumerate() { + let _ = writeln!(x, " {}", i + 1, xml_escape(&p.name)); + } + x.push_str(" \n"); + for (i, p) in parts.iter().enumerate() { + let _ = writeln!(x, " ", i + 1); + let mut meter = (0, 0); + for (mi, m) in p.measures.iter().enumerate() { + let _ = writeln!(x, " ", mi + 1); + if mi == 0 || meter != (m.numerator, m.denominator) { + x.push_str(" "); + if mi == 0 { + x.push_str("40"); + } + let _ = write!(x, "", m.numerator, m.denominator); + if mi == 0 { + x.push_str(if p.bass { "F4" } else { "G2" }); + } + x.push_str("\n"); + meter = (m.numerator, m.denominator); + } + for it in &m.items { + let (ty, dot) = note_type(it.units); + if it.pitches.is_empty() { + let _ = writeln!( + x, + " {}1{ty}{}", + it.units, + if dot { "" } else { "" } + ); + continue; + } + for (k, pitch) in it.pitches.iter().enumerate() { + let (step, alter, octave) = spell(*pitch); + x.push_str(" "); + if k > 0 { + x.push_str(""); + } + let _ = write!(x, "{step}"); + if alter != 0 { + let _ = write!(x, "{alter}"); + } + let _ = write!(x, "{octave}{}", it.units); + if it.tie_stop { + x.push_str(""); + } + if it.tie_start { + x.push_str(""); + } + let _ = write!(x, "1{ty}"); + if dot { + x.push_str(""); + } + if alter != 0 { + x.push_str("sharp"); + } + if it.tie_stop || it.tie_start { + x.push_str(""); + if it.tie_stop { + x.push_str(""); + } + if it.tie_start { + x.push_str(""); + } + x.push_str(""); + } + x.push_str("\n"); + } + } + x.push_str(" \n"); + } + x.push_str(" \n"); + } + x.push_str("\n"); + x +} + +/// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2). +fn staff_step(pitch: u8, bass: bool) -> i32 { + let (step, _, octave) = spell(pitch); + let idx = "CDEFGAB".find(step).map_or(0, |i| i as i32); + let diatonic = octave * 7 + idx; + diatonic - if bass { 2 * 7 + 4 } else { 4 * 7 + 2 } +} + +/// An engraved score as a printable SVG page (A4 portrait width, as tall as needed). +pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String { + const W: f64 = 794.0; + const MARGIN: f64 = 48.0; + const SP: f64 = 7.0; // staff space + const STAFF_GAP: f64 = 70.0; + const SYSTEM_GAP: f64 = 40.0; + let n_measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0); + let per = setup.bars_per_system.max(1) as usize; + let systems = n_measures.div_ceil(per).max(1); + let label_w = if setup.show_track_names { 70.0 } else { 0.0 }; + let staff_x0 = MARGIN + label_w + 34.0; + let staff_x1 = W - MARGIN; + let sys_h = parts.len().max(1) as f64 * STAFF_GAP + SYSTEM_GAP; + let top = 110.0; + let h = top + systems as f64 * sys_h + MARGIN; + let mut o = String::new(); + let _ = + writeln!(o, ""); + let _ = writeln!(o, ""); + let _ = writeln!(o, "{}", W / 2.0, xml_escape(&setup.title)); + if !setup.composer.is_empty() { + let _ = writeln!(o, "{}", W - MARGIN, xml_escape(&setup.composer)); + } + let bar_w = (staff_x1 - staff_x0) / per as f64; + for sys in 0..systems { + let y_sys = top + sys as f64 * sys_h; + for (pi, p) in parts.iter().enumerate() { + let y0 = y_sys + pi as f64 * STAFF_GAP; // top line + let bottom = y0 + 4.0 * SP; + if setup.show_track_names && sys == 0 { + let _ = writeln!(o, "{}", y0 + 2.0 * SP + 4.0, xml_escape(&p.name)); + } + for l in 0..5 { + let y = y0 + f64::from(l) * SP; + let _ = writeln!( + o, + "", + staff_x0 - 30.0 + ); + } + let clef = if p.bass { "𝄢" } else { "𝄞" }; + let _ = writeln!( + o, + "{clef}", + staff_x0 - 29.0, + bottom + if p.bass { -SP } else { SP * 0.6 }, + if p.bass { 26 } else { 36 } + ); + for k in 0..per { + let mi = sys * per + k; + let x0 = staff_x0 + k as f64 * bar_w; + let _ = writeln!( + o, + "", + x0 + bar_w, + x0 + bar_w + ); + let Some(m) = p.measures.get(mi) else { continue }; + if pi == 0 { + let _ = writeln!(o, "{}", x0 + 2.0, y0 - 8.0, mi + 1); + } + let total: u32 = m.items.iter().map(|i| i.units).sum::().max(1); + let mut acc = 0u32; + for it in &m.items { + let x = x0 + 10.0 + (bar_w - 16.0) * f64::from(acc) / f64::from(total); + acc += it.units; + let (_, dot) = note_type(it.units); + if it.pitches.is_empty() { + rest(&mut o, x, y0, SP, it.units); + if dot { + let _ = writeln!(o, "", x + 7.0, y0 + 1.5 * SP); + } + continue; + } + let steps: Vec = it.pitches.iter().map(|p2| staff_step(*p2, p.bass)).collect(); + let filled = it.units < 8; + for (pitch, st) in it.pitches.iter().zip(&steps) { + let y = bottom - f64::from(*st) * SP / 2.0; + // Ledger lines. + let mut l = if *st < 0 { -2 } else { 10 }; + while (*st < 0 && l >= *st) || (*st > 8 && l <= *st) { + let ly = bottom - f64::from(l) * SP / 2.0; + let _ = writeln!( + o, + "", + x - 7.0, + x + 7.0 + ); + l += if *st < 0 { -2 } else { 2 }; + } + let fill = if filled { "black" } else { "white" }; + let _ = writeln!( + o, + "" + ); + if spell(*pitch).1 != 0 { + let _ = writeln!(o, "♯", x - 14.0, y + 4.0); + } + if dot { + let _ = writeln!(o, "", x + 8.0, y - 1.5); + } + } + if it.units < 16 { + let (lo, hi) = (steps.iter().copied().min().unwrap_or(4), steps.iter().copied().max().unwrap_or(4)); + let up = (lo + hi) < 8; + let (sx, y_from, y_to) = if up { + (x + 4.3, bottom - f64::from(lo) * SP / 2.0, bottom - f64::from(hi) * SP / 2.0 - 3.5 * SP) + } else { + (x - 4.3, bottom - f64::from(hi) * SP / 2.0, bottom - f64::from(lo) * SP / 2.0 + 3.5 * SP) + }; + let _ = writeln!( + o, + "" + ); + let flags = match it.units { + 1 => 2, + 2 | 3 => 1, + _ => 0, + }; + for f in 0..flags { + let fy = y_to + f64::from(f) * if up { 5.0 } else { -5.0 }; + let dy = if up { 9.0 } else { -9.0 }; + let _ = writeln!( + o, + "", + dy * 0.5 + ); + } + } + if it.tie_start { + let y = bottom - f64::from(steps[0]) * SP / 2.0 + 6.0; + let _ = writeln!(o, "", x + 3.0); + } + } + } + } + // System bracket. + let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP; + let _ = writeln!( + o, + "", + staff_x0 - 30.0, + staff_x0 - 30.0 + ); + } + o.push_str("\n"); + o +} + +fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) { + let mid = y0 + 2.0 * sp; + match units { + 16 => { + let _ = writeln!(o, "", x - 4.5, y0 + sp, sp / 2.0); + } + 8 | 12 => { + let _ = writeln!(o, "", x - 4.5, mid - sp / 2.0, sp / 2.0); + } + 4 | 6 => { + let _ = writeln!( + o, + "", + x - 2.0, + mid - 9.0 + ); + } + _ => { + let _ = writeln!(o, "", x - 2.0, mid - 3.0); + let _ = writeln!( + o, + "", + x + 2.5, + mid - 4.0, + x - 1.0, + mid + 7.0 + ); + if units == 1 { + let _ = writeln!(o, "", x - 3.5, mid + 2.0); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_units_uses_representable_values() { + assert_eq!(split_units(16), vec![16]); + assert_eq!(split_units(5), vec![4, 1]); + assert_eq!(split_units(7), vec![6, 1]); + assert!(split_units(0).is_empty()); + } + + #[test] + fn spelling_and_staff_steps() { + assert_eq!(spell(60), ('C', 0, 4)); + assert_eq!(spell(61), ('C', 1, 4)); + assert_eq!(staff_step(64, false), 0); // E4: bottom treble line + assert_eq!(staff_step(77, false), 8); // F5: top treble line + assert_eq!(staff_step(43, true), 0); // G2: bottom bass line + } + + #[test] + fn measures_fill_exactly_and_tie_across_bars() { + // 4/4: one bar = 16 units. A note from unit 12 to 20 crosses the bar line. + let bars = vec![(0, 16, 4, 4), (16, 16, 4, 4)]; + let m = fill(&bars, &[(0, 4, 60), (0, 4, 64), (12, 20, 67)]); + assert_eq!(m.len(), 2); + for meas in &m { + assert_eq!(meas.items.iter().map(|i| i.units).sum::(), 16); + } + assert_eq!(m[0].items[0].pitches, vec![60, 64]); + assert!(m[0].items.last().is_some_and(|i| i.tie_start)); + assert!(m[1].items[0].tie_stop); + } + + #[test] + fn demo_exports_well_formed_documents() { + let e = crate::demo::demo_engine(); + let parts = layout(e.session(), &[]); + assert!(!parts.is_empty(), "demo has MIDI tracks"); + let setup = ScoreSetup::from_session(e.session()); + let x = musicxml(&parts, &setup); + assert!(x.contains("") && x.trim_end().ends_with("")); + assert_eq!(x.matches("").count()); + let s = svg(&parts, &setup); + assert!(s.starts_with(" }, Session { frame_rate: String, bit_depth: String }, + ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool }, } #[derive(Default)] @@ -41,6 +42,7 @@ impl Dialogs { Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", Dialog::Session { .. } => "session", + Dialog::ScoreSetup { .. } => "score_setup", }) } @@ -91,14 +93,56 @@ impl Dialogs { Dialog::Group { name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members } } "setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() }, - _ => { - return false; + "file.score_setup" => { + let s = soundcraft_engine::score::ScoreSetup::from_session(e.session()); + Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names } } + _ => match path_dialog(id, &home, &name) { + Some(d) => d, + None => return false, + }, }); true } } +/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus +/// Print Score, whose path is optional), with a default file name from the command. +pub fn takes_path(id: &str) -> bool { + path_key(id).is_some() +} + +fn path_key(id: &str) -> Option<&'static str> { + let spec = soundcraft_engine::command_specs().iter().find(|c| c.id == id)?; + if spec.params.starts_with("{path?") && id != "file.print_score" { + None + } else if spec.params.starts_with("{path") { + Some("path") + } else if spec.params.starts_with("{dir") { + Some("dir") + } else { + None + } +} + +fn path_dialog(id: &str, home: &str, name: &str) -> Option { + let spec = soundcraft_engine::command_specs().iter().find(|c| c.id == id)?; + let key = path_key(id)?; + let importing = id.contains("import") || id.contains("open"); + let ext = match id { + "file.export_midi" => ".mid", + "file.export_sibelius" => ".musicxml", + "file.print_score" => ".svg", + "file.export_clip_groups" => ".scgrp", + "file.export_session_text" => ".txt", + _ if id.contains("session") => ".scraft", + _ => "", + }; + let path = if importing || key == "dir" { format!("{home}/") } else { format!("{home}/{name}{ext}") }; + let title = format!("{} — {}", spec.menu.last().copied().unwrap_or("File"), spec.label.trim_end_matches("...")); + Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() }) +} + fn default_dir() -> String { #[cfg(not(target_arch = "wasm32"))] { @@ -127,6 +171,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", Dialog::Session { .. } => "Session Setup", + Dialog::ScoreSetup { .. } => "Score Setup", } .to_string(); let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter)); @@ -325,6 +370,26 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("setup.session".into(), json!({"frame_rate": frame_rate, "bit_depth": bit_depth}))); } } + Dialog::ScoreSetup { title, composer, bars_per_system, show_track_names } => { + egui::Grid::new("score_setup").num_columns(2).spacing(vec2(10.0, 8.0)).show(ui, |ui| { + ui.label("Title"); + ui.text_edit_singleline(title); + ui.end_row(); + ui.label("Composer"); + ui.text_edit_singleline(composer); + ui.end_row(); + ui.label("Bars per system"); + ui.add(egui::DragValue::new(bars_per_system).range(1..=16)); + ui.end_row(); + }); + ui.checkbox(show_track_names, "Show track names"); + if buttons(ui, "OK", enter) { + action = Some(( + "file.score_setup".into(), + json!({"title": title, "composer": composer, "bars_per_system": bars_per_system, "show_track_names": show_track_names}), + )); + } + } } }); if esc || ctx.memory(|m| m.data.get_temp::(egui::Id::new("dlg_cancel")).unwrap_or(false)) { @@ -403,3 +468,31 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { app.ui.audiosuite = None; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_commands_get_path_prompts_with_sensible_names() { + let e = soundcraft_engine::demo::demo_engine(); + let mut d = Dialogs::default(); + for (id, ext) in [("file.export_sibelius", ".musicxml"), ("file.print_score", ".svg"), ("file.export_clip_groups", ".scgrp")] { + assert!(takes_path(id), "{id}"); + assert!(d.open_for_command(&e, id), "{id}"); + match d.open.take() { + Some(Dialog::PathPrompt { cmd, path, key, .. }) => { + assert_eq!(cmd, id); + assert_eq!(key, "path"); + assert!(path.ends_with(ext), "{path}"); + } + other => panic!("{id}: {other:?}"), + } + } + // Optional-path commands run directly. + assert!(!takes_path("file.send_to_sibelius")); + assert!(!takes_path("edit.copy")); + assert!(d.open_for_command(&e, "file.score_setup")); + assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 6e06862..c970870 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -339,7 +339,7 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { app.ui.audiosuite = p.get("process").and_then(Value::as_str).map(str::to_string); return; } - if DIALOG_COMMANDS.contains(&id) && app.dialogs.open_for_command(&app.engine, id) { + if wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) { return; } let params = params_for(path, id); @@ -348,7 +348,12 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { /// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled. pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool { - DIALOG_COMMANDS.contains(&id) && app.dialogs.open_for_command(&app.engine, id) + wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) +} + +/// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path. +fn wants_dialog(id: &str) -> bool { + DIALOG_COMMANDS.contains(&id) || id == "file.score_setup" || crate::dialogs::takes_path(id) } /// Handle UI-layer commands. Returns None when `id` is not a UI command. diff --git a/docs/parity.md b/docs/parity.md index 54a8fa9..5572f35 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -4,16 +4,16 @@ Generated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask p It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry. UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel). -**Menu items implemented by the engine: 388 / 512 (75.8%)** +**Menu items implemented by the engine: 397 / 512 (77.5%)** | Menu | Implemented | Total | |---|---:|---:| | AudioSuite | 0 | 28 | | Clip | 28 | 29 | -| Edit | 93 | 98 | +| Edit | 95 | 98 | | Event | 32 | 32 | -| File | 16 | 30 | -| Options | 39 | 41 | +| File | 22 | 30 | +| Options | 40 | 41 | | Setup | 18 | 19 | | Track | 69 | 71 | | View | 92 | 112 | @@ -25,18 +25,10 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en - File > Open > Sketch... - File > Dashboard... - File > Start Collaboration... -- File > Send To... > Sibelius -- File > Import > Video... -- File > Import > Clip Groups... - File > Import > VENUE Channel Names as Track Names - File > Export > Selected Tracks as New AAF/OMF... - File > Export > Selected Tracks as New Media Composer Compatible Session... -- File > Export > Sibelius... -- File > Score Setup... -- File > Print Score... - File > Sign In -- Edit > Copy Special > Audio as MIDI -- Edit > Paste Special > Merge Markers - Edit > Automation > Duplicate Dolby Atmos Plugin Automation to Pan Automation - Edit > Automation > Duplicate Pan Automation to Dolby Atmos Plugin Automation - Edit > Automation > Coalesce Pan Guide Automation @@ -91,7 +83,6 @@ UI-layer commands (windows, AudioSuite) are counted by the app's own report (`en - AudioSuite > Other > Reverse - AudioSuite > Other > Signal Generator - AudioSuite > Other > Time Compression Expansion -- Options > Video Track Online - Options > Activate HEAT - Setup > Machine Track Arming Profiles... - Window > Configurations > Window Configuration List