diff --git a/src/private/mx/impl/MeasureReader.cpp b/src/private/mx/impl/MeasureReader.cpp index 88f8a4e84..ee4974fc5 100644 --- a/src/private/mx/impl/MeasureReader.cpp +++ b/src/private/mx/impl/MeasureReader.cpp @@ -166,7 +166,7 @@ MeasureReader::MeasureReader(const core::PartwiseMeasure &inPartwiseMeasureRef, const MeasureCursor &previousMeasureCursor, DiagnosticsContext diagnostics) : myMutex{}, myPartwiseMeasure{inPartwiseMeasureRef}, myConverter{}, myDiagnostics{diagnostics}, myOutMeasureData{}, myCurrentCursor{cursor}, myPreviousMeasureCursor{previousMeasureCursor}, myHistory{}, myCrossStaffHomes{}, - myPreviousNoteBucketStaffIndex{-1} + myPreviousNoteBucketStaffIndex{-1}, myVoiceGroups{}, myNextVoiceBucket{0} { HistoryRecord initialCursorRecord; initialCursorRecord.amount = 0; @@ -224,6 +224,8 @@ std::pair> MeasureReader::ge myCrossStaffHomes.clear(); myPreviousNoteBucketStaffIndex = -1; + myVoiceGroups.clear(); + myNextVoiceBucket = 0; scanForCrossStaffBeamGroups(); const auto mdcSpan = myPartwiseMeasure.musicData(); @@ -537,8 +539,74 @@ void MeasureReader::parseNote(const core::Note &inMxNote, const core::Note *next MX_ASSERT(bucketStaffIndex >= 0); MX_ASSERT(static_cast(bucketStaffIndex) < myOutMeasureData.staves.size()); myPreviousNoteBucketStaffIndex = bucketStaffIndex; - insertNoteData(std::move(noteData), myCurrentCursor.staffIndex, myCurrentCursor.voiceIndex, - noteReader.getVoiceLabel()); + const int voiceBucket = findVoiceBucket(bucketStaffIndex, noteReader.getIsChord(), noteReader.getVoiceLabel()); + insertNoteData(std::move(noteData), bucketStaffIndex, voiceBucket, noteReader.getVoiceLabel()); +} + +// A starts another voice. Between backups, MusicXML can also change voice from one note to +// the next with nothing but the text, the next note starting where the last one ended. +// +// This returns the voice a note goes into, as its key in the staff's voices map. It looks at the +// notes of the same staff since the last in the measure, or since the start of the measure +// when there has not been one. Each different text among those notes gets a voice of its +// own, and the note goes into the voice for its text. For example, three notes labelled 4, 5 and 4 +// make two voices: the first and third notes are in one, and the middle note is in the other. When +// those notes name at most one voice (they all have the same text, or none of them has a ), +// they are all one voice. The header calls each of these voices a group. +int MeasureReader::findVoiceBucket(int staff, bool isChordContinuation, const std::string &voiceText) const +{ + auto &staffGroups = myVoiceGroups[staff]; + + if (staffGroups.run != myCurrentCursor.voiceIndex) + { + staffGroups = StaffVoiceGroups{}; + staffGroups.run = myCurrentCursor.voiceIndex; + } + + auto &groups = staffGroups.groups; + const auto findGroup = [&](auto isMatch) { return std::find_if(groups.begin(), groups.end(), isMatch); }; + auto group = groups.end(); + + if (isChordContinuation || voiceText.empty()) + { + // A note sounds with the note before it and so is in its voice, whatever its own + // says. A note without a continues the voice of the note before it. + group = findGroup([&](const VoiceGroup &g) { return g.bucket == staffGroups.previousNoteBucket; }); + } + else + { + group = findGroup([&](const VoiceGroup &g) { return g.name == voiceText; }); + + // While the run's notes so far have not named their voice, this note names it. Otherwise + // a name the run has not seen yet starts another voice. + if (group == groups.end() && !(groups.size() == 1 && groups.front().name.empty())) + { + groups.push_back(VoiceGroup{voiceText, myNextVoiceBucket}); + ++myNextVoiceBucket; + group = std::prev(groups.end()); + } + else if (group == groups.end()) + { + group = groups.begin(); + } + } + + if (group == groups.end()) + { + // the first note of the run + groups.push_back(VoiceGroup{"", myNextVoiceBucket}); + ++myNextVoiceBucket; + group = std::prev(groups.end()); + } + + // As with VoiceData::label, the first note that names the voice names it for all of them. + if (group->name.empty()) + { + group->name = voiceText; + } + + staffGroups.previousNoteBucket = group->bucket; + return group->bucket; } void MeasureReader::scanForCrossStaffBeamGroups() const diff --git a/src/private/mx/impl/MeasureReader.h b/src/private/mx/impl/MeasureReader.h index 85e9e67f8..696f7566b 100644 --- a/src/private/mx/impl/MeasureReader.h +++ b/src/private/mx/impl/MeasureReader.h @@ -82,6 +82,31 @@ class MeasureReader // chord cannot be split across staff buckets; -1 means "no previous note" mutable int myPreviousNoteBucketStaffIndex; + // The notes of one staff between two s are split into voices by the text of their + // element (see findVoiceBucket). A group is one such voice: the text that + // named it, empty until a note names it, and the voice bucket its notes go into. + struct VoiceGroup + { + std::string name; + int bucket = 0; + }; + + // The voice groups of one staff since the last . run is the cursor's backup count + // when they were made; a later count means a came since, and the groups start over. + struct StaffVoiceGroups + { + int run = -1; + std::vector groups; + int previousNoteBucket = -1; + }; + + // keyed by staff bucket + mutable std::map myVoiceGroups; + + // The next voice bucket to hand out. Buckets count up through the measure, so on each staff + // they sort by backup run first and by order of appearance within the run. + mutable int myNextVoiceBucket; + private: static api::FigureData parseFigure(const core::Figure &figure); static int figuredBassStaffIndex(const MeasureCursor &cursor, const api::MeasureData &measure, @@ -123,6 +148,7 @@ class MeasureReader void importStaffDetails(const core::Attributes &inMxAttributes) const; void importClefs(std::span inClefs) const; void importClef(const core::Clef &inClef) const; + int findVoiceBucket(int staff, bool isChordContinuation, const std::string &voiceText) const; void insertNoteData(api::NoteData &¬eData, int staff, int voice, const std::string &voiceLabel) const; void insertClef(api::ClefData &&clefData, int staff) const; void consolidateVoicesForAllStaves() const; diff --git a/src/private/mx/impl/MeasureWriter.cpp b/src/private/mx/impl/MeasureWriter.cpp index 04b4d81ef..da237586e 100644 --- a/src/private/mx/impl/MeasureWriter.cpp +++ b/src/private/mx/impl/MeasureWriter.cpp @@ -42,6 +42,7 @@ #include "mx/impl/NoteWriter.h" #include "mx/impl/PositionFunctions.h" #include "mx/impl/ScoreWriter.h" +#include "mx/impl/VoicePass.h" #include "mx/utility/Throw.h" #include @@ -614,28 +615,36 @@ void MeasureWriter::writeVoices(const api::StaffData &inStaff) auto directionIter = inStaff.directions.cbegin(); auto directionEnd = inStaff.directions.cend(); - for (const auto &voice : inStaff.voices) + for (const auto &pass : makeVoicePasses(inStaff)) { bool isFirstNote = true; int currentChordTickPosition = -1; int previousChordTickPosition = -2; bool previousChordNoteWasGrace = false; - myHistory.setVoiceIndex(voice.first); - auto noteIter = voice.second.notes.cbegin(); - auto noteEnd = voice.second.notes.cend(); + myHistory.setVoiceIndex(pass.voices.front()->first); + const auto ¬es = pass.notes(); + auto noteIter = notes.cbegin(); + auto noteEnd = notes.cend(); if (directionIter != directionEnd) { backupToLeadingDirection(directionIter, directionEnd, noteIter, noteEnd); - writeDirections(directionIter, directionEnd, noteIter, std::cbegin(voice.second.notes), noteEnd); + writeDirections(directionIter, directionEnd, noteIter, std::cbegin(notes), noteEnd); } for (; noteIter != noteEnd; ++noteIter) { bool isStartOfChord = false; + const auto &written = pass.written.at(static_cast(noteIter - notes.cbegin())); + const auto &voice = *written.voice; + const auto &apiNote = *written.note; + + if (voice.first != myHistory.getCursor().voiceIndex) + { + myHistory.setVoiceIndex(voice.first); + } myHistory.setChord(noteIter->isChord); - const auto &apiNote = *noteIter; // Middle barlines are checked before this note's or , so that one at the // end of a voice stays ahead of the into the next voice, and again after it, for @@ -705,7 +714,7 @@ void MeasureWriter::writeVoices(const api::StaffData &inStaff) } myPropertiesWriter->flushBuffer(); - writeDirections(directionIter, directionEnd, noteIter, std::cbegin(voice.second.notes), noteEnd); + writeDirections(directionIter, directionEnd, noteIter, std::cbegin(notes), noteEnd); NoteWriter writer{ apiNote, myHistory.getCursor(), myScoreWriter, myPreviousCursor.isChordActive, voice.second.notes, @@ -719,10 +728,9 @@ void MeasureWriter::writeVoices(const api::StaffData &inStaff) } // foreach note - writeDirections(directionIter, directionEnd, std::cend(voice.second.notes), std::cbegin(voice.second.notes), - std::cend(voice.second.notes)); + writeDirections(directionIter, directionEnd, std::cend(notes), std::cbegin(notes), std::cend(notes)); - } // foreach voice + } // foreach pass std::vector fakeEmptyNoteVector; const auto iterBegin = std::cbegin(fakeEmptyNoteVector); diff --git a/src/private/mx/impl/SpannerResolver.cpp b/src/private/mx/impl/SpannerResolver.cpp index 3cdeae0ca..bcce36fff 100644 --- a/src/private/mx/impl/SpannerResolver.cpp +++ b/src/private/mx/impl/SpannerResolver.cpp @@ -5,6 +5,7 @@ #include "mx/impl/SpannerResolver.h" #include "mx/api/GlissandoData.h" #include "mx/impl/OttavaFunctions.h" +#include "mx/impl/VoicePass.h" #include "mx/impl/WriteRefusal.h" #include "mx/utility/Throw.h" @@ -638,12 +639,12 @@ void SpannerResolver::resolvePart(const api::PartData &inPart, int partIndex, Di for (const auto &staff : measure.staves) { collector.setStaff(staffIndex); - for (const auto &voicePair : staff.voices) + for (const auto &pass : makeVoicePasses(staff)) { - collector.setVoice(voicePair.first); - for (const auto ¬e : voicePair.second.notes) + for (const auto &written : pass.written) { - collector.addNote(note); + collector.setVoice(written.voice->first); + collector.addNote(*written.note); } } for (const auto &direction : staff.directions) diff --git a/src/private/mx/impl/SpannerResolver.h b/src/private/mx/impl/SpannerResolver.h index 1ea8cf762..9c0a2e3c2 100644 --- a/src/private/mx/impl/SpannerResolver.h +++ b/src/private/mx/impl/SpannerResolver.h @@ -35,12 +35,14 @@ namespace impl // never the staff: two spanners conflict exactly when they overlap in the // order a streaming reader encounters them, even when they sit on different // staves of the part. So resolvePart walks the part in the exact order -// MeasureWriter serializes it: measures in order, staves in order, voices -// ascending, notes in vector order (curve stops/continues/starts, -// glissando/slide stops then starts, tuplet starts then stops, wavy-line -// stops/continues/starts, per note -- mirroring NotationsWriter within each -// class; the order between classes does not matter, since each class has its -// own pool), and each staff's directions in +// MeasureWriter serializes it: measures in order, staves in order, each +// staff's notes in the order makeVoicePasses gives them (voices ascending, +// except where a voice is written in the gaps of the voice before it), and +// within a note curve stops/continues/starts, glissando/slide stops then +// starts, tuplet starts then stops, wavy-line stops/continues/starts +// (mirroring NotationsWriter within each class; the order between classes +// does not matter, since each class has its own pool), and each staff's +// directions in // vector order (mirroring DirectionWriter's per-direction emission order). An // identity spanner takes the lowest number that is free across its whole // serialized extent -- from its first event to its last, whichever of diff --git a/src/private/mx/impl/VoicePass.cpp b/src/private/mx/impl/VoicePass.cpp new file mode 100644 index 000000000..3e79c5364 --- /dev/null +++ b/src/private/mx/impl/VoicePass.cpp @@ -0,0 +1,202 @@ +// MusicXML Class Library +// Copyright (c) by Matthew James Briggs +// Distributed under the MIT License + +#include "mx/impl/VoicePass.h" + +#include +#include +#include +#include + +namespace mx +{ +namespace impl +{ +// A note, or the notes of a chord, that a voice plays at one tick. It lasts from its tick until the +// write position moves on after it, which for a chord is the end of its last note. +struct VoicePassUnit +{ + VoicePassVoiceIter voice; + size_t firstNote = 0; + size_t noteCount = 0; + int start = 0; + int end = 0; +}; + +const std::vector &VoicePass::notes() const +{ + return voices.size() == 1 ? voices.front()->second.notes : mergedNotes; +} + +static std::vector voicePassUnits(VoicePassVoiceIter voice) +{ + std::vector units; + const auto ¬es = voice->second.notes; + + for (size_t first = 0; first < notes.size();) + { + // chord notes at one tick end together, as in MeasureWriter::isAdvanceNeeded + size_t last = first; + while (notes.at(last).isChord && last + 1 < notes.size() && + notes.at(last + 1).tickTimePosition == notes.at(last).tickTimePosition) + { + ++last; + } + + // as in MeasureWriter::advanceCursorIfNeeded + const auto &lastNote = notes.at(last); + const int duration = lastNote.isGrace ? 0 : std::max(lastNote.durationData.durationTimeTicks, 0); + const int start = notes.at(first).tickTimePosition; + units.push_back(VoicePassUnit{voice, first, last - first + 1, start, start + duration}); + first = last + 1; + } + + return units; +} + +// The text NoteWriter puts in each note's element. +static std::string voicePassVoiceText(VoicePassVoiceIter voice) +{ + return voice->second.label.empty() ? std::to_string(voice->first + 1) : voice->second.label; +} + +// Whether a staff's voices may share a pass at all. A reader splits the notes between two s +// into voices by their text, and a pass can share that stretch of the stream with the passes +// before and after it when no is needed between them, so every voice of the staff must +// have a text of its own. And every note needs a tick, since a pass puts its notes in tick order. +static bool canStaffShareVoicePasses(const api::StaffData &inStaff) +{ + std::set texts; + for (auto voice = inStaff.voices.cbegin(); voice != inStaff.voices.cend(); ++voice) + { + const auto ¬es = voice->second.notes; + const bool isTimed = + std::none_of(notes.cbegin(), notes.cend(), [](const auto ¬e) { return note.tickTimePosition < 0; }); + if (!isTimed || !texts.insert(voicePassVoiceText(voice)).second) + { + return false; + } + } + return true; +} + +// Whether a voice can be written inside the pass before it, filling the pass's gaps instead of +// following it after a . On success, outMerged holds the units of both in written order. +// +// The voice must start before the pass ends; a voice that starts later is written after it as +// usual. Together, the units must form a single line in time: each starts no earlier than the one +// before it ends. Two voices may not start a unit at the same tick, since the order of the two +// would then be a guess; that keeps a grace note of one voice apart from a note of another. Each +// voice must make its first entrance in key order, so that a reader numbering voices as they +// appear gets them back in the same order. +static bool canJoinVoicePass(const std::vector &passUnits, + const std::vector &passVoices, + const std::vector &voiceUnits, VoicePassVoiceIter voice, + std::vector &outMerged) +{ + if (passUnits.empty() || voiceUnits.empty()) + { + return false; + } + + const auto passEnd = std::max_element(passUnits.cbegin(), passUnits.cend(), [](const auto &a, const auto &b) { + return a.end < b.end; + })->end; + + if (voiceUnits.front().start >= passEnd) + { + return false; + } + + std::vector merged; + merged.reserve(passUnits.size() + voiceUnits.size()); + std::merge(passUnits.cbegin(), passUnits.cend(), voiceUnits.cbegin(), voiceUnits.cend(), std::back_inserter(merged), + [](const auto &a, const auto &b) { return a.start < b.start; }); + + std::vector entrances; + + for (size_t i = 0; i < merged.size(); ++i) + { + const auto &unit = merged.at(i); + + if (i > 0) + { + const auto &previous = merged.at(i - 1); + if (unit.start < previous.end || (unit.start == previous.start && unit.voice != previous.voice)) + { + return false; + } + } + + if (std::find(entrances.cbegin(), entrances.cend(), unit.voice) == entrances.cend()) + { + entrances.push_back(unit.voice); + } + } + + auto voices = passVoices; + voices.push_back(voice); + + if (entrances != voices) + { + return false; + } + + outMerged = std::move(merged); + return true; +} + +static void finishVoicePass(VoicePass &pass, const std::vector &units) +{ + for (const auto &unit : units) + { + const auto ¬es = unit.voice->second.notes; + for (size_t i = unit.firstNote; i < unit.firstNote + unit.noteCount; ++i) + { + if (pass.voices.size() > 1) + { + pass.mergedNotes.push_back(notes.at(i)); + } + pass.written.push_back(VoicePassNote{unit.voice, ¬es.at(i)}); + } + } +} + +std::vector makeVoicePasses(const api::StaffData &inStaff) +{ + std::vector passes; + std::vector passUnits; + const bool canShare = canStaffShareVoicePasses(inStaff); + + for (auto voice = inStaff.voices.cbegin(); voice != inStaff.voices.cend(); ++voice) + { + auto voiceUnits = voicePassUnits(voice); + std::vector merged; + + if (canShare && !passes.empty() && canJoinVoicePass(passUnits, passes.back().voices, voiceUnits, voice, merged)) + { + passes.back().voices.push_back(voice); + passUnits = std::move(merged); + continue; + } + + if (!passes.empty()) + { + finishVoicePass(passes.back(), passUnits); + } + + passes.emplace_back(); + passes.back().voices.push_back(voice); + passUnits = std::move(voiceUnits); + } + + if (!passes.empty()) + { + finishVoicePass(passes.back(), passUnits); + } + + return passes; +} +} // namespace impl +} // namespace mx diff --git a/src/private/mx/impl/VoicePass.h b/src/private/mx/impl/VoicePass.h new file mode 100644 index 000000000..30ea27726 --- /dev/null +++ b/src/private/mx/impl/VoicePass.h @@ -0,0 +1,57 @@ +// MusicXML Class Library +// Copyright (c) by Matthew James Briggs +// Distributed under the MIT License + +#pragma once + +#include "mx/api/StaffData.h" + +#include +#include + +namespace mx +{ +namespace impl +{ +// A pass is one trip forward through the time of a measure on one staff: notes written in time +// order, with a over any gap and no in between. MeasureWriter writes a staff as +// one or more passes, with a between them that goes back to where the next pass starts. A +// pass usually holds one voice. It holds more than one when a voice only plays in the rests of the +// voice before it, so that the voices can share the pass, each note carrying its own text. +using VoicePassVoiceIter = std::map::const_iterator; + +// One note of a pass and the voice it belongs to. note points into the ScoreData being written, so +// anything that looks a note's parts up by address (SpannerResolver) finds them. +struct VoicePassNote +{ + VoicePassVoiceIter voice; + const api::NoteData *note = nullptr; +}; + +// Voices of one staff that are written as one stream, in time order, with no inside it. +// Most passes hold one voice; makeVoicePasses says when a pass holds more. +struct VoicePass +{ + // the pass's voices, in key order + std::vector voices; + + // the notes in the order they are written + std::vector written; + + // Copies of the notes in the order they are written, when the pass has more than one voice. They + // are for stepping through the stream by tick; a note that is written or looked up by address + // comes from written. + std::vector mergedNotes; + + // The notes in the order they are written: the voice's own notes when the pass has one voice. + const std::vector ¬es() const; +}; + +// Groups a staff's voices, in key order, into the passes MeasureWriter writes them in. Each voice +// joins the pass before it when it fits into that pass's gaps; otherwise it starts a pass of its +// own. MusicXML reads a change of text between two notes as a change of voice, so a voice +// that only plays while another rests can be written in that rest without a and a +// . +std::vector makeVoicePasses(const api::StaffData &inStaff); +} // namespace impl +} // namespace mx diff --git a/src/private/mxtest/api/VoiceInterleaveApiTest.cpp b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp new file mode 100644 index 000000000..392df37bc --- /dev/null +++ b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp @@ -0,0 +1,678 @@ +// MusicXML Class Library +// Copyright (c) by Matthew James Briggs +// Distributed under the MIT License + +#include "mxtest/control/CompileControl.h" +#ifdef MX_COMPILE_API_TESTS + +#include "cpul/cpulTestHarness.h" +#include "mx/api/MusicXml.h" +#include "mxtest/api/RoundTrip.h" +#include "mxtest/api/TestHelpers.h" + +#include +#include +#include +#include +#include +#include + +using namespace std; +using namespace mx::api; +using namespace mxtest; + +namespace voiceInterleaveApiTest +{ +// Replaces each {name} in inTemplate with the value inValues gives for name. An empty value stands +// for an element the XML leaves out, and a line left with nothing but spaces is dropped. +inline std::string fillXmlTemplate(const std::string &inTemplate, const std::map &inValues) +{ + std::string filled; + size_t pos = 0; + for (auto open = inTemplate.find('{'); open != std::string::npos; open = inTemplate.find('{', pos)) + { + const auto close = inTemplate.find('}', open); + filled += inTemplate.substr(pos, open - pos); + filled += inValues.at(inTemplate.substr(open + 1, close - open - 1)); + pos = close + 1; + } + filled += inTemplate.substr(pos); + + std::string xml; + std::istringstream lines{filled}; + for (std::string line; std::getline(lines, line);) + { + if (line.find_first_not_of(' ') != std::string::npos) + { + xml += line + "\n"; + } + } + return xml; +} + +// A one-part score in 3/4 with one division per quarter, holding a single measure. {staves}, +// {trebleClefNumber} and {bassClef} are empty for a single staff. +constexpr const char *kVoiceInterleaveScoreXml = R"( + + + Flute + + + + + + 1 + + {staves} + + G + 2 + + {bassClef} + +{measureBody} + + + +)"; + +// A note in octave 4. {chord}, {voice}, {dot} and {staff} are whole elements that a note can leave +// out. +constexpr const char *kVoiceInterleaveNoteXml = R"( + {chord} + + {step} + 4 + + {duration} + {voice} + {type} + {dot} + {staff} + +)"; + +// A or a , as {element}. +constexpr const char *kVoiceInterleaveMoveXml = R"( <{element}> + {duration} + +)"; + +// The score above, holding inMeasureBody. With two staves, the second has a bass clef. +inline std::string makeScoreXml(const std::string &inMeasureBody, int inStaves = 1) +{ + const bool isGrandStaff = inStaves != 1; + return fillXmlTemplate(kVoiceInterleaveScoreXml, + {{"staves", isGrandStaff ? "2" : ""}, + {"trebleClefNumber", isGrandStaff ? R"( number="1")" : ""}, + {"bassClef", isGrandStaff ? R"(F4)" : ""}, + {"measureBody", inMeasureBody}}); +} + +// A quarter note, or a dotted half when inDuration is 3. An empty inVoice leaves the element +// out, and so does a zero inStaff for the element. +inline std::string makeNoteXml(const std::string &inStep, int inDuration, const std::string &inVoice, + bool inIsChord = false, int inStaff = 0) +{ + const bool isDottedHalf = inDuration == 3; + return fillXmlTemplate(kVoiceInterleaveNoteXml, + {{"chord", inIsChord ? "" : ""}, + {"step", inStep}, + {"duration", std::to_string(inDuration)}, + {"voice", inVoice.empty() ? "" : "" + inVoice + ""}, + {"type", isDottedHalf ? "half" : "quarter"}, + {"dot", isDottedHalf ? "" : ""}, + {"staff", inStaff > 0 ? "" + std::to_string(inStaff) + "" : ""}}); +} + +inline std::string makeBackupXml(int inDuration) +{ + return fillXmlTemplate(kVoiceInterleaveMoveXml, {{"element", "backup"}, {"duration", std::to_string(inDuration)}}); +} + +inline std::string makeForwardXml(int inDuration) +{ + return fillXmlTemplate(kVoiceInterleaveMoveXml, {{"element", "forward"}, {"duration", std::to_string(inDuration)}}); +} + +inline size_t countOccurrences(const std::string &haystack, const std::string &needle) +{ + size_t count = 0; + for (auto pos = haystack.find(needle); pos != std::string::npos; pos = haystack.find(needle, pos + 1)) + { + ++count; + } + return count; +} + +// The text of every element, in document order. +inline std::vector findVoiceTexts(const std::string &inXml) +{ + const std::string open = ""; + const std::string close = ""; + std::vector texts; + for (auto pos = inXml.find(open); pos != std::string::npos; pos = inXml.find(open, pos + 1)) + { + const auto start = pos + open.size(); + texts.push_back(inXml.substr(start, inXml.find(close, start) - start)); + } + return texts; +} + +// The pitch steps of every note, in document order. +inline std::string findSteps(const std::string &inXml) +{ + const std::string open = ""; + std::string steps; + for (auto pos = inXml.find(open); pos != std::string::npos; pos = inXml.find(open, pos + 1)) + { + steps += inXml.at(pos + open.size()); + } + return steps; +} + +inline NoteData makeNote(Step inStep, int inTick, int inTicks, DurationName inDurationName) +{ + NoteData note; + note.pitchData.step = inStep; + note.pitchData.octave = 4; + note.tickTimePosition = inTick; + note.durationData.durationName = inDurationName; + note.durationData.durationTimeTicks = inTicks; + return note; +} + +inline NoteData makeQuarter(Step inStep, int inTick) +{ + return makeNote(inStep, inTick, 1, DurationName::quarter); +} + +// One staff at one tick per quarter; the caller fills in the voices. +inline ScoreData makeScore() +{ + ScoreData score; + score.ticksPerQuarter = 1; + score.parts.emplace_back(); + auto &part = score.parts.back(); + part.measures.emplace_back(); + part.measures.back().staves.emplace_back(); + return score; +} + +inline std::map &voicesOf(ScoreData &score) +{ + return score.parts.at(0).measures.at(0).staves.at(0).voices; +} + +inline const std::map &voicesOf(const ScoreData &score) +{ + return score.parts.at(0).measures.at(0).staves.at(0).voices; +} + +// Three F quarters in voices 4, 5 and 4: voice 4 has the first and third beats, voice 5 the second. +inline ScoreData makeGapFillScore() +{ + auto score = makeScore(); + auto &voices = voicesOf(score); + voices[3].notes = {makeQuarter(Step::f, 0), makeQuarter(Step::f, 2)}; + voices[4].notes = {makeQuarter(Step::f, 1)}; + return score; +} + +// The number and type of every , in document order, as "1 start", "2 stop" and so on. +inline std::vector findSlurs(const std::string &inXml) +{ + std::vector slurs; + for (auto pos = inXml.find("', pos); + const auto element = inXml.substr(pos, end - pos); + const auto attribute = [&](const std::string &inName) { + const auto start = element.find(inName + "=\"") + inName.size() + 2; + return element.substr(start, element.find('"', start) - start); + }; + slurs.push_back(attribute("number") + " " + attribute("type")); + } + return slurs; +} + +inline void addSlur(NoteData &inFrom, NoteData &inTo, const std::string &inIdentity) +{ + CurveStart start{CurveType::slur}; + start.number = SpannerNumber{inIdentity}; + inFrom.noteAttachmentData.curveStarts.push_back(start); + CurveStop stop{CurveType::slur}; + stop.number = SpannerNumber{inIdentity}; + inTo.noteAttachmentData.curveStops.push_back(stop); +} + +// What a note's voice must keep through a write and a read: where it is and what it is. +inline std::string describeVoices(const std::map &inVoices) +{ + std::string text; + for (const auto &voice : inVoices) + { + text += "voice " + std::to_string(voice.first) + " '" + voice.second.label + "':"; + for (const auto ¬e : voice.second.notes) + { + text += " " + std::to_string(note.tickTimePosition) + "/" + + std::to_string(static_cast(note.pitchData.step)); + text += note.isChord ? "c" : ""; + text += note.isGrace ? "g" : ""; + } + text += "\n"; + } + return text; +} +} // namespace voiceInterleaveApiTest + +using namespace voiceInterleaveApiTest; + +// Three quarters in a row, labelled voice 4, 5 and 4, with no between them. The middle note +// belongs to voice 5, so voice 4 has a rest-like gap on beat two that voice 5 fills. +TEST(aVoiceChangeWithoutABackupStartsAnotherVoice, VoiceInterleave) +{ + const auto xml = makeScoreXml(makeNoteXml("F", 1, "4") + makeNoteXml("F", 1, "5") + makeNoteXml("F", 1, "4")); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const int quarter = score.ticksPerQuarter; + const auto &voices = voicesOf(score); + REQUIRE(voices.size() == 2); + REQUIRE(voices.count(3) == 1); + REQUIRE(voices.count(4) == 1); + + const auto &voiceFour = voices.at(3).notes; + REQUIRE(voiceFour.size() == 2); + CHECK_EQUAL(0, voiceFour.at(0).tickTimePosition); + CHECK_EQUAL(2 * quarter, voiceFour.at(1).tickTimePosition); + + const auto &voiceFive = voices.at(4).notes; + REQUIRE(voiceFive.size() == 1); + CHECK_EQUAL(quarter, voiceFive.at(0).tickTimePosition); +} + +T_END; + +// The writer puts voice 5's note in the gap it fills, so the three notes follow one another with no +// or , and each carries the name of its own voice. +TEST(aVoiceThatFillsAGapIsWrittenInline, VoiceInterleave) +{ + const auto score = makeGapFillScore(); + const auto xml = toXml(score); + CHECK_EQUAL(0, countOccurrences(xml, "")); + CHECK_EQUAL(0, countOccurrences(xml, "")); + const std::vector expectedTexts{"4", "5", "4"}; + CHECK(expectedTexts == findVoiceTexts(xml)); + + const auto out = roundTrip(score); + REQUIRE(out.parts.size() == 1); + const auto &voices = voicesOf(out); + REQUIRE(voices.size() == 2); + REQUIRE(voices.count(3) == 1); + REQUIRE(voices.count(4) == 1); + REQUIRE(voices.at(3).notes.size() == 2); + CHECK_EQUAL(0, voices.at(3).notes.at(0).tickTimePosition); + CHECK_EQUAL(2, voices.at(3).notes.at(1).tickTimePosition); + REQUIRE(voices.at(4).notes.size() == 1); + CHECK_EQUAL(1, voices.at(4).notes.at(0).tickTimePosition); +} + +T_END; + +// A note sounds with the note before it, so it stays in that note's voice even when its own +// names another one. +TEST(aChordMemberStaysWithItsChord, VoiceInterleave) +{ + const auto xml = makeScoreXml(makeNoteXml("C", 1, "1") + makeNoteXml("E", 1, "2", true) + makeNoteXml("G", 1, "1")); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const auto &voices = voicesOf(score); + REQUIRE(voices.size() == 1); + const auto ¬es = voices.cbegin()->second.notes; + REQUIRE(notes.size() == 3); + CHECK(notes.at(0).isChord); + CHECK(notes.at(1).isChord); + CHECK(!notes.at(2).isChord); +} + +T_END; + +// A note that does not say which voice it is in continues the voice of the note before it. +TEST(aNoteWithoutAVoiceStaysWithThePreviousNote, VoiceInterleave) +{ + { + const auto xml = makeScoreXml(makeNoteXml("C", 1, "1") + makeNoteXml("D", 1, "") + makeNoteXml("E", 1, "1")); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const auto &voices = voicesOf(score); + REQUIRE(voices.size() == 1); + CHECK_EQUAL(3, voices.cbegin()->second.notes.size()); + } + { + const auto xml = makeScoreXml(makeNoteXml("C", 1, "1") + makeNoteXml("D", 1, "2") + makeNoteXml("E", 1, "")); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const auto &voices = voicesOf(score); + REQUIRE(voices.size() == 2); + const auto &first = voices.cbegin()->second.notes; + const auto &second = std::next(voices.cbegin())->second.notes; + REQUIRE(first.size() == 1); + CHECK(Step::c == first.at(0).pitchData.step); + REQUIRE(second.size() == 2); + CHECK(Step::d == second.at(0).pitchData.step); + CHECK(Step::e == second.at(1).pitchData.step); + } +} + +T_END; + +// The same voice name on both sides of a is two voices: a backup always starts another one. +TEST(aVoiceRepeatedAfterABackupStaysSeparate, VoiceInterleave) +{ + const auto xml = makeScoreXml(makeNoteXml("C", 3, "1") + makeBackupXml(3) + makeNoteXml("E", 3, "1")); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const auto &voices = voicesOf(score); + REQUIRE(voices.size() == 2); + REQUIRE(voices.cbegin()->second.notes.size() == 1); + CHECK(Step::c == voices.cbegin()->second.notes.at(0).pitchData.step); + REQUIRE(std::next(voices.cbegin())->second.notes.size() == 1); + CHECK(Step::e == std::next(voices.cbegin())->second.notes.at(0).pitchData.step); +} + +T_END; + +// A voice change after a is another voice too. The second voice starts after the first +// ends, so it is written after it as before, with the between them and no . +TEST(aVoiceChangeAfterAForwardStartsAnotherVoice, VoiceInterleave) +{ + const auto xml = makeScoreXml(makeNoteXml("C", 1, "1") + makeForwardXml(1) + makeNoteXml("E", 1, "2")); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const auto &voices = voicesOf(score); + REQUIRE(voices.size() == 2); + REQUIRE(voices.count(0) == 1); + REQUIRE(voices.count(1) == 1); + REQUIRE(voices.at(0).notes.size() == 1); + CHECK_EQUAL(0, voices.at(0).notes.at(0).tickTimePosition); + REQUIRE(voices.at(1).notes.size() == 1); + CHECK_EQUAL(2 * score.ticksPerQuarter, voices.at(1).notes.at(0).tickTimePosition); + + const auto written = toXml(score); + CHECK_EQUAL(0, countOccurrences(written, "")); + CHECK_EQUAL(1, countOccurrences(written, "")); + const std::vector expectedTexts{"1", "2"}; + CHECK(expectedTexts == findVoiceTexts(written)); +} + +T_END; + +// Voices are told apart by name within each staff: the names on each staff of a piano part split +// that staff into voices as they would on a single staff. +TEST(voiceNamesAreGroupedPerStaff, VoiceInterleave) +{ + const auto xml = makeScoreXml(makeNoteXml("F", 1, "1", false, 1) + makeNoteXml("F", 1, "2", false, 1) + + makeNoteXml("F", 1, "1", false, 1) + makeBackupXml(3) + + makeNoteXml("C", 1, "5", false, 2) + makeNoteXml("D", 2, "6", false, 2), + 2); + const auto score = fromXml(xml); + REQUIRE(score.parts.size() == 1); + const auto &staves = score.parts.at(0).measures.at(0).staves; + REQUIRE(staves.size() == 2); + REQUIRE(staves.at(0).voices.size() == 2); + CHECK_EQUAL(2, staves.at(0).voices.cbegin()->second.notes.size()); + CHECK_EQUAL(1, std::next(staves.at(0).voices.cbegin())->second.notes.size()); + REQUIRE(staves.at(1).voices.size() == 2); + + const auto written = toXml(score); + CHECK_EQUAL(1, countOccurrences(written, "")); + CHECK_EQUAL(0, countOccurrences(written, "")); + const std::vector expectedTexts{"1", "2", "1", "5", "6"}; + CHECK(expectedTexts == findVoiceTexts(written)); + CHECK(score.parts == fromXml(written).parts); +} + +T_END; + +// Voices that sound at the same time are written one after the other, with a between them. +TEST(completeVoicesAreWrittenOneAfterAnother, VoiceInterleave) +{ + auto score = makeScore(); + auto &voices = voicesOf(score); + voices[0].notes = {makeQuarter(Step::c, 0), makeQuarter(Step::d, 1), makeQuarter(Step::e, 2)}; + voices[1].notes = {makeNote(Step::g, 0, 2, DurationName::half), makeQuarter(Step::a, 2)}; + const auto xml = toXml(score); + CHECK_EQUAL(1, countOccurrences(xml, "")); + CHECK_EQUAL(0, countOccurrences(xml, "")); + CHECK_EQUAL(std::string{"CDEGA"}, findSteps(xml)); +} + +T_END; + +// When the gap and the voice that fills it have the same name, writing them in one stream would +// read back as a single voice, so the second voice keeps its own place after a . +TEST(aGapFillWithTheSameVoiceTextIsNotInlined, VoiceInterleave) +{ + auto score = makeGapFillScore(); + auto &voices = voicesOf(score); + voices.at(3).label = "x"; + voices.at(4).label = "x"; + const auto xml = toXml(score); + CHECK_EQUAL(1, countOccurrences(xml, "")); + CHECK_EQUAL(1, countOccurrences(xml, "")); + + const auto out = roundTrip(score); + REQUIRE(out.parts.size() == 1); + CHECK_EQUAL(2, voicesOf(out).size()); +} + +T_END; + +// A grace note at the end of the filling voice would share a tick with the next note of the voice +// it fills, and the order of the two would be ambiguous in one stream, so the voices are written +// separately. +TEST(aGraceNoteOnAVoiceBoundaryIsNotInlined, VoiceInterleave) +{ + auto score = makeGapFillScore(); + auto grace = makeNote(Step::g, 2, 0, DurationName::eighth); + grace.isGrace = true; + voicesOf(score).at(4).notes.push_back(grace); + const auto xml = toXml(score); + CHECK_EQUAL(1, countOccurrences(xml, "")); + CHECK_EQUAL(1, countOccurrences(xml, "")); + CHECK_EQUAL(std::string{"FFFG"}, findSteps(xml)); +} + +T_END; + +// Slurs that an author names by identity are numbered in the order the notes are written. With the +// second voice written in the first voice's gap, its slur opens while the first voice's slur is +// still open, so the two need different numbers. +TEST(slursInAGapFillAreNumberedInWrittenOrder, VoiceInterleave) +{ + auto score = makeScore(); + auto &voices = voicesOf(score); + voices[0].notes = {makeQuarter(Step::c, 0), makeQuarter(Step::f, 3)}; + voices[1].notes = {makeQuarter(Step::d, 1), makeQuarter(Step::e, 2)}; + addSlur(voices[0].notes.at(0), voices[0].notes.at(1), "outer"); + addSlur(voices[1].notes.at(0), voices[1].notes.at(1), "inner"); + const auto xml = toXml(score); + REQUIRE(!xml.empty()); + CHECK_EQUAL(0, countOccurrences(xml, "")); + CHECK_EQUAL(std::string{"CDEF"}, findSteps(xml)); + const std::vector expectedSlurs{"1 start", "2 start", "2 stop", "1 stop"}; + CHECK(expectedSlurs == findSlurs(xml)); +} + +T_END; + +// On a staff where two voices have the same name, no voice is written in another's gap: a reader +// puts notes with one name together when no lies between them, and here the voice before +// the gap ends exactly where the next pass would begin. +TEST(aGapFillOnAStaffWithARepeatedNameIsNotInlined, VoiceInterleave) +{ + auto score = makeScore(); + auto &voices = voicesOf(score); + voices[0].notes = {makeQuarter(Step::c, 0)}; + voices[1].notes = {makeQuarter(Step::d, 1), makeQuarter(Step::f, 3)}; + voices[2].notes = {makeQuarter(Step::e, 2)}; + voices[2].label = "1"; + const auto xml = toXml(score); + CHECK_EQUAL(1, countOccurrences(xml, "")); + + const auto out = roundTrip(score); + REQUIRE(out.parts.size() == 1); + CHECK_EQUAL(3, voicesOf(out).size()); +} + +T_END; + +// Two voices can fill the gaps of a third, and the voice after them still backs up from the end of +// the stream they make. The next staff starts with its own , as before. +TEST(aMergedPassIsFollowedByABackup, VoiceInterleave) +{ + auto score = makeScore(); + score.parts.at(0).measures.at(0).staves.emplace_back(); + auto &voices = voicesOf(score); + voices[0].notes = {makeQuarter(Step::c, 0), makeQuarter(Step::f, 3)}; + voices[1].notes = {makeQuarter(Step::d, 1)}; + voices[2].notes = {makeQuarter(Step::e, 2)}; + voices[3].notes = {makeNote(Step::g, 0, 4, DurationName::whole)}; + auto &lowerStaff = score.parts.at(0).measures.at(0).staves.at(1).voices; + lowerStaff[0].notes = {makeNote(Step::a, 0, 4, DurationName::whole)}; + + const auto xml = toXml(score); + CHECK_EQUAL(2, countOccurrences(xml, "")); + CHECK_EQUAL(0, countOccurrences(xml, "")); + CHECK_EQUAL(std::string{"CDEFGA"}, findSteps(xml)); + const std::vector expectedTexts{"1", "2", "3", "1", "4"}; + CHECK(expectedTexts == findVoiceTexts(xml)); + + const auto out = roundTrip(score); + REQUIRE(out.parts.size() == 1); + CHECK_EQUAL(describeVoices(voicesOf(score)), describeVoices(voicesOf(out))); + CHECK_EQUAL(describeVoices(lowerStaff), describeVoices(out.parts.at(0).measures.at(0).staves.at(1).voices)); +} + +T_END; + +// A chord fills a gap as one unit, and a clef and a direction in the gap are written at the tick +// where they belong. +TEST(aChordClefAndDirectionInAGapFill, VoiceInterleave) +{ + auto score = makeGapFillScore(); + auto &voices = voicesOf(score); + auto chordNote = makeQuarter(Step::a, 1); + chordNote.isChord = true; + voices.at(4).notes.at(0).isChord = true; + voices.at(4).notes.push_back(chordNote); + + auto &staff = score.parts.at(0).measures.at(0).staves.at(0); + ClefData clef; + clef.setBass(); + clef.tickTimePosition = 1; + clef.location = ClefLocation::midMeasure; + staff.clefs.push_back(clef); + DirectionData direction; + direction.tickTimePosition = 1; + direction.directionTypes.emplace_back(DirectionChoice{DampData{}}); + staff.directions.push_back(direction); + + const auto xml = toXml(score); + CHECK_EQUAL(0, countOccurrences(xml, "")); + CHECK_EQUAL(0, countOccurrences(xml, "")); + CHECK_EQUAL(std::string{"FFAF"}, findSteps(xml)); + + const auto out = roundTrip(score); + REQUIRE(out.parts.size() == 1); + CHECK_EQUAL(describeVoices(voicesOf(score)), describeVoices(voicesOf(out))); + const auto &outStaff = out.parts.at(0).measures.at(0).staves.at(0); + REQUIRE(outStaff.directions.size() == 1); + CHECK_EQUAL(1, outStaff.directions.at(0).tickTimePosition); + CHECK(!outStaff.directions.at(0).offset.has_value()); + REQUIRE(outStaff.clefs.size() == 1); + CHECK_EQUAL(1, outStaff.clefs.at(0).tickTimePosition); +} + +T_END; + +// Whether or not the writer puts voices in one another's gaps, the voices read back as they were +// written, and slurs named by identity never share a number while both are open. Many small staves +// of one to three voices with notes, chords and grace notes on random beats. +TEST(gapFilledVoicesReadBackAsWritten, VoiceInterleave) +{ + std::mt19937 random{425}; + const auto roll = [&](unsigned inSides) { return static_cast(random() % inSides); }; + const std::vector steps{Step::c, Step::d, Step::e, Step::f, Step::g, Step::a, Step::b}; + int mergedCount = 0; + + for (int i = 0; i < 400; ++i) + { + auto score = makeScore(); + auto &voices = voicesOf(score); + const int voiceCount = 1 + roll(3); + + for (int v = 0; v < voiceCount; ++v) + { + auto ¬es = voices[v].notes; + for (int tick = 0; tick < 4; ++tick) + { + if (roll(2) == 0) + { + continue; + } + if (roll(6) == 0) + { + auto grace = makeNote(steps.at(static_cast(roll(7))), tick, 0, DurationName::eighth); + grace.isGrace = true; + notes.push_back(grace); + } + notes.push_back(makeQuarter(steps.at(static_cast(roll(7))), tick)); + if (roll(5) == 0) + { + notes.back().isChord = true; + notes.push_back(notes.back()); + notes.back().pitchData.octave = 5; + } + } + if (notes.empty()) + { + notes.push_back(makeQuarter(Step::c, roll(4))); + } + if (notes.size() > 1) + { + addSlur(notes.front(), notes.back(), "voice" + std::to_string(v)); + } + } + + const auto xml = toXml(score); + REQUIRE(!xml.empty()); + if (voiceCount > 1 && countOccurrences(xml, "") < static_cast(voiceCount - 1)) + { + ++mergedCount; + } + + std::map isOpen; + for (const auto &slur : findSlurs(xml)) + { + const auto number = slur.substr(0, slur.find(' ')); + const bool isStart = slur.find("start") != std::string::npos; + CHECK(isOpen[number] != isStart); + isOpen[number] = isStart; + } + + const auto out = roundTrip(score); + REQUIRE(out.parts.size() == 1); + CHECK_EQUAL(describeVoices(voices), describeVoices(voicesOf(out))); + } + + // the cases above include many where voices share a stream + CHECK(mergedCount > 20); +} + +T_END; + +#endif diff --git a/src/private/mxtest/api/roundtrip-baseline.txt b/src/private/mxtest/api/roundtrip-baseline.txt index f46b0615c..40f3ae19c 100644 --- a/src/private/mxtest/api/roundtrip-baseline.txt +++ b/src/private/mxtest/api/roundtrip-baseline.txt @@ -887,3 +887,9 @@ synthetic/creator.3.0.xml # how much time a grace note takes from the note before or after it, or adds; only slash was kept. lysuite/ly24d_AfterGrace.xml synthetic/grace.3.0.xml + +# Unblocked by reading a change of without a as another voice. Three notes +# labelled 4, 5, 4 in a row were read as one voice with one name, so the middle note came back +# as voice 4. They are now two voices, and the writer puts the voice-5 note in the gap it fills +# rather than adding a and a . +musuite/testVoiceMapper1.xml