Preserve editorial fingerings on MusicXML/PDF import, fix .xml drag-drop regex
This commit is contained in:
@@ -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);
|
||||||
@@ -43,7 +59,7 @@ export function TransportControls() {
|
|||||||
|
|
||||||
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,8 +78,9 @@ 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,13 +90,22 @@ export function TransportControls() {
|
|||||||
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,7 +128,10 @@ 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
|
||||||
@@ -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,16 +282,23 @@ 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
|
||||||
|
? "var(--color-left-hand-subtle)"
|
||||||
|
: "var(--color-right-hand-subtle)",
|
||||||
|
color: isLeft
|
||||||
|
? "var(--color-left-hand-text)"
|
||||||
|
: "var(--color-right-hand-text)",
|
||||||
border: isLeft
|
border: isLeft
|
||||||
? "1px solid var(--color-left-hand-subtle-border)"
|
? "1px solid var(--color-left-hand-subtle-border)"
|
||||||
: "1px solid var(--color-right-hand-subtle-border)",
|
: "1px solid var(--color-right-hand-subtle-border)",
|
||||||
} : {
|
}
|
||||||
|
: {
|
||||||
background: "var(--color-surface-2)",
|
background: "var(--color-surface-2)",
|
||||||
color: "var(--color-text-muted)",
|
color: "var(--color-text-muted)",
|
||||||
border: "1px solid var(--color-border)",
|
border: "1px solid var(--color-border)",
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user