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:
55 files changed
+17536
No files matched your search
@@ -0,0 +1,285 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DURATION_PRESETS, quartersMatch } from "@/lib/duration";
|
||||
import { CHART_NOTES } from "@/lib/fingering";
|
||||
import {
|
||||
barStatus,
|
||||
newId,
|
||||
parseSignature,
|
||||
signatureKey,
|
||||
TIME_SIGNATURES,
|
||||
type Score,
|
||||
type ScoreEvent,
|
||||
} from "@/lib/score";
|
||||
import { ChevronDown, ChevronUp, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
const fieldClass =
|
||||
"h-10 min-w-0 rounded-lg border border-input bg-card px-2 text-sm text-foreground outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||
|
||||
export function NoteEditor({
|
||||
score,
|
||||
onChange,
|
||||
}: {
|
||||
score: Score;
|
||||
onChange: (score: Score) => void;
|
||||
}) {
|
||||
const signatures = TIME_SIGNATURES.some(
|
||||
(item) =>
|
||||
item.beats === score.timeSignature.beats && item.beatType === score.timeSignature.beatType,
|
||||
)
|
||||
? TIME_SIGNATURES
|
||||
: [score.timeSignature, ...TIME_SIGNATURES];
|
||||
|
||||
function updateEvent(measureId: string, eventId: string, patch: Partial<ScoreEvent>) {
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.map((measure) =>
|
||||
measure.id !== measureId
|
||||
? measure
|
||||
: {
|
||||
...measure,
|
||||
events: measure.events.map((event) =>
|
||||
event.id === eventId ? { ...event, ...patch } : event,
|
||||
),
|
||||
},
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function move(measureIndex: number, eventIndex: number, direction: -1 | 1) {
|
||||
const measures = score.measures.map((measure) => ({ ...measure, events: [...measure.events] }));
|
||||
const measure = measures[measureIndex];
|
||||
const target = eventIndex + direction;
|
||||
if (target >= 0 && target < measure.events.length) {
|
||||
const [item] = measure.events.splice(eventIndex, 1);
|
||||
measure.events.splice(target, 0, item);
|
||||
} else if (direction === -1 && measureIndex > 0) {
|
||||
const [item] = measure.events.splice(eventIndex, 1);
|
||||
measures[measureIndex - 1].events.push(item);
|
||||
} else if (direction === 1 && measureIndex < measures.length - 1) {
|
||||
const [item] = measure.events.splice(eventIndex, 1);
|
||||
measures[measureIndex + 1].events.unshift(item);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
onChange({ ...score, measures });
|
||||
}
|
||||
|
||||
function add(measureId: string, kind: "note" | "rest") {
|
||||
const measure = score.measures.find((item) => item.id === measureId);
|
||||
const previous = [...(measure?.events ?? [])].reverse().find((event) => event.kind === "note");
|
||||
const event: ScoreEvent = {
|
||||
id: newId("e"),
|
||||
kind,
|
||||
pitch: kind === "note" ? (previous?.pitch ?? "C5") : null,
|
||||
quarters: 1,
|
||||
label: "quarter",
|
||||
tied: false,
|
||||
};
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.map((item) =>
|
||||
item.id === measureId ? { ...item, events: [...item.events, event] } : item,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="score-title">Title</Label>
|
||||
<Input
|
||||
id="score-title"
|
||||
value={score.title}
|
||||
onChange={(event) => onChange({ ...score, title: event.target.value })}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="time-signature">Time signature</Label>
|
||||
<Select
|
||||
value={signatureKey(score.timeSignature)}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
onChange({ ...score, timeSignature: parseSignature(value) });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="time-signature" className="w-full data-[size=default]:h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{signatures.map((signature) => (
|
||||
<SelectItem key={signatureKey(signature)} value={signatureKey(signature)}>
|
||||
{signatureKey(signature)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{score.measures.map((measure, measureIndex) => {
|
||||
const status = barStatus(measure, score.timeSignature);
|
||||
return (
|
||||
<fieldset key={measure.id} className="space-y-3 rounded-2xl bg-card p-3 ring-1 ring-foreground/10">
|
||||
<legend className="px-1 font-medium">Bar {measureIndex + 1}</legend>
|
||||
<p className={status.ok ? "text-xs text-muted-foreground" : "text-xs text-[#8a4b12]"}>
|
||||
{status.text}
|
||||
</p>
|
||||
{measure.events.map((event, eventIndex) => (
|
||||
<EventRow
|
||||
key={event.id}
|
||||
event={event}
|
||||
onPitch={(value) => {
|
||||
if (value === "rest") {
|
||||
updateEvent(measure.id, event.id, { kind: "rest", pitch: null });
|
||||
} else {
|
||||
updateEvent(measure.id, event.id, { kind: "note", pitch: value });
|
||||
}
|
||||
}}
|
||||
onDuration={(quarters) => {
|
||||
const preset = DURATION_PRESETS.find((item) => quartersMatch(item.quarters, quarters));
|
||||
updateEvent(measure.id, event.id, {
|
||||
quarters,
|
||||
label: preset?.label ?? event.label,
|
||||
});
|
||||
}}
|
||||
onDelete={() =>
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.map((item) =>
|
||||
item.id === measure.id
|
||||
? { ...item, events: item.events.filter((row) => row.id !== event.id) }
|
||||
: item,
|
||||
),
|
||||
})
|
||||
}
|
||||
onUp={() => move(measureIndex, eventIndex, -1)}
|
||||
onDown={() => move(measureIndex, eventIndex, 1)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "note")}>
|
||||
<Plus />
|
||||
Note
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => add(measure.id, "rest")}>
|
||||
<Plus />
|
||||
Rest
|
||||
</Button>
|
||||
{score.measures.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...score,
|
||||
measures: score.measures.filter((item) => item.id !== measure.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete bar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...score,
|
||||
measures: [...score.measures, { id: newId("m"), events: [] }],
|
||||
})
|
||||
}
|
||||
>
|
||||
<Plus />
|
||||
Add bar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({
|
||||
event,
|
||||
onPitch,
|
||||
onDuration,
|
||||
onDelete,
|
||||
onUp,
|
||||
onDown,
|
||||
}: {
|
||||
event: ScoreEvent;
|
||||
onPitch: (value: string) => void;
|
||||
onDuration: (quarters: number) => void;
|
||||
onDelete: () => void;
|
||||
onUp: () => void;
|
||||
onDown: () => void;
|
||||
}) {
|
||||
const pitches = [...CHART_NOTES] as string[];
|
||||
if (event.pitch && !pitches.includes(event.pitch)) pitches.unshift(event.pitch);
|
||||
const durations = [...DURATION_PRESETS];
|
||||
if (!durations.some((item) => quartersMatch(item.quarters, event.quarters))) {
|
||||
durations.push({ quarters: event.quarters, label: event.label });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="sr-only" htmlFor={`${event.id}-pitch`}>
|
||||
Pitch or rest
|
||||
</label>
|
||||
<select
|
||||
id={`${event.id}-pitch`}
|
||||
className={`${fieldClass} w-[7.5rem]`}
|
||||
value={event.kind === "rest" ? "rest" : (event.pitch ?? "C5")}
|
||||
onChange={(change) => onPitch(change.target.value)}
|
||||
>
|
||||
<option value="rest">rest</option>
|
||||
{pitches.map((pitch) => (
|
||||
<option key={pitch} value={pitch}>
|
||||
{pitch}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="sr-only" htmlFor={`${event.id}-duration`}>
|
||||
Duration
|
||||
</label>
|
||||
<select
|
||||
id={`${event.id}-duration`}
|
||||
className={`${fieldClass} w-[9.5rem]`}
|
||||
value={String(event.quarters)}
|
||||
onChange={(change) => onDuration(Number(change.target.value))}
|
||||
>
|
||||
{durations.map((duration) => (
|
||||
<option key={`${duration.label}-${duration.quarters}`} value={String(duration.quarters)}>
|
||||
{duration.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="ml-auto flex gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onUp} aria-label="Move earlier">
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onDown} aria-label="Move later">
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onDelete} aria-label="Delete note">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user