From def7ee0cc4fe4a33d1f4dc2696182a87c5774cbb Mon Sep 17 00:00:00 2001 From: Fabricio Giglio <58532944+gigliof@users.noreply.github.com> Date: Mon, 11 May 2026 13:30:17 +0200 Subject: [PATCH] Preserve editorial fingerings on MusicXML/PDF import, fix .xml drag-drop regex --- .../components/Transport/SettingsPanel.tsx | 6 +- .../Transport/TransportControls.tsx | 370 +++++++++++++----- frontend/src/store/useAppStore.ts | 43 +- frontend/src/views/PracticeView.tsx | 2 +- 4 files changed, 318 insertions(+), 103 deletions(-) diff --git a/frontend/src/components/Transport/SettingsPanel.tsx b/frontend/src/components/Transport/SettingsPanel.tsx index b784eb6..7d0e5c6 100644 --- a/frontend/src/components/Transport/SettingsPanel.tsx +++ b/frontend/src/components/Transport/SettingsPanel.tsx @@ -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"} diff --git a/frontend/src/components/Transport/TransportControls.tsx b/frontend/src/components/Transport/TransportControls.tsx index 64ceebf..e716fe3 100644 --- a/frontend/src/components/Transport/TransportControls.tsx +++ b/frontend/src/components/Transport/TransportControls.tsx @@ -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) => { 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 (
{/* ── Progress bar ──────────────────────────────────────────────────── */}
@@ -102,13 +128,16 @@ export function TransportControls() { aria-valuemin={0} aria-valuemax={100} > -
+
{/* Loop region */} {loopActive && total > 0 && (
{/* ── Controls row ──────────────────────────────────────────────────── */} -
- +
{/* ── Zone 1: File import - accent-styled to stand out ── */} @@ -185,10 +231,20 @@ export function TransportControls() { {/* ── Zone 2: Playback controls ───────────────────────── */}
- + - syncEngine.skipBy(-10)} title="Skip back 10 seconds" aria-label="Skip back 10 seconds" disabled={!doc}> + syncEngine.skipBy(-10)} + title="Skip back 10 seconds" + aria-label="Skip back 10 seconds" + disabled={!doc} + > {isPlaying ? : } - syncEngine.skipBy(10)} title="Skip forward 10 seconds" aria-label="Skip forward 10 seconds" disabled={!doc}> + syncEngine.skipBy(10)} + title="Skip forward 10 seconds" + aria-label="Skip forward 10 seconds" + disabled={!doc} + >
@@ -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 ──────────────────────────── */}
- - setWaitMode(!waitMode)} title="Pause until you play the correct note" aria-label="Wait mode" aria-pressed={waitMode}> + setWaitMode(!waitMode)} + title="Pause until you play the correct note" + aria-label="Wait mode" + aria-pressed={waitMode} + > Wait @@ -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", }} > -
- Playback Speed - +
+ + Playback Speed + + {Math.round(tempoMultiplier * 100)}%
setTempoMultiplier(Number(e.target.value) / 100)} aria-label="Playback speed" style={{ width: "100%", accentColor: "var(--color-accent)" }} /> -
+
25% 100% 200% @@ -389,12 +500,15 @@ export function TransportControls() {
)}
-
{/* Waiting status pill */} @@ -419,7 +532,6 @@ export function TransportControls() { ● Waiting… )} -
); @@ -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
; + return ( +
+ ); } // ── Icons ───────────────────────────────────────────────────────────────────── -const PlayIcon = () => ; -const PauseIcon = () => ; -const StopIcon = () => ; +const PlayIcon = () => ( + + + +); +const PauseIcon = () => ( + + + +); +const StopIcon = () => ( + + + +); const SkipBackIcon = () => ( - - 10 + + + 10 + ); const SkipFwdIcon = () => ( - - 10 + + + 10 + + +); +const UploadIcon = () => ( + + + +); +const SpinnerIcon = () => ( + + ); -const UploadIcon = () => ; -const SpinnerIcon = () => ; const WaitIcon = () => ( - - - - - + + + + + ); const LoopIcon = () => ( - + ); const SpeedIcon = () => ( - + ); const MetronomeIcon = () => ( - - - + + + ); diff --git a/frontend/src/store/useAppStore.ts b/frontend/src/store/useAppStore.ts index 70b56e4..7954303 100644 --- a/frontend/src/store/useAppStore.ts +++ b/frontend/src/store/useAppStore.ts @@ -372,8 +372,24 @@ export const useAppStore = create((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 + // elements with 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); } } diff --git a/frontend/src/views/PracticeView.tsx b/frontend/src/views/PracticeView.tsx index af35ab5..39ce1f0 100644 --- a/frontend/src/views/PracticeView.tsx +++ b/frontend/src/views/PracticeView.tsx @@ -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]