diff --git a/docs/ai/api-feature-audit.md b/docs/ai/api-feature-audit.md index 3d69de3ed..11b361797 100644 --- a/docs/ai/api-feature-audit.md +++ b/docs/ai/api-feature-audit.md @@ -180,8 +180,8 @@ so these boxes are status markers, not clickable; the live checklist is the pare | [x] | `` gaps (credit-image, no-words credits, multiple credit-type) | 2(2) | #187 | | [x] | read and write `` | 2(3) | #188 | | [x] | defaults fonts (`word-font`, `lyric-font`, `music-font`) | 2(4) | #189 | -| [ ] | round-trip `` | 2(5) | #190 | -| [ ] | harmony `inversion`, `function`, `numeral` | 2(6) | #191 | +| [x] | round-trip `` | 2(5) | #190 | +| [x] | harmony `inversion`, `function`, `numeral` | 2(6) | #191 | | [x] | write `midi-name` on output | 3 | #192 | Parent tracker: #159. The section-2 lower-use gaps are intentionally not tracked yet. diff --git a/src/include/mx/api/ChordData.h b/src/include/mx/api/ChordData.h index 8f9a34ea3..cbfbcb476 100644 --- a/src/include/mx/api/ChordData.h +++ b/src/include/mx/api/ChordData.h @@ -191,19 +191,72 @@ MXAPI_EQUALS_MEMBER(notes) MXAPI_EQUALS_END; MXAPI_NOT_EQUALS_AND_VECTORS(FrameData); +// A harmony-chord identifies its pitch source one of three mutually exclusive ways (the MusicXML +// harmony-chord choice): a 'root' (pop-style chord symbol, the default), a 'numeral' (Roman numeral +// / Nashville number for functional harmony, MusicXML 4.0), or the deprecated 'function' string. +enum class HarmonyChordSource +{ + root, + numeral, + function +}; + +// The mode used to interpret a numeral when it differs from the key signature +// (the MusicXML numeral-mode element). MusicXML requires a numeral-mode inside every numeral-key, +// so 'unspecified' is only meaningful when there is no numeral-key; combined with hasNumeralKey it +// is written out as 'major' (see DirectionWriter and the numeralMode field below). +enum class NumeralMode +{ + unspecified, + major, + minor, + naturalMinor, + melodicMinor, + harmonicMinor +}; + class ChordData { public: ChordData(); public: + // Which of the mutually exclusive harmony-chord alternatives is in use. Defaults to 'root', + // which preserves the historical behavior; 'root', 'rootAlter', 'numeral*', and 'functionText' + // are interpreted according to this value. + HarmonyChordSource harmonyChordSource; + Step root; int rootAlter; + + // The deprecated MusicXML element: Roman numeral text such as "V". Used only when + // harmonyChordSource == function. + std::string functionText; + + // The MusicXML 4.0 . Used only when harmonyChordSource == numeral. + // numeralRoot is a scale degree 1-7; numeralRootText is its optional display text (e.g. "V"). + int numeralRoot; + std::string numeralRootText; + // The numeral-alter (chromatic alteration of the numeral root), in semitones. + int numeralAlter; + bool hasNumeralAlter; + // The optional (key local to the numeral): numeral-fifths plus numeral-mode. + // When hasNumeralKey is true, numeralMode must name a concrete mode: numeral-mode is required by + // MusicXML inside numeral-key, so leaving it 'unspecified' is a contradiction that the writer + // resolves by emitting 'major'. + bool hasNumeralKey; + int numeralKeyFifths; + NumeralMode numeralMode; + ChordKind chordKind; std::string text; Bool useSymbols; Step bass; int bassAlter; + // The MusicXML (0 = root position, 1 = first inversion, ...). Present only when + // hasInversion is true. + int inversion; + bool hasInversion; std::vector extensions; std::vector miscData; bool hasFrameData; @@ -212,13 +265,24 @@ class ChordData }; MXAPI_EQUALS_BEGIN(ChordData) +MXAPI_EQUALS_MEMBER(harmonyChordSource) MXAPI_EQUALS_MEMBER(root) MXAPI_EQUALS_MEMBER(rootAlter) +MXAPI_EQUALS_MEMBER(functionText) +MXAPI_EQUALS_MEMBER(numeralRoot) +MXAPI_EQUALS_MEMBER(numeralRootText) +MXAPI_EQUALS_MEMBER(numeralAlter) +MXAPI_EQUALS_MEMBER(hasNumeralAlter) +MXAPI_EQUALS_MEMBER(hasNumeralKey) +MXAPI_EQUALS_MEMBER(numeralKeyFifths) +MXAPI_EQUALS_MEMBER(numeralMode) MXAPI_EQUALS_MEMBER(chordKind) MXAPI_EQUALS_MEMBER(text) MXAPI_EQUALS_MEMBER(useSymbols) MXAPI_EQUALS_MEMBER(bass) MXAPI_EQUALS_MEMBER(bassAlter) +MXAPI_EQUALS_MEMBER(inversion) +MXAPI_EQUALS_MEMBER(hasInversion) MXAPI_EQUALS_MEMBER(extensions) MXAPI_EQUALS_MEMBER(miscData) MXAPI_EQUALS_MEMBER(hasFrameData) diff --git a/src/include/mx/api/DirectionData.h b/src/include/mx/api/DirectionData.h index 3bd246e45..e9591c718 100644 --- a/src/include/mx/api/DirectionData.h +++ b/src/include/mx/api/DirectionData.h @@ -7,6 +7,7 @@ #include "mx/api/ApiCommon.h" #include "mx/api/ChordData.h" #include "mx/api/CodaData.h" +#include "mx/api/FiguredBassData.h" #include "mx/api/MarkData.h" #include "mx/api/OttavaData.h" #include "mx/api/RehearsalData.h" @@ -115,6 +116,7 @@ struct DirectionData std::vector pedalStops; std::vector words; std::vector chords; + std::vector figuredBasses; std::vector segnos; std::vector codas; std::vector rehearsals; @@ -138,7 +140,8 @@ inline bool isDirectionDataEmpty(const DirectionData &directionData) directionData.pedalStarts.size() == 0 && directionData.pedalStops.size() == 0 && directionData.tempos.size() == 0 && directionData.ottavaStarts.size() == 0 && directionData.ottavaStops.size() == 0 && directionData.words.size() == 0 && - directionData.segnos.size() == 0 && directionData.codas.size() == 0 && !directionData.isSoundDataSpecified && + directionData.segnos.size() == 0 && directionData.codas.size() == 0 && + directionData.figuredBasses.size() == 0 && !directionData.isSoundDataSpecified && directionData.orderedComponents.size() == 0; } @@ -163,6 +166,7 @@ MXAPI_EQUALS_MEMBER(pedalStarts) MXAPI_EQUALS_MEMBER(pedalStops) MXAPI_EQUALS_MEMBER(words) MXAPI_EQUALS_MEMBER(chords) +MXAPI_EQUALS_MEMBER(figuredBasses) MXAPI_EQUALS_MEMBER(segnos) MXAPI_EQUALS_MEMBER(codas) MXAPI_EQUALS_MEMBER(orderedComponents) diff --git a/src/include/mx/api/FiguredBassData.h b/src/include/mx/api/FiguredBassData.h new file mode 100644 index 000000000..72c311dcc --- /dev/null +++ b/src/include/mx/api/FiguredBassData.h @@ -0,0 +1,64 @@ +// MusicXML Class Library +// Copyright (c) by Matthew James Briggs +// Distributed under the MIT License + +#pragma once + +#include "mx/api/ApiCommon.h" + +#include +#include + +namespace mx +{ +namespace api +{ +// A single
within a . Each part is optional; an empty string means the +// corresponding child element (, , ) is absent. +class FigureData +{ + public: + std::string prefix; + std::string figureNumber; + std::string suffix; + + FigureData() : prefix{}, figureNumber{}, suffix{} + { + } +}; + +MXAPI_EQUALS_BEGIN(FigureData) +MXAPI_EQUALS_MEMBER(prefix) +MXAPI_EQUALS_MEMBER(figureNumber) +MXAPI_EQUALS_MEMBER(suffix) +MXAPI_EQUALS_END; +MXAPI_NOT_EQUALS_AND_VECTORS(FigureData); + +// The MusicXML element: an ordered list of figures plus optional parentheses and an +// optional duration. Figured bass takes its position from the first regular note that follows it in +// score order, so it carries no explicit tick position of its own here; it is attached to the +// DirectionData whose tickTimePosition locates it. +class FiguredBassData +{ + public: + std::vector figures; + // parentheses around the whole figured-bass group. unspecified means the attribute is absent + // (MusicXML treats absent as "no"). + Bool parentheses; + // The optional , in ticks. A value less than 0 means 'unspecified' (no duration child). + int durationTimeTicks; + + FiguredBassData() : figures{}, parentheses{Bool::unspecified}, durationTimeTicks{-1} + { + } +}; + +MXAPI_EQUALS_BEGIN(FiguredBassData) +MXAPI_EQUALS_MEMBER(figures) +MXAPI_EQUALS_MEMBER(parentheses) +MXAPI_EQUALS_MEMBER(durationTimeTicks) +MXAPI_EQUALS_END; +MXAPI_NOT_EQUALS_AND_VECTORS(FiguredBassData); + +} // namespace api +} // namespace mx diff --git a/src/private/mx/api/ChordData.cpp b/src/private/mx/api/ChordData.cpp index 5d7a88fa8..2f9a92bdf 100644 --- a/src/private/mx/api/ChordData.cpp +++ b/src/private/mx/api/ChordData.cpp @@ -29,8 +29,11 @@ FrameData::FrameData() : stringCount{6}, fretCount{4}, firstFret{1}, isFirstFret } ChordData::ChordData() - : root{Step::c}, rootAlter{0}, chordKind{ChordKind::unspecified}, text{}, useSymbols{Bool::unspecified}, - bass{Step::unspecified}, bassAlter{0}, extensions{}, miscData{}, hasFrameData{false}, frameData{}, positionData{} + : 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{} { } diff --git a/src/private/mx/impl/DirectionReader.cpp b/src/private/mx/impl/DirectionReader.cpp index e9cdb5015..7f4a41cdd 100644 --- a/src/private/mx/impl/DirectionReader.cpp +++ b/src/private/mx/impl/DirectionReader.cpp @@ -30,9 +30,15 @@ #include "mx/core/generated/HarmonyChordGroupChoice.h" #include "mx/core/generated/HarpPedals.h" #include "mx/core/generated/Image.h" +#include "mx/core/generated/Inversion.h" #include "mx/core/generated/Kind.h" #include "mx/core/generated/KindValue.h" #include "mx/core/generated/Metronome.h" +#include "mx/core/generated/Numeral.h" +#include "mx/core/generated/NumeralKey.h" +#include "mx/core/generated/NumeralMode.h" +#include "mx/core/generated/NumeralRoot.h" +#include "mx/core/generated/NumeralValue.h" #include "mx/core/generated/OctaveShift.h" #include "mx/core/generated/Offset.h" #include "mx/core/generated/OtherDirection.h" @@ -50,6 +56,7 @@ #include "mx/core/generated/Step.h" #include "mx/core/generated/String.h" #include "mx/core/generated/StringMute.h" +#include "mx/core/generated/StyleText.h" #include "mx/core/generated/UpDownStopContinue.h" #include "mx/core/generated/Wedge.h" #include "mx/core/generated/WedgeType.h" @@ -169,10 +176,7 @@ void DirectionReader::parseValues() { for (const auto &hcg : myHarmony->harmonyChord()) { - if (hcg.choice().isRoot()) - { - parseHarmony(*myHarmony, hcg); - } + parseHarmony(*myHarmony, hcg); } } } @@ -837,12 +841,78 @@ void DirectionReader::parseOtherDirection(const core::DirectionType &directionTy void DirectionReader::parseHarmony(const core::Harmony &inHarmony, const core::HarmonyChordGroup &inGrp) { mx::api::ChordData chord; - const auto &root = inGrp.choice().asRoot(); - chord.root = myConverter.convert(root.rootStep().value()); + const auto &choice = inGrp.choice(); - if (root.rootAlter().has_value()) + switch (choice.kind()) { - chord.rootAlter = mx::utility::roundTo(root.rootAlter()->value().value().value()); + case core::HarmonyChordGroupChoice::Kind::root: { + chord.harmonyChordSource = api::HarmonyChordSource::root; + const auto &root = choice.asRoot(); + chord.root = myConverter.convert(root.rootStep().value()); + + if (root.rootAlter().has_value()) + { + chord.rootAlter = mx::utility::roundTo(root.rootAlter()->value().value().value()); + } + break; + } + case core::HarmonyChordGroupChoice::Kind::numeral: { + chord.harmonyChordSource = api::HarmonyChordSource::numeral; + const auto &numeral = choice.asNumeral(); + chord.numeralRoot = numeral.numeralRoot().value().value(); + + if (numeral.numeralRoot().text().has_value()) + { + chord.numeralRootText = *numeral.numeralRoot().text(); + } + + if (numeral.numeralAlter().has_value()) + { + chord.hasNumeralAlter = true; + chord.numeralAlter = mx::utility::roundTo(numeral.numeralAlter()->value().value().value()); + } + + if (numeral.numeralKey().has_value()) + { + chord.hasNumeralKey = true; + const auto &numeralKey = *numeral.numeralKey(); + chord.numeralKeyFifths = numeralKey.numeralFifths().value(); + + switch (numeralKey.numeralMode().tag()) + { + case core::NumeralMode::Tag::major: + chord.numeralMode = api::NumeralMode::major; + break; + case core::NumeralMode::Tag::minor: + chord.numeralMode = api::NumeralMode::minor; + break; + case core::NumeralMode::Tag::naturalMinor: + chord.numeralMode = api::NumeralMode::naturalMinor; + break; + case core::NumeralMode::Tag::melodicMinor: + chord.numeralMode = api::NumeralMode::melodicMinor; + break; + case core::NumeralMode::Tag::harmonicMinor: + chord.numeralMode = api::NumeralMode::harmonicMinor; + break; + default: + // A core numeral mode we do not model yet: leave numeralMode unspecified rather + // than guess. No -Wswitch guard exists to flag a newly added core tag. + break; + } + } + break; + } + case core::HarmonyChordGroupChoice::Kind::function: { + chord.harmonyChordSource = api::HarmonyChordSource::function; + chord.functionText = choice.asFunction().value(); + break; + } + default: + // A core harmony-chord kind we do not model yet: leave harmonyChordSource at its default + // (root) with no pitch data rather than silently misreading. No -Wswitch guard exists to + // flag a newly added core kind. + break; } const auto &kind = inGrp.kind(); @@ -877,6 +947,12 @@ void DirectionReader::parseHarmony(const core::Harmony &inHarmony, const core::H } } + if (inGrp.inversion().has_value()) + { + chord.hasInversion = true; + chord.inversion = inGrp.inversion()->value(); + } + const auto °rees = inGrp.degree(); for (const auto °ree : degrees) diff --git a/src/private/mx/impl/DirectionWriter.cpp b/src/private/mx/impl/DirectionWriter.cpp index 84e31cf8c..12124bf98 100644 --- a/src/private/mx/impl/DirectionWriter.cpp +++ b/src/private/mx/impl/DirectionWriter.cpp @@ -22,6 +22,8 @@ #include "mx/core/generated/Divisions.h" #include "mx/core/generated/Dynamics.h" #include "mx/core/generated/Empty.h" +#include "mx/core/generated/Figure.h" +#include "mx/core/generated/FiguredBass.h" #include "mx/core/generated/FirstFret.h" #include "mx/core/generated/FormattedTextID.h" #include "mx/core/generated/Frame.h" @@ -31,17 +33,24 @@ #include "mx/core/generated/HarmonyAlter.h" #include "mx/core/generated/HarmonyChordGroup.h" #include "mx/core/generated/HarmonyChordGroupChoice.h" +#include "mx/core/generated/Inversion.h" #include "mx/core/generated/Kind.h" #include "mx/core/generated/Metronome.h" #include "mx/core/generated/MetronomeChoice.h" #include "mx/core/generated/MetronomeChoiceGroup.h" #include "mx/core/generated/MetronomeChoiceGroupChoice.h" #include "mx/core/generated/MusicDataChoice.h" +#include "mx/core/generated/Numeral.h" +#include "mx/core/generated/NumeralKey.h" +#include "mx/core/generated/NumeralMode.h" +#include "mx/core/generated/NumeralRoot.h" +#include "mx/core/generated/NumeralValue.h" #include "mx/core/generated/OctaveShift.h" #include "mx/core/generated/Offset.h" #include "mx/core/generated/Pedal.h" #include "mx/core/generated/PedalType.h" #include "mx/core/generated/PerMinute.h" +#include "mx/core/generated/PositiveDivisions.h" #include "mx/core/generated/Root.h" #include "mx/core/generated/RootStep.h" #include "mx/core/generated/Segno.h" @@ -51,6 +60,7 @@ #include "mx/core/generated/StartStopContinue.h" #include "mx/core/generated/String.h" #include "mx/core/generated/StringNumber.h" +#include "mx/core/generated/StyleText.h" #include "mx/core/generated/Wedge.h" #include "mx/core/generated/WedgeType.h" #include "mx/core/generated/YesNo.h" @@ -471,6 +481,9 @@ std::vector DirectionWriter::getDirectionLikeThings() auto harmonyMdcs = createHarmonyElements(offset); addMusicDataChoices(harmonyMdcs, output); + auto figuredBassMdcs = createFiguredBassElements(); + addMusicDataChoices(figuredBassMdcs, output); + // clear state myPlacements.clear(); myIsFirstDirectionTypeAdded = false; @@ -530,21 +543,89 @@ std::vector DirectionWriter::createHarmonyElements(int in } core::HarmonyChordGroup grp{}; - auto step = chordIter->root == api::Step::unspecified ? api::Step::c : chordIter->root; - - core::Root root{}; - core::RootStep rootStep{}; - rootStep.setValue(myConverter.convert(step)); - root.setRootStep(rootStep); - if (chordIter->rootAlter != 0) + switch (chordIter->harmonyChordSource) { - core::HarmonyAlter rootAlter{}; - rootAlter.setValue(core::Semitones{core::Decimal{static_cast(chordIter->rootAlter)}}); - root.setRootAlter(rootAlter); + case api::HarmonyChordSource::numeral: { + core::Numeral numeral{}; + core::NumeralRoot numeralRoot{}; + numeralRoot.setValue(core::NumeralValue{chordIter->numeralRoot}); + + if (!chordIter->numeralRootText.empty()) + { + numeralRoot.setText(chordIter->numeralRootText); + } + + numeral.setNumeralRoot(numeralRoot); + + if (chordIter->hasNumeralAlter) + { + core::HarmonyAlter numeralAlter{}; + numeralAlter.setValue(core::Semitones{core::Decimal{static_cast(chordIter->numeralAlter)}}); + numeral.setNumeralAlter(numeralAlter); + } + + if (chordIter->hasNumeralKey) + { + core::NumeralKey numeralKey{}; + numeralKey.setNumeralFifths(core::Fifths{chordIter->numeralKeyFifths}); + + switch (chordIter->numeralMode) + { + case api::NumeralMode::minor: + numeralKey.setNumeralMode(core::NumeralMode::minor()); + break; + case api::NumeralMode::naturalMinor: + numeralKey.setNumeralMode(core::NumeralMode::naturalMinor()); + break; + case api::NumeralMode::melodicMinor: + numeralKey.setNumeralMode(core::NumeralMode::melodicMinor()); + break; + case api::NumeralMode::harmonicMinor: + numeralKey.setNumeralMode(core::NumeralMode::harmonicMinor()); + break; + case api::NumeralMode::major: + case api::NumeralMode::unspecified: + default: + // numeral-mode is required inside numeral-key, so it cannot be omitted. An + // 'unspecified' mode here means the caller set hasNumeralKey without a concrete + // mode (a contradiction); fall back to major rather than drop the numeral-key. + numeralKey.setNumeralMode(core::NumeralMode::major()); + break; + } + + numeral.setNumeralKey(numeralKey); + } + + grp.setChoice(core::HarmonyChordGroupChoice::numeral(numeral)); + break; + } + case api::HarmonyChordSource::function: { + core::StyleText function{}; + function.setValue(chordIter->functionText); + grp.setChoice(core::HarmonyChordGroupChoice::function(function)); + break; } + case api::HarmonyChordSource::root: + default: { + auto step = chordIter->root == api::Step::unspecified ? api::Step::c : chordIter->root; - grp.setChoice(core::HarmonyChordGroupChoice::root(root)); + core::Root root{}; + core::RootStep rootStep{}; + rootStep.setValue(myConverter.convert(step)); + root.setRootStep(rootStep); + + if (chordIter->rootAlter != 0) + { + core::HarmonyAlter rootAlter{}; + rootAlter.setValue(core::Semitones{core::Decimal{static_cast(chordIter->rootAlter)}}); + root.setRootAlter(rootAlter); + } + + grp.setChoice(core::HarmonyChordGroupChoice::root(root)); + break; + } + } if (chordIter->bass != api::Step::unspecified) { @@ -563,6 +644,13 @@ std::vector DirectionWriter::createHarmonyElements(int in grp.setBass(bass); } + if (chordIter->hasInversion) + { + core::Inversion inversion{}; + inversion.setValue(chordIter->inversion); + grp.setInversion(inversion); + } + const auto k = myConverter.convert(chordIter->chordKind); core::Kind kind{}; kind.setValue(k); @@ -726,6 +814,78 @@ std::vector DirectionWriter::createHarmonyElements(int in return output; } +std::vector DirectionWriter::createFiguredBassElements() +{ + std::vector output; + + for (const auto &figuredBassData : myDirectionData.figuredBasses) + { + // The core figure list is never-empty (OneOrMore), so a figured-bass with zero figures + // would still serialize one fabricated empty
. Skip it: an empty figures list + // means "no figured-bass", and round-tripping must not invent content. + if (figuredBassData.figures.empty()) + { + continue; + } + + core::FiguredBass figuredBass{}; + + bool isFirstFigure = true; + + for (const auto &figureData : figuredBassData.figures) + { + core::Figure figure{}; + + if (!figureData.prefix.empty()) + { + core::StyleText prefix{}; + prefix.setValue(figureData.prefix); + figure.setPrefix(prefix); + } + + if (!figureData.figureNumber.empty()) + { + core::StyleText figureNumber{}; + figureNumber.setValue(figureData.figureNumber); + figure.setFigureNumber(figureNumber); + } + + if (!figureData.suffix.empty()) + { + core::StyleText suffix{}; + suffix.setValue(figureData.suffix); + figure.setSuffix(suffix); + } + + if (isFirstFigure) + { + isFirstFigure = false; + figuredBass.setFigure(core::OneOrMore{figure}); + } + else + { + figuredBass.addFigure(figure); + } + } + + if (figuredBassData.parentheses != api::Bool::unspecified) + { + figuredBass.setParentheses(figuredBassData.parentheses == api::Bool::yes ? core::YesNo::yes() + : core::YesNo::no()); + } + + if (figuredBassData.durationTimeTicks >= 0) + { + figuredBass.setDuration( + core::PositiveDivisions{core::Decimal{static_cast(figuredBassData.durationTimeTicks)}}); + } + + output.push_back(core::MusicDataChoice::figuredBass(figuredBass)); + } + + return output; +} + void DirectionWriter::addMusicDataChoices(const std::vector &inMdcs, std::vector &ioOutputSet) { diff --git a/src/private/mx/impl/DirectionWriter.h b/src/private/mx/impl/DirectionWriter.h index 6c8a31c99..9ee2ec2bf 100644 --- a/src/private/mx/impl/DirectionWriter.h +++ b/src/private/mx/impl/DirectionWriter.h @@ -25,6 +25,7 @@ class DirectionWriter private: void addDirectionType(core::DirectionType directionType, core::Direction &ioDirection); std::vector createHarmonyElements(int inOffset); + std::vector createFiguredBassElements(); void addMusicDataChoices(const std::vector &inMdcs, std::vector &ioOutputSet); diff --git a/src/private/mx/impl/MeasureReader.cpp b/src/private/mx/impl/MeasureReader.cpp index 9afd74f2c..b6e237bea 100644 --- a/src/private/mx/impl/MeasureReader.cpp +++ b/src/private/mx/impl/MeasureReader.cpp @@ -66,50 +66,26 @@ namespace impl { namespace { -std::string figureToText(const core::Figure &figure) +api::FigureData parseFigure(const core::Figure &figure) { - std::string text; + api::FigureData figureData; if (figure.prefix().has_value()) { - text += figure.prefix()->value(); + figureData.prefix = figure.prefix()->value(); } if (figure.figureNumber().has_value()) { - text += figure.figureNumber()->value(); + figureData.figureNumber = figure.figureNumber()->value(); } if (figure.suffix().has_value()) { - text += figure.suffix()->value(); + figureData.suffix = figure.suffix()->value(); } - return text; -} - -std::string figuredBassToText(const core::FiguredBass &figuredBass) -{ - std::string text; - - for (const auto &figure : figuredBass.figure()) - { - const auto figureText = figureToText(figure); - - if (figureText.empty()) - { - continue; - } - - if (!text.empty()) - { - text += "\n"; - } - - text += figureText; - } - - return text; + return figureData; } int getFiguredBassStaffIndex(const MeasureCursor &cursor, const api::MeasureData &measure, @@ -580,13 +556,29 @@ void MeasureReader::parseHarmony(const core::Harmony &inHarmony) const void MeasureReader::parseFiguredBass(const core::FiguredBass &inMxFiguredBass, const core::Note *nextNotePtr) const { - auto text = figuredBassToText(inMxFiguredBass); - - if (text.empty()) + if (myOutMeasureData.staves.empty()) { return; } + api::FiguredBassData figuredBass; + + for (const auto &figure : inMxFiguredBass.figure()) + { + figuredBass.figures.emplace_back(parseFigure(figure)); + } + + if (inMxFiguredBass.parentheses().has_value()) + { + figuredBass.parentheses = + inMxFiguredBass.parentheses()->tag() == core::YesNo::Tag::yes ? api::Bool::yes : api::Bool::no; + } + + if (inMxFiguredBass.duration().has_value()) + { + figuredBass.durationTimeTicks = myCurrentCursor.convertDurationToGlobalTickScale(*inMxFiguredBass.duration()); + } + auto direction = api::DirectionData{}; direction.tickTimePosition = myCurrentCursor.tickTimePosition; direction.placement = api::Placement::below; @@ -597,14 +589,7 @@ void MeasureReader::parseFiguredBass(const core::FiguredBass &inMxFiguredBass, c direction.voice = NoteReader{*nextNotePtr}.getVoiceNumber(); } - auto words = api::WordsData{}; - words.text = std::move(text); - direction.words.emplace_back(std::move(words)); - - if (myOutMeasureData.staves.empty()) - { - return; - } + direction.figuredBasses.emplace_back(std::move(figuredBass)); const auto staffIndex = getFiguredBassStaffIndex(myCurrentCursor, myOutMeasureData, nextNotePtr); myOutMeasureData.staves.at(static_cast(staffIndex)).directions.emplace_back(std::move(direction)); diff --git a/src/private/mxtest/api/FiguredBassApiTest.cpp b/src/private/mxtest/api/FiguredBassApiTest.cpp new file mode 100644 index 000000000..7950711ae --- /dev/null +++ b/src/private/mxtest/api/FiguredBassApiTest.cpp @@ -0,0 +1,129 @@ +// 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/ScoreData.h" +#include "mxtest/api/RoundTrip.h" + +using namespace std; +using namespace mx::api; +using namespace mxtest; + +namespace +{ +// Builds a single-part, single-measure score: one quarter note on beat 1 with a figured-bass +// direction attached at the same tick. The caller fills the returned direction's figuredBasses. +ScoreData makeScoreWithFiguredBass(FiguredBassData figuredBass) +{ + ScoreData score; + score.ticksPerQuarter = 4; + score.parts.emplace_back(); + auto &part = score.parts.back(); + part.name = "P1"; + part.uniqueId = "P1"; + part.measures.emplace_back(); + auto &measure = part.measures.back(); + measure.staves.emplace_back(); + auto &staff = measure.staves.back(); + + NoteData note{}; + note.pitchData.step = Step::c; + note.pitchData.octave = 3; + note.durationData.durationTimeTicks = 4; + note.durationData.durationName = DurationName::quarter; + note.tickTimePosition = 0; + staff.voices[0].notes.push_back(note); + + DirectionData direction{}; + direction.tickTimePosition = 0; + direction.isStaffValueSpecified = true; + direction.placement = Placement::below; + direction.figuredBasses.emplace_back(std::move(figuredBass)); + staff.directions.emplace_back(std::move(direction)); + + return score; +} + +const FiguredBassData *firstFiguredBass(const ScoreData &score) +{ + const auto &staff = score.parts.front().measures.front().staves.front(); + for (const auto &dir : staff.directions) + { + if (!dir.figuredBasses.empty()) + { + return &dir.figuredBasses.front(); + } + } + return nullptr; +} +} // namespace + +TEST(figuredBassFiguresRoundTrip, FiguredBassApi) +{ + FiguredBassData fb{}; + FigureData f0{}; + f0.figureNumber = "6"; + FigureData f1{}; + f1.prefix = "flat"; + f1.figureNumber = "5"; + f1.suffix = "slash"; + fb.figures.push_back(f0); + fb.figures.push_back(f1); + + const auto score = makeScoreWithFiguredBass(fb); + const auto out = mxtest::roundTrip(score); + + const auto *outFb = firstFiguredBass(out); + REQUIRE(outFb != nullptr); + REQUIRE(outFb->figures.size() == 2); + CHECK_EQUAL("6", outFb->figures.at(0).figureNumber); + CHECK_EQUAL("", outFb->figures.at(0).prefix); + CHECK_EQUAL("flat", outFb->figures.at(1).prefix); + CHECK_EQUAL("5", outFb->figures.at(1).figureNumber); + CHECK_EQUAL("slash", outFb->figures.at(1).suffix); +} + +T_END; + +TEST(figuredBassParenthesesAndDurationRoundTrip, FiguredBassApi) +{ + FiguredBassData fb{}; + FigureData f0{}; + f0.figureNumber = "7"; + fb.figures.push_back(f0); + fb.parentheses = Bool::yes; + fb.durationTimeTicks = 4; + + const auto score = makeScoreWithFiguredBass(fb); + const auto out = mxtest::roundTrip(score); + + const auto *outFb = firstFiguredBass(out); + REQUIRE(outFb != nullptr); + CHECK(Bool::yes == outFb->parentheses); + CHECK_EQUAL(4, outFb->durationTimeTicks); + REQUIRE(outFb->figures.size() == 1); + CHECK_EQUAL("7", outFb->figures.at(0).figureNumber); +} + +T_END; + +TEST(figuredBassEmptyFiguresOmitted, FiguredBassApi) +{ + // A FiguredBassData carrying no figures must not produce a on output. The core + // figure list is never-empty (OneOrMore), so emitting one anyway would fabricate a stray empty + //
that reads back as a one-figure figured-bass -- a round-trip that invents content. + FiguredBassData fb{}; + + const auto score = makeScoreWithFiguredBass(fb); + const auto out = mxtest::roundTrip(score); + + CHECK(firstFiguredBass(out) == nullptr); +} + +T_END; + +#endif diff --git a/src/private/mxtest/api/HarmonyExtrasApiTest.cpp b/src/private/mxtest/api/HarmonyExtrasApiTest.cpp new file mode 100644 index 000000000..40135a495 --- /dev/null +++ b/src/private/mxtest/api/HarmonyExtrasApiTest.cpp @@ -0,0 +1,114 @@ +// 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/ScoreData.h" +#include "mxtest/api/RoundTrip.h" + +using namespace std; +using namespace mx::api; +using namespace mxtest; + +namespace +{ +// Builds a single-part, single-measure score whose first staff carries one harmony direction with a +// single default chord. Tests mutate that chord in place via chordOf() before round-tripping. +ScoreData makeScoreWithChord() +{ + ScoreData score; + score.ticksPerQuarter = 4; + score.parts.emplace_back(); + auto &part = score.parts.back(); + part.name = "P1"; + part.uniqueId = "P1"; + part.measures.emplace_back(); + auto &measure = part.measures.back(); + measure.staves.emplace_back(); + auto &staff = measure.staves.back(); + staff.directions.emplace_back(); + auto &direction = staff.directions.back(); + direction.tickTimePosition = 0; + direction.isStaffValueSpecified = true; + direction.chords.emplace_back(); + return score; +} + +ChordData &chordOf(ScoreData &score) +{ + return score.parts.front().measures.front().staves.front().directions.front().chords.front(); +} + +const ChordData &firstChord(const ScoreData &score) +{ + return score.parts.front().measures.front().staves.front().directions.front().chords.front(); +} +} // namespace + +TEST(harmonyInversionRoundTrip, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.root = Step::c; + chord.chordKind = ChordKind::major; + chord.hasInversion = true; + chord.inversion = 2; + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + CHECK(outChord.hasInversion); + CHECK_EQUAL(2, outChord.inversion); + CHECK(HarmonyChordSource::root == outChord.harmonyChordSource); + CHECK(Step::c == outChord.root); +} + +T_END; + +TEST(harmonyFunctionRoundTrip, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.harmonyChordSource = HarmonyChordSource::function; + chord.functionText = "V"; + chord.chordKind = ChordKind::major; + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + CHECK(HarmonyChordSource::function == outChord.harmonyChordSource); + CHECK_EQUAL("V", outChord.functionText); +} + +T_END; + +TEST(harmonyNumeralRoundTrip, HarmonyExtrasApi) +{ + auto score = makeScoreWithChord(); + auto &chord = chordOf(score); + chord.harmonyChordSource = HarmonyChordSource::numeral; + chord.numeralRoot = 5; + chord.numeralRootText = "V"; + chord.hasNumeralAlter = true; + chord.numeralAlter = 0; + chord.hasNumeralKey = true; + chord.numeralKeyFifths = -3; + chord.numeralMode = NumeralMode::minor; + chord.chordKind = ChordKind::major; + + const auto out = mxtest::roundTrip(score); + const auto &outChord = firstChord(out); + CHECK(HarmonyChordSource::numeral == outChord.harmonyChordSource); + CHECK_EQUAL(5, outChord.numeralRoot); + CHECK_EQUAL("V", outChord.numeralRootText); + CHECK(outChord.hasNumeralAlter); + CHECK_EQUAL(0, outChord.numeralAlter); + CHECK(outChord.hasNumeralKey); + CHECK_EQUAL(-3, outChord.numeralKeyFifths); + CHECK(NumeralMode::minor == outChord.numeralMode); +} + +T_END; + +#endif