From b71eb69e0142b5737076eb30dcaecbad2beeb8c4 Mon Sep 17 00:00:00 2001 From: Matthew James Briggs Date: Thu, 5 Nov 2026 23:08:31 +0100 Subject: [PATCH 1/5] fix: read a voice change without a backup as another voice MusicXML can change voice between two notes with nothing but the text: the next note starts where the last one ended, no needed. The reader keyed voices only by runs, so notes labelled 4, 5, 4 in a row became one voice with one name, and the middle note was written back as voice 4. Within a backup run, the reader now groups each staff's notes by their text, and each group is a voice of its own. A note stays with the note it sounds with, and a note without a continues the voice of the note before it. A run that names at most one voice reads as before, and the same name after a is still another voice. The writer, which writes a staff's voices one after another, now writes a voice inside the voice before it when it exactly fills that voice's gaps: it starts before that voice ends, the two form one line in time with no two voices starting at the same tick, the voices enter in order, and their texts differ. Such a voice is written in place, rather than as a in the first voice and a to the second, and reads back as the same voices. Every other voice is written as before. Fixes #425. --- src/private/mx/impl/MeasureReader.cpp | 68 +++- src/private/mx/impl/MeasureReader.h | 26 ++ src/private/mx/impl/MeasureWriter.cpp | 231 +++++++++++- .../mxtest/api/VoiceInterleaveApiTest.cpp | 331 ++++++++++++++++++ src/private/mxtest/api/roundtrip-baseline.txt | 6 + 5 files changed, 650 insertions(+), 12 deletions(-) create mode 100644 src/private/mxtest/api/VoiceInterleaveApiTest.cpp diff --git a/src/private/mx/impl/MeasureReader.cpp b/src/private/mx/impl/MeasureReader.cpp index 88f8a4e84..ceb3e27ec 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,68 @@ 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. So +// within a backup run, each staff's notes are grouped by their text, and each group becomes +// a voice of its own. When a run names at most one voice, it is one group, as if text were ignored. +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..c47db3cfe 100644 --- a/src/private/mx/impl/MeasureWriter.cpp +++ b/src/private/mx/impl/MeasureWriter.cpp @@ -44,12 +44,219 @@ #include "mx/impl/ScoreWriter.h" #include "mx/utility/Throw.h" +#include +#include #include +#include +#include +#include namespace mx { namespace impl { +using MeasureWriterVoiceIter = std::map::const_iterator; + +// 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 MeasureWriterVoiceUnit +{ + MeasureWriterVoiceIter voice; + size_t firstNote = 0; + size_t noteCount = 0; + int start = 0; + int end = 0; +}; + +// Voices of one staff that are written as one stream, in time order, with no inside it. +// Most passes hold one voice; makeMeasureWriterVoicePasses says when a pass holds more. +struct MeasureWriterVoicePass +{ + // the pass's voices, in key order + std::vector voices; + + // the notes in the order they are written, when the pass has more than one voice + std::vector mergedNotes; + + // the voice of each note, in the order they are written + std::vector noteVoices; + + const std::vector ¬es() const + { + return voices.size() == 1 ? voices.front()->second.notes : mergedNotes; + } +}; + +static std::vector makeMeasureWriterVoiceUnits(MeasureWriterVoiceIter 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(MeasureWriterVoiceUnit{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 measureWriterVoiceText(MeasureWriterVoiceIter voice) +{ + return voice->second.label.empty() ? std::to_string(voice->first + 1) : voice->second.label; +} + +// 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, and its text must differ from those of +// the pass, so that a reader sorting notes by their text gets the same voices back. +static bool canJoinMeasureWriterVoicePass(const std::vector &passUnits, + const std::vector &passVoices, + const std::vector &voiceUnits, + MeasureWriterVoiceIter 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; + } + + const auto text = measureWriterVoiceText(voice); + auto voices = passVoices; + voices.push_back(voice); + + for (const auto &v : voices) + { + if (v != voice && measureWriterVoiceText(v) == text) + { + return false; + } + + // a note without a position has no place in a timeline + const auto ¬es = v->second.notes; + if (std::any_of(notes.cbegin(), notes.cend(), [](const auto ¬e) { return note.tickTimePosition < 0; })) + { + 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); + } + } + + if (entrances != voices) + { + return false; + } + + outMerged = std::move(merged); + return true; +} + +static void finishMeasureWriterVoicePass(MeasureWriterVoicePass &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.noteVoices.push_back(unit.voice); + } + } +} + +// Groups a staff's voices, in key order, into passes. Each voice joins the pass before it when it +// fits into that pass's gaps (canJoinMeasureWriterVoicePass); 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 . +static std::vector makeMeasureWriterVoicePasses(const api::StaffData &inStaff) +{ + std::vector passes; + std::vector passUnits; + + for (auto voice = inStaff.voices.cbegin(); voice != inStaff.voices.cend(); ++voice) + { + auto voiceUnits = makeMeasureWriterVoiceUnits(voice); + std::vector merged; + + if (!passes.empty() && + canJoinMeasureWriterVoicePass(passUnits, passes.back().voices, voiceUnits, voice, merged)) + { + passes.back().voices.push_back(voice); + passUnits = std::move(merged); + continue; + } + + if (!passes.empty()) + { + finishMeasureWriterVoicePass(passes.back(), passUnits); + } + + passes.emplace_back(); + passes.back().voices.push_back(voice); + passUnits = std::move(voiceUnits); + } + + if (!passes.empty()) + { + finishMeasureWriterVoicePass(passes.back(), passUnits); + } + + return passes; +} + MeasureWriter::MeasureWriter(const api::MeasureData &inMeasureData, const MeasureCursor &inCursor, const ScoreWriter &inScoreWriter) : myMeasureData{inMeasureData}, myOutMeasure{}, myPreviousCursor{inCursor}, myScoreWriter{inScoreWriter}, @@ -614,25 +821,32 @@ 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 : makeMeasureWriterVoicePasses(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 &voice = *pass.noteVoices.at(static_cast(noteIter - notes.cbegin())); + + if (voice.first != myHistory.getCursor().voiceIndex) + { + myHistory.setVoiceIndex(voice.first); + } myHistory.setChord(noteIter->isChord); const auto &apiNote = *noteIter; @@ -705,7 +919,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 +933,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/mxtest/api/VoiceInterleaveApiTest.cpp b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp new file mode 100644 index 000000000..68473f89f --- /dev/null +++ b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp @@ -0,0 +1,331 @@ +// 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 + +using namespace std; +using namespace mx::api; +using namespace mxtest; + +namespace voiceInterleaveApiTest +{ +// A one-part, one-staff score in 3/4 with one division per quarter, holding a single measure +// whose music is inMeasureBody. +inline std::string makeScoreXml(const std::string &inMeasureBody) +{ + return std::string{R"( + + + Flute + + + + + + 1 + + + G + 2 + + +)"} + inMeasureBody + + R"( + + +)"; +} + +// A in octave 4. An empty inVoice leaves the element out. +inline std::string makeNoteXml(const std::string &inStep, int inDuration, const std::string &inVoice, + bool inIsChord = false) +{ + std::string xml = " \n"; + if (inIsChord) + { + xml += " \n"; + } + xml += " \n " + inStep + "\n 4\n \n"; + xml += " " + std::to_string(inDuration) + "\n"; + if (!inVoice.empty()) + { + xml += " " + inVoice + "\n"; + } + xml += inDuration == 3 ? " half\n \n" : " quarter\n"; + xml += " \n"; + return xml; +} + +inline std::string makeBackupXml(int inDuration) +{ + return " \n " + std::to_string(inDuration) + "\n \n"; +} + +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; +} +} // 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; + +// 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; + +#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 From 61a6c456949f90a5d0d31c157b6d065ff2f6cfff Mon Sep 17 00:00:00 2001 From: Matthew James Briggs Date: Fri, 6 Nov 2026 00:10:47 +0100 Subject: [PATCH 2/5] fix: resolve spanners and voice names for voices written in a gap A voice written in another voice's gap was written from a copy of its notes. Spanner numbers are looked up by the address of the note's spanner objects, so an author's identity slur or tuplet on such a note was not found and the whole write failed. The spanner resolver also walked the staff voice by voice, not in the order the notes are written, so two identity spanners open at once in the shared stream could be given the same number. The grouping of a staff's voices into passes moves to VoicePass so the writer and the resolver walk the same order, and the writer writes each note from the score itself. A voice could also be written in a gap when it shared its voice name with a voice of an earlier or later pass that met the merged stream without a backup, and a reader then put the two voices together. Now a staff writes voices into one another's gaps only when every voice on it has a name of its own and every note has a position. --- src/private/mx/impl/MeasureWriter.cpp | 215 +----------------- src/private/mx/impl/SpannerResolver.cpp | 9 +- src/private/mx/impl/SpannerResolver.h | 14 +- src/private/mx/impl/VoicePass.cpp | 202 ++++++++++++++++ src/private/mx/impl/VoicePass.h | 52 +++++ .../mxtest/api/VoiceInterleaveApiTest.cpp | 167 ++++++++++++++ 6 files changed, 439 insertions(+), 220 deletions(-) create mode 100644 src/private/mx/impl/VoicePass.cpp create mode 100644 src/private/mx/impl/VoicePass.h diff --git a/src/private/mx/impl/MeasureWriter.cpp b/src/private/mx/impl/MeasureWriter.cpp index c47db3cfe..da237586e 100644 --- a/src/private/mx/impl/MeasureWriter.cpp +++ b/src/private/mx/impl/MeasureWriter.cpp @@ -42,221 +42,15 @@ #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 -#include #include -#include -#include -#include namespace mx { namespace impl { -using MeasureWriterVoiceIter = std::map::const_iterator; - -// 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 MeasureWriterVoiceUnit -{ - MeasureWriterVoiceIter voice; - size_t firstNote = 0; - size_t noteCount = 0; - int start = 0; - int end = 0; -}; - -// Voices of one staff that are written as one stream, in time order, with no inside it. -// Most passes hold one voice; makeMeasureWriterVoicePasses says when a pass holds more. -struct MeasureWriterVoicePass -{ - // the pass's voices, in key order - std::vector voices; - - // the notes in the order they are written, when the pass has more than one voice - std::vector mergedNotes; - - // the voice of each note, in the order they are written - std::vector noteVoices; - - const std::vector ¬es() const - { - return voices.size() == 1 ? voices.front()->second.notes : mergedNotes; - } -}; - -static std::vector makeMeasureWriterVoiceUnits(MeasureWriterVoiceIter 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(MeasureWriterVoiceUnit{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 measureWriterVoiceText(MeasureWriterVoiceIter voice) -{ - return voice->second.label.empty() ? std::to_string(voice->first + 1) : voice->second.label; -} - -// 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, and its text must differ from those of -// the pass, so that a reader sorting notes by their text gets the same voices back. -static bool canJoinMeasureWriterVoicePass(const std::vector &passUnits, - const std::vector &passVoices, - const std::vector &voiceUnits, - MeasureWriterVoiceIter 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; - } - - const auto text = measureWriterVoiceText(voice); - auto voices = passVoices; - voices.push_back(voice); - - for (const auto &v : voices) - { - if (v != voice && measureWriterVoiceText(v) == text) - { - return false; - } - - // a note without a position has no place in a timeline - const auto ¬es = v->second.notes; - if (std::any_of(notes.cbegin(), notes.cend(), [](const auto ¬e) { return note.tickTimePosition < 0; })) - { - 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); - } - } - - if (entrances != voices) - { - return false; - } - - outMerged = std::move(merged); - return true; -} - -static void finishMeasureWriterVoicePass(MeasureWriterVoicePass &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.noteVoices.push_back(unit.voice); - } - } -} - -// Groups a staff's voices, in key order, into passes. Each voice joins the pass before it when it -// fits into that pass's gaps (canJoinMeasureWriterVoicePass); 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 . -static std::vector makeMeasureWriterVoicePasses(const api::StaffData &inStaff) -{ - std::vector passes; - std::vector passUnits; - - for (auto voice = inStaff.voices.cbegin(); voice != inStaff.voices.cend(); ++voice) - { - auto voiceUnits = makeMeasureWriterVoiceUnits(voice); - std::vector merged; - - if (!passes.empty() && - canJoinMeasureWriterVoicePass(passUnits, passes.back().voices, voiceUnits, voice, merged)) - { - passes.back().voices.push_back(voice); - passUnits = std::move(merged); - continue; - } - - if (!passes.empty()) - { - finishMeasureWriterVoicePass(passes.back(), passUnits); - } - - passes.emplace_back(); - passes.back().voices.push_back(voice); - passUnits = std::move(voiceUnits); - } - - if (!passes.empty()) - { - finishMeasureWriterVoicePass(passes.back(), passUnits); - } - - return passes; -} - MeasureWriter::MeasureWriter(const api::MeasureData &inMeasureData, const MeasureCursor &inCursor, const ScoreWriter &inScoreWriter) : myMeasureData{inMeasureData}, myOutMeasure{}, myPreviousCursor{inCursor}, myScoreWriter{inScoreWriter}, @@ -821,7 +615,7 @@ void MeasureWriter::writeVoices(const api::StaffData &inStaff) auto directionIter = inStaff.directions.cbegin(); auto directionEnd = inStaff.directions.cend(); - for (const auto &pass : makeMeasureWriterVoicePasses(inStaff)) + for (const auto &pass : makeVoicePasses(inStaff)) { bool isFirstNote = true; int currentChordTickPosition = -1; @@ -841,7 +635,9 @@ void MeasureWriter::writeVoices(const api::StaffData &inStaff) for (; noteIter != noteEnd; ++noteIter) { bool isStartOfChord = false; - const auto &voice = *pass.noteVoices.at(static_cast(noteIter - notes.cbegin())); + 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) { @@ -849,7 +645,6 @@ void MeasureWriter::writeVoices(const api::StaffData &inStaff) } 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 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..1a3e16ddf --- /dev/null +++ b/src/private/mx/impl/VoicePass.h @@ -0,0 +1,52 @@ +// 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 +{ +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 index 68473f89f..7874ffdbb 100644 --- a/src/private/mxtest/api/VoiceInterleaveApiTest.cpp +++ b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp @@ -12,6 +12,7 @@ #include #include +#include #include #include @@ -159,6 +160,51 @@ inline ScoreData makeGapFillScore() 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; @@ -328,4 +374,125 @@ TEST(aGraceNoteOnAVoiceBoundaryIsNotInlined, VoiceInterleave) 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; + + +// 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 From d68fa13ecd882f130c52347f2a92a7a45b7e4e6d Mon Sep 17 00:00:00 2001 From: Matthew James Briggs Date: Fri, 6 Nov 2026 00:10:52 +0100 Subject: [PATCH 3/5] test: cover voice changes after a forward, per staff, and around merged voices --- .../mxtest/api/VoiceInterleaveApiTest.cpp | 158 ++++++++++++++++-- 1 file changed, 148 insertions(+), 10 deletions(-) diff --git a/src/private/mxtest/api/VoiceInterleaveApiTest.cpp b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp index 7874ffdbb..ffae72763 100644 --- a/src/private/mxtest/api/VoiceInterleaveApiTest.cpp +++ b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp @@ -22,10 +22,17 @@ using namespace mxtest; namespace voiceInterleaveApiTest { -// A one-part, one-staff score in 3/4 with one division per quarter, holding a single measure -// whose music is inMeasureBody. -inline std::string makeScoreXml(const std::string &inMeasureBody) +// A one-part score in 3/4 with one division per quarter, holding a single measure whose music is +// inMeasureBody. With two staves, the second has a bass clef. +inline std::string makeScoreXml(const std::string &inMeasureBody, int inStaves = 1) { + const std::string staves = inStaves == 1 ? "" : " 2\n"; + const std::string clefNumber = inStaves == 1 ? "" : R"( number="1")"; + const std::string bassClef = inStaves == 1 ? "" : R"( + F + 4 + +)"; return std::string{R"( @@ -40,21 +47,24 @@ inline std::string makeScoreXml(const std::string &inMeasureBody) 3 4 - +)"} + staves + + " G 2 - -)"} + inMeasureBody + +)" + bassClef + + R"( +)" + inMeasureBody + R"( )"; } -// A in octave 4. An empty inVoice leaves the element out. +// A in octave 4. 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) + bool inIsChord = false, int inStaff = 0) { std::string xml = " \n"; if (inIsChord) @@ -68,6 +78,10 @@ inline std::string makeNoteXml(const std::string &inStep, int inDuration, const xml += " " + inVoice + "\n"; } xml += inDuration == 3 ? " half\n \n" : " quarter\n"; + if (inStaff > 0) + { + xml += " " + std::to_string(inStaff) + "\n"; + } xml += " \n"; return xml; } @@ -77,6 +91,11 @@ inline std::string makeBackupXml(int inDuration) return " \n " + std::to_string(inDuration) + "\n \n"; } +inline std::string makeForwardXml(int inDuration) +{ + return " \n " + std::to_string(inDuration) + "\n \n"; +} + inline size_t countOccurrences(const std::string &haystack, const std::string &needle) { size_t count = 0; @@ -160,6 +179,7 @@ inline ScoreData makeGapFillScore() 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) { @@ -323,6 +343,58 @@ TEST(aVoiceRepeatedAfterABackupStaysSeparate, VoiceInterleave) 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) { @@ -374,7 +446,6 @@ TEST(aGraceNoteOnAVoiceBoundaryIsNotInlined, VoiceInterleave) 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. @@ -396,7 +467,6 @@ TEST(slursInAGapFillAreNumberedInWrittenOrder, VoiceInterleave) 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. @@ -418,6 +488,74 @@ TEST(aGapFillOnAStaffWithARepeatedNameIsNotInlined, VoiceInterleave) 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 From 34e309643ebac015de84774a04c73bef30784677 Mon Sep 17 00:00:00 2001 From: Matthew James Briggs Date: Sat, 7 Nov 2026 09:28:57 +0100 Subject: [PATCH 4/5] docs: explain how notes are sorted into voices and what a voice pass is --- src/private/mx/impl/MeasureReader.cpp | 12 +++++++++--- src/private/mx/impl/VoicePass.h | 5 +++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/private/mx/impl/MeasureReader.cpp b/src/private/mx/impl/MeasureReader.cpp index ceb3e27ec..ee4974fc5 100644 --- a/src/private/mx/impl/MeasureReader.cpp +++ b/src/private/mx/impl/MeasureReader.cpp @@ -544,9 +544,15 @@ void MeasureReader::parseNote(const core::Note &inMxNote, const core::Note *next } // 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. So -// within a backup run, each staff's notes are grouped by their text, and each group becomes -// a voice of its own. When a run names at most one voice, it is one group, as if text were ignored. +// 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]; diff --git a/src/private/mx/impl/VoicePass.h b/src/private/mx/impl/VoicePass.h index 1a3e16ddf..30ea27726 100644 --- a/src/private/mx/impl/VoicePass.h +++ b/src/private/mx/impl/VoicePass.h @@ -13,6 +13,11 @@ 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 From 0e1a1a0258de48820c1945689d525bfa6ed5d7f1 Mon Sep 17 00:00:00 2001 From: Matthew James Briggs Date: Sat, 7 Nov 2026 09:28:57 +0100 Subject: [PATCH 5/5] test: build the voice interleave test xml from readable templates --- .../mxtest/api/VoiceInterleaveApiTest.cpp | 120 ++++++++++++------ 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/src/private/mxtest/api/VoiceInterleaveApiTest.cpp b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp index ffae72763..392df37bc 100644 --- a/src/private/mxtest/api/VoiceInterleaveApiTest.cpp +++ b/src/private/mxtest/api/VoiceInterleaveApiTest.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -22,18 +23,36 @@ using namespace mxtest; namespace voiceInterleaveApiTest { -// A one-part score in 3/4 with one division per quarter, holding a single measure whose music is -// inMeasureBody. With two staves, the second has a bass clef. -inline std::string makeScoreXml(const std::string &inMeasureBody, int inStaves = 1) +// 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) { - const std::string staves = inStaves == 1 ? "" : " 2\n"; - const std::string clefNumber = inStaves == 1 ? "" : R"( number="1")"; - const std::string bassClef = inStaves == 1 ? "" : R"( - F - 4 - -)"; - return std::string{R"( + 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 @@ -47,53 +66,76 @@ inline std::string makeScoreXml(const std::string &inMeasureBody, int inStaves = 3 4 -)"} + staves + - " + {staves} + G 2 -)" + bassClef + - R"( -)" + inMeasureBody + - R"( + {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 in octave 4. An empty inVoice leaves the element out, and so does a zero -// inStaff for the element. +// 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) { - std::string xml = " \n"; - if (inIsChord) - { - xml += " \n"; - } - xml += " \n " + inStep + "\n 4\n \n"; - xml += " " + std::to_string(inDuration) + "\n"; - if (!inVoice.empty()) - { - xml += " " + inVoice + "\n"; - } - xml += inDuration == 3 ? " half\n \n" : " quarter\n"; - if (inStaff > 0) - { - xml += " " + std::to_string(inStaff) + "\n"; - } - xml += " \n"; - return xml; + 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 " \n " + std::to_string(inDuration) + "\n \n"; + return fillXmlTemplate(kVoiceInterleaveMoveXml, {{"element", "backup"}, {"duration", std::to_string(inDuration)}}); } inline std::string makeForwardXml(int inDuration) { - return " \n " + std::to_string(inDuration) + "\n \n"; + return fillXmlTemplate(kVoiceInterleaveMoveXml, {{"element", "forward"}, {"duration", std::to_string(inDuration)}}); } inline size_t countOccurrences(const std::string &haystack, const std::string &needle)