Skip to content

Commit

Permalink
Better tuplet detection for #51
Browse files Browse the repository at this point in the history
  • Loading branch information
infojunkie authored Sep 27, 2024
1 parent 6c13041 commit 4ddb584
Show file tree
Hide file tree
Showing 1,800 changed files with 51,126 additions and 184,337 deletions.
2 changes: 1 addition & 1 deletion build/filter.sef.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"N":"package","version":"10","packageVersion":"1","saxonVersion":"SaxonJS 2.6","target":"JS","targetVersion":"2","name":"TOP-LEVEL","relocatable":"true","buildDateTime":"2024-09-16T22:47:15.421-07:00","ns":"xml=~ xsl=~","C":[{"N":"co","binds":"","id":"0","vis":"PUBLIC","ex:uniform":"true","C":[{"N":"globalParam","name":"Q{}filter","sType":"* ","slots":"200","module":"filter.xsl","flags":"r","as":"","ns":"xml=~ xsl=~","C":[{"N":"str","sType":"1AS ","val":"","role":"select"}]}]},{"N":"co","id":"1","binds":"0 1","C":[{"N":"mode","onNo":"TC","flags":"","patternSlots":"0","prec":"","C":[{"N":"templateRule","rank":"0","prec":"0","seq":"1","ns":"xml=~ xsl=~","minImp":"0","flags":"s","slots":"200","line":"23","module":"filter.xsl","expand-text":"false","match":"*[local-name()=tokenize($filter,'\\|')]","prio":"0.5","matches":"NE","C":[{"N":"p.withPredicate","role":"match","sType":"1NE","ns":"= xml=~ fn=~ xsl=~ ","C":[{"N":"p.nodeTest","test":"NE"},{"N":"gc","op":"=","comp":"GAC|http://www.w3.org/2005/xpath-functions/collation/codepoint","card":"1:1","C":[{"N":"fn","name":"local-name","C":[{"N":"dot"}]},{"N":"fn","name":"tokenize","C":[{"N":"treat","as":"AS","diag":"0|0||tokenize","C":[{"N":"check","card":"?","diag":"0|0||tokenize","C":[{"N":"cvUntyped","to":"AS","diag":"0|0||tokenize","C":[{"N":"check","card":"?","diag":"0|0||tokenize","C":[{"N":"data","diag":"0|0||tokenize","C":[{"N":"gVarRef","name":"Q{}filter","bSlot":"0"}]}]}]}]}]},{"N":"str","val":"\\|"}]}]}]},{"N":"empty","sType":"0 ","role":"action"}]},{"N":"templateRule","rank":"1","prec":"0","seq":"0","ns":"xml=~ xsl=~","minImp":"0","flags":"s","slots":"200","line":"17","module":"filter.xsl","expand-text":"false","match":"node()|@*","prio":"-0.5","matches":"N u[NT,NP,NC,NE]","C":[{"N":"p.nodeTest","role":"match","test":"N u[NT,NP,NC,NE]","sType":"1N u[NT,NP,NC,NE]"},{"N":"copy","sType":"1N u[1NT ,1NP ,1NC ,1NE ] ","flags":"cin","role":"action","line":"18","C":[{"N":"applyT","sType":"* ","line":"19","mode":"#unnamed","bSlot":"1","C":[{"N":"docOrder","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","role":"select","line":"19","C":[{"N":"union","op":"|","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","ns":"= xml=~ fn=~ xsl=~ ","C":[{"N":"axis","name":"child","nodeTest":"*N u[NT,NP,NC,NE]"},{"N":"axis","name":"attribute","nodeTest":"*NA"}]}]}]}]}]},{"N":"templateRule","rank":"2","prec":"0","seq":"0","ns":"xml=~ xsl=~","minImp":"0","flags":"s","slots":"200","line":"17","module":"filter.xsl","expand-text":"false","match":"node()|@*","prio":"-0.5","matches":"NA","C":[{"N":"p.nodeTest","role":"match","test":"NA","sType":"1NA"},{"N":"copy","sType":"1NA ","flags":"cin","role":"action","line":"18","C":[{"N":"applyT","sType":"* ","line":"19","mode":"#unnamed","bSlot":"1","C":[{"N":"docOrder","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","role":"select","line":"19","C":[{"N":"union","op":"|","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","ns":"= xml=~ fn=~ xsl=~ ","C":[{"N":"axis","name":"child","nodeTest":"*N u[NT,NP,NC,NE]"},{"N":"axis","name":"attribute","nodeTest":"*NA"}]}]}]}]}]}]}]},{"N":"overridden"},{"N":"output","C":[{"N":"property","name":"Q{http://saxon.sf.net/}stylesheet-version","value":"10"},{"N":"property","name":"omit-xml-declaration","value":"no"},{"N":"property","name":"indent","value":"yes"}]},{"N":"decimalFormat"}],"Σ":"c521f3b7"}
{"N":"package","version":"10","packageVersion":"1","saxonVersion":"SaxonJS 2.6","target":"JS","targetVersion":"2","name":"TOP-LEVEL","relocatable":"true","buildDateTime":"2024-09-26T22:49:48.865-07:00","ns":"xml=~ xsl=~","C":[{"N":"co","binds":"","id":"0","vis":"PUBLIC","ex:uniform":"true","C":[{"N":"globalParam","name":"Q{}filter","sType":"* ","slots":"200","module":"filter.xsl","flags":"r","as":"","ns":"xml=~ xsl=~","C":[{"N":"str","sType":"1AS ","val":"","role":"select"}]}]},{"N":"co","id":"1","binds":"0 1","C":[{"N":"mode","onNo":"TC","flags":"","patternSlots":"0","prec":"","C":[{"N":"templateRule","rank":"0","prec":"0","seq":"1","ns":"xml=~ xsl=~","minImp":"0","flags":"s","slots":"200","line":"23","module":"filter.xsl","expand-text":"false","match":"*[local-name()=tokenize($filter,'\\|')]","prio":"0.5","matches":"NE","C":[{"N":"p.withPredicate","role":"match","sType":"1NE","ns":"= xml=~ fn=~ xsl=~ ","C":[{"N":"p.nodeTest","test":"NE"},{"N":"gc","op":"=","comp":"GAC|http://www.w3.org/2005/xpath-functions/collation/codepoint","card":"1:1","C":[{"N":"fn","name":"local-name","C":[{"N":"dot"}]},{"N":"fn","name":"tokenize","C":[{"N":"treat","as":"AS","diag":"0|0||tokenize","C":[{"N":"check","card":"?","diag":"0|0||tokenize","C":[{"N":"cvUntyped","to":"AS","diag":"0|0||tokenize","C":[{"N":"check","card":"?","diag":"0|0||tokenize","C":[{"N":"data","diag":"0|0||tokenize","C":[{"N":"gVarRef","name":"Q{}filter","bSlot":"0"}]}]}]}]}]},{"N":"str","val":"\\|"}]}]}]},{"N":"empty","sType":"0 ","role":"action"}]},{"N":"templateRule","rank":"1","prec":"0","seq":"0","ns":"xml=~ xsl=~","minImp":"0","flags":"s","slots":"200","line":"17","module":"filter.xsl","expand-text":"false","match":"node()|@*","prio":"-0.5","matches":"N u[NT,NP,NC,NE]","C":[{"N":"p.nodeTest","role":"match","test":"N u[NT,NP,NC,NE]","sType":"1N u[NT,NP,NC,NE]"},{"N":"copy","sType":"1N u[1NT ,1NP ,1NC ,1NE ] ","flags":"cin","role":"action","line":"18","C":[{"N":"applyT","sType":"* ","line":"19","mode":"#unnamed","bSlot":"1","C":[{"N":"docOrder","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","role":"select","line":"19","C":[{"N":"union","op":"|","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","ns":"= xml=~ fn=~ xsl=~ ","C":[{"N":"axis","name":"child","nodeTest":"*N u[NT,NP,NC,NE]"},{"N":"axis","name":"attribute","nodeTest":"*NA"}]}]}]}]}]},{"N":"templateRule","rank":"2","prec":"0","seq":"0","ns":"xml=~ xsl=~","minImp":"0","flags":"s","slots":"200","line":"17","module":"filter.xsl","expand-text":"false","match":"node()|@*","prio":"-0.5","matches":"NA","C":[{"N":"p.nodeTest","role":"match","test":"NA","sType":"1NA"},{"N":"copy","sType":"1NA ","flags":"cin","role":"action","line":"18","C":[{"N":"applyT","sType":"* ","line":"19","mode":"#unnamed","bSlot":"1","C":[{"N":"docOrder","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","role":"select","line":"19","C":[{"N":"union","op":"|","sType":"*N u[N u[N u[N u[NT,NP],NC],NE],NA]","ns":"= xml=~ fn=~ xsl=~ ","C":[{"N":"axis","name":"child","nodeTest":"*N u[NT,NP,NC,NE]"},{"N":"axis","name":"attribute","nodeTest":"*NA"}]}]}]}]}]}]}]},{"N":"overridden"},{"N":"output","C":[{"N":"property","name":"Q{http://saxon.sf.net/}stylesheet-version","value":"10"},{"N":"property","name":"omit-xml-declaration","value":"no"},{"N":"property","name":"indent","value":"yes"}]},{"N":"decimalFormat"}],"Σ":"c55266b7"}
2 changes: 1 addition & 1 deletion build/groove.sef.json

Large diffs are not rendered by default.

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.

38 changes: 19 additions & 19 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "musicxml-midi",
"version": "2.8.1",
"version": "2.8.2",
"description": "MusicXML to MIDI converter",
"type": "module",
"directories": {
Expand Down
63 changes: 40 additions & 23 deletions src/js/musicxml-grooves.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ import path from 'path'
const require = createRequire(import.meta.url)
const { version } = require('../../package.json')

// https://gomakethings.com/the-new-array.prototype.tospliced-method-in-vanilla-js/
if (!Array.prototype.toSpliced) {
Array.prototype.toSpliced = function (...args) {
return Array.from(this).splice(...args);
};
}

const options = {
'output': {
type: 'string',
Expand Down Expand Up @@ -62,6 +69,9 @@ const options = {
},
'dashes': {
type: 'boolean'
},
'tracks': {
type: 'string'
}
}
const { values: args } = (() => {
Expand Down Expand Up @@ -105,6 +115,7 @@ const instruments = await SaxonJS.getResource({

const grid = args['grid'].split(',').map(g => parseInt(g.trim()))
const grooves = 'grooves' in args ? args['grooves'].split(',').map(g => g.trim()) : []
const tracks = 'tracks' in args ? args['tracks'].split(',').map(t => t.trim()) : []
for (const groove of JSON.parse(fs.readFileSync('build/grooves.json'))) {
if (grooves.length > 0 && grooves.indexOf(groove.groove) < 0) continue

Expand Down Expand Up @@ -135,7 +146,10 @@ for (const groove of JSON.parse(fs.readFileSync('build/grooves.json'))) {
* Main entrypoint for MusicXML generation.
*/
function createMusicXML(groove) {
groove.tracks = groove.tracks.filter(t => t.track.startsWith('DRUM')).reverse()
groove.tracks = groove.tracks.filter(
t => t.track.startsWith('DRUM') &&
(tracks.length === 0 || tracks.find(tt => tt.localeCompare(t.track.replace('DRUM-', ''), undefined, {sensitivity: 'base'}) == 0))
).reverse()
if (!groove.tracks.length) {
throw Error(`[${groove.groove}] No drum tracks found.`)
}
Expand Down Expand Up @@ -480,11 +494,13 @@ function createMeasureNotes(groove, part, measure) {

// Generate note types, durations and extra notes as needed.
// Ignore notes that have already been processed by an earlier iteration in createNoteTiming().
// Also ignore notes that are dropped by the timing algorithm.
.reduce((notes, note, index, input) => {
const extra = 'musicXml' in note ? [] : createNoteTiming(note, index, input)
notes.push(note, ...extra)
return notes
}, [])
.filter(n => 'musicXml' in n)

// Generate MusicXML.
// When voices change, we backup to the beginning of the measure.
Expand Down Expand Up @@ -582,19 +598,15 @@ function quantizeNoteOnset(note, index, notes, beats, grid) {
if (onset !== undefined) {
return onset
}

if (isFirstNote || notes[index-1].quantized.onset < candidate.multiple) {
if (isFirstNote || notes[index-1].quantized.onset <= candidate.multiple) {
return candidate
}
}, undefined)

// Validate the quantization.
if (onset === undefined) {
console.warn(`[${note.track}:${note.measure+1}] Failed to quantize note onset at ${note.onset} to avoid collision with previous note. Moving it manually.`)
onset = {
multiple: notes[index-1].quantized.onset + DIVISIONS_1024th,
error_sgn: scoreOnset - (notes[index-1].quantized.onset + DIVISIONS_1024th)
}
console.error(`[${note.track}:${note.measure+1}] Failed to quantize note onset at ${note.onset} to avoid collision with previous note. Dropping it.`)
return
}

// Store the note.
Expand All @@ -606,14 +618,13 @@ function quantizeNoteOnset(note, index, notes, beats, grid) {

/**
* Quantize a single note duration.
* Don't let duration remain at 0.
*/
function quantizeNoteDuration(note, index, notes, beats, grid) {
const isFirstNote = index === 0 || notes[index-1].voice !== note.voice
const isLastNote = index === notes.length - 1 || notes[index+1].voice !== note.voice
const scoreOffset = Math.min(
note.quantized.onset + note.quantized.duration,
isLastNote ? beats * DIVISIONS : notes[index+1].quantized.onset
isLastNote ? beats * DIVISIONS : notes[index+1].quantized.onset + (notes[index+1].quantized.onset === note.quantized.onset ? notes[index+1].quantized.onset : 0)
)
let offset = grid.map(unit => {
return nearestMultiple(scoreOffset, DIVISIONS/unit)
Expand All @@ -630,13 +641,8 @@ function quantizeNoteDuration(note, index, notes, beats, grid) {

if (offset === undefined) {
// TODO Handle this case.
console.warn(`[${note.track}:${note.measure+1}] Failed to quantize note duration at ${note.onset} to avoid zero duration.`)
}

// Adjust the note duration if it crosses the measure boundary.
if (offset.multiple > beats * DIVISIONS) {
console.warn(`[${note.track}:${note.measure+1}] Quantized note duration at ${note.onset} crosses measure boundary. Reducing the duration.`)
offset.multiple = beats * DIVISIONS
console.warn(`[${note.track}:${note.measure+1}] Failed to quantize note duration at ${note.onset} to avoid zero duration. Dropping it.`)
return []
}

// Store the note.
Expand Down Expand Up @@ -766,21 +772,21 @@ function createNoteTiming(note, index, notes) {
// - Each have a duration of a tuplet fraction of the enclosing note type
// - Fall within the same enclosing note, instead of crossing note boundaries
// TODO Handle tuplets where individual notes can be multiples of the tuplet division.
if (entry < note.quantized.duration && note.quantized.duration < entry * 2) {
if (entry / 2 < note.quantized.duration && note.quantized.duration < entry * 2) {
for (const tupletCount of [3, 5]) {
const target = entry * Math.pow(2, Math.ceil(Math.log2(tupletCount)))
const tuplet = tuplets(note, index, notes, tupletCount)
const ratio = Math.round(target / tupletCount)
if (
tuplet.length === tupletCount &&
Math.abs(tupletsDuration(tuplet) - target) <= Number.EPSILON &&
tuplet.every(n => Math.min(n.quantized.duration % ratio, ratio - (n.quantized.duration % ratio)) <= Number.EPSILON) &&
//tuplet.every(n => Math.min(n.quantized.duration % ratio, ratio - (n.quantized.duration % ratio)) <= Number.EPSILON) &&
tuplet.every(n => Math.floor(n.quantized.onset / target) === Math.floor(tuplet[0].quantized.onset / target))
) {
tuplet.forEach((n, i) => {
tuplet.forEach((n, i, t) => {
n.quantized = {
duration: target / tupletCount,
onset: note.quantized.onset + (i * target / tupletCount)
onset: i === 0 ? note.quantized.onset : (t[i-1].quantized.onset + t[i-1].quantized.duration)
}
n.musicXml = {
duration: target / tupletCount,
Expand All @@ -804,7 +810,7 @@ function createNoteTiming(note, index, notes) {
// - Sum up to a quarter
// - Each be within a triplet multiple of a quarter
// - Fall within the same quarter note, instead of crossing quarter not boundaries
if (entry === DIVISIONS_EIGHTH && entry < note.quantized.duration && note.quantized.duration < entry * 2) {
if (entry === DIVISIONS_EIGHTH && entry / 2 < note.quantized.duration && note.quantized.duration < entry * 2) {
const target = entry * 2
const pair = tuplets(note, index, notes, 2)
const ratio = Math.round(target / 3)
Expand All @@ -815,6 +821,10 @@ function createNoteTiming(note, index, notes) {
Math.floor(pair[0].quantized.onset / target) === Math.floor(pair[1].quantized.onset / target)
) {
pair.forEach((n, i, t) => {
n.quantized = {
duration: n.quantized.duration > ratio ? target * 2 / 3 : target / 3,
onset: i === 0 ? note.quantized.onset : (t[i-1].quantized.onset + t[i-1].quantized.duration)
}
n.musicXml = {
duration: n.quantized.duration,
type: n.quantized.duration > ratio ? lookupType(target) : lookupType(entry),
Expand Down Expand Up @@ -862,7 +872,14 @@ function createNoteTiming(note, index, notes) {

// Check that the gap is all filled.
if (gap > Number.EPSILON) {
console.warn(`[${note.track}:${note.measure+1}] Remaining gap of ${gap} left after note at ${note.onset}.`)
const isFirstNote = index === 0 || notes[index-1].voice !== note.voice
if (!isFirstNote && !('midi' in note && notes[index-1].midi != note.midi)) {
console.warn(`[${note.track}:${note.measure+1}] Remaining gap of ${gap} left after note at ${note.onset}. This indicates a missed tuplet. Attempting to fix.`)
notes[index-1].quantized.duration += note.quantized.duration
notes[index-1].duration += note.duration
return createNoteTiming(notes[index-1], index-1, notes.toSpliced(index, 1))
}
console.error(`[${note.track}:${note.measure+1}] Remaining gap of ${gap} left after note at ${note.onset}. This indicates a missed tuplet.`)
}

// Close up the last tie.
Expand Down
Loading

0 comments on commit 4ddb584

Please sign in to comment.