UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature.

643 lines (642 loc) 26.8 kB
import { Element } from './element.js'; import { Fraction } from './fraction.js'; import { Stem } from './stem.js'; import { Tables } from './tables.js'; import { isStaveNote, isTabNote } from './typeguard.js'; import { RuntimeError } from './util.js'; 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; } function 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 const BEAM_LEFT = 'L'; export const BEAM_RIGHT = 'R'; export const BEAM_BOTH = 'B'; export class Beam extends Element { static get CATEGORY() { return "Beam"; } getStemDirection() { return this.stem_direction; } 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) { 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)]; } static applyAndGetBeams(voice, stem_direction, groups) { return Beam.generateBeams(voice.getTickables(), { groups, stem_direction }); } static generateBeams(notes, config = {}) { if (!config.groups || !config.groups.length) { config.groups = [new Fraction(2, 8)]; } 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 = 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 = []; let currentGroupTotalTicks = new Fraction(0, 1); unprocessedNotes.forEach((unprocessedNote) => { nextGroup = []; if (unprocessedNote.shouldIgnoreTicks()) { noteGroups.push(currentGroup); currentGroup = nextGroup; return; } currentGroup.push(unprocessedNote); const ticksPerGroup = tickGroups[currentTickGroup].clone(); const totalTicks = getTotalTicks(currentGroup).add(currentGroupTotalTicks); const unbeamable = Tables.durationToNumber(unprocessedNote.getDuration()) < 8; if (unbeamable && unprocessedNote.getTuplet()) { ticksPerGroup.numerator *= 2; } if (totalTicks.greaterThan(ticksPerGroup)) { if (!unbeamable) { const note = currentGroup.pop(); if (note) nextGroup.push(note); } noteGroups.push(currentGroup); 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(); } }); 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; }); } 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.getDuration(), 10) < 8; const shouldBreak = breaksOnEachRest || breaksOnFirstOrLastRest || breakOnStemChange || isUnbeamableDuration; if (shouldBreak) { if (tempGroup.length > 0) { sanitizedGroups.push(tempGroup); } tempGroup = breakOnStemChange ? [note] : []; } else { tempGroup.push(note); } }); 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); }); } function getTuplets() { const uniqueTuplets = []; noteGroups.forEach((group) => { let tuplet; group.forEach((note) => { const noteTuplet = note.getTuplet(); if (noteTuplet && tuplet !== noteTuplet) { tuplet = noteTuplet; uniqueTuplets.push(tuplet); } }); }); return uniqueTuplets; } createGroups(); sanitizeGroups(); formatStems(); const beamedNoteGroups = getBeamGroups(); const allTuplets = getTuplets(); 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 = 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); }); allTuplets.forEach((tuplet) => { const direction = tuplet.notes[0].stem_direction === Stem.DOWN ? -1 : 1; tuplet.setTupletLocation(direction); 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, auto_stem = false) { super(); this.slope = 0; this.y_shift = 0; this.forcedPartialDirections = {}; 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.'); } 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; let note; this.stem_direction = notes[0].getStemDirection(); let stem_direction = this.stem_direction; if (auto_stem && isStaveNote(notes[0])) { stem_direction = calculateStemDirection(notes); } else if (auto_stem && isTabNote(notes[0])) { const stem_weight = notes.reduce((memo, note) => memo + note.getStemDirection(), 0); stem_direction = stem_weight > -1 ? Stem.UP : Stem.DOWN; } 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, }; } getNotes() { return this.notes; } getBeamCount() { const beamCounts = this.notes.map((note) => note.getGlyphProps().beam_count); const maxBeamCount = beamCounts.reduce((max, beamCount) => (beamCount > max ? beamCount : max)); return maxBeamCount; } breakSecondaryAt(indices) { this.break_on_indices = indices; return this; } setPartialBeamSideAt(noteIndex, side) { this.forcedPartialDirections[noteIndex] = side; return this; } unsetPartialBeamSideAt(noteIndex) { delete this.forcedPartialDirections[noteIndex]; return this; } getSlopeY(x, first_x_px, first_y_px, slope) { return first_y_px + (x - first_x_px) * slope; } 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; for (let slope = min_slope; slope <= max_slope; slope += increment) { let totalStemExtension = 0; let yShiftTemp = 0; 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; if (stemTipY * stemDirection < adjustedStemTipY * stemDirection) { const diff = Math.abs(stemTipY - adjustedStemTipY); yShiftTemp += diff * -stemDirection; totalStemExtension += diff * i; } else { totalStemExtension += (stemTipY - adjustedStemTipY) * stemDirection; } } } const idealSlope = initialSlope / 2; const distanceFromIdeal = Math.abs(idealSlope - slope); const cost = slope_cost * distanceFromIdeal + Math.abs(totalStemExtension); if (cost < minCost) { minCost = cost; bestSlope = slope; yShift = yShiftTemp; } } this.slope = bestSlope; this.y_shift = yShift; } calculateFlatSlope() { const { notes, stem_direction, render_options: { beam_width, min_flat_beam_offset, flat_beam_offset }, } = this; let total = 0; let extremeY = 0; let extremeBeamCount = 0; let currentExtreme = 0; for (let i = 0; i < notes.length; i++) { const note = notes[i]; const stemTipY = note.getStemExtents().topY; total += stemTipY; 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(); } } let offset = total / notes.length; 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 = 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; } this.slope = 0; this.y_shift = 0; } getBeamYToDraw() { const firstNote = this.notes[0]; const firstStemTipY = firstNote.getStemExtents().topY; let beamY = firstStemTipY; if (this.render_options.flat_beams && this.render_options.flat_beam_offset) { beamY = this.render_options.flat_beam_offset; } return beamY; } applyStemExtensions() { 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; 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; } 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); } } } } lookupBeamDirection(duration, prev_tick, tick, next_tick, noteIndex) { 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); } getBeamLines(duration) { const tick_of_duration = Tables.durationToTicks(duration); let beam_started = false; const beam_lines = []; let current_beam = 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]; const ticks = note.getTicks().value(); tick_tally += ticks; let should_break = false; if (parseInt(duration, 10) >= 8) { should_break = this.break_on_indices.indexOf(i) !== -1; 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; 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; if (note_gets_beam) { if (beam_started) { current_beam = beam_lines[beam_lines.length - 1]; current_beam.end = stem_x; if (should_break) { beam_started = false; if (next_note && !next_note_gets_beam && current_beam.end === undefined) { current_beam.end = current_beam.start - partial_beam_length; } } } else { current_beam = { start: stem_x, end: undefined }; beam_started = true; if (beam_alone) { 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) { if ((previous_should_break || i === 0) && next_note) { current_beam.end = current_beam.start + partial_beam_length; } else { current_beam.end = current_beam.start - partial_beam_length; } } else if (should_break) { current_beam.end = current_beam.start - partial_beam_length; beam_started = false; } beam_lines.push(current_beam); } } else { beam_started = false; } previous_should_break = should_break; } 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; } drawStems(ctx) { 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); } drawBeamLines(ctx) { 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; 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; } } preFormat() { return this; } postFormat() { if (this.postFormatted) return; if (isTabNote(this.notes[0]) || this.render_options.flat_beams) { this.calculateFlatSlope(); } else { this.calculateSlope(); } this.applyStemExtensions(); this.postFormatted = true; } draw() { 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(); } }