diff --git a/src/main/org/audiveris/omr/CLI.java b/src/main/org/audiveris/omr/CLI.java
index 0c7c18c71..b5442ccbd 100644
--- a/src/main/org/audiveris/omr/CLI.java
+++ b/src/main/org/audiveris/omr/CLI.java
@@ -927,7 +927,7 @@ protected void processBook (Book book)
LogUtil.stopBook();
if (OMR.gui == null) {
- LogUtil.removeAppender(book.getRadix());
+ LogUtil.stopAndRemoveAppender(book.getRadix());
}
}
}
diff --git a/src/main/org/audiveris/omr/log/LogUtil.java b/src/main/org/audiveris/omr/log/LogUtil.java
index 3fa9a0dba..ffc8f1390 100644
--- a/src/main/org/audiveris/omr/log/LogUtil.java
+++ b/src/main/org/audiveris/omr/log/LogUtil.java
@@ -314,10 +314,11 @@ private static void initMessage (String str)
*
* @param name appender name (typically the book radix)
*/
- public static void removeAppender (String name)
+ public static void stopAndRemoveAppender (String name)
{
Logger root = (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(
Logger.ROOT_LOGGER_NAME);
+ root.getAppender(name).stop();
root.detachAppender(name);
}
diff --git a/src/main/org/audiveris/omr/util/FileUtil.java b/src/main/org/audiveris/omr/util/FileUtil.java
index 2e598e595..f265268a5 100644
--- a/src/main/org/audiveris/omr/util/FileUtil.java
+++ b/src/main/org/audiveris/omr/util/FileUtil.java
@@ -43,6 +43,8 @@
import java.nio.file.attribute.BasicFileAttributes;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
+import java.util.function.Predicate;
/**
* Class FileUtil
gathers convenient utility methods for files (and paths).
@@ -486,4 +488,38 @@ public FileVisitResult visitFile (Path file,
return pathsFound;
}
+
+ //---------------------//
+ // findFileInDirectory //
+ //---------------------//
+ /**
+ * Finds a file in the given directory (non-recursive) matching the given predicate.
+ *
+ * @param directory the directory in which to look for the file
+ * @param fileMatches the condition for inclusion, evaluated for each file
+ * @return the first matching file
+ */
+ public static Optional findFileInDirectory (Path directory, Predicate fileMatches)
+ throws IOException
+ {
+ return Files.walk(directory, 1)
+ .filter(Files::isRegularFile)
+ .filter(fileMatches)
+ .findFirst();
+ }
+
+ //---------------------------------//
+ // fileNameWithoutExtensionMatches //
+ //---------------------------------//
+ /**
+ * Creates a predicate on Path that evaluates to true if the path's name matches the given file
+ * name. To be used e.g. as parameter to {@link #findFileInDirectory(Path, Predicate)}.
+ *
+ * @param fileName the file name to match
+ * @return the predicate
+ */
+ public static Predicate fileNameWithoutExtensionMatches (String fileName)
+ {
+ return path -> FileUtil.sansExtension(path.getFileName().toString()).equals(fileName);
+ }
}
diff --git a/src/main/org/audiveris/omr/util/SetOperation.java b/src/main/org/audiveris/omr/util/SetOperation.java
new file mode 100644
index 000000000..06c928f77
--- /dev/null
+++ b/src/main/org/audiveris/omr/util/SetOperation.java
@@ -0,0 +1,72 @@
+//------------------------------------------------------------------------------------------------//
+// //
+// S e t O p e r a t i o n //
+// //
+//------------------------------------------------------------------------------------------------//
+//
+//
+// Copyright © Audiveris 2022. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify it under the terms of the
+// GNU Affero General Public License as published by the Free Software Foundation, either version
+// 3 of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License along with this
+// program. If not, see .
+//------------------------------------------------------------------------------------------------//
+//
+package org.audiveris.omr.util;
+
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Utility class for operations on Sets.
+ *
+ * @author Peter Greth
+ */
+public abstract class SetOperation
+{
+ /**
+ * The union of two sets.
+ * Returns all elements that are in any of the two sets.
+ */
+ public static Set union (Set a, Set b)
+ {
+ Set unionSet = new HashSet<>();
+ unionSet.addAll(a);
+ unionSet.addAll(b);
+ return unionSet;
+ }
+
+ /**
+ * The intersection of two sets.
+ * Returns only those elements that are in both of the sets.
+ */
+ @SuppressWarnings("CollectionAddAllCanBeReplacedWithConstructor")
+ public static Set intersection (Set a, Set b)
+ {
+ Set unionSet = new HashSet<>();
+ unionSet.addAll(a);
+ unionSet.retainAll(b);
+ return unionSet;
+ }
+
+ /**
+ * The diff of two sets.
+ * Returns only those elements of set a that are not in set b.
+ */
+ @SuppressWarnings("CollectionAddAllCanBeReplacedWithConstructor")
+ public static Set diff (Set a, Set b)
+ {
+ Set diffSet = new HashSet<>();
+ diffSet.addAll(a);
+ diffSet.removeAll(b);
+ return diffSet;
+ }
+
+}
diff --git a/src/test/conversion/score/ConversionScoreRegressionTest.java b/src/test/conversion/score/ConversionScoreRegressionTest.java
new file mode 100644
index 000000000..b866eb172
--- /dev/null
+++ b/src/test/conversion/score/ConversionScoreRegressionTest.java
@@ -0,0 +1,228 @@
+//------------------------------------------------------------------------------------------------//
+// //
+// C o n v e r s i o n S c o r e R e g r e s s i o n T e s t //
+// //
+//------------------------------------------------------------------------------------------------//
+//
+//
+// Copyright © Audiveris 2021. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify it under the terms of the
+// GNU Affero General Public License as published by the Free Software Foundation, either version
+// 3 of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License along with this
+// program. If not, see .
+//------------------------------------------------------------------------------------------------//
+//
+package conversion.score;
+
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import org.audiveris.omr.Main;
+import org.audiveris.omr.util.FileUtil;
+import org.audiveris.proxymusic.ScorePartwise;
+import org.audiveris.proxymusic.mxl.Mxl;
+import org.audiveris.proxymusic.util.Marshalling;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
+import org.junit.runners.Parameterized.Parameter;
+import org.junit.runners.Parameterized.Parameters;
+import org.slf4j.LoggerFactory;
+
+import javax.xml.bind.JAXBException;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.zip.ZipEntry;
+
+import static org.audiveris.omr.OMR.COMPRESSED_SCORE_EXTENSION;
+import static org.audiveris.omr.OMR.SCORE_EXTENSION;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Regression test that checks Audiveris' accuracy according to samples located in
+ * src/test/resources/conversion/score.
+ * Similarity of converted input and expected output is measured using class
+ * {@link ScoreSimilarity}.
+ * To add a new sample, please include its directory name and the resulting conversion score in
+ * {@link #TEST_CASES}.
+ *
+ * @author Peter Greth
+ */
+@RunWith(Parameterized.class)
+public class ConversionScoreRegressionTest
+{
+ /**
+ * List of test cases. Each of these will be tested separately
+ */
+ private final static List TEST_CASES = List.of(
+ ConversionScoreTestCase.ofSubDirectory("01-klavier").withExpectedConversionScore(15)
+ );
+
+ /**
+ * The name of the input file for each test case
+ */
+ private final static String INPUT_FILE_NAME = "input";
+
+ /**
+ * The current test case (provided by {@link #testCaseProvider()}, executed separately by JUnit)
+ */
+ @Parameter
+ public ConversionScoreTestCase underTest;
+
+ /**
+ * The directory into which Audiveris can output the parsed score
+ */
+ private Path outputDirectory;
+
+ /**
+ * @return a list of test cases that are executed each
+ */
+ // "{0}" provides a readable test name using conversion.score.TestCase::toString
+ @Parameters(name = "{0}")
+ public static Collection testCaseProvider ()
+ {
+ return TEST_CASES;
+ }
+
+ /**
+ * Reduce logging verbosity (increases execution time significantly)
+ */
+ @BeforeClass
+ public static void reduceLoggingVerbosity ()
+ {
+ Logger root = (ch.qos.logback.classic.Logger)
+ LoggerFactory.getLogger(Logger.ROOT_LOGGER_NAME);
+ root.setLevel(Level.WARN);
+ }
+
+ @Before
+ public void createOutputDirectory ()
+ throws IOException
+ {
+ String tempDirectoryName = String.format("audiveris-test-%s", underTest.subDirectoryName);
+ outputDirectory = Files.createTempDirectory(tempDirectoryName);
+ }
+
+ @After
+ public void removeOutputDirectory ()
+ throws IOException
+ {
+ if (Files.exists(outputDirectory)) {
+ FileUtil.deleteDirectory(outputDirectory);
+ }
+ }
+
+ /**
+ * The actual regression test, executed for each test case defined in {@link #TEST_CASES}.
+ * Converts the input file using Audiveris, then compares the produced result with the expected
+ * result.
+ * For comparison,
+ * {@link ScoreSimilarity#conversionScore(ScorePartwise.Part, ScorePartwise.Part)} is used.
+ * This test will fail if the conversion score changed in any direction.
+ */
+ @Test
+ public void testConversionScoreChanged ()
+ throws IOException,
+ JAXBException,
+ Mxl.MxlException,
+ Marshalling.UnmarshallingException
+ {
+ ScorePartwise expectedScore = loadXmlScore(underTest.findExpectedOutputFile());
+ assertFalse(String.format("Could not load expected output at '%s': Contains no Part",
+ underTest.findExpectedOutputFile()),
+ expectedScore.getPart().isEmpty());
+
+ Path outputMxl = audiverisBatchExport(outputDirectory, underTest.findInputFile());
+ ScorePartwise actualScore = loadMxlScore(outputMxl);
+ assertFalse(String.format("Could not load actual output in '%s': Contains no Part",
+ outputDirectory),
+ actualScore.getPart().isEmpty());
+
+ int actualConversionScore = ScoreSimilarity.conversionScore(expectedScore, actualScore);
+ failIfConversionScoreDecreased(underTest.expectedConversionScore, actualConversionScore);
+ failIfConversionScoreIncreased(underTest.expectedConversionScore, actualConversionScore);
+ }
+
+ private static ScorePartwise loadXmlScore (Path xmlFile)
+ throws IOException,
+ Marshalling.UnmarshallingException
+ {
+ try (InputStream inputStream = new FileInputStream(xmlFile.toFile())) {
+ Object score = Marshalling.unmarshal(inputStream);
+ assertTrue(String.format("The parsed xml in '%s' is not of type ScorePartwise",
+ xmlFile),
+ score instanceof ScorePartwise);
+ return (ScorePartwise) score;
+ }
+ }
+
+ private static Path audiverisBatchExport (Path outputDirectory, Path inputFile)
+ {
+ Main.main(new String[]{
+ "-batch",
+ "-export",
+ "-output", outputDirectory.toAbsolutePath().toString(),
+ inputFile.toAbsolutePath().toString()
+ });
+ Path outputMxlFile = outputDirectory
+ .resolve(INPUT_FILE_NAME) // folder named equal to input file name
+ .resolve(INPUT_FILE_NAME + COMPRESSED_SCORE_EXTENSION);
+ assertTrue(String.format("Audiveris batch export seems to have failed. Cannot find " +
+ "output file '%s'", outputMxlFile),
+ Files.exists(outputMxlFile));
+ return outputMxlFile;
+ }
+
+ private static ScorePartwise loadMxlScore (Path mxlFile)
+ throws Mxl.MxlException,
+ Marshalling.UnmarshallingException,
+ JAXBException,
+ IOException
+ {
+ try (Mxl.Input outputMxlFileReader = new Mxl.Input(mxlFile.toFile())) {
+ ZipEntry xmlEntry = outputMxlFileReader.getEntry(INPUT_FILE_NAME + SCORE_EXTENSION);
+ Object score = Marshalling.unmarshal(outputMxlFileReader.getInputStream(xmlEntry));
+ assertTrue(String.format("The parsed mxl in '%s' is not of type ScorePartwise",
+ mxlFile),
+ score instanceof ScorePartwise);
+ return (ScorePartwise) score;
+ }
+ }
+
+ private static void failIfConversionScoreDecreased (int expectedConversionScore,
+ int actualConversionScore)
+ {
+ String message = String.format("The conversion score decreased from %d to %d (diff: %d).",
+ expectedConversionScore,
+ actualConversionScore,
+ actualConversionScore - expectedConversionScore);
+ assertFalse(message, actualConversionScore < expectedConversionScore);
+ }
+
+ private static void failIfConversionScoreIncreased (int expectedConversionScore,
+ int actualConversionScore)
+ {
+ String message = String.format("Well done, the conversion score increased from %d to %d " +
+ "(diff: %d). Please adapt " +
+ "conversion.score.TestCase::TEST_CASES accordingly.",
+ expectedConversionScore,
+ actualConversionScore,
+ actualConversionScore - expectedConversionScore);
+ assertFalse(message, actualConversionScore > expectedConversionScore);
+ }
+
+}
diff --git a/src/test/conversion/score/ConversionScoreTestCase.java b/src/test/conversion/score/ConversionScoreTestCase.java
new file mode 100644
index 000000000..19b7884fd
--- /dev/null
+++ b/src/test/conversion/score/ConversionScoreTestCase.java
@@ -0,0 +1,108 @@
+//------------------------------------------------------------------------------------------------//
+// //
+// C o n v e r s i o n S c o r e T e s t C a s e //
+// //
+//------------------------------------------------------------------------------------------------//
+//
+//
+// Copyright © Audiveris 2021. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify it under the terms of the
+// GNU Affero General Public License as published by the Free Software Foundation, either version
+// 3 of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License along with this
+// program. If not, see .
+//------------------------------------------------------------------------------------------------//
+//
+package conversion.score;
+
+import org.audiveris.omr.util.FileUtil;
+
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.*;
+
+import static org.audiveris.omr.util.FileUtil.fileNameWithoutExtensionMatches;
+
+/**
+ * A test case configuration for {@link ConversionScoreRegressionTest}. It consists of
+ * - a directory name that should be a subdirectory of src/test/resources/conversion/score, and
+ * - an expected conversion score, that will be compared to the actual conversion score.
+ *
+ * @author Peter Greth
+ */
+public class ConversionScoreTestCase
+{
+ private final static String INPUT_FILE_NAME = "input";
+ private final static String EXPECTED_OUTPUT_FILE_NAME = "expected-output.xml";
+
+ public String subDirectoryName;
+ public int expectedConversionScore;
+
+ public static ConversionScoreTestCase ofSubDirectory (String subDirectoryName)
+ {
+ ConversionScoreTestCase conversionScoreTestCase = new ConversionScoreTestCase();
+ conversionScoreTestCase.subDirectoryName = subDirectoryName;
+ return conversionScoreTestCase;
+ }
+
+ public ConversionScoreTestCase withExpectedConversionScore (int expectedConversionScore)
+ {
+ this.expectedConversionScore = expectedConversionScore;
+ return this;
+ }
+
+ public Path findInputFile ()
+ {
+ Path testCaseDirectory = getTestCaseDirectory();
+ try {
+ return FileUtil
+ .findFileInDirectory(testCaseDirectory,
+ fileNameWithoutExtensionMatches(INPUT_FILE_NAME))
+ .orElseThrow()
+ .toAbsolutePath();
+ } catch (IOException | NoSuchElementException e) {
+ String message = String.format("Could not find file with name '%s.*' in directory %s",
+ INPUT_FILE_NAME,
+ testCaseDirectory);
+ throw new IllegalStateException(message, e);
+ }
+ }
+
+ public Path findExpectedOutputFile ()
+ {
+ return getTestCaseDirectory().resolve(EXPECTED_OUTPUT_FILE_NAME).toAbsolutePath();
+ }
+
+ private Path getTestCaseDirectory ()
+ {
+ try {
+ return Path.of(getTestCaseDirectoryUrl().toURI());
+ } catch (URISyntaxException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ private URL getTestCaseDirectoryUrl ()
+ {
+ URL resource = getClass().getResource(subDirectoryName);
+ String message = String.format("Could not find directory with name '%s' in test " +
+ "resources for package %s", subDirectoryName,
+ getClass().getPackageName());
+ Objects.requireNonNull(resource, message);
+ return resource;
+ }
+
+ @Override
+ public String toString ()
+ {
+ return String.format("TestCase %s", subDirectoryName);
+ }
+}
diff --git a/src/test/conversion/score/ScoreSimilarity.java b/src/test/conversion/score/ScoreSimilarity.java
new file mode 100644
index 000000000..24ae7d161
--- /dev/null
+++ b/src/test/conversion/score/ScoreSimilarity.java
@@ -0,0 +1,203 @@
+//------------------------------------------------------------------------------------------------//
+//------------------------------------------------------------------------------------------------//
+// //
+// S c o r e S i m i l a r i t y //
+// //
+//------------------------------------------------------------------------------------------------//
+//
+//
+// Copyright © Audiveris 2021. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify it under the terms of the
+// GNU Affero General Public License as published by the Free Software Foundation, either version
+// 3 of the License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
+// without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+// See the GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License along with this
+// program. If not, see .
+//------------------------------------------------------------------------------------------------//
+//
+package conversion.score;
+
+import org.audiveris.proxymusic.Backup;
+import org.audiveris.proxymusic.Forward;
+import org.audiveris.proxymusic.Note;
+import org.audiveris.proxymusic.ScorePartwise;
+import org.audiveris.proxymusic.ScorePartwise.Part;
+import org.audiveris.proxymusic.ScorePartwise.Part.Measure;
+import org.audiveris.proxymusic.Step;
+
+import java.lang.String;
+import java.math.BigDecimal;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import static org.audiveris.omr.util.SetOperation.diff;
+import static org.audiveris.omr.util.SetOperation.intersection;
+import static org.audiveris.omr.util.SetOperation.union;
+import static org.junit.Assert.assertEquals;
+
+/**
+ * Utility class that calculates the similarity of two Scores.
+ * Currently the similarity calculation is quite simplistic, just checking pitch and duration for
+ * the notes and rests.
+ *
+ * @author Peter Greth
+ */
+public abstract class ScoreSimilarity
+{
+ /**
+ * The conversion score of two ScorePartwises is the conversion score of their only Part.
+ * Multiple Parts currently are not supported.
+ */
+ public static int conversionScore (ScorePartwise expected, ScorePartwise actual)
+ {
+ assertEquals("Multiple parts are currently not supported!", 1, expected.getPart().size());
+ assertEquals("Multiple parts are currently not supported!", 1, actual.getPart().size());
+ return conversionScore(expected.getPart().get(0), actual.getPart().get(0));
+ }
+
+ public static int conversionScore (Part expected, Part actual)
+ {
+ return conversionScore(expected.getMeasure(), actual.getMeasure());
+ }
+
+ /**
+ * The conversion score of two lists of measures is the sum of corresponding measures'
+ * conversion scores.
+ * Two measures correspond if they have the same measure number.
+ */
+ public static int conversionScore (List expected, List actual)
+ {
+ final Map expectedMeasuresByNumber = groupBy(expected, Measure::getNumber);
+ final Map actualMeasuresByNumber = groupBy(actual, Measure::getNumber);
+ final Set allMeasureNumbers = union(expectedMeasuresByNumber.keySet(),
+ actualMeasuresByNumber.keySet());
+
+ return allMeasureNumbers
+ .stream()
+ .mapToInt(measureNumber -> conversionScore(
+ expectedMeasuresByNumber.getOrDefault(measureNumber, new Measure()),
+ actualMeasuresByNumber.getOrDefault(measureNumber, new Measure())
+ ))
+ .sum();
+ }
+
+ /**
+ * The conversion score of two Measures is calculated by taking several "samples" and then
+ * taking their score.
+ * The measure is sampled using {@link #sampleMeasure(Measure)}.
+ */
+ public static int conversionScore (Measure expected, Measure actual)
+ {
+ return conversionScore(sampleMeasure(expected), sampleMeasure(actual));
+ }
+
+ /**
+ * Conversion score of two Sets of samples is calculated by counting the common Samples and then
+ * subtracting the amount of samples that just occur in one of the two Sets.
+ */
+ public static int conversionScore (Set expected, Set actual)
+ {
+ Set common = intersection(expected, actual);
+ Set missing = diff(expected, actual);
+ Set superfluous = diff(actual, expected);
+ return common.size() - missing.size() - superfluous.size();
+ }
+
+ /**
+ * Takes samples of a measure by taking samples of the measure's notes and their in the measure.
+ */
+ public static Set sampleMeasure (Measure measure)
+ {
+ Set samples = new HashSet<>();
+
+ BigDecimal currentOffset = BigDecimal.ZERO;
+ BigDecimal lastNotesDuration = BigDecimal.ZERO;
+ for (Object noteOrBackupOrForward : measure.getNoteOrBackupOrForward()) {
+ // calculate a note's offset by tracking Backups and Forwards
+ if (noteOrBackupOrForward instanceof Backup backup) {
+ currentOffset = currentOffset.subtract(backup.getDuration());
+ } else if (noteOrBackupOrForward instanceof Forward forward) {
+ currentOffset = currentOffset.add(forward.getDuration());
+ } else if (noteOrBackupOrForward instanceof Note note) {
+ BigDecimal noteOffset = currentOffset;
+ if (note.getChord() != null) { // "chord" note
+ // The "chord" is set for notes that have the same offset than the previous note. Unfortunately,
+ // the previous note's duration has already been added to the current offset (see below). To
+ // mitigate, we subtract it again here, s.t. both notes have the same offset.
+ noteOffset = noteOffset.subtract(lastNotesDuration);
+ } else { // normal note
+ currentOffset = currentOffset.add(note.getDuration());
+ lastNotesDuration = note.getDuration();
+ }
+
+ samples.add(sampleNoteWithOffset(note, noteOffset));
+ }
+ }
+
+ return samples;
+ }
+
+ /**
+ * Takes several sample points of a Note while differing between normal notes and rests.
+ */
+ private static NoteSample sampleNoteWithOffset (Note note, BigDecimal noteOffset)
+ {
+ if (note.getRest() != null) {
+ return NoteSample.ofRest(noteOffset, note.getDuration());
+ } else {
+ return NoteSample.ofNote(note.getPitch().getStep(), note.getPitch().getAlter(),
+ note.getPitch().getOctave(), noteOffset, note.getDuration());
+ }
+ }
+
+ /**
+ * Utility function that transforms a List to a Map by getting the key from each entry.
+ *
+ * @param list the List to convert
+ * @param getKey the function that is executed for each entry to produce a key
+ * @param the key type
+ * @param the value type
+ * @return a map whose values are the entries of the input list
+ */
+ private static Map groupBy (List list, Function getKey)
+ {
+ return list
+ .stream()
+ .collect(Collectors.toMap(getKey, m -> m));
+ }
+
+ /**
+ * A sample of a note according to several sample points such as pitch, offset and duration.
+ */
+ private static record NoteSample(boolean isRest,
+ Step step,
+ BigDecimal alter,
+ int octave,
+ BigDecimal offset,
+ BigDecimal duration)
+ {
+ private static NoteSample ofRest (BigDecimal offset, BigDecimal duration)
+ {
+ return new NoteSample(true, null, null, 0, offset, duration);
+ }
+
+ private static NoteSample ofNote (Step step,
+ BigDecimal alter,
+ int octave,
+ BigDecimal offset,
+ BigDecimal duration)
+ {
+ return new NoteSample(false, step, alter, octave, offset, duration);
+ }
+ }
+
+}
diff --git a/src/test/resources/conversion/score/01-klavier/expected-output.xml b/src/test/resources/conversion/score/01-klavier/expected-output.xml
new file mode 100644
index 000000000..a1347e56d
--- /dev/null
+++ b/src/test/resources/conversion/score/01-klavier/expected-output.xml
@@ -0,0 +1,551 @@
+
+
+
+
+
+ MuseScore 2.1.0
+ 2018-03-08
+
+
+
+
+
+
+
+
+
+
+ 6.096
+ 40
+
+
+ 1948.82
+ 1377.95
+
+ 79.9869
+ 79.9869
+ 79.9869
+ 79.9869
+
+
+
+
+
+
+
+ Klavier
+
+ Acoustic Grand Piano
+
+
+
+ 1
+ 1
+ 77.9528
+ 0
+
+
+
+
+
+
+
+
+ 21.00
+ 0.00
+
+ 170.00
+
+
+ 65.00
+
+
+
+ 1
+
+ -1
+
+
+ 2
+
+ G
+ 2
+
+
+ F
+ 4
+
+
+
+
+ 1
+ 1
+ quarter
+ 1
+
+
+
+ F
+ 4
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+
+
+
+
+
+
+
+ A
+ -1
+ 4
+
+ 2
+ 1
+ half
+ flat
+ up
+ 1
+
+
+
+
+ B
+ -1
+ 4
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+
+
+ D
+ 5
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+ 3
+
+
+
+ 1
+ 5
+ quarter
+ 2
+
+
+
+ D
+ 4
+
+ 2
+ 5
+ half
+ up
+ 2
+
+
+
+
+
+
+
+ 3
+
+
+
+ D
+ 3
+
+ 3
+ 6
+ half
+
+ down
+ 2
+
+
+
+
+
+
+
+
+
+
+ 1
+ 1
+ quarter
+ 1
+
+
+
+ B
+ 3
+
+ 2
+ 1
+ half
+ natural
+ up
+ 1
+
+
+
+
+
+
+
+
+
+ D
+ 4
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+
+
+ F
+ 4
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+
+
+ A
+ 4
+
+ 2
+ 1
+ half
+ natural
+ up
+ 1
+
+
+ 3
+
+
+
+ 1
+ 5
+ quarter
+ 2
+
+
+
+
+
+
+
+ 2
+
+
+
+
+ A
+ 3
+
+ 2
+ 5
+ half
+ up
+ 2
+
+
+ 3
+
+
+
+ D
+ 3
+
+ 3
+ 6
+ half
+
+ down
+ 2
+
+
+
+
+
+
+
+
+
+
+ D
+ 5
+
+ 1
+ 1
+ quarter
+ 1
+
+
+
+ F
+ 4
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+
+
+
+
+
+
+
+ A
+ -1
+ 4
+
+ 2
+ 1
+ half
+ flat
+ up
+ 1
+
+
+
+
+ D
+ 5
+
+ 2
+ 1
+ half
+ up
+ 1
+
+
+ 3
+
+
+
+ D
+ 4
+
+ 3
+ 2
+ half
+
+ down
+ 1
+
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+
+ 2
+
+
+
+
+ B
+ -1
+ 2
+
+ 3
+ 5
+ half
+
+ flat
+ down
+ 2
+
+
+
+
+
+
+
+
+
+ F
+ 3
+
+ 3
+ 5
+ half
+
+ down
+ 2
+
+
+
+
+
+ C
+ 1
+ 4
+
+ 2
+ 1
+ half
+ sharp
+ up
+ 1
+
+
+
+
+
+
+
+
+
+
+ A
+ 4
+
+ 2
+ 1
+ half
+ natural
+ up
+ 1
+
+
+
+ A
+ 4
+
+ 1
+ 1
+ quarter
+ up
+ 1
+
+
+ 3
+
+
+
+ F
+ 4
+
+ 1
+ 2
+ quarter
+ down
+ 1
+
+
+
+
+
+
+ E
+ 4
+
+ 1
+ 2
+ quarter
+ down
+ 1
+
+
+
+
+
+
+
+ 1
+ 2
+ quarter
+ 1
+
+
+ 3
+
+
+
+ A
+ 2
+
+ 2
+ 5
+ half
+ natural
+ down
+ 2
+
+
+
+
+
+
+
+
+
+
+ G
+ 3
+
+ 2
+ 5
+ half
+ down
+ 2
+
+
+
+ 1
+ 5
+ quarter
+ 2
+
+
+ light-light
+
+
+
+
\ No newline at end of file
diff --git a/src/test/resources/conversion/score/01-klavier/input.png b/src/test/resources/conversion/score/01-klavier/input.png
new file mode 100644
index 000000000..0ab7b6a3f
Binary files /dev/null and b/src/test/resources/conversion/score/01-klavier/input.png differ