string-fingerings
Version:
Utilities for calculating fingerings on string instruments
387 lines • 13.4 kB
JavaScript
import { describe, expect, it } from 'vitest';
import { abcnote, calculateFingerings, expandPitchClass, fingeringHardness, generateNoteCombinations, getStopRelPos, hasNoGaps, hasPossibleStretch, isNote, nn, noteName, noteNumber, parse, tryParseInput } from './fingerings.js';
describe('noteName', () => {
it('return C4 for number 60', () => {
expect(noteName(60)).toBe('C4');
});
it('return C#4 for number 61', () => {
expect(noteName(61)).toBe('C#4');
});
it('return B4 for number 59', () => {
expect(noteName(59)).toBe('B3');
});
it('throws for number 4.7', () => {
expect(() => noteName(4.7)).toThrow();
});
it('throws for number -12', () => {
expect(() => noteName(-12)).toThrow();
});
});
describe('noteNumber', () => {
it('returns 60 for middle C (C4)', () => {
expect(noteNumber('C4')).toBe(60);
});
it('complies with octave changes between B and C', () => {
expect(noteNumber('B3')).toBe(59);
});
it('recognizes lower case note names', () => {
expect(noteNumber('d4')).toBe(62);
});
it('recognizes double flat alteration', () => {
expect(noteNumber('Cbb4')).toBe(58);
});
it('recognizes double sharp alteration', () => {
expect(noteNumber('Cx4')).toBe(62);
});
it('recognizes sharp alteration', () => {
expect(noteNumber('C#4')).toBe(61);
});
it('recognizes flat alteration', () => {
expect(noteNumber('Bb3')).toBe(58);
});
it('returns error message with empty string', () => {
expect(noteNumber('')).to.be.a('string');
});
it('returns error message if no valid note is given', () => {
expect(noteNumber('X5')).toBeTypeOf('string');
});
it('returns error message if no octave is given', () => {
expect(noteNumber('G')).toBeTypeOf('string');
});
it('returns error message if an invalid alteration is given', () => {
expect(noteNumber('Gy6')).toBeTypeOf('string');
});
it('returns error message if there are extra chars', () => {
expect(noteNumber('Gb6!')).toBeTypeOf('string');
});
it('works with white spaces around', () => {
expect(noteNumber(' gb6 ')).toBe(90);
});
});
describe('nn', () => {
it('returns the same as noteNumber', () => {
expect(nn('D4')).toBe(62);
});
it('fails if no valid note is given', () => {
expect(() => nn('X5')).toThrow();
});
});
describe('getStopRelPos', () => {
it('Returns expected results', () => {
expect(getStopRelPos(0)).toBeCloseTo(0);
expect(getStopRelPos(7)).toBeCloseTo(0.33258, 3);
expect(getStopRelPos(12)).toBeCloseTo(0.5);
expect(getStopRelPos(24)).toBeCloseTo(0.75);
});
});
describe('abcnote', () => {
it.each([
['C', 12 * 5],
['^C', (12 * 5) + 1],
['B,', 12 * 5 - 1],
['c', 12 * 6],
["c'", 12 * 7],
["c''", 12 * 8],
['C,', 12 * 4],
['C,,', 12 * 3]
])('returns %s for %i', (expected, midiNumber) => {
expect(abcnote(midiNumber)).toBe(expected);
});
});
describe('Note.abcnote', () => {
it.each([
['C', parse('C4')],
['^C', parse('C#4')],
['_C', parse('Cb4')],
['B,', parse('B3')],
['c', parse('C5')],
["c'", parse('C6')],
["c''", parse('C7')],
['C,', parse('C3')],
['C,,', parse('C2')]
])('returns %s for %o', (expected, midiNumber) => {
expect(midiNumber.abcnote()).toBe(expected);
});
});
const violin = {
name: 'Violin',
stops: 24,
scaleLength: 330,
hardStretch: 78,
maxStretch: 92,
strings: [
{
openNote: parse('G3'),
},
{
openNote: parse('D4'),
},
{
openNote: parse('A4'),
},
{
openNote: parse('E5'),
}
]
};
const bassExtension = {
name: 'Double bass with C-ext',
stops: 24,
scaleLength: 1049,
hardStretch: 116,
maxStretch: 120,
strings: [
{
openNote: parse('E1'),
additionalOpenSemitones: [-1, -2, -3, -4],
},
{
openNote: parse('A1')
},
{
openNote: parse('D2')
},
{
openNote: parse('G2')
}
]
};
describe('hasNoGaps', () => {
it('Returns expected results', () => {
expect(hasNoGaps(undefined, [
{ stringIndex: 3 },
{ stringIndex: 4 }
])).toBe(true);
expect(hasNoGaps(undefined, [
{ stringIndex: 1 },
{ stringIndex: 3 }
])).toBe(false);
expect(hasNoGaps(undefined, [
{ stringIndex: 1 },
{ stringIndex: 2 },
{ stringIndex: 4 }
])).toBe(false);
});
});
describe('calculateFingerings', () => {
it('works with single note', () => {
expect(calculateFingerings(violin, [72], [], false)).toStrictEqual([
[
{
'naturalHarmonic': false,
'noteNumber': 72,
'stopIndex': 17,
'stringIndex': 0
}
],
[
{
'naturalHarmonic': false,
'noteNumber': 72,
'stopIndex': 10,
'stringIndex': 1
}
],
[
{
'naturalHarmonic': false,
'noteNumber': 72,
'stopIndex': 3,
'stringIndex': 2
}
]
]);
});
it('works with quadruple stop C-major chord', () => {
expect(calculateFingerings(violin, [60, 67, 72, 76], [], false)).toStrictEqual([
[{
'stringIndex': 0,
'noteNumber': 60,
'naturalHarmonic': false,
'stopIndex': 5
},
{
'stringIndex': 1,
'noteNumber': 67,
'naturalHarmonic': false,
'stopIndex': 5
},
{
'stringIndex': 2,
'noteNumber': 72,
'naturalHarmonic': false,
'stopIndex': 3
},
{
'stringIndex': 3,
'noteNumber': 76,
'naturalHarmonic': false,
'stopIndex': 0
}]
]);
});
it('works with bass with c-extension', () => {
expect(calculateFingerings(bassExtension, [nn('D1'), nn('A1')], [], false)).toStrictEqual([
[{
'stringIndex': 0,
'noteNumber': nn('D1'),
'naturalHarmonic': false,
'stopIndex': -2
},
{
'stringIndex': 1,
'noteNumber': nn('A1'),
'naturalHarmonic': false,
'stopIndex': 0
}]
]);
expect(calculateFingerings(bassExtension, [nn('B0'), nn('A1')], [], false)).toStrictEqual([]);
});
it('discards reported impossible cases', () => {
expect(calculateFingerings(violin, [nn('E4'), nn('G4'), nn('B4')], [hasPossibleStretch], false)).toStrictEqual([]);
expect(calculateFingerings(violin, [nn('E4'), nn('G4'), nn('A#4')], [hasPossibleStretch], false)).toStrictEqual([]);
expect(calculateFingerings(violin, [nn('F4'), nn('G#4'), nn('A#4')], [hasPossibleStretch], false)).toStrictEqual([]);
});
it('accepts reported possible cases', () => {
expect(calculateFingerings(violin, [nn('A#4'), nn('B5')], [hasPossibleStretch], false)).toContainEqual([{
'stringIndex': 2,
'noteNumber': nn('A#4'),
'naturalHarmonic': false,
'stopIndex': 1
},
{
'stringIndex': 3,
'noteNumber': nn('B5'),
'naturalHarmonic': false,
'stopIndex': 7
}]);
});
});
describe('fingeringHardness', () => {
it('Returns expected results', () => {
expect(fingeringHardness(violin, [
{ stringIndex: 0, stopIndex: 9 },
{ stringIndex: 1, stopIndex: 5 },
{ stringIndex: 2, stopIndex: 2 }
])).toBe(1.0);
});
});
describe('tryParseInput', () => {
it('parses a note with octave as a Note', () => {
const result = tryParseInput('C4');
expect(typeof result).not.toBe('string');
expect(isNote(result)).toBe(true);
const note = result;
expect(note.name).toBe('C');
expect(note.octave).toBe(5);
expect(note.number).toBe(60);
});
it('parses a single letter as a PitchClass', () => {
const result = tryParseInput('G');
expect(typeof result).not.toBe('string');
expect(isNote(result)).toBe(false);
expect(result.name).toBe('G');
expect(result.alteration).toBe('');
expect(result.text()).toBe('G');
});
it('parses a letter with sharp as a PitchClass', () => {
const result = tryParseInput('C#');
expect(typeof result).not.toBe('string');
expect(isNote(result)).toBe(false);
expect(result.name).toBe('C');
expect(result.alteration).toBe('#');
expect(result.text()).toBe('C#');
});
it('parses a letter with flat as a PitchClass', () => {
const result = tryParseInput('Db');
expect(typeof result).not.toBe('string');
expect(isNote(result)).toBe(false);
expect(result.name).toBe('D');
expect(result.alteration).toBe('b');
});
it('parses double flat as a PitchClass', () => {
const result = tryParseInput('Ebb');
expect(typeof result).not.toBe('string');
expect(isNote(result)).toBe(false);
expect(result.alteration).toBe('bb');
});
it('parses double sharp as a PitchClass', () => {
const result = tryParseInput('Fx');
expect(typeof result).not.toBe('string');
expect(isNote(result)).toBe(false);
expect(result.alteration).toBe('x');
});
it('is case-insensitive', () => {
const result = tryParseInput('c#');
expect(typeof result).not.toBe('string');
expect(result.name).toBe('C');
});
it('returns error for empty string', () => {
expect(tryParseInput('')).toBeTypeOf('string');
});
it('returns error for invalid letter', () => {
expect(tryParseInput('X')).toBeTypeOf('string');
});
it('returns error for invalid alteration', () => {
expect(tryParseInput('Cy')).toBeTypeOf('string');
});
it('returns error for too-long input', () => {
expect(tryParseInput('Cbb99')).toBeTypeOf('string');
});
});
describe('expandPitchClass', () => {
it('returns Notes within the MIDI range', () => {
const pc = tryParseInput('C');
expect(isNote(pc)).toBe(false);
const notes = expandPitchClass(pc, 55, 100);
expect(notes.length).toBeGreaterThan(0);
for (const n of notes) {
expect(n.name).toBe('C');
expect(n.number).toBeGreaterThanOrEqual(55);
expect(n.number).toBeLessThanOrEqual(100);
}
});
it('returns C4, C5, C6, C7 for violin range', () => {
const pc = tryParseInput('C');
const notes = expandPitchClass(pc, 55, 100);
expect(notes.map(n => n.text())).toEqual(['C4', 'C5', 'C6', 'C7']);
});
it('returns empty array when no octave fits', () => {
const pc = tryParseInput('C');
const notes = expandPitchClass(pc, 61, 71);
expect(notes).toEqual([]);
});
});
describe('generateNoteCombinations', () => {
it('returns a single combination when all inputs have octaves', () => {
const inputs = [parse('G3'), parse('D4')];
const combos = generateNoteCombinations(inputs, 0, 127);
expect(combos).toHaveLength(1);
expect(combos[0].map(n => n.text())).toEqual(['G3', 'D4']);
});
it('expands a PitchClass into multiple combinations', () => {
const pc = tryParseInput('C');
const note = parse('G5');
const combos = generateNoteCombinations([pc, note], 55, 100);
expect(combos.length).toBeGreaterThan(1);
for (const combo of combos) {
expect(combo).toHaveLength(2);
expect(combo[0].name).toBe('C');
expect(combo[1].text()).toBe('G5');
}
});
it('produces cartesian product for two PitchClasses', () => {
const c = tryParseInput('C');
const g = tryParseInput('G');
const combos = generateNoteCombinations([c, g], 55, 100);
const cCount = expandPitchClass(c, 55, 100).length;
const gCount = expandPitchClass(g, 55, 100).length;
expect(combos).toHaveLength(cCount * gCount);
});
it('returns empty when a PitchClass has no valid octaves', () => {
const pc = tryParseInput('C');
const combos = generateNoteCombinations([pc], 61, 71);
expect(combos).toHaveLength(0);
});
});
//# sourceMappingURL=fingerings.spec.js.map