UNPKG

vexflow

Version:

A JavaScript library for rendering music notation and guitar tablature.

539 lines (480 loc) 14.4 kB
// [VexFlow](https://vexflow.com) - Copyright (c) Mohit Muthanna 2010. // MIT License import { Accidental } from './accidental'; import { Articulation } from './articulation'; import { PartialBeamDirection } from './beam'; import { Dot } from './dot'; import { Factory } from './factory'; import { FretHandFinger } from './frethandfinger'; import { Music } from './music'; import { Note } from './note'; import { Grammar, Match, Parser, Result, Rule, RuleFunction } from './parser'; import { RenderContext } from './rendercontext'; import { Stem } from './stem'; import { StemmableNote } from './stemmablenote'; import { TupletOptions } from './tuplet'; import { defined, log, RuntimeError } from './util'; import { Voice } from './voice'; // To enable logging for this class. Set `Vex.Flow.EasyScore.DEBUG` to `true`. // eslint-disable-next-line function L(...args: any[]): void { if (EasyScore.DEBUG) log('Vex.Flow.EasyScore', args); } // eslint-disable-next-line export type CommitHook = (obj: any, note: StemmableNote, builder: Builder) => void; export class EasyScoreGrammar implements Grammar { builder: Builder; constructor(builder: Builder) { this.builder = builder; } begin(): RuleFunction { return this.LINE; } LINE(): Rule { return { expect: [this.PIECE, this.PIECES, this.EOL], }; } PIECE(): Rule { return { expect: [this.CHORDORNOTE, this.PARAMS], run: () => this.builder.commitPiece(), }; } PIECES(): Rule { return { expect: [this.COMMA, this.PIECE], zeroOrMore: true, }; } PARAMS(): Rule { return { expect: [this.DURATION, this.TYPE, this.DOTS, this.OPTS], }; } CHORDORNOTE(): Rule { return { expect: [this.CHORD, this.SINGLENOTE], or: true, }; } CHORD(): Rule { return { expect: [this.LPAREN, this.NOTES, this.RPAREN], // eslint-disable-next-line run: (state) => this.builder.addChord(state!.matches[1] as Match[]), }; } NOTES(): Rule { return { expect: [this.NOTE], oneOrMore: true, }; } NOTE(): Rule { return { expect: [this.NOTENAME, this.ACCIDENTAL, this.OCTAVE], }; } SINGLENOTE(): Rule { return { expect: [this.NOTENAME, this.ACCIDENTAL, this.OCTAVE], run: (state) => { // eslint-disable-next-line const s = state!; this.builder.addSingleNote(s.matches[0] as string, s.matches[1] as string, s.matches[2] as string); }, }; } ACCIDENTAL(): Rule { return { expect: [this.MICROTONES, this.ACCIDENTALS], maybe: true, or: true, }; } DOTS(): Rule { return { expect: [this.DOT], zeroOrMore: true, // eslint-disable-next-line run: (state) => this.builder.setNoteDots(state!.matches), }; } TYPE(): Rule { return { expect: [this.SLASH, this.MAYBESLASH, this.TYPES], maybe: true, // eslint-disable-next-line run: (state) => this.builder.setNoteType(state!.matches[2] as string), }; } DURATION(): Rule { return { expect: [this.SLASH, this.DURATIONS], maybe: true, // eslint-disable-next-line run: (state) => this.builder.setNoteDuration(state!.matches[1] as string), }; } OPTS(): Rule { return { expect: [this.LBRACKET, this.KEYVAL, this.KEYVALS, this.RBRACKET], maybe: true, }; } KEYVALS(): Rule { return { expect: [this.COMMA, this.KEYVAL], zeroOrMore: true, }; } KEYVAL(): Rule { const unquote = (str: string) => str.slice(1, -1); return { expect: [this.KEY, this.EQUALS, this.VAL], // eslint-disable-next-line run: (state) => this.builder.addNoteOption(state!.matches[0] as string, unquote(state!.matches[2] as string)), }; } VAL(): Rule { return { expect: [this.SVAL, this.DVAL], or: true, }; } KEY(): Rule { return { token: '[a-zA-Z][a-zA-Z0-9]*' }; } DVAL(): Rule { return { token: '["][^"]*["]' }; } SVAL(): Rule { return { token: "['][^']*[']" }; } NOTENAME(): Rule { return { token: '[a-gA-G]' }; } OCTAVE(): Rule { return { token: '[0-9]+' }; } ACCIDENTALS(): Rule { return { token: 'bb|b|##|#|n' }; } MICROTONES(): Rule { return { token: 'bbs|bss|bs|db|d|\\+\\+-|\\+-|\\+\\+|\\+|k|o' }; } DURATIONS(): Rule { return { token: '[0-9whq]+' }; } TYPES(): Rule { return { token: '[rRsSmMhHgG]' }; } LPAREN(): Rule { return { token: '[(]' }; } RPAREN(): Rule { return { token: '[)]' }; } COMMA(): Rule { return { token: '[,]' }; } DOT(): Rule { return { token: '[.]' }; } SLASH(): Rule { return { token: '[/]' }; } MAYBESLASH(): Rule { return { token: '[/]?' }; } EQUALS(): Rule { return { token: '[=]' }; } LBRACKET(): Rule { return { token: '\\[' }; } RBRACKET(): Rule { return { token: '\\]' }; } EOL(): Rule { return { token: '$' }; } } export interface NotePiece { key: string; accid?: string | null; octave?: string; } export class Piece { chord: NotePiece[] = []; duration: string; dots: number = 0; type?: string; options: { [x: string]: string } = {}; constructor(duration: string) { this.duration = duration; } } export interface BuilderElements { notes: StemmableNote[]; accidentals: (Accidental | undefined)[][]; } // Extending Record<string, any> allows arbitrary properties via Builder.reset() & EasyScore.parse(). // eslint-disable-next-line export interface BuilderOptions extends Record<string, any> { stem?: string; clef?: string; } export class Builder { factory: Factory; // Initialized by the constructor via this.reset(). elements!: BuilderElements; // Initialized by the constructor via this.reset(). options!: BuilderOptions; // Initialized by the constructor via this.resetPiece(). piece!: Piece; commitHooks: CommitHook[] = []; rollingDuration!: string; constructor(factory: Factory) { this.factory = factory; this.reset(); } reset(options?: BuilderOptions): void { this.options = { stem: 'auto', clef: 'treble', ...options, }; this.elements = { notes: [], accidentals: [] }; this.rollingDuration = '8'; this.resetPiece(); } getFactory(): Factory { return this.factory; } getElements(): BuilderElements { return this.elements; } addCommitHook(commitHook: CommitHook): void { this.commitHooks.push(commitHook); } resetPiece(): void { L('resetPiece'); this.piece = new Piece(this.rollingDuration); } setNoteDots(dots: Match[]): void { L('setNoteDots:', dots); if (dots) this.piece.dots = dots.length; } setNoteDuration(duration?: string): void { L('setNoteDuration:', duration); this.rollingDuration = this.piece.duration = duration || this.rollingDuration; } setNoteType(type?: string): void { L('setNoteType:', type); if (type) this.piece.type = type; } addNoteOption(key: string, value: string): void { L('addNoteOption: key:', key, 'value:', value); this.piece.options[key] = value; } addNote(key?: string, accid?: string | null, octave?: string): void { L('addNote:', key, accid, octave); this.piece.chord.push({ key: key as string, accid, octave, }); } addSingleNote(key: string, accid?: string | null, octave?: string): void { L('addSingleNote:', key, accid, octave); this.addNote(key, accid, octave); } // notes is an array with 3 entries addChord(notes: Match[]): void { L('startChord'); if (typeof notes[0] !== 'object') { this.addSingleNote(notes[0]); } else { notes.forEach((n) => { if (n) this.addNote(...(n as string[])); // n => [string, string | null, string] }); } L('endChord'); } commitPiece(): void { L('commitPiece'); const { factory } = this; if (!factory) return; const options = { ...this.options, ...this.piece.options }; // reset() sets this.options.stem & this.options.clef but we check to make sure nothing has changed. // e.g., auto | up | down const stem = defined(options.stem, 'BadArguments', 'options.stem is not defined').toLowerCase(); // e.g., treble | bass const clef = defined(options.clef, 'BadArguments', 'options.clef is not defined').toLowerCase(); const { chord, duration, dots, type } = this.piece; // Create a string[] that will be assigned to the .keys property of the StaveNote. // Each string in the array represents a note pitch and is of the form: {NoteName}{Accidental}/{Octave} // Only standard accidentals are included in the .keys property. Microtonal accidentals are not included. const standardAccidentals = Music.accidentals; const keys = chord.map( (notePiece) => notePiece.key + (standardAccidentals.includes(notePiece.accid ?? '') ? notePiece.accid : '') + '/' + notePiece.octave ); const auto_stem = stem === 'auto'; // StaveNoteStruct expects the underscore & lowercase. // Build a GhostNote or StaveNote using the information we gathered. const note = type?.toLowerCase() == 'g' ? factory.GhostNote({ duration, dots }) : factory.StaveNote({ keys, duration, dots, type, clef, auto_stem }); if (!auto_stem) note.setStemDirection(stem === 'up' ? Stem.UP : Stem.DOWN); // Attach accidentals. const accidentals: (Accidental | undefined)[] = []; chord.forEach((notePiece: NotePiece, index: number) => { const accid = notePiece.accid; if (typeof accid === 'string') { const accidental = factory.Accidental({ type: accid }); note.addModifier(accidental, index); accidentals.push(accidental); } else { accidentals.push(undefined); } }); // Attach dots. for (let i = 0; i < dots; i++) Dot.buildAndAttach([note], { all: true }); this.commitHooks.forEach((commitHook) => commitHook(options, note, this)); this.elements.notes.push(note); this.elements.accidentals.push(accidentals); this.resetPiece(); } } export interface EasyScoreOptions { factory?: Factory; builder?: Builder; commitHooks?: CommitHook[]; throwOnError?: boolean; } // Extending Record<string, any> allows arbitrary properties via set(defaults). // eslint-disable-next-line export interface EasyScoreDefaults extends Record<string, any> { clef?: string; time?: string; stem?: string; } /** * Commit hook used by EasyScore.setOptions(). */ function setId(options: { id?: string }, note: StemmableNote) { if (options.id === undefined) return; note.setAttribute('id', options.id); } // Used by setClass() below. const commaSeparatedRegex = /\s*,\s*/; /** * Commit hook used by EasyScore.setOptions(). */ function setClass(options: { class?: string }, note: StemmableNote) { if (options.class === undefined) return; options.class.split(commaSeparatedRegex).forEach((className: string) => note.addClass(className)); } /** * EasyScore implements a parser for a simple language to generate VexFlow objects. */ export class EasyScore { static DEBUG: boolean = false; defaults: EasyScoreDefaults = { clef: 'treble', time: '4/4', stem: 'auto', }; // options, factory, builder, grammar, and parser are all // initialized by the constructor via this.setOptions(). options!: EasyScoreOptions; factory!: Factory; builder!: Builder; grammar!: EasyScoreGrammar; parser!: Parser; constructor(options: EasyScoreOptions = {}) { this.setOptions(options); } /** * Set the score defaults. * clef must be set appropriately to avoid errors when adding Staves. * @param defaults.clef default clef ( treble | bass ...) see {@link Clef.types} * @param defaults.time default time signature ( 4/4 | 9/8 ...) * @param defaults.stem default stem arrangement (auto | up | down) * @returns this */ set(defaults: EasyScoreDefaults): this { this.defaults = { ...this.defaults, ...defaults }; return this; } /** * @param options.factory is required. * @returns this */ setOptions(options: EasyScoreOptions): this { // eslint-disable-next-line const factory = options.factory!; // ! operator, because options.factory was set in Factory.EasyScore(). const builder = options.builder ?? new Builder(factory); this.options = { commitHooks: [setId, setClass, Articulation.easyScoreHook, FretHandFinger.easyScoreHook], throwOnError: false, ...options, factory, builder, }; this.factory = factory; this.builder = builder; this.grammar = new EasyScoreGrammar(this.builder); this.parser = new Parser(this.grammar); this.options.commitHooks?.forEach((commitHook) => this.addCommitHook(commitHook)); return this; } setContext(context: RenderContext): this { this.factory.setContext(context); return this; } parse(line: string, options: BuilderOptions = {}): Result { this.builder.reset(options); const result = this.parser.parse(line); if (!result.success && this.options.throwOnError) { L(result); throw new RuntimeError('Error parsing line: ' + line); } return result; } beam( notes: StemmableNote[], options?: { autoStem?: boolean; secondaryBeamBreaks?: number[]; partialBeamDirections?: { [noteIndex: number]: PartialBeamDirection; }; } ): StemmableNote[] { this.factory.Beam({ notes, options }); return notes; } tuplet(notes: StemmableNote[], options?: TupletOptions): StemmableNote[] { this.factory.Tuplet({ notes, options }); return notes; } notes(line: string, options: BuilderOptions = {}): StemmableNote[] { options = { clef: this.defaults.clef, stem: this.defaults.stem, ...options }; this.parse(line, options); return this.builder.getElements().notes; } voice(notes: Note[], options: { time?: string; options?: { softmaxFactor: number } } = {}): Voice { options = { time: this.defaults.time, ...options }; return this.factory.Voice(options).addTickables(notes); } addCommitHook(commitHook: CommitHook): void { this.builder.addCommitHook(commitHook); } }