Preserve editorial fingerings on MusicXML/PDF import, fix .xml drag-drop regex

This commit is contained in:
Fabricio Giglio
2026-05-11 13:30:17 +02:00
parent bd8fd38099
commit def7ee0cc4
4 changed files with 318 additions and 103 deletions
@@ -1100,8 +1100,8 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
onClick={() => void generateFingering()} onClick={() => void generateFingering()}
disabled={isGeneratingFingering} disabled={isGeneratingFingering}
title={ title={
doc.fingeringVersion === "auto" doc.fingeringVersion !== "none"
? "Regenerate finger numbers using the Parncutt algorithm" ? "Refine finger numbers using the Parncutt algorithm. Existing fingerings are kept as anchors and the algorithm only fills in gaps - useful for editorial scores (e.g. Henle) where only hard passages are annotated."
: "Generate finger number hints using the Parncutt algorithm" : "Generate finger number hints using the Parncutt algorithm"
} }
style={{ style={{
@@ -1121,7 +1121,7 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
> >
{isGeneratingFingering {isGeneratingFingering
? "Generating…" ? "Generating…"
: doc.fingeringVersion === "auto" : doc.fingeringVersion !== "none"
? "Regenerate" ? "Regenerate"
: "Generate"} : "Generate"}
</button> </button>
@@ -4,18 +4,34 @@ import { syncEngine } from "../../engine/SyncEngine";
function formatTime(s: number): string { function formatTime(s: number): string {
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
return `${m}:${Math.floor(s % 60).toString().padStart(2, "0")}`; return `${m}:${Math.floor(s % 60)
.toString()
.padStart(2, "0")}`;
} }
export function TransportControls() { export function TransportControls() {
const { const {
status, currentSeconds, tempoMultiplier, status,
loopStart, loopEnd, waitMode, currentSeconds,
tempoMultiplier,
loopStart,
loopEnd,
waitMode,
document: doc, document: doc,
play, pause, stop, seek, play,
setTempoMultiplier, setLoopPoints, setWaitMode, setMetronome, pause,
settings, loadMidiFile, loadMusicXmlFile, loadPdfFile, stop,
isLoadingDocument, loadingMessage, seek,
setTempoMultiplier,
setLoopPoints,
setWaitMode,
setMetronome,
settings,
loadMidiFile,
loadMusicXmlFile,
loadPdfFile,
isLoadingDocument,
loadingMessage,
} = useAppStore(); } = useAppStore();
const [speedOpen, setSpeedOpen] = useState(false); const [speedOpen, setSpeedOpen] = useState(false);
@@ -42,17 +58,17 @@ export function TransportControls() {
}, [speedOpen]); }, [speedOpen]);
const openFile = (file: File) => { const openFile = (file: File) => {
if (file.name.match(/\.midi?$/i)) loadMidiFile(file); if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file); else if (file.name.match(/\.(xml|mxl)$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
}; };
const total = doc?.totalDuration ?? 0; const total = doc?.totalDuration ?? 0;
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0; const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
const loopActive = loopStart !== null && loopEnd !== null; const loopActive = loopStart !== null && loopEnd !== null;
const isPlaying = status === "playing"; const isPlaying = status === "playing";
const isPaused = status === "paused"; const isPaused = status === "paused";
const isStopped = status === "stopped"; const isStopped = status === "stopped";
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => { const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!total) return; if (!total) return;
@@ -62,24 +78,34 @@ export function TransportControls() {
const toggleHand = (hand: "left" | "right") => { const toggleHand = (hand: "left" | "right") => {
const next = new Set(settings.activeHands); const next = new Set(settings.activeHands);
if (next.has(hand)) { if (next.size > 1) next.delete(hand); } if (next.has(hand)) {
else next.add(hand); if (next.size > 1) next.delete(hand);
} else next.add(hand);
useAppStore.getState().setActiveHands(next); useAppStore.getState().setActiveHands(next);
}; };
const startLoop = () => { const startLoop = () => {
const start = currentSeconds; const start = currentSeconds;
const end = Math.min(total, start + 30); const end = Math.min(total, start + 30);
setLoopPoints(start, end > start ? end : total); setLoopPoints(start, end > start ? end : total);
}; };
const clearLoop = () => setLoopPoints(null, null); const clearLoop = () => setLoopPoints(null, null);
const setLoopA = () => { if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd); }; const setLoopA = () => {
const setLoopB = () => { if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1)); }; if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd);
};
const setLoopB = () => {
if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1));
};
return ( return (
<div <div
className="flex flex-col gap-1.5 pt-2 pb-2 flex-shrink-0 select-none" className="flex flex-col gap-1.5 pt-2 pb-2 flex-shrink-0 select-none"
style={{ borderTop: "1px solid var(--color-border)", background: "var(--color-surface)", paddingLeft: 12, paddingRight: 12 }} style={{
borderTop: "1px solid var(--color-border)",
background: "var(--color-surface)",
paddingLeft: 12,
paddingRight: 12,
}}
> >
{/* ── Progress bar ──────────────────────────────────────────────────── */} {/* ── Progress bar ──────────────────────────────────────────────────── */}
<div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}> <div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}>
@@ -102,13 +128,16 @@ export function TransportControls() {
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
> >
<div className="relative rounded-full" style={{ height: 6, background: "var(--color-surface-3)" }}> <div
className="relative rounded-full"
style={{ height: 6, background: "var(--color-surface-3)" }}
>
{/* Loop region */} {/* Loop region */}
{loopActive && total > 0 && ( {loopActive && total > 0 && (
<div <div
className="absolute top-0 h-full" className="absolute top-0 h-full"
style={{ style={{
left: `${(loopStart! / total) * 100}%`, left: `${(loopStart! / total) * 100}%`,
width: `${((loopEnd! - loopStart!) / total) * 100}%`, width: `${((loopEnd! - loopStart!) / total) * 100}%`,
background: "var(--color-accent)", background: "var(--color-accent)",
opacity: 0.25, opacity: 0.25,
@@ -148,36 +177,53 @@ export function TransportControls() {
</div> </div>
{/* ── Controls row ──────────────────────────────────────────────────── */} {/* ── Controls row ──────────────────────────────────────────────────── */}
<div className="flex items-center min-w-0" style={{ gap: 6, overflowX: "auto", scrollbarWidth: "none" }}> <div
className="flex items-center min-w-0"
style={{ gap: 6, overflowX: "auto", scrollbarWidth: "none" }}
>
{/* ── Zone 1: File import - accent-styled to stand out ── */} {/* ── Zone 1: File import - accent-styled to stand out ── */}
<label <label
title="Open MIDI, MusicXML, or PDF sheet music" title="Open MIDI, MusicXML, or PDF sheet music"
aria-label="Import MIDI, MusicXML, or PDF file" aria-label="Import MIDI, MusicXML, or PDF file"
style={{ style={{
display: "inline-flex", alignItems: "center", gap: 5, display: "inline-flex",
height: 32, padding: "0 12px", alignItems: "center",
borderRadius: 7, cursor: "pointer", gap: 5,
fontSize: 12, fontWeight: 600, fontFamily: "inherit", height: 32,
padding: "0 12px",
borderRadius: 7,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
flexShrink: 0, flexShrink: 0,
background: "rgba(147,51,234,0.14)", background: "rgba(147,51,234,0.14)",
border: "1px solid rgba(147,51,234,0.35)", border: "1px solid rgba(147,51,234,0.35)",
color: "var(--color-accent-text)", color: "var(--color-accent-text)",
transition: "background 0.12s", transition: "background 0.12s",
}} }}
onMouseEnter={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)")} onMouseEnter={(e) =>
onMouseLeave={(e) => ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)")} ((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)")
}
onMouseLeave={(e) =>
((e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)")
}
> >
{isLoadingDocument ? <SpinnerIcon /> : <UploadIcon />} {isLoadingDocument ? <SpinnerIcon /> : <UploadIcon />}
{isLoadingDocument && loadingMessage {isLoadingDocument && loadingMessage ? (
? <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span> <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
: <span>Import</span> ) : (
} <span>Import</span>
)}
<input <input
type="file" type="file"
accept=".mid,.midi,.xml,.mxl,.pdf" accept=".mid,.midi,.xml,.mxl,.pdf"
className="hidden" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) openFile(f); e.target.value = ""; }} onChange={(e) => {
const f = e.target.files?.[0];
if (f) openFile(f);
e.target.value = "";
}}
/> />
</label> </label>
@@ -185,10 +231,20 @@ export function TransportControls() {
{/* ── Zone 2: Playback controls ───────────────────────── */} {/* ── Zone 2: Playback controls ───────────────────────── */}
<div className="flex items-center shrink-0" style={{ gap: 4 }}> <div className="flex items-center shrink-0" style={{ gap: 4 }}>
<Btn onClick={stop} title="Stop and return to start" aria-label="Stop" disabled={isStopped}> <Btn
onClick={stop}
title="Stop and return to start"
aria-label="Stop"
disabled={isStopped}
>
<StopIcon /> <StopIcon />
</Btn> </Btn>
<Btn onClick={() => syncEngine.skipBy(-10)} title="Skip back 10 seconds" aria-label="Skip back 10 seconds" disabled={!doc}> <Btn
onClick={() => syncEngine.skipBy(-10)}
title="Skip back 10 seconds"
aria-label="Skip back 10 seconds"
disabled={!doc}
>
<SkipBackIcon /> <SkipBackIcon />
</Btn> </Btn>
<Btn <Btn
@@ -200,7 +256,12 @@ export function TransportControls() {
> >
{isPlaying ? <PauseIcon /> : <PlayIcon />} {isPlaying ? <PauseIcon /> : <PlayIcon />}
</Btn> </Btn>
<Btn onClick={() => syncEngine.skipBy(10)} title="Skip forward 10 seconds" aria-label="Skip forward 10 seconds" disabled={!doc}> <Btn
onClick={() => syncEngine.skipBy(10)}
title="Skip forward 10 seconds"
aria-label="Skip forward 10 seconds"
disabled={!doc}
>
<SkipFwdIcon /> <SkipFwdIcon />
</Btn> </Btn>
</div> </div>
@@ -221,21 +282,28 @@ export function TransportControls() {
aria-pressed={active} aria-pressed={active}
className="rounded text-xs font-bold transition-all" className="rounded text-xs font-bold transition-all"
style={{ style={{
width: 28, height: 28, width: 28,
height: 28,
cursor: "pointer", cursor: "pointer",
fontFamily: "inherit", fontFamily: "inherit",
...(active ? { ...(active
background: isLeft ? "var(--color-left-hand-subtle)" : "var(--color-right-hand-subtle)", ? {
color: isLeft ? "var(--color-left-hand-text)" : "var(--color-right-hand-text)", background: isLeft
border: isLeft ? "var(--color-left-hand-subtle)"
? "1px solid var(--color-left-hand-subtle-border)" : "var(--color-right-hand-subtle)",
: "1px solid var(--color-right-hand-subtle-border)", color: isLeft
} : { ? "var(--color-left-hand-text)"
background: "var(--color-surface-2)", : "var(--color-right-hand-text)",
color: "var(--color-text-muted)", border: isLeft
border: "1px solid var(--color-border)", ? "1px solid var(--color-left-hand-subtle-border)"
opacity: 0.5, : "1px solid var(--color-right-hand-subtle-border)",
}), }
: {
background: "var(--color-surface-2)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
opacity: 0.5,
}),
}} }}
> >
{isLeft ? "L" : "R"} {isLeft ? "L" : "R"}
@@ -249,8 +317,13 @@ export function TransportControls() {
{/* ── Zone 5: Practice tools ──────────────────────────── */} {/* ── Zone 5: Practice tools ──────────────────────────── */}
<div className="flex items-center shrink-0" style={{ gap: 4 }}> <div className="flex items-center shrink-0" style={{ gap: 4 }}>
<ChipBtn
<ChipBtn active={waitMode} onClick={() => setWaitMode(!waitMode)} title="Pause until you play the correct note" aria-label="Wait mode" aria-pressed={waitMode}> active={waitMode}
onClick={() => setWaitMode(!waitMode)}
title="Pause until you play the correct note"
aria-label="Wait mode"
aria-pressed={waitMode}
>
<WaitIcon /> Wait <WaitIcon /> Wait
</ChipBtn> </ChipBtn>
@@ -271,12 +344,15 @@ export function TransportControls() {
title="Set loop start (A) to current position" title="Set loop start (A) to current position"
aria-label="Set loop start to current position" aria-label="Set loop start to current position"
style={{ style={{
height: 28, padding: "0 8px", height: 28,
padding: "0 8px",
background: "transparent", background: "transparent",
border: "none", border: "none",
borderRight: "1px solid var(--color-border)", borderRight: "1px solid var(--color-border)",
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)", color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
fontSize: 11, fontWeight: 700, fontFamily: "inherit", fontSize: 11,
fontWeight: 700,
fontFamily: "inherit",
cursor: loopActive ? "pointer" : "default", cursor: loopActive ? "pointer" : "default",
opacity: loopActive ? 1 : 0.5, opacity: loopActive ? 1 : 0.5,
transition: "all 0.12s", transition: "all 0.12s",
@@ -293,13 +369,18 @@ export function TransportControls() {
aria-label={loopActive ? "Clear loop" : "Start loop"} aria-label={loopActive ? "Clear loop" : "Start loop"}
aria-pressed={loopActive} aria-pressed={loopActive}
style={{ style={{
height: 28, padding: "0 9px", height: 28,
padding: "0 9px",
background: loopActive ? "var(--color-accent-subtle)" : "transparent", background: loopActive ? "var(--color-accent-subtle)" : "transparent",
border: "none", border: "none",
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)", color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
fontSize: 11, fontWeight: 500, fontFamily: "inherit", fontSize: 11,
fontWeight: 500,
fontFamily: "inherit",
cursor: doc ? "pointer" : "default", cursor: doc ? "pointer" : "default",
display: "flex", alignItems: "center", gap: 4, display: "flex",
alignItems: "center",
gap: 4,
transition: "all 0.12s", transition: "all 0.12s",
}} }}
> >
@@ -314,12 +395,15 @@ export function TransportControls() {
title="Set loop end (B) to current position" title="Set loop end (B) to current position"
aria-label="Set loop end to current position" aria-label="Set loop end to current position"
style={{ style={{
height: 28, padding: "0 8px", height: 28,
padding: "0 8px",
background: "transparent", background: "transparent",
border: "none", border: "none",
borderLeft: "1px solid var(--color-border)", borderLeft: "1px solid var(--color-border)",
color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)", color: loopActive ? "var(--color-accent-text)" : "var(--color-text-muted)",
fontSize: 11, fontWeight: 700, fontFamily: "inherit", fontSize: 11,
fontWeight: 700,
fontFamily: "inherit",
cursor: loopActive ? "pointer" : "default", cursor: loopActive ? "pointer" : "default",
opacity: loopActive ? 1 : 0.5, opacity: loopActive ? 1 : 0.5,
transition: "all 0.12s", transition: "all 0.12s",
@@ -367,20 +451,47 @@ export function TransportControls() {
padding: "12px 14px", padding: "12px 14px",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}> <div
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text)" }}>Playback Speed</span> style={{
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--color-accent-text)", fontVariantNumeric: "tabular-nums" }}> display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 10,
}}
>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text)" }}>
Playback Speed
</span>
<span
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--color-accent-text)",
fontVariantNumeric: "tabular-nums",
}}
>
{Math.round(tempoMultiplier * 100)}% {Math.round(tempoMultiplier * 100)}%
</span> </span>
</div> </div>
<input <input
type="range" min={25} max={200} step={5} type="range"
min={25}
max={200}
step={5}
value={Math.round(tempoMultiplier * 100)} value={Math.round(tempoMultiplier * 100)}
onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)} onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)}
aria-label="Playback speed" aria-label="Playback speed"
style={{ width: "100%", accentColor: "var(--color-accent)" }} style={{ width: "100%", accentColor: "var(--color-accent)" }}
/> />
<div style={{ display: "flex", justifyContent: "space-between", fontSize: 10, color: "var(--color-text-muted)", marginTop: 4 }}> <div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 10,
color: "var(--color-text-muted)",
marginTop: 4,
}}
>
<span>25%</span> <span>25%</span>
<span>100%</span> <span>100%</span>
<span>200%</span> <span>200%</span>
@@ -389,12 +500,15 @@ export function TransportControls() {
<button <button
onClick={() => setTempoMultiplier(1.0)} onClick={() => setTempoMultiplier(1.0)}
style={{ style={{
marginTop: 10, width: "100%", marginTop: 10,
width: "100%",
background: "var(--color-surface-2)", background: "var(--color-surface-2)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
borderRadius: 6, borderRadius: 6,
color: "var(--color-accent-text)", color: "var(--color-accent-text)",
fontSize: 11, fontWeight: 600, fontFamily: "inherit", fontSize: 11,
fontWeight: 600,
fontFamily: "inherit",
padding: "5px 0", padding: "5px 0",
cursor: "pointer", cursor: "pointer",
}} }}
@@ -405,7 +519,6 @@ export function TransportControls() {
</div> </div>
)} )}
</div> </div>
</div> </div>
{/* Waiting status pill */} {/* Waiting status pill */}
@@ -419,7 +532,6 @@ export function TransportControls() {
● Waiting… ● Waiting…
</span> </span>
)} )}
</div> </div>
</div> </div>
); );
@@ -427,7 +539,14 @@ export function TransportControls() {
// ── Sub-components ──────────────────────────────────────────────────────────── // ── Sub-components ────────────────────────────────────────────────────────────
function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disabled }: { function Btn({
onClick,
title,
"aria-label": ariaLabel,
children,
primary,
disabled,
}: {
onClick: () => void; onClick: () => void;
title?: string; title?: string;
"aria-label"?: string; "aria-label"?: string;
@@ -448,7 +567,15 @@ function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disab
); );
} }
function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-pressed": ariaPressed, children, disabled }: { function ChipBtn({
active,
onClick,
title,
"aria-label": ariaLabel,
"aria-pressed": ariaPressed,
children,
disabled,
}: {
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
title?: string; title?: string;
@@ -466,12 +593,21 @@ function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-presse
disabled={disabled} disabled={disabled}
className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0" className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0"
style={{ style={{
height: 28, padding: "0 8px", height: 28,
padding: "0 8px",
cursor: disabled ? "default" : "pointer", cursor: disabled ? "default" : "pointer",
fontFamily: "inherit", fontFamily: "inherit",
...(active ...(active
? { background: "var(--color-accent-subtle)", color: "var(--color-accent-text)", border: "1px solid var(--color-accent-subtle-border)" } ? {
: { background: "var(--color-surface-2)", color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }), background: "var(--color-accent-subtle)",
color: "var(--color-accent-text)",
border: "1px solid var(--color-accent-subtle-border)",
}
: {
background: "var(--color-surface-2)",
color: "var(--color-text-muted)",
border: "1px solid var(--color-border)",
}),
}} }}
> >
{children} {children}
@@ -480,53 +616,97 @@ function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-presse
} }
function Divider() { function Divider() {
return <div className="shrink-0" style={{ width: 1, height: 20, background: "var(--color-border)", margin: "0 2px" }} />; return (
<div
className="shrink-0"
style={{ width: 1, height: 20, background: "var(--color-border)", margin: "0 2px" }}
/>
);
} }
// ── Icons ───────────────────────────────────────────────────────────────────── // ── Icons ─────────────────────────────────────────────────────────────────────
const PlayIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>; const PlayIcon = () => (
const PauseIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>; <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
const StopIcon = () => <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="1"/></svg>; <path d="M8 5v14l11-7z" />
</svg>
);
const PauseIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
);
const StopIcon = () => (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<rect x="5" y="5" width="14" height="14" rx="1" />
</svg>
);
const SkipBackIcon = () => ( const SkipBackIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"> <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/> <path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z" />
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text> <text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">
10
</text>
</svg> </svg>
); );
const SkipFwdIcon = () => ( const SkipFwdIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor"> <svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z"/> <path d="M18 13c0 3.31-2.69 6-6 6s-6-2.69-6-6 2.69-6 6-6v4l5-5-5-5v4c-4.42 0-8 3.58-8 8s3.58 8 8 8 8-3.58 8-8h-2z" />
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text> <text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">
10
</text>
</svg>
);
const UploadIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" />
</svg>
);
const SpinnerIcon = () => (
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
style={{ animation: "spin 1s linear infinite" }}
>
<path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z" />
</svg> </svg>
); );
const UploadIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z"/></svg>;
const SpinnerIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style={{ animation: "spin 1s linear infinite" }}><path d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8z"/></svg>;
const WaitIcon = () => ( const WaitIcon = () => (
<svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor"> <svg width="10" height="10" viewBox="0 0 20 20" fill="currentColor">
<rect x="3.5" y="10" width="13" height="7" rx="1.5"/> <rect x="3.5" y="10" width="13" height="7" rx="1.5" />
<rect x="4" y="2.5" width="2.5" height="9" rx="1.25"/> <rect x="4" y="2.5" width="2.5" height="9" rx="1.25" />
<rect x="7.5" y="1" width="2.5" height="10.5" rx="1.25"/> <rect x="7.5" y="1" width="2.5" height="10.5" rx="1.25" />
<rect x="11" y="1.5" width="2.5" height="10" rx="1.25"/> <rect x="11" y="1.5" width="2.5" height="10" rx="1.25" />
<rect x="14" y="4" width="2.5" height="7.5" rx="1.25"/> <rect x="14" y="4" width="2.5" height="7.5" rx="1.25" />
</svg> </svg>
); );
const LoopIcon = () => ( const LoopIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"> <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.68 14.72 20 13.41 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.32 9.28 4 10.59 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/> <path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.68 14.72 20 13.41 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.32 9.28 4 10.59 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z" />
</svg> </svg>
); );
const SpeedIcon = () => ( const SpeedIcon = () => (
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"> <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 2v11h3v9l7-12h-4l4-8z"/> <path d="M7 2v11h3v9l7-12h-4l4-8z" />
</svg> </svg>
); );
const MetronomeIcon = () => ( const MetronomeIcon = () => (
<svg width="10" height="10" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M10 2L2 18h16L10 2z"/> width="10"
<line x1="10" y1="15" x2="13.5" y2="6"/> height="10"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 2L2 18h16L10 2z" />
<line x1="10" y1="15" x2="13.5" y2="6" />
</svg> </svg>
); );
+39 -4
View File
@@ -372,8 +372,24 @@ export const useAppStore = create<AppState>((set, get) => {
doc.musicXml = musicxml; doc.musicXml = musicxml;
doc.sourceType = "musicxml"; doc.sourceType = "musicxml";
set({ document: { ...doc, midiBuffer: bufferForDoc } }); // Preserve any printed fingerings Audiveris detected in the PDF
await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc }); // (uncommon but possible for well-scanned editorial scores).
let manualFingeringsFound = false;
if (musicxml) {
const notesWithFingerings = applyFingeringFromXml(doc.notes, musicxml);
if (notesWithFingerings.some((n) => n.finger !== null)) {
doc.notes = notesWithFingerings;
doc.fingeringVersion = "manual";
manualFingeringsFound = true;
}
}
const finalDoc = { ...doc, midiBuffer: bufferForDoc };
set((s) => ({
document: finalDoc,
settings: manualFingeringsFound ? { ...s.settings, showFingering: true } : s.settings,
}));
await syncEngine.loadDocument(finalDoc);
} catch (err) { } catch (err) {
set({ loadError: String(err) }); set({ loadError: String(err) });
} finally { } finally {
@@ -689,10 +705,29 @@ async function fetchMidiFromXml(
updatedDoc.musicXml = doc.musicXml; updatedDoc.musicXml = doc.musicXml;
updatedDoc.sourceType = "musicxml"; updatedDoc.sourceType = "musicxml";
// Preserve editorial fingerings from the source MusicXML when present
// (e.g. publisher scores from Henle, Barenreiter). Without this the
// round-trip through MIDI would strip them, since MIDI has no fingering
// field. Same matching helper as Generate - any standard <fingering>
// elements with <staff> hints are honored.
let manualFingeringsFound = false;
if (doc.musicXml) {
const notesWithFingerings = applyFingeringFromXml(updatedDoc.notes, doc.musicXml);
if (notesWithFingerings.some((n) => n.finger !== null)) {
updatedDoc.notes = notesWithFingerings;
updatedDoc.fingeringVersion = "manual";
manualFingeringsFound = true;
}
}
const { document: currentDoc } = useAppStore.getState(); const { document: currentDoc } = useAppStore.getState();
if (currentDoc?.id === id) { if (currentDoc?.id === id) {
useAppStore.setState({ document: { ...updatedDoc, midiBuffer: bufferForDoc } }); const finalDoc = { ...updatedDoc, midiBuffer: bufferForDoc };
await syncEngine.loadDocument({ ...updatedDoc, midiBuffer: bufferForDoc }); useAppStore.setState((s) => ({
document: finalDoc,
settings: manualFingeringsFound ? { ...s.settings, showFingering: true } : s.settings,
}));
await syncEngine.loadDocument(finalDoc);
} }
} }
+1 -1
View File
@@ -115,7 +115,7 @@ export function PracticeView() {
const loadFile = useCallback( const loadFile = useCallback(
(file: File) => { (file: File) => {
if (file.name.match(/\.midi?$/i)) loadMidiFile(file); if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file); else if (file.name.match(/\.(xml|mxl)$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file); else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
}, },
[loadMidiFile, loadMusicXmlFile, loadPdfFile] [loadMidiFile, loadMusicXmlFile, loadPdfFile]