vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature
831 lines (712 loc) • 27.3 kB
JavaScript
// [VexFlow](http://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
//
// ## Description
//
// This file implements `Beams` that span over a set of `StemmableNotes`.
import { Vex } from './vex';
import { Flow } from './tables';
import { Element } from './element';
import { Fraction } from './fraction';
import { Tuplet } from './tuplet';
import { Stem } from './stem';
function calculateStemDirection(notes) {
let lineSum = 0;
notes.forEach(note => {
if (note.keyProps) {
note.keyProps.forEach(keyProp => {
lineSum += (keyProp.line - 3);
});
}
});
if (lineSum >= 0) {
return Stem.DOWN;
}
return Stem.UP;
}
const getStemSlope = (firstNote, lastNote) => {
const firstStemTipY = firstNote.getStemExtents().topY;
const firstStemX = firstNote.getStemX();
const lastStemTipY = lastNote.getStemExtents().topY;
const lastStemX = lastNote.getStemX();
return (lastStemTipY - firstStemTipY) / (lastStemX - firstStemX);
};
export class Beam extends Element {
// Gets the default beam groups for a provided time signature.
// Attempts to guess if the time signature is not found in table.
// Currently this is fairly naive.
static getDefaultBeamGroups(time_sig) {
if (!time_sig || time_sig === 'c') {
time_sig = '4/4';
}
const defaults = {
'1/2': ['1/2'],
'2/2': ['1/2'],
'3/2': ['1/2'],
'4/2': ['1/2'],
'1/4': ['1/4'],
'2/4': ['1/4'],
'3/4': ['1/4'],
'4/4': ['1/4'],
'1/8': ['1/8'],
'2/8': ['2/8'],
'3/8': ['3/8'],
'4/8': ['2/8'],
'1/16': ['1/16'],
'2/16': ['2/16'],
'3/16': ['3/16'],
'4/16': ['2/16'],
};
const groups = defaults[time_sig];
if (groups === undefined) {
// If no beam groups found, naively determine
// the beam groupings from the time signature
const beatTotal = parseInt(time_sig.split('/')[0], 10);
const beatValue = parseInt(time_sig.split('/')[1], 10);
const tripleMeter = beatTotal % 3 === 0;
if (tripleMeter) {
return [new Fraction(3, beatValue)];
} else if (beatValue > 4) {
return [new Fraction(2, beatValue)];
} else if (beatValue <= 4) {
return [new Fraction(1, beatValue)];
}
} else {
return groups.map(group => new Fraction().parse(group));
}
return [new Fraction(1, 4)];
}
// A helper function to automatically build basic beams for a voice. For more
// complex auto-beaming use `Beam.generateBeams()`.
//
// Parameters:
// * `voice` - The voice to generate the beams for
// * `stem_direction` - A stem direction to apply to the entire voice
// * `groups` - An array of `Fraction` representing beat groupings for the beam
static applyAndGetBeams(voice, stem_direction, groups) {
return Beam.generateBeams(voice.getTickables(), {
groups,
stem_direction,
});
}
// A helper function to autimatically build beams for a voice with
// configuration options.
//
// Example configuration object:
//
// ```
// config = {
// groups: [new Vex.Flow.Fraction(2, 8)],
// stem_direction: -1,
// beam_rests: true,
// beam_middle_only: true,
// show_stemlets: false
// };
// ```
//
// Parameters:
// * `notes` - An array of notes to create the beams for
// * `config` - The configuration object
// * `groups` - Array of `Fractions` that represent the beat structure to beam the notes
// * `stem_direction` - Set to apply the same direction to all notes
// * `beam_rests` - Set to `true` to include rests in the beams
// * `beam_middle_only` - Set to `true` to only beam rests in the middle of the beat
// * `show_stemlets` - Set to `true` to draw stemlets for rests
// * `maintain_stem_directions` - Set to `true` to not apply new stem directions
//
static generateBeams(notes, config) {
if (!config) config = {};
if (!config.groups || !config.groups.length) {
config.groups = [new Fraction(2, 8)];
}
// Convert beam groups to tick amounts
const tickGroups = config.groups.map(group => {
if (!group.multiply) {
throw new Vex.RuntimeError('InvalidBeamGroups',
'The beam groups must be an array of Vex.Flow.Fractions');
}
return group.clone().multiply(Flow.RESOLUTION, 1);
});
const unprocessedNotes = notes;
let currentTickGroup = 0;
let noteGroups = [];
let currentGroup = [];
function getTotalTicks(vf_notes) {
return vf_notes.reduce((memo, note) => note.getTicks().clone().add(memo), new Fraction(0, 1));
}
function nextTickGroup() {
if (tickGroups.length - 1 > currentTickGroup) {
currentTickGroup += 1;
} else {
currentTickGroup = 0;
}
}
function createGroups() {
let nextGroup = [];
unprocessedNotes.forEach(unprocessedNote => {
nextGroup = [];
if (unprocessedNote.shouldIgnoreTicks()) {
noteGroups.push(currentGroup);
currentGroup = nextGroup;
return; // Ignore untickables (like bar notes)
}
currentGroup.push(unprocessedNote);
const ticksPerGroup = tickGroups[currentTickGroup].clone();
const totalTicks = getTotalTicks(currentGroup);
// Double the amount of ticks in a group, if it's an unbeamable tuplet
const unbeamable = Flow.durationToNumber(unprocessedNote.duration) < 8;
if (unbeamable && unprocessedNote.tuplet) {
ticksPerGroup.numerator *= 2;
}
// If the note that was just added overflows the group tick total
if (totalTicks.greaterThan(ticksPerGroup)) {
// If the overflow note can be beamed, start the next group
// with it. Unbeamable notes leave the group overflowed.
if (!unbeamable) {
nextGroup.push(currentGroup.pop());
}
noteGroups.push(currentGroup);
currentGroup = nextGroup;
nextTickGroup();
} else if (totalTicks.equals(ticksPerGroup)) {
noteGroups.push(currentGroup);
currentGroup = nextGroup;
nextTickGroup();
}
});
// Adds any remainder notes
if (currentGroup.length > 0) {
noteGroups.push(currentGroup);
}
}
function getBeamGroups() {
return noteGroups.filter(group => {
if (group.length > 1) {
let beamable = true;
group.forEach(note => {
if (note.getIntrinsicTicks() >= Flow.durationToTicks('4')) {
beamable = false;
}
});
return beamable;
}
return false;
});
}
// Splits up groups by Rest
function sanitizeGroups() {
const sanitizedGroups = [];
noteGroups.forEach(group => {
let tempGroup = [];
group.forEach((note, index, group) => {
const isFirstOrLast = index === 0 || index === group.length - 1;
const prevNote = group[index - 1];
const breaksOnEachRest = !config.beam_rests && note.isRest();
const breaksOnFirstOrLastRest = (config.beam_rests &&
config.beam_middle_only && note.isRest() && isFirstOrLast);
let breakOnStemChange = false;
if (config.maintain_stem_directions && prevNote &&
!note.isRest() && !prevNote.isRest()) {
const prevDirection = prevNote.getStemDirection();
const currentDirection = note.getStemDirection();
breakOnStemChange = currentDirection !== prevDirection;
}
const isUnbeamableDuration = parseInt(note.duration, 10) < 8;
// Determine if the group should be broken at this note
const shouldBreak = breaksOnEachRest || breaksOnFirstOrLastRest ||
breakOnStemChange || isUnbeamableDuration;
if (shouldBreak) {
// Add current group
if (tempGroup.length > 0) {
sanitizedGroups.push(tempGroup);
}
// Start a new group. Include the current note if the group
// was broken up by stem direction, as that note needs to start
// the next group of notes
tempGroup = breakOnStemChange ? [note] : [];
} else {
// Add note to group
tempGroup.push(note);
}
});
// If there is a remaining group, add it as well
if (tempGroup.length > 0) {
sanitizedGroups.push(tempGroup);
}
});
noteGroups = sanitizedGroups;
}
function formatStems() {
noteGroups.forEach(group => {
let stemDirection;
if (config.maintain_stem_directions) {
const note = findFirstNote(group);
stemDirection = note ? note.getStemDirection() : Stem.UP;
} else {
if (config.stem_direction) {
stemDirection = config.stem_direction;
} else {
stemDirection = calculateStemDirection(group);
}
}
applyStemDirection(group, stemDirection);
});
}
function findFirstNote(group) {
for (let i = 0; i < group.length; i++) {
const note = group[i];
if (!note.isRest()) {
return note;
}
}
return false;
}
function applyStemDirection(group, direction) {
group.forEach(note => {
note.setStemDirection(direction);
});
}
// Get all of the tuplets in all of the note groups
function getTuplets() {
const uniqueTuplets = [];
// Go through all of the note groups and inspect for tuplets
noteGroups.forEach(group => {
let tuplet = null;
group.forEach(note => {
if (note.tuplet && (tuplet !== note.tuplet)) {
tuplet = note.tuplet;
uniqueTuplets.push(tuplet);
}
});
});
return uniqueTuplets;
}
// Using closures to store the variables throughout the various functions
// IMO Keeps it this process lot cleaner - but not super consistent with
// the rest of the API's style - Silverwolf90 (Cyril)
createGroups();
sanitizeGroups();
formatStems();
// Get the notes to be beamed
const beamedNoteGroups = getBeamGroups();
// Get the tuplets in order to format them accurately
const allTuplets = getTuplets();
// Create a Vex.Flow.Beam from each group of notes to be beamed
const beams = [];
beamedNoteGroups.forEach(group => {
const beam = new Beam(group);
if (config.show_stemlets) {
beam.render_options.show_stemlets = true;
}
if (config.secondary_breaks) {
beam.render_options.secondary_break_ticks = Flow.durationToTicks(config.secondary_breaks);
}
if (config.flat_beams === true) {
beam.render_options.flat_beams = true;
beam.render_options.flat_beam_offset = config.flat_beam_offset;
}
beams.push(beam);
});
// Reformat tuplets
allTuplets.forEach(tuplet => {
// Set the tuplet location based on the stem direction
const direction = tuplet.notes[0].stem_direction === Stem.DOWN ?
Tuplet.LOCATION_BOTTOM : Tuplet.LOCATION_TOP;
tuplet.setTupletLocation(direction);
// If any of the notes in the tuplet are not beamed, draw a bracket.
let bracketed = false;
for (let i = 0; i < tuplet.notes.length; i++) {
const note = tuplet.notes[i];
if (note.beam === null) {
bracketed = true;
break;
}
}
tuplet.setBracketed(bracketed);
});
return beams;
}
constructor(notes, auto_stem) {
super();
this.setAttribute('type', 'Beam');
if (!notes || notes === []) {
throw new Vex.RuntimeError('BadArguments', 'No notes provided for beam.');
}
if (notes.length === 1) {
throw new Vex.RuntimeError('BadArguments', 'Too few notes for beam.');
}
// Validate beam line, direction and ticks.
this.ticks = notes[0].getIntrinsicTicks();
if (this.ticks >= Flow.durationToTicks('4')) {
throw new Vex.RuntimeError('BadArguments',
'Beams can only be applied to notes shorter than a quarter note.');
}
let i; // shared iterator
let note;
this.stem_direction = Stem.UP;
for (i = 0; i < notes.length; ++i) {
note = notes[i];
if (note.hasStem()) {
this.stem_direction = note.getStemDirection();
break;
}
}
let stem_direction = this.stem_direction;
// Figure out optimal stem direction based on given notes
if (auto_stem && notes[0].getCategory() === 'stavenotes') {
stem_direction = calculateStemDirection(notes);
} else if (auto_stem && notes[0].getCategory() === 'tabnotes') {
// Auto Stem TabNotes
const stem_weight = notes.reduce((memo, note) => memo + note.stem_direction, 0);
stem_direction = stem_weight > -1 ? Stem.UP : Stem.DOWN;
}
// Apply stem directions and attach beam to notes
for (i = 0; i < notes.length; ++i) {
note = notes[i];
if (auto_stem) {
note.setStemDirection(stem_direction);
this.stem_direction = stem_direction;
}
note.setBeam(this);
}
this.postFormatted = false;
this.notes = notes;
this.beam_count = this.getBeamCount();
this.break_on_indices = [];
this.render_options = {
beam_width: 5,
max_slope: 0.25,
min_slope: -0.25,
slope_iterations: 20,
slope_cost: 100,
show_stemlets: false,
stemlet_extension: 7,
partial_beam_length: 10,
flat_beams: false,
min_flat_beam_offset: 15,
};
}
// Get the notes in this beam
getNotes() { return this.notes; }
// Get the max number of beams in the set of notes
getBeamCount() {
const beamCounts = this.notes.map(note => note.getGlyph().beam_count);
const maxBeamCount = beamCounts.reduce((max, beamCount) => beamCount > max ? beamCount : max);
return maxBeamCount;
}
// Set which note `indices` to break the secondary beam at
breakSecondaryAt(indices) {
this.break_on_indices = indices;
return this;
}
// Return the y coordinate for linear function
getSlopeY(x, first_x_px, first_y_px, slope) {
return first_y_px + ((x - first_x_px) * slope);
}
// Calculate the best possible slope for the provided notes
calculateSlope() {
const {
notes,
stem_direction: stemDirection,
render_options: { max_slope, min_slope, slope_iterations, slope_cost },
} = this;
const firstNote = notes[0];
const initialSlope = getStemSlope(firstNote, notes[notes.length - 1]);
const increment = (max_slope - min_slope) / slope_iterations;
let minCost = Number.MAX_VALUE;
let bestSlope = 0;
let yShift = 0;
// iterate through slope values to find best weighted fit
for (let slope = min_slope; slope <= max_slope; slope += increment) {
let totalStemExtension = 0;
let yShiftTemp = 0;
// iterate through notes, calculating y shift and stem extension
for (let i = 1; i < notes.length; ++i) {
const note = notes[i];
const adjustedStemTipY = this.getSlopeY(
note.getStemX(),
firstNote.getStemX(),
firstNote.getStemExtents().topY,
slope
) + yShiftTemp;
const stemTipY = note.getStemExtents().topY;
// beam needs to be shifted up to accommodate note
if (stemTipY * stemDirection < adjustedStemTipY * stemDirection) {
const diff = Math.abs(stemTipY - adjustedStemTipY);
yShiftTemp += diff * -stemDirection;
totalStemExtension += diff * i;
} else { // beam overshoots note, account for the difference
totalStemExtension += (stemTipY - adjustedStemTipY) * stemDirection;
}
}
// most engraving books suggest aiming for a slope about half the angle of the
// difference between the first and last notes' stem length;
const idealSlope = initialSlope / 2;
const distanceFromIdeal = Math.abs(idealSlope - slope);
// This tries to align most beams to something closer to the idealSlope, but
// doesn't go crazy. To disable, set this.render_options.slope_cost = 0
const cost = slope_cost * distanceFromIdeal + Math.abs(totalStemExtension);
// update state when a more ideal slope is found
if (cost < minCost) {
minCost = cost;
bestSlope = slope;
yShift = yShiftTemp;
}
}
this.slope = bestSlope;
this.y_shift = yShift;
}
// Calculate a slope and y-shift for flat beams
calculateFlatSlope() {
const {
notes, stem_direction,
render_options: { beam_width, min_flat_beam_offset, flat_beam_offset },
} = this;
// If a flat beam offset has not yet been supplied or calculated,
// generate one based on the notes in this particular note group
let total = 0;
let extremeY = 0; // Store the highest or lowest note here
let extremeBeamCount = 0; // The beam count of the extreme note
let currentExtreme = 0;
for (let i = 0; i < notes.length; i++) {
// Total up all of the offsets so we can average them out later
const note = notes[i];
const stemTipY = note.getStemExtents().topY;
total += stemTipY;
// Store the highest (stems-up) or lowest (stems-down) note so the
// offset can be adjusted in case the average isn't enough
if (stem_direction === Stem.DOWN && currentExtreme < stemTipY) {
currentExtreme = stemTipY;
extremeY = Math.max(...note.getYs());
extremeBeamCount = note.getBeamCount();
} else if (
stem_direction === Stem.UP && (currentExtreme === 0 || currentExtreme > stemTipY)
) {
currentExtreme = stemTipY;
extremeY = Math.min(...note.getYs());
extremeBeamCount = note.getBeamCount();
}
}
// Average the offsets to try and come up with a reasonable one that
// works for all of the notes in the beam group.
let offset = total / notes.length;
// In case the average isn't long enough, add or subtract some more
// based on the highest or lowest note (again, based on the stem
// direction). This also takes into account the added height due to
// the width of the beams.
const beamWidth = beam_width * 1.5;
const extremeTest = min_flat_beam_offset + (extremeBeamCount * beamWidth);
const newOffset = extremeY + (extremeTest * -stem_direction);
if (stem_direction === Stem.DOWN && offset < newOffset) {
offset = extremeY + extremeTest;
} else if (stem_direction === Stem.UP && offset > newOffset) {
offset = extremeY - extremeTest;
}
if (!flat_beam_offset) {
// Set the offset for the group based on the calculations above.
this.render_options.flat_beam_offset = offset;
} else if (stem_direction === Stem.DOWN && offset > flat_beam_offset) {
this.render_options.flat_beam_offset = offset;
} else if (stem_direction === Stem.UP && offset < flat_beam_offset) {
this.render_options.flat_beam_offset = offset;
}
// for flat beams, the slope and y_shift are simply 0
this.slope = 0;
this.y_shift = 0;
}
getBeamYToDraw() {
const firstNote = this.notes[0];
const firstStemTipY = firstNote.getStemExtents().topY;
let beamY = firstStemTipY;
// For flat beams, set the first and last Y to the offset, rather than
// using the note's stem extents.
if (this.render_options.flat_beams && this.render_options.flat_beam_offset) {
beamY = this.render_options.flat_beam_offset;
}
return beamY;
}
// Create new stems for the notes in the beam, so that each stem
// extends into the beams.
applyStemExtensions() {
const {
notes, slope, y_shift, stem_direction, beam_count,
render_options: {
show_stemlets,
stemlet_extension,
beam_width,
},
} = this;
const firstNote = notes[0];
const firstStemTipY = this.getBeamYToDraw();
const firstStemX = firstNote.getStemX();
for (let i = 0; i < notes.length; ++i) {
const note = notes[i];
const stemX = note.getStemX();
const { topY: stemTipY } = note.getStemExtents();
const beamedStemTipY = this.getSlopeY(stemX, firstStemX, firstStemTipY, slope) + y_shift;
const preBeamExtension = note.getStem().getExtension();
const beamExtension = stem_direction === Stem.UP
? stemTipY - beamedStemTipY
: beamedStemTipY - stemTipY;
note.stem.setExtension(preBeamExtension + beamExtension);
note.stem.renderHeightAdjustment = -Stem.WIDTH / 2;
if (note.isRest() && show_stemlets) {
const beamWidth = beam_width;
const totalBeamWidth = ((beam_count - 1) * beamWidth * 1.5) + beamWidth;
note.stem
.setVisibility(true)
.setStemlet(true, totalBeamWidth + stemlet_extension);
}
}
}
// Get the x coordinates for the beam lines of specific `duration`
getBeamLines(duration) {
const beam_lines = [];
let beam_started = false;
let current_beam = null;
const partial_beam_length = this.render_options.partial_beam_length;
let previous_should_break = false;
let tick_tally = 0;
for (let i = 0; i < this.notes.length; ++i) {
const note = this.notes[i];
// See if we need to break secondary beams on this note.
const ticks = note.ticks.value();
tick_tally += ticks;
let should_break = false;
// 8th note beams are always drawn.
if (parseInt(duration, 10) >= 8) {
// First, check to see if any indices were set up through breakSecondaryAt()
should_break = this.break_on_indices.indexOf(i) !== -1;
// If the secondary breaks were auto-configured in the render options,
// handle that as well.
if (this.render_options.secondary_break_ticks && tick_tally >=
this.render_options.secondary_break_ticks) {
tick_tally = 0;
should_break = true;
}
}
const note_gets_beam = note.getIntrinsicTicks() < Flow.durationToTicks(duration);
const stem_x = note.getStemX() - (Stem.WIDTH / 2);
// Check to see if the next note in the group will get a beam at this
// level. This will help to inform the partial beam logic below.
const next_note = this.notes[i + 1];
const beam_next = next_note && next_note.getIntrinsicTicks() < Flow.durationToTicks(duration);
if (note_gets_beam) {
// This note gets a beam at the current level
if (beam_started) {
// We're currently in the middle of a beam. Just continue it on to
// the stem X of the current note.
current_beam = beam_lines[beam_lines.length - 1];
current_beam.end = stem_x;
// If a secondary beam break is set up, end the beam right now.
if (should_break) {
beam_started = false;
if (next_note && !beam_next && current_beam.end === null) {
// This note gets a beam,.but the next one does not. This means
// we need a partial pointing right.
current_beam.end = current_beam.start - partial_beam_length;
}
}
} else {
// No beam started yet. Start a new one.
current_beam = { start: stem_x, end: null };
beam_started = true;
if (!beam_next) {
// The next note doesn't get a beam. Draw a partial.
if ((previous_should_break || i === 0) && next_note) {
// This is the first note (but not the last one), or it is
// following a secondary break. Draw a partial to the right.
current_beam.end = current_beam.start + partial_beam_length;
} else {
// By default, draw a partial to the left.
current_beam.end = current_beam.start - partial_beam_length;
}
} else if (should_break) {
// This note should have a secondary break after it. Even though
// we just started a beam, it needs to end immediately.
current_beam.end = current_beam.start - partial_beam_length;
beam_started = false;
}
beam_lines.push(current_beam);
}
} else {
// The current note does not get a beam.
beam_started = false;
}
// Store the secondary break flag to inform the partial beam logic in
// the next iteration of the loop.
previous_should_break = should_break;
}
// Add a partial beam pointing left if this is the last note in the group
const last_beam = beam_lines[beam_lines.length - 1];
if (last_beam && last_beam.end === null) {
last_beam.end = last_beam.start - partial_beam_length;
}
return beam_lines;
}
// Render the stems for each notes
drawStems() {
this.notes.forEach(note => {
if (note.getStem()) {
note.getStem().setContext(this.context).draw();
}
}, this);
}
// Render the beam lines
drawBeamLines() {
this.checkContext();
const valid_beam_durations = ['4', '8', '16', '32', '64'];
const firstNote = this.notes[0];
let beamY = this.getBeamYToDraw();
const firstStemX = firstNote.getStemX();
const beamThickness = this.render_options.beam_width * this.stem_direction;
// Draw the beams.
for (let i = 0; i < valid_beam_durations.length; ++i) {
const duration = valid_beam_durations[i];
const beamLines = this.getBeamLines(duration);
for (let j = 0; j < beamLines.length; ++j) {
const beam_line = beamLines[j];
const startBeamX = beam_line.start;
const startBeamY = this.getSlopeY(startBeamX, firstStemX, beamY, this.slope);
const lastBeamX = beam_line.end;
const lastBeamY = this.getSlopeY(lastBeamX, firstStemX, beamY, this.slope);
this.context.beginPath();
this.context.moveTo(startBeamX, startBeamY);
this.context.lineTo(startBeamX, startBeamY + beamThickness);
this.context.lineTo(lastBeamX + 1, lastBeamY + beamThickness);
this.context.lineTo(lastBeamX + 1, lastBeamY);
this.context.closePath();
this.context.fill();
}
beamY += beamThickness * 1.5;
}
}
// Pre-format the beam
preFormat() { return this; }
// Post-format the beam. This can only be called after
// the notes in the beam have both `x` and `y` values. ie: they've
// been formatted and have staves
postFormat() {
if (this.postFormatted) return;
// Calculate a smart slope if we're not forcing the beams to be flat.
if (this.notes[0].getCategory() === 'tabnotes' || this.render_options.flat_beams) {
this.calculateFlatSlope();
} else {
this.calculateSlope();
}
this.applyStemExtensions();
this.postFormatted = true;
}
// Render the beam to the canvas context
draw() {
this.checkContext();
this.setRendered();
if (this.unbeamable) return;
if (!this.postFormatted) {
this.postFormat();
}
this.drawStems();
this.applyStyle();
this.drawBeamLines();
this.restoreStyle();
}
}