vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature.
1,109 lines (958 loc) • 41.4 kB
text/typescript
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// MIT License
import { Beam } from './beam';
import { BoundingBox } from './boundingbox';
import { Font } from './font';
import { Fraction } from './fraction';
import { ModifierContext } from './modifiercontext';
import { RenderContext } from './rendercontext';
import { Stave } from './stave';
import { StaveConnector } from './staveconnector';
import { StemmableNote } from './stemmablenote';
import { Tables } from './tables';
import { TabNote } from './tabnote';
import { TabStave } from './tabstave';
import { Tickable } from './tickable';
import { TickContext } from './tickcontext';
import { isNote, isStaveNote } from './typeguard';
import { defined, log, midLine, RuntimeError, sumArray } from './util';
import { Voice } from './voice';
interface Distance {
maxNegativeShiftPx: number;
expectedDistance: number;
fromTickable?: Tickable;
errorPx?: number;
fromTickablePx?: number;
}
export interface FormatterOptions {
/** Defaults to Tables.SOFTMAX_FACTOR. */
softmaxFactor?: number;
/** Defaults to `false`. */
globalSoftmax?: boolean;
/** Defaults to 5. */
maxIterations?: number;
}
export interface FormatParams {
align_rests?: boolean;
stave?: Stave;
context?: RenderContext;
auto_beam?: boolean;
}
export interface AlignmentContexts<T> {
list: number[];
map: Record<number, T>;
array: T[];
resolutionMultiplier: number;
}
export interface AlignmentModifierContexts {
map: Map<Stave | undefined, Record<number, ModifierContext>>;
array: ModifierContext[];
resolutionMultiplier: number;
}
type addToContextFn<T> = (tickable: Tickable, context: T, voiceIndex: number) => void;
type makeContextFn<T> = (tick?: { tickID: number }) => T;
/**
* Create `Alignment`s for each tick in `voices`. Also calculate the
* total number of ticks in voices.
*/
function createContexts<T>(
voices: Voice[],
makeContext: makeContextFn<T>,
addToContext: addToContextFn<T>
): AlignmentContexts<T> {
if (voices.length == 0)
return {
map: {},
array: [],
list: [],
resolutionMultiplier: 0,
};
// Initialize tick maps.
const tickToContextMap: Record<number, T> = {};
const tickList: number[] = [];
const contexts: T[] = [];
const resolutionMultiplier = Formatter.getResolutionMultiplier(voices);
// For each voice, extract notes and create a context for every
// new tick that hasn't been seen before.
voices.forEach((voice, voiceIndex) => {
// Use resolution multiplier as denominator 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 = makeContext({ tickID: integerTicks });
contexts.push(newContext);
tickToContextMap[integerTicks] = newContext;
// Maintain a list of unique integerTicks.
tickList.push(integerTicks);
}
// Add this tickable to the TickContext.
addToContext(tickable, tickToContextMap[integerTicks], voiceIndex);
ticksUsed.add(tickable.getTicks());
});
});
return {
map: tickToContextMap,
array: contexts,
list: tickList.sort((a, b) => a - b),
resolutionMultiplier,
};
}
// eslint-disable-next-line
function L(...args: any[]) {
if (Formatter.DEBUG) log('Vex.Flow.Formatter', args);
}
/**
* Get the rest line number of the next non-rest note(s).
* @param notes array of Notes
* @param currRestLine
* @param currNoteIndex current note index
* @param compare if true, return the midpoint between the current rest line and the next rest line
* @returns a line number, which determines the vertical position of a rest.
*/
function getRestLineForNextNoteGroup(
notes: Tickable[],
currRestLine: number,
currNoteIndex: number,
compare: boolean
): number {
// If no valid next note group, nextRestLine is same as current.
let nextRestLine = currRestLine;
// Start with the next note and keep going until we find a valid non-rest note group.
for (let noteIndex = currNoteIndex + 1; noteIndex < notes.length; noteIndex++) {
const note = notes[noteIndex];
if (isNote(note) && !note.isRest() && !note.shouldIgnoreTicks()) {
nextRestLine = note.getLineForRest();
break;
}
}
// Locate the mid point between two lines.
if (compare && currRestLine !== nextRestLine) {
const top = Math.max(currRestLine, nextRestLine);
const bot = Math.min(currRestLine, nextRestLine);
nextRestLine = midLine(top, bot);
}
return nextRestLine;
}
/**
* Format 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.ts` for usage examples. The helper functions included
* here (`FormatAndDraw`, `FormatAndDrawTab`) also serve as useful usage examples.
*/
export class Formatter {
// To enable logging for this class. Set `Vex.Flow.Formatter.DEBUG` to `true`.
static DEBUG: boolean = false;
protected hasMinTotalWidth: boolean;
protected minTotalWidth: number;
protected contextGaps: {
total: number;
gaps: { x1: number; x2: number }[];
};
protected justifyWidth: number;
protected totalCost: number;
protected totalShift: number;
protected tickContexts: AlignmentContexts<TickContext>;
protected formatterOptions: Required<FormatterOptions>;
protected modifierContexts: AlignmentModifierContexts[];
protected voices: Voice[];
protected lossHistory: number[];
protected durationStats: Record<string, { mean: number; count: number }>;
/**
* Helper function to layout "notes" one after the other without
* regard for proportions. Useful for tests and debugging.
*/
static SimpleFormat(notes: Tickable[], x = 0, { paddingBetween = 10 } = {}): void {
notes.reduce((accumulator, note) => {
note.addToModifierContext(new ModifierContext());
const tick = new TickContext().addTickable(note).preFormat();
const metrics = tick.getMetrics();
tick.setX(accumulator + metrics.totalLeftPx);
return accumulator + tick.getWidth() + metrics.totalRightPx + paddingBetween;
}, x);
}
/** Helper function to plot formatter debug info. */
static plotDebugging(
ctx: RenderContext,
formatter: Formatter,
xPos: number,
y1: number,
y2: number,
options?: { stavePadding: number }
): void {
options = {
stavePadding: Tables.currentMusicFont().lookupMetric('stave.padding'),
...options,
};
const x = xPos + options.stavePadding;
const contextGaps = formatter.contextGaps;
function stroke(x1: number, x2: number, color: string) {
ctx.beginPath();
ctx.setStrokeStyle(color);
ctx.setFillStyle(color);
ctx.setLineWidth(1);
ctx.fillRect(x1, y1, Math.max(x2 - x1, 0), y2 - y1);
}
ctx.save();
ctx.setFont(Font.SANS_SERIF, 8);
contextGaps.gaps.forEach((gap) => {
stroke(x + gap.x1, x + gap.x2, 'rgba(100,200,100,0.4)');
ctx.setFillStyle('green');
ctx.fillText(Math.round(gap.x2 - gap.x1).toString(), x + gap.x1, y2 + 12);
});
ctx.setFillStyle('red');
ctx.fillText(
`Loss: ${(formatter.totalCost || 0).toFixed(2)} Shift: ${(formatter.totalShift || 0).toFixed(
2
)} Gap: ${contextGaps.total.toFixed(2)}`,
x - 20,
y2 + 27
);
ctx.restore();
}
/**
* Helper function to format and draw a single voice. Returns a bounding
* box for the notation.
* @param ctx the rendering context
* @param stave the stave to which to draw (`Stave` or `TabStave`)
* @param notes array of `Note` instances (`Note`, `TextNote`, `TabNote`, etc.)
* @param 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: RenderContext,
stave: Stave,
notes: StemmableNote[],
params?: FormatParams | boolean
): BoundingBox | undefined {
let options = {
auto_beam: false,
align_rests: false,
};
if (typeof params === 'object') {
options = { ...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(Tables.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.
* @param ctx the rendering context
* @param tabstave a `TabStave` instance on which to render `TabNote`s.
* @param stave a `Stave` instance on which to render `Note`s.
* @param notes array of `Note` instances for the stave (`Note`, `BarNote`, etc.)
* @param tabnotes array of `Note` instances for the tab stave (`TabNote`, `BarNote`, etc.)
* @param autobeam automatically generate beams.
* @param params a configuration object:
* * `autobeam` automatically generates beams for the notes.
* * `align_rests` aligns rests with nearby notes.
*/
static FormatAndDrawTab(
ctx: RenderContext,
tabstave: TabStave,
stave: Stave,
tabnotes: TabNote[],
notes: Tickable[],
autobeam: boolean,
params: FormatParams
): void {
let opts = {
auto_beam: autobeam,
align_rests: false,
};
if (typeof params === 'object') {
opts = { ...opts, ...params };
} else if (typeof params === 'boolean') {
opts.auto_beam = params;
}
// Create a `4/4` voice for `notes`.
const notevoice = new Voice(Tables.TIME4_4).setMode(Voice.Mode.SOFT).addTickables(notes);
// Create a `4/4` voice for `tabnotes`.
const tabvoice = new Voice(Tables.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();
}
/**
* Automatically set the vertical position of rests based on previous/next note positions.
* @param tickables an array of Tickables.
* @param alignAllNotes If `false`, only align rests that are within a group of beamed notes.
* @param alignTuplets If `false`, ignores tuplets.
*/
static AlignRestsToNotes(tickables: Tickable[], alignAllNotes: boolean, alignTuplets?: boolean): void {
tickables.forEach((currTickable: Tickable, index: number) => {
if (isStaveNote(currTickable) && currTickable.isRest()) {
if (currTickable.getTuplet() && !alignTuplets) {
return;
}
// If activated rests not on default can be rendered as specified.
const position = currTickable.getGlyphProps().position.toUpperCase();
if (position !== 'R/4' && position !== 'B/4') {
return;
}
if (alignAllNotes || currTickable.getBeam()) {
// Align rests with previous/next notes.
const props = currTickable.getKeyProps()[0];
if (index === 0) {
props.line = getRestLineForNextNoteGroup(tickables, props.line, index, false);
} else if (index > 0 && index < tickables.length) {
// If previous tickable is a rest, use its line number.
const prevTickable = tickables[index - 1];
if (isStaveNote(prevTickable)) {
if (prevTickable.isRest()) {
props.line = prevTickable.getKeyProps()[0].line;
} else {
const restLine = prevTickable.getLineForRest();
// Get the rest line for next valid non-rest note group.
props.line = getRestLineForNextNoteGroup(tickables, restLine, index, true);
}
}
}
currTickable.setKeyLine(0, props.line);
}
}
});
}
constructor(options?: FormatterOptions) {
this.formatterOptions = {
globalSoftmax: false,
softmaxFactor: Tables.SOFTMAX_FACTOR,
maxIterations: 5,
...options,
};
this.justifyWidth = 0;
this.totalCost = 0;
this.totalShift = 0;
this.durationStats = {};
// 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;
// Arrays of tick and modifier contexts.
this.tickContexts = {
map: {},
array: [],
list: [],
resolutionMultiplier: 0,
};
this.modifierContexts = [];
// Gaps between contexts, for free movement of notes post
// formatting.
this.contextGaps = {
total: 0,
gaps: [],
};
this.voices = [];
this.lossHistory = [];
}
/**
* Find all the rests in each of the `voices` and align them to neighboring notes.
*
* @param voices
* @param alignAllNotes If `false`, only align rests within beamed groups of notes. If `true`, align all rests.
*/
alignRests(voices: Voice[], alignAllNotes: boolean): void {
if (!voices || !voices.length) {
throw new RuntimeError('BadArgument', 'No voices to format rests');
}
voices.forEach((voice) => Formatter.AlignRestsToNotes(voice.getTickables(), alignAllNotes));
}
/**
* Estimate the width required to render 'voices'. This is done by:
* 1. Sum the widths of all the tick contexts
* 2. Estimate the padding.
* The latter is done by calculating the padding 3 different ways, and taking the
* greatest value:
* 1. the padding required for unaligned notes in different voices
* 2. the padding based on the stddev of the tickable widths
* 3. the padding based on the stddev of the tickable durations.
*
* The last 2 quantities estimate a 'width entropy', where notes might need more
* room than the proportional formatting gives them. A measure of all same duration
* and width will need no extra padding, and all these quantities will be
* zero in that case.
*
* Note: joinVoices has to be called before calling preCalculateMinTotalWidth.
*
* @param voices the voices that contain the notes
* @returns the estimated width in pixels
*/
preCalculateMinTotalWidth(voices: Voice[]): number {
const unalignedPadding = Tables.currentMusicFont().lookupMetric('stave.unalignedNotePadding');
// Calculate additional padding based on 3 methods:
// 1) unaligned beats in voices, 2) variance of width, 3) variance of durations
let unalignedCtxCount = 0;
let wsum = 0;
let dsum = 0;
const widths: number[] = [];
const durations: number[] = [];
// Cache results.
if (this.hasMinTotalWidth) return this.minTotalWidth;
// Create tick contexts.
if (!voices) {
throw new RuntimeError('BadArgument', "'voices' required to run preCalculateMinTotalWidth");
}
this.createTickContexts(voices);
// eslint-disable-next-line
const { list: contextList, map: contextMap } = this.tickContexts!;
this.minTotalWidth = 0;
// Go through each tick context and calculate total width,
// and also accumulate values used in padding hints
contextList.forEach((tick) => {
const context = contextMap[tick];
context.preFormat();
// If this TC doesn't have all the voices on it, it's unaligned.
// so increment the unaligned padding accumulator
if (context.getTickables().length < voices.length) {
unalignedCtxCount += 1;
}
// Calculate the 'width entropy' over all the Tickables.
context.getTickables().forEach((t: Tickable) => {
wsum += t.getMetrics().width;
dsum += t.getTicks().value();
widths.push(t.getMetrics().width);
durations.push(t.getTicks().value());
});
const width = context.getWidth();
this.minTotalWidth += width;
});
this.hasMinTotalWidth = true;
// normalized (0-1) STDDEV of widths/durations gives us padding hints.
const wavg = wsum > 0 ? wsum / widths.length : 1 / widths.length;
const wvar = sumArray(widths.map((ll) => Math.pow(ll - wavg, 2)));
const wpads = Math.pow(wvar / widths.length, 0.5) / wavg;
const davg = dsum / durations.length;
const dvar = sumArray(durations.map((ll) => Math.pow(ll - davg, 2)));
const dpads = Math.pow(dvar / durations.length, 0.5) / davg;
// Find max of 3 methods pad the width with that
const padmax = Math.max(dpads, wpads) * contextList.length * unalignedPadding;
const unalignedPad = unalignedPadding * unalignedCtxCount;
return this.minTotalWidth + Math.max(unalignedPad, padmax);
}
/**
* Get minimum width required to render all voices. Either `format` or
* `preCalculateMinTotalWidth` must be called before this method.
*/
getMinTotalWidth(): number {
if (!this.hasMinTotalWidth) {
throw new RuntimeError(
'NoMinTotalWidth',
"Call 'preCalculateMinTotalWidth' or 'preFormat' before calling 'getMinTotalWidth'"
);
}
return this.minTotalWidth;
}
/** Calculate the resolution multiplier for `voices`. */
static getResolutionMultiplier(voices: Voice[]): number {
if (!voices || !voices.length) {
throw new RuntimeError('BadArgument', 'No voices to format');
}
const totalTicks = voices[0].getTotalTicks();
const resolutionMultiplier = voices.reduce((accumulator, voice) => {
if (!voice.getTotalTicks().equals(totalTicks)) {
throw new RuntimeError('TickMismatch', 'Voices should have same total note duration in ticks.');
}
if (voice.getMode() === Voice.Mode.STRICT && !voice.isComplete()) {
throw new RuntimeError('IncompleteVoice', 'Voice does not have enough notes.');
}
return Math.max(accumulator, Fraction.LCM(accumulator, voice.getResolutionMultiplier()));
}, 1);
return resolutionMultiplier;
}
/** Create a `ModifierContext` for each tick in `voices`. */
createModifierContexts(voices: Voice[]) {
if (voices.length == 0) return;
const resolutionMultiplier = Formatter.getResolutionMultiplier(voices);
// Initialize tick maps.
const tickToContextMap: Map<Stave | undefined, Record<number, ModifierContext>> = new Map();
const contexts: ModifierContext[] = [];
// 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 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;
let staveTickToContextMap = tickToContextMap.get(tickable.getStave());
// If we have no tick context for this tick, create one.
if (!staveTickToContextMap) {
tickToContextMap.set(tickable.getStave(), {});
staveTickToContextMap = tickToContextMap.get(tickable.getStave());
}
if (!(staveTickToContextMap ? staveTickToContextMap[integerTicks] : undefined)) {
const newContext = new ModifierContext();
contexts.push(newContext);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
staveTickToContextMap![integerTicks] = newContext;
}
// Add this tickable to the TickContext.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
tickable.addToModifierContext(staveTickToContextMap![integerTicks]);
ticksUsed.add(tickable.getTicks());
});
});
this.modifierContexts.push({
map: tickToContextMap,
array: contexts,
resolutionMultiplier,
});
}
/**
* Create a `TickContext` for each tick in `voices`. Also calculate the
* total number of ticks in voices.
*/
createTickContexts(voices: Voice[]): AlignmentContexts<TickContext> {
const fn: addToContextFn<TickContext> = (tickable: Tickable, context: TickContext, voiceIndex: number) =>
context.addTickable(tickable, voiceIndex);
const contexts = createContexts(voices, (tick?: { tickID: number }) => new TickContext(tick), fn);
this.tickContexts = contexts;
const contextArray = this.tickContexts.array;
contextArray.forEach((context) => {
context.tContexts = contextArray;
});
return contexts;
}
/**
* Get the AlignmentContexts of TickContexts that were created by createTickContexts.
* Returns undefined if createTickContexts has not yet been run.
*/
getTickContexts(): AlignmentContexts<TickContext> | undefined {
return this.tickContexts;
}
/**
* This is the core formatter logic. Format voices and justify them
* to `justifyWidth` pixels. `renderingContext` is required to justify elements
* that can't retrieve widths without a canvas. This method sets the `x` positions
* of all the tickables/notes in the formatter.
*/
preFormat(justifyWidth = 0, renderingContext?: RenderContext, voicesParam?: Voice[], stave?: Stave): number {
// Initialize context maps.
const contexts = this.tickContexts;
if (!contexts) {
throw new RuntimeError('NoTickContexts', 'preFormat requires TickContexts');
}
const { list: contextList, map: contextMap } = contexts;
// Reset loss history for evaluator.
this.lossHistory = [];
// If voices and a stave were provided, set the Stave for each voice
// and preFormat to apply Y values to the notes;
if (voicesParam && stave) {
voicesParam.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;
this.minTotalWidth = 0;
let totalTicks = 0;
// Pass 1: Give each note maximum width requested by context.
contextList.forEach((tick) => {
const context = contextMap[tick];
// Make sure that all tickables in this context have calculated their
// space requirements.
context.preFormat();
const width = context.getWidth();
this.minTotalWidth += width;
const maxTicks = context.getMaxTicks().value();
totalTicks += maxTicks;
const metrics = context.getMetrics();
x = x + shift + metrics.totalLeftPx;
context.setX(x);
// Calculate shift for the next tick.
shift = width - metrics.totalLeftPx;
});
// Use softmax based on all notes across all staves. (options.globalSoftmax)
const { globalSoftmax, softmaxFactor, maxIterations } = this.formatterOptions;
const exp = (tick: number) => softmaxFactor ** (contextMap[tick].getMaxTicks().value() / totalTicks);
const expTicksUsed = sumArray(contextList.map(exp));
this.minTotalWidth = x + shift;
this.hasMinTotalWidth = true;
// No justification needed. End formatting.
if (justifyWidth <= 0) return this.evaluate();
// Start justification. Subtract the right extra pixels of the final context because the formatter
// justifies based on the context's X position, which is the left-most part of the note head.
const firstContext = contextMap[contextList[0]];
const lastContext = contextMap[contextList[contextList.length - 1]];
// Calculate the "distance error" between the tick contexts. The expected distance is the spacing proportional to
// the softmax of the ticks.
function calculateIdealDistances(adjustedJustifyWidth: number): Distance[] {
const distances: Distance[] = contextList.map((tick: number, i: number) => {
const context: TickContext = contextMap[tick];
const voices = context.getTickablesByVoice();
let backTickable: Tickable | undefined;
if (i > 0) {
const prevContext: TickContext = contextMap[contextList[i - 1]];
// Go through each tickable and search backwards for another tickable
// in the same voice. If found, use that duration (ticks) to calculate
// the expected distance.
for (let j = i - 1; j >= 0; j--) {
const backTick: TickContext = contextMap[contextList[j]];
const backVoices = backTick.getTickablesByVoice();
// Look for matching voices between tick contexts.
const matchingVoices: string[] = [];
Object.keys(voices).forEach((v) => {
if (backVoices[v]) {
matchingVoices.push(v);
}
});
if (matchingVoices.length > 0) {
// Found matching voices, get largest duration
let maxTicks = 0;
let maxNegativeShiftPx = Infinity;
let expectedDistance = 0;
matchingVoices.forEach((v) => {
const ticks = backVoices[v].getTicks().value();
if (ticks > maxTicks) {
backTickable = backVoices[v];
maxTicks = ticks;
}
// Calculate the limits of the shift based on modifiers, etc.
const thisTickable = voices[v];
const insideLeftEdge =
thisTickable.getX() -
(thisTickable.getMetrics().modLeftPx + thisTickable.getMetrics().leftDisplacedHeadPx);
const backMetrics = backVoices[v].getMetrics();
const insideRightEdge =
backVoices[v].getX() + backMetrics.notePx + backMetrics.modRightPx + backMetrics.rightDisplacedHeadPx;
// Don't allow shifting if notes in the same voice can collide
maxNegativeShiftPx = Math.min(maxNegativeShiftPx, insideLeftEdge - insideRightEdge);
});
// Don't shift further left than the notehead of the last context. Actually, stay at most 5% to the right
// so that two different tick contexts don't align across staves.
maxNegativeShiftPx = Math.min(
maxNegativeShiftPx,
context.getX() - (prevContext.getX() + adjustedJustifyWidth * 0.05)
);
// Calculate the expected distance of the current context from the last matching tickable. The
// distance is scaled down by the softmax for the voice.
if (globalSoftmax) {
const t = totalTicks;
expectedDistance = (softmaxFactor ** (maxTicks / t) / expTicksUsed) * adjustedJustifyWidth;
} else if (typeof backTickable !== 'undefined') {
expectedDistance = backTickable.getVoice().softmax(maxTicks) * adjustedJustifyWidth;
}
return {
expectedDistance,
maxNegativeShiftPx,
fromTickable: backTickable,
};
}
}
}
return { expectedDistance: 0, fromTickablePx: 0, maxNegativeShiftPx: 0 };
});
return distances;
}
function shiftToIdealDistances(idealDistances: Distance[]): number {
// Distribute ticks to the contexts based on the calculated distance error.
const centerX = adjustedJustifyWidth / 2;
let spaceAccum = 0;
contextList.forEach((tick, index) => {
const context = contextMap[tick];
if (index > 0) {
const contextX = context.getX();
const ideal = idealDistances[index];
const errorPx = defined(ideal.fromTickable).getX() + ideal.expectedDistance - (contextX + spaceAccum);
let negativeShiftPx = 0;
if (errorPx > 0) {
spaceAccum += errorPx;
} else if (errorPx < 0) {
negativeShiftPx = Math.min(ideal.maxNegativeShiftPx, Math.abs(errorPx));
spaceAccum += -negativeShiftPx;
}
context.setX(contextX + spaceAccum);
}
// Move center aligned tickables to middle
context.getCenterAlignedTickables().forEach((tickable: Tickable) => {
tickable.setCenterXShift(centerX - context.getX());
});
});
return lastContext.getX() - firstContext.getX();
}
const adjustedJustifyWidth =
justifyWidth -
lastContext.getMetrics().notePx -
lastContext.getMetrics().totalRightPx -
firstContext.getMetrics().totalLeftPx;
const musicFont = Tables.currentMusicFont();
const configMinPadding = musicFont.lookupMetric('stave.endPaddingMin');
const configMaxPadding = musicFont.lookupMetric('stave.endPaddingMax');
const leftPadding = musicFont.lookupMetric('stave.padding');
let targetWidth = adjustedJustifyWidth;
const distances = calculateIdealDistances(targetWidth);
let actualWidth = shiftToIdealDistances(distances);
// Just one context. Done formatting.
if (contextList.length === 1) return 0;
const calcMinDistance = (targetWidth: number, distances: Distance[]) => {
let mdCalc = targetWidth / 2;
if (distances.length > 1) {
for (let di = 1; di < distances.length; ++di) {
mdCalc = Math.min(distances[di].expectedDistance / 2, mdCalc);
}
}
return mdCalc;
};
const minDistance = calcMinDistance(targetWidth, distances);
// right justify to either the configured padding, or the min distance between notes, whichever is greatest.
// This * 2 keeps the existing formatting unless there is 'a lot' of extra whitespace, which won't break
// existing visual regression tests.
const paddingMaxCalc = (curTargetWidth: number) => {
let lastTickablePadding = 0;
const lastTickable = lastContext && lastContext.getMaxTickable();
if (lastTickable) {
const voice = lastTickable.getVoice();
// If the number of actual ticks in the measure <> configured ticks, right-justify
// because the softmax won't yield the correct value
if (voice.getTicksUsed().value() > voice.getTotalTicks().value()) {
return configMaxPadding * 2 < minDistance ? minDistance : configMaxPadding;
}
const tickWidth = lastTickable.getWidth();
lastTickablePadding =
voice.softmax(lastContext.getMaxTicks().value()) * curTargetWidth - (tickWidth + leftPadding);
}
return configMaxPadding * 2 < lastTickablePadding ? lastTickablePadding : configMaxPadding;
};
let paddingMax = paddingMaxCalc(targetWidth);
let paddingMin = paddingMax - (configMaxPadding - configMinPadding);
const maxX = adjustedJustifyWidth - paddingMin;
let iterations = maxIterations;
// Adjust justification width until the right margin is as close as possible to the calculated padding,
// without going over
while ((actualWidth > maxX && iterations > 0) || (actualWidth + paddingMax < maxX && iterations > 1)) {
targetWidth -= actualWidth - maxX;
paddingMax = paddingMaxCalc(targetWidth);
paddingMin = paddingMax - (configMaxPadding - configMinPadding);
actualWidth = shiftToIdealDistances(calculateIdealDistances(targetWidth));
iterations--;
}
this.justifyWidth = justifyWidth;
return this.evaluate();
}
/** Calculate the total cost of this formatting decision. */
evaluate(): number {
const contexts = this.tickContexts;
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: [] };
contexts.list.forEach((tick, index) => {
if (index === 0) return;
const prevTick = contexts.list[index - 1];
const prevContext = contexts.map[prevTick];
const context = contexts.map[tick];
const prevMetrics = prevContext.getMetrics();
const currMetrics = context.getMetrics();
// Calculate X position of right edge of previous note
const insideRightEdge = prevContext.getX() + prevMetrics.notePx + prevMetrics.totalRightPx;
// Calculate X position of left edge of current note
const insideLeftEdge = context.getX() - currMetrics.totalLeftPx;
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.
this.durationStats = {};
const durationStats = this.durationStats;
function updateStats(duration: string, space: number) {
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.notePx + metrics.modRightPx + metrics.rightDisplacedHeadPx;
let space = 0;
if (i < notes.length - 1) {
const rightNote = notes[i + 1];
const rightMetrics = rightNote.getMetrics();
const rightNoteEdge = rightNote.getX() - rightMetrics.modLeftPx - rightMetrics.leftDisplacedHeadPx;
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.space.mean = durationStats[duration].mean;
metrics.duration = duration;
metrics.iterations += 1;
metrics.space.deviation = metrics.space.used - metrics.space.mean;
totalDeviation += metrics.space.deviation ** 2;
});
});
this.totalCost = Math.sqrt(totalDeviation);
this.lossHistory.push(this.totalCost);
return this.totalCost;
}
/**
* 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.
* @param options[alpha] the "learning rate" for the formatter. It determines how much of a shift
* the formatter should make based on its cost function.
*/
tune(options?: { alpha?: number }): number {
const contexts = this.tickContexts;
if (!contexts) {
return 0;
}
const alpha = options?.alpha ?? 0.5;
// Move `current` tickcontext by `shift` pixels, and adjust the freedom
// on adjacent tickcontexts.
function move(current: TickContext, shift: number, prev?: TickContext, next?: TickContext) {
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.totalShift = 0;
contexts.list.forEach((tick, index, list) => {
const context = contexts.map[tick];
const prevContext = index > 0 ? contexts.map[list[index - 1]] : undefined;
const nextContext = index < list.length - 1 ? contexts.map[list[index + 1]] : undefined;
move(context, shift, prevContext, nextContext);
const cost = -sumArray(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;
}
}
shift *= alpha;
this.totalShift += shift;
});
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(): this {
this.modifierContexts.forEach((modifierContexts) => {
modifierContexts.array.forEach((mc) => mc.postFormat());
});
this.tickContexts.list.forEach((tick) => {
this.tickContexts.map[tick].postFormat();
});
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: Voice[]): this {
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: Voice[], justifyWidth?: number, options?: FormatParams): this {
const opts = {
align_rests: false,
...options,
};
this.voices = voices;
const softmaxFactor = this.formatterOptions.softmaxFactor;
if (softmaxFactor) {
this.voices.forEach((v) => v.setSoftmaxFactor(softmaxFactor));
}
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: Voice[], stave: Stave, optionsParam?: FormatParams): this {
const options: FormatParams = { context: stave.getContext(), ...optionsParam };
// eslint-disable-next-line
const justifyWidth = stave.getNoteEndX() - stave.getNoteStartX() - Stave.defaultPadding;
L('Formatting voices to width: ', justifyWidth);
return this.format(voices, justifyWidth, options);
}
getTickContext(tick: number): TickContext | undefined {
return this.tickContexts?.map[tick];
}
}