A channel average, a 16 kHz resample, and the anti-alias filter that is not there.
Short answer: before the detector runs, every file is decoded, averaged down to one channel, and resampled to 16 kHz with plain linear interpolation. I measured all three steps. The sample rate of your file changes nothing - 8 kHz, 44.1 kHz and 96 kHz sources gave identical note lists. The channel count does: because the channels are averaged rather than one of them picked, a stereo file whose two sides are out of phase averages to silence and returns nothing. And the resampler has no anti-alias filter, so anything above 8 kHz folds back into the analysis - a pure 15 kHz tone, far above the 2000 Hz ceiling, came back as a B5 note at 991 Hz.
This page is the third in a series that takes the converter apart. The pitch detection article covers what the detector does with a frame; this one covers what happens to your audio before a frame exists. Everything below is read out of the page's own source or measured by me, and the last measurement was made on the live page itself, not on a copy of it.
The analysis is a few hundred lines of plain JavaScript inside a single HTML file, so the preprocessing is readable in full. Two short functions do all of it.
function toMono(buf) {
var n = buf.length;
var chs = buf.numberOfChannels;
var out = new Float32Array(n);
for (var c = 0; c < chs; c++) {
var d = buf.getChannelData(c);
for (var i = 0; i < n; i++) { out[i] += d[i]; }
}
if (chs > 1) { for (var j = 0; j < n; j++) { out[j] /= chs; } }
return out;
}
function resample(src, srcRate, dstRate) {
if (dstRate === srcRate) { return src; }
var ratio = srcRate / dstRate;
var n = Math.max(1, Math.floor(src.length / ratio));
var out = new Float32Array(n);
for (var i = 0; i < n; i++) {
var pos = i * ratio;
var i0 = Math.floor(pos);
var i1 = Math.min(i0 + 1, src.length - 1);
var f = pos - i0;
out[i] = src[i0] * (1 - f) + src[i1] * f;
}
return out;
}
They are called in this order, in the main run:
var mono = resample(toMono(audioBuffer), audioBuffer.sampleRate, TARGET_RATE);
var notes = framesToNotes(mono, TARGET_RATE, threshold, minMs);
with TARGET_RATE = 16000. Three things are worth pulling out of that.
resample returns the buffer untouched when the rates match. That shortcut turns out to be
the way to sidestep the aliasing problem, which is also covered below.One thing I want to be careful about: the source does not say why 16 kHz was chosen. What the source does show is that 16 kHz gives a Nyquist limit of 8000 Hz, four times the 2000 Hz ceiling the detector can actually report. So the resample is not what sets the pitch range - the lag window is. I am reading the number, not the intent.
I generated the same eight-note C major scale - C4 to C5, half a second per note, amplitude 0.5 - at six
different sample rates, and ran each one through the page's own toMono and
resample and then its own detector, at the default sensitivity.
| Source rate | Samples in | After resample | Notes found | Note list |
|---|---|---|---|---|
| 8,000 Hz | 32,000 | 64,000 | 8 | C4 D4 E4 F4 G4 A4 B4 C5 |
| 16,000 Hz | 64,000 | 64,000 | 8 | C4 D4 E4 F4 G4 A4 B4 C5 |
| 22,050 Hz | 88,200 | 64,000 | 8 | C4 D4 E4 F4 G4 A4 B4 C5 |
| 44,100 Hz | 176,400 | 64,000 | 8 | C4 D4 E4 F4 G4 A4 B4 C5 |
| 48,000 Hz | 192,000 | 64,000 | 8 | C4 D4 E4 F4 G4 A4 B4 C5 |
| 96,000 Hz | 384,000 | 64,000 | 8 | C4 D4 E4 F4 G4 A4 B4 C5 |
Not just the same notes - the same start times, to the millisecond: 0.000, 0.496, 0.992, 1.488, 2.000, 2.496, 2.992, 3.488 seconds, and the same velocity of 100 on all eight. A 12-times difference in sample rate produced byte-identical note data.
That is less surprising once you notice that every row collapses to the same 64,000 samples - 4.0 seconds at 16 kHz - before the detector sees anything. The resampling step throws away the extra resolution the higher rates were carrying. If you have been exporting a 96 kHz master because you assumed the converter would make use of it, it does not.
The 8 kHz row is worth a second look. An 8 kHz file has a Nyquist limit of 4 kHz, and the detector's ceiling is 2000 Hz, so there is still headroom. It works - but only just, and any real content up there would already have been damaged by the low sample rate before the converter ever saw it.
Because toMono sums the channels and divides by the count, the result depends on how the two
sides relate to each other. I ran the same scale through five channel arrangements:
| Input | Peak after mixdown | Notes found | What happened |
|---|---|---|---|
| Mono (reference) | 0.5000 | 8 | Baseline |
| Stereo, L = R | 0.5000 | 8 | Identical to mono, as expected |
| Stereo, R silent | 0.2500 | 8 | Half the level, same eight notes |
| Stereo, R at half level | 0.3750 | 8 | Same eight notes |
| Stereo, L and R out of phase | 0.0000 | 0 | Total cancellation - no notes at all |
The inverted case is the one to remember. The left channel is the scale; the right channel is the same scale multiplied by minus one. Each channel on its own is a perfectly good signal. Averaged, every sample pair sums to zero, and the converter is handed two seconds of digital silence.
This is not a hypothetical. Out-of-phase stereo shows up in real material - a badly wired cable, a stereo-widening plugin pushed too far, a doubled take that was phase-flipped, a mid-side decode gone wrong. It is easy to miss because the file plays back fine in one ear and fine in the other, and often sounds wider than the original. The converter reports zero notes, and the status line says the conversion finished. There is no error, because from the inside there is nothing wrong: the audio really is silent.
The other two rows are the quieter lesson. Halving the level - one channel silent - did not change a single note or a single velocity value. That is not the mixdown being clever; it is the velocity curve being flat at the top, which is documented in the how to check a conversion article.
One more stereo case worth knowing: I fed 220 Hz into the left channel and 440 Hz into the right. The averaged signal contains both tones. The output was one note, A3 - the lower one. This is the monophonic limitation showing up in the mixdown stage: two pitches in, one pitch out, decided before the detector ever sees a frame.
Bit depth sets how much dynamic range the file can carry, so it is a reasonable thing to suspect. I quantised the same scale to six different word lengths - uniform quantisation referenced to full scale - and ran each through the detector.
| Signal level | 24-bit | 16-bit | 8-bit | 6-bit | 4-bit |
|---|---|---|---|---|---|
| Peaking at 0.50 | 8 notes | 8 notes | 8 notes | 8 notes | 8 notes |
| Peaking at 0.10 | 8 notes | 8 notes | 8 notes | 8 notes | 8 notes |
| Peaking at 0.03 | 8 notes | 8 notes | 8 notes | 8 notes | 0 notes |
Every note list was identical, all eight notes, all velocity 100, right down to 4-bit at normal level. The only failure is in the bottom-right cell: a signal peaking at 3 percent of full scale has so little room above the quantisation step at 4 bits that every sample rounds to zero, and there is nothing left to analyse.
The reason bit depth is so quiet here is that the audio pipeline is floating point. The browser's decoder hands the page 32-bit floats regardless of what the file contained, so by the time the detector runs, the original word length is already gone - it survives only as quantisation noise, and a clean, reasonably loud recording has plenty of margin over that. Bit depth is a real quality decision for archiving and for quiet material, but it is not what decides whether a conversion works.
Now the part that actually bites. Look again at what resample does when the rates differ: it
walks the source at a fractional step and linearly interpolates between the two nearest samples. That is
it. There is no step that removes frequencies which cannot be represented at the new rate.
When you sample a signal at 16 kHz, the highest frequency it can carry is 8000 Hz. Anything above that does not disappear - it reflects back down. The arithmetic is one line:
folded frequency = | f - 16000 * round(f / 16000) |
A 15,000 Hz component folds to |15000 - 16000| = 1000 Hz. That is squarely inside the detector's 60-2000 Hz window. The detector has no way to tell it apart from a real 1000 Hz tone, because at 16 kHz the two are literally the same samples.
I fed pure sine tones from 14 kHz to 18 kHz through the page's own pipeline at 44.1 kHz. The detector reported a note for every single one:
| Tone fed in | Fold arithmetic predicts | Reported note | Reported Hz |
|---|---|---|---|
| 14,000 Hz | 2000 Hz | B6 | 2009.01 |
| 14,250 Hz | 1750 Hz | A6 | 1778.97 |
| 14,500 Hz | 1500 Hz | F#6 | 1493.55 |
| 14,750 Hz | 1250 Hz | D#6 | 1267.54 |
| 15,000 Hz | 1000 Hz | B5 | 991.08 |
| 15,250 Hz | 750 Hz | G5 | 769.53 |
| 15,500 Hz | 500 Hz | B4 | 488.70 |
| 15,750 Hz | 250 Hz | C4 | 260.11 |
| 16,250 Hz | 250 Hz | C#4 | 275.59 |
| 16,500 Hz | 500 Hz | C#5 | 545.28 |
| 16,750 Hz | 750 Hz | G5 | 768.30 |
| 17,000 Hz | 1000 Hz | B5 | 990.94 |
| 17,250 Hz | 1250 Hz | D#6 | 1277.12 |
| 17,500 Hz | 1500 Hz | G5 | 761.68 |
| 17,750 Hz | 1750 Hz | A#6 | 1812.44 |
| 18,000 Hz | 2000 Hz | B6 | 2002.50 |
The reported pitch follows the fold arithmetic closely across the middle of the window. Two rows do not match cleanly and I am leaving them in rather than tidying them away: 17,500 Hz came back at 761.68 Hz instead of 1500 Hz, exactly an octave low, which is the same second-period failure the pitch detection article documents above the ceiling. The resampled artifact is not always a clean tone, so the detector sometimes halves it.
Two control runs pin the cause down. I took the identical 15 kHz tone and removed everything above 7.8 kHz in the frequency domain before resampling it. Same file, same pipeline, same settings: zero notes. And when I fed the page a 220 Hz tone on its own, at either 44.1 kHz or 16 kHz, it reported A3 at 220 Hz in both cases. So the B5 is not a quirk of the detector and not a quirk of 15 kHz - it is produced by the resampler, and removing the energy above the new Nyquist removes it.
The fold formula puts content between 14 kHz and 18 kHz into the 60-2000 Hz window. But tones outside that window still produced notes too, because a folded component that lands at 3 kHz or 6 kHz is still a strong periodic signal, and the detector's lag search will happily lock onto a sub-multiple of it. Every tone I tried from 8 kHz to 20 kHz returned a note. A few examples from outside the window:
| Tone fed in | Folds to | Reported |
|---|---|---|
| 8,250 Hz | 7750 Hz | D5 at 592.89 Hz |
| 10,000 Hz | 6000 Hz | B6 at 2003.82 Hz |
| 12,000 Hz | 4000 Hz | B6 at 2008.34 Hz |
| 19,500 Hz | 3500 Hz | G4 at 389.66 Hz |
None of those reported pitches has any relationship to the tone that went in. This is the part I would flag hardest: the tool's stated range is 60-2000 Hz, and it is easy to read that as "everything outside the range is ignored". It is not. Content outside the range is not ignored; it is folded, and then it is believed.
I swept the level of a 15 kHz tone down to find where it stops mattering:
| 15 kHz level | Level in dBFS | Result |
|---|---|---|
| 0.030 | -30.5 dB | B5 at 991 Hz |
| 0.020 | -34.0 dB | B5 at 991 Hz |
| 0.016 | -35.9 dB | B5 at 991 Hz |
| 0.014 | -37.1 dB | B5 at 991 Hz |
| 0.012 | -38.4 dB | No notes |
| 0.010 | -40.0 dB | No notes |
The artifact survives down to about -37 dBFS. That is not a tiny amount of energy. Cymbal wash, sibilance, string bow noise, tape hiss and dither all live in that region, and plenty of them sit well above it.
A pure 15 kHz tone is a laboratory signal. The question that matters is whether high-frequency content disturbs a note that is actually there. I built a more realistic source: A3 at 220 Hz with harmonics two through eight at amplitude 1/k, then added a 15 kHz component at four different levels.
| 15 kHz level | Relative to the fundamental | Reported |
|---|---|---|
| none | - | A3 at 220.01 Hz - correct |
| 0.02 | -23.5 dB | A3 at 220.00 Hz - correct |
| 0.05 | -15.6 dB | A3 at 219.98 Hz - correct |
| 0.10 | -9.5 dB | A3 at 219.93 Hz - correct |
| 0.20 | -3.5 dB | A2 at 110.51 Hz - one octave low |
Up to about 10 dB below the fundamental, the high-frequency content is harmless. At 3.5 dB below, the whole conversion drops an octave: the file says A3, the MIDI says A2.
Everything above was measured with the page's functions extracted and run outside the browser. That is the same code, but it is not the same thing as the page. So I made real WAV files, loaded easyaudiotomidi.com in a browser, set the file input, pressed Convert, and read the numbers the page printed:
| File uploaded | Notes found | Pitch range shown |
|---|---|---|
| 44.1 kHz, A3 + harmonics | 1 | A3 - A3 |
| 44.1 kHz, same + 15 kHz at -3.5 dB | 1 | A2 - A2 |
| 44.1 kHz, same + 15 kHz at -23.5 dB | 1 | A3 - A3 |
| 16 kHz, the -3.5 dB file resampled first | 1 | A3 - A3 |
The live page reports A2 for the third file and A3 for the fourth, exactly as the offline measurements predicted. This is not a theoretical property of the code - it is what the page on this site does with a file you hand it today.
if (dstRate === srcRate) { return src; }. Hand the page a 16 kHz file and
its own resampler never runs, so the aliasing step is skipped. I tested this end to end - the file that
produced A2 as a 44.1 kHz upload produced A3 once it had been resampled to 16 kHz with a proper tool.
Any audio editor or ffmpeg -ar 16000 will do it.The other side of the same coin - how much the input bitrate moves the result, as opposed to the sample rate - was measured separately in 128 kbps vs 320 kbps MP3 to MIDI, and the answer there was also less dramatic than you might expect.
toMono, resample and the detector verbatim from the shipped source and ran
them in Node. The end-to-end table above is the exception - those four rows are the real page in a real
browser - but the sweeps and level tables are the extracted code.