vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature.
392 lines (342 loc) • 13.7 kB
text/typescript
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// Author: Larry Kuhns.
// MIT License
import { Builder } from './easyscore';
import { Glyph } from './glyph';
import { Modifier } from './modifier';
import { ModifierContextState } from './modifiercontext';
import { Note } from './note';
import { Stave } from './stave';
import { Stem } from './stem';
import { StemmableNote } from './stemmablenote';
import { Tables } from './tables';
import { Category, isGraceNote, isStaveNote, isStemmableNote, isTabNote } from './typeguard';
import { defined, log, RuntimeError } from './util';
export interface ArticulationStruct {
code?: string;
aboveCode?: string;
belowCode?: string;
between_lines: boolean;
}
// eslint-disable-next-line
function L(...args: any[]) {
if (Articulation.DEBUG) log('Vex.Flow.Articulation', args);
}
const { ABOVE, BELOW } = Modifier.Position;
function roundToNearestHalf(mathFn: (a: number) => number, value: number): number {
return mathFn(value / 0.5) * 0.5;
}
// This includes both staff and ledger lines
function isWithinLines(line: number, position: number): boolean {
return position === ABOVE ? line <= 5 : line >= 1;
}
function getRoundingFunction(line: number, position: number): (a: number) => number {
if (isWithinLines(line, position)) {
if (position === ABOVE) {
return Math.ceil;
} else {
return Math.floor;
}
} else {
return Math.round;
}
}
function snapLineToStaff(canSitBetweenLines: boolean, line: number, position: number, offsetDirection: number): number {
// Initially, snap to nearest staff line or space
const snappedLine = roundToNearestHalf(getRoundingFunction(line, position), line);
const canSnapToStaffSpace = canSitBetweenLines && isWithinLines(snappedLine, position);
const onStaffLine = snappedLine % 1 === 0;
if (canSnapToStaffSpace && onStaffLine) {
const HALF_STAFF_SPACE = 0.5;
return snappedLine + HALF_STAFF_SPACE * -offsetDirection;
} else {
return snappedLine;
}
}
// Helper function for checking if a Note object is either a StaveNote or a GraceNote.
const isStaveOrGraceNote = (note: Note) => isStaveNote(note) || isGraceNote(note);
function getTopY(note: Note, textLine: number): number {
const stemDirection = note.getStemDirection();
const { topY: stemTipY, baseY: stemBaseY } = note.getStemExtents();
if (isStaveOrGraceNote(note)) {
if (note.hasStem()) {
if (stemDirection === Stem.UP) {
return stemTipY;
} else {
return stemBaseY;
}
} else {
return Math.min(...note.getYs());
}
} else if (isTabNote(note)) {
if (note.hasStem()) {
if (stemDirection === Stem.UP) {
return stemTipY;
} else {
return note.checkStave().getYForTopText(textLine);
}
} else {
return note.checkStave().getYForTopText(textLine);
}
} else {
throw new RuntimeError('UnknownCategory', 'Only can get the top and bottom ys of stavenotes and tabnotes');
}
}
function getBottomY(note: Note, textLine: number): number {
const stemDirection = note.getStemDirection();
const { topY: stemTipY, baseY: stemBaseY } = note.getStemExtents();
if (isStaveOrGraceNote(note)) {
if (note.hasStem()) {
if (stemDirection === Stem.UP) {
return stemBaseY;
} else {
return stemTipY;
}
} else {
return Math.max(...note.getYs());
}
} else if (isTabNote(note)) {
if (note.hasStem()) {
if (stemDirection === Stem.UP) {
return note.checkStave().getYForBottomText(textLine);
} else {
return stemTipY;
}
} else {
return note.checkStave().getYForBottomText(textLine);
}
} else {
throw new RuntimeError('UnknownCategory', 'Only can get the top and bottom ys of stavenotes and tabnotes');
}
}
/**
* Get the initial offset of the articulation from the y value of the starting position.
* This is required because the top/bottom text positions already have spacing applied to
* provide a "visually pleasant" default position. However the y values provided from
* the stavenote's top/bottom do *not* have any pre-applied spacing. This function
* normalizes this asymmetry.
* @param note
* @param position
* @returns
*/
function getInitialOffset(note: Note, position: number): number {
const isOnStemTip =
(position === ABOVE && note.getStemDirection() === Stem.UP) ||
(position === BELOW && note.getStemDirection() === Stem.DOWN);
if (isStaveOrGraceNote(note)) {
if (note.hasStem() && isOnStemTip) {
return 0.5;
} else {
// this amount is larger than the stem-tip offset because we start from
// the center of the notehead
return 1;
}
} else {
if (note.hasStem() && isOnStemTip) {
return 1;
} else {
return 0;
}
}
}
/**
* Articulations and Accents are modifiers that can be
* attached to notes. The complete list of articulations is available in
* `tables.ts` under `Vex.Flow.articulationCodes`.
*
* See `tests/articulation_tests.ts` for usage examples.
*/
export class Articulation extends Modifier {
/** To enable logging for this class. Set `Vex.Flow.Articulation.DEBUG` to `true`. */
static DEBUG: boolean = false;
/** Articulations category string. */
static get CATEGORY(): string {
return Category.Articulation;
}
protected static readonly INITIAL_OFFSET: number = -0.5;
/** Articulation code provided to the constructor. */
readonly type: string;
public render_options: { font_scale: number };
// articulation defined calling reset in constructor
protected articulation!: ArticulationStruct;
// glyph defined calling reset in constructor
protected glyph!: Glyph;
/**
* FIXME:
* Most of the complex formatting logic (ie: snapping to space) is
* actually done in .render(). But that logic belongs in this method.
*
* Unfortunately, this isn't possible because, by this point, stem lengths
* have not yet been finalized. Finalized stem lengths are required to determine the
* initial position of any stem-side articulation.
*
* This indicates that all objects should have their stave set before being
* formatted. It can't be an optional if you want accurate vertical positioning.
* Consistently positioned articulations that play nice with other modifiers
* won't be possible until we stop relying on render-time formatting.
*
* Ideally, when this function has completed, the vertical articulation positions
* should be ready to render without further adjustment. But the current state
* is far from this ideal.
*/
static format(articulations: Articulation[], state: ModifierContextState): boolean {
if (!articulations || articulations.length === 0) return false;
const margin = 0.5;
let maxGlyphWidth = 0;
const getIncrement = (articulation: Articulation, line: number, position: number) =>
roundToNearestHalf(
getRoundingFunction(line, position),
defined(articulation.glyph.getMetrics().height) / 10 + margin
);
articulations.forEach((articulation) => {
const note = articulation.checkAttachedNote();
maxGlyphWidth = Math.max(note.getGlyphProps().getWidth(), maxGlyphWidth);
let lines = 5;
const stemDirection = note.hasStem() ? note.getStemDirection() : Stem.UP;
let stemHeight = 0;
// Decide if we need to consider beam direction in placement.
if (isStemmableNote(note)) {
const stem = note.getStem();
if (stem) {
stemHeight = Math.abs(stem.getHeight()) / Tables.STAVE_LINE_DISTANCE;
}
}
const stave: Stave | undefined = note.getStave();
if (stave) {
lines = stave.getNumLines();
}
if (articulation.getPosition() === ABOVE) {
let noteLine = note.getLineNumber(true);
if (stemDirection === Stem.UP) {
noteLine += stemHeight;
}
let increment = getIncrement(articulation, state.top_text_line, ABOVE);
const curTop = noteLine + state.top_text_line + 0.5;
// If articulation must be above stave, add lines between note and stave top
if (!articulation.articulation.between_lines && curTop < lines) {
increment += lines - curTop;
}
articulation.setTextLine(state.top_text_line);
state.top_text_line += increment;
} else if (articulation.getPosition() === BELOW) {
let noteLine = Math.max(lines - note.getLineNumber(), 0);
if (stemDirection === Stem.DOWN) {
noteLine += stemHeight;
}
let increment = getIncrement(articulation, state.text_line, BELOW);
const curBottom = noteLine + state.text_line + 0.5;
// if articulation must be below stave, add lines from note to stave bottom
if (!articulation.articulation.between_lines && curBottom < lines) {
increment += lines - curBottom;
}
articulation.setTextLine(state.text_line);
state.text_line += increment;
}
});
const width = articulations
.map((articulation) => articulation.getWidth())
.reduce((maxWidth, articWidth) => Math.max(articWidth, maxWidth));
const overlap = Math.min(
Math.max(width - maxGlyphWidth, 0),
Math.max(width - (state.left_shift + state.right_shift), 0)
);
state.left_shift += overlap / 2;
state.right_shift += overlap / 2;
return true;
}
static easyScoreHook({ articulations }: { articulations: string }, note: StemmableNote, builder: Builder): void {
if (!articulations) return;
const articNameToCode: Record<string, string> = {
staccato: 'a.',
tenuto: 'a-',
accent: 'a>',
};
articulations
.split(',')
.map((articString) => articString.trim().split('.'))
.map(([name, position]) => {
const artic: { type: string; position?: number } = { type: articNameToCode[name] };
if (position) artic.position = Modifier.PositionString[position];
return builder.getFactory().Articulation(artic);
})
.map((artic) => note.addModifier(artic, 0));
}
/**
* Create a new articulation.
* @param type entry in `Vex.Flow.articulationCodes` in `tables.ts` or Glyph code.
*
* Notes (by default):
* - Glyph codes ending with 'Above' will be positioned ABOVE
* - Glyph codes ending with 'Below' will be positioned BELOW
*/
constructor(type: string) {
super();
this.type = type;
this.position = ABOVE;
this.render_options = {
font_scale: Tables.NOTATION_FONT_SCALE,
};
this.reset();
}
protected reset(): void {
this.articulation = Tables.articulationCodes(this.type);
// Use type as glyph code, if not defined as articulation code
if (!this.articulation) {
this.articulation = { code: this.type, between_lines: false };
if (this.type.endsWith('Above')) this.position = ABOVE;
if (this.type.endsWith('Below')) this.position = BELOW;
}
const code =
(this.position === ABOVE ? this.articulation.aboveCode : this.articulation.belowCode) || this.articulation.code;
this.glyph = new Glyph(code ?? '', this.render_options.font_scale);
defined(this.glyph, 'ArgumentError', `Articulation not found: ${this.type}`);
this.setWidth(defined(this.glyph.getMetrics().width));
}
/** Set if articulation should be rendered between lines. */
setBetweenLines(betweenLines = true): this {
this.articulation.between_lines = betweenLines;
return this;
}
/** Render articulation in position next to note. */
draw(): void {
const ctx = this.checkContext();
const note = this.checkAttachedNote();
this.setRendered();
const index = this.checkIndex();
const { position, glyph, text_line: textLine } = this;
const canSitBetweenLines = this.articulation.between_lines;
const stave = note.checkStave();
const staffSpace = stave.getSpacingBetweenLines();
const isTab = isTabNote(note);
// Articulations are centered over/under the note head.
const { x } = note.getModifierStartXY(position, index);
const shouldSitOutsideStaff = !canSitBetweenLines || isTab;
const initialOffset = getInitialOffset(note, position);
const padding = Tables.currentMusicFont().lookupMetric(`articulation.${glyph.getCode()}.padding`, 0);
let y = (
{
[ABOVE]: () => {
glyph.setOrigin(0.5, 1);
const y = getTopY(note, textLine) - (textLine + initialOffset) * staffSpace;
return shouldSitOutsideStaff ? Math.min(stave.getYForTopText(Articulation.INITIAL_OFFSET), y) : y;
},
[BELOW]: () => {
glyph.setOrigin(0.5, 0);
const y = getBottomY(note, textLine) + (textLine + initialOffset) * staffSpace;
return shouldSitOutsideStaff ? Math.max(stave.getYForBottomText(Articulation.INITIAL_OFFSET), y) : y;
},
} as Record<number, () => number>
)[position]();
if (!isTab) {
const offsetDirection = position === ABOVE ? -1 : +1;
const noteLine = note.getKeyProps()[index].line;
const distanceFromNote = (note.getYs()[index] - y) / staffSpace;
const articLine = distanceFromNote + Number(noteLine);
const snappedLine = snapLineToStaff(canSitBetweenLines, articLine, position, offsetDirection);
if (isWithinLines(snappedLine, position)) glyph.setOrigin(0.5, 0.5);
y += Math.abs(snappedLine - articLine) * staffSpace * offsetDirection + padding * offsetDirection;
}
L(`Rendering articulation at (x: ${x}, y: ${y})`);
glyph.render(ctx, x, y);
}
}