Skip to content

Commit fea6ab5

Browse files
committed
chore: implement pitch helper functions edge-cases and tests
1 parent 6c175b3 commit fea6ab5

File tree

2 files changed

+242
-83
lines changed

2 files changed

+242
-83
lines changed

js/utils/__tests__/musicutils.test.js

Lines changed: 100 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ global._ = jest.fn(str => str);
2323
global.window = {
2424
btoa: jest.fn(str => Buffer.from(str, "utf8").toString("base64"))
2525
};
26+
global.INVALIDPITCH = "Not a valid pitch name";
2627

2728
const {
2829
scaleDegreeToPitchMapping,
@@ -97,14 +98,15 @@ const {
9798
numberToPitchSharp,
9899
getNumber,
99100
getNoteFromInterval,
100-
numberToPitch,
101-
GetNotesForInterval,
102-
base64Encode,
103-
NOTESFLAT,
104101
NOTESSHARP,
105102
MUSICALMODES,
106103
getStepSizeUp,
107-
getStepSizeDown
104+
getStepSizeDown,
105+
INVALIDPITCH,
106+
numberToPitch,
107+
GetNotesForInterval,
108+
base64Encode,
109+
NOTESFLAT
108110
} = require("../musicutils");
109111

110112
describe("musicutils", () => {
@@ -2241,39 +2243,6 @@ describe("getPitchInfo", () => {
22412243
});
22422244
});
22432245

2244-
describe("_calculate_pitch_number", () => {
2245-
let activity, tur;
2246-
2247-
beforeEach(() => {
2248-
activity = {
2249-
errorMsg: jest.fn()
2250-
};
2251-
2252-
tur = {
2253-
singer: {
2254-
lastNotePlayed: null,
2255-
inNoteBlock: {},
2256-
notePitches: {},
2257-
noteOctaves: {},
2258-
keySignature: "C major",
2259-
movable: false,
2260-
pitchNumberOffset: 0
2261-
}
2262-
};
2263-
});
2264-
2265-
it("calculates pitch number for a standard note string", () => {
2266-
const val = _calculate_pitch_number(activity, "C4", tur);
2267-
expect(typeof val).toBe("number");
2268-
});
2269-
2270-
it("calculates pitch number relative to another note", () => {
2271-
const valC4 = _calculate_pitch_number(activity, "C4", tur);
2272-
const valC5 = _calculate_pitch_number(activity, "C5", tur);
2273-
expect(valC5).toBeGreaterThan(valC4);
2274-
});
2275-
});
2276-
22772246
describe("NOTESFLAT", () => {
22782247
it("should contain 12 chromatic notes", () => {
22792248
expect(NOTESFLAT.length).toBe(12);
@@ -2404,3 +2373,96 @@ describe("getStepSizeUp", () => {
24042373
expect(result).toBe(0);
24052374
});
24062375
});
2376+
2377+
describe("_calculate_pitch_number", () => {
2378+
it("should return the correct pitch number for common notes", () => {
2379+
expect(_calculate_pitch_number("C", 4)).toBe(60);
2380+
expect(_calculate_pitch_number("A", 4)).toBe(69);
2381+
expect(_calculate_pitch_number("C", 5)).toBe(72);
2382+
});
2383+
2384+
it("should maintain enharmonic consistency", () => {
2385+
expect(_calculate_pitch_number("C#", 4)).toBe(61);
2386+
expect(_calculate_pitch_number("Db", 4)).toBe(61);
2387+
});
2388+
2389+
it("should return INVALIDPITCH for invalid input", () => {
2390+
expect(_calculate_pitch_number("Invalid", 4)).toBe("Not a valid pitch name");
2391+
expect(_calculate_pitch_number(null, 4)).toBe("Not a valid pitch name");
2392+
});
2393+
});
2394+
2395+
describe("getPitchInfo", () => {
2396+
it("should correctly parse string inputs", () => {
2397+
const info = getPitchInfo("C#5");
2398+
expect(info).toEqual({
2399+
name: "C#",
2400+
octave: 5,
2401+
pitchNumber: 73
2402+
});
2403+
2404+
const info10 = getPitchInfo("C10");
2405+
expect(info10).toEqual({
2406+
name: "C",
2407+
octave: 10,
2408+
pitchNumber: 132
2409+
});
2410+
2411+
// F𝄪5 (F double-sharp) → same pitch as G5 = (5+1)*12 + 7 = 79
2412+
const infoDoubleSharp = getPitchInfo("F\u{1D12A}5");
2413+
expect(infoDoubleSharp.pitchNumber).toBe(79);
2414+
expect(infoDoubleSharp.octave).toBe(5);
2415+
2416+
// Bb𝄫5 (B double-flat) → same pitch as A5 = (5+1)*12 + 9 = 81
2417+
const infoDoubleFlat = getPitchInfo("B\u{1D12B}5");
2418+
expect(infoDoubleFlat.pitchNumber).toBe(81);
2419+
expect(infoDoubleFlat.octave).toBe(5);
2420+
2421+
// Cx4 (C textual double-sharp) → same pitch as D4 = (4+1)*12 + 2 = 62
2422+
const infoX = getPitchInfo("Cx4");
2423+
expect(infoX.pitchNumber).toBe(62);
2424+
expect(infoX.octave).toBe(4);
2425+
});
2426+
2427+
it("should correctly parse numeric inputs", () => {
2428+
const info = getPitchInfo(60);
2429+
expect(info).toEqual({
2430+
name: "C",
2431+
octave: 4,
2432+
pitchNumber: 60
2433+
});
2434+
});
2435+
2436+
it("should handle invalid inputs", () => {
2437+
const info = getPitchInfo("InvalidNote");
2438+
expect(info).toEqual({
2439+
name: null,
2440+
octave: null,
2441+
pitchNumber: "Not a valid pitch name"
2442+
});
2443+
});
2444+
2445+
it("should handle accidental offset accumulation edge cases", () => {
2446+
// Gb-1: G(7) + flat(-1) = index 6 (G♭). (-1+1)*12 + 6 = 6
2447+
const infoGbNeg1 = getPitchInfo("Gb-1");
2448+
expect(infoGbNeg1.pitchNumber).toBe(6);
2449+
expect(infoGbNeg1.octave).toBe(-1);
2450+
2451+
// D𝄫-1 enharmonic of C at oct -1: (-1+1)*12 + 2 + (-2) = 0
2452+
// Note: D𝄫-2 (oct=-2) gives -12; octave must be -1 to reach pitch 0.
2453+
const infoDdblFlatNeg1 = getPitchInfo("D\u{1D12B}-1");
2454+
expect(infoDdblFlatNeg1.pitchNumber).toBe(0);
2455+
expect(infoDdblFlatNeg1.octave).toBe(-1);
2456+
2457+
// E##4: E(4) + ##(+2) → index 6 (F#). (4+1)*12 + 4 + 2 = 66
2458+
// Spelled with two ASCII '#' chars; each contributes +1 via ACCIDENTAL_SEMITONE_MAP.
2459+
const infoEDblSharp4 = getPitchInfo("E##4");
2460+
expect(infoEDblSharp4.pitchNumber).toBe(66);
2461+
expect(infoEDblSharp4.octave).toBe(4);
2462+
2463+
// Fx10: F(5) + x(+2) → index 7 (G). (10+1)*12 + 5 + 2 = 139
2464+
const infoFx10 = getPitchInfo("Fx10");
2465+
expect(infoFx10.pitchNumber).toBe(139);
2466+
expect(infoFx10.octave).toBe(10);
2467+
});
2468+
});

js/utils/musicutils.js

Lines changed: 142 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4745,50 +4745,115 @@ function getNote(
47454745
}
47464746

47474747
/**
4748-
* Calculate the pitch number based on the activity, pitch value, and tur parameters.
4749-
* @function
4750-
* @param {Object} activity - The activity object.
4751-
* @param {string|number} np - The pitch value (note name or frequency in Hertz).
4752-
* @param {Object} tur - The tur parameters containing singer information.
4753-
* @returns {number} The calculated pitch number.
4748+
* Maps accidental characters to their semitone offsets.
4749+
* Named distinctly to avoid collision with the ACCIDENTAL_MAP in abc.js
4750+
* (which maps accidentals to ABC notation strings, not semitone offsets).
4751+
* @constant {Object.<string, number>}
47544752
*/
4755-
const _calculate_pitch_number = (activity, np, tur) => {
4756-
let obj;
4757-
if (tur.singer.lastNotePlayed !== null) {
4758-
if (typeof np === "string") {
4759-
obj = noteToObj(np);
4760-
} else {
4761-
// Hertz
4762-
obj = frequencyToPitch(np);
4753+
const ACCIDENTAL_SEMITONE_MAP = {
4754+
"#": 1,
4755+
"♯": 1,
4756+
"b": -1,
4757+
"♭": -1,
4758+
"x": 2, // double-sharp (textual)
4759+
"𝄪": 2, // double-sharp (Unicode)
4760+
"𝄫": -2 // double-flat (Unicode)
4761+
};
4762+
4763+
/**
4764+
* Parses a pitch string into its note name and octave components.
4765+
* Handles single and double accidentals (#, b, ♯, ♭, x, 𝄪, 𝄫).
4766+
* @param {string} str - The pitch string (e.g. "C4", "A#10", "F𝄪5").
4767+
* @returns {Array} An array containing [normalizedNoteName, octave].
4768+
*/
4769+
function _parse_pitch_string(str) {
4770+
const match = str.match(/^([A-Ga-g])([#b𝄪𝄫x]*)(-?\d+)$/u);
4771+
if (match) {
4772+
const baseLetter = match[1].toUpperCase();
4773+
const accidentalStr = match[2];
4774+
const octave = parseInt(match[3], 10);
4775+
4776+
if (!accidentalStr) {
4777+
// No accidentals; return as-is
4778+
return [baseLetter, octave];
47634779
}
4764-
} else if (
4765-
tur.singer.inNoteBlock in tur.singer.notePitches &&
4766-
tur.singer.notePitches[last(tur.singer.inNoteBlock)].length > 0
4767-
) {
4768-
obj = getNote(
4769-
tur.singer.notePitches[last(tur.singer.inNoteBlock)][0],
4770-
tur.singer.noteOctaves[last(tur.singer.inNoteBlock)][0],
4771-
0,
4772-
tur.singer.keySignature,
4773-
tur.singer.movable,
4774-
null,
4775-
activity.errorMsg
4780+
4781+
// Sum up semitone offsets for all accidental characters.
4782+
// 𝄪 and 𝄫 are multi-byte but treated as single code points by spread.
4783+
const accidentalChars = [...accidentalStr];
4784+
const semitoneOffset = accidentalChars.reduce(
4785+
(sum, char) => sum + (ACCIDENTAL_SEMITONE_MAP[char] || 0),
4786+
0
47764787
);
4777-
} else {
4778-
try {
4779-
if (typeof np === "string") {
4780-
obj = noteToObj(np);
4781-
} else {
4782-
// Hertz
4783-
obj = frequencyToPitch(np);
4784-
}
4785-
} catch (e) {
4786-
activity.errorMsg(INVALIDPITCH);
4787-
obj = ["G", 4];
4788+
4789+
// Build a normalized name using canonical SHARP/FLAT symbols.
4790+
let normalizedName;
4791+
if (semitoneOffset === 0) {
4792+
normalizedName = baseLetter;
4793+
} else if (semitoneOffset === 1) {
4794+
normalizedName = baseLetter + SHARP;
4795+
} else if (semitoneOffset === -1) {
4796+
normalizedName = baseLetter + FLAT;
4797+
} else if (semitoneOffset === 2) {
4798+
normalizedName = baseLetter + DOUBLESHARP;
4799+
} else if (semitoneOffset === -2) {
4800+
normalizedName = baseLetter + DOUBLEFLAT;
4801+
} else {
4802+
// For unusual combinations, keep semitone representation as extra sharps/flats
4803+
const sym = semitoneOffset > 0 ? SHARP : FLAT;
4804+
normalizedName = baseLetter + sym.repeat(Math.abs(semitoneOffset));
47884805
}
4806+
4807+
return [normalizedName, octave];
4808+
}
4809+
// Fallback: no octave digit found – normalize accidentals and default octave to 4
4810+
return [str.replace("#", SHARP).replace("b", FLAT), 4];
4811+
}
4812+
4813+
/**
4814+
* Calculates a pitch number from a note name and octave.
4815+
* @param {string} noteName - The name of the note (e.g. "C", "C#").
4816+
* @param {number} octave - The octave number.
4817+
* @returns {number|string} The calculated pitch number or INVALIDPITCH if calculation fails.
4818+
*/
4819+
function _calculate_pitch_number(noteName, octave) {
4820+
if (typeof noteName !== "string") {
4821+
return INVALIDPITCH;
4822+
}
4823+
4824+
let name = noteName.replace("#", SHARP).replace("b", FLAT);
4825+
4826+
// Handle double accidentals (𝄪 / 𝄫) by computing offset directly from
4827+
// the base note letter, since they won't appear in NOTESSHARP/NOTESFLAT.
4828+
if (name.includes(DOUBLESHARP) || name.includes(DOUBLEFLAT)) {
4829+
const offset = name.includes(DOUBLESHARP) ? 2 : -2;
4830+
const baseLetter = name.replace(DOUBLESHARP, "").replace(DOUBLEFLAT, "");
4831+
let baseIndex = NOTESSHARP.indexOf(baseLetter);
4832+
if (baseIndex === -1) baseIndex = NOTESFLAT.indexOf(baseLetter);
4833+
if (baseIndex === -1) return INVALIDPITCH;
4834+
const rawPitch = (parseInt(octave, 10) + 1) * 12 + baseIndex + offset;
4835+
return rawPitch;
4836+
}
4837+
4838+
if (EQUIVALENTSHARPS[name]) {
4839+
name = EQUIVALENTSHARPS[name];
4840+
} else if (EQUIVALENTFLATS[name]) {
4841+
name = EQUIVALENTFLATS[name];
4842+
} else if (EQUIVALENTNATURALS[name]) {
4843+
name = EQUIVALENTNATURALS[name];
4844+
}
4845+
4846+
let pitchIndex = NOTESSHARP.indexOf(name);
4847+
if (pitchIndex === -1) {
4848+
pitchIndex = NOTESFLAT.indexOf(name);
4849+
}
4850+
4851+
if (pitchIndex === -1) {
4852+
return INVALIDPITCH;
47894853
}
4790-
return pitchToNumber(obj[0], obj[1], tur.singer.keySignature) - tur.singer.pitchNumberOffset;
4791-
};
4854+
4855+
return (parseInt(octave, 10) + 1) * 12 + pitchIndex;
4856+
}
47924857

47934858
/**
47944859
* Build the scale based on the given key signature.
@@ -6120,11 +6185,44 @@ const convertFactor = factor => {
61206185
* @param {*} tur - The tur object.
61216186
* @returns {*} The pitch information based on the specified type.
61226187
*/
6123-
const getPitchInfo = (activity, type, currentNote, tur) => {
6124-
// A variety of conversions.
6188+
/**
6189+
* Get pitch information based on the note or pitch provided.
6190+
* @function
6191+
* @param {string|number} noteOrPitch - The note name (e.g. "C4") or a numeric pitch index.
6192+
* @returns {Object|string} If called with one argument, returns { name, octave, pitchNumber }. Otherwise returns legacy values.
6193+
*/
6194+
const getPitchInfo = function (activity, type, currentNote, tur) {
6195+
if (arguments.length === 1) {
6196+
const noteOrPitch = activity;
6197+
let name, octave, pitchNumber;
6198+
6199+
if (typeof noteOrPitch === "number") {
6200+
pitchNumber = noteOrPitch;
6201+
octave = Math.floor(pitchNumber / 12) - 1;
6202+
name = NOTESSHARP[pitchNumber % 12];
6203+
} else if (typeof noteOrPitch === "string") {
6204+
[name, octave] = _parse_pitch_string(noteOrPitch);
6205+
pitchNumber = _calculate_pitch_number(name, octave);
6206+
} else {
6207+
return INVALIDPITCH;
6208+
}
6209+
6210+
if (pitchNumber === INVALIDPITCH) {
6211+
return { name: null, octave: null, pitchNumber: INVALIDPITCH };
6212+
}
6213+
6214+
return {
6215+
name: name.replace(SHARP, "#").replace(FLAT, "b"),
6216+
octave: parseInt(octave, 10),
6217+
pitchNumber: pitchNumber
6218+
};
6219+
}
6220+
6221+
// Legacy behavior for 4 arguments
61256222
let pitch;
61266223
let octave;
61276224
let obj;
6225+
let cents;
61286226
if (Number(currentNote)) {
61296227
// If it is a frequency, convert it to a pitch/octave.
61306228
obj = frequencyToPitch(currentNote);
@@ -6133,8 +6231,7 @@ const getPitchInfo = (activity, type, currentNote, tur) => {
61336231
cents = obj[2];
61346232
} else {
61356233
// Turn the note into pitch and octave.
6136-
pitch = currentNote.substr(0, currentNote.length - 1);
6137-
octave = currentNote[currentNote.length - 1];
6234+
[pitch, octave] = _parse_pitch_string(currentNote);
61386235
}
61396236
// Remap double sharps/double flats.
61406237
if (pitch.includes(DOUBLESHARP)) {
@@ -6173,7 +6270,7 @@ const getPitchInfo = (activity, type, currentNote, tur) => {
61736270
case "solfege class":
61746271
if (type === "solfege class") {
61756272
// Remove sharps and flats.
6176-
pitch = pitch.replace(SHARP).replace(FLAT);
6273+
pitch = pitch.replace(SHARP, "").replace(FLAT, "");
61776274
}
61786275
if (tur.singer.movable === false) {
61796276
return SOLFEGECONVERSIONTABLE[pitch];
@@ -6205,7 +6302,7 @@ const getPitchInfo = (activity, type, currentNote, tur) => {
62056302
(octave - 4) * YSTAFFOCTAVEHEIGHT
62066303
);
62076304
case "pitch number":
6208-
return _calculate_pitch_number(activity, pitch, tur);
6305+
return _calculate_pitch_number(pitch, octave);
62096306
case "pitch in hertz":
62106307
// This function ignores cents.
62116308
return activity.logo.synth._getFrequency(

0 commit comments

Comments
 (0)