Skip to content

Commit 7662ab4

Browse files
committed
Added note interface to represent specific enharmonic.
1 parent 363959b commit 7662ab4

3 files changed

Lines changed: 110 additions & 50 deletions

File tree

packages/string-fingerings/fingerings.spec.ts

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3-
abcnote,
4-
calculateFingerings,
5-
fingeringHardness,
6-
getStopRelPos,
7-
hasNoGaps,
8-
hasPossibleStretch,
9-
nn,
10-
noteName,
11-
noteNumber
3+
abcnote,
4+
calculateFingerings,
5+
fingeringHardness,
6+
getStopRelPos,
7+
hasNoGaps,
8+
hasPossibleStretch,
9+
nn,
10+
noteName,
11+
noteNumber, parse
1212
} from './fingerings.js'
1313

1414
describe('noteName', () => {
@@ -90,15 +90,30 @@ describe('abcnote', () => {
9090
['^C', (12 * 5) + 1],
9191
['B,', 12 * 5 - 1],
9292
['c', 12 * 6],
93-
['c\'', 12 * 7],
94-
['c\'\'', 12 * 8],
93+
["c'", 12 * 7],
94+
["c''", 12 * 8],
9595
['C,', 12 * 4],
9696
['C,,', 12 * 3]
9797
])('returns %s for %i', (expected, midiNumber) => {
9898
expect(abcnote(midiNumber)).toBe(expected)
9999
})
100100
})
101101

102+
describe('Note.abcnote', () => {
103+
it.each([
104+
['C', parse('C4')],
105+
['^C', parse('C#4')],
106+
['_C', parse('Cb4')],
107+
['B,', parse('B3')],
108+
['c', parse('C5')],
109+
["c'", parse('C6')],
110+
["c''", parse('C7')],
111+
['C,', parse('C3')],
112+
['C,,', parse('C2')]
113+
])('returns %s for %o', (expected, midiNumber) => {
114+
expect(midiNumber.abcnote()).toBe(expected)
115+
})
116+
})
102117
const violin = {
103118
name: 'Violin',
104119
stops: 24,

packages/string-fingerings/fingerings.ts

Lines changed: 73 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,82 @@
1-
import type {Instrument, InstrumentString, Stop, StopCalculationData} from './types.js'
1+
import type {Instrument, InstrumentString, Note, Stop, StopCalculationData} from './types.js'
2+
class NoteImpl implements Note {
3+
constructor(
4+
public name: Note['name'],
5+
public octave: Note['octave'],
6+
public alteration: Note['alteration'],
7+
public number: Note['number']
8+
) {}
9+
text() {
10+
return this.name + this.alteration + (this.octave -1)
11+
}
212

13+
abcnote(): string {
14+
let noteName = this.name.toLowerCase()
15+
let alteration = ''
16+
switch (this.alteration) {
17+
case '#': alteration = '^'; break
18+
case 'b': alteration = '_'; break
19+
case 'x': alteration = '^^'; break
20+
case 'bb': alteration = '__'; break
21+
case '': alteration = ''; break
22+
}
23+
const octave = this.octave
24+
let ticks: number
25+
if (octave <= 5) {
26+
noteName = noteName.toUpperCase()
27+
ticks = 5 - octave
28+
return `${alteration}${noteName}${','.repeat(ticks)}`
29+
} else {
30+
ticks = octave - 6
31+
return `${alteration}${noteName}${"'".repeat(ticks)}`
32+
}
33+
}
34+
}
35+
export function tryParse(noteText: string): Note | string {
36+
noteText = noteText.trim()
37+
if (noteText.length < 2) return 'Note names must be at least two characters long'
38+
if (noteText.length > 4) return 'Note names must be at most four characters long'
39+
const firstChar = noteText.charAt(0).toUpperCase()
40+
const lastChar = noteText.charAt(noteText.length - 1)
41+
if (firstChar < 'A' || firstChar > 'G') return 'First char must be A-G'
42+
if (lastChar < '0' || lastChar > '9') return 'Last char must be a number'
43+
const octave = parseInt(lastChar)
44+
if (isNaN(octave)) return 'Last char must be a number'
45+
const alteration = noteText.substring(1, noteText.length - 1)
46+
let alterationOffset = 0
47+
switch (alteration) {
48+
case 'bb': alterationOffset = -2; break
49+
case 'x': alterationOffset = 1; break
50+
case 'b': alterationOffset = -1; break
51+
case '#': alterationOffset = 1; break
52+
case '': alterationOffset = 0; break
53+
default: return 'Second char must be #, b, x or bb. Text: ' + noteText + ', alteration: ' + alteration
54+
}
55+
const noteIndex = (firstChar.charCodeAt(0) - 67 + 7) % 7
56+
const semitoneIndex = [0, 2, 4, 5, 7, 9, 11]
57+
const number =
58+
(semitoneIndex[noteIndex] ?? NaN) + alterationOffset + (octave + 1) * 12
59+
return new NoteImpl(
60+
firstChar as Note['name'],
61+
octave + 1,
62+
alteration as Note['alteration'],
63+
number
64+
)
65+
}
66+
export function parse(noteText: string): Note {
67+
const result = tryParse(noteText)
68+
if (typeof result === 'string') throw result
69+
return result
70+
}
371
/**
472
* Gets the MIDI note number from a text representation (middle C is 'C4')
573
* @param noteName Can include sharp (#) and flat (b) alterations, like Db5 or F#2.
674
* @returns The MIDI note number or a string with an explanatory error message.
775
*/
876
export function noteNumber(noteName: string): number | string {
9-
noteName = noteName.trim()
10-
if (noteName.length < 2) return 'Note names must be at least two characters long'
11-
let pointer = 0
12-
const firstChar = noteName.charAt(pointer).toUpperCase().charCodeAt(0)
13-
if (firstChar < 65 || firstChar > 65 + 7) return 'First char must be ABCDEFG'
14-
15-
const noteIndex = (firstChar - 67 + 7) % 7
16-
const semitoneIndex = [0, 2, 4, 5, 7, 9, 11]
17-
18-
pointer++
19-
let alteration = 0
20-
if (noteName.charAt(pointer) === '#') {
21-
alteration = 1
22-
pointer++
23-
} else if (noteName.charAt(pointer) === 'b') {
24-
alteration = -1
25-
pointer++
26-
}
27-
const octave = parseInt(noteName.charAt(pointer))
28-
if (isNaN(octave)) return 'Last char must be a number'
29-
pointer++
30-
if (pointer !== noteName.length) return 'Note name has extra characters'
31-
32-
return (semitoneIndex[noteIndex] ?? NaN) + alteration + (octave + 1) * 12
77+
const n = tryParse(noteName)
78+
if (typeof n === 'string') return n
79+
return n.number
3380
}
3481

3582
export function nn(noteName: string) {
@@ -50,19 +97,7 @@ export function noteName(noteNumber: number): string {
5097
}
5198

5299
export function abcnote(noteNumber: number): string {
53-
const noteIndex = noteNumber % 12
54-
let noteName = ['c', 'c', 'd', 'd', 'e', 'f', 'f', 'g', 'g', 'a', 'a', 'b'][noteIndex] ?? ""
55-
const alteration = ['', '^', '', '^', '', '', '^', '', '^', '', '^', ''][noteIndex] ?? ""
56-
const octave = Math.floor(noteNumber / 12)
57-
let ticks: number
58-
if (octave <= 5) {
59-
noteName = noteName.toUpperCase()
60-
ticks = 5 - octave
61-
return `${alteration}${noteName}${','.repeat(ticks)}`
62-
} else {
63-
ticks = octave - 6
64-
return `${alteration}${noteName}${"'".repeat(ticks)}`
65-
}
100+
return parse(noteName(noteNumber)).abcnote()
66101
}
67102

68103
function* stopsForString<TString extends InstrumentString>(

packages/string-fingerings/types.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
/** Represents a single fingering, either stopping or touching the string with one finger. */
1+
/** Represents a note with a specific enharmonic representation */
2+
export interface Note {
3+
name: 'A'|'B'|'C'|'D'|'E'|'F'|'G'
4+
octave: number
5+
alteration: '#'|'b'|'x'|'bb'|''
6+
number: number
7+
text: () => string
8+
abcnote: () => string
9+
}
10+
11+
/** Represents a single fingering, either stopping or touching the string with one finger. */
212
export interface StopCalculationData {
313
/** The string index in the instrument (0 = first string, etc.) */
414
stringIndex: number

0 commit comments

Comments
 (0)