vexflow
Version:
A JavaScript library for rendering music notation and guitar tablature.
685 lines (586 loc) • 19.9 kB
text/typescript
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010.
// MIT License
import { Beam } from './beam';
import { Font } from './font';
import { Fraction } from './fraction';
import { GlyphProps } from './glyph';
import { Modifier } from './modifier';
import { drawDot, RenderContext } from './rendercontext';
import { Stave } from './stave';
import { Stroke } from './strokes';
import { Tables } from './tables';
import { Tickable } from './tickable';
import { TickContext } from './tickcontext';
import { Category } from './typeguard';
import { defined, RuntimeError } from './util';
import { Voice } from './voice';
export interface KeyProps {
stem_down_x_offset?: number;
stem_up_x_offset?: number;
key: string;
octave: number;
line: number;
int_value?: number;
accidental?: string;
code?: string;
stroke: number;
shift_right?: number;
displaced: boolean;
}
export interface NoteMetrics {
/** The total width of the note (including modifiers). */
width: number;
glyphWidth?: number;
/** The width of the note head only. */
notePx: number;
/** Start `X` for left modifiers. */
modLeftPx: number;
/** Start `X` for right modifiers. */
modRightPx: number;
/** Extra space on left of note. */
leftDisplacedHeadPx: number;
glyphPx: number;
/** Extra space on right of note. */
rightDisplacedHeadPx: number;
}
export interface NoteDuration {
duration: string;
dots: number;
type: string;
}
export interface ParsedNote {
duration: string;
type: string;
customTypes: string[];
dots: number;
ticks: number;
}
export interface NoteStruct {
/** Array of pitches, e.g: `['c/4', 'e/4', 'g/4']` */
keys?: string[];
/** The time length (e.g., `q` for quarter, `h` for half, `8` for eighth etc.). */
duration: string;
line?: number;
/** The number of dots, which affects the duration. */
dots?: number;
/** The note type (e.g., `r` for rest, `s` for slash notes, etc.). */
type?: string;
align_center?: boolean;
duration_override?: Fraction;
}
/**
* Note implements an abstract interface for notes and chords that
* are rendered on a stave. Notes have some common properties: All of them
* have a value (e.g., pitch, fret, etc.) and a duration (quarter, half, etc.)
*
* Some notes have stems, heads, dots, etc. Most notational elements that
* surround a note are called *modifiers*, and every note has an associated
* array of them. All notes also have a rendering context and belong to a stave.
*/
export abstract class Note extends Tickable {
//////////////////////////////////////////////////////////////////////////////////////////////////
// STATIC MEMBERS
static get CATEGORY(): string {
return Category.Note;
}
/** Debug helper. Displays various note metrics for the given note. */
static plotMetrics(ctx: RenderContext, note: Tickable, yPos: number): void {
const metrics = note.getMetrics();
const xStart = note.getAbsoluteX() - metrics.modLeftPx - metrics.leftDisplacedHeadPx;
const xPre1 = note.getAbsoluteX() - metrics.leftDisplacedHeadPx;
const xAbs = note.getAbsoluteX();
const xPost1 = note.getAbsoluteX() + metrics.notePx;
const xPost2 = note.getAbsoluteX() + metrics.notePx + metrics.rightDisplacedHeadPx;
const xEnd = note.getAbsoluteX() + metrics.notePx + metrics.rightDisplacedHeadPx + metrics.modRightPx;
const xFreedomRight = xEnd + (note.getFormatterMetrics().freedom.right || 0);
const xWidth = xEnd - xStart;
ctx.save();
ctx.setFont(Font.SANS_SERIF, 8);
ctx.fillText(Math.round(xWidth) + 'px', xStart + note.getXShift(), yPos);
const y = yPos + 7;
function stroke(x1: number, x2: number, color: string, yy: number = y) {
ctx.beginPath();
ctx.setStrokeStyle(color);
ctx.setFillStyle(color);
ctx.setLineWidth(3);
ctx.moveTo(x1 + note.getXShift(), yy);
ctx.lineTo(x2 + note.getXShift(), yy);
ctx.stroke();
}
stroke(xStart, xPre1, 'red');
stroke(xPre1, xAbs, '#999');
stroke(xAbs, xPost1, 'green');
stroke(xPost1, xPost2, '#999');
stroke(xPost2, xEnd, 'red');
stroke(xEnd, xFreedomRight, '#DD0');
stroke(xStart - note.getXShift(), xStart, '#BBB'); // Shift
drawDot(ctx, xAbs + note.getXShift(), y, 'blue');
const formatterMetrics = note.getFormatterMetrics();
if (formatterMetrics.iterations > 0) {
const spaceDeviation = formatterMetrics.space.deviation;
const prefix = spaceDeviation >= 0 ? '+' : '';
ctx.setFillStyle('red');
ctx.fillText(prefix + Math.round(spaceDeviation), xAbs + note.getXShift(), yPos - 10);
}
ctx.restore();
}
protected static parseDuration(durationString?: string): NoteDuration | undefined {
if (!durationString) {
return undefined;
}
const regexp = /(\d*\/?\d+|[a-z])(d*)([nrhms]|$)/;
const result = regexp.exec(durationString);
if (!result) {
return undefined;
}
const duration = result[1];
const dots = result[2].length;
const type = result[3] || 'n';
return { duration, dots, type };
}
protected static parseNoteStruct(noteStruct: NoteStruct): ParsedNote | undefined {
// Preserve backwards-compatibility
const durationProps = Note.parseDuration(noteStruct.duration);
if (!durationProps) {
return undefined;
}
// If specified type is invalid, return undefined.
let type = noteStruct.type;
if (type && !Tables.validTypes[type]) {
return undefined;
}
// If no type specified, check duration or custom types
const customTypes: string[] = [];
if (!type) {
type = durationProps.type || 'n';
// If we have keys, try and check if we've got a custom glyph
if (noteStruct.keys !== undefined) {
noteStruct.keys.forEach((k, i) => {
const result = k.split('/');
// We have a custom glyph specified after the note eg. /X2
customTypes[i] = (result && result.length === 3 ? result[2] : type) as string;
});
}
}
// Calculate the tick duration of the note
let ticks = Tables.durationToTicks(durationProps.duration);
if (!ticks) {
return undefined;
}
// Are there any dots?
const dots = noteStruct.dots ? noteStruct.dots : durationProps.dots;
if (typeof dots !== 'number') {
return undefined;
}
// Add ticks as necessary depending on the numbr of dots
let currentTicks = ticks;
for (let i = 0; i < dots; i++) {
if (currentTicks <= 1) return undefined;
currentTicks = currentTicks / 2;
ticks += currentTicks;
}
return {
duration: durationProps.duration,
type,
customTypes,
dots,
ticks,
};
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// INSTANCE MEMBERS
glyphProps: GlyphProps;
keys: string[];
keyProps: KeyProps[];
protected stave?: Stave;
public render_options: {
draw_stem_through_stave?: boolean;
draw?: boolean;
draw_dots?: boolean;
draw_stem?: boolean;
y_shift: number;
extend_left?: number;
extend_right?: number;
glyph_font_scale: number;
annotation_spacing: number;
glyph_font_size?: number;
scale: number;
font: string;
stroke_px: number;
};
protected duration: string;
protected leftDisplacedHeadPx: number;
protected rightDisplacedHeadPx: number;
protected noteType: string;
protected customGlyphs: GlyphProps[];
protected ys: number[];
protected customTypes: string[];
protected playNote?: Note;
protected beam?: Beam;
/**
* Every note is a tickable, i.e., it can be mutated by the `Formatter` class for
* positioning and layout.
*
* @param noteStruct To create a new note you need to provide a `noteStruct`.
*/
constructor(noteStruct: NoteStruct) {
super();
if (!noteStruct) {
throw new RuntimeError('BadArguments', 'Note must have valid initialization data to identify duration and type.');
}
/** Parses `noteStruct` and get note properties. */
const parsedNoteStruct = Note.parseNoteStruct(noteStruct);
if (!parsedNoteStruct) {
throw new RuntimeError('BadArguments', `Invalid note initialization object: ${JSON.stringify(noteStruct)}`);
}
// Set note properties from parameters.
this.keys = noteStruct.keys || [];
// per-pitch properties
this.keyProps = [];
this.duration = parsedNoteStruct.duration;
this.noteType = parsedNoteStruct.type;
this.customTypes = parsedNoteStruct.customTypes;
if (noteStruct.duration_override) {
// Custom duration
this.setDuration(noteStruct.duration_override);
} else {
// Default duration
this.setIntrinsicTicks(parsedNoteStruct.ticks);
}
this.modifiers = [];
// Get the glyph code for this note from the font.
this.glyphProps = Tables.getGlyphProps(this.duration, this.noteType);
this.customGlyphs = this.customTypes.map((t) => Tables.getGlyphProps(this.duration, t));
// Note to play for audio players.
this.playNote = undefined;
// Positioning contexts used by the Formatter.
this.ignore_ticks = false;
// Positioning variables
this.width = 0; // Width in pixels calculated after preFormat
this.leftDisplacedHeadPx = 0; // Extra room on left for displaced note head
this.rightDisplacedHeadPx = 0; // Extra room on right for displaced note head
this.x_shift = 0; // X shift from tick context X
this.ys = []; // list of y coordinates for each note
// we need to hold on to these for ties and beams.
if (noteStruct.align_center) {
this.setCenterAlignment(noteStruct.align_center);
}
// The render surface.
this.render_options = {
annotation_spacing: 5,
glyph_font_scale: 1,
stroke_px: 1,
scale: 1,
font: '',
y_shift: 0,
};
}
/**
* Get the play note, which is arbitrary data that can be used by an
* audio player.
*/
getPlayNote(): Note | undefined {
return this.playNote;
}
/**
* Set the play note, which is arbitrary data that can be used by an
* audio player.
*/
setPlayNote(note: Note): this {
this.playNote = note;
return this;
}
/**
* @returns true if this note is a type of rest.
*
* Rests don't have pitches, but take up space in the score.
* Subclasses should override this default implementation.
*/
isRest(): boolean {
return false;
}
/** Add stroke. */
addStroke(index: number, stroke: Stroke): this {
stroke.setNote(this);
stroke.setIndex(index);
this.modifiers.push(stroke);
this.preFormatted = false;
return this;
}
/** Get the target stave. */
getStave(): Stave | undefined {
return this.stave;
}
/** Check and get the target stave. */
checkStave(): Stave {
return defined(this.stave, 'NoStave', 'No stave attached to instance.');
}
/** Set the target stave. */
setStave(stave: Stave): this {
this.stave = stave;
this.setYs([stave.getYForLine(0)]); // Update Y values if the stave is changed.
this.setContext(this.stave.getContext());
return this;
}
/** Get spacing to the left of the notes. */
getLeftDisplacedHeadPx(): number {
return this.leftDisplacedHeadPx;
}
/** Get spacing to the right of the notes. */
getRightDisplacedHeadPx(): number {
return this.rightDisplacedHeadPx;
}
/** Set spacing to the left of the notes. */
setLeftDisplacedHeadPx(x: number): this {
this.leftDisplacedHeadPx = x;
return this;
}
/** Set spacing to the right of the notes. */
setRightDisplacedHeadPx(x: number): this {
this.rightDisplacedHeadPx = x;
return this;
}
/** True if this note has no duration (e.g., bar notes, spacers, etc.). */
shouldIgnoreTicks(): boolean {
return this.ignore_ticks;
}
/** Get the stave line number for the note. */
// eslint-disable-next-line
getLineNumber(isTopNote?: boolean): number {
return 0;
}
/** Get the stave line number for rest. */
getLineForRest(): number {
return 0;
}
/**
* @deprecated Use `getGlyphProps()` instead.
*/
// eslint-disable-next-line
getGlyph(): any {
return this.glyphProps;
}
/** Get the glyph associated with this note. */
getGlyphProps(): GlyphProps {
return this.glyphProps;
}
/** Get the glyph width. */
getGlyphWidth(): number {
return this.glyphProps.getWidth(this.render_options.glyph_font_scale);
}
/**
* Set Y positions for this note. Each Y value is associated with
* an individual pitch/key within the note/chord.
*/
setYs(ys: number[]): this {
this.ys = ys;
return this;
}
/**
* Get Y positions for this note. Each Y value is associated with
* an individual pitch/key within the note/chord.
*/
getYs(): number[] {
if (this.ys.length === 0) {
throw new RuntimeError('NoYValues', 'No Y-values calculated for this note.');
}
return this.ys;
}
/**
* Get the Y position of the space above the stave onto which text can
* be rendered.
*/
getYForTopText(text_line: number): number {
return this.checkStave().getYForTopText(text_line);
}
/** Return the voice that this note belongs in. */
getVoice(): Voice {
if (!this.voice) throw new RuntimeError('NoVoice', 'Note has no voice.');
return this.voice;
}
/** Attach this note to `voice`. */
setVoice(voice: Voice): this {
this.voice = voice;
this.preFormatted = false;
return this;
}
/** Get the `TickContext` for this note. */
getTickContext(): TickContext {
return this.checkTickContext();
}
/** Set the `TickContext` for this note. */
setTickContext(tc: TickContext): this {
this.tickContext = tc;
this.preFormatted = false;
return this;
}
/** Accessor to duration. */
getDuration(): string {
return this.duration;
}
/** Accessor to isDotted. */
isDotted(): boolean {
return this.getModifiersByType(Category.Dot).length > 0;
}
/** Accessor to hasStem. */
hasStem(): boolean {
return false;
}
/** Accessor to note type. */
getNoteType(): string {
return this.noteType;
}
/** Get the beam. */
getBeam(): Beam | undefined {
return this.beam;
}
/** Check and get the beam. */
checkBeam(): Beam {
return defined(this.beam, 'NoBeam', 'No beam attached to instance');
}
/** Check it has a beam. */
hasBeam(): boolean {
return this.beam != undefined;
}
/** Set the beam. */
setBeam(beam: Beam): this {
this.beam = beam;
return this;
}
/**
* Attach a modifier to this note.
* @param modifier the Modifier to add.
* @param index of the key to modify.
* @returns this
*/
addModifier(modifier: Modifier, index: number = 0): this {
const signature = 'Note.addModifier(modifier: Modifier, index: number=0)';
// Backwards compatibility with 3.0.9.
if (typeof index === 'string') {
index = parseInt(index);
// eslint-disable-next-line
console.warn(signature + ' expected a number for `index`, but received a string.');
}
// Some versions of VexFlow had the two parameters reversed.
// Check here and throw an error if the argument types are not correct.
if (typeof modifier !== 'object' || typeof index !== 'number') {
throw new RuntimeError('WrongParams', 'Incorrect call signature. Use ' + signature + ' instead.');
}
modifier.setNote(this);
modifier.setIndex(index);
super.addModifier(modifier);
return this;
}
/** Get all modifiers of a specific type in `this.modifiers`. */
getModifiersByType(type: string): Modifier[] {
return this.modifiers.filter((modifier) => modifier.getCategory() === type);
}
/** Get the coordinates for where modifiers begin. */
// eslint-disable-next-line
getModifierStartXY(position?: number, index?: number, options?: any): { x: number; y: number } {
if (!this.preFormatted) {
throw new RuntimeError('UnformattedNote', "Can't call GetModifierStartXY on an unformatted note");
}
return {
x: this.getAbsoluteX(),
y: this.ys[0],
};
}
getRightParenthesisPx(index: number): number {
const props = this.getKeyProps()[index];
return props.displaced ? this.getRightDisplacedHeadPx() : 0;
}
getLeftParenthesisPx(index: number): number {
const props = this.getKeyProps()[index];
return props.displaced ? this.getLeftDisplacedHeadPx() - this.x_shift : -this.x_shift;
}
getFirstDotPx(): number {
let px = this.getRightDisplacedHeadPx();
if (this.checkModifierContext().getMembers('Parenthesis').length !== 0)
px += Tables.currentMusicFont().lookupMetric('parenthesis.default.width');
return px;
}
/** Get the metrics for this note. */
getMetrics(): NoteMetrics {
if (!this.preFormatted) {
throw new RuntimeError('UnformattedNote', "Can't call getMetrics on an unformatted note.");
}
const modLeftPx = this.modifierContext ? this.modifierContext.getState().left_shift : 0;
const modRightPx = this.modifierContext ? this.modifierContext.getState().right_shift : 0;
const width = this.getWidth();
const glyphWidth = this.getGlyphWidth();
const notePx =
width -
modLeftPx - // subtract left modifiers
modRightPx - // subtract right modifiers
this.leftDisplacedHeadPx - // subtract left displaced head
this.rightDisplacedHeadPx; // subtract right displaced head
// NOTE: If you change this, remember to update MockTickable.getMetrics() in the tests/ directory.
return {
width,
glyphWidth,
notePx,
// Modifier spacing.
modLeftPx,
modRightPx,
// Displaced note head on left or right.
leftDisplacedHeadPx: this.leftDisplacedHeadPx,
rightDisplacedHeadPx: this.rightDisplacedHeadPx,
glyphPx: 0,
};
}
/**
* Get the absolute `X` position of this note's tick context. This
* excludes x_shift, so you'll need to factor it in if you're
* looking for the post-formatted x-position.
*/
getAbsoluteX(): number {
const tickContext = this.checkTickContext(`Can't getAbsoluteX() without a TickContext.`);
// Position note to left edge of tick context.
let x = tickContext.getX();
if (this.stave) {
x += this.stave.getNoteStartX() + Tables.currentMusicFont().lookupMetric('stave.padding');
}
if (this.isCenterAligned()) {
x += this.getCenterXShift();
}
return x;
}
/** Get point for notes. */
static getPoint(size?: string): number {
// for sizes other than 'default', note is 2/3 of the default value
return size == 'default' ? Tables.NOTATION_FONT_SCALE : (Tables.NOTATION_FONT_SCALE / 5) * 3;
}
/** Get the direction of the stem. */
getStemDirection(): number {
throw new RuntimeError('NoStem', 'No stem attached to this note.');
}
/** Get the top and bottom `y` values of the stem. */
getStemExtents(): Record<string, number> {
throw new RuntimeError('NoStem', 'No stem attached to this note.');
}
/** Get the `x` coordinate to the right of the note. */
getTieRightX(): number {
let tieStartX = this.getAbsoluteX();
const note_glyph_width = this.glyphProps.getWidth();
tieStartX += note_glyph_width / 2;
tieStartX += -this.width / 2 + this.width + 2;
return tieStartX;
}
/** Get the `x` coordinate to the left of the note. */
getTieLeftX(): number {
let tieEndX = this.getAbsoluteX();
const note_glyph_width = this.glyphProps.getWidth();
tieEndX += note_glyph_width / 2;
tieEndX -= this.width / 2 + 2;
return tieEndX;
}
// Get the pitches in the note
getKeys(): string[] {
return this.keys;
}
// Get the properties for all the keys in the note
getKeyProps(): KeyProps[] {
return this.keyProps;
}
}