From a48ff15eb9bcbcc03458c58a4d2b76fdd14fe990 Mon Sep 17 00:00:00 2001 From: tharos Date: Tue, 11 Aug 2026 14:57:06 +0200 Subject: [PATCH 01/36] Add per-note playback start/duration offset properties Adds two new per-note properties (playbackStartOffset, playbackDurationOffset) that let users nudge a note's audio playback timing independently of its notated position and duration. Exposed in the Properties panel's Play section and on the plugins API. Resolves musescore/MuseScore#34545 --- src/engraving/api/v1/elements.h | 5 +++ src/engraving/dom/note.cpp | 40 +++++++++++++++++ src/engraving/dom/note.h | 12 +++++ src/engraving/dom/property.cpp | 3 ++ src/engraving/dom/property.h | 3 ++ .../playback/renderers/noterenderer.cpp | 7 +++ src/engraving/rw/read460/tread.cpp | 2 + src/engraving/rw/write/twrite.cpp | 3 +- .../playback/internal/NoteExpandableBlank.qml | 45 ++++++++++++++++++- .../playback/internal/noteplaybackmodel.cpp | 14 ++++++ .../playback/internal/noteplaybackmodel.h | 6 +++ 11 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/engraving/api/v1/elements.h b/src/engraving/api/v1/elements.h index 4cc9966e8d8fd..24e51592302ca 100644 --- a/src/engraving/api/v1/elements.h +++ b/src/engraving/api/v1/elements.h @@ -1426,6 +1426,11 @@ class Note : public EngravingItem /// The tuning of this note, in cents. API_PROPERTY_T(qreal, tuning, TUNING) + /// The start time offset for playback, in ticks. + API_PROPERTY_T(int, playbackStartOffset, PLAYBACK_START_OFFSET) + /// The duration offset for playback, in ticks. + API_PROPERTY_T(int, playbackDurationOffset, PLAYBACK_DURATION_OFFSET) + /// For notes on non-tab staves: the line this note is on. API_PROPERTY_T(int, line, LINE) /// For notes on non-tab staves: Whether this note is diff --git a/src/engraving/dom/note.cpp b/src/engraving/dom/note.cpp index 4e3a7485da63a..dc26ff83bc830 100644 --- a/src/engraving/dom/note.cpp +++ b/src/engraving/dom/note.cpp @@ -3132,6 +3132,10 @@ PropertyValue Note::getProperty(Pid propertyId) const return fixed(); case Pid::FIXED_LINE: return fixedLine(); + case Pid::PLAYBACK_START_OFFSET: + return m_playbackStartOffset; + case Pid::PLAYBACK_DURATION_OFFSET: + return m_playbackDurationOffset; case Pid::HAS_PARENTHESES: return m_hasParens ? ParenthesesMode::BOTH : ParenthesesMode::NONE; case Pid::HIDE_GENERATED_PARENTHESES: @@ -3240,6 +3244,12 @@ bool Note::setProperty(Pid propertyId, const PropertyValue& v) case Pid::FIXED_LINE: setFixedLine(v.toInt()); break; + case Pid::PLAYBACK_START_OFFSET: + setPlaybackStartOffset(v.toInt()); + break; + case Pid::PLAYBACK_DURATION_OFFSET: + setPlaybackDurationOffset(v.toInt()); + break; case Pid::HAS_PARENTHESES: if (v.value() != ParenthesesMode::BOTH && v.value() != ParenthesesMode::NONE) { ASSERT_X("Notes cannot set left & right parens individually"); @@ -3306,6 +3316,10 @@ PropertyValue Note::propertyDefault(Pid propertyId) const return 0; case Pid::TPC2: return getProperty(Pid::TPC1); + case Pid::PLAYBACK_START_OFFSET: + return 0; + case Pid::PLAYBACK_DURATION_OFFSET: + return 0; case Pid::PITCH: case Pid::TPC1: return PropertyValue(); @@ -4182,6 +4196,32 @@ int Note::stringOrLine() const return staff()->staffType(tick())->isTabStaff() ? string() * 2 : line(); } +//-------------------------------------------------------- +// effectivePlaybackStartTime +//-------------------------------------------------------- + +int Note::effectivePlaybackStartTime() const +{ + const Chord* ch = chord(); + if (!ch) { + return 0; + } + return ch->tick().ticks() + playbackStartOffset(); +} + +//-------------------------------------------------------- +// effectivePlaybackDuration +//-------------------------------------------------------- + +int Note::effectivePlaybackDuration() const +{ + const Chord* ch = chord(); + if (!ch) { + return 0; + } + return ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset(); +} + //--------------------------------------------------------- // Note::transposeDiatonic //--------------------------------------------------------- diff --git a/src/engraving/dom/note.h b/src/engraving/dom/note.h index 7059529da3996..13431ee4f504f 100644 --- a/src/engraving/dom/note.h +++ b/src/engraving/dom/note.h @@ -434,6 +434,15 @@ class Note final : public EngravingItem bool isTrillCueNote() const { return m_isTrillCueNote; } void setIsTrillCueNote(bool v); + int playbackStartOffset() const { return m_playbackStartOffset; } + void setPlaybackStartOffset(int offset) { m_playbackStartOffset = offset; } + + int playbackDurationOffset() const { return m_playbackDurationOffset; } + void setPlaybackDurationOffset(int offset) { m_playbackDurationOffset = offset; } + + int effectivePlaybackStartTime() const; + int effectivePlaybackDuration() const; + SymId noteHead() const; bool isNoteName() const; @@ -561,5 +570,8 @@ class Note final : public EngravingItem std::vector m_lineAttachPoints; TieJumpPointList m_jumpPoints { this }; + + int m_playbackStartOffset = 0; // offset in ticks to add to chord's tick for playback start + int m_playbackDurationOffset = 0; // offset in ticks to add to chord's ticks for playback duration }; } // namespace mu::engraving diff --git a/src/engraving/dom/property.cpp b/src/engraving/dom/property.cpp index e497f9ddc1a2e..880477a3899f6 100644 --- a/src/engraving/dom/property.cpp +++ b/src/engraving/dom/property.cpp @@ -519,6 +519,9 @@ static constexpr PropertyMetaData propertyList[] = { { Pid::SHARED_PART_ENABLED, P_TYPE::BOOL, PropertyGroup::NONE, false, "sharedPartEnabled", QT_TRANSLATE_NOOP("engraving/propertyName", "shared part enabled") }, + { Pid::PLAYBACK_START_OFFSET, P_TYPE::INT, PropertyGroup::APPEARANCE, false, "playbackStartOffset", QT_TRANSLATE_NOOP("engraving/propertyName", "playback start offset") }, + { Pid::PLAYBACK_DURATION_OFFSET, P_TYPE::INT, PropertyGroup::APPEARANCE, false, "playbackDurationOffset", QT_TRANSLATE_NOOP("engraving/propertyName", "playback duration offset") }, + { Pid::END, P_TYPE::INT, PropertyGroup::NONE, false, "++end++", nullptr } }; /* *INDENT-ON* */ diff --git a/src/engraving/dom/property.h b/src/engraving/dom/property.h index 60cbb2d00744f..0e4a5c6ea051d 100644 --- a/src/engraving/dom/property.h +++ b/src/engraving/dom/property.h @@ -528,6 +528,9 @@ enum class Pid : short { SHARED_PART_ENABLED, + PLAYBACK_START_OFFSET, + PLAYBACK_DURATION_OFFSET, + END }; diff --git a/src/engraving/playback/renderers/noterenderer.cpp b/src/engraving/playback/renderers/noterenderer.cpp index e3bb30f0a93d3..ae1fb25c30c40 100644 --- a/src/engraving/playback/renderers/noterenderer.cpp +++ b/src/engraving/playback/renderers/noterenderer.cpp @@ -128,6 +128,13 @@ void NoteRenderer::render(const Note* note, const RenderingContext& ctx, mpe::Pl return; } + int startTicks = note->effectivePlaybackStartTime(); + int durationTicks = note->effectivePlaybackDuration(); + + auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, startTicks, durationTicks, 0); + noteCtx.timestamp = effectiveTnD.timestamp; + noteCtx.duration = effectiveTnD.duration; + const Tie* tieFor = note->tieFor(); if (tieFor && tieFor->playSpanner()) { if (tieFor->isPartialTie()) { diff --git a/src/engraving/rw/read460/tread.cpp b/src/engraving/rw/read460/tread.cpp index 46b376aba437e..cc18db1ff2b81 100644 --- a/src/engraving/rw/read460/tread.cpp +++ b/src/engraving/rw/read460/tread.cpp @@ -3410,6 +3410,8 @@ bool TRead::readProperties(Note* n, XmlReader& e, ReadContext& ctx) } else if (tag == "overrideBendVisibilityRules") { n->setOverrideBendVisibilityRules(e.readBool()); } else if (TRead::readProperty(n, tag, e, ctx, Pid::HIDE_GENERATED_PARENTHESES)) { + } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_START_OFFSET)) { + } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_DURATION_OFFSET)) { } else if (readItemProperties(n, e, ctx)) { } else { return false; diff --git a/src/engraving/rw/write/twrite.cpp b/src/engraving/rw/write/twrite.cpp index d59aec9330a61..43db75f581be2 100644 --- a/src/engraving/rw/write/twrite.cpp +++ b/src/engraving/rw/write/twrite.cpp @@ -2503,7 +2503,8 @@ void TWrite::write(const Note* item, XmlWriter& xml, WriteContext& ctx) } for (Pid id : { Pid::PITCH, Pid::CENT_OFFSET, Pid::TPC1, Pid::TPC2, Pid::SMALL, Pid::MIRROR_HEAD, Pid::DOT_POSITION, Pid::HEAD_SCHEME, Pid::HEAD_GROUP, Pid::USER_VELOCITY, Pid::PLAY, Pid::TUNING, Pid::FRET, Pid::STRING, - Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE }) { + Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE, + Pid::PLAYBACK_START_OFFSET, Pid::PLAYBACK_DURATION_OFFSET }) { writeProperty(item, xml, id); } diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml index 34d1b1a0bad74..220179d6dbb27 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml @@ -42,7 +42,7 @@ ExpandableBlank { width: parent.width contentItemComponent: Column { - property int navigationRowEnd: tuningsSection.navigationRowEnd + property int navigationRowEnd: playbackDurationOffsetSection.navigationRowEnd spacing: 12 @@ -86,5 +86,48 @@ ExpandableBlank { propertyItem: root.model ? root.model.tuning : null } } + + Item { + height: childrenRect.height + width: root.width + + SpinBoxPropertyView { + id: playbackStartOffsetSection + anchors.left: parent.left + anchors.right: parent.horizontalCenter + anchors.rightMargin: 2 + + navigationName: "PlaybackStartOffset" + navigationPanel: root.navigation.panel + navigationRowStart: tuningsSection.navigationRowEnd + 1 + + titleText: qsTrc("propertiespanel", "Start offset") + propertyItem: root.model ? root.model.playbackStartOffset : null + + step: 1 + decimals: 0 + maxValue: 1920 + minValue: -1920 + } + + SpinBoxPropertyView { + id: playbackDurationOffsetSection + anchors.left: parent.horizontalCenter + anchors.leftMargin: 2 + anchors.right: parent.right + + navigationName: "PlaybackDurationOffset" + navigationPanel: root.navigation.panel + navigationRowStart: playbackStartOffsetSection.navigationRowEnd + 1 + + titleText: qsTrc("propertiespanel", "Duration offset") + propertyItem: root.model ? root.model.playbackDurationOffset : null + + step: 1 + decimals: 0 + maxValue: 1920 + minValue: -1920 + } + } } } diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp index 531d1e0985d19..025447c322841 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -39,6 +39,8 @@ void NotePlaybackModel::createProperties() { m_tuning = buildPropertyItem(mu::engraving::Pid::TUNING); m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY); + m_playbackStartOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_START_OFFSET); + m_playbackDurationOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET); } void NotePlaybackModel::requestElements() @@ -53,6 +55,8 @@ void NotePlaybackModel::loadProperties() //! NOTE: display 64 instead of 0 in the Velocity field to avoid confusing the user return value.toInt() == 0 ? 64 : value; }); + loadPropertyItem(m_playbackStartOffset); + loadPropertyItem(m_playbackDurationOffset); } PropertyItem* NotePlaybackModel::tuning() const @@ -64,3 +68,13 @@ PropertyItem* NotePlaybackModel::velocity() const { return m_velocity; } + +PropertyItem* NotePlaybackModel::playbackStartOffset() const +{ + return m_playbackStartOffset; +} + +PropertyItem* NotePlaybackModel::playbackDurationOffset() const +{ + return m_playbackDurationOffset; +} diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h index 6753612c9ee3a..aaf6c6b02dc9a 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h @@ -34,12 +34,16 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel Q_PROPERTY(mu::propertiespanel::PropertyItem * tuning READ tuning CONSTANT) Q_PROPERTY(mu::propertiespanel::PropertyItem * velocity READ velocity CONSTANT) + Q_PROPERTY(mu::propertiespanel::PropertyItem * playbackStartOffset READ playbackStartOffset CONSTANT) + Q_PROPERTY(mu::propertiespanel::PropertyItem * playbackDurationOffset READ playbackDurationOffset CONSTANT) public: explicit NotePlaybackModel(QObject* parent, const muse::modularity::ContextPtr& iocCtx, IElementRepositoryService* repository); PropertyItem* tuning() const; PropertyItem* velocity() const; + PropertyItem* playbackStartOffset() const; + PropertyItem* playbackDurationOffset() const; protected: void createProperties() override; @@ -49,5 +53,7 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel private: PropertyItem* m_tuning = nullptr; PropertyItem* m_velocity = nullptr; + PropertyItem* m_playbackStartOffset = nullptr; + PropertyItem* m_playbackDurationOffset = nullptr; }; } From 756caf52c9b7dba96c2d33974c65f5806f41a383 Mon Sep 17 00:00:00 2001 From: sfer Date: Thu, 13 Aug 2026 12:29:38 +0200 Subject: [PATCH 02/36] Fix note playback offset copy and add duration safety clamps - Note::Note(const Note&, bool) did not copy m_playbackStartOffset / m_playbackDurationOffset, so cloning a note (copy-paste, duplication, linked parts) silently reset both offsets to 0. - effectivePlaybackStartTime()/effectivePlaybackDuration() had no lower bound. Since the two offsets are set independently (Properties panel spinboxes each range -1920..1920 with no cross-validation), an inconsistent combination could produce a negative effective start tick or a non-positive effective duration, both unguarded downstream in NoteRenderer. The duration formula intentionally keeps "- playbackStartOffset()" so that the effective end time (chordTick + chordTicks + durationOffset) does not depend on the start offset - this keeps start/duration independently adjustable, which upcoming UI work relies on. Clamping is applied at the computation itself rather than changing the formula, so every caller (Properties panel, plugins, future UI) is protected centrally. --- src/engraving/dom/note.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/engraving/dom/note.cpp b/src/engraving/dom/note.cpp index dc26ff83bc830..e6dbda70900d9 100644 --- a/src/engraving/dom/note.cpp +++ b/src/engraving/dom/note.cpp @@ -736,6 +736,8 @@ Note::Note(const Note& n, bool link) m_harmonic = n.m_harmonic; m_hasParens = n.m_hasParens; m_hideGeneratedParens = n.m_hideGeneratedParens; + m_playbackStartOffset = n.m_playbackStartOffset; + m_playbackDurationOffset = n.m_playbackDurationOffset; if (n.m_accidental) { add(new Accidental(*(n.m_accidental))); @@ -4206,7 +4208,9 @@ int Note::effectivePlaybackStartTime() const if (!ch) { return 0; } - return ch->tick().ticks() + playbackStartOffset(); + // playbackStartOffset() and playbackDurationOffset() are independently user-settable (e.g. + // via the Properties panel), so clamp here rather than trust their combination to stay sane. + return std::max(0, ch->tick().ticks() + playbackStartOffset()); } //-------------------------------------------------------- @@ -4219,7 +4223,7 @@ int Note::effectivePlaybackDuration() const if (!ch) { return 0; } - return ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset(); + return std::max(1, ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset()); } //--------------------------------------------------------- From 89906035252f33ffd23a4c37a112bc7f5d57164e Mon Sep 17 00:00:00 2001 From: sfer Date: Thu, 13 Aug 2026 12:38:42 +0200 Subject: [PATCH 03/36] Add drag-handle overlay to edit per-note playback start/duration offsets Adds an on-canvas alternative to the Properties panel spinboxes for editing the playbackStartOffset/playbackDurationOffset introduced in "Add per-note playback start/duration offset properties": a toggleable overlay ("Note offsets" toolbar action, alongside Automation) draws a small rounded rectangle above each note, anchored on the note's own position. Dragging the rectangle's left/right edge adjusts the start/duration offset respectively, with a live preview during the drag and a single undo/redo step on release. Dragging a note that is part of a multi-note selection applies the same tick delta to every selected note. The notation dims while the mode is active, matching the existing Automation overlay's behavior. New files: - INotationNoteOffsets / NotationNoteOffsets: edit-mode toggle state, mirroring INotationAutomation. - NotationNoteOffsetController: builds/positions one overlay per staff from the actual note layout (not a fixed lane), converts drag input to ticks via segment interpolation, and commits offset changes through the existing undoChangeProperty/Pid machinery (no new UndoableCommand needed). - NoteOffsetOverlay: QQuickPaintedItem drawing the rectangles/handles and doing its own hit-testing, independent of the Grip/NotationInteraction edit path - the same pattern PolylinePlot uses for automation. - segmentcanvasinterpolation.{h,cpp}: canvasX<->tick interpolation shared between the automation and note-offset controllers (previously duplicated). Wiring follows the existing "toggle-automation" action end to end (notationuiactions.cpp, notationactioncontroller.cpp, notationcommandsregister.cpp, notationcommandsstate.cpp, notationtoolbarmodel.cpp) and reuses AbstractNotationPaintView's existing per-view-matrix redraw/dim hooks. --- src/notation/CMakeLists.txt | 3 + src/notation/imasternotation.h | 1 + src/notation/inotation_fwd.h | 3 + src/notation/inotationnoteoffsets.h | 39 ++ src/notation/internal/masternotation.cpp | 7 + src/notation/internal/masternotation.h | 2 + src/notation/internal/notationnoteoffsets.cpp | 44 ++ src/notation/internal/notationnoteoffsets.h | 40 ++ .../inotationcommandscontroller.h | 3 + .../internal/notationactioncontroller.cpp | 30 + .../internal/notationactioncontroller.h | 5 + .../internal/notationcommandsregister.cpp | 7 + .../internal/notationcommandsstate.cpp | 8 + .../internal/notationuiactions.cpp | 20 + src/notationscene/notationcommands.h | 1 + .../MuseScore/NotationScene/CMakeLists.txt | 6 + .../abstractnotationpaintview.cpp | 36 +- .../NotationScene/abstractnotationpaintview.h | 4 + .../notationautomationcontroller.cpp | 40 +- .../notationnoteoffsetcontroller.cpp | 550 ++++++++++++++++++ .../notationnoteoffsetcontroller.h | 115 ++++ .../NotationScene/notationtoolbarmodel.cpp | 3 +- .../NotationScene/noteoffsetoverlay.cpp | 191 ++++++ .../NotationScene/noteoffsetoverlay.h | 89 +++ .../segmentcanvasinterpolation.cpp | 93 +++ .../segmentcanvasinterpolation.h | 38 ++ 26 files changed, 1342 insertions(+), 36 deletions(-) create mode 100644 src/notation/inotationnoteoffsets.h create mode 100644 src/notation/internal/notationnoteoffsets.cpp create mode 100644 src/notation/internal/notationnoteoffsets.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h diff --git a/src/notation/CMakeLists.txt b/src/notation/CMakeLists.txt index d697fa3d44d7a..8505fa6c06256 100644 --- a/src/notation/CMakeLists.txt +++ b/src/notation/CMakeLists.txt @@ -36,6 +36,7 @@ target_sources(notation PRIVATE inotationselectionfilter.h inotationselectionrange.h inotationautomation.h + inotationnoteoffsets.h inotationinteraction.h inotationstyle.h inotationundostack.h @@ -83,6 +84,8 @@ target_sources(notation PRIVATE internal/notationcontextconfiguration.h internal/notationautomation.cpp internal/notationautomation.h + internal/notationnoteoffsets.cpp + internal/notationnoteoffsets.h internal/notationelements.cpp internal/notationelements.h internal/notationinteraction.cpp diff --git a/src/notation/imasternotation.h b/src/notation/imasternotation.h index a90c27dc17784..36413d0060014 100644 --- a/src/notation/imasternotation.h +++ b/src/notation/imasternotation.h @@ -72,6 +72,7 @@ class IMasterNotation virtual void initNotationSoloMuteState(const INotationPtr notation) = 0; virtual INotationAutomationPtr automation() const = 0; + virtual INotationNoteOffsetsPtr noteOffsets() const = 0; }; using IMasterNotationPtr = std::shared_ptr; diff --git a/src/notation/inotation_fwd.h b/src/notation/inotation_fwd.h index 7dd484b61e270..02a0a182e888f 100644 --- a/src/notation/inotation_fwd.h +++ b/src/notation/inotation_fwd.h @@ -84,4 +84,7 @@ using INotationPlaybackPtr = std::shared_ptr; class INotationAutomation; using INotationAutomationPtr = std::shared_ptr; + +class INotationNoteOffsets; +using INotationNoteOffsetsPtr = std::shared_ptr; } diff --git a/src/notation/inotationnoteoffsets.h b/src/notation/inotationnoteoffsets.h new file mode 100644 index 0000000000000..3f72866af235c --- /dev/null +++ b/src/notation/inotationnoteoffsets.h @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "async/notification.h" + +namespace mu::notation { +class INotationNoteOffsets +{ +public: + virtual ~INotationNoteOffsets() = default; + + virtual bool isEditModeEnabled() const = 0; + virtual void setEditModeEnabled(bool enabled) = 0; + virtual muse::async::Notification editModeEnabledChanged() const = 0; +}; + +using INotationNoteOffsetsPtr = std::shared_ptr; +} diff --git a/src/notation/internal/masternotation.cpp b/src/notation/internal/masternotation.cpp index b14f6835d9a94..232ae03b3257c 100644 --- a/src/notation/internal/masternotation.cpp +++ b/src/notation/internal/masternotation.cpp @@ -51,6 +51,7 @@ #include "excerptnotation.h" #include "masternotationparts.h" #include "notationautomation.h" +#include "notationnoteoffsets.h" #include "types/scorecreateoptions.h" #ifdef MUE_BUILD_ENGRAVING_PLAYBACK @@ -92,6 +93,7 @@ MasterNotation::MasterNotation(project::INotationProject* project, const muse::m #endif m_notationAutomation = std::make_shared(undoStack()); + m_notationNoteOffsets = std::make_shared(); m_parts->partsChanged().onNotify(this, [this]() { notifyAboutNotationChanged(); @@ -766,6 +768,11 @@ INotationAutomationPtr MasterNotation::automation() const return m_notationAutomation; } +INotationNoteOffsetsPtr MasterNotation::noteOffsets() const +{ + return m_notationNoteOffsets; +} + void MasterNotation::initNotationSoloMuteState(const INotationPtr notation) { IF_ASSERT_FAILED(notation) { diff --git a/src/notation/internal/masternotation.h b/src/notation/internal/masternotation.h index 586e73b0a66cf..8c9aeb5977be4 100644 --- a/src/notation/internal/masternotation.h +++ b/src/notation/internal/masternotation.h @@ -74,6 +74,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab void initNotationSoloMuteState(const INotationPtr notation) override; INotationAutomationPtr automation() const override; + INotationNoteOffsetsPtr noteOffsets() const override; private: friend class project::NotationProject; @@ -102,6 +103,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab muse::async::Notification m_excerptsChanged; INotationPlaybackPtr m_notationPlayback = nullptr; INotationAutomationPtr m_notationAutomation = nullptr; + INotationNoteOffsetsPtr m_notationNoteOffsets = nullptr; muse::async::Notification m_hasPartsChanged; mutable ExcerptNotationList m_potentialExcerpts; diff --git a/src/notation/internal/notationnoteoffsets.cpp b/src/notation/internal/notationnoteoffsets.cpp new file mode 100644 index 0000000000000..72aa563956959 --- /dev/null +++ b/src/notation/internal/notationnoteoffsets.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notationnoteoffsets.h" + +using namespace mu::notation; + +bool NotationNoteOffsets::isEditModeEnabled() const +{ + return m_isEditModeEnabled; +} + +void NotationNoteOffsets::setEditModeEnabled(bool enabled) +{ + if (m_isEditModeEnabled == enabled) { + return; + } + m_isEditModeEnabled = enabled; + m_editModeEnabledChanged.notify(); +} + +muse::async::Notification NotationNoteOffsets::editModeEnabledChanged() const +{ + return m_editModeEnabledChanged; +} diff --git a/src/notation/internal/notationnoteoffsets.h b/src/notation/internal/notationnoteoffsets.h new file mode 100644 index 0000000000000..f719537e33673 --- /dev/null +++ b/src/notation/internal/notationnoteoffsets.h @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "../inotationnoteoffsets.h" + +#include "async/notification.h" + +namespace mu::notation { +class NotationNoteOffsets : public INotationNoteOffsets +{ +public: + bool isEditModeEnabled() const override; + void setEditModeEnabled(bool enabled) override; + muse::async::Notification editModeEnabledChanged() const override; + +private: + bool m_isEditModeEnabled = false; + muse::async::Notification m_editModeEnabledChanged; +}; +} diff --git a/src/notationscene/inotationcommandscontroller.h b/src/notationscene/inotationcommandscontroller.h index 32048863bc533..a20c720a76d95 100644 --- a/src/notationscene/inotationcommandscontroller.h +++ b/src/notationscene/inotationcommandscontroller.h @@ -89,6 +89,9 @@ class INotationCommandsController : MODULE_CONTEXT_INTERFACE virtual bool isAutomationModeEnabled() const = 0; virtual muse::async::Notification automationModeEnabledChanged() const = 0; + virtual bool isNoteOffsetEditModeEnabled() const = 0; + virtual muse::async::Notification noteOffsetEditModeEnabledChanged() const = 0; + virtual bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const = 0; virtual muse::async::Notification debuggingOptionsChanged() const = 0; }; diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index e5eaa0373bbff..9ad23b1817332 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -39,6 +39,7 @@ #include "notation/imasternotation.h" #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep +#include "notation/inotationnoteoffsets.h" // IWYU pragma: keep #include "notation/inotationelements.h" #include "notation/inotationmidiinput.h" #include "notation/inotationnoteinput.h" @@ -581,6 +582,7 @@ void NotationActionController::init() registerCommand(TOGGLE_AUTOMATION_COMMAND, &Controller::toggleAutomation); registerQueryCommand(SELECT_AUTOMATION_TYPE_COMMAND, &Controller::selectAutomationType); + registerCommand(TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, &Controller::toggleNoteOffsetEditor); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -1052,6 +1054,7 @@ void NotationActionController::init() { "scoop", ADD_SCOOP_COMMAND, {} }, { "hammer-on-pull-off", ADD_HAMMER_ON_PULL_OFF_COMMAND, {} }, { "toggle-automation", TOGGLE_AUTOMATION_COMMAND, {} }, + { "toggle-note-offset-editor", TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, {} }, { "string-up", GOTO_STRING_ABOVE_COMMAND, {} }, { "string-down", GOTO_STRING_BELOW_COMMAND, {} }, { "move-up", MOVE_UP_COMMAND, {} }, @@ -1129,6 +1132,10 @@ void NotationActionController::init() masterNotation->automation()->automationModeEnabledChanged().onNotify(this, [this]() { m_automationModeEnabledChanged.notify(); }, Asyncable::Mode::SetReplace); + + masterNotation->noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + m_noteOffsetEditModeEnabledChanged.notify(); + }, Asyncable::Mode::SetReplace); } } @@ -3187,6 +3194,16 @@ bool NotationActionController::isAutomationModeEnabled() const return currentMasterNotation() ? currentMasterNotation()->automation()->isAutomationModeEnabled() : false; } +bool NotationActionController::isNoteOffsetEditModeEnabled() const +{ + return currentMasterNotation() ? currentMasterNotation()->noteOffsets()->isEditModeEnabled() : false; +} + +muse::async::Notification NotationActionController::noteOffsetEditModeEnabledChanged() const +{ + return m_noteOffsetEditModeEnabledChanged; +} + muse::async::Notification NotationActionController::automationModeEnabledChanged() const { return m_automationModeEnabledChanged; @@ -3259,6 +3276,19 @@ void NotationActionController::toggleAutomation() masterNotation->automation()->setAutomationModeEnabled(!isEnabled); } +void NotationActionController::toggleNoteOffsetEditor() +{ + TRACEFUNC; + + IMasterNotationPtr masterNotation = currentMasterNotation(); + if (!masterNotation) { + return; + } + + const bool isEnabled = masterNotation->noteOffsets()->isEditModeEnabled(); + masterNotation->noteOffsets()->setEditModeEnabled(!isEnabled); +} + muse::Ret NotationActionController::selectAutomationType(const muse::rcommand::CommandQuery& query) { const std::string type = query.param("type").toString(); diff --git a/src/notationscene/internal/notationactioncontroller.h b/src/notationscene/internal/notationactioncontroller.h index dab952921fb30..efd04fc578fca 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -118,6 +118,9 @@ class NotationActionController : public INotationCommandsController, public muse bool isAutomationModeEnabled() const override; muse::async::Notification automationModeEnabledChanged() const override; + bool isNoteOffsetEditModeEnabled() const override; + muse::async::Notification noteOffsetEditModeEnabledChanged() const override; + bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const override; muse::async::Notification debuggingOptionsChanged() const override; @@ -269,6 +272,7 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); + void toggleNoteOffsetEditor(); // commands void registerCommand(const muse::rcommand::Command&, std::function); @@ -311,6 +315,7 @@ class NotationActionController : public INotationCommandsController, public muse muse::async::Channel m_scoreConfigChanged; muse::async::Notification m_currentNotationStyleChanged; muse::async::Notification m_automationModeEnabledChanged; + muse::async::Notification m_noteOffsetEditModeEnabledChanged; using IsActionEnabledFunc = std::function; std::map m_isEnabledMap; diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index c19a54d906caa..40abd65d81c6a 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2914,6 +2914,13 @@ static const std::vector s_commandInfos = { InputSchema(), Decoration(IconCode::Code::AUTOMATION, rcommand::Checkable::Yes) }, + CommandInfo { + TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, + TranslatableString("action", "Note offsets"), + TranslatableString("action", "Toggle note offset editor"), + InputSchema(), + Decoration(IconCode::Code::CLOCK, rcommand::Checkable::Yes) + }, CommandInfo { SELECT_AUTOMATION_TYPE_COMMAND, TranslatableString::untranslatable("Automation type"), diff --git a/src/notationscene/internal/notationcommandsstate.cpp b/src/notationscene/internal/notationcommandsstate.cpp index 7c53a0d1beaa6..b72bbc1f1645c 100644 --- a/src/notationscene/internal/notationcommandsstate.cpp +++ b/src/notationscene/internal/notationcommandsstate.cpp @@ -347,6 +347,10 @@ void NotationCommandsState::init() updateCommandStates({ TOGGLE_AUTOMATION_COMMAND }); }); + controller()->noteOffsetEditModeEnabledChanged().onNotify(this, [this]() { + updateCommandStates({ TOGGLE_NOTE_OFFSET_EDITOR_COMMAND }); + }); + controller()->debuggingOptionsChanged().onNotify(this, [this]() { updateCommandStates(DEBUG_COMMANDS); }); @@ -485,6 +489,10 @@ CommandState NotationCommandsState::doCommandState(const Command& command) const return CommandState(true, controller()->isAutomationModeEnabled()); } + if (command == TOGGLE_NOTE_OFFSET_EDITOR_COMMAND) { + return CommandState(true, controller()->isNoteOffsetEditModeEnabled()); + } + if (muse::contains(DEBUG_COMMANDS, command)) { return CommandState(true, controller()->isDebuggingCommandEnabled(command)); } diff --git a/src/notationscene/internal/notationuiactions.cpp b/src/notationscene/internal/notationuiactions.cpp index 63e04057d19b1..12ccd084a8ddb 100644 --- a/src/notationscene/internal/notationuiactions.cpp +++ b/src/notationscene/internal/notationuiactions.cpp @@ -32,6 +32,7 @@ #include "notation/imasternotation.h" #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep +#include "notation/inotationnoteoffsets.h" // IWYU pragma: keep #include "notation/inotationinteraction.h" #include "notation/inotationnoteinput.h" // IWYU pragma: keep #include "notation/inotationselection.h" // IWYU pragma: keep @@ -55,6 +56,7 @@ static const ActionCode SHOW_IRREGULAR_CODE("show-irregular"); static const ActionCode TOGGLE_CONCERT_PITCH_CODE("concert-pitch"); static const ActionCode TOGGLE_AUTOMATION_CODE("toggle-automation"); +static const ActionCode TOGGLE_NOTE_OFFSET_EDITOR_CODE("toggle-note-offset-editor"); // avoid translation duplication @@ -2700,6 +2702,14 @@ const UiActionList NotationUiActions::s_actions = { IconCode::Code::AUTOMATION, Checkable::Yes ), + UiAction(TOGGLE_NOTE_OFFSET_EDITOR_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_NOTATION_OPENED, + TranslatableString("action", "Note offsets"), + TranslatableString("action", "Toggle note offset editor"), + IconCode::Code::CLOCK, + Checkable::Yes + ), }; const UiActionList NotationUiActions::s_scoreConfigActions = { @@ -2924,11 +2934,16 @@ void NotationUiActions::init() m_controller->currentMasterNotationChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_AUTOMATION_CODE }); + m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); if (const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation()) { masterNotation->automation()->automationModeEnabledChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_AUTOMATION_CODE }); }, Asyncable::Mode::SetReplace); + + masterNotation->noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); + }, Asyncable::Mode::SetReplace); } }); @@ -3047,6 +3062,11 @@ bool NotationUiActions::actionChecked(const UiAction& act) const return masterNotation ? masterNotation->automation()->isAutomationModeEnabled() : false; } + if (act.code == TOGGLE_NOTE_OFFSET_EDITOR_CODE) { + const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets()->isEditModeEnabled() : false; + } + if (isScoreConfigAction(act.code)) { auto interaction = m_controller->currentNotationInteraction(); if (interaction) { diff --git a/src/notationscene/notationcommands.h b/src/notationscene/notationcommands.h index 8544e5313e6df..2d3d5f53d3af8 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -484,6 +484,7 @@ inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_INSTR_COMMAN inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_STAFF_COMMAND("command://notation/voice-assignment-all-in-staff"); inline static const muse::rcommand::Command TOGGLE_AUTOMATION_COMMAND("command://notation/toggle-automation"); inline static const muse::rcommand::Command SELECT_AUTOMATION_TYPE_COMMAND("command://notation/select-automation-type"); // with params +inline static const muse::rcommand::Command TOGGLE_NOTE_OFFSET_EDITOR_COMMAND("command://notation/toggle-note-offset-editor"); // TAB commands inline static const muse::rcommand::Command SET_DURATION_WHOLE_TAB_COMMAND("command://notation/set-duration-whole-tab"); diff --git a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt index 247446b260609..713fada176449 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt +++ b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt @@ -70,6 +70,8 @@ qt_add_qml_module(notationscene_qml notationcontextmenumodel.h notationnavigator.cpp notationnavigator.h + notationnoteoffsetcontroller.h + notationnoteoffsetcontroller.cpp notationpaintview.cpp notationpaintview.h notationruler.cpp @@ -88,6 +90,8 @@ qt_add_qml_module(notationscene_qml noteinputbarmodel.h noteinputcursor.cpp noteinputcursor.h + noteoffsetoverlay.cpp + noteoffsetoverlay.h paintedengravingitem.cpp paintedengravingitem.h partlistmodel.cpp @@ -109,6 +113,8 @@ qt_add_qml_module(notationscene_qml playbackcursor.h searchpopupmodel.cpp searchpopupmodel.h + segmentcanvasinterpolation.cpp + segmentcanvasinterpolation.h selectionfilter/abstractselectionfiltermodel.cpp selectionfilter/abstractselectionfiltermodel.h selectionfilter/elementsselectionfiltermodel.cpp diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index 5f68e6953ed19..b5b3fef3e921b 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -35,6 +35,7 @@ #include "notation/imasternotation.h" // IWYU pragma: keep #include "notation/inotationaccessibility.h" // IWYU pragma: keep #include "notation/inotationautomation.h" +#include "notation/inotationnoteoffsets.h" #include "notation/inotationelements.h" #include "notation/inotationnoteinput.h" #include "notation/inotationpainting.h" // IWYU pragma: keep @@ -111,6 +112,20 @@ void AbstractNotationPaintView::load() }); m_notationAutomationController = std::make_unique(m_automationLinesContainer, iocContext()); + + // Clip note offset overlays to the view bounds + m_noteOffsetOverlayContainer = new QQuickItem(this); + m_noteOffsetOverlayContainer->setClip(true); + m_noteOffsetOverlayContainer->setWidth(width()); + m_noteOffsetOverlayContainer->setHeight(height()); + connect(this, &QQuickItem::widthChanged, m_noteOffsetOverlayContainer, [this]() { + m_noteOffsetOverlayContainer->setWidth(width()); + }); + connect(this, &QQuickItem::heightChanged, m_noteOffsetOverlayContainer, [this]() { + m_noteOffsetOverlayContainer->setHeight(height()); + }); + + m_notationNoteOffsetController = std::make_unique(m_noteOffsetOverlayContainer, iocContext()); m_playbackCursor = std::make_unique(iocContext()); m_playbackCursor->setVisible(false); m_noteInputCursor = std::make_unique(iocContext(), notationConfiguration()->thinNoteInputCursor()); @@ -375,6 +390,12 @@ void AbstractNotationPaintView::onLoadNotation(INotationPtr) emit automationModeChanged(); }); + // FIXME: only un-/re-subscribe when master notation changes + m_notationNoteOffsetController->init(); + notationNoteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + scheduleRedraw(); + }); + if (isMainView()) { connect(this, &QQuickPaintedItem::focusChanged, this, [this](bool focused) { if (notation()) { @@ -427,6 +448,7 @@ void AbstractNotationPaintView::onUnloadNotation(INotationPtr) notationPlayback()->loopBoundariesChanged().disconnect(this); m_notation->viewModeChanged().disconnect(this); notationAutomation()->automationModeEnabledChanged().disconnect(this); + notationNoteOffsets()->editModeEnabledChanged().disconnect(this); if (isMainView()) { disconnect(this, &QQuickPaintedItem::focusChanged, this, nullptr); @@ -477,6 +499,10 @@ void AbstractNotationPaintView::onMatrixChanged(const Transform& oldMatrix, cons m_notationAutomationController->setViewMatrix(newMatrix); } + if (m_notationNoteOffsetController) { + m_notationNoteOffsetController->setViewMatrix(newMatrix); + } + scheduleRedraw(); emit horizontalScrollChanged(); @@ -602,6 +628,11 @@ INotationAutomationPtr AbstractNotationPaintView::notationAutomation() const return m_notation ? m_notation->masterNotation()->automation() : nullptr; } +INotationNoteOffsetsPtr AbstractNotationPaintView::notationNoteOffsets() const +{ + return m_notation ? m_notation->masterNotation()->noteOffsets() : nullptr; +} + void AbstractNotationPaintView::onNoteInputStateChanged() { TRACEFUNC; @@ -743,8 +774,9 @@ void AbstractNotationPaintView::paint(QPainter* qp) painter->setWorldTransform(m_matrix * guiScalingCompensation); const bool isPrinting = publishMode() || m_inputController->readonly(); - const bool isAutomation = automationMode(); - notation()->painting()->paintView(painter, toLogical(rect), isPrinting, isAutomation); + const INotationNoteOffsetsPtr noteOffsets = notationNoteOffsets(); + const bool dimNotation = automationMode() || (noteOffsets && noteOffsets->isEditModeEnabled()); + notation()->painting()->paintView(painter, toLogical(rect), isPrinting, dimNotation); const INotationNoteInputPtr noteInput = notationNoteInput(); if (noteInput->isNoteInputMode() && !publishMode()) { diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h index 425c742f238f0..ee46ae40c3475 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h @@ -41,6 +41,7 @@ #include "notationscene/inotationsceneconfiguration.h" #include "notationviewinputcontroller.h" #include "notationautomationcontroller.h" +#include "notationnoteoffsetcontroller.h" #include "noteinputcursor.h" #include "notationruler.h" #include "playbackcursor.h" @@ -218,6 +219,7 @@ protected slots: INotationStylePtr notationStyle() const; INotationSelectionPtr notationSelection() const; INotationAutomationPtr notationAutomation() const; + INotationNoteOffsetsPtr notationNoteOffsets() const; void clear(); void initBackground(); @@ -288,6 +290,8 @@ protected slots: std::unique_ptr m_inputController; QQuickItem* m_automationLinesContainer = nullptr; std::unique_ptr m_notationAutomationController; + QQuickItem* m_noteOffsetOverlayContainer = nullptr; + std::unique_ptr m_notationNoteOffsetController; std::unique_ptr m_playbackCursor; std::unique_ptr m_noteInputCursor; std::unique_ptr m_ruler; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp index 9a2808ec3cc49..607c87fd60d28 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationautomationcontroller.cpp @@ -29,6 +29,8 @@ #include "async/async.h" +#include "segmentcanvasinterpolation.h" + #include "uicomponents/qml/Muse/UiComponents/polylineplot.h" #include "engraving/automation/automationdata.h" @@ -158,38 +160,10 @@ static const Segment* lastSegmentOfSystem(const System* system) } // Maps an x position to a tick via linear interpolation between the nearest Duration/barline segments on either side of it -static std::optional tickFromCanvasX(const System* system, const muse::RectF& staffCanvasRect, qreal x) +static std::optional automationTickFromCanvasX(const System* system, const muse::RectF& staffCanvasRect, qreal x) { - IF_ASSERT_FAILED(system) { - return std::nullopt; - } - const double pointCanvasX = staffCanvasRect.x() + x * staffCanvasRect.width(); - const mu::engraving::SegmentType type = mu::engraving::SegmentType::Duration | mu::engraving::SegmentType::BarLineTypes; - - const Segment* prevSeg = nullptr; - const Segment* nextSeg = nullptr; - for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr; - seg && seg->system() == system; seg = seg->next1(type)) { - if (seg->canvasX() <= pointCanvasX) { - prevSeg = seg; - } else { - nextSeg = seg; - break; - } - } - - if (!prevSeg) { - return nextSeg ? std::make_optional(nextSeg->tick().ticks()) : std::nullopt; - } - - // No next segment - use prevSeg's own end as a virtual next point - const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width(); - const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks(); - const double canvasSpan = nextCanvasX - prevSeg->canvasX(); - const double ratio = canvasSpan > 0.0 ? (pointCanvasX - prevSeg->canvasX()) / canvasSpan : 0.0; - - return prevSeg->tick().ticks() + static_cast(ratio * (nextTick - prevSeg->tick().ticks())); + return tickFromCanvasX(system, pointCanvasX); } static AutomationCurveKey curveKeyFor(AutomationType type, const Staff* staff) @@ -394,7 +368,7 @@ muse::uicomponents::PolylinePlot* NotationAutomationController::createPolylineFo return; } - const std::optional tick = tickFromCanvasX(system, staffCanvasRect, x); + const std::optional tick = automationTickFromCanvasX(system, staffCanvasRect, x); if (!tick) { return; } @@ -907,7 +881,7 @@ bool NotationAutomationController::requestEditPoint(const PointData& oldPointDat // STEP 2 - Determine the new tick value based on the x parameter... const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); - const std::optional newTickOpt = tickFromCanvasX(system, staffCanvasRect, x); + const std::optional newTickOpt = automationTickFromCanvasX(system, staffCanvasRect, x); const int newTick = newTickOpt.value_or(oldPointData.tick); const bool tickChanged = newTick != oldPointData.tick; @@ -997,7 +971,7 @@ bool NotationAutomationController::requestAddPoint(const SysStaffKey& key, qreal } const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); - const std::optional newTick = tickFromCanvasX(system, staffCanvasRect, x); + const std::optional newTick = automationTickFromCanvasX(system, staffCanvasRect, x); if (!newTick) { return false; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp new file mode 100644 index 0000000000000..a142818a8ed81 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -0,0 +1,550 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notationnoteoffsetcontroller.h" + +#include "noteoffsetoverlay.h" +#include "segmentcanvasinterpolation.h" + +#include +#include + +#include "async/async.h" +#include "global/containers.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/mscore.h" +#include "engraving/dom/note.h" +#include "engraving/dom/property.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/system.h" + +#include "notation/imasternotation.h" +#include "notation/inotation.h" +#include "notation/inotationinteraction.h" +#include "notation/inotationnoteoffsets.h" +#include "notation/inotationselection.h" +#include "notation/inotationundostack.h" +#include "notation/inotationelements.h" // IWYU pragma: keep + +using namespace mu::notation; +using namespace mu::engraving; + +// Each rectangle is anchored on its own note's vertical position, not on a fixed lane above the +// staff - this way a rectangle always sits right above its notehead, and chord notes naturally +// stack in the same order as their pitches instead of needing an artificial row index. +constexpr static double RECT_TOP_MARGIN_SP = 0.45; // gap between the notehead center and the rectangle's top edge +constexpr static double RECT_BOTTOM_OVERLAP_SP = 0.4; // how far below the notehead center the rectangle's bottom edge extends + +constexpr static int MAX_OFFSET_TICKS = 1920; // matches the Properties panel spinbox range +constexpr static int MIN_EFFECTIVE_TICKS = 1; + +static std::optional noteOffsetTickFromCanvasX(const System* system, const muse::RectF& bandCanvasRect, qreal xN) +{ + const double pointCanvasX = bandCanvasRect.x() + xN * bandCanvasRect.width(); + return mu::notation::tickFromCanvasX(system, pointCanvasX); +} + +// Pixel shift corresponding to a tick offset away from baseTick, using the same segment +// interpolation as canvasXFromTick/noteOffsetTickFromCanvasX so it round-trips exactly with how +// the mouse position was interpreted. Falls back to a locally-derived ratio only if the note +// sits right at a system boundary where interpolation has nothing to anchor to. +static double pixelDeltaForTickOffset(const System* system, int baseTick, int tickOffset, double fallbackPxPerTick) +{ + if (tickOffset == 0) { + return 0.0; + } + + const std::optional basePx = mu::notation::canvasXFromTick(system, baseTick); + const std::optional offsetPx = mu::notation::canvasXFromTick(system, baseTick + tickOffset); + if (basePx && offsetPx) { + return *offsetPx - *basePx; + } + + return tickOffset * fallbackPxPerTick; +} + +NotationNoteOffsetController::NotationNoteOffsetController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx) + : muse::Contextable(iocCtx), m_overlaysParent(overlaysParent) +{ +} + +void NotationNoteOffsetController::init() +{ + IF_ASSERT_FAILED(noteOffsets() && currentNotation()) { + return; + } + + onCurrentNotationChanged(); + + noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { + if (noteOffsets()->isEditModeEnabled()) { + rebuildAllOverlays(); + } else { + updateOverlaysGeometry(); + } + }, Asyncable::Mode::SetReplace); + + globalContext()->currentNotationChanged().onNotify(this, [this]() { + onCurrentNotationChanged(); + }, Asyncable::Mode::SetReplace); +} + +void NotationNoteOffsetController::onCurrentNotationChanged() +{ + rebuildAllOverlays(); + + if (score()) { + // TODO: More efficient if we only rebuild the affected staves/systems... + score()->changesChannel().onReceive(this, [this](const mu::engraving::ScoreChanges&) { + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } + + const INotationPtr notation = currentNotation(); + if (notation) { + // Switching between Page/Continuous/Continuous vertical view completely re-flows the + // systems - the overlays' cached positions need to be rebuilt from scratch, not just + // repositioned via the view matrix. + notation->viewModeChanged().onNotify(this, [this]() { + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } +} + +void NotationNoteOffsetController::scheduleRebuild() +{ + if (m_rebuildScheduled) { + return; + } + m_rebuildScheduled = true; + + // Defer to the next event loop iteration - the score may still be mid-layout at the + // point the changesChannel notification fires, so rebuilding synchronously here (which + // reads System/Segment/Chord layout data) is not safe. + muse::async::Async::call(this, [this]() { + m_rebuildScheduled = false; + if (noteOffsets() && noteOffsets()->isEditModeEnabled()) { + rebuildAllOverlays(); + } + }); +} + +void NotationNoteOffsetController::rebuildAllOverlays() +{ + for (const auto& [key, overlay] : m_overlaysByStaff) { + delete overlay; + } + m_overlaysByStaff.clear(); + m_notesByStaff.clear(); + m_bandRectByStaff.clear(); + m_noteLocations.clear(); + + if (!score()) { + // Happens on close... + return; + } + + for (const System* system : score()->systems()) { + staff_idx_t staffIdx = system->firstVisibleStaff(); + while (staffIdx != muse::nidx) { + createOverlayForStaff(system, staffIdx); + staffIdx = system->nextVisibleStaff(staffIdx); + } + } + + updateOverlaysGeometry(); +} + +void NotationNoteOffsetController::createOverlayForStaff(const System* system, staff_idx_t staffIdx) +{ + IF_ASSERT_FAILED(system && m_overlaysParent && score()) { + return; + } + + const Staff* staff = score()->staff(staffIdx); + const SysStaff* sysStaff = system->staff(staffIdx); + if (!staff || !sysStaff || !staff->isPrimaryStaff()) { + return; + } + + std::vector entries; + + const track_idx_t strack = staffIdx * VOICES; + const track_idx_t etrack = strack + VOICES; + + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(SegmentType::ChordRest) : nullptr; + seg && seg->system() == system; seg = seg->next1(SegmentType::ChordRest)) { + for (track_idx_t track = strack; track < etrack; ++track) { + EngravingItem* item = seg->element(track); + if (!item || !item->isChord()) { + continue; + } + const Chord* chord = toChord(item); + + // The nominal (zero-offset) span is anchored on the note/segment's own real layout + // position, not on a tick->x interpolation - this guarantees the rectangle sits + // exactly on the notehead when there's no offset yet. + const Segment* nextSeg = seg->next1(SegmentType::ChordRest); + const double nominalRightX = (nextSeg && nextSeg->system() == system) + ? nextSeg->canvasX() : (seg->canvasX() + seg->width()); + + for (Note* note : chord->notes()) { + NoteEntry entry; + entry.note = note; + entry.nominalLeftX = note->canvasX(); + entry.nominalRightX = nominalRightX; + entries.push_back(entry); + } + } + } + + if (entries.empty()) { + return; + } + + const double spatium = entries.front().note->spatium(); + const double topMargin = RECT_TOP_MARGIN_SP * spatium; + const double bottomOverlap = RECT_BOTTOM_OVERLAP_SP * spatium; + const double rectHeight = topMargin + bottomOverlap; + const double vPadding = 0.3 * spatium; + + // Anchored on each note's own vertical position, so the rectangle sits right above its + // notehead (and chord notes stack in pitch order without needing an artificial row index) + std::vector centerY; + centerY.reserve(entries.size()); + double minY = 0.0; + double maxY = 0.0; + for (size_t i = 0; i < entries.size(); ++i) { + const double noteY = entries[i].note->canvasPos().y(); + const double y = noteY - topMargin + rectHeight / 2.0; + centerY.push_back(y); + if (i == 0) { + minY = noteY - topMargin; + maxY = noteY + bottomOverlap; + } else { + minY = std::min(minY, noteY - topMargin); + maxY = std::max(maxY, noteY + bottomOverlap); + } + } + minY -= vPadding; + maxY += vPadding; + + // The overlay's vertical bounds are derived from the actual note positions rather than a + // fixed margin around the staff - this way it always contains every rectangle regardless of + // how far above/below the staff a note sits (ledger lines, etc.) + const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); + const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); + + QVector rects; + rects.reserve(static_cast(entries.size())); + + for (size_t i = 0; i < entries.size(); ++i) { + const NoteEntry& entry = entries[i]; + const Note* note = entry.note; + const Chord* chord = note->chord(); + IF_ASSERT_FAILED(chord) { + continue; + } + + // Fallback local px-per-tick rate, only used if a note's offset pushes it right at a + // system boundary where segment interpolation has nothing to anchor to. + const int chordTicks = chord->ticks().ticks(); + const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; + + const int chordStartTick = chord->tick().ticks(); + const int chordEndTick = chordStartTick + chordTicks; + const double leftPx = entry.nominalLeftX + + pixelDeltaForTickOffset(system, chordStartTick, note->playbackStartOffset(), fallbackPxPerTick); + const double rightPx = entry.nominalRightX + + pixelDeltaForTickOffset(system, chordEndTick, note->playbackDurationOffset(), fallbackPxPerTick); + + NoteOffsetOverlay::RectData rect; + rect.leftN = (leftPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.rightN = (rightPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.centerYN = (centerY[i] - overlayCanvasRect.y()) / overlayCanvasRect.height(); + rect.heightYN = rectHeight / overlayCanvasRect.height(); + rects.push_back(rect); + } + + if (rects.isEmpty()) { + return; + } + + const SysStaffKey key { system, staffIdx }; + for (int i = 0; i < static_cast(entries.size()); ++i) { + m_noteLocations[entries[i].note] = NoteLocation { key, i }; + } + m_notesByStaff[key] = std::move(entries); + m_bandRectByStaff[key] = overlayCanvasRect; + + NoteOffsetOverlay* overlay = new NoteOffsetOverlay(m_overlaysParent); + overlay->setRects(rects); + applyOverlayColors(overlay); + overlay->setVisible(false); + + QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { + onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); + }); + + m_overlaysByStaff[key] = overlay; +} + +void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay) const +{ + IF_ASSERT_FAILED(overlay) { + return; + } + + overlay->setFillColor(QColor(100, 150, 220, 60)); + overlay->setBorderColor(QColor(80, 130, 200, 200)); + overlay->setHandleColor(QColor(60, 110, 190, 230)); +} + +void NotationNoteOffsetController::updateOverlaysGeometry() +{ + const bool visible = noteOffsets() && noteOffsets()->isEditModeEnabled(); + + for (const auto& [key, overlay] : m_overlaysByStaff) { + overlay->setVisible(visible); + if (!visible) { + continue; + } + + const auto bandRectIt = m_bandRectByStaff.find(key); + IF_ASSERT_FAILED(bandRectIt != m_bandRectByStaff.end()) { + continue; + } + + const muse::RectF screenRect = m_viewMatrix.map(bandRectIt->second); + overlay->setWidth(screenRect.width()); + overlay->setHeight(screenRect.height()); + overlay->setX(screenRect.x()); + overlay->setY(screenRect.y()); + } +} + +void NotationNoteOffsetController::setViewMatrix(const muse::draw::Transform& viewMatrix) +{ + if (viewMatrix == m_viewMatrix) { + return; + } + m_viewMatrix = viewMatrix; + + if (noteOffsets() && noteOffsets()->isEditModeEnabled()) { + updateOverlaysGeometry(); + } +} + +std::vector NotationNoteOffsetController::selectedNotes() const +{ + const INotationPtr notation = currentNotation(); + if (!notation || !notation->interaction() || !notation->interaction()->selection()) { + return {}; + } + + return notation->interaction()->selection()->notes(); +} + +void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, int newStartOffset, int newDurationOffset) +{ + const auto notesIt = m_notesByStaff.find(location.key); + const auto bandRectIt = m_bandRectByStaff.find(location.key); + const auto overlayIt = m_overlaysByStaff.find(location.key); + IF_ASSERT_FAILED(notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() + && overlayIt != m_overlaysByStaff.end() && location.rectIndex >= 0 + && static_cast(location.rectIndex) < notesIt->second.size()) { + return; + } + + const NoteEntry& entry = notesIt->second.at(location.rectIndex); + const Chord* chord = entry.note ? entry.note->chord() : nullptr; + IF_ASSERT_FAILED(chord) { + return; + } + + const int chordTicks = chord->ticks().ticks(); + const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; + const int chordStartTick = chord->tick().ticks(); + const int chordEndTick = chordStartTick + chordTicks; + const double leftPx = entry.nominalLeftX + + pixelDeltaForTickOffset(location.key.system, chordStartTick, newStartOffset, fallbackPxPerTick); + const double rightPx = entry.nominalRightX + + pixelDeltaForTickOffset(location.key.system, chordEndTick, newDurationOffset, fallbackPxPerTick); + + QVector rects = overlayIt->second->rects(); + if (location.rectIndex >= rects.size()) { + return; + } + + NoteOffsetOverlay::RectData& rect = rects[location.rectIndex]; + rect.leftN = (leftPx - bandRectIt->second.x()) / bandRectIt->second.width(); + rect.rightN = (rightPx - bandRectIt->second.x()) / bandRectIt->second.width(); + overlayIt->second->setRects(rects); +} + +void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rectIndex, bool isLeftEdge, qreal newXN, bool completed) +{ + const auto notesIt = m_notesByStaff.find(key); + const auto bandRectIt = m_bandRectByStaff.find(key); + IF_ASSERT_FAILED(key.isValid() && notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() + && rectIndex >= 0 && static_cast(rectIndex) < notesIt->second.size()) { + return; + } + + const NoteEntry& draggedEntry = notesIt->second.at(rectIndex); + Note* draggedNote = draggedEntry.note; + Chord* draggedChord = draggedNote ? draggedNote->chord() : nullptr; + IF_ASSERT_FAILED(draggedNote && draggedChord) { + return; + } + + const std::optional newTick = noteOffsetTickFromCanvasX(key.system, bandRectIt->second, newXN); + if (!newTick) { + return; + } + + const int draggedChordStartTick = draggedChord->tick().ticks(); + const int draggedChordEndTick = draggedChordStartTick + draggedChord->ticks().ticks(); + + int newStartOffset = draggedNote->playbackStartOffset(); + int newDurationOffset = draggedNote->playbackDurationOffset(); + + if (isLeftEdge) { + newStartOffset = std::clamp(*newTick - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int effEnd = draggedChordEndTick + newDurationOffset; + if (effEnd - (draggedChordStartTick + newStartOffset) < MIN_EFFECTIVE_TICKS) { + newStartOffset = std::clamp(effEnd - MIN_EFFECTIVE_TICKS - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } else { + newDurationOffset = std::clamp(*newTick - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int effStart = draggedChordStartTick + newStartOffset; + if ((draggedChordEndTick + newDurationOffset) - effStart < MIN_EFFECTIVE_TICKS) { + newDurationOffset = std::clamp(effStart + MIN_EFFECTIVE_TICKS - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } + + // If the dragged note is part of a multi-note selection, apply the same tick delta to every + // other selected note's corresponding offset, each clamped independently. + const int delta = isLeftEdge ? (newStartOffset - draggedNote->playbackStartOffset()) + : (newDurationOffset - draggedNote->playbackDurationOffset()); + + std::vector affectedNotes { draggedNote }; + if (delta != 0 || !completed) { + const std::vector selected = selectedNotes(); + if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + affectedNotes = selected; + } + } + + struct PendingChange { + Note* note = nullptr; + int startOffset = 0; + int durationOffset = 0; + }; + std::vector changes; + changes.reserve(affectedNotes.size()); + + for (Note* note : affectedNotes) { + if (note == draggedNote) { + changes.push_back({ note, newStartOffset, newDurationOffset }); + continue; + } + + const Chord* chord = note->chord(); + if (!chord) { + continue; + } + + int otherStartOffset = note->playbackStartOffset(); + int otherDurationOffset = note->playbackDurationOffset(); + + if (isLeftEdge) { + otherStartOffset = std::clamp(otherStartOffset + delta, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int chordEndTick = chord->tick().ticks() + chord->ticks().ticks(); + const int effEnd = chordEndTick + otherDurationOffset; + if (effEnd - (chord->tick().ticks() + otherStartOffset) < MIN_EFFECTIVE_TICKS) { + otherStartOffset = std::clamp(effEnd - MIN_EFFECTIVE_TICKS - chord->tick().ticks(), + -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } else { + otherDurationOffset = std::clamp(otherDurationOffset + delta, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + const int chordStartTick = chord->tick().ticks(); + const int chordEndTick = chordStartTick + chord->ticks().ticks(); + const int effStart = chordStartTick + otherStartOffset; + if ((chordEndTick + otherDurationOffset) - effStart < MIN_EFFECTIVE_TICKS) { + otherDurationOffset = std::clamp(effStart + MIN_EFFECTIVE_TICKS - chordEndTick, + -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + } + } + + changes.push_back({ note, otherStartOffset, otherDurationOffset }); + } + + if (!completed) { + // Live drag preview - update every affected overlay's displayed rect without touching + // the score, anchored on the same nominal note positions used when overlays were built + for (const PendingChange& change : changes) { + const auto locIt = m_noteLocations.find(change.note); + if (locIt != m_noteLocations.end()) { + previewNoteRect(locIt->second, change.startOffset, change.durationOffset); + } + } + return; + } + + const INotationPtr notation = currentNotation(); + const INotationUndoStackPtr undoStack = notation ? notation->undoStack() : nullptr; + IF_ASSERT_FAILED(undoStack) { + return; + } + + undoStack->prepareChanges(muse::TranslatableString("undoableAction", "Change note playback offset")); + for (const PendingChange& change : changes) { + if (isLeftEdge) { + change.note->undoChangeProperty(mu::engraving::Pid::PLAYBACK_START_OFFSET, change.startOffset, + mu::engraving::PropertyFlags::NOSTYLE); + } else { + change.note->undoChangeProperty(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET, change.durationOffset, + mu::engraving::PropertyFlags::NOSTYLE); + } + } + undoStack->commitChanges(); +} + +INotationNoteOffsetsPtr NotationNoteOffsetController::noteOffsets() const +{ + const IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets() : nullptr; +} + +INotationPtr NotationNoteOffsetController::currentNotation() const +{ + return globalContext()->currentNotation(); +} + +mu::engraving::Score* NotationNoteOffsetController::score() const +{ + return currentNotation() ? currentNotation()->elements()->msScore() : nullptr; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h new file mode 100644 index 0000000000000..6687dfbe8de03 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "context/iglobalcontext.h" +#include "async/asyncable.h" +#include "notation/notationtypes.h" + +namespace mu::engraving { +struct ScoreChanges; +} + +namespace mu::notation { +class NoteOffsetOverlay; + +class NotationNoteOffsetController : public muse::Contextable, public muse::async::Asyncable +{ + muse::ContextInject globalContext = { this }; + +public: + NotationNoteOffsetController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx); + + void init(); + void setViewMatrix(const muse::draw::Transform& viewMatrix); + +private: + // Necessary since SysStaff doesn't hold a reference to its system, which is needed + // for calculating a SysStaff's relative position... + struct SysStaffKey { + const System* system = nullptr; + staff_idx_t staffIdx = muse::nidx; + + bool isValid() const + { + return system && !system->measures().empty() && staffIdx != muse::nidx; + } + + bool operator<(const SysStaffKey& k) const + { + if (system == k.system) { + return staffIdx < k.staffIdx; + } + return system->first()->index() < k.system->first()->index(); + } + }; + + // Nominal (zero-offset) canvas X positions, taken directly from the note's own layout - + // anchors the rectangle exactly on the notehead rather than relying on tick interpolation. + struct NoteEntry { + mu::engraving::Note* note = nullptr; + double nominalLeftX = 0.0; + double nominalRightX = 0.0; + }; + + // Where a given note's rectangle lives, so a drag on a multi-note selection can update/commit + // every selected note's overlay entry, not just the one under the mouse. + struct NoteLocation { + SysStaffKey key; + int rectIndex = -1; + }; + + using OverlaysMap = std::map; + using NotesByStaffMap = std::map >; + using BandRectByStaffMap = std::map; + using NoteLocationMap = std::map; + + void rebuildAllOverlays(); + void createOverlayForStaff(const System* system, staff_idx_t staffIdx); + void updateOverlaysGeometry(); + void applyOverlayColors(NoteOffsetOverlay* overlay) const; + + void onCurrentNotationChanged(); + void scheduleRebuild(); + void onEdgeDragged(const SysStaffKey& key, int rectIndex, bool isLeftEdge, qreal newXN, bool completed); + void previewNoteRect(const NoteLocation& location, int newStartOffset, int newDurationOffset); + + std::vector selectedNotes() const; + + INotationNoteOffsetsPtr noteOffsets() const; + INotationPtr currentNotation() const; + mu::engraving::Score* score() const; + + QQuickItem* m_overlaysParent = nullptr; + OverlaysMap m_overlaysByStaff; + NotesByStaffMap m_notesByStaff; + BandRectByStaffMap m_bandRectByStaff; + NoteLocationMap m_noteLocations; + muse::draw::Transform m_viewMatrix; + bool m_rebuildScheduled = false; +}; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp index 67b99af157f47..9789b0d08f4be 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp @@ -37,7 +37,8 @@ void NotationToolBarModel::load() muse::actions::ActionCodeList itemsCodes = { "parts", "toggle-mixer", - "toggle-automation" + "toggle-automation", + "toggle-note-offset-editor" }; ToolBarItemList items; diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp new file mode 100644 index 0000000000000..3df795f32e99b --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -0,0 +1,191 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "noteoffsetoverlay.h" + +#include +#include + +#include +#include +#include +#include + +using namespace mu::notation; + +constexpr static qreal EDGE_HANDLE_HIT_MARGIN_PX = 6.0; +constexpr static qreal EDGE_HANDLE_WIDTH_PX = 4.0; + +NoteOffsetOverlay::NoteOffsetOverlay(QQuickItem* parent) + : QQuickPaintedItem(parent) +{ + setAcceptHoverEvents(true); + setAcceptedMouseButtons(Qt::LeftButton); +} + +void NoteOffsetOverlay::setRects(const QVector& rects) +{ + m_rects = rects; + update(); +} + +const QVector& NoteOffsetOverlay::rects() const +{ + return m_rects; +} + +void NoteOffsetOverlay::setFillColor(const QColor& color) +{ + m_fillColor = color; + update(); +} + +void NoteOffsetOverlay::setBorderColor(const QColor& color) +{ + m_borderColor = color; + update(); +} + +void NoteOffsetOverlay::setHandleColor(const QColor& color) +{ + m_handleColor = color; + update(); +} + +void NoteOffsetOverlay::paint(QPainter* painter) +{ + if (m_rects.isEmpty()) { + return; + } + + painter->setRenderHint(QPainter::Antialiasing); + + for (const RectData& rect : m_rects) { + const qreal leftPx = rect.leftN * width(); + const qreal rightPx = rect.rightN * width(); + const qreal centerYPx = rect.centerYN * height(); + const qreal halfHeightPx = (rect.heightYN * height()) / 2.0; + + const QRectF bodyRect(leftPx, centerYPx - halfHeightPx, rightPx - leftPx, halfHeightPx * 2.0); + + // Fully-rounded "pill" ends - radius tied to the rectangle's own height so it stays + // consistent at any zoom level or rectangle size, rather than a fixed pixel amount. + const qreal cornerRadius = std::min(halfHeightPx, bodyRect.width() / 2.0); + + painter->setPen(QPen(m_borderColor, 1.0)); + painter->setBrush(m_fillColor); + painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); + + painter->setPen(Qt::NoPen); + painter->setBrush(m_handleColor); + painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + } +} + +NoteOffsetOverlay::HitResult NoteOffsetOverlay::hitTestPx(const QPointF& posPx) const +{ + for (int i = 0; i < m_rects.size(); ++i) { + const RectData& rect = m_rects.at(i); + const qreal centerYPx = rect.centerYN * height(); + const qreal halfHeightPx = (rect.heightYN * height()) / 2.0 + EDGE_HANDLE_HIT_MARGIN_PX; + if (posPx.y() < centerYPx - halfHeightPx || posPx.y() > centerYPx + halfHeightPx) { + continue; + } + + const qreal leftPx = rect.leftN * width(); + const qreal rightPx = rect.rightN * width(); + + const qreal distToLeft = std::abs(posPx.x() - leftPx); + const qreal distToRight = std::abs(posPx.x() - rightPx); + + if (distToLeft > EDGE_HANDLE_HIT_MARGIN_PX && distToRight > EDGE_HANDLE_HIT_MARGIN_PX) { + continue; + } + + HitResult hit; + hit.rectIndex = i; + hit.isLeftEdge = distToLeft <= distToRight; + return hit; + } + + return HitResult(); +} + +void NoteOffsetOverlay::updateCursor(bool hoveringEdge) +{ + if (hoveringEdge == m_hoveringEdge) { + return; + } + m_hoveringEdge = hoveringEdge; + setCursor(hoveringEdge ? Qt::SizeHorCursor : Qt::ArrowCursor); +} + +void NoteOffsetOverlay::hoverMoveEvent(QHoverEvent* e) +{ + const HitResult hit = hitTestPx(e->position()); + updateCursor(hit.isValid()); +} + +void NoteOffsetOverlay::hoverLeaveEvent(QHoverEvent*) +{ + updateCursor(false); +} + +void NoteOffsetOverlay::mousePressEvent(QMouseEvent* e) +{ + const HitResult hit = hitTestPx(e->position()); + if (!hit.isValid()) { + e->ignore(); + return; + } + + m_pressed = true; + m_activeRectIndex = hit.rectIndex; + m_activeIsLeftEdge = hit.isLeftEdge; + e->accept(); +} + +void NoteOffsetOverlay::mouseMoveEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + const qreal xN = std::clamp(e->position().x() / std::max(1.0, width()), 0.0, 1.0); + emit edgeDragged(m_activeRectIndex, m_activeIsLeftEdge, xN, false); +} + +void NoteOffsetOverlay::mouseReleaseEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + const qreal xN = std::clamp(e->position().x() / std::max(1.0, width()), 0.0, 1.0); + emit edgeDragged(m_activeRectIndex, m_activeIsLeftEdge, xN, true); + + m_pressed = false; + m_activeRectIndex = -1; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h new file mode 100644 index 0000000000000..01dc65b53e160 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +// NOTE: all rectangle coordinates are normalized [0, 1], relative to this item's own width/height, +// mirroring the approach used by muse::uicomponents::PolylinePlot for the automation overlay - this +// keeps stored positions valid regardless of the live view/zoom transform applied to the item itself. + +namespace mu::notation { +class NoteOffsetOverlay : public QQuickPaintedItem +{ + Q_OBJECT + +public: + struct RectData { + qreal leftN = 0.0; + qreal rightN = 0.0; + qreal centerYN = 0.5; + qreal heightYN = 1.0; + }; + + explicit NoteOffsetOverlay(QQuickItem* parent); + + void setRects(const QVector& rects); + const QVector& rects() const; + + void setFillColor(const QColor& color); + void setBorderColor(const QColor& color); + void setHandleColor(const QColor& color); + + void paint(QPainter* painter) override; + +signals: + void edgeDragged(int rectIndex, bool isLeftEdge, qreal newXN, bool completed); + +protected: + void hoverMoveEvent(QHoverEvent* e) override; + void hoverLeaveEvent(QHoverEvent* e) override; + void mousePressEvent(QMouseEvent* e) override; + void mouseMoveEvent(QMouseEvent* e) override; + void mouseReleaseEvent(QMouseEvent* e) override; + +private: + struct HitResult { + int rectIndex = -1; + bool isLeftEdge = false; + + bool isValid() const { return rectIndex >= 0; } + }; + + HitResult hitTestPx(const QPointF& posPx) const; + void updateCursor(bool hoveringEdge); + + QVector m_rects; + + QColor m_fillColor; + QColor m_borderColor; + QColor m_handleColor; + + bool m_pressed = false; + int m_activeRectIndex = -1; + bool m_activeIsLeftEdge = false; + bool m_hoveringEdge = false; +}; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp new file mode 100644 index 0000000000000..0bff67dadf0c9 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.cpp @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "segmentcanvasinterpolation.h" + +#include "engraving/dom/segment.h" +#include "engraving/dom/system.h" + +using namespace mu::notation; +using namespace mu::engraving; + +std::optional mu::notation::tickFromCanvasX(const System* system, double canvasX) +{ + IF_ASSERT_FAILED(system) { + return std::nullopt; + } + + const SegmentType type = SegmentType::Duration | SegmentType::BarLineTypes; + + const Segment* prevSeg = nullptr; + const Segment* nextSeg = nullptr; + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr; + seg && seg->system() == system; seg = seg->next1(type)) { + if (seg->canvasX() <= canvasX) { + prevSeg = seg; + } else { + nextSeg = seg; + break; + } + } + + if (!prevSeg) { + return nextSeg ? std::make_optional(nextSeg->tick().ticks()) : std::nullopt; + } + + const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width(); + const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks(); + const double canvasSpan = nextCanvasX - prevSeg->canvasX(); + const double ratio = canvasSpan > 0.0 ? (canvasX - prevSeg->canvasX()) / canvasSpan : 0.0; + + return prevSeg->tick().ticks() + static_cast(ratio * (nextTick - prevSeg->tick().ticks())); +} + +std::optional mu::notation::canvasXFromTick(const System* system, int tick) +{ + IF_ASSERT_FAILED(system) { + return std::nullopt; + } + + const SegmentType type = SegmentType::Duration | SegmentType::BarLineTypes; + + const Segment* prevSeg = nullptr; + const Segment* nextSeg = nullptr; + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(type) : nullptr; + seg && seg->system() == system; seg = seg->next1(type)) { + if (seg->tick().ticks() <= tick) { + prevSeg = seg; + } else { + nextSeg = seg; + break; + } + } + + if (!prevSeg) { + return nextSeg ? std::make_optional(nextSeg->canvasX()) : std::nullopt; + } + + const int nextTick = nextSeg ? nextSeg->tick().ticks() : prevSeg->tick().ticks() + prevSeg->ticks().ticks(); + const double nextCanvasX = nextSeg ? nextSeg->canvasX() : prevSeg->canvasX() + prevSeg->width(); + const int tickSpan = nextTick - prevSeg->tick().ticks(); + const double ratio = tickSpan > 0 ? static_cast(tick - prevSeg->tick().ticks()) / tickSpan : 0.0; + + return prevSeg->canvasX() + ratio * (nextCanvasX - prevSeg->canvasX()); +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h new file mode 100644 index 0000000000000..8e26fae378922 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/segmentcanvasinterpolation.h @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include + +#include "notation/notationtypes.h" + +// Shared canvasX<->tick interpolation used by overlay controllers (automation, note offsets) to +// translate between a mouse/canvas X position and a musical tick, and back. Both directions +// interpolate linearly between the nearest Duration/barline segments on either side of the point, +// so a caller that uses one direction to interpret input and the other to render output gets +// values that round-trip exactly. + +namespace mu::notation { +std::optional tickFromCanvasX(const System* system, double canvasX); +std::optional canvasXFromTick(const System* system, int tick); +} From b84570b517d6e77b45a2698e8b43f627028b625b Mon Sep 17 00:00:00 2001 From: sfer Date: Thu, 13 Aug 2026 17:58:24 +0200 Subject: [PATCH 04/36] Rebuild note-offset overlay geometry on style changes Live-dragging a style value (e.g. "Staff space (sp)" in Page Settings) relayouts the score without going through changesChannel(), so the overlay's cached note positions went stale and stopped tracking the rescaled notation in real time. --- .../NotationScene/notationnoteoffsetcontroller.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index a142818a8ed81..ed417a217b625 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -45,6 +45,7 @@ #include "notation/inotationinteraction.h" #include "notation/inotationnoteoffsets.h" #include "notation/inotationselection.h" +#include "notation/inotationstyle.h" #include "notation/inotationundostack.h" #include "notation/inotationelements.h" // IWYU pragma: keep @@ -130,6 +131,15 @@ void NotationNoteOffsetController::onCurrentNotationChanged() notation->viewModeChanged().onNotify(this, [this]() { scheduleRebuild(); }, Asyncable::Mode::SetReplace); + + if (notation->style()) { + // Style edits (e.g. live-dragging "Staff space (sp)" in Page Settings) relayout the + // score without necessarily going through changesChannel() - without this, the + // overlay's cached note positions go stale and stop tracking the rescaled notation. + notation->style()->styleChanged().onNotify(this, [this]() { + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } } } From 009a62839dec1f34d3f8c91103c92f2ce4369ac0 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:04:52 +0200 Subject: [PATCH 05/36] Fix repeat/volta playback timing and start/duration clamp mismatch timestampAndDurationFromStartAndDurationTicks() was called with a hardcoded tick-position offset of 0 instead of ctx.positionTickOffset, so every note (not just ones with a non-zero playback offset) played at first-playthrough timing on repeat/volta/D.C. passes. effectivePlaybackDuration() also independently recomputed from the raw, unclamped playbackStartOffset() instead of the same (possibly clamped) start effectivePlaybackStartTime() returns, so the two could disagree once the start clamp kicked in, making the note play longer than its clamped start implied. --- src/engraving/dom/note.cpp | 7 ++++++- src/engraving/playback/renderers/noterenderer.cpp | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/engraving/dom/note.cpp b/src/engraving/dom/note.cpp index e6dbda70900d9..0238db78180b7 100644 --- a/src/engraving/dom/note.cpp +++ b/src/engraving/dom/note.cpp @@ -4223,7 +4223,12 @@ int Note::effectivePlaybackDuration() const if (!ch) { return 0; } - return std::max(1, ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset()); + // Derive from the same (possibly clamped) start effectivePlaybackStartTime() returns, rather + // than recomputing independently from the raw offsets - otherwise the two can disagree once + // the start clamp kicks in, and the note would end up playing longer than its clamped start + // implies. + const int nominalEndTick = ch->tick().ticks() + ch->ticks().ticks() + playbackDurationOffset(); + return std::max(1, nominalEndTick - effectivePlaybackStartTime()); } //--------------------------------------------------------- diff --git a/src/engraving/playback/renderers/noterenderer.cpp b/src/engraving/playback/renderers/noterenderer.cpp index ae1fb25c30c40..ee4341b7602e3 100644 --- a/src/engraving/playback/renderers/noterenderer.cpp +++ b/src/engraving/playback/renderers/noterenderer.cpp @@ -131,7 +131,7 @@ void NoteRenderer::render(const Note* note, const RenderingContext& ctx, mpe::Pl int startTicks = note->effectivePlaybackStartTime(); int durationTicks = note->effectivePlaybackDuration(); - auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, startTicks, durationTicks, 0); + auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, startTicks, durationTicks, ctx.positionTickOffset); noteCtx.timestamp = effectiveTnD.timestamp; noteCtx.duration = effectiveTnD.duration; From b74429ca39a7303c33e37945b8e827c57bf4360a Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:07:20 +0200 Subject: [PATCH 06/36] Fix crash and lifecycle bugs in the note-offset drag-handle overlay - Compare SysStaffKey's System pointer by address only, never dereferencing it - a stale key still held from a previous rebuild had a dangling System* once Page/Continuous view mode switching destroyed and recreated every System, crashing on MeasureBase::index(). - Skip tied-continuation notes when building overlay handles: their own playback offset is ignored by NoteRenderer::shouldRender() in most cases, so a handle there could never actually do anything. - Disconnect from a document's viewModeChanged/styleChanged the moment a different document becomes current, instead of leaking one subscription per document ever opened in the session. - Don't delete an overlay that's mid-drag (holding the mouse grab) when a rebuild is triggered; defer until the drag finishes. - Handle mouseUngrabEvent so a stolen mouse grab mid-drag doesn't leave the overlay stuck thinking a drag is still in progress. - Consolidate the three parallel per-staff maps (overlay/notes/band rect) into one map to a per-staff struct, and reuse an existing overlay item in place instead of destroying and recreating every overlay on every rebuild. - Mutate a single rect in place during a drag instead of copying the whole staff's rect vector out and back on every mouse-move. --- .../notationnoteoffsetcontroller.cpp | 157 ++++++++++++------ .../notationnoteoffsetcontroller.h | 29 +++- .../NotationScene/noteoffsetoverlay.cpp | 19 +++ .../NotationScene/noteoffsetoverlay.h | 8 + 4 files changed, 151 insertions(+), 62 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index ed417a217b625..1801b6a5382ef 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -116,19 +116,31 @@ void NotationNoteOffsetController::onCurrentNotationChanged() { rebuildAllOverlays(); - if (score()) { + if (mu::engraving::Score* thisScore = score()) { // TODO: More efficient if we only rebuild the affected staves/systems... - score()->changesChannel().onReceive(this, [this](const mu::engraving::ScoreChanges&) { + // SetReplace only dedupes a subscription against the exact same Score/Notation instance - + // switching documents subscribes to a brand new instance each time, so guard the callback + // itself against firing for a document that's no longer current, rather than leaking one + // live subscription per every document ever opened this session. + score()->changesChannel().onReceive(this, [this, thisScore](const mu::engraving::ScoreChanges&) { + if (thisScore != score()) { + return; + } scheduleRebuild(); }, Asyncable::Mode::SetReplace); } const INotationPtr notation = currentNotation(); if (notation) { + mu::notation::INotation* thisNotation = notation.get(); + // Switching between Page/Continuous/Continuous vertical view completely re-flows the // systems - the overlays' cached positions need to be rebuilt from scratch, not just // repositioned via the view matrix. - notation->viewModeChanged().onNotify(this, [this]() { + notation->viewModeChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } scheduleRebuild(); }, Asyncable::Mode::SetReplace); @@ -136,7 +148,10 @@ void NotationNoteOffsetController::onCurrentNotationChanged() // Style edits (e.g. live-dragging "Staff space (sp)" in Page Settings) relayout the // score without necessarily going through changesChannel() - without this, the // overlay's cached note positions go stale and stop tracking the rescaled notation. - notation->style()->styleChanged().onNotify(this, [this]() { + notation->style()->styleChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } scheduleRebuild(); }, Asyncable::Mode::SetReplace); } @@ -163,31 +178,52 @@ void NotationNoteOffsetController::scheduleRebuild() void NotationNoteOffsetController::rebuildAllOverlays() { - for (const auto& [key, overlay] : m_overlaysByStaff) { - delete overlay; + for (const auto& [key, data] : m_overlaysByStaff) { + if (data.overlay->isDragging()) { + // Deleting an overlay that currently holds the mouse grab (mid-drag) would drop the + // in-progress edit and risk delivering the next mouse event to a freed item - wait + // for the drag to finish instead of rebuilding out from under it. + scheduleRebuild(); + return; + } } - m_overlaysByStaff.clear(); - m_notesByStaff.clear(); - m_bandRectByStaff.clear(); + m_noteLocations.clear(); if (!score()) { // Happens on close... + for (const auto& [key, data] : m_overlaysByStaff) { + delete data.overlay; + } + m_overlaysByStaff.clear(); return; } + // createOverlayForStaff reuses an existing overlay item in place (just updating its rects) + // when a staff already had one, instead of destroying and recreating every overlay QQuickItem + // on every edit - it consumes matching entries out of m_overlaysByStaff as it goes, so + // whatever is left there afterwards belongs to a staff that's no longer visible/primary/has + // no offsettable notes anymore, and can be deleted. + OverlaysMap newOverlays; + for (const System* system : score()->systems()) { staff_idx_t staffIdx = system->firstVisibleStaff(); while (staffIdx != muse::nidx) { - createOverlayForStaff(system, staffIdx); + createOverlayForStaff(system, staffIdx, newOverlays); staffIdx = system->nextVisibleStaff(staffIdx); } } + for (const auto& [key, data] : m_overlaysByStaff) { + delete data.overlay; + } + + m_overlaysByStaff = std::move(newOverlays); + updateOverlaysGeometry(); } -void NotationNoteOffsetController::createOverlayForStaff(const System* system, staff_idx_t staffIdx) +void NotationNoteOffsetController::createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays) { IF_ASSERT_FAILED(system && m_overlaysParent && score()) { return; @@ -221,6 +257,13 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s ? nextSeg->canvasX() : (seg->canvasX() + seg->width()); for (Note* note : chord->notes()) { + if (note->tieBack()) { + // Playback (NoteRenderer::shouldRender) skips tied-continuation notes + // entirely in most cases, so their own offset would silently do nothing - + // don't offer a handle that can't actually affect anything. + continue; + } + NoteEntry entry; entry.note = note; entry.nominalLeftX = note->canvasX(); @@ -306,19 +349,32 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s for (int i = 0; i < static_cast(entries.size()); ++i) { m_noteLocations[entries[i].note] = NoteLocation { key, i }; } - m_notesByStaff[key] = std::move(entries); - m_bandRectByStaff[key] = overlayCanvasRect; - - NoteOffsetOverlay* overlay = new NoteOffsetOverlay(m_overlaysParent); - overlay->setRects(rects); - applyOverlayColors(overlay); - overlay->setVisible(false); - - QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { - onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); - }); - m_overlaysByStaff[key] = overlay; + NoteOffsetOverlay* overlay = nullptr; + const auto oldIt = m_overlaysByStaff.find(key); + if (oldIt != m_overlaysByStaff.end()) { + // Reuse the existing overlay item in place rather than destroying and recreating it - + // its drag-signal connection (bound to this same key) is still valid. + overlay = oldIt->second.overlay; + overlay->setRects(rects); + m_overlaysByStaff.erase(oldIt); + } else { + overlay = new NoteOffsetOverlay(m_overlaysParent); + overlay->setRects(rects); + applyOverlayColors(overlay); + overlay->setVisible(false); + + QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, + [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { + onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); + }); + } + + StaffOverlayData data; + data.overlay = overlay; + data.notes = std::move(entries); + data.bandRect = overlayCanvasRect; + newOverlays[key] = std::move(data); } void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay) const @@ -336,22 +392,17 @@ void NotationNoteOffsetController::updateOverlaysGeometry() { const bool visible = noteOffsets() && noteOffsets()->isEditModeEnabled(); - for (const auto& [key, overlay] : m_overlaysByStaff) { - overlay->setVisible(visible); + for (const auto& [key, data] : m_overlaysByStaff) { + data.overlay->setVisible(visible); if (!visible) { continue; } - const auto bandRectIt = m_bandRectByStaff.find(key); - IF_ASSERT_FAILED(bandRectIt != m_bandRectByStaff.end()) { - continue; - } - - const muse::RectF screenRect = m_viewMatrix.map(bandRectIt->second); - overlay->setWidth(screenRect.width()); - overlay->setHeight(screenRect.height()); - overlay->setX(screenRect.x()); - overlay->setY(screenRect.y()); + const muse::RectF screenRect = m_viewMatrix.map(data.bandRect); + data.overlay->setWidth(screenRect.width()); + data.overlay->setHeight(screenRect.height()); + data.overlay->setX(screenRect.x()); + data.overlay->setY(screenRect.y()); } } @@ -379,16 +430,14 @@ std::vector NotationNoteOffsetController::selectedNotes() void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, int newStartOffset, int newDurationOffset) { - const auto notesIt = m_notesByStaff.find(location.key); - const auto bandRectIt = m_bandRectByStaff.find(location.key); - const auto overlayIt = m_overlaysByStaff.find(location.key); - IF_ASSERT_FAILED(notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() - && overlayIt != m_overlaysByStaff.end() && location.rectIndex >= 0 - && static_cast(location.rectIndex) < notesIt->second.size()) { + const auto dataIt = m_overlaysByStaff.find(location.key); + IF_ASSERT_FAILED(dataIt != m_overlaysByStaff.end() && location.rectIndex >= 0 + && static_cast(location.rectIndex) < dataIt->second.notes.size()) { return; } + const StaffOverlayData& data = dataIt->second; - const NoteEntry& entry = notesIt->second.at(location.rectIndex); + const NoteEntry& entry = data.notes.at(location.rectIndex); const Chord* chord = entry.note ? entry.note->chord() : nullptr; IF_ASSERT_FAILED(chord) { return; @@ -403,34 +452,36 @@ void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, const double rightPx = entry.nominalRightX + pixelDeltaForTickOffset(location.key.system, chordEndTick, newDurationOffset, fallbackPxPerTick); - QVector rects = overlayIt->second->rects(); + const QVector& rects = data.overlay->rects(); if (location.rectIndex >= rects.size()) { return; } - NoteOffsetOverlay::RectData& rect = rects[location.rectIndex]; - rect.leftN = (leftPx - bandRectIt->second.x()) / bandRectIt->second.width(); - rect.rightN = (rightPx - bandRectIt->second.x()) / bandRectIt->second.width(); - overlayIt->second->setRects(rects); + // Single-struct copy plus an in-place update, instead of copying the whole staff's rect + // vector out and back on every mouse-move during a drag. + NoteOffsetOverlay::RectData rect = rects.at(location.rectIndex); + rect.leftN = (leftPx - data.bandRect.x()) / data.bandRect.width(); + rect.rightN = (rightPx - data.bandRect.x()) / data.bandRect.width(); + data.overlay->updateRect(location.rectIndex, rect); } void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { - const auto notesIt = m_notesByStaff.find(key); - const auto bandRectIt = m_bandRectByStaff.find(key); - IF_ASSERT_FAILED(key.isValid() && notesIt != m_notesByStaff.end() && bandRectIt != m_bandRectByStaff.end() - && rectIndex >= 0 && static_cast(rectIndex) < notesIt->second.size()) { + const auto dataIt = m_overlaysByStaff.find(key); + IF_ASSERT_FAILED(key.isValid() && dataIt != m_overlaysByStaff.end() + && rectIndex >= 0 && static_cast(rectIndex) < dataIt->second.notes.size()) { return; } + const StaffOverlayData& data = dataIt->second; - const NoteEntry& draggedEntry = notesIt->second.at(rectIndex); + const NoteEntry& draggedEntry = data.notes.at(rectIndex); Note* draggedNote = draggedEntry.note; Chord* draggedChord = draggedNote ? draggedNote->chord() : nullptr; IF_ASSERT_FAILED(draggedNote && draggedChord) { return; } - const std::optional newTick = noteOffsetTickFromCanvasX(key.system, bandRectIt->second, newXN); + const std::optional newTick = noteOffsetTickFromCanvasX(key.system, data.bandRect, newXN); if (!newTick) { return; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h index 6687dfbe8de03..9ce43dcc789ea 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -61,10 +61,16 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn bool operator<(const SysStaffKey& k) const { - if (system == k.system) { - return staffIdx < k.staffIdx; + // Compare the System pointer by address only - never dereference it here. This key + // is looked up against entries left over from a previous rebuild (to reuse an + // existing overlay item instead of recreating it), and a view mode switch + // (Page <-> Continuous) destroys and recreates every System, so a stale key still + // sitting in the map at that point has a dangling `system` - dereferencing it (as + // `system->first()->index()` used to) is a use-after-free/crash. + if (system != k.system) { + return system < k.system; } - return system->first()->index() < k.system->first()->index(); + return staffIdx < k.staffIdx; } }; @@ -83,13 +89,20 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn int rectIndex = -1; }; - using OverlaysMap = std::map; - using NotesByStaffMap = std::map >; - using BandRectByStaffMap = std::map; + // The overlay item, its notes and its canvas-space band rect were previously three separate + // maps kept in lockstep by every add/remove/clear - a single map to this struct removes the + // risk of them silently desyncing for a staff. + struct StaffOverlayData { + NoteOffsetOverlay* overlay = nullptr; + std::vector notes; + muse::RectF bandRect; + }; + + using OverlaysMap = std::map; using NoteLocationMap = std::map; void rebuildAllOverlays(); - void createOverlayForStaff(const System* system, staff_idx_t staffIdx); + void createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays); void updateOverlaysGeometry(); void applyOverlayColors(NoteOffsetOverlay* overlay) const; @@ -106,8 +119,6 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn QQuickItem* m_overlaysParent = nullptr; OverlaysMap m_overlaysByStaff; - NotesByStaffMap m_notesByStaff; - BandRectByStaffMap m_bandRectByStaff; NoteLocationMap m_noteLocations; muse::draw::Transform m_viewMatrix; bool m_rebuildScheduled = false; diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 3df795f32e99b..7c18ecc62f743 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -53,6 +53,16 @@ const QVector& NoteOffsetOverlay::rects() const return m_rects; } +void NoteOffsetOverlay::updateRect(int index, const RectData& rect) +{ + if (index < 0 || index >= m_rects.size()) { + return; + } + + m_rects[index] = rect; + update(); +} + void NoteOffsetOverlay::setFillColor(const QColor& color) { m_fillColor = color; @@ -189,3 +199,12 @@ void NoteOffsetOverlay::mouseReleaseEvent(QMouseEvent* e) m_pressed = false; m_activeRectIndex = -1; } + +void NoteOffsetOverlay::mouseUngrabEvent() +{ + // The mouse grab taken in mousePressEvent can be stolen mid-drag (e.g. a popup opening) - + // without this, mouseReleaseEvent never fires and this item is left thinking a drag is still + // active. Treat it as a cancel rather than guessing a commit at an unknown final position. + m_pressed = false; + m_activeRectIndex = -1; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index 01dc65b53e160..0d1ddfeca7ca2 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -48,12 +48,19 @@ class NoteOffsetOverlay : public QQuickPaintedItem void setRects(const QVector& rects); const QVector& rects() const; + // Mutates a single rect in place, avoiding a full-vector copy-out/copy-back - used for live + // preview during a drag and for selection-highlight updates, both of which only ever touch a + // handful of rects at a time even on a staff with many notes. + void updateRect(int index, const RectData& rect); + void setFillColor(const QColor& color); void setBorderColor(const QColor& color); void setHandleColor(const QColor& color); void paint(QPainter* painter) override; + bool isDragging() const { return m_pressed; } + signals: void edgeDragged(int rectIndex, bool isLeftEdge, qreal newXN, bool completed); @@ -63,6 +70,7 @@ class NoteOffsetOverlay : public QQuickPaintedItem void mousePressEvent(QMouseEvent* e) override; void mouseMoveEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override; + void mouseUngrabEvent() override; private: struct HitResult { From b0d68ec778cea4f7073674585406a38e6659fcf7 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:09:56 +0200 Subject: [PATCH 07/36] Add drag-handle overlay to edit per-note velocities Mirrors the note-offset drag-handle overlay's architecture with a NoteVelocityOverlay/NotationNoteVelocityController pair: a draggable vertical bar per note maps its position between the staff's bottom line (velocity 0) and where a 5th line would sit if the staff had one (velocity 127), so it works the same way on non-5-line staves (e.g. 1-line percussion) as on a standard staff. Chord notes stack their bars by pitch (lowest-pitched note painted frontmost) instead of offsetting horizontally, matching how DAW piano rolls stack overlapping velocity lanes. Dragging one note in a multi-note selection applies the same delta to every selected note, including ones hidden behind others in the same chord's stack - only what's selected moves. Unedited notes display the velocity their dynamics marking/hairpin context alone would produce at that exact tick (via new PlaybackModel::appliableDynamicLevel(), exposed through INotationPlayback and converted with a shared muse::mpe::dynamicLevelToVelocityRatio() curve) rather than a flat default, so nudging one starts from a musically coherent baseline. VeloType::OFFSET_VAL notes (a percentage nudge on the dynamics baseline, distinct from an absolute VeloType::USER_VAL override) are read correctly rather than treated as an absolute value. Bars are colored to show state at a glance: green for the dynamics-derived default, orange for a user-modified velocity, blue when selected. Requires the companion musescore/muse_framework fix that forwards per-note velocity overrides to MuseSampler's main playback stream (without it, per-note velocity is audible via the legacy/FluidSynth sound library but not via MuseSampler-hosted MuseSounds). --- src/engraving/playback/playbackmodel.cpp | 10 + src/engraving/playback/playbackmodel.h | 2 + src/notation/CMakeLists.txt | 3 + src/notation/imasternotation.h | 1 + src/notation/inotation_fwd.h | 3 + src/notation/inotationnotevelocity.h | 39 ++ src/notation/inotationplayback.h | 4 + src/notation/internal/masternotation.cpp | 7 + src/notation/internal/masternotation.h | 2 + .../internal/notationnotevelocity.cpp | 44 ++ src/notation/internal/notationnotevelocity.h | 40 ++ src/notation/internal/notationplayback.cpp | 5 + src/notation/internal/notationplayback.h | 2 + .../internal/notationplaybackstub.cpp | 5 + src/notation/internal/notationplaybackstub.h | 2 + .../inotationcommandscontroller.h | 3 + .../internal/notationactioncontroller.cpp | 30 + .../internal/notationactioncontroller.h | 5 + .../internal/notationcommandsregister.cpp | 7 + .../internal/notationcommandsstate.cpp | 8 + .../internal/notationuiactions.cpp | 20 + src/notationscene/notationcommands.h | 1 + .../MuseScore/NotationScene/CMakeLists.txt | 6 + .../abstractnotationpaintview.cpp | 35 +- .../NotationScene/abstractnotationpaintview.h | 4 + .../notationnotevelocitycontroller.cpp | 564 ++++++++++++++++++ .../notationnotevelocitycontroller.h | 137 +++++ .../NotationScene/notationtoolbarmodel.cpp | 3 +- .../NotationScene/notevelocitygeometry.cpp | 76 +++ .../NotationScene/notevelocitygeometry.h | 43 ++ .../NotationScene/notevelocityoverlay.cpp | 192 ++++++ .../NotationScene/notevelocityoverlay.h | 96 +++ 32 files changed, 1397 insertions(+), 2 deletions(-) create mode 100644 src/notation/inotationnotevelocity.h create mode 100644 src/notation/internal/notationnotevelocity.cpp create mode 100644 src/notation/internal/notationnotevelocity.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp create mode 100644 src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h diff --git a/src/engraving/playback/playbackmodel.cpp b/src/engraving/playback/playbackmodel.cpp index 20af572487ce8..0290073239e2b 100644 --- a/src/engraving/playback/playbackmodel.cpp +++ b/src/engraving/playback/playbackmodel.cpp @@ -396,6 +396,16 @@ muse::async::Channel PlaybackModel::trackRemoved() const return m_trackRemoved; } +dynamic_level_t PlaybackModel::appliableDynamicLevel(track_idx_t trackIdx, int tick) const +{ + if (!m_playbackCtx) { + return dynamicLevelFromType(muse::mpe::DynamicType::Natural); + } + + const int utick = repeatList().tick2utick(tick); + return m_playbackCtx->appliableDynamicLevel(trackIdx, utick); +} + void PlaybackModel::update(const int tickFrom, const int tickTo, const track_idx_t trackFrom, const track_idx_t trackTo, ChangedTrackIdSet* trackChanges) { diff --git a/src/engraving/playback/playbackmodel.h b/src/engraving/playback/playbackmodel.h index 983a40f67f731..9ef8472e48c0f 100644 --- a/src/engraving/playback/playbackmodel.h +++ b/src/engraving/playback/playbackmodel.h @@ -93,6 +93,8 @@ class PlaybackModel : public muse::Contextable, public muse::async::Asyncable muse::async::Channel trackAdded() const; muse::async::Channel trackRemoved() const; + muse::mpe::dynamic_level_t appliableDynamicLevel(track_idx_t trackIdx, int tick) const; + private: static const InstrumentTrackId METRONOME_TRACK_ID; static const InstrumentTrackId CHORD_SYMBOLS_TRACK_ID; diff --git a/src/notation/CMakeLists.txt b/src/notation/CMakeLists.txt index 8505fa6c06256..c60b68f41ea7b 100644 --- a/src/notation/CMakeLists.txt +++ b/src/notation/CMakeLists.txt @@ -37,6 +37,7 @@ target_sources(notation PRIVATE inotationselectionrange.h inotationautomation.h inotationnoteoffsets.h + inotationnotevelocity.h inotationinteraction.h inotationstyle.h inotationundostack.h @@ -86,6 +87,8 @@ target_sources(notation PRIVATE internal/notationautomation.h internal/notationnoteoffsets.cpp internal/notationnoteoffsets.h + internal/notationnotevelocity.cpp + internal/notationnotevelocity.h internal/notationelements.cpp internal/notationelements.h internal/notationinteraction.cpp diff --git a/src/notation/imasternotation.h b/src/notation/imasternotation.h index 36413d0060014..6d6f3e5edd5c3 100644 --- a/src/notation/imasternotation.h +++ b/src/notation/imasternotation.h @@ -73,6 +73,7 @@ class IMasterNotation virtual INotationAutomationPtr automation() const = 0; virtual INotationNoteOffsetsPtr noteOffsets() const = 0; + virtual INotationNoteVelocityPtr noteVelocity() const = 0; }; using IMasterNotationPtr = std::shared_ptr; diff --git a/src/notation/inotation_fwd.h b/src/notation/inotation_fwd.h index 02a0a182e888f..1d092b2cb2b18 100644 --- a/src/notation/inotation_fwd.h +++ b/src/notation/inotation_fwd.h @@ -87,4 +87,7 @@ using INotationAutomationPtr = std::shared_ptr; class INotationNoteOffsets; using INotationNoteOffsetsPtr = std::shared_ptr; + +class INotationNoteVelocity; +using INotationNoteVelocityPtr = std::shared_ptr; } diff --git a/src/notation/inotationnotevelocity.h b/src/notation/inotationnotevelocity.h new file mode 100644 index 0000000000000..f38d3d42eef6b --- /dev/null +++ b/src/notation/inotationnotevelocity.h @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include "async/notification.h" + +namespace mu::notation { +class INotationNoteVelocity +{ +public: + virtual ~INotationNoteVelocity() = default; + + virtual bool isEditModeEnabled() const = 0; + virtual void setEditModeEnabled(bool enabled) = 0; + virtual muse::async::Notification editModeEnabledChanged() const = 0; +}; + +using INotationNoteVelocityPtr = std::shared_ptr; +} diff --git a/src/notation/inotationplayback.h b/src/notation/inotationplayback.h index 99ddc32b4632f..6c448edf17ca8 100644 --- a/src/notation/inotationplayback.h +++ b/src/notation/inotationplayback.h @@ -67,6 +67,10 @@ class INotationPlayback virtual muse::async::Channel trackAdded() const = 0; virtual muse::async::Channel trackRemoved() const = 0; + // Dynamic level (marking/hairpin only, no per-note override) that would apply at this tick, + // for use by UI that needs a musically-coherent baseline (e.g. a velocity editor). + virtual muse::mpe::dynamic_level_t appliableDynamicLevel(engraving::track_idx_t trackIdx, int tick) const = 0; + virtual muse::audio::secs_t totalPlayTime() const = 0; virtual muse::async::Channel totalPlayTimeChanged() const = 0; diff --git a/src/notation/internal/masternotation.cpp b/src/notation/internal/masternotation.cpp index 232ae03b3257c..9a18d3b5592ac 100644 --- a/src/notation/internal/masternotation.cpp +++ b/src/notation/internal/masternotation.cpp @@ -52,6 +52,7 @@ #include "masternotationparts.h" #include "notationautomation.h" #include "notationnoteoffsets.h" +#include "notationnotevelocity.h" #include "types/scorecreateoptions.h" #ifdef MUE_BUILD_ENGRAVING_PLAYBACK @@ -94,6 +95,7 @@ MasterNotation::MasterNotation(project::INotationProject* project, const muse::m m_notationAutomation = std::make_shared(undoStack()); m_notationNoteOffsets = std::make_shared(); + m_notationNoteVelocity = std::make_shared(); m_parts->partsChanged().onNotify(this, [this]() { notifyAboutNotationChanged(); @@ -773,6 +775,11 @@ INotationNoteOffsetsPtr MasterNotation::noteOffsets() const return m_notationNoteOffsets; } +INotationNoteVelocityPtr MasterNotation::noteVelocity() const +{ + return m_notationNoteVelocity; +} + void MasterNotation::initNotationSoloMuteState(const INotationPtr notation) { IF_ASSERT_FAILED(notation) { diff --git a/src/notation/internal/masternotation.h b/src/notation/internal/masternotation.h index 8c9aeb5977be4..e53af4ad45af2 100644 --- a/src/notation/internal/masternotation.h +++ b/src/notation/internal/masternotation.h @@ -75,6 +75,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab INotationAutomationPtr automation() const override; INotationNoteOffsetsPtr noteOffsets() const override; + INotationNoteVelocityPtr noteVelocity() const override; private: friend class project::NotationProject; @@ -104,6 +105,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab INotationPlaybackPtr m_notationPlayback = nullptr; INotationAutomationPtr m_notationAutomation = nullptr; INotationNoteOffsetsPtr m_notationNoteOffsets = nullptr; + INotationNoteVelocityPtr m_notationNoteVelocity = nullptr; muse::async::Notification m_hasPartsChanged; mutable ExcerptNotationList m_potentialExcerpts; diff --git a/src/notation/internal/notationnotevelocity.cpp b/src/notation/internal/notationnotevelocity.cpp new file mode 100644 index 0000000000000..4a8b4a7f4e860 --- /dev/null +++ b/src/notation/internal/notationnotevelocity.cpp @@ -0,0 +1,44 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notationnotevelocity.h" + +using namespace mu::notation; + +bool NotationNoteVelocity::isEditModeEnabled() const +{ + return m_isEditModeEnabled; +} + +void NotationNoteVelocity::setEditModeEnabled(bool enabled) +{ + if (m_isEditModeEnabled == enabled) { + return; + } + m_isEditModeEnabled = enabled; + m_editModeEnabledChanged.notify(); +} + +muse::async::Notification NotationNoteVelocity::editModeEnabledChanged() const +{ + return m_editModeEnabledChanged; +} diff --git a/src/notation/internal/notationnotevelocity.h b/src/notation/internal/notationnotevelocity.h new file mode 100644 index 0000000000000..beb9fff097da6 --- /dev/null +++ b/src/notation/internal/notationnotevelocity.h @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#pragma once + +#include "../inotationnotevelocity.h" + +#include "async/notification.h" + +namespace mu::notation { +class NotationNoteVelocity : public INotationNoteVelocity +{ +public: + bool isEditModeEnabled() const override; + void setEditModeEnabled(bool enabled) override; + muse::async::Notification editModeEnabledChanged() const override; + +private: + bool m_isEditModeEnabled = false; + muse::async::Notification m_editModeEnabledChanged; +}; +} diff --git a/src/notation/internal/notationplayback.cpp b/src/notation/internal/notationplayback.cpp index 8115dfaf6b42e..5f228aff17a2f 100644 --- a/src/notation/internal/notationplayback.cpp +++ b/src/notation/internal/notationplayback.cpp @@ -218,6 +218,11 @@ muse::async::Channel NotationPlayback::trackRemoved() const return m_playbackModel.trackRemoved(); } +muse::mpe::dynamic_level_t NotationPlayback::appliableDynamicLevel(track_idx_t trackIdx, int tick) const +{ + return m_playbackModel.appliableDynamicLevel(trackIdx, tick); +} + void NotationPlayback::updateLoopBoundaries() { LoopBoundaries newBoundaries; diff --git a/src/notation/internal/notationplayback.h b/src/notation/internal/notationplayback.h index 0ed987fd97752..0a519b6b6462a 100644 --- a/src/notation/internal/notationplayback.h +++ b/src/notation/internal/notationplayback.h @@ -66,6 +66,8 @@ class NotationPlayback : public INotationPlayback, public muse::async::Asyncable muse::async::Channel trackAdded() const override; muse::async::Channel trackRemoved() const override; + muse::mpe::dynamic_level_t appliableDynamicLevel(engraving::track_idx_t trackIdx, int tick) const override; + muse::audio::secs_t totalPlayTime() const override; muse::async::Channel totalPlayTimeChanged() const override; diff --git a/src/notation/internal/notationplaybackstub.cpp b/src/notation/internal/notationplaybackstub.cpp index 7ae42a325e466..17f37ba9f0974 100644 --- a/src/notation/internal/notationplaybackstub.cpp +++ b/src/notation/internal/notationplaybackstub.cpp @@ -106,6 +106,11 @@ muse::async::Channel NotationPlaybackStub::trackRemoved() con return muse::async::Channel(); } +muse::mpe::dynamic_level_t NotationPlaybackStub::appliableDynamicLevel(track_idx_t, int) const +{ + return muse::mpe::dynamicLevelFromType(muse::mpe::DynamicType::Natural); +} + muse::audio::secs_t NotationPlaybackStub::totalPlayTime() const { return muse::audio::secs_t(); diff --git a/src/notation/internal/notationplaybackstub.h b/src/notation/internal/notationplaybackstub.h index 60790427c966a..eb2cd5d3a932c 100644 --- a/src/notation/internal/notationplaybackstub.h +++ b/src/notation/internal/notationplaybackstub.h @@ -52,6 +52,8 @@ class NotationPlaybackStub : public INotationPlayback muse::async::Channel trackAdded() const override; muse::async::Channel trackRemoved() const override; + muse::mpe::dynamic_level_t appliableDynamicLevel(engraving::track_idx_t trackIdx, int tick) const override; + muse::audio::secs_t totalPlayTime() const override; muse::async::Channel totalPlayTimeChanged() const override; diff --git a/src/notationscene/inotationcommandscontroller.h b/src/notationscene/inotationcommandscontroller.h index a20c720a76d95..5b3bb9e20794b 100644 --- a/src/notationscene/inotationcommandscontroller.h +++ b/src/notationscene/inotationcommandscontroller.h @@ -92,6 +92,9 @@ class INotationCommandsController : MODULE_CONTEXT_INTERFACE virtual bool isNoteOffsetEditModeEnabled() const = 0; virtual muse::async::Notification noteOffsetEditModeEnabledChanged() const = 0; + virtual bool isNoteVelocityEditModeEnabled() const = 0; + virtual muse::async::Notification noteVelocityEditModeEnabledChanged() const = 0; + virtual bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const = 0; virtual muse::async::Notification debuggingOptionsChanged() const = 0; }; diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 9ad23b1817332..8c38f03800c76 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -40,6 +40,7 @@ #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep #include "notation/inotationnoteoffsets.h" // IWYU pragma: keep +#include "notation/inotationnotevelocity.h" // IWYU pragma: keep #include "notation/inotationelements.h" #include "notation/inotationmidiinput.h" #include "notation/inotationnoteinput.h" @@ -583,6 +584,7 @@ void NotationActionController::init() registerCommand(TOGGLE_AUTOMATION_COMMAND, &Controller::toggleAutomation); registerQueryCommand(SELECT_AUTOMATION_TYPE_COMMAND, &Controller::selectAutomationType); registerCommand(TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, &Controller::toggleNoteOffsetEditor); + registerCommand(TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, &Controller::toggleNoteVelocityEditor); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -1055,6 +1057,7 @@ void NotationActionController::init() { "hammer-on-pull-off", ADD_HAMMER_ON_PULL_OFF_COMMAND, {} }, { "toggle-automation", TOGGLE_AUTOMATION_COMMAND, {} }, { "toggle-note-offset-editor", TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, {} }, + { "toggle-note-velocity-editor", TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, {} }, { "string-up", GOTO_STRING_ABOVE_COMMAND, {} }, { "string-down", GOTO_STRING_BELOW_COMMAND, {} }, { "move-up", MOVE_UP_COMMAND, {} }, @@ -1136,6 +1139,10 @@ void NotationActionController::init() masterNotation->noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { m_noteOffsetEditModeEnabledChanged.notify(); }, Asyncable::Mode::SetReplace); + + masterNotation->noteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + m_noteVelocityEditModeEnabledChanged.notify(); + }, Asyncable::Mode::SetReplace); } } @@ -3204,6 +3211,16 @@ muse::async::Notification NotationActionController::noteOffsetEditModeEnabledCha return m_noteOffsetEditModeEnabledChanged; } +bool NotationActionController::isNoteVelocityEditModeEnabled() const +{ + return currentMasterNotation() ? currentMasterNotation()->noteVelocity()->isEditModeEnabled() : false; +} + +muse::async::Notification NotationActionController::noteVelocityEditModeEnabledChanged() const +{ + return m_noteVelocityEditModeEnabledChanged; +} + muse::async::Notification NotationActionController::automationModeEnabledChanged() const { return m_automationModeEnabledChanged; @@ -3289,6 +3306,19 @@ void NotationActionController::toggleNoteOffsetEditor() masterNotation->noteOffsets()->setEditModeEnabled(!isEnabled); } +void NotationActionController::toggleNoteVelocityEditor() +{ + TRACEFUNC; + + IMasterNotationPtr masterNotation = currentMasterNotation(); + if (!masterNotation) { + return; + } + + const bool isEnabled = masterNotation->noteVelocity()->isEditModeEnabled(); + masterNotation->noteVelocity()->setEditModeEnabled(!isEnabled); +} + muse::Ret NotationActionController::selectAutomationType(const muse::rcommand::CommandQuery& query) { const std::string type = query.param("type").toString(); diff --git a/src/notationscene/internal/notationactioncontroller.h b/src/notationscene/internal/notationactioncontroller.h index efd04fc578fca..1c21f967d9186 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -121,6 +121,9 @@ class NotationActionController : public INotationCommandsController, public muse bool isNoteOffsetEditModeEnabled() const override; muse::async::Notification noteOffsetEditModeEnabledChanged() const override; + bool isNoteVelocityEditModeEnabled() const override; + muse::async::Notification noteVelocityEditModeEnabledChanged() const override; + bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const override; muse::async::Notification debuggingOptionsChanged() const override; @@ -273,6 +276,7 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); void toggleNoteOffsetEditor(); + void toggleNoteVelocityEditor(); // commands void registerCommand(const muse::rcommand::Command&, std::function); @@ -316,6 +320,7 @@ class NotationActionController : public INotationCommandsController, public muse muse::async::Notification m_currentNotationStyleChanged; muse::async::Notification m_automationModeEnabledChanged; muse::async::Notification m_noteOffsetEditModeEnabledChanged; + muse::async::Notification m_noteVelocityEditModeEnabledChanged; using IsActionEnabledFunc = std::function; std::map m_isEnabledMap; diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index 40abd65d81c6a..c7c29a4ee5141 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2921,6 +2921,13 @@ static const std::vector s_commandInfos = { InputSchema(), Decoration(IconCode::Code::CLOCK, rcommand::Checkable::Yes) }, + CommandInfo { + TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, + TranslatableString("action", "Note velocities"), + TranslatableString("action", "Toggle note velocity editor"), + InputSchema(), + Decoration(IconCode::Code::DYNAMIC_FORTE, rcommand::Checkable::Yes) + }, CommandInfo { SELECT_AUTOMATION_TYPE_COMMAND, TranslatableString::untranslatable("Automation type"), diff --git a/src/notationscene/internal/notationcommandsstate.cpp b/src/notationscene/internal/notationcommandsstate.cpp index b72bbc1f1645c..0d38075a03d40 100644 --- a/src/notationscene/internal/notationcommandsstate.cpp +++ b/src/notationscene/internal/notationcommandsstate.cpp @@ -351,6 +351,10 @@ void NotationCommandsState::init() updateCommandStates({ TOGGLE_NOTE_OFFSET_EDITOR_COMMAND }); }); + controller()->noteVelocityEditModeEnabledChanged().onNotify(this, [this]() { + updateCommandStates({ TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND }); + }); + controller()->debuggingOptionsChanged().onNotify(this, [this]() { updateCommandStates(DEBUG_COMMANDS); }); @@ -493,6 +497,10 @@ CommandState NotationCommandsState::doCommandState(const Command& command) const return CommandState(true, controller()->isNoteOffsetEditModeEnabled()); } + if (command == TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND) { + return CommandState(true, controller()->isNoteVelocityEditModeEnabled()); + } + if (muse::contains(DEBUG_COMMANDS, command)) { return CommandState(true, controller()->isDebuggingCommandEnabled(command)); } diff --git a/src/notationscene/internal/notationuiactions.cpp b/src/notationscene/internal/notationuiactions.cpp index 12ccd084a8ddb..c0899663be991 100644 --- a/src/notationscene/internal/notationuiactions.cpp +++ b/src/notationscene/internal/notationuiactions.cpp @@ -33,6 +33,7 @@ #include "notation/inotation.h" #include "notation/inotationautomation.h" // IWYU pragma: keep #include "notation/inotationnoteoffsets.h" // IWYU pragma: keep +#include "notation/inotationnotevelocity.h" // IWYU pragma: keep #include "notation/inotationinteraction.h" #include "notation/inotationnoteinput.h" // IWYU pragma: keep #include "notation/inotationselection.h" // IWYU pragma: keep @@ -57,6 +58,7 @@ static const ActionCode SHOW_IRREGULAR_CODE("show-irregular"); static const ActionCode TOGGLE_CONCERT_PITCH_CODE("concert-pitch"); static const ActionCode TOGGLE_AUTOMATION_CODE("toggle-automation"); static const ActionCode TOGGLE_NOTE_OFFSET_EDITOR_CODE("toggle-note-offset-editor"); +static const ActionCode TOGGLE_NOTE_VELOCITY_EDITOR_CODE("toggle-note-velocity-editor"); // avoid translation duplication @@ -2710,6 +2712,14 @@ const UiActionList NotationUiActions::s_actions = { IconCode::Code::CLOCK, Checkable::Yes ), + UiAction(TOGGLE_NOTE_VELOCITY_EDITOR_CODE, + mu::context::UiCtxProjectOpened, + mu::context::CTX_NOTATION_OPENED, + TranslatableString("action", "Note velocities"), + TranslatableString("action", "Toggle note velocity editor"), + IconCode::Code::DYNAMIC_FORTE, + Checkable::Yes + ), }; const UiActionList NotationUiActions::s_scoreConfigActions = { @@ -2935,6 +2945,7 @@ void NotationUiActions::init() m_controller->currentMasterNotationChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_AUTOMATION_CODE }); m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); + m_actionCheckedChanged.send({ TOGGLE_NOTE_VELOCITY_EDITOR_CODE }); if (const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation()) { masterNotation->automation()->automationModeEnabledChanged().onNotify(this, [this]() { @@ -2944,6 +2955,10 @@ void NotationUiActions::init() masterNotation->noteOffsets()->editModeEnabledChanged().onNotify(this, [this]() { m_actionCheckedChanged.send({ TOGGLE_NOTE_OFFSET_EDITOR_CODE }); }, Asyncable::Mode::SetReplace); + + masterNotation->noteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + m_actionCheckedChanged.send({ TOGGLE_NOTE_VELOCITY_EDITOR_CODE }); + }, Asyncable::Mode::SetReplace); } }); @@ -3067,6 +3082,11 @@ bool NotationUiActions::actionChecked(const UiAction& act) const return masterNotation ? masterNotation->noteOffsets()->isEditModeEnabled() : false; } + if (act.code == TOGGLE_NOTE_VELOCITY_EDITOR_CODE) { + const IMasterNotationPtr masterNotation = m_controller->currentMasterNotation(); + return masterNotation ? masterNotation->noteVelocity()->isEditModeEnabled() : false; + } + if (isScoreConfigAction(act.code)) { auto interaction = m_controller->currentNotationInteraction(); if (interaction) { diff --git a/src/notationscene/notationcommands.h b/src/notationscene/notationcommands.h index 2d3d5f53d3af8..0f4103844c15b 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -485,6 +485,7 @@ inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_STAFF_COMMAN inline static const muse::rcommand::Command TOGGLE_AUTOMATION_COMMAND("command://notation/toggle-automation"); inline static const muse::rcommand::Command SELECT_AUTOMATION_TYPE_COMMAND("command://notation/select-automation-type"); // with params inline static const muse::rcommand::Command TOGGLE_NOTE_OFFSET_EDITOR_COMMAND("command://notation/toggle-note-offset-editor"); +inline static const muse::rcommand::Command TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND("command://notation/toggle-note-velocity-editor"); // TAB commands inline static const muse::rcommand::Command SET_DURATION_WHOLE_TAB_COMMAND("command://notation/set-duration-whole-tab"); diff --git a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt index 713fada176449..1ee4a79ddab2e 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt +++ b/src/notationscene/qml/MuseScore/NotationScene/CMakeLists.txt @@ -72,6 +72,8 @@ qt_add_qml_module(notationscene_qml notationnavigator.h notationnoteoffsetcontroller.h notationnoteoffsetcontroller.cpp + notationnotevelocitycontroller.h + notationnotevelocitycontroller.cpp notationpaintview.cpp notationpaintview.h notationruler.cpp @@ -92,6 +94,10 @@ qt_add_qml_module(notationscene_qml noteinputcursor.h noteoffsetoverlay.cpp noteoffsetoverlay.h + notevelocitygeometry.cpp + notevelocitygeometry.h + notevelocityoverlay.cpp + notevelocityoverlay.h paintedengravingitem.cpp paintedengravingitem.h partlistmodel.cpp diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index b5b3fef3e921b..4c10f035e44ff 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -36,6 +36,7 @@ #include "notation/inotationaccessibility.h" // IWYU pragma: keep #include "notation/inotationautomation.h" #include "notation/inotationnoteoffsets.h" +#include "notation/inotationnotevelocity.h" #include "notation/inotationelements.h" #include "notation/inotationnoteinput.h" #include "notation/inotationpainting.h" // IWYU pragma: keep @@ -126,6 +127,20 @@ void AbstractNotationPaintView::load() }); m_notationNoteOffsetController = std::make_unique(m_noteOffsetOverlayContainer, iocContext()); + + // Clip note velocity overlays to the view bounds + m_noteVelocityOverlayContainer = new QQuickItem(this); + m_noteVelocityOverlayContainer->setClip(true); + m_noteVelocityOverlayContainer->setWidth(width()); + m_noteVelocityOverlayContainer->setHeight(height()); + connect(this, &QQuickItem::widthChanged, m_noteVelocityOverlayContainer, [this]() { + m_noteVelocityOverlayContainer->setWidth(width()); + }); + connect(this, &QQuickItem::heightChanged, m_noteVelocityOverlayContainer, [this]() { + m_noteVelocityOverlayContainer->setHeight(height()); + }); + + m_notationNoteVelocityController = std::make_unique(m_noteVelocityOverlayContainer, iocContext()); m_playbackCursor = std::make_unique(iocContext()); m_playbackCursor->setVisible(false); m_noteInputCursor = std::make_unique(iocContext(), notationConfiguration()->thinNoteInputCursor()); @@ -396,6 +411,12 @@ void AbstractNotationPaintView::onLoadNotation(INotationPtr) scheduleRedraw(); }); + // FIXME: only un-/re-subscribe when master notation changes + m_notationNoteVelocityController->init(); + notationNoteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + scheduleRedraw(); + }); + if (isMainView()) { connect(this, &QQuickPaintedItem::focusChanged, this, [this](bool focused) { if (notation()) { @@ -449,6 +470,7 @@ void AbstractNotationPaintView::onUnloadNotation(INotationPtr) m_notation->viewModeChanged().disconnect(this); notationAutomation()->automationModeEnabledChanged().disconnect(this); notationNoteOffsets()->editModeEnabledChanged().disconnect(this); + notationNoteVelocity()->editModeEnabledChanged().disconnect(this); if (isMainView()) { disconnect(this, &QQuickPaintedItem::focusChanged, this, nullptr); @@ -503,6 +525,10 @@ void AbstractNotationPaintView::onMatrixChanged(const Transform& oldMatrix, cons m_notationNoteOffsetController->setViewMatrix(newMatrix); } + if (m_notationNoteVelocityController) { + m_notationNoteVelocityController->setViewMatrix(newMatrix); + } + scheduleRedraw(); emit horizontalScrollChanged(); @@ -633,6 +659,11 @@ INotationNoteOffsetsPtr AbstractNotationPaintView::notationNoteOffsets() const return m_notation ? m_notation->masterNotation()->noteOffsets() : nullptr; } +INotationNoteVelocityPtr AbstractNotationPaintView::notationNoteVelocity() const +{ + return m_notation ? m_notation->masterNotation()->noteVelocity() : nullptr; +} + void AbstractNotationPaintView::onNoteInputStateChanged() { TRACEFUNC; @@ -775,7 +806,9 @@ void AbstractNotationPaintView::paint(QPainter* qp) const bool isPrinting = publishMode() || m_inputController->readonly(); const INotationNoteOffsetsPtr noteOffsets = notationNoteOffsets(); - const bool dimNotation = automationMode() || (noteOffsets && noteOffsets->isEditModeEnabled()); + const INotationNoteVelocityPtr noteVelocity = notationNoteVelocity(); + const bool dimNotation = automationMode() || (noteOffsets && noteOffsets->isEditModeEnabled()) + || (noteVelocity && noteVelocity->isEditModeEnabled()); notation()->painting()->paintView(painter, toLogical(rect), isPrinting, dimNotation); const INotationNoteInputPtr noteInput = notationNoteInput(); diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h index ee46ae40c3475..fce824b36658a 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h @@ -42,6 +42,7 @@ #include "notationviewinputcontroller.h" #include "notationautomationcontroller.h" #include "notationnoteoffsetcontroller.h" +#include "notationnotevelocitycontroller.h" #include "noteinputcursor.h" #include "notationruler.h" #include "playbackcursor.h" @@ -220,6 +221,7 @@ protected slots: INotationSelectionPtr notationSelection() const; INotationAutomationPtr notationAutomation() const; INotationNoteOffsetsPtr notationNoteOffsets() const; + INotationNoteVelocityPtr notationNoteVelocity() const; void clear(); void initBackground(); @@ -292,6 +294,8 @@ protected slots: std::unique_ptr m_notationAutomationController; QQuickItem* m_noteOffsetOverlayContainer = nullptr; std::unique_ptr m_notationNoteOffsetController; + QQuickItem* m_noteVelocityOverlayContainer = nullptr; + std::unique_ptr m_notationNoteVelocityController; std::unique_ptr m_playbackCursor; std::unique_ptr m_noteInputCursor; std::unique_ptr m_ruler; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp new file mode 100644 index 0000000000000..1b5955d909783 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -0,0 +1,564 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notationnotevelocitycontroller.h" + +#include "notevelocityoverlay.h" + +#include +#include + +#include "async/async.h" +#include "global/containers.h" + +#include "engraving/dom/chord.h" +#include "engraving/dom/masterscore.h" +#include "engraving/dom/mscore.h" +#include "engraving/dom/note.h" +#include "engraving/dom/property.h" +#include "engraving/dom/segment.h" +#include "engraving/dom/staff.h" +#include "engraving/dom/system.h" +#include "engraving/types/types.h" + +#include "mpe/mpetypes.h" + +#include "notation/imasternotation.h" +#include "notation/inotation.h" +#include "notation/inotationinteraction.h" +#include "notation/inotationnotevelocity.h" +#include "notation/inotationplayback.h" +#include "notation/inotationselection.h" +#include "notation/inotationstyle.h" +#include "notation/inotationundostack.h" +#include "notation/inotationelements.h" // IWYU pragma: keep + +using namespace mu::notation; +using namespace mu::engraving; + +// Reserve velocity 0 for the model's own "no override, fall back to the dynamic marking" sentinel +// (Note::userVelocity() == 0) - the overlay itself always writes an explicit absolute value, so it +// never produces that sentinel by accident. +constexpr static int MIN_DRAGGABLE_VELOCITY = 1; +constexpr static int MAX_DRAGGABLE_VELOCITY = 127; + +constexpr static double BAR_HALF_WIDTH_SP = 0.45; +constexpr static double BAND_V_PADDING_SP = 0.3; + + +NotationNoteVelocityController::NotationNoteVelocityController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx) + : muse::Contextable(iocCtx), m_overlaysParent(overlaysParent) +{ +} + +void NotationNoteVelocityController::init() +{ + IF_ASSERT_FAILED(noteVelocity() && currentNotation()) { + return; + } + + onCurrentNotationChanged(); + + noteVelocity()->editModeEnabledChanged().onNotify(this, [this]() { + if (noteVelocity()->isEditModeEnabled()) { + rebuildAllOverlays(); + } else { + updateOverlaysGeometry(); + } + }, Asyncable::Mode::SetReplace); + + globalContext()->currentNotationChanged().onNotify(this, [this]() { + onCurrentNotationChanged(); + }, Asyncable::Mode::SetReplace); +} + +void NotationNoteVelocityController::onCurrentNotationChanged() +{ + rebuildAllOverlays(); + + if (mu::engraving::Score* thisScore = score()) { + // TODO: More efficient if we only rebuild the affected staves/systems... + // SetReplace only dedupes a subscription against the exact same Score/Notation instance - + // switching documents subscribes to a brand new instance each time, so guard the callback + // itself against firing for a document that's no longer current, rather than leaking one + // live subscription per every document ever opened this session. + score()->changesChannel().onReceive(this, [this, thisScore](const mu::engraving::ScoreChanges&) { + if (thisScore != score()) { + return; + } + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } + + const INotationPtr notation = currentNotation(); + if (notation) { + mu::notation::INotation* thisNotation = notation.get(); + + notation->viewModeChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + + if (notation->style()) { + // Style edits (e.g. live-dragging "Staff space (sp)" in Page Settings) relayout the + // score without necessarily going through changesChannel() - without this, the + // overlay's cached note positions go stale and stop tracking the rescaled notation. + notation->style()->styleChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + scheduleRebuild(); + }, Asyncable::Mode::SetReplace); + } + + if (notation->interaction()) { + notation->interaction()->selectionChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + updateSelectionHighlight(); + }, Asyncable::Mode::SetReplace); + } + } +} + +void NotationNoteVelocityController::scheduleRebuild() +{ + if (m_rebuildScheduled) { + return; + } + m_rebuildScheduled = true; + + // Defer to the next event loop iteration - the score may still be mid-layout at the + // point the changesChannel notification fires, so rebuilding synchronously here (which + // reads System/Segment/Chord layout data) is not safe. + muse::async::Async::call(this, [this]() { + m_rebuildScheduled = false; + if (noteVelocity() && noteVelocity()->isEditModeEnabled()) { + rebuildAllOverlays(); + } + }); +} + +void NotationNoteVelocityController::rebuildAllOverlays() +{ + for (const auto& [key, data] : m_overlaysByStaff) { + if (data.overlay->isDragging()) { + // Deleting an overlay that currently holds the mouse grab (mid-drag) would drop the + // in-progress edit and risk delivering the next mouse event to a freed item - wait + // for the drag to finish instead of rebuilding out from under it. + scheduleRebuild(); + return; + } + } + + m_noteLocations.clear(); + + if (!score()) { + // Happens on close... + for (const auto& [key, data] : m_overlaysByStaff) { + delete data.overlay; + } + m_overlaysByStaff.clear(); + return; + } + + // createOverlayForStaff reuses an existing overlay item in place (just updating its rects) + // when a staff already had one, instead of destroying and recreating every overlay QQuickItem + // on every edit - it consumes matching entries out of m_overlaysByStaff as it goes, so + // whatever is left there afterwards belongs to a staff that's no longer visible/primary/has + // no notes anymore, and can be deleted. + OverlaysMap newOverlays; + + for (const System* system : score()->systems()) { + staff_idx_t staffIdx = system->firstVisibleStaff(); + while (staffIdx != muse::nidx) { + createOverlayForStaff(system, staffIdx, newOverlays); + staffIdx = system->nextVisibleStaff(staffIdx); + } + } + + for (const auto& [key, data] : m_overlaysByStaff) { + delete data.overlay; + } + + m_overlaysByStaff = std::move(newOverlays); + + updateOverlaysGeometry(); +} + +void NotationNoteVelocityController::createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays) +{ + IF_ASSERT_FAILED(system && m_overlaysParent && score()) { + return; + } + + const Staff* staff = score()->staff(staffIdx); + const SysStaff* sysStaff = system->staff(staffIdx); + if (!staff || !sysStaff || !staff->isPrimaryStaff()) { + return; + } + + std::vector entries; + + const track_idx_t strack = staffIdx * VOICES; + const track_idx_t etrack = strack + VOICES; + + for (const Segment* seg = system->firstMeasure() ? system->firstMeasure()->first(SegmentType::ChordRest) : nullptr; + seg && seg->system() == system; seg = seg->next1(SegmentType::ChordRest)) { + for (track_idx_t track = strack; track < etrack; ++track) { + EngravingItem* item = seg->element(track); + if (!item || !item->isChord()) { + continue; + } + const Chord* chord = toChord(item); + + std::vector chordNotes = chord->notes(); + // Highest pitch first - matches NoteVelocityOverlay's expected back-to-front paint + // order, so chord notes stack with the lowest-pitched note's bar fully in front. + std::sort(chordNotes.begin(), chordNotes.end(), [](const Note* a, const Note* b) { + return a->line() < b->line(); + }); + + for (Note* note : chordNotes) { + if (note->tieBack()) { + // Playback (NoteRenderer::shouldRender) skips tied-continuation notes + // entirely in most cases, so their own velocity would silently do nothing - + // don't offer a handle that can't actually affect anything. + continue; + } + + NoteEntry entry; + entry.note = note; + entry.leftX = note->canvasX() - BAR_HALF_WIDTH_SP * note->spatium(); + entry.rightX = note->canvasX() + BAR_HALF_WIDTH_SP * note->spatium(); + entry.yRange = noteVelocityYRange(note); + entries.push_back(entry); + } + } + } + + if (entries.empty()) { + return; + } + + const double vPadding = BAND_V_PADDING_SP * entries.front().note->spatium(); + const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); + + double minY = staffCanvasRect.top(); + double maxY = staffCanvasRect.bottom(); + for (const NoteEntry& entry : entries) { + minY = std::min({ minY, entry.yRange.y0, entry.yRange.y127 }); + maxY = std::max({ maxY, entry.yRange.y0, entry.yRange.y127 }); + } + minY -= vPadding; + maxY += vPadding; + + const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); + + const std::vector selected = selectedNotes(); + + QVector rects; + rects.reserve(static_cast(entries.size())); + + for (const NoteEntry& entry : entries) { + NoteVelocityOverlay::RectData rect; + rect.leftN = (entry.leftX - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.rightN = (entry.rightX - overlayCanvasRect.x()) / overlayCanvasRect.width(); + rect.y0N = (entry.yRange.y0 - overlayCanvasRect.y()) / overlayCanvasRect.height(); + const double initialTopY = canvasYFromVelocity(entry.yRange, displayedVelocity(entry.note)); + rect.yTopN = (initialTopY - overlayCanvasRect.y()) / overlayCanvasRect.height(); + rect.selected = muse::contains(selected, entry.note); + rect.userModified = entry.note->userVelocity() != 0; + rects.push_back(rect); + } + + const SysStaffKey key { system, staffIdx }; + for (int i = 0; i < static_cast(entries.size()); ++i) { + m_noteLocations[entries[i].note] = NoteLocation { key, i }; + } + + NoteVelocityOverlay* overlay = nullptr; + const auto oldIt = m_overlaysByStaff.find(key); + if (oldIt != m_overlaysByStaff.end()) { + // Reuse the existing overlay item in place rather than destroying and recreating it - + // its drag-signal connection (bound to this same key) is still valid. + overlay = oldIt->second.overlay; + overlay->setRects(rects); + m_overlaysByStaff.erase(oldIt); + } else { + overlay = new NoteVelocityOverlay(m_overlaysParent); + overlay->setRects(rects); + applyOverlayColors(overlay); + overlay->setVisible(false); + + QObject::connect(overlay, &NoteVelocityOverlay::barDragged, [this, key](int rectIndex, qreal newYN, bool completed) { + onBarDragged(key, rectIndex, newYN, completed); + }); + } + + StaffOverlayData data; + data.overlay = overlay; + data.notes = std::move(entries); + data.bandRect = overlayCanvasRect; + newOverlays[key] = std::move(data); +} + +void NotationNoteVelocityController::applyOverlayColors(NoteVelocityOverlay* overlay) const +{ + IF_ASSERT_FAILED(overlay) { + return; + } + + overlay->setFillColor(QColor(90, 180, 140, 220)); + overlay->setSelectedFillColor(QColor(60, 160, 210, 235)); + overlay->setModifiedFillColor(QColor(235, 140, 40, 230)); + overlay->setBorderColor(QColor(50, 130, 100, 255)); +} + +void NotationNoteVelocityController::updateOverlaysGeometry() +{ + const bool visible = noteVelocity() && noteVelocity()->isEditModeEnabled(); + + for (const auto& [key, data] : m_overlaysByStaff) { + data.overlay->setVisible(visible); + if (!visible) { + continue; + } + + const muse::RectF screenRect = m_viewMatrix.map(data.bandRect); + data.overlay->setWidth(screenRect.width()); + data.overlay->setHeight(screenRect.height()); + data.overlay->setX(screenRect.x()); + data.overlay->setY(screenRect.y()); + } +} + +void NotationNoteVelocityController::updateSelectionHighlight() +{ + if (!noteVelocity() || !noteVelocity()->isEditModeEnabled()) { + return; + } + + const std::vector selected = selectedNotes(); + + for (const auto& [key, data] : m_overlaysByStaff) { + const QVector& rects = data.overlay->rects(); + if (rects.size() != static_cast(data.notes.size())) { + continue; + } + + // Only a handful of notes typically change selection at once, even on a staff with many + // notes - update just those rects in place instead of copying the whole vector out and + // back regardless of how many actually changed. + for (int i = 0; i < rects.size(); ++i) { + const bool isSelected = muse::contains(selected, data.notes.at(i).note); + if (rects.at(i).selected != isSelected) { + NoteVelocityOverlay::RectData rect = rects.at(i); + rect.selected = isSelected; + data.overlay->updateRect(i, rect); + } + } + } +} + +void NotationNoteVelocityController::setViewMatrix(const muse::draw::Transform& viewMatrix) +{ + if (viewMatrix == m_viewMatrix) { + return; + } + m_viewMatrix = viewMatrix; + + if (noteVelocity() && noteVelocity()->isEditModeEnabled()) { + updateOverlaysGeometry(); + } +} + +std::vector NotationNoteVelocityController::selectedNotes() const +{ + const INotationPtr notation = currentNotation(); + if (!notation || !notation->interaction() || !notation->interaction()->selection()) { + return {}; + } + + return notation->interaction()->selection()->notes(); +} + +void NotationNoteVelocityController::previewBarHeight(const NoteLocation& location, int newVelocity) +{ + const auto dataIt = m_overlaysByStaff.find(location.key); + IF_ASSERT_FAILED(dataIt != m_overlaysByStaff.end() && location.rectIndex >= 0 + && static_cast(location.rectIndex) < dataIt->second.notes.size()) { + return; + } + const StaffOverlayData& data = dataIt->second; + + const NoteEntry& entry = data.notes.at(location.rectIndex); + const double newTopY = canvasYFromVelocity(entry.yRange, newVelocity); + + const QVector& rects = data.overlay->rects(); + if (location.rectIndex >= rects.size()) { + return; + } + + // Single-struct copy plus an in-place update, instead of copying the whole staff's rect + // vector out and back on every mouse-move during a drag. + NoteVelocityOverlay::RectData rect = rects.at(location.rectIndex); + rect.yTopN = (newTopY - data.bandRect.y()) / data.bandRect.height(); + data.overlay->updateRect(location.rectIndex, rect); +} + +void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal newYN, bool completed) +{ + const auto dataIt = m_overlaysByStaff.find(key); + IF_ASSERT_FAILED(key.isValid() && dataIt != m_overlaysByStaff.end() + && rectIndex >= 0 && static_cast(rectIndex) < dataIt->second.notes.size()) { + return; + } + const StaffOverlayData& data = dataIt->second; + + const NoteEntry& draggedEntry = data.notes.at(rectIndex); + Note* draggedNote = draggedEntry.note; + IF_ASSERT_FAILED(draggedNote) { + return; + } + + const double canvasY = data.bandRect.y() + newYN * data.bandRect.height(); + const int newVelocity = std::clamp(velocityFromCanvasY(draggedEntry.yRange, canvasY), + MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + + // If the dragged note is part of a multi-note selection, apply the same velocity delta to + // every other selected note - including notes hidden behind others in the same chord's + // stack - each clamped independently. Only what's selected moves. + const int delta = newVelocity - displayedVelocity(draggedNote); + + std::vector affectedNotes { draggedNote }; + if (delta != 0 || !completed) { + const std::vector selected = selectedNotes(); + if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + affectedNotes = selected; + } + } + + struct PendingChange { + Note* note = nullptr; + int velocity = 0; + }; + std::vector changes; + changes.reserve(affectedNotes.size()); + + for (Note* note : affectedNotes) { + if (note == draggedNote) { + changes.push_back({ note, newVelocity }); + continue; + } + + const int otherVelocity = std::clamp(displayedVelocity(note) + delta, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + changes.push_back({ note, otherVelocity }); + } + + if (!completed) { + // Live drag preview - update every affected overlay's displayed bar height without + // touching the score. + for (const PendingChange& change : changes) { + const auto locIt = m_noteLocations.find(change.note); + if (locIt != m_noteLocations.end()) { + previewBarHeight(locIt->second, change.velocity); + } + } + return; + } + + const INotationPtr notation = currentNotation(); + const INotationUndoStackPtr undoStack = notation ? notation->undoStack() : nullptr; + IF_ASSERT_FAILED(undoStack) { + return; + } + + // Dragging sets an absolute target (this overlay is a fixed 0-127 viewport), so every + // affected note - including a VeloType::OFFSET_VAL one whose pre-drag effective value was + // already correctly resolved via displayedVelocity() above - ends up as an absolute + // USER_VAL. Its relative-to-the-dynamic-marking behavior is intentionally traded for "this is + // now the value I dragged it to" once the user has directly edited it through this UI. + undoStack->prepareChanges(muse::TranslatableString("undoableAction", "Change note velocity")); + for (const PendingChange& change : changes) { + if (change.note->getProperty(mu::engraving::Pid::VELO_TYPE).value() != VeloType::USER_VAL) { + change.note->undoChangeProperty(mu::engraving::Pid::VELO_TYPE, VeloType::USER_VAL, + mu::engraving::PropertyFlags::NOSTYLE); + } + change.note->undoChangeProperty(mu::engraving::Pid::USER_VELOCITY, change.velocity, mu::engraving::PropertyFlags::NOSTYLE); + } + undoStack->commitChanges(); +} + +int NotationNoteVelocityController::contextVelocity(const Note* note) const +{ + const IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + const INotationPlaybackPtr playback = masterNotation ? masterNotation->playback() : nullptr; + if (!playback) { + return 64; + } + + const muse::mpe::dynamic_level_t level = playback->appliableDynamicLevel(note->track(), note->tick().ticks()); + const double ratio = muse::mpe::dynamicLevelToVelocityRatio(level); + return std::clamp(static_cast(std::lround(ratio * 127.0)), 0, 127); +} + +int NotationNoteVelocityController::displayedVelocity(const Note* note) const +{ + const int userVelocity = note->userVelocity(); + if (userVelocity == 0) { + return contextVelocity(note); + } + + // Note::customizeVelocity(): VeloType::USER_VAL means userVelocity() IS the absolute value, + // but VeloType::OFFSET_VAL means it's a *percentage* nudge applied on top of the dynamic + // context (velo += velo * userVelocity() / 100) - treating it as absolute here would both + // show the wrong bar height and compute a wrong drag delta for these (rare, e.g. + // plugin-authored or imported) notes. + const VeloType veloType = note->getProperty(mu::engraving::Pid::VELO_TYPE).value(); + if (veloType == VeloType::USER_VAL) { + return userVelocity; + } + + const int context = contextVelocity(note); + const int offset = static_cast(std::lround(context * userVelocity / 100.0)); + return std::clamp(context + offset, 0, 127); +} + +INotationNoteVelocityPtr NotationNoteVelocityController::noteVelocity() const +{ + const IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteVelocity() : nullptr; +} + +INotationPtr NotationNoteVelocityController::currentNotation() const +{ + return globalContext()->currentNotation(); +} + +mu::engraving::Score* NotationNoteVelocityController::score() const +{ + return currentNotation() ? currentNotation()->elements()->msScore() : nullptr; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h new file mode 100644 index 0000000000000..f8c7e41e4d394 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -0,0 +1,137 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include + +#include "context/iglobalcontext.h" +#include "async/asyncable.h" +#include "notation/notationtypes.h" +#include "notevelocitygeometry.h" + +namespace mu::engraving { +struct ScoreChanges; +} + +namespace mu::notation { +class NoteVelocityOverlay; + +class NotationNoteVelocityController : public muse::Contextable, public muse::async::Asyncable +{ + muse::ContextInject globalContext = { this }; + +public: + NotationNoteVelocityController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx); + + void init(); + void setViewMatrix(const muse::draw::Transform& viewMatrix); + +private: + // Necessary since SysStaff doesn't hold a reference to its system, which is needed + // for calculating a SysStaff's relative position... + struct SysStaffKey { + const System* system = nullptr; + staff_idx_t staffIdx = muse::nidx; + + bool isValid() const + { + return system && !system->measures().empty() && staffIdx != muse::nidx; + } + + bool operator<(const SysStaffKey& k) const + { + // Compare the System pointer by address only - never dereference it here. This key + // is looked up against entries left over from a previous rebuild (to reuse an + // existing overlay item instead of recreating it), and a view mode switch + // (Page <-> Continuous) destroys and recreates every System, so a stale key still + // sitting in the map at that point has a dangling `system` - dereferencing it (as + // `system->first()->index()` used to) is a use-after-free/crash. + if (system != k.system) { + return system < k.system; + } + return staffIdx < k.staffIdx; + } + }; + + // One entry per note. Entries belonging to the same chord are kept contiguous and sorted + // highest-pitch-first, matching NoteVelocityOverlay's expected back-to-front paint order. + struct NoteEntry { + mu::engraving::Note* note = nullptr; + double leftX = 0.0; + double rightX = 0.0; + NoteVelocityYRange yRange; + }; + + struct NoteLocation { + SysStaffKey key; + int rectIndex = -1; + }; + + // The overlay item, its notes and its canvas-space band rect were previously three separate + // maps kept in lockstep by every add/remove/clear - a single map to this struct removes the + // risk of them silently desyncing for a staff. + struct StaffOverlayData { + NoteVelocityOverlay* overlay = nullptr; + std::vector notes; + muse::RectF bandRect; + }; + + using OverlaysMap = std::map; + using NoteLocationMap = std::map; + + void rebuildAllOverlays(); + void createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays); + void updateOverlaysGeometry(); + void updateSelectionHighlight(); + void applyOverlayColors(NoteVelocityOverlay* overlay) const; + + void onCurrentNotationChanged(); + void scheduleRebuild(); + void onBarDragged(const SysStaffKey& key, int rectIndex, qreal newYN, bool completed); + void previewBarHeight(const NoteLocation& location, int newVelocity); + + std::vector selectedNotes() const; + + // What the dynamics-marking/hairpin context alone would produce at this note's tick, with no + // per-note override - used both as the displayed baseline for unedited notes and as the base + // that a VeloType::OFFSET_VAL note's percentage override applies on top of. + int contextVelocity(const mu::engraving::Note* note) const; + + // The velocity a note effectively plays at right now: its own explicit override if it has + // one, otherwise the dynamics-marking/hairpin level alone would produce at its tick - used as + // the displayed baseline for unedited notes, so nudging one starts from a musically coherent + // value instead of an arbitrary flat default. + int displayedVelocity(const mu::engraving::Note* note) const; + + INotationNoteVelocityPtr noteVelocity() const; + INotationPtr currentNotation() const; + mu::engraving::Score* score() const; + + QQuickItem* m_overlaysParent = nullptr; + OverlaysMap m_overlaysByStaff; + NoteLocationMap m_noteLocations; + muse::draw::Transform m_viewMatrix; + bool m_rebuildScheduled = false; +}; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp index 9789b0d08f4be..d01c4fb83afee 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationtoolbarmodel.cpp @@ -38,7 +38,8 @@ void NotationToolBarModel::load() "parts", "toggle-mixer", "toggle-automation", - "toggle-note-offset-editor" + "toggle-note-offset-editor", + "toggle-note-velocity-editor" }; ToolBarItemList items; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.cpp new file mode 100644 index 0000000000000..f9828f9b824e1 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.cpp @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notevelocitygeometry.h" + +#include +#include + +#include "engraving/dom/note.h" +#include "engraving/dom/stafftype.h" + +using namespace mu::notation; +using namespace mu::engraving; + +// A standard 5-line staff spans 8 half-line units (4 gaps x 2 half-line units per gap), using the +// same half-line-step convention as Note::updateRelLine()/Note::line(). Anchoring the "virtual 5th +// line" this many half-line units above the staff's real bottom line is what lets a 1-line +// percussion staff (or any staff with fewer than 5 lines) get the same velocity range as a normal +// 5-line staff, without needing to special-case the line count anywhere else. +constexpr static int STANDARD_STAFF_HALF_LINE_SPAN = 8; + +NoteVelocityYRange mu::notation::noteVelocityYRange(const Note* note) +{ + IF_ASSERT_FAILED(note && note->staffType()) { + return NoteVelocityYRange(); + } + + const StaffType* st = note->staffType(); + const double halfLineStepPx = note->spatium() * 0.5 * st->lineDistance().val(); + const double noteCanvasY = note->canvasPos().y(); + const int noteLine = note->line(); + + const int bottomLine = st->bottomLine(); + const int virtualTopLine = bottomLine - STANDARD_STAFF_HALF_LINE_SPAN; + + NoteVelocityYRange range; + range.y0 = noteCanvasY + (bottomLine - noteLine) * halfLineStepPx; + range.y127 = noteCanvasY + (virtualTopLine - noteLine) * halfLineStepPx; + return range; +} + +double mu::notation::canvasYFromVelocity(const NoteVelocityYRange& range, int velocity) +{ + const double v = std::clamp(velocity, 0, 127) / 127.0; + return range.y0 + (range.y127 - range.y0) * v; +} + +int mu::notation::velocityFromCanvasY(const NoteVelocityYRange& range, double canvasY) +{ + const double span = range.y127 - range.y0; + if (std::abs(span) < 1e-9) { + return 0; + } + + const double v = (canvasY - range.y0) / span; + return std::clamp(static_cast(std::lround(v * 127.0)), 0, 127); +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h new file mode 100644 index 0000000000000..23f6665a07c4e --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocitygeometry.h @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +namespace mu::engraving { +class Note; +} + +// Maps a note's velocity (0-127) to a canvas Y position between its staff's bottom line +// (velocity 0) and where a 5th staff line would sit if the staff had one (velocity 127), even on +// staves that don't actually have 5 lines (e.g. 1-line percussion staves). + +namespace mu::notation { +struct NoteVelocityYRange { + double y0 = 0.0; // canvas Y of the staff's actual bottom line (velocity 0) + double y127 = 0.0; // canvas Y of the (possibly virtual) 5th line from the bottom (velocity 127) +}; + +NoteVelocityYRange noteVelocityYRange(const mu::engraving::Note* note); + +double canvasYFromVelocity(const NoteVelocityYRange& range, int velocity); +int velocityFromCanvasY(const NoteVelocityYRange& range, double canvasY); +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp new file mode 100644 index 0000000000000..9656dfe9d20dd --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -0,0 +1,192 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "notevelocityoverlay.h" + +#include +#include + +#include +#include + +using namespace mu::notation; + +constexpr static qreal EDGE_HIT_MARGIN_PX = 4.0; +constexpr static qreal BAR_HALF_WIDTH_MARGIN_PX = 1.0; // keeps adjacent chord bars from visually touching + +NoteVelocityOverlay::NoteVelocityOverlay(QQuickItem* parent) + : QQuickPaintedItem(parent) +{ + setAcceptedMouseButtons(Qt::LeftButton); +} + +void NoteVelocityOverlay::setRects(const QVector& rects) +{ + m_rects = rects; + update(); +} + +const QVector& NoteVelocityOverlay::rects() const +{ + return m_rects; +} + +void NoteVelocityOverlay::updateRect(int index, const RectData& rect) +{ + if (index < 0 || index >= m_rects.size()) { + return; + } + + m_rects[index] = rect; + update(); +} + +void NoteVelocityOverlay::setFillColor(const QColor& color) +{ + m_fillColor = color; + update(); +} + +void NoteVelocityOverlay::setSelectedFillColor(const QColor& color) +{ + m_selectedFillColor = color; + update(); +} + +void NoteVelocityOverlay::setModifiedFillColor(const QColor& color) +{ + m_modifiedFillColor = color; + update(); +} + +void NoteVelocityOverlay::setBorderColor(const QColor& color) +{ + m_borderColor = color; + update(); +} + +void NoteVelocityOverlay::paint(QPainter* painter) +{ + if (m_rects.isEmpty()) { + return; + } + + painter->setRenderHint(QPainter::Antialiasing); + painter->setPen(QPen(m_borderColor, 1.0)); + + // Bars are stored in back-to-front paint order (see header comment) - simply painting each + // one's fully opaque body in order reproduces the stacked/overlapping look of a DAW velocity + // lane, with no extra bookkeeping needed here. + for (const RectData& rect : m_rects) { + const qreal leftPx = rect.leftN * width() + BAR_HALF_WIDTH_MARGIN_PX; + const qreal rightPx = rect.rightN * width() - BAR_HALF_WIDTH_MARGIN_PX; + const qreal topPx = rect.yTopN * height(); + const qreal basePx = rect.y0N * height(); + + const QRectF barRect(leftPx, topPx, std::max(0.0, rightPx - leftPx), std::max(0.0, basePx - topPx)); + + painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); + painter->drawRect(barRect); + } +} + +int NoteVelocityOverlay::hitTestPx(const QPointF& posPx) const +{ + // Only bars whose horizontal span contains the click are candidates - chord columns never + // overlap in X, so this alone isolates the relevant column. + QVector candidates; + for (int i = 0; i < m_rects.size(); ++i) { + const RectData& r = m_rects.at(i); + const qreal leftPx = r.leftN * width(); + const qreal rightPx = r.rightN * width(); + if (posPx.x() >= leftPx && posPx.x() <= rightPx) { + candidates.push_back(i); + } + } + + if (candidates.isEmpty()) { + return -1; + } + + // candidates preserve the original back-to-front order - scanning in reverse visits the + // frontmost (lowest-pitched) bar first, exactly matching what's actually visible on screen. + qreal minTopSoFarPx = std::numeric_limits::max(); + for (auto it = candidates.rbegin(); it != candidates.rend(); ++it) { + const RectData& r = m_rects.at(*it); + const qreal topPx = r.yTopN * height(); + const qreal basePx = r.y0N * height(); + const qreal exposedBottomPx = std::min(basePx, minTopSoFarPx); + + if (posPx.y() >= topPx - EDGE_HIT_MARGIN_PX && posPx.y() <= exposedBottomPx) { + return *it; + } + + minTopSoFarPx = std::min(minTopSoFarPx, topPx); + } + + return -1; +} + +void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) +{ + const int hit = hitTestPx(e->position()); + if (hit < 0) { + e->ignore(); + return; + } + + m_pressed = true; + m_activeRectIndex = hit; + e->accept(); +} + +void NoteVelocityOverlay::mouseMoveEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + const qreal yN = std::clamp(e->position().y() / std::max(1.0, height()), 0.0, 1.0); + emit barDragged(m_activeRectIndex, yN, false); +} + +void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + const qreal yN = std::clamp(e->position().y() / std::max(1.0, height()), 0.0, 1.0); + emit barDragged(m_activeRectIndex, yN, true); + + m_pressed = false; + m_activeRectIndex = -1; +} + +void NoteVelocityOverlay::mouseUngrabEvent() +{ + // The mouse grab taken in mousePressEvent can be stolen mid-drag (e.g. a popup opening) - + // without this, mouseReleaseEvent never fires and this item is left thinking a drag is still + // active. Treat it as a cancel rather than guessing a commit at an unknown final position. + m_pressed = false; + m_activeRectIndex = -1; +} diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h new file mode 100644 index 0000000000000..4921989f5dbdc --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +// NOTE: all rectangle coordinates are normalized [0, 1], relative to this item's own width/height, +// mirroring NoteOffsetOverlay's convention. +// +// Bars belonging to the same note column (i.e. sharing the same left/right X span - the notes of a +// chord) are expected to be stored in back-to-front paint order: the highest-pitched note's bar +// first (painted first, furthest back), the lowest-pitched note's bar last (painted last, frontmost +// and fully opaque). Painting each bar's opaque body in that order naturally makes a taller, +// further-back bar's tip peek out above a shorter, more-frontward one - exactly like an overlapping +// velocity lane in a DAW piano roll. hitTestPx() reconstructs the same front-to-back visibility +// order to find which bar is actually clickable at a given pixel. + +namespace mu::notation { +class NoteVelocityOverlay : public QQuickPaintedItem +{ + Q_OBJECT + +public: + struct RectData { + qreal leftN = 0.0; + qreal rightN = 0.0; + qreal y0N = 1.0; // velocity 0 (baseline) + qreal yTopN = 1.0; // current top edge, i.e. the note's velocity + bool selected = false; + bool userModified = false; // has an explicit user-set velocity, vs. the dynamics-derived default + }; + + explicit NoteVelocityOverlay(QQuickItem* parent); + + void setRects(const QVector& rects); + const QVector& rects() const; + + // Mutates a single rect in place, avoiding a full-vector copy-out/copy-back - used for live + // preview during a drag and for selection-highlight updates, both of which only ever touch a + // handful of rects at a time even on a staff with many notes. + void updateRect(int index, const RectData& rect); + + void setFillColor(const QColor& color); + void setSelectedFillColor(const QColor& color); + void setModifiedFillColor(const QColor& color); + void setBorderColor(const QColor& color); + + void paint(QPainter* painter) override; + + bool isDragging() const { return m_pressed; } + +signals: + void barDragged(int rectIndex, qreal newYN, bool completed); + +protected: + void mousePressEvent(QMouseEvent* e) override; + void mouseMoveEvent(QMouseEvent* e) override; + void mouseReleaseEvent(QMouseEvent* e) override; + void mouseUngrabEvent() override; + +private: + int hitTestPx(const QPointF& posPx) const; + + QVector m_rects; + + QColor m_fillColor; + QColor m_selectedFillColor; + QColor m_modifiedFillColor; + QColor m_borderColor; + + bool m_pressed = false; + int m_activeRectIndex = -1; +}; +} From 2e1c2ad07e5cef54a4422cf5c21ef4f3ace0ceba Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:19:27 +0200 Subject: [PATCH 08/36] Bump muse_framework submodule for note velocity override forwarding Picks up the fix for per-note velocity overrides not reaching MuseSampler's main playback stream, so edits made with the note velocity drag-handle overlay are actually audible. --- muse | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/muse b/muse index 5763913da515b..56f8f626f38eb 160000 --- a/muse +++ b/muse @@ -1 +1 @@ -Subproject commit 5763913da515b9120d3d81912e789cf2be639b3f +Subproject commit 56f8f626f38eb25fcaa909dd97f3fbc3e7ea682f From 52bd18330eec7e4b46cec6441b667cb77c69bb46 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:57:27 +0200 Subject: [PATCH 09/36] Fix codestyle (uncrustify) violation in NotationNoteOffsetController Continuation-line indentation was off by one space, flagged by the codestyle CI check. --- .../MuseScore/NotationScene/notationnoteoffsetcontroller.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 1801b6a5382ef..8d314776dd4b3 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -365,7 +365,7 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s overlay->setVisible(false); QObject::connect(overlay, &NoteOffsetOverlay::edgeDragged, - [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { + [this, key](int rectIndex, bool isLeftEdge, qreal newXN, bool completed) { onEdgeDragged(key, rectIndex, isLeftEdge, newXN, completed); }); } From be48c0d855b23c910ef8d7c739167cba9893ca97 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:57:52 +0200 Subject: [PATCH 10/36] Point muse submodule back at upstream main This branch doesn't depend on the (not yet merged) MuseSampler velocity fix, so pin it to a commit that's actually on musescore/muse_framework:main - the check_muse_framework CI check rejects fork-only commits. --- muse | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/muse b/muse index 56f8f626f38eb..a7422db9322a7 160000 --- a/muse +++ b/muse @@ -1 +1 @@ -Subproject commit 56f8f626f38eb25fcaa909dd97f3fbc3e7ea682f +Subproject commit a7422db9322a7e461692e280c7aca2654082eb40 From c4a8b0d8a919e654aed13e4674e8f67ce52a1a85 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:58:39 +0200 Subject: [PATCH 11/36] Re-pin muse submodule to velocity fix, fix codestyle violation The merge from feature/note-offset-drag-handles reset the submodule pointer back to upstream main; this branch actually needs the MuseSampler velocity fix (musescore/muse_framework#221), so re-pin it to fix/musesampler-note-velocity's tip. Also fixes an extra blank line flagged by the codestyle CI check in NotationNoteVelocityController. --- muse | 2 +- .../MuseScore/NotationScene/notationnotevelocitycontroller.cpp | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/muse b/muse index a7422db9322a7..56f8f626f38eb 160000 --- a/muse +++ b/muse @@ -1 +1 @@ -Subproject commit a7422db9322a7e461692e280c7aca2654082eb40 +Subproject commit 56f8f626f38eb25fcaa909dd97f3fbc3e7ea682f diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index 1b5955d909783..e9de5aebc9ec3 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -64,7 +64,6 @@ constexpr static int MAX_DRAGGABLE_VELOCITY = 127; constexpr static double BAR_HALF_WIDTH_SP = 0.45; constexpr static double BAND_V_PADDING_SP = 0.3; - NotationNoteVelocityController::NotationNoteVelocityController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx) : muse::Contextable(iocCtx), m_overlaysParent(overlaysParent) { From 12ad71954b1532a199679a17f7b54c9db86da293 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 13:35:23 +0200 Subject: [PATCH 12/36] Fix playback timing regression for grace notes, arpeggios and bends Note::effectivePlaybackStartTime()/effectivePlaybackDuration() derived the note's playback window from the chord's own tick()/ticks(), which is correct for an ordinary note but not for a grace note, an arpeggio note, or a note inside a repeated section: their actual playback window is computed separately (see GraceChordCtx::buildCtx and the repeat-aware positionTickOffset handling) and can differ substantially from the chord's notated tick/duration. Recomputing from chord tick/ticks discarded that and collapsed grace notes back onto their principal note's timing, breaking 16 unit tests. Apply playbackStartOffset()/playbackDurationOffset() directly in NoteRenderer::render() instead, on top of the RenderingContext's already-correct nominal tick range, and only when an offset is actually set (so unedited notes take the exact same code path as before this feature existed). --- src/engraving/dom/note.cpp | 33 ------------------- src/engraving/dom/note.h | 3 -- .../playback/renderers/noterenderer.cpp | 25 ++++++++++---- 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/src/engraving/dom/note.cpp b/src/engraving/dom/note.cpp index 479eb18ffd40c..edf61dbf612af 100644 --- a/src/engraving/dom/note.cpp +++ b/src/engraving/dom/note.cpp @@ -4200,39 +4200,6 @@ int Note::stringOrLine() const return staff()->staffType(tick())->isTabStaff() ? string() * 2 : line(); } -//-------------------------------------------------------- -// effectivePlaybackStartTime -//-------------------------------------------------------- - -int Note::effectivePlaybackStartTime() const -{ - const Chord* ch = chord(); - if (!ch) { - return 0; - } - // playbackStartOffset() and playbackDurationOffset() are independently user-settable (e.g. - // via the Properties panel), so clamp here rather than trust their combination to stay sane. - return std::max(0, ch->tick().ticks() + playbackStartOffset()); -} - -//-------------------------------------------------------- -// effectivePlaybackDuration -//-------------------------------------------------------- - -int Note::effectivePlaybackDuration() const -{ - const Chord* ch = chord(); - if (!ch) { - return 0; - } - // Derive from the same (possibly clamped) start effectivePlaybackStartTime() returns, rather - // than recomputing independently from the raw offsets - otherwise the two can disagree once - // the start clamp kicks in, and the note would end up playing longer than its clamped start - // implies. - const int nominalEndTick = ch->tick().ticks() + ch->ticks().ticks() + playbackDurationOffset(); - return std::max(1, nominalEndTick - effectivePlaybackStartTime()); -} - //--------------------------------------------------------- // Note::transposeDiatonic //--------------------------------------------------------- diff --git a/src/engraving/dom/note.h b/src/engraving/dom/note.h index 13431ee4f504f..153ae8da42104 100644 --- a/src/engraving/dom/note.h +++ b/src/engraving/dom/note.h @@ -440,9 +440,6 @@ class Note final : public EngravingItem int playbackDurationOffset() const { return m_playbackDurationOffset; } void setPlaybackDurationOffset(int offset) { m_playbackDurationOffset = offset; } - int effectivePlaybackStartTime() const; - int effectivePlaybackDuration() const; - SymId noteHead() const; bool isNoteName() const; diff --git a/src/engraving/playback/renderers/noterenderer.cpp b/src/engraving/playback/renderers/noterenderer.cpp index ee4341b7602e3..3b0f8734cfce1 100644 --- a/src/engraving/playback/renderers/noterenderer.cpp +++ b/src/engraving/playback/renderers/noterenderer.cpp @@ -128,12 +128,25 @@ void NoteRenderer::render(const Note* note, const RenderingContext& ctx, mpe::Pl return; } - int startTicks = note->effectivePlaybackStartTime(); - int durationTicks = note->effectivePlaybackDuration(); - - auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, startTicks, durationTicks, ctx.positionTickOffset); - noteCtx.timestamp = effectiveTnD.timestamp; - noteCtx.duration = effectiveTnD.duration; + if (note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0) { + // playbackStartOffset()/playbackDurationOffset() are ticks relative to the note's own + // nominal position - apply them on top of ctx's nominal tick range rather than + // recomputing from the chord's own tick()/ticks(). The chord's tick()/ticks() are its + // notated position/duration, which for a grace note or a note inside a repeated section + // is NOT the same as when/how long it actually plays - ctx.nominalPositionStartTick/ + // nominalPositionEndTick already reflect that (see GraceChordCtx::buildCtx), whereas + // chord->tick() would collapse a grace note's "before the beat" timing back to the + // principal note's tick. + const int nominalStartTick = ctx.nominalPositionStartTick; + const int nominalEndTick = ctx.nominalPositionEndTick + note->playbackDurationOffset(); + const int effectiveStartTick = std::max(0, nominalStartTick + note->playbackStartOffset()); + const int effectiveDurationTicks = std::max(1, nominalEndTick - effectiveStartTick); + + auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, effectiveStartTick, effectiveDurationTicks, + ctx.positionTickOffset); + noteCtx.timestamp = effectiveTnD.timestamp; + noteCtx.duration = effectiveTnD.duration; + } const Tie* tieFor = note->tieFor(); if (tieFor && tieFor->playSpanner()) { From 59e5ccce924df7edc850f8b38c6de026ceb9d9ad Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 13:53:04 +0200 Subject: [PATCH 13/36] Fix codestyle (uncrustify) violation in NoteRenderer::render() Continuation-line indentation was off by one space, flagged by the codestyle CI check. --- src/engraving/playback/renderers/noterenderer.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/engraving/playback/renderers/noterenderer.cpp b/src/engraving/playback/renderers/noterenderer.cpp index 3b0f8734cfce1..036a1f04556af 100644 --- a/src/engraving/playback/renderers/noterenderer.cpp +++ b/src/engraving/playback/renderers/noterenderer.cpp @@ -143,7 +143,7 @@ void NoteRenderer::render(const Note* note, const RenderingContext& ctx, mpe::Pl const int effectiveDurationTicks = std::max(1, nominalEndTick - effectiveStartTick); auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, effectiveStartTick, effectiveDurationTicks, - ctx.positionTickOffset); + ctx.positionTickOffset); noteCtx.timestamp = effectiveTnD.timestamp; noteCtx.duration = effectiveTnD.duration; } From 917b4f903bbee0133605408dfd50bd15e2f4fea9 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 15:06:34 +0200 Subject: [PATCH 14/36] Show a live numeric readout next to the bar while dragging a velocity handle Displays the velocity value (0-127) in a small chip next to the bar's top edge, updated in real time as the bar is dragged, so the exact new value is visible without guessing from bar height alone - matches the convention used by Dorico's own velocity lane. Only the actively dragged bar shows the readout, to keep the staff uncluttered the rest of the time. The chip's colors are picked from the score's current background color (INotationConfiguration::backgroundColor(), already theme-aware: light/dark/high-contrast paper, or a user-customized color) rather than hardcoded, so it stays legible against light or dark paper alike instead of only working for the default white background. --- .../notationnotevelocitycontroller.cpp | 17 +++++- .../notationnotevelocitycontroller.h | 3 + .../NotationScene/notevelocityoverlay.cpp | 57 +++++++++++++++++++ .../NotationScene/notevelocityoverlay.h | 5 ++ 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index e9de5aebc9ec3..c4055d1676901 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -286,10 +286,12 @@ void NotationNoteVelocityController::createOverlayForStaff(const System* system, rect.leftN = (entry.leftX - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.rightN = (entry.rightX - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.y0N = (entry.yRange.y0 - overlayCanvasRect.y()) / overlayCanvasRect.height(); - const double initialTopY = canvasYFromVelocity(entry.yRange, displayedVelocity(entry.note)); + const int velocity = displayedVelocity(entry.note); + const double initialTopY = canvasYFromVelocity(entry.yRange, velocity); rect.yTopN = (initialTopY - overlayCanvasRect.y()) / overlayCanvasRect.height(); rect.selected = muse::contains(selected, entry.note); rect.userModified = entry.note->userVelocity() != 0; + rect.velocity = velocity; rects.push_back(rect); } @@ -334,6 +336,18 @@ void NotationNoteVelocityController::applyOverlayColors(NoteVelocityOverlay* ove overlay->setSelectedFillColor(QColor(60, 160, 210, 235)); overlay->setModifiedFillColor(QColor(235, 140, 40, 230)); overlay->setBorderColor(QColor(50, 130, 100, 255)); + + // The value-label chip needs to stay legible against whatever the score's own background + // currently is (light/dark/high-contrast paper, or a user-customized color) - picking its + // colors from that background's luminance, rather than hardcoding per theme, keeps it correct + // even for a custom paper color that doesn't match either preset. + const QColor background = notationConfiguration() ? notationConfiguration()->backgroundColor() : QColor(Qt::white); + const double luminance = 0.299 * background.red() + 0.587 * background.green() + 0.114 * background.blue(); + if (luminance > 128.0) { + overlay->setValueLabelColors(QColor(40, 40, 40, 235), QColor(255, 255, 255)); + } else { + overlay->setValueLabelColors(QColor(235, 235, 235, 235), QColor(20, 20, 20)); + } } void NotationNoteVelocityController::updateOverlaysGeometry() @@ -425,6 +439,7 @@ void NotationNoteVelocityController::previewBarHeight(const NoteLocation& locati // vector out and back on every mouse-move during a drag. NoteVelocityOverlay::RectData rect = rects.at(location.rectIndex); rect.yTopN = (newTopY - data.bandRect.y()) / data.bandRect.height(); + rect.velocity = newVelocity; data.overlay->updateRect(location.rectIndex, rect); } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h index f8c7e41e4d394..249a9f04dcc59 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -27,6 +27,8 @@ #include "context/iglobalcontext.h" #include "async/asyncable.h" +#include "modularity/ioc.h" +#include "notation/inotationconfiguration.h" #include "notation/notationtypes.h" #include "notevelocitygeometry.h" @@ -40,6 +42,7 @@ class NoteVelocityOverlay; class NotationNoteVelocityController : public muse::Contextable, public muse::async::Asyncable { muse::ContextInject globalContext = { this }; + muse::GlobalInject notationConfiguration; public: NotationNoteVelocityController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index 9656dfe9d20dd..434e819cdbbb6 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -33,6 +33,12 @@ using namespace mu::notation; constexpr static qreal EDGE_HIT_MARGIN_PX = 4.0; constexpr static qreal BAR_HALF_WIDTH_MARGIN_PX = 1.0; // keeps adjacent chord bars from visually touching +constexpr static qreal VALUE_LABEL_FONT_PX = 11.0; +constexpr static qreal VALUE_LABEL_GAP_PX = 4.0; // horizontal gap between the bar and the label chip +constexpr static qreal VALUE_LABEL_PADDING_X_PX = 4.0; +constexpr static qreal VALUE_LABEL_PADDING_Y_PX = 2.0; +constexpr static qreal VALUE_LABEL_CORNER_RADIUS_PX = 3.0; + NoteVelocityOverlay::NoteVelocityOverlay(QQuickItem* parent) : QQuickPaintedItem(parent) { @@ -84,6 +90,13 @@ void NoteVelocityOverlay::setBorderColor(const QColor& color) update(); } +void NoteVelocityOverlay::setValueLabelColors(const QColor& background, const QColor& text) +{ + m_valueLabelBgColor = background; + m_valueLabelTextColor = text; + update(); +} + void NoteVelocityOverlay::paint(QPainter* painter) { if (m_rects.isEmpty()) { @@ -107,6 +120,50 @@ void NoteVelocityOverlay::paint(QPainter* painter) painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); painter->drawRect(barRect); } + + // Only the bar actually being dragged gets a live numeric readout, to keep the staff + // uncluttered the rest of the time (matches Dorico's convention for its velocity lane). + if (m_pressed && m_activeRectIndex >= 0 && m_activeRectIndex < m_rects.size()) { + paintValueLabel(painter, m_rects.at(m_activeRectIndex)); + } +} + +void NoteVelocityOverlay::paintValueLabel(QPainter* painter, const RectData& rect) const +{ + const QString text = QString::number(rect.velocity); + + QFont font = painter->font(); + font.setPixelSize(static_cast(VALUE_LABEL_FONT_PX)); + painter->setFont(font); + + const QFontMetrics metrics(font); + const QSize textSize = metrics.size(Qt::TextSingleLine, text); + + const qreal chipWidth = textSize.width() + 2 * VALUE_LABEL_PADDING_X_PX; + const qreal chipHeight = textSize.height() + 2 * VALUE_LABEL_PADDING_Y_PX; + + const qreal leftPx = rect.leftN * width(); + const qreal rightPx = rect.rightN * width(); + const qreal topPx = rect.yTopN * height(); + + // Prefer sitting to the right of the bar; flip to the left if there isn't room, rather than + // letting the chip run off the edge of the overlay. + qreal chipLeft = rightPx + VALUE_LABEL_GAP_PX; + if (chipLeft + chipWidth > width()) { + chipLeft = leftPx - VALUE_LABEL_GAP_PX - chipWidth; + } + chipLeft = std::clamp(chipLeft, 0.0, std::max(0.0, width() - chipWidth)); + + const qreal chipTop = std::clamp(topPx - chipHeight / 2.0, 0.0, std::max(0.0, height() - chipHeight)); + + const QRectF chipRect(chipLeft, chipTop, chipWidth, chipHeight); + + painter->setPen(Qt::NoPen); + painter->setBrush(m_valueLabelBgColor); + painter->drawRoundedRect(chipRect, VALUE_LABEL_CORNER_RADIUS_PX, VALUE_LABEL_CORNER_RADIUS_PX); + + painter->setPen(m_valueLabelTextColor); + painter->drawText(chipRect, Qt::AlignCenter, text); } int NoteVelocityOverlay::hitTestPx(const QPointF& posPx) const diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index 4921989f5dbdc..30b75ed87a4ba 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -50,6 +50,7 @@ class NoteVelocityOverlay : public QQuickPaintedItem qreal yTopN = 1.0; // current top edge, i.e. the note's velocity bool selected = false; bool userModified = false; // has an explicit user-set velocity, vs. the dynamics-derived default + int velocity = 0; // current velocity (0-127), shown next to the bar while it's being dragged }; explicit NoteVelocityOverlay(QQuickItem* parent); @@ -66,6 +67,7 @@ class NoteVelocityOverlay : public QQuickPaintedItem void setSelectedFillColor(const QColor& color); void setModifiedFillColor(const QColor& color); void setBorderColor(const QColor& color); + void setValueLabelColors(const QColor& background, const QColor& text); void paint(QPainter* painter) override; @@ -82,6 +84,7 @@ class NoteVelocityOverlay : public QQuickPaintedItem private: int hitTestPx(const QPointF& posPx) const; + void paintValueLabel(QPainter* painter, const RectData& rect) const; QVector m_rects; @@ -89,6 +92,8 @@ class NoteVelocityOverlay : public QQuickPaintedItem QColor m_selectedFillColor; QColor m_modifiedFillColor; QColor m_borderColor; + QColor m_valueLabelBgColor; + QColor m_valueLabelTextColor; bool m_pressed = false; int m_activeRectIndex = -1; From dd03a90ad612c52c33d71d79899fa83a217888af Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 16:48:35 +0200 Subject: [PATCH 15/36] Fix playback start/duration offset not surviving save/reload Note::playbackStartOffset()/playbackDurationOffset() were only wired into read460/tread.cpp's XML reader, but files saved by this app go through read500 (the current format version, dispatched by RWRegister::reader() for version >= 500) - read460 is only ever used to open older 4.60-4.99 files, which can never contain this property in the first place since it didn't exist yet. The property was written correctly (twrite.cpp) but silently dropped on reload because the reader that actually matters never looked for the tag, resetting both values to 0 every time a file was saved and reopened. Moved the read hooks to read500/tread.cpp, removed the dead ones from read460, and added a save/reload regression test (writeReadElement round-trip) to Engraving_NoteTests.note. --- src/engraving/rw/read460/tread.cpp | 2 -- src/engraving/rw/read500/tread.cpp | 2 ++ src/engraving/tests/note_tests.cpp | 8 ++++++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/engraving/rw/read460/tread.cpp b/src/engraving/rw/read460/tread.cpp index 525d32c8a041b..062160a386ea5 100644 --- a/src/engraving/rw/read460/tread.cpp +++ b/src/engraving/rw/read460/tread.cpp @@ -3414,8 +3414,6 @@ bool TRead::readProperties(Note* n, XmlReader& e, ReadContext& ctx) } else if (tag == "overrideBendVisibilityRules") { n->setOverrideBendVisibilityRules(e.readBool()); } else if (TRead::readProperty(n, tag, e, ctx, Pid::HIDE_GENERATED_PARENTHESES)) { - } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_START_OFFSET)) { - } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_DURATION_OFFSET)) { } else if (readItemProperties(n, e, ctx)) { } else { return false; diff --git a/src/engraving/rw/read500/tread.cpp b/src/engraving/rw/read500/tread.cpp index 95e43e82d1555..a3f86dd44931c 100644 --- a/src/engraving/rw/read500/tread.cpp +++ b/src/engraving/rw/read500/tread.cpp @@ -3470,6 +3470,8 @@ bool TRead::readProperties(Note* n, XmlReader& e, ReadContext& ctx) } else if (tag == "overrideBendVisibilityRules") { n->setOverrideBendVisibilityRules(e.readBool()); } else if (TRead::readProperty(n, tag, e, ctx, Pid::HIDE_GENERATED_PARENTHESES)) { + } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_START_OFFSET)) { + } else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_DURATION_OFFSET)) { } else if (readItemProperties(n, e, ctx)) { } else { return false; diff --git a/src/engraving/tests/note_tests.cpp b/src/engraving/tests/note_tests.cpp index 99aed7a3410b0..1f42733412c0f 100644 --- a/src/engraving/tests/note_tests.cpp +++ b/src/engraving/tests/note_tests.cpp @@ -149,6 +149,14 @@ TEST_F(Engraving_NoteTests, note) EXPECT_EQ(n->userVelocity(), 71); delete n; + // playback start/duration offset + note->setPlaybackStartOffset(120); + note->setPlaybackDurationOffset(-60); + n = toNote(ScoreRW::writeReadElement(note)); + EXPECT_EQ(n->playbackStartOffset(), 120); + EXPECT_EQ(n->playbackDurationOffset(), -60); + delete n; + // tuning note->setTuning(1.3); n = toNote(ScoreRW::writeReadElement(note)); From 142dde7e49231070ee3aeb0a86dec3baa0b3c0d9 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 22:40:02 +0200 Subject: [PATCH 16/36] Fix note-offset rectangle width when another voice has a shorter note createOverlayForStaff() anchored the nominal (zero-offset) right edge on "the next ChordRest segment", but that segment is shared across every voice/track on the staff. If a different voice had a shorter simultaneous note (e.g. an eighth note under a quarter note), its segment became the "next" one for every voice, cutting the longer note's rectangle down to the shorter note's end tick instead of its own. Anchor the right edge on this chord's own end tick via canvasXFromTick instead, which already correctly interpolates/snaps to the real segment at that tick regardless of which voice created it. --- .../NotationScene/notationnoteoffsetcontroller.cpp | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 8d314776dd4b3..dcdbb80545181 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -249,12 +249,13 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s } const Chord* chord = toChord(item); - // The nominal (zero-offset) span is anchored on the note/segment's own real layout - // position, not on a tick->x interpolation - this guarantees the rectangle sits - // exactly on the notehead when there's no offset yet. - const Segment* nextSeg = seg->next1(SegmentType::ChordRest); - const double nominalRightX = (nextSeg && nextSeg->system() == system) - ? nextSeg->canvasX() : (seg->canvasX() + seg->width()); + // The nominal (zero-offset) right edge is anchored on this chord's own end tick, not + // on "the next ChordRest segment" - that segment is shared across every voice/track at + // this staff, so a shorter simultaneous note in another voice would otherwise cut this + // chord's rectangle down to the shorter note's end tick instead of its own. + const int chordEndTick = chord->tick().ticks() + chord->ticks().ticks(); + const std::optional interpolatedRightX = mu::notation::canvasXFromTick(system, chordEndTick); + const double nominalRightX = interpolatedRightX ? *interpolatedRightX : (seg->canvasX() + seg->width()); for (Note* note : chord->notes()) { if (note->tieBack()) { From ddfdd631d8881b523305699d37f40d1827a605ca Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 14:43:10 +0200 Subject: [PATCH 17/36] Fix note velocity not propagating to tied-continuation notes Dragging a note's velocity only updated that note's own USER_VELOCITY/ VELO_TYPE properties. A tied-continuation note is usually skipped entirely by playback rendering, but in some configurations (tremolo across the tie, partial ties across a repeat, multi-note articulations, a trill ending on the tie's start chord) it is still rendered as its own independent event using its own, never-touched velocity, causing an audible volume jump. Mirror the dragged velocity onto the whole forward tie chain so every note in it stays in sync. --- .../notationnotevelocitycontroller.cpp | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index c4055d1676901..de2a647102375 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -38,6 +38,7 @@ #include "engraving/dom/segment.h" #include "engraving/dom/staff.h" #include "engraving/dom/system.h" +#include "engraving/dom/tie.h" #include "engraving/types/types.h" #include "mpe/mpetypes.h" @@ -492,6 +493,32 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re changes.push_back({ note, otherVelocity }); } + // A tied-continuation note either produces no playback event of its own (its own velocity is + // then irrelevant) or, in some tie configurations (a tremolo spanning the tie, a partial tie + // across a repeat, a multi-note articulation, a trill ending on the tie's start chord), is + // rendered as its own independent event using its own velocity - which was otherwise never + // touched by this overlay (createOverlayForStaff() doesn't offer it a handle at all). Mirror + // every affected note's new value onto its whole forward tie chain so neither case is left + // with a stale value. + std::vector tiedChanges; + for (const PendingChange& change : changes) { + std::vector chain { change.note }; + for (Tie* tie = change.note->tieFor(); tie; tie = tie->endNote() ? tie->endNote()->tieFor() : nullptr) { + Note* tied = tie->endNote(); + if (!tied || muse::contains(chain, tied)) { + break; + } + chain.push_back(tied); + + const bool alreadyPending = muse::contains_if(changes, [tied](const PendingChange& c) { return c.note == tied; }) + || muse::contains_if(tiedChanges, [tied](const PendingChange& c) { return c.note == tied; }); + if (!alreadyPending) { + tiedChanges.push_back({ tied, change.velocity }); + } + } + } + changes.insert(changes.end(), tiedChanges.begin(), tiedChanges.end()); + if (!completed) { // Live drag preview - update every affected overlay's displayed bar height without // touching the score. From 2f81c01a0a3f3e8c48e0fe517ff7d7d09d8c5ce2 Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:08:36 +0200 Subject: [PATCH 18/36] Write Pid::VELO_TYPE when saving notes VELO_TYPE was read from XML on load but never included in the note property write list, so it was silently dropped on save. Harmless for the common USER_VAL case (the in-class default happens to match), but loses an OFFSET_VAL (percentage nudge) override on save/reload. --- src/engraving/rw/write/twrite.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/engraving/rw/write/twrite.cpp b/src/engraving/rw/write/twrite.cpp index 0eeb258707884..84d286394de69 100644 --- a/src/engraving/rw/write/twrite.cpp +++ b/src/engraving/rw/write/twrite.cpp @@ -2505,8 +2505,8 @@ void TWrite::write(const Note* item, XmlWriter& xml, WriteContext& ctx) xml.endElement(); } for (Pid id : { Pid::PITCH, Pid::CENT_OFFSET, Pid::TPC1, Pid::TPC2, Pid::SMALL, Pid::MIRROR_HEAD, Pid::DOT_POSITION, - Pid::HEAD_SCHEME, Pid::HEAD_GROUP, Pid::USER_VELOCITY, Pid::PLAY, Pid::TUNING, Pid::FRET, Pid::STRING, - Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE, + Pid::HEAD_SCHEME, Pid::HEAD_GROUP, Pid::VELO_TYPE, Pid::USER_VELOCITY, Pid::PLAY, Pid::TUNING, Pid::FRET, + Pid::STRING, Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE, Pid::PLAYBACK_START_OFFSET, Pid::PLAYBACK_DURATION_OFFSET }) { writeProperty(item, xml, id); } From 8816db7f0950cfb7b26c23e33cd6861eb925306d Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:20:34 +0200 Subject: [PATCH 19/36] Color-code note-offset rectangles by modification/selection state Match the note-velocity overlay's convention: green when both playback offsets are at their default (0), orange as soon as either the start or duration offset has been user-modified, blue when the note is selected (taking priority over the modified color, same as velocity). Both offsets already round-trip correctly through save/reload, so the color - computed live from those persisted values on every rebuild - does too. --- .../notationnoteoffsetcontroller.cpp | 49 +++++++++++++++++-- .../notationnoteoffsetcontroller.h | 1 + .../NotationScene/noteoffsetoverlay.cpp | 14 +++++- .../NotationScene/noteoffsetoverlay.h | 6 +++ 4 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index dcdbb80545181..8b71de233e8d2 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -155,6 +155,15 @@ void NotationNoteOffsetController::onCurrentNotationChanged() scheduleRebuild(); }, Asyncable::Mode::SetReplace); } + + if (notation->interaction()) { + notation->interaction()->selectionChanged().onNotify(this, [this, thisNotation]() { + if (thisNotation != currentNotation().get()) { + return; + } + updateSelectionHighlight(); + }, Asyncable::Mode::SetReplace); + } } } @@ -311,6 +320,8 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); + const std::vector selected = selectedNotes(); + QVector rects; rects.reserve(static_cast(entries.size())); @@ -339,6 +350,8 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s rect.rightN = (rightPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.centerYN = (centerY[i] - overlayCanvasRect.y()) / overlayCanvasRect.height(); rect.heightYN = rectHeight / overlayCanvasRect.height(); + rect.selected = muse::contains(selected, entry.note); + rect.userModified = note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0; rects.push_back(rect); } @@ -384,9 +397,39 @@ void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay return; } - overlay->setFillColor(QColor(100, 150, 220, 60)); - overlay->setBorderColor(QColor(80, 130, 200, 200)); - overlay->setHandleColor(QColor(60, 110, 190, 230)); + overlay->setFillColor(QColor(90, 180, 140, 60)); + overlay->setSelectedFillColor(QColor(60, 160, 210, 90)); + overlay->setModifiedFillColor(QColor(235, 140, 40, 90)); + overlay->setBorderColor(QColor(50, 130, 100, 200)); + overlay->setHandleColor(QColor(70, 70, 70, 230)); +} + +void NotationNoteOffsetController::updateSelectionHighlight() +{ + if (!noteOffsets() || !noteOffsets()->isEditModeEnabled()) { + return; + } + + const std::vector selected = selectedNotes(); + + for (const auto& [key, data] : m_overlaysByStaff) { + const QVector& rects = data.overlay->rects(); + if (rects.size() != static_cast(data.notes.size())) { + continue; + } + + // Only a handful of notes typically change selection at once, even on a staff with many + // notes - update just those rects in place instead of copying the whole vector out and + // back regardless of how many actually changed. + for (int i = 0; i < rects.size(); ++i) { + const bool isSelected = muse::contains(selected, data.notes.at(i).note); + if (rects.at(i).selected != isSelected) { + NoteOffsetOverlay::RectData rect = rects.at(i); + rect.selected = isSelected; + data.overlay->updateRect(i, rect); + } + } + } } void NotationNoteOffsetController::updateOverlaysGeometry() diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h index 9ce43dcc789ea..b62ab090eb5b9 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -104,6 +104,7 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn void rebuildAllOverlays(); void createOverlayForStaff(const System* system, staff_idx_t staffIdx, OverlaysMap& newOverlays); void updateOverlaysGeometry(); + void updateSelectionHighlight(); void applyOverlayColors(NoteOffsetOverlay* overlay) const; void onCurrentNotationChanged(); diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 7c18ecc62f743..032902c2e28d6 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -69,6 +69,18 @@ void NoteOffsetOverlay::setFillColor(const QColor& color) update(); } +void NoteOffsetOverlay::setSelectedFillColor(const QColor& color) +{ + m_selectedFillColor = color; + update(); +} + +void NoteOffsetOverlay::setModifiedFillColor(const QColor& color) +{ + m_modifiedFillColor = color; + update(); +} + void NoteOffsetOverlay::setBorderColor(const QColor& color) { m_borderColor = color; @@ -102,7 +114,7 @@ void NoteOffsetOverlay::paint(QPainter* painter) const qreal cornerRadius = std::min(halfHeightPx, bodyRect.width() / 2.0); painter->setPen(QPen(m_borderColor, 1.0)); - painter->setBrush(m_fillColor); + painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); painter->setPen(Qt::NoPen); diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index 0d1ddfeca7ca2..f6fbe7540d673 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -41,6 +41,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem qreal rightN = 0.0; qreal centerYN = 0.5; qreal heightYN = 1.0; + bool selected = false; + bool userModified = false; // either playback offset is non-zero }; explicit NoteOffsetOverlay(QQuickItem* parent); @@ -54,6 +56,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem void updateRect(int index, const RectData& rect); void setFillColor(const QColor& color); + void setSelectedFillColor(const QColor& color); + void setModifiedFillColor(const QColor& color); void setBorderColor(const QColor& color); void setHandleColor(const QColor& color); @@ -86,6 +90,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem QVector m_rects; QColor m_fillColor; + QColor m_selectedFillColor; + QColor m_modifiedFillColor; QColor m_borderColor; QColor m_handleColor; From 76ac7a2b130a7bff2aca40c8bf9f4b534b6c8d59 Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:41:12 +0200 Subject: [PATCH 20/36] Add "Reset note offsets" context-menu item Mirrors the existing Automation context-menu entry: only appears when note-offset edit mode is enabled (NotationContextMenuModel::loadItems), and resets both playback offsets to 0 for every selected note via the undo stack, so notes revert to the system-default (unmodified) offset. --- .../internal/notationactioncontroller.cpp | 25 +++++++++++++++++++ .../internal/notationactioncontroller.h | 1 + .../internal/notationcommandsregister.cpp | 7 ++++++ src/notationscene/notationcommands.h | 1 + .../notationcontextmenumodel.cpp | 12 +++++++++ .../NotationScene/notationcontextmenumodel.h | 2 ++ 6 files changed, 48 insertions(+) diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 9ad23b1817332..42a532f79cfef 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -31,6 +31,7 @@ #include "engraving/dom/harmony.h" #include "engraving/dom/masterscore.h" #include "engraving/dom/note.h" +#include "engraving/dom/property.h" #include "engraving/dom/chord.h" #include "engraving/dom/text.h" #include "engraving/dom/sig.h" @@ -583,6 +584,7 @@ void NotationActionController::init() registerCommand(TOGGLE_AUTOMATION_COMMAND, &Controller::toggleAutomation); registerQueryCommand(SELECT_AUTOMATION_TYPE_COMMAND, &Controller::selectAutomationType); registerCommand(TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, &Controller::toggleNoteOffsetEditor); + registerCommand(RESET_NOTE_OFFSETS_COMMAND, &Controller::resetNoteOffsets); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -3289,6 +3291,29 @@ void NotationActionController::toggleNoteOffsetEditor() masterNotation->noteOffsets()->setEditModeEnabled(!isEnabled); } +void NotationActionController::resetNoteOffsets() +{ + TRACEFUNC; + + INotationSelectionPtr selection = currentNotationSelection(); + std::vector notes = selection ? selection->notes() : std::vector(); + if (notes.empty()) { + return; + } + + INotationUndoStackPtr undoStack = currentNotationUndoStack(); + if (!undoStack) { + return; + } + + undoStack->prepareChanges(TranslatableString("undoableAction", "Reset note offsets")); + for (Note* note : notes) { + note->undoChangeProperty(Pid::PLAYBACK_START_OFFSET, 0, mu::engraving::PropertyFlags::NOSTYLE); + note->undoChangeProperty(Pid::PLAYBACK_DURATION_OFFSET, 0, mu::engraving::PropertyFlags::NOSTYLE); + } + undoStack->commitChanges(); +} + muse::Ret NotationActionController::selectAutomationType(const muse::rcommand::CommandQuery& query) { const std::string type = query.param("type").toString(); diff --git a/src/notationscene/internal/notationactioncontroller.h b/src/notationscene/internal/notationactioncontroller.h index efd04fc578fca..8b1ee2cc6cbf9 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -273,6 +273,7 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); void toggleNoteOffsetEditor(); + void resetNoteOffsets(); // commands void registerCommand(const muse::rcommand::Command&, std::function); diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index 40abd65d81c6a..dd66f7b4dbfd7 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2921,6 +2921,13 @@ static const std::vector s_commandInfos = { InputSchema(), Decoration(IconCode::Code::CLOCK, rcommand::Checkable::Yes) }, + CommandInfo { + RESET_NOTE_OFFSETS_COMMAND, + TranslatableString("action", "Reset note offsets"), + TranslatableString("action", "Reset note offsets"), + InputSchema(), + Decoration() + }, CommandInfo { SELECT_AUTOMATION_TYPE_COMMAND, TranslatableString::untranslatable("Automation type"), diff --git a/src/notationscene/notationcommands.h b/src/notationscene/notationcommands.h index 2d3d5f53d3af8..c037b6eecb828 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -485,6 +485,7 @@ inline static const muse::rcommand::Command VOICE_ASSIGNMENT_ALL_IN_STAFF_COMMAN inline static const muse::rcommand::Command TOGGLE_AUTOMATION_COMMAND("command://notation/toggle-automation"); inline static const muse::rcommand::Command SELECT_AUTOMATION_TYPE_COMMAND("command://notation/select-automation-type"); // with params inline static const muse::rcommand::Command TOGGLE_NOTE_OFFSET_EDITOR_COMMAND("command://notation/toggle-note-offset-editor"); +inline static const muse::rcommand::Command RESET_NOTE_OFFSETS_COMMAND("command://notation/reset-note-offsets"); // TAB commands inline static const muse::rcommand::Command SET_DURATION_WHOLE_TAB_COMMAND("command://notation/set-duration-whole-tab"); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp index e004fe50b0553..b6ecaeea67136 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp @@ -57,6 +57,12 @@ void NotationContextMenuModel::loadItems(int elementType) << makeMenu(TranslatableString::untranslatable("Automation type"), makeAutomationTypeItems()); } + const INotationNoteOffsetsPtr noteOffsets = this->noteOffsets(); + if (noteOffsets && noteOffsets->isEditModeEnabled()) { + items << makeSeparator() + << makeMenuItem(RESET_NOTE_OFFSETS_COMMAND); + } + setItems(items); } @@ -537,6 +543,12 @@ INotationAutomationPtr NotationContextMenuModel::automation() const return masterNotation ? masterNotation->automation() : nullptr; } +INotationNoteOffsetsPtr NotationContextMenuModel::noteOffsets() const +{ + IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteOffsets() : nullptr; +} + const EngravingItem* NotationContextMenuModel::currentElement() const { const EngravingItem* element = hitElementContext().element; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h index 114fa8b33e57b..cc6ff97f0b528 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h @@ -29,6 +29,7 @@ #include "notation/inotationinteraction.h" #include "notation/inotationautomation.h" +#include "notation/inotationnoteoffsets.h" #include "notation/inotationconfiguration.h" namespace mu::notation { @@ -80,6 +81,7 @@ class NotationContextMenuModel : public muse::uicomponents::AbstractMenuModel INotationInteractionPtr interaction() const; INotationSelectionPtr selection() const; INotationAutomationPtr automation() const; + INotationNoteOffsetsPtr noteOffsets() const; const engraving::EngravingItem* currentElement() const; From b84d0714e5ea2c923b59ecf410fc671dc295b34b Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:55:27 +0200 Subject: [PATCH 21/36] Add "Reset note velocities" context-menu item Mirrors the existing "Reset note offsets" entry: only appears when note-velocity edit mode is enabled (NotationContextMenuModel::loadItems), and resets USER_VELOCITY to 0 for every selected note via the undo stack, so notes revert to the system-default (dynamics-derived) velocity - matching the userModified check already used by the velocity overlay's own color-coding. --- .../internal/notationactioncontroller.cpp | 23 +++++++++++++++++++ .../internal/notationactioncontroller.h | 1 + .../internal/notationcommandsregister.cpp | 7 ++++++ src/notationscene/notationcommands.h | 1 + .../notationcontextmenumodel.cpp | 12 ++++++++++ .../NotationScene/notationcontextmenumodel.h | 2 ++ 6 files changed, 46 insertions(+) diff --git a/src/notationscene/internal/notationactioncontroller.cpp b/src/notationscene/internal/notationactioncontroller.cpp index 4e830f6d6a9a0..82708a7ec6cde 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -587,6 +587,7 @@ void NotationActionController::init() registerCommand(TOGGLE_NOTE_OFFSET_EDITOR_COMMAND, &Controller::toggleNoteOffsetEditor); registerCommand(TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND, &Controller::toggleNoteVelocityEditor); registerCommand(RESET_NOTE_OFFSETS_COMMAND, &Controller::resetNoteOffsets); + registerCommand(RESET_NOTE_VELOCITIES_COMMAND, &Controller::resetNoteVelocities); // TAB registerCommand(SET_DURATION_WHOLE_TAB_COMMAND, [this]() { setDuration(DurationType::V_WHOLE); }); @@ -3344,6 +3345,28 @@ void NotationActionController::resetNoteOffsets() undoStack->commitChanges(); } +void NotationActionController::resetNoteVelocities() +{ + TRACEFUNC; + + INotationSelectionPtr selection = currentNotationSelection(); + std::vector notes = selection ? selection->notes() : std::vector(); + if (notes.empty()) { + return; + } + + INotationUndoStackPtr undoStack = currentNotationUndoStack(); + if (!undoStack) { + return; + } + + undoStack->prepareChanges(TranslatableString("undoableAction", "Reset note velocities")); + for (Note* note : notes) { + note->undoChangeProperty(Pid::USER_VELOCITY, 0, mu::engraving::PropertyFlags::NOSTYLE); + } + undoStack->commitChanges(); +} + muse::Ret NotationActionController::selectAutomationType(const muse::rcommand::CommandQuery& query) { const std::string type = query.param("type").toString(); diff --git a/src/notationscene/internal/notationactioncontroller.h b/src/notationscene/internal/notationactioncontroller.h index 0c9ecc076a62c..155955340f010 100644 --- a/src/notationscene/internal/notationactioncontroller.h +++ b/src/notationscene/internal/notationactioncontroller.h @@ -278,6 +278,7 @@ class NotationActionController : public INotationCommandsController, public muse void toggleNoteOffsetEditor(); void toggleNoteVelocityEditor(); void resetNoteOffsets(); + void resetNoteVelocities(); // commands void registerCommand(const muse::rcommand::Command&, std::function); diff --git a/src/notationscene/internal/notationcommandsregister.cpp b/src/notationscene/internal/notationcommandsregister.cpp index 306d48e8a1e45..cfe177f12a766 100644 --- a/src/notationscene/internal/notationcommandsregister.cpp +++ b/src/notationscene/internal/notationcommandsregister.cpp @@ -2935,6 +2935,13 @@ static const std::vector s_commandInfos = { InputSchema(), Decoration() }, + CommandInfo { + RESET_NOTE_VELOCITIES_COMMAND, + TranslatableString("action", "Reset note velocities"), + TranslatableString("action", "Reset note velocities"), + InputSchema(), + Decoration() + }, CommandInfo { SELECT_AUTOMATION_TYPE_COMMAND, TranslatableString::untranslatable("Automation type"), diff --git a/src/notationscene/notationcommands.h b/src/notationscene/notationcommands.h index a5ffb3bd32681..45319f0cd757a 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -487,6 +487,7 @@ inline static const muse::rcommand::Command SELECT_AUTOMATION_TYPE_COMMAND("comm inline static const muse::rcommand::Command TOGGLE_NOTE_OFFSET_EDITOR_COMMAND("command://notation/toggle-note-offset-editor"); inline static const muse::rcommand::Command TOGGLE_NOTE_VELOCITY_EDITOR_COMMAND("command://notation/toggle-note-velocity-editor"); inline static const muse::rcommand::Command RESET_NOTE_OFFSETS_COMMAND("command://notation/reset-note-offsets"); +inline static const muse::rcommand::Command RESET_NOTE_VELOCITIES_COMMAND("command://notation/reset-note-velocities"); // TAB commands inline static const muse::rcommand::Command SET_DURATION_WHOLE_TAB_COMMAND("command://notation/set-duration-whole-tab"); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp index b6ecaeea67136..5adc4f257aec3 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp @@ -63,6 +63,12 @@ void NotationContextMenuModel::loadItems(int elementType) << makeMenuItem(RESET_NOTE_OFFSETS_COMMAND); } + const INotationNoteVelocityPtr noteVelocity = this->noteVelocity(); + if (noteVelocity && noteVelocity->isEditModeEnabled()) { + items << makeSeparator() + << makeMenuItem(RESET_NOTE_VELOCITIES_COMMAND); + } + setItems(items); } @@ -549,6 +555,12 @@ INotationNoteOffsetsPtr NotationContextMenuModel::noteOffsets() const return masterNotation ? masterNotation->noteOffsets() : nullptr; } +INotationNoteVelocityPtr NotationContextMenuModel::noteVelocity() const +{ + IMasterNotationPtr masterNotation = globalContext()->currentMasterNotation(); + return masterNotation ? masterNotation->noteVelocity() : nullptr; +} + const EngravingItem* NotationContextMenuModel::currentElement() const { const EngravingItem* element = hitElementContext().element; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h index cc6ff97f0b528..7adea55346d6a 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.h @@ -30,6 +30,7 @@ #include "notation/inotationinteraction.h" #include "notation/inotationautomation.h" #include "notation/inotationnoteoffsets.h" +#include "notation/inotationnotevelocity.h" #include "notation/inotationconfiguration.h" namespace mu::notation { @@ -82,6 +83,7 @@ class NotationContextMenuModel : public muse::uicomponents::AbstractMenuModel INotationSelectionPtr selection() const; INotationAutomationPtr automation() const; INotationNoteOffsetsPtr noteOffsets() const; + INotationNoteVelocityPtr noteVelocity() const; const engraving::EngravingItem* currentElement() const; From 558094d59f555d4f54e3a745d1885d55ea4406fe Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 22:11:31 +0200 Subject: [PATCH 22/36] Keep a selected chord note's velocity bar on top and draggable Bars are painted back-to-front by pitch (lowest note frontmost) to mimic a piano-roll velocity lane, but that meant selecting a non-frontmost chord note left its bar visible only where a taller neighbor didn't cover it - and often not clickable at all, since hit-testing only exposed the portion of a back bar poking out above the front one. Selected bars are now redrawn on top of every other bar in their column, and hit-tested first, ignoring stacking-order occlusion, so picking a note (however it's selected) always makes its velocity bar fully visible and draggable. --- .../NotationScene/notevelocityoverlay.cpp | 57 +++++++++++++++++-- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index 434e819cdbbb6..e127ec3281b10 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -106,10 +106,7 @@ void NoteVelocityOverlay::paint(QPainter* painter) painter->setRenderHint(QPainter::Antialiasing); painter->setPen(QPen(m_borderColor, 1.0)); - // Bars are stored in back-to-front paint order (see header comment) - simply painting each - // one's fully opaque body in order reproduces the stacked/overlapping look of a DAW velocity - // lane, with no extra bookkeeping needed here. - for (const RectData& rect : m_rects) { + const auto drawBar = [&](const RectData& rect) { const qreal leftPx = rect.leftN * width() + BAR_HALF_WIDTH_MARGIN_PX; const qreal rightPx = rect.rightN * width() - BAR_HALF_WIDTH_MARGIN_PX; const qreal topPx = rect.yTopN * height(); @@ -119,6 +116,25 @@ void NoteVelocityOverlay::paint(QPainter* painter) painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); painter->drawRect(barRect); + }; + + // Bars are stored in back-to-front paint order (see header comment) - simply painting each + // one's fully opaque body in order reproduces the stacked/overlapping look of a DAW velocity + // lane, with no extra bookkeeping needed here. + for (const RectData& rect : m_rects) { + if (!rect.selected) { + drawBar(rect); + } + } + + // A selected note's bar must stay fully visible (and, per hitTestPx(), clickable) no matter + // where it sits in the pitch-based stacking order - otherwise selecting a chord note that isn't + // the pitch-frontmost one leaves its bar hidden behind another note's, with no way to drag it. + // Redraw selected bars last so they always end up on top. + for (const RectData& rect : m_rects) { + if (rect.selected) { + drawBar(rect); + } } // Only the bar actually being dragged gets a live numeric readout, to keep the staff @@ -184,11 +200,40 @@ int NoteVelocityOverlay::hitTestPx(const QPointF& posPx) const return -1; } - // candidates preserve the original back-to-front order - scanning in reverse visits the - // frontmost (lowest-pitched) bar first, exactly matching what's actually visible on screen. + // A selected bar is always redrawn on top of every other bar in its column (see paint()), so + // it must win hit-testing too, regardless of pitch-based stacking order - otherwise a selected + // chord note that isn't the pitch-frontmost one would be visible but not draggable. Selected + // bars occlude everything below them, so account for all of them up front... qreal minTopSoFarPx = std::numeric_limits::max(); + for (int idx : candidates) { + const RectData& r = m_rects.at(idx); + if (r.selected) { + minTopSoFarPx = std::min(minTopSoFarPx, r.yTopN * height()); + } + } + + // ...then let each selected bar claim any click within its own full body, ignoring occlusion + // from other selected bars (there's normally at most one per column anyway). + for (int idx : candidates) { + const RectData& r = m_rects.at(idx); + if (!r.selected) { + continue; + } + const qreal topPx = r.yTopN * height(); + const qreal basePx = r.y0N * height(); + if (posPx.y() >= topPx - EDGE_HIT_MARGIN_PX && posPx.y() <= basePx) { + return idx; + } + } + + // candidates preserve the original back-to-front order - scanning in reverse visits the + // frontmost (lowest-pitched) unselected bar first, exactly matching what's actually visible + // once any selected bar's on-top redraw (accounted for above) is factored in. for (auto it = candidates.rbegin(); it != candidates.rend(); ++it) { const RectData& r = m_rects.at(*it); + if (r.selected) { + continue; + } const qreal topPx = r.yTopN * height(); const qreal basePx = r.y0N * height(); const qreal exposedBottomPx = std::min(basePx, minTopSoFarPx); From 4dc153a0274a3b64c1573d40ae32f810ecd0d275 Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 22:36:49 +0200 Subject: [PATCH 23/36] Tint note-offset drag handles by the rectangle's own state color The edge-drag handle nubs were always a fixed gray regardless of whether the pill body was showing default/modified/selected color, making them visually disconnected from the rectangle they belong to. Handles now use a darker shade of the same state color (green/orange/ blue) as the pill body they're attached to. --- .../NotationScene/notationnoteoffsetcontroller.cpp | 4 +++- .../MuseScore/NotationScene/noteoffsetoverlay.cpp | 14 +++++++++++++- .../MuseScore/NotationScene/noteoffsetoverlay.h | 4 ++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 8b71de233e8d2..52a8c43d5a2dc 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -401,7 +401,9 @@ void NotationNoteOffsetController::applyOverlayColors(NoteOffsetOverlay* overlay overlay->setSelectedFillColor(QColor(60, 160, 210, 90)); overlay->setModifiedFillColor(QColor(235, 140, 40, 90)); overlay->setBorderColor(QColor(50, 130, 100, 200)); - overlay->setHandleColor(QColor(70, 70, 70, 230)); + overlay->setHandleColor(QColor(90, 180, 140, 230).darker(160)); + overlay->setSelectedHandleColor(QColor(60, 160, 210, 230).darker(140)); + overlay->setModifiedHandleColor(QColor(235, 140, 40, 230).darker(140)); } void NotationNoteOffsetController::updateSelectionHighlight() diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 032902c2e28d6..d7d792e96b1d6 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -93,6 +93,18 @@ void NoteOffsetOverlay::setHandleColor(const QColor& color) update(); } +void NoteOffsetOverlay::setSelectedHandleColor(const QColor& color) +{ + m_selectedHandleColor = color; + update(); +} + +void NoteOffsetOverlay::setModifiedHandleColor(const QColor& color) +{ + m_modifiedHandleColor = color; + update(); +} + void NoteOffsetOverlay::paint(QPainter* painter) { if (m_rects.isEmpty()) { @@ -118,7 +130,7 @@ void NoteOffsetOverlay::paint(QPainter* painter) painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); painter->setPen(Qt::NoPen); - painter->setBrush(m_handleColor); + painter->setBrush(rect.selected ? m_selectedHandleColor : (rect.userModified ? m_modifiedHandleColor : m_handleColor)); painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index f6fbe7540d673..998259762e35d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -60,6 +60,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem void setModifiedFillColor(const QColor& color); void setBorderColor(const QColor& color); void setHandleColor(const QColor& color); + void setSelectedHandleColor(const QColor& color); + void setModifiedHandleColor(const QColor& color); void paint(QPainter* painter) override; @@ -94,6 +96,8 @@ class NoteOffsetOverlay : public QQuickPaintedItem QColor m_modifiedFillColor; QColor m_borderColor; QColor m_handleColor; + QColor m_selectedHandleColor; + QColor m_modifiedHandleColor; bool m_pressed = false; int m_activeRectIndex = -1; From 5efb9d596e9bdf01579a630cbb6470ff5c017b20 Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 10:35:47 +0200 Subject: [PATCH 24/36] Extend note-offset overlay rectangles across full tie chains Previously a tied-into note (note->tieBack()) got no overlay entry at all, so the rectangle stopped at the first note's own duration and left a visual gap over the rest of the tied chain, even though playback already correctly sounds through the whole chain. NoteEntry now tracks headNote/tailNote/anchorNote instead of a single note. A chain-head's rectangle extends to the tail's own end tick (walked via Note::lastTiedNote(), playback-accurate to match NoteRenderer::renderNormalTie()), with the right/duration handle shown only if the tail resolves within the same System. A new branch handles a tied-continuation note whose *predecessor* lives in a different System: it gets its own continuation fragment starting at that System's own left edge, which is what actually closes the gap for a tie crossing a system or page break - each System still only knows its own coordinate space, so a single rectangle can't literally span two of them, but a fragment per System reads as continuous. Only the chain's first note owns the left/start handle and only its last note owns the right/duration handle; everything in between has neither, matching the fact that dragging duration from the middle of a tie chain (or start from past its own end) has no sensible meaning. Property writes always target the chain's head note regardless of which fragment/handle was actually dragged, since only the head's own offset is ever honored during playback. Also added two one-sided drag clamps, both only active for a real tie (tailNote != headNote): the duration handle can't shrink the total span to end before the tail note's own start, and the start handle can't push the start past the head note's own end. --- .../notationnoteoffsetcontroller.cpp | 244 +++++++++++++----- .../notationnoteoffsetcontroller.h | 16 +- .../NotationScene/noteoffsetoverlay.cpp | 20 +- .../NotationScene/noteoffsetoverlay.h | 6 + 4 files changed, 207 insertions(+), 79 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp index 52a8c43d5a2dc..e4a4129666161 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.cpp @@ -39,6 +39,7 @@ #include "engraving/dom/segment.h" #include "engraving/dom/staff.h" #include "engraving/dom/system.h" +#include "engraving/dom/tie.h" #include "notation/imasternotation.h" #include "notation/inotation.h" @@ -244,6 +245,12 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s return; } + // Computed up front (not just for the final overlay geometry, below) - a tie chain that + // enters this system from a previous one, or continues past it into the next, has nothing of + // its own tick to anchor a rectangle edge on within this system, so that edge is clamped to + // the system's own visual bounds for this staff instead. + const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); + std::vector entries; const track_idx_t strack = staffIdx * VOICES; @@ -258,26 +265,77 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s } const Chord* chord = toChord(item); - // The nominal (zero-offset) right edge is anchored on this chord's own end tick, not - // on "the next ChordRest segment" - that segment is shared across every voice/track at - // this staff, so a shorter simultaneous note in another voice would otherwise cut this - // chord's rectangle down to the shorter note's end tick instead of its own. - const int chordEndTick = chord->tick().ticks() + chord->ticks().ticks(); - const std::optional interpolatedRightX = mu::notation::canvasXFromTick(system, chordEndTick); - const double nominalRightX = interpolatedRightX ? *interpolatedRightX : (seg->canvasX() + seg->width()); - for (Note* note : chord->notes()) { - if (note->tieBack()) { - // Playback (NoteRenderer::shouldRender) skips tied-continuation notes - // entirely in most cases, so their own offset would silently do nothing - - // don't offer a handle that can't actually affect anything. + const Tie* backTie = note->tieBack(); + + if (!backTie) { + // Chain head (or an untied note) - walk forward to where the tie chain + // actually ends (mirroring the tick range NoteRenderer::renderNormalTie() + // already applies to playback), so the rectangle covers the whole chain + // instead of stopping at this note's own duration. + Note* tailNote = note->lastTiedNote(/*ignorePlayback*/ false); + const Chord* tailChord = tailNote->chord(); + const bool tailInSameSystem = tailChord && tailChord->segment()->system() == system; + + NoteEntry entry; + entry.headNote = note; + entry.tailNote = tailNote; + entry.anchorNote = note; + entry.nominalLeftX = note->canvasX(); + entry.hasLeftHandle = true; + entry.hasRightHandle = tailInSameSystem; + + if (tailInSameSystem) { + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + const std::optional rx = mu::notation::canvasXFromTick(system, tailEndTick); + entry.nominalRightX = rx ? *rx : (staffCanvasRect.x() + staffCanvasRect.width()); + } else { + // The chain continues past this system - stop at the system's own right + // edge instead of interpolating a tick that lies entirely outside it. The + // rest of the chain gets its own fragment wherever its later systems are + // processed (see the tieBack() branch below). + entry.nominalRightX = staffCanvasRect.x() + staffCanvasRect.width(); + } + + entries.push_back(entry); + continue; + } + + // A tied-continuation note. Playback (NoteRenderer::shouldRender) skips these + // entirely - only the chain's head note's own offset is ever honored - so it never + // gets an independent handle of its own. It only needs a fragment here if the + // previous note in the chain lives in a *different* system: that's the one case + // the head's own fragment (built above, in the head's own system) can't reach, + // since each system's overlay only has coordinate data for itself. A continuation + // note whose predecessor is in this same system is already fully covered by that + // fragment's extended right edge. + const Note* prevNote = backTie->startNote(); + const Chord* prevChord = prevNote ? prevNote->chord() : nullptr; + if (!prevChord || prevChord->segment()->system() == system) { continue; } + Note* headNote = note->firstTiedNote(/*ignorePlayback*/ false); + Note* tailNote = note->lastTiedNote(/*ignorePlayback*/ false); + const Chord* tailChord = tailNote->chord(); + const bool tailInSameSystem = tailChord && tailChord->segment()->system() == system; + NoteEntry entry; - entry.note = note; - entry.nominalLeftX = note->canvasX(); - entry.nominalRightX = nominalRightX; + entry.headNote = headNote; + entry.tailNote = tailNote; + entry.anchorNote = note; + entry.nominalLeftX = staffCanvasRect.x(); + entry.hasLeftHandle = false; + entry.hasRightHandle = tailInSameSystem; + + if (tailInSameSystem) { + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + const std::optional rx = mu::notation::canvasXFromTick(system, tailEndTick); + entry.nominalRightX = rx ? *rx : (staffCanvasRect.x() + staffCanvasRect.width()); + } else { + entry.nominalRightX = staffCanvasRect.x() + staffCanvasRect.width(); + } + entries.push_back(entry); } } @@ -287,20 +345,21 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s return; } - const double spatium = entries.front().note->spatium(); + const double spatium = entries.front().anchorNote->spatium(); const double topMargin = RECT_TOP_MARGIN_SP * spatium; const double bottomOverlap = RECT_BOTTOM_OVERLAP_SP * spatium; const double rectHeight = topMargin + bottomOverlap; const double vPadding = 0.3 * spatium; - // Anchored on each note's own vertical position, so the rectangle sits right above its - // notehead (and chord notes stack in pitch order without needing an artificial row index) + // Anchored on each fragment's own anchor note's vertical position (the note actually laid + // out in this system), so the rectangle sits right above its notehead (and chord notes stack + // in pitch order without needing an artificial row index) std::vector centerY; centerY.reserve(entries.size()); double minY = 0.0; double maxY = 0.0; for (size_t i = 0; i < entries.size(); ++i) { - const double noteY = entries[i].note->canvasPos().y(); + const double noteY = entries[i].anchorNote->canvasPos().y(); const double y = noteY - topMargin + rectHeight / 2.0; centerY.push_back(y); if (i == 0) { @@ -317,7 +376,6 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s // The overlay's vertical bounds are derived from the actual note positions rather than a // fixed margin around the staff - this way it always contains every rectangle regardless of // how far above/below the staff a note sits (ledger lines, etc.) - const muse::RectF staffCanvasRect = sysStaff->bbox().translated(system->canvasPos()); const muse::RectF overlayCanvasRect(staffCanvasRect.x(), minY, staffCanvasRect.width(), maxY - minY); const std::vector selected = selectedNotes(); @@ -327,31 +385,44 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s for (size_t i = 0; i < entries.size(); ++i) { const NoteEntry& entry = entries[i]; - const Note* note = entry.note; - const Chord* chord = note->chord(); - IF_ASSERT_FAILED(chord) { + const Note* headNote = entry.headNote; + const Note* tailNote = entry.tailNote; + const Chord* headChord = headNote ? headNote->chord() : nullptr; + const Chord* tailChord = tailNote ? tailNote->chord() : nullptr; + IF_ASSERT_FAILED(headChord && tailChord) { continue; } - // Fallback local px-per-tick rate, only used if a note's offset pushes it right at a - // system boundary where segment interpolation has nothing to anchor to. - const int chordTicks = chord->ticks().ticks(); - const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; + const int headStartTick = headChord->tick().ticks(); + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); - const int chordStartTick = chord->tick().ticks(); - const int chordEndTick = chordStartTick + chordTicks; - const double leftPx = entry.nominalLeftX - + pixelDeltaForTickOffset(system, chordStartTick, note->playbackStartOffset(), fallbackPxPerTick); - const double rightPx = entry.nominalRightX - + pixelDeltaForTickOffset(system, chordEndTick, note->playbackDurationOffset(), fallbackPxPerTick); + // Fallback local px-per-tick rate, only used if an offset pushes an edge right at a + // system boundary where segment interpolation has nothing to anchor to. + const int totalTicks = tailEndTick - headStartTick; + const double fallbackPxPerTick = totalTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / totalTicks : 0.0; + + // A fragment without a given handle doesn't own that edge (it belongs to a fragment in a + // different system) - its position stays pinned to the system boundary it was clamped to, + // rather than tracking an offset that isn't actually about this fragment's own edge. + const double leftPx = entry.hasLeftHandle + ? entry.nominalLeftX + + pixelDeltaForTickOffset(system, headStartTick, headNote->playbackStartOffset(), fallbackPxPerTick) + : entry.nominalLeftX; + const double rightPx = entry.hasRightHandle + ? entry.nominalRightX + + pixelDeltaForTickOffset(system, tailEndTick, headNote->playbackDurationOffset(), fallbackPxPerTick) + : entry.nominalRightX; NoteOffsetOverlay::RectData rect; rect.leftN = (leftPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.rightN = (rightPx - overlayCanvasRect.x()) / overlayCanvasRect.width(); rect.centerYN = (centerY[i] - overlayCanvasRect.y()) / overlayCanvasRect.height(); rect.heightYN = rectHeight / overlayCanvasRect.height(); - rect.selected = muse::contains(selected, entry.note); - rect.userModified = note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0; + rect.hasLeftHandle = entry.hasLeftHandle; + rect.hasRightHandle = entry.hasRightHandle; + rect.selected = muse::contains(selected, entry.headNote) || muse::contains(selected, entry.tailNote) + || muse::contains(selected, entry.anchorNote); + rect.userModified = headNote->playbackStartOffset() != 0 || headNote->playbackDurationOffset() != 0; rects.push_back(rect); } @@ -361,7 +432,11 @@ void NotationNoteOffsetController::createOverlayForStaff(const System* system, s const SysStaffKey key { system, staffIdx }; for (int i = 0; i < static_cast(entries.size()); ++i) { - m_noteLocations[entries[i].note] = NoteLocation { key, i }; + const NoteEntry& entry = entries[i]; + m_noteLocations[entry.anchorNote] = NoteLocation { key, i }; + if (entry.hasRightHandle && entry.tailNote != entry.anchorNote) { + m_noteLocations[entry.tailNote] = NoteLocation { key, i }; + } } NoteOffsetOverlay* overlay = nullptr; @@ -424,7 +499,9 @@ void NotationNoteOffsetController::updateSelectionHighlight() // notes - update just those rects in place instead of copying the whole vector out and // back regardless of how many actually changed. for (int i = 0; i < rects.size(); ++i) { - const bool isSelected = muse::contains(selected, data.notes.at(i).note); + const NoteEntry& entry = data.notes.at(i); + const bool isSelected = muse::contains(selected, entry.headNote) || muse::contains(selected, entry.tailNote) + || muse::contains(selected, entry.anchorNote); if (rects.at(i).selected != isSelected) { NoteOffsetOverlay::RectData rect = rects.at(i); rect.selected = isSelected; @@ -484,19 +561,25 @@ void NotationNoteOffsetController::previewNoteRect(const NoteLocation& location, const StaffOverlayData& data = dataIt->second; const NoteEntry& entry = data.notes.at(location.rectIndex); - const Chord* chord = entry.note ? entry.note->chord() : nullptr; - IF_ASSERT_FAILED(chord) { + const Chord* headChord = entry.headNote ? entry.headNote->chord() : nullptr; + const Chord* tailChord = entry.tailNote ? entry.tailNote->chord() : nullptr; + IF_ASSERT_FAILED(headChord && tailChord) { return; } - const int chordTicks = chord->ticks().ticks(); - const double fallbackPxPerTick = chordTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / chordTicks : 0.0; - const int chordStartTick = chord->tick().ticks(); - const int chordEndTick = chordStartTick + chordTicks; - const double leftPx = entry.nominalLeftX - + pixelDeltaForTickOffset(location.key.system, chordStartTick, newStartOffset, fallbackPxPerTick); - const double rightPx = entry.nominalRightX - + pixelDeltaForTickOffset(location.key.system, chordEndTick, newDurationOffset, fallbackPxPerTick); + const int headStartTick = headChord->tick().ticks(); + const int tailEndTick = tailChord->tick().ticks() + tailChord->ticks().ticks(); + const int totalTicks = tailEndTick - headStartTick; + const double fallbackPxPerTick = totalTicks > 0 ? (entry.nominalRightX - entry.nominalLeftX) / totalTicks : 0.0; + + const double leftPx = entry.hasLeftHandle + ? entry.nominalLeftX + + pixelDeltaForTickOffset(location.key.system, headStartTick, newStartOffset, fallbackPxPerTick) + : entry.nominalLeftX; + const double rightPx = entry.hasRightHandle + ? entry.nominalRightX + + pixelDeltaForTickOffset(location.key.system, tailEndTick, newDurationOffset, fallbackPxPerTick) + : entry.nominalRightX; const QVector& rects = data.overlay->rects(); if (location.rectIndex >= rects.size()) { @@ -521,9 +604,11 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec const StaffOverlayData& data = dataIt->second; const NoteEntry& draggedEntry = data.notes.at(rectIndex); - Note* draggedNote = draggedEntry.note; - Chord* draggedChord = draggedNote ? draggedNote->chord() : nullptr; - IF_ASSERT_FAILED(draggedNote && draggedChord) { + Note* headNote = draggedEntry.headNote; + Note* tailNote = draggedEntry.tailNote; + Chord* headChord = headNote ? headNote->chord() : nullptr; + Chord* tailChord = tailNote ? tailNote->chord() : nullptr; + IF_ASSERT_FAILED(headNote && tailNote && headChord && tailChord) { return; } @@ -532,35 +617,49 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec return; } - const int draggedChordStartTick = draggedChord->tick().ticks(); - const int draggedChordEndTick = draggedChordStartTick + draggedChord->ticks().ticks(); + // Only the chain's head note's own offset is ever honored during playback (see the tieBack() + // skip in createOverlayForStaff), so it's always the target here regardless of which + // fragment/handle - possibly on the chain's last note, in a different system - was dragged. + const int headChordStartTick = headChord->tick().ticks(); + const int headChordEndTick = headChordStartTick + headChord->ticks().ticks(); + const int tailChordStartTick = tailChord->tick().ticks(); + const int tailChordEndTick = tailChordStartTick + tailChord->ticks().ticks(); - int newStartOffset = draggedNote->playbackStartOffset(); - int newDurationOffset = draggedNote->playbackDurationOffset(); + int newStartOffset = headNote->playbackStartOffset(); + int newDurationOffset = headNote->playbackDurationOffset(); if (isLeftEdge) { - newStartOffset = std::clamp(*newTick - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); - const int effEnd = draggedChordEndTick + newDurationOffset; - if (effEnd - (draggedChordStartTick + newStartOffset) < MIN_EFFECTIVE_TICKS) { - newStartOffset = std::clamp(effEnd - MIN_EFFECTIVE_TICKS - draggedChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + newStartOffset = std::clamp(*newTick - headChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + // Never let the start creep past the end of the *first* tied note's own span - dragging + // the start into (or past) a later tied note has no sensible meaning either, mirroring + // the floor applied to the duration handle below. For an untied note tailNote == headNote, + // so this reduces to the original same-note bound (can't cross wherever the duration + // handle currently puts the note's own effective end). + const int ceilingTick = (tailNote == headNote) ? (tailChordEndTick + newDurationOffset) : headChordEndTick; + if (ceilingTick - (headChordStartTick + newStartOffset) < MIN_EFFECTIVE_TICKS) { + newStartOffset = std::clamp(ceilingTick - MIN_EFFECTIVE_TICKS - headChordStartTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); } } else { - newDurationOffset = std::clamp(*newTick - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); - const int effStart = draggedChordStartTick + newStartOffset; - if ((draggedChordEndTick + newDurationOffset) - effStart < MIN_EFFECTIVE_TICKS) { - newDurationOffset = std::clamp(effStart + MIN_EFFECTIVE_TICKS - draggedChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + newDurationOffset = std::clamp(*newTick - tailChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); + // Never let the total duration shrink to end before the *last* tied note's own start - + // dragging into the middle of the tie chain has no sensible meaning (there's no tick at + // which "the note" could be said to end while a tied continuation is still sounding). + // For an untied note tailNote == headNote, so this reduces to the original same-note bound. + const int floorTick = (tailNote == headNote) ? (headChordStartTick + newStartOffset) : tailChordStartTick; + if ((tailChordEndTick + newDurationOffset) - floorTick < MIN_EFFECTIVE_TICKS) { + newDurationOffset = std::clamp(floorTick + MIN_EFFECTIVE_TICKS - tailChordEndTick, -MAX_OFFSET_TICKS, MAX_OFFSET_TICKS); } } // If the dragged note is part of a multi-note selection, apply the same tick delta to every // other selected note's corresponding offset, each clamped independently. - const int delta = isLeftEdge ? (newStartOffset - draggedNote->playbackStartOffset()) - : (newDurationOffset - draggedNote->playbackDurationOffset()); + const int delta = isLeftEdge ? (newStartOffset - headNote->playbackStartOffset()) + : (newDurationOffset - headNote->playbackDurationOffset()); - std::vector affectedNotes { draggedNote }; + std::vector affectedNotes { headNote }; if (delta != 0 || !completed) { const std::vector selected = selectedNotes(); - if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + if (selected.size() > 1 && muse::contains(selected, headNote)) { affectedNotes = selected; } } @@ -574,7 +673,7 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec changes.reserve(affectedNotes.size()); for (Note* note : affectedNotes) { - if (note == draggedNote) { + if (note == headNote) { changes.push_back({ note, newStartOffset, newDurationOffset }); continue; } @@ -611,8 +710,17 @@ void NotationNoteOffsetController::onEdgeDragged(const SysStaffKey& key, int rec if (!completed) { // Live drag preview - update every affected overlay's displayed rect without touching - // the score, anchored on the same nominal note positions used when overlays were built + // the score, anchored on the same nominal note positions used when overlays were built. + // The actually-dragged fragment is addressed directly by its own (key, rectIndex) rather + // than via m_noteLocations, since that map resolves headNote back to *its own* fragment - + // which, when dragging the duration handle on a different system's tail fragment, is not + // the same fragment the mouse is over. + const NoteLocation draggedLocation { key, rectIndex }; for (const PendingChange& change : changes) { + if (change.note == headNote) { + previewNoteRect(draggedLocation, change.startOffset, change.durationOffset); + continue; + } const auto locIt = m_noteLocations.find(change.note); if (locIt != m_noteLocations.end()) { previewNoteRect(locIt->second, change.startOffset, change.durationOffset); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h index b62ab090eb5b9..189170bbcd959 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnoteoffsetcontroller.h @@ -74,12 +74,22 @@ class NotationNoteOffsetController : public muse::Contextable, public muse::asyn } }; - // Nominal (zero-offset) canvas X positions, taken directly from the note's own layout - - // anchors the rectangle exactly on the notehead rather than relying on tick interpolation. + // One rectangle fragment, possibly covering only part of a tie chain (a chain that crosses a + // System boundary is drawn as one fragment per System it touches). headNote is always the + // chain's first note - the only one whose playbackStartOffset/playbackDurationOffset are ever + // honored during playback, so it's the sole target for property writes regardless of which + // fragment/handle was actually dragged. tailNote is the chain's last note, used as the tick + // reference for the duration handle. anchorNote is whichever note is physically laid out in + // this fragment's own System (equal to headNote unless this fragment is a continuation + // picked up from a previous System) - used for vertical positioning and note-selection lookup. struct NoteEntry { - mu::engraving::Note* note = nullptr; + mu::engraving::Note* headNote = nullptr; + mu::engraving::Note* tailNote = nullptr; + mu::engraving::Note* anchorNote = nullptr; double nominalLeftX = 0.0; double nominalRightX = 0.0; + bool hasLeftHandle = true; + bool hasRightHandle = true; }; // Where a given note's rectangle lives, so a drag on a multi-note selection can update/commit diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index d7d792e96b1d6..46f31c9a88487 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -131,10 +131,14 @@ void NoteOffsetOverlay::paint(QPainter* painter) painter->setPen(Qt::NoPen); painter->setBrush(rect.selected ? m_selectedHandleColor : (rect.userModified ? m_modifiedHandleColor : m_handleColor)); - painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), - EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); - painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), - EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + if (rect.hasLeftHandle) { + painter->drawRoundedRect(QRectF(leftPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + } + if (rect.hasRightHandle) { + painter->drawRoundedRect(QRectF(rightPx - EDGE_HANDLE_WIDTH_PX / 2.0, bodyRect.top(), EDGE_HANDLE_WIDTH_PX, bodyRect.height()), + EDGE_HANDLE_WIDTH_PX / 2.0, EDGE_HANDLE_WIDTH_PX / 2.0); + } } } @@ -151,16 +155,16 @@ NoteOffsetOverlay::HitResult NoteOffsetOverlay::hitTestPx(const QPointF& posPx) const qreal leftPx = rect.leftN * width(); const qreal rightPx = rect.rightN * width(); - const qreal distToLeft = std::abs(posPx.x() - leftPx); - const qreal distToRight = std::abs(posPx.x() - rightPx); + const bool hitLeft = rect.hasLeftHandle && std::abs(posPx.x() - leftPx) <= EDGE_HANDLE_HIT_MARGIN_PX; + const bool hitRight = rect.hasRightHandle && std::abs(posPx.x() - rightPx) <= EDGE_HANDLE_HIT_MARGIN_PX; - if (distToLeft > EDGE_HANDLE_HIT_MARGIN_PX && distToRight > EDGE_HANDLE_HIT_MARGIN_PX) { + if (!hitLeft && !hitRight) { continue; } HitResult hit; hit.rectIndex = i; - hit.isLeftEdge = distToLeft <= distToRight; + hit.isLeftEdge = hitLeft && (!hitRight || std::abs(posPx.x() - leftPx) <= std::abs(posPx.x() - rightPx)); return hit; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h index 998259762e35d..6b5bd8c4bee5d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.h @@ -43,6 +43,12 @@ class NoteOffsetOverlay : public QQuickPaintedItem qreal heightYN = 1.0; bool selected = false; bool userModified = false; // either playback offset is non-zero + + // A tie-chain fragment only offers the handle for the edge it actually owns: the start + // handle on the chain's first note, the duration handle on its last - an intermediate + // fragment (or one whose own chain-end lives in a different system) has neither. + bool hasLeftHandle = true; + bool hasRightHandle = true; }; explicit NoteOffsetOverlay(QQuickItem* parent); From 71efc9952818fed332146e336f16f4b83c4916aa Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 10:35:47 +0200 Subject: [PATCH 25/36] Make note-offset Properties panel spinboxes tie-aware The "Start offset"/"Duration offset" spinboxes read and wrote Pid::PLAYBACK_START_OFFSET/DURATION_OFFSET on the exact selected note with no tie-awareness at all: selecting a tied-continuation note let you type a value that was silently ignored during playback (only the chain's head note's own offset is ever honored) and, now that the on-canvas overlay is tie-aware too, disagreed with what it shows for that same chain. NotePlaybackModel::headNoteElements() now remaps each selected note to its firstTiedNote(false) for both directions, via a custom onPropertyChangedCallBack passed to buildPropertyItem - ties into an extension point PropertiesPanelAbstractModel already supported, no base-class changes needed. A note that is neither its chain's head nor its tail (a middle link in a 3+-note chain) owns no handle at all in the overlay, so it's dropped from the list entirely rather than redirected, leaving both spinboxes disabled for it instead of quietly editing a value it has no visual handle for. Also fixed a separate, pre-existing refresh bug found while testing this: committing a property change (e.g. releasing a drag) never live-updated these spinboxes without leaving and re-entering the Properties tab. GeneralSettingsModel::onNotationChanged() only ever reloaded its own four Pids and never forwarded the notification down into m_playbackProxyModel (unlike onCurrentNotationChanged() right below it, which does forward to both nested models), and NotePlaybackModel never overrode onNotationChanged() to react even if it had been forwarded. Both gaps are now closed for NotePlaybackModel; PlaybackProxyModel's other sibling models (arpeggio/fermata/breath/ glissando/gradual tempo change) have the same gap and are left as a known, separate follow-up. --- .../general/generalsettingsmodel.cpp | 9 +++- .../playback/internal/noteplaybackmodel.cpp | 51 +++++++++++++++++-- .../playback/internal/noteplaybackmodel.h | 15 ++++++ 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp index 2c8c5a231e9c4..bcee4014a7af1 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/generalsettingsmodel.cpp @@ -118,9 +118,16 @@ void GeneralSettingsModel::loadProperties() updateAreGeneralPropertiesAvailable(); } -void GeneralSettingsModel::onNotationChanged(const PropertyIdSet& changedPropertyIdSet, const StyleIdSet&) +void GeneralSettingsModel::onNotationChanged(const PropertyIdSet& changedPropertyIdSet, const StyleIdSet& changedStyleIdSet) { loadProperties(changedPropertyIdSet); + + // Forwarded here rather than relying on PropertiesPanelListModel to reach these nested models + // directly - only top-level section models are in its own list (see onCurrentNotationChanged() + // just below, which forwards for the same reason). Without this, an external score change (e.g. + // committing a note-offset drag, or an undo/redo) never reaches m_playbackProxyModel's nested + // models, which then only ever refresh via the unrelated elementsUpdated()/reselection path. + m_playbackProxyModel->onNotationChanged(changedPropertyIdSet, changedStyleIdSet); } void GeneralSettingsModel::loadProperties(const mu::engraving::PropertyIdSet& propertyIdSet) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp index 025447c322841..c4a486a4232b6 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -24,6 +24,8 @@ #include "translation.h" #include "dataformatter.h" +#include "engraving/dom/note.h" + using namespace mu::propertiespanel; NotePlaybackModel::NotePlaybackModel(QObject* parent, const muse::modularity::ContextPtr& iocCtx, IElementRepositoryService* repository) @@ -39,8 +41,15 @@ void NotePlaybackModel::createProperties() { m_tuning = buildPropertyItem(mu::engraving::Pid::TUNING); m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY); - m_playbackStartOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_START_OFFSET); - m_playbackDurationOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET); + + // Redirected to each note's own chain head (see headNoteElements()) instead of the default + // callback, which would write to the exact selected note. + auto onOffsetChanged = [this](const mu::engraving::Pid pid, const QVariant& newValue) { + setPropertyValue(headNoteElements(), pid, newValue); + loadProperties(); + }; + m_playbackStartOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_START_OFFSET, onOffsetChanged); + m_playbackDurationOffset = buildPropertyItem(mu::engraving::Pid::PLAYBACK_DURATION_OFFSET, onOffsetChanged); } void NotePlaybackModel::requestElements() @@ -55,8 +64,42 @@ void NotePlaybackModel::loadProperties() //! NOTE: display 64 instead of 0 in the Velocity field to avoid confusing the user return value.toInt() == 0 ? 64 : value; }); - loadPropertyItem(m_playbackStartOffset); - loadPropertyItem(m_playbackDurationOffset); + loadPropertyItem(m_playbackStartOffset, headNoteElements()); + loadPropertyItem(m_playbackDurationOffset, headNoteElements()); +} + +void NotePlaybackModel::onNotationChanged(const mu::engraving::PropertyIdSet&, const mu::engraving::StyleIdSet&) +{ + loadProperties(); +} + +QList NotePlaybackModel::headNoteElements() const +{ + QList result; + result.reserve(m_elementList.size()); + + for (mu::engraving::EngravingItem* item : m_elementList) { + mu::engraving::Note* note = item && item->isNote() ? mu::engraving::toNote(item) : nullptr; + if (!note) { + result.push_back(item); + continue; + } + + mu::engraving::Note* head = note->firstTiedNote(/*ignorePlayback*/ false); + mu::engraving::Note* tail = note->lastTiedNote(/*ignorePlayback*/ false); + + // A note buried in the middle of a longer tie chain (neither the chain's head nor its + // tail) owns neither edge of the overlay's rectangle for that chain - it's excluded here + // entirely, rather than merely redirected, so both spinboxes read as disabled instead of + // silently editing a value this note has no visual handle for. + if (note != head && note != tail) { + continue; + } + + result.push_back(head); + } + + return result; } PropertyItem* NotePlaybackModel::tuning() const diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h index aaf6c6b02dc9a..12ceb1e340d70 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h @@ -49,8 +49,23 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel void createProperties() override; void requestElements() override; void loadProperties() override; + void onNotationChanged(const mu::engraving::PropertyIdSet& changedPropertyIdSet, + const mu::engraving::StyleIdSet& changedStyleIdSet) override; private: + // Playback start/duration offset are only ever honored on a tie chain's first note - a + // tied-continuation note is skipped entirely during rendering (see NoteRenderer::shouldRender() + // and the matching tieBack() skip in NotationNoteOffsetController::createOverlayForStaff()). + // Reading/writing these two properties on the exact selected note would silently affect + // nothing whenever that note is a tied continuation, and would disagree with what the + // on-canvas drag-handle overlay shows for the same chain - so both directions are redirected + // to each note's own chain head, regardless of which note in the chain is selected. A note + // that is neither its chain's head nor its tail (a middle link in a 3+-note chain) owns no + // handle at all in that overlay, so it's dropped from the returned list entirely rather than + // redirected - loadPropertyItem()/setPropertyValue() then treat it as no selection at all, + // leaving both spinboxes disabled instead of silently editing a value it has no handle for. + QList headNoteElements() const; + PropertyItem* m_tuning = nullptr; PropertyItem* m_velocity = nullptr; PropertyItem* m_playbackStartOffset = nullptr; From 0cda5e4c81479654028fa4f0e1cf88b427326d17 Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 11:25:09 +0200 Subject: [PATCH 26/36] Show the actual dynamics-derived velocity in the Properties panel The "Velocity" spinbox hardcoded a flat 64 whenever a note had no explicit userVelocity() (0), completely ignoring any dynamic (piano, forte...) actually in effect at that note - unlike the on-canvas velocity-bar overlay, which already falls back to the real dynamics-derived value (NotationNoteVelocityController:: displayedVelocity()/contextVelocity(), via INotationPlayback::appliableDynamicLevel() + muse::mpe::dynamicLevelToVelocityRatio()). A forte note that had never been dragged showed a bar at ~96 but a spinbox stuck at 64. NotePlaybackModel::effectiveVelocity() mirrors that same fallback so both surfaces agree. loadPropertyItem()'s convertElementPropertyValueFunc only ever sees the already-read value, not the element it came from - not enough to compute a per-note contextual fallback - so the velocity spinbox is now loaded through a dedicated loadVelocityProperty() instead of the generic path. Also fixes a related, more subtle bug found while testing this: since an unset note's displayed value is now a computed fallback rather than a fixed constant, dragging such a note to a value that happens to match its own displayed fallback (e.g. dragging a forte note to exactly 96) left the spinbox showing the same number both before and after, even though the note genuinely went from "following the dynamic" to "explicit user velocity" underneath - PropertyItem:: updateCurrentValue() only notifies when the displayed value itself changes, which can't tell those two states apart when they coincide numerically. Gave updateCurrentValue() an optional forceNotify parameter (defaults to false, so every other call site is unaffected) and pass it whenever isModified is about to flip, so the spinbox never silently disagrees with the (always-correct) isModified-driven color in that situation. --- .../playback/internal/noteplaybackmodel.cpp | 88 ++++++++++++++++++- .../playback/internal/noteplaybackmodel.h | 12 +++ .../PropertiesPanel/propertyitem.cpp | 4 +- .../MuseScore/PropertiesPanel/propertyitem.h | 8 +- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp index c4a486a4232b6..5102f9fab890a 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -21,11 +21,19 @@ */ #include "noteplaybackmodel.h" +#include +#include + #include "translation.h" #include "dataformatter.h" #include "engraving/dom/note.h" +#include "mpe/mpetypes.h" + +#include "notation/imasternotation.h" +#include "notation/inotationplayback.h" + using namespace mu::propertiespanel; NotePlaybackModel::NotePlaybackModel(QObject* parent, const muse::modularity::ContextPtr& iocCtx, IElementRepositoryService* repository) @@ -60,14 +68,86 @@ void NotePlaybackModel::requestElements() void NotePlaybackModel::loadProperties() { loadPropertyItem(m_tuning, formatDoubleFunc); - loadPropertyItem(m_velocity, [](const QVariant& value) { - //! NOTE: display 64 instead of 0 in the Velocity field to avoid confusing the user - return value.toInt() == 0 ? 64 : value; - }); + loadVelocityProperty(); loadPropertyItem(m_playbackStartOffset, headNoteElements()); loadPropertyItem(m_playbackDurationOffset, headNoteElements()); } +void NotePlaybackModel::loadVelocityProperty() +{ + // loadPropertyItem()'s convertElementPropertyValueFunc only ever receives the already-read + // property value, with no way back to which element it came from - not enough to compute a + // per-note contextual fallback, so this walks m_elementList directly instead. + if (m_elementList.isEmpty()) { + m_velocity->setIsEnabled(false); + return; + } + + QVariant value; + bool isUndefined = false; + bool isModified = false; + + for (mu::engraving::EngravingItem* item : m_elementList) { + IF_ASSERT_FAILED(item) { + continue; + } + + mu::engraving::Note* note = item->isNote() ? mu::engraving::toNote(item) : nullptr; + if (!note) { + continue; + } + + const int elementValue = effectiveVelocity(note); + + if (!value.isValid()) { + value = elementValue; + } else if (!isUndefined && value.toInt() != elementValue) { + isUndefined = true; + } + + if (!isModified && note->userVelocity() != 0) { + isModified = true; + } + } + + // The displayed number alone can't distinguish "still following the dynamic context" from + // "just pinned explicitly to the same number that context happened to produce" - e.g. dragging + // a forte note's velocity bar to exactly 96 doesn't change what's displayed (96 both before and + // after), so the plain value-equality check in updateCurrentValue() would otherwise skip + // notifying entirely. Force the notification through whenever isModified is about to flip, so + // the spinbox never silently disagrees with the (always-correct) isModified-driven color. + const bool forceNotify = m_velocity->isModified() != isModified; + + m_velocity->setIsEnabled(value.isValid()); + m_velocity->updateCurrentValue(isUndefined ? QVariant() : value, forceNotify); + m_velocity->setIsModified(isModified); +} + +int NotePlaybackModel::effectiveVelocity(const mu::engraving::Note* note) const +{ + if (!note) { + return 64; + } + + const int userVelocity = note->userVelocity(); + if (userVelocity != 0) { + return userVelocity; + } + + // No explicit velocity set on this note - fall back to the same dynamics-derived value the + // on-canvas velocity-bar overlay already shows (NotationNoteVelocityController::contextVelocity()) + // instead of a flat constant that ignores whatever dynamic (piano, forte...) actually applies. + const notation::IMasterNotationPtr masterNotation = context()->currentMasterNotation(); + const notation::INotationPlaybackPtr playback = masterNotation ? masterNotation->playback() : nullptr; + if (!playback) { + return 64; + } + + const muse::mpe::dynamic_level_t level = playback->appliableDynamicLevel(note->track(), note->tick().ticks()); + const double ratio = muse::mpe::dynamicLevelToVelocityRatio(level); + return std::clamp(static_cast(std::lround(ratio * 127.0)), 0, 127); +} + void NotePlaybackModel::onNotationChanged(const mu::engraving::PropertyIdSet&, const mu::engraving::StyleIdSet&) { loadProperties(); diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h index 12ceb1e340d70..8004e865de862 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h @@ -66,6 +66,18 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel // leaving both spinboxes disabled instead of silently editing a value it has no handle for. QList headNoteElements() const; + // loadPropertyItem()'s convertElementPropertyValueFunc only ever sees the already-read property + // value, not the element it came from - not enough to compute a per-note contextual fallback, so + // the velocity spinbox is loaded through this dedicated method instead of the generic one. + void loadVelocityProperty(); + + // The velocity spinbox used to hardcode a flat 64 whenever a note had no explicit userVelocity() + // (0), completely ignoring any dynamic (piano, forte...) actually in effect at that note - unlike + // the on-canvas velocity-bar overlay, which already falls back to the real dynamics-derived value + // (NotationNoteVelocityController::displayedVelocity()/contextVelocity()). Mirrors that same + // fallback here so both surfaces agree. + int effectiveVelocity(const mu::engraving::Note* note) const; + PropertyItem* m_tuning = nullptr; PropertyItem* m_velocity = nullptr; PropertyItem* m_playbackStartOffset = nullptr; diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp index 8ea43a5528df5..89e230a209289 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.cpp @@ -32,9 +32,9 @@ PropertyItem::PropertyItem(const mu::engraving::Pid propertyId, QObject* parent) m_propertyId = propertyId; } -void PropertyItem::updateCurrentValue(const QVariant& currentValue) +void PropertyItem::updateCurrentValue(const QVariant& currentValue, bool forceNotify) { - if (m_currentValue == currentValue) { + if (!forceNotify && m_currentValue == currentValue) { return; } diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h index 0f6f2f0f78695..73b1edac335b8 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/propertyitem.h @@ -46,7 +46,13 @@ class PropertyItem : public QObject public: explicit PropertyItem(const mu::engraving::Pid propertyId, QObject* parent = nullptr); - void updateCurrentValue(const QVariant& currentValue); + // forceNotify: emit valueChanged() even if currentValue equals the cached value. Needed by a + // property whose displayed number is a fallback computed from something other than the raw + // stored property (e.g. a note's contextual/dynamics-derived velocity when no explicit value + // is set) - the underlying state can genuinely change (unset -> explicit) while numerically + // landing on the same displayed number, which the plain equality check can't tell apart from + // "nothing changed". + void updateCurrentValue(const QVariant& currentValue, bool forceNotify = false); Q_INVOKABLE void resetToDefault(); Q_INVOKABLE void applyToStyle(); From 70acf5b1f14f929ab12bf197ac4d05735d6377d9 Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 22:36:30 +0200 Subject: [PATCH 27/36] Give velocity bars cursor priority over note-offset handles they cover Qt Quick's per-item cursor arbitration follows whichever topmost item has ever called setCursor(), independent of hover event accept/ignore. NoteOffsetOverlay unconditionally declares a cursor on every hover move, so its east-west edge cursor was winning even where a velocity bar - painted on top, and already capturing mouse presses there - visually covered one of its drag handles. NoteVelocityOverlay now claims/releases its own cursor declaratively so hover matches what a click there actually does. --- .../NotationScene/notevelocityoverlay.cpp | 23 +++++++++++++++++++ .../NotationScene/notevelocityoverlay.h | 4 ++++ 2 files changed, 27 insertions(+) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index e127ec3281b10..db9c1d947e953 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -25,6 +25,7 @@ #include #include +#include #include #include @@ -43,6 +44,7 @@ NoteVelocityOverlay::NoteVelocityOverlay(QQuickItem* parent) : QQuickPaintedItem(parent) { setAcceptedMouseButtons(Qt::LeftButton); + setAcceptHoverEvents(true); } void NoteVelocityOverlay::setRects(const QVector& rects) @@ -248,6 +250,27 @@ int NoteVelocityOverlay::hitTestPx(const QPointF& posPx) const return -1; } +void NoteVelocityOverlay::hoverMoveEvent(QHoverEvent* e) +{ + // Which item's cursor actually gets displayed over an overlap is decided by QQuickWindow from + // each item's *declared* cursor (whichever topmost item has ever called setCursor()) - it has + // nothing to do with which item's hoverMoveEvent ignore()s the event. NoteOffsetOverlay + // unconditionally declares a cursor on every hover move, so unless this item declares (and + // un-declares) its own right here, Qt falls through to the offset overlay's stale declaration + // underneath even where a bar - painted on top, and already winning mouse presses via the same + // hit test - visually covers it. + if (hitTestPx(e->position()) >= 0) { + setCursor(Qt::ArrowCursor); + } else { + unsetCursor(); + } +} + +void NoteVelocityOverlay::hoverLeaveEvent(QHoverEvent*) +{ + unsetCursor(); +} + void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) { const int hit = hitTestPx(e->position()); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index 30b75ed87a4ba..d7e548a7b9432 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -26,6 +26,8 @@ #include #include +class QHoverEvent; + // NOTE: all rectangle coordinates are normalized [0, 1], relative to this item's own width/height, // mirroring NoteOffsetOverlay's convention. // @@ -77,6 +79,8 @@ class NoteVelocityOverlay : public QQuickPaintedItem void barDragged(int rectIndex, qreal newYN, bool completed); protected: + void hoverMoveEvent(QHoverEvent* e) override; + void hoverLeaveEvent(QHoverEvent* e) override; void mousePressEvent(QMouseEvent* e) override; void mouseMoveEvent(QMouseEvent* e) override; void mouseReleaseEvent(QMouseEvent* e) override; From a1473851300279c3b1aa596d9227a3c01b7bc1c5 Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 22:51:57 +0200 Subject: [PATCH 28/36] Make velocity bar dragging relative instead of jump-to-click Clicking anywhere on a velocity bar and dragging used to snap the velocity to whatever absolute value the click's Y position corresponded to, which felt wrong for a click that landed mid-bar rather than exactly on its top edge. The whole bar now acts as a drag handle: barDragged reports the mouse's own displacement since the press instead of an absolute position, and the controller nudges the note's pre-drag velocity by that amount rather than computing an absolute target. --- .../notationnotevelocitycontroller.cpp | 23 +++++++++++++------ .../notationnotevelocitycontroller.h | 2 +- .../NotationScene/notevelocityoverlay.cpp | 13 +++++++---- .../NotationScene/notevelocityoverlay.h | 7 +++++- 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index de2a647102375..eaed69e001adb 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -315,8 +315,8 @@ void NotationNoteVelocityController::createOverlayForStaff(const System* system, applyOverlayColors(overlay); overlay->setVisible(false); - QObject::connect(overlay, &NoteVelocityOverlay::barDragged, [this, key](int rectIndex, qreal newYN, bool completed) { - onBarDragged(key, rectIndex, newYN, completed); + QObject::connect(overlay, &NoteVelocityOverlay::barDragged, [this, key](int rectIndex, qreal deltaYN, bool completed) { + onBarDragged(key, rectIndex, deltaYN, completed); }); } @@ -444,7 +444,7 @@ void NotationNoteVelocityController::previewBarHeight(const NoteLocation& locati data.overlay->updateRect(location.rectIndex, rect); } -void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal newYN, bool completed) +void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed) { const auto dataIt = m_overlaysByStaff.find(key); IF_ASSERT_FAILED(key.isValid() && dataIt != m_overlaysByStaff.end() @@ -459,14 +459,23 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re return; } - const double canvasY = data.bandRect.y() + newYN * data.bandRect.height(); - const int newVelocity = std::clamp(velocityFromCanvasY(draggedEntry.yRange, canvasY), - MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + // The whole bar is a drag handle, wherever it was clicked - deltaYN is the mouse's own + // displacement since the press, never an absolute position, so this nudges the note's velocity + // by however far the mouse has moved rather than snapping it to whatever value the click + // position happens to correspond to. Computed directly from the y0-y127 span rather than via + // velocityFromCanvasY(), which clamps its result to [0, 127] - fine for an absolute position, + // but that clamp would floor every downward (negative) delta to 0 and make the bar impossible + // to drag back down. + const double deltaCanvasY = deltaYN * data.bandRect.height(); + const double span = draggedEntry.yRange.y127 - draggedEntry.yRange.y0; + const int deltaVelocity = std::abs(span) < 1e-9 ? 0 : static_cast(std::lround(deltaCanvasY / span * 127.0)); + const int startVelocity = displayedVelocity(draggedNote); + const int newVelocity = std::clamp(startVelocity + deltaVelocity, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); // If the dragged note is part of a multi-note selection, apply the same velocity delta to // every other selected note - including notes hidden behind others in the same chord's // stack - each clamped independently. Only what's selected moves. - const int delta = newVelocity - displayedVelocity(draggedNote); + const int delta = newVelocity - startVelocity; std::vector affectedNotes { draggedNote }; if (delta != 0 || !completed) { diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h index 249a9f04dcc59..f41984916a181 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -111,7 +111,7 @@ class NotationNoteVelocityController : public muse::Contextable, public muse::as void onCurrentNotationChanged(); void scheduleRebuild(); - void onBarDragged(const SysStaffKey& key, int rectIndex, qreal newYN, bool completed); + void onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed); void previewBarHeight(const NoteLocation& location, int newVelocity); std::vector selectedNotes() const; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index db9c1d947e953..5ce30867a0a1f 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -281,6 +281,7 @@ void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) m_pressed = true; m_activeRectIndex = hit; + m_dragStartYN = e->position().y() / std::max(1.0, height()); e->accept(); } @@ -290,8 +291,12 @@ void NoteVelocityOverlay::mouseMoveEvent(QMouseEvent* e) return; } - const qreal yN = std::clamp(e->position().y() / std::max(1.0, height()), 0.0, 1.0); - emit barDragged(m_activeRectIndex, yN, false); + // Not clamped to [0, 1] - unlike the drag-start position, which is always a valid in-bounds + // click on a bar, the mouse can (and, mid-drag, routinely does) move outside this item's own + // bounds while still grabbed; clamping here would flatten the delta near the edges instead of + // tracking the mouse's actual displacement all the way through. + const qreal yN = e->position().y() / std::max(1.0, height()); + emit barDragged(m_activeRectIndex, yN - m_dragStartYN, false); } void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) @@ -300,8 +305,8 @@ void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) return; } - const qreal yN = std::clamp(e->position().y() / std::max(1.0, height()), 0.0, 1.0); - emit barDragged(m_activeRectIndex, yN, true); + const qreal yN = e->position().y() / std::max(1.0, height()); + emit barDragged(m_activeRectIndex, yN - m_dragStartYN, true); m_pressed = false; m_activeRectIndex = -1; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index d7e548a7b9432..c210129acb974 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -76,7 +76,11 @@ class NoteVelocityOverlay : public QQuickPaintedItem bool isDragging() const { return m_pressed; } signals: - void barDragged(int rectIndex, qreal newYN, bool completed); + // deltaYN is the mouse's own vertical displacement (normalized to this item's height) since + // the press that started this drag, not an absolute position - clicking anywhere on a bar acts + // as a drag handle for it, nudging its velocity relative to wherever it already was, rather + // than jumping the value to whatever the click position happens to correspond to. + void barDragged(int rectIndex, qreal deltaYN, bool completed); protected: void hoverMoveEvent(QHoverEvent* e) override; @@ -101,5 +105,6 @@ class NoteVelocityOverlay : public QQuickPaintedItem bool m_pressed = false; int m_activeRectIndex = -1; + qreal m_dragStartYN = 0.0; }; } From a07a3cc683eaba77c59158dfeeefd739678c111f Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 09:57:06 +0200 Subject: [PATCH 29/36] Fix velocity-drag delta scale mismatch and redundant cursor updates Code review of the two preceding commits found: the drag delta compared a press-time position already normalized by height() against a move/release-time position normalized by a height() read later - if the overlay's height changes mid-drag (window resize, view zoom/pan), the two ends of the subtraction used different scales. Now stores the raw pixel press position and divides once by the current height(). Also made hoverMoveEvent skip redundant setCursor()/unsetCursor() calls when the hovered/not-hovered state hasn't changed, matching the cached-state pattern NoteOffsetOverlay::updateCursor() already uses. --- .../NotationScene/notevelocityoverlay.cpp | 25 ++++++++++++++----- .../NotationScene/notevelocityoverlay.h | 3 ++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index 5ce30867a0a1f..38c4daaa4f7fb 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -259,7 +259,13 @@ void NoteVelocityOverlay::hoverMoveEvent(QHoverEvent* e) // un-declares) its own right here, Qt falls through to the offset overlay's stale declaration // underneath even where a bar - painted on top, and already winning mouse presses via the same // hit test - visually covers it. - if (hitTestPx(e->position()) >= 0) { + const bool hoveringBar = hitTestPx(e->position()) >= 0; + if (hoveringBar == m_hoveringBar) { + return; + } + m_hoveringBar = hoveringBar; + + if (hoveringBar) { setCursor(Qt::ArrowCursor); } else { unsetCursor(); @@ -268,6 +274,7 @@ void NoteVelocityOverlay::hoverMoveEvent(QHoverEvent* e) void NoteVelocityOverlay::hoverLeaveEvent(QHoverEvent*) { + m_hoveringBar = false; unsetCursor(); } @@ -281,7 +288,13 @@ void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) m_pressed = true; m_activeRectIndex = hit; - m_dragStartYN = e->position().y() / std::max(1.0, height()); + // Stored as a raw pixel position, not pre-divided by height() - the height a drag started at + // and the height read on a later move/release event aren't guaranteed to be the same value (a + // window resize or a view zoom/pan can call setHeight() on this item while the mouse is still + // held down), so normalizing each endpoint separately before subtracting could mix two + // different scales into one delta. Dividing the raw pixel delta by a single, current height() + // below keeps both ends of the subtraction on the same scale. + m_dragStartYPx = e->position().y(); e->accept(); } @@ -295,8 +308,8 @@ void NoteVelocityOverlay::mouseMoveEvent(QMouseEvent* e) // click on a bar, the mouse can (and, mid-drag, routinely does) move outside this item's own // bounds while still grabbed; clamping here would flatten the delta near the edges instead of // tracking the mouse's actual displacement all the way through. - const qreal yN = e->position().y() / std::max(1.0, height()); - emit barDragged(m_activeRectIndex, yN - m_dragStartYN, false); + const qreal deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + emit barDragged(m_activeRectIndex, deltaYN, false); } void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) @@ -305,8 +318,8 @@ void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) return; } - const qreal yN = e->position().y() / std::max(1.0, height()); - emit barDragged(m_activeRectIndex, yN - m_dragStartYN, true); + const qreal deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + emit barDragged(m_activeRectIndex, deltaYN, true); m_pressed = false; m_activeRectIndex = -1; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index c210129acb974..90fbbd2f52a7a 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -105,6 +105,7 @@ class NoteVelocityOverlay : public QQuickPaintedItem bool m_pressed = false; int m_activeRectIndex = -1; - qreal m_dragStartYN = 0.0; + qreal m_dragStartYPx = 0.0; + bool m_hoveringBar = false; }; } From 60d054f5fe12e0afe82a02cb84707925af7d7592 Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 10:44:56 +0200 Subject: [PATCH 30/36] Square off note-offset rectangle corners Draw the offset rectangle body with plain square corners instead of a fully-rounded pill shape, per user preference. --- .../qml/MuseScore/NotationScene/noteoffsetoverlay.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp index 46f31c9a88487..d7369e0153f52 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/noteoffsetoverlay.cpp @@ -121,13 +121,9 @@ void NoteOffsetOverlay::paint(QPainter* painter) const QRectF bodyRect(leftPx, centerYPx - halfHeightPx, rightPx - leftPx, halfHeightPx * 2.0); - // Fully-rounded "pill" ends - radius tied to the rectangle's own height so it stays - // consistent at any zoom level or rectangle size, rather than a fixed pixel amount. - const qreal cornerRadius = std::min(halfHeightPx, bodyRect.width() / 2.0); - painter->setPen(QPen(m_borderColor, 1.0)); painter->setBrush(rect.selected ? m_selectedFillColor : (rect.userModified ? m_modifiedFillColor : m_fillColor)); - painter->drawRoundedRect(bodyRect, cornerRadius, cornerRadius); + painter->drawRect(bodyRect); painter->setPen(Qt::NoPen); painter->setBrush(rect.selected ? m_selectedHandleColor : (rect.userModified ? m_modifiedHandleColor : m_handleColor)); From bec6daaa514bdab1d715c84b62f72c17a423d178 Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 13:06:59 +0200 Subject: [PATCH 31/36] Audition the note at its live velocity while dragging its bar Lets the user hear the effect of a velocity edit before committing it, mirroring the existing pitch-drag audition pattern via IPlaybackController::playNotes() with an ad-hoc velocityOverride on a throwaway NoteVal - the real Note is never touched until the drag completes. Also plays once on a plain click with no movement, since the overlay otherwise swallows the click MuseScore would normally give audible feedback for on note selection. Throttled to at most one retrigger per 200ms during the drag (untriggered mouse-move events fire far more often than that, which sounded like a machine gun without a minimum interval), always auditions the exact value that ends up committed on release regardless of the throttle window, copies headGroup so cross/diamond noteheads audition with their own articulation, skips entirely while real transport playback is running so it doesn't fight the transport for the track, and resets its throttle state if the drag's mouse grab is stolen mid-gesture (e.g. by a popup) rather than only on a normal release. --- .../notationnotevelocitycontroller.cpp | 67 +++++++++++++++++++ .../notationnotevelocitycontroller.h | 13 ++++ .../NotationScene/notevelocityoverlay.cpp | 7 ++ .../NotationScene/notevelocityoverlay.h | 7 ++ 4 files changed, 94 insertions(+) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index eaed69e001adb..b0263ca5ae814 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -62,6 +62,11 @@ using namespace mu::engraving; constexpr static int MIN_DRAGGABLE_VELOCITY = 1; constexpr static int MAX_DRAGGABLE_VELOCITY = 127; +// A mouse-move event fires far more often than the velocity value actually needs to be re-heard - +// without a minimum gap between auditions, a fast drag retriggers the sound almost every pixel of +// movement, which sounds like a machine gun rather than a musical preview. +constexpr static qint64 AUDITION_MIN_INTERVAL_MS = 200; + constexpr static double BAR_HALF_WIDTH_SP = 0.45; constexpr static double BAND_V_PADDING_SP = 0.3; @@ -318,6 +323,9 @@ void NotationNoteVelocityController::createOverlayForStaff(const System* system, QObject::connect(overlay, &NoteVelocityOverlay::barDragged, [this, key](int rectIndex, qreal deltaYN, bool completed) { onBarDragged(key, rectIndex, deltaYN, completed); }); + QObject::connect(overlay, &NoteVelocityOverlay::dragCancelled, [this]() { + resetAuditionThrottle(); + }); } StaffOverlayData data; @@ -444,6 +452,50 @@ void NotationNoteVelocityController::previewBarHeight(const NoteLocation& locati data.overlay->updateRect(location.rectIndex, rect); } +void NotationNoteVelocityController::auditionNote(const Note* note, int velocity) +{ + IF_ASSERT_FAILED(note && note->chord()) { + return; + } + + // playNotes() always flushes the track's sound (all-notes-off, sustain/sostenuto reset) before + // playing - fine for a one-off preview, but retriggering that every ~200ms while real playback + // is running would audibly cut the actual transport playback instead of just previewing a + // value. Skip the audition rather than fight the transport for the track. + if (playbackController()->isPlaying()) { + return; + } + + // A throwaway NoteVal, never written to the real Note - playNotes() builds its own temporary + // Chord/Note from this to play, so the live drag value is heard without touching the score + // (or needing an undo entry) until the drag is actually committed. + NoteVal nval; + nval.pitch = note->pitch(); + nval.tpc1 = note->tpc1(); + nval.tpc2 = note->tpc2(); + nval.headGroup = note->headGroup(); + nval.velocityOverride = velocity; + + playbackController()->playNotes({ nval }, note->staffIdx(), note->chord()->segment()); +} + +bool NotationNoteVelocityController::auditionThrottleElapsed() const +{ + return !m_auditionThrottle.isValid() || m_auditionThrottle.elapsed() >= AUDITION_MIN_INTERVAL_MS; +} + +void NotationNoteVelocityController::markAudition(int velocity) +{ + m_lastAuditionedVelocity = velocity; + m_auditionThrottle.restart(); +} + +void NotationNoteVelocityController::resetAuditionThrottle() +{ + m_lastAuditionedVelocity = -1; + m_auditionThrottle.invalidate(); +} + void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed) { const auto dataIt = m_overlaysByStaff.find(key); @@ -472,6 +524,21 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re const int startVelocity = displayedVelocity(draggedNote); const int newVelocity = std::clamp(startVelocity + deltaVelocity, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + // Let the user hear the note at its live drag value before the change is committed - only the + // bar actually being dragged, and only when the (rounded) velocity has actually changed. While + // still dragging, also never more often than AUDITION_MIN_INTERVAL_MS - a mouse-move event + // fires far more often than that, so without the time gate a fast drag retriggers the sound + // almost every pixel of movement. On release, the throttle is bypassed rather than reset first + // - otherwise the exact value that ends up committed to the score could be one the user never + // actually heard, if it changed again within the last throttle window before release. + if (newVelocity != m_lastAuditionedVelocity && (completed || auditionThrottleElapsed())) { + auditionNote(draggedNote, newVelocity); + markAudition(newVelocity); + } + if (completed) { + resetAuditionThrottle(); + } + // If the dragged note is part of a multi-note selection, apply the same velocity delta to // every other selected note - including notes hidden behind others in the same chord's // stack - each clamped independently. Only what's selected moves. diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h index f41984916a181..6c455d190b1ea 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -23,6 +23,7 @@ #pragma once #include +#include #include #include "context/iglobalcontext.h" @@ -30,6 +31,7 @@ #include "modularity/ioc.h" #include "notation/inotationconfiguration.h" #include "notation/notationtypes.h" +#include "playback/iplaybackcontroller.h" #include "notevelocitygeometry.h" namespace mu::engraving { @@ -43,6 +45,7 @@ class NotationNoteVelocityController : public muse::Contextable, public muse::as { muse::ContextInject globalContext = { this }; muse::GlobalInject notationConfiguration; + muse::ContextInject playbackController = { this }; public: NotationNoteVelocityController(QQuickItem* overlaysParent, const muse::modularity::ContextPtr& iocCtx); @@ -113,6 +116,10 @@ class NotationNoteVelocityController : public muse::Contextable, public muse::as void scheduleRebuild(); void onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed); void previewBarHeight(const NoteLocation& location, int newVelocity); + void auditionNote(const mu::engraving::Note* note, int velocity); + bool auditionThrottleElapsed() const; + void markAudition(int velocity); + void resetAuditionThrottle(); std::vector selectedNotes() const; @@ -136,5 +143,11 @@ class NotationNoteVelocityController : public muse::Contextable, public muse::as NoteLocationMap m_noteLocations; muse::draw::Transform m_viewMatrix; bool m_rebuildScheduled = false; + + // Avoids re-triggering the audition sound on every single mouse-move event during a drag - + // only once per actually-distinct velocity value, and never faster than a fixed minimum + // interval (see AUDITION_MIN_INTERVAL_MS). + int m_lastAuditionedVelocity = -1; + QElapsedTimer m_auditionThrottle; }; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index 38c4daaa4f7fb..a90c3581475e9 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -296,6 +296,10 @@ void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) // below keeps both ends of the subtraction on the same scale. m_dragStartYPx = e->position().y(); e->accept(); + + // A zero delta - the mouse hasn't moved yet - so the controller hears a plain click on a bar + // even if it never turns into an actual drag. + emit barDragged(m_activeRectIndex, 0.0, false); } void NoteVelocityOverlay::mouseMoveEvent(QMouseEvent* e) @@ -330,6 +334,9 @@ void NoteVelocityOverlay::mouseUngrabEvent() // The mouse grab taken in mousePressEvent can be stolen mid-drag (e.g. a popup opening) - // without this, mouseReleaseEvent never fires and this item is left thinking a drag is still // active. Treat it as a cancel rather than guessing a commit at an unknown final position. + if (m_pressed) { + emit dragCancelled(); + } m_pressed = false; m_activeRectIndex = -1; } diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index 90fbbd2f52a7a..800744e40de69 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -82,6 +82,13 @@ class NoteVelocityOverlay : public QQuickPaintedItem // than jumping the value to whatever the click position happens to correspond to. void barDragged(int rectIndex, qreal deltaYN, bool completed); + // Fired instead of a final barDragged() when a drag is cancelled by having its mouse grab + // stolen mid-gesture (e.g. a popup opening) - unlike barDragged(..., completed=true), this is + // NOT a commit signal (no score change should follow it); it only exists so the controller can + // reset any of its own live-drag-only state (e.g. audition throttling) that would otherwise be + // left stuck mid-gesture with no matching completion event to clear it. + void dragCancelled(); + protected: void hoverMoveEvent(QHoverEvent* e) override; void hoverLeaveEvent(QHoverEvent* e) override; From ee103eb30b6e5843ca99c79aca3aa227f28a91b3 Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 16:00:58 +0200 Subject: [PATCH 32/36] Add a Cmd/Ctrl-tap toggle to swap note-offset/velocity overlay priority With both note-offset and note-velocity edit modes active, a velocity bar visually covering an offset edge handle also always won clicks and hover there, since it's stacked on top - making that handle both invisible and unreachable whenever a bar happened to cover it. A standalone Cmd (macOS) / Ctrl (Windows, Linux) tap - pressed and released with nothing else happening in between - now swaps which of the two overlay containers paints, and is hit-tested, on top of the other, persisting until tapped again. Committing only on release, and only if nothing else used the modifier in the meantime, keeps this from firing as a side effect of every other Cmd/Ctrl interaction (copy, undo, Ctrl-click to extend a selection, Ctrl-wheel zoom, passive hover in note-input mode, ...): a single general check in event() cancels the pending toggle for any QInputEvent that carries the modifier and isn't the Control key's own press/release, rather than reproducing that check in every individual handler. Also fixes a pre-existing gap surfaced while reviewing this: a velocity bar drag interrupted by its mouse grab being stolen mid-gesture (e.g. a popup opening) only reset the audition throttle, leaving the bar's live-preview height on screen indefinitely instead of snapping back to the note's actual velocity. --- .../abstractnotationpaintview.cpp | 50 +++++++++++++++++++ .../NotationScene/abstractnotationpaintview.h | 12 +++++ .../notationnotevelocitycontroller.cpp | 26 +++++++++- .../notationnotevelocitycontroller.h | 1 + .../NotationScene/notevelocityoverlay.cpp | 2 +- .../NotationScene/notevelocityoverlay.h | 11 ++-- 6 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index 4c10f035e44ff..59764be9f050b 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -1433,6 +1433,17 @@ bool AbstractNotationPaintView::shortcutOverride(QKeyEvent* event) void AbstractNotationPaintView::keyPressEvent(QKeyEvent* event) { + // Qt::Key_Control is Cmd on macOS, Ctrl on Windows/Linux (same swap as + // Qt::ControlModifier). Only *arms* here - the actual toggle only commits on a matching + // keyReleaseEvent() with nothing else having cancelled it in between (see event(), the single + // general choke point that does the cancelling). Committing on press instead would also fire + // as a side effect of every other Cmd/Ctrl shortcut in the app (copy, undo, Ctrl-click to + // extend a selection, Ctrl-wheel zoom, ...), which all necessarily start with this same + // physical key-down. + if (event->key() == Qt::Key_Control && !event->isAutoRepeat()) { + m_offsetOverlaysTogglePending = true; + } + if (isInited()) { m_inputController->keyPressEvent(event); } @@ -1447,6 +1458,18 @@ void AbstractNotationPaintView::keyPressEvent(QKeyEvent* event) void AbstractNotationPaintView::keyReleaseEvent(QKeyEvent* event) { + // See keyPressEvent(). Swaps which of the note-offset and note-velocity + // overlays paints - and is hit-tested - on top of the other, persisting until tapped again + // (not just while held). + if (event->key() == Qt::Key_Control && !event->isAutoRepeat() && m_offsetOverlaysTogglePending) { + m_offsetOverlaysTogglePending = false; + m_offsetOverlaysOnTop = !m_offsetOverlaysOnTop; + if (m_noteOffsetOverlayContainer && m_noteVelocityOverlayContainer) { + m_noteOffsetOverlayContainer->setZ(m_offsetOverlaysOnTop ? 1.0 : 0.0); + m_noteVelocityOverlayContainer->setZ(m_offsetOverlaysOnTop ? 0.0 : 1.0); + } + } + if (isInited()) { m_inputController->keyReleaseEvent(event); } @@ -1461,6 +1484,33 @@ bool AbstractNotationPaintView::event(QEvent* event) QEvent::Type eventType = event->type(); auto keyEvent = dynamic_cast(event); + // See keyPressEvent()/keyReleaseEvent(). A single general choke point for + // cancelling the pending overlay-priority toggle, instead of reproducing this check in every + // individual event handler (key, mouse press, wheel, a future trackpad-gesture or tablet + // handler, ...): every one of those event types derives from QInputEvent and carries the live + // modifier state in modifiers(), and event() is the one dispatch point they all pass through + // before reaching their specific handler. Any of them carrying Control - other than the + // Control key's own press/release, which legitimately arms/commits the toggle itself - means + // Control is being used as a modifier for something else (a shortcut, Ctrl-click, Ctrl-wheel + // zoom, ...), so the tap in progress shouldn't also toggle the overlays on release. Note this + // still can't see a key combo a native OS-level menu resolves entirely outside Qt's event + // system (observed to not be an issue for Cmd-C/Cmd-V in practice, but not guaranteed for + // every shortcut). + if (m_offsetOverlaysTogglePending) { + const bool isControlKeyEventItself = keyEvent && keyEvent->key() == Qt::Key_Control; + // A QHoverEvent is passive mouse-position tracking, not a user action - it's still a + // QInputEvent and still carries whatever modifiers happen to be held, so without this + // exclusion the pending toggle would self-cancel just from the mouse sitting still over + // the canvas while Control is held (e.g. hoverMoveEvent() is enabled here whenever note + // input mode is active), making the tap silently do nothing in that mode. + const bool isPassiveHover = dynamic_cast(event) != nullptr; + if (auto* inputEvent = dynamic_cast(event)) { + if (!isPassiveHover && (inputEvent->modifiers() & Qt::ControlModifier) && !isControlKeyEventItself) { + m_offsetOverlaysTogglePending = false; + } + } + } + bool isContextMenuEvent = ((eventType == QEvent::ShortcutOverride && keyEvent->key() == Qt::Key_Menu) || eventType == QEvent::Type::ContextMenu) && hasFocus(); diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h index fce824b36658a..e81e42882c79d 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.h @@ -296,6 +296,18 @@ protected slots: std::unique_ptr m_notationNoteOffsetController; QQuickItem* m_noteVelocityOverlayContainer = nullptr; std::unique_ptr m_notationNoteVelocityController; + + // Toggled by a standalone Cmd/Ctrl *tap* (pressed and released with nothing + // else happening in between - see keyPressEvent()/keyReleaseEvent()/event()), swaps which of + // the two containers paints (and is hit-tested) on top - lets a note-offset edge handle a + // velocity bar visually covers become both visible and reachable again, and vice versa. Only + // committing on release, and only if nothing else used Cmd/Ctrl as a modifier in the meantime + // (event() is the single choke point that cancels the pending toggle for that), keeps this + // from firing as a side effect of every other Cmd/Ctrl shortcut in the app (copy, undo, + // Ctrl-click to extend a selection, Ctrl-wheel zoom, ...), which all still start with the same + // physical key-down this feature would otherwise see first. + bool m_offsetOverlaysOnTop = false; + bool m_offsetOverlaysTogglePending = false; std::unique_ptr m_playbackCursor; std::unique_ptr m_noteInputCursor; std::unique_ptr m_ruler; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index b0263ca5ae814..8c944156507cd 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -323,8 +323,8 @@ void NotationNoteVelocityController::createOverlayForStaff(const System* system, QObject::connect(overlay, &NoteVelocityOverlay::barDragged, [this, key](int rectIndex, qreal deltaYN, bool completed) { onBarDragged(key, rectIndex, deltaYN, completed); }); - QObject::connect(overlay, &NoteVelocityOverlay::dragCancelled, [this]() { - resetAuditionThrottle(); + QObject::connect(overlay, &NoteVelocityOverlay::dragCancelled, [this, key](int rectIndex) { + onDragCancelled(key, rectIndex); }); } @@ -496,6 +496,28 @@ void NotationNoteVelocityController::resetAuditionThrottle() m_auditionThrottle.invalidate(); } +void NotationNoteVelocityController::onDragCancelled(const SysStaffKey& key, int rectIndex) +{ + resetAuditionThrottle(); + + const auto dataIt = m_overlaysByStaff.find(key); + IF_ASSERT_FAILED(key.isValid() && dataIt != m_overlaysByStaff.end() + && rectIndex >= 0 && static_cast(rectIndex) < dataIt->second.notes.size()) { + return; + } + + // previewBarHeight() calls during the drag mutate the overlay's rect directly, without ever + // touching the score - a grab stolen mid-drag (e.g. a popup opening) means no final + // barDragged(..., completed=true) ever arrives to settle that back to the note's real value, + // so without this the bar would keep showing the live-preview height indefinitely, out of + // sync with the note's actual (untouched) velocity. + Note* note = dataIt->second.notes.at(rectIndex).note; + IF_ASSERT_FAILED(note) { + return; + } + previewBarHeight(NoteLocation { key, rectIndex }, displayedVelocity(note)); +} + void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed) { const auto dataIt = m_overlaysByStaff.find(key); diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h index 6c455d190b1ea..e9583ecafa21b 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -115,6 +115,7 @@ class NotationNoteVelocityController : public muse::Contextable, public muse::as void onCurrentNotationChanged(); void scheduleRebuild(); void onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed); + void onDragCancelled(const SysStaffKey& key, int rectIndex); void previewBarHeight(const NoteLocation& location, int newVelocity); void auditionNote(const mu::engraving::Note* note, int velocity); bool auditionThrottleElapsed() const; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index a90c3581475e9..b5d43a55ad9d4 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -335,7 +335,7 @@ void NoteVelocityOverlay::mouseUngrabEvent() // without this, mouseReleaseEvent never fires and this item is left thinking a drag is still // active. Treat it as a cancel rather than guessing a commit at an unknown final position. if (m_pressed) { - emit dragCancelled(); + emit dragCancelled(m_activeRectIndex); } m_pressed = false; m_activeRectIndex = -1; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index 800744e40de69..059b1b5f42040 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -84,10 +84,13 @@ class NoteVelocityOverlay : public QQuickPaintedItem // Fired instead of a final barDragged() when a drag is cancelled by having its mouse grab // stolen mid-gesture (e.g. a popup opening) - unlike barDragged(..., completed=true), this is - // NOT a commit signal (no score change should follow it); it only exists so the controller can - // reset any of its own live-drag-only state (e.g. audition throttling) that would otherwise be - // left stuck mid-gesture with no matching completion event to clear it. - void dragCancelled(); + // NOT a commit signal (no score change should follow it); it exists so the controller can both + // reset its own live-drag-only state (e.g. audition throttling) and snap the bar's displayed + // height back to the note's actual (uncommitted) velocity - previewBarHeight() calls during + // the drag mutate the overlay's rect directly, so without this it would keep showing the + // live-preview height indefinitely, out of sync with the note's real value, until some + // unrelated rebuild happened to refresh it. + void dragCancelled(int rectIndex); protected: void hoverMoveEvent(QHoverEvent* e) override; From a607d108853ab0f48c4e9061b5c8d8b9d50c251f Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 16:48:56 +0200 Subject: [PATCH 33/36] Click a velocity bar to jump it directly to the clicked position A plain click (press+release without moving past a small threshold) now sets the note's velocity directly to whatever value the clicked position corresponds to, instead of being a no-op. Pressing and dragging past that threshold keeps today's existing relative-nudge behavior unchanged - the two are distinguished by tracking whether the mouse ever moved past CLICK_MOVE_THRESHOLD_PX before release. Implemented without a second code path: a click's delta is expressed as (clicked position - the bar's current top edge), which resolves through the same linear canvasY-to-velocity mapping used for drags to exactly the velocity at the clicked position, regardless of what that delta happens to be measured from. --- .../NotationScene/notevelocityoverlay.cpp | 24 ++++++++++++++++++- .../NotationScene/notevelocityoverlay.h | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp index b5d43a55ad9d4..752e7ef1e13c0 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -34,6 +34,10 @@ using namespace mu::notation; constexpr static qreal EDGE_HIT_MARGIN_PX = 4.0; constexpr static qreal BAR_HALF_WIDTH_MARGIN_PX = 1.0; // keeps adjacent chord bars from visually touching +// Below this, a press+release is a plain click (jump straight to that position) rather than a +// drag (nudge relative to wherever the bar already was) - see mouseReleaseEvent(). +constexpr static qreal CLICK_MOVE_THRESHOLD_PX = 3.0; + constexpr static qreal VALUE_LABEL_FONT_PX = 11.0; constexpr static qreal VALUE_LABEL_GAP_PX = 4.0; // horizontal gap between the bar and the label chip constexpr static qreal VALUE_LABEL_PADDING_X_PX = 4.0; @@ -295,6 +299,7 @@ void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) // different scales into one delta. Dividing the raw pixel delta by a single, current height() // below keeps both ends of the subtraction on the same scale. m_dragStartYPx = e->position().y(); + m_movedPastClickThreshold = false; e->accept(); // A zero delta - the mouse hasn't moved yet - so the controller hears a plain click on a bar @@ -308,6 +313,10 @@ void NoteVelocityOverlay::mouseMoveEvent(QMouseEvent* e) return; } + if (std::abs(e->position().y() - m_dragStartYPx) > CLICK_MOVE_THRESHOLD_PX) { + m_movedPastClickThreshold = true; + } + // Not clamped to [0, 1] - unlike the drag-start position, which is always a valid in-bounds // click on a bar, the mouse can (and, mid-drag, routinely does) move outside this item's own // bounds while still grabbed; clamping here would flatten the delta near the edges instead of @@ -322,7 +331,20 @@ void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) return; } - const qreal deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + qreal deltaYN; + if (m_movedPastClickThreshold) { + // A real drag - unchanged relative behavior, nudging from wherever the bar already was. + deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + } else { + // A plain click, released without ever moving past the threshold - jump straight to the + // clicked position instead. barDragged()'s delta is always relative to the bar's *current* + // position (see its own doc comment) rather than an absolute target, so this is expressed + // as the delta from the bar's current top edge (yTopN) to the click position - the + // controller's linear canvasY -> velocity mapping means that delta alone, regardless of + // what it's measured from, resolves to exactly the velocity at the clicked position. + const qreal clickYN = e->position().y() / std::max(1.0, height()); + deltaYN = clickYN - m_rects.at(m_activeRectIndex).yTopN; + } emit barDragged(m_activeRectIndex, deltaYN, true); m_pressed = false; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h index 059b1b5f42040..735c7596fb278 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -116,6 +116,7 @@ class NoteVelocityOverlay : public QQuickPaintedItem bool m_pressed = false; int m_activeRectIndex = -1; qreal m_dragStartYPx = 0.0; + bool m_movedPastClickThreshold = false; bool m_hoveringBar = false; }; } From 5dee8210438716612fbb549f7514cdc313864846 Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 16:58:08 +0200 Subject: [PATCH 34/36] Fix velocity-drag edge cases: phantom 0->1 bump, incomplete cancel revert A genuinely zero-delta gesture (a plain click landing back on the bar's own current position, or a drag that ends up where it started) was still run through the [MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY] clamp, silently flooring a note whose dynamics-derived velocity is legitimately 0 (e.g. under ppppppppp) to 1 and pinning it to an explicit VeloType::USER_VAL it never asked for - same issue for any other co-selected note whose own displayed velocity was 0. Now skips the clamp (and the property write entirely, for any note whose target value already matches what's displayed) whenever the actual delta is zero, and skips the whole undo entry if nothing ends up changing. Also, onDragCancelled() (mouse grab stolen mid-drag, e.g. by a popup) only reverted the one bar that owned the grab - if the dragged note was part of a multi-selection, every other selected note's bar (and their tie chains) had been live-previewed too and stayed stuck at that uncommitted height indefinitely. Now reverts the whole affected set, mirroring onBarDragged()'s own selection/tie-chain expansion. --- .../notationnotevelocitycontroller.cpp | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp index 8c944156507cd..60fd279f50e63 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -506,16 +506,42 @@ void NotationNoteVelocityController::onDragCancelled(const SysStaffKey& key, int return; } - // previewBarHeight() calls during the drag mutate the overlay's rect directly, without ever - // touching the score - a grab stolen mid-drag (e.g. a popup opening) means no final - // barDragged(..., completed=true) ever arrives to settle that back to the note's real value, - // so without this the bar would keep showing the live-preview height indefinitely, out of - // sync with the note's actual (untouched) velocity. - Note* note = dataIt->second.notes.at(rectIndex).note; - IF_ASSERT_FAILED(note) { + Note* draggedNote = dataIt->second.notes.at(rectIndex).note; + IF_ASSERT_FAILED(draggedNote) { return; } - previewBarHeight(NoteLocation { key, rectIndex }, displayedVelocity(note)); + + // previewBarHeight() calls during the drag mutate an overlay's rect directly, without ever + // touching the score - a grab stolen mid-drag (e.g. a popup opening) means no final + // barDragged(..., completed=true) ever arrives to settle those back to each note's real + // value, so without this the bar(s) would keep showing the live-preview height indefinitely, + // out of sync with the note's actual (untouched) velocity. If the dragged note was part of a + // multi-note selection, onBarDragged() would have live-previewed every selected note (and + // their forward tie chains) too - revert all of those the same way, not just the one bar that + // happened to own the mouse grab. + std::vector affectedNotes { draggedNote }; + const std::vector selected = selectedNotes(); + if (selected.size() > 1 && muse::contains(selected, draggedNote)) { + affectedNotes = selected; + } + + std::vector notesToRevert = affectedNotes; + for (Note* note : affectedNotes) { + for (Tie* tie = note->tieFor(); tie; tie = tie->endNote() ? tie->endNote()->tieFor() : nullptr) { + Note* tied = tie->endNote(); + if (!tied || muse::contains(notesToRevert, tied)) { + break; + } + notesToRevert.push_back(tied); + } + } + + for (Note* note : notesToRevert) { + const auto locIt = m_noteLocations.find(note); + if (locIt != m_noteLocations.end()) { + previewBarHeight(locIt->second, displayedVelocity(note)); + } + } } void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int rectIndex, qreal deltaYN, bool completed) @@ -544,7 +570,15 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re const double span = draggedEntry.yRange.y127 - draggedEntry.yRange.y0; const int deltaVelocity = std::abs(span) < 1e-9 ? 0 : static_cast(std::lround(deltaCanvasY / span * 127.0)); const int startVelocity = displayedVelocity(draggedNote); - const int newVelocity = std::clamp(startVelocity + deltaVelocity, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + // A genuinely zero delta (a plain click landing back on the bar's own current position, or a + // drag that ends up exactly where it started) must leave the value untouched rather than run + // it through the [MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY] clamp - otherwise a note + // whose dynamics-derived velocity is legitimately 0 (e.g. under ppppppppp) gets silently + // floored to 1 by a no-op interaction, converting it from dynamics-following to an explicit + // user override it never asked for. + const int newVelocity = deltaVelocity == 0 + ? startVelocity + : std::clamp(startVelocity + deltaVelocity, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); // Let the user hear the note at its live drag value before the change is committed - only the // bar actually being dragged, and only when the (rounded) velocity has actually changed. While @@ -587,7 +621,10 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re continue; } - const int otherVelocity = std::clamp(displayedVelocity(note) + delta, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); + // Same reasoning as newVelocity above - a zero delta must leave every co-selected note's + // own value untouched too, rather than floor a legitimately-0 one to 1. + const int otherStart = displayedVelocity(note); + const int otherVelocity = delta == 0 ? otherStart : std::clamp(otherStart + delta, MIN_DRAGGABLE_VELOCITY, MAX_DRAGGABLE_VELOCITY); changes.push_back({ note, otherVelocity }); } @@ -629,6 +666,21 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re return; } + // A note whose target velocity turned out identical to what it's already effectively playing + // at (the whole gesture net out to a zero delta - e.g. a plain click that lands back on the + // bar's own current position) has nothing to write - skip it rather than pin it to an + // explicit VeloType::USER_VAL it never asked for, and skip the whole undo entry if every + // affected note turns out this way (e.g. a click that amounts to just an audition). + std::vector realChanges; + for (const PendingChange& change : changes) { + if (change.velocity != displayedVelocity(change.note)) { + realChanges.push_back(change); + } + } + if (realChanges.empty()) { + return; + } + const INotationPtr notation = currentNotation(); const INotationUndoStackPtr undoStack = notation ? notation->undoStack() : nullptr; IF_ASSERT_FAILED(undoStack) { @@ -641,7 +693,7 @@ void NotationNoteVelocityController::onBarDragged(const SysStaffKey& key, int re // USER_VAL. Its relative-to-the-dynamic-marking behavior is intentionally traded for "this is // now the value I dragged it to" once the user has directly edited it through this UI. undoStack->prepareChanges(muse::TranslatableString("undoableAction", "Change note velocity")); - for (const PendingChange& change : changes) { + for (const PendingChange& change : realChanges) { if (change.note->getProperty(mu::engraving::Pid::VELO_TYPE).value() != VeloType::USER_VAL) { change.note->undoChangeProperty(mu::engraving::Pid::VELO_TYPE, VeloType::USER_VAL, mu::engraving::PropertyFlags::NOSTYLE); From f378cb0495930c1bf062c177e19c7d689af6d43d Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 16:58:20 +0200 Subject: [PATCH 35/36] Force a cursor refresh right after the overlay-priority Cmd/Ctrl tap Which overlay's cursor is displayed over an overlap is only re-evaluated by Qt on the next hover event (see the cursor-priority handling in notevelocityoverlay.cpp/noteoffsetoverlay.cpp). Without this, swapping which of the note-offset/note-velocity overlays is on top left a stationary mouse showing the previous top overlay's cursor until it happened to move even a pixel, even though a click there would already route to the new top overlay - a visible mismatch between the cursor and what a click would actually do. Synthesizes a button-less mouse-move at the current pointer position right after the swap, forcing Qt Quick's normal hover-delivery path to run again immediately, the same as a real (zero-distance) move would. --- .../NotationScene/abstractnotationpaintview.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp index 59764be9f050b..d8b2b8dfe8236 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/abstractnotationpaintview.cpp @@ -22,9 +22,11 @@ #include "abstractnotationpaintview.h" +#include #include #include #include +#include #include "async/async.h" #include "log.h" @@ -1468,6 +1470,20 @@ void AbstractNotationPaintView::keyReleaseEvent(QKeyEvent* event) m_noteOffsetOverlayContainer->setZ(m_offsetOverlaysOnTop ? 1.0 : 0.0); m_noteVelocityOverlayContainer->setZ(m_offsetOverlaysOnTop ? 0.0 : 1.0); } + + // Which overlay's cursor is shown is only re-evaluated by Qt on the next hover event + // (see the cursor-priority comments in notevelocityoverlay.cpp/noteoffsetoverlay.cpp) - + // without this, a stationary mouse keeps showing whichever overlay's cursor was on top + // *before* the swap until it happens to move even a pixel, so a click there would already + // route to the new top overlay while the cursor still displays the old one. Synthesizing + // a button-less mouse-move at the current pointer position forces Qt Quick's normal + // hover-delivery path to run again immediately, the same as a real (zero-distance) move. + if (QQuickWindow* win = window()) { + const QPointF posInWindow = win->mapFromGlobal(QCursor::pos()); + QMouseEvent hoverRefresh(QEvent::MouseMove, posInWindow, posInWindow, QCursor::pos(), + Qt::NoButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(win, &hoverRefresh); + } } if (isInited()) { From cdba40634af4a855451bb9aada697cf0c4cfaf79 Mon Sep 17 00:00:00 2001 From: sfer Date: Wed, 19 Aug 2026 16:58:33 +0200 Subject: [PATCH 36/36] Make the Properties panel velocity field VeloType::OFFSET_VAL-aware effectiveVelocity() always treated a nonzero userVelocity() as an absolute value, but for VeloType::OFFSET_VAL notes it's actually a percentage nudge on top of the dynamics-derived context velocity (see Note::customizeVelocity()) - the spinbox showed a raw, meaningless number instead of either the percentage or the actual playing velocity, disagreeing with the on-canvas velocity-bar overlay this was meant to mirror (NotationNoteVelocityController:: displayedVelocity()). Now shares the same VeloType-aware logic, factored into a new contextVelocity() helper mirroring the controller's own. Editing the spinbox had the matching write-side bug: it went through the default single-Pid write path, which never touched VELO_TYPE, so typing an absolute value into an OFFSET_VAL note's velocity field silently got reinterpreted as a percentage the next time it was read. A dedicated callback now forces VELO_TYPE to USER_VAL first, matching what dragging the on-canvas bar already does. --- .../playback/internal/noteplaybackmodel.cpp | 89 ++++++++++++++++--- .../playback/internal/noteplaybackmodel.h | 7 +- 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp index 5102f9fab890a..648431feae7ad 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -28,6 +28,7 @@ #include "dataformatter.h" #include "engraving/dom/note.h" +#include "engraving/types/types.h" #include "mpe/mpetypes.h" @@ -48,7 +49,49 @@ NotePlaybackModel::NotePlaybackModel(QObject* parent, const muse::modularity::Co void NotePlaybackModel::createProperties() { m_tuning = buildPropertyItem(mu::engraving::Pid::TUNING); - m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY); + + // Redirected to a dedicated callback instead of the default setPropertyValue() (which only + // ever writes the one Pid it's given) - dragging the on-canvas velocity bar always ends up as + // an absolute VeloType::USER_VAL (see NotationNoteVelocityController::onBarDragged()), and + // this mirrors that here too. Without it, typing a value into this spinbox for a + // VeloType::OFFSET_VAL note (userVelocity() is a *percentage* nudge on the dynamics-derived + // context for that type, not an absolute value) would leave VELO_TYPE untouched, silently + // reinterpreting the just-typed absolute number as a percentage the next time it's read. + auto onVelocityChanged = [this](const mu::engraving::Pid pid, const QVariant& newValue) { + if (m_elementList.empty()) { + return; + } + + beginCommand(muse::TranslatableString("undoableAction", "Change note velocity")); + + for (mu::engraving::EngravingItem* item : m_elementList) { + IF_ASSERT_FAILED(item) { + continue; + } + mu::engraving::Note* note = item->isNote() ? mu::engraving::toNote(item) : nullptr; + if (!note) { + continue; + } + + if (note->getProperty(mu::engraving::Pid::VELO_TYPE).value() + != mu::engraving::VeloType::USER_VAL) { + note->undoChangeProperty(mu::engraving::Pid::VELO_TYPE, mu::engraving::VeloType::USER_VAL, + mu::engraving::PropertyFlags::NOSTYLE); + } + + mu::engraving::PropertyFlags ps = item->propertyFlags(pid); + if (ps == mu::engraving::PropertyFlags::STYLED) { + ps = mu::engraving::PropertyFlags::UNSTYLED; + } + item->undoChangeProperty(pid, valueToElementUnits(pid, newValue, item), ps); + } + + updateNotation(); + endCommand(); + + loadProperties(); + }; + m_velocity = buildPropertyItem(mu::engraving::Pid::USER_VELOCITY, onVelocityChanged); // Redirected to each note's own chain head (see headNoteElements()) instead of the default // callback, which would write to the exact selected note. @@ -123,6 +166,22 @@ void NotePlaybackModel::loadVelocityProperty() m_velocity->setIsModified(isModified); } +int NotePlaybackModel::contextVelocity(const mu::engraving::Note* note) const +{ + // What the dynamics-marking/hairpin context alone would produce at this note's tick, with no + // per-note override - falls back to a flat constant only when there's no playback available + // to ask (mirrors NotationNoteVelocityController::contextVelocity()). + const notation::IMasterNotationPtr masterNotation = context()->currentMasterNotation(); + const notation::INotationPlaybackPtr playback = masterNotation ? masterNotation->playback() : nullptr; + if (!playback) { + return 64; + } + + const muse::mpe::dynamic_level_t level = playback->appliableDynamicLevel(note->track(), note->tick().ticks()); + const double ratio = muse::mpe::dynamicLevelToVelocityRatio(level); + return std::clamp(static_cast(std::lround(ratio * 127.0)), 0, 127); +} + int NotePlaybackModel::effectiveVelocity(const mu::engraving::Note* note) const { if (!note) { @@ -130,22 +189,26 @@ int NotePlaybackModel::effectiveVelocity(const mu::engraving::Note* note) const } const int userVelocity = note->userVelocity(); - if (userVelocity != 0) { - return userVelocity; + if (userVelocity == 0) { + // No explicit velocity set on this note - fall back to the same dynamics-derived value + // the on-canvas velocity-bar overlay already shows, instead of a flat constant that + // ignores whatever dynamic (piano, forte...) actually applies. + return contextVelocity(note); } - // No explicit velocity set on this note - fall back to the same dynamics-derived value the - // on-canvas velocity-bar overlay already shows (NotationNoteVelocityController::contextVelocity()) - // instead of a flat constant that ignores whatever dynamic (piano, forte...) actually applies. - const notation::IMasterNotationPtr masterNotation = context()->currentMasterNotation(); - const notation::INotationPlaybackPtr playback = masterNotation ? masterNotation->playback() : nullptr; - if (!playback) { - return 64; + // Note::customizeVelocity(): VeloType::USER_VAL means userVelocity() IS the absolute value, + // but VeloType::OFFSET_VAL means it's a *percentage* nudge applied on top of the dynamic + // context (velo += velo * userVelocity() / 100) - treating it as absolute here would show a + // value with no relation to either the percentage or what actually plays, and disagree with + // NotationNoteVelocityController::displayedVelocity(), which this is meant to mirror. + const mu::engraving::VeloType veloType = note->getProperty(mu::engraving::Pid::VELO_TYPE).value(); + if (veloType == mu::engraving::VeloType::USER_VAL) { + return userVelocity; } - const muse::mpe::dynamic_level_t level = playback->appliableDynamicLevel(note->track(), note->tick().ticks()); - const double ratio = muse::mpe::dynamicLevelToVelocityRatio(level); - return std::clamp(static_cast(std::lround(ratio * 127.0)), 0, 127); + const int context = contextVelocity(note); + const int offset = static_cast(std::lround(context * userVelocity / 100.0)); + return std::clamp(context + offset, 0, 127); } void NotePlaybackModel::onNotationChanged(const mu::engraving::PropertyIdSet&, const mu::engraving::StyleIdSet&) diff --git a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h index 8004e865de862..ee5b14911d411 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h @@ -71,11 +71,16 @@ class NotePlaybackModel : public PropertiesPanelAbstractModel // the velocity spinbox is loaded through this dedicated method instead of the generic one. void loadVelocityProperty(); + // What the dynamics-marking/hairpin context alone would produce at this note's tick, with no + // per-note override - mirrors NotationNoteVelocityController::contextVelocity(). + int contextVelocity(const mu::engraving::Note* note) const; + // The velocity spinbox used to hardcode a flat 64 whenever a note had no explicit userVelocity() // (0), completely ignoring any dynamic (piano, forte...) actually in effect at that note - unlike // the on-canvas velocity-bar overlay, which already falls back to the real dynamics-derived value // (NotationNoteVelocityController::displayedVelocity()/contextVelocity()). Mirrors that same - // fallback here so both surfaces agree. + // fallback here so both surfaces agree - including displayedVelocity()'s VeloType::OFFSET_VAL + // handling (a percentage nudge on the context, not an absolute value). int effectiveVelocity(const mu::engraving::Note* note) const; PropertyItem* m_tuning = nullptr;