Skip to content

Commit

Permalink
More robust tuplet detection
Browse files Browse the repository at this point in the history
  • Loading branch information
infojunkie committed Sep 16, 2024
1 parent 8314ac9 commit 46c8ba5
Showing 1 changed file with 28 additions and 29 deletions.
57 changes: 28 additions & 29 deletions src/js/musicxml-grooves.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const DIVISIONS_256th = DIVISIONS/64
const DIVISIONS_512th = DIVISIONS/128
const DIVISIONS_1024th = DIVISIONS/256
const QUANTIZATION_DEFAULT_GRID = [4, 3]
const TUPLET_TOLERANCE = 0.025
const TUPLET_TOLERANCE = 0.05

import fs from 'fs'
import xmlFormat from 'xml-formatter'
Expand Down Expand Up @@ -425,16 +425,14 @@ function createMeasureNotes(groove, part, measure) {

// Calculate notes duration.
// A note's duration is the difference between the next note's onset and its own onset.
// A note's duration does not exceed beat boundaries (drum tracks only).
// At each note, we 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.
// At the last note of each voice, the duration is the remaining time until the measure end.
.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 previousOnset = isFirstNote ? 1 : notes[notes.length-1].onset
const boundary = Math.floor(previousOnset) + 1 - previousOnset
const duration = Math.min(note.onset - previousOnset, boundary)
const duration = note.onset - previousOnset
if (duration > 0) {
notes.filter(n => n.onset === previousOnset && n.voice === note.voice && n.duration === undefined).forEach(n => { n.duration = duration })
}
Expand All @@ -443,8 +441,7 @@ function createMeasureNotes(groove, part, measure) {
// 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(n => {
const boundary = Math.floor(n.onset) + 1 - n.onset
n.duration = Math.min(beats + 1 - n.onset, boundary)
n.duration = beats + 1 - n.onset
})
}

Expand Down Expand Up @@ -601,9 +598,6 @@ function quantizeNoteOnset(note, index, notes, beats, grid) {
error_sgn: scoreOnset - (notes[index-1].quantized.onset + DIVISIONS_1024th)
}
}
if (onset.multiple >= beats * DIVISIONS) {
console.warn(`[${note.track}:${note.measure+1}] Quantized note onset at ${note.onset} crosses beat boundary. Moving to next measure.`)
}

// Store the note.
note.quantized = {
Expand All @@ -621,7 +615,7 @@ function quantizeNoteDuration(note, index, notes, beats, grid) {
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 + notes[index+1].quantized.duration)
isLastNote ? beats * DIVISIONS : notes[index+1].quantized.onset
)
let offset = grid.map(unit => {
return nearestMultiple(scoreOffset, DIVISIONS/unit)
Expand Down Expand Up @@ -771,31 +765,34 @@ function createNoteTiming(note, index, notes) {
}

// Detect 3- and 5-tuplets.
if (entry === DIVISIONS_QUARTER && entry > note.quantized.duration) {
// To qualify, tuplet notes must:
// - Sum up to the duration of the enclosing note type
// - 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 Relax the constraint of quarter-note tuplets.
if (entry < note.quantized.duration && note.quantized.duration < entry * 2) {
const target = entry * 2
for (const tupletCount of [3, 5]) {
const tuplet = tuplets(note, index, notes, tupletCount)
const ratio = Math.round(entry / tupletCount)
const beat = Math.floor(tuplet[0].quantized.onset / entry)
const ratio = Math.round(target / tupletCount)
if (
tuplet.length === tupletCount &&
Math.abs(tupletsDuration(tuplet) - entry) <= TUPLET_TOLERANCE * tupletCount &&
tuplet.every(n =>
//Math.min(n.quantized.duration % ratio, ratio - (n.quantized.duration % ratio)) <= TUPLET_TOLERANCE &&
Math.floor(n.quantized.onset / entry) === beat
)
Math.abs(tupletsDuration(tuplet) - target) <= TUPLET_TOLERANCE * tupletCount &&
tuplet.every(n => Math.min(n.quantized.duration % ratio, ratio - (n.quantized.duration % ratio)) <= TUPLET_TOLERANCE) &&
tuplet.every(n => Math.floor(n.quantized.onset / target) === Math.floor(tuplet[0].quantized.onset / target))
) {
tuplet.forEach((n, i) => {
n.quantized = {
duration: entry / tupletCount,
onset: note.quantized.onset + (i * entry / tupletCount)
duration: target / tupletCount,
onset: note.quantized.onset + (i * target / tupletCount)
}
n.musicXml = {
duration: entry / tupletCount,
type: lookupType(entry / 2),
duration: target / tupletCount,
type: lookupType(entry),
tuplet: {
actualNotes: tupletCount,
normalNotes: 2,
normalType: lookupType(entry / 2),
normalType: lookupType(entry),
startStop: i === 0 ? 'start' : i === tuplet.length - 1 ? 'stop' : undefined,
number: 1
}
Expand All @@ -810,23 +807,25 @@ function createNoteTiming(note, index, notes) {
// To qualify, 2 consecutive notes must:
// - Sum up to a quarter
// - Each be within a triplet multiple of a quarter
if (entry === DIVISIONS_QUARTER && entry > note.quantized.duration) {
// - 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) {
const target = entry * 2
const pair = tuplets(note, index, notes, 2)
const ratio = Math.round(entry / 3)
const ratio = Math.round(target / 3)
if (
pair.length === 2 &&
Math.abs(tupletsDuration(pair) - entry) <= TUPLET_TOLERANCE * 2 &&
Math.abs(tupletsDuration(pair) - target) <= TUPLET_TOLERANCE * 2 &&
pair.every(n => Math.min(n.quantized.duration % ratio, ratio - (n.quantized.duration % ratio)) <= TUPLET_TOLERANCE) &&
Math.floor(pair[0].quantized.onset / entry) === Math.floor(pair[1].quantized.onset / entry)
Math.floor(pair[0].quantized.onset / target) === Math.floor(pair[1].quantized.onset / target)
) {
pair.forEach((n, i, t) => {
n.musicXml = {
duration: n.quantized.duration,
type: n.quantized.duration > ratio ? lookupType(entry) : lookupType(entry / 2),
type: n.quantized.duration > ratio ? lookupType(target) : lookupType(entry),
tuplet: {
actualNotes: 3,
normalNotes: 2,
normalType: lookupType(entry / 2),
normalType: lookupType(target),
startStop: i === 0 ? 'start' : i === t.length - 1 ? 'stop' : undefined,
number: 1
}
Expand Down

0 comments on commit 46c8ba5

Please sign in to comment.