UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature

701 lines (590 loc) 24.6 kB
// [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) => { 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); } }