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

+97
View File
@@ -0,0 +1,97 @@
import { formatQuarterLengths, quartersMatch } from "@/lib/duration";
export type TimeSignature = {
beats: number;
beatType: number;
};
export type ScoreEvent = {
id: string;
kind: "note" | "rest";
pitch: string | null;
quarters: number;
label: string;
tied: boolean;
};
export type Measure = {
id: string;
events: ScoreEvent[];
};
export type ScoreOrigin = "sample" | "upload" | "blank";
export type Score = {
title: string;
attribution: string;
timeSignature: TimeSignature;
measures: Measure[];
origin: ScoreOrigin;
};
export const TIME_SIGNATURES: TimeSignature[] = [
{ beats: 2, beatType: 4 },
{ beats: 3, beatType: 4 },
{ beats: 4, beatType: 4 },
{ beats: 6, beatType: 8 },
{ beats: 3, beatType: 8 },
{ beats: 2, beatType: 2 },
];
export function newId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
}
export function signatureKey(timeSignature: TimeSignature): string {
return `${timeSignature.beats}/${timeSignature.beatType}`;
}
export function parseSignature(key: string): TimeSignature {
const [beats, beatType] = key.split("/").map(Number);
return { beats, beatType };
}
export function expectedQuarters(timeSignature: TimeSignature): number {
return (timeSignature.beats * 4) / timeSignature.beatType;
}
export function barStatus(
measure: Measure,
timeSignature: TimeSignature,
): { ok: boolean; text: string } {
const sum = measure.events.reduce((total, event) => total + event.quarters, 0);
const expected = expectedQuarters(timeSignature);
const signature = signatureKey(timeSignature);
if (quartersMatch(sum, expected)) {
return { ok: true, text: `${signature} · ${formatQuarterLengths(sum)}` };
}
return {
ok: false,
text: `${formatQuarterLengths(sum)} in this bar; ${signature} holds ${formatQuarterLengths(expected)}.`,
};
}
export function startBeats(events: ScoreEvent[], timeSignature: TimeSignature): number[] {
const quartersPerBeat = 4 / timeSignature.beatType;
let cursor = 0;
return events.map((event) => {
const beat = cursor / quartersPerBeat + 1;
cursor += event.quarters;
return beat;
});
}
export function formatBeat(beat: number): string {
const rounded = Math.round(beat * 100) / 100;
return Number.isInteger(rounded) ? String(rounded) : String(rounded);
}
export function blankScore(): Score {
return {
title: "Untitled line",
attribution: "Hand-entered lead line. Add notes, then read the tabs.",
timeSignature: { beats: 4, beatType: 4 },
origin: "blank",
measures: [{ id: newId("m"), events: [] }],
};
}