satie
Version:
A sheet music renderer for the web
746 lines (641 loc) • 27.1 kB
text/typescript
/**
* 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 {Clef, Count, MultipleRest, Note, NoteheadType, Stem, StemType, Tremolo,
TimeModification, serializeNote} from "musicxml-interfaces";
import {forEach, times, reduce, map, max, some} from "lodash";
import * as invariant from "invariant";
import {Type} from "./document";
import {IBoundingRect} from "./private_boundingRect";
import {getBeamingPattern} from "./private_metre_checkBeaming";
import {IChord, ledgerLines, notationObj, countToIsBeamable, countToFlag,
startingLine, averageLine, highestLine, lowestLine,
heightDeterminingLine, countToHasStem, getNoteheadGlyph,
divisions as calcDivisions, FractionalDivisionsException,
barDivisions} from "./private_chordUtil";
import {IReadOnlyValidationCursor, LayoutCursor} from "./private_cursor";
import {VoiceBuilder} from "./engine_createPatch";
import {getWidth as getGlyphWidth} from "./private_smufl";
import ChordModel from "./implChord_chordModel";
import {IBeamLayout} from "./implChord_beamLayout";
import NoteImpl from "./implChord_noteImpl";
import {getChordLyricWidth} from "./implChord_lyrics";
import {getBoundingRects} from "./implChord_notation";
const IDEAL_STEM_HEIGHT = 35;
const MIN_STEM_HEIGHT = 30;
const BASE_GRACE_WIDTH = 11.4;
const BASE_STD_WIDTH = 30;
const GRACE_FLATTEN_FACTOR = 0.1;
const ACCIDENTAL_WIDTH = 0.73;
const LOG_STRETCH = 28;
let countToNotehead: { [key: number]: string } = {
[Count.Maxima]: "noteheadDoubleWhole",
[Count.Long]: "noteheadDoubleWhole",
[Count.Breve]: "noteheadDoubleWhole",
[Count.Whole]: "noteheadWhole",
[-1]: "noteheadWhole",
[Count.Half]: "noteheadHalf",
[Count.Quarter]: "noteheadBlack",
[Count.Eighth]: "noteheadBlack",
[Count._16th]: "noteheadBlack",
[Count._32nd]: "noteheadBlack",
[Count._64th]: "noteheadBlack",
[Count._128th]: "noteheadBlack",
[Count._256th]: "noteheadBlack",
[Count._512th]: "noteheadBlack",
[Count._1024th]: "noteheadBlack"
};
let countToRest: { [key: number]: string } = {
[Count.Maxima]: "restLonga",
[Count.Long]: "restLonga",
[Count.Breve]: "restDoubleWhole",
[Count.Whole]: "restWhole",
[-1]: "restWhole",
[Count.Half]: "restHalf",
[Count.Quarter]: "restQuarter",
[Count.Eighth]: "rest8th",
[Count._16th]: "rest16th",
[Count._32nd]: "rest32nd",
[Count._64th]: "rest64th",
[Count._128th]: "rest128th",
[Count._256th]: "rest256th",
[Count._512th]: "rest512th",
[Count._1024th]: "rest1024th"
};
/**
* A model that represents 1 or more notes in the same voice, starting on the same beat, and each
* with the same duration. Any number of these notes may be rests.
*/
class ChordModelImpl implements ChordModel.IChordModel, ArrayLike<NoteImpl> {
/*---- I.1 IModel ---------------------------------------------------------------------------*/
/** set in validate */
divCount: number;
divisions: number;
get staffIdx(): number {
return this[0].staff || 1;
}
set staffIdx(n: number) {
// Ignore.
}
/*---- I.2 IChord ---------------------------------------------------------------------------*/
[key: number]: NoteImpl;
length: number = 0;
/*---- II. Ext ------------------------------------------------------------------------------*/
wholebar: boolean;
satieStem: {
direction: number;
stemHeight: number;
stemStart: number;
tremolo?: Tremolo;
};
satieFlag: string;
satieDirection: StemType;
satieMultipleRest: MultipleRest;
noteheadGlyph: string[];
satieUnbeamedTuplet: IBeamLayout;
_clef: Clef;
key: string;
stem: Stem;
private _layout: ChordModelImpl.Layout;
get satieLedger(): number[] {
return ledgerLines(this, this._clef);
}
get rest() {
return some(this, note => note.rest);
}
get timeModification(): TimeModification {
return this[0].timeModification;
}
get notes(): NoteImpl[] {
return times(this.length, i => this[i]);
}
get count(): Count {
let noteType = this[0].noteType;
if (!noteType) {
return null;
}
return noteType.duration;
}
push(...notes: Note[]) {
forEach(notes, note => {
this[this.length] = new NoteImpl(this, this.length, note);
++this.length;
});
return this.length;
}
splice(start: number, deleteCount: number, ...replacements: NoteImpl[]) {
let notes = this.notes;
notes.splice(start, deleteCount, ...replacements);
times(this.length, i => delete this[i]);
forEach(notes, (n, i) => {
invariant(n instanceof NoteImpl, "Notes must be NoteImpls in Chords");
this[i] = n;
});
this.length = notes.length;
}
/*---- Implementation -----------------------------------------------------------------------*/
/**
* We accept either a Note from musicxml-interfaces, or an IChord, which
* is an array-like element of Notes. In either case, we create a deep copy.
*/
constructor(spec?: IChord | Note) {
if (!!spec) {
if (spec._class === "Note") {
this[0] = new NoteImpl(this, 0, spec);
this.length = 1;
} else if ((<IChord>spec).length) {
forEach((<IChord>spec), (note, idx) => {
this[idx] = new NoteImpl(this, idx, note);
});
this.length = (<IChord>spec).length;
}
}
}
_init: boolean = false;
refresh(cursor: IReadOnlyValidationCursor): void {
if (!this[0].noteType || !this[0].noteType.duration) {
let count = this._implyCountFromPerformanceData(cursor);
cursor.dangerouslyPatchWithoutValidation(voice => reduce(this, (builder, note, idx) => builder
.note(idx, j => j.noteType({duration: count})) as any, voice));
}
try {
const divCount = calcDivisions(this, cursor.staffAttributes);
if (divCount !== this.divCount) {
cursor.fixup([
{
p: [cursor.measureInstance.uuid, "parts", cursor.segmentInstance.part, "voices",
cursor.segmentInstance.owner, cursor.segmentPosition, "divCount"],
oi: divCount,
od: this.divCount,
},
]);
}
} catch (err) {
if (err instanceof FractionalDivisionsException) {
cursor.fixup([
{
p: ["divisions"],
oi: (err as FractionalDivisionsException).requiredDivisions,
od: cursor.staffAttributes.divisions,
}
]);
}
}
invariant(isFinite(this.divCount), "The beat count must be numeric");
invariant(this.divCount >= 0, "The beat count must be non-negative.");
const direction = this._pickDirection(cursor);
const clef = cursor.staffAttributes.clef;
this._clef = clef;
forEach(this, (note, idx) => {
if (!note.grace && note.duration !== this.divCount) {
cursor.patch(partBuilder => partBuilder
.note(idx, note => note
.duration(this.divCount),
)
);
}
if (idx > 0 && !note.chord) {
cursor.patch(partBuilder => partBuilder
.note(idx, note => note
.chord({}),
)
);
} else if (idx === 0 && note.chord) {
cursor.patch(partBuilder => partBuilder
.note(idx, note => note
.chord(null),
)
);
}
note.refresh(cursor);
note.updateAccidental(cursor);
});
// Check for second intervals:
let notesSortedByY = this.notes.sort((a,b) => a.defaultY - b.defaultY);
for (let i = 0; i < notesSortedByY.length; ++i) {
if (i + 1 < notesSortedByY.length && notesSortedByY[i + 1].defaultY - notesSortedByY[i].defaultY === 5) {
if (direction > 0) {
if (notesSortedByY[i].relativeX !== 0 || notesSortedByY[i + 1].relativeX !== 13) {
cursor.patch(voice => voice
.note(notesSortedByY[i]._idx, note => note.relativeX(0))
.note(notesSortedByY[i + 1]._idx, note => note.relativeX(13))
);
}
} else {
if (notesSortedByY[i].relativeX !== -13 || notesSortedByY[i + 1].relativeX !== 0) {
cursor.patch(voice => voice
.note(notesSortedByY[i]._idx, note => note.relativeX(-13))
.note(notesSortedByY[i + 1]._idx, note => note.relativeX(0))
);
}
}
++i;
} else {
if (notesSortedByY[i].relativeX !== 0) {
cursor.patch(voice => voice
.note(notesSortedByY[i]._idx, note => note.relativeX(0))
);
}
}
}
this.wholebar = this.divCount === barDivisions(cursor.staffAttributes) || this.divCount === -1;
let count = this.count;
invariant(isFinite(count) && count !== null,
"%s is not a valid count", count);
for (let i = 0; i < this.length; ++i) {
invariant(this[i].noteType.duration === count, "Inconsistent count (%s != %s)",
this[i].noteType.duration, count);
}
this._checkMulitpleRest(cursor);
this._implyNoteheads(cursor);
if (!this._init) {
if (countToIsBeamable[count]) {
this.satieFlag = countToFlag[count];
} else {
this.satieFlag = null;
}
if (this._hasStem()) {
this.satieStem = {
direction,
stemHeight: this._getStemHeight(direction, clef),
stemStart: startingLine(this, direction, clef)
};
this.satieDirection = direction === 1 ? StemType.Up : StemType.Down;
} else {
this.satieStem = null;
this.satieDirection = NaN;
}
}
}
getLayout(cursor: LayoutCursor): ChordModel.IChordLayout {
this._init = true;
if (!this._layout) {
this._layout = new ChordModelImpl.Layout();
}
this._layout.refresh(this, cursor);
return this._layout;
}
toJSON() {
let data: any = map(this, note => note);
return data;
}
toXML(): string {
let xml = "";
for (let i = 0; i < this.length; ++i) {
xml += serializeNote(this[i]) + "\n";
}
return xml;
}
inspect() {
return this.toXML();
}
calcWidth(shortest: number) {
let accidentalWidth = this.calcAccidentalWidth();
// TODO: Each note's width has a linear component proportional to log of its duration
// with respect to the shortest length
let extraWidth = this.divCount ?
(Math.log(this.divCount) - Math.log(shortest)) * LOG_STRETCH : 0;
const grace = this[0].grace;
if (grace) {
// TODO: Put grace notes in own segment
extraWidth *= GRACE_FLATTEN_FACTOR;
}
const baseWidth = grace ? BASE_GRACE_WIDTH : BASE_STD_WIDTH;
invariant(extraWidth >= 0, "Invalid extraWidth %s. shortest is %s, got %s", extraWidth,
shortest, this.divCount);
const totalWidth = baseWidth + extraWidth +
accidentalWidth + this.calcDotWidth();
return totalWidth;
}
calcAccidentalWidth() {
return reduce(this, (maxWidth, note) => {
return Math.max(maxWidth, note.accidental ? -note.accidental.defaultX : 0);
}, 0) * ACCIDENTAL_WIDTH;
}
calcDotWidth(): number {
if (this.wholebar || this.satieMultipleRest) {
return 0;
}
return max(map(this, m => (m.dots || []).length)) * 6;
}
private _implyCountFromPerformanceData(cursor: IReadOnlyValidationCursor) {
let count: Count;
const {time, divisions} = cursor.staffAttributes;
const ts = {
beatType: time.beatTypes[0], // FIXME
beats: reduce(time.beats, (sum, beat) => sum + parseInt(beat, 10), 0)
};
let factor = ts.beatType / 4;
let beats = factor * (this[0].duration / divisions);
count = 4 / (this[0].duration / divisions);
// Try dots
let dotFactor = 1;
let dots = 0;
while (!isPO2(1 / (beats / dotFactor / 4))) {
if (dots === 5) {
dots = 0;
break;
}
++dots;
dotFactor += Math.pow(1 / 2, dots);
}
if (dots > 0) {
count = (1 / (beats / dotFactor / 4 / factor));
cursor.patch(voiceA => reduce(times(this.length), (voice, idx) =>
voice.note(idx, note => note.dots(times(dots, dot => ({})))),
voiceA as VoiceBuilder));
}
// Try tuplets
// TODO
// Try ties
if (!isPO2(count)) {
// Whole bar rests can still exist even when there's no single NOTE duration
// that spans a bar.
if (beats === ts.beats && !!this[0].rest) {
count = Count.Whole;
} else {
let nextPO2 = Math.pow(2, Math.ceil(Math.log(this.count) / Math.log(2)));
count = nextPO2;
// TODO: Add 1+ tie.
}
}
// TODO: Find the best match for performance data
function isPO2(n: number) {
if (Math.abs(Math.round(n) - n) > 0.00001) {
return false;
}
n = Math.round(n);
/* tslint:disable */
return !!n && !(n & (n - 1));
/* tslint:enable */
}
return count;
}
private _getStemHeight(direction: number, clef: Clef): number {
let heightFromOtherNotes = (highestLine(this, clef) -
lowestLine(this, clef)) * 10;
let start = heightDeterminingLine(this, direction, clef) * 10;
let idealExtreme = start + direction * IDEAL_STEM_HEIGHT;
let result: number;
if (idealExtreme >= 65) {
result = Math.max(MIN_STEM_HEIGHT, IDEAL_STEM_HEIGHT - (idealExtreme - 65));
} else if (idealExtreme <= -15) {
result = Math.max(MIN_STEM_HEIGHT, IDEAL_STEM_HEIGHT - (-15 - idealExtreme));
} else {
result = 35;
}
// All stems in the main voice should touch the center line.
if (start > 30 && direction === -1 && start - result > 30) {
result = start - 30;
} else if (start < 30 && direction === 1 && start + result < 30) {
result = 30 - start;
}
// Grace note stems are short (though still proportionally pretty tall)
if (this[0].grace) {
result *= 0.75;
}
result += heightFromOtherNotes;
return result;
}
private _pickDirection(cursor: IReadOnlyValidationCursor) {
const {clef} = cursor.staffAttributes;
let avgLine = averageLine(this, clef);
if (avgLine > 3) {
return -1;
} else if (avgLine < 3) {
return 1;
} else {
// There's no "right answer" here. We'll follow what "Behind Bars" recommends.
// TODO: Consider notes outside current bar
// TODO: Consider notes outside current stave
// TODO: Handle clef changes correctly
let notes: ChordModelImpl[] =
cursor.segmentInstance.filter(el => cursor.factory.modelHasType(el, Type.Chord)) as any;
let nIdx = notes.indexOf(this);
// 1. Continue the stem direction of surrounding stems that are in one
// direction only
let linePrev = nIdx > 0 ? averageLine(notes[nIdx - 1], clef) : 3;
if (linePrev === 3 && nIdx > 0) {
// Note, the solution obtained may not be ideal, because we greedily resolve
// ties in a forward direction.
linePrev = notes[nIdx - 1].satieDirection === 1 ? 2.99 : 3.01;
}
let lineNext = nIdx + 1 < notes.length ?
averageLine(notes[nIdx + 1], clef) : 3;
if (linePrev > 3 && lineNext > 3) {
return -1;
}
if (linePrev < 3 && lineNext < 3) {
return 1;
}
// 2. When the stem direction varies within a bar, maintain the stem direction
// of the notes that are part of the same beat or half-bar.
// (Note: we use the more general beaming pattern instead of half-bar to
// decide boundries)
let {time} = cursor.staffAttributes;
let beamingPattern = getBeamingPattern(time);
let bpDivisions = map(beamingPattern, seg => calcDivisions(seg, cursor.staffAttributes));
let currDivision = cursor.segmentDivision;
let prevDivisionStart = 0;
let i = 0;
for (; i < bpDivisions.length; ++i) {
if (prevDivisionStart + bpDivisions[i] >= currDivision) {
break;
}
prevDivisionStart += bpDivisions[i];
}
let nextDivisionStart = prevDivisionStart + bpDivisions[i] || NaN;
let prevExists = prevDivisionStart < currDivision;
let nextExists = nextDivisionStart > currDivision + this.divCount;
let considerPrev = prevExists ? notes[nIdx - 1] : null;
let considerNext = nextExists ? notes[nIdx + 1] : null;
if (considerPrev && !considerNext && linePrev !== 3) {
return linePrev > 3 ? -1 : 1;
} else if (considerNext && !considerPrev && lineNext !== 3) {
return lineNext > 3 ? -1 : 1;
}
// 2b. Check beat when considerPrev && considerNext
// TODO: Implement me
// 3. When there is no clear-cut case for either direction, the convention
// is to use down-stem
return -1;
}
}
private _checkMulitpleRest(cursor: IReadOnlyValidationCursor) {
let {measureStyle} = cursor.staffAttributes;
let multipleRest = measureStyle && measureStyle.multipleRest;
if (multipleRest && multipleRest.count > 1) {
this.satieMultipleRest = measureStyle.multipleRest;
}
}
private _implyNoteheads(cursor: IReadOnlyValidationCursor) {
let {measureStyle} = cursor.staffAttributes;
if (measureStyle) {
forEach(this, note => {
if (measureStyle.slash) {
note.notehead = note.notehead || {type: null};
note.notehead.type = NoteheadType.Slash;
if (!measureStyle.slash.useStems) {
note.stem = {
type: StemType.None
};
}
}
});
}
if (this.rest) {
if (this.satieMultipleRest) {
this.noteheadGlyph = ["restHBar"];
} else {
this.noteheadGlyph = [countToRest[this.count]];
}
} else {
this.noteheadGlyph = times(this.length, () => countToNotehead[this.count]);
}
this.noteheadGlyph = this.noteheadGlyph.map((stdGlyph, idx) =>
getNoteheadGlyph(this[idx].notehead, stdGlyph));
}
private _hasStem() {
if (this[0] && this[0].stem && this[0].stem.type === StemType.None) {
return false;
}
return countToHasStem[this.count];
}
}
module ChordModelImpl {
export class Layout implements ChordModel.IChordLayout {
/*---- IChordLayout ------------------------------------------------------*/
// Constructed:
model: ChordModel.IDetachedChordModel;
x: number;
division: number;
renderedWidth: number;
notehead: string;
minSpaceBefore: number;
minSpaceAfter: number;
// Prototype:
boundingBoxes: IBoundingRect[];
renderClass: Type;
expandPolicy: "none" | "centered" | "after";
satieBeam: IBeamLayout;
satieStem: {
direction: number;
stemHeight: number;
stemStart: number;
tremolo?: Tremolo;
};
satieFlag: string;
/*---- Implementation ----------------------------------------------------*/
refresh(baseModel: ChordModelImpl, cursor: LayoutCursor) {
// ** this function should not modify baseModel **
this.division = cursor.segmentDivision;
let {measureStyle} = cursor.staffAttributes;
if (measureStyle.multipleRest && !measureStyle.multipleRestInitiatedHere) {
// This is not displayed because it is part of a multirest.
this.x = 0;
this.expandPolicy = "none";
return;
}
this.model = this._detachModelWithContext(cursor, baseModel);
this.satieStem = baseModel.satieStem;
this.satieFlag = baseModel.satieFlag;
this.boundingBoxes = this._captureBoundingBoxes();
let isWholeBar = baseModel.wholebar || baseModel.count === Count.Whole;
this.expandPolicy = baseModel.satieMultipleRest || baseModel.rest &&
isWholeBar ? "centered" : "after";
forEach(this.model, note => {
let staff = note.staff || 1;
invariant(!!staff,
"Expected the staff to be a non-zero number, got %s", staff);
let paddingTop = cursor.lineMaxPaddingTopByStaff[staff] || 0;
let paddingBottom = cursor.lineMaxPaddingBottomByStaff[staff] || 0;
cursor.lineMaxPaddingTopByStaff[staff] = Math.max(paddingTop, note.defaultY - 50);
cursor.lineMaxPaddingBottomByStaff[staff] = Math.max(paddingBottom, -note.defaultY - 25);
});
let accidentalWidth = baseModel.calcAccidentalWidth();
let totalWidth = baseModel.calcWidth(cursor.lineShortest);
invariant(isFinite(totalWidth), "Invalid width %s", totalWidth);
let noteheads = baseModel.noteheadGlyph;
let widths = map(noteheads, getGlyphWidth);
this.renderedWidth = max(widths);
if (baseModel.satieMultipleRest || baseModel.count === Count.Whole) {
forEach(this.model, note => note.dots = []);
}
this.x = cursor.segmentX + accidentalWidth;
this.minSpaceAfter = this._getMinWidthAfter(cursor);
this.minSpaceBefore = this._getMinWidthBefore(cursor);
cursor.segmentX += totalWidth;
}
private _captureBoundingBoxes(): IBoundingRect[] {
let bboxes: IBoundingRect[] = [];
forEach(this.model, note => {
let notations = notationObj(note); // TODO: detach this
let bbn = getBoundingRects(notations, note, this);
bboxes = bboxes.concat(bbn.bb);
note.notations = [bbn.n];
});
return bboxes;
}
private _getMinWidthBefore(cursor: LayoutCursor) {
return this._getLyricWidth(cursor) / 2;
}
private _getMinWidthAfter(cursor: LayoutCursor) {
return this._getLyricWidth(cursor) / 2;
}
private _getLyricWidth(cursor: LayoutCursor) {
let factor = 40 * 25.4 / 96; // 40 tenths in staff * pixelFactor
return getChordLyricWidth(this.model, factor);
}
private _detachModelWithContext(cursor: LayoutCursor,
baseModel: ChordModelImpl): ChordModel.IDetachedChordModel {
let model: ChordModel.IDetachedChordModel =
map(baseModel, (note, idx) => {
/* Here, we're extending each note to have the correct
* default position. To do so, we use prototypical
* inheritance. See Object.create. */
return Object.create(note, {
defaultX: {
get: () => {
return (note.relativeX || 0) +
((this as any).overrideX ||
this.x);
}
},
stem: {
get: () => {
return baseModel.stem || {
type: baseModel.satieDirection
};
}
}
});
}) as any;
model.stemX = () => ((this as any).overrideX || this.x);
model.staffIdx = baseModel.staffIdx;
model.divCount = baseModel.divCount;
model.satieLedger = baseModel.satieLedger;
model.noteheadGlyph = baseModel.noteheadGlyph;
model.satieMultipleRest = baseModel.satieMultipleRest;
model.satieUnbeamedTuplet = baseModel.satieUnbeamedTuplet;
return model;
}
}
Layout.prototype.expandPolicy = "after";
Layout.prototype.renderClass = Type.Chord;
Layout.prototype.boundingBoxes = [];
}
export default ChordModelImpl;