What Your MP3 to MIDI Piano Roll Actually Shows

It is a preview, not an editor. Velocity is drawn as opacity rather than height, there is no time axis, and changing Transpose does not move it.

Short answer: the roll under the converter encodes pitch as vertical position, time as horizontal position, and velocity as opacity - not as bar height, and not as colour. It has no time axis at all: not one vertical gridline and not one seconds label exists in the drawing code. Its horizontal scale is set by the last note's end, so the last bar always touches the right edge and a single 0.944-second note is drawn 862 pixels wide, the full width of the plot. And changing Transpose leaves the picture pixel-identical - I converted the same file at Transpose 0 and +12 and 0 of 135,000 pixels differed, while the downloaded .mid and the copied note list both moved up an octave.

Everything below is read out of this page's own drawing code, or measured by me while that code ran in a real browser. Where a number comes from a rendered pixel rather than from source, I say so.

The whole preview is one function

The canvas is declared as a fixed 900 by 150 pixels, and it is filled by a single function called drawRoll. Stripped of the arithmetic it looks like this:

function drawRoll(notes) {
  var ctx = roll.getContext('2d');
  ctx.clearRect(0, 0, W, H);
  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, W, H);
  if (!notes.length) { return; }          // empty result = a blank white box

  // find the lowest note, the highest note, and the latest end
  var lo = 127, hi = 0, tMax = 0;
  for (...) { if (notes[i].note < lo) lo = notes[i].note;
              if (notes[i].note > hi) hi = notes[i].note;
              if (notes[i].end > tMax) tMax = notes[i].end; }
  if (hi - lo < 11) { hi = lo + 11; }     // never less than 12 semitones tall
  if (tMax <= 0) { tMax = 1; }

  // one horizontal line per C, and a text label for it
  for (var m = lo; m <= hi; m++) {
    if (m % 12 === 0) { /* moveTo / lineTo / stroke across the full width */ }
  }

  // one filled rectangle per note, opacity from velocity
  for (var k = 0; k < notes.length; k++) {
    ctx.globalAlpha = 0.45 + (n.vel / 127) * 0.55;
    ctx.fillRect(bx, by, bw, Math.max(2, bh - 1));
  }
}

That is the entire visual vocabulary. Running this function against ten different note sets and recording every call it makes, it emitted exactly seven kinds of drawing call in total: clearRect, fillRect, beginPath, moveTo, lineTo, stroke and fillText. Nothing else.

What the picture encodes

On screenComes fromRange in practice
Vertical positionThe note number Scaled to the lowest and highest note in this result, never less than 12 semitones tall
Horizontal position and widthThe note's start and end time Scaled so the last note's end is at the right edge; the axis has no printed values
OpacityThe note's velocity 0.45 + vel/127 * 0.55 - about 0.45 to 0.88 in practice
Bar heightNothingOne value for every note in a given result
ColourNothingOne fixed blue, #1f6feb, for every note

Bar height is worth dwelling on. The height is computed once, from the pitch range of the whole result, and then every note gets the same height. Within one result, two notes an octave apart are drawn as bars of identical height; only their vertical position and their opacity differ.

The horizontal axis has no scale

The time mapping is one line of arithmetic:

var x = function (t) { return x0 + (t / tMax) * (x1 - x0); };

with x0 = 34, x1 = 896 and tMax being the end time of the last note - not the duration of the file. Two consequences follow directly, and both showed up when I measured the rendered canvas.

First, a note's drawn length says nothing about its real length. I fed in a one-second file containing a single 440 Hz tone. The page reported one note of 0.944 seconds. It was drawn from x = 34 to x = 895: 862 pixels wide, which is exactly the full width of the plot area. A four-second note would have been drawn the same 862 pixels wide, because in both cases that note is the last note and its own end defines the scale.

Second, the whole picture rescales when any note's end changes. I converted an eight-note scale twice. The audio was identical for the first four seconds; the only difference was the last note's end, which the page reported as 3.952 s in one run and 4.000 s in the other. That 48 ms changed 929 of the 135,000 pixels, spread across x = 137 to 794 - including the first bar, which had not changed at all. Its right edge moved from x = 138.7 to x = 137.4, purely because the axis was re-scaled underneath it.

The practical version: you cannot compare two conversions by eye. Each result sets its own time scale and its own pitch scale, so the same melody converted from two files can look subtly different for reasons that have nothing to do with the audio.

There is no time gridline and no seconds label

The only stroke calls in the function are the horizontal lines drawn at each C. Across all ten note sets I ran, the function emitted 14 gridlines and 14 text labels, and every single label was a note name - C2, C3, C4, C5, C6. There were no vertical lines at all, and no label containing a number of seconds.

On the rendered canvas this is easy to see. Converting the eight-note scale, exactly one horizontal gridline is visible, at row 9 of 150. Converting the single-note file, no gridline is visible at all - only the one label.

And the label you would expect to anchor the bottom of the picture is not there. The label for the lowest C is drawn at a baseline of y = 154.46 on a canvas that is only 150 pixels tall, and its gridline at y = 151.46 is further out still. Reading the pixels back, what survives of that label is four rows - rows 146 to 149, 26 pixels of clipped text - and the gridline is gone completely.

This is not a coincidence of that one file. It follows from the arithmetic: when the lowest note in the result is a C, its label baseline is 149 + bh/2, and bh is floored at 3, so the baseline is always at least 150.5 - always below the canvas. The gridline survives only if the pitch range is wide enough to shrink bh below 8, which needs a range of about 18 semitones or more.

Velocity is opacity, and the usable range is narrow

Velocity is written straight into globalAlpha:

ctx.globalAlpha = 0.45 + (n.vel / 127) * 0.55;

I converted a file whose eight notes were deliberately recorded at increasing levels, took the velocity values from the page's own note list, and then sampled the centre pixel of each drawn bar. Every bar matched the formula to within one colour step:

Velocity (from the page's note list)Opacity from the formula Opacity read back from the pixelBar height
450.64490.64299.92 px
500.66650.66529.92 px
650.73150.73219.92 px
800.79650.79469.92 px
900.83980.83939.92 px
1000.88310.88399.92 px

Three things to take from that table.

The bar heights are identical. A soft note and a loud note are drawn the same size. Velocity is visible only as a change in how solid the bar looks.

The range is compressed. Velocity 45 to 100 is a factor of 2.22. The opacity it produces runs 0.6449 to 0.8831, a factor of 1.37. The picture flattens dynamic differences rather than exaggerating them.

The picture never reaches full opacity. The formula would allow 1.0 at velocity 127, but the detector in this page tops out at 100 - I have never seen it report more, in this test or in the earlier ones. At 100 the opacity is 0.883, and the pixel I read back was RGB(57, 128, 237), which is the note colour composited onto white at exactly that fraction. So the darkest a note can ever look is 88% opaque.

The vertical axis is relative too, with a 12-semitone floor

The pitch range is the minimum and maximum of the notes that were found - not the range of the instrument, and not anything you can set. If the result happens to span fewer than 12 semitones, the code widens the axis to 12 anyway:

if (hi - lo < 11) { hi = lo + 11; }

That is why the single-note file above is drawn as a bar sitting at the very bottom of the plot with a large empty area above it: one note plus a forced 12-semitone axis.

At the other end, bar height shrinks as the range grows: bh = max(3, 142 / (hi - lo + 1)), and the drawn bar is max(2, bh - 1) tall. For a result spanning 48 semitones - four octaves - bh comes out at 3 and each bar is drawn 2 pixels tall. At that size you can tell roughly which register a note is in, and not much more.

Changing Transpose does not move the picture

This is the one that surprised me. The drawing function takes the notes and nothing else:

drawRoll(notes);                            // no transpose argument
var midi = buildMidi(notes, bpm, transpose);  // transpose applied here

Transpose is applied when the .mid is written, and again when the note list is copied to the clipboard. It is never applied to the preview. I checked this on the live page rather than trusting the reading: I converted the same four-second file at Transpose 0, then set Transpose to +12 and converted again, and compared the two canvases pixel by pixel.

Transpose 0Transpose +12
Canvas pixels differing0 of 135,000
Pitch range readoutC4 - C5C4 - C5
Copied note list, first three notesC4 D4 E4C5 D5 E5
Downloaded .mid105 bytes105 bytes, one octave higher

So if you set Transpose, download, and then look at the preview to check what happened, the preview will tell you nothing changed. It did change - in the file. The Pitch range readout next to the canvas has the same blind spot, because it is computed from the same untransposed notes.

The preview is a snapshot, not a live view

The function is called from exactly one place: inside the conversion, right after the notes are detected. There is no other call site anywhere in the page. To confirm the consequence, I converted a file and then changed each control in turn without pressing Convert again, fingerprinting the canvas after each change:

After conversion, changing…Canvas fingerprint
(just converted)1740055203
Transpose to +121740055203
Tempo to 60 BPM1740055203
Confidence to 0.501740055203
Shortest note to 400 ms1740055203

Identical every time. The preview shows the result of the conversion you already ran, and it keeps showing it until you run another one.

The Clear button behaves the way the code says it does. It hides the whole result card with display: none and drops the stored notes and .mid, but it does not erase the canvas - the fingerprint was still 1740055203 after clicking it - and it does not reset the four controls. Whatever you typed into Tempo, Confidence, Transpose and Shortest note is still there for your next conversion.

What the preview is good for, and what it is not

Genuinely useful for: seeing whether the result is empty, seeing roughly how many notes came back, and seeing the shape of the line - whether the melody rises and falls where you expect it to. For a monophonic line that is most of what you need to know before you download.

Not reliable for:

For anything that needs real numbers, use the Copy note list button instead. It writes out one line per note with the start, end, duration and velocity to three decimal places, which is precise enough to check against the audio. One caution: that list is transposed while the preview is not, so after a Transpose the list and the picture will disagree about pitch. The line starting # Start(s) End(s) Dur(s) Vel Note is the header; the numbers under it are the ones worth keeping.

If you want to check a conversion properly rather than eyeball the preview, the checks that actually catch mistakes are all numeric, and if you want to know what the downloaded file contains before you open it, the format is fixed and documented here.

How I measured this

  1. Read the drawing code. drawRoll, noteListText and noteName were lifted verbatim out of this page's HTML and run under Node against a canvas stub that recorded every drawing call and the globalAlpha in force at the time. Ten note sets, from a single note to a four-octave spread. All geometry below comes from those recorded calls, not from estimating the picture.
  2. Ran the real page. Real WAV files were pushed into the page's file input over the browser's debugging protocol, Convert was clicked, and the resulting canvas was read back with getImageData - all 135,000 pixels of it - six conversions in total. Nothing in the pixel measurements is simulated.
  3. Closed the loop. For each bar, the position and opacity were computed from the page's own note list using the source formulas, and then compared with the actual pixel at that point. 17 of 17 bars matched to within one colour step.

Honest boundaries