The app turns a lead line into Baroque fingering tabs with rhythm kept, ships Twinkle and Ode to Joy, and reads photos locally with oemer when the bundled Python is present. Co-authored-by: catpersonisme <catpersonisme@gmail.com>
196 lines
5.6 KiB
TypeScript
196 lines
5.6 KiB
TypeScript
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<string> {
|
||
const closed = new Set<string>();
|
||
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<number, Fingering> {
|
||
const map = new Map<number, Fingering>();
|
||
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);
|
||
}
|