|
@@ -0,0 +1,886 @@
|
|
|
+// [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);
|
|
|
+};
|
|
|
+
|
|
|
+const BEAM_LEFT = 'L';
|
|
|
+const BEAM_RIGHT = 'R';
|
|
|
+const BEAM_BOTH = 'B';
|
|
|
+
|
|
|
+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 + this.render_options.flat_beam_offset_per_beam > 0)) {
|
|
|
+ // 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_beams) {
|
|
|
+ this.render_options.flat_beam_offset = offset;
|
|
|
+ } else if (stem_direction === Stem.UP && offset < flat_beam_offset && !this.render_options.flat_beams) {
|
|
|
+ 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 + this.render_options.flat_beam_offset_per_beam > 0) {
|
|
|
+ let offset = this.render_options.flat_beam_offset;
|
|
|
+ if (this.render_options.flat_beam_offset_per_beam) {
|
|
|
+ offset += this.render_options.flat_beam_offset_per_beam * this.getBeamCount();
|
|
|
+ }
|
|
|
+ let maxY = this.notes[0].note_heads[0].y;
|
|
|
+ const minOrMax = this.stem_direction > 0 ? Math.min : Math.max;
|
|
|
+ for (const note of this.notes) {
|
|
|
+ for (const note_head of note.note_heads) {
|
|
|
+ maxY = minOrMax(maxY, note_head.y);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ beamY = maxY + (offset * -this.stem_direction);
|
|
|
+ }
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // return upper level beam direction.
|
|
|
+ lookupBeamDirection(duration, prev_tick, tick, next_tick) {
|
|
|
+ if (duration === '4') {
|
|
|
+ return BEAM_LEFT;
|
|
|
+ }
|
|
|
+
|
|
|
+ const lookup_duration = `${Flow.durationToNumber(duration) / 2}`;
|
|
|
+ const prev_note_gets_beam = prev_tick < Flow.durationToTicks(lookup_duration);
|
|
|
+ const next_note_gets_beam = next_tick < Flow.durationToTicks(lookup_duration);
|
|
|
+ const note_gets_beam = tick < Flow.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);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Get the x coordinates for the beam lines of specific `duration`
|
|
|
+ getBeamLines(duration) {
|
|
|
+ const tick_of_duration = Flow.durationToTicks(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() < 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 === 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_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);
|
|
|
+
|
|
|
+ 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 === 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();
|
|
|
+ }
|
|
|
+}
|