Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create valid MusicXML from W3C examples #40

Merged
merged 10 commits into from
Nov 14, 2023
1,254 changes: 794 additions & 460 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "musicxml-midi",
"version": "2.1.2",
"version": "2.2.0",
"description": "MusicXML to MIDI converter",
"type": "module",
"directories": {
Expand Down Expand Up @@ -52,9 +52,10 @@
"express-fileupload": "^1.3.1",
"morgan": "^1.10.0",
"node-fetch": "^3.3.1",
"saxon-js": "^2.3.0",
"saxon-js": "2.5.0",
"unzipit": "^1.4.0",
"validate-with-xmllint": "^1.2.0",
"xml-formatter": "^3.6.0",
"xslt3": "^2.3.0"
},
"devDependencies": {
Expand Down
216 changes: 202 additions & 14 deletions src/js/musicxml-examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,220 @@
*/

const URL_EXAMPLES_ROOT = 'https://www.w3.org/2021/06/musicxml40/musicxml-reference/examples/'
const MUSICXML_VERSION = '4.0'

import fetch from 'node-fetch'
import * as cheerio from 'cheerio'
import xmlFormat from 'xml-formatter';
import fs from 'fs'
import process from 'process'
import path from 'path'
import { parseArgs } from 'node:util'
import { createRequire } from 'node:module'
import { validateXMLWithXSD } from 'validate-with-xmllint'

const output = process.argv[2] || ''
if (output != '' && !fs.existsSync(output)) {
console.error(`Missing output dir ${output}`)
process.exit(1)
const require = createRequire(import.meta.url)
const { version } = require('../../package.json')

const options = {
'xml': {
type: 'boolean',
short: 'x',
},
'output': {
type: 'string',
short: 'o'
},
'help': {
type: 'boolean',
short: 'h'
},
'version': {
type: 'boolean',
short: 'v'
},
'example': {
type: 'string',
short: 'e'
},
'validate': {
type: 'boolean'
},
'source': {
type: 'string',
short: 's'
}
}
const { values: args } = (function() {
try {
return parseArgs({ options, allowPositionals: false })
}
catch (e) {
console.error(e.message)
process.exit(1)
}
})()
if (!('source' in args)) {
args['source'] = URL_EXAMPLES_ROOT
}

async function extractMusicXML(page, title) {
const response = await fetch(page)
const body = await response.text()
const $ = cheerio.load(body)
const musicxml = "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + $('.xmlmarkup').text()
fs.writeFileSync(path.join(output, `${title}.musicxml`), musicxml)
if ('help' in args) {
console.log(`
Usage: musicxml-examples v${version} [--output|-o /path/to/output] [--example|-e example-slug] [--source|-s url/or/path/to/examples/] [--xml|-x] [--validate] [--version|-v] [--help|-h]

Extracts MusicXML examples from ${args['source']}.
Use --xml to recreate a valid MusicXML structure around examples that lack it.
`.trim())
process.exit(0)
}

if ('version' in args) {
console.log(`musicxml-examples v${version}`)
process.exit(0)
}

const output = args['output'] || ''
if (output !== '' && !fs.existsSync(output)) {
console.error(`Missing output dir ${output}`)
process.exit(1)
}

const response = await fetch(URL_EXAMPLES_ROOT)
const main = await response.text()
const main = await fetchPage(args['source'])
const $ = cheerio.load(main)
for (const example of $('body').find('a:has(img)')) {
const href = $(example).prop('href')
console.log(`Extracting ${href}...`)
await extractMusicXML(URL_EXAMPLES_ROOT + href, href.replace('/', ''))
if ('example' in args && args['example'] !== href.replace('/', '')) continue
console.error(`Extracting ${href}...`)
await extractMusicXml(args['source'] + href, href.replace('/', ''))
}

async function extractMusicXml(page, slug) {
const body = await fetchPage(page)
const $ = cheerio.load(body)
const musicxml = scaffoldMusicXml($('.xmlmarkup').text())

if ('validate' in args) {
await validateXMLWithXSD(musicxml, 'src/xsd/musicxml.xsd')
.catch(error => {
console.error(`Failed to validate MusicXML: ${error.message}`)
})
}

if (output !== '') {
fs.writeFileSync(path.join(output, `${slug}.musicxml`), musicxml)
}
else {
process.stdout.write(musicxml + '\n')
}
}

async function fetchPage(url) {
if (/^http/.test(url)) {
const response = await fetch(url)
return await response.text()
}
return fs.readFileSync(path.join(url, 'index.html'), { encoding: 'utf-8' })
}

function scaffoldMusicXml(xml) {
if (!('xml' in args)) {
return `<?xml version="1.0" encoding="utf-8"?>\n${xml}`
}

const template = `
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!DOCTYPE score-partwise PUBLIC
"-//Recordare//DTD MusicXML ${MUSICXML_VERSION} Partwise//EN"
"http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="${MUSICXML_VERSION}">
<defaults>
<system-layout optional-example="yes"/>
<staff-layout optional-example="yes"/>
<appearance optional-example="yes"/>
</defaults>
<credit optional-example="yes"/>
<part-list>
<part-group optional-example="yes"/>
<score-part id="P1">
<part-name>placeholder</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<direction optional-example="yes">
<direction-type optional-example="yes">
<dynamics optional-example="yes"/>
<metronome optional-example="yes"/>
<scordatura optional-example="yes"/>
<accordion-registration optional-example="yes"/>
</direction-type>
</direction>
<attributes>
<divisions>1</divisions>
<key>
<fifths>0</fifths>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
<interchangeable optional-example="yes">
<time-relation optional-example="yes"/>
</interchangeable>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
<staff-details optional-example="yes"/>
<measure-style optional-example="yes"/>
</attributes>
<harmony>
<root>
<root-step>C</root-step>
</root>
<kind use-symbols="yes">major-seventh</kind>
<inversion optional-example="yes"/>
<degree optional-example="yes"/>
<frame optional-example="yes"/>
</harmony>
<figured-bass optional-example="yes"/>
<note>
<pitch>
<step>C</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<type>whole</type>
<notehead-text optional-example="yes"/>
<notations>
<technical optional-example="yes"/>
</notations>
<lyric optional-example="yes"/>
</note>
<barline optional-example="yes"/>
<print>
<part-name-display optional-example="yes"/>
<part-abbreviation-display optional-example="yes"/>
</print>
</measure>
</part>
</score-partwise>
`.trim()

// Insert the example fragment into the fully-formed template.
// - Find the example's root element in the template
// - Replace it with the full example fragment
// - Remove optional-example attribute from parents of the example fragment
// - Remove all elements that still include attribute optional-example="yes"
const src = cheerio.load(xml, { xml: true })
const core = src.root().children().first().prop('nodeName')
const dst = cheerio.load(template, { xml: { xmlMode: true, lowerCaseTags: true, lowerCaseAttributeNames : true }})
if (dst(core).length === 0) {
console.error(`${core} element not found in template. Returning verbatim XML.`)
return `<?xml version="1.0" encoding="utf-8"?>\n${xml}`
}
dst(core).replaceWith(src.root())
dst(src.root()).parents().removeAttr('optional-example')
dst('[optional-example="yes"]').remove()
return xmlFormat(dst.html(), { collapseContent: true })
}
70 changes: 54 additions & 16 deletions test/data/examples/accent-element.musicxml
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<note default-x="36">
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<voice>1</voice>
<type>half</type>
<stem default-y="10">up</stem>
<notations>
<articulations>
<accent default-x="-1" default-y="-55" placement="below"/>
</articulations>
</notations>
</note>
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!DOCTYPE score-partwise PUBLIC
"-//Recordare//DTD MusicXML 4.0 Partwise//EN"
"http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<defaults>
</defaults>
<part-list>
<score-part id="P1">
<part-name>placeholder</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>1</divisions>
<key>
<fifths>0</fifths>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<harmony>
<root>
<root-step>C</root-step>
</root>
<kind use-symbols="yes">major-seventh</kind>
</harmony>
<note default-x="36">
<pitch>
<step>A</step>
<octave>4</octave>
</pitch>
<duration>4</duration>
<voice>1</voice>
<type>half</type>
<stem default-y="10">up</stem>
<notations>
<articulations>
<accent default-x="-1" default-y="-55" placement="below"/>
</articulations>
</notations>
</note>
<print>
</print>
</measure>
</part>
</score-partwise>
64 changes: 51 additions & 13 deletions test/data/examples/accidental-element.musicxml
Original file line number Diff line number Diff line change
@@ -1,13 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<note default-x="83">
<pitch>
<step>A</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>2</duration>
<voice>1</voice>
<type>quarter</type>
<accidental>sharp</accidental>
<stem default-y="10">up</stem>
</note>
<?xml version="1.0" encoding="utf-8" standalone="no"?>
<!DOCTYPE score-partwise PUBLIC
"-//Recordare//DTD MusicXML 4.0 Partwise//EN"
"http://www.musicxml.org/dtds/partwise.dtd">
<score-partwise version="4.0">
<defaults>
</defaults>
<part-list>
<score-part id="P1">
<part-name>placeholder</part-name>
</score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>1</divisions>
<key>
<fifths>0</fifths>
</key>
<time>
<beats>4</beats>
<beat-type>4</beat-type>
</time>
<clef>
<sign>G</sign>
<line>2</line>
</clef>
</attributes>
<harmony>
<root>
<root-step>C</root-step>
</root>
<kind use-symbols="yes">major-seventh</kind>
</harmony>
<note default-x="83">
<pitch>
<step>A</step>
<alter>1</alter>
<octave>4</octave>
</pitch>
<duration>2</duration>
<voice>1</voice>
<type>quarter</type>
<accidental>sharp</accidental>
<stem default-y="10">up</stem>
</note>
<print>
</print>
</measure>
</part>
</score-partwise>
Loading
Loading