UNPKG

satie

Version:

A sheet music renderer for the web

576 lines (508 loc) 20.2 kB
/** * This file is part of Satie music engraver <https://github.com/jnetterf/satie>. * Copyright (C) Joshua Netterfield <joshua.ca> 2015 - present. * * Satie is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * Satie is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Satie. If not, see <http://www.gnu.org/licenses/>. */ import {Note, Count, TimeModification, Tie, Clef, Rest, Time, MxmlAccidental, Notehead, NoteheadType, Notations, Articulations, Tied, Pitch, Beam} from "musicxml-interfaces"; import {some, find, map, reduce, filter, chain, times} from "lodash"; import * as invariant from "invariant"; import {IAttributesSnapshot} from "./private_attributesSnapshot"; import {ValidationCursor, LayoutCursor} from "./private_cursor"; import {lcm} from "./private_util"; export interface IChord { [key: number]: Note; length: number; push: (...notes: Note[]) => number; _class?: string; } export interface IDurationDescription { count: number; dots?: number; timeModification?: TimeModification; } const EMPTY_FROZEN = Object.freeze({}); export function hasAccidental(chord: IChord, cursor: ValidationCursor | LayoutCursor) { return some(chord, function(c) { if (!c.pitch) { return false; } let accidental = (cursor.staffAccidentals[c.pitch.alter] || 0); return ((c.pitch.alter || 0) !== accidental && (c.pitch.alter || 0) !== (cursor.staffAccidentals[c.pitch.alter + c.pitch.octave] || 0) || !!c.accidental); }); } function _isIDurationDescription(chord: any): chord is IDurationDescription { return !isNaN(chord.count); } function _isIChord(chord: any): chord is IChord { return !isNaN(chord.length); } export function count(chord: IChord): Count; export function count(duration: IDurationDescription): Count; export function count(chord: IChord | IDurationDescription): Count; export function count(chord: IChord | IDurationDescription): Count { if (_isIChord(chord)) { let target = find(chord, note => note.noteType); return target ? target.noteType.duration : NaN; } else if (_isIDurationDescription(chord)) { return chord.count; } else { invariant(false, "count() expected a chord or duration."); } } export function dots(chord: IChord): number; export function dots(duration: IDurationDescription): number; export function dots(chord: IChord | IDurationDescription): number; export function dots(chord: IChord | IDurationDescription): number { if (_isIChord(chord)) { return (find(chord, note => note.dots) || {dots: <any[]> []}).dots.length || 0; } else if (_isIDurationDescription(chord)) { return chord.dots || 0; } else { invariant(false, "dots() expected a chord or duration"); } } export function timeModification(chord: IChord): TimeModification; export function timeModification(duration: IDurationDescription): TimeModification; export function timeModification(chord: IChord | IDurationDescription): TimeModification; export function timeModification(chord: IChord | IDurationDescription): TimeModification { if (_isIChord(chord)) { return (find(chord, note => note.timeModification) || {timeModification: <TimeModification> null}) .timeModification || null; } else if (_isIDurationDescription(chord)) { return chord.timeModification || null; } else { invariant(false, "timeModification() expected a chord or duration"); } } export function ties(chord: IChord): Tie[] { let ties = map(chord, note => note.ties && note.ties.length ? note.ties[0] : null); return filter(ties, t => !!t).length ? ties : null; } export function beams(chord: IChord): Beam[] { let target = find(chord, note => !!note.beams); if (target) { return target.beams; } return null; } export function hasFlagOrBeam(chord: IChord): boolean { // TODO: check if flag/beam forcefully set to "off" return some(chord, note => note.noteType.duration <= Count.Eighth); } /** * Returns the mean of all the lines, in SMuFL coordinates, where * 3 is the middle line. (SMuFL coordinates are 10x MusicXML coordinates) */ export function averageLine(chord: IChord, clef: Clef): number { return reduce(linesForClef(chord, clef), (memo: number, line: number) => memo + line, 0) / chord.length; } /** * Returns the minimum of all the lines, in SMuFL coordinates, where * 3 is the middle line. (SMuFL coordinates are 10x MusicXML coordinates) */ export function lowestLine(chord: IChord, clef: Clef) { return reduce(linesForClef(chord, clef), (memo: number, line: number) => Math.min(memo, line), 10000); } /** * Returns the highest of all the lines, in SMuFL coordinates, where * 3 is the middle line. (SMuFL coordinates are 10x MusicXML coordinates) */ export function highestLine(chord: IChord, clef: Clef) { return reduce(linesForClef(chord, clef), (memo: number, line: number) => Math.max(memo, line), -10000); } /** * Returns the position where the line starts. For single notes, this is where * the notehead appears. For chords, this is where the furthest notehead appears. */ export function startingLine(chord: IChord, direction: number, clef: Clef) { if (direction !== -1 && direction !== 1) { throw new Error("Direction was not a number"); } return direction === 1 ? lowestLine(chord, clef) : highestLine(chord, clef); } /** * The line of the notehead closest to the dangling end of the stem. For single notes, * startingLine and heightDeterminingLine are equal. * * Note: The minimum size of a stem is determinted by this value. */ export function heightDeterminingLine(chord: IChord, direction: number, clef: Clef) { if (direction !== -1 && direction !== 1) { throw new Error("Direction was not a number"); } return direction === 1 ? highestLine(chord, clef) : lowestLine(chord, clef); } export function linesForClef(chord: IChord, clef: Clef): Array<number> { if (!clef) { throw new Error("Exepected a valid clef"); } return map(chord, (note: Note) => lineForClef(note, clef)); }; export function lineForClef(note: Note, clef: Clef): number { if (!clef) { throw new Error("Exepected a valid clef"); } if (!note) { return 3; } else if (!!note.rest) { if (note.rest.displayStep) { return lineForClef_(note.rest.displayStep, note.rest.displayOctave, clef); } else if (note.noteType.duration === Count.Whole) { return 4; } else { return 3; } } else if (!!note.unpitched) { return lineForClef_(note.unpitched.displayStep, note.unpitched.displayOctave, clef); } else if (!!note.pitch) { return lineForClef_(note.pitch.step, note.pitch.octave, clef); } else { throw new Error("Invalid note"); } } export let offsetToPitch: { [key: string]: string } = { 0: "C", 0.5: "D", 1: "E", 1.5: "F", 2: "G", 2.5: "A", 3: "B" }; export let pitchOffsets: { [key: string]: number } = { C: 0, D: 0.5, E: 1, F: 1.5, G: 2, A: 2.5, B: 3 }; export function pitchForClef(relativeY: number, clef: Clef): Pitch { let line = relativeY / 10 + 3; let clefOffset = getClefOffset(clef); let offset2x = Math.round((line - clefOffset) * 2); let octave = Math.floor(offset2x / 7) + 3; let stepQuant = (Math.round(offset2x + 7*1000) % 7) / 2; if (stepQuant === 3.5) { octave = octave + 1; stepQuant = 0; } let step = offsetToPitch[stepQuant]; return { octave, step }; } export function lineForClef_(step: string, octave: string | number, clef: Clef): number { let octaveNum = (parseInt(<string> octave, 10) || 0); return getClefOffset(clef) + (octaveNum - 3) * 3.5 + pitchOffsets[step]; } /** * Returns true if a ledger line is needed, and false otherwise. * Will be changed once staves with > 5 lines are available. */ export function onLedger(note: Note, clef: Clef) { if (!note || note.rest || note.unpitched) { return false; } const line = lineForClef(note, clef); return line < 0.5 || line > 5.5; } export function ledgerLines(chord: IChord, clef: Clef) { let low = lowestLine(chord, clef); let high = highestLine(chord, clef); let lines: number[] = []; for (let i = 6; i <= high; ++i) { lines.push(i); } for (let i = 0; i >= low; --i) { lines.push(i); } return lines; } export function rest(chord: IChord): Rest { return !chord.length || chord[0].rest; } export let defaultClefLines: { [key: string]: number} = { G: 2, F: 4, C: 3, PERCUSSION: 3, TAB: 5, NONE: 3 }; export let clefOffsets: { [key: string]: number } = { G: -3.5, F: 2.5, C: -0.5, PERCUSSION: -0.5, TAB: -0.5, NONE: -0.5 }; export function getClefOffset(clef: Clef) { return clefOffsets[clef.sign] + clef.line - defaultClefLines[clef.sign.toUpperCase()] - 3.5 * parseInt(clef.clefOctaveChange || "0", 10); } export function barDivisionsDI(time: Time, divisions: number) { invariant(!!divisions, "Expected divisions to be set before calculating bar divisions."); if (time.senzaMisura != null) { return 1000000 * divisions; } const quarterNotes = reduce(time.beats, (memo, timeStr, idx) => memo + reduce(timeStr.split("+"), (memo, timeStr) => memo + parseInt(timeStr, 10) * 4 / time.beatTypes[idx], 0), 0); return quarterNotes * divisions || NaN; } export function barDivisions({time, divisions}: IAttributesSnapshot) { return barDivisionsDI(time, divisions); } export let IDEAL_STEM_HEIGHT: number = 35; export let MIN_STEM_HEIGHT: number = 25; export let chromaticScale: { [key: string]: number } = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; // c:12 export let countToHasStem: { [key: string]: boolean } = { 0.25: true, 0.5: false, 1: false, 2: true, 4: true, 8: true, 16: true, 32: true, 64: true, 128: true, 256: true, 512: true, 1024: true }; export let countToIsBeamable: { [key: string]: boolean } = { 8: true, 16: true, 32: true, 64: true, 128: true, 256: true, 512: true, 1024: true }; export let countToFlag: { [key: string]: string } = { 8: "flag8th", 16: "flag16th", 32: "flag32nd", 64: "flag64th", 128: "flag128th", 256: "flag256th", 512: "flag512th", 1024: "flag1024th" }; export let accidentalGlyphs: { [key: number]: string } = { [MxmlAccidental.NaturalFlat]: "accidentalNaturalSharp", [MxmlAccidental.SharpUp]: "accidentalThreeQuarterTonesSharpArrowUp", [MxmlAccidental.ThreeQuartersFlat]: "accidentalThreeQuarterTonesFlatZimmermann", [MxmlAccidental.ThreeQuartersSharp]: "accidentalThreeQuarterTonesSharpStein", [MxmlAccidental.QuarterFlat]: "accidentalQuarterToneFlatStein", [MxmlAccidental.Flat]: "accidentalFlat", [MxmlAccidental.TripleSharp]: "accidentalTripleSharp", [MxmlAccidental.Flat1]: null, [MxmlAccidental.Flat2]: null, [MxmlAccidental.Flat3]: null, [MxmlAccidental.Flat4]: null, [MxmlAccidental.Flat5]: null, [MxmlAccidental.Sharp1]: null, [MxmlAccidental.Sharp2]: null, [MxmlAccidental.Sharp3]: null, [MxmlAccidental.Sharp4]: null, [MxmlAccidental.Sharp5]: null, [MxmlAccidental.SlashQuarterSharp]: null, [MxmlAccidental.DoubleSlashFlat]: null, [MxmlAccidental.TripleFlat]: "accidentalTripleFlat", [MxmlAccidental.Sharp]: "accidentalSharp", [MxmlAccidental.QuarterSharp]: "accidentalQuarterToneSharpStein", [MxmlAccidental.SlashFlat]: "accidentalTavenerFlat", [MxmlAccidental.FlatDown]: "accidentalFlatJohnstonDown", [MxmlAccidental.NaturalDown]: "accidentalQuarterToneFlatNaturalArrowDown", [MxmlAccidental.SharpSharp]: "accidentalSharpSharp", [MxmlAccidental.FlatUp]: "accidentalFlatJohnstonUp", [MxmlAccidental.DoubleSharp]: "accidentalDoubleSharp", [MxmlAccidental.Sori]: "accidentalSori", [MxmlAccidental.SharpDown]: "accidentalQuarterToneSharpArrowDown", [MxmlAccidental.Koron]: "accidentalKoron", [MxmlAccidental.NaturalUp]: "accidentalQuarterToneSharpNaturalArrowUp", [MxmlAccidental.SlashSharp]: "accidentalTavenerSharp", [MxmlAccidental.NaturalSharp]: "accidentalNaturalSharp", [MxmlAccidental.FlatFlat]: "accidentalDoubleFlat", [MxmlAccidental.Natural]: "accidentalNatural", [MxmlAccidental.DoubleFlat]: "accidentalDoubleFlat" }; export let InvalidAccidental = -999; const CUSTOM_NOTEHEADS: {[key: number]: string[]} = { [NoteheadType.ArrowDown]: [ "noteheadLargeArrowDownBlack", "noteheadLargeArrowDownHalf", "noteheadLargeArrowDownWhole", "noteheadLargeArrowDownDoubleWhole"], [NoteheadType.ArrowUp]: ["noteheadLargeArrowUpBlack", "noteheadLargeArrowUpHalf", "noteheadLargeArrowUpWhole", "noteheadLargeArrowUpDoubleWhole"], [NoteheadType.BackSlashed]: ["noteheadSlashedBlack2", "noteheadSlashedHalf2", "noteheadSlashedWhole2", "noteheadSlashedDoubleWhole2"], [NoteheadType.CircleDot]: ["noteheadRoundWhiteWithDot", "noteheadCircledHalf", "noteheadCircledWhole", "noteheadCircledDoubleWhole"], [NoteheadType.CircleX]: ["noteheadCircledXLarge", "noteheadCircledXLarge", "noteheadCircledXLarge", "noteheadCircledXLarge"], [NoteheadType.Cluster]: ["noteheadNull", "noteheadNull", "noteheadNull", "noteheadNull"], // TODO [NoteheadType.Cross]: ["noteheadPlusBlack", "noteheadPlusHalf", "noteheadPlusWhole", "noteheadPlusDoubleWhole"], [NoteheadType.InvertedTriangle]: [ "noteheadTriangleDownBlack", "noteheadTriangleDownHalf", "noteheadTriangleDownWhole", "noteheadTriangleDownDoubleWhole"], [NoteheadType.LeftTriangle]: [ "noteheadTriangleRightBlack", "noteheadTriangleRightHalf", "noteheadTriangleRightWhole", "noteheadTriangleRightDoubleWhole"], // Finale has a different idea about what left means [NoteheadType.None]: [ "noteheadNull", "noteheadNull", "noteheadNull", "noteheadNull"], [NoteheadType.Slash]: ["noteheadSlashHorizontalEnds", "noteheadSlashWhiteHalf", "noteheadSlashWhiteWhole", "noteheadDoubleWhole"], [NoteheadType.Slashed]: ["noteheadSlashedBlack1", "noteheadSlashedHalf1", "noteheadSlashedWhole1", "noteheadSlashedDoubleWhole1"], [NoteheadType.X]: ["noteheadXBlack", "noteheadXHalf", "noteheadXWhole", "noteheadXDoubleWhole"], [NoteheadType.Do]: ["noteheadTriangleUpBlack", "noteheadTriangleUpHalf", "noteheadTriangleUpWhole", "noteheadTriangleUpDoubleWhole"], [NoteheadType.Triangle]: ["noteheadTriangleUpBlack", "noteheadTriangleUpHalf", "noteheadTriangleUpWhole", "noteheadTriangleUpDoubleWhole"], [NoteheadType.Re]: ["noteheadMoonBlack", "noteheadMoonWhite", "noteheadMoonWhite", "noteheadMoonWhite"], [NoteheadType.Mi]: ["noteheadDiamondBlack", "noteheadDiamondHalf", "noteheadDiamondWhole", "noteheadDiamondDoubleWhole"], [NoteheadType.Diamond]: ["noteheadDiamondBlack", "noteheadDiamondHalf", "noteheadDiamondWhole", "noteheadDiamondDoubleWhole"], [NoteheadType.Fa]: ["noteheadTriangleUpRightBlack", "noteheadTriangleUpRightWhite", "noteheadTriangleUpRightWhite", "noteheadTriangleUpRightWhite"], [NoteheadType.FaUp]: [ "noteheadTriangleUpRightBlack", "noteheadTriangleUpRightWhite", "noteheadTriangleUpRightWhite", "noteheadTriangleUpRightWhite"], [NoteheadType.So]: ["noteheadBlack", "noteheadHalf", "noteheadWhole", "noteheadDoubleWhole"], [NoteheadType.La]: ["noteheadSquareBlack", "noteheadSquareWhite", "noteheadSquareWhite", "noteheadSquareWhite"], [NoteheadType.Square]: ["noteheadSquareBlack", "noteheadSquareWhite", "noteheadSquareWhite", "noteheadSquareWhite"], [NoteheadType.Rectangle]: ["noteheadSquareBlack", "noteheadSquareWhite", "noteheadSquareWhite", "noteheadSquareWhite"], [NoteheadType.Ti]: [ "noteheadTriangleRoundDownBlack", "noteheadTriangleRoundDownWhite", "noteheadTriangleRoundDownWhite", "noteheadTriangleRoundDownWhite"] }; export function getNoteheadGlyph(notehead: Notehead, stdGlyph: string) { let type = notehead ? notehead.type : NoteheadType.Normal; if (type === NoteheadType.Normal) { return stdGlyph; } else { let noteheads = CUSTOM_NOTEHEADS[type]; if (noteheads) { if (noteheads[0] && stdGlyph === "noteheadBlack") { return noteheads[0]; } else if (noteheads[1] && stdGlyph === "noteheadHalf") { return noteheads[1]; } else if (noteheads[2] && stdGlyph === "noteheadWhole") { return noteheads[2]; } else if (noteheads[3] && stdGlyph === "noteheadDoubleWhole") { return noteheads[3]; } } } console.warn(`The custom notehead with ID ${type} cannot replace ` + `${this.props.notehead}, probably because it's not implemented.`); return this.props.notehead; } export function notationObj(n: Note): Notations { invariant(!n.notations || n.notations.length === 1, "Deprecated notations format"); return n.notations ? n.notations[0] : EMPTY_FROZEN; } export function articulationObj(n: Note): Articulations { return notationObj(n).articulations ? notationObj(n).articulations[0] : Object.freeze({}); } export function tieds(n: Note[]): Tied[] { return chain(n) .map(n => notationObj(n).tieds) .map(t => t && t.length ? t[0] : null) .value(); } export class FractionalDivisionsException { requiredDivisions: number; constructor(requiredDevisions: number) { this.requiredDivisions = requiredDevisions; } } export function divisions(chord: IChord | IDurationDescription, attributes: {time: Time, divisions: number}, allowFractional?: boolean) { if (_isIChord(chord) && some(chord, note => note.grace)) { return 0; } const chordCount = count(chord); const chordDots = dots(chord); const chordTM = timeModification(chord); const attributesTime = attributes.time.senzaMisura === undefined ? attributes.time : { beatTypes: [4], beats: ["1000"], }; let attributeDivisions = attributes.divisions; invariant(!!attributesTime, "A time signature must be specified."); if (chordCount === -1 || chordCount <= 1) { // TODO: What if beatType isn't consistent? return attributeDivisions * reduce(attributesTime.beats, (memo, durr) => memo + reduce(durr.split("+"), (m, l) => m + parseInt(l, 10), 0), 0); } if ((attributeDivisions * 4) % chordCount > 0 && !allowFractional) { const newDivisions = lcm(attributeDivisions * 4, chordCount) / 4; throw new FractionalDivisionsException(newDivisions); } const base = (attributeDivisions * 4) / chordCount; const tmFactor = chordTM ? chordTM.normalNotes / chordTM.actualNotes : 1.0; const dotFactor = times(chordDots, d => 1 / Math.pow(2, d + 1)) .reduce((m, i) => m + i, 1); const total = base * tmFactor * dotFactor; invariant(!isNaN(total), "calcDivisions must return a number. %s is not a number.", total); return total; }