diff --git a/README.md b/README.md
index b6b4ee7..4aaaff8 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,16 @@ PDF to MusicXML uses [Audiveris](https://github.com/Audiveris/audiveris), an ope
Without the JAR, MIDI / MusicXML import still works, only PDF import is unavailable.
+### Optional: PDF export via LilyPond
+
+PDF export uses [LilyPond](https://lilypond.org/) for typesetting. It's not bundled by default (heavy install, ~200 MB).
+
+- **macOS**: `brew install lilypond`
+- **Linux**: `sudo apt install lilypond`
+- **Docker**: add `lilypond` to `backend/Dockerfile`'s `apt-get install` line, then `docker compose build backend`
+
+Restart the backend after installing. Without LilyPond, MIDI and MusicXML export still work; only PDF export is unavailable.
+
## Using the app on iPad / iPhone (same network)
1. In `frontend/vite.config.ts`, add `host: true` to the `server` block (or run `pnpm --filter frontend dev --host`)
@@ -197,7 +207,7 @@ The MIDI-to-MusicXML conversion runs on the backend. Check that the backend is u
No sound from the on-screen piano
-The first interaction (any click) wakes the audio context. If you still hear nothing, open DevTools > Console and look for errors related to `AudioContext` or sample fetches from `danigb.github.io` / `gleitz.github.io`. Some corporate networks block these CDNs.
+The first interaction (any click) wakes the audio context. If you still hear nothing, open DevTools > Console and look for errors related to `AudioContext` or sample fetches from `smpldsnds.github.io` / `gleitz.github.io`. Some corporate networks block these CDNs.
diff --git a/backend/routers/export_routes.py b/backend/routers/export_routes.py
index 10555f7..11c96cd 100644
--- a/backend/routers/export_routes.py
+++ b/backend/routers/export_routes.py
@@ -64,9 +64,25 @@ async def export_pdf(req: ExportPdfRequest, background_tasks: BackgroundTasks):
)
except HTTPException:
raise
- except Exception:
+ except Exception as e:
+ # Surface actionable runtime errors (e.g. missing LilyPond) to the client
+ # so the user knows what to install instead of seeing a generic message.
+ # NOTE: musicxml_to_pdf runs in a ProcessPoolExecutor; exceptions get
+ # round-tripped through pickle, so we match by message substring rather
+ # than exception type (which can be wrapped / mutated on the way back).
logger.exception("export_pdf failed")
- raise HTTPException(status_code=500, detail="PDF export failed. Please try again.")
+ msg = str(e)
+ if "lilypond" in msg.lower():
+ raise HTTPException(
+ status_code=503,
+ detail=(
+ "PDF export needs LilyPond on the backend (not bundled by default).\n\n"
+ "Quick install: brew install lilypond (macOS) or apt install lilypond "
+ "(Linux). For Docker, see the README section 'Optional: PDF export via "
+ "LilyPond'. MIDI and MusicXML export work without it."
+ ),
+ )
+ raise HTTPException(status_code=500, detail=f"PDF export failed: {msg or 'Unknown error'}")
@router.post("/mp3")
diff --git a/frontend/package.json b/frontend/package.json
index 7af2bf1..f2a110f 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -42,6 +42,7 @@
"tailwindcss": "^4.3.0",
"typescript": "~6.0.3",
"vite": "^8.0.12",
+ "vite-plugin-pwa": "^1.3.0",
"vitest": "^4.1.5"
}
}
diff --git a/frontend/src/components/PianoKeyboard/PianoKeyboardView.tsx b/frontend/src/components/PianoKeyboard/PianoKeyboardView.tsx
index d1228d7..c94da69 100644
--- a/frontend/src/components/PianoKeyboard/PianoKeyboardView.tsx
+++ b/frontend/src/components/PianoKeyboard/PianoKeyboardView.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef } from "react";
+import { useEffect, useRef, useState } from "react";
import bus from "../../engine/EventBus";
import { PianoKeyboard } from "./PianoKeyboard";
import { useAppStore, COLOR_PRESET_VALUES } from "../../store/useAppStore";
@@ -11,6 +11,9 @@ export function PianoKeyboardView() {
const canvasRef = useRef(null);
const containerRef = useRef(null);
const kbRef = useRef(null);
+ /** Flips true once PixiJS has finished drawing the keys; we fade the canvas in
+ * and the skeleton out at that point. Avoids the brief "black bar" flash. */
+ const [pianoReady, setPianoReady] = useState(false);
const { settings } = useAppStore();
useEffect(() => {
@@ -19,9 +22,10 @@ export function PianoKeyboardView() {
const container = containerRef.current;
const width = container.clientWidth || window.innerWidth;
- const themeColors = settings.colorTheme === "custom"
- ? settings.customColors
- : COLOR_PRESET_VALUES[settings.colorTheme];
+ const themeColors =
+ settings.colorTheme === "custom"
+ ? settings.customColors
+ : COLOR_PRESET_VALUES[settings.colorTheme];
const kb = new PianoKeyboard(canvasRef.current, {
width,
@@ -41,7 +45,9 @@ export function PianoKeyboardView() {
},
});
kbRef.current = kb;
- kb.init(canvasRef.current);
+ // init() is async (awaits PIXI.Application.init() internally).
+ // Flip the ready flag once it resolves so the skeleton can fade out.
+ kb.init(canvasRef.current).then(() => setPianoReady(true));
const onNoteOn = (note: NoteEvent) => kb.noteOn(note, "playback");
const onNoteOff = (note: NoteEvent) => kb.noteOff(note, "playback");
@@ -86,9 +92,10 @@ export function PianoKeyboardView() {
showFingering: settings.showFingering,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
- customColors: settings.colorTheme === "custom"
- ? settings.customColors
- : COLOR_PRESET_VALUES[settings.colorTheme],
+ customColors:
+ settings.colorTheme === "custom"
+ ? settings.customColors
+ : COLOR_PRESET_VALUES[settings.colorTheme],
});
}, [
settings.noteLabelMode,
@@ -110,9 +117,43 @@ export function PianoKeyboardView() {
// matches the look of a real piano's wooden frame regardless of app theme.
background: "#0d0d10",
flexShrink: 0,
+ position: "relative",
+ overflow: "hidden",
}}
>
-
+ {/* Skeleton: matches the falling-notes pane above so the piano slot looks
+ like one continuous panel until PixiJS finishes drawing the keys.
+ We replicate the falling-notes background EXACTLY (base color + the same
+ purple radial gradient), but mirror the gradient anchor to `50% 0%` so the
+ brightest tint is at the TOP of the skeleton - meeting the brightest tint
+ at the BOTTOM of the falling-notes pane. Result: no color seam at the boundary.
+ Note: only this overlay's color matches the falling-notes pane - the piano
+ container's `#0d0d10` background (the dark "wooden frame" that shows through
+ key gaps after init) is intentionally NOT changed. */}
+
+
);
}
diff --git a/frontend/src/components/Transport/TransportControls.tsx b/frontend/src/components/Transport/TransportControls.tsx
index e716fe3..19ac168 100644
--- a/frontend/src/components/Transport/TransportControls.tsx
+++ b/frontend/src/components/Transport/TransportControls.tsx
@@ -32,12 +32,20 @@ export function TransportControls() {
loadPdfFile,
isLoadingDocument,
loadingMessage,
+ exportMidi,
+ exportMusicXml,
+ exportPdf,
+ isExportingPdf,
} = useAppStore();
const [speedOpen, setSpeedOpen] = useState(false);
const [speedRect, setSpeedRect] = useState<{ bottom: number; right: number } | null>(null);
const speedContainerRef = useRef(null);
+ const [exportOpen, setExportOpen] = useState(false);
+ const [exportRect, setExportRect] = useState<{ bottom: number; left: number } | null>(null);
+ const exportContainerRef = useRef(null);
+
// When opening, capture fixed position so popover escapes the overflow:auto container
const toggleSpeed = () => {
if (!speedOpen && speedContainerRef.current) {
@@ -47,6 +55,24 @@ export function TransportControls() {
setSpeedOpen((v) => !v);
};
+ const toggleExport = () => {
+ if (!exportOpen && exportContainerRef.current) {
+ const r = exportContainerRef.current.getBoundingClientRect();
+ setExportRect({ bottom: window.innerHeight - r.top + 8, left: r.left });
+ }
+ setExportOpen((v) => !v);
+ };
+
+ // Close export popover on outside click
+ useEffect(() => {
+ if (!exportOpen) return;
+ const handler = (e: MouseEvent) => {
+ if (!exportContainerRef.current?.contains(e.target as Node)) setExportOpen(false);
+ };
+ document.addEventListener("mousedown", handler);
+ return () => document.removeEventListener("mousedown", handler);
+ }, [exportOpen]);
+
// Close speed popover on outside click
useEffect(() => {
if (!speedOpen) return;
@@ -227,6 +253,98 @@ export function TransportControls() {
/>
+ {/* ── Zone 1b: Export dropdown - visually paired with Import (same accent style) ── */}
+
+
+ {exportOpen && exportRect && (
+
+ {
+ exportMidi();
+ setExportOpen(false);
+ }}
+ disabledReason="No MIDI data in this document"
+ />
+ {
+ exportMusicXml();
+ setExportOpen(false);
+ }}
+ disabledReason="No MusicXML data in this document"
+ />
+ {
+ void exportPdf();
+ setExportOpen(false);
+ }}
+ disabledReason={
+ !doc?.musicXml ? "No MusicXML data in this document" : "Already rendering a PDF"
+ }
+ />
+