Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a48ff15
Add per-note playback start/duration offset properties
tharosd Aug 11, 2026
756caf5
Fix note playback offset copy and add duration safety clamps
tharos-devs Aug 13, 2026
8990603
Add drag-handle overlay to edit per-note playback start/duration offsets
tharos-devs Aug 13, 2026
b84570b
Rebuild note-offset overlay geometry on style changes
tharos-devs Aug 13, 2026
009a628
Fix repeat/volta playback timing and start/duration clamp mismatch
tharos-devs Aug 14, 2026
d14be1b
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 14, 2026
b74429c
Fix crash and lifecycle bugs in the note-offset drag-handle overlay
tharos-devs Aug 14, 2026
ae924b1
Merge remote-tracking branch 'origin/main' into feature/note-offsets
tharos-devs Aug 14, 2026
5fc3d1a
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 14, 2026
52bd183
Fix codestyle (uncrustify) violation in NotationNoteOffsetController
tharos-devs Aug 14, 2026
be48c0d
Point muse submodule back at upstream main
tharos-devs Aug 14, 2026
fdd2101
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 14, 2026
12ad719
Fix playback timing regression for grace notes, arpeggios and bends
tharos-devs Aug 14, 2026
b2c9257
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 14, 2026
59e5ccc
Fix codestyle (uncrustify) violation in NoteRenderer::render()
tharos-devs Aug 14, 2026
7e4762d
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 14, 2026
dd03a90
Fix playback start/duration offset not surviving save/reload
tharos-devs Aug 14, 2026
1100bcf
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 14, 2026
142dde7
Fix note-offset rectangle width when another voice has a shorter note
tharos-devs Aug 14, 2026
8816db7
Color-code note-offset rectangles by modification/selection state
tharos-devs Aug 15, 2026
76ac7a2
Add "Reset note offsets" context-menu item
tharos-devs Aug 15, 2026
4dc153a
Tint note-offset drag handles by the rectangle's own state color
tharos-devs Aug 15, 2026
5efb9d5
Extend note-offset overlay rectangles across full tie chains
tharos-devs Aug 18, 2026
71efc99
Make note-offset Properties panel spinboxes tie-aware
tharos-devs Aug 18, 2026
af27a3b
Merge remote-tracking branch 'origin/main' into feature/note-offsets
tharos-devs Aug 19, 2026
69b9700
Merge branch 'feature/note-offsets' into feature/note-offset-drag-han…
tharos-devs Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/engraving/api/v1/elements.h
Original file line number Diff line number Diff line change
Expand Up @@ -1426,6 +1426,11 @@ class Note : public EngravingItem
/// The tuning of this note, in cents.
API_PROPERTY_T(qreal, tuning, TUNING)

/// The start time offset for playback, in ticks.
API_PROPERTY_T(int, playbackStartOffset, PLAYBACK_START_OFFSET)
/// The duration offset for playback, in ticks.
API_PROPERTY_T(int, playbackDurationOffset, PLAYBACK_DURATION_OFFSET)

/// For notes on non-tab staves: the line this note is on.
API_PROPERTY_T(int, line, LINE)
/// For notes on non-tab staves: Whether this note is
Expand Down
16 changes: 16 additions & 0 deletions src/engraving/dom/note.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,8 @@ Note::Note(const Note& n, bool link)
m_harmonic = n.m_harmonic;
m_hasParens = n.m_hasParens;
m_hideGeneratedParens = n.m_hideGeneratedParens;
m_playbackStartOffset = n.m_playbackStartOffset;
m_playbackDurationOffset = n.m_playbackDurationOffset;

if (n.m_accidental) {
add(new Accidental(*(n.m_accidental)));
Expand Down Expand Up @@ -3134,6 +3136,10 @@ PropertyValue Note::getProperty(Pid propertyId) const
return fixed();
case Pid::FIXED_LINE:
return fixedLine();
case Pid::PLAYBACK_START_OFFSET:
return m_playbackStartOffset;
case Pid::PLAYBACK_DURATION_OFFSET:
return m_playbackDurationOffset;
case Pid::HAS_PARENTHESES:
return m_hasParens ? ParenthesesMode::BOTH : ParenthesesMode::NONE;
case Pid::HIDE_GENERATED_PARENTHESES:
Expand Down Expand Up @@ -3242,6 +3248,12 @@ bool Note::setProperty(Pid propertyId, const PropertyValue& v)
case Pid::FIXED_LINE:
setFixedLine(v.toInt());
break;
case Pid::PLAYBACK_START_OFFSET:
setPlaybackStartOffset(v.toInt());
break;
case Pid::PLAYBACK_DURATION_OFFSET:
setPlaybackDurationOffset(v.toInt());
break;
case Pid::HAS_PARENTHESES:
if (v.value<ParenthesesMode>() != ParenthesesMode::BOTH && v.value<ParenthesesMode>() != ParenthesesMode::NONE) {
ASSERT_X("Notes cannot set left & right parens individually");
Expand Down Expand Up @@ -3308,6 +3320,10 @@ PropertyValue Note::propertyDefault(Pid propertyId) const
return 0;
case Pid::TPC2:
return getProperty(Pid::TPC1);
case Pid::PLAYBACK_START_OFFSET:
return 0;
case Pid::PLAYBACK_DURATION_OFFSET:
return 0;
case Pid::PITCH:
case Pid::TPC1:
return PropertyValue();
Expand Down
9 changes: 9 additions & 0 deletions src/engraving/dom/note.h
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,12 @@ class Note final : public EngravingItem
bool isTrillCueNote() const { return m_isTrillCueNote; }
void setIsTrillCueNote(bool v);

int playbackStartOffset() const { return m_playbackStartOffset; }
void setPlaybackStartOffset(int offset) { m_playbackStartOffset = offset; }

int playbackDurationOffset() const { return m_playbackDurationOffset; }
void setPlaybackDurationOffset(int offset) { m_playbackDurationOffset = offset; }

SymId noteHead() const;
bool isNoteName() const;

Expand Down Expand Up @@ -561,5 +567,8 @@ class Note final : public EngravingItem

std::vector<LineAttachPoint> m_lineAttachPoints;
TieJumpPointList m_jumpPoints { this };

int m_playbackStartOffset = 0; // offset in ticks to add to chord's tick for playback start
int m_playbackDurationOffset = 0; // offset in ticks to add to chord's ticks for playback duration
};
} // namespace mu::engraving
3 changes: 3 additions & 0 deletions src/engraving/dom/property.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,9 @@ static constexpr PropertyMetaData propertyList[] = {

{ Pid::SHARED_PART_ENABLED, P_TYPE::BOOL, PropertyGroup::NONE, false, "sharedPartEnabled", QT_TRANSLATE_NOOP("engraving/propertyName", "shared part enabled") },

{ Pid::PLAYBACK_START_OFFSET, P_TYPE::INT, PropertyGroup::APPEARANCE, false, "playbackStartOffset", QT_TRANSLATE_NOOP("engraving/propertyName", "playback start offset") },
{ Pid::PLAYBACK_DURATION_OFFSET, P_TYPE::INT, PropertyGroup::APPEARANCE, false, "playbackDurationOffset", QT_TRANSLATE_NOOP("engraving/propertyName", "playback duration offset") },

{ Pid::END, P_TYPE::INT, PropertyGroup::NONE, false, "++end++", nullptr }
};
/* *INDENT-ON* */
Expand Down
3 changes: 3 additions & 0 deletions src/engraving/dom/property.h
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,9 @@ enum class Pid : short {

SHARED_PART_ENABLED,

PLAYBACK_START_OFFSET,
PLAYBACK_DURATION_OFFSET,

END
};

Expand Down
20 changes: 20 additions & 0 deletions src/engraving/playback/renderers/noterenderer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,26 @@ void NoteRenderer::render(const Note* note, const RenderingContext& ctx, mpe::Pl
return;
}

if (note->playbackStartOffset() != 0 || note->playbackDurationOffset() != 0) {
// playbackStartOffset()/playbackDurationOffset() are ticks relative to the note's own
// nominal position - apply them on top of ctx's nominal tick range rather than
// recomputing from the chord's own tick()/ticks(). The chord's tick()/ticks() are its
// notated position/duration, which for a grace note or a note inside a repeated section
// is NOT the same as when/how long it actually plays - ctx.nominalPositionStartTick/
// nominalPositionEndTick already reflect that (see GraceChordCtx::buildCtx), whereas
// chord->tick() would collapse a grace note's "before the beat" timing back to the
// principal note's tick.
const int nominalStartTick = ctx.nominalPositionStartTick;
const int nominalEndTick = ctx.nominalPositionEndTick + note->playbackDurationOffset();
const int effectiveStartTick = std::max(0, nominalStartTick + note->playbackStartOffset());
const int effectiveDurationTicks = std::max(1, nominalEndTick - effectiveStartTick);

auto effectiveTnD = timestampAndDurationFromStartAndDurationTicks(ctx.score, effectiveStartTick, effectiveDurationTicks,
ctx.positionTickOffset);
noteCtx.timestamp = effectiveTnD.timestamp;
noteCtx.duration = effectiveTnD.duration;
}

const Tie* tieFor = note->tieFor();
if (tieFor && tieFor->playSpanner()) {
if (tieFor->isPartialTie()) {
Expand Down
2 changes: 2 additions & 0 deletions src/engraving/rw/read500/tread.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3470,6 +3470,8 @@ bool TRead::readProperties(Note* n, XmlReader& e, ReadContext& ctx)
} else if (tag == "overrideBendVisibilityRules") {
n->setOverrideBendVisibilityRules(e.readBool());
} else if (TRead::readProperty(n, tag, e, ctx, Pid::HIDE_GENERATED_PARENTHESES)) {
} else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_START_OFFSET)) {
} else if (TRead::readProperty(n, tag, e, ctx, Pid::PLAYBACK_DURATION_OFFSET)) {
} else if (readItemProperties(n, e, ctx)) {
} else {
return false;
Expand Down
3 changes: 2 additions & 1 deletion src/engraving/rw/write/twrite.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2506,7 +2506,8 @@ void TWrite::write(const Note* item, XmlWriter& xml, WriteContext& ctx)
}
for (Pid id : { Pid::PITCH, Pid::CENT_OFFSET, Pid::TPC1, Pid::TPC2, Pid::SMALL, Pid::MIRROR_HEAD, Pid::DOT_POSITION,
Pid::HEAD_SCHEME, Pid::HEAD_GROUP, Pid::USER_VELOCITY, Pid::PLAY, Pid::TUNING, Pid::FRET, Pid::STRING,
Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE }) {
Pid::GHOST, Pid::DEAD, Pid::HEAD_TYPE, Pid::FIXED, Pid::FIXED_LINE,
Pid::PLAYBACK_START_OFFSET, Pid::PLAYBACK_DURATION_OFFSET }) {
writeProperty(item, xml, id);
}

Expand Down
8 changes: 8 additions & 0 deletions src/engraving/tests/note_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,14 @@ TEST_F(Engraving_NoteTests, note)
EXPECT_EQ(n->userVelocity(), 71);
delete n;

// playback start/duration offset
note->setPlaybackStartOffset(120);
note->setPlaybackDurationOffset(-60);
n = toNote(ScoreRW::writeReadElement(note));
EXPECT_EQ(n->playbackStartOffset(), 120);
EXPECT_EQ(n->playbackDurationOffset(), -60);
delete n;

// tuning
note->setTuning(1.3);
n = toNote(ScoreRW::writeReadElement(note));
Expand Down
3 changes: 3 additions & 0 deletions src/notation/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ target_sources(notation PRIVATE
inotationselectionfilter.h
inotationselectionrange.h
inotationautomation.h
inotationnoteoffsets.h
inotationinteraction.h
inotationstyle.h
inotationundostack.h
Expand Down Expand Up @@ -83,6 +84,8 @@ target_sources(notation PRIVATE
internal/notationcontextconfiguration.h
internal/notationautomation.cpp
internal/notationautomation.h
internal/notationnoteoffsets.cpp
internal/notationnoteoffsets.h
internal/notationelements.cpp
internal/notationelements.h
internal/notationinteraction.cpp
Expand Down
1 change: 1 addition & 0 deletions src/notation/imasternotation.h
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ class IMasterNotation
virtual void initNotationSoloMuteState(const INotationPtr notation) = 0;

virtual INotationAutomationPtr automation() const = 0;
virtual INotationNoteOffsetsPtr noteOffsets() const = 0;
};

using IMasterNotationPtr = std::shared_ptr<IMasterNotation>;
Expand Down
3 changes: 3 additions & 0 deletions src/notation/inotation_fwd.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,7 @@ using INotationPlaybackPtr = std::shared_ptr<INotationPlayback>;

class INotationAutomation;
using INotationAutomationPtr = std::shared_ptr<INotationAutomation>;

class INotationNoteOffsets;
using INotationNoteOffsetsPtr = std::shared_ptr<INotationNoteOffsets>;
}
39 changes: 39 additions & 0 deletions src/notation/inotationnoteoffsets.h
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

#pragma once

#include "async/notification.h"

namespace mu::notation {
class INotationNoteOffsets
{
public:
virtual ~INotationNoteOffsets() = default;

virtual bool isEditModeEnabled() const = 0;
virtual void setEditModeEnabled(bool enabled) = 0;
virtual muse::async::Notification editModeEnabledChanged() const = 0;
};

using INotationNoteOffsetsPtr = std::shared_ptr<INotationNoteOffsets>;
}
7 changes: 7 additions & 0 deletions src/notation/internal/masternotation.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
#include "excerptnotation.h"
#include "masternotationparts.h"
#include "notationautomation.h"
#include "notationnoteoffsets.h"
#include "types/scorecreateoptions.h"

#ifdef MUE_BUILD_ENGRAVING_PLAYBACK
Expand Down Expand Up @@ -92,6 +93,7 @@ MasterNotation::MasterNotation(project::INotationProject* project, const muse::m
#endif

m_notationAutomation = std::make_shared<NotationAutomation>(undoStack());
m_notationNoteOffsets = std::make_shared<NotationNoteOffsets>();

m_parts->partsChanged().onNotify(this, [this]() {
notifyAboutNotationChanged();
Expand Down Expand Up @@ -766,6 +768,11 @@ INotationAutomationPtr MasterNotation::automation() const
return m_notationAutomation;
}

INotationNoteOffsetsPtr MasterNotation::noteOffsets() const
{
return m_notationNoteOffsets;
}

void MasterNotation::initNotationSoloMuteState(const INotationPtr notation)
{
IF_ASSERT_FAILED(notation) {
Expand Down
2 changes: 2 additions & 0 deletions src/notation/internal/masternotation.h
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab
void initNotationSoloMuteState(const INotationPtr notation) override;

INotationAutomationPtr automation() const override;
INotationNoteOffsetsPtr noteOffsets() const override;

private:
friend class project::NotationProject;
Expand Down Expand Up @@ -102,6 +103,7 @@ class MasterNotation : public IMasterNotation, public Notation, public std::enab
muse::async::Notification m_excerptsChanged;
INotationPlaybackPtr m_notationPlayback = nullptr;
INotationAutomationPtr m_notationAutomation = nullptr;
INotationNoteOffsetsPtr m_notationNoteOffsets = nullptr;
muse::async::Notification m_hasPartsChanged;

mutable ExcerptNotationList m_potentialExcerpts;
Expand Down
44 changes: 44 additions & 0 deletions src/notation/internal/notationnoteoffsets.cpp
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

#include "notationnoteoffsets.h"

using namespace mu::notation;

bool NotationNoteOffsets::isEditModeEnabled() const
{
return m_isEditModeEnabled;
}

void NotationNoteOffsets::setEditModeEnabled(bool enabled)
{
if (m_isEditModeEnabled == enabled) {
return;
}
m_isEditModeEnabled = enabled;
m_editModeEnabledChanged.notify();
}

muse::async::Notification NotationNoteOffsets::editModeEnabledChanged() const
{
return m_editModeEnabledChanged;
}
40 changes: 40 additions & 0 deletions src/notation/internal/notationnoteoffsets.h
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
#pragma once

#include "../inotationnoteoffsets.h"

#include "async/notification.h"

namespace mu::notation {
class NotationNoteOffsets : public INotationNoteOffsets
{
public:
bool isEditModeEnabled() const override;
void setEditModeEnabled(bool enabled) override;
muse::async::Notification editModeEnabledChanged() const override;

private:
bool m_isEditModeEnabled = false;
muse::async::Notification m_editModeEnabledChanged;
};
}
3 changes: 3 additions & 0 deletions src/notationscene/inotationcommandscontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ class INotationCommandsController : MODULE_CONTEXT_INTERFACE
virtual bool isAutomationModeEnabled() const = 0;
virtual muse::async::Notification automationModeEnabledChanged() const = 0;

virtual bool isNoteOffsetEditModeEnabled() const = 0;
virtual muse::async::Notification noteOffsetEditModeEnabledChanged() const = 0;

virtual bool isDebuggingCommandEnabled(const muse::rcommand::Command& command) const = 0;
virtual muse::async::Notification debuggingOptionsChanged() const = 0;
};
Expand Down
Loading
Loading