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>
286 lines
9.2 KiB
TypeScript
286 lines
9.2 KiB
TypeScript
"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>
|
|
);
|
|
}
|