Skip to main content

music21/musescore/
program.rs

1// MuseScore run as a converter. A score is handed across as MusicXML: to
2// read a file MuseScore writes it out as MusicXML and `from_musicxml` reads
3// that; to write one, `to_musicxml` writes the score and MuseScore converts
4// it. This is the one part of the crate that runs a program and touches
5// files, which is why it sits behind the `musescore` feature and is left out
6// of a wasm build.
7
8use 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
15/// The environment variable naming the MuseScore executable, where it is
16/// not where [`MuseScore::find`] looks.
17pub const MUSESCORE_PATH: &str = "MUSESCORE_PATH";
18
19/// The names MuseScore's executable goes by on a search path.
20const NAMES: [&str; 7] = [
21    "MuseScore4",
22    "mscore4portable",
23    "musescore4",
24    "mscore",
25    "musescore",
26    "MuseScore3",
27    "mscore3",
28];
29
30/// A MuseScore installation.
31///
32/// ```no_run
33/// use music21::musescore::MuseScore;
34/// use std::path::Path;
35///
36/// let musescore = MuseScore::find().expect("MuseScore is installed");
37/// let score = musescore.read(Path::new("quartet.mscz"))?;
38/// musescore.write(&score, Path::new("quartet.pdf"), &Default::default())?;
39/// # Ok::<(), music21::Error>(())
40/// ```
41#[derive(Clone, Debug, PartialEq, Eq)]
42pub struct MuseScore {
43    executable: PathBuf,
44}
45
46fn failed(message: impl Into<String>) -> Error {
47    Error::muse_score(message.into()).of_kind(crate::error::FormatErrorKind::External)
48}
49
50/// A directory in the system's temporary directory that this call made and
51/// only this user can open, removed with what is in it when the call is
52/// over. The temporary directory may be shared with other users, so the
53/// files a call writes and reads back go in here, where no one else can put
54/// a file or a link in their place.
55struct Scratch(PathBuf);
56
57impl Scratch {
58    fn new() -> Result<Self> {
59        static COUNTER: AtomicU64 = AtomicU64::new(0);
60        // Elsewhere the temporary directory is the user's own.
61        #[cfg(not(unix))]
62        let builder = std::fs::DirBuilder::new();
63        #[cfg(unix)]
64        let builder = {
65            use std::os::unix::fs::DirBuilderExt;
66            let mut builder = std::fs::DirBuilder::new();
67            builder.mode(0o700);
68            builder
69        };
70        loop {
71            let serial = COUNTER.fetch_add(1, Ordering::Relaxed);
72            let nanos = std::time::SystemTime::now()
73                .duration_since(std::time::UNIX_EPOCH)
74                .map_or(0, |elapsed| elapsed.subsec_nanos());
75            let path = std::env::temp_dir().join(format!(
76                "music21-rs-{}-{serial}-{nanos}",
77                std::process::id()
78            ));
79            // Creating the directory fails where anything, a link included,
80            // is already there, so the one made is new.
81            match builder.create(&path) {
82                Ok(()) => return Ok(Self(path)),
83                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
84                Err(error) => {
85                    return Err(failed(format!(
86                        "no scratch directory could be made in {}: {error}",
87                        path.display()
88                    )));
89                }
90            }
91        }
92    }
93
94    /// A file of this name in the directory.
95    fn file(&self, name: &str) -> PathBuf {
96        self.0.join(name)
97    }
98}
99
100impl Drop for Scratch {
101    fn drop(&mut self) {
102        let _ = std::fs::remove_dir_all(&self.0);
103    }
104}
105
106impl MuseScore {
107    /// The installation whose executable is at this path.
108    pub fn at(executable: impl Into<PathBuf>) -> Self {
109        Self {
110            executable: executable.into(),
111        }
112    }
113
114    /// Looks for MuseScore: at the path [`MUSESCORE_PATH`] names, then on
115    /// the search path, then where its installers put it.
116    pub fn find() -> Option<Self> {
117        if let Some(named) = std::env::var_os(MUSESCORE_PATH) {
118            let path = PathBuf::from(named);
119            if path.is_file() {
120                return Some(Self::at(path));
121            }
122        }
123        let suffix = std::env::consts::EXE_SUFFIX;
124        if let Some(paths) = std::env::var_os("PATH") {
125            for directory in std::env::split_paths(&paths) {
126                for name in NAMES {
127                    let candidate = directory.join(format!("{name}{suffix}"));
128                    if candidate.is_file() {
129                        return Some(Self::at(candidate));
130                    }
131                }
132            }
133        }
134        let installed: [&str; 6] = [
135            r"C:\Program Files\MuseScore 4\bin\MuseScore4.exe",
136            r"C:\Program Files\MuseScore 3\bin\MuseScore3.exe",
137            "/Applications/MuseScore 4.app/Contents/MacOS/mscore",
138            "/Applications/MuseScore 3.app/Contents/MacOS/mscore",
139            "/usr/bin/mscore",
140            "/usr/local/bin/mscore",
141        ];
142        installed
143            .iter()
144            .map(Path::new)
145            .find(|path| path.is_file())
146            .map(Self::at)
147    }
148
149    /// Where the executable is.
150    pub fn executable(&self) -> &Path {
151        &self.executable
152    }
153
154    /// Converts one file into another, each in the format its extension
155    /// names: `mscore -o output input`.
156    pub fn convert(&self, input: &Path, output: &Path) -> Result<()> {
157        if !input.is_file() {
158            return Err(failed(format!("{} is not a file", input.display())));
159        }
160        let ran = Command::new(&self.executable)
161            .arg("-o")
162            .arg(output)
163            .arg(input)
164            .output()
165            .map_err(|error| {
166                failed(format!(
167                    "{} could not be run: {error}",
168                    self.executable.display()
169                ))
170            })?;
171        if !ran.status.success() || !output.is_file() {
172            let said = String::from_utf8_lossy(&ran.stderr);
173            return Err(failed(format!(
174                "MuseScore could not convert {} to {}{}{}",
175                input.display(),
176                output.display(),
177                if said.trim().is_empty() { "" } else { ": " },
178                said.trim()
179            )));
180        }
181        Ok(())
182    }
183
184    /// Reads a file of any format MuseScore opens -- `.mscz`, `.mscx`,
185    /// `.gp`, `.cap`, `.mei`, `.mid`, `.mxl` -- as a score.
186    pub fn read(&self, path: &Path) -> Result<Stream> {
187        let scratch = Scratch::new()?;
188        let converted = scratch.file("score.musicxml");
189        self.convert(path, &converted)?;
190        let text = std::fs::read_to_string(&converted)
191            .map_err(|error| failed(format!("what MuseScore wrote could not be read: {error}")))?;
192        from_musicxml(&text)
193    }
194
195    /// Writes a score as a file of any format MuseScore saves, named by the
196    /// path's extension: `.mscz`, `.mscx`, `.pdf`, `.png`, `.svg`, `.mid`,
197    /// `.mp3`, `.mei`.
198    pub fn write(&self, score: &Stream, path: &Path, options: &ExportOptions) -> Result<()> {
199        let scratch = Scratch::new()?;
200        let written = scratch.file("score.musicxml");
201        std::fs::write(&written, to_musicxml(score, options)?)
202            .map_err(|error| failed(format!("the score could not be written out: {error}")))?;
203        self.convert(&written, path)
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::{Clef, Note, StreamKind, TimeSignature};
211
212    #[test]
213    fn a_missing_program_and_a_missing_file_are_errors() {
214        let nowhere = MuseScore::at("no-such-directory/mscore");
215        let missing = Path::new("no-such-file.mscz");
216        assert!(matches!(
217            nowhere.convert(missing, Path::new("out.musicxml")),
218            Err(Error::MuseScore(_))
219        ));
220        let scratch = Scratch::new().unwrap();
221        let here = scratch.file("score.musicxml");
222        std::fs::write(&here, "<score-partwise/>").unwrap();
223        let ran = nowhere.convert(&here, &scratch.file("score.mscz"));
224        assert!(
225            matches!(ran, Err(Error::MuseScore(read)) if read.message.contains("could not be run"))
226        );
227    }
228
229    #[test]
230    fn scratch_directories_are_new_private_and_removed() {
231        let first = Scratch::new().unwrap();
232        let second = Scratch::new().unwrap();
233        assert_ne!(first.0, second.0);
234        for scratch in [&first, &second] {
235            let found = std::fs::symlink_metadata(&scratch.0).unwrap();
236            assert!(found.is_dir());
237            assert_eq!(std::fs::read_dir(&scratch.0).unwrap().count(), 0);
238            #[cfg(unix)]
239            {
240                use std::os::unix::fs::PermissionsExt;
241                assert_eq!(found.permissions().mode() & 0o077, 0);
242            }
243        }
244        let gone = first.0.clone();
245        std::fs::write(first.file("score.musicxml"), "<score-partwise/>").unwrap();
246        drop(first);
247        assert!(!gone.exists());
248    }
249
250    /// Round the houses through a real MuseScore, where there is one.
251    #[test]
252    fn a_score_survives_a_trip_through_musescore() {
253        let Some(musescore) = MuseScore::find() else {
254            // Nothing to test against on a machine without MuseScore.
255            return;
256        };
257        let mut measure = Stream::with_kind(StreamKind::Measure);
258        measure.set_number(1);
259        measure.insert(0.0, Clef::treble());
260        measure.insert(0.0, TimeSignature::new(3, 4).unwrap());
261        for name in ["C4", "E4", "G4"] {
262            measure.push(Note::from_name(name).unwrap());
263        }
264        let mut part = Stream::with_kind(StreamKind::Part);
265        part.push(measure);
266        let mut score = Stream::with_kind(StreamKind::Score);
267        score.push(part);
268
269        let scratch = Scratch::new().unwrap();
270        let file = scratch.file("score.mscz");
271        musescore
272            .write(&score, &file, &ExportOptions::default())
273            .unwrap();
274        let read = musescore.read(&file).unwrap();
275        let names: Vec<String> = read
276            .pitches()
277            .iter()
278            .map(|pitch| pitch.name_with_octave())
279            .collect();
280        assert_eq!(names, ["C4", "E4", "G4"]);
281    }
282}