Home Reference Source Repository

src/TempoInduction.js

/** 
 * Performs tempo induction by finding clusters of similar inter-onset intervals (IOIs)
 * @class
 */
export default class TempoInduction {
    /**
     * Find clusters
     * @param {Array} events - the onsets from which the tempo is induced
     * @param {Object} [params={}] - parameters
     * @param {Number} [params.widthTreshold=0.025] - the maximum difference in IOIs which are in the same cluster
     * @param {Number} [params.maxIOI=2.5] - the maximum IOI for inclusion in a cluster
     * @param {Number} [params.minIOI=0.07] - the minimum IOI for inclusion in a cluster
     * @return {{clIntervals: Array, clSizes: Array}} - object with clusters
     */      
    static processRhythmicEvents(events, params = {}) {
        const widthTreshold = params.widthTreshold || 0.025,
              maxIOI = params.maxIOI || 2.5,
              minIOI = params.minIOI || 0.07,
              length = events.length;

        let clIntervals = [],
            clSizes = [],
            clCount = 0;

        for (let i = 0; i < length - 1; i++) {
            for(let j = i + 1; j < length; j++) {
                let ioi = events[j] - events[i];
                if (ioi < minIOI) {
                    continue
                }                
                if (ioi > maxIOI) {
                    break;
                }
                let k = 0;
                for ( ; k < clCount; k++) {
                    if (Math.abs(clIntervals[k] - ioi) < widthTreshold) {
                        if ( Math.abs(clIntervals[k + 1] - ioi) < Math.abs(clIntervals[k] - ioi) 
                            && k < clCount - 1 ) {
                            k++;
                        }
                        clIntervals[k] = (clIntervals[k] * clSizes[k] + ioi) / (clSizes[k] + 1);
                        clSizes[k]++;
                        break;
                    }
                }
                if (k != clCount) continue;
                clCount++;
                for ( ; k > 0 && clIntervals[k - 1] > ioi; k--) {
                    clIntervals[k] = clIntervals[k - 1];
                    clSizes[k] = clSizes[k - 1];
                }
                clIntervals[k] = ioi;
                clSizes[k] = 1;
            }
        }
        if (clCount == 0) {
          throw "Fail to find IOIs";
        }
        clIntervals.length = clCount;
        clSizes.length = clCount;
        return {clIntervals, clSizes};
    }
    /**
     * Merge similar intervals
     * @param {Object} clusters - object with clusters
     * @param {Array} clusters.clIntervals - clusters IOIs array
     * @param {Array} clusters.clSizes - clusters sizes array
     * @param {Object} [params={}] - parameters
     * @param {Number} [params.widthTreshold=0.025] - the maximum difference in IOIs which are in the same cluster
     * @return {{clIntervals: Array, clSizes: Array}} - object with clusters
     */
    static mergeClusters(clusters, params = {}) {
        const widthTreshold = params.widthTreshold || 0.025;

        let clIntervals = clusters.clIntervals,
            clSizes = clusters.clSizes;
        let clCount = clIntervals.length;

        for (let i = 0; i < clCount; i++)
            for (let j = i + 1; j < clCount; j++)
                if (Math.abs(clIntervals[i] - clIntervals[j]) < widthTreshold) {
                    clIntervals[i] = 
                        (clIntervals[i] * clSizes[i] + 
                        clIntervals[j] * clSizes[j]) / 
                        (clSizes[i] + clSizes[j]);
                    clSizes[i] = clSizes[i] + clSizes[j];
                    --clCount;
                    for (let k = j + 1; k <= clCount; k++) {
                        clIntervals[k-1] = clIntervals[k];
                        clSizes[k-1] = clSizes[k];
                    }
                }
        clIntervals.length = clCount;
        clSizes.length = clCount;
        return {clIntervals, clSizes};
    }
    /**
     * Score intervals
     * @param {Object} clusters - object with clusters
     * @param {Array} clusters.clIntervals - clusters IOIs array
     * @param {Array} clusters.clSizes - clusters sizes array
     * @param {Object} [params={}] - parameters
     * @param {Number} [params.widthTreshold=0.025] - the maximum difference in IOIs which are in the same cluster
     * @param {Number} [params.maxTempos=10] - initial amount of tempo hypotheses
     * @return {{clScores: Array, clScoresIdxs: Array}} - object with intervals scores
     */
    static calculateScore(clusters, params = {}) {
        const widthTreshold = params.widthTreshold || 0.025;
        let maxTempos = params.maxTempos || 10;

        let clIntervals = clusters.clIntervals,
            clSizes = clusters.clSizes,
            clScores = [],
            clScoresIdxs = [];
        let clCount = clIntervals.length;

        for (let i = 0; i < clCount; i++) {
            clScores[i] = 10 * clSizes[i];
            clScoresIdxs[i] = { score: clScores[i], idx: i};
        }        

        clScoresIdxs.sort( (a, b) => b.score - a.score );
        if (clScoresIdxs.length > maxTempos) {
          for (let i = maxTempos - 1; i <  clScoresIdxs.length - 1; i++) {
              if (clScoresIdxs[i].score == clScoresIdxs[i + 1].score) {
                  maxTempos++;
              } else {
                  break;
              }
          }
          clScoresIdxs.length = maxTempos;
        }

        clScoresIdxs = clScoresIdxs.map( a => a.idx );

        for (let i = 0; i < clCount; i++) {
            for (let j = i + 1; j < clCount; j++) {
                let ratio = clIntervals[i] / clIntervals[j];
                let isFraction = ratio < 1;
                let d, err;
                d = isFraction ? Math.round(1 / ratio) : Math.round(ratio);
                if (d < 2 || d > 8) continue;

                if (isFraction)
                    err = Math.abs(clIntervals[i] * d - clIntervals[j]);
                else
                    err = Math.abs(clIntervals[i] - clIntervals[j] * d);
                let errTreshold = isFraction ? widthTreshold : widthTreshold * d;
                if (err >= errTreshold) continue;

                d = d >= 5 ? 1 : 6 - d;
                clScores[i] += d * clSizes[j];
                clScores[j] += d * clSizes[i];
            }
        }
        return {clScores, clScoresIdxs};
    }
    /**
     * Get array of tempo hypotheses
     * @param {Object} clusters - object with clusters
     * @param {Array} clusters.clIntervals - clusters IOIs array
     * @param {Array} clusters.clSizes - clusters sizes array
     * @param {Array} clusters.clScores - clusters scores array
     * @param {Array} clusters.clScoresIdxs - clusters scores indexes array
     * @param {Object} [params={}] - parameters
     * @param {Number} [params.widthTreshold=0.025] - the maximum difference in IOIs which are in the same cluster
     * @param {Number} [params.minBeatInterval=0.3] - the minimum inter-beat interval (IBI) (0.30 seconds == 200 BPM)
     * @param {Number} [params.maxBeatInterval=1] - the maximum inter-beat interval (IBI) (1.00 seconds ==  60 BPM)
     * @return {Array} tempoList - tempo hypotheses array
     */
    static createTempoList(clusters, params = {}) {
        const widthTreshold = params.widthTreshold || 0.025,
            minBeatInterval = params.minBeatInterval || 0.3,
            maxBeatInterval = params.maxBeatInterval || 1;
        let clIntervals = clusters.clIntervals,
            clSizes = clusters.clSizes,
            clScores = clusters.clScores,
            clScoresIdxs = clusters.clScoresIdxs,
            tempoList = [];
        let clCount = clIntervals.length;

        for (let i = 0; i < clScoresIdxs.length; i++) {
            let idx = clScoresIdxs[i];            
            let newSum = clIntervals[idx] * clScores[idx];
            let newWeight = clScores[idx];
            let err, errTreshold;
            for (let j = 0; j < clCount; j++) {
                if (j == idx) continue;
                let ratio = clIntervals[idx] / clIntervals[j];
                let isFraction = ratio < 1;
                let sumInc = 0;
                let d = isFraction ? Math.round(1 / ratio) : Math.round(ratio);
                if (d < 2 || d > 8) continue;

                if (isFraction) {
                    err = Math.abs(clIntervals[idx] * d - clIntervals[j]);
                    errTreshold = widthTreshold;
                } else {
                    err = Math.abs(clIntervals[idx] - d * clIntervals[j]);
                    errTreshold = widthTreshold * d;
                }
                if (err >= errTreshold) continue;

                if (isFraction) {
                    newSum += clIntervals[j] / d * clScores[j];
                } else {
                    newSum += clIntervals[j] * d * clScores[j];
                }
                newWeight += clScores[j];
            }
            let beat = newSum / newWeight;
            
            while (beat < minBeatInterval) beat *= 2;
            while (beat > maxBeatInterval) beat /= 2;

            tempoList.push(beat);
        }
        return tempoList;
    }    
}