diff --git a/muse b/muse index 07c98aa2c85ba..836dbaff3de5d 160000 --- a/muse +++ b/muse @@ -1 +1 @@ -Subproject commit 07c98aa2c85baeb0a12ab9ba53cc47af8885e11f +Subproject commit 836dbaff3de5d7dba56d4f4e44cc5fb0f86c6fc7 diff --git a/src/engraving/playback/playbackmodel.cpp b/src/engraving/playback/playbackmodel.cpp index 97d6df4392b2a..a5683fdecbef0 100644 --- a/src/engraving/playback/playbackmodel.cpp +++ b/src/engraving/playback/playbackmodel.cpp @@ -394,6 +394,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 fd4afe6017c23..9aac710dfc8a7 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/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); } 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 c7d570b37c13d..bfa25e895b2b6 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 786a56f1c4791..36f7199adae63 100644 --- a/src/notationscene/internal/notationactioncontroller.cpp +++ b/src/notationscene/internal/notationactioncontroller.cpp @@ -41,6 +41,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" @@ -584,7 +585,9 @@ 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); 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); }); @@ -1057,6 +1060,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, {} }, @@ -1138,6 +1142,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); } } @@ -3206,6 +3214,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; @@ -3291,6 +3309,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); +} + void NotationActionController::resetNoteOffsets() { TRACEFUNC; @@ -3314,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 8b1ee2cc6cbf9..155955340f010 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,7 +276,9 @@ class NotationActionController : public INotationCommandsController, public muse void toggleAutomation(); muse::Ret selectAutomationType(const muse::rcommand::CommandQuery& query); void toggleNoteOffsetEditor(); + void toggleNoteVelocityEditor(); void resetNoteOffsets(); + void resetNoteVelocities(); // commands void registerCommand(const muse::rcommand::Command&, std::function); @@ -317,6 +322,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 dd66f7b4dbfd7..cfe177f12a766 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 { RESET_NOTE_OFFSETS_COMMAND, TranslatableString("action", "Reset note offsets"), @@ -2928,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/internal/notationcommandsstate.cpp b/src/notationscene/internal/notationcommandsstate.cpp index 55f2731b13398..2370b3349719d 100644 --- a/src/notationscene/internal/notationcommandsstate.cpp +++ b/src/notationscene/internal/notationcommandsstate.cpp @@ -353,6 +353,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); }); @@ -497,6 +501,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 1b64744df1546..bc4fa2d4c6aff 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 c037b6eecb828..45319f0cd757a 100644 --- a/src/notationscene/notationcommands.h +++ b/src/notationscene/notationcommands.h @@ -485,7 +485,9 @@ 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"); 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/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..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" @@ -36,6 +38,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 +129,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 +413,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 +472,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 +527,10 @@ void AbstractNotationPaintView::onMatrixChanged(const Transform& oldMatrix, cons m_notationNoteOffsetController->setViewMatrix(newMatrix); } + if (m_notationNoteVelocityController) { + m_notationNoteVelocityController->setViewMatrix(newMatrix); + } + scheduleRedraw(); emit horizontalScrollChanged(); @@ -633,6 +661,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 +808,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(); @@ -1400,6 +1435,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); } @@ -1414,6 +1460,32 @@ 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); + } + + // 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()) { m_inputController->keyReleaseEvent(event); } @@ -1428,6 +1500,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 ee46ae40c3475..e81e42882c79d 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,20 @@ 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; + + // 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/notationcontextmenumodel.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp index 249136463b2ae..d25eef1824d46 100644 --- a/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp +++ b/src/notationscene/qml/MuseScore/NotationScene/notationcontextmenumodel.cpp @@ -67,6 +67,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); } @@ -548,6 +554,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; diff --git a/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp new file mode 100644 index 0000000000000..60fd279f50e63 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.cpp @@ -0,0 +1,755 @@ +/* + * 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/dom/tie.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; + +// 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; + +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 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); + } + + 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 deltaYN, bool completed) { + onBarDragged(key, rectIndex, deltaYN, completed); + }); + QObject::connect(overlay, &NoteVelocityOverlay::dragCancelled, [this, key](int rectIndex) { + onDragCancelled(key, rectIndex); + }); + } + + 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)); + + // 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() +{ + 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(); + rect.velocity = newVelocity; + 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::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; + } + + Note* draggedNote = dataIt->second.notes.at(rectIndex).note; + IF_ASSERT_FAILED(draggedNote) { + return; + } + + // 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) +{ + 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; + } + + // 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); + // 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 + // 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. + const int delta = newVelocity - startVelocity; + + 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; + } + + // 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 }); + } + + // 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. + for (const PendingChange& change : changes) { + const auto locIt = m_noteLocations.find(change.note); + if (locIt != m_noteLocations.end()) { + previewBarHeight(locIt->second, change.velocity); + } + } + 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) { + 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 : 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); + } + 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..e9583ecafa21b --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notationnotevelocitycontroller.h @@ -0,0 +1,154 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-Studio-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once + +#include +#include +#include + +#include "context/iglobalcontext.h" +#include "async/asyncable.h" +#include "modularity/ioc.h" +#include "notation/inotationconfiguration.h" +#include "notation/notationtypes.h" +#include "playback/iplaybackcontroller.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 }; + muse::GlobalInject notationConfiguration; + muse::ContextInject playbackController = { 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 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; + void markAudition(int velocity); + void resetAuditionThrottle(); + + 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; + + // 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/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/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)); 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..752e7ef1e13c0 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.cpp @@ -0,0 +1,364 @@ +/* + * 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 +#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 + +// 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; +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) +{ + setAcceptedMouseButtons(Qt::LeftButton); + setAcceptHoverEvents(true); +} + +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::setValueLabelColors(const QColor& background, const QColor& text) +{ + m_valueLabelBgColor = background; + m_valueLabelTextColor = text; + update(); +} + +void NoteVelocityOverlay::paint(QPainter* painter) +{ + if (m_rects.isEmpty()) { + return; + } + + painter->setRenderHint(QPainter::Antialiasing); + painter->setPen(QPen(m_borderColor, 1.0)); + + 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(); + 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); + }; + + // 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 + // 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 +{ + // 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; + } + + // 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); + + if (posPx.y() >= topPx - EDGE_HIT_MARGIN_PX && posPx.y() <= exposedBottomPx) { + return *it; + } + + minTopSoFarPx = std::min(minTopSoFarPx, topPx); + } + + 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. + const bool hoveringBar = hitTestPx(e->position()) >= 0; + if (hoveringBar == m_hoveringBar) { + return; + } + m_hoveringBar = hoveringBar; + + if (hoveringBar) { + setCursor(Qt::ArrowCursor); + } else { + unsetCursor(); + } +} + +void NoteVelocityOverlay::hoverLeaveEvent(QHoverEvent*) +{ + m_hoveringBar = false; + unsetCursor(); +} + +void NoteVelocityOverlay::mousePressEvent(QMouseEvent* e) +{ + const int hit = hitTestPx(e->position()); + if (hit < 0) { + e->ignore(); + return; + } + + m_pressed = true; + m_activeRectIndex = hit; + // 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(); + m_movedPastClickThreshold = false; + 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) +{ + if (!m_pressed) { + 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 + // tracking the mouse's actual displacement all the way through. + const qreal deltaYN = (e->position().y() - m_dragStartYPx) / std::max(1.0, height()); + emit barDragged(m_activeRectIndex, deltaYN, false); +} + +void NoteVelocityOverlay::mouseReleaseEvent(QMouseEvent* e) +{ + if (!m_pressed) { + return; + } + + 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; + 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. + if (m_pressed) { + 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 new file mode 100644 index 0000000000000..735c7596fb278 --- /dev/null +++ b/src/notationscene/qml/MuseScore/NotationScene/notevelocityoverlay.h @@ -0,0 +1,122 @@ +/* + * 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 + +class QHoverEvent; + +// 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 + int velocity = 0; // current velocity (0-127), shown next to the bar while it's being dragged + }; + + 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 setValueLabelColors(const QColor& background, const QColor& text); + + void paint(QPainter* painter) override; + + bool isDragging() const { return m_pressed; } + +signals: + // 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); + + // 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 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; + void hoverLeaveEvent(QHoverEvent* e) override; + 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; + void paintValueLabel(QPainter* painter, const RectData& rect) const; + + QVector m_rects; + + QColor m_fillColor; + QColor m_selectedFillColor; + QColor m_modifiedFillColor; + QColor m_borderColor; + QColor m_valueLabelBgColor; + QColor m_valueLabelTextColor; + + bool m_pressed = false; + int m_activeRectIndex = -1; + qreal m_dragStartYPx = 0.0; + bool m_movedPastClickThreshold = false; + bool m_hoveringBar = false; +}; +} 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..648431feae7ad 100644 --- a/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp +++ b/src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp @@ -21,10 +21,19 @@ */ #include "noteplaybackmodel.h" +#include +#include + #include "translation.h" #include "dataformatter.h" #include "engraving/dom/note.h" +#include "engraving/types/types.h" + +#include "mpe/mpetypes.h" + +#include "notation/imasternotation.h" +#include "notation/inotationplayback.h" using namespace mu::propertiespanel; @@ -40,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. @@ -60,14 +111,106 @@ 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::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) { + return 64; + } + + const int userVelocity = note->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); + } + + // 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 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&) { 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..ee5b14911d411 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,23 @@ 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(); + + // 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 - 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; 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();