LCOV - code coverage report
Current view: top level - src/engraving/automation/internal - scoreautomationcontroller.cpp Coverage Total Hit
Test: filtered_coverage.info Lines: 88.4 % 855 756
Test Date: 2026-11-05 04:07:02

            Line data    Source code
       1              : /*
       2              :  * SPDX-License-Identifier: GPL-3.0-only
       3              :  * MuseScore-Studio-CLA-applies
       4              :  *
       5              :  * MuseScore Studio
       6              :  * Music Composition & Notation
       7              :  *
       8              :  * Copyright (C) 2025 MuseScore Limited and others
       9              :  *
      10              :  * This program is free software: you can redistribute it and/or modify
      11              :  * it under the terms of the GNU General Public License version 3 as
      12              :  * published by the Free Software Foundation.
      13              :  *
      14              :  * This program is distributed in the hope that it will be useful,
      15              :  * but WITHOUT ANY WARRANTY; without even the implied warranty of
      16              :  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
      17              :  * GNU General Public License for more details.
      18              :  *
      19              :  * You should have received a copy of the GNU General Public License
      20              :  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
      21              :  */
      22              : 
      23              : #include "scoreautomationcontroller.h"
      24              : 
      25              : #include <algorithm>
      26              : #include <unordered_map>
      27              : #include <unordered_set>
      28              : 
      29              : #include "engraving/dom/score.h"
      30              : #include "engraving/dom/repeatlist.h"
      31              : #include "engraving/dom/segment.h"
      32              : #include "engraving/dom/measure.h"
      33              : #include "engraving/dom/dynamic.h"
      34              : #include "engraving/dom/measurerepeat.h"
      35              : #include "engraving/dom/part.h"
      36              : #include "engraving/dom/staff.h"
      37              : #include "engraving/dom/hairpin.h"
      38              : #include "engraving/dom/breath.h"
      39              : #include "engraving/dom/fermata.h"
      40              : #include "engraving/dom/tempotext.h"
      41              : #include "engraving/dom/gradualtempochange.h"
      42              : #include "engraving/dom/volta.h"
      43              : 
      44              : #include "engraving/automation/automationdata.h"
      45              : #include "engraving/automation/dynamicvalues.h"
      46              : #include "engraving/automation/tempovalues.h"
      47              : 
      48              : #include "engraving/types/constants.h"
      49              : 
      50              : #include "global/containers.h"
      51              : #include "global/realfn.h"
      52              : #include "global/log.h"
      53              : 
      54              : using namespace mu::engraving;
      55              : 
      56              : static constexpr real_t DYNAMIC_STEP = real_t::make(0.05);
      57              : 
      58              : // Dynamics, measure repeats, fermatas, and tempo texts can only appear on these segment types
      59              : static constexpr SegmentType RELEVANT_SEGMENT_TYPES = SegmentType::ChordRest | SegmentType::TimeTick;
      60              : 
      61           86 : static BeatsPerSecond roundTempo(const BeatsPerSecond& bps)
      62              : {
      63           86 :     return muse::RealRound(bps.val, Constants::TEMPO_PRECISION);
      64              : }
      65              : 
      66          103 : static AutomationPoint makeTempoPoint(real_t normalizedBps, std::optional<EID> itemId)
      67              : {
      68          103 :     AutomationPoint point;
      69          103 :     point.value.outValue = std::clamp(normalizedBps, MIN_NORMALIZED_TEMPO, MAX_NORMALIZED_TEMPO);
      70          103 :     point.itemId = itemId;
      71          103 :     point.generated = true;
      72          103 :     return point;
      73              : }
      74              : 
      75              : //! TODO: map EXPONENTIAL/EASE_IN/EASE_OUT/EASE_IN_OUT to a real ease shape; every method is linear for now
      76           15 : static AutomationPoint::Ease changeMethodToEase(ChangeMethod)
      77              : {
      78           15 :     return AutomationPoint::Ease::none();
      79              : }
      80              : 
      81              : //! NOTE: segments are ordered by utick; returns the index of the last one starting at or before utick
      82           16 : static std::optional<size_t> findSegmentIndex(const std::vector<RepeatSegmentInfo>& segments, utick_t utick)
      83              : {
      84           16 :     const auto it = std::upper_bound(segments.begin(), segments.end(), utick,
      85           32 :                                      [](utick_t u, const RepeatSegmentInfo& seg) { return u < seg.utick; });
      86           16 :     if (it == segments.begin()) {
      87            0 :         return std::nullopt;
      88              :     }
      89              : 
      90           32 :     return size_t(std::prev(it) - segments.begin());
      91              : }
      92              : 
      93          180 : ScoreAutomationController::StaffRange::StaffRange(const Score* score, staff_idx_t staffIdxFrom, staff_idx_t staffIdxTo)
      94              : {
      95          180 :     const staff_idx_t lastStaffIdx = score->nstaves() - 1;
      96              : 
      97          180 :     if (staffIdxFrom == muse::nidx) {
      98          154 :         from = 0;
      99          154 :         to = lastStaffIdx;
     100              :     } else {
     101           26 :         from = staffIdxFrom;
     102           26 :         to = std::min(staffIdxTo, lastStaffIdx);
     103              :     }
     104              : 
     105          180 :     isFull = (from == 0 && to == lastStaffIdx);
     106              : 
     107          180 :     if (!isFull) {
     108            0 :         for (staff_idx_t staffIdx = from; staffIdx <= to; ++staffIdx) {
     109            0 :             if (const Staff* staff = score->staff(staffIdx)) {
     110            0 :                 staffIds.insert(staff->id());
     111              :             }
     112              :         }
     113              :     }
     114          180 : }
     115              : 
     116          501 : bool ScoreAutomationController::StaffRange::contains(staff_idx_t staffIdx) const
     117              : {
     118          501 :     return isFull || (staffIdx >= from && staffIdx <= to);
     119              : }
     120              : 
     121           15 : bool ScoreAutomationController::StaffRange::contains(const muse::ID& staffId) const
     122              : {
     123           15 :     return isFull || muse::contains(staffIds, staffId);
     124              : }
     125              : 
     126           39 : static std::optional<real_t> dynamicValue(DynamicType type, bool startValue)
     127              : {
     128           39 :     if (auto it = ORDINARY_DYNAMIC_VALUES.find(type); it != ORDINARY_DYNAMIC_VALUES.end()) {
     129           37 :         return it->second;
     130              :     }
     131              : 
     132            2 :     if (auto it = SINGLE_NOTE_DYNAMIC_VALUES.find(type); it != SINGLE_NOTE_DYNAMIC_VALUES.end()) {
     133            0 :         return it->second;
     134              :     }
     135              : 
     136            2 :     if (auto it = COMPOUND_DYNAMIC_VALUES.find(type); it != COMPOUND_DYNAMIC_VALUES.end()) {
     137            2 :         return startValue ? it->second.first : it->second.second;
     138              :     }
     139              : 
     140            0 :     return std::nullopt;
     141              : }
     142              : 
     143           38 : static std::optional<real_t> startHairpinValue(const Hairpin* hairpin)
     144              : {
     145           38 :     const DynamicType type = hairpin->dynamicTypeFrom();
     146           38 :     if (type == DynamicType::OTHER) {
     147           37 :         return std::nullopt;
     148              :     }
     149              : 
     150            1 :     return dynamicValue(type, false);
     151              : }
     152              : 
     153           38 : static DynamicType findEndDynamicType(const Hairpin* hairpin)
     154              : {
     155           38 :     const DynamicType textType = hairpin->dynamicTypeTo();
     156           38 :     if (textType != DynamicType::OTHER) {
     157            1 :         return textType;
     158              :     }
     159              : 
     160           37 :     if (hairpin->spannerSegments().empty()) {
     161            0 :         const Segment* endSegment = hairpin->endSegment();
     162            0 :         if (!endSegment) {
     163            0 :             return DynamicType::OTHER;
     164              :         }
     165              : 
     166            0 :         const track_idx_t trackIdx = hairpin->track();
     167            0 :         const std::vector<EngravingItem*> dynamics = endSegment->findAnnotations(ElementType::DYNAMIC, trackIdx, trackIdx);
     168            0 :         for (const EngravingItem* item : dynamics) {
     169            0 :             if (item && item->isDynamic() && toDynamic(item)->playDynamic()) {
     170            0 :                 return toDynamic(item)->dynamicType();
     171              :             }
     172              :         }
     173              : 
     174            0 :         return DynamicType::OTHER;
     175            0 :     }
     176              : 
     177           37 :     const LineSegment* seg = hairpin->backSegment();
     178           37 :     if (!seg) {
     179            0 :         return DynamicType::OTHER;
     180              :     }
     181              : 
     182              :     // Optimization: first check if there is a cached dynamic
     183           37 :     const EngravingItem* snappedItem = seg->ldata()->itemSnappedAfter();
     184           37 :     if (!snappedItem || !snappedItem->isDynamic() || !toDynamic(snappedItem)->playDynamic()) {
     185            2 :         snappedItem = toHairpinSegment(seg)->findElementToSnapAfter(false /*ignoreInvisible*/, true /*requirePlayable*/);
     186            2 :         if (!snappedItem || !snappedItem->isDynamic() || !toDynamic(snappedItem)->playDynamic()) {
     187            0 :             return DynamicType::OTHER;
     188              :         }
     189              :     }
     190              : 
     191           37 :     return toDynamic(snappedItem)->dynamicType();
     192              : }
     193              : 
     194           38 : static std::optional<real_t> endHairpinValue(const Hairpin* hairpin)
     195              : {
     196           38 :     const DynamicType type = findEndDynamicType(hairpin);
     197           38 :     if (type == DynamicType::OTHER) {
     198            0 :         return std::nullopt;
     199              :     }
     200              : 
     201           38 :     return dynamicValue(type, true);
     202              : }
     203              : 
     204          263 : static int dynamicPriority(const EngravingItem* item)
     205              : {
     206          263 :     return static_cast<int>(item->getProperty(Pid::VOICE_ASSIGNMENT).value<VoiceAssignment>());
     207              : }
     208              : 
     209           60 : static const AutomationPoint* activePoint(const AutomationCurveMap& curves, const AutomationCurveKey& key, utick_t tick)
     210              : {
     211           60 :     static const AutomationCurve NO_CURVE;
     212              : 
     213           60 :     const auto keyCurveIt = curves.find(key);
     214           60 :     const AutomationCurve& keyCurve = keyCurveIt != curves.end() ? keyCurveIt->second : NO_CURVE;
     215           60 :     const auto keyIt = muse::findLessOrEqual(keyCurve, tick);
     216              : 
     217           60 :     if (key.voiceIdx().has_value()) {
     218            0 :         const AutomationCurveKey sharedKey = key.withoutVoice();
     219            0 :         const auto sharedCurveIt = curves.find(sharedKey);
     220            0 :         const AutomationCurve& sharedCurve = sharedCurveIt != curves.end() ? sharedCurveIt->second : NO_CURVE;
     221            0 :         const auto sharedIt = muse::findLessOrEqual(sharedCurve, tick);
     222              : 
     223            0 :         if (sharedIt != sharedCurve.cend()) {
     224            0 :             if (keyIt == keyCurve.cend() || sharedIt->first > keyIt->first) {
     225            0 :                 return &sharedIt->second;
     226              :             }
     227              :         }
     228            0 :     }
     229              : 
     230           60 :     return keyIt != keyCurve.cend() ? &keyIt->second : nullptr;
     231              : }
     232              : 
     233          156 : static real_t currentTempoAt(const AutomationCurve& tempoCurve, utick_t tick)
     234              : {
     235          156 :     const auto it = muse::findLessOrEqual(tempoCurve, tick);
     236          156 :     return it != tempoCurve.cend() ? it->second.value.outValue : DEFAULT_NORMALIZED_TEMPO;
     237              : }
     238              : 
     239              : struct ChangeRelevance {
     240              :     bool dynamics = false;
     241              :     bool tempo = false;
     242              : };
     243              : 
     244          968 : static ChangeRelevance classifyChanges(const ScoreChanges& changes)
     245              : {
     246          968 :     ChangeRelevance result;
     247              : 
     248         3869 :     for (const ElementType type : changes.changedTypes) {
     249         2920 :         switch (type) {
     250           64 :         case ElementType::DYNAMIC:
     251              :         case ElementType::HAIRPIN:
     252              :         case ElementType::HAIRPIN_SEGMENT:
     253              :         case ElementType::MEASURE_REPEAT:
     254           64 :             result.dynamics = true;
     255           64 :             break;
     256           46 :         case ElementType::BREATH:
     257              :         case ElementType::LAYOUT_BREAK:
     258              :         case ElementType::FERMATA:
     259              :         case ElementType::GRADUAL_TEMPO_CHANGE:
     260              :         case ElementType::GRADUAL_TEMPO_CHANGE_SEGMENT:
     261              :         case ElementType::TEMPO_TEXT:
     262           46 :             result.tempo = true;
     263           46 :             break;
     264            3 :         case ElementType::VOLTA:
     265              :         case ElementType::VOLTA_SEGMENT:
     266              :         case ElementType::MARKER:
     267              :         case ElementType::JUMP:
     268            3 :             return { true, true };
     269         2807 :         default:
     270         2807 :             break;
     271              :         }
     272              : 
     273         2917 :         if (result.dynamics && result.tempo) {
     274           16 :             break;
     275              :         }
     276              :     }
     277              : 
     278              :     // VoiceAssignment change shifts which staves a dynamic/hairpin covers,
     279              :     // so the old points on staves outside the new assignment must also be cleared
     280          965 :     result.dynamics |= muse::contains(changes.changedPropertyIdSet, Pid::VOICE_ASSIGNMENT);
     281              : 
     282              :     static const std::unordered_set<Pid> REPEAT_PROPERTIES {
     283              :         Pid::REPEAT_START,
     284              :         Pid::REPEAT_END,
     285              :         Pid::REPEAT_JUMP,
     286              :         Pid::REPEAT_COUNT,
     287          971 :     };
     288              : 
     289          965 :     bool repeatPropertyChanged = false;
     290         4810 :     for (const Pid pid : REPEAT_PROPERTIES) {
     291         3854 :         if (muse::contains(changes.changedPropertyIdSet, pid)) {
     292            9 :             repeatPropertyChanged = true;
     293            9 :             break;
     294              :         }
     295              :     }
     296              : 
     297          965 :     if (repeatPropertyChanged) {
     298            9 :         result.dynamics = true;
     299            9 :         result.tempo = true;
     300              :     }
     301              : 
     302          965 :     return result;
     303              : }
     304              : 
     305         9559 : const TempoTimeline& ScoreAutomationController::tempoTimeline(bool expandRepeats) const
     306              : {
     307         9559 :     if (m_tempoTimelineOverride) {
     308            0 :         return *m_tempoTimelineOverride;
     309              :     }
     310              : 
     311         9559 :     return expandRepeats ? m_tempoTimeline : m_flattenedTempoTimeline;
     312              : }
     313              : 
     314           26 : void ScoreAutomationController::setTempoMultiplier(const BeatsPerSecond& bps)
     315              : {
     316           26 :     m_tempoTimeline.setTempoMultiplier(bps);
     317           26 :     m_flattenedTempoTimeline.setTempoMultiplier(bps);
     318           26 : }
     319              : 
     320            0 : void ScoreAutomationController::setTempoTimelineOverride(std::optional<TempoTimeline> timeline)
     321              : {
     322            0 :     m_tempoTimelineOverride = std::move(timeline);
     323            0 : }
     324              : 
     325          144 : void ScoreAutomationController::init(Score* score)
     326              : {
     327          144 :     m_score = score;
     328              : 
     329          144 :     if (!m_automationData) {
     330           17 :         m_automationData = std::make_shared<AutomationData>();
     331              :     }
     332              : 
     333          144 :     AutomationCurveMap curves = m_automationData->curves();
     334          144 :     fullRebuild(curves);
     335          144 : }
     336              : 
     337          151 : void ScoreAutomationController::fullRebuild(AutomationCurveMap& curves, bool includeTempo, bool includeDynamics)
     338              : {
     339          151 :     const UpdateRequest request { 0, muse::nidx, muse::nidx, includeTempo, includeDynamics };
     340          151 :     update(request, curves);
     341          151 : }
     342              : 
     343         9531 : void ScoreAutomationController::ensureInitialized(Score* score)
     344              : {
     345         9531 :     if (m_score) {
     346         9413 :         return;
     347              :     }
     348          118 :     init(score);
     349              : }
     350              : 
     351          457 : void ScoreAutomationController::insertTime(const Fraction& tick, const Fraction& len, const std::vector<RepeatSegmentInfo>& oldSegments)
     352              : {
     353          459 :     TRACEFUNC;
     354              : 
     355          457 :     const int diff = len.ticks();
     356          457 :     if (!m_score || !m_automationData || m_automationData->isEmpty() || diff == 0) {
     357          451 :         return;
     358              :     }
     359              : 
     360            6 :     const int rawInsertTick = tick.ticks();
     361            6 :     const std::vector<RepeatSegmentInfo> newSegments = m_score->expandedRepeatList().segmentInfoList();
     362              : 
     363            6 :     AutomationCurveMap curves = m_automationData->curves();
     364              : 
     365           24 :     for (auto& entry : curves) {
     366           18 :         AutomationCurve& curve = entry.second;
     367           18 :         AutomationCurve shifted;
     368              : 
     369          136 :         for (const auto& [utick, point] : curve) {
     370              :             // Generated points are regenerated by the full update below - only authored points need relocating
     371          118 :             if (point.generated) {
     372          106 :                 continue;
     373              :             }
     374              : 
     375              :             // Re-express the point in raw-tick coordinates local to its repeat pass,
     376              :             // so the shift below is correct even for a pass other than the first
     377           16 :             const std::optional<size_t> oldIdx = findSegmentIndex(oldSegments, utick);
     378           16 :             if (!oldIdx || *oldIdx >= newSegments.size()) {
     379            0 :                 continue; // no corresponding pass anymore (edit changed the repeat structure itself)
     380              :             }
     381              : 
     382           16 :             const RepeatSegmentInfo& oldSeg = oldSegments[*oldIdx];
     383           16 :             const int oldRawTick = utick - (oldSeg.utick - oldSeg.tick);
     384              : 
     385           16 :             if (diff < 0 && oldRawTick >= rawInsertTick && oldRawTick < rawInsertTick - diff) {
     386            4 :                 continue; // point sat inside the removed range
     387              :             }
     388              : 
     389           12 :             const int newRawTick = oldRawTick >= rawInsertTick ? oldRawTick + diff : oldRawTick;
     390              : 
     391              :             // Re-derive the utick for the *same* repeat pass under the new (post-edit) RepeatList
     392           12 :             const RepeatSegmentInfo& newSeg = newSegments[*oldIdx];
     393           12 :             const utick_t newUtick = utick_t(newRawTick + (newSeg.utick - newSeg.tick));
     394              : 
     395           12 :             shifted.emplace_hint(shifted.end(), newUtick, point);
     396              :         }
     397              : 
     398           18 :         curve = std::move(shifted);
     399           18 :     }
     400              : 
     401              :     // Kept as empty entries, not erased: replaceCurves() only clears a key when given it with an
     402              :     // empty curve - a key absent from the map below would leave stale data untouched instead
     403            6 :     fullRebuild(curves);
     404          457 : }
     405              : 
     406          972 : void ScoreAutomationController::update(const ScoreChanges& changes)
     407              : {
     408          972 :     if (changes.isTextEditing) {
     409          868 :         return;
     410              :     }
     411              : 
     412          968 :     const ChangeRelevance relevance = classifyChanges(changes);
     413          968 :     if (!relevance.dynamics && !relevance.tempo) {
     414          864 :         return;
     415              :     }
     416              : 
     417          104 :     UpdateRequest request;
     418          104 :     request.includeTempo = relevance.tempo;
     419          104 :     request.includeDynamics = relevance.dynamics;
     420              : 
     421              :     // When tempo is relevant, the whole score must be reparsed in one full pass,
     422              :     // so tickFrom/staffIdxFrom/staffIdxTo are left at their defaults (0/nidx/nidx)
     423          104 :     if (!relevance.tempo) {
     424           46 :         request.tickFrom = std::max(changes.tickFrom, 0);
     425              : 
     426              :         // VoiceAssignment change shifts which staves a dynamic/hairpin covers,
     427              :         // so the old points on staves outside the new assignment must also be cleared
     428           46 :         const bool voiceAssignmentChanged = muse::contains(changes.changedPropertyIdSet, Pid::VOICE_ASSIGNMENT);
     429           46 :         if (!voiceAssignmentChanged) {
     430           46 :             request.staffIdxFrom = changes.staffIdxFrom;
     431           46 :             request.staffIdxTo = changes.staffIdxTo;
     432              :         }
     433              :     }
     434              : 
     435          104 :     if (!m_automationData) {
     436            4 :         m_automationData = std::make_shared<AutomationData>();
     437              :     }
     438              : 
     439          104 :     update(request, m_automationData->curves());
     440              : }
     441              : 
     442           15 : void ScoreAutomationController::editPoints(const AutomationCurveKey& key, AutomationPointEdits& edits)
     443              : {
     444           15 :     IF_ASSERT_FAILED(m_automationData && m_score) {
     445            0 :         return;
     446              :     }
     447              : 
     448              :     // Tempo edits can affect later relative tempo markings, so we need a full rescan
     449           15 :     if (key.type == AutomationType::Tempo) {
     450            1 :         AutomationCurveMap curves = m_automationData->curves();
     451            1 :         AutomationCurve& tempoCurve = curves[key];
     452              : 
     453            2 :         for (const AutomationPointEdit& edit : edits) {
     454            1 :             if (const auto* setPoint = std::get_if<AutomationPointEdit::SetPoint>(&edit.change)) {
     455            1 :                 tempoCurve[edit.tick] = setPoint->point;
     456            0 :             } else if (const auto* movePoint = std::get_if<AutomationPointEdit::MovePoint>(&edit.change)) {
     457            0 :                 if (movePoint->from != edit.tick) {
     458            0 :                     tempoCurve.erase(movePoint->from);
     459              :                 }
     460            0 :                 tempoCurve[edit.tick] = movePoint->point;
     461              :             } else {
     462            0 :                 tempoCurve.erase(edit.tick);
     463              :             }
     464              :         }
     465              : 
     466            1 :         fullRebuild(curves, /*includeTempo*/ true, /*includeDynamics*/ false);
     467            1 :         return;
     468            1 :     }
     469              : 
     470              :     // Dynamics/Volume/Pan edits don't affect other points, so no rescan is needed
     471           14 :     mirrorEditsToRepeats(key, edits);
     472           14 :     m_automationData->editPoints(key, edits);
     473              : }
     474              : 
     475          255 : void ScoreAutomationController::update(const UpdateRequest& request, const AutomationCurveMap& curves)
     476              : {
     477          259 :     TRACEFUNC;
     478              : 
     479          255 :     if (!m_score) {
     480          101 :         return;
     481              :     }
     482              : 
     483          154 :     const RepeatList& repeatList = m_score->expandedRepeatList();
     484          154 :     if (repeatList.empty()) {
     485            0 :         return;
     486              :     }
     487              : 
     488          154 :     const auto repeatFromIt = std::find_if(repeatList.cbegin(), repeatList.cend(),
     489          154 :                                            [tickFrom = request.tickFrom](const RepeatSegment* seg) {
     490          154 :         return seg->endTick() > tickFrom;
     491              :     });
     492              : 
     493          154 :     IF_ASSERT_FAILED(repeatFromIt != repeatList.cend()) {
     494            0 :         return;
     495              :     }
     496              : 
     497          154 :     const StaffRange range(m_score, request.staffIdxFrom, request.staffIdxTo);
     498          154 :     const RepeatSegment* firstSeg = *repeatFromIt;
     499              : 
     500          154 :     UpdateContext ctx;
     501          154 :     ctx.request = request;
     502          154 :     ctx.tempoMultiplier = m_tempoTimeline.tempoMultiplier();
     503              : 
     504              :     // The first utick the rebuild will regenerate; everything before it is kept as is
     505          154 :     ctx.clearFromUTick = std::max(firstSeg->utick, request.tickFrom + (firstSeg->utick - firstSeg->tick));
     506              : 
     507              :     // Copy only the curves this update can affect, dropping the generated points that the rebuild
     508              :     // below re-creates; all other curves stay untouched
     509          154 :     copyCurvesForRebuild(curves, range, ctx.clearFromUTick, request.includeTempo, request.includeDynamics, ctx.curves);
     510              : 
     511          154 :     AutomationCurve emptyTempoCurve;
     512              : 
     513          154 :     if (request.includeTempo) {
     514          153 :         ctx.tempoCurve = &ctx.curves[TEMPO_KEY];
     515          153 :         fillNoRepeatTempoCurve(*ctx.tempoCurve, ctx.noRepeatTempoCurve);
     516              :     } else {
     517              :         // Read-only access for e.g. compound dynamics,
     518              :         // which need the current tempo even when this update doesn't rebuild it
     519            1 :         const auto tempoIt = ctx.curves.find(TEMPO_KEY);
     520            1 :         ctx.tempoCurve = tempoIt != ctx.curves.end() ? &tempoIt->second : &emptyTempoCurve;
     521              :     }
     522              : 
     523              :     // Step 1: segment dynamics (+ tempo/pause when relevant): populates ctx.dynamicPriorities
     524          154 :     int prevSegRawEndTick = -1;
     525          417 :     for (auto it = repeatFromIt; it != repeatList.cend(); ++it) {
     526          263 :         const RepeatSegment* seg = *it;
     527          263 :         const int tickOffset = seg->utick - seg->tick;
     528              : 
     529          263 :         if (request.includeTempo && prevSegRawEndTick != -1 && seg->tick != prevSegRawEndTick) {
     530              :             // Playback jumped elsewhere in the score (repeat loop-back / volta switch) - this
     531              :             // pass's start needs its tempo reset from raw tick order; resolved after Step 2, once
     532              :             // any volta reset affecting the lookup has landed in noRepeatTempoCurve
     533          104 :             ctx.pendingTempoResets.push_back({ seg->utick, seg->tick });
     534              :         }
     535          263 :         prevSegRawEndTick = seg->endTick();
     536              : 
     537              :         // tickFrom limits only the first affected repeat segment: later segments may replay
     538              :         // measures from before tickFrom, and all their points were cleared, so rebuild them in full
     539          263 :         const int measureFrom = (it == repeatFromIt) ? std::max(seg->tick, request.tickFrom) : seg->tick;
     540              : 
     541          263 :         const std::vector<const Measure*>& segMeasures = seg->measureList();
     542         1183 :         for (size_t mi = 0; mi < segMeasures.size(); ++mi) {
     543          920 :             const Measure* measure = segMeasures[mi];
     544          920 :             if (measure->endTick().ticks() <= measureFrom) {
     545            0 :                 continue;
     546              :             }
     547              : 
     548          920 :             if (request.includeTempo) {
     549          912 :                 collectPauses(measure, tickOffset, ctx.pauses, ctx.noRepeatPauses);
     550          912 :                 if (measure->isAnacrusis()) {
     551            1 :                     std::optional<utick_t> nextMeasureUTick;
     552            1 :                     if (mi + 1 < segMeasures.size()) {
     553            1 :                         nextMeasureUTick = segMeasures[mi + 1]->tick().ticks() + tickOffset;
     554              :                     }
     555            1 :                     ctx.anacrusisMeasures.push_back({ measure, tickOffset, nextMeasureUTick });
     556              :                 }
     557              :             }
     558              : 
     559              :             // A MeasureRepeat can only ever sit on the measure's first ChordRest segment
     560          920 :             const Segment* firstChordRestSegment = measure->first(SegmentType::ChordRest);
     561          920 :             if (firstChordRestSegment && firstChordRestSegment->tick().ticks() >= measureFrom) {
     562          920 :                 collectMeasureRepeats(firstChordRestSegment, tickOffset, range, ctx.measureRepeats);
     563              :             }
     564              : 
     565         3989 :             for (const Segment* segment = measure->first(RELEVANT_SEGMENT_TYPES); segment;
     566         3069 :                  segment = segment->next(RELEVANT_SEGMENT_TYPES)) {
     567         3069 :                 if (segment->annotations().empty() || segment->tick().ticks() < measureFrom) {
     568         2719 :                     continue;
     569              :                 }
     570              : 
     571          350 :                 addSegmentPoints(segment, tickOffset, range, ctx);
     572              :             }
     573              :         }
     574              :     }
     575              : 
     576          154 :     if (!ctx.anacrusisMeasures.empty()) {
     577            1 :         fixAnacrusisTempo(ctx);
     578              :     }
     579              : 
     580              :     // Step 2: spanner points (+ gradual tempo change when relevant):
     581              :     // ctx.dynamicPriorities fully populated; sets inValues on hairpin-end dynamics
     582          417 :     for (auto it = repeatFromIt; it != repeatList.cend(); ++it) {
     583          263 :         const RepeatSegment* seg = *it;
     584          263 :         addSpannerPoints(m_score, seg->tick, seg->endTick(), seg->utick - seg->tick, range, ctx);
     585              :     }
     586              : 
     587              :     // Step 3: resolve pending tempo resets now that noRepeatTempoCurve is fully built
     588          154 :     resolvePendingTempoResets(ctx);
     589              : 
     590              :     // Step 4: fill each voice curve with any points from the base (all-voice) curve it doesn't already have
     591          154 :     fillVoiceCurvesFromBase(ctx);
     592              : 
     593              :     // Step 5: measure repeats
     594          154 :     addMeasureRepeatPoints(ctx);
     595              : 
     596              :     // Step 6: Mirror directly-authored points to repeats
     597          154 :     mirrorAuthoredPointsToRepeats(ctx);
     598              : 
     599          154 :     if (request.includeTempo) {
     600              :         // TempoTimeline falls back to the default tempo when the curve has no points
     601          153 :         m_tempoTimeline.rebuild(*ctx.tempoCurve, ctx.pauses);
     602          153 :         m_flattenedTempoTimeline.rebuild(ctx.noRepeatTempoCurve, ctx.noRepeatPauses);
     603              :     }
     604              : 
     605              :     // Passed through as-is: replaceCurves() clears a key when given an empty curve for it
     606          154 :     m_automationData->replaceCurves(ctx.curves);
     607          255 : }
     608              : 
     609          153 : void ScoreAutomationController::fillNoRepeatTempoCurve(const AutomationCurve& tempoCurve, AutomationCurve& noRepeatTempoCurve)
     610              : {
     611          156 :     TRACEFUNC;
     612              : 
     613          153 :     const RepeatList& repeatList = m_score->expandedRepeatList();
     614              : 
     615              :     // tempoCurve is expected to be authored-only
     616          154 :     for (const auto& [utick, point] : tempoCurve) {
     617            1 :         const auto segIt = repeatList.findRepeatSegmentFromUTick(utick);
     618            1 :         IF_ASSERT_FAILED(segIt != repeatList.cend()) {
     619            0 :             continue;
     620              :         }
     621              : 
     622            1 :         const int segTickOffset = (*segIt)->utick - (*segIt)->tick;
     623            1 :         noRepeatTempoCurve.emplace(utick - segTickOffset, point);
     624              :     }
     625          153 : }
     626              : 
     627          154 : void ScoreAutomationController::mirrorAuthoredPointsToRepeats(UpdateContext& ctx)
     628              : {
     629          157 :     TRACEFUNC;
     630              : 
     631          389 :     for (auto& [key, curve] : ctx.curves) {
     632          235 :         if (curve.empty()) {
     633          222 :             continue;
     634              :         }
     635              : 
     636          114 :         AutomationPointEdits edits;
     637          731 :         for (const auto& [tick, point] : curve) {
     638          617 :             if (!point.generated) {
     639           25 :                 edits.push_back({ tick, AutomationPointEdit::SetPoint { point } });
     640              :             }
     641              :         }
     642              : 
     643          114 :         if (edits.empty()) {
     644          101 :             continue;
     645              :         }
     646              : 
     647              :         //! NOTE: appends the corresponding mirrored edit(s) - one per other repeat pass - directly to edits
     648           13 :         mirrorEditsToRepeats(key, edits);
     649              : 
     650           39 :         for (const AutomationPointEdit& edit : edits) {
     651           26 :             const auto* setPoint = std::get_if<AutomationPointEdit::SetPoint>(&edit.change);
     652           26 :             IF_ASSERT_FAILED(setPoint) {
     653            0 :                 continue;
     654              :             }
     655              : 
     656           26 :             curve.insert_or_assign(edit.tick, setPoint->point);
     657              :         }
     658          114 :     }
     659          154 : }
     660              : 
     661          154 : void ScoreAutomationController::copyCurvesForRebuild(const AutomationCurveMap& curves, const StaffRange& range, utick_t clearFromUTick,
     662              :                                                      bool includeTempo, bool includeDynamics, AutomationCurveMap& destCurves)
     663              : {
     664          157 :     TRACEFUNC;
     665              : 
     666          178 :     for (const auto& [key, curve] : curves) {
     667           24 :         const bool isDynamics = key.type == AutomationType::Dynamics;
     668           24 :         const bool isTempo = key.type == AutomationType::Tempo;
     669              : 
     670              :         // Instrument curves (Volume, Pan) aren't staff-scoped or score-derived, so only
     671              :         // user points survive here - mirrorAuthoredPointsToRepeats() recomputes the rest
     672           24 :         if (isDynamics) {
     673           15 :             if (!includeDynamics) {
     674            0 :                 continue;
     675              :             }
     676              : 
     677           15 :             const std::optional<muse::ID> staffId = key.staffId();
     678           15 :             if (!staffId || !range.contains(*staffId)) {
     679            0 :                 continue;
     680              :             }
     681              :         }
     682              : 
     683           24 :         AutomationCurve& curveCopy = destCurves.emplace_hint(destCurves.end(), key, AutomationCurve())->second;
     684              : 
     685           70 :         for (const auto& [tick, point] : curve) {
     686              :             // Dynamics: keep generated points before clearFromUTick (the incremental rebuild regenerates the rest)
     687              :             // Tempo: keep generated points only if this update doesn't include tempo (otherwise the whole curve is regenerated below)
     688              :             // Instrument: never keep generated points (there's no generation step for them, only user edits)
     689           46 :             const bool keepGenerated = isDynamics ? tick < clearFromUTick : isTempo && !includeTempo;
     690           46 :             if (!point.generated || keepGenerated) {
     691           14 :                 curveCopy.emplace_hint(curveCopy.end(), tick, point);
     692              :             }
     693              :         }
     694              :     }
     695          154 : }
     696              : 
     697          350 : void ScoreAutomationController::addSegmentPoints(const Segment* segment, int tickOffset, const StaffRange& range,
     698              :                                                  UpdateContext& ctx)
     699              : {
     700          352 :     TRACEFUNC;
     701              : 
     702          350 :     double fermataStretch = 0.0;
     703          350 :     const Fermata* fermata = nullptr;
     704              : 
     705          727 :     for (const EngravingItem* annotation : segment->annotations()) {
     706          377 :         if (!annotation) {
     707            0 :             continue;
     708              :         }
     709              : 
     710          377 :         if (annotation->isDynamic()) {
     711          225 :             if (ctx.request.includeDynamics && range.contains(annotation->staffIdx())) {
     712          225 :                 addDynamicPoints(toDynamic(annotation), tickOffset, range, ctx);
     713              :             }
     714          152 :         } else if (ctx.request.includeTempo && annotation->isFermata() && toFermata(annotation)->play()) {
     715           12 :             const Fermata* f = toFermata(annotation);
     716           12 :             if (f->timeStretch() > fermataStretch) {
     717           12 :                 fermataStretch = f->timeStretch();
     718           12 :                 fermata = f;
     719              :             }
     720          140 :         } else if (ctx.request.includeTempo && annotation->isTempoText()) {
     721           77 :             addTempoTextPoint(toTempoText(annotation), tickOffset, ctx);
     722              :         }
     723              :     }
     724              : 
     725          350 :     if (fermata && !muse::RealIsNull(fermataStretch) && !muse::RealIsEqual(fermataStretch, 1.0)) {
     726           12 :         addFermataStretchPoints(fermata, tickOffset, fermataStretch, ctx);
     727              :     }
     728          350 : }
     729              : 
     730          225 : void ScoreAutomationController::addDynamicPoints(const Dynamic* dynamic, int tickOffset, const StaffRange& range, UpdateContext& ctx)
     731              : {
     732          227 :     TRACEFUNC;
     733              : 
     734          225 :     if (!dynamic->playDynamic()) {
     735            0 :         return;
     736              :     }
     737              : 
     738          225 :     const DynamicType dynamicType = dynamic->dynamicType();
     739          225 :     const utick_t tick = dynamic->tick().ticks() + tickOffset;
     740              : 
     741          225 :     DynamicInfo info;
     742          225 :     info.tick = tick;
     743              : 
     744          225 :     if (auto ordinaryIt = ORDINARY_DYNAMIC_VALUES.find(dynamicType); ordinaryIt != ORDINARY_DYNAMIC_VALUES.end()) {
     745          176 :         info.kind = DynamicInfo::Ordinary { ordinaryIt->second };
     746           49 :     } else if (auto singleNoteIt = SINGLE_NOTE_DYNAMIC_VALUES.find(dynamicType); singleNoteIt != SINGLE_NOTE_DYNAMIC_VALUES.end()) {
     747           22 :         DynamicInfo::SingleNote singleNote { singleNoteIt->second, std::nullopt };
     748           22 :         if (const Segment* nextSeg = dynamic->segment()->next()) {
     749           22 :             singleNote.nextTick = nextSeg->tick().ticks() + tickOffset;
     750              :         }
     751           22 :         info.kind = singleNote;
     752           27 :     } else if (auto compoundIt = COMPOUND_DYNAMIC_VALUES.find(dynamicType); compoundIt != COMPOUND_DYNAMIC_VALUES.end()) {
     753           27 :         const BeatsPerSecond tempo = denormalizeTempo(currentTempoAt(*ctx.tempoCurve, tick)) * ctx.tempoMultiplier;
     754           27 :         info.kind = DynamicInfo::Compound { compoundIt->second.first, compoundIt->second.second,
     755           27 :                                             tick + dynamic->velocityChangeLength(tempo).ticks() };
     756              :     } else {
     757            0 :         NOT_SUPPORTED;
     758            0 :         return;
     759              :     }
     760              : 
     761          225 :     const std::vector<AutomationCurveKey> keys = resolveKeys(dynamic, AutomationType::Dynamics, range);
     762          225 :     if (keys.empty()) {
     763            0 :         return;
     764              :     }
     765              : 
     766          225 :     info.priority = dynamicPriority(dynamic);
     767              : 
     768          225 :     info.eid = dynamic->eid();
     769          225 :     if (!info.eid.isValid()) {
     770           53 :         info.eid = dynamic->assignNewEID();
     771              :     }
     772              : 
     773          453 :     for (const AutomationCurveKey& key : keys) {
     774          228 :         addDynamicPoints(info, key, ctx);
     775              :     }
     776          225 : }
     777              : 
     778          228 : void ScoreAutomationController::addDynamicPoints(const DynamicInfo& info, const AutomationCurveKey& key, UpdateContext& ctx)
     779              : {
     780          228 :     IF_ASSERT_FAILED(key.isValid()) {
     781            0 :         return;
     782              :     }
     783              : 
     784          228 :     AutomationCurve& curve = ctx.curves[key];
     785          228 :     std::map<utick_t, int>& tickPrioMap = ctx.dynamicPriorities[key];
     786              : 
     787          228 :     if (const auto* ordinary = std::get_if<DynamicInfo::Ordinary>(&info.kind)) {
     788          179 :         AutomationPoint point;
     789          179 :         point.value.inValue = AutomationPoint::ArrivalFromPrevious {};
     790          179 :         point.value.outValue = ordinary->value;
     791          179 :         point.itemId = info.eid;
     792          179 :         point.generated = true;
     793          179 :         addDynamicPoint(curve, tickPrioMap, info.tick, point, info.priority);
     794          179 :         return;
     795              :     }
     796              : 
     797           49 :     if (const auto* singleNote = std::get_if<DynamicInfo::SingleNote>(&info.kind)) {
     798           22 :         const AutomationPoint* prevPoint = singleNote->nextTick ? activePoint(ctx.curves, key, info.tick) : nullptr;
     799              : 
     800           22 :         AutomationPoint point;
     801           22 :         point.value.inValue = AutomationPoint::ArrivalFromPrevious {};
     802           22 :         point.value.outValue = singleNote->value;
     803           22 :         point.itemId = info.eid;
     804           22 :         point.generated = true;
     805           22 :         addDynamicPoint(curve, tickPrioMap, info.tick, point, info.priority);
     806              : 
     807           22 :         if (singleNote->nextTick) {
     808              :             // Recovers to whatever was active before this dynamic
     809           22 :             AutomationPoint nextPoint = prevPoint ? *prevPoint : AutomationPoint{};
     810           22 :             const AutomationPoint::Ease preservedEase = ease(nextPoint).value_or(AutomationPoint::Ease::none());
     811           22 :             nextPoint.value.inValue = AutomationPoint::ExplicitArrival { point.value.outValue, preservedEase };
     812           22 :             nextPoint.generated = true;
     813           22 :             tryAddDynamicPoint(curve, tickPrioMap, *singleNote->nextTick, nextPoint, info.priority);
     814              :         }
     815              : 
     816           22 :         return;
     817              :     }
     818              : 
     819           27 :     if (const auto* compound = std::get_if<DynamicInfo::Compound>(&info.kind)) {
     820           27 :         AutomationPoint startPoint;
     821           27 :         startPoint.value.inValue = AutomationPoint::ArrivalFromPrevious {};
     822           27 :         startPoint.value.outValue = compound->startValue;
     823           27 :         startPoint.itemId = info.eid;
     824           27 :         startPoint.generated = true;
     825           27 :         addDynamicPoint(curve, tickPrioMap, info.tick, startPoint, info.priority);
     826              : 
     827           27 :         AutomationPoint endPoint;
     828           27 :         endPoint.value.outValue = compound->endValue;
     829           27 :         endPoint.value.inValue = AutomationPoint::ExplicitArrival { endPoint.value.outValue, AutomationPoint::Ease::none() };
     830           27 :         endPoint.itemId = info.eid;
     831           27 :         endPoint.generated = true;
     832           27 :         tryAddDynamicPoint(curve, tickPrioMap, compound->endPointTick, endPoint, info.priority);
     833              :     }
     834              : }
     835              : 
     836          263 : void ScoreAutomationController::addSpannerPoints(const Score* score, int repeatStartTick, int repeatEndTick,
     837              :                                                  int tickOffset, const StaffRange& range, UpdateContext& ctx)
     838              : {
     839          266 :     TRACEFUNC;
     840              : 
     841          263 :     const SpannerMap& spannerMap = score->spannerMap();
     842          263 :     if (spannerMap.empty()) {
     843          174 :         return;
     844              :     }
     845              : 
     846           89 :     const int overlapStart = std::max(repeatStartTick + 1, ctx.clearFromUTick - tickOffset);
     847           89 :     const int overlapStop = repeatEndTick - 1;
     848           89 :     if (overlapStart > overlapStop) {
     849            0 :         return;
     850              :     }
     851              : 
     852           89 :     const auto& intervals = spannerMap.findOverlapping(overlapStart, overlapStop);
     853          170 :     for (const auto& interval : intervals) {
     854           81 :         const Spanner* spanner = interval.value;
     855           81 :         if (!spanner->playSpanner()) {
     856            0 :             continue;
     857              :         }
     858              : 
     859           81 :         if (spanner->isHairpin()) {
     860           38 :             if (!ctx.request.includeDynamics) {
     861            0 :                 continue;
     862              :             }
     863           38 :             const Hairpin* hairpin = toHairpin(spanner);
     864           38 :             const std::vector<AutomationCurveKey> keys = resolveKeys(hairpin, AutomationType::Dynamics, range);
     865           38 :             if (!keys.empty()) {
     866           38 :                 addHairpinPoints(hairpin, tickOffset, keys, ctx);
     867              :             }
     868           81 :         } else if (ctx.request.includeTempo && spanner->isGradualTempoChange()) {
     869           15 :             addGradualTempoChangePoints(toGradualTempoChange(spanner), tickOffset, ctx);
     870           28 :         } else if (ctx.request.includeTempo && spanner->isVolta()) {
     871            6 :             addVoltaTempoResetPoint(toVolta(spanner), ctx);
     872              :         }
     873              :     }
     874          263 : }
     875              : 
     876           38 : void ScoreAutomationController::addHairpinPoints(const Hairpin* hairpin, int tickOffset, const std::vector<AutomationCurveKey>& keys,
     877              :                                                  UpdateContext& ctx)
     878              : {
     879           38 :     HairpinInfo info;
     880           38 :     info.from = hairpin->tick().ticks() + tickOffset;
     881           38 :     info.to = info.from + hairpin->ticks().ticks();
     882              : 
     883              :     // --- Check start tick
     884              :     {
     885           38 :         const Segment* startSegment = hairpin->startSegment();
     886           38 :         const track_idx_t trackIdx = hairpin->track();
     887              :         const Dynamic* startDynamic = startSegment
     888           38 :                                       ? toDynamic(startSegment->findAnnotation(ElementType::DYNAMIC, trackIdx, trackIdx))
     889           38 :                                       : nullptr;
     890           38 :         if (startDynamic && muse::contains(COMPOUND_DYNAMIC_VALUES, startDynamic->dynamicType())) {
     891              :             // The hairpin starts with a compound dynamic; we should start the hairpin after the transition is complete
     892              :             // This solution should be replaced once we have better infrastructure to see relations between Dynamics and Hairpins.
     893            5 :             const BeatsPerSecond tempo = denormalizeTempo(currentTempoAt(*ctx.tempoCurve, info.from)) * ctx.tempoMultiplier;
     894            5 :             info.from += startDynamic->velocityChangeLength(tempo).ticks();
     895              :         }
     896              :     }
     897              : 
     898           38 :     IF_ASSERT_FAILED(info.from < info.to) {
     899            0 :         return;
     900              :     }
     901              : 
     902           38 :     info.eid = hairpin->eid();
     903           38 :     if (!info.eid.isValid()) {
     904           15 :         info.eid = hairpin->assignNewEID();
     905              :     }
     906              : 
     907           38 :     info.priority = dynamicPriority(hairpin);
     908           38 :     info.isCrescendo = hairpin->isCrescendo();
     909           38 :     info.nominalValueFrom = startHairpinValue(hairpin);
     910           38 :     info.nominalValueTo = endHairpinValue(hairpin);
     911              : 
     912           76 :     for (const AutomationCurveKey& key : keys) {
     913           38 :         addHairpinPoints(info, key, ctx);
     914              :     }
     915              : }
     916              : 
     917           38 : void ScoreAutomationController::addHairpinPoints(const HairpinInfo& info, const AutomationCurveKey& key, UpdateContext& ctx)
     918              : {
     919           38 :     AutomationCurve& curve = ctx.curves[key];
     920           38 :     std::map<utick_t, int>& tickPrioMap = ctx.dynamicPriorities[key];
     921              : 
     922              :     // --- Determine valueFrom
     923           38 :     const AutomationPoint* prevPoint = activePoint(ctx.curves, key, info.from);
     924           38 :     const real_t prevOutValue = prevPoint ? prevPoint->value.outValue : real_t(0.0);
     925              : 
     926              :     // If the hairpin has no specific start value, use the currently-applicable value at the start tick of the hairpin
     927           38 :     const real_t valueFrom = info.nominalValueFrom.value_or(prevOutValue);
     928              : 
     929              :     {
     930           38 :         AutomationPoint startPoint;
     931           38 :         startPoint.value.outValue = valueFrom;
     932           38 :         startPoint.value.inValue = AutomationPoint::ArrivalFromPrevious {};
     933           38 :         startPoint.itemId = info.eid;
     934           38 :         startPoint.generated = true;
     935           38 :         tryAddDynamicPoint(curve, tickPrioMap, info.from, startPoint, info.priority);
     936              :     }
     937              : 
     938              :     // --- Determine valueTo
     939              :     // If there is an end dynamic marking, check if it matches the 'direction' of the hairpin (cresc. vs dim.)
     940           38 :     const bool useNominalValueTo = info.nominalValueTo.has_value()
     941           44 :                                    && (info.isCrescendo ? info.nominalValueTo.value() > valueFrom
     942            6 :                                        : info.nominalValueTo.value() < valueFrom);
     943              : 
     944              :     // --- Check end tick
     945              :     const real_t valueTo = useNominalValueTo
     946           38 :                            ? info.nominalValueTo.value()
     947           38 :                            : valueFrom + (info.isCrescendo ? DYNAMIC_STEP : -DYNAMIC_STEP);
     948              : 
     949           38 :     const auto endPointIt = curve.find(info.to);
     950           38 :     const bool hasPointAtEnd = endPointIt != curve.end();
     951              : 
     952           38 :     if (hasPointAtEnd) {
     953              :         // A point already exists at the end tick; encode the hairpin's arrival via inValue, but only
     954              :         // if this hairpin has at least as much priority as whoever placed that point
     955           37 :         const auto tickIt = tickPrioMap.find(info.to);
     956           37 :         const bool canModify = tickIt == tickPrioMap.end() || info.priority >= tickIt->second;
     957           37 :         if (canModify) {
     958           37 :             const AutomationPoint::Ease preservedEase = ease(endPointIt->second).value_or(AutomationPoint::Ease::none());
     959           37 :             endPointIt->second.value.inValue = AutomationPoint::ExplicitArrival { valueTo, preservedEase };
     960              :         }
     961           37 :         return;
     962              :     }
     963              : 
     964            1 :     if (info.from < info.to) {
     965            1 :         AutomationPoint point;
     966            1 :         point.value.outValue = valueTo;
     967            1 :         point.value.inValue = AutomationPoint::ExplicitArrival { point.value.outValue, AutomationPoint::Ease::none() };
     968            1 :         point.itemId = info.eid;
     969            1 :         point.generated = true;
     970            1 :         tryAddDynamicPoint(curve, tickPrioMap, info.to, point, info.priority);
     971              :     }
     972              : }
     973              : 
     974          154 : void ScoreAutomationController::fillVoiceCurvesFromBase(UpdateContext& ctx)
     975              : {
     976          157 :     TRACEFUNC;
     977              : 
     978          389 :     for (auto& [key, curve] : ctx.curves) {
     979          235 :         if (!key.voiceIdx().has_value()) {
     980          211 :             continue;
     981              :         }
     982              : 
     983           24 :         const AutomationCurveKey baseKey = key.withoutVoice();
     984           24 :         const auto baseIt = ctx.curves.find(baseKey);
     985           24 :         if (baseIt == ctx.curves.end()) {
     986            0 :             continue;
     987              :         }
     988              : 
     989           24 :         curve.insert(baseIt->second.cbegin(), baseIt->second.cend());
     990           24 :     }
     991          154 : }
     992              : 
     993         1296 : void ScoreAutomationController::collectMeasureRepeats(const Segment* segment, int tickOffset, const StaffRange& range,
     994              :                                                       MeasureRepeats& result)
     995              : {
     996         1296 :     if (!segment->isChordRestType()) {
     997            0 :         return;
     998              :     }
     999              : 
    1000         1299 :     TRACEFUNC;
    1001              : 
    1002         3450 :     for (staff_idx_t staffIdx = range.from; staffIdx <= range.to; ++staffIdx) {
    1003         2154 :         const EngravingItem* item = segment->element(staff2track(staffIdx));
    1004         2154 :         if (!item || !item->isMeasureRepeat()) {
    1005         2066 :             continue;
    1006              :         }
    1007              : 
    1008           88 :         result.emplace_back(toMeasureRepeat(item), tickOffset);
    1009              :     }
    1010         1296 : }
    1011              : 
    1012          154 : void ScoreAutomationController::addMeasureRepeatPoints(UpdateContext& ctx)
    1013              : {
    1014          157 :     TRACEFUNC;
    1015              : 
    1016          154 :     if (ctx.measureRepeats.empty()) {
    1017          125 :         return;
    1018              :     }
    1019              : 
    1020           29 :     std::unordered_map<uint64_t, std::vector<AutomationCurve*> > curvesByStaff;
    1021          104 :     for (auto& [key, curve] : ctx.curves) {
    1022           75 :         const std::optional<muse::ID> staffId = key.staffId();
    1023           75 :         if (staffId) {
    1024           46 :             curvesByStaff[staffId->toUint64()].push_back(&curve);
    1025              :         }
    1026              :     }
    1027              : 
    1028           29 :     if (curvesByStaff.empty()) {
    1029            6 :         return;
    1030              :     }
    1031              : 
    1032              :     struct MeasureRange {
    1033              :         int tickShift = 0;
    1034              :         utick_t srcFrom = 0;
    1035              :         utick_t srcTo = 0;
    1036              :     };
    1037              : 
    1038           46 :     for (const auto& [mr, tickOffset] : ctx.measureRepeats) {
    1039           23 :         const Staff* staff = mr->staff();
    1040           23 :         if (!staff) {
    1041            0 :             continue;
    1042              :         }
    1043              : 
    1044           23 :         const auto staffCurvesIt = curvesByStaff.find(staff->id().toUint64());
    1045           23 :         if (staffCurvesIt == curvesByStaff.end()) {
    1046            0 :             continue;
    1047              :         }
    1048              : 
    1049           23 :         std::vector<MeasureRange> ranges;
    1050           23 :         ranges.reserve(mr->numMeasures());
    1051              : 
    1052           23 :         const Measure* currMeasure = mr->firstMeasureOfGroup();
    1053           47 :         for (int num = 0; currMeasure && num < mr->numMeasures(); ++num, currMeasure = currMeasure->nextMeasure()) {
    1054           24 :             const Measure* referringMeasure = mr->referringMeasure(currMeasure);
    1055           24 :             IF_ASSERT_FAILED(referringMeasure && referringMeasure != currMeasure) {
    1056            0 :                 continue;
    1057              :             }
    1058              : 
    1059           24 :             const int referringMeasureTick = referringMeasure->tick().ticks();
    1060           24 :             ranges.push_back({ currMeasure->tick().ticks() - referringMeasureTick,
    1061           24 :                                referringMeasureTick + tickOffset,
    1062           24 :                                referringMeasure->endTick().ticks() + tickOffset });
    1063              :         }
    1064              : 
    1065           23 :         if (ranges.empty()) {
    1066            0 :             continue;
    1067              :         }
    1068              : 
    1069           68 :         for (AutomationCurve* curve : staffCurvesIt->second) {
    1070           45 :             const MeasureRange& firstRange = ranges.front();
    1071           45 :             auto srcIt = curve->lower_bound(firstRange.srcFrom);
    1072           45 :             const auto curveEnd = curve->end();
    1073           45 :             if (srcIt == curveEnd) {
    1074           44 :                 continue;
    1075              :             }
    1076              : 
    1077            1 :             const auto destHint = curve->lower_bound(firstRange.srcFrom + firstRange.tickShift);
    1078              : 
    1079            3 :             for (const MeasureRange& range : ranges) {
    1080              :                 // Ranges are contiguous for consecutive measures, but a skipped measure (assert
    1081              :                 // failure above) can leave a gap; catch up if srcIt fell behind this range's start
    1082            2 :                 if (srcIt != curveEnd && srcIt->first < range.srcFrom) {
    1083            0 :                     srcIt = curve->lower_bound(range.srcFrom);
    1084              :                 }
    1085              : 
    1086            6 :                 for (; srcIt != curveEnd && srcIt->first < range.srcTo; ++srcIt) {
    1087            4 :                     if (srcIt->second.generated) {
    1088            4 :                         curve->insert(destHint, { srcIt->first + range.tickShift, srcIt->second });
    1089              :                     } else {
    1090            0 :                         AutomationPoint mirroredPoint = srcIt->second;
    1091            0 :                         mirroredPoint.generated = true;
    1092            0 :                         curve->insert(destHint, { srcIt->first + range.tickShift, mirroredPoint });
    1093              :                     }
    1094              :                 }
    1095              :             }
    1096              :         }
    1097           23 :     }
    1098          160 : }
    1099              : 
    1100          912 : void ScoreAutomationController::collectPauses(const Measure* measure, int tickOffset, PausesMap& pauses,
    1101              :                                               PausesMap& noRepeatPauses)
    1102              : {
    1103          912 :     const Fraction& startTick = measure->tick();
    1104          912 :     const int startUtick = startTick.ticks() + tickOffset;
    1105              : 
    1106              :     // Implement section break rest
    1107         1721 :     for (MeasureBase* mb = measure->prev(); mb && mb->endTick() == startTick; mb = mb->prev()) {
    1108          809 :         if (mb->pause()) {
    1109            2 :             pauses[startUtick] = mb->pause();
    1110              :             // Identical value across every pass that plays this raw tick, so last-write-wins is fine
    1111            2 :             noRepeatPauses[startTick.ticks()] = mb->pause();
    1112              :         }
    1113              :     }
    1114              : 
    1115              :     // Add pauses from the end of the previous measure (at measure->tick()):
    1116         2590 :     for (Segment* s = measure->first(); s && s->tick() == startTick; s = s->prev1()) {
    1117         1678 :         if (!s->isBreathType()) {
    1118         1678 :             continue;
    1119              :         }
    1120            0 :         double length = 0.0;
    1121            0 :         for (EngravingItem* e : s->elist()) {
    1122            0 :             if (e && e->isBreath()) {
    1123            0 :                 length = std::max(length, toBreath(e)->pause());
    1124              :             }
    1125              :         }
    1126            0 :         if (!muse::RealIsNull(length)) {
    1127            0 :             pauses[startUtick] = length;
    1128            0 :             noRepeatPauses[startTick.ticks()] = length;
    1129              :         }
    1130              :     }
    1131              : 
    1132         6357 :     for (const Segment& segment : measure->segments()) {
    1133         5445 :         if (!segment.isBreathType()) {
    1134         5442 :             continue;
    1135              :         }
    1136              : 
    1137            3 :         double length = 0.0;
    1138            3 :         Fraction tick = segment.tick();
    1139              :         // find longest pause
    1140           15 :         for (track_idx_t i = 0, n = measure->score()->ntracks(); i < n; ++i) {
    1141           12 :             EngravingItem* e = segment.element(i);
    1142           12 :             if (e && e->isBreath()) {
    1143            3 :                 length = std::max(length, toBreath(e)->pause());
    1144              :             }
    1145              :         }
    1146            3 :         if (!muse::RealIsNull(length)) {
    1147            3 :             pauses[tick.ticks() + tickOffset] = length;
    1148            3 :             noRepeatPauses[tick.ticks()] = length;
    1149              :         }
    1150              :     }
    1151          912 : }
    1152              : 
    1153           77 : void ScoreAutomationController::addTempoTextPoint(const TempoText* tt, int tickOffset, UpdateContext& ctx)
    1154              : {
    1155           77 :     if (!tt->playTempoText()) {
    1156            0 :         return;
    1157              :     }
    1158              : 
    1159           77 :     if (tt->isNormal() && !tt->isRelative() && !ctx.tempoPrimo) {
    1160           33 :         ctx.tempoPrimo = roundTempo(tt->tempo());
    1161              :     }
    1162              : 
    1163           77 :     const utick_t tick = tt->segment()->tick().ticks() + tickOffset;
    1164              : 
    1165           77 :     EID itemId = tt->eid();
    1166           77 :     if (!itemId.isValid()) {
    1167           27 :         itemId = tt->assignNewEID();
    1168              :     }
    1169              : 
    1170           77 :     if (tt->isATempo() && tt->followText()) {
    1171              :         // This will effectively reset the tempo to the previous one when a progressive change was active
    1172           12 :         setTempoPoint(tick, tickOffset, currentTempoAt(*ctx.tempoCurve, tick), ctx, itemId);
    1173           65 :     } else if (tt->isTempoPrimo() && tt->followText()) {
    1174           12 :         setTempoPoint(tick, tickOffset, normalizeTempo(ctx.tempoPrimo.value_or(Constants::DEFAULT_TEMPO)), ctx, itemId);
    1175           53 :     } else if (tt->isRelative()) {
    1176            0 :         const real_t before = currentTempoAt(*ctx.tempoCurve, tick);
    1177            0 :         setTempoPoint(tick, tickOffset, before * tt->relativeValue(), ctx, itemId);
    1178              :     } else {
    1179           53 :         setTempoPoint(tick, tickOffset, normalizeTempo(roundTempo(tt->tempo())), ctx, itemId);
    1180              :     }
    1181              : }
    1182              : 
    1183           12 : void ScoreAutomationController::addFermataStretchPoints(const Fermata* fermata, int tickOffset, double stretch, UpdateContext& ctx)
    1184              : {
    1185           12 :     const Segment* segment = fermata->segment();
    1186           12 :     const utick_t tick = segment->tick().ticks() + tickOffset;
    1187              : 
    1188           12 :     const auto beforeIt = muse::findLessOrEqual(*ctx.tempoCurve, tick);
    1189           12 :     const bool hasBefore = beforeIt != ctx.tempoCurve->cend();
    1190           12 :     const real_t before = hasBefore ? beforeIt->second.value.outValue : DEFAULT_NORMALIZED_TEMPO;
    1191              : 
    1192           12 :     EID itemId = fermata->eid();
    1193           12 :     if (!itemId.isValid()) {
    1194            0 :         itemId = fermata->assignNewEID();
    1195              :     }
    1196              : 
    1197           12 :     setTempoPoint(tick, tickOffset, before / stretch, ctx, itemId);
    1198              : 
    1199           12 :     const Segment* nextActiveSegment = segment->next1();
    1200           12 :     while (nextActiveSegment && !nextActiveSegment->isActive()) {
    1201            0 :         nextActiveSegment = nextActiveSegment->next1();
    1202              :     }
    1203              : 
    1204           12 :     const Fraction tempoEndTick = nextActiveSegment ? nextActiveSegment->tick() : segment->tick() + segment->ticks();
    1205           12 :     const utick_t etick = tempoEndTick.ticks() + tickOffset;
    1206              : 
    1207              :     // Restore to whatever was active before the fermata, so the point belongs to that marking, not the fermata
    1208           12 :     const auto etickIt = ctx.tempoCurve->lower_bound(etick);
    1209           12 :     if (etickIt == ctx.tempoCurve->end() || etickIt->first != etick) {
    1210           12 :         setTempoPoint(etickIt, etick, tickOffset, before, ctx, hasBefore ? beforeIt->second.itemId : std::nullopt);
    1211              :     }
    1212           12 : }
    1213              : 
    1214           15 : void ScoreAutomationController::addGradualTempoChangePoints(const GradualTempoChange* tempoChange, int tickOffset, UpdateContext& ctx)
    1215              : {
    1216           15 :     const utick_t tickFrom = tempoChange->tick().ticks() + tickOffset;
    1217           15 :     const utick_t tickTo = tickFrom + tempoChange->ticks().ticks();
    1218           15 :     const real_t normalizedCurrent = currentTempoAt(*ctx.tempoCurve, tickFrom);
    1219              : 
    1220           15 :     EID itemId = tempoChange->eid();
    1221           15 :     if (!itemId.isValid()) {
    1222            3 :         itemId = tempoChange->assignNewEID();
    1223              :     }
    1224              : 
    1225           15 :     const auto tickFromIt = ctx.tempoCurve->lower_bound(tickFrom);
    1226           15 :     if (tickFromIt == ctx.tempoCurve->end() || tickFromIt->first != tickFrom) {
    1227              :         // Explicit flat arrival, so the ramp is anchored here rather than at whatever
    1228              :         // unrelated marking last happened to be an arrival
    1229           15 :         AutomationPoint startPoint;
    1230           15 :         startPoint.value.outValue = normalizedCurrent;
    1231           15 :         startPoint.value.inValue = AutomationPoint::ExplicitArrival { normalizedCurrent, AutomationPoint::Ease::none() };
    1232           15 :         startPoint.itemId = itemId;
    1233           15 :         startPoint.generated = true;
    1234           15 :         ctx.tempoCurve->emplace_hint(tickFromIt, tickFrom, startPoint);
    1235           15 :         setNoRepeatTempoPoint(tickFrom - tickOffset, startPoint, ctx);
    1236              :     }
    1237              : 
    1238           15 :     const real_t normalizedTarget = std::clamp(normalizedCurrent * tempoChange->tempoChangeFactor(),
    1239           15 :                                                MIN_NORMALIZED_TEMPO, MAX_NORMALIZED_TEMPO);
    1240           15 :     const AutomationPoint::ExplicitArrival arrival { normalizedTarget, changeMethodToEase(tempoChange->easingMethod()) };
    1241              : 
    1242              :     // Don't overwrite a tempo already set at tickTo (e.g. by a tempo marking) - just record the
    1243              :     // arrival so the ramp leading up to it still interpolates correctly
    1244              :     // See https://github.com/musescore/MuseScore/issues/12140
    1245           15 :     const auto tickToIt = ctx.tempoCurve->lower_bound(tickTo);
    1246           15 :     if (tickToIt != ctx.tempoCurve->end() && tickToIt->first == tickTo) {
    1247            2 :         tickToIt->second.value.inValue = arrival;
    1248              : 
    1249            2 :         const auto rawIt = ctx.noRepeatTempoCurve.find(tickTo - tickOffset);
    1250            2 :         if (rawIt != ctx.noRepeatTempoCurve.end()) {
    1251            2 :             rawIt->second.value.inValue = arrival;
    1252              :         }
    1253              : 
    1254            2 :         return;
    1255              :     }
    1256              : 
    1257           13 :     AutomationPoint endPoint;
    1258           13 :     endPoint.value.outValue = normalizedTarget;
    1259           13 :     endPoint.value.inValue = arrival;
    1260           13 :     endPoint.itemId = itemId;
    1261           13 :     endPoint.generated = true;
    1262           13 :     ctx.tempoCurve->emplace_hint(tickToIt, tickTo, endPoint);
    1263           13 :     setNoRepeatTempoPoint(tickTo - tickOffset, endPoint, ctx);
    1264              : }
    1265              : 
    1266            6 : void ScoreAutomationController::addVoltaTempoResetPoint(const Volta* volta, UpdateContext& ctx)
    1267              : {
    1268            6 :     const Measure* startMeasure = volta->startMeasure();
    1269            6 :     const Measure* endMeasure = volta->endMeasure();
    1270            6 :     if (!startMeasure || !endMeasure || !endMeasure->repeatEnd()) {
    1271            1 :         return;
    1272              :     }
    1273              : 
    1274              :     // Changes noRepeatTempoCurve only, so later content (e.g. a seconda volta) sees the pre-volta tempo, not
    1275              :     // the volta's own changes; findLessOrEqual so an earlier volta's own reset at this tick counts
    1276            5 :     const auto beforeIt = muse::findLessOrEqual(ctx.noRepeatTempoCurve, startMeasure->tick().ticks());
    1277            5 :     const bool hasBefore = beforeIt != ctx.noRepeatTempoCurve.end();
    1278            5 :     const real_t tempoBeforeVolta = hasBefore ? beforeIt->second.value.outValue : DEFAULT_NORMALIZED_TEMPO;
    1279              : 
    1280            5 :     AutomationPoint point;
    1281            5 :     point.value.outValue = tempoBeforeVolta;
    1282              :     // Recovers to whatever was active before the volta, so the point belongs to that marking, not the volta
    1283            5 :     point.itemId = hasBefore ? beforeIt->second.itemId : std::nullopt;
    1284            5 :     point.generated = true;
    1285              : 
    1286              :     // Don't overwrite an explicit tempo marking already sitting at this exact tick
    1287              :     // (e.g. the seconda volta's own TempoText, written in Step 1 before this Step 2 pass runs)
    1288            5 :     ctx.noRepeatTempoCurve.try_emplace(endMeasure->endTick().ticks(), point);
    1289              : }
    1290              : 
    1291          154 : void ScoreAutomationController::resolvePendingTempoResets(UpdateContext& ctx)
    1292              : {
    1293          258 :     for (const auto& [utick, rawTick] : ctx.pendingTempoResets) {
    1294          104 :         const auto utickIt = ctx.tempoCurve->lower_bound(utick);
    1295          104 :         if (utickIt != ctx.tempoCurve->end() && utickIt->first == utick) {
    1296          102 :             continue; // this pass's own marking already sits exactly here - don't overwrite it
    1297              :         }
    1298              : 
    1299           97 :         const auto rawIt = muse::findLessOrEqual(ctx.noRepeatTempoCurve, rawTick);
    1300           97 :         const real_t tempo = rawIt != ctx.noRepeatTempoCurve.end() ? rawIt->second.value.outValue
    1301           97 :                              : DEFAULT_NORMALIZED_TEMPO;
    1302              : 
    1303           97 :         const real_t currentTempo = currentTempoAt(*ctx.tempoCurve, utick);
    1304           97 :         if (RealIsEqual(tempo, currentTempo)) {
    1305           95 :             continue; // no actual change - don't emit a redundant point
    1306              :         }
    1307              : 
    1308            2 :         setTempoPoint(utickIt, utick, utick - rawTick, tempo, ctx);
    1309              :     }
    1310          154 : }
    1311              : 
    1312            1 : void ScoreAutomationController::fixAnacrusisTempo(UpdateContext& ctx)
    1313              : {
    1314              :     // An anacrusis measure with no tempo marking of its own should start at whatever tempo
    1315              :     // the next (first full) measure marks, rather than whatever tempo preceded it
    1316            2 :     for (const AnacrusisMeasureInfo& info : ctx.anacrusisMeasures) {
    1317            1 :         const utick_t measureTick = info.measure->tick().ticks() + info.tickOffset;
    1318            1 :         const auto measureIt = ctx.tempoCurve->lower_bound(measureTick);
    1319            1 :         if (measureIt != ctx.tempoCurve->end() && measureIt->first == measureTick) {
    1320            0 :             continue;
    1321              :         }
    1322              : 
    1323            1 :         if (!info.nextMeasureUTick) {
    1324            0 :             continue; // anacrusis is the last measure of its repeat pass - no "next" within this pass
    1325              :         }
    1326              : 
    1327            1 :         const auto nextIt = ctx.tempoCurve->find(*info.nextMeasureUTick);
    1328            1 :         if (nextIt != ctx.tempoCurve->end()) {
    1329            0 :             setTempoPoint(measureIt, measureTick, info.tickOffset, nextIt->second.value.outValue, ctx);
    1330              :         }
    1331              :     }
    1332            1 : }
    1333              : 
    1334          315 : bool ScoreAutomationController::tryAddDynamicPoint(AutomationCurve& curve, std::map<utick_t, int>& tickPrioMap, utick_t tick,
    1335              :                                                    const AutomationPoint& point, int priority)
    1336              : {
    1337              :     //! See: https://github.com/musescore/MuseScore/issues/23355
    1338          315 :     const auto prioIt = tickPrioMap.lower_bound(tick);
    1339          315 :     const bool hasPrio = prioIt != tickPrioMap.end() && prioIt->first == tick;
    1340              : 
    1341          315 :     if (hasPrio) {
    1342           35 :         if (priority <= prioIt->second) {
    1343           35 :             return false;
    1344              :         }
    1345              : 
    1346            0 :         curve[tick] = point;
    1347            0 :         prioIt->second = priority;
    1348            0 :         return true;
    1349              :     }
    1350              : 
    1351          280 :     const auto curveIt = curve.lower_bound(tick);
    1352          280 :     if (curveIt != curve.end() && curveIt->first == tick) {
    1353            0 :         return false; // point from a prior update pass — don't overwrite
    1354              :     }
    1355              : 
    1356          280 :     curve.emplace_hint(curveIt, tick, point);
    1357          280 :     tickPrioMap.emplace_hint(prioIt, tick, priority);
    1358              : 
    1359          280 :     return true;
    1360              : }
    1361              : 
    1362          228 : void ScoreAutomationController::addDynamicPoint(AutomationCurve& curve, std::map<utick_t, int>& tickPrioMap, utick_t tick,
    1363              :                                                 const AutomationPoint& point, int priority)
    1364              : {
    1365              :     // If a point with the same priority already exists at this tick, merge them:
    1366              :     // keep the existing arrival value and use this point's departure value
    1367          228 :     const auto prioIt = tickPrioMap.find(tick);
    1368          228 :     if (prioIt != tickPrioMap.end() && prioIt->second == priority) {
    1369            1 :         AutomationPoint& existing = curve[tick];
    1370            1 :         const AutomationPoint::InValue arrivalValue = existing.value.inValue;
    1371            1 :         existing = point;
    1372            1 :         existing.value.inValue = arrivalValue;
    1373            1 :         return;
    1374              :     }
    1375              : 
    1376          227 :     tryAddDynamicPoint(curve, tickPrioMap, tick, point, priority);
    1377              : }
    1378              : 
    1379          131 : void ScoreAutomationController::setNoRepeatTempoPoint(utick_t rawTick, const AutomationPoint& point, UpdateContext& ctx)
    1380              : {
    1381          131 :     const auto it = ctx.noRepeatTempoCurve.lower_bound(rawTick);
    1382          131 :     const bool exists = it != ctx.noRepeatTempoCurve.end() && it->first == rawTick;
    1383          131 :     if (exists && !it->second.generated) {
    1384            0 :         return; // an authored point at this raw tick wins over the generated one
    1385              :     }
    1386              : 
    1387          131 :     if (exists) {
    1388           46 :         it->second = point;
    1389              :     } else {
    1390           85 :         ctx.noRepeatTempoCurve.emplace_hint(it, rawTick, point);
    1391              :     }
    1392              : }
    1393              : 
    1394           89 : void ScoreAutomationController::setTempoPoint(utick_t tick, int tickOffset, real_t normalizedBps, UpdateContext& ctx,
    1395              :                                               std::optional<EID> itemId)
    1396              : {
    1397           89 :     const auto it = ctx.tempoCurve->lower_bound(tick);
    1398           89 :     const bool exists = it != ctx.tempoCurve->end() && it->first == tick;
    1399           89 :     if (exists && !it->second.generated) {
    1400           83 :         return; // an authored point at this tick wins over the generated one
    1401              :     }
    1402              : 
    1403           89 :     if (!exists) {
    1404           83 :         setTempoPoint(it, tick, tickOffset, normalizedBps, ctx, itemId);
    1405           83 :         return;
    1406              :     }
    1407              : 
    1408            6 :     const AutomationPoint point = makeTempoPoint(normalizedBps, itemId);
    1409            6 :     it->second = point;
    1410            6 :     setNoRepeatTempoPoint(tick - tickOffset, point, ctx);
    1411              : }
    1412              : 
    1413           97 : void ScoreAutomationController::setTempoPoint(AutomationCurve::iterator hint, utick_t tick, int tickOffset, real_t normalizedBps,
    1414              :                                               UpdateContext& ctx, std::optional<EID> itemId)
    1415              : {
    1416           97 :     const AutomationPoint point = makeTempoPoint(normalizedBps, itemId);
    1417           97 :     ctx.tempoCurve->emplace_hint(hint, tick, point);
    1418           97 :     setNoRepeatTempoPoint(tick - tickOffset, point, ctx);
    1419           97 : }
    1420              : 
    1421          263 : std::vector<AutomationCurveKey> ScoreAutomationController::resolveKeys(const EngravingItem* item, AutomationType type,
    1422              :                                                                        const StaffRange& range)
    1423              : {
    1424          263 :     const Score* score = item->score();
    1425          263 :     IF_ASSERT_FAILED(score) {
    1426            0 :         return {};
    1427              :     }
    1428              : 
    1429          263 :     std::vector<AutomationCurveKey> result;
    1430              : 
    1431          276 :     auto tryAddStaffKey = [type, score, &range, &result](staff_idx_t staffIdx, std::optional<size_t> voiceIdx = std::nullopt) {
    1432          276 :         if (!range.contains(staffIdx)) {
    1433            0 :             return;
    1434              :         }
    1435              : 
    1436          276 :         const Staff* staff = score->staff(staffIdx);
    1437          276 :         IF_ASSERT_FAILED(staff) {
    1438            0 :             return;
    1439              :         }
    1440              : 
    1441          276 :         if (!staff->isPrimaryStaff()) {
    1442           10 :             return; // ignore linked staves
    1443              :         }
    1444              : 
    1445          266 :         result.push_back(AutomationCurveKey::staff(type, staff->id(), voiceIdx));
    1446          263 :     };
    1447              : 
    1448          263 :     const VoiceAssignment voiceAssignment = item->getProperty(Pid::VOICE_ASSIGNMENT).value<VoiceAssignment>();
    1449          263 :     switch (voiceAssignment) {
    1450          238 :     case VoiceAssignment::ALL_VOICE_IN_INSTRUMENT: {
    1451          238 :         const Part* part = item->part();
    1452          238 :         IF_ASSERT_FAILED(part) {
    1453            0 :             return result;
    1454              :         }
    1455              : 
    1456          238 :         const TrackRange trackRange = part->trackRange();
    1457          238 :         const staff_idx_t startStaffIdx = track2staff(trackRange.startTrack);
    1458          238 :         const staff_idx_t endStaffIdx = track2staff(trackRange.endTrack);
    1459          238 :         result.reserve(endStaffIdx - startStaffIdx);
    1460              : 
    1461          489 :         for (staff_idx_t staffIdx = startStaffIdx; staffIdx < endStaffIdx; ++staffIdx) {
    1462          251 :             tryAddStaffKey(staffIdx);
    1463              :         }
    1464          238 :     } break;
    1465           24 :     case VoiceAssignment::CURRENT_VOICE_ONLY:
    1466           24 :         tryAddStaffKey(item->staffIdx(), item->voice());
    1467           24 :         break;
    1468            1 :     case VoiceAssignment::ALL_VOICE_IN_STAFF:
    1469            1 :         tryAddStaffKey(item->staffIdx());
    1470            1 :         break;
    1471              :     }
    1472              : 
    1473          263 :     return result;
    1474          263 : }
    1475              : 
    1476           27 : void ScoreAutomationController::mirrorEditsToRepeats(const AutomationCurveKey& key, AutomationPointEdits& edits)
    1477              : {
    1478           28 :     TRACEFUNC;
    1479              : 
    1480           27 :     const RepeatList& repeatList = m_score->expandedRepeatList();
    1481           27 :     if (repeatList.size() <= 1) {
    1482            0 :         return;
    1483              :     }
    1484              : 
    1485              :     // Measure repeats are staff-specific; global/instrument-scoped points (e.g. Tempo, Volume, Pan)
    1486              :     // aren't tied to any staff, so they're mirrored to regular repeats only, not measure repeats
    1487           27 :     std::optional<StaffRange> staffRange;
    1488           27 :     if (const std::optional<muse::ID> staffId = key.staffId()) {
    1489           26 :         const Staff* staff = m_score->staffById(*staffId);
    1490           26 :         IF_ASSERT_FAILED(staff) {
    1491            0 :             return;
    1492              :         }
    1493              : 
    1494           26 :         staffRange.emplace(m_score, staff->idx(), staff->idx());
    1495              :     }
    1496              : 
    1497              :     // Bounded by the original size, since mirrored edits are appended to the same vector below
    1498           27 :     const size_t originalEditCount = edits.size();
    1499           75 :     for (size_t i = 0; i < originalEditCount; ++i) {
    1500              :         // Copied by value: push_back below may reallocate edits
    1501           48 :         AutomationPointEdit localEdit = edits[i];
    1502           48 :         const auto sourceIt = repeatList.findRepeatSegmentFromUTick(localEdit.tick);
    1503           48 :         IF_ASSERT_FAILED(sourceIt != repeatList.cend()) {
    1504            0 :             continue;
    1505              :         }
    1506              : 
    1507           48 :         const RepeatSegment* sourceSeg = *sourceIt;
    1508           48 :         const int sourceTickOffset = sourceSeg->utick - sourceSeg->tick;
    1509              : 
    1510              :         // Re-express the edit in tick coordinates local to sourceSeg
    1511           48 :         localEdit.tick -= sourceTickOffset;
    1512           48 :         if (auto* movePoint = std::get_if<AutomationPointEdit::MovePoint>(&localEdit.change)) {
    1513            0 :             movePoint->from -= sourceTickOffset;
    1514              :         }
    1515              : 
    1516          144 :         for (const RepeatSegment* targetSeg : repeatList) {
    1517           96 :             if (targetSeg != sourceSeg) {
    1518           48 :                 const MirrorRange targetRange { targetSeg->tick, targetSeg->endTick(), targetSeg->utick - targetSeg->tick };
    1519           48 :                 mirrorPointIfInRange(localEdit, targetRange, edits);
    1520              :             }
    1521           96 :             if (staffRange) {
    1522           94 :                 mirrorToMeasureRepeats(targetSeg, *staffRange, localEdit, edits);
    1523              :             }
    1524              :         }
    1525              :     }
    1526           27 : }
    1527              : 
    1528           95 : void ScoreAutomationController::mirrorPointIfInRange(const AutomationPointEdit& localEdit, const MirrorRange& range,
    1529              :                                                      AutomationPointEdits& allEdits)
    1530              : {
    1531           95 :     const int localTick = static_cast<int>(localEdit.tick);
    1532           95 :     if (localTick < range.from || localTick >= range.toExclusive) {
    1533           92 :         return;
    1534              :     }
    1535              : 
    1536            3 :     const utick_t mirroredTick = static_cast<utick_t>(localTick + range.tickOffset);
    1537              : 
    1538            3 :     if (std::holds_alternative<AutomationPointEdit::ErasePoint>(localEdit.change)) {
    1539            0 :         allEdits.push_back({ mirroredTick, AutomationPointEdit::ErasePoint {} });
    1540            0 :         return;
    1541              :     }
    1542              : 
    1543            3 :     const auto* movePoint = std::get_if<AutomationPointEdit::MovePoint>(&localEdit.change);
    1544              : 
    1545              :     // Mirrored points are derived from the user's edit, not edited directly, so they are marked generated
    1546            3 :     AutomationPoint mirroredPoint = movePoint ? movePoint->point : std::get<AutomationPointEdit::SetPoint>(localEdit.change).point;
    1547            3 :     mirroredPoint.generated = true;
    1548              : 
    1549            3 :     if (movePoint && movePoint->from >= range.from && movePoint->from < range.toExclusive) {
    1550            0 :         const utick_t mirroredMoveFrom = static_cast<utick_t>(movePoint->from + range.tickOffset);
    1551            0 :         allEdits.push_back({ mirroredTick, AutomationPointEdit::MovePoint { mirroredPoint, mirroredMoveFrom } });
    1552            0 :     } else {
    1553            3 :         allEdits.push_back({ mirroredTick, AutomationPointEdit::SetPoint { mirroredPoint } });
    1554              :     }
    1555              : }
    1556              : 
    1557           94 : void ScoreAutomationController::mirrorToMeasureRepeats(const RepeatSegment* targetSeg, const StaffRange& range,
    1558              :                                                        const AutomationPointEdit& localEdit, AutomationPointEdits& allEdits)
    1559              : {
    1560           94 :     const int tickOffset = targetSeg->utick - targetSeg->tick;
    1561              : 
    1562              :     // A MeasureRepeat can only ever sit on the measure's first ChordRest segment
    1563           94 :     MeasureRepeats measureRepeats;
    1564          470 :     for (const Measure* measure : targetSeg->measureList()) {
    1565          376 :         if (const Segment* firstChordRestSegment = measure->first(SegmentType::ChordRest)) {
    1566          376 :             collectMeasureRepeats(firstChordRestSegment, tickOffset, range, measureRepeats);
    1567              :         }
    1568              :     }
    1569              : 
    1570          141 :     for (const auto& [mr, mrTickOffset] : measureRepeats) {
    1571           47 :         const Measure* currMeasure = mr->firstMeasureOfGroup();
    1572           94 :         for (int num = 0; currMeasure && num < mr->numMeasures(); ++num, currMeasure = currMeasure->nextMeasure()) {
    1573           47 :             const Measure* referringMeasure = mr->referringMeasure(currMeasure);
    1574           47 :             IF_ASSERT_FAILED(referringMeasure && referringMeasure != currMeasure) {
    1575            0 :                 continue;
    1576              :             }
    1577              : 
    1578           47 :             const int measureFrom = referringMeasure->tick().ticks();
    1579           47 :             const int measureToExclusive = referringMeasure->endTick().ticks();
    1580           47 :             const int tickShift = currMeasure->tick().ticks() - measureFrom;
    1581              : 
    1582           47 :             const MirrorRange measureRange { measureFrom, measureToExclusive, tickShift + mrTickOffset };
    1583           47 :             mirrorPointIfInRange(localEdit, measureRange, allEdits);
    1584              :         }
    1585              :     }
    1586           94 : }
        

Generated by: LCOV version 2.0-1