musescore / musescore/MuseScore

MusicXML import: `<note dynamics="0">` is silently dropped — a zero-velocity (silent) note plays at full volume

Open
#33,803 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
C++
Stars
15.1k
Forks
3.3k
Avg merge
2d 2h
Merged PRs (30d)
91

Description

Describe the bug

MusicXML defines a dynamics attribute on the <note> element:

The dynamics attribute corresponds to MIDI 1.0's Note On velocity. It is expressed in terms of percentages of the default forte value (90 for MIDI 1.0).

So <note dynamics="0"> unambiguously means "this note sounds at zero velocity" — the natural way to notate a visible but silent note (ghost notes, unpitched gestures written with an x notehead, educational scores, etc.).

The importer handles this attribute correctly for positive values: dynamics="50" imports as <velocity>45</velocity> on the Note, and playback/MIDI export honour it (verified). But dynamics="0" is dropped entirely, and the note plays at full default velocity — the one value for which the attribute expresses something that cannot be approximated by anything else in the file.

Root cause analysis

In MusicXmlParserPass2::note() the attribute is read into an int:

int velocity = round(m_e.doubleAttribute("dynamics") * 0.9);

(importmusicxmlpass2.cpp#L6932)

doubleAttribute() returns 0.0 when the attribute is absent, so 0 doubles as the "no attribute" sentinel, and the value is only applied when positive:

if (velocity > 0) {
    note->setUserVelocity(velocity);
}

(importmusicxmlpass2.cpp#L7263-L7265)

An explicit dynamics="0" is therefore indistinguishable from "attribute not present" and is discarded.

There is a second wrinkle: the engraving model itself reserves userVelocity == 0 for "unset" — both playback paths skip the override when it is zero (playbackmodel.cpp#L331, compatmidirenderinternal.cpp#L227), and the MusicXML exporter likewise only writes the dynamics attribute for non-zero velocities (exportmusicxml.cpp#L4360-L4363). So even a fixed importer cannot map dynamics="0" to velocity 0; the closest representable value is velocity 1, which is effectively silent (verified: a hand-edited <velocity>1</velocity> in the .mscx exports MIDI Note On velocity 1).

Suggested fix: in note(), distinguish "attribute absent" from "attribute present with value 0" (e.g. test the raw attribute string for emptiness instead of testing the computed value), and clamp the computed velocity into the representable range:

const String dynamicsAttr = m_e.attribute("dynamics");
...
if (!dynamicsAttr.empty()) {
    note->setUserVelocity(std::clamp(velocity, 1, 127));
}

The lower bound of 1 is the smallest value representable in the score model (0 means "unset"); the upper bound mirrors the [0, 127] clamping the importer already applies to direction-level <sound dynamics> values (importmusicxmlpass2.cpp#L3729-L3737) — the current note-level code stores e.g. dynamics="200" as velocity 180, above the MIDI maximum. The exporter would then round-trip dynamics="0" as dynamics="1.11" (velocity 1), which is acceptable; exact 0 round-tripping would require the model to distinguish "unset" from "zero", which seems out of scope.

To Reproduce

Minimal test case (one measure, four quarter notes; validates against the MusicXML 4.0 schema):

<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<score-partwise version="4.0">
  <movement-title>Muted note playback test</movement-title>
  <part-list>
    <score-part id="P1">
      <part-name>Voice</part-name>
    </score-part>
  </part-list>
  <part id="P1">
    <measure number="1">
      <attributes>
        <divisions>480</divisions>
        <key><fifths>0</fifths></key>
        <time><beats>4</beats><beat-type>4</beat-type></time>
        <clef><sign>G</sign><line>2</line></clef>
      </attributes>
      <note>
        <pitch><step>C</step><octave>4</octave></pitch>
        <duration>480</duration>
        <voice>1</voice>
        <type>quarter</type>
      </note>
      <note dynamics="0">
        <pitch><step>D</step><octave>4</octave></pitch>
        <duration>480</duration>
        <voice>1</voice>
        <type>quarter</type>
        <notehead>x</notehead>
      </note>
      <note dynamics="50">
        <pitch><step>E</step><octave>4</octave></pitch>
        <duration>480</duration>
        <voice>1</voice>
        <type>quarter</type>
      </note>
      <note>
        <pitch><step>F</step><octave>4</octave></pitch>
        <duration>480</duration>
        <voice>1</voice>
        <type>quarter</type>
      </note>
    </measure>
  </part>
</score-partwise>
  1. Convert to MIDI: MuseScore4 -o out.mid test.musicxml
  2. Inspect the Note On velocities (midicsv):
Note MusicXML Imported velocity MIDI velocity
C4 (none) (unset) 80 ✓ control
D4 dynamics="0" (unset — dropped) 80 ✗ should be silent
E4 dynamics="50" 45 45 ✓ works
F4 (none) (unset) 80 ✓ control

Converting to .mscx confirms the model state: the E4 note carries <velocity>45</velocity>, the D4 note carries nothing.

Expected behavior

<note dynamics="0"> should import as a silent note (velocity 1, the lowest value representable in the score model), consistent with how all positive dynamics values are already imported.

Platform information

  • OS: Windows 11
  • MuseScore version: reproduced on MuseScore Studio 4.7.2 (release); code analysis against current master (d53a8edc98). Unlike #33801 this is not a regression — the velocity > 0 guard has been there since the attribute was first supported.
  • Reproduced via command-line conversion

Additional context

Found while writing a NoteWorthy Composer → MusicXML converter. NWC has note-level muting (Opts:Muted), commonly used for percussive gestures written with x noteheads. Without an importable silent-note representation those must be converted to rests, losing the notation; dynamics="0" is the spec-backed way to keep both notation and playback faithful.

(Deliberately out of scope: <note><play><mute>on</mute></play> should not silence a note — mute is a playing-technique indication (straight mute, cup mute, …) and a technique-muted instrument still sounds. The dynamics attribute is the unambiguous vehicle for note-level loudness including silence.)

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in src/importexport/musicxml/internal/import/importmusicxmlpass2.cpp, especially MusicXmlParserPass2::note() at the referenced attribute-read and velocity-application locations. Run the supplied MusicXML through MuseScore4 -o out.mid test.musicxml and inspect the resulting velocities with midicsv. Done means an explicit dynamics="0" note is imported at velocity 1 while absent and positive dynamics values retain their current behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
cpp
Domain
backend
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.