UNPKG

satie

Version:

A sheet music renderer for the web

496 lines (446 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/>. */ /** * @file models/musicxml/import.ts tools for converting MXMLJSON to SatieJSON */ import {ScoreTimewise, Attributes, Note, Backup, Forward, Time, Direction, parseScore} from "musicxml-interfaces"; import {buildNote} from "musicxml-interfaces/builders"; import {map, reduce, some, filter, minBy, times, every, forEach, startsWith, endsWith} from "lodash"; import * as invariant from "invariant"; import {Document} from "./document"; import {IMeasure, IMeasurePart, IModel, Type} from "./document"; import {IFactory} from "./private_factory"; import {ILayoutOptions} from "./private_layoutOptions"; import {MAX_SAFE_INTEGER} from "./private_util"; import {IChord, barDivisionsDI, divisions as calcDivisions} from "./private_chordUtil"; import {scoreParts} from "./private_part"; import {lcm} from "./private_util"; import {requireFont, whenReady} from "./private_fontManager"; import validate from "./engine_processors_validate"; import ScoreHeader from "./engine_scoreHeader"; import {makeFactory} from "./engine_setup"; /*---- Exports ----------------------------------------------------------------------------------*/ export function stringToDocument(src: string, factory: IFactory) { let mxmljson = parseScore(src); if ((mxmljson as any).error) { throw (mxmljson as any).error; } let document = timewiseStructToDocument(mxmljson, factory); if (document.error) { throw document.error; } let contextOptions: ILayoutOptions = { attributes: null, document, fixup: null, header: document.header, lineCount: NaN, // YYY lineIndex: NaN, // YYY measures: document.measures, modelFactory: factory, postprocessors: [], preprocessors: factory.preprocessors, preview: false, print: null, singleLineMode: false, }; validate(contextOptions); ScoreHeader.prototype.overwriteEncoding.call(document.header); return document; } /** * Converts a timewise MXMLJSON score to an uninitialized Satie score. * See also Models.importXML. * * @param score produced by github.com/jnetterf/musicxml-interfaces * @returns A structure that can be consumed by a score. If an error occurred * error will be set, and all other properties will be null. */ export function timewiseStructToDocument(score: ScoreTimewise, factory: IFactory): Document { try { let header = _extractMXMLHeader(score); let partData = _extractMXMLPartsAndMeasures(score, factory); if (partData.error) { return new Document(null, null, null, null, new Error(partData.error)); } return new Document(header, partData.measures, partData.parts, factory); } catch (error) { return new Document(null, null, null, null, error); } } /*---- Private ----------------------------------------------------------------------------------*/ export function _extractMXMLHeader(m: ScoreTimewise): ScoreHeader { let header = new ScoreHeader({ credits: m.credits, defaults: m.defaults, identification: m.identification, movementNumber: m.movementNumber, movementTitle: m.movementTitle, partList: m.partList, work: m.work }); // Add credits to help exporters don't record credits, but do record movementTitle. if ((!header.credits || !header.credits.length) && header.movementTitle) { header.title = header.movementTitle; } return header; } export function _extractMXMLPartsAndMeasures(input: ScoreTimewise, factory: IFactory): {measures?: IMeasure[]; parts?: string[]; error?: string} { let parts: string[] = map(scoreParts(input.partList), inPart => inPart.id); let createModel: typeof factory.create = factory.create.bind(factory); // TODO/STOPSHIP - sync division count in each measure let divisions = 768; // XXX: lilypond-regression 41g.xml does not specify divisions let gStaves = 0; let chordBeingBuilt: IChord = null; let lastAttribs: Attributes = null; let maxVoice = 0; let measures: IMeasure[] = map(input.measures, (inMeasure, measureIdx) => { let measure = { idx: measureIdx, implicit: inMeasure.implicit, nonControlling: inMeasure.nonControlling, number: inMeasure.number, parts: <{[key: string]: IMeasurePart}> {}, uuid: Math.floor(Math.random() * MAX_SAFE_INTEGER), width: inMeasure.width, version: 0 }; if (Object.keys(inMeasure.parts).length === 1 && "" in inMeasure.parts) { // See lilypond-regression >> 41g. inMeasure.parts[parts[0]] = inMeasure.parts[""]; delete inMeasure.parts[""]; } let linkedParts = map(inMeasure.parts, (val, key) => { if (!some(parts, part => part === key)) { // See lilypond-regression >> 41h. return null; } let output: IMeasurePart = { staves: [], voices: [] }; invariant(!(key in measure.parts), "Duplicate part ID %s", key); measure.parts[key] = output; invariant(!!key, "Part ID must be defined"); return { division: 0, divisionPerStaff: <number[]>[], divisionPerVoice: <number[]>[], id: key, idx: 0, input: val, lastNote: <IChord> null, output: output, times: <Time[]> [{ beatTypes: [4], beats: ["4"] }] }; }); linkedParts = filter(linkedParts, p => !!p); let commonDivisions = reduce(linkedParts, (memo, part) => { return reduce(part.input, (memo, input) => { if (input._class === "Attributes" && input.divisions) { return lcm(memo, input.divisions); } return memo; }, memo); }, divisions); // Lets normalize divisions here. forEach(linkedParts, part => { let previousDivisions = divisions; forEach(part.input, input => { if (input.divisions) { previousDivisions = input.divisions; input.divisions = commonDivisions; } if (input.count) { input.count *= commonDivisions / previousDivisions; } if (input.duration) { input.duration *= commonDivisions / previousDivisions; } }); }); let target = linkedParts[0]; // Create base structure while (!done()) { // target is accessed outside loop in syncStaffDivisions target = minBy(linkedParts, part => part.idx === part.input.length ? MAX_SAFE_INTEGER : part.division); invariant(!!target, "Target not specified"); let input = target.input[target.idx]; let prevStaff = 1; switch (input._class) { case "Note": let note: Note = input; // TODO: is this the case even if voice/staff don't match up? if (!!note.chord) { invariant(!!chordBeingBuilt, "Cannot add chord to a previous note without a chord"); chordBeingBuilt.push(note); } else { // Notes go in the voice context. let voice = note.voice || 1; let staff = note.staff || 1; prevStaff = staff; if (!(voice in target.output.voices)) { createVoice(voice, target.output); maxVoice = Math.max(voice, maxVoice); } // Make sure there is a staff segment reserved for the given staff if (!(staff in target.output.staves)) { createStaff(staff, target.output); } // Check target voice division and add spacing if needed target.divisionPerVoice[voice] = target.divisionPerVoice[voice] || 0; invariant(target.division >= target.divisionPerVoice[voice], "Ambiguous voice timing: all voices must be monotonic."); if (target.divisionPerVoice[voice] < target.division) { // Add rest let divisionsInVoice = target.divisionPerVoice[voice]; // This beautiful IIFE is needed because of undefined behaviour for // block-scoped variables in modules. let restModel = ((divisionsInVoice: number) => factory.fromSpec( buildNote(note => note .printObject(false) .rest({}) .duration(target.division - divisionsInVoice))) ) (divisionsInVoice); let division = target.divisionPerVoice[voice]; restModel[0].duration = target.division - division; target.output.voices[voice].push(restModel); target.divisionPerVoice[voice] = target.division; } // Add the note to the voice segment and register it as the // last inserted note let newNote = factory.fromSpec(input); target.output.voices[voice].push(newNote); chordBeingBuilt = newNote; // Update target division let divs: number; try { divs = calcDivisions([input], { time: target.times[0], divisions }); } catch(err) { console.warn("Guessing count from duration"); divs = input.duration; } target.divisionPerVoice[voice] += divs; target.division += divs; } break; case "Attributes": case "Barline": case "Direction": case "FiguredBass": case "Grouping": case "Harmony": case "Print": case "Sound": const staff = input._class === "Harmony" && !input.staff ? prevStaff : input.staff || 1; // Explodes to all staves at a later point. prevStaff = staff; if (!(staff in target.output.staves)) { target.output.staves[staff] = <any> []; target.output.staves[staff].owner = staff; target.output.staves[staff].ownerType = "staff"; } let newModel = factory.fromSpec(input); // Check if this is metadata: if (input._class === "Direction") { let direction = newModel as any as Direction; let words = direction.directionTypes.length === 1 && direction.directionTypes[0].words; if (words && words.length === 1) { let maybeMeta = words[0].data.trim(); if (startsWith(maybeMeta, "SATIE_SONG_META = ") && endsWith(maybeMeta, ";")) { // let songMeta = JSON.parse(maybeMeta.replace(/^SATIE_SONG_META = /, "").replace(/;$/, "")); break; // Do not actually import as direction } else if (startsWith(maybeMeta, "SATIE_MEASURE_META = ") && endsWith(maybeMeta, ";")) { let measureMeta = JSON.parse(maybeMeta.replace(/^SATIE_MEASURE_META = /, "").replace(/;$/, "")); measure.uuid = measureMeta.uuid; break; // Do not actually import as direction } } } syncAppendStaff(staff, newModel, input.divisions || divisions); if (input._class === "Attributes") { lastAttribs = <Attributes> input; divisions = lastAttribs.divisions || divisions; let oTimes = lastAttribs.times; if (oTimes && oTimes.length) { target.times = oTimes; } let staves = lastAttribs.staves || 1; gStaves = staves; times(staves, staffMinusOne => { let staff = staffMinusOne + 1; if (!(staff in target.output.staves)) { createStaff(staff, target.output); } }); } break; case "Forward": let forward = <Forward> input; forEach(target.output.staves, (staff, staffIdx) => { syncAppendStaff(staffIdx, null, input.divisions || divisions); }); target.division += forward.duration; break; case "Backup": let backup = <Backup> input; forEach(target.output.staves, (staff, staffIdx) => { syncAppendStaff(staffIdx, null, input.divisions || divisions); }); target.division -= backup.duration; break; default: invariant(false, "Unknown type %s", input._class); break; } ++target.idx; } // Finish up times(gStaves, staffMinusOne => { let staff = staffMinusOne + 1; if (!(staff in target.output.staves)) { createStaff(staff, target.output); maxVoice++; let voice = createVoice(maxVoice, target.output); let newNote: IChord = <any> factory.create(Type.Chord); newNote.push({ duration: barDivisionsDI(lastAttribs.times[0], lastAttribs.divisions), rest: {}, staff: staff, voice: maxVoice }); voice.push(<any>newNote); } }); forEach(linkedParts, part => { // Note: target is 'var'-scoped! target = part; // Set divCounts of final elements in staff segments and divisions of all segments forEach(target.output.staves, (staff, staffIdx) => { syncAppendStaff(staffIdx, null, divisions); let segment = target.output.staves[staffIdx]; if (segment) { segment.divisions = divisions; } }); forEach(target.output.voices, (voice, voiceIdx) => { let segment = target.output.voices[voiceIdx]; if (segment) { segment.divisions = divisions; } }); }); function syncAppendStaff(staff: number, model: IModel, localDivisions: number) { let ratio = localDivisions / divisions || 1; const divCount = ratio * (target.division - (target.divisionPerStaff[staff] || 0)); let segment = target.output.staves[staff]; invariant(!!model && !!segment || !model, "Unknown staff %s"); if (divCount > 0) { if (segment) { if (segment.length) { let model = segment[segment.length - 1]; model.divCount = model.divCount || 0; model.divCount += divCount; } else { let model = createModel(Type.Spacer, { divCount, staff }); segment.push(model); } } target.divisionPerStaff[staff] = target.division; } if (model) { if (divCount >= 0 || !divCount) { segment.push(model); } else { let offset = divCount; let spliced = false; for (let i = segment.length - 1; i >= 0; --i) { offset += segment[i].divCount; if (offset >= 0) { model.divCount = segment[i].divCount - offset; invariant(isFinite(model.divCount), "Invalid loaded divCount"); segment[i].divCount = offset; segment.splice(i + 1, 0, model); spliced = true; break; } } invariant(spliced, "Could not insert %s", model); } } } function done() { return every(linkedParts, part => { return part.idx === part.input.length; }); } return measure; }); return { measures: measures, parts: parts }; } function createVoice(voice: number, output: IMeasurePart) { output.voices[voice] = <any> []; output.voices[voice].owner = voice; output.voices[voice].ownerType = "voice"; return output.voices[voice]; } function createStaff(staff: number, output: IMeasurePart) { output.staves[staff] = <any> []; output.staves[staff].owner = staff; output.staves[staff].ownerType = "staff"; return output.staves[staff]; } /** * Parses a MusicXML document and returns a Document. */ export function importXML(src: string, cb: (error: Error, document?: Document, factory?: IFactory) => void) { requireFont("Bravura", "root://bravura/otf/Bravura.otf"); requireFont("Alegreya", "root://alegreya/Alegreya-Regular.ttf"); requireFont("Alegreya", "root://alegreya/Alegreya-Bold.ttf", "bold"); whenReady((err) => { if (err) { cb(err); } else { try { let factory = makeFactory(); cb(null, stringToDocument(src, factory), factory); } catch (err) { cb(err); } } }); }