UNPKG

string-fingerings

Version:

Utilities for calculating fingerings on string instruments

202 lines 7.94 kB
/** * Gets the MIDI note number from a text representation (middle C is 'C4') * @param noteName Can include sharp (#) and flat (b) alterations, like Db5 or F#2. * @returns The MIDI note number or a string with an explanatory error message. */ export function noteNumber(noteName) { noteName = noteName.trim(); if (noteName.length < 2) return 'Note names must be at least two characters long'; let pointer = 0; const firstChar = noteName.charAt(pointer).toUpperCase().charCodeAt(0); if (firstChar < 65 || firstChar > 65 + 7) return 'First char must be ABCDEFG'; const noteIndex = (firstChar - 67 + 7) % 7; const semitoneIndex = [0, 2, 4, 5, 7, 9, 11]; pointer++; let alteration = 0; if (noteName.charAt(pointer) === '#') { alteration = 1; pointer++; } else if (noteName.charAt(pointer) === 'b') { alteration = -1; pointer++; } const octave = parseInt(noteName.charAt(pointer)); if (isNaN(octave)) return 'Last char must be a number'; pointer++; if (pointer !== noteName.length) return 'Note name has extra characters'; return (semitoneIndex[noteIndex] ?? NaN) + alteration + (octave + 1) * 12; } export function nn(noteName) { const n = noteNumber(noteName); if (typeof n === 'string') throw `Invalid note name: ${n}`; return n; } export function noteName(noteNumber) { if (!Number.isSafeInteger(noteNumber) || noteNumber < 0 || noteNumber > 127) { throw "Invalid note number"; } const noteIndex = noteNumber % 12; const noteName = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'][noteIndex] ?? ""; const octave = Math.floor((noteNumber / 12)) - 1; return noteName + octave; } export function abcnote(noteNumber) { const noteIndex = noteNumber % 12; let noteName = ['c', 'c', 'd', 'd', 'e', 'f', 'f', 'g', 'g', 'a', 'a', 'b'][noteIndex] ?? ""; const alteration = ['', '^', '', '^', '', '', '^', '', '^', '', '^', ''][noteIndex] ?? ""; const octave = Math.floor(noteNumber / 12); let ticks; if (octave <= 5) { noteName = noteName.toUpperCase(); ticks = 5 - octave; return `${alteration}${noteName}${','.repeat(ticks)}`; } else { ticks = octave - 6; return `${alteration}${noteName}${"'".repeat(ticks)}`; } } function* stopsForString(instrument, stringIndex, noteNumber, includeNaturalHarmonics) { console.debug(`Calculating stop for note ${noteNumber} on string ${stringIndex}`); const instrumentString = instrument.strings[stringIndex]; if (!instrumentString) return; const openNote = instrumentString.openNote; const stopIndex = noteNumber - openNote; if (stopIndex < 0) { console.debug(`${noteName(noteNumber)} is too low for ${instrumentString.name} string`); return; } if (stopIndex > instrument.stops) { console.debug(`${noteName(noteNumber)} is too high to stop for ${instrumentString.name} string`); } else { yield { stringIndex, noteNumber, stopIndex, naturalHarmonic: false }; } if (includeNaturalHarmonics) { const naturalHarmonics = [ { partial: 2, touch: 12, produces: 12 }, { partial: 3, touch: 7, produces: 12 + 7 }, { partial: 3, touch: 12 + 7, produces: 12 + 7 }, { partial: 4, touch: 5, produces: 12 + 7 + 5 }, { partial: 4, touch: 12 + 7 + 5, produces: 12 + 7 + 5 }, { partial: 5, touch: 4, produces: 12 + 7 + 5 + 4 }, { partial: 5, touch: 12 + 4, produces: 12 + 7 + 5 + 4 }, { partial: 5, touch: 12 + 7 + 5 + 4, produces: 12 + 7 + 5 + 4 } ]; yield* naturalHarmonics .map((h) => ({ stringIndex, stopIndex: h.touch, noteNumber: h.produces + openNote, naturalHarmonic: true })) .filter((h) => h.noteNumber === noteNumber); } } /** * Returns the stop position relative to a string of length 1 * @param stopIndex semitone index (number of semitones above the open string) */ export function getStopRelPos(stopIndex) { return 1 - Math.pow(2, -stopIndex / 12); } function stopsForInstrument(instrument, notes, includeNaturalHarmonics) { const result = notes.map((note) => instrument.strings .flatMap((_s, stringIndex) => [ ...stopsForString(instrument, stringIndex, note, includeNaturalHarmonics) ]) .filter((f) => f !== null)); if (result.some((r) => r.length === 0)) { return []; } return result; } function cross(addValidation, l1, l2) { if (l1.length === 0) { return l2.map((l) => [l]); } return l1.flatMap((i1) => l2.filter((i2) => addValidation(i1, i2)).map((i2) => i1.concat([i2]))); } function fingeringStretch(instrument, stop1, stop2) { const stopRelPos1 = getStopRelPos(stop1.stopIndex); const stopRelPos2 = getStopRelPos(stop2.stopIndex); return instrument.scaleLength * Math.abs(stopRelPos2 - stopRelPos1); } Array.prototype.pairwise = function () { return this.slice(1).map((x, i) => [this[i], x]); }; function isOpenString(stop) { return stop.stopIndex === 0; } function stretchHardness(instrument, stop1, stop2, multiplier) { const stretch = fingeringStretch(instrument, stop1, stop2); console.debug(`qwerStretch: ${stretch}`, stop1, stop2); if (isOpenString(stop1) || isOpenString(stop2)) { return 0.0; } if (stretch > instrument.maxStretch * multiplier) { return 1.0; } if (stretch > instrument.hardStretch * multiplier) { return 0.5; } return 0.1; } export function fingeringHardness(instrument, fingering) { const sortedFingering = fingering.sort((s1, s2) => s1.stringIndex - s2.stringIndex); const stopPairs = sortedFingering.pairwise(); const sortedByStopIndex = sortedFingering.sort((s1, s2) => s1.stopIndex - s2.stopIndex); const minStop = sortedByStopIndex[0]; if (!minStop) throw "minStop is undefined"; const maxStop = sortedByStopIndex[sortedByStopIndex.length - 1]; if (!maxStop) throw "maxStop is undefined"; const contiguousStretchHardnesses = stopPairs .map(([stop1, stop2]) => stretchHardness(instrument, stop1, stop2, 1)); const totalStretchHardness = stretchHardness(instrument, minStop, maxStop, 1.02); const allStretchHardnesses = contiguousStretchHardnesses.concat(totalStretchHardness); return Math.max(...allStretchHardnesses); } export function hasNoGaps(_instrument, fingering) { return fingering .map((stop) => stop.stringIndex) .sort() .pairwise() .every(([str1, str2]) => Math.abs(str1 - str2) <= 1); } export function hasPossibleStretch(instrument, fingering) { return fingeringHardness(instrument, fingering) !== 1.0; } /** * Calculates the fingerings for an instrument and a set of notes. * @param instrument * @param notes * @param validations * @param includeNaturalHarmonics */ export function calculateFingerings(instrument, notes, validations, includeNaturalHarmonics = false) { console.debug(`Calculating fingerings for ${notes} on ${instrument.name}`); const stopsByNote = stopsForInstrument(instrument, notes, includeNaturalHarmonics); if (notes.length === 1) { return stopsByNote.flatMap(noteStops => noteStops.map(s => [s])); } const init = []; return stopsByNote .reduce((acc, stops) => cross(stringIsNotRepeated, acc, stops), init) .filter(validation); function stringIsNotRepeated(stopList, stop) { return stopList.every((existingStop) => existingStop.stringIndex !== stop.stringIndex); } function validation(fng) { return validations.map((val) => val(instrument, fng)).every((result) => result); } } //# sourceMappingURL=fingerings.js.map