Skip to content

Commit

Permalink
New API endpoint /groove
Browse files Browse the repository at this point in the history
  • Loading branch information
infojunkie committed Jul 18, 2024
1 parent e2e31cd commit 3b6ce10
Show file tree
Hide file tree
Showing 11 changed files with 515 additions and 528 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ A suite of tools to convert MusicXML scores to MIDI via [Musical MIDI Accompanim
- `PORT=3000 npm run develop` for development (including hot-reload)
- `PORT=3000 npm run start` for production
- `curl -sSf -F "musicXml=@test/data/salma-ya-salama.musicxml" -F "globalGroove=Maqsum" http://localhost:3000/convert -o "salma-ya-salama.mid"`
- `curl -sSf -F "groove=Maqsum" -F"chords=I, vi, ii, V7" -F"count=8" http://localhost:3000/groove -o maqsum.mid`

# Theory of operation
This converter aims to create a valid MMA accompaniment script out of a MusicXML score. The MMA script is then converted to MIDI using the bundled `mma` tool. To accomplish this, the converter expects to find the following information in the sheet:
Expand Down
2 changes: 1 addition & 1 deletion build/mma.sef.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/musicxml.sef.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/timemap.sef.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion build/unroll.sef.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions grooves/brazil/forro-miranda.mma
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ SeqSize 1
// Baião pattern

Begin Drum-Zabumba-Maceta-Mute
Tone MuteSurdo
Sequence { 1 0 90 }
Tone OpenSurdo
Sequence { 1 0 60 }
End

Begin Drum-Zabumba-Maceta-Open
Expand Down
925 changes: 416 additions & 509 deletions package-lock.json

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "musicxml-midi",
"version": "2.3.0",
"version": "2.4.0",
"description": "MusicXML to MIDI converter",
"type": "module",
"directories": {
Expand All @@ -13,18 +13,18 @@
},
"scripts": {
"build:mmarc": "cp mmarc.example mmarc",
"build:grooves": "${MMA_HOME:-mma}/mma.py -G && npm run --silent print:grooves > build/grooves.txt",
"build:grooves": "${MMA_HOME:-mma}/mma.py -G && npm run --silent debug:grooves > build/grooves.txt",
"build:sef": "for xsl in src/xsl/*.xsl; do sef=$(basename \"$xsl\"); xslt3 -relocate:on -xsl:$xsl -export:build/${sef/.xsl/.sef.json} -nogo:1 -t -ns:##html5; done && rm -f cache/*.mid",
"build": "[ ! -f mmarc ] && npm run build:mmarc; npm run build:grooves; npm run build:sef",
"convert:unroll": "run() { xslt3 -xsl:src/xsl/unroll.xsl -s:\"$1\" ${@:2}; }; run",
"convert:timemap": "run() { xslt3 -xsl:src/xsl/timemap.xsl -s:\"$1\" ${@:2}; }; run",
"convert:mma": "run() { xslt3 -xsl:src/xsl/mma.xsl -s:\"$1\" ${@:2}; }; run",
"convert:midi": "run() { ${MMA_HOME:-mma}/mma.py -II \"$1\" -f \"${1/.mma/.mid}\"; }; run",
"convert": "run() { mma=$(xslt3 -xsl:build/mma.sef.json -s:\"$1\" useSef=1 ${@:2}); echo \"$mma\" | ${MMA_HOME:-mma}/mma.py -II -f \"${1/.musicxml/.mid}\" -; }; run",
"print:chord": "run() { echo \"PrintChord $1\" | ${MMA_HOME:-mma}/mma.py -n -; }; run",
"print:grooves": "find ${MMA_HOME:-mma}/lib grooves -name '*.mma' | while read f; do MMA_ENCODING=utf-8 ${MMA_HOME:-mma}/mma.py -Dbo \"$f\" | tail -n +2; done",
"print:preview": "run() { ${MMA_HOME:-mma}/mma.py -V \"$@\"; }; run",
"print:musicxml": "run() { xslt3 -xsl:src/xsl/musicxml.xsl -s:\"$1\" ${@:2}; }; run",
"debug:chord": "run() { echo \"PrintChord $1\" | ${MMA_HOME:-mma}/mma.py -n -; }; run",
"debug:grooves": "find ${MMA_HOME:-mma}/lib grooves -name '*.mma' | while read f; do MMA_ENCODING=utf-8 ${MMA_HOME:-mma}/mma.py -Dbo \"$f\" | tail -n +2; done",
"debug:preview": "run() { ${MMA_HOME:-mma}/mma.py -V \"$@\"; }; run",
"debug:musicxml": "run() { xslt3 -xsl:src/xsl/musicxml.xsl -s:\"$1\" ${@:2}; }; run",
"validate:musicxml": "run() { xmllint --noout --schema src/xsd/musicxml.xsd \"$1\"; }; run",
"validate:mma": "run() { ${MMA_HOME:-mma}/mma.py -II -n \"$1\"; }; run",
"develop": "nodemon -e js,json src/js/server.js",
Expand Down
70 changes: 68 additions & 2 deletions src/js/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,6 @@ app.get('/', (req, res) => res.json({ name, version, description, author }))

app.get('/grooves', (req, res) => res.status(200).sendFile(path.resolve('build/grooves.txt')))

app.get('/convert', (req, res) => res.status(400).send(ERROR_BAD_PARAM))

async function tryCompressedMusicXml(buffer) {
try {
const decoder = new TextDecoder()
Expand Down Expand Up @@ -103,6 +101,7 @@ app.post('/convert', async (req, res, next) => {
const cacheFile = path.resolve(path.join(process.env.CACHE_DIR || 'cache', `${sig}.mid`))
try {
await fs.access(cacheFile, constants.R_OK)
console.info(`Sending ${cacheFile} from cache...`)
res.status(200).sendFile(cacheFile)
return
}
Expand Down Expand Up @@ -153,5 +152,72 @@ app.post('/convert', async (req, res, next) => {
}
})

app.post('/groove', async (req, res, next) => {
if (!req.body || !('groove' in req.body) ) {
return res.status(400).json(ERROR_BAD_PARAM)
}

// Assemble parameters.
const params = {
'groove': null,
'chords': 'z',
'tempo': '120',
'count': '4',
'keysig': 'C'
}
if (req.body) {
['groove', 'chords', 'tempo', 'count', 'keysig'].forEach(param => {
if (param in req.body) params[param] = req.body[param]
})
}

// Generate MMA.
const chords = params['chords'].split(',').map(s => s.trim())
const measures = [...Array(parseInt(params['count']))].map((_, index) => {
return `
MidiMark Measure:${index+1}
${chords[index % chords.length]}
`.trim()
}).join('\n')
const mma = `
MidiText Generated by musicxml-midi converter https://github.com/infojunkie/musicxml-midi
KeySig ${params['keysig']}
Tempo ${params['tempo']}
Groove ${params['groove']}
MidiMark Groove:${params['groove']}
${measures}
`.trim()

// Check first in cache.
const hash = crypto.createHash('sha256')
hash.update(mma)
const sig = hash.digest('hex')
const cacheFile = path.resolve(path.join(process.env.CACHE_DIR || 'cache', `${sig}.mid`))
try {
await fs.access(cacheFile, constants.R_OK)
console.info(`Sending ${cacheFile} from cache...`)
res.status(200).sendFile(cacheFile)
return
}
catch {
// Could not access cache file: Keep going below to generate it.
}

try {
const execResult = await exec('echo "$mma" | ${MMA_HOME:-mma}/mma.py -II -f "$out" -', {
env: { ...process.env, 'mma': mma, 'out': cacheFile }
})
.catch(AbortChainError.chain(error => {
console.error(`[MMA] ${error.stdout.replace(/^\s+|\s+$/g, '')}`)
res.status(500).send(ERROR_MMA_CRASH)
}))
console.info('[MMA] ' + execResult.stdout.replace(/^\s+|\s+$/g, ''))
return res.status(200).sendFile(cacheFile)
}
catch {
// Do nothing.
}
})

const port = process.env.PORT || 3000
export const server = app.listen(port, () => console.log(`${name} v${version} listening at http://localhost:${port}`))
2 changes: 1 addition & 1 deletion src/xsl/mma.xsl
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@
-->
<xsl:template match="score-partwise">
<xsl:text>
MidiText Generated by musicxml-mma converter https://github.com/infojunkie/musicxml-mma
MidiText Generated by musicxml-midi converter https://github.com/infojunkie/musicxml-midi

Begin Chord-Custom
Voice </xsl:text><xsl:value-of select="$chordInstrument"/><xsl:text>
Expand Down
21 changes: 17 additions & 4 deletions test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('MusicXML to MIDI conversion server', () => {

test('should reject invalid invocations', async () => {
const res1 = await request(app).get('/convert')
expect(res1.statusCode).toEqual(400)
expect(res1.statusCode).toEqual(404)
const res2 = await request(app).post('/convert').field('foo', 'bar')
expect(res2.statusCode).toEqual(400)
const res3 = await request(app).post('/convert').attach('musicXml', 'package.json')
Expand Down Expand Up @@ -54,9 +54,7 @@ describe('MusicXML to MIDI conversion server', () => {
const res = await request(app).post('/convert').attach('musicXml', file).responseType('blob')
expect(res.statusCode).toEqual(200)
expect(res.type).toEqual('audio/midi')
expect(() => {
const midi = parseMidi(res.body)
}).not.toThrow()
expect(() => { parseMidi(res.body) }).not.toThrow()
})

test('should cache valid MusicXML files and use the cache', async () => {
Expand Down Expand Up @@ -87,6 +85,21 @@ describe('MusicXML to MIDI conversion server', () => {
expect(res.text.includes('Ayyub')).toBeTruthy()
expect(res.text.includes('Baiao-Miranda')).toBeTruthy()
})

test('should generate groove', async () => {
const res = await request(app)
.post('/groove')
.field('groove', 'Maqsum')
.field('tempo', 120)
.field('count', 4)
.responseType('blob')
const midi = parseMidi(res.body)
expect(midi.tracks.find(track => !!track.find(event => event.type === 'marker' && event.text === 'Groove:Maqsum')))
.not.toEqual(undefined)
const track = midi.tracks.find(track => !!track.find(event => event.type === 'marker' && event.text.includes('Measure:')))
expect(track.filter(event => event.type === 'marker' && event.text.includes('Measure:')).length)
.toEqual(4)
})
})

afterAll(() => {
Expand Down

0 comments on commit 3b6ce10

Please sign in to comment.