diff --git a/build/grooves.json b/build/grooves.json
index 22fdcbe1..edc22cdf 100644
--- a/build/grooves.json
+++ b/build/grooves.json
@@ -967,6 +967,119 @@
}
]
},
+ {
+ "groove": "JazzBasieAQuantized",
+ "description": "4 bars main.",
+ "size": 4,
+ "timeSignature": "4/4",
+ "tracks": [
+ {
+ "track": "BASS-11",
+ "voice": [
+ "AcousticBass",
+ "AcousticBass",
+ "AcousticBass",
+ "AcousticBass"
+ ],
+ "midi": [
+ 32,
+ 32,
+ 32,
+ 32
+ ],
+ "sequence": [
+ "1 288t 1 85; 2.97917 192t 2# 74; 3.96875 192t 3 85; 4.96875 288t 1 73",
+ "2.96875 288t 1 82; 4.96875 288t 1 84",
+ "2.96875 192t 5 81; 3.95833 192t 3 81; 4.96875 288t 1 73",
+ "2.95833 288t 5 82"
+ ]
+ },
+ {
+ "track": "CHORD-12",
+ "voice": [
+ "Piano1",
+ "Piano1",
+ "Piano1",
+ "Piano1"
+ ],
+ "midi": [
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "sequence": [
+ "1.66667 48t 63 63 63 63 63 63 63 63; 1.67708 48t 67 67 67 67 67 67 67 67; 4.69792 96t 64 64 64 64 64 64 64 64; 4.70833 96t 59 59 59 59 59 59 59 59; 4.71875 48t 67 67 67 67 67 67 67 67",
+ "1.66667 48t 59 59 59 59 59 59 59 59; 1.66667 96t 66 66 66 66 66 66 66 66; 1.6875 48t 62 62 62 62 62 62 62 62",
+ " z ",
+ " z "
+ ]
+ },
+ {
+ "track": "DRUM-CLOSEDHIHAT",
+ "voice": [
+ "ClosedHiHat",
+ "ClosedHiHat",
+ "ClosedHiHat",
+ "ClosedHiHat"
+ ],
+ "midi": [
+ 42,
+ 42,
+ 42,
+ 42
+ ],
+ "sequence": [
+ "2 1t 107 ; 2.55 1t 0 ; 4 1t 115 ; 4.55 1t 0 ",
+ "2 1t 108 ; 2.55 1t 0 ; 4 1t 110 ; 4.55 1t 0 ",
+ "2 1t 114 ; 2.55 1t 0 ; 4 1t 106 ; 4.55 1t 0 ",
+ "2 1t 106 ; 2.55 1t 0 ; 4 1t 109 ; 4.55 1t 0 "
+ ]
+ },
+ {
+ "track": "DRUM-KICKDRUM1",
+ "voice": [
+ "KickDrum1",
+ "KickDrum1",
+ "KickDrum1",
+ "KickDrum1"
+ ],
+ "midi": [
+ 36,
+ 36,
+ 36,
+ 36
+ ],
+ "sequence": [
+ " z ",
+ " z ",
+ " z ",
+ "3.66 1t 17 ; 4 1t 0 "
+ ]
+ },
+ {
+ "track": "DRUM-OPENHIHAT",
+ "voice": [
+ "OpenHiHat",
+ "OpenHiHat",
+ "OpenHiHat",
+ "OpenHiHat"
+ ],
+ "midi": [
+ 46,
+ 46,
+ 46,
+ 46
+ ],
+ "sequence": [
+ "1 1t 86 ; 1.89583 1t 0 ; 2.66 1t 81 ; 2.88542 1t 0 ; 3 1t 74 ; 3.57812 1t 0 ; 3.66 1t 86 ; 4.15625 1t 0 ; 4.66 1t 82 ; 4.83854 1t 0 ",
+ "1 1t 84 ; 1.88542 1t 0 ; 2.66 1t 75 ; 2.88542 1t 0 ; 3 1t 80 ; 3.90625 1t 0 ; 4.66 1t 82 ; 4.84896 1t 0 ",
+ "1 1t 86 ; 1.89583 1t 0 ; 2.66 1t 80 ; 2.88542 1t 0 ; 3 1t 85 ; 3.88542 1t 0 ; 4.66 1t 77 ; 4.82812 1t 0 ",
+ "1 1t 74 ; 1.90625 1t 0 ; 2.66 1t 74 ; 2.88542 1t 0 ; 3 1t 76 ; 3.85938 1t 0 ; 4.66 1t 80 ; 4.83854 1t 0 "
+ ]
+ }
+ ]
+ },
{
"groove": "JazzBasieFillAA",
"description": "1 bar fill AA.",
diff --git a/src/js/musicxml-grooves.js b/src/js/musicxml-grooves.js
index c876ce2c..299375c0 100755
--- a/src/js/musicxml-grooves.js
+++ b/src/js/musicxml-grooves.js
@@ -18,7 +18,8 @@ const DIVISIONS_128th = DIVISIONS*8/128
const DIVISIONS_256th = DIVISIONS*8/256
const DIVISIONS_512th = DIVISIONS*8/512
const DIVISIONS_1024th = DIVISIONS*8/1024
-const TOLERANCE = 0
+const TOLERANCE = 0.025
+const SWING = 0.66
import fs from 'fs'
import xmlFormat from 'xml-formatter'
@@ -374,13 +375,12 @@ function createMeasureNotes(groove, part, i) {
const beatType = parseInt(groove.timeSignature.split('/')[1])
// Step 1: Gather all notes and parse them.
- // Ignore full rests and notes with velocity 0.
// TODO Handle case of empty measure.
return part.reduce((notes, track) => {
const voice = SaxonJS.XPath.evaluate(`//instrument[@id="${instrumentId}"]/drum[@midi="${track.midi[0]}"]/voice/text()`, instruments) ?? '1'
return notes.concat(track.sequence[i].split(';').map(note => {
const parts = note.split(/\s+/).filter(part => !!part)
- return parts[0] !== 'z' ? {
+ return parts[0] === 'z' ? undefined : {
midi: track.midi[0],
onset: parseFloat(parts[0]),
duration: undefined,
@@ -388,8 +388,23 @@ function createMeasureNotes(groove, part, i) {
partId: track.partId,
track: track.track,
voice: parseInt(voice)
- } : undefined
- }).filter(note => !!note && note.velocity > 0))
+ }
+ }).filter(note => !!note))
+ }, [])
+
+ // Step 1.5: Detect velocity 0 notes and set the duration of previous notes accordingly.
+ .reduce((notes, note) => {
+ if (note.velocity > 0) {
+ notes.push(note)
+ }
+ // else {
+ // const previous = notes.reverse().find(n => n.midi === note.midi && n.voice === note.voice)
+ // // TODO Handle note that was open in previous measure.
+ // if (previous) {
+ // previous.duration = note.onset - previous.onset
+ // }
+ // }
+ return notes
}, [])
// Step 2: Sort the notes, first by voice, then by onset.
@@ -399,7 +414,7 @@ function createMeasureNotes(groove, part, i) {
n1.onset - n2.onset
})
- // Step 3: Calculate note durations.
+ // Step 3: Calculate notes duration.
// A note's duration is the difference between the next note's onset and its own onset.
// Therefore, at each note, we can calculate the previous note's duration.
// At the first note of each voice, if the onset is > 1, we insert a rest to start the measure.
@@ -407,7 +422,7 @@ function createMeasureNotes(groove, part, i) {
.reduce((notes, note, index, input) => {
const isFirstNote = notes.length === 0 || notes[notes.length - 1].voice !== note.voice
const isLastNote = index === input.length - 1 || input[index + 1].voice !== note.voice
- const previous = isFirstNote ? 1 : notes[notes.length-1].onset
+ const previous = isFirstNote ? 1 : notes[notes.length - 1].onset
const duration = note.onset - previous
if (duration > 0) {
// If the first note starts later than 1, insert a rest before the note.
@@ -421,28 +436,22 @@ function createMeasureNotes(groove, part, i) {
})
}
else {
- notes.filter(n => n.onset === previous && n.voice === note.voice).forEach(note => { note.duration = duration })
+ notes.filter(n => n.onset === previous && n.voice === note.voice && n.duration === undefined).forEach(n => { n.duration = duration })
}
}
-
- // Only add the note if it's being sounded.
- if (note.velocity > 0) {
- notes.push(note)
- }
+ notes.push(note)
// If we're at the end of the measure, calculate the duration of all remaining notes.
if (isLastNote) {
- notes.filter(n => n.duration === undefined && n.voice === note.voice).forEach(note => {
- note.duration = beats + 1 - note.onset
+ notes.filter(n => n.duration === undefined && n.voice === note.voice).forEach(n => {
+ n.duration = beats + 1 - n.onset
})
}
+
return notes
}, [])
- // Step 3.5: Discard notes whose duration is under the tolerance level.
- .filter(note => note.duration > TOLERANCE)
-
- // Step 4. Insert extra rests where needed.
+ // Step 4: Insert extra rests where needed.
// Each note is at most 1 beat, and does not cross beat boundaries.
// Any note with duration that crosses beat boundaries get truncated and the rest of the time is filled with rests.
.reduce((notes, note) => {
@@ -465,138 +474,255 @@ function createMeasureNotes(groove, part, i) {
return notes
}, [])
- // Step 5: Generate MusicXML.
+ // Step 5: Generate note types, durations and extra notes as needed.
+ // Ignore notes that have already been processed by an earlier iteration in createNoteTiming().
+ .reduce((notes, note, index, input) => {
+ const extra = 'musicXml' in note ? [] : createNoteTiming(note, index, input, beatType)
+ notes.push(note, ...extra)
+ return notes
+ }, [])
+
+ // Step 5.5: Generate MusicXML.
// When voices change, we backup to the beginning of the measure.
+ // TODO Add dynamics, articulations.
.map((note, index, notes) => {
+ const backup = (index > 0 && notes[index-1].voice !== note.voice) ? `
+
+ ${beats * DIVISIONS}
+
+ `.trim() : ''
const drum = note.midi ? SaxonJS.XPath.evaluate(`//instrument[@id="${instrumentId}"]/drum[@midi="${note.midi}"]`, instruments) : undefined
+ const chord = (index > 0 && notes[index-1].onset === note.onset) ? '' : ''
const pitch = drum ? `
${drum.getElementsByTagName('display-step')[0].textContent}
${drum.getElementsByTagName('display-octave')[0].textContent}
`.trim() : ''
+ const tieStart = 'tie' in note.musicXml && note.musicXml.tie.start ? `` : ''
+ const tieStop = 'tie' in note.musicXml && note.musicXml.tie.stop ? `` : ''
const instrument = drum ? `` : ''
- const chord = (index > 0 && notes[index - 1].onset === note.onset) ? '' : ''
+ const type = 'type' in note.musicXml ? `${note.musicXml.type}` : ''
+ const dots = 'dots' in note.musicXml ? Array.from(Array(note.musicXml.dots), _ => '') : []
+ const timeModification = 'tuplet' in note.musicXml ? `
+
+ ${note.musicXml.tuplet.actualNotes}
+ ${note.musicXml.tuplet.normalNotes}
+ ${note.musicXml.tuplet.normalType}
+
+ `.trim() : ''
const stem = drum ? `${drum.getElementsByTagName('stem')[0].textContent}` : ''
const notehead = drum ? `${drum.getElementsByTagName('notehead')[0].textContent}` : ''
- const duration = Math.round(note.duration * DIVISIONS)
- const backup = (index > 0 && notes[index - 1].voice !== note.voice) ? `
-
- ${beats * DIVISIONS}
-
- `.trim() : ''
+ const tiedStart = 'tie' in note.musicXml && note.musicXml.tie.start ? `` : ''
+ const tiedStop = 'tie' in note.musicXml && note.musicXml.tie.stop ? `` : ''
+ const tuplet = 'tuplet' in note.musicXml ? (
+ 'startStop' in note.musicXml.tuplet && note.musicXml.tuplet.startStop !== undefined ? (
+ note.musicXml.tuplet.startStop === 'start' ? `
+
+
+ ${note.musicXml.tuplet.actualNotes}
+ ${note.musicXml.type}
+
+
+ ${note.musicXml.tuplet.normalNotes}
+ ${note.musicXml.tuplet.normalType}
+
+
+ `.trim() : ``
+ ) : ''
+ ) : ''
return `
${backup}
${chord}
${pitch}
- ${duration}
+ ${note.musicXml.duration}
+ ${tieStart}
+ ${tieStop}
${instrument}
${note.voice}
- ${createNoteTiming(note, index, notes, beatType)}
+ ${type}
+ ${dots.join('')}
+ ${timeModification}
${stem}
${notehead}
- ${createNoteNotations(note, index, notes, beatType)}
+
+ ${tiedStart}
+ ${tiedStop}
+ ${tuplet}
+
`.trim()
}).join('')
}
/**
- * Derive a note type given its raw duration.
+ * Derive a note type and timing given its raw duration.
+ *
+ * This function _mutates_ the current note to set the MusicXML information about the note's timing:
+ *
+ * note.musicXml = {
+ * duration, // expressed in multiples of score divisions
+ * type, //
+ * dots, // count of
+ * tuplet: { // Information for both and
+ * actualNotes,
+ * normalNotes,
+ * normalType,
+ * startStop, // undefined | 'start' | 'stop',
+ * number, //
+ * },
+ * tie: { // Information for both and
+ * start, // true | false
+ * stop, // true | false
+ * }
+ * }
*
- * This is a heuristic algorithm that can grow arbitrarily complex in the general case.
- * We use the fact that we're dealing with drum beats and knowledge about MMA grooves to avoid going down the full rabbit hole.
+ * The function also _returns_ an array of extra notes that should be inserted following the current one, to complement the note timing,
+ * such as extra rests or tied notes.
+ *
+ * The function potentially also _mutates_ the next note(s) in the incoming array with their own musicXml structure, to account for
+ * cases like swing note pairs or other tuplets.
*/
-function createNoteTiming(note, index, notes, beatType) {
- // Don't attempt to detect notes < 16th.
- const types = {
- [DIVISIONS_WHOLE]: 'whole',
- [DIVISIONS_HALF]: 'half',
- [DIVISIONS_QUARTER]: 'quarter',
- [DIVISIONS_EIGHTH]: 'eighth',
- [DIVISIONS_16th]: '16th',
- }
- const elements = []
- const scoreDuration = Math.round(note.duration * DIVISIONS * 8 / beatType)
- const isLastNote = index === notes.length - 1 || notes[index + 1].voice !== note.voice
-
- // Detect 2nd note in pair of swing notes.
- // The first note in the pair will set the swing value for this one.
- if ('swing' in note) {
- elements.push(`${note.swing}`)
- elements.push(`32`)
- }
- // Detect simple types from the map above.
- else if (scoreDuration in types) {
- elements.push(`${types[scoreDuration]}`)
- }
- // Detect first note in pair of swing 8th notes.
- // The sum of first + second swing notes = 1.
- // Set the swing value for the 2nd note to catch it early next time.
- else if (!isLastNote && Math.abs(note.duration + notes[index + 1].duration - 1) <= Number.EPSILON) {
- if (note.duration > notes[index + 1].duration) {
- elements.push(`quarter`)
- notes[index + 1].swing = 'eighth'
- }
- else {
- elements.push(`eighth`)
- notes[index + 1].swing = 'quarter'
- }
- elements.push(`32`)
+function createNoteTiming(note, index, notes, beatType, tolerance = 0) {
+
+ // Some functions we'll need.
+ const normalizedDuration = d => Math.round(d * DIVISIONS * 8 / beatType)
+ const tuplets = (note, index, tuplets) => notes.filter((n, i) => n.voice === note.voice && i >= index && i < index + tuplets)
+ const tupletsDuration = tuplets => tuplets.reduce((s, n) => s + normalizedDuration(n.duration), 0)
+
+ // The map from score duration to MusicXML note type, and its opposite function.
+ const types = [
+ [DIVISIONS_WHOLE, 'whole'],
+ [DIVISIONS_HALF, 'half'],
+ [DIVISIONS_QUARTER, 'quarter'],
+ [DIVISIONS_EIGHTH, 'eighth'],
+ [DIVISIONS_16th, '16th'],
+ [DIVISIONS_32nd, '32nd'],
+ [DIVISIONS_64th, '64th'],
+ [DIVISIONS_128th, '128th'],
+ [DIVISIONS_256th, '256th'],
+ [DIVISIONS_512th, '512th'],
+ [DIVISIONS_1024th, '1024th'],
+ ]
+ const scoreDuration = normalizedDuration(note.duration)
+ const scoreTolerance = normalizedDuration(tolerance)
+
+ // Timing structure we will fill here.
+ note.musicXml = {
+ duration: Math.round(note.duration * DIVISIONS)
}
- else for (const [entry, type] of Object.entries(types)) {
- // Detect simple types with epsilon tolerance.
- if (Math.abs(scoreDuration - entry) <= Number.EPSILON) {
- elements.push(`${type}`)
+
+ for (const [entry, type] of types) {
+ // Detect simple types with tolerance.
+ if (Math.abs(scoreDuration - entry) <= scoreTolerance + Number.EPSILON) {
+ note.musicXml = { ...note.musicXml, type }
break
}
- // Detect dotted notes.
- if (entry < scoreDuration) {
+ // Detect dotted notes, only for non-rests.
+ if (note.midi && entry < scoreDuration) {
const dots = Math.log(2 - scoreDuration / entry) / Math.log(0.5)
if (Number.isInteger(dots)) {
- elements.push(`${type}`)
- elements.push(...Array.from(Array(dots), _ => ''))
+ note.musicXml = { ...note.musicXml, type, dots }
break
}
}
- // Detect 3- and 5-tuplets.
+ // TODO Detect 3- and 5-tuplets.
for (const tuplet of [3, 5]) {
- if (Math.abs(scoreDuration * tuplet - entry * 2) < Number.EPSILON) {
- elements.push(`${type}`)
- elements.push(`${tuplet}2`)
- break
+ }
+
+ // Detect swing 8th pair.
+ // To qualify, 2 consecutive notes must:
+ // - Sum up to a quarter
+ // - Each be within a SWING factor of a quarter
+ if (entry === DIVISIONS_QUARTER) {
+ const pair = tuplets(note, index, 2)
+ const [swingHi, swingLo] = [SWING * entry, (1 - SWING) * entry]
+ if (
+ pair.length == 2 &&
+ Math.abs(tupletsDuration(pair) - entry) <= scoreTolerance * pair.length &&
+ pair.every(n => {
+ const d = normalizedDuration(n.duration)
+ return Math.abs(swingHi - d) <= scoreTolerance || Math.abs(swingLo - d) <= scoreTolerance
+ })
+ ) {
+ note.musicXml = {
+ ...note.musicXml,
+ type: pair[0].duration > pair[1].duration ? 'quarter' : 'eighth',
+ tuplet: {
+ actualNotes: 3,
+ normalNotes: 2,
+ normalType: 'eighth',
+ startStop: 'start',
+ number: 1
+ }
+ }
+ pair[1].musicXml = {
+ duration: Math.round(pair[1].duration * DIVISIONS),
+ type: pair[0].duration < pair[1].duration ? 'quarter' : 'eighth',
+ tuplet: {
+ actualNotes: 3,
+ normalNotes: 2,
+ normalType: 'eighth',
+ startStop: 'stop',
+ number: 1
+ }
+ }
+ break;
}
}
}
- if (elements.length < 1) {
- console.error(`[${note.track}] Could not transform note duration ${note.duration} to MusicXML.`)
+ // None of the closed formulas worked. Create extra notes to add up to the duration.
+ // - First "extra" note is actually current note.
+ // - All notes will be tied. First note starts a tie, last note ends a tie, middle notes have both.
+ // if (note.musicXml.typeElements.length < 1) {
+ // const extra = []
+ // let remainingDuration = scoreDuration
+ // for (const [entry, type] of types) {
+ // if (remainingDuration > entry) {
+ // extra.push({
+ // duration: entry * beatType / 8,
+ // type,
+ // tie: { start: true, stop: extra.length > 0 },
+ // })
+ // remainingDuration -= entry
+ // if (remainingDuration <= 0) break
+ // }
+ // }
+
+ // // Close up last tie.
+ // extra[extra.length - 1].tie.start = false
+
+ // // Transfer first extra note to current note.
+ // note.musicXml = extra.shift()
+
+ // // Return extra notes.
+ // return extra.map(e => { return {
+ // midi: note.midi,
+ // velocity: note.velocity,
+ // partId: note.partId,
+ // track: note.track,
+ // voice: note.voice,
+ // musicXml: e
+ // }})
+ // }
+
+ // Nothing found. Try again, but with a greater tolerance.
+ if (note.musicXml.type === undefined) {
+ console.error(`[${note.track}] Could not transform note duration ${note.duration} with tolerance ${tolerance} to MusicXML.`)
+ if (tolerance === 0) {
+ return createNoteTiming(note, index, notes, beatType, TOLERANCE)
+ }
}
- return elements.join('')
-}
-/**
- * Create extra note notations including articulations.
- */
-function createNoteNotations(note, _index, _notes) {
- const articulations = []
- if ('staccato' in note ) {
- articulations.push('')
- }
- return articulations.length ? `
-
-
- ${articulations.join('')}
-
-
- `.trim() : ''
+ return []
}
/**
- * Create a synthetic (undocumented) from a single track.
+ * Create a synthetic from a single track.
* The structure is compatible with drums.xml entries.
*/
function createInstrument(document, _groove, track) {