Skip to content

Commit 80f1fe3

Browse files
jonashogstromclaude
andcommitted
Honour an explicit dynamics="0" on MusicXML note import
The dynamics attribute of the <note> element expresses the note velocity as a percentage of the MIDI 1.0 default forte value of 90. Positive values were imported correctly, but an explicit dynamics="0" (a silent note, e.g. a ghost note or an unpitched gesture with an x notehead) was indistinguishable from an absent attribute and silently dropped, so the note played at full default velocity. Detect the attribute's presence instead of testing the computed value, and clamp the velocity to [1, 127]: the score model reserves velocity 0 for "unset", so 1 is the lowest representable (effectively silent) value, and the upper bound matches the clamping already applied to direction-level <sound dynamics> values. Resolves: #33803 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent d53a8ed commit 80f1fe3

3 files changed

Lines changed: 111 additions & 2 deletions

File tree

src/importexport/musicxml/internal/import/importmusicxmlpass2.cpp

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6929,7 +6929,11 @@ Note* MusicXmlParserPass2::note(const String& partId,
69296929
Color beamColor;
69306930
bool noteheadParentheses = false;
69316931
String noteheadFilled;
6932-
int velocity = round(m_e.doubleAttribute("dynamics") * 0.9);
6932+
// velocity as a percentage of the MIDI 1.0 default forte value of 90;
6933+
// an explicit dynamics="0" means a silent note, which the score model can
6934+
// only represent as velocity 1 (velocity 0 means "unset")
6935+
const bool hasDynamics = m_e.hasAttribute("dynamics");
6936+
const int velocity = std::clamp(int(round(m_e.doubleAttribute("dynamics") * 0.9)), 1, 127);
69336937
bool graceSlash = false;
69346938
bool printObject = m_e.asciiAttribute("print-object") != "no";
69356939
bool printLyric = (printObject && m_e.asciiAttribute("print-lyric") != "no") || m_e.asciiAttribute("print-lyric") == "yes";
@@ -7260,7 +7264,7 @@ Note* MusicXmlParserPass2::note(const String& partId,
72607264
}
72617265
}
72627266

7263-
if (velocity > 0) {
7267+
if (hasDynamics) {
72647268
note->setUserVelocity(velocity);
72657269
}
72667270

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2+
<score-partwise version="4.0">
3+
<movement-title>Note dynamics attribute</movement-title>
4+
<part-list>
5+
<score-part id="P1">
6+
<part-name>Voice</part-name>
7+
</score-part>
8+
</part-list>
9+
<part id="P1">
10+
<measure number="1">
11+
<attributes>
12+
<divisions>480</divisions>
13+
<key>
14+
<fifths>0</fifths>
15+
</key>
16+
<time>
17+
<beats>5</beats>
18+
<beat-type>4</beat-type>
19+
</time>
20+
<clef>
21+
<sign>G</sign>
22+
<line>2</line>
23+
</clef>
24+
</attributes>
25+
<note>
26+
<pitch>
27+
<step>C</step>
28+
<octave>4</octave>
29+
</pitch>
30+
<duration>480</duration>
31+
<voice>1</voice>
32+
<type>quarter</type>
33+
</note>
34+
<note dynamics="0">
35+
<pitch>
36+
<step>D</step>
37+
<octave>4</octave>
38+
</pitch>
39+
<duration>480</duration>
40+
<voice>1</voice>
41+
<type>quarter</type>
42+
<notehead>x</notehead>
43+
</note>
44+
<note dynamics="50">
45+
<pitch>
46+
<step>E</step>
47+
<octave>4</octave>
48+
</pitch>
49+
<duration>480</duration>
50+
<voice>1</voice>
51+
<type>quarter</type>
52+
</note>
53+
<note dynamics="200">
54+
<pitch>
55+
<step>F</step>
56+
<octave>4</octave>
57+
</pitch>
58+
<duration>480</duration>
59+
<voice>1</voice>
60+
<type>quarter</type>
61+
</note>
62+
<note>
63+
<pitch>
64+
<step>G</step>
65+
<octave>4</octave>
66+
</pitch>
67+
<duration>480</duration>
68+
<voice>1</voice>
69+
<type>quarter</type>
70+
</note>
71+
</measure>
72+
</part>
73+
</score-partwise>

src/importexport/musicxml/tests/musicxml_tests.cpp

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@
2323
#include <gtest/gtest.h>
2424

2525
#include "engraving/engravingerrors.h"
26+
#include "engraving/dom/chord.h"
2627
#include "engraving/dom/masterscore.h"
28+
#include "engraving/dom/measure.h"
29+
#include "engraving/dom/note.h"
30+
#include "engraving/dom/segment.h"
2731

2832
#include "settings.h"
2933
#include "importexport/musicxml/imusicxmlconfiguration.h"
@@ -1024,6 +1028,34 @@ TEST_F(MusicXml_Tests, noteAttributes3) {
10241028
TEST_F(MusicXml_Tests, noteColor) {
10251029
musicXmlIoTest("testNoteColor");
10261030
}
1031+
TEST_F(MusicXml_Tests, noteDynamics) {
1032+
// The dynamics attribute of <note> maps to the note's velocity as a
1033+
// percentage of the MIDI 1.0 default forte value of 90. An explicit
1034+
// dynamics="0" (a silent note) must not be confused with an absent
1035+
// attribute; since velocity 0 means "unset" in the score model, the
1036+
// imported value is clamped to [1, 127].
1037+
MScore::debugMode = true;
1038+
1039+
MasterScore* score = readScore(XML_IO_DATA_DIR + u"testNoteDynamics.xml");
1040+
ASSERT_TRUE(score);
1041+
1042+
std::vector<int> velocities;
1043+
for (const Segment* s = score->firstMeasure()->first(SegmentType::ChordRest); s; s = s->next1(SegmentType::ChordRest)) {
1044+
const EngravingItem* el = s->element(0);
1045+
if (el && el->isChord()) {
1046+
velocities.push_back(toChord(el)->notes().front()->userVelocity());
1047+
}
1048+
}
1049+
1050+
ASSERT_EQ(velocities.size(), 5u);
1051+
EXPECT_EQ(velocities.at(0), 0); // no dynamics attribute: unset
1052+
EXPECT_EQ(velocities.at(1), 1); // dynamics="0": silent, clamped to the lowest representable value
1053+
EXPECT_EQ(velocities.at(2), 45); // dynamics="50": 50% of forte (90)
1054+
EXPECT_EQ(velocities.at(3), 127); // dynamics="200": clamped to the MIDI maximum
1055+
EXPECT_EQ(velocities.at(4), 0); // no dynamics attribute: unset
1056+
1057+
delete score;
1058+
}
10271059
TEST_F(MusicXml_Tests, noteheadNames) {
10281060
musicXmlIoTest("testNoteheadNames");
10291061
}

0 commit comments

Comments
 (0)