UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature

831 lines (712 loc) 27.3 kB
// [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(); } }