Skip to content

Refactor Implode/Explode and Exchange voices#32850

Open
cbjeukendrup wants to merge 5 commits intomusescore:masterfrom
cbjeukendrup:exchange-voices-implode-explode-cleanup
Open

Refactor Implode/Explode and Exchange voices#32850
cbjeukendrup wants to merge 5 commits intomusescore:masterfrom
cbjeukendrup:exchange-voices-implode-explode-cleanup

Conversation

@cbjeukendrup
Copy link
Copy Markdown
Collaborator

@cbjeukendrup cbjeukendrup commented Mar 30, 2026

  • Move from dom to editing folder
  • Simplify undoability, possibly fixing subtle bugs -> most importantly, don’t make CloneVoice an UndoCommand, because all things that it calls are already UndoCommands.

See the commit messages for details.

There should be no changes in behaviour, except that some undo bugs might be resolved.

Summary by CodeRabbit

Release Notes

  • Refactor
    • Internal reorganization of voice editing operations for improved code structure and maintainability. Functionality for exchanging voices, exploding, and imploding staves remains unchanged.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 30, 2026

📝 Walkthrough

Walkthrough

The changes refactor voice editing operations from Score class methods into three dedicated utility classes: ExchangeVoices, CloneVoice, and ImplodeExplode. The legacy editvoicing.cpp/h implementation is removed, and callers are updated to use the new static APIs. This consolidates voice editing logic into focused, specialized components.

Changes

Cohort / File(s) Summary
Voice Editing Utility Classes - New Implementations
src/engraving/editing/clonevoice.cpp, src/engraving/editing/exchangevoices.cpp, src/engraving/editing/implodeexplode.cpp
New implementations for voice cloning, voice exchange in selections, and explode/implode operations. These provide static utility methods to perform complex voice/staff manipulations including segment/element cloning, tuplet/beam/tie/spanner recreation, and voice content swapping with undo support.
Voice Editing Utility Classes - Headers
src/engraving/editing/clonevoice.h, src/engraving/editing/exchangevoices.h, src/engraving/editing/implodeexplode.h
New public headers declaring static API methods for voice cloning, voice exchange, and explode/implode operations with forward declarations and type includes.
Removed Legacy Voice Editing
src/engraving/editing/editvoicing.cpp, src/engraving/editing/editvoicing.h
Deleted the old ExchangeVoice and CloneVoice undo command classes that previously encapsulated voice editing operations.
Score API Removal
src/engraving/dom/score.h, src/engraving/dom/score.cpp (via edit.cpp, cmd.cpp)
Removed public methods: cmdExchangeVoice(), cmdExplode(), cmdImplode(), cloneVoice(), and undoExchangeVoice(). Implementations deleted from src/engraving/editing/edit.cpp and src/engraving/editing/cmd.cpp.
Measure API Removal
src/engraving/dom/measure.h, src/engraving/dom/measure.cpp
Removed the Measure::exchangeVoice() method that previously performed low-level voice track swapping and spanner adjustment within a measure.
Build Configuration & Command Types
src/engraving/CMakeLists.txt, src/engraving/editing/undo.h
Updated source list to replace editvoicing.{cpp,h} with clonevoice.{cpp,h}, exchangevoices.{cpp,h}, and implodeexplode.{cpp,h}. Removed CommandType::CloneVoice enum value.
Caller Updates - Tests
src/engraving/tests/exchangevoices_tests.cpp, src/engraving/tests/implodeexplode_tests.cpp
Updated test calls from Score::cmdExchangeVoice() to ExchangeVoices::exchangeVoicesInSelection(), and from Score::cmdExplode()/cmdImplode() to ImplodeExplode::explode()/implode().
Caller Updates - Notation UI
src/notation/internal/notationinteraction.cpp
Updated voice swap, explode, and implode methods to call the new utility class static methods instead of Score command methods.
Braille Voice Exchange Update
src/braille/internal/braille.cpp
Replaced two calls to m_score->cmdExchangeVoice() with ExchangeVoices::exchangeVoicesInSelection() in measure processing logic.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description explains the main objectives (moving code, simplifying undo) but lacks required template elements like issue reference, CLA confirmation, and commit message details. Complete the description template by adding issue reference (Resolves: #NNNNN), CLA checkbox, and confirming that commits follow the checklist items.
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 (1 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main refactoring: moving implode/explode and exchange-voices code and simplifying undo handling.

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

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

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: 9


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 9dcabea7-5c1e-444f-84ac-4c20edbbf09c

📥 Commits

Reviewing files that changed from the base of the PR and between 0388b15 and 74f97bf.

📒 Files selected for processing (19)
  • src/braille/internal/braille.cpp
  • src/engraving/CMakeLists.txt
  • src/engraving/dom/measure.cpp
  • src/engraving/dom/measure.h
  • src/engraving/dom/score.h
  • src/engraving/editing/clonevoice.cpp
  • src/engraving/editing/clonevoice.h
  • src/engraving/editing/cmd.cpp
  • src/engraving/editing/edit.cpp
  • src/engraving/editing/editvoicing.cpp
  • src/engraving/editing/editvoicing.h
  • src/engraving/editing/exchangevoices.cpp
  • src/engraving/editing/exchangevoices.h
  • src/engraving/editing/implodeexplode.cpp
  • src/engraving/editing/implodeexplode.h
  • src/engraving/editing/undo.h
  • src/engraving/tests/exchangevoices_tests.cpp
  • src/engraving/tests/implodeexplode_tests.cpp
  • src/notation/internal/notationinteraction.cpp
💤 Files with no reviewable changes (8)
  • src/engraving/dom/measure.h
  • src/engraving/editing/undo.h
  • src/engraving/dom/measure.cpp
  • src/engraving/editing/cmd.cpp
  • src/engraving/editing/editvoicing.h
  • src/engraving/dom/score.h
  • src/engraving/editing/editvoicing.cpp
  • src/engraving/editing/edit.cpp

Comment on lines +70 to +126
if (oe && !oe->generated() && oe->isChordRest()) {
ChordRest* ocr = toChordRest(oe);
ChordRest* ncr = toChordRest(maybeLinkedClone(ocr));
ncr->setScore(destScore);
ncr->setTrack(dstTrack);

//Don't clone gaps to a first voice
if (!(ncr->track() % VOICES) && ncr->isRest()) {
toRest(ncr)->setGap(false);
}

//Handle beams
if (ocr->beam() && !ocr->beam()->empty() && ocr->beam()->elements().front() == ocr) {
Beam* nb = ocr->beam()->clone();
nb->clear();
nb->setTrack(dstTrack);
nb->setScore(destScore);
nb->add(ncr);
ncr->setBeam(nb);
}

// clone Tuplets
Tuplet* ot = ocr->tuplet();
if (ot) {
ot->setTrack(srcTrack);
Tuplet* nt = tupletMap.findNew(ot);
if (nt == 0) {
nt = toTuplet(maybeLinkedClone(ot));
nt->setTrack(dstTrack);
nt->setParent(dm);
tupletMap.add(ot, nt);

Tuplet* nt1 = nt;
while (ot->tuplet()) {
Tuplet* nt2 = tupletMap.findNew(ot->tuplet());
if (nt2 == 0) {
nt2 = toTuplet(maybeLinkedClone(ot->tuplet()));
nt2->setTrack(dstTrack);
nt2->setParent(dm);
tupletMap.add(ot->tuplet(), nt2);
}
nt2->add(nt1);
nt1->setTuplet(nt2);
ot = ot->tuplet();
nt1 = nt2;
}
}
nt->add(ncr);
ncr->setTuplet(nt);
}

// clone additional settings
if (oe->isRest()) {
Rest* ore = toRest(ocr);
// If we would clone a full measure rest just don't clone this rest
if (ore->isFullMeasureRest() && (dstTrack % VOICES)) {
continue;
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

Skip full-measure rests before cloning.

The continue on Line 126 runs after maybeLinkedClone(ocr), so every skipped whole-measure rest in a secondary voice leaves an unattached clone behind. Move that guard ahead of the clone, or delete ncr before continuing.

Suggested fix
         if (oe && !oe->generated() && oe->isChordRest()) {
             ChordRest* ocr = toChordRest(oe);
+            if (ocr->isRest() && toRest(ocr)->isFullMeasureRest() && (dstTrack % VOICES)) {
+                continue;
+            }
+
             ChordRest* ncr = toChordRest(maybeLinkedClone(ocr));
             ncr->setScore(destScore);
             ncr->setTrack(dstTrack);
@@
-            // clone additional settings
-            if (oe->isRest()) {
-                Rest* ore = toRest(ocr);
-                // If we would clone a full measure rest just don't clone this rest
-                if (ore->isFullMeasureRest() && (dstTrack % VOICES)) {
-                    continue;
-                }
-            }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (oe && !oe->generated() && oe->isChordRest()) {
ChordRest* ocr = toChordRest(oe);
ChordRest* ncr = toChordRest(maybeLinkedClone(ocr));
ncr->setScore(destScore);
ncr->setTrack(dstTrack);
//Don't clone gaps to a first voice
if (!(ncr->track() % VOICES) && ncr->isRest()) {
toRest(ncr)->setGap(false);
}
//Handle beams
if (ocr->beam() && !ocr->beam()->empty() && ocr->beam()->elements().front() == ocr) {
Beam* nb = ocr->beam()->clone();
nb->clear();
nb->setTrack(dstTrack);
nb->setScore(destScore);
nb->add(ncr);
ncr->setBeam(nb);
}
// clone Tuplets
Tuplet* ot = ocr->tuplet();
if (ot) {
ot->setTrack(srcTrack);
Tuplet* nt = tupletMap.findNew(ot);
if (nt == 0) {
nt = toTuplet(maybeLinkedClone(ot));
nt->setTrack(dstTrack);
nt->setParent(dm);
tupletMap.add(ot, nt);
Tuplet* nt1 = nt;
while (ot->tuplet()) {
Tuplet* nt2 = tupletMap.findNew(ot->tuplet());
if (nt2 == 0) {
nt2 = toTuplet(maybeLinkedClone(ot->tuplet()));
nt2->setTrack(dstTrack);
nt2->setParent(dm);
tupletMap.add(ot->tuplet(), nt2);
}
nt2->add(nt1);
nt1->setTuplet(nt2);
ot = ot->tuplet();
nt1 = nt2;
}
}
nt->add(ncr);
ncr->setTuplet(nt);
}
// clone additional settings
if (oe->isRest()) {
Rest* ore = toRest(ocr);
// If we would clone a full measure rest just don't clone this rest
if (ore->isFullMeasureRest() && (dstTrack % VOICES)) {
continue;
if (oe && !oe->generated() && oe->isChordRest()) {
ChordRest* ocr = toChordRest(oe);
if (ocr->isRest() && toRest(ocr)->isFullMeasureRest() && (dstTrack % VOICES)) {
continue;
}
ChordRest* ncr = toChordRest(maybeLinkedClone(ocr));
ncr->setScore(destScore);
ncr->setTrack(dstTrack);
//Don't clone gaps to a first voice
if (!(ncr->track() % VOICES) && ncr->isRest()) {
toRest(ncr)->setGap(false);
}
//Handle beams
if (ocr->beam() && !ocr->beam()->empty() && ocr->beam()->elements().front() == ocr) {
Beam* nb = ocr->beam()->clone();
nb->clear();
nb->setTrack(dstTrack);
nb->setScore(destScore);
nb->add(ncr);
ncr->setBeam(nb);
}
// clone Tuplets
Tuplet* ot = ocr->tuplet();
if (ot) {
ot->setTrack(srcTrack);
Tuplet* nt = tupletMap.findNew(ot);
if (nt == 0) {
nt = toTuplet(maybeLinkedClone(ot));
nt->setTrack(dstTrack);
nt->setParent(dm);
tupletMap.add(ot, nt);
Tuplet* nt1 = nt;
while (ot->tuplet()) {
Tuplet* nt2 = tupletMap.findNew(ot->tuplet());
if (nt2 == 0) {
nt2 = toTuplet(maybeLinkedClone(ot->tuplet()));
nt2->setTrack(dstTrack);
nt2->setParent(dm);
tupletMap.add(ot->tuplet(), nt2);
}
nt2->add(nt1);
nt1->setTuplet(nt2);
ot = ot->tuplet();
nt1 = nt2;
}
}
nt->add(ncr);
ncr->setTuplet(nt);
}

Comment on lines +167 to +172
for (Spanner* oldSp : on->spannerBack()) {
Note* newStart = Spanner::startElementFromSpanner(oldSp, nn);
if (newStart) {
Spanner* newSp = toSpanner(maybeLinkedClone(oldSp));
newSp->setNoteSpan(newStart, nn);
destScore->doUndoAddElement(newSp);
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 | 🟠 Major

Respect link == false when recreating spanners.

The unlinked path is broken in two ways: cr1->links()/cr2->links() only finds anchors for linkedClone(), and both spanner insertion sites still call doUndoAddElement() unconditionally. In implode/explode that can leave slurs, hairpins, and HPO spanners orphaned in the current score instead of recreating them on the cloned destination content across linked staves. Track the cloned anchors explicitly and use undoAddElement() when link is false.

Also applies to: 277-314

Comment on lines +265 to +267
// Find and add corresponding slurs and hairpins
static const std::set<ElementType> SPANNERS_TO_COPY { ElementType::SLUR, ElementType::HAMMER_ON_PULL_OFF, ElementType::HAIRPIN };
auto spanners = sourceScore->spannerMap().findOverlapping(start.ticks(), lTick.ticks());
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 | 🟠 Major

deleteSource should clean up the same spanner types that were copied.

doCloneVoice() duplicates SLUR, HAMMER_ON_PULL_OFF, and HAIRPIN, but the source cleanup only removes slurs. With deleteSource == true, the move leaves the original hairpins/HPOs behind on the source track, so the edit is no longer move-only.

Also applies to: 343-349

Comment on lines +114 to +116
if (score->excerpt()) {
return;
}
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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C8 'ExchangeVoices::exchangeVoicesInSelection\s*\(' src

Repository: musescore/MuseScore

Length of output: 10399


🏁 Script executed:

# Find the NotationInteraction class definition and score() method
rg -n 'class NotationInteraction' src

Repository: musescore/MuseScore

Length of output: 474


🏁 Script executed:

# Look at the score() method in NotationInteraction
fd -e 'h' -e 'cpp' | xargs rg -l 'NotationInteraction' | head -5

Repository: musescore/MuseScore

Length of output: 245


🏁 Script executed:

# Check the context around the call at line 6143
sed -n '6100,6150p' src/notation/internal/notationinteraction.cpp

Repository: musescore/MuseScore

Length of output: 1686


🏁 Script executed:

# Check test fixture to understand what score is being used
head -80 src/engraving/tests/exchangevoices_tests.cpp

Repository: musescore/MuseScore

Length of output: 2545


🏁 Script executed:

# Look at NotationInteraction header to find the score() method
sed -n '60,200p' src/notation/internal/notationinteraction.h

Repository: musescore/MuseScore

Length of output: 7072


🏁 Script executed:

# Search for score() method definition in NotationInteraction
rg -n 'Score\*\s+score\(\)' src/notation/internal/notationinteraction.h

Repository: musescore/MuseScore

Length of output: 107


🏁 Script executed:

# Check if there's a base class definition in Notation or INotationInteraction
rg -A 5 'Score\*\s+score\(\)' src/notation

Repository: musescore/MuseScore

Length of output: 6565


🏁 Script executed:

# Find the implementation of NotationInteraction::score()
rg -A 10 'Score\* NotationInteraction::score\(\)' src/notation

Repository: musescore/MuseScore

Length of output: 749


🏁 Script executed:

# Check the Notation class to understand what score it holds
rg -n 'class Notation' src/notation/internal/notation.h | head -5

Repository: musescore/MuseScore

Length of output: 320


🏁 Script executed:

# Look at Notation class definition to see how score is stored
sed -n '1,150p' src/notation/internal/notation.h

Repository: musescore/MuseScore

Length of output: 4027


🏁 Script executed:

# Look for how Notation objects are created, particularly for excerpts/parts
rg -n 'new Notation' src/notation

Repository: musescore/MuseScore

Length of output: 283


🏁 Script executed:

# Search for MasterNotation and how it handles excerpts/parts
rg -n 'class MasterNotation' src

Repository: musescore/MuseScore

Length of output: 454


🏁 Script executed:

# Check if there's a way to get Notation objects for excerpts
rg -n 'excerpt' src/notation/internal/notation.h -i

Repository: musescore/MuseScore

Length of output: 45


🏁 Script executed:

# Search for how parts/excerpts are handled in the notation layer
rg -n 'Notation.*excerpt' src/notation -i

Repository: musescore/MuseScore

Length of output: 11918


🏁 Script executed:

# Look at ExcerptNotation reinit and initialization
sed -n '30,80p' src/notation/internal/excerptnotation.cpp

Repository: musescore/MuseScore

Length of output: 1081


🏁 Script executed:

# Check Notation constructor implementation
rg -A 15 'Notation::Notation' src/notation/internal/notation.cpp | head -40

Repository: musescore/MuseScore

Length of output: 1043


🏁 Script executed:

# Search for where swapVoices is called to understand user interaction context
rg -B 5 -A 5 'swapVoices' src

Repository: musescore/MuseScore

Length of output: 6097


🏁 Script executed:

# Check if there's any validation in swapVoices to prevent calling on excerpts
sed -n '6130,6150p' src/notation/internal/notationinteraction.cpp

Repository: musescore/MuseScore

Length of output: 574


🏁 Script executed:

# Look for any guards in the UI action that might prevent calling on excerpts
rg -B 10 'voice-x12' src/notationscene/internal/notationactioncontroller.cpp | head -30

Repository: musescore/MuseScore

Length of output: 750


🏁 Script executed:

# Check if there's any selection filtering based on excerpt state
rg -n 'excerpt' src/notation/internal/notationinteraction.cpp

Repository: musescore/MuseScore

Length of output: 45


Excerpt scores silently bypass voice exchange with no error.

Line 115 returns before voice exchange can occur if an excerpt score is passed. Users can invoke swapVoices action on a part notation, which calls this function with an excerpt score, resulting in a silent no-op with no error message. Either add an excerpt guard at the NotationInteraction level or report an error when an excerpt score reaches this function.

Comment on lines +155 to +176
for (track_idx_t srcTrack2 : srcTrackList) {
// don't care about other linked staves
if (!(staffTrack <= srcTrack2) || !(srcTrack2 < staffTrack + VOICES)) {
continue;
}

track_idx_t tempTrack = srcTrack;
std::vector<track_idx_t> testTracks = muse::values(tracks, tempTrack + trackDiff);
bool hasVoice = false;
for (track_idx_t testTrack : testTracks) {
if (staffTrack <= testTrack && testTrack < staffTrack + VOICES && muse::contains(dstTrackList, testTrack)) {
hasVoice = true;
// voice is simply exchangeable now (deal directly)
score->undo(new ExchangeVoicesInMeasure(measure2, srcTrack2, testTrack, staffTrack / 4));
}
}

// only source voice is in this staff
if (!hasVoice) {
CloneVoice::cloneVoice(measure->first(), measure2->endTick(), measure2->first(), tempTrack, srcTrack2, false, true);
muse::remove(srcTrackList, srcTrack2);
}
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 | 🔴 Critical

Don’t remove from these vectors while range-iterating them.

Lines 175 and 197 mutate srcTrackList / dstTrackList inside range-for loops. That invalidates the iterators backing the loops and can skip or duplicate excerpt remaps nondeterministically.

🐛 Proposed fix
-                for (track_idx_t srcTrack2 : srcTrackList) {
+                for (auto it = srcTrackList.begin(); it != srcTrackList.end(); ) {
+                    track_idx_t srcTrack2 = *it;
                     // don't care about other linked staves
                     if (!(staffTrack <= srcTrack2) || !(srcTrack2 < staffTrack + VOICES)) {
-                        continue;
+                        ++it;
+                        continue;
                     }

                     track_idx_t tempTrack = srcTrack;
                     std::vector<track_idx_t> testTracks = muse::values(tracks, tempTrack + trackDiff);
                     bool hasVoice = false;
@@
                     // only source voice is in this staff
                     if (!hasVoice) {
                         CloneVoice::cloneVoice(measure->first(), measure2->endTick(), measure2->first(), tempTrack, srcTrack2, false, true);
-                        muse::remove(srcTrackList, srcTrack2);
+                        it = srcTrackList.erase(it);
+                        continue;
                     }
+                    ++it;
                 }

-                for (track_idx_t dstTrack2 : dstTrackList) {
+                for (auto it = dstTrackList.begin(); it != dstTrackList.end(); ) {
+                    track_idx_t dstTrack2 = *it;
                     // don't care about other linked staves
                     if (!(staffTrack <= dstTrack2) || !(dstTrack2 < staffTrack + VOICES)) {
-                        continue;
+                        ++it;
+                        continue;
                     }

                     track_idx_t tempTrack = dstTrack;
                     std::vector<track_idx_t> testTracks = muse::values(tracks, tempTrack - trackDiff);
                     bool hasVoice = false;
@@
                     // only destination voice is in this staff
                     if (!hasVoice) {
                         CloneVoice::cloneVoice(measure->first(), measure2->endTick(), measure2->first(), tempTrack, dstTrack2, false, true);
-                        muse::remove(dstTrackList, dstTrack2);
+                        it = dstTrackList.erase(it);
+                        continue;
                     }
+                    ++it;
                 }

Also applies to: 179-197

Comment on lines +71 to +103
score->deselectAll();
score->select(startMeasure, SelectType::RANGE, srcStaff);
score->select(endMeasure, SelectType::RANGE, srcStaff);
startSegment = score->selection().startSegment();
endSegment = score->selection().endSegment();
if (srcStaff == lastStaff - 1) {
// only one staff was selected up front - determine number of staves
// loop through all chords looking for maximum number of notes
int n = 0;
for (Segment* s = startSegment; s && s != endSegment; s = s->next1()) {
EngravingItem* e = s->element(srcTrack);
if (e && e->isChord()) {
Chord* c = toChord(e);
n = std::max(n, int(c->notes().size()));
for (Chord* graceChord : c->graceNotes()) {
n = std::max(n, int(graceChord->notes().size()));
}
}
}
lastStaff = std::min(score->nstaves(), srcStaff + n);
}

// Check that all source and dest measures have the same time stretch - allows explode within a local time signature,
// but don't yet support it between differing local time signatures.
Fraction timeStretch(1, 0);
for (Measure* m = startMeasure; m && m->tick() <= endMeasure->tick(); m = m->nextMeasure()) {
for (size_t staffIdx = srcStaff; staffIdx < lastStaff; ++staffIdx) {
Fraction mTimeStretch = score->staff(staffIdx)->timeStretch(m->tick());
if (!timeStretch.isValid()) {
timeStretch = mTimeStretch;
} else if (timeStretch != mTimeStretch) {
MScore::setError(MsError::CANNOT_EXPLODE_IMPLODE_LOCAL_TIMESIG);
return false;
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 | 🟠 Major

Guard the no-note path before lastStaff becomes invalid.

This branch narrows the selection to the top staff before the local-time-signature check, so a failed explode leaves the user's original selection changed even though the edit is rolled back. It also lets n stay 0 on rest-only input, which makes lastStaff == srcStaff and the final lastStaff - 1 reselection underflow.

Comment on lines +176 to +223
sTracks[full] = i;

for (size_t j = srcTrack + full * VOICES; j < lastStaff * VOICES; j++) {
if (i == j) {
dTracks[full] = j;
break;
}
for (Measure* m = seg->measure(); m && m->tick() < lTick; m = m->nextMeasure()) {
if (!m->hasVoice(j) || (m->hasVoice(j) && m->isOnlyRests(j))) {
dTracks[full] = j;
} else {
dTracks[full] = muse::nidx;
break;
}
}
if (dTracks[full] != muse::nidx) {
break;
}
}
full++;
}
}

IF_ASSERT_FAILED(full > 0) {
return false;
}
lastStaff = track2staff(dTracks[full - 1]) + 1;

// Check that all source and dest measures have the same time stretch - allows explode within a local time signature,
// but don't yet support it between differing local time signatures.
Fraction timeStretch(1, 0);
for (Measure* m = startMeasure; m && m->tick() <= endMeasure->tick(); m = m->nextMeasure()) {
for (size_t staffIdx = srcStaff; staffIdx < lastStaff; ++staffIdx) {
Fraction mTimeStretch = score->staff(staffIdx)->timeStretch(m->tick());
if (!timeStretch.isValid()) {
timeStretch = mTimeStretch;
} else if (timeStretch != mTimeStretch) {
MScore::setError(MsError::CANNOT_EXPLODE_IMPLODE_LOCAL_TIMESIG);
return false;
}
}
}

for (track_idx_t i = srcTrack, j = 0; i < lastStaff * VOICES && j < VOICES; i += VOICES, j++) {
track_idx_t strack = sTracks[j % VOICES];
track_idx_t dtrack = dTracks[j % VOICES];
if (strack != muse::nidx && strack != dtrack && dtrack != muse::nidx) {
CloneVoice::cloneVoice(startSegment, lTick, startSegment, strack, dtrack, true, false);
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 | 🟠 Major

Only advance a voice slot after you've found a destination track for the whole clone range.

The vacancy probe starts at seg->measure(), even though the later clone starts at startSegment, so a destination track with earlier material in the selected range can still be picked. And if no candidate is found, full is still incremented, which leaves dTracks[full - 1] == muse::nidx when lastStaff is derived.

Comment on lines +243 to +248
Segment* startSegment = score->selection().startSegment();
Segment* endSegment = score->selection().endSegment();
Measure* startMeasure = startSegment->measure();
Measure* endMeasure = endSegment ? endSegment->measure() : score->lastMeasure();
Fraction startTick = startSegment->tick();
Fraction endTick = endSegment ? endSegment->tick() : score->lastMeasure()->endTick();
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 | 🟠 Major

Treat measure-boundary end segments as exclusive here too.

When endSegment is the first segment of the next measure, this code includes that next measure in endMeasure. The mutation loop stops at endSegment, but the time-stretch validation, source-track discovery, and final reselection still operate one measure too far.

Patch sketch
-    Measure* endMeasure = endSegment ? endSegment->measure() : score->lastMeasure();
+    Measure* endMeasure = nullptr;
+    if (!endSegment) {
+        endMeasure = score->lastMeasure();
+    } else if (endSegment->tick() == endSegment->measure()->tick()) {
+        endMeasure = endSegment->measure()->prevMeasure()
+                     ? endSegment->measure()->prevMeasure()
+                     : score->firstMeasure();
+    } else {
+        endMeasure = endSegment->measure();
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Segment* startSegment = score->selection().startSegment();
Segment* endSegment = score->selection().endSegment();
Measure* startMeasure = startSegment->measure();
Measure* endMeasure = endSegment ? endSegment->measure() : score->lastMeasure();
Fraction startTick = startSegment->tick();
Fraction endTick = endSegment ? endSegment->tick() : score->lastMeasure()->endTick();
Segment* startSegment = score->selection().startSegment();
Segment* endSegment = score->selection().endSegment();
Measure* startMeasure = startSegment->measure();
Measure* endMeasure = nullptr;
if (!endSegment) {
endMeasure = score->lastMeasure();
} else if (endSegment->tick() == endSegment->measure()->tick()) {
endMeasure = endSegment->measure()->prevMeasure()
? endSegment->measure()->prevMeasure()
: score->firstMeasure();
} else {
endMeasure = endSegment->measure();
}
Fraction startTick = startSegment->tick();
Fraction endTick = endSegment ? endSegment->tick() : score->lastMeasure()->endTick();

Comment on lines 61 to 63
score->startCmd(TranslatableString::untranslatable("Implode/explode tests"));
score->cmdExplode();
ImplodeExplode::explode(score);
score->endCmd();
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

Mirror the production rollback path in these tests.

Lines 62 and 93 ignore the new bool return and still commit the surrounding command. ImplodeExplode::{explode, implode} now signal validation failures this way, and src/notation/internal/notationinteraction.cpp:6451-6481 rolls back instead of applying when they return false. These tests should do the same, otherwise they can exercise a state the app would never keep.

Also applies to: 92-94

@cbjeukendrup
Copy link
Copy Markdown
Collaborator Author

All those "review comments" are in code that was moved, not new code, so they can better be fixed by others in next PRs.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant