VexFlow - Copyright (c) Mohit Muthanna 2010.
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 forstem_direction
- A stem direction to apply to the entire voicegroups
- 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 forconfig
- The configuration objectgroups
- Array of Fractions
that represent the beat structure to beam the notesstem_direction
- Set to apply the same direction to all notesbeam_rests
- Set to true
to include rests in the beamsbeam_middle_only
- Set to true
to only beam rests in the middle of the beatshow_stemlets
- Set to true
to draw stemlets for restsmaintain_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.attrs.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;
}
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,
flat_beam_offset,
flat_beams,
stemlet_extension,
beam_width,
},
} = this;
const firstNote = notes[0];
let firstStemTipY = firstNote.getStemExtents().topY;
If rendering flat beams, and an offset exists, set the y-coordinat`e to the offset so the stems all end at the beam offset.
if (flat_beams && flat_beam_offset) {
firstStemTipY = flat_beam_offset;
}
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];
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;
}
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();
if (this.unbeamable) return;
if (!this.postFormatted) {
this.postFormat();
}
this.drawStems();
this.drawBeamLines();
}
}