From 8698ee47c1fc6d640dad41b0676e3ef337e68c34 Mon Sep 17 00:00:00 2001 From: Robert Patterson Date: Wed, 29 Jul 2026 10:41:06 -0500 Subject: [PATCH 1/2] Add stackDegrees and parenthesesDegrees to ChordData Expose the MusicXML kind stack-degrees and parentheses-degrees attributes, which control how a chord symbol's degree alterations are laid out. Both are api::Bool defaulting to unspecified, so the writer emits nothing unless the field is set. DirectionReader reads them off core::Kind; DirectionWriter writes them back. Adds round-trip, negative, and file-based tests in HarmonyExtrasApiTest, and registers data/synthetic/kind.3.0.xml with MxFileRepository. --- src/include/mx/api/ChordData.h | 9 ++++ src/private/mx/api/ChordData.cpp | 4 +- src/private/mx/impl/DirectionReader.cpp | 12 +++++ src/private/mx/impl/DirectionWriter.cpp | 12 +++++ .../mxtest/api/HarmonyExtrasApiTest.cpp | 52 +++++++++++++++++++ src/private/mxtest/file/MxFileRepositoy.cpp | 1 + 6 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/include/mx/api/ChordData.h b/src/include/mx/api/ChordData.h index cbfbcb476..cef16d76c 100644 --- a/src/include/mx/api/ChordData.h +++ b/src/include/mx/api/ChordData.h @@ -251,6 +251,13 @@ class ChordData ChordKind chordKind; std::string text; Bool useSymbols; + // How the chord's degree alterations (the 'extensions' below) are laid out next to the chord + // symbol. stackDegrees stacks them vertically instead of running them left to right; + // parenthesesDegrees wraps all of them in parentheses, e.g. C7(#9b13). These are the MusicXML + // stack-degrees and parentheses-degrees attributes; unspecified leaves the choice to the + // notation software rendering the score. + Bool stackDegrees; + Bool parenthesesDegrees; Step bass; int bassAlter; // The MusicXML (0 = root position, 1 = first inversion, ...). Present only when @@ -279,6 +286,8 @@ MXAPI_EQUALS_MEMBER(numeralMode) MXAPI_EQUALS_MEMBER(chordKind) MXAPI_EQUALS_MEMBER(text) MXAPI_EQUALS_MEMBER(useSymbols) +MXAPI_EQUALS_MEMBER(stackDegrees) +MXAPI_EQUALS_MEMBER(parenthesesDegrees) MXAPI_EQUALS_MEMBER(bass) MXAPI_EQUALS_MEMBER(bassAlter) MXAPI_EQUALS_MEMBER(inversion) diff --git a/src/private/mx/api/ChordData.cpp b/src/private/mx/api/ChordData.cpp index 2f9a92bdf..442ef181f 100644 --- a/src/private/mx/api/ChordData.cpp +++ b/src/private/mx/api/ChordData.cpp @@ -32,8 +32,8 @@ ChordData::ChordData() : harmonyChordSource{HarmonyChordSource::root}, root{Step::c}, rootAlter{0}, functionText{}, numeralRoot{0}, numeralRootText{}, numeralAlter{0}, hasNumeralAlter{false}, hasNumeralKey{false}, numeralKeyFifths{0}, numeralMode{NumeralMode::unspecified}, chordKind{ChordKind::unspecified}, text{}, useSymbols{Bool::unspecified}, - bass{Step::unspecified}, bassAlter{0}, inversion{0}, hasInversion{false}, extensions{}, miscData{}, - hasFrameData{false}, frameData{}, positionData{} + stackDegrees{Bool::unspecified}, parenthesesDegrees{Bool::unspecified}, bass{Step::unspecified}, bassAlter{0}, + inversion{0}, hasInversion{false}, extensions{}, miscData{}, hasFrameData{false}, frameData{}, positionData{} { } diff --git a/src/private/mx/impl/DirectionReader.cpp b/src/private/mx/impl/DirectionReader.cpp index 64f6f6c5b..cafaacf33 100644 --- a/src/private/mx/impl/DirectionReader.cpp +++ b/src/private/mx/impl/DirectionReader.cpp @@ -1285,6 +1285,18 @@ void DirectionReader::parseHarmony(const core::Harmony &inHarmony, const core::H } } + if (kind.stackDegrees().has_value()) + { + const bool isYes = kind.stackDegrees()->tag() == mx::core::YesNo::Tag::yes; + chord.stackDegrees = isYes ? api::Bool::yes : api::Bool::no; + } + + if (kind.parenthesesDegrees().has_value()) + { + const bool isYes = kind.parenthesesDegrees()->tag() == mx::core::YesNo::Tag::yes; + chord.parenthesesDegrees = isYes ? api::Bool::yes : api::Bool::no; + } + if (inGrp.bass().has_value()) { const auto &bass = *inGrp.bass(); diff --git a/src/private/mx/impl/DirectionWriter.cpp b/src/private/mx/impl/DirectionWriter.cpp index d528dc722..2990e1c62 100644 --- a/src/private/mx/impl/DirectionWriter.cpp +++ b/src/private/mx/impl/DirectionWriter.cpp @@ -1591,6 +1591,18 @@ std::vector DirectionWriter::createHarmonyElements(int in kind.setUseSymbols(chordIter->useSymbols == api::Bool::yes ? core::YesNo::yes() : core::YesNo::no()); } + if (chordIter->stackDegrees != api::Bool::unspecified) + { + const bool isYes = chordIter->stackDegrees == api::Bool::yes; + kind.setStackDegrees(isYes ? core::YesNo::yes() : core::YesNo::no()); + } + + if (chordIter->parenthesesDegrees != api::Bool::unspecified) + { + const bool isYes = chordIter->parenthesesDegrees == api::Bool::yes; + kind.setParenthesesDegrees(isYes ? core::YesNo::yes() : core::YesNo::no()); + } + grp.setKind(kind); for (const auto &extension : chordIter->extensions) diff --git a/src/private/mxtest/api/HarmonyExtrasApiTest.cpp b/src/private/mxtest/api/HarmonyExtrasApiTest.cpp index 40135a495..5651b6549 100644 --- a/src/private/mxtest/api/HarmonyExtrasApiTest.cpp +++ b/src/private/mxtest/api/HarmonyExtrasApiTest.cpp @@ -8,6 +8,8 @@ #include "cpul/cpulTestHarness.h" #include "mx/api/ScoreData.h" #include "mxtest/api/RoundTrip.h" +#include "mxtest/api/TestHelpers.h" +#include "mxtest/file/MxFileRepository.h" using namespace std; using namespace mx::api; @@ -48,6 +50,56 @@ const ChordData &firstChord(const ScoreData &score) } } // namespace +TEST(harmonyDegreeLayoutRoundTrip, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.root = Step::c; + chord.chordKind = ChordKind::dominantNinth; + chord.stackDegrees = Bool::yes; + chord.parenthesesDegrees = Bool::no; + + const auto xml = mxtest::toXml(score); + CHECK(xml.find("stack-degrees=\"yes\"") != std::string::npos); + CHECK(xml.find("parentheses-degrees=\"no\"") != std::string::npos); + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + CHECK(Bool::yes == outChord.stackDegrees); + CHECK(Bool::no == outChord.parenthesesDegrees); +} + +T_END; + +TEST(harmonyDegreeLayoutUnspecifiedIsNotWritten, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.root = Step::c; + chord.chordKind = ChordKind::major; + + const auto xml = mxtest::toXml(score); + CHECK(xml.find("stack-degrees") == std::string::npos); + CHECK(xml.find("parentheses-degrees") == std::string::npos); + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + CHECK(Bool::unspecified == outChord.stackDegrees); + CHECK(Bool::unspecified == outChord.parenthesesDegrees); +} + +T_END; + +TEST(harmonyDegreeLayoutFromFile, HarmonyExtrasApi) +{ + const auto score = mxtest::MxFileRepository::loadFile("kind.3.0.xml"); + const auto &outChord = firstChord(score); + CHECK(Bool::yes == outChord.stackDegrees); + CHECK(Bool::yes == outChord.parenthesesDegrees); +} + +T_END; + TEST(harmonyInversionRoundTrip, HarmonyExtrasApi) { auto score = makeScoreWithChord(); diff --git a/src/private/mxtest/file/MxFileRepositoy.cpp b/src/private/mxtest/file/MxFileRepositoy.cpp index 139052f4a..89a7e76ee 100644 --- a/src/private/mxtest/file/MxFileRepositoy.cpp +++ b/src/private/mxtest/file/MxFileRepositoy.cpp @@ -478,5 +478,6 @@ void MxFileRepository::initializeNameSubdirectoryMap() myNameSubdirectoryMap.emplace("segno.3.1.xml", "synthetic"); myNameSubdirectoryMap.emplace("coda.3.0.xml", "synthetic"); myNameSubdirectoryMap.emplace("coda.3.1.xml", "synthetic"); + myNameSubdirectoryMap.emplace("kind.3.0.xml", "synthetic"); } } // namespace mxtest From 6c083c50d57d19a8a99e68120396781d7c9ea30f Mon Sep 17 00:00:00 2001 From: Robert Patterson Date: Wed, 29 Jul 2026 12:47:24 -0500 Subject: [PATCH 2/2] Write the degree print-object attribute DirectionWriter built each core::Degree with only degree-type, degree-value, and degree-alter, so api::Extension::printObject was dropped on write. The read side already populated it, meaning the field round-tripped to nothing. MusicXML marks a degree print-object="no" when the kind text attribute already spells it out; Finale exports suspended-fourth with a hidden added seventh. Without this the seventh prints twice. Covered by a round-trip test, a negative test, and a read of the three hidden degrees in recsuite/BrookeWestSample.xml. --- src/private/mx/impl/DirectionWriter.cpp | 7 ++ .../mxtest/api/HarmonyExtrasApiTest.cpp | 87 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/private/mx/impl/DirectionWriter.cpp b/src/private/mx/impl/DirectionWriter.cpp index 2990e1c62..ba2109c0b 100644 --- a/src/private/mx/impl/DirectionWriter.cpp +++ b/src/private/mx/impl/DirectionWriter.cpp @@ -1683,6 +1683,13 @@ std::vector DirectionWriter::createHarmonyElements(int in degree.setDegreeType(degreeType); degree.setDegreeValue(degreeValue); degree.setDegreeAlter(degreeAlter); + + if (extension.printObject != api::Bool::unspecified) + { + const bool isYes = extension.printObject == api::Bool::yes; + degree.setPrintObject(isYes ? core::YesNo::yes() : core::YesNo::no()); + } + grp.addDegree(degree); } diff --git a/src/private/mxtest/api/HarmonyExtrasApiTest.cpp b/src/private/mxtest/api/HarmonyExtrasApiTest.cpp index 5651b6549..f8c29831f 100644 --- a/src/private/mxtest/api/HarmonyExtrasApiTest.cpp +++ b/src/private/mxtest/api/HarmonyExtrasApiTest.cpp @@ -100,6 +100,93 @@ TEST(harmonyDegreeLayoutFromFile, HarmonyExtrasApi) T_END; +// A degree the text already spells out is marked print-object="no" so it is not printed +// twice. Finale exports exactly this: suspended-fourth plus a hidden +// seventh. +TEST(harmonyDegreePrintObjectRoundTrip, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.root = Step::c; + chord.chordKind = ChordKind::suspendedFourth; + chord.text = "7sus4"; + Extension seventh; + seventh.extensionType = ExtensionType::add; + seventh.extensionNumber = ExtensionNumber::seventh; + seventh.extensionAlter = ExtensionAlter::none; + seventh.printObject = Bool::no; + chord.extensions.push_back(seventh); + + const auto xml = mxtest::toXml(score); + CHECK(xml.find("") != std::string::npos); + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + REQUIRE(outChord.extensions.size() == 1); + CHECK(Bool::no == outChord.extensions.front().printObject); + CHECK(ExtensionNumber::seventh == outChord.extensions.front().extensionNumber); +} + +T_END; + +TEST(harmonyDegreePrintObjectUnspecifiedIsNotWritten, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.root = Step::c; + chord.chordKind = ChordKind::major; + Extension ninth; + ninth.extensionType = ExtensionType::add; + ninth.extensionNumber = ExtensionNumber::ninth; + ninth.extensionAlter = ExtensionAlter::sharp; + chord.extensions.push_back(ninth); + + const auto xml = mxtest::toXml(score); + CHECK(xml.find("print-object") == std::string::npos); + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + REQUIRE(outChord.extensions.size() == 1); + CHECK(Bool::unspecified == outChord.extensions.front().printObject); +} + +T_END; + +// The read path against a real Finale export: three chord symbols in this file hide a degree the +// kind text already spells out. +TEST(harmonyDegreePrintObjectFromFinaleExport, HarmonyExtrasApi) +{ + const auto score = mxtest::MxFileRepository::loadFile("BrookeWestSample.xml"); + int hiddenDegreeCount = 0; + + for (const auto &part : score.parts) + { + for (const auto &measure : part.measures) + { + for (const auto &staff : measure.staves) + { + for (const auto &direction : staff.directions) + { + for (const auto &chord : direction.chords) + { + for (const auto &extension : chord.extensions) + { + if (extension.printObject == Bool::no) + { + ++hiddenDegreeCount; + } + } + } + } + } + } + } + + CHECK_EQUAL(3, hiddenDegreeCount); +} + +T_END; + TEST(harmonyInversionRoundTrip, HarmonyExtrasApi) { auto score = makeScoreWithChord();