musicvis-lib
Version:
Music analysis and visualization library
281 lines (266 loc) • 8.29 kB
JavaScript
import { countOnesOfBinary, roundToNDecimals } from './MathUtils.js'
import { binarySearch } from './ArrayUtils.js'
/**
* @module utils/MusicUtils
*/
/**
* Converts beats per minute to seconds per beat
*
* @param {number} bpm tempo in beats per minute
* @returns {number} seconds per beat
*/
export function bpmToSecondsPerBeat (bpm) {
return 1 / (bpm / 60)
}
/**
* Maps any frequency (in Hz) to an approximate MIDI note number. Result can be
* rounded to get to the closest MIDI note or used as is for a sound in between
* two notes.
*
* @param {number} frequency a frequency in Hz
* @returns {number} MIDI note number (not rounded)
*/
export function freqToApproxMidiNr (frequency) {
return 12 * Math.log2(frequency / 440) + 69
}
/**
* Maps any MIDI number (can be in-between, like 69.5 for A4 + 50 cents) to its
* frequency.
*
* @param {number} midi MIDI note number
* @returns {number} frequency in Hz
*/
export function midiToFrequency (midi) {
return 2 ** ((midi - 69) / 12) * 440
}
/**
* Turns a chord into an integer that uniquely describes the occuring chroma.
* If the same chroma occurs twice this will not make a difference
* (e.g. [C4, E4, G4, C5] will equal [C4, E4, G4])
*
* How it works:
* Chord has C, E, and G
* x = 000010010001
* G E C
*
* @param {Note[]} notes notes
* @returns {number} an integer that uniquely identifies this chord's chroma
*/
export function chordToInteger (notes) {
let value = 0x0
for (const note of notes) {
const chroma = note.pitch % 12
// eslint-disable-next-line no-bitwise
const noteInteger = 1 << chroma
// eslint-disable-next-line no-bitwise
value = value | noteInteger
}
return value
}
/**
* Takes two chord integer representations from chordToInteger() and computes
* the Jaccard index
*
* @param {number} chord1 chord as integer representation
* @param {number} chord2 chord as integer representation
* @returns {number} Jackard index, from 0 for different to 1 for identical
*/
export function chordIntegerJaccardIndex (chord1, chord2) {
if (chord1 === chord2) {
return 1
}
// eslint-disable-next-line no-bitwise
const intersection = chord1 & chord2
// eslint-disable-next-line no-bitwise
const union = chord1 | chord2
const intersectionSize = countOnesOfBinary(intersection)
const unionSize = countOnesOfBinary(union)
return intersectionSize / unionSize
}
/*
* noteTypeDurationRatios
* 1 = whole note, 1/2 = half note, ...
*
* With added dots:
* o. has duration of 1.5, o.. 1.75, ...
*/
const noteTypeDurationRatios = []
const baseDurations = [2, 1, 1 / 2, 1 / 4, 1 / 8, 1 / 16, 1 / 32, 1 / 64]
for (const d of baseDurations) {
for (let dots = 0; dots < 4; dots++) {
let duration = d
let toAdd = d
for (let dot = 0; dot < dots; dot++) {
// Each dot after the note adds half of the one before
toAdd /= 2
duration += toAdd
}
noteTypeDurationRatios.push({
type: d,
dots,
duration
})
}
}
noteTypeDurationRatios.sort((a, b) => a.duration - b.duration)
/**
* Estimates the note type (whole, quarter, ...) and number of dots for dotted
* notes
*
* @todo test if corrrectly 'calibrated'
* @param {number} duration duration of a note
* @param {number} bpm tempo of the piece in bpm
* @returns {object} note type and number of dots
* e.g. { "dots": 0, "duration": 1, "type": 1 } for a whole note
* e.g. { "dots": 1, "duration": 1.5, "type": 1 } for a dotted whole note
*/
export function noteDurationToNoteType (duration, bpm) {
const quarterDuration = bpmToSecondsPerBeat(bpm)
const ratio = duration / quarterDuration / 4
// TODO: round to finest representable step?
// Binary search
return binarySearch(noteTypeDurationRatios, ratio, d => d.duration)
}
/**
* Circle of 5ths as
* [midiNr, noteAsSharp, noteAsFlat, numberOfSharps, numberOfFlats]
*
* @see https://en.wikipedia.org/wiki/Circle_of_fifths
* @type {any[][]}
*/
export const CIRCLE_OF_5THS = [
[0, 'C', 'C', 0, 0],
[7, 'G', 'G', 1, 0],
[2, 'D', 'D', 2, 0],
[9, 'A', 'A', 3, 0],
[4, 'E', 'E', 4, 0],
[11, 'B', 'B', 5, 7],
[6, 'F#', 'Gb', 6, 6],
[1, 'C#', 'Db', 7, 5],
[8, 'G#', 'Ab', 0, 4],
[3, 'D#', 'Eb', 0, 3],
[10, 'A#', 'Bb', 0, 2],
[5, 'F', 'F', 0, 1]
]
/**
* Maps number of semitones to interval name
* m - minor
* M - major
* P - perfect
* aug - augmented
*
* @type {Map<number,string>}
*/
export const INTERVALS = new Map([
[1, 'unison'],
[1, 'm2'],
[2, 'M2'],
[3, 'm3'],
[4, 'M3'],
[5, 'P4'],
[6, 'aug4'],
[7, 'P5'],
[8, 'm6'],
[9, 'M6'],
[10, 'm7'],
[11, 'M7'],
[12, 'P8']
])
/**
* Estimates a difficulty score for playing a set of notes.
* Can be used for an entire piece or measure-by-measure.
*
* @todo different modi, e.g. for piano or guitar (fingering is different)
* @param {Note[]} notes notes
* @param {string} mode mode
* @param {number[]} fingering finger as number for each note, same order
* @returns {number} difficulty, can range within [0, infinity)
* @throws {'Invalid mode parameter'} when mode is invalid
*/
// export function estimateDifficulty(notes, mode, fingering) {
// if (mode === 'noteDensity') {
// // Naive mode, only look at density of notes
// const startTimeExtent = extent(notes, d => d.start);
// return notes.length / startTimeExtent;
// } else if (mode === 'fingering') {
// // TODO: check complexity of fingering
// }
// throw new Error('Invalid mode parameter');
// }
/**
* Creates a track of metronome ticks for a given tempo and meter.
*
* @param {number} tempo tempo in bpm, e.g. 120
* @param {number[]} meter e.g. [4, 4]
* @param {number} duration duration of the resulting track in seconds
* @returns {object[]} metronome track with {time: number, accent: boolean}
*/
export function metronomeTrackFromTempoAndMeter (tempo = 120, meter = [4, 4], duration = 60) {
const track = []
const secondsPerBeat = bpmToSecondsPerBeat(tempo) / (meter[1] / 4)
let currentTime = 0
while (currentTime <= duration) {
for (let beat = 0; beat < meter[0]; beat++) {
track.push({
time: roundToNDecimals(currentTime, 4),
accent: beat % meter[0] === 0
})
currentTime += secondsPerBeat
if (currentTime > duration) {
return track
}
}
}
}
/**
* Creates a track of metronome ticks for a given music piece.
*
* @param {MusicPiece} musicPiece music piece
* @param {number} [tempoFactor=1] rescale the tempo of the metronome, e.g. 2
* for twice the speed
* @returns {object[]} metronome track with {time: number, accent: boolean}
*/
export function metronomeTrackFromMusicPiece (musicPiece, tempoFactor = 1) {
const { duration, tempos, timeSignatures } = musicPiece
const track = []
let currentTime = 0
// Time signatures
const initialTimeSig = timeSignatures[0].signature ?? [4, 4]
let [beatCount, beatType] = initialTimeSig
const timeSigsTodo = timeSignatures.slice(1)
// Tempi
const initialTempo = tempos[0].bpm ?? 120
let secondsPerBeat = bpmToSecondsPerBeat(initialTempo) / (beatType / 4)
const temposTodo = tempos.slice(1)
while (currentTime <= duration) {
// Always use the most recent tempo and meter
const lookahead = currentTime + secondsPerBeat
if (timeSigsTodo.length > 0 && timeSigsTodo[0].time <= lookahead) {
// console.log(
// 'timesig change to', timeSigsTodo[0].signature,
// 'after', track.length,
// 'beeps, at', currentTime);
[beatCount, beatType] = timeSigsTodo[0].signature
timeSigsTodo.shift()
}
if (temposTodo.length > 0 && temposTodo[0].time <= lookahead) {
// console.log(
// 'tempo change to', temposTodo[0].bpm,
// 'after', track.length,
// 'beeps, at', currentTime);
secondsPerBeat = bpmToSecondsPerBeat(temposTodo[0].bpm) / (beatType / 4)
temposTodo.shift()
}
for (let beat = 0; beat < beatCount; beat++) {
track.push({
time: roundToNDecimals(currentTime / tempoFactor, 3),
accent: beat === 0
})
currentTime += secondsPerBeat
if (currentTime > duration) {
return track
}
}
}
return track
}