string-fingerings
Version:
Utilities for calculating fingerings on string instruments
202 lines • 7.94 kB
JavaScript
/**
* 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