Skip to content

Add per-note playback start/duration offset properties - #34546

Open
tharos-devs wants to merge 9 commits into
musescore:mainfrom
tharos-devs:feature/note-offsets
Open

Add per-note playback start/duration offset properties#34546
tharos-devs wants to merge 9 commits into
musescore:mainfrom
tharos-devs:feature/note-offsets

Conversation

@tharos-devs

Copy link
Copy Markdown

Resolves: #34545

Adds two new per-note properties (playbackStartOffset, playbackDurationOffset) that let users nudge a note's audio playback timing independently of its notated position and duration. Exposed in the Properties panel's Play section and on the plugins API. See #34545 for the full motivation and scope.

  • I signed the CLA as tharosd
  • The title of the PR describes the problem it addresses.
  • Each commit's message describes its purpose and effects, and references the issue it resolves. If changes are extensive, there is a sequence of easily reviewable commits.
  • The code in the PR follows the coding rules.
  • I understand all aspects of the code I'm contributing and I'm able to explain it if requested.
  • The code compiles and runs on my machine, preferably after each commit individually. I have manually tested and verified that my changes fulfil their intended purpose.
  • No prior attempts to resolve this problem exist, or if they do, I listed them in my PR description and described how I avoided repeating past mistakes.
  • There are no unnecessary changes.
  • I created a unit test or vtest to verify the changes I made (if applicable).

Note: no unit test/vtest yet for this playback-timing change — happy to add one if reviewers think it's warranted.

Adds two new per-note properties (playbackStartOffset, playbackDurationOffset)
that let users nudge a note's audio playback timing independently of its
notated position and duration. Exposed in the Properties panel's Play
section and on the plugins API.

Resolves musescore#34545
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b5a69e7b-d18e-4760-ad9a-45c74576af2c

📥 Commits

Reviewing files that changed from the base of the PR and between ae924b1 and 12ad719.

📒 Files selected for processing (3)
  • src/engraving/dom/note.cpp
  • src/engraving/dom/note.h
  • src/engraving/playback/renderers/noterenderer.cpp
💤 Files with no reviewable changes (2)
  • src/engraving/dom/note.h
  • src/engraving/dom/note.cpp
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/engraving/playback/renderers/noterenderer.cpp

📝 Walkthrough

Walkthrough

The change adds integer playback start and duration offsets to Note. It defines property identifiers, metadata, API properties, accessors, mutators, copy behavior, and zero defaults. Note XML reading and writing now handle both offsets. NoteRenderer applies the offsets to note timing. The properties panel model and QML view expose bounded editable controls with updated navigation order.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: adding per-note playback start and duration offset properties.
Description check ✅ Passed The description explains the change, links issue #34545, and completes the checklist except for the optional test item, which it explicitly addresses.
Linked Issues check ✅ Passed The changes satisfy #34545 by adding per-note offsets, rendering support, persistence, UI controls, zero defaults, and plugin API exposure.
Out of Scope Changes check ✅ Passed All changed files directly support the linked issue objectives, with no unrelated or unnecessary code changes identified.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

Review ran into problems

🔥 Problems

Linked repositories: Public OSS repositories can only analyze public repositories installed in this organization. No linked repositories were analyzed; skipped musescore/muse_framework.git.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/engraving/dom/note.cpp`:
- Around line 4216-4222: Update Note::effectivePlaybackDuration() to compute
duration from ch->ticks().ticks() plus playbackDurationOffset(), without
subtracting playbackStartOffset(). Ensure nonpositive results are clamped or
skipped before rendering, while preserving the existing zero result when no
chord is available.

In `@src/engraving/dom/note.h`:
- Around line 574-575: Update the manual copy constructor Note::Note(const
Note&, bool) to copy both m_playbackStartOffset and m_playbackDurationOffset
from the source note, preserving their values when notes are cloned.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3265a300-c3b4-48a4-9e3f-80ba5bba1bc7

📥 Commits

Reviewing files that changed from the base of the PR and between 7f3b4f3 and a48ff15.

📒 Files selected for processing (11)
  • src/engraving/api/v1/elements.h
  • src/engraving/dom/note.cpp
  • src/engraving/dom/note.h
  • src/engraving/dom/property.cpp
  • src/engraving/dom/property.h
  • src/engraving/playback/renderers/noterenderer.cpp
  • src/engraving/rw/read460/tread.cpp
  • src/engraving/rw/write/twrite.cpp
  • src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/NoteExpandableBlank.qml
  • src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.cpp
  • src/propertiespanel/qml/MuseScore/PropertiesPanel/general/playback/internal/noteplaybackmodel.h

Comment thread src/engraving/dom/note.cpp Outdated
Comment on lines +4216 to +4222
int Note::effectivePlaybackDuration() const
{
const Chord* ch = chord();
if (!ch) {
return 0;
}
return ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep duration independent from the start offset.

Line 4222 subtracts playbackStartOffset() from the duration. A note with a 480-tick duration and a 120-tick start offset plays for 360 ticks even when playbackDurationOffset is zero. This violates the independent duration contract.

The current formula can also produce a negative duration. The UI permits offsets from -1920 to 1920, and XML or plugins can supply larger values. Use the nominal chord duration plus playbackDurationOffset(), then clamp or skip nonpositive durations before rendering.

Proposed fix
-    return ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset();
+    return std::max(0, ch->ticks().ticks() + playbackDurationOffset());
📝 Committable suggestion

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

Suggested change
int Note::effectivePlaybackDuration() const
{
const Chord* ch = chord();
if (!ch) {
return 0;
}
return ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset();
int Note::effectivePlaybackDuration() const
{
const Chord* ch = chord();
if (!ch) {
return 0;
}
return std::max(0, ch->ticks().ticks() + playbackDurationOffset());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/engraving/dom/note.cpp` around lines 4216 - 4222, Update
Note::effectivePlaybackDuration() to compute duration from ch->ticks().ticks()
plus playbackDurationOffset(), without subtracting playbackStartOffset(). Ensure
nonpositive results are clamped or skipped before rendering, while preserving
the existing zero result when no chord is available.

Comment thread src/engraving/dom/note.h
Comment thread src/engraving/dom/note.h
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 87d97ba. Note::Note(const Note&, bool) now also copies m_playbackStartOffset and m_playbackDurationOffset.

Comment thread src/engraving/dom/note.cpp Outdated
if (!ch) {
return 0;
}
return std::max(1, ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The risk of a non-positive effective duration is real, but I'd like to keep the - playbackStartOffset() term rather than drop it.

The intent is for playbackStartOffset and playbackDurationOffset to move the effective start and end of playback independently: with the current formula, effectiveEnd = effectiveStart + effectiveDuration simplifies to chordTick + chordTicks + durationOffset — moving the start offset never shifts the end point, and vice versa. This is the anchor for an upcoming edit UI that lets users adjust the start and end of playback as two separate handles.

Dropping - playbackStartOffset() (as suggested) would make effectiveEnd also depend on startOffset, so moving the start would drag the whole playback window along with it rather than just resizing it — not the intended behavior.

I've addressed the actual risk you raised — a bad combination of independently-set offsets producing a non-positive duration — by clamping at the computation itself instead, in 87d97ba:

return std::max(1, ch->ticks().ticks() - playbackStartOffset() + playbackDurationOffset());

and similarly clamped effectivePlaybackStartTime() to std::max(0, ...). This keeps every caller (Properties panel, plugins, file loading) protected without changing the start/duration independence.

- Note::Note(const Note&, bool) did not copy m_playbackStartOffset /
  m_playbackDurationOffset, so cloning a note (copy-paste, duplication,
  linked parts) silently reset both offsets to 0.
- effectivePlaybackStartTime()/effectivePlaybackDuration() had no lower
  bound. Since the two offsets are set independently (Properties panel
  spinboxes each range -1920..1920 with no cross-validation), an
  inconsistent combination could produce a negative effective start tick
  or a non-positive effective duration, both unguarded downstream in
  NoteRenderer.

The duration formula intentionally keeps "- playbackStartOffset()" so that
the effective end time (chordTick + chordTicks + durationOffset) does not
depend on the start offset - this keeps start/duration independently
adjustable, which upcoming UI work relies on. Clamping is applied at the
computation itself rather than changing the formula, so every caller
(Properties panel, plugins, future UI) is protected centrally.
timestampAndDurationFromStartAndDurationTicks() was called with a
hardcoded tick-position offset of 0 instead of ctx.positionTickOffset,
so every note (not just ones with a non-zero playback offset) played at
first-playthrough timing on repeat/volta/D.C. passes.

effectivePlaybackDuration() also independently recomputed from the raw,
unclamped playbackStartOffset() instead of the same (possibly clamped)
start effectivePlaybackStartTime() returns, so the two could disagree
once the start clamp kicked in, making the note play longer than its
clamped start implied.
This branch doesn't depend on the (not yet merged) MuseSampler
velocity fix, so pin it to a commit that's actually on
musescore/muse_framework:main - the check_muse_framework CI check
rejects fork-only commits.
Note::effectivePlaybackStartTime()/effectivePlaybackDuration() derived
the note's playback window from the chord's own tick()/ticks(), which
is correct for an ordinary note but not for a grace note, an arpeggio
note, or a note inside a repeated section: their actual playback
window is computed separately (see GraceChordCtx::buildCtx and the
repeat-aware positionTickOffset handling) and can differ substantially
from the chord's notated tick/duration. Recomputing from chord
tick/ticks discarded that and collapsed grace notes back onto their
principal note's timing, breaking 16 unit tests.

Apply playbackStartOffset()/playbackDurationOffset() directly in
NoteRenderer::render() instead, on top of the RenderingContext's
already-correct nominal tick range, and only when an offset is
actually set (so unedited notes take the exact same code path as
before this feature existed).
Continuation-line indentation was off by one space, flagged by the
codestyle CI check.
@cbjeukendrup

Copy link
Copy Markdown
Contributor

Note that there is already Note::setOnTimeOffset and Note::setOffTimeOffset, for the same purpose as the properties you are adding here. It might be interesting to investigate how the existing methods and the underlying m_playEvents are used. You could also look how these related to the piano roll in MuseScore 3. Based on the findings, you could decide to either reuse the existing methods, or delete the existing m_playEvents system in favour of your new methods.

I would be particularly interested if a note can ever have more than one entry in m_playEvents, for example for trills and ornaments.

@cbjeukendrup

Copy link
Copy Markdown
Contributor

Git hint: it looks like this PR contains a merge commit ("Merge remote-tracking branch 'origin/main' into feature/note-offsets". It would be good to rebase it (git pull --rebase upstream main; git push -f).

Note::playbackStartOffset()/playbackDurationOffset() were only wired
into read460/tread.cpp's XML reader, but files saved by this app go
through read500 (the current format version, dispatched by
RWRegister::reader() for version >= 500) - read460 is only ever used
to open older 4.60-4.99 files, which can never contain this property
in the first place since it didn't exist yet. The property was
written correctly (twrite.cpp) but silently dropped on reload because
the reader that actually matters never looked for the tag, resetting
both values to 0 every time a file was saved and reopened.

Moved the read hooks to read500/tread.cpp, removed the dead ones from
read460, and added a save/reload regression test (writeReadElement
round-trip) to Engraving_NoteTests.note.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add per-note playback start/duration offset properties

6 participants