vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature.
995 lines (862 loc) • 34.8 kB
text/typescript
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// MIT License
import { Element } from './element';
import { Fraction } from './fraction';
import { Note } from './note';
import { RenderContext } from './rendercontext';
import { Stem } from './stem';
import { StemmableNote } from './stemmablenote';
import { Tables } from './tables';
import { Tuplet, TupletLocation } from './tuplet';
import { Category, isStaveNote, isTabNote } from './typeguard';
import { RuntimeError } from './util';
import { Voice } from './voice';
function calculateStemDirection(notes: StemmableNote[]) {
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;
}
function getStemSlope(firstNote: StemmableNote, lastNote: StemmableNote) {
const firstStemTipY = firstNote.getStemExtents().topY;
const firstStemX = firstNote.getStemX();
const lastStemTipY = lastNote.getStemExtents().topY;
const lastStemX = lastNote.getStemX();
return (lastStemTipY - firstStemTipY) / (lastStemX - firstStemX);
}
export const BEAM_LEFT = 'L';
export const BEAM_RIGHT = 'R';
export const BEAM_BOTH = 'B';
export type PartialBeamDirection = typeof BEAM_LEFT | typeof BEAM_RIGHT | typeof BEAM_BOTH;
/** `Beams` span over a set of `StemmableNotes`. */
export class Beam extends Element {
static get CATEGORY(): string {
return Category.Beam;
}
public render_options: {
flat_beam_offset?: number;
flat_beams: boolean;
secondary_break_ticks?: number;
show_stemlets: boolean;
beam_width: number;
max_slope: number;
min_slope: number;
slope_iterations: number;
slope_cost: number;
stemlet_extension: number;
partial_beam_length: number;
min_flat_beam_offset: number;
};
notes: StemmableNote[];
postFormatted: boolean;
slope: number = 0;
private readonly stem_direction: number;
private readonly ticks: number;
private y_shift: number = 0;
private break_on_indices: number[];
private beam_count: number;
private unbeamable?: boolean;
/**
* Overrides to default beam directions for secondary-level beams that do not
* connect to any other note. See further explanation at
* `setPartialBeamSideAt`
*/
private forcedPartialDirections: {
[noteIndex: number]: PartialBeamDirection;
} = {};
/** Get the direction of the beam */
getStemDirection(): number {
return this.stem_direction;
}
/**
* Get the default beam groups for a provided time signature.
* Attempt to guess if the time signature is not found in table.
* Currently this is fairly naive.
*/
static getDefaultBeamGroups(time_sig: string): Fraction[] {
if (!time_sig || time_sig === 'c') {
time_sig = '4/4';
}
const defaults: { [key: string]: [string] } = {
'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: string[] = 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()`.
* @param voice the voice to generate the beams for
* @param stem_direction a stem direction to apply to the entire voice
* @param groups an array of `Fraction` representing beat groupings for the beam
*/
static applyAndGetBeams(voice: Voice, stem_direction?: number, groups?: Fraction[]): Beam[] {
return Beam.generateBeams(voice.getTickables() as StemmableNote[], { 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
* };
* ```
* @param notes an array of notes to create the beams for
* @param config the configuration object
* @param config.stem_direction set to apply the same direction to all notes
* @param config.beam_rests set to `true` to include rests in the beams
* @param config.beam_middle_only set to `true` to only beam rests in the middle of the beat
* @param config.show_stemlets set to `true` to draw stemlets for rests
* @param config.maintain_stem_directions set to `true` to not apply new stem directions
* @param config.groups array of `Fractions` that represent the beat structure to beam the notes
*
*/
static generateBeams(
notes: StemmableNote[],
config: {
flat_beam_offset?: number;
flat_beams?: boolean;
secondary_breaks?: string;
show_stemlets?: boolean;
maintain_stem_directions?: boolean;
beam_middle_only?: boolean;
beam_rests?: boolean;
groups?: Fraction[];
stem_direction?: number;
} = {}
): Beam[] {
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 RuntimeError('InvalidBeamGroups', 'The beam groups must be an array of Vex.Flow.Fractions');
}
return group.clone().multiply(Tables.RESOLUTION, 1);
});
const unprocessedNotes: StemmableNote[] = notes;
let currentTickGroup = 0;
let noteGroups: StemmableNote[][] = [];
let currentGroup: StemmableNote[] = [];
function getTotalTicks(vf_notes: Note[]) {
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: StemmableNote[] = [];
// number of ticks in current group
let currentGroupTotalTicks = new Fraction(0, 1);
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).add(currentGroupTotalTicks);
// Double the amount of ticks in a group, if it's an unbeamable tuplet
const unbeamable = Tables.durationToNumber(unprocessedNote.getDuration()) < 8;
if (unbeamable && unprocessedNote.getTuplet()) {
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) {
const note = currentGroup.pop();
if (note) nextGroup.push(note);
}
noteGroups.push(currentGroup);
// We have overflown, so we're going to next tick group. As we might have
// overflown by more than 1 group, we need to go forward as many times as
// needed, decreasing currentGroupTotalTicks by as many ticks as there are
// in current groups as we go forward.
do {
currentGroupTotalTicks = totalTicks.subtract(tickGroups[currentTickGroup]);
nextTickGroup();
} while (currentGroupTotalTicks.greaterThanEquals(tickGroups[currentTickGroup]));
currentGroup = nextGroup;
} else if (totalTicks.equals(ticksPerGroup)) {
noteGroups.push(currentGroup);
currentGroupTotalTicks = new Fraction(0, 1);
currentGroup = nextGroup;
nextTickGroup();
}
});
// Adds any remainder notes beam
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() >= Tables.durationToTicks('4')) {
beamable = false;
}
});
return beamable;
}
return false;
});
}
// Splits up groups by Rest
function sanitizeGroups() {
const sanitizedGroups: StemmableNote[][] = [];
noteGroups.forEach((group) => {
let tempGroup: StemmableNote[] = [];
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.getDuration(), 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: StemmableNote[]) {
for (let i = 0; i < group.length; i++) {
const note = group[i];
if (!note.isRest()) {
return note;
}
}
return false;
}
function applyStemDirection(group: StemmableNote[], direction: number) {
group.forEach((note) => {
note.setStemDirection(direction);
});
}
// Get all of the tuplets in all of the note groups
function getTuplets() {
const uniqueTuplets: Tuplet[] = [];
// Go through all of the note groups and inspect for tuplets
noteGroups.forEach((group) => {
let tuplet: Tuplet;
group.forEach((note) => {
const noteTuplet = note.getTuplet();
if (noteTuplet && tuplet !== noteTuplet) {
tuplet = noteTuplet;
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: Beam[] = [];
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 = Tables.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] as StemmableNote).stem_direction === Stem.DOWN ? TupletLocation.BOTTOM : TupletLocation.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.hasBeam()) {
bracketed = true;
break;
}
}
tuplet.setBracketed(bracketed);
});
return beams;
}
constructor(notes: StemmableNote[], auto_stem: boolean = false) {
super();
if (!notes || notes.length === 0) {
throw new RuntimeError('BadArguments', 'No notes provided for beam.');
}
if (notes.length === 1) {
throw new RuntimeError('BadArguments', 'Too few notes for beam.');
}
// Validate beam line, direction and ticks.
this.ticks = notes[0].getIntrinsicTicks();
if (this.ticks >= Tables.durationToTicks('4')) {
throw new RuntimeError('BadArguments', 'Beams can only be applied to notes shorter than a quarter note.');
}
let i; // shared iterator
let note;
this.stem_direction = notes[0].getStemDirection();
let stem_direction = this.stem_direction;
// Figure out optimal stem direction based on given notes
if (auto_stem && isStaveNote(notes[0])) {
stem_direction = calculateStemDirection(notes);
} else if (auto_stem && isTabNote(notes[0])) {
// Auto Stem TabNotes
const stem_weight = notes.reduce((memo, note) => memo + note.getStemDirection(), 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(): StemmableNote[] {
return this.notes;
}
/** Get the max number of beams in the set of notes. */
getBeamCount(): number {
const beamCounts = this.notes.map((note) => note.getGlyphProps().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: number[]): this {
this.break_on_indices = indices;
return this;
}
/**
* Forces the direction of a partial beam (a secondary-level beam that exists
* on one note only of the beam group). This is useful in rhythms such as 6/8
* eighth-sixteenth-eighth-sixteenth, where the direction of the beam on the
* first sixteenth note can help imply whether the rhythm is to be felt as
* three groups of eighth notes (typical) or as two groups of three-sixteenths
* (less common):
* ```
* ┌───┬──┬──┐ ┌──┬──┬──┐
* │ ├─ │ ─┤ vs │ ─┤ │ ─┤
* │ │ │ │ │ │ │ │
* ```
*/
setPartialBeamSideAt(noteIndex: number, side: PartialBeamDirection) {
this.forcedPartialDirections[noteIndex] = side;
return this;
}
/**
* Restore the default direction of a partial beam (a secondary-level beam
* that does not connect to any other notes).
*/
unsetPartialBeamSideAt(noteIndex: number) {
delete this.forcedPartialDirections[noteIndex];
return this;
}
/** Return the y coordinate for linear function. */
getSlopeY(x: number, first_x_px: number, first_y_px: number, slope: number): number {
return first_y_px + (x - first_x_px) * slope;
}
/** Calculate the best possible slope for the provided notes. */
calculateSlope(): void {
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];
if (note.hasStem() || note.isRest()) {
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(): void {
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;
}
/** Return the Beam y offset. */
getBeamYToDraw(): number {
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(): void {
const {
notes,
slope,
y_shift,
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 stem = note.getStem();
if (stem) {
const stemX = note.getStemX();
const { topY: stemTipY } = note.getStemExtents();
const beamedStemTipY = this.getSlopeY(stemX, firstStemX, firstStemTipY, slope) + y_shift;
const preBeamExtension = stem.getExtension();
const beamExtension =
note.getStemDirection() === Stem.UP ? stemTipY - beamedStemTipY : beamedStemTipY - stemTipY;
// Determine necessary extension for cross-stave notes in the beam group
let crossStemExtension = 0;
if (note.getStemDirection() !== this.stem_direction) {
const beamCount = note.getGlyphProps().beam_count;
crossStemExtension = (1 + (beamCount - 1) * 1.5) * this.render_options.beam_width;
/* This will be required if the partial beams are moved to the note side.
if (i > 0 && note.getGlyph().beam_count > 1) {
const prevBeamCount = this.notes[i - 1].getGlyph().beam_count;
const beamDiff = Math.abs(prevBeamCount - beamCount);
if (beamDiff > 0) crossStemExtension -= beamDiff * (this.render_options.beam_width * 1.5);
}
*/
}
stem.setExtension(preBeamExtension + beamExtension + crossStemExtension);
stem.adjustHeightForBeam();
if (note.isRest() && show_stemlets) {
const beamWidth = beam_width;
const totalBeamWidth = (beam_count - 1) * beamWidth * 1.5 + beamWidth;
stem.setVisibility(true).setStemlet(true, totalBeamWidth + stemlet_extension);
}
}
}
}
/** Return upper level beam direction. */
lookupBeamDirection(
duration: string,
prev_tick: number,
tick: number,
next_tick: number,
noteIndex: number
): PartialBeamDirection {
if (duration === '4') {
return BEAM_LEFT;
}
const forcedBeamDirection = this.forcedPartialDirections[noteIndex];
if (forcedBeamDirection) return forcedBeamDirection;
const lookup_duration = `${Tables.durationToNumber(duration) / 2}`;
const prev_note_gets_beam = prev_tick < Tables.durationToTicks(lookup_duration);
const next_note_gets_beam = next_tick < Tables.durationToTicks(lookup_duration);
const note_gets_beam = tick < Tables.durationToTicks(lookup_duration);
if (prev_note_gets_beam && next_note_gets_beam && note_gets_beam) {
return BEAM_BOTH;
} else if (prev_note_gets_beam && !next_note_gets_beam && note_gets_beam) {
return BEAM_LEFT;
} else if (!prev_note_gets_beam && next_note_gets_beam && note_gets_beam) {
return BEAM_RIGHT;
}
return this.lookupBeamDirection(lookup_duration, prev_tick, tick, next_tick, noteIndex);
}
/** Get the x coordinates for the beam lines of specific `duration`. */
getBeamLines(duration: string): { start: number; end?: number }[] {
const tick_of_duration = Tables.durationToTicks(duration);
let beam_started = false;
type BeamInfo = { start: number; end?: number };
const beam_lines: BeamInfo[] = [];
let current_beam: BeamInfo | undefined = undefined;
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.getTicks().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() < tick_of_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 prev_note = this.notes[i - 1];
const next_note = this.notes[i + 1];
const next_note_gets_beam = next_note && next_note.getIntrinsicTicks() < tick_of_duration;
const prev_note_gets_beam = prev_note && prev_note.getIntrinsicTicks() < tick_of_duration;
const beam_alone = prev_note && next_note && note_gets_beam && !prev_note_gets_beam && !next_note_gets_beam;
// const beam_alone = note_gets_beam && !prev_note_gets_beam && !next_note_gets_beam;
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 && !next_note_gets_beam && current_beam.end === undefined) {
// 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: undefined };
beam_started = true;
if (beam_alone) {
// previous and next beam exists and does not get a beam but current gets it.
const prev_tick = prev_note.getIntrinsicTicks();
const next_tick = next_note.getIntrinsicTicks();
const tick = note.getIntrinsicTicks();
const beam_direction = this.lookupBeamDirection(duration, prev_tick, tick, next_tick, i);
if ([BEAM_LEFT, BEAM_BOTH].includes(beam_direction)) {
current_beam.end = current_beam.start - partial_beam_length;
} else {
current_beam.end = current_beam.start + partial_beam_length;
}
} else if (!next_note_gets_beam) {
// 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 === undefined) {
last_beam.end = last_beam.start - partial_beam_length;
}
return beam_lines;
}
/** Render the stems for each note. */
protected drawStems(ctx: RenderContext): void {
this.notes.forEach((note) => {
const stem = note.getStem();
if (stem) {
const stem_x = note.getStemX();
stem.setNoteHeadXBounds(stem_x, stem_x);
stem.setContext(ctx).draw();
}
}, this);
}
// Render the beam lines
protected drawBeamLines(ctx: RenderContext): void {
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;
if (lastBeamX) {
const lastBeamY = this.getSlopeY(lastBeamX, firstStemX, beamY, this.slope);
ctx.beginPath();
ctx.moveTo(startBeamX, startBeamY);
ctx.lineTo(startBeamX, startBeamY + beamThickness);
ctx.lineTo(lastBeamX + 1, lastBeamY + beamThickness);
ctx.lineTo(lastBeamX + 1, lastBeamY);
ctx.closePath();
ctx.fill();
} else {
throw new RuntimeError('NoLastBeamX', 'lastBeamX undefined.');
}
}
beamY += beamThickness * 1.5;
}
}
/** Pre-format the beam. */
preFormat(): this {
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(): void {
if (this.postFormatted) return;
// Calculate a smart slope if we're not forcing the beams to be flat.
if (isTabNote(this.notes[0]) || this.render_options.flat_beams) {
this.calculateFlatSlope();
} else {
this.calculateSlope();
}
this.applyStemExtensions();
this.postFormatted = true;
}
/** Render the beam to the canvas context */
draw(): void {
const ctx = this.checkContext();
this.setRendered();
if (this.unbeamable) return;
if (!this.postFormatted) {
this.postFormat();
}
this.drawStems(ctx);
this.applyStyle();
ctx.openGroup('beam', this.getAttribute('id'));
this.drawBeamLines(ctx);
ctx.closeGroup();
this.restoreStyle();
}
}