Inconsistent spacing of same-duration notes

• Jan 15, 2020 - 19:38
Reported version
3.3
Type
Functional
Frequency
Few
Severity
S3 - Major
Reproducibility
Always
Status
active
Regression
No
Workaround
Yes
Project

If a measure contains longer note values (quarter- and half-notes for example), then shorter notes (8ths, 16ths) in the same measure are spaced more tightly than when the measure contains only shorter note values. This causes same-duration notes to be spaced differently/inconsistently in different measures within the same system.

See how the 16ths are spaced differently in the two measures.
pic

All same-duration notes should have the same horizontal spacing within a system (while obviously taking into account extra space for accidentals and other elements)

The workaround is to carefully manually adjust layout stretch.

This issue's effect is most apparent in situations where tight spacing is needed.

This is probably related to the issue where accidentals and other elements causes unnecessary space to be added to a measure...
https://musescore.org/en/node/289446

Attachment Size
16ths_spacing.png 7.26 KB
Inconsistent_note_spacing.mscz 8.74 KB

Comments

It's related in that both involve the spacing algorithm, but the issues are rather different really. I'm a bit surprised to see such a big difference in this example, normally that's something the spacing algorithm does know it should be shooting for, but it doesn't really do this directly, so while it does more or less happen in many cases, it's not guaranteed by any means.

In reply to by Marc Sabatella

This issue occurs all the time. Its only in tight spacing that its becomes really apparent (I actually noticed this while writing strings of 16ths.). In wider spacing, the effect is much more subtle. But sometimes its still there to such a degree that you can sense something feels off, but can't put your finger on exactly why.

In this case, the difference in spacing between a pair of 8ths is the width of a stem. Yes, I have OCD :)
pic

Attachment Size
wider_spacing.PNG 7 KB

Yep, that's why I said it "more or less" happens :-). We aren't literally ensuring consistent spacing, but we're doing calculations that in many real world situations will result in it coming close enough that most people wouldn't notice.

No doubt, it would be good for someone to go into and rewrite these algorithms based on these examples. It would be a fairly big job, but not impossible.

It's not out of the question. To a large extent, programming is programming, you can get the gist of an algorithm without knowing the exact syntax to code it yourself. But, it's not really one standalone algorithm that does everything in one place in the code and moves on, it's a bunch of moving parts that come together. And you'd have to understand quite a bit about our data structures to understand the algorithm itself. Which is to say, even an experienced C++ programmer isn't likely to just be able to step in and figure it out easily.

Anyhow, that said, I can give a brief overview, either for you or for anyone (including me)( who might eventually work on this code somewhere down the line):

The layout happens in two main passes, both of which occur within Score::collectSystem():

https://github.com/musescore/MuseScore/blob/1279610c0be44c6abb0356b5eb7…

The first pass involves figuring out an appropriate basic "min" width for each measure so we can decide how many measures fit on a system, and the second pass then stretches those measures out to fill the system. The word "stretch" is used throughout the process, so don't be confused by references to stretching in the first pass.

The first pass begins with going looping through the measures, calling Measure::computeMinWidth() to compute their minimum widths:

https://github.com/musescore/MuseScore/blob/1279610c0be44c6abb0356b5eb7…

Within that function, we loop through the "segments" (time slices) of the measure computing the bare minimum width for each, via the calls to minHorizontalDistance(), which figures the amount of space needed by this segment, and we set the initial segment widths accordingly. This is actually mostly just about avoiding collisions, not so much about looking at durations of notes.

Still within that first pass in collectSystem(), right after calling computeMinWidth() we call Measure::stretchMeasure(), which I think is where we figure out relative spacing in terms of the actual durations of notes.

https://github.com/musescore/MuseScore/blob/1279610c0be44c6abb0356b5eb7…

This is where we space the segments within the measure. That's the part of the code that actually deals the most how much space to give sixteenth notes versus a half note - you'll see calculations involving logarithms, etc. Greek to me, but this is doubtless something that we'd need to look at.

Once we decide we have enough measures to fill the system, we need to stretch those measures out to the margin. That happens mostly right there in collectSystem(), after the comment "stretch incomplete row". It's a matter of figuring out how much weight to give each measure (calling layoutWeight() which cares only about total duration of the measure, so normally all measures get the same weight), then deciding how much space is leftover ("rest"), then pro-rating the that space among the measures according to their weight.

I think the issue here is probably mostly about the first pass, because it yields inconsistent spacing even if you effectively disable the second pass (a debug build allows you to do this by turning on Debug / No Horizontal Stretch, but you can simulate this simply adding a horizontal frame and sizing it take as much room as possible on the system). The issue with additional symbols throwing things off is more a second pass issue, we need to adjust the amount of "rest" given to each measure so account for the additional space they are already requiring for accidentals etc. I think the solution is to calculate and remember that value during computeMinWidth(), then apply it during the second pass calculation when we're giving each measure its weight.

I only barely have my brain wrapped around this well enough to document. Realistically, I don't expect anyone else to care enough about this enough to invest the effort it would take to deal with it (and risk breaking things in the process!), but by the time I might get around to it (need to do things to make a living in the mean time!), I'll probably forget, so these notes are as much for my benefit as yours or anyone else's.

In reply to by Marc Sabatella

Thanks for the detailed overview Marc! Yes, I expected it to be much more complicated than what one might think (its often like that in programming, right?). I'm going to start looking at the code and see where it leads me. If I'm very lucky, I'll be able to understand it enough to be able to point more precisely to where the spacing issues originate and offer possible solutions, and thereby making it easier for someone else to actually fix it.

I understand why many might not be bothered enough to invest a lot of effort into fixing this. After all, these are not score-breaking issues. The music turns out readable. The results are good enough for practical purposes. But on the other hand, if one wants to create really polished scores, these issues increase your work tenfold. These kind of problems are what will prevent MuseScore from ever really being taken seriously outside the hobbyist/student-circle. Hopefully in time the kinks can be ironed out.

On further reflection, something really interesting occurred to me, and checking it out within MuseScore even with no code changes at all, I have a suggestion for how you can get results you might find an improvement (I haven't done extensive testing).

The idea is this: when we calculate the initial minimum width, it's supposed literally just enough to avoid collisions (and honor the minimum note distance style setting). So, how do we manage to get such inconsistent spacing here even before the second pass? Answer: it's because during that first pass when computing the first pass, we multiply the minimum width of the measure by the measure spacing style setting (and until today I never really thought about what that setting did exactly). So, measures that were already wide get wider still, by a larger amount than narrow measures do. I think that's at least part of why the measure with more notes has wider spacing - it got stretched more than the narrower measure during the first pass.

So, armed with this insight, I thought, what would happen if I set "measure spacing" to 1.0 - meaning, no stretch at all is applied during the first pass. The would mean measures are narrower by default, but you can always add your own line breaks (and you probably do). You can also increase minimum note distance to compensate, to give you measures that are "roughly" the same width by default as before. Things will still be stretched in the second pass in ways that aren't really ideal, but at least it's not compounding the problems of the first pass.

It does give rather better spacing, I think, in limited testing. Give it a shot - measure spacing 1.0, maybe increase minimum note distance to 0.5 sp.

Playing with it a little more, it's really fascinating. Barnie, if you can at all manage to build yourself a copy of MuseScore - so the Debug menu becomes available - I think you'll really get a lot out of it. Even if you never touch a line of code.

The pictures below illustrate aspects of the algorithm I describe far better than words.

First, here is what I get for a an example inspired by the ones you have been using, if I set measure spacing = 1, note distance = 0, and then set Debug / No Horizontal Stretch. This basically shows that the result of computeMinWidth() is as I said: notes are spaced so as to not collide, and no additional space is added (well, except between accidentals notes, that is hardcoded I think) - not even to give half notes extra room compared to sixteenths:

note-spacing-0.png

Pretty crazy, right? Here is what you get if you set minimum note distance to 0.5sp, btw - now there is breathing room, but still, half notes get no more space than sixteenths, and all notes have exactly equal spacing:

note-spacing-1.png

OK, back to note distance = 0. But this time, I have applied stretch to the first measure - three presses of "}", for a stretch value of 1.3 (equivalent to setting measure spacing to 1.3, which is slightly more than the default of 1.2):

note-spacing-2.png

This is mimicing what normally happens in the first pass where we stretch the measure by the measure spacing value. You can see that MuseScore is giving the half note more breathing space but doesn't bother giving any to the sixteenths, because this is where it is doing the duration-based spacing. But as soon as I increase stretch another notch, then the sixteenths start getting space. By the time I've increase stretch to 2.0, it's looking like a normal measure - it's more or less how much the second pass would likely have stretched the measure, depending on what else is going on in that system:

note-spacing-3.png

To me this shows the "Greek" stretch algorithm is doing something pretty cool with those logarithm functions even if I don't understand it :-)

Finally, I reset the stretch for that measure but turn off the "No Horizontal Stretch" option. Now finally the second pass is going to do its thing. So now all the measures are stretched by whatever amount is needed to fill the page width. This is finally a "real world" example of what you can actually achieve today by using the "measure spacing = 1, add your own line breaks" approach:

note-spacing-4.png

In this particular case the spacing seems almost perfect between the first two measures. Perhaps we just got lucky there, but there is still too much space in the third. We really need to give that third measure a lot less of the remaining space. Probably, by an amount exactly equal to the space actually taken up by the accidentals.

Bottom line: the first pass is doing things right in terms of getting an absolute minimum size for the measure, and the algorithm to stretch any given measure does a good job of stretching it, and since no stretching happens in the first pass when using these settings, it tells me that any unevenness in the spacing really is do to figuring out how much to stretch each measure.

So, two changes would need to happen:

1) during the first pass, we can't uniformly stretch each measure by the same measure spacing value - this is what is resulting in uneven spacing right out of the box. Here is the result with that example if I disable the second pass again but return to default measure spacing:

note-spacing-5.png

Not good, as has been observed in this issue and related ones.

2) During the second pass, we similarly can't evenly divide the remaining space on the system between the measures. We the measures that have accidentals and so forth need to get corresponding less, but also we probably need to deal with pro-rating the stretch even between measures without extra symbols, like the first two in my example.

Thinking ahead towards possible implementation, changing anything about how the first pass works means some scores will render more noticeably differently than before - different minimum widths means possibly different number of measures per system. To avoid problems, I'd propose a new checkbox called something like "Engraver spacing", would default to off (at least for existing scores) and thus give you the old algorithm, but checking the box will give you the new algorithm. And the only differences between algorithm would be, not using a constant "measure spacing" for the stretch in the first pass, and accounting for the accidentals and other symbols in the second.

It's doable, for sure.

Wow, You're way ahead of me with this! Fascinating indeed! I was especially intrigued by the part where adding stretch increased the halfnote's spacing, but ignored the sixteenths until the stretch got big enough. So I ran the latest nightly with the --layout-debug option (which apparently disables the stretching done in the second pass) and played around. Here's what I've got...

Pass1_stretch_mimicking.PNG

If manually adding stretch mimics the stretching done in pass one, then this seems to demonstrate that the bug is somewhere within Measure::stretchMeasure() ... quite possibly hidden within those "Greek" algorithms that calculates the duration-based spacing.

So unless I'm misunderstading something, the fix for this would be one of two things...

1) Compensate for the apparent bug in Measure::stretchMeasure() by somehow applying different amounts of stretch per measure based on their note-duration content (this will probably be just as complicated as those "Greek" algorithms).

2) Fix Measure::stretchMeasure() itself. This would also ensure that, when manually adjusting stretch with "{" and "}", the results would be more consistent and predictable.

It also seems that your workaround of spacing=1.0 together with manually added linebreaks does indeed give 99.9999% perfect spacing (when no accidentals/etc are involved). Thanks!!

As a side note... switching between the old and new algorithm with an "Engraver spacing" checkbox so as to not mess up older scores... why not place all such "backwards-compatibility" settings together on a single page in style settings? And when a score is loaded, enable/disable those settings based on which version of MuseScore that score was made with? That way, older scores can be rendered as intended, while layout-bugfixes can be released without repercussions.

Attachment Size
Pass1_stretch_mimicking.PNG 38.98 KB

I realize there is a lot of info here, but I think you are misunderstanding the role of stretchMeasure(). It doesn't decide how much to stretch the measure; it is told (passed in as a parameter) the target width. It's just is merely to space the notes within the measure given that target width. So I think the experiment demonstrates exactly the opposite conclusion: stretchMeasure(0 does a marvelous job of spacing notes within a measure given a particular width. After all, if you look at any single measure in any of these examples in isolation (not comparing to others on the same line), each looks about as one would expect given the width (although of course, some of those widths are unreasonable)

So as far as i can tell, stretchMeasure() itself is fine, it's the width parameter we are passing in that is the issue. We call stretchMeasure(0 during both passes - during the first to apply the measure spacing setting, and during the second to divide up the remaining space. And in both cases, the target width being passed in causing problems.

To a rough approximation, the issue here in this thread is caused by the bad sizes during pass 1. Uniformly stretching everything by a factor of 1.2 means that measures with more notes (and hence are wider to begin with) get stretched more, and this often results in their sixteenths being more widely spaced. More particularly, measures without long notes to "soak up" the stretch end up passing it all on to their sixteenths. A value of 1.0 gives consistent spacing - but too consistent. So instead, we should calculate specific initial stretch values for each measure based on their content. Basically, we should stretch each measure enough to give extra space to the half notes but not so much that it affects sixteenths (for example).

The problem with accidentals and other symbols is caused by bad sizes during pass 2. Currently, we give each measure the same amount (assuming same time signatures). But instead, we need to reduce each measure's share by some amount corresponding to how much extra space is taken up by the accidentals etc.

I'm not really sure how to calculate these values, but I'm pretty confident that this would be an effective approach.

In reply to by Marc Sabatella

Thanks Marc, I think I understand better now :) So am I right that next on the todo-list is to figure out a formula to calculate the exact correct value to give to stretchMeasure() during the first pass?

I think I can help with that. If I manually correct a number of measures' spacing, and then look at the exact stretch-values per measure in the measure properties, I might be able to come up with a formula that gives those stretch-values based on the contents of the measure.

Am I thinking in the right direction?

PS - is the number of measures to be included in a system decided before or after this stretch phase in the first pass?

Yes, I think coming up with a better initial width for the first pass is a good next step. Really, it could be either pass we tackle first, but I think the first is easier, and more fundamental - you can't get good results in the second pass if they are bad in the first.

We already have the function computeMinWidth() that I mentioned, to calculate a base minimum width for the measure based on collisions only. Could be a matter of adjusting it to take durations into account also, using something like the diagram on Gould p. 30 as a guide. A complication we have glossed over completely are multiple voices and multiple staves, of course. That gets in the way of simply changing the width of each segment to account for duration.

In reply to by Marc Sabatella

Multiple voices and staves actually have been in the back of my mind from very early on. But I haven't thought yet how to handle them.

Also, a whole bunch of further first-pass-complications just occurred to me...

Pass1_stretch_complications.png

It seems that, in order to correctly calculate the stretch value for the first pass, we'll need the following info for each measure...
1) the total minimum width
2) the minimum width needed for non-notes, ledger lines and the "extra" space taken by 2nd intervals.

Perhaps one can collect this info within computeMinWidth() ?

I think there is a possibility that, with this info, I can figure out a way of calculating the correct stretch value without computeMinWidth() needing to consider note durations. Not sure if this would be more complicated though...

Attachment Size
Pass1_stretch_complications.png 51.92 KB

@Marc Sabatella I'm think I'm making progress on a possible way to calculate the correct stretch value to give to stretchMeasure() during the first pass, but I need some clarity on some things. Can you help please? Or point me to the right documentation?

How is a measure's duration represented in the code? For example, is a 4/4 measure given a duration "value" of 400 in the code and a 3/4 measure a value of 300?

(More questions will probably follow as I go along...)

Glad to hear your working on this! I'm busy with other things and wouldn't be able to look at this at all for some time.

Durations in MuseScore are represented as Fractions. A 4/4 measure has duration of Fraction(4, 4), you get that via Measure::ticks(). The function is called that for historical reasons - we used to use MIDI ticks as the unit rather than fractions. So a quarter note was 480 ticks, and a 4/4/ measure came out to 1920 ticks. Now, you get the old-style tick count via ticks().ticks() (as in, really tell me the duration in ticks, damnit).

Currently the layout code - well, the part of algorithm I have been describing, anyhow - does not worry about measure duration directly. Instead they call Measure::layoutWeight(). This defaults to returning ticks().ticks(), but performs some additional calculations for multimeasure rests in an effort to get them to have a reasonable size that is only indirectly proportional to their duration. We've tweaked that code a bit over the past year, it could still stand further refinement I'm sure.

Thanks @Marc Sabatella , I think that gives me one piece of the puzzle I need.

I've been trying to find a way to calculate the correct stretch value for each measure (stretch value as in 1.2 or 2.0 or 5.0, etc...). But I've noticed that the same stretch applied to different measures resulted in their widths increasing by different factors.

To explain : the same stretch-value is applied to two measures. One measure ends up double its minimum width and the other triple its minimum width. Thus, a stretch value of 2.0 does not guarantee that a measure's width would increase by a factor of two (or any other constant factor). The factor of width-increase depends on the specific note-values in the measure.

This leads me to my questions for today:

1) You said the parameter passed to stretchMeasure() is the target width of the measure. Just to make sure I don't misunderstand... let's say a measure's minimum width is 200 units and stretchMeasure() is given a target width of 400 units. Does this then guarantee that the measure's width would be doubled?

2) Assuming that the answer to 1) is "yes", could Spacing (the style-setting) be used as a simple multiplier for a measure's minimum width?

(If the answer to 1) is "no" then I might still have a workaround to what I'm trying to achieve...)

The stretch value is applied before the "rest" of the system is divvied up, so that could be what you are seeing. Would need to see the context to understand.

Regarding you question, not sure I understand, doubled from what? Doubled from its originally calculated width? As in the width you saw when you turn off on "No horizontal stretch"? Well, yes, if the originally calculated width was 200, and we're stretch it to 400, that is factor of 2. But keep in mind, the first pass width is only the minimum width if the spacing style setting is 1.0. Otherwise it's already been stretched. And remember, that user stretch value is applied during that first pass. So I'm not really sure what you are comparing here.

Thanks Marc. However further experimentation rendered my questions moot.

Here's what led to my questions... I understood that the stretch value from the spacing style setting is intended to be the factor by which a measure's width is increased after its minimum width is calculated during the first pass.

This didn't appear to be the case when I was physically measuring measures' widths in order to better understand how stretchMeasure() behaves. ( "b ≠ 3.0 x a" in the picture below).

However, it turned out I was simply measuring the wrong parts of the measures and that stretching does indeed behave as expected (the "d = 3.0 x c" part).

In any case, my apologies for being so unclear. I could've explained better and asked clearer questions.

measures_measured.png

Attachment Size
measures_measured.png 19.9 KB

@Marc Sabatella I'm currently trying to identify the exact math that stretchMeasure() uses to decide how much space a note gets within the measure. I need this to be able to come up with a way to calculate a measure's pre-stretch width (during the first pass) based on the note values.

The calculation seems to be based on a note's number of ticks and the "minTick" value returned by computeTicks(). What exactly is this minTick value? And besides returning minTick, what does computeTicks() do otherwise?

From the code, it appears computeTicks() is going through each segment calculating the duration of the segment. Consider, it's different from the duration of a note, as there might be a whole note as the only note on the segment for beat 1, but the next segment might be a chord symbol on beat 3, so the duration of that beat 1 segment would be two beats. So computeTicks() is calling setTicks() on that beat 1 segment so it "knows" it is two beats long (that information is not otherwise available, and indeed is seldom used in the code). The return value is the duration of the shortest segment in the measure. So, if a measure contains a mix of half notes, eighths, and sixteenths, return value is Fraction(1, 16) it seems. So that's what minTick is in stretchMeasure(). At least that's how it looks to me.

I'm making progress and have been able to come up with an algorithm (based on the math in stretchMeasure() ) that gives more consistent spacing than the current algorithm. But its not 100% there yet...

I just need to make sure... is space AKA sp the unit of width used in the code? E.g, do we tell stretchMeasure() to resize a measure to a specified amount of spaces?

I look forward to seeing the results! The units used in the code is not not normally in sp, it's in absolute size that is that times whatever the spatium() value is. At the default scaling, I think it works out to 25 but don't quote me on that - just set breakpoints in places where this value is used and check the values for yourself.

Breakpoints? Um... I'm almost completely illiterate in C++ and haven't even yet figured out how to compile the code. So I can't use breakpoints at this stage. :)

My intention was to come up with a way to calculate a correct target-width for each measure (during the first pass) to pass to stretchMeasure() based on collisions as well as note-durations. Here's how I tried doing that in a nutshell...

First I identify the shortest note-duration on the system ('SYSTEM_MIN_TICKS' for brevity) and also get the measure-spacing-value from style settings ('SPACING' for short).

Then, for each system and for each measure on that system, I loop through the measure's segments (notes), calculating a target width for each and adding up these segment-widths to get a total target width for the measure. Thus:

(edit:the website deleted my indentation)
for all_systems do
for all_measures_on_the_system do {
measure_total_width=0;
for all_segments_in_the_measure do {
calculate_segment_width;
add_segment_width_to_measure_total_width;
}
}

I calculated a target-width for each segment as follows:

1) I get the segment's ticks ('TICKS' for short).

2) I calculate a duration-based width (DURATION_WIDTH) with this formula copied and adapted from stretchMeasure():

DURATION_WIDTH = SPACING * (1.0 + 0.865617 * log(TICKS / SYSTEM_MIN_TICKS));

3) Then I get the collision-avoidance-based minimum width between the start of the segment's notehead, and the start of the next segment's notehead (MIN_TRAILING_WIDTH).

4) Then I test whether DURATION_WIDTH is wide enough to accomodate the segment's notehead and all the symbols before the next notehead (dots, accidentals, arpeggios, etc) and decide the segments width (SEGMENT_WIDTH) accordingly. Thus:

(edit:the website deleted my indentation)
if (MIN_TRAILING_WIDTH > DURATION_WIDTH) then
SEGMENT_WIDTH=MIN_TRAILING_WIDTH
else
SEGMENT_WIDTH=DURATION_WIDTH

5) Then I sum all the SEGMENT_WIDTHs as well as other unaccounted-for widths within the measure (style settings such as barline-to-note-width, etc)

To test this, I created a minimum-width score like this...

MinWidth.PNG

... and then physically measured the minimum-width for each measure (in sp), and then calculated a stretch-value for each measure to achieve the previously calculated target-widths for each measure.

The result is that measures 2,3,4,7 and 8 gets spaced perfectly consistently, but the rest of the measures' spacing is slightly too narrow to a degree to be apparent just by looking. I haven't figured out yet why this is.

(And here is the part where I'm supposed to post a screenshot, but, to my embarrassment, I realised I've misplaced the score with the the calculated stretches applied as well as the textfile containing said stretch-values!)

However, I'm not so sure anymore whether this is the best approach. I've since had a new idea that might be much easier to implement. Details to follow in the my next reply...

Attachment Size
MinWidth.PNG 24.28 KB

@Marc Sabatella Continued from my previous reply... stretchMeasure() spaces same-duration notes consistently WITHIN a measure. So I thought :

What it we treated an entire system as if it is one big measure?
What if we stretched an entire system in one go instead of stretching measure by measure?
What if we had a "stretchSystem()" that stretched a system across the width of the page?
What if we simply copied the contents of stretchMeasure() into stretchSystem(), and modified the code to loop through an entire system's segments instead of just a single measure's segments?

My thinking is that, if stretchMeasure() spaces consistently within a measure, it would also space consistently if it could loop through the entire system's segments instead of just a specific measure.

I mimicked this hypothetical "stretchSystem()" by joining all the measures in a system and then manually adding barlines. The resultant spacing is just about PERFECT! (see the attached score for more examples.)

joining_measures_fixes_inconsistent_spacing.png

(This example was made with a normal non-development copy of MuseScore. So the second-pass stretching is also enabled)

So if we could somehow have a copy of the code in stretchMeasure() loop through an entire system in one go, I think that would fix the inconsistent spacing.

We might also need to modify the code that decides how many measures would fit on a system to calculate measures' width in a similar fashion as described in my previous post instead of calculating widths based on avoiding collisions only. Otherwise, we might get a situation where too many or too few measures are placed on a system, resulting in overly wide or narrow spacing on one system compared to the others.

(I hope I'm making sense. English isn't my first language and I often have trouble putting my thoughts to words.)

At the moment I think the idea described above (applying stretchMeasure() to a whole system instead of one measure at a time) provides the (seemingly) simplest fix for the inconsistent spacing. Any comment on this? Could this be the way forward?

Meanwhile I'm still trying to decipher how stretchMeasure() works. Any idea what the "springs" thing within stretchMeasure() is?

Well, we still need to calculate reasonable minimum widths in the first pass measure by measure because that's the only way to determine how many measures will fit. And that's really where the heart of the problem is - the minimum width calculation during the first pass. But it's possible indeed that after calculating what we think are reasonable minimum widths (even if they are too conservative for longer measures), we could indeed to the stretch across the entire system.

Realistically, I don't see any way you could possibly make any significant/usable progress on this without compiling the code and testing and debugging for yourself, though. So I'd focus on getting that happening.

Oh, and "springs" are the data structure used to try to track how much space to allocate to each segment, but it's pretty convoluted. At one time I worked the code enough to gain a rudimentary understanding of what it was doing, but I've pretty forgotten it, and would need to spend some serious time stepping through the code and examining the values with a debugger to regain that sort of understanding. Which is what I am suggesting you might want to start working on :-)

So this is about as far as I'll get by only observing how MuseScore behaves, reading your notes and reading the code (or at least trying to)? Got it. I'll get into compiling and messing with the code and see how that goes. Thanks for all your help so far! :)

You're welcome, and thank you for the work on this! But yeah, this stuff is complex enough that while you can learn much just by studying the code, doing thought exercises, and faking things using the debug menu options, manual stretch etc, you won't really know what's going on without trying some code. I encourage you to join the Telegram chat for real-time discussion as you do so - MuseScore Developers Chat
https://t.me/musescoreeditorchat

So I've managed to compile MuseScore (with Visual Studio) and setting breakpoints and stepping through the code. Its quite overwhelming and I'm in waaaaaaay over my head at this point, but maybe I'll get the hang of it over time.

Meanwhile, I thought of a possible solution based on a some assumptions (which I'll get to in a minute):

Looking at collectSystem() and your explanations, its seems the spacing currently happens in the following main steps...
1) Decide how many measures would fit on a system
2) Stretch those measures one by one to their minimum width times the spacing style setting
3) Stretch those measures to right-justify the system

What if this is changed to the following...
1) Decide how many measures would fit on a system (this code remains unchanged for now)
2) Treat the system like one large measure (as if its measures are all joined into one single measure... see the examples in my previous comments) and stretch it to page-width with the same code/algorithm contained in stretchMeasure(). This is based on the fact that stretchMeasure() currently spaces notes perfectly consistently within a measure, so it could probably do the same for an entire system.

Based on my limited understanding of the code, this approach seems to have the following advantages...
1) We don't need to rewrite any of the code that actually calculates the note-duration-based spacing.
2) We don't need to calculate a correct target width to pass to stretchMeasure() for each measure. If we had to, it would've become very complex as we would've needed to take into account things like tuplets, tremolos, multiple voices, etc. A further complication is that, in order to calculate a correct target width for a measure, we would seemingly need to know the shortest note-duration on the entire system.
3) No more second-pass stretching, thus eliminating the need to fix the issue of how to correctly divide up the remaining space between measures.

There would be the following disadvantages...
1) In continuous view, we would probably still need to use the current algorithm (otherwise I suspect MuseScore would grind to a halt). However, this is a minor disadvantage to me as I mainly care about how the score looks on the printed page.
2) Since the code deciding the number of measures per system remains the same, we might not always automatically get the right number of measures on a system. However, this is easily manually corrected by adjusting the spacing-style-setting and using system breaks. And the code can always be fixed at a later stage if needed.

All of this is based on the assumption that stretchMeasure() can be applied to a whole system instead of a single measure. Or alternatively stretchMeasure() can be copied and adapted into a new function like a hypothetical stretchSystem().

Looking at stretchMeasure(), it seems to me that it loops through a list of segments. So can it perhaps loop through an entire system's segments instead of a single measure's segments? My proposed solution is based on the answer being "yes".

So what do you think? Could this the solution? Could this be implemented without needing to do any really mind-breaking coding or major surgery on the code?

Before I get too excited that the above might be the answer to the spacing issue, I'm going to experiment with some real scores and see how good the spacing turns out.

I think this idea has real potential and is very possibly the way forward. However, there is one error in your assumption that complicates things: the initial stretch of the measure based on measure spacing setting (also considering user-applied stretch) happens within computeMinWidth() before deciding how measures would fit. For obvious reasons - otherwise we'd likely end up with measures that don't actually fit once stretched. The second stretch to right-justify happens after settling on the number of measures.

It might work to use that initial stretch calculation to decide on how many measures, but then run another pass across the whole system to even things out. The worry would be that the initial calculation might occasionally be a little too optimistic, and that second pass would actually find the measures get too long after stretching. Or vice versa, and after the second pass we discover another measure would have actually fit.

So maybe better would be, instead of calculation each measure's stretched width as we test whether it would fit, re-calculate the full system's stretched width each time we tentatively add a measure. It's a bit more expensive, but in theory we only do this for systems that have changed, so it shouldn't be noticeable. plus I don't think the stretching calculation is likely to be that big a part of the overall layout time.