Spaces:
Sleeping
Sleeping
import {DOMParser, XMLSerializer} from "xmldom"; | |
import {MusicNotation} from "@k-l-lambda/music-widgets"; | |
import {MIDI} from "@k-l-lambda/music-widgets"; | |
import {Readable} from "stream"; | |
import CRC32 from "crc-32"; | |
import npmPackage from "../package.json"; | |
import {xml2ly, engraveSvg, LilyProcessOptions} from "./lilyCommands"; | |
import {LilyDocument, LilyTerms, docLocationSet} from "../inc/lilyParser"; | |
import * as staffSvg from "../inc/staffSvg"; | |
import {SingleLock} from "../inc/mutex"; | |
import * as LilyNotation from "../inc/lilyNotation"; | |
import {svgToPng} from "./canvas"; | |
import LogRecorder from "../inc/logRecorder"; | |
import ScoreJSON from "../inc/scoreJSON"; | |
import {LilyDocumentAttributeReadOnly} from "../inc/lilyParser/lilyDocument"; | |
import {Block} from "../inc/lilyParser/lilyTerms"; | |
interface GrammarParser { | |
parse (source: string): any; | |
}; | |
const markupLily = (source: string, markup: string, lilyParser: GrammarParser): string => { | |
const docMarkup = new LilyDocument(lilyParser.parse(markup)); | |
const docSource = new LilyDocument(lilyParser.parse(source)); | |
/*// copy attributes | |
const attrS = docSource.globalAttributes() as LilyDocumentAttribute; | |
const attrM = docMarkup.globalAttributes({readonly: true}) as LilyDocumentAttributeReadOnly; | |
[ | |
"staffSize", "paperWidth", "paperHeight", | |
"topMargin", "bottomMargin", "leftMargin", "rightMargin", | |
"systemSpacing", "topMarkupSpacing", "raggedLast", "raggedBottom", "raggedLastBottom", | |
"printPageNumber", | |
].forEach(field => { | |
if (attrM[field] !== undefined) { | |
if (typeof attrS[field].value === "object" && attrS[field].value && (attrS[field].value as any).set) | |
(attrS[field].value as any).set(attrM[field]); | |
else | |
attrS[field].value = attrM[field]; | |
} | |
}); | |
// execute commands list | |
const commands = docMarkup.root.getField("LotusCommands"); | |
const cmdList = commands && commands.value && commands.value.args && commands.value.args[0].body; | |
if (cmdList && Array.isArray(cmdList)) { | |
for (const command of cmdList) { | |
if (command.exp && docSource[command.exp]) | |
docSource[command.exp](); | |
else | |
console.warn("unexpected markup command:", command); | |
} | |
} | |
// copy LotusOption assignments | |
const assignments = docMarkup.root.entries.filter(term => term instanceof LilyTerms.Assignment && /^LotusOption\..+/.test(term.key.toString())); | |
assignments.forEach(assignment => docSource.root.sections.push(assignment.clone()));*/ | |
docSource.markup(docMarkup); | |
return docSource.toString(); | |
}; | |
const xmlBufferToLy = async (xml: Buffer, options: LilyProcessOptions = {}): Promise<string> => { | |
const bom = (xml[0] << 8 | xml[1]); | |
const utf16 = bom === 0xfffe; | |
const content = xml.toString(utf16 ? "utf16le" : "utf8"); | |
return await xml2ly(content, {replaceEncoding: utf16, ...options}); | |
}; | |
const unescapeStringExp = exp => exp && exp.toString(); | |
interface SheetNotationResult extends Partial<ScoreJSON> { | |
midi: MIDI.MidiData; | |
midiNotation: MusicNotation.NotationData; | |
//sheetNotation: staffSvg.StaffNotation.SheetNotation; | |
lilyDocument: LilyDocument; | |
bakingImages?: Readable[]; | |
}; | |
const makeSheetNotation = async (source: string, lilyParser: GrammarParser, {withNotation = false, logger, lilyDocument, includeFolders, baking}: { | |
withNotation?: boolean, | |
logger?: LogRecorder, | |
lilyDocument?: LilyDocument, | |
includeFolders?: string[], | |
baking?: boolean, | |
} = {}): Promise<SheetNotationResult> => { | |
let midi = null; | |
let midiNotation = null; | |
const pages = []; | |
const hashTable = {}; | |
const bakingImages = []; | |
const t0 = Date.now(); | |
type ParserArguments = { | |
attributes: LilyDocumentAttributeReadOnly, | |
tieLocations: Set<string>, | |
briefChordLocations: Set<string>, | |
lyricLocations: Set<string>, | |
}; | |
const argsGen = new SingleLock<ParserArguments>(true); | |
const engraving = await engraveSvg(source, { | |
includeFolders, | |
// do some work during lilypond process running to save time | |
onProcStart: () => { | |
//console.log("tp.0:", Date.now() - t0); | |
if (!lilyDocument) { | |
lilyDocument = new LilyDocument(lilyParser.parse(source)); | |
lilyDocument.interpret(); | |
} | |
const attributes = lilyDocument.globalAttributes({readonly: true}) as LilyDocumentAttributeReadOnly; | |
const tieLocations = docLocationSet(lilyDocument.getTiedNoteLocations2()); | |
const briefChordLocations = docLocationSet(lilyDocument.getBriefChordLocations()); | |
const lyricLocations = docLocationSet(lilyDocument.getLyricLocations()); | |
argsGen.release({attributes, tieLocations, briefChordLocations, lyricLocations}); | |
//console.log("tp.1:", Date.now() - t0); | |
}, | |
onMidiRead: midi_ => { | |
//console.log("tm.0:", Date.now() - t0); | |
midi = midi_; | |
if (withNotation) | |
midiNotation = midi && MusicNotation.Notation.parseMidi(midi); | |
//console.log("tm.1:", Date.now() - t0); | |
}, | |
onSvgRead: async (index, svg) => { | |
//console.log("ts.0:", Date.now() - t0); | |
const args = await argsGen.wait(); | |
const page = staffSvg.parseSvgPage(svg, source, {DOMParser, logger, ...args}); | |
pages[index] = page.structure; | |
Object.assign(hashTable, page.hashTable); | |
//console.log("ts.1:", Date.now() - t0); | |
}, | |
}); | |
logger.append("scoreMaker.profile.engraving", {cost: Date.now() - t0}); | |
logger.append("lilypond.log", engraving.logs); | |
const doc = new staffSvg.SheetDocument({pages}); | |
staffSvg.postProcessSheetDocument(doc, lilyDocument); | |
if (baking) { | |
await Promise.all(engraving.svgs.map(async (svg, index) => { | |
const svgText = staffSvg.turnRawSvgWithSheetDocument(svg, pages[index], {DOMParser, XMLSerializer}); | |
bakingImages[index] = await svgToPng(Buffer.from(svgText)); | |
})); | |
} | |
const midiMusic = lilyDocument.interpret().midiMusic; | |
const {attributes} = await argsGen.wait(); | |
const meta = { | |
title: unescapeStringExp(attributes.title), | |
composer: unescapeStringExp(attributes.composer), | |
pageSize: doc.pageSize, | |
pageCount: doc.pages.length, | |
staffSize: attributes.staffSize as number, | |
trackInfos: midiMusic && midiMusic.trackContextDicts, | |
}; | |
/*const t00 = Date.now(); | |
const sheetNotation = staffSvg.StaffNotation.parseNotationFromSheetDocument(doc, {logger}); | |
// correct notation time by location-tick table from lily document | |
const tickTable = lilyDocument.getLocationTickTable(); | |
staffSvg.StaffNotation.assignTickByLocationTable(sheetNotation, tickTable); | |
console.log("parseNotationFromSheetDocument cost:", Date.now() - t00);*/ | |
return { | |
midi, | |
bakingImages: baking ? bakingImages : null, | |
midiNotation, | |
//sheetNotation, | |
meta, | |
doc, | |
hashTable, | |
lilyDocument, | |
}; | |
}; | |
interface MakerOptions { | |
midi: MIDI.MidiData; | |
logger: LogRecorder; | |
includeFolders: string[]; | |
baking: boolean; | |
ignoreNotation: boolean; | |
}; | |
interface MakerResult { | |
bakingImages?: Readable[], | |
score: Partial<ScoreJSON>, | |
}; | |
// non-negative crc-32 | |
const hashString = (str: string): number => { | |
const value = CRC32.str(str); | |
return value < 0 ? 0x100000000 + value : value; | |
}; | |
const makeScore = async ( | |
source: string, | |
lilyParser: GrammarParser, | |
{midi, logger, baking = false, includeFolders, ignoreNotation = false}: Partial<MakerOptions> = {}, | |
): Promise<MakerResult> => { | |
const t0 = Date.now(); | |
const hash = hashString(source); | |
const foldData = await makeSheetNotation(source, lilyParser, {logger, includeFolders, baking}); | |
const {meta, doc, hashTable, bakingImages, lilyDocument} = foldData; | |
midi = midi || foldData.midi; | |
const lilyNotation = !ignoreNotation && lilyDocument.interpret().getNotation(); | |
if (ignoreNotation || !midi || !lilyNotation) { | |
if (!ignoreNotation) { | |
if (!midi) | |
console.warn("Neither lilypond or external arguments did not offer MIDI data, score maker finished incompletely."); | |
if (!lilyNotation) | |
console.warn("lilyNotation parsing failed, score maker finished incompletely."); | |
} | |
return { | |
score: { | |
version: npmPackage.version, | |
hash, | |
meta, | |
doc, | |
hashTable, | |
}, | |
}; | |
} | |
const t5 = Date.now(); | |
const matcher = await LilyNotation.matchWithExactMIDI(lilyNotation, midi); | |
logger.append("scoreMaker.profile.matching", {cost: Date.now() - t5}); | |
if (logger && logger.enabled) { | |
const cis = new Set(Array(matcher.criterion.notes.length).keys()); | |
matcher.path.forEach(ci => cis.delete(ci)); | |
const omitC = cis.size; | |
const omitS = matcher.path.filter(ci => ci < 0).length; | |
const coverage = ((matcher.criterion.notes.length - omitC) / matcher.criterion.notes.length) | |
* ((matcher.sample.notes.length - omitS) / matcher.sample.notes.length); | |
logger.append("makeScore.match", {coverage, omitC, omitS, path: matcher.path}); | |
} | |
doc.updateMatchedTokens(lilyNotation.idSet); | |
// idTrackMap is useless in bundled score | |
delete lilyNotation.idTrackMap; | |
if (baking) | |
doc.pruneForBakingMode(); | |
logger.append("scoreMaker.profile.full", {cost: Date.now() - t0}); | |
return { | |
bakingImages, | |
score: { | |
version: npmPackage.version, | |
hash, | |
meta, | |
doc, | |
hashTable: !baking ? hashTable : null, | |
lilyNotation, | |
}, | |
}; | |
}; | |
const makeMIDI = async (source: string, lilyParser: GrammarParser, {unfoldRepeats = false, fixNestedRepeat = false, includeFolders = undefined} = {}): Promise<MIDI.MidiData> => { | |
const lilyDocument = new LilyDocument(lilyParser.parse(source)); | |
if (fixNestedRepeat) | |
lilyDocument.fixNestedRepeat(); | |
if (unfoldRepeats) | |
lilyDocument.unfoldRepeats(); | |
const score = lilyDocument.root.getBlock("score"); | |
if (score) { | |
// remove layout block to save time | |
score.body = score.body.filter(term => !(term instanceof LilyTerms.Block && term.head === "\\layout")); | |
// remove invalid tempo | |
const midi: any = score.body.find(term => term instanceof LilyTerms.Block && term.head === "\\midi"); | |
if (midi) | |
midi.body = midi.body.filter(term => !(term instanceof LilyTerms.Tempo && term.beatsPerMinute > 200)); | |
} | |
const markupSource = lilyDocument.toString(); | |
//console.log("markupSource:", markupSource); | |
return new Promise((resolve, reject) => engraveSvg(markupSource, { | |
includeFolders, | |
onMidiRead: resolve, | |
}).catch(reject)); | |
}; | |
const makeArticulatedMIDI = async (source: string, lilyParser: GrammarParser, {ignoreRepeats = true, includeFolders = undefined} = {}): Promise<MIDI.MidiData> => { | |
const lilyDocument = new LilyDocument(lilyParser.parse(source)); | |
if (ignoreRepeats) | |
lilyDocument.removeRepeats(); | |
lilyDocument.articulateMIDIOutput(); | |
// remove layout block to save time | |
lilyDocument.root.sections = lilyDocument.root.sections.filter(section => !(section instanceof Block) | |
|| !(section.head === "\\score") | |
|| section.isMIDIDedicated); | |
const markupSource = lilyDocument.toString(); | |
//console.log("markupSource:", markupSource); | |
return new Promise((resolve, reject) => engraveSvg(markupSource, { | |
includeFolders, | |
onMidiRead: resolve, | |
}).catch(reject)); | |
}; | |
export { | |
markupLily, | |
xmlBufferToLy, | |
makeScore, | |
makeMIDI, | |
makeArticulatedMIDI, | |
}; | |