UNPKG

satie

Version:

A sheet music renderer for the web

597 lines (525 loc) 24.3 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/>. */ /** * @file engine/processors/measure.ts provides functions for validating and laying out measures */ import * as invariant from "invariant"; import {IAny} from "musicxml-interfaces/operations"; import {ScoreHeader, Print} from "musicxml-interfaces"; import {keyBy, filter, map, reduce, values, flatten, forEach, some, last} from "lodash"; import {Document, IMeasure, ISegment, IModel, Type, ILayout} from "./document"; import {getNativeKeyAccidentals} from "./implAttributes_attributesData"; import {IAttributesSnapshot} from "./private_attributesSnapshot"; import {ICombinedLayout, mergeSegmentsInPlace} from "./private_combinedLayout"; import {ValidationCursor, LayoutCursor} from "./private_cursor"; import {IFactory} from "./private_factory"; import {IMeasureLayout} from "./private_measureLayout"; import {scoreParts} from "./private_part"; import {cloneObject} from "./private_util"; import {barDivisions, InvalidAccidental} from "./private_chordUtil"; import DivisionOverflowException from "./engine_divisionOverflowException"; export interface IMeasureLayoutOptions { document: Document; header: ScoreHeader; measure: IMeasure; print: Print; x: number; lineShortest: number; lineBarOnLine: number; lineTotalBarsOnLine: number; lineIndex: number; lineCount: number; singleLineMode: boolean; factory: IFactory; preview: boolean; attributes: {[key: string]: IAttributesSnapshot[]}; fixup: (segment: ISegment, operations: IAny[]) => void; } interface IStaffContext { attributes: IAttributesSnapshot; accidentals: {[key: string]: number}; division: number; } /** * Given a bunch of segments and the context (measure, line), returns information needed to lay the * models out. Note that the order of the output is arbitrary and may not correspond to the order * of the input segments. * * @segments Models to lay out or validate. * @measure Model to which the model belongs to. * @line Line context * * Complexity: O(staff-voice pairs) */ export function refreshMeasure(spec: IRefreshMeasureOpts): IMeasureLayout { const gMeasure = spec.measure; invariant(!!spec.attributes, "Attributes must be defined"); const gInitialAttribs = cloneObject(spec.attributes); let gPrint: Print = spec.print; let gMaxDivisions = 0; if (!spec.document.cleanlinessTracking.measures[spec.measure.uuid]) { spec.document.cleanlinessTracking.measures[spec.measure.uuid] = { clean: null, x: {}, layout: null, }; } // Cleanliness is also part-owned by the line processor. The layout in // cleanliness is not the output of this function -- it also has been // treated by postprocessors. This function sets "x" and uses the clean-state // to avoid unnecessary work. let cleanliness = spec.document.cleanlinessTracking.measures[spec.measure.uuid]; let oldLayout = cleanliness.layout; invariant(spec.segments.length >= 1, "_processMeasure expects at least one segment."); Object.keys(spec.measure.parts).forEach(part => { cleanliness.x[part] = cleanliness.x[part] || {}; spec.measure.parts[part].voices.forEach(voice => { if (!voice) { return; } cleanliness.x[part][voice.owner] = cleanliness.x[part][voice.owner] || { voiceX: [], staffX: spec.measure.parts[part].staves.reduce((memo, staff) => { if (staff) { memo[staff.owner] = []; } return memo; }, {} as {[staff: number]: number[]}), }; }); }); const gStaffMeasure: { [key: string]: ISegment } = keyBy(filter(spec.segments, seg => seg.ownerType === "staff"), seg => `${seg.part}_${seg.owner}`); const gVoiceMeasure: { [key: string]: ISegment } = keyBy(filter(spec.segments, seg => seg.ownerType === "voice"), seg => `${seg.part}_${seg.owner}`); const gStaffLayouts: { [key: string]: ILayout[][] } = {}; let gMaxXInMeasure = spec.measureX; const gMaxPaddingTopInMeasure = <number[]> []; const gMaxPaddingBottomInMeasure = <number[]> []; let gDivOverflow: DivisionOverflowException = null; let lastPrint = spec.print; let vCursor: ValidationCursor; function fixup(operations: IAny[]) { const localSegment = vCursor.segmentInstance; const restartRequired = some(operations, op => { if (op.p[0] === "divisions") { return true; } invariant(String(op.p[0]) === String(spec.measure.uuid), `Unexpected fixup for a measure ${op.p[0]} ` + `other than the current ${spec.measure.uuid}`); invariant(op.p[1] === "parts", "Expected p[1] to be parts"); invariant(op.p[2] === localSegment.part, `Expected part ${op.p[2]} to be ${localSegment.part}`); if (localSegment.ownerType === "voice") { if (typeof op.p[4] === "string") { op.p[4] = parseInt(op.p[4] as string, 10); } invariant(op.p[3] === "voices", "We are in a voice, so we can only patch the voice"); invariant(op.p[4] === localSegment.owner, `Expected voice owner ${localSegment.owner}, got ${op.p[4]}`); return op.p.length === 6 && (op.p[5] <= vCursor.segmentPosition) || op.p[5] < vCursor.segmentPosition; } else if (localSegment.ownerType === "staff") { invariant(op.p[3] === "staves", "We are in a staff, so we can only patch the staff"); invariant(op.p[4] === localSegment.owner, `Expected staff owner ${localSegment.owner}, got ${op.p[4]}`); return op.p.length === 6 && (op.p[5] <= vCursor.segmentPosition) || op.p[5] < vCursor.segmentPosition; } invariant(false, `Invalid segment owner type ${localSegment.ownerType}`); }); spec.fixup(localSegment, operations, restartRequired); }; const gVoiceLayouts = map(gVoiceMeasure, voiceSegment => { const {part} = voiceSegment; gInitialAttribs[part] = gInitialAttribs[part] || []; const voiceStaves: {[key: number]: ILayout[]} = {}; const staffContexts: {[key: number]: IStaffContext} = {}; const xPerStaff: {[key: number]: number} = []; let measureIsLast = gMeasure.uuid === last(spec.document.measures).uuid; vCursor = new ValidationCursor({ ...spec, measureInstance: gMeasure, measureIsLast, page: 1, print: lastPrint, segment: voiceSegment, staffAccidentals: null, staffAttributes: null, staffIdx: NaN, fixup, }); const lCursor = new LayoutCursor({ ...spec, validationCursor: vCursor, x: spec.measureX }); /** * Processes a staff model within this voice's context. */ function pushStaffSegment(staffIdx: number, model: IModel, catchUp: boolean) { if (!model) { staffContexts[staffIdx].division = vCursor.segmentDivision + 1; return; } const oldDivision = vCursor.segmentDivision; const oldSegment = vCursor.segmentInstance; const oldIdx = vCursor.segmentPosition; vCursor.segmentDivision = staffContexts[staffIdx].division; vCursor.staffAccidentals = staffContexts[staffIdx].accidentals; vCursor.staffAttributes = staffContexts[staffIdx].attributes; if (catchUp) { lCursor.segmentX = xPerStaff[staffIdx]; } vCursor.segmentInstance = gStaffMeasure[`${part}_${staffIdx}`]; vCursor.segmentPosition = voiceStaves[staffIdx].length; let layout: ILayout; model.key = `SATIE${vCursor.measureInstance.uuid}_parts_${vCursor.segmentInstance.part}_staves_${ vCursor.segmentInstance.owner}_${vCursor.segmentPosition}`; model.staffIdx = vCursor.staffIdx; if (vCursor.factory.modelHasType(model, Type.Barline)) { const totalDivisions = barDivisions(vCursor.staffAttributes); const divsToAdvance = totalDivisions - vCursor.segmentDivision; if (divsToAdvance > 0) { vCursor.advance(divsToAdvance); } } if (spec.mode === RefreshMode.RefreshModel) { model.refresh(vCursor.const()); } if (vCursor.factory.modelHasType(model, Type.Attributes)) { vCursor.staffAttributes = model._snapshot; vCursor.staffAccidentals = getNativeKeyAccidentals(model._snapshot.keySignature); } if (vCursor.factory.modelHasType(model, Type.Print)) { vCursor.print = model; } if (spec.mode === RefreshMode.RefreshLayout) { layout = model.getLayout(lCursor); layout.part = part; layout.key = model.key; if (spec.preview) { lCursor.segmentX = cleanliness.x[part][voiceSegment.owner].staffX[staffIdx][lCursor.segmentPosition]; } cleanliness.x[part][voiceSegment.owner].staffX[staffIdx][lCursor.segmentPosition] = lCursor.segmentX; } invariant(isFinite(model.divCount), "%s should be a constant division count", model.divCount); vCursor.segmentDivision += model.divCount; if (vCursor.staffAttributes) { const totalDivisions = barDivisions(vCursor.staffAttributes); if (vCursor.segmentDivision > totalDivisions && !!gDivOverflow) { if (!gDivOverflow) { gDivOverflow = new DivisionOverflowException( totalDivisions, spec.measure, vCursor.staffAttributes); } invariant(totalDivisions === gDivOverflow.maxDiv, "Divisions are not consistent. Found %s but expected %s", totalDivisions, gDivOverflow.maxDiv); } } else { invariant(vCursor.segmentDivision === 0, "Expected attributes to be set on cursor"); } staffContexts[staffIdx].division = vCursor.segmentDivision; staffContexts[staffIdx].accidentals = vCursor.staffAccidentals; staffContexts[staffIdx].attributes = vCursor.staffAttributes; xPerStaff[staffIdx] = lCursor.segmentX; vCursor.segmentDivision = oldDivision; vCursor.segmentInstance = oldSegment; vCursor.segmentPosition = oldIdx; if (spec.mode === RefreshMode.RefreshLayout) { invariant(!!layout, "%s must be a valid layout", layout); } voiceStaves[staffIdx].push(layout); } let segmentLayout: ILayout[] = []; for (let i = 0; i < voiceSegment.length; ++i) { const model = voiceSegment[i]; const atEnd = i + 1 === voiceSegment.length; const staffIdx: number = model.staffIdx; invariant(isFinite(model.staffIdx), "%s must be finite", model.staffIdx); if (!lCursor.lineMaxPaddingTopByStaff[model.staffIdx]) { lCursor.lineMaxPaddingTopByStaff[model.staffIdx] = 0; } if (!lCursor.lineMaxPaddingBottomByStaff[model.staffIdx]) { lCursor.lineMaxPaddingBottomByStaff[model.staffIdx] = 0; } if (!staffContexts[staffIdx]) { staffContexts[staffIdx] = { accidentals: null, attributes: gInitialAttribs[part][staffIdx], division: 0, }; } // Create a voice-staff pair if needed. We'll later merge all the // voice staff pairs. if (!voiceStaves[staffIdx]) { voiceStaves[staffIdx] = []; gStaffLayouts[`${part}_${staffIdx}`] = gStaffLayouts[`${part}_${staffIdx}`] || []; gStaffLayouts[`${part}_${staffIdx}`].push(voiceStaves[staffIdx]); xPerStaff[staffIdx] = 0; } vCursor.segmentPosition = i; vCursor.staffAccidentals = staffContexts[staffIdx].accidentals; vCursor.staffAttributes = staffContexts[staffIdx].attributes; vCursor.staffIdx = staffIdx; while (staffContexts[staffIdx].division <= vCursor.segmentDivision) { let nextStaffEl = gStaffMeasure[`${part}_${staffIdx}`] [voiceStaves[staffIdx].length]; // We can mostly ignore priorities here, since except for barlines, // staff segments are more important than voice segments. let nextIsBarline = spec.factory.modelHasType(nextStaffEl, Type.Barline); if (nextIsBarline && staffContexts[staffIdx].division === vCursor.segmentDivision) { break; } // Process a staff model within a voice context. let catchUp = staffContexts[staffIdx].division < vCursor.segmentDivision; pushStaffSegment(staffIdx, nextStaffEl, catchUp); invariant(isFinite(staffContexts[staffIdx].division), "divisionPerStaff is supposed " + "to be a number, got %s", staffContexts[staffIdx].division); } // All layout that can be controlled by the model is done here. let layout: ILayout; model.key = `SATIE${vCursor.measureInstance.uuid}_parts_${vCursor.segmentInstance.part}_voices_${ vCursor.segmentInstance.owner}_${vCursor.segmentPosition}`; model.staffIdx = vCursor.staffIdx; if (!vCursor.staffAccidentals) { vCursor.staffAccidentals = getNativeKeyAccidentals(vCursor.staffAttributes.keySignature); } if (spec.mode === RefreshMode.RefreshModel) { model.refresh(vCursor.const()); } if (vCursor.factory.modelHasType(model, Type.Chord)) { forEach(model, note => { if (note.rest) { return; } const pitch = note.pitch; if ((vCursor.staffAccidentals[pitch.step + pitch.octave] || 0) !== (pitch.alter || 0) || (vCursor.staffAccidentals[pitch.step] || 0) !== (pitch.alter || 0)) { vCursor.staffAccidentals[pitch.step + pitch.octave] = pitch.alter || 0; if ((vCursor.staffAccidentals[pitch.step] || 0) !== (pitch.alter || 0)) { vCursor.staffAccidentals[pitch.step] = InvalidAccidental; } } }); } if (spec.mode === RefreshMode.RefreshLayout) { layout = model.getLayout(lCursor); if (spec.preview) { lCursor.segmentX = cleanliness.x[part][voiceSegment.owner].voiceX[lCursor.segmentPosition]; } cleanliness.x[part][voiceSegment.owner].voiceX[lCursor.segmentPosition] = lCursor.segmentX; layout.part = part; layout.key = model.key; } vCursor.segmentDivision += model.divCount; gMaxDivisions = Math.max(gMaxDivisions, vCursor.segmentDivision); const totalDivisions = barDivisions(vCursor.staffAttributes); if (vCursor.segmentDivision > totalDivisions && !spec.preview) { // Note: unfortunate copy-pasta. if (!gDivOverflow) { gDivOverflow = new DivisionOverflowException( totalDivisions, spec.measure, vCursor.staffAttributes); } invariant(totalDivisions === gDivOverflow.maxDiv, "Divisions are not consistent. Found %s but expected %s", totalDivisions, gDivOverflow.maxDiv); invariant(!!voiceSegment.part, "Part must be defined -- is this spec from Engine.validate$?"); } if (atEnd) { // Finalize. forEach(gStaffMeasure, (staff, idx) => { const pIdx = idx.lastIndexOf("_"); const staffMeasurePart = idx.substr(0, pIdx); if (staffMeasurePart !== part) { return; } const nidx = parseInt(idx.substr(pIdx + 1), 10); let voiceStaff = voiceStaves[nidx]; if (!!staff && !!voiceStaff) { while (voiceStaff.length < staff.length) { pushStaffSegment(nidx, staff[voiceStaff.length], false); } } }); } const previousAttribs = vCursor.staffAttributes; gInitialAttribs[voiceSegment.part][model.staffIdx] = previousAttribs; gPrint = vCursor.print; gMaxXInMeasure = Math.max(lCursor.segmentX, gMaxXInMeasure); gMaxPaddingTopInMeasure[model.staffIdx] = Math.max( lCursor.lineMaxPaddingTopByStaff[model.staffIdx], gMaxPaddingTopInMeasure[model.staffIdx] || 0); gMaxPaddingBottomInMeasure[model.staffIdx] = Math.max( lCursor.lineMaxPaddingBottomByStaff[model.staffIdx], gMaxPaddingBottomInMeasure[model.staffIdx] || 0); segmentLayout.push(layout); } lastPrint = spec.print; return segmentLayout; }); if (gDivOverflow) { throw gDivOverflow; } // Get an ideal voice layout for each voice-staff combination const gStaffLayoutsUnkeyed: ILayout[][][] = values(gStaffLayouts) as ILayout[][][]; const gStaffLayoutsCombined: ILayout[][] = flatten(gStaffLayoutsUnkeyed); // Create a layout that satisfies the constraints in every single voice. // IModel.mergeSegmentsInPlace requires two passes to fully merge the layouts. // We do the second pass once we filter unneeded staff segments. const gAllLayouts = gStaffLayoutsCombined.concat(gVoiceLayouts); // We have a staff layout for every single voice-staff combination. // They will be merged, so it doesn't matter which one we pick. // Pick the first. const gStaffLayoutsUnique = map(gStaffLayoutsUnkeyed, layouts => layouts[0]); if (!spec.noAlign) { // Calculate and finish applying the master layout. // Two passes is always sufficient. let masterLayout = reduce(gAllLayouts, mergeSegmentsInPlace, []); // Avoid lining up different divisions reduce(masterLayout, ({prevDivision, min}: ISpreadMemo, layout: ICombinedLayout) => { let newMin = layout.x; if (min >= layout.x && layout.division !== prevDivision && layout.renderClass !== Type.Spacer && layout.renderClass !== Type.Barline) { layout.x = min + 20; } return { prevDivision: layout.division, min: newMin }; }, {prevDivision: -1, min: -10}); reduce(gVoiceLayouts, mergeSegmentsInPlace, masterLayout); // Merge in the staves reduce(gStaffLayoutsUnique, mergeSegmentsInPlace, masterLayout); } const gPadding = gMaxXInMeasure === spec.measureX || spec.lineBarOnLine + 1 === spec.lineTotalBarsOnLine ? 0 : 15; let newLayout: IMeasureLayout; if (spec.mode === RefreshMode.RefreshLayout && spec.preview) { newLayout = { attributes: oldLayout.attributes, print: oldLayout.print, elements: oldLayout.elements, width: oldLayout.width, maxDivisions: oldLayout.maxDivisions, originX: spec.measureX, originY: {}, paddingTop: oldLayout.paddingTop, paddingBottom: oldLayout.paddingBottom, getVersion: () => gMeasure.version, uuid: gMeasure.uuid, }; } else { newLayout = { attributes: gInitialAttribs, print: gPrint, elements: gStaffLayoutsUnique.concat(gVoiceLayouts), width: gMaxXInMeasure + gPadding - spec.measureX, maxDivisions: gMaxDivisions, originX: spec.measureX, originY: {}, paddingTop: gMaxPaddingTopInMeasure, paddingBottom: gMaxPaddingBottomInMeasure, getVersion: () => gMeasure.version, uuid: gMeasure.uuid, }; } if (spec.mode === RefreshMode.RefreshLayout && !spec.preview) { cleanliness.clean = newLayout; } return newLayout; } interface ISpreadMemo { prevDivision: number; min: number; } export enum RefreshMode { RefreshModel, RefreshLayout, }; export interface IRefreshMeasureOpts { document: Document; print: Print; factory: IFactory; header: ScoreHeader; measure: IMeasure; measureX: number; segments: ISegment[]; singleLineMode: boolean; attributes: {[part: string]: IAttributesSnapshot[]}; lineShortest: number; lineBarOnLine: number; lineTotalBarsOnLine: number; lineIndex: number; lineCount: number; noAlign?: boolean; mode: RefreshMode; preview: boolean; fixup: (segment: ISegment, operations: IAny[], restartRequired: boolean) => void; } /** * Given the context and constraints given, creates a possible layout for items within a measure. * * @param opts structure with __normalized__ voices and staves * @returns an array of staff and voice layouts with an undefined order */ export function layoutMeasure( { document, header, print, measure, factory, x, singleLineMode, preview, fixup, lineShortest, lineBarOnLine, lineTotalBarsOnLine, lineIndex, lineCount, attributes, }: IMeasureLayoutOptions): IMeasureLayout { const parts = map(scoreParts(header.partList), part => part.id); const staves = flatten(map(parts, partId => measure.parts[partId].staves)) as ISegment[]; const voices = flatten(map(parts, partId => measure.parts[partId].voices)) as ISegment[]; const segments = filter(voices.concat(staves), s => !!s); const status = refreshMeasure({ document, factory, print, header, measure: measure, measureX: x, segments, lineShortest, lineBarOnLine, lineTotalBarsOnLine, lineIndex, lineCount, mode: RefreshMode.RefreshLayout, singleLineMode, preview, fixup, attributes, }); return status; }