Files
Recorder-Tabber/src/lib/fingering.ts
T
Cursor Agentandchen 59d65333e3 Add a soprano recorder tab converter as a Linux AppImage.
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>
2026-10-09 03:35:16 +00:00

196 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}