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()}
disabled={isGeneratingFingering}
title={
doc.fingeringVersion === "auto"
? "Regenerate finger numbers using the Parncutt algorithm"
doc.fingeringVersion !== "none"
? "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"
}
style={{
@@ -1121,7 +1121,7 @@ export function SettingsPanel({ open, onClose }: { open: boolean; onClose: () =>
>
{isGeneratingFingering
? "Generating…"
: doc.fingeringVersion === "auto"
: doc.fingeringVersion !== "none"
? "Regenerate"
: "Generate"}
</button>
@@ -4,18 +4,34 @@ import { syncEngine } from "../../engine/SyncEngine";
function formatTime(s: number): string {
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() {
const {
status, currentSeconds, tempoMultiplier,
loopStart, loopEnd, waitMode,
status,
currentSeconds,
tempoMultiplier,
loopStart,
loopEnd,
waitMode,
document: doc,
play, pause, stop, seek,
setTempoMultiplier, setLoopPoints, setWaitMode, setMetronome,
settings, loadMidiFile, loadMusicXmlFile, loadPdfFile,
isLoadingDocument, loadingMessage,
play,
pause,
stop,
seek,
setTempoMultiplier,
setLoopPoints,
setWaitMode,
setMetronome,
settings,
loadMidiFile,
loadMusicXmlFile,
loadPdfFile,
isLoadingDocument,
loadingMessage,
} = useAppStore();
const [speedOpen, setSpeedOpen] = useState(false);
@@ -42,17 +58,17 @@ export function TransportControls() {
}, [speedOpen]);
const openFile = (file: File) => {
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.mxl?$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
if (file.name.match(/\.midi?$/i)) loadMidiFile(file);
else if (file.name.match(/\.(xml|mxl)$/i)) loadMusicXmlFile(file);
else if (file.name.match(/\.pdf$/i)) loadPdfFile(file);
};
const total = doc?.totalDuration ?? 0;
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
const total = doc?.totalDuration ?? 0;
const progress = total > 0 ? Math.min(1, currentSeconds / total) : 0;
const loopActive = loopStart !== null && loopEnd !== null;
const isPlaying = status === "playing";
const isPaused = status === "paused";
const isStopped = status === "stopped";
const isPlaying = status === "playing";
const isPaused = status === "paused";
const isStopped = status === "stopped";
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!total) return;
@@ -62,24 +78,34 @@ export function TransportControls() {
const toggleHand = (hand: "left" | "right") => {
const next = new Set(settings.activeHands);
if (next.has(hand)) { if (next.size > 1) next.delete(hand); }
else next.add(hand);
if (next.has(hand)) {
if (next.size > 1) next.delete(hand);
} else next.add(hand);
useAppStore.getState().setActiveHands(next);
};
const startLoop = () => {
const start = currentSeconds;
const end = Math.min(total, start + 30);
const end = Math.min(total, start + 30);
setLoopPoints(start, end > start ? end : total);
};
const clearLoop = () => setLoopPoints(null, null);
const setLoopA = () => { if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd); };
const setLoopB = () => { if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1)); };
const setLoopA = () => {
if (loopEnd !== null) setLoopPoints(Math.min(currentSeconds, loopEnd - 1), loopEnd);
};
const setLoopB = () => {
if (loopStart !== null) setLoopPoints(loopStart, Math.max(currentSeconds, loopStart + 1));
};
return (
<div
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 ──────────────────────────────────────────────────── */}
<div className="flex items-center gap-2" style={{ marginLeft: -12, marginRight: -12 }}>
@@ -102,13 +128,16 @@ export function TransportControls() {
aria-valuemin={0}
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 */}
{loopActive && total > 0 && (
<div
className="absolute top-0 h-full"
style={{
left: `${(loopStart! / total) * 100}%`,
left: `${(loopStart! / total) * 100}%`,
width: `${((loopEnd! - loopStart!) / total) * 100}%`,
background: "var(--color-accent)",
opacity: 0.25,
@@ -148,36 +177,53 @@ export function TransportControls() {
</div>
{/* ── 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 ── */}
<label
title="Open MIDI, MusicXML, or PDF sheet music"
aria-label="Import MIDI, MusicXML, or PDF file"
style={{
display: "inline-flex", alignItems: "center", gap: 5,
height: 32, padding: "0 12px",
borderRadius: 7, cursor: "pointer",
fontSize: 12, fontWeight: 600, fontFamily: "inherit",
display: "inline-flex",
alignItems: "center",
gap: 5,
height: 32,
padding: "0 12px",
borderRadius: 7,
cursor: "pointer",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
flexShrink: 0,
background: "rgba(147,51,234,0.14)",
border: "1px solid rgba(147,51,234,0.35)",
color: "var(--color-accent-text)",
transition: "background 0.12s",
}}
onMouseEnter={(e) => ((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)")}
onMouseEnter={(e) =>
((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 && loadingMessage
? <span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
: <span>Import</span>
}
{isLoadingDocument && loadingMessage ? (
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{loadingMessage}</span>
) : (
<span>Import</span>
)}
<input
type="file"
accept=".mid,.midi,.xml,.mxl,.pdf"
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>
@@ -185,10 +231,20 @@ export function TransportControls() {
{/* ── Zone 2: Playback controls ───────────────────────── */}
<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 />
</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 />
</Btn>
<Btn
@@ -200,7 +256,12 @@ export function TransportControls() {
>
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</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 />
</Btn>
</div>
@@ -221,21 +282,28 @@ export function TransportControls() {
aria-pressed={active}
className="rounded text-xs font-bold transition-all"
style={{
width: 28, height: 28,
width: 28,
height: 28,
cursor: "pointer",
fontFamily: "inherit",
...(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)",
border: isLeft
? "1px solid var(--color-left-hand-subtle-border)"
: "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,
}),
...(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)",
border: isLeft
? "1px solid var(--color-left-hand-subtle-border)"
: "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"}
@@ -249,8 +317,13 @@ export function TransportControls() {
{/* ── Zone 5: Practice tools ──────────────────────────── */}
<div className="flex items-center shrink-0" style={{ gap: 4 }}>
<ChipBtn active={waitMode} onClick={() => setWaitMode(!waitMode)} title="Pause until you play the correct note" aria-label="Wait mode" aria-pressed={waitMode}>
<ChipBtn
active={waitMode}
onClick={() => setWaitMode(!waitMode)}
title="Pause until you play the correct note"
aria-label="Wait mode"
aria-pressed={waitMode}
>
<WaitIcon /> Wait
</ChipBtn>
@@ -271,12 +344,15 @@ export function TransportControls() {
title="Set loop start (A) to current position"
aria-label="Set loop start to current position"
style={{
height: 28, padding: "0 8px",
height: 28,
padding: "0 8px",
background: "transparent",
border: "none",
borderRight: "1px solid var(--color-border)",
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",
opacity: loopActive ? 1 : 0.5,
transition: "all 0.12s",
@@ -293,13 +369,18 @@ export function TransportControls() {
aria-label={loopActive ? "Clear loop" : "Start loop"}
aria-pressed={loopActive}
style={{
height: 28, padding: "0 9px",
height: 28,
padding: "0 9px",
background: loopActive ? "var(--color-accent-subtle)" : "transparent",
border: "none",
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",
display: "flex", alignItems: "center", gap: 4,
display: "flex",
alignItems: "center",
gap: 4,
transition: "all 0.12s",
}}
>
@@ -314,12 +395,15 @@ export function TransportControls() {
title="Set loop end (B) to current position"
aria-label="Set loop end to current position"
style={{
height: 28, padding: "0 8px",
height: 28,
padding: "0 8px",
background: "transparent",
border: "none",
borderLeft: "1px solid var(--color-border)",
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",
opacity: loopActive ? 1 : 0.5,
transition: "all 0.12s",
@@ -367,20 +451,47 @@ export function TransportControls() {
padding: "12px 14px",
}}
>
<div style={{ 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" }}>
<div
style={{
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)}%
</span>
</div>
<input
type="range" min={25} max={200} step={5}
type="range"
min={25}
max={200}
step={5}
value={Math.round(tempoMultiplier * 100)}
onChange={(e) => setTempoMultiplier(Number(e.target.value) / 100)}
aria-label="Playback speed"
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>100%</span>
<span>200%</span>
@@ -389,12 +500,15 @@ export function TransportControls() {
<button
onClick={() => setTempoMultiplier(1.0)}
style={{
marginTop: 10, width: "100%",
marginTop: 10,
width: "100%",
background: "var(--color-surface-2)",
border: "1px solid var(--color-border)",
borderRadius: 6,
color: "var(--color-accent-text)",
fontSize: 11, fontWeight: 600, fontFamily: "inherit",
fontSize: 11,
fontWeight: 600,
fontFamily: "inherit",
padding: "5px 0",
cursor: "pointer",
}}
@@ -405,7 +519,6 @@ export function TransportControls() {
</div>
)}
</div>
</div>
{/* Waiting status pill */}
@@ -419,7 +532,6 @@ export function TransportControls() {
● Waiting…
</span>
)}
</div>
</div>
);
@@ -427,7 +539,14 @@ export function TransportControls() {
// ── Sub-components ────────────────────────────────────────────────────────────
function Btn({ onClick, title, "aria-label": ariaLabel, children, primary, disabled }: {
function Btn({
onClick,
title,
"aria-label": ariaLabel,
children,
primary,
disabled,
}: {
onClick: () => void;
title?: 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;
onClick: () => void;
title?: string;
@@ -466,12 +593,21 @@ function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-presse
disabled={disabled}
className="flex items-center gap-1 rounded text-xs font-medium transition-all shrink-0"
style={{
height: 28, padding: "0 8px",
height: 28,
padding: "0 8px",
cursor: disabled ? "default" : "pointer",
fontFamily: "inherit",
...(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}
@@ -480,53 +616,97 @@ function ChipBtn({ active, onClick, title, "aria-label": ariaLabel, "aria-presse
}
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 ─────────────────────────────────────────────────────────────────────
const PlayIcon = () => <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><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 PlayIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<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 = () => (
<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"/>
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
<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>
</svg>
);
const SkipFwdIcon = () => (
<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"/>
<text x="12" y="16" fontSize="7" fontWeight="bold" textAnchor="middle" fontFamily="inherit">10</text>
<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>
</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 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 = () => (
<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="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="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="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="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="14" y="4" width="2.5" height="7.5" rx="1.25" />
</svg>
);
const LoopIcon = () => (
<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>
);
const SpeedIcon = () => (
<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>
);
const MetronomeIcon = () => (
<svg width="10" 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
width="10"
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>
);
+39 -4
View File
@@ -372,8 +372,24 @@ export const useAppStore = create<AppState>((set, get) => {
doc.musicXml = musicxml;
doc.sourceType = "musicxml";
set({ document: { ...doc, midiBuffer: bufferForDoc } });
await syncEngine.loadDocument({ ...doc, midiBuffer: bufferForDoc });
// Preserve any printed fingerings Audiveris detected in the PDF
// (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) {
set({ loadError: String(err) });
} finally {
@@ -689,10 +705,29 @@ async function fetchMidiFromXml(
updatedDoc.musicXml = doc.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();
if (currentDoc?.id === id) {
useAppStore.setState({ document: { ...updatedDoc, midiBuffer: bufferForDoc } });
await syncEngine.loadDocument({ ...updatedDoc, midiBuffer: bufferForDoc });
const finalDoc = { ...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(
(file: 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);
},
[loadMidiFile, loadMusicXmlFile, loadPdfFile]