Skip to content

Fix unnecessary notehead separation for unison notes of different durations#33206

Draft
julianz- wants to merge 1 commit intomusescore:masterfrom
julianz-:notehead-unison-fix
Draft

Fix unnecessary notehead separation for unison notes of different durations#33206
julianz- wants to merge 1 commit intomusescore:masterfrom
julianz-:notehead-unison-fix

Conversation

@julianz-
Copy link
Copy Markdown

@julianz- julianz- commented Apr 30, 2026

When two notes share the same pitch and spelling (unison), MuseScore was forcing them apart even when their noteheads could be shared. This change allows unison notes to share a notehead regardless of duration difference, hiding the shorter note's filled notehead in favour of the open one.

Dot differences are also no longer treated as a reason to separate unison notes, since the dot can survive independently while the noteheads are combined.

Notes with explicitly set notehead types are unaffected, providing a natural override for cases where separation is intentional.

  • I signed the CLA
  • The title of the PR describes the problem it addresses
  • Each commit's message describes its purpose and effects, and references the issue it resolves
  • If changes are extensive, there is a sequence of easily reviewable commits
  • The code in the PR follows the coding rules
  • There are no unnecessary changes
  • The code compiles and runs on my machine, preferably after each commit individually
  • I created a unit test or vtest to verify the changes I made (if applicable)

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@julianz- julianz- force-pushed the notehead-unison-fix branch from d82fe2b to f6f48d2 Compare April 30, 2026 09:15
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Unison notehead-sharing logic was relaxed: unisonMatch is computed from (pitch, tpc), so mismatched dot counts or notehead types no longer block sharing when pitches/TPCs match. When a true unison has differing resolved head types, any HEAD_QUARTER open head is hidden to prefer the open head. layoutChords2 now resets per-note hideNotehead during setup. TLayout::layoutNote forces the cached notehead symbol to SymId::noSym when a note’s hideNotehead() is true. The Note class gained hideNotehead()/setHideNotehead(bool).

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: allowing unison notes to share noteheads despite duration differences, which directly corresponds to the primary modifications in chordlayout.cpp and tlayout.cpp.
Description check ✅ Passed The description covers the main purpose and effects of the changes, explains the motivation, and includes completed checkboxes for most requirements. However, two checkboxes remain unchecked: extensive commit sequencing and unit/vtest creation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/engraving/rendering/score/chordlayout.cpp`:
- Around line 1995-2017: The hunk's brace/blank-line formatting violates the
project's uncrustify style and is causing the codestyle job to fail; run the
repository's uncrustify/codestyle formatter to normalize spacing or manually fix
the blank-line and brace placement in this block so it matches the project's
style. Focus on the conditional block around variables and symbols like
unisonMatch, nHeadType, pHeadType, nchord->dots(), pchord->dots(), shareHeads,
and the subsequent NoteHeadType::HEAD_QUARTER / setHidden branches so braces and
blank lines follow the project's formatter output.
- Around line 1996-2017: The unison-sharing logic currently hides a quarter
notehead based only on nHeadType != pHeadType and unisonMatch, which
accidentally overrides explicit notehead choices when one side is auto-derived;
update the branch that handles unison head-type conflicts (the else if with
condition "nHeadType != pHeadType && unisonMatch") to skip hiding when exactly
one side is auto (i.e., preserve explicit overrides). Concretely, only perform
the existing setHidden(true) logic on n or p when both notes have headType() ==
NoteHeadType::HEAD_AUTO or when both are explicitly set (both headType() !=
HEAD_AUTO); if one side is auto and the other explicit, do not hide either head
— this change touches the unisonMatch check, the nHeadType/pHeadType comparison,
and the calls to n->headType(), p->headType(), n->setHidden(), p->setHidden().

In `@src/engraving/rendering/score/tlayout.cpp`:
- Line 4150: The else-if condition using NoteHeadType::HEAD_QUARTER contains a
spacing typo around the item->hidden() call; update the condition in tlayout.cpp
(the else if that checks NoteHeadType::HEAD_QUARTER && item->hidden()) to remove
the extra space before the closing parenthesis so it reads with correct
parenthesis spacing (e.g., item->hidden()) and matches surrounding codestyle for
conditional spacing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7b0f2504-9bac-45d9-904c-c3c1a4394450

📥 Commits

Reviewing files that changed from the base of the PR and between d469bbc and d82fe2b.

📒 Files selected for processing (2)
  • src/engraving/rendering/score/chordlayout.cpp
  • src/engraving/rendering/score/tlayout.cpp

Comment on lines +1995 to +2017

// check if the notes are unison
bool unisonMatch = (n->pitch() == p->pitch() && n->tpc() == p->tpc());

// for unisons we allow half noteheads to merge even with different durations,
// as the stem, flag, or beam makes the duration clear.
// dotted notes can also merge with non-dotted notes at unison.
if (((!unisonMatch && nchord->dots() != pchord->dots()) || !nchord->stem() || !pchord->stem()
|| (nHeadType != pHeadType && !unisonMatch)
|| n->isSmall() || p->isSmall())
&& ((n->headType() == NoteHeadType::HEAD_AUTO && p->headType() == NoteHeadType::HEAD_AUTO)
|| nHeadType != pHeadType)
&& (n->visible() == p->visible())) {
shareHeads = false;
} else if (nHeadType != pHeadType && unisonMatch) {
// ensure if a unison includes an open notehead this is shown in
// preference to a filled notehead
if (nHeadType == NoteHeadType::HEAD_QUARTER) {
n->setHidden(true);
}
else if (pHeadType == NoteHeadType::HEAD_QUARTER) {
p->setHidden(true);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Run uncrustify on this hunk before merge.

The codestyle job is currently failing on this block, so the PR will stay red until the blank-line/brace formatting here is normalized. Running tools/codestyle/uncrustify_run_file.sh on this file should fix it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/engraving/rendering/score/chordlayout.cpp` around lines 1995 - 2017, The
hunk's brace/blank-line formatting violates the project's uncrustify style and
is causing the codestyle job to fail; run the repository's uncrustify/codestyle
formatter to normalize spacing or manually fix the blank-line and brace
placement in this block so it matches the project's style. Focus on the
conditional block around variables and symbols like unisonMatch, nHeadType,
pHeadType, nchord->dots(), pchord->dots(), shareHeads, and the subsequent
NoteHeadType::HEAD_QUARTER / setHidden branches so braces and blank lines follow
the project's formatter output.

Comment thread src/engraving/rendering/score/chordlayout.cpp
Comment thread src/engraving/rendering/score/tlayout.cpp Outdated
@julianz- julianz- force-pushed the notehead-unison-fix branch 2 times, most recently from f9f1cd5 to 2dd9251 Compare April 30, 2026 10:01
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/engraving/rendering/score/chordlayout.cpp`:
- Around line 2009-2016: The current logic in the chord-layout branch (when
nHeadType != pHeadType && unisonMatch) uses n->setHidden(true) /
p->setHidden(true) which hides the whole Note (Note::setHidden) and suppresses
rendering/MIDI/voice behavior; change this to suppress only the notehead
glyph/symbol instead of the entire Note: replace the n->setHidden(true) and
p->setHidden(true) calls with a notehead-only suppression API (e.g. call
Note::setHeadHidden(true) or Note::setNoteheadVisible(false) if such exists), or
if no API exists add a lightweight Note::setHeadHidden(bool) (and corresponding
head-only render check) so the quarter head is not drawn but the Note remains
active for rendering paths and MIDI overlap handling; keep the same conditional
logic around nHeadType/pHeadType/unisonMatch and update render code to respect
the new head-only flag.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c9f0607c-3433-4a76-aeb4-e0e5fd069877

📥 Commits

Reviewing files that changed from the base of the PR and between f9f1cd5 and 2dd9251.

📒 Files selected for processing (2)
  • src/engraving/rendering/score/chordlayout.cpp
  • src/engraving/rendering/score/tlayout.cpp

Comment thread src/engraving/rendering/score/chordlayout.cpp
…ations

When two notes share the same pitch and spelling (unison), MuseScore was
forcing them apart even when their noteheads could be shared. This change
allows unison notes to share a notehead regardless of duration difference,
hiding the shorter note's filled notehead in favour of the open one.

Dot differences are also no longer treated as a reason to separate unison
notes, since the dot can survive independently while the noteheads are
combined.

Notes with explicitly set notehead types are unaffected, providing a
natural override for cases where separation is intentional.
@julianz- julianz- force-pushed the notehead-unison-fix branch from 2dd9251 to 35780ed Compare April 30, 2026 10:55
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/engraving/rendering/score/chordlayout.cpp (1)

2002-2016: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Explicit notehead overrides can still be auto-merged in unisons.

At Line 2009, the quarter-head hiding branch still runs for unison mismatches even when one side is explicit and the other is auto-derived. That can override intentional separation. Restrict this path to auto/auto pairs (and same-visibility pairs) before applying setHideNotehead(true).

Suggested fix
+                    const bool bothAutoHeadTypes
+                        = n->headType() == NoteHeadType::HEAD_AUTO
+                          && p->headType() == NoteHeadType::HEAD_AUTO;
+                    const bool sameVisibility = (n->visible() == p->visible());
+
                     if (((!unisonMatch && nchord->dots() != pchord->dots()) || !nchord->stem() || !pchord->stem()
-                         || (nHeadType != pHeadType && !unisonMatch)
+                         || (nHeadType != pHeadType && (!unisonMatch || !bothAutoHeadTypes))
                          || n->isSmall() || p->isSmall())
-                        && ((n->headType() == NoteHeadType::HEAD_AUTO && p->headType() == NoteHeadType::HEAD_AUTO)
+                        && (bothAutoHeadTypes
                             || nHeadType != pHeadType)
-                        && (n->visible() == p->visible())) {
+                        && sameVisibility) {
                         shareHeads = false;
-                    } else if (nHeadType != pHeadType && unisonMatch) {
+                    } else if (bothAutoHeadTypes && sameVisibility && nHeadType != pHeadType && unisonMatch) {
                         if (nHeadType == NoteHeadType::HEAD_QUARTER) {
                             n->setHideNotehead(true);
                         } else if (pHeadType == NoteHeadType::HEAD_QUARTER) {
                             p->setHideNotehead(true);
                         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/engraving/rendering/score/chordlayout.cpp` around lines 2002 - 2016, The
unison branch that hides quarter noteheads can override explicit notehead
choices; update the conditional before calling n->setHideNotehead(true) /
p->setHideNotehead(true) in the chord-layout logic so it only runs when both
notes are auto-derived and have matching visibility — i.e., add checks that
n->headType() == NoteHeadType::HEAD_AUTO && p->headType() ==
NoteHeadType::HEAD_AUTO (or equivalent stored booleans) and n->visible() ==
p->visible() in the else-if (unisonMatch && nHeadType != pHeadType) path so
explicit/auto pairs are not auto-merged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/engraving/rendering/score/chordlayout.cpp`:
- Around line 2002-2016: The unison branch that hides quarter noteheads can
override explicit notehead choices; update the conditional before calling
n->setHideNotehead(true) / p->setHideNotehead(true) in the chord-layout logic so
it only runs when both notes are auto-derived and have matching visibility —
i.e., add checks that n->headType() == NoteHeadType::HEAD_AUTO && p->headType()
== NoteHeadType::HEAD_AUTO (or equivalent stored booleans) and n->visible() ==
p->visible() in the else-if (unisonMatch && nHeadType != pHeadType) path so
explicit/auto pairs are not auto-merged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 46f26657-3513-4c0a-a780-5d1b861e7125

📥 Commits

Reviewing files that changed from the base of the PR and between 2dd9251 and 35780ed.

📒 Files selected for processing (3)
  • src/engraving/dom/note.h
  • src/engraving/rendering/score/chordlayout.cpp
  • src/engraving/rendering/score/tlayout.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/engraving/rendering/score/tlayout.cpp

@MarcSabatella
Copy link
Copy Markdown
Contributor

Hmm, I have some major reservations with this.

First, it's already completely possible to force the non-matching noteheads to overlap - just make the shorter notehead invisible, or set its notehead explicitly to match the longer. And that's exactly what people have been doing for years when they want to achieve this result. So what we're really talking about here is changing the default, not enabling something that was never possible before. And changing it such that the method that formerly forced noteheads to merge now forces the opposite.

So, second, if we're going to change the default - and the behavior of the existing override - we need to handle this in a way that preserves compatibility for all the existing scores that rely on the existing behavior (both the existing default and the existing override). So, yes, it definitely needs to be an option, and there needs to be handling to make sure older scores are not adversely affected.

Third, I am not at all convinced it makes sense to change the default in the first place. Yes, obviously there do exist cases where sharing the notehead makes sense. But separate noteheads for filled and open notes - and for dotted and undotted notes - are absolutely required in many cases, such as vocal music with separate parts reading off the same staff, or piano or guitar music that is inherently contrapuntal and needs to show the independent lines. It's really only a few pretty special cases - e.g., piano/guitar/harp music with repeated patterns of arpeggios over sustained bass notes - where it's appropriate to merge them. If we're going to change the default to make those cases work more automatically, we could limit it to piano/guitar/harp, or least exclude vocal staves from the equation. Or making it a staff and not a style setting. We could also consider limiting it to cases where the filled notes are eighth notes or shorter - it would be less common to want to share quarters and halves. Without some additional refinement of the algorithm to not always merge non-matching durations, I suspect this will actually create more work for users overall.

But mostly, this strikes me as an unnecessary change even if all these precautionary steps were taken.

@julianz-
Copy link
Copy Markdown
Author

julianz- commented Apr 30, 2026

@MarcSabatella Thanks for insightful comments. I was not aware of the trick for rendering unisons by making one note invisible. I would say this is not a very intuitive solution though as I would assume by making one note invisible all traces of it would disappear whereas what I would like is for the two notes to merge in some controllable way. But there's another potentially bigger problem and that's with representing the result in musicxml. My idea for this fix came from loading musicxml from this dataset: https://github.com/fosfrancesco/asap-dataset/. If you look at the copy of Chopin's Ballade No 1 in G minor, measure 8 was something of a disaster with multiple notation errors:

Screenshot 2026-04-30 at 9 34 30 AM

Some of this came from various performance hacks the dataset owner had applied which I was not interested in. I tried to fix the xml so that the notation would look more like how this part is typically printed and found that I could not achieve a good result. My fix enables me to load from musicxml this version (which is close to how this phrase is printed in the Polish Instytut Fryderyka Chopina edition):

Screenshot 2026-04-30 at 9 20 33 AM

I just tried using your solution with the invisible notes and was able to achieve the same thing but when saving to musicxml and then reloading from musicxml, the empty notehead for the eighth note C is filled in.

Screenshot 2026-04-30 at 9 19 55 AM

Looking at the musicxml, I see invisibility is not encoded so unless I am missing something your solution does not work for roundtripping to musicxml and back?

Nevertheless, I do take your point about backward compatibility. Perhaps this should not be a default but rather a note property or style option that can be explicitly set. So unless there is another way to fix the musicxml representation issue, I'd be happy to think about a revision that avoided making this the default. Let me know your thoughts.

@MarcSabatella
Copy link
Copy Markdown
Contributor

The override to merge the notes is to make the note head invisible. If you make the entire note invisible, that does indeed hide all trace of it. Or, as I said, explicitly override the visual duration - the exact same method you were proposing to use. It's true that no matter which way you make the default, it's not obvious how to override it, so adding a simple checkbox to the Properties panel would be another possible solution. Or just be sure the app[roriate Handbook section is clearly written.

MusicXML is not a perfect representation of the score, which is why we don't say you can "save" to it - only "export". but visibility is definitely encoded. if it weren't, you wouldn't see the notes merged in your example. I think you are just being confused by the fact that be defualt, invisible elements do still display on screen as greyed out so they can be easily selected and edited. But you can turn off via the View menu. And if you use the other method I suggested - overriding the visual duration of the shorter note to match the longer - that also gets exported to the MusicXML and then reimported correctly.

In any case, I think you should think about these considerations and then if you have a proposal for how you think things can be improved, open a new feature request issue with your proposal. And then see what kind of feedback it gets from others, including the design team, before worrying about attempting to implement it.

@MarcSabatella
Copy link
Copy Markdown
Contributor

MarcSabatella commented Apr 30, 2026

For the record, this is how the invisible notehead method gets exported as MusicXML:

      <note default-x="80.21" default-y="-15">
        <pitch>
          <step>C</step>
          <octave>5</octave>
          </pitch>
        <duration>1</duration>
        <voice>1</voice>
        <type>eighth</type>
        <stem>up</stem>
        <notehead>none</notehead>
        <beam number="1">begin</beam>
        </note>

And here is how the explicit visual duration overrriude is encoded:

      <note default-x="185" default-y="-15">
        <pitch>
          <step>C</step>
          <octave>5</octave>
          </pitch>
        <duration>1</duration>
        <voice>1</voice>
        <type>eighth</type>
        <stem>up</stem>
        <notehead filled="no">normal</notehead>
        <beam number="1">begin</beam>
        </note>

In both cases, it's the "notehead" tag that is used.

@julianz-
Copy link
Copy Markdown
Author

julianz- commented Apr 30, 2026

Ok if use <notehead>none</notehead> on the C 8th note in my Chopin example, then I would not expect it to darken the half note C but it does. Is that the intended behavior or a bug?

If I use <notehead filled="no">normal</notehead> then I can achieve Chopin's desired notation so that is good to know. I know MusicXML is not always expressive enough but in this case it is but, ironically, I was not able to achieve the desired notation using the UI. I got close until I turned off invisible elements and then voilà!

Screenshot 2026-04-30 at 1 12 43 PM

In the end I had to fix by manually editing the XML. It may be that I am not using the UI correctly but on the example above the UI is grayed out for changing the notehead for both notes in the unison. So my feeling is there is a bug in the UI too. Either that or the UI is causing a lot of confusion. It shouldn't be that hard to fix this kind of thing.

Update: in the end I could fix the above example in the UI by playing with the visibility of the notes. Changing visibility on the noteheads did not appear to work properly though.

@julianz- julianz- marked this pull request as draft April 30, 2026 21:12
@MarcSabatella
Copy link
Copy Markdown
Contributor

As I said, invisible objects are shown as gray on screen by defaulty, so yes, it's correct that if your MusicXML calls for an object to be rendered as invisible, you'll see it gray onscreen. And yes, it's correct that the option to hide the notehead in the UI does the same thing. As I explained, that is to make it possible to select and edit the invisible object later. The grayed-out object t won't export or print that way, though - it really is treated as invisible. And again, if you wish to make the invisible object completely invisible even in the on-screen preview, just turn off the option to show invisible elements in the View menu. All of this works exactly as intended.

If you expect the noteheads to be merged, then the MusicXML file you have is incorrect and you should indeed consider fixing it there. Or fix it in the UI if you prefer. Either way, the program is working as intended, and even to the extent it might be possible to improve the defaults or the discoverability of the existing overrides, I don't think this PR is an appropriate way to achieve either goal.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs design Design is needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants