Add MP3 export via offline rendering (Splendid Grand Piano), polish deployment docs
This commit is contained in:
@@ -65,6 +65,15 @@ Drag-and-drop anywhere on the window, or use the import button.
|
|||||||
- **MIDI input** via Web MIDI: connect a hardware keyboard (Roland, Yamaha, etc.); your input lights up the on-screen keys and drives wait mode
|
- **MIDI input** via Web MIDI: connect a hardware keyboard (Roland, Yamaha, etc.); your input lights up the on-screen keys and drives wait mode
|
||||||
- **Tempo control**: 25%-200% with snap-back to 100%
|
- **Tempo control**: 25%-200% with snap-back to 100%
|
||||||
|
|
||||||
|
### Export
|
||||||
|
|
||||||
|
Save the loaded piece in any of these formats via the Export button (next to Import):
|
||||||
|
|
||||||
|
- **MIDI** (`.midi`) - the raw note data
|
||||||
|
- **MusicXML** (`.musicxml`) - full notation, opens in any score editor
|
||||||
|
- **PDF sheet music** (`.pdf`) - typeset via LilyPond (optional backend setup, see below)
|
||||||
|
- **MP3 audio** (`.mp3`) - rendered with the Splendid Grand Piano via an OfflineAudioContext (other instruments to follow; the grand covers the common case)
|
||||||
|
|
||||||
### Look & feel
|
### Look & feel
|
||||||
|
|
||||||
- **Color themes**: Cascade, Violet, Classic, Ocean, Forest, or fully custom (left/right/unknown hand colors)
|
- **Color themes**: Cascade, Violet, Classic, Ocean, Forest, or fully custom (left/right/unknown hand colors)
|
||||||
@@ -213,7 +222,6 @@ Requires a Chromium-based browser (Chrome, Edge, Brave, Arc) for **Web MIDI API*
|
|||||||
The backend already exposes endpoints for several features that don't yet have UI hooks:
|
The backend already exposes endpoints for several features that don't yet have UI hooks:
|
||||||
|
|
||||||
- **YouTube-to-MIDI** via yt-dlp + Basic Pitch (`/youtube/extract`), sync data model already in place
|
- **YouTube-to-MIDI** via yt-dlp + Basic Pitch (`/youtube/extract`), sync data model already in place
|
||||||
- **MP3 export** (MIDI / MusicXML / PDF export already shipped; MP3 needs an OfflineAudioContext WAV-render pipeline)
|
|
||||||
|
|
||||||
These will become user-facing in upcoming releases. PRs welcome.
|
These will become user-facing in upcoming releases. PRs welcome.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -48,4 +48,4 @@ Use **GitHub's private vulnerability reporting**:
|
|||||||
2. Click **"Report a vulnerability"**
|
2. Click **"Report a vulnerability"**
|
||||||
3. Fill in the details - steps to reproduce, impact, any suggested fix
|
3. Fill in the details - steps to reproduce, impact, any suggested fix
|
||||||
|
|
||||||
You'll get a response within a few days. There's no formal embargo policy given the nature of this project, but please give a reasonable heads-up before going public.
|
Reports are handled by a single maintainer in personal time, so response times vary.
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ export function TransportControls() {
|
|||||||
exportMidi,
|
exportMidi,
|
||||||
exportMusicXml,
|
exportMusicXml,
|
||||||
exportPdf,
|
exportPdf,
|
||||||
|
exportMp3,
|
||||||
isExportingPdf,
|
isExportingPdf,
|
||||||
|
isExportingMp3,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
|
||||||
const [speedOpen, setSpeedOpen] = useState(false);
|
const [speedOpen, setSpeedOpen] = useState(false);
|
||||||
@@ -290,7 +292,7 @@ export function TransportControls() {
|
|||||||
if (doc) (e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)";
|
if (doc) (e.currentTarget as HTMLElement).style.background = "rgba(147,51,234,0.14)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExportingPdf ? <SpinnerIcon /> : <DownloadIcon />}
|
{isExportingPdf || isExportingMp3 ? <SpinnerIcon /> : <DownloadIcon />}
|
||||||
</button>
|
</button>
|
||||||
{exportOpen && exportRect && (
|
{exportOpen && exportRect && (
|
||||||
<div
|
<div
|
||||||
@@ -343,6 +345,18 @@ export function TransportControls() {
|
|||||||
!doc?.musicXml ? "No MusicXML data in this document" : "Already rendering a PDF"
|
!doc?.musicXml ? "No MusicXML data in this document" : "Already rendering a PDF"
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<ExportMenuItem
|
||||||
|
label="MP3 audio"
|
||||||
|
sublabel={isExportingMp3 ? "rendering..." : ".mp3"}
|
||||||
|
enabled={!!doc?.notes?.length && !isExportingMp3}
|
||||||
|
onClick={() => {
|
||||||
|
void exportMp3();
|
||||||
|
setExportOpen(false);
|
||||||
|
}}
|
||||||
|
disabledReason={
|
||||||
|
!doc?.notes?.length ? "No notes to render" : "Already rendering an MP3"
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,25 +18,48 @@ export const INSTRUMENT_LABELS: Record<InstrumentId, string> = {
|
|||||||
honkytonk: "Honky",
|
honkytonk: "Honky",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Minimal common interface shared by all smplr players
|
// Minimal common interface shared by all smplr players. Exported so the
|
||||||
type Player = {
|
// MP3 export pipeline can type the offline-rendering player the same way.
|
||||||
|
export type Player = {
|
||||||
load: Promise<unknown>;
|
load: Promise<unknown>;
|
||||||
start(opts: { note: number; velocity?: number; stopId?: number | string }): unknown;
|
/** `time` (seconds, relative to ctx.currentTime) + `duration` enable
|
||||||
|
* offline batch rendering - if omitted, the note plays immediately. */
|
||||||
|
start(opts: {
|
||||||
|
note: number;
|
||||||
|
velocity?: number;
|
||||||
|
stopId?: number | string;
|
||||||
|
time?: number;
|
||||||
|
duration?: number;
|
||||||
|
}): unknown;
|
||||||
stop(opts?: { stopId?: number | string }): void;
|
stop(opts?: { stopId?: number | string }): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createPlayer(id: InstrumentId, ctx: AudioContext): Player {
|
/**
|
||||||
|
* Build a smplr player for the given instrument against any AudioContext-like.
|
||||||
|
* Exported (not local) so the MP3 export pipeline can reuse it for an
|
||||||
|
* OfflineAudioContext - same sample set / same instrument as live playback,
|
||||||
|
* which is critical for the rendered audio to match what the user hears.
|
||||||
|
*
|
||||||
|
* Accepts `BaseAudioContext` (the common parent of both AudioContext and
|
||||||
|
* OfflineAudioContext) rather than `AudioContext` alone, so callers can pass
|
||||||
|
* an offline context for batch rendering. The smplr classes accept either.
|
||||||
|
*/
|
||||||
|
export function createPlayer(id: InstrumentId, ctx: BaseAudioContext): Player {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case "grand":
|
case "grand":
|
||||||
return new SplendidGrandPiano(ctx) as unknown as Player;
|
return new SplendidGrandPiano(ctx as AudioContext) as unknown as Player;
|
||||||
case "bright":
|
case "bright":
|
||||||
return new Soundfont(ctx, { instrument: "bright_acoustic_piano" }) as unknown as Player;
|
return new Soundfont(ctx as AudioContext, {
|
||||||
|
instrument: "bright_acoustic_piano",
|
||||||
|
}) as unknown as Player;
|
||||||
case "electric":
|
case "electric":
|
||||||
return new ElectricPiano(ctx, { instrument: "CP80" }) as unknown as Player;
|
return new ElectricPiano(ctx as AudioContext, { instrument: "CP80" }) as unknown as Player;
|
||||||
case "harpsichord":
|
case "harpsichord":
|
||||||
return new Soundfont(ctx, { instrument: "harpsichord" }) as unknown as Player;
|
return new Soundfont(ctx as AudioContext, { instrument: "harpsichord" }) as unknown as Player;
|
||||||
case "honkytonk":
|
case "honkytonk":
|
||||||
return new Soundfont(ctx, { instrument: "honkytonk_piano" }) as unknown as Player;
|
return new Soundfont(ctx as AudioContext, {
|
||||||
|
instrument: "honkytonk_piano",
|
||||||
|
}) as unknown as Player;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,10 @@ export interface AppState {
|
|||||||
isLoadingDocument: boolean;
|
isLoadingDocument: boolean;
|
||||||
/** True while /fingering/generate is in flight */
|
/** True while /fingering/generate is in flight */
|
||||||
isGeneratingFingering: boolean;
|
isGeneratingFingering: boolean;
|
||||||
/** True while /export/pdf is in flight (PDF rendering is the only async export) */
|
/** True while /export/pdf is in flight (server-side LilyPond render) */
|
||||||
isExportingPdf: boolean;
|
isExportingPdf: boolean;
|
||||||
|
/** True while MP3 export is in flight (frontend offline-render → backend FFmpeg) */
|
||||||
|
isExportingMp3: boolean;
|
||||||
loadError: string | null;
|
loadError: string | null;
|
||||||
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
|
/** Non-null during slow imports (e.g. OMR) to show a descriptive message */
|
||||||
loadingMessage: string | null;
|
loadingMessage: string | null;
|
||||||
@@ -145,6 +147,7 @@ export interface AppState {
|
|||||||
exportMidi: () => void;
|
exportMidi: () => void;
|
||||||
exportMusicXml: () => void;
|
exportMusicXml: () => void;
|
||||||
exportPdf: () => Promise<void>;
|
exportPdf: () => Promise<void>;
|
||||||
|
exportMp3: () => Promise<void>;
|
||||||
play: () => void;
|
play: () => void;
|
||||||
pause: () => void;
|
pause: () => void;
|
||||||
stop: () => void;
|
stop: () => void;
|
||||||
@@ -265,6 +268,7 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
isLoadingDocument: false,
|
isLoadingDocument: false,
|
||||||
isGeneratingFingering: false,
|
isGeneratingFingering: false,
|
||||||
isExportingPdf: false,
|
isExportingPdf: false,
|
||||||
|
isExportingMp3: false,
|
||||||
loadError: null,
|
loadError: null,
|
||||||
loadingMessage: null,
|
loadingMessage: null,
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
@@ -579,6 +583,104 @@ export const useAppStore = create<AppState>((set, get) => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
exportMp3: async () => {
|
||||||
|
const { document: doc } = get();
|
||||||
|
if (!doc?.notes || doc.notes.length === 0) return;
|
||||||
|
|
||||||
|
set({ isExportingMp3: true, loadError: null });
|
||||||
|
try {
|
||||||
|
// Lazy-load smplr so this code path doesn't bloat the initial bundle
|
||||||
|
// for users who never export.
|
||||||
|
const { renderOffline, Smplr, Scheduler, HttpStorage, pianoToSmplrJson } =
|
||||||
|
await import("smplr");
|
||||||
|
|
||||||
|
// Tail buffer so the final note's release isn't cut off + small lead-in
|
||||||
|
// so the first note isn't clipped at sample 0.
|
||||||
|
const RENDER_TAIL_SEC = 1.5;
|
||||||
|
const RENDER_LEAD_SEC = 0.05;
|
||||||
|
|
||||||
|
const renderResult = await renderOffline(
|
||||||
|
async (ctx) => {
|
||||||
|
// === Working around a real bug in smplr's offline rendering ===
|
||||||
|
//
|
||||||
|
// smplr's default Scheduler uses setInterval to drain queued notes
|
||||||
|
// whose `time` is outside its 200ms lookahead window. setInterval
|
||||||
|
// callbacks NEVER fire during synchronous OfflineAudioContext
|
||||||
|
// rendering, so any note past 200ms would be silently discarded
|
||||||
|
// (you'd get a long silent WAV with only the very first beats
|
||||||
|
// audible). The README's renderOffline example has the same bug.
|
||||||
|
//
|
||||||
|
// Workaround: construct our own Scheduler with a huge lookahead
|
||||||
|
// (effectively infinite) so EVERY note falls within the synchronous
|
||||||
|
// dispatch branch in Scheduler.schedule(). Then we bypass the
|
||||||
|
// SplendidGrandPiano wrapper (which doesn't forward `scheduler` to
|
||||||
|
// its internal Smplr) and instantiate Smplr directly with the
|
||||||
|
// piano's JSON config + our scheduler.
|
||||||
|
//
|
||||||
|
// v1: always render with SplendidGrandPiano regardless of the
|
||||||
|
// currently-selected live instrument. 99% of users are on the
|
||||||
|
// default "grand" anyway; we can extend per-instrument later.
|
||||||
|
const scheduler = new Scheduler(ctx, { lookaheadMs: 86_400_000 });
|
||||||
|
// pianoToSmplrJson only takes piano-shape options. The storage
|
||||||
|
// backend belongs in the Smplr constructor's options (where the
|
||||||
|
// SplendidGrandPiano wrapper also passes it). HttpStorage is a
|
||||||
|
// const singleton, not a class - no `new`.
|
||||||
|
const json = pianoToSmplrJson({
|
||||||
|
baseUrl: "https://smpldsnds.github.io/sfzinstruments-splendid-grand-piano/samples",
|
||||||
|
detune: 0,
|
||||||
|
decayTime: 0.5,
|
||||||
|
});
|
||||||
|
const player = new Smplr(ctx, json, { scheduler, storage: HttpStorage });
|
||||||
|
await player.load;
|
||||||
|
|
||||||
|
// Schedule every note. velocity/duration straight from the doc;
|
||||||
|
// we ignore current tempoMultiplier/transpose/hand-volume on
|
||||||
|
// purpose - MP3 export is "render the score as-written", not
|
||||||
|
// "render my current practice settings".
|
||||||
|
for (const note of doc.notes) {
|
||||||
|
player.start({
|
||||||
|
note: note.midi,
|
||||||
|
velocity: note.velocity,
|
||||||
|
time: RENDER_LEAD_SEC + note.startSeconds,
|
||||||
|
duration: Math.max(0.05, note.endSeconds - note.startSeconds),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sampleRate: 44100,
|
||||||
|
channels: 2,
|
||||||
|
duration: RENDER_LEAD_SEC + doc.totalDuration + RENDER_TAIL_SEC,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encode the AudioBuffer as 16-bit WAV - half the size of float32, the
|
||||||
|
// codec FFmpeg expects, and well within the backend's 200 MB cap for
|
||||||
|
// any practical song length. smplr returns a Blob; we need raw bytes
|
||||||
|
// for base64 encoding.
|
||||||
|
const wavBlob = renderResult.toWav16();
|
||||||
|
const wavBuffer = await wavBlob.arrayBuffer();
|
||||||
|
const wavBase64 = arrayBufferToBase64(wavBuffer);
|
||||||
|
|
||||||
|
const res = await fetch("/api/export/mp3", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ wav_base64: wavBase64 }),
|
||||||
|
});
|
||||||
|
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)}.mp3`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
set({ loadError: msg });
|
||||||
|
} finally {
|
||||||
|
set({ isExportingMp3: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
play: () => {
|
play: () => {
|
||||||
const { settings, status } = get();
|
const { settings, status } = get();
|
||||||
// Speed trainer: seed the engine's current state before play
|
// Speed trainer: seed the engine's current state before play
|
||||||
@@ -727,6 +829,19 @@ function safeFilename(name: string): string {
|
|||||||
return name.replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, "_").slice(0, 200) || "untitled";
|
return name.replace(/[\x00-\x1f\x7f/\\:*?"<>|]/g, "_").slice(0, 200) || "untitled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Encode a Uint8Array / ArrayBuffer as a standard base64 string.
|
||||||
|
* Chunked to avoid `String.fromCharCode(...veryLongArray)` blowing the call
|
||||||
|
* stack on large WAV exports (a 5-min stereo render is ~26 MB). */
|
||||||
|
function arrayBufferToBase64(buffer: Uint8Array | ArrayBuffer): string {
|
||||||
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
|
const CHUNK = 0x8000; // 32 KB
|
||||||
|
let binary = "";
|
||||||
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||||
|
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)));
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
/** Create an anchor, click it to trigger a download, then revoke the object URL. */
|
/** Create an anchor, click it to trigger a download, then revoke the object URL. */
|
||||||
function triggerDownload(blob: Blob, filename: string): void {
|
function triggerDownload(blob: Blob, filename: string): void {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
Reference in New Issue
Block a user