music21_rs/musescore/
program.rs1use crate::error::{Error, Result};
9use crate::musicxml::{ExportOptions, from_musicxml, to_musicxml};
10use crate::stream::Stream;
11use std::path::{Path, PathBuf};
12use std::process::Command;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15pub const MUSESCORE_PATH: &str = "MUSESCORE_PATH";
18
19const NAMES: [&str; 7] = [
21 "MuseScore4",
22 "mscore4portable",
23 "musescore4",
24 "mscore",
25 "musescore",
26 "MuseScore3",
27 "mscore3",
28];
29
30#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct MuseScore {
43 executable: PathBuf,
44}
45
46fn failed(message: impl Into<String>) -> Error {
47 Error::MuseScore(message.into())
48}
49
50fn scratch(extension: &str) -> PathBuf {
52 static COUNTER: AtomicU64 = AtomicU64::new(0);
53 let serial = COUNTER.fetch_add(1, Ordering::Relaxed);
54 let nanos = std::time::SystemTime::now()
55 .duration_since(std::time::UNIX_EPOCH)
56 .map_or(0, |elapsed| elapsed.subsec_nanos());
57 std::env::temp_dir().join(format!(
58 "music21-rs-{}-{serial}-{nanos}.{extension}",
59 std::process::id()
60 ))
61}
62
63struct Scratch(PathBuf);
65
66impl Drop for Scratch {
67 fn drop(&mut self) {
68 let _ = std::fs::remove_file(&self.0);
69 }
70}
71
72impl MuseScore {
73 pub fn at(executable: impl Into<PathBuf>) -> Self {
75 Self {
76 executable: executable.into(),
77 }
78 }
79
80 pub fn find() -> Option<Self> {
83 if let Some(named) = std::env::var_os(MUSESCORE_PATH) {
84 let path = PathBuf::from(named);
85 if path.is_file() {
86 return Some(Self::at(path));
87 }
88 }
89 let suffix = std::env::consts::EXE_SUFFIX;
90 if let Some(paths) = std::env::var_os("PATH") {
91 for directory in std::env::split_paths(&paths) {
92 for name in NAMES {
93 let candidate = directory.join(format!("{name}{suffix}"));
94 if candidate.is_file() {
95 return Some(Self::at(candidate));
96 }
97 }
98 }
99 }
100 let installed: [&str; 6] = [
101 r"C:\Program Files\MuseScore 4\bin\MuseScore4.exe",
102 r"C:\Program Files\MuseScore 3\bin\MuseScore3.exe",
103 "/Applications/MuseScore 4.app/Contents/MacOS/mscore",
104 "/Applications/MuseScore 3.app/Contents/MacOS/mscore",
105 "/usr/bin/mscore",
106 "/usr/local/bin/mscore",
107 ];
108 installed
109 .iter()
110 .map(Path::new)
111 .find(|path| path.is_file())
112 .map(Self::at)
113 }
114
115 pub fn executable(&self) -> &Path {
117 &self.executable
118 }
119
120 pub fn convert(&self, input: &Path, output: &Path) -> Result<()> {
123 if !input.is_file() {
124 return Err(failed(format!("{} is not a file", input.display())));
125 }
126 let ran = Command::new(&self.executable)
127 .arg("-o")
128 .arg(output)
129 .arg(input)
130 .output()
131 .map_err(|error| {
132 failed(format!(
133 "{} could not be run: {error}",
134 self.executable.display()
135 ))
136 })?;
137 if !ran.status.success() || !output.is_file() {
138 let said = String::from_utf8_lossy(&ran.stderr);
139 return Err(failed(format!(
140 "MuseScore could not convert {} to {}{}{}",
141 input.display(),
142 output.display(),
143 if said.trim().is_empty() { "" } else { ": " },
144 said.trim()
145 )));
146 }
147 Ok(())
148 }
149
150 pub fn read(&self, path: &Path) -> Result<Stream> {
153 let converted = Scratch(scratch("musicxml"));
154 self.convert(path, &converted.0)?;
155 let text = std::fs::read_to_string(&converted.0)
156 .map_err(|error| failed(format!("what MuseScore wrote could not be read: {error}")))?;
157 from_musicxml(&text)
158 }
159
160 pub fn write(&self, score: &Stream, path: &Path, options: &ExportOptions) -> Result<()> {
164 let written = Scratch(scratch("musicxml"));
165 std::fs::write(&written.0, to_musicxml(score, options)?)
166 .map_err(|error| failed(format!("the score could not be written out: {error}")))?;
167 self.convert(&written.0, path)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::{Clef, Note, StreamKind, TimeSignature};
175
176 #[test]
177 fn a_missing_program_and_a_missing_file_are_errors() {
178 let nowhere = MuseScore::at("no-such-directory/mscore");
179 let missing = Path::new("no-such-file.mscz");
180 assert!(matches!(
181 nowhere.convert(missing, Path::new("out.musicxml")),
182 Err(Error::MuseScore(_))
183 ));
184 let here = scratch("musicxml");
185 std::fs::write(&here, "<score-partwise/>").unwrap();
186 let ran = nowhere.convert(&here, &scratch("mscz"));
187 let _ = std::fs::remove_file(&here);
188 assert!(
189 matches!(ran, Err(Error::MuseScore(message)) if message.contains("could not be run"))
190 );
191 }
192
193 #[test]
194 fn scratch_files_do_not_collide() {
195 assert_ne!(scratch("musicxml"), scratch("musicxml"));
196 }
197
198 #[test]
200 fn a_score_survives_a_trip_through_musescore() {
201 let Some(musescore) = MuseScore::find() else {
202 return;
204 };
205 let mut measure = Stream::with_kind(StreamKind::Measure);
206 measure.set_number(1);
207 measure.insert(0.0, Clef::treble());
208 measure.insert(0.0, TimeSignature::new(3, 4).unwrap());
209 for name in ["C4", "E4", "G4"] {
210 measure.push(Note::from_name(name).unwrap());
211 }
212 let mut part = Stream::with_kind(StreamKind::Part);
213 part.push(measure);
214 let mut score = Stream::with_kind(StreamKind::Score);
215 score.push(part);
216
217 let file = Scratch(scratch("mscz"));
218 musescore
219 .write(&score, &file.0, &ExportOptions::default())
220 .unwrap();
221 let read = musescore.read(&file.0).unwrap();
222 let names: Vec<String> = read
223 .pitches()
224 .iter()
225 .map(|pitch| pitch.name_with_octave())
226 .collect();
227 assert_eq!(names, ["C4", "E4", "G4"]);
228 }
229}