I have a folder full of MIDI files from old composition projects. They're great for playback, but useless when I want to hand a score to a pianist or import something into Finale. Most MIDI-to-sheet converters are either desktop software with painful licensing or online tools that want you to upload your files to who-knows-where.
So I built one that runs entirely in the browser. Drop a .mid or .midi file in, and seconds later you're staring at properly rendered sheet music. Export it as MusicXML for further editing, or as a PDF if you just need to print it. No uploads, no accounts, no waiting.
Try it out on our free MIDI to sheet music converter.
Why Keep This Client-Side?
MIDI files might not feel as sensitive as photos, but they still represent someone's creative work — unfinished compositions, licensed arrangements, personal projects. Sending them to a remote server for conversion is unnecessary exposure.
Zero Network Uploads
The entire pipeline — parse MIDI, generate MusicXML, render sheet music, export PDF — happens inside your browser. Your file bytes never traverse the network. Even if you're working on a plane with no Wi-Fi, the tool works perfectly.
Instant Feedback
A typical MIDI file parses and renders in under a second. There's no queue, no "your job is 47th in line," no email-me-when-done. You see the result immediately.
No Compatibility Headaches
Because everything runs in a modern browser, it works on macOS, Windows, Linux, ChromeOS, even tablets. No installer, no plugin, no "this version is not supported on your operating system."
The Full Pipeline
Here's what happens from the moment you drop a file to the moment the sheet music appears on screen:
Let's walk through each stage.
Parsing MIDI: From Binary Blob to Note Objects
MIDI files are binary. They're not JSON, not XML, not text you can eyeball. The @tonejs/midi library handles the low-level binary parsing and gives us a clean JavaScript object model:
const { Midi } = await import('@tonejs/midi');
const arrayBuffer = await midiFile.arrayBuffer();
const midi = new Midi(arrayBuffer);
The resulting midi object contains:
-
header.ppq— pulses per quarter note (typically 480 or 960) -
header.timeSignatures— array of time signature changes -
tracks— array of tracks, each withnotes,controlChanges, etc. - Each note has
midi(pitch 0–127),ticks(onset time),durationTicks, andvelocity
We pick the first track that actually contains notes:
const track = midi.tracks.find((t) => t.notes.length > 0) || midi.tracks[0];
Most single-instrument MIDI files only have one meaningful track anyway. For multi-track files, this keeps things simple — we render the first melodic track we find.
Grouping Notes: Turning Monophonic Events Into Chords
MIDI stores every note as an independent event. If you play a C major chord, the file contains three separate note-on events at roughly the same time. For sheet music, we need to present that as a single chord symbol with three stacked noteheads.
The solution is a chord tolerance window. Any notes whose onset times are within a small threshold get grouped together:
const chordTolerance = Math.max(1, Math.floor(ppq / 64));
const events: { ticks: number; durationTicks: number; notes: any[] }[] = [];
for (const note of sortedNotes) {
const existing = events.find(e => Math.abs(e.ticks - note.ticks) <= chordTolerance);
if (existing) {
existing.notes.push(note);
} else {
events.push({ ticks: note.ticks, durationTicks: note.durationTicks, notes: [note] });
}
}
With a default PPQ of 480, the tolerance is about 7 ticks — roughly 1/64th of a quarter note. That's tight enough to avoid falsely grouping rapid arpeggios, but loose enough to catch human timing imperfections in chord playback.
Measure Splitting: The Heart of the Layout Problem
Sheet music is organized into measures (bars). MIDI is just a flat timeline of events. To convert one into the other, we need to know where each measure begins and ends.
const timeSig = midi.header.timeSignatures[0] || { timeSignature: [4, 4] };
const [beats, beatValue] = timeSig.timeSignature;
const ticksPerMeasure = beats * (ppq * 4 / beatValue);
For a 4/4 time signature with PPQ = 480, each measure is 4 * 480 = 1920 ticks long. We iterate through the grouped events and cut them into measures:
const measures: typeof events[] = [];
let currentMeasure: typeof events = [];
let measureStart = 0;
for (const event of events) {
while (event.ticks >= measureStart + ticksPerMeasure) {
measures.push(currentMeasure);
currentMeasure = [];
measureStart += ticksPerMeasure;
}
currentMeasure.push(event);
}
if (currentMeasure.length > 0) measures.push(currentMeasure);
This also handles notes that cross measure boundaries. In a proper notation program, you'd split those into tied notes. For this tool, we keep it simple — each note lives in the measure where it starts.
Generating MusicXML: Speaking the Lingua Franca of Sheet Music
MusicXML is the standard exchange format for digital sheet music. It's verbose, it's XML, and every notation program understands it. Generating it by hand means concatenating strings, which sounds messy but is actually quite straightforward.
First, the pitch conversion. MIDI uses numeric pitch (0–127). MusicXML needs step, alter, and octave:
function midiToXmlPitch(midi: number) {
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(midi / 12) - 1;
const noteIndex = midi % 12;
const name = noteNames[noteIndex];
const step = name.charAt(0);
const alter = name.length > 1 ? (name.charAt(1) === '#' ? '1' : '-1') : '0';
return { step, alter, octave };
}
MIDI 60 is Middle C (C4). The formula Math.floor(midi / 12) - 1 correctly maps this to octave 4. Sharps get alter = 1, flats would get -1 (though this simple mapping only produces sharps).
Next, duration mapping. MIDI stores duration in ticks. MusicXML needs symbolic note types — whole, half, quarter, eighth, 16th, 32nd:
function durationToType(durTicks: number, ppq: number): string {
const ratio = durTicks / ppq;
if (ratio >= 3.5) return 'whole';
if (ratio >= 1.75) return 'half';
if (ratio >= 0.875) return 'quarter';
if (ratio >= 0.4) return 'eighth';
if (ratio >= 0.25) return '16th';
return '32nd';
}
The thresholds use approximate ranges rather than exact ratios. This is intentional — MIDI durations rarely line up perfectly with symbolic notation. A "quarter note" in a live performance might be 470 or 490 ticks instead of exactly 480. The fuzzy matching handles this gracefully.
The MusicXML Template
With pitches and durations ready, we assemble the XML. The first measure gets special treatment — it holds the <attributes> element that declares the key signature, time signature, clef, and divisions:
<measure number="1">
<attributes>
<divisions>480</divisions>
<key><fifths>0</fifths></key>
<time><beats>4</beats><beat-type>4</beat-type></time>
<clef><sign>G</sign><line>2</line></clef>
</attributes>
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>480</duration>
<type>quarter</type>
</note>
...
</measure>
Chords are represented by adding a <chord/> element to every note after the first in a simultaneous group:
<note>
<pitch><step>C</step><octave>4</octave></pitch>
<duration>480</duration>
<type>quarter</type>
</note>
<note>
<chord/>
<pitch><step>E</step><octave>4</octave></pitch>
<duration>480</duration>
<type>quarter</type>
</note>
<note>
<chord/>
<pitch><step>G</step><octave>4</octave></pitch>
<duration>480</duration>
<type>quarter</type>
</note>
This tells the renderer: "these three notes share the same rhythmic position. Stack them vertically."
Rendering the Score: OpenSheetMusicDisplay
Generating MusicXML is only half the battle. Humans can't read raw XML — we need actual notation. For that, we use opensheetmusicdisplay (OSMD), a powerful TypeScript library that renders MusicXML as SVG.
const { OpenSheetMusicDisplay } = await import('opensheetmusicdisplay');
const osmd = new OpenSheetMusicDisplay(container, {
autoResize: true,
backend: 'svg',
drawingParameters: 'compact',
});
await osmd.load(musicXml);
osmd.render();
OSMD handles the heavy lifting: staff spacing, note positioning, beam grouping, accidental placement, clef rendering, and more. The compact drawing mode keeps the layout tight — useful for viewing on screens rather than printed pages. The svg backend means the output is a scalable vector graphic that stays crisp at any zoom level.
Under the hood, OSMD uses VexFlow for the actual drawing primitives. It parses the MusicXML DOM, builds an internal object model of the score, calculates layouts, and emits SVG path commands. All of this happens in the browser — no server rendering farm required.
Exporting to MusicXML
The rendered sheet music is great for viewing, but what if you want to edit it in MuseScore, Sibelius, or Finale? The tool can export the generated MusicXML as a downloadable file:
const blob = new Blob([xml], { type: 'application/vnd.recordare.musicxml+xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${baseName}.musicxml`;
a.click();
URL.revokeObjectURL(url);
This is a standard browser trick: create a Blob from the string, generate a temporary object URL, trigger a download via a hidden anchor tag, then clean up. The file is a fully valid MusicXML 3.1 document that any modern notation program can import.
Exporting to PDF: SVG to Canvas to jsPDF
PDF export is trickier. OSMD renders SVG, but we need a PDF. The pipeline looks like this:
The key steps:
- Serialize the SVG with a proper XML declaration and namespace:
let svgData = new XMLSerializer().serializeToString(svgElement);
if (!svgData.startsWith('<?xml')) {
svgData = '<?xml version="1.0" encoding="UTF-8"?>\n' + svgData;
}
if (!svgData.includes('xmlns=')) {
svgData = svgData.replace('<svg', '<svg xmlns="http://www.w3.org/2000/svg"');
}
- Load as image via a Blob URL. This is more reliable than base64 for complex Unicode content:
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const blobUrl = URL.createObjectURL(svgBlob);
const img = new Image();
img.src = blobUrl;
await img.decode();
- Draw to canvas at 2x scale for print-quality resolution:
const scale = 2;
const canvas = document.createElement('canvas');
canvas.width = Math.ceil(svgWidth * scale);
canvas.height = Math.ceil(svgHeight * scale);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
- Convert to JPEG and embed in a PDF using jsPDF:
const imgData = canvas.toDataURL('image/jpeg', 0.92);
const pdf = new jsPDF({
orientation: svgWidth > svgHeight ? 'landscape' : 'portrait',
unit: 'mm',
format: 'a4',
});
// Fit image to page with margins
const fitScale = Math.min(
availableWidth / imgWidthMm,
availableHeight / imgHeightMm,
1
);
pdf.addImage(imgData, 'JPEG', x, y, renderWidth, renderHeight);
pdf.save(`${baseName}.pdf`);
Using JPEG instead of PNG keeps the PDF small. The 0.92 quality setting strikes a balance between file size and visual fidelity. The image is centered on an A4 page with 10mm margins, automatically switching to landscape orientation if the score is wider than it is tall.
Handling Edge Cases
Real-world MIDI files are messy. The tool handles several common scenarios:
Empty Measures
If a MIDI file has long gaps between notes, we insert rest measures so the measure numbering stays correct:
while (note.ticks >= measureTickStart + ticksPerMeasure) {
measuresXml += `<measure number="${measureNum}">\n`;
measuresXml += ` <note><rest measure="yes" /><duration>${ticksPerMeasure}</duration></note>\n`;
measuresXml += `</measure>\n`;
measureNum++;
measureTickStart += ticksPerMeasure;
}
Multi-Track Files
The tool picks the first track with notes and ignores the rest. This works well for solo piano or melody lines. Full orchestral scores would need a more sophisticated track-selection UI, but for the 90% use case — a single instrument — this is the right default.
No Time Signature
If the MIDI file lacks a time signature event, it defaults to 4/4. This is the MIDI spec's implied default, and it covers the vast majority of popular music.
Why Not Use a Server-Side Tool?
Tools like MuseScore's command-line interface or LilyPond produce beautiful output, but they require a server. Running them client-side would mean bundling enormous WASM binaries or relying on experimental APIs. Our approach — parse with Tone.js, generate MusicXML, render with OSMD — stays entirely in JavaScript and keeps the bundle reasonable.
The trade-off is that the notation isn't as "smart" as a dedicated desktop app. We don't do automatic voice separation, complex beaming, or slur detection. But for quickly checking what a MIDI file sounds like on paper, it's more than enough.
Try It Yourself
Got a MIDI file sitting in your downloads folder? Maybe an old ringtone, a game soundtrack rip, or something you exported from a DAW? Drop it into our free MIDI to sheet music converter and see the notes come to life. Export to MusicXML to edit it further, or grab a PDF to print and play from.
All processing happens in your browser — your files never leave your device.


Top comments (0)