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>
This commit is contained in:
Cursor Agentandchen committed 2026-10-09 03:35:16 +00:00
1 parent 72c2f509f6
commit 59d65333e3
55 files changed
+17536

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
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);
}