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

Better tuplet detection #52

Merged
merged 4 commits into from
Sep 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
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
Loading