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

+125
View File
@@ -0,0 +1,125 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { fingeringCode, fingeringFor, fingeringMarks, CHART_NOTES } from "@/lib/fingering";
import { parseMusicXml } from "@/lib/musicxml";
import { parsePitch } from "@/lib/pitch";
import { barStatus, expectedQuarters } from "@/lib/score";
import { odeToJoy, twinkle } from "@/lib/samples";
import { formatTextTab } from "@/lib/text-tab";
const BAROQUE: Record<string, string> = {
C5: "0 1 2 3 4 5 6a 6b 7a 7b",
"C#5": "0 1 2 3 4 5 6a 6b 7a",
D5: "0 1 2 3 4 5 6a 6b",
Eb5: "0 1 2 3 4 5 6a",
E5: "0 1 2 3 4 5",
F5: "0 1 2 3 4 6a 6b 7a 7b",
"F#5": "0 1 2 3 5 6a 6b",
G5: "0 1 2 3",
Ab5: "0 1 2 4 5 6a",
A5: "0 1 2",
Bb5: "0 1 3 4",
B5: "0 1",
C6: "0 2",
"C#6": "- 1 2",
D6: "- 2",
Eb6: "- 2 3 4 5 6a 6b",
E6: "x 1 2 3 4 5",
F6: "x 1 2 3 4 6a 6b",
"F#6": "x 1 2 3 5",
G6: "x 1 2 3",
Ab6: "x 1 2 4",
A6: "x 1 2",
Bb6: "x 1 2 5 6a 6b",
B6: "x 1 2 4 5",
C7: "x 1 4 5",
"C#7": "x 1 3 4 6a 6b bell",
D7: "x 1 3 4 6a 6b",
};
describe("soprano chart", () => {
it("covers every semitone from C5 through D7 and no others", () => {
const midis = CHART_NOTES.map((note) => parsePitch(note)?.midi);
expect(midis).toEqual(Array.from({ length: 27 }, (_, index) => 72 + index));
});
it("matches the Dolmetsch first-choice Baroque rows", () => {
for (const [note, code] of Object.entries(BAROQUE)) {
const fingering = fingeringFor(note, "baroque");
expect(fingering, note).not.toBeNull();
expect(fingeringCode(fingering!), note).toBe(code);
}
});
it("marks fork, half-holes, pinch, and bell without guessing outside the range", () => {
expect(fingeringMarks(fingeringFor("F5", "baroque")!)).toContain("fork");
expect(fingeringMarks(fingeringFor("Eb5", "baroque")!)).toContain("half 6");
expect(fingeringMarks(fingeringFor("C#5", "baroque")!)).toContain("half 7");
expect(fingeringMarks(fingeringFor("E6", "baroque")!)).toContain("pinch");
expect(fingeringMarks(fingeringFor("C#7", "baroque")!)).toContain("cover bell");
expect(fingeringFor("B4", "baroque")).toBeNull();
expect(fingeringFor("E7", "baroque")).toBeNull();
expect(fingeringCode(fingeringFor("Db5", "baroque")!)).toBe(BAROQUE["C#5"]);
});
it("applies only the cited German changes", () => {
expect(fingeringCode(fingeringFor("F5", "german")!)).toBe("0 1 2 3 4");
expect(fingeringCode(fingeringFor("F#5", "german")!)).toBe("0 1 2 3 5 6a 6b 7a");
expect(fingeringCode(fingeringFor("Bb5", "german")!)).toBe("0 1 3 4 6a 6b");
expect(fingeringCode(fingeringFor("F6", "german")!)).toBe("x 1 2 3 4");
expect(fingeringCode(fingeringFor("G5", "german")!)).toBe(fingeringCode(fingeringFor("G5", "baroque")!));
expect(fingeringCode(fingeringFor("E6", "german")!)).toBe(fingeringCode(fingeringFor("E6", "baroque")!));
});
});
describe("sample melodies", () => {
it("keeps Twinkle in 4/4 with forked F and full bars", () => {
expect(twinkle.measures).toHaveLength(12);
expect(twinkle.measures[0].events.map((event) => event.pitch)).toEqual(["C5", "C5", "G5", "G5"]);
expect(twinkle.measures[2].events[0].pitch).toBe("F5");
for (const measure of twinkle.measures) {
expect(barStatus(measure, twinkle.timeSignature).ok).toBe(true);
}
expect(formatTextTab(twinkle, "baroque")).toContain("0 1 2 3 4 6a 6b 7a 7b");
});
it("keeps Ode to Joy dotted rhythm inside the bar", () => {
expect(expectedQuarters(odeToJoy.timeSignature)).toBe(4);
const bar = odeToJoy.measures[3];
expect(bar.events.map((event) => event.label)).toEqual(["dotted quarter", "eighth", "half"]);
expect(bar.events.reduce((sum, event) => sum + event.quarters, 0)).toBe(4);
for (const measure of odeToJoy.measures) {
expect(barStatus(measure, odeToJoy.timeSignature).ok).toBe(true);
}
});
});
describe("lead-line MusicXML", () => {
const xml = readFileSync(path.join(__dirname, "fixtures", "lead.musicxml"), "utf8");
const parsed = parseMusicXml(xml);
it("keeps timing, the top note, and the top staff", () => {
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.score.title).toBe("Probe");
expect(parsed.score.timeSignature).toEqual({ beats: 4, beatType: 4 });
const [first, second, third, fourth] = parsed.score.measures;
expect(first.events.map((event) => [event.kind, event.pitch, event.label])).toEqual([
["note", "E5", "quarter"],
["rest", null, "quarter"],
["note", "G5", "dotted quarter"],
["note", "A4", "eighth"],
]);
expect(second.events.map((event) => [event.pitch, event.label, event.tied])).toEqual([
["E5", "half", false],
[null, "half", false],
]);
expect(third.events.map((event) => event.pitch)).toEqual(["G5"]);
expect(fourth.events.map((event) => event.pitch)).toEqual(["C6", "D6"]);
expect(parsed.warnings.join(" ")).toMatch(/first part/);
expect(parsed.warnings.join(" ")).toMatch(/lower staves/);
expect(parsed.warnings.join(" ")).toMatch(/highest note/);
expect(parsed.warnings.join(" ")).toMatch(/Grace notes/);
});
});
+113
View File
@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<score-partwise version="4.0">
<work><work-title>Probe</work-title></work>
<part-list>
<score-part id="P1"><part-name>Lead</part-name></score-part>
<score-part id="P2"><part-name>Other</part-name></score-part>
</part-list>
<part id="P1">
<measure number="1">
<attributes>
<divisions>2</divisions>
<time><beats>4</beats><beat-type>4</beat-type></time>
</attributes>
<note>
<pitch><step>E</step><octave>5</octave></pitch>
<duration>2</duration>
<type>quarter</type>
</note>
<note>
<rest/>
<duration>2</duration>
<type>quarter</type>
</note>
<note>
<pitch><step>G</step><octave>5</octave></pitch>
<duration>3</duration>
<type>quarter</type>
<dot/>
</note>
<note>
<pitch><step>A</step><octave>4</octave></pitch>
<duration>1</duration>
<type>eighth</type>
</note>
</measure>
<measure number="2">
<note>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>4</duration>
<type>half</type>
<tie type="start"/>
</note>
<note>
<chord/>
<pitch><step>E</step><octave>5</octave></pitch>
<duration>4</duration>
<type>half</type>
</note>
<note>
<rest/>
<duration>4</duration>
<type>half</type>
</note>
</measure>
<measure number="3">
<note>
<grace/>
<pitch><step>F</step><octave>5</octave></pitch>
<type>eighth</type>
</note>
<note>
<pitch><step>G</step><octave>5</octave></pitch>
<duration>2</duration>
<voice>1</voice>
<staff>1</staff>
<type>quarter</type>
</note>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>2</duration>
<voice>2</voice>
<staff>2</staff>
<type>quarter</type>
</note>
</measure>
<measure number="4">
<note>
<pitch><step>C</step><octave>6</octave></pitch>
<duration>2</duration>
<voice>1</voice>
<type>quarter</type>
</note>
<note>
<pitch><step>D</step><octave>6</octave></pitch>
<duration>2</duration>
<voice>1</voice>
<type>quarter</type>
</note>
<backup><duration>4</duration></backup>
<note>
<pitch><step>C</step><octave>5</octave></pitch>
<duration>2</duration>
<voice>2</voice>
<type>quarter</type>
</note>
<note>
<pitch><step>D</step><octave>5</octave></pitch>
<duration>2</duration>
<voice>2</voice>
<type>quarter</type>
</note>
</measure>
</part>
<part id="P2">
<measure number="1">
<note>
<pitch><step>C</step><octave>3</octave></pitch>
<duration>2</duration>
<type>quarter</type>
</note>
</measure>
</part>
</score-partwise>