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:
parent
0368d7f541
commit
0751dd31f1
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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, ¬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<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('&', "&").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("<?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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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, .. })));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue