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 + + + + + + + test/01-klavier/source.png + + + + 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