musicvis-lib
Version:
Music analysis and visualization library
432 lines (411 loc) • 13.3 kB
JavaScript
import { difference, extent, intersection, max } from 'd3'
import { getMidiNoteByLabel, getMidiNoteByNr } from '../fileFormats/Midi.js'
import { detectChordsByExactStart } from '../chords/Chords.js'
import Note from '../types/Note.js'
import { bpmToSecondsPerBeat } from '../utils/MusicUtils.js'
/**
* @module instruments/Lamellophone
*/
/**
* Represents Lamellophone tunings
*/
export class LamellophoneTuning {
/**
* Represents a tuning of lamellophone.
*
* @param {string} name name
* @param {string[]} notes array of notes, same order as on instrument
* e.g. [..., 'D4','C4', 'F#4', ...]
*/
constructor (name, notes) {
this.name = name
this.notes = notes
this.short = notes.join(' ')
this.pitches = notes.map(note => getMidiNoteByLabel(note).pitch)
this.pitchesSorted = [...this.pitches]
.sort((a, b) => a - b)
this.keyCount = notes.length
}
/**
* Returns an array of the tuning's notes as number representation:
* Tuning notes: C4, D4, ... C5, D5, ... C6, D6
* Number format: 1, 2, ... 1°, 2°, ... 1°°, 2°°
*
* @returns {string[]} array with tuning notes in number representation
*/
getNumbers () {
const pitches = this.pitchesSorted
const numbers = new Map()
for (const [index, pitch] of pitches.entries()) {
let number = index + 1
let ending = ''
let lowerOctave = pitch - 12
while (lowerOctave > 0 && numbers.has(lowerOctave)) {
number = numbers.get(lowerOctave).number
ending += '°'
lowerOctave -= 12
}
numbers.set(pitch, { number, numberString: `${number}${ending}` })
}
return [...numbers.values()]
.map(d => d.numberString)
}
/**
* Returns an array of the tuning's notes as letter representation:
* Tuning notes: C4, D4, ... C5, D5, ... C6, D6
* Number format: C, D, ... C°, D°, ... C°°, D°°
*
* @returns {string[]} array with tuning notes in letter representation
*/
getLetters () {
const pitches = this.pitchesSorted
const numbers = new Map()
for (const [index, pitch] of pitches.entries()) {
let number = index + 1
let ending = ''
let lowerOctave = pitch - 12
while (lowerOctave > 0 && numbers.has(lowerOctave)) {
number = numbers.get(lowerOctave).number
ending += '°'
lowerOctave -= 12
}
const letter = getMidiNoteByNr(pitch).name
numbers.set(pitch, { number, letterString: `${letter}${ending}` })
}
return [...numbers.values()].map(d => d.letterString)
}
}
/**
* Tunings.
* Notes are in the same order as on the instrument
*
* @type {Map<string,Map<string,LamellophoneTuning>>}
*/
export const lamellophoneTunings = new Map([
['Kalimba', new Map([
[
'9 A Major',
new LamellophoneTuning('9 A Major', ['A5', 'C#6', 'C#5', 'A5', 'A4', 'F#5', 'E5', 'E6', 'B5'])
],
[
'9 A Minor',
new LamellophoneTuning('9 A Minor', ['A5', 'C6', 'C5', 'A5', 'A4', 'F5', 'E5', 'E6', 'B5'])
],
[
'9 A Minor 7',
new LamellophoneTuning('9 A Minor 7', ['A5', 'C6', 'C5', 'A5', 'A4', 'F#5', 'E5', 'E6', 'B5'])
],
[
'9 A Ake Bono',
new LamellophoneTuning('9 A Ake Bono', ['A5', 'D6', 'D5', 'A5', 'A4', 'F5', 'E5', 'E6', 'A#5'])
],
[
'9 A Hijaz',
new LamellophoneTuning('9 A Hijaz', ['G5', 'D6', 'D5', 'A5', 'A4', 'F#5', 'D#5', 'D#6', 'A#5'])
],
[
'9 A Pygmy',
new LamellophoneTuning('9 A Pygmy', ['G5', 'C6', 'C5', 'G5', 'G4', 'D#5', 'D5', 'D#6', 'A#5'])
],
[
'17 C Major',
new LamellophoneTuning('17 C Major', ['D6', 'B5', 'G5', 'E5', 'C5', 'A4', 'F4', 'D4', 'C4', 'E4', 'G4', 'B4', 'D5', 'F5', 'A5', 'C6', 'E6'])
],
[
'21 C Major',
new LamellophoneTuning('21 C Major', ['D6', 'B5', 'G5', 'E5', 'C5', 'A4', 'F4', 'D4', 'B3', 'G3', 'F3', 'A3', 'C4', 'E4', 'G4', 'B4', 'D5', 'F5', 'A5', 'C6', 'E6'])
]
])]
])
/**
* Parses a tab into notes
*
* @param {string} tab in letter format
* @param {LamellophoneTuning} tuning tuning
* @param {number} tempo tempo in bpm
* @returns {Note[]} notes
*/
export function convertTabToNotes (tab, tuning, tempo = 120) {
if (!tab || tab.length === 0) { return [] }
// Create a mapping symbol->pitch
const symbolToPitchMap = new Map()
const symbols = tuning.getLetters()
for (let index = 0; index < tuning.keyCount; index++) {
symbolToPitchMap.set(symbols[index], tuning.pitchesSorted[index])
}
// Parse tab to notes
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
const noteNamesSet = new Set(noteNames)
const lowestNote = tuning.pitchesSorted[0]
const startOct = getMidiNoteByNr(lowestNote).octave
const secondsPerBeat = bpmToSecondsPerBeat(tempo)
let insideChord = false
let insideNote = false
let currentTime = 0
let currentPitch = 0
let currentOctOffset = 0
const notes = []
tab = `${tab.toUpperCase().replaceAll('\n', ' \n')} `
// This is needed more often
const finishNote = () => {
try {
notes.push(Note.from({
pitch: currentPitch + 12 * (startOct + 1 + currentOctOffset),
start: currentTime,
end: currentTime + secondsPerBeat
}))
currentOctOffset = 0
if (!insideChord) {
currentTime += secondsPerBeat
}
} catch {
console.log(currentPitch)
}
insideNote = false
}
for (const char of tab) {
if (char === '(') {
// Start chord (but finish current if any)
if (insideChord) {
insideChord = false
}
if (insideNote) {
finishNote()
}
insideChord = true
} else if (noteNamesSet.has(char)) {
// Start note (but finish current if any)
if (insideNote) {
finishNote()
}
insideNote = true
currentPitch = noteNames.indexOf(char)
} else if (char === '#') {
// Sharpen note
currentPitch++
} else if (char === '°') {
// Increase ocatve
currentOctOffset++
} else if (char === ' ' || char === '\n' || char === ')') {
// End chord and note if inside
if (char === ')') {
insideChord = false
}
if (char === '\n') {
insideChord = false
currentTime += secondsPerBeat
}
if (insideNote) {
finishNote()
}
}
}
return notes
}
/**
* Converts an array of notes into a text tab
*
* @param {Note[]} notes notes
* @param {LamellophoneTuning} tuning tuning
* @param {'letter'|'number'} mode mode
* @param {number} restSize number of seconds for a gap between chords to insert
* a line break
* @returns {string} text tab
*/
export function convertNotesToTab (notes, tuning, mode = 'letter', restSize = 0.1) {
if (!notes || notes.length === 0) { return [] }
// Create a mapping pitch->symbol
const pitchToSymbolMap = new Map()
const symbols = mode === 'letter' ? tuning.getLetters() : tuning.getNumbers()
for (let index = 0; index < tuning.keyCount; index++) {
pitchToSymbolMap.set(tuning.pitchesSorted[index], symbols[index])
}
// Get chords
const chords = detectChordsByExactStart(notes)
// Create tab
let tab = ''
let previousEnd = 0
for (const chord of chords) {
// Format chord's notes
let chordString = chord
.map(note => {
if (pitchToSymbolMap.has(note.pitch)) {
return pitchToSymbolMap.get(note.pitch) || `[${note.pitch}]`
} else {
return mode === 'letter' ? getMidiNoteByNr(note.pitch)?.name ?? note.pitch : note.pitch
}
})
.join(' ')
if (chord.length > 1) {
// Mark chords with backets (for multiple notes)
chordString = `(${chordString})`
}
tab = chord[0].start - previousEnd > restSize ? `${tab}\n${chordString}` : `${tab} ${chordString}`
// Update last end time of chord
previousEnd = max(chord, n => n.end)
}
// Remove leading space
return tab.slice(1)
}
/**
* Converts an array of notes into an HTML tab with colored notes
*
* @param {Note[]} notes notes
* @param {LamellophoneTuning} tuning tuning
* @param {'letter'|'number'} mode mode
* @param {number} restSize number of seconds for a gap between chords to insert
* a line break
* @param {Function} colormap color map function: pitch to color
* @returns {string} HTML tab
*/
export function convertNotesToHtmlTab (
notes,
tuning,
mode = 'letter',
restSize = 0.1,
colormap = () => 'black'
) {
if (!notes || notes.length === 0) { return [] }
// Create a mapping pitch->symbol
const pitchToSymbolMap = new Map()
const symbols = mode === 'letter' ? tuning.getLetters() : tuning.getNumbers()
for (let index = 0; index < tuning.keyCount; index++) {
pitchToSymbolMap.set(tuning.pitches[index], symbols[index])
}
// Get chords
const chords = detectChordsByExactStart(notes)
// Create tab
let tab = ''
let previousEnd = 0
for (const chord of chords) {
// Format chord's notes
let chordString = chord
.map(note => {
let string
if (pitchToSymbolMap.has(note.pitch)) {
string = pitchToSymbolMap.get(note.pitch) || `[${note.pitch}]`
} else {
string = mode === 'letter' ? getMidiNoteByNr(note.pitch)?.name ?? note.pitch : note.pitch
}
const color = colormap(note.pitch)
return `<span class='note' style='background-color: ${color}'>${string}</span>`
})
.join('\n')
if (chord.length > 1) {
// Mark chords with backets (for multiple notes)
chordString = `<span class='chord'>${chordString}</span>`
}
tab = chord[0].start - previousEnd > restSize ? `${tab}<br/>${chordString}` : `${tab}${chordString}`
// Update last end time of chord
previousEnd = max(chord, n => n.end)
}
return tab
}
/**
* Converts a number-based tab to note letter format
*
* @param {string} numberTab tab text with number format
* @param {Map<number, string>} numberLetterMap maps numbers to letters
* @returns {string} tab in letter format
*/
export function convertNumbersToLetters (numberTab, numberLetterMap) {
if (!numberTab || numberTab.length === 0) { return '' }
// Normalize to °
numberTab = numberTab.replaceAll('\'', '°')
numberTab = numberTab.replaceAll('’', '°')
numberTab = numberTab.replaceAll('*', '°')
numberTab = numberTab.replaceAll('º', '°')
numberTab = numberTab.replaceAll('^', '°')
for (const [key, value] of numberLetterMap.entries()) {
numberTab = numberTab.replaceAll(key, value)
}
return numberTab
}
/**
* Tries to find a transposition s.t. the tuning is able to play all notes.
* If not not possible, return the transposition that requires the least keys to
* be retuned.
*
* @todo tests fail
* @param {Note[]} notes notes
* @param {LamellophoneTuning} tuning tuning
* @returns {object} {transpose: number, retune: Map}
*/
export function bestTransposition (notes, tuning) {
if (!notes || notes.length === 0) {
return { transpose: 0, retune: new Map() }
}
const occuringPitches = new Set(notes.map(n => n.pitch))
if (occuringPitches.size > tuning.keyCount) {
// Cannot play all notes, no matter the transp. and tuning
// TODO:
// just choose best approx?
}
const notePitches = [...occuringPitches]
// Already perfect? return now
if (difference(notePitches, tuning.pitches).size === 0) {
return { transpose: 0, retune: new Map() }
}
const [minPitch, maxPitch] = extent(notePitches)
const transpose = (array, steps) => array.map(d => d + steps)
// Just brute force through all transpositions
let bestSteps = 0
let bestTransposed
let commonPitches
for (let steps = -minPitch; steps <= 127 - maxPitch; steps++) {
const transposed = transpose(notePitches, steps)
const common = intersection(transposed, tuning.pitches)
if (!commonPitches || common.size > commonPitches.size) {
bestSteps = steps
bestTransposed = transposed
}
}
bestTransposed = new Set(bestTransposed)
// Get pitches in tuning but not in notes and other way round
const uncommon = difference(bestTransposed, tuning.pitches)
console.log(uncommon)
const freePitches = new Set()
const neededPitches = []
for (const p of uncommon) {
if (bestTransposed.has(p)) {
neededPitches.push(p)
} else {
freePitches.add(p)
}
}
console.log(neededPitches)
console.log(freePitches)
if (neededPitches.length === 0) {
// Everything is fine!
return {
transpose: bestSteps,
retune: new Map()
}
}
if (freePitches.size === 0) {
// Cannot solve this!
return {
transpose: bestSteps,
retune: new Map()
}
}
// Get closest free pitch for each needed one
const retune = new Map()
for (const neededPitch of neededPitches) {
let bestMatch = null
const bestDiff = Number.POSITIVE_INFINITY
let freePitch
for (freePitch of freePitches) {
const diff = Math.abs(neededPitch - freePitch)
if (diff < bestDiff) {
bestMatch = freePitch
}
}
freePitches.delete(bestMatch)
retune.set(freePitch, neededPitch)
}
return {
transpose: bestSteps,
retune
}
}