From b0d68ec778cea4f7073674585406a38e6659fcf7 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:09:56 +0200 Subject: [PATCH 01/19] 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 02/19] 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 c4a8b0d8a919e654aed13e4674e8f67ce52a1a85 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 10:58:39 +0200 Subject: [PATCH 03/19] 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 917b4f903bbee0133605408dfd50bd15e2f4fea9 Mon Sep 17 00:00:00 2001 From: sfer Date: Fri, 14 Aug 2026 15:06:34 +0200 Subject: [PATCH 04/19] 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 ddfdd631d8881b523305699d37f40d1827a605ca Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 14:43:10 +0200 Subject: [PATCH 05/19] 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 06/19] 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 b84d0714e5ea2c923b59ecf410fc671dc295b34b Mon Sep 17 00:00:00 2001 From: sfer Date: Sat, 15 Aug 2026 15:55:27 +0200 Subject: [PATCH 07/19] 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 08/19] 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 0cda5e4c81479654028fa4f0e1cf88b427326d17 Mon Sep 17 00:00:00 2001 From: sfer Date: Tue, 18 Aug 2026 11:25:09 +0200 Subject: [PATCH 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] 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 16/19] 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 17/19] 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 18/19] 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 19/19] 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;