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 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.
| On screen | Comes from | Range in practice |
|---|---|---|
| Vertical position | The note number | Scaled to the lowest and highest note in this result, never less than 12 semitones tall |
| Horizontal position and width | The note's start and end time | Scaled so the last note's end is at the right edge; the axis has no printed values |
| Opacity | The note's velocity | 0.45 + vel/127 * 0.55 - about 0.45 to 0.88 in practice |
| Bar height | Nothing | One value for every note in a given result |
| Colour | Nothing | One 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 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.
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 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 pixel | Bar height |
|---|---|---|---|
| 45 | 0.6449 | 0.6429 | 9.92 px |
| 50 | 0.6665 | 0.6652 | 9.92 px |
| 65 | 0.7315 | 0.7321 | 9.92 px |
| 80 | 0.7965 | 0.7946 | 9.92 px |
| 90 | 0.8398 | 0.8393 | 9.92 px |
| 100 | 0.8831 | 0.8839 | 9.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 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.
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 0 | Transpose +12 | |
|---|---|---|
| Canvas pixels differing | 0 of 135,000 | |
| Pitch range readout | C4 - C5 | C4 - C5 |
| Copied note list, first three notes | C4 D4 E4 | C5 D5 E5 |
| Downloaded .mid | 105 bytes | 105 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 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 +12 | 1740055203 |
| Tempo to 60 BPM | 1740055203 |
| Confidence to 0.50 | 1740055203 |
| Shortest note to 400 ms | 1740055203 |
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.
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.
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.getImageData - all 135,000 pixels of it - six conversions in total. Nothing in the
pixel measurements is simulated.