Skip to main content

music21_rs/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_rs::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_rs::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::MuseScore(message.into())
48}
49
50/// A file in the system's temporary directory no other call is using.
51fn 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
63/// Removes a scratch file when the call that made it is over.
64struct 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    /// The installation whose executable is at this path.
74    pub fn at(executable: impl Into<PathBuf>) -> Self {
75        Self {
76            executable: executable.into(),
77        }
78    }
79
80    /// Looks for MuseScore: at the path [`MUSESCORE_PATH`] names, then on
81    /// the search path, then where its installers put it.
82    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    /// Where the executable is.
116    pub fn executable(&self) -> &Path {
117        &self.executable
118    }
119
120    /// Converts one file into another, each in the format its extension
121    /// names: `mscore -o output input`.
122    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    /// Reads a file of any format MuseScore opens -- `.mscz`, `.mscx`,
151    /// `.gp`, `.cap`, `.mei`, `.mid`, `.mxl` -- as a score.
152    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    /// Writes a score as a file of any format MuseScore saves, named by the
161    /// path's extension: `.mscz`, `.mscx`, `.pdf`, `.png`, `.svg`, `.mid`,
162    /// `.mp3`, `.mei`.
163    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    /// Round the houses through a real MuseScore, where there is one.
199    #[test]
200    fn a_score_survives_a_trip_through_musescore() {
201        let Some(musescore) = MuseScore::find() else {
202            // Nothing to test against on a machine without MuseScore.
203            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}