Skip to content

Commit

Permalink
Merge pull request #1055 from AaronDavidNewman/master
Browse files Browse the repository at this point in the history
Calculate preCalculateMinWidth with enough (but not too much) padding (Issue 826)
  • Loading branch information
0xfe authored Jul 6, 2021
2 parents 31384d4 + eb4f309 commit 8d8137e
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 107 deletions.
1 change: 1 addition & 0 deletions src/fonts/bravura_metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const BravuraMetrics = {
padding: 12,
endPaddingMax: 10,
endPaddingMin: 5,
unalignedNotePadding: 10
},
accidental: {
noteheadAccidentalPadding: 1,
Expand Down
1 change: 1 addition & 0 deletions src/fonts/gonville_metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const GonvilleMetrics = {
padding: 12,
endPaddingMax: 10,
endPaddingMin: 5,
unalignedNotePadding: 10
},
accidental: {
noteheadAccidentalPadding: 1,
Expand Down
1 change: 1 addition & 0 deletions src/fonts/petaluma_metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const PetalumaMetrics = {
padding: 15,
endPaddingMax: 15,
endPaddingMin: 7,
unalignedNotePadding: 12
},
accidental: {
noteheadAccidentalPadding: 1,
Expand Down
76 changes: 63 additions & 13 deletions src/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ export class Formatter {
constructor(formatterOptions: Partial<FormatterOptions> = {}) {
this.formatterOptions = {
globalSoftmax: false,
maxIterations: 2,
maxIterations: 5,
...formatterOptions,
};
this.justifyWidth = 0;
Expand Down Expand Up @@ -411,8 +411,34 @@ export class Formatter {
voices.forEach((voice) => Formatter.AlignRestsToNotes(voice.getTickables(), alignAllNotes));
}

// Calculate the minimum width required to align and format `voices`.
/**
* 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.
*
* @param {Voice []} voices - the voices that contain the notes
* @returns {number} - the estimated width in pixels
*/
preCalculateMinTotalWidth(voices: Voice[]): number {
const unalignedPadding = Flow.DEFAULT_FONT_STACK[0].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;

Expand All @@ -427,20 +453,44 @@ export class Formatter {

// eslint-disable-next-line
const { list: contextList, map: contextMap } = this.tickContexts!;
this.minTotalWidth = 0;

// const maxTicks = contextList.map(tick => tick.maxTicks.value()).reduce((a, b) => a + b, 0);
// 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: number, b: number) => a + b, 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((tt) => {
wsum += tt.getMetrics().width;
dsum += tt.getTicks().value();
widths.push(tt.getMetrics().width);
durations.push(tt.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 = widths.map((ll) => Math.pow(ll - wavg, 2)).reduce((a, b) => a + b);
const wpads = Math.pow(wvar / widths.length, 0.5) / wavg;

return this.minTotalWidth;
const davg = dsum / durations.length;
const dvar = durations.map((ll) => Math.pow(ll - davg, 2)).reduce((a, b) => a + b);
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
Expand Down Expand Up @@ -692,7 +742,7 @@ export class Formatter {
const musicFont = Flow.DEFAULT_FONT_STACK[0];
const paddingMax = musicFont.lookupMetric('stave.endPaddingMax');
const paddingMin = musicFont.lookupMetric('stave.endPaddingMin');
const maxX = adjustedJustifyWidth + lastContext.getMetrics().notePx - paddingMin;
const maxX = adjustedJustifyWidth - paddingMin;

let iterations = this.formatterOptions.maxIterations;
while ((actualWidth > maxX && iterations > 0) || (actualWidth + paddingMax < maxX && iterations > 1)) {
Expand Down
187 changes: 93 additions & 94 deletions tests/formatter_tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { MockTickable } from './mocks';
const FormatterTests = (function () {
var run = VF.Test.runTests;
var runSVG = VF.Test.runSVGTest;
// Should this be a static call in glyph? Or font?
const glyphWidth = (vexGlyph) => {
const vf = VF.DEFAULT_FONT_STACK[0].getGlyphs()[vexGlyph];
return (vf.x_max - vf.x_min) * glyphPixels();
};
const glyphPixels = () => {
return 96 * (38 / (VF.DEFAULT_FONT_STACK[0].getResolution() * 72));
};

var Formatter = {
Start: () => {
Expand All @@ -21,7 +29,7 @@ const FormatterTests = (function () {
});
runSVG('StaveNote - Justification', Formatter.justifyStaveNotes);
runSVG('Notes with Tab', Formatter.notesWithTab);
runSVG('Multiple Staves - Justified', Formatter.multiStaves, { justify: true, iterations: 0 });
runSVG('Multiple Staves - Justified', Formatter.multiStaves, { debug: true });
runSVG('Softmax', Formatter.softMax);
runSVG('Mixtime', Formatter.mixTime);
runSVG('Tight', Formatter.tightNotes);
Expand Down Expand Up @@ -117,21 +125,17 @@ const FormatterTests = (function () {
const voice2 = new VF.Voice().setMode(VF.Voice.Mode.Soft);
voice2.addTickables(notes2);
voice1.addTickables(notes1);
var formatter = vf.Formatter().joinVoices([voice1]).joinVoices([voice2]);
const width = formatter.preCalculateMinTotalWidth([voice1, voice2]);
formatter.format([voice1, voice2], width);
const stave1 = vf.Stave({
y: 50,
width: 1500,
width: width + VF.Stave.defaultPadding,
});
const stave2 = vf.Stave({
y: 200,
width: 1500,
});
vf.StaveConnector({
top_stave: stave1,
bottom_stave: stave2,
type: 'brace',
width: width + VF.Stave.defaultPadding,
});
var formatter = vf.Formatter().joinVoices([voice1]).joinVoices([voice2]);
formatter.format([voice1, voice2], 1500);
stave1.draw();
stave2.draw();
voice1.draw(vf.context, stave1);
Expand All @@ -152,19 +156,12 @@ const FormatterTests = (function () {
var beams = VF.Beam.generateBeams(notes11.slice(2));
beams = beams.concat(beams, VF.Beam.generateBeams(notes21.slice(1, 3)));
beams = beams.concat(VF.Beam.generateBeams(notes21.slice(3)));
var formatter = vf.Formatter({ softmaxFactor: 10 }).joinVoices([voice11]).joinVoices([voice21]);
var formatter = vf.Formatter({ softmaxFactor: 100 }).joinVoices([voice11]).joinVoices([voice21]);

var width = formatter.preCalculateMinTotalWidth([voice11, voice21]) + 50;
var stave11 = vf.Stave({ y: 20, width: width + 30 });
var stave21 = vf.Stave({ y: 130, width: width + 30 });
var width = formatter.preCalculateMinTotalWidth([voice11, voice21]);
var stave11 = vf.Stave({ y: 20, width: width + VF.Stave.defaultPadding });
var stave21 = vf.Stave({ y: 130, width: width + VF.Stave.defaultPadding });
formatter.format([voice11, voice21], width);

vf.StaveConnector({
top_stave: stave11,
bottom_stave: stave21,
type: 'brace',
});

var ctx = vf.getContext();
stave11.setContext(ctx).draw();
stave21.setContext(ctx).draw();
Expand Down Expand Up @@ -200,9 +197,9 @@ const FormatterTests = (function () {
formatter.joinVoices([voice11]);
formatter.joinVoices([voice21]);

var width = formatter.preCalculateMinTotalWidth([voice11, voice21]) + 50;
var stave11 = vf.Stave({ y: 20, width: width + 20 });
var stave21 = vf.Stave({ y: 130, width: width + 20 });
var width = formatter.preCalculateMinTotalWidth([voice11, voice21]);
var stave11 = vf.Stave({ y: 20, width: width + VF.Stave.defaultPadding });
var stave21 = vf.Stave({ y: 130, width: width + VF.Stave.defaultPadding });
formatter.format([voice11, voice21], width);
stave11.setContext(ctx).draw();
stave21.setContext(ctx).draw();
Expand Down Expand Up @@ -253,14 +250,14 @@ const FormatterTests = (function () {
var voice2 = new VF.Voice({ num_beats: 4, beat_value: 4 });
voice2.addTickables(notes2);

var formatter = new VF.Formatter({ softmaxFactor: 10, globalSoftmax: options.params.globalSoftmax });
var formatter = new VF.Formatter({ softmaxFactor: 100, globalSoftmax: options.params.globalSoftmax });
formatter.joinVoices([voice1]);
formatter.joinVoices([voice2]);
var width = formatter.preCalculateMinTotalWidth([voice1, voice2]);

formatter.format([voice1, voice2], width);
var stave1 = new VF.Stave(10, 40, width + 50);
var stave2 = new VF.Stave(10, 100, width + 50);
var stave1 = new VF.Stave(10, 40, width + VF.Stave.defaultPadding);
var stave2 = new VF.Stave(10, 100, width + VF.Stave.defaultPadding);
stave1.setContext(context).draw();
stave2.setContext(context).draw();
voice1.draw(context, stave1);
Expand Down Expand Up @@ -353,90 +350,92 @@ const FormatterTests = (function () {

multiStaves: (options) => {
var vf = VF.Test.makeFactory(options, 600, 400);
var ctx = vf.getContext();
var score = vf.EasyScore();

var stave11 = vf.Stave({ y: 20, width: 275 }).addTrebleGlyph().addTimeSignature('6/8');
var staves = [];
var beams = [];
var voices = [];

var notes11 = score.notes('f4/4, d4/8, g4/4, eb4/8');
var voice11 = score.voice(notes11, { time: '6/8' });

var stave21 = vf.Stave({ y: 130, width: 275 }).addTrebleGlyph().addTimeSignature('6/8');
voices.push(score.voice(notes11, { time: '6/8' }));

var notes21 = score.notes('d4/8, d4, d4, d4, e4, eb4');
var voice21 = score.voice(notes21, { time: '6/8' });

var stave31 = vf.Stave({ y: 250, width: 275 }).addClef('bass').addTimeSignature('6/8');
voices.push(score.voice(notes21, { time: '6/8' }));

var notes31 = score.notes('a5/8, a5, a5, a5, a5, a5', { stem: 'down' });
var voice31 = score.voice(notes31, { time: '6/8' });
voices.push(score.voice(notes31, { time: '6/8' }));

var formatter = vf.Formatter();
voices.forEach((vv) => formatter.joinVoices([vv]));
var width = formatter.preCalculateMinTotalWidth(voices);
var staveWidth = width + glyphWidth('gClef') + glyphWidth('timeSig8') + VF.Stave.defaultPadding;

staves.push(vf.Stave({ y: 20, width: staveWidth }).addTrebleGlyph().addTimeSignature('6/8'));
staves.push(vf.Stave({ y: 130, width: staveWidth }).addTrebleGlyph().addTimeSignature('6/8'));
staves.push(vf.Stave({ y: 250, width: staveWidth }).addClef('bass').addTimeSignature('6/8'));
formatter.format(voices, width);
beams.push(new VF.Beam(notes21.slice(0, 3), true));
beams.push(new VF.Beam(notes21.slice(3, 6), true));
beams.push(new VF.Beam(notes31.slice(0, 3), true));
beams.push(new VF.Beam(notes31.slice(3, 6), true));

vf.StaveConnector({
top_stave: stave21,
bottom_stave: stave31,
top_stave: staves[1],
bottom_stave: staves[2],
type: 'brace',
});

vf.Beam({ notes: notes21.slice(0, 3) });
vf.Beam({ notes: notes21.slice(3, 6) });
vf.Beam({ notes: notes31.slice(0, 3) });
vf.Beam({ notes: notes31.slice(3, 6) });

var formatter = vf.Formatter().joinVoices([voice11]).joinVoices([voice21]).joinVoices([voice31]);

if (options.params.justify) {
formatter.formatToStave([voice11, voice21, voice31], stave11);
} else {
formatter.format([voice11, voice21, voice31], 0);
}

for (var i = 0; i < options.params.iterations; i++) {
formatter.tune();
for (var i = 0; i < staves.length; ++i) {
staves[i].setContext(ctx).draw();
voices[i].draw(ctx, staves[i]);
}
beams.forEach((beam) => beam.setContext(ctx).draw());

var stave12 = vf.Stave({
x: stave11.width + stave11.x,
y: stave11.y,
width: stave11.width,
});

const x = staves[0].x + staves[0].width;
const ys = staves.map((ss) => ss.y);
voices = [];
staves = [];
var notes12 = score.notes('ab4/4, bb4/8, (cb5 eb5)/4[stem="down"], d5/8[stem="down"]');
var voice12 = score.voice(notes12, { time: '6/8' });

vf.Stave({
x: stave21.width + stave21.x,
y: stave21.y,
width: stave21.width,
});

voices.push(score.voice(notes12, { time: '6/8' }));
var notes22 = score.notes('(eb4 ab4)/4., (c4 eb4 ab4)/4, db5/8', { stem: 'up' });
var voice22 = score.voice(notes22, { time: '6/8' });

vf.Stave({
x: stave31.width + stave31.x,
y: stave31.y,
width: stave31.width,
});

voices.push(score.voice(notes22, { time: '6/8' }));
var notes32 = score.notes('a5/8, a5, a5, a5, a5, a5', { stem: 'down' });
var voice32 = score.voice(notes32, { time: '6/8' });

formatter = vf.Formatter().joinVoices([voice12]).joinVoices([voice22]).joinVoices([voice32]);

if (options.params.justify) {
formatter.formatToStave([voice12, voice22, voice32], stave12);
} else {
formatter.format([voice12, voice22, voice32], 0);
}

for (var j = 0; j < options.params.iterations; j++) {
formatter.tune();
voices.push(score.voice(notes32, { time: '6/8' }));

formatter = vf.Formatter();
voices.forEach((vv) => formatter.joinVoices([vv]));
width = formatter.preCalculateMinTotalWidth(voices);
staveWidth = width + VF.Stave.defaultPadding;
staves.push(
vf.Stave({
x,
y: ys[0],
width: staveWidth,
})
);
staves.push(
vf.Stave({
x,
y: ys[1],
width: staveWidth,
})
);
staves.push(
vf.Stave({
x,
y: ys[2],
width: staveWidth,
})
);
formatter.format(voices, width);
beams = [];
beams.push(new VF.Beam(notes32.slice(0, 3), true));
beams.push(new VF.Beam(notes32.slice(3, 6), true));
for (i = 0; i < staves.length; ++i) {
staves[i].setContext(ctx).draw();
voices[i].draw(ctx, staves[i]);
voices[i].getTickables().forEach((note) => VF.Note.plotMetrics(ctx, note, ys[i] - 20));
}

vf.Beam({ notes: notes32.slice(0, 3) });
vf.Beam({ notes: notes32.slice(3, 6) });

vf.draw();

beams.forEach((beam) => beam.setContext(ctx).draw());
ok(true);
},

Expand Down

0 comments on commit 8d8137e

Please sign in to comment.