import { parsePitch } from "@/lib/pitch"; export type HoleState = "open" | "closed" | "half"; export type Fingering = { thumb: HoleState; /** Holes 1, 2, 3, 4, 5, 6a, 6b, 7a, 7b. */ holes: HoleState[]; bell: boolean; }; export type FingeringSystem = "baroque" | "german"; export const RANGE_LABEL = "C5–D7"; export const LOW_MIDI = 72; export const HIGH_MIDI = 98; const HOLE_IDS = ["1", "2", "3", "4", "5", "6a", "6b", "7a", "7b"] as const; type ThumbCode = "0" | "x" | "-"; type Row = [pitch: string, thumb: ThumbCode, holes: string, bell?: boolean]; /** * First-choice Baroque (English) fingerings from the Dolmetsch Online chart * for recorders in C (Brian Blood). Octave 1 is concert C5. * A listed hole is closed. 6a without 6b, or 7a without 7b, is the half-hole. * Thumb 0 is closed, x is pinched, - is open. * Alternates on that chart (second B, all-closed D, and so on) are not encoded. */ const BAROQUE_ROWS: Row[] = [ ["C5", "0", "123456a6b7a7b"], ["C#5", "0", "123456a6b7a"], ["D5", "0", "123456a6b"], ["Eb5", "0", "123456a"], ["E5", "0", "12345"], ["F5", "0", "12346a6b7a7b"], ["F#5", "0", "12356a6b"], ["G5", "0", "123"], ["Ab5", "0", "12456a"], ["A5", "0", "12"], ["Bb5", "0", "134"], ["B5", "0", "1"], ["C6", "0", "2"], ["C#6", "-", "12"], ["D6", "-", "2"], ["Eb6", "-", "23456a6b"], ["E6", "x", "12345"], ["F6", "x", "12346a6b"], ["F#6", "x", "1235"], ["G6", "x", "123"], ["Ab6", "x", "124"], ["A6", "x", "12"], ["Bb6", "x", "1256a6b"], ["B6", "x", "1245"], ["C7", "x", "145"], ["C#7", "x", "1346a6b", true], ["D7", "x", "1346a6b"], ]; /** * German differs on F, F♯, and B♭. * Low F is Dolmetsch’s German fingering: 0 123 4 (holes 5–7 open). * F♯ is thumb, 1, 2, 3, 5, 6, and half-hole 7. * B♭ is thumb, 1, 3, 4, 6. * Those two accidentals are the school German pattern described by * Zach VanderGraaff (Dynamic Music Room). The octave above keeps the same * front holes and uses the Dolmetsch second-octave thumb. */ const GERMAN_ROWS: Row[] = [ ["F5", "0", "1234"], ["F#5", "0", "12356a6b7a"], ["Bb5", "0", "1346a6b"], ["F6", "x", "1234"], ["F#6", "x", "12356a6b7a"], ["Bb6", "x", "1346a6b"], ]; export const CHART_NOTES = [ "C5", "C#5", "D5", "Eb5", "E5", "F5", "F#5", "G5", "Ab5", "A5", "Bb5", "B5", "C6", "C#6", "D6", "Eb6", "E6", "F6", "F#6", "G6", "Ab6", "A6", "Bb6", "B6", "C7", "C#7", "D7", ] as const; function closedSet(spec: string): Set { const closed = new Set(); let index = 0; while (index < spec.length) { const pair = spec.slice(index, index + 2); if (pair === "6a" || pair === "6b" || pair === "7a" || pair === "7b") { closed.add(pair); index += 2; } else { closed.add(spec[index]); index += 1; } } return closed; } function make(thumb: ThumbCode, spec: string, bell = false): Fingering { const closed = closedSet(spec); return { thumb: thumb === "0" ? "closed" : thumb === "x" ? "half" : "open", holes: HOLE_IDS.map((id) => (closed.has(id) ? "closed" : "open")), bell, }; } function table(rows: Row[]): Map { const map = new Map(); for (const [pitch, thumb, holes, bell] of rows) { const parsed = parsePitch(pitch); if (!parsed) throw new Error(`Bad chart pitch ${pitch}`); map.set(parsed.midi, make(thumb, holes, bell ?? false)); } return map; } const BAROQUE = table(BAROQUE_ROWS); const GERMAN = table(GERMAN_ROWS); export function fingeringFor(pitch: string, system: FingeringSystem): Fingering | null { const parsed = parsePitch(pitch); if (!parsed || parsed.midi < LOW_MIDI || parsed.midi > HIGH_MIDI) return null; if (system === "german") { const german = GERMAN.get(parsed.midi); if (german) return german; } return BAROQUE.get(parsed.midi) ?? null; } export function fingeringCode(fingering: Fingering): string { const thumb = fingering.thumb === "closed" ? "0" : fingering.thumb === "half" ? "x" : "-"; const closed = HOLE_IDS.filter((_, index) => fingering.holes[index] === "closed"); const bell = fingering.bell ? " bell" : ""; return `${thumb} ${closed.join(" ")}${bell}`; } export function fingeringMarks(fingering: Fingering): string[] { const marks: string[] = []; if (fingering.thumb === "half") marks.push("pinch"); const [hole1, hole2, hole3, hole4, hole5, hole6a, hole6b, hole7a, hole7b] = fingering.holes; if (hole6a !== hole6b) marks.push("half 6"); if (hole7a !== hole7b) marks.push("half 7"); const lowerRightClosed = hole6a === "closed" || hole6b === "closed" || hole7a === "closed" || hole7b === "closed"; if (hole4 === "closed" && hole5 === "open" && lowerRightClosed) marks.push("fork"); if (hole1 === "closed" && hole2 === "open" && hole3 === "closed") marks.push("fork"); if (fingering.bell) marks.push("cover bell"); return marks; } export function fingeringProse(fingering: Fingering): string { const thumb = fingering.thumb === "closed" ? "thumb closed" : fingering.thumb === "half" ? "thumb pinched" : "thumb open"; const names = ["1", "2", "3", "4", "5", "6a", "6b", "7a", "7b"]; const closed = names.filter((_, index) => fingering.holes[index] === "closed"); const holes = closed.length > 0 ? `holes ${closed.join(" ")} closed` : "no front holes closed"; const bell = fingering.bell ? ", cover the bell" : ""; return `${thumb}, ${holes}${bell}`; } export function germanDiffers(pitch: string): boolean { const baroque = fingeringFor(pitch, "baroque"); const german = fingeringFor(pitch, "german"); if (!baroque || !german) return false; return fingeringCode(baroque) !== fingeringCode(german); }