Score export and print, clip group files, Merge Markers, path dialogs for every file command

- 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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 01:17:42 -04:00
parent 0368d7f541
commit 0751dd31f1
10 changed files with 1018 additions and 18 deletions

View File

@ -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");
}
}

View File

@ -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 `<name> 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<Vec<Clip>>,
/// Source id (as written) → audio file path relative to the group file.
audio: BTreeMap<u64, String>,
}
/// 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<usize> {
let s = e.session();
let mut lanes: Vec<(usize, Vec<Clip>)> = 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<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
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::<usize>() > 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<u64, SourceId> = 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::<Vec<_>>()})).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);
}
}

View File

@ -185,6 +185,19 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
.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();

View File

@ -107,6 +107,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<ClipId>) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);

View File

@ -129,6 +129,57 @@ pub fn specs() -> Vec<CommandSpec> {
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::<Vec<_>>()}))
}),
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<Value> {
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<Value> {
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}))
}

View File

@ -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<Vec<(Samples, f32)>>,
/// 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<soundcraft_model::MemoryLocation>,
}
#[derive(Debug, Clone)]

561
crates/engine/src/score.rs Normal file
View File

@ -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::<String>();
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<Item>,
}
/// A notehead group (chord) or a rest (no pitches).
#[derive(Debug, Clone, PartialEq)]
pub struct Item {
pub pitches: Vec<u8>,
/// 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<Measure>,
}
/// Lay out the MIDI (and instrument) tracks `tracks` (all of them when empty).
pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec<Part> {
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::<f64>() / notes.len() as f64 };
Part { name, bass: avg < 57.0, measures: fill(&bars, &notes) }
})
.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<u32> {
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<Measure> {
// Onsets → chords, each held until its longest note ends or the next onset.
let mut onsets: std::collections::BTreeMap<i64, (i64, Vec<u8>)> = 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<i64> = onsets.keys().copied().collect();
let mut spans: Vec<(i64, i64, Vec<u8>)> = 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<Item>| {
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
}
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("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
x.push_str("<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 4.0 Partwise//EN\" \"http://www.musicxml.org/dtds/partwise.dtd\">\n");
x.push_str("<score-partwise version=\"4.0\">\n");
let _ = writeln!(x, " <work><work-title>{}</work-title></work>", xml_escape(&setup.title));
x.push_str(" <identification>\n");
if !setup.composer.is_empty() {
let _ = writeln!(x, " <creator type=\"composer\">{}</creator>", xml_escape(&setup.composer));
}
x.push_str(" <encoding><software>SoundCraft</software></encoding>\n </identification>\n <part-list>\n");
for (i, p) in parts.iter().enumerate() {
let _ = writeln!(x, " <score-part id=\"P{}\"><part-name>{}</part-name></score-part>", i + 1, xml_escape(&p.name));
}
x.push_str(" </part-list>\n");
for (i, p) in parts.iter().enumerate() {
let _ = writeln!(x, " <part id=\"P{}\">", i + 1);
let mut meter = (0, 0);
for (mi, m) in p.measures.iter().enumerate() {
let _ = writeln!(x, " <measure number=\"{}\">", mi + 1);
if mi == 0 || meter != (m.numerator, m.denominator) {
x.push_str(" <attributes>");
if mi == 0 {
x.push_str("<divisions>4</divisions><key><fifths>0</fifths></key>");
}
let _ = write!(x, "<time><beats>{}</beats><beat-type>{}</beat-type></time>", m.numerator, m.denominator);
if mi == 0 {
x.push_str(if p.bass { "<clef><sign>F</sign><line>4</line></clef>" } else { "<clef><sign>G</sign><line>2</line></clef>" });
}
x.push_str("</attributes>\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,
" <note><rest/><duration>{}</duration><voice>1</voice><type>{ty}</type>{}</note>",
it.units,
if dot { "<dot/>" } else { "" }
);
continue;
}
for (k, pitch) in it.pitches.iter().enumerate() {
let (step, alter, octave) = spell(*pitch);
x.push_str(" <note>");
if k > 0 {
x.push_str("<chord/>");
}
let _ = write!(x, "<pitch><step>{step}</step>");
if alter != 0 {
let _ = write!(x, "<alter>{alter}</alter>");
}
let _ = write!(x, "<octave>{octave}</octave></pitch><duration>{}</duration>", it.units);
if it.tie_stop {
x.push_str("<tie type=\"stop\"/>");
}
if it.tie_start {
x.push_str("<tie type=\"start\"/>");
}
let _ = write!(x, "<voice>1</voice><type>{ty}</type>");
if dot {
x.push_str("<dot/>");
}
if alter != 0 {
x.push_str("<accidental>sharp</accidental>");
}
if it.tie_stop || it.tie_start {
x.push_str("<notations>");
if it.tie_stop {
x.push_str("<tied type=\"stop\"/>");
}
if it.tie_start {
x.push_str("<tied type=\"start\"/>");
}
x.push_str("</notations>");
}
x.push_str("</note>\n");
}
}
x.push_str(" </measure>\n");
}
x.push_str(" </part>\n");
}
x.push_str("</score-partwise>\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, "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{W}\" height=\"{h:.0}\" viewBox=\"0 0 {W} {h:.0}\" font-family=\"serif\">");
let _ = writeln!(o, "<rect width=\"100%\" height=\"100%\" fill=\"white\"/>");
let _ = writeln!(o, "<text x=\"{}\" y=\"56\" font-size=\"24\" text-anchor=\"middle\">{}</text>", W / 2.0, xml_escape(&setup.title));
if !setup.composer.is_empty() {
let _ = writeln!(o, "<text x=\"{}\" y=\"84\" font-size=\"13\" text-anchor=\"end\">{}</text>", 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, "<text x=\"{MARGIN}\" y=\"{:.1}\" font-size=\"11\">{}</text>", 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,
"<line x1=\"{:.1}\" y1=\"{y:.1}\" x2=\"{staff_x1:.1}\" y2=\"{y:.1}\" stroke=\"black\" stroke-width=\"0.8\"/>",
staff_x0 - 30.0
);
}
let clef = if p.bass { "𝄢" } else { "𝄞" };
let _ = writeln!(
o,
"<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"{}\">{clef}</text>",
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,
"<line x1=\"{:.1}\" y1=\"{y0:.1}\" x2=\"{:.1}\" y2=\"{bottom:.1}\" stroke=\"black\" stroke-width=\"1\"/>",
x0 + bar_w,
x0 + bar_w
);
let Some(m) = p.measures.get(mi) else { continue };
if pi == 0 {
let _ = writeln!(o, "<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"9\">{}</text>", x0 + 2.0, y0 - 8.0, mi + 1);
}
let total: u32 = m.items.iter().map(|i| i.units).sum::<u32>().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, "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"1.3\"/>", x + 7.0, y0 + 1.5 * SP);
}
continue;
}
let steps: Vec<i32> = 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,
"<line x1=\"{:.1}\" y1=\"{ly:.1}\" x2=\"{:.1}\" y2=\"{ly:.1}\" stroke=\"black\" stroke-width=\"0.8\"/>",
x - 7.0,
x + 7.0
);
l += if *st < 0 { -2 } else { 2 };
}
let fill = if filled { "black" } else { "white" };
let _ = writeln!(
o,
"<ellipse cx=\"{x:.1}\" cy=\"{y:.1}\" rx=\"4.6\" ry=\"3.3\" transform=\"rotate(-20 {x:.1} {y:.1})\" fill=\"{fill}\" stroke=\"black\" stroke-width=\"1.1\"/>"
);
if spell(*pitch).1 != 0 {
let _ = writeln!(o, "<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"12\">♯</text>", x - 14.0, y + 4.0);
}
if dot {
let _ = writeln!(o, "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"1.3\"/>", 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,
"<line x1=\"{sx:.1}\" y1=\"{y_from:.1}\" x2=\"{sx:.1}\" y2=\"{y_to:.1}\" stroke=\"black\" stroke-width=\"1\"/>"
);
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,
"<path d=\"M{sx:.1} {fy:.1} q 7 {:.1} 5 {dy:.1}\" fill=\"none\" stroke=\"black\" stroke-width=\"1.4\"/>",
dy * 0.5
);
}
}
if it.tie_start {
let y = bottom - f64::from(steps[0]) * SP / 2.0 + 6.0;
let _ = writeln!(o, "<path d=\"M{:.1} {y:.1} q 8 5 16 0\" fill=\"none\" stroke=\"black\" stroke-width=\"0.9\"/>", 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,
"<line x1=\"{:.1}\" y1=\"{y_sys:.1}\" x2=\"{:.1}\" y2=\"{y_last:.1}\" stroke=\"black\" stroke-width=\"1.5\"/>",
staff_x0 - 30.0,
staff_x0 - 30.0
);
}
o.push_str("</svg>\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, "<rect x=\"{:.1}\" y=\"{:.1}\" width=\"9\" height=\"{:.1}\"/>", x - 4.5, y0 + sp, sp / 2.0);
}
8 | 12 => {
let _ = writeln!(o, "<rect x=\"{:.1}\" y=\"{:.1}\" width=\"9\" height=\"{:.1}\"/>", x - 4.5, mid - sp / 2.0, sp / 2.0);
}
4 | 6 => {
let _ = writeln!(
o,
"<path d=\"M{:.1} {:.1} l 4 5 l -4 4 l 4 5 q -6 -2 -2 5\" fill=\"none\" stroke=\"black\" stroke-width=\"1.8\"/>",
x - 2.0,
mid - 9.0
);
}
_ => {
let _ = writeln!(o, "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"2\"/>", x - 2.0, mid - 3.0);
let _ = writeln!(
o,
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"black\" stroke-width=\"1.2\"/>",
x + 2.5,
mid - 4.0,
x - 1.0,
mid + 7.0
);
if units == 1 {
let _ = writeln!(o, "<circle cx=\"{:.1}\" cy=\"{:.1}\" r=\"2\"/>", 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::<u32>(), 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("<score-partwise") && x.contains("<pitch>") && x.trim_end().ends_with("</score-partwise>"));
assert_eq!(x.matches("<measure ").count(), x.matches("</measure>").count());
let s = svg(&parts, &setup);
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
}
}

View File

@ -20,6 +20,7 @@ pub enum Dialog {
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
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<Dialog> {
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::<bool>(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, .. })));
}
}

View File

@ -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.

View File

@ -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