From 42c9fcec52a3fe298202a58344729591c71685f3 Mon Sep 17 00:00:00 2001 From: Matthew James Briggs Date: Sat, 19 Sep 2026 12:31:48 +0200 Subject: [PATCH] fix: carry a dotted note's dots into a guessed tuplet-normal --- src/private/mx/impl/TupletReader.cpp | 10 +- src/private/mxtest/api/NoteDataTest.cpp | 62 +++++++++ src/private/mxtest/impl/TupletReaderTest.cpp | 138 +++++++++++++++++++ 3 files changed, 208 insertions(+), 2 deletions(-) diff --git a/src/private/mx/impl/TupletReader.cpp b/src/private/mx/impl/TupletReader.cpp index afa361d6b..f02af756b 100644 --- a/src/private/mx/impl/TupletReader.cpp +++ b/src/private/mx/impl/TupletReader.cpp @@ -132,9 +132,15 @@ void TupletReader::guessNormalFromNote(api::TupletStart &outTupletStart) outTupletStart.normalDurationName = converter.convert(grp.normalType()); outTupletStart.normalDots = static_cast(grp.normalDot().size()); } - else if (myNote.type().has_value()) + else { - outTupletStart.normalDurationName = converter.convert(myNote.type()->value()); + // An absent normal-type means the normal figure is the note's own written figure, + // its dots included (see the time-modification type in the MusicXML schema). + if (myNote.type().has_value()) + { + outTupletStart.normalDurationName = converter.convert(myNote.type()->value()); + } + outTupletStart.normalDots = static_cast(myNote.dot().size()); } } diff --git a/src/private/mxtest/api/NoteDataTest.cpp b/src/private/mxtest/api/NoteDataTest.cpp index 7264add70..c90af6a62 100644 --- a/src/private/mxtest/api/NoteDataTest.cpp +++ b/src/private/mxtest/api/NoteDataTest.cpp @@ -1807,4 +1807,66 @@ TEST(implicitStaffOnSingleStaffPartOmitsElement, NoteData) T_END; +// issue #452: a on a dotted note whose does not restate the +// normal figure guesses that figure from the note, dots included, so the tuplet written back +// carries a tuplet-dot. +TEST(guessedTupletNormalKeepsTheNoteDots, NoteData) +{ + const std::string xml = R"( + + + MusicXML Part + + + + + + 2 + + + + C + 4 + + 1 + 1 + quarter + + + 3 + 2 + + + + + + + + +)"; + + const auto score = mxtest::fromXml(xml); + const auto &starts = + score.parts.at(0).measures.at(0).staves.at(0).voices.at(0).notes.at(0).noteAttachmentData.tupletStarts; + REQUIRE(1 == starts.size()); + CHECK(DurationName::quarter == starts.at(0).normalDurationName); + CHECK_EQUAL(1, starts.at(0).normalDots); + + const auto written = mxtest::toXml(score); + const auto normalPos = written.find(""); + REQUIRE(normalPos != std::string::npos); + const auto normalEnd = written.find("", normalPos); + REQUIRE(normalEnd != std::string::npos); + CHECK(written.substr(normalPos, normalEnd - normalPos).find(" starts; + std::vector stops; + reader.parseTuplet(starts, stops); + + REQUIRE(starts.size() == 1); + CHECK(api::DurationName::quarter == starts.front().normalDurationName); + CHECK_EQUAL(1, starts.front().normalDots); + CHECK_EQUAL(1, starts.front().actualDots); +} + +T_END + +// The count matters rather than the presence of one dot: a double-dotted note has two. +TEST(guessesTwoNormalDotsFromTwoNoteDots, TupletReader) +{ + core::Tuplet tuplet; + tuplet.setType(core::StartStop::start()); + + core::TimeModification timeMod; + timeMod.setActualNotes(3); + timeMod.setNormalNotes(2); + + core::NoteType noteType; + noteType.setValue(core::NoteTypeValue::quarter()); + + core::Note note; + note.setType(noteType); + note.setTimeModification(timeMod); + note.addDot(core::EmptyPlacement{}); + note.addDot(core::EmptyPlacement{}); + + Cursor cursor{1, 480}; + TupletReader reader{tuplet, cursor, note}; + + std::vector starts; + std::vector stops; + reader.parseTuplet(starts, stops); + + REQUIRE(starts.size() == 1); + CHECK_EQUAL(2, starts.front().normalDots); +} + +T_END + +// An explicit normal-type/normal-dot still wins: the note's own dots must not overwrite +// the normal side that the file spelled out. +TEST(explicitNormalDotsWinOverNoteDots, TupletReader) +{ + core::Tuplet tuplet; + tuplet.setType(core::StartStop::start()); + + core::TimeModificationGroup group; + group.setNormalType(core::NoteTypeValue::half()); + group.addNormalDot(core::Empty{}); + + core::TimeModification timeMod; + timeMod.setActualNotes(3); + timeMod.setNormalNotes(2); + timeMod.setGroup(group); + + core::NoteType noteType; + noteType.setValue(core::NoteTypeValue::quarter()); + + core::Note note; + note.setType(noteType); + note.setTimeModification(timeMod); + note.addDot(core::EmptyPlacement{}); + note.addDot(core::EmptyPlacement{}); + + Cursor cursor{1, 480}; + TupletReader reader{tuplet, cursor, note}; + + std::vector starts; + std::vector stops; + reader.parseTuplet(starts, stops); + + REQUIRE(starts.size() == 1); + CHECK(api::DurationName::half == starts.front().normalDurationName); + CHECK_EQUAL(1, starts.front().normalDots); +} + +T_END + +// A note with no dots guesses zero dots rather than the unspecified sentinel, so that a +// second round trip reads the same value back. +TEST(guessesZeroNormalDotsWhenNoteHasNoDots, TupletReader) +{ + core::Tuplet tuplet; + tuplet.setType(core::StartStop::start()); + + core::TimeModification timeMod; + timeMod.setActualNotes(3); + timeMod.setNormalNotes(2); + + core::NoteType noteType; + noteType.setValue(core::NoteTypeValue::quarter()); + + core::Note note; + note.setType(noteType); + note.setTimeModification(timeMod); + + Cursor cursor{1, 480}; + TupletReader reader{tuplet, cursor, note}; + + std::vector starts; + std::vector stops; + reader.parseTuplet(starts, stops); + + REQUIRE(starts.size() == 1); + CHECK_EQUAL(0, starts.front().normalDots); +} + +T_END + #endif