UNPKG

string-fingerings

Version:

Utilities for calculating fingerings on string instruments

343 lines 12.4 kB
class NoteImpl { name; octave; alteration; number; constructor(name, octave, alteration, number) { this.name = name; this.octave = octave; this.alteration = alteration; this.number = number; } text() { return this.name + this.alteration + (this.octave - 1); } abcnote() { let noteName = this.name.toLowerCase(); let alteration = ''; switch (this.alteration) { case '#': alteration = '^'; break; case 'b': alteration = '_'; break; case 'x': alteration = '^^'; break; case 'bb': alteration = '__'; break; case '': alteration = ''; break; } const octave = this.octave; 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)}`; } } } class PitchClassImpl { name; alteration; constructor(name, alteration) { this.name = name; this.alteration = alteration; } text() { return this.name + this.alteration; } } export function tryParse(noteText) { noteText = noteText.trim(); if (noteText.length < 2) return 'Note names must be at least two characters long'; if (noteText.length > 4) return 'Note names must be at most four characters long'; const firstChar = noteText.charAt(0).toUpperCase(); const lastChar = noteText.charAt(noteText.length - 1); if (firstChar < 'A' || firstChar > 'G') return 'First char must be A-G'; if (lastChar < '0' || lastChar > '9') return 'Last char must be a number'; const octave = parseInt(lastChar); if (isNaN(octave)) return 'Last char must be a number'; const alteration = noteText.substring(1, noteText.length - 1); let alterationOffset = 0; switch (alteration) { case 'bb': alterationOffset = -2; break; case 'x': alterationOffset = 2; break; case 'b': alterationOffset = -1; break; case '#': alterationOffset = 1; break; case '': alterationOffset = 0; break; default: return 'Second char must be #, b, x or bb. Text: ' + noteText + ', alteration: ' + alteration; } const noteIndex = (firstChar.charCodeAt(0) - 67 + 7) % 7; const semitoneIndex = [0, 2, 4, 5, 7, 9, 11]; const number = (semitoneIndex[noteIndex] ?? NaN) + alterationOffset + (octave + 1) * 12; return new NoteImpl(firstChar, octave + 1, alteration, number); } export function parse(noteText) { const result = tryParse(noteText); if (typeof result === 'string') throw result; return result; } /** * Parses a note with an optional octave. If the octave is present, returns a Note. * If the octave is absent, returns a PitchClass (e.g. "C", "C#", "Db", "Ebb", "Fx"). */ export function tryParseInput(noteText) { noteText = noteText.trim(); if (noteText.length < 1) return 'Note names must be at least one character long'; if (noteText.length > 4) return 'Note names must be at most four characters long'; const firstChar = noteText.charAt(0).toUpperCase(); if (firstChar < 'A' || firstChar > 'G') return 'First char must be A-G'; const lastChar = noteText.charAt(noteText.length - 1); if (lastChar >= '0' && lastChar <= '9') { return tryParse(noteText); } const alteration = noteText.substring(1); switch (alteration) { case 'bb': case 'x': case 'b': case '#': case '': break; default: return 'Alteration must be #, b, x or bb. Text: ' + noteText + ', alteration: ' + alteration; } return new PitchClassImpl(firstChar, alteration); } export function isNote(input) { return 'octave' in input; } /** * Expands a PitchClass into all Notes whose MIDI number falls within [minMidi, maxMidi]. */ export function expandPitchClass(pc, minMidi, maxMidi) { const results = []; for (let octave = 0; octave <= 9; octave++) { const result = tryParse(pc.name + pc.alteration + octave); if (typeof result === 'string') continue; if (result.number >= minMidi && result.number <= maxMidi) { results.push(result); } } return results; } /** * Generates all possible Note[] combinations from NoteInput[]. * Notes with octaves produce a single option; PitchClasses are expanded * into all octaves within the given MIDI range. */ export function generateNoteCombinations(inputs, minMidi, maxMidi) { const expanded = inputs.map(input => { if (isNote(input)) return [input]; return expandPitchClass(input, minMidi, maxMidi); }); return cartesianProduct(expanded); } function cartesianProduct(arrays) { if (arrays.length === 0) return [[]]; const [first, ...rest] = arrays; if (!first || first.length === 0) return []; const restProduct = cartesianProduct(rest); return first.flatMap(item => restProduct.map(combo => [item, ...combo])); } /** * 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) { const n = tryParse(noteName); if (typeof n === 'string') return n; return n.number; } 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) { return parse(noteName(noteNumber)).abcnote(); } 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.number; const stopIndex = noteNumber - openNote; const additionalOpenSemitones = instrumentString.additionalOpenSemitones ?? []; const additionalOpenNotes = additionalOpenSemitones.map((s) => s + openNote); if (stopIndex < 0 && !additionalOpenNotes.includes(noteNumber)) { console.debug(`${noteName(noteNumber)} is too low for ${instrumentString.openNote.name} string`); return; } if (stopIndex > (instrumentString.stops ?? instrument.stops)) { console.debug(`${noteName(noteNumber)} is too high to stop for ${instrumentString.openNote.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(`Stretch: ${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