Browse Source

merge osmd-public 1.5.1: fix layout with whole measure rests and e.g. 12/8 rhythm

see https://github.com/opensheetmusicdisplay/opensheetmusicdisplay/issues/1187
sschmidTU 2 years ago
parent
commit
a7ba778191

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "osmd-extended",
-  "version": "1.5.0",
+  "version": "1.5.1",
   "description": "Private / sponsor exclusive OSMD mirror/audio player.",
   "main": "build/opensheetmusicdisplay.min.js",
   "types": "build/dist/src/index.d.ts",

+ 2 - 2
src/MusicalScore/Graphical/VexFlow/VexFlowMeasure.ts

@@ -1240,8 +1240,8 @@ export class VexFlowMeasure extends GraphicalMeasure {
 
             // add a vexFlow voice for this voice:
             this.vfVoices[voice.VoiceId] = new VF.Voice({
-                        beat_value: this.parentSourceMeasure.Duration.Denominator,
-                        num_beats: this.parentSourceMeasure.Duration.Numerator,
+                        beat_value: this.parentSourceMeasure.ActiveTimeSignature.Denominator,
+                        num_beats: this.parentSourceMeasure.ActiveTimeSignature.Numerator,
                         resolution: VF.RESOLUTION,
                     }).setMode(VF.Voice.Mode.SOFT);
 

+ 8 - 2
src/MusicalScore/Graphical/VexFlow/VexFlowMusicSheetCalculator.ts

@@ -178,8 +178,14 @@ export class VexFlowMusicSheetCalculator extends MusicSheetCalculator {
       const voices: VF.Voice[] = [];
       for (const voiceID in mvoices) {
         if (mvoices.hasOwnProperty(voiceID)) {
-          voices.push(mvoices[voiceID]);
-          allVoices.push(mvoices[voiceID]);
+          const mvoice: any = mvoices[voiceID];
+          if (measure.hasOnlyRests && !mvoice.ticksUsed.equals(mvoice.totalTicks)) {
+            // fix layouting issues with whole measure rests in one staff and notes in other. especially in 12/8 rthythm (#1187)
+            mvoice.ticksUsed = mvoice.totalTicks;
+            // Vexflow 1.2.93: needs VexFlowPatch for formatter.js (see #1187)
+          }
+          voices.push(mvoice);
+          allVoices.push(mvoice);
         }
       }
 

+ 1 - 1
src/OpenSheetMusicDisplay/OpenSheetMusicDisplay.ts

@@ -35,7 +35,7 @@ import { DynamicsCalculator } from "../MusicalScore/ScoreIO/MusicSymbolModules/D
  * After the constructor, use load() and render() to load and render a MusicXML file.
  */
 export class OpenSheetMusicDisplay {
-    private version: string = "1.5.0-audio-extended"; // getter: this.Version
+    private version: string = "1.5.1-audio-extended"; // getter: this.Version
     // at release, bump version and change to -release, afterwards to -dev again
 
     /**

+ 4 - 0
src/VexFlowPatch/readme.txt

@@ -17,6 +17,10 @@ able to add svg node id+class to beam (not yet in vexflow 4)
 clef.js (merged vexflow 4):
 open group to get SVG group+class for clef
 
+formatter.js (custom addition, unnecessary in vexflow 4):
+comment out unnecessary error thrown, which prevents the fix to
+layouting improvements with whole measure rests and e.g. 12/8 rhythm in #1187.
+
 keysignature.js (merged vexflow 4):
 open group to get SVG group+class for key signature
 

+ 701 - 0
src/VexFlowPatch/src/formatter.js

@@ -0,0 +1,701 @@
+// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
+//
+// ## Description
+//
+// This file implements the formatting and layout algorithms that are used
+// to position notes in a voice. The algorithm can align multiple voices both
+// within a stave, and across multiple staves.
+//
+// To do this, the formatter breaks up voices into a grid of rational-valued
+// `ticks`, to which each note is assigned. Then, minimum widths are assigned
+// to each tick based on the widths of the notes and modifiers in that tick. This
+// establishes the smallest amount of space required for each tick.
+//
+// Finally, the formatter distributes the left over space proportionally to
+// all the ticks, setting the `x` values of the notes in each tick.
+//
+// See `tests/formatter_tests.js` for usage examples. The helper functions included
+// here (`FormatAndDraw`, `FormatAndDrawTab`) also serve as useful usage examples.
+
+import { Vex } from './vex';
+import { Beam } from './beam';
+import { Flow } from './tables';
+import { Fraction } from './fraction';
+import { Voice } from './voice';
+import { StaveConnector } from './staveconnector';
+import { StaveNote } from './stavenote';
+import { Note } from './note';
+import { ModifierContext } from './modifiercontext';
+import { TickContext } from './tickcontext';
+
+// To enable logging for this class. Set `Vex.Flow.Formatter.DEBUG` to `true`.
+function L(...args) { if (Formatter.DEBUG) Vex.L('Vex.Flow.Formatter', args); }
+
+// Helper function to locate the next non-rest note(s).
+function lookAhead(notes, restLine, i, compare) {
+  // If no valid next note group, nextRestLine is same as current.
+  let nextRestLine = restLine;
+
+  // Get the rest line for next valid non-rest note group.
+  for (i += 1; i < notes.length; i += 1) {
+    const note = notes[i];
+    if (!note.isRest() && !note.shouldIgnoreTicks()) {
+      nextRestLine = note.getLineForRest();
+      break;
+    }
+  }
+
+  // Locate the mid point between two lines.
+  if (compare && restLine !== nextRestLine) {
+    const top = Math.max(restLine, nextRestLine);
+    const bot = Math.min(restLine, nextRestLine);
+    nextRestLine = Vex.MidLine(top, bot);
+  }
+  return nextRestLine;
+}
+
+// Take an array of `voices` and place aligned tickables in the same context. Returns
+// a mapping from `tick` to `ContextType`, a list of `tick`s, and the resolution
+// multiplier.
+//
+// Params:
+// * `voices`: Array of `Voice` instances.
+// * `ContextType`: A context class (e.g., `ModifierContext`, `TickContext`)
+// * `addToContext`: Function to add tickable to context.
+function createContexts(voices, ContextType, addToContext) {
+  if (!voices || !voices.length) {
+    throw new Vex.RERR('BadArgument', 'No voices to format');
+  }
+
+  // Find out highest common multiple of resolution multipliers.
+  // The purpose of this is to find out a common denominator
+  // for all fractional tick values in all tickables of all voices,
+  // so that the values can be expanded and the numerator used
+  // as an integer tick value.
+  const totalTicks = voices[0].getTotalTicks();
+  const resolutionMultiplier = voices.reduce((resolutionMultiplier, voice) => {
+    // VexFlowPatch: this error is unnecessary, and prevents the fix to the layouting in #1187.
+    // if (!voice.getTotalTicks().equals(totalTicks)) {
+    //   throw new Vex.RERR(
+    //     'TickMismatch', 'Voices should have same total note duration in ticks.'
+    //   );
+    // }
+
+    if (voice.getMode() === Voice.Mode.STRICT && !voice.isComplete()) {
+      throw new Vex.RERR(
+        'IncompleteVoice', 'Voice does not have enough notes.'
+      );
+    }
+
+    return Math.max(
+      resolutionMultiplier,
+      Fraction.LCM(resolutionMultiplier, voice.getResolutionMultiplier())
+    );
+  }, 1);
+
+  // Initialize tick maps.
+  const tickToContextMap = {};
+  const tickList = [];
+  const contexts = [];
+
+  // For each voice, extract notes and create a context for every
+  // new tick that hasn't been seen before.
+  voices.forEach(voice => {
+    // Use resolution multiplier as denominator to expand ticks
+    // to suitable integer values, so that no additional expansion
+    // of fractional tick values is needed.
+    const ticksUsed = new Fraction(0, resolutionMultiplier);
+
+    voice.getTickables().forEach(tickable => {
+      const integerTicks = ticksUsed.numerator;
+
+      // If we have no tick context for this tick, create one.
+      if (!tickToContextMap[integerTicks]) {
+        const newContext = new ContextType();
+        contexts.push(newContext);
+        tickToContextMap[integerTicks] = newContext;
+      }
+
+      // Add this tickable to the TickContext.
+      addToContext(tickable, tickToContextMap[integerTicks]);
+
+      // Maintain a sorted list of tick contexts.
+      tickList.push(integerTicks);
+      ticksUsed.add(tickable.getTicks());
+    });
+  });
+
+  return {
+    map: tickToContextMap,
+    array: contexts,
+    list: Vex.SortAndUnique(tickList, (a, b) => a - b, (a, b) => a === b),
+    resolutionMultiplier,
+  };
+}
+
+export class Formatter {
+  // Helper function to layout "notes" one after the other without
+  // regard for proportions. Useful for tests and debugging.
+  static SimpleFormat(notes, x = 0, { paddingBetween = 10 } = {}) {
+    notes.reduce((x, note) => {
+      note.addToModifierContext(new ModifierContext());
+      const tick = new TickContext().addTickable(note).preFormat();
+      const extra = tick.getExtraPx();
+      tick.setX(x + extra.left);
+
+      return x + tick.getWidth() + extra.right + paddingBetween;
+    }, x);
+  }
+
+  // Helper function to plot formatter debug info.
+  static plotDebugging(ctx, formatter, xPos, y1, y2) {
+    const x = xPos + Note.STAVEPADDING;
+    const contextGaps = formatter.contextGaps;
+    function stroke(x1, x2, color) {
+      ctx.beginPath();
+      ctx.setStrokeStyle(color);
+      ctx.setFillStyle(color);
+      ctx.setLineWidth(1);
+      ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
+    }
+
+    ctx.save();
+    ctx.setFont('Arial', 8, '');
+
+    contextGaps.gaps.forEach(gap => {
+      stroke(x + gap.x1, x + gap.x2, '#aaa');
+      // Vex.drawDot(ctx, xPos + gap.x1, yPos, 'blue');
+      ctx.fillText(Math.round(gap.x2 - gap.x1), x + gap.x1, y2 + 12);
+    });
+
+    ctx.fillText(Math.round(contextGaps.total) + 'px', x - 20, y2 + 12);
+    ctx.setFillStyle('red');
+
+    ctx.fillText('Loss: ' +
+      formatter.lossHistory.map(loss => Math.round(loss)), x - 20, y2 + 22);
+    ctx.restore();
+  }
+
+  // Helper function to format and draw a single voice. Returns a bounding
+  // box for the notation.
+  //
+  // Parameters:
+  // * `ctx` - The rendering context
+  // * `stave` - The stave to which to draw (`Stave` or `TabStave`)
+  // * `notes` - Array of `Note` instances (`StaveNote`, `TextNote`, `TabNote`, etc.)
+  // * `params` - One of below:
+  //    * Setting `autobeam` only `(context, stave, notes, true)` or
+  //      `(ctx, stave, notes, {autobeam: true})`
+  //    * Setting `align_rests` a struct is needed `(context, stave, notes, {align_rests: true})`
+  //    * Setting both a struct is needed `(context, stave, notes, {
+  //      autobeam: true, align_rests: true})`
+  //
+  // `autobeam` automatically generates beams for the notes.
+  // `align_rests` aligns rests with nearby notes.
+  static FormatAndDraw(ctx, stave, notes, params) {
+    const options = {
+      auto_beam: false,
+      align_rests: false,
+    };
+
+    if (typeof params === 'object') {
+      Vex.Merge(options, params);
+    } else if (typeof params === 'boolean') {
+      options.auto_beam = params;
+    }
+
+    // Start by creating a voice and adding all the notes to it.
+    const voice = new Voice(Flow.TIME4_4)
+      .setMode(Voice.Mode.SOFT)
+      .addTickables(notes);
+
+    // Then create beams, if requested.
+    const beams = options.auto_beam ? Beam.applyAndGetBeams(voice) : [];
+
+    // Instantiate a `Formatter` and format the notes.
+    new Formatter()
+      .joinVoices([voice], { align_rests: options.align_rests })
+      .formatToStave([voice], stave, { align_rests: options.align_rests, stave });
+
+    // Render the voice and beams to the stave.
+    voice.setStave(stave).draw(ctx, stave);
+    beams.forEach(beam => beam.setContext(ctx).draw());
+
+    // Return the bounding box of the voice.
+    return voice.getBoundingBox();
+  }
+
+  // Helper function to format and draw aligned tab and stave notes in two
+  // separate staves.
+  //
+  // Parameters:
+  // * `ctx` - The rendering context
+  // * `tabstave` - A `TabStave` instance on which to render `TabNote`s.
+  // * `stave` - A `Stave` instance on which to render `Note`s.
+  // * `notes` - Array of `Note` instances for the stave (`StaveNote`, `BarNote`, etc.)
+  // * `tabnotes` - Array of `Note` instances for the tab stave (`TabNote`, `BarNote`, etc.)
+  // * `autobeam` - Automatically generate beams.
+  // * `params` - A configuration object:
+  //    * `autobeam` automatically generates beams for the notes.
+  //    * `align_rests` aligns rests with nearby notes.
+  static FormatAndDrawTab(ctx, tabstave, stave, tabnotes, notes, autobeam, params) {
+    const opts = {
+      auto_beam: autobeam,
+      align_rests: false,
+    };
+
+    if (typeof params === 'object') {
+      Vex.Merge(opts, params);
+    } else if (typeof params === 'boolean') {
+      opts.auto_beam = params;
+    }
+
+    // Create a `4/4` voice for `notes`.
+    const notevoice = new Voice(Flow.TIME4_4)
+      .setMode(Voice.Mode.SOFT)
+      .addTickables(notes);
+
+    // Create a `4/4` voice for `tabnotes`.
+    const tabvoice = new Voice(Flow.TIME4_4)
+      .setMode(Voice.Mode.SOFT)
+      .addTickables(tabnotes);
+
+    // Then create beams, if requested.
+    const beams = opts.auto_beam ? Beam.applyAndGetBeams(notevoice) : [];
+
+    // Instantiate a `Formatter` and align tab and stave notes.
+    new Formatter()
+      .joinVoices([notevoice], { align_rests: opts.align_rests })
+      .joinVoices([tabvoice])
+      .formatToStave([notevoice, tabvoice], stave, { align_rests: opts.align_rests });
+
+    // Render voices and beams to staves.
+    notevoice.draw(ctx, stave);
+    tabvoice.draw(ctx, tabstave);
+    beams.forEach(beam => beam.setContext(ctx).draw());
+
+    // Draw a connector between tab and note staves.
+    new StaveConnector(stave, tabstave).setContext(ctx).draw();
+  }
+
+  // Auto position rests based on previous/next note positions.
+  //
+  // Params:
+  // * `notes`: An array of notes.
+  // * `alignAllNotes`: If set to false, only aligns non-beamed notes.
+  // * `alignTuplets`: If set to false, ignores tuplets.
+  static AlignRestsToNotes(notes, alignAllNotes, alignTuplets) {
+    notes.forEach((note, index) => {
+      if (note instanceof StaveNote && note.isRest()) {
+        if (note.tuplet && !alignTuplets) return;
+
+        // If activated rests not on default can be rendered as specified.
+        const position = note.getGlyph().position.toUpperCase();
+        if (position !== 'R/4' && position !== 'B/4') return;
+
+        if (alignAllNotes || note.beam != null) {
+          // Align rests with previous/next notes.
+          const props = note.getKeyProps()[0];
+          if (index === 0) {
+            props.line = lookAhead(notes, props.line, index, false);
+            note.setKeyLine(0, props.line);
+          } else if (index > 0 && index < notes.length) {
+            // If previous note is a rest, use its line number.
+            let restLine;
+            if (notes[index - 1].isRest()) {
+              restLine = notes[index - 1].getKeyProps()[0].line;
+              props.line = restLine;
+            } else {
+              restLine = notes[index - 1].getLineForRest();
+              // Get the rest line for next valid non-rest note group.
+              props.line = lookAhead(notes, restLine, index, true);
+            }
+            note.setKeyLine(0, props.line);
+          }
+        }
+      }
+    });
+
+    return this;
+  }
+
+  constructor() {
+    // Minimum width required to render all the notes in the voices.
+    this.minTotalWidth = 0;
+
+    // This is set to `true` after `minTotalWidth` is calculated.
+    this.hasMinTotalWidth = false;
+
+    // Total number of ticks in the voice.
+    this.totalTicks = new Fraction(0, 1);
+
+    // Arrays of tick and modifier contexts.
+    this.tickContexts = null;
+    this.modiferContexts = null;
+
+    // Gaps between contexts, for free movement of notes post
+    // formatting.
+    this.contextGaps = {
+      total: 0,
+      gaps: [],
+    };
+
+    this.voices = [];
+  }
+
+  // Find all the rests in each of the `voices` and align them
+  // to neighboring notes. If `alignAllNotes` is `false`, then only
+  // align non-beamed notes.
+  alignRests(voices, alignAllNotes) {
+    if (!voices || !voices.length) {
+      throw new Vex.RERR('BadArgument', 'No voices to format rests');
+    }
+
+    voices.forEach(voice =>
+      Formatter.AlignRestsToNotes(voice.getTickables(), alignAllNotes));
+  }
+
+  // Calculate the minimum width required to align and format `voices`.
+  preCalculateMinTotalWidth(voices) {
+    // Cache results.
+    if (this.hasMinTotalWidth) return this.minTotalWidth;
+
+    // Create tick contexts if not already created.
+    if (!this.tickContexts) {
+      if (!voices) {
+        throw new Vex.RERR(
+          'BadArgument', "'voices' required to run preCalculateMinTotalWidth"
+        );
+      }
+
+      this.createTickContexts(voices);
+    }
+
+    const { list: contextList, map: contextMap } = this.tickContexts;
+
+    // Go through each tick context and calculate total width.
+    this.minTotalWidth = contextList
+      .map(tick => {
+        const context = contextMap[tick];
+        context.preFormat();
+        return context.getWidth();
+      })
+      .reduce((a, b) => a + b, 0);
+
+    this.hasMinTotalWidth = true;
+
+    return this.minTotalWidth;
+  }
+
+  // Get minimum width required to render all voices. Either `format` or
+  // `preCalculateMinTotalWidth` must be called before this method.
+  getMinTotalWidth() {
+    if (!this.hasMinTotalWidth) {
+      throw new Vex.RERR(
+        'NoMinTotalWidth',
+        "Call 'preCalculateMinTotalWidth' or 'preFormat' before calling 'getMinTotalWidth'"
+      );
+    }
+
+    return this.minTotalWidth;
+  }
+
+  // Create `ModifierContext`s for each tick in `voices`.
+  createModifierContexts(voices) {
+    const contexts = createContexts(
+      voices,
+      ModifierContext,
+      (tickable, context) => tickable.addToModifierContext(context)
+    );
+
+    this.modiferContexts = contexts;
+    return contexts;
+  }
+
+  // Create `TickContext`s for each tick in `voices`. Also calculate the
+  // total number of ticks in voices.
+  createTickContexts(voices) {
+    const contexts = createContexts(
+      voices,
+      TickContext,
+      (tickable, context) => context.addTickable(tickable)
+    );
+
+    contexts.array.forEach(context => {
+      context.tContexts = contexts.array;
+    });
+
+    this.totalTicks = voices[0].getTicksUsed().clone();
+    this.tickContexts = contexts;
+    return contexts;
+  }
+
+  // This is the core formatter logic. Format voices and justify them
+  // to `justifyWidth` pixels. `renderingContext` is required to justify elements
+  // that can't retreive widths without a canvas. This method sets the `x` positions
+  // of all the tickables/notes in the formatter.
+  preFormat(justifyWidth = 0, renderingContext, voices, stave) {
+    // Initialize context maps.
+    const contexts = this.tickContexts;
+    const { list: contextList, map: contextMap, resolutionMultiplier } = contexts;
+
+    // If voices and a stave were provided, set the Stave for each voice
+    // and preFormat to apply Y values to the notes;
+    if (voices && stave) {
+      voices.forEach(voice => voice.setStave(stave).preFormat());
+    }
+
+    // Now distribute the ticks to each tick context, and assign them their
+    // own X positions.
+    let x = 0;
+    let shift = 0;
+    const centerX = justifyWidth / 2;
+    this.minTotalWidth = 0;
+
+    // Pass 1: Give each note maximum width requested by context.
+    contextList.forEach((tick) => {
+      const context = contextMap[tick];
+      if (renderingContext) context.setContext(renderingContext);
+
+      // Make sure that all tickables in this context have calculated their
+      // space requirements.
+      context.preFormat();
+
+      const width = context.getWidth();
+      this.minTotalWidth += width;
+
+      const metrics = context.getMetrics();
+      x = x + shift + metrics.extraLeftPx;
+      context.setX(x);
+
+      // Calculate shift for the next tick.
+      shift = width - metrics.extraLeftPx;
+    });
+
+    this.minTotalWidth = x + shift;
+    this.hasMinTotalWidth = true;
+
+    // No justification needed. End formatting.
+    if (justifyWidth <= 0) return;
+
+    // Pass 2: Take leftover width, and distribute it to proportionately to
+    // all notes.
+    const remainingX = justifyWidth - this.minTotalWidth;
+    const leftoverPxPerTick = remainingX / (this.totalTicks.value() * resolutionMultiplier);
+    let spaceAccum = 0;
+
+    contextList.forEach((tick, index) => {
+      const prevTick = contextList[index - 1] || 0;
+      const context = contextMap[tick];
+      const tickSpace = (tick - prevTick) * leftoverPxPerTick;
+
+      spaceAccum += tickSpace;
+      context.setX(context.getX() + spaceAccum);
+
+      // Move center aligned tickables to middle
+      context
+        .getCenterAlignedTickables()
+        .forEach(tickable => { // eslint-disable-line
+          tickable.center_x_shift = centerX - context.getX();
+        });
+    });
+
+    // Just one context. Done formatting.
+    if (contextList.length === 1) return;
+
+    this.justifyWidth = justifyWidth;
+    this.lossHistory = [];
+    this.evaluate();
+  }
+
+  // Calculate the total cost of this formatting decision.
+  evaluate() {
+    const justifyWidth = this.justifyWidth;
+    // Calculate available slack per tick context. This works out how much freedom
+    // to move a context has in either direction, without affecting other notes.
+    this.contextGaps = { total: 0, gaps: [] };
+    this.tickContexts.list.forEach((tick, index) => {
+      if (index === 0) return;
+      const prevTick = this.tickContexts.list[index - 1];
+      const prevContext = this.tickContexts.map[prevTick];
+      const context = this.tickContexts.map[tick];
+      const prevMetrics = prevContext.getMetrics();
+
+      const insideRightEdge = prevContext.getX() + prevMetrics.width;
+      const insideLeftEdge = context.getX();
+      const gap = insideLeftEdge - insideRightEdge;
+      this.contextGaps.total += gap;
+      this.contextGaps.gaps.push({ x1: insideRightEdge, x2: insideLeftEdge });
+
+      // Tell the tick contexts how much they can reposition themselves.
+      context.getFormatterMetrics().freedom.left = gap;
+      prevContext.getFormatterMetrics().freedom.right = gap;
+    });
+
+    // Calculate mean distance in each voice for each duration type, then calculate
+    // how far each note is from the mean.
+    const durationStats = this.durationStats = {};
+
+    function updateStats(duration, space) {
+      const stats = durationStats[duration];
+      if (stats === undefined) {
+        durationStats[duration] = { mean: space, count: 1 };
+      } else {
+        stats.count += 1;
+        stats.mean = (stats.mean + space) / 2;
+      }
+    }
+
+    this.voices.forEach(voice => {
+      voice.getTickables().forEach((note, i, notes) => {
+        const duration = note.getTicks().clone().simplify().toString();
+        const metrics = note.getMetrics();
+        const formatterMetrics = note.getFormatterMetrics();
+        const leftNoteEdge = note.getX() + metrics.noteWidth +
+          metrics.modRightPx + metrics.extraRightPx;
+        let space = 0;
+
+        if (i < (notes.length - 1)) {
+          const rightNote = notes[i + 1];
+          const rightMetrics = rightNote.getMetrics();
+          const rightNoteEdge = rightNote.getX() -
+            rightMetrics.modLeftPx - rightMetrics.extraLeftPx;
+
+          space = rightNoteEdge - leftNoteEdge;
+          formatterMetrics.space.used = rightNote.getX() - note.getX();
+          rightNote.getFormatterMetrics().freedom.left = space;
+        } else {
+          space = justifyWidth - leftNoteEdge;
+          formatterMetrics.space.used = justifyWidth - note.getX();
+        }
+
+        formatterMetrics.freedom.right = space;
+        updateStats(duration, formatterMetrics.space.used);
+      });
+    });
+
+    // Calculate how much each note deviates from the mean. Loss function is square
+    // root of the sum of squared deviations.
+    let totalDeviation = 0;
+    this.voices.forEach(voice => {
+      voice.getTickables().forEach((note) => {
+        const duration = note.getTicks().clone().simplify().toString();
+        const metrics = note.getFormatterMetrics();
+        metrics.iterations += 1;
+        metrics.space.deviation = metrics.space.used - durationStats[duration].mean;
+        metrics.duration = duration;
+        metrics.space.mean = durationStats[duration].mean;
+
+        totalDeviation += Math.pow(durationStats[duration].mean, 2);
+      });
+    });
+
+    this.totalCost = Math.sqrt(totalDeviation);
+    this.lossHistory.push(this.totalCost);
+    return this;
+  }
+
+  // Run a single iteration of rejustification. At a high level, this method calculates
+  // the overall "loss" (or cost) of this layout, and repositions tickcontexts in an
+  // attempt to reduce the cost. You can call this method multiple times until it finds
+  // and oscillates around a global minimum.
+  tune() {
+    const sum = (means) => means.reduce((a, b) => a + b);
+
+    // Move `current` tickcontext by `shift` pixels, and adjust the freedom
+    // on adjacent tickcontexts.
+    function move(current, prev, next, shift) {
+      current.setX(current.getX() + shift);
+      current.getFormatterMetrics().freedom.left += shift;
+      current.getFormatterMetrics().freedom.right -= shift;
+
+      if (prev) prev.getFormatterMetrics().freedom.right += shift;
+      if (next) next.getFormatterMetrics().freedom.left -= shift;
+    }
+
+    let shift = 0;
+    this.tickContexts.list.forEach((tick, index, list) => {
+      const context = this.tickContexts.map[tick];
+      const prevContext = (index > 0) ? this.tickContexts.map[list[index - 1]] : null;
+      const nextContext = (index < list.length - 1) ? this.tickContexts.map[list[index + 1]] : null;
+
+      move(context, prevContext, nextContext, shift);
+
+      const cost = -sum(
+        context.getTickables().map(t => t.getFormatterMetrics().space.deviation));
+
+      if (cost > 0) {
+        shift = -Math.min(context.getFormatterMetrics().freedom.right, Math.abs(cost));
+      } else if (cost < 0) {
+        if (nextContext) {
+          shift = Math.min(nextContext.getFormatterMetrics().freedom.right, Math.abs(cost));
+        } else {
+          shift = 0;
+        }
+      }
+
+      const minShift = Math.min(5, Math.abs(shift));
+      shift = shift > 0 ? minShift : -minShift;
+    });
+
+    return this.evaluate();
+  }
+
+  // This is the top-level call for all formatting logic completed
+  // after `x` *and* `y` values have been computed for the notes
+  // in the voices.
+  postFormat() {
+    const postFormatContexts = (contexts) =>
+      contexts.list.forEach(tick => contexts.map[tick].postFormat());
+
+    postFormatContexts(this.modiferContexts);
+    postFormatContexts(this.tickContexts);
+
+    return this;
+  }
+
+  // Take all `voices` and create `ModifierContext`s out of them. This tells
+  // the formatters that the voices belong on a single stave.
+  joinVoices(voices) {
+    this.createModifierContexts(voices);
+    this.hasMinTotalWidth = false;
+    return this;
+  }
+
+  // Align rests in voices, justify the contexts, and position the notes
+  // so voices are aligned and ready to render onto the stave. This method
+  // mutates the `x` positions of all tickables in `voices`.
+  //
+  // Voices are full justified to fit in `justifyWidth` pixels.
+  //
+  // Set `options.context` to the rendering context. Set `options.align_rests`
+  // to true to enable rest alignment.
+  format(voices, justifyWidth, options) {
+    const opts = {
+      align_rests: false,
+      context: null,
+      stave: null,
+    };
+
+    Vex.Merge(opts, options);
+    this.voices = voices;
+    this.alignRests(voices, opts.align_rests);
+    this.createTickContexts(voices);
+    this.preFormat(justifyWidth, opts.context, voices, opts.stave);
+
+    // Only postFormat if a stave was supplied for y value formatting
+    if (opts.stave) this.postFormat();
+
+    return this;
+  }
+
+  // This method is just like `format` except that the `justifyWidth` is inferred
+  // from the `stave`.
+  formatToStave(voices, stave, options) {
+    const justifyWidth = stave.getNoteEndX() - stave.getNoteStartX() - 10;
+    L('Formatting voices to width: ', justifyWidth);
+    const opts = { context: stave.getContext() };
+    Vex.Merge(opts, options);
+    return this.format(voices, justifyWidth, opts);
+  }
+}

+ 357 - 0
test/data/test_12-8-rhythm-overflow.musicxml

@@ -0,0 +1,357 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 3.1 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
+<score-partwise version="3.1">
+  <identification>
+    <encoding>
+      <software>MuseScore 3.6.2</software>
+      <encoding-date>2022-07-18</encoding-date>
+      <supports element="accidental" type="yes"/>
+      <supports element="beam" type="yes"/>
+      <supports element="print" attribute="new-page" type="yes" value="yes"/>
+      <supports element="print" attribute="new-system" type="yes" value="yes"/>
+      <supports element="stem" type="yes"/>
+      </encoding>
+    </identification>
+  <defaults>
+    <scaling>
+      <millimeters>7</millimeters>
+      <tenths>40</tenths>
+      </scaling>
+    <page-layout>
+      <page-height>1697.14</page-height>
+      <page-width>1200</page-width>
+      <page-margins type="even">
+        <left-margin>85.7143</left-margin>
+        <right-margin>85.7143</right-margin>
+        <top-margin>85.7143</top-margin>
+        <bottom-margin>85.7143</bottom-margin>
+        </page-margins>
+      <page-margins type="odd">
+        <left-margin>85.7143</left-margin>
+        <right-margin>85.7143</right-margin>
+        <top-margin>85.7143</top-margin>
+        <bottom-margin>85.7143</bottom-margin>
+        </page-margins>
+      </page-layout>
+    <word-font font-family="Edwin" font-size="10"/>
+    <lyric-font font-family="Edwin" font-size="10"/>
+    </defaults>
+  <part-list>
+    <score-part id="P1">
+      <part-name>Piano</part-name>
+      <part-abbreviation>Pno.</part-abbreviation>
+      <score-instrument id="P1-I1">
+        <instrument-name>Piano</instrument-name>
+        </score-instrument>
+      <midi-device id="P1-I1" port="1"></midi-device>
+      <midi-instrument id="P1-I1">
+        <midi-channel>1</midi-channel>
+        <midi-program>1</midi-program>
+        <volume>78.7402</volume>
+        <pan>0</pan>
+        </midi-instrument>
+      </score-part>
+    </part-list>
+  <part id="P1">
+    <measure number="1" width="431.52">
+      <print>
+        <system-layout>
+          <system-margins>
+            <left-margin>64.90</left-margin>
+            <right-margin>0.00</right-margin>
+            </system-margins>
+          <top-system-distance>170.00</top-system-distance>
+          </system-layout>
+        <staff-layout number="2">
+          <staff-distance>65.00</staff-distance>
+          </staff-layout>
+        </print>
+      <attributes>
+        <divisions>2</divisions>
+        <key>
+          <fifths>1</fifths>
+          </key>
+        <time>
+          <beats>12</beats>
+          <beat-type>8</beat-type>
+          </time>
+        <staves>2</staves>
+        <clef number="1">
+          <sign>G</sign>
+          <line>2</line>
+          </clef>
+        <clef number="2">
+          <sign>F</sign>
+          <line>4</line>
+          </clef>
+        </attributes>
+      <note color="#ADADAD">
+        <rest measure="yes"/>
+        <duration>12</duration>
+        <voice>1</voice>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>12</duration>
+        </backup>
+      <note default-x="114.64" default-y="-145.00">
+        <pitch>
+          <step>G</step>
+          <octave>2</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="163.11" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="193.41" default-y="-110.00">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="241.89" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="272.18" default-y="-145.00">
+        <pitch>
+          <step>G</step>
+          <octave>2</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="320.66" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="350.95" default-y="-110.00">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="399.43" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      </measure>
+    <measure number="2" width="329.88">
+      <note default-x="13.00" default-y="-30.00">
+        <pitch>
+          <step>G</step>
+          <octave>4</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>2</duration>
+        <voice>1</voice>
+        <type>quarter</type>
+        <staff>1</staff>
+        </note>
+      <note color="#ADADAD">
+        <rest/>
+        <duration>1</duration>
+        <voice>1</voice>
+        <type>eighth</type>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>12</duration>
+        </backup>
+      <note default-x="13.00" default-y="-135.00">
+        <pitch>
+          <step>B</step>
+          <octave>2</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="61.47" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="91.77" default-y="-110.00">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="140.25" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="170.54" default-y="-135.00">
+        <pitch>
+          <step>B</step>
+          <octave>2</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>up</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="219.02" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="249.31" default-y="-110.00">
+        <pitch>
+          <step>G</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>2</duration>
+        <voice>5</voice>
+        <type>quarter</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      <note default-x="297.79" default-y="-125.00">
+        <pitch>
+          <step>D</step>
+          <octave>3</octave>
+          </pitch>
+        <duration>1</duration>
+        <voice>5</voice>
+        <type>eighth</type>
+        <stem>down</stem>
+        <staff>2</staff>
+        </note>
+      </measure>
+    <measure number="3" width="202.26">
+      <note>
+        <rest measure="yes"/>
+        <duration>12</duration>
+        <voice>1</voice>
+        <staff>1</staff>
+        </note>
+      <backup>
+        <duration>12</duration>
+        </backup>
+      <note>
+        <rest measure="yes"/>
+        <duration>12</duration>
+        <voice>5</voice>
+        <staff>2</staff>
+        </note>
+      </measure>
+    </part>
+  </score-partwise>