Add installable PWA, score export (MIDI/MusicXML/PDF), and persist theme across reloads

This commit is contained in:
Fabricio Giglio
2026-05-15 13:10:18 +02:00
parent efe5d4c8de
commit bb9bd46c57
8 changed files with 3522 additions and 29 deletions
+11 -1
View File
@@ -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
<details>
<summary><strong>No sound from the on-screen piano</strong></summary>
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.
</details>
+18 -2
View File
@@ -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")
+1
View File
@@ -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"
}
}
@@ -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<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const kbRef = useRef<PianoKeyboard | null>(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,7 +22,8 @@ export function PianoKeyboardView() {
const container = containerRef.current;
const width = container.clientWidth || window.innerWidth;
const themeColors = settings.colorTheme === "custom"
const themeColors =
settings.colorTheme === "custom"
? settings.customColors
: COLOR_PRESET_VALUES[settings.colorTheme];
@@ -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,7 +92,8 @@ export function PianoKeyboardView() {
showFingering: settings.showFingering,
showHandColors: settings.showHandColors,
useFlats: settings.useFlats,
customColors: settings.colorTheme === "custom"
customColors:
settings.colorTheme === "custom"
? settings.customColors
: COLOR_PRESET_VALUES[settings.colorTheme],
});
@@ -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",
}}
>
<canvas ref={canvasRef} style={{ display: "block", width: "100%", height: "100%" }} />
{/* 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. */}
<div
aria-hidden
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(ellipse at 50% 0%, rgba(147,51,234,0.07) 0%, transparent 65%), " +
(settings.theme === "dark" ? "#141428" : "#eef1fa"),
opacity: pianoReady ? 0 : 1,
transition: "opacity 0.2s ease-out",
pointerEvents: "none",
}}
/>
<canvas
ref={canvasRef}
style={{
display: "block",
width: "100%",
height: "100%",
position: "relative",
opacity: pianoReady ? 1 : 0,
transition: "opacity 0.15s ease-in",
}}
/>
</div>
);
}
@@ -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<HTMLDivElement>(null);
const [exportOpen, setExportOpen] = useState(false);
const [exportRect, setExportRect] = useState<{ bottom: number; left: number } | null>(null);
const exportContainerRef = useRef<HTMLDivElement>(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() {
/>
</label>
{/* ── Zone 1b: Export dropdown - visually paired with Import (same accent style) ── */}
<div ref={exportContainerRef} style={{ position: "relative", flexShrink: 0 }}>
<button
onClick={toggleExport}
title={
doc ? "Save the current score in another format" : "Load a file to enable export"
}
aria-label="Export current score"
aria-haspopup="menu"
aria-expanded={exportOpen}
disabled={!doc}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
height: 32,
width: 32,
padding: 0,
borderRadius: 7,
cursor: doc ? "pointer" : "not-allowed",
flexShrink: 0,
background: doc ? "rgba(147,51,234,0.14)" : "var(--color-surface-2)",
border: doc ? "1px solid rgba(147,51,234,0.35)" : "1px solid var(--color-border)",
color: doc ? "var(--color-accent-text)" : "var(--color-text-muted)",
opacity: doc ? 1 : 0.55,
transition: "background 0.12s",
fontFamily: "inherit",
}}
onMouseEnter={(e) => {
if (doc) (e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.24)";
}}
onMouseLeave={(e) => {
if (doc) (e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)";
}}
>
{isExportingPdf ? <SpinnerIcon /> : <DownloadIcon />}
</button>
{exportOpen && exportRect && (
<div
role="menu"
style={{
position: "fixed",
bottom: exportRect.bottom,
left: exportRect.left,
zIndex: 1100,
minWidth: 180,
padding: 6,
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: 8,
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<ExportMenuItem
label="MIDI"
sublabel=".midi"
enabled={!!doc?.midiBuffer}
onClick={() => {
exportMidi();
setExportOpen(false);
}}
disabledReason="No MIDI data in this document"
/>
<ExportMenuItem
label="MusicXML"
sublabel=".musicxml"
enabled={!!doc?.musicXml}
onClick={() => {
exportMusicXml();
setExportOpen(false);
}}
disabledReason="No MusicXML data in this document"
/>
<ExportMenuItem
label="PDF sheet music"
sublabel={isExportingPdf ? "rendering..." : ".pdf"}
enabled={!!doc?.musicXml && !isExportingPdf}
onClick={() => {
void exportPdf();
setExportOpen(false);
}}
disabledReason={
!doc?.musicXml ? "No MusicXML data in this document" : "Already rendering a PDF"
}
/>
</div>
)}
</div>
<Divider />
{/* ── Zone 2: Playback controls ───────────────────────── */}
@@ -624,6 +742,58 @@ function Divider() {
);
}
// ── Sub-components ───────────────────────────────────────────────────────────
function ExportMenuItem({
label,
sublabel,
enabled,
onClick,
disabledReason,
}: {
label: string;
sublabel: string;
enabled: boolean;
onClick: () => void;
disabledReason: string;
}) {
return (
<button
role="menuitem"
onClick={enabled ? onClick : undefined}
disabled={!enabled}
title={enabled ? `Save as ${sublabel}` : disabledReason}
style={{
display: "flex",
alignItems: "baseline",
justifyContent: "space-between",
gap: 12,
padding: "8px 10px",
background: "transparent",
border: "none",
borderRadius: 5,
cursor: enabled ? "pointer" : "not-allowed",
opacity: enabled ? 1 : 0.45,
color: "var(--color-text)",
fontSize: 13,
fontWeight: 500,
fontFamily: "inherit",
textAlign: "left",
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (enabled) (e.currentTarget as HTMLElement).style.background = "var(--color-surface-2)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span>{label}</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{sublabel}</span>
</button>
);
}
// ── Icons ─────────────────────────────────────────────────────────────────────
const PlayIcon = () => (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
@@ -661,6 +831,12 @@ const UploadIcon = () => (
<path d="M9 16h6v-6h4l-7-7-7 7h4v6zm-4 2h14v2H5v-2z" />
</svg>
);
const DownloadIcon = () => (
// Vertical mirror of UploadIcon - same stem width, same bar at bottom, arrow flipped.
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 4h6v6h4l-7 7-7-7h4v-6zm-4 14h14v2H5v-2z" />
</svg>
);
const SpinnerIcon = () => (
<svg
width="12"
+123 -1
View File
@@ -115,6 +115,8 @@ export interface AppState {
isLoadingDocument: boolean;
/** True while /fingering/generate is in flight */
isGeneratingFingering: boolean;
/** True while /export/pdf is in flight (PDF rendering is the only async export) */
isExportingPdf: boolean;
loadError: string | null;
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
loadingMessage: string | null;
@@ -139,6 +141,9 @@ export interface AppState {
loadMusicXmlFile: (file: File) => Promise<void>;
loadPdfFile: (file: File) => Promise<void>;
generateFingering: () => Promise<void>;
exportMidi: () => void;
exportMusicXml: () => void;
exportPdf: () => Promise<void>;
play: () => void;
pause: () => void;
stop: () => void;
@@ -155,13 +160,46 @@ export interface AppState {
syncEngineState: () => void;
}
/** localStorage key for persisting the user's theme choice across reloads. */
const THEME_STORAGE_KEY = "b2b-theme";
/**
* Initial theme - the standard "system default + persisted override" pattern
* used by GitHub, Linear, Notion, Vercel, etc.:
*
* 1. If the user has previously toggled, honor that choice (localStorage)
* 2. Otherwise match the OS preference (`prefers-color-scheme: dark`)
* 3. Fall back to light
*
* Runs at module load (before React mounts) so the first paint is correct -
* no flash of wrong theme on reload.
*/
function getInitialTheme(): "dark" | "light" {
if (typeof window === "undefined") return "light";
try {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "dark" || stored === "light") return stored;
} catch {
/* localStorage may throw in private/incognito mode */
}
try {
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) return "dark";
} catch {
/* matchMedia not supported (very old browsers) */
}
return "light";
}
export const DEFAULT_SETTINGS: AppSettings = {
showFingering: false,
showFingeringOnNotes: false,
showHandColors: true,
useFlats: false,
viewportSeconds: 4.0,
theme: "light",
theme: getInitialTheme(),
activeHands: new Set(["left", "right"]),
volume: 0.8,
noteFilter: "all",
@@ -225,6 +263,7 @@ export const useAppStore = create<AppState>((set, get) => {
document: null,
isLoadingDocument: false,
isGeneratingFingering: false,
isExportingPdf: false,
loadError: null,
loadingMessage: null,
status: "stopped",
@@ -443,6 +482,58 @@ export const useAppStore = create<AppState>((set, get) => {
}
},
// ── Export ──────────────────────────────────────────────────────────────
// MIDI + MusicXML are blob-and-download from data we already have in memory.
// PDF requires a backend round-trip (music21 + LilyPond / Audiveris).
// MP3 is intentionally NOT here yet - it needs an OfflineAudioContext WAV
// pipeline which is a separate feature.
exportMidi: () => {
const { document: doc } = get();
if (!doc?.midiBuffer) return;
triggerDownload(
new Blob([doc.midiBuffer], { type: "audio/midi" }),
`${safeFilename(doc.title)}.midi`
);
},
exportMusicXml: () => {
const { document: doc } = get();
if (!doc?.musicXml) return;
triggerDownload(
new Blob([doc.musicXml], { type: "application/vnd.recordare.musicxml+xml" }),
`${safeFilename(doc.title)}.musicxml`
);
},
exportPdf: async () => {
const { document: doc } = get();
if (!doc?.musicXml) return;
set({ isExportingPdf: true, loadError: null });
try {
const res = await fetch("/api/export/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ musicxml: doc.musicXml }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(body.detail ?? `Server error ${res.status}`);
}
const blob = await res.blob();
triggerDownload(blob, `${safeFilename(doc.title)}.pdf`);
} catch (err) {
// Use the backend's detail verbatim - it's already worded for the user.
// Don't prefix with "PDF export failed:" because the toast's warning icon
// already conveys the failure state and the backend message is descriptive.
const msg = err instanceof Error ? err.message : String(err);
set({ loadError: msg });
} finally {
set({ isExportingPdf: false });
}
},
play: () => {
const { settings, status } = get();
// Speed trainer: seed the engine's current state before play
@@ -510,6 +601,16 @@ export const useAppStore = create<AppState>((set, get) => {
syncEngine.setWaitForHand(patch.waitForHand);
if ("renderOffset" in patch && patch.renderOffset !== undefined)
syncEngine.setRenderOffset(patch.renderOffset);
// Persist theme choice across reloads. localStorage write is fire-and-forget;
// private/incognito mode can throw, in which case the choice just won't persist.
if ("theme" in patch && (patch.theme === "dark" || patch.theme === "light")) {
try {
window.localStorage.setItem(THEME_STORAGE_KEY, patch.theme);
} catch {
/* localStorage unavailable - ignore */
}
}
},
resetSettings: () => {
@@ -573,6 +674,27 @@ function parseMidiInWorker(buffer: ArrayBuffer, id: string, title: string): Prom
});
}
// ── Export helpers ────────────────────────────────────────────────────────────
/** Make a string safe for use as a filename: strip path separators, control chars,
* and characters disallowed on Windows. Browsers further sanitize on download. */
function safeFilename(name: string): string {
return name.replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, "_").slice(0, 200) || "untitled";
}
/** Create an anchor, click it to trigger a download, then revoke the object URL. */
function triggerDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke - some browsers race the download otherwise.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ── Fingering helpers ─────────────────────────────────────────────────────────
const STEP_TO_SEMITONE: Record<string, number> = {
+83 -1
View File
@@ -1,10 +1,92 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { VitePWA } from "vite-plugin-pwa";
import { resolve } from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
VitePWA({
// Silently install updates - new SW takes over on next page open (no popup, no reload prompt).
// Matches the consumer-app pattern (Gmail, Spotify Web, etc.).
registerType: "autoUpdate",
// Off in dev so HMR isn't interfered with by cached responses.
devOptions: { enabled: false },
// App icons + display name shown on install / home-screen / dock.
manifest: {
name: "Bach to Basics",
short_name: "Bach to Basics",
description:
"Browser-based piano practice tool - falling notes, sheet music, MIDI, and practice tools",
theme_color: "#9333ea", // matches the purple accent
background_color: "#0a0a0a",
display: "standalone",
orientation: "landscape-primary",
start_url: "/",
scope: "/",
icons: [
// Reuses existing public/ assets - logo.png is the high-res one,
// apple-touch-icon and favicon are smaller fallbacks.
{
src: "/logo.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/logo.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
{
src: "/apple-touch-icon.png",
sizes: "180x180",
type: "image/png",
},
{
src: "/favicon.png",
sizes: "32x32",
type: "image/png",
},
],
},
workbox: {
// Precache built static assets so the shell loads instantly + works offline.
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
// Don't try to precache the soundfont folder (large; fetched lazily by AlphaTab).
globIgnores: ["**/soundfont/**"],
// Critical: NEVER cache backend API calls - we want fresh data, not a stale
// cached response (e.g. old fingerings, old transcription results).
navigateFallbackDenylist: [/^\/api/],
// Runtime caching for cross-origin sample CDNs (smplr): serve fresh when online,
// fall back to cache when offline so the on-screen piano still works.
// Hosts: gleitz (Soundfont), smpldsnds (SplendidGrandPiano + ElectricPiano in 0.20+),
// danigb (legacy, kept for older smplr versions).
runtimeCaching: [
{
urlPattern: /^https:\/\/(smpldsnds|gleitz|danigb)\.github\.io\//,
handler: "StaleWhileRevalidate",
options: {
cacheName: "smplr-samples",
expiration: { maxEntries: 500, maxAgeSeconds: 60 * 60 * 24 * 30 },
},
},
],
// Bigger than the default 2 MiB so AlphaTab's bundle isn't rejected from precache.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
}),
],
resolve: {
alias: {
"@bach-to-basics/shared": resolve(__dirname, "../shared/types/index.ts"),
+3060 -15
View File
File diff suppressed because it is too large Load Diff