Skip to content

Commit

Permalink
feat: add volume threshold control (#189)
Browse files Browse the repository at this point in the history
Closes #188
  • Loading branch information
ianprime0509 authored Jan 4, 2024
1 parent b740fe6 commit d830636
Show file tree
Hide file tree
Showing 4 changed files with 190 additions and 4 deletions.
1 change: 1 addition & 0 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ <h1>Pitchy example</h1>
navigator.mediaDevices.getUserMedia({ audio: true }).then((stream) => {
audioContext.createMediaStreamSource(stream).connect(analyserNode);
const detector = PitchDetector.forFloat32Array(analyserNode.fftSize);
detector.minVolumeDecibels = -10;
const input = new Float32Array(detector.inputLength);
updatePitch(analyserNode, detector, input, audioContext.sampleRate);
});
Expand Down
17 changes: 17 additions & 0 deletions docs/playground.html
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ <h1>Pitchy playground</h1>
<fieldset>
<legend>Data quality</legend>

<div class="row">
<label
>Minimum volume (dB):
<input id="min-volume-decibels" type="number" max="0" value="-10"
/></label>
</div>

<div class="row">
<label
>Minimum clarity (%):
Expand Down Expand Up @@ -173,6 +180,7 @@ <h1>Pitchy playground</h1>
const history = [];
let historyLength = 100;

let minVolumeDecibels = -10;
let minClarityPercent = 95;
let [minPitch, maxPitch] = [60, 10000];

Expand Down Expand Up @@ -317,6 +325,7 @@ <h1>Pitchy playground</h1>
});
audioContext.createMediaStreamSource(micStream).connect(analyserNode);
detector = PitchDetector.forFloat32Array(analyserNode.fftSize);
detector.minVolumeDecibels = minVolumeDecibels;
inputBuffer = new Float32Array(detector.inputLength);
actualInputBufferSize.innerText = inputBuffer.length.toFixed();
}
Expand Down Expand Up @@ -362,6 +371,14 @@ <h1>Pitchy playground</h1>
resetAudioContext();
});

const minVolumeDecibelsInput = document.getElementById(
"min-volume-decibels",
);
minVolumeDecibelsInput.addEventListener("change", () => {
minVolumeDecibels = Number.parseFloat(minVolumeDecibelsInput.value);
if (detector) detector.minVolumeDecibels = minVolumeDecibels;
});

const minClarityPercentInput = document.getElementById(
"min-clarity-percent",
);
Expand Down
99 changes: 96 additions & 3 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,9 +254,12 @@ export class PitchDetector {
_autocorrelator;
/** @private @type {T} */
_nsdfBuffer;
// TODO: it might be nice if this were configurable
/** @private @readonly */
/** @private @type {number} */
_clarityThreshold = 0.9;
/** @private @type {number} */
_minVolumeAbsolute = 0.0;
/** @private @type {number} */
_maxInputAmplitude = 1.0;

/**
* A helper method to create an {@link PitchDetector} using {@link Float32Array} buffers.
Expand Down Expand Up @@ -313,6 +316,73 @@ export class PitchDetector {
return this._autocorrelator.inputLength;
}

/**
* Sets the clarity threshold used when identifying the correct pitch (the constant
* `k` from the MPM paper). The value must be between 0 (exclusive) and 1
* (inclusive), with the most suitable range being between 0.8 and 1.
*
* @param threshold {number} the clarity threshold
*/
set clarityThreshold(threshold) {
if (!Number.isFinite(threshold) || threshold <= 0 || threshold > 1) {
throw new Error("clarityThreshold must be a number in the range (0, 1]");
}
this._clarityThreshold = threshold;
}

/**
* Sets the minimum detectable volume, as an absolute number between 0 and
* `maxInputAmplitude`, inclusive, to consider in a sample when detecting the
* pitch. If a sample fails to meet this minimum volume, `findPitch` will
* return a clarity of 0.
*
* Volume is calculated as the RMS (root mean square) of the input samples.
*
* @param volume {number} the minimum volume as an absolute amplitude value
*/
set minVolumeAbsolute(volume) {
if (
!Number.isFinite(volume) ||
volume < 0 ||
volume > this._maxInputAmplitude
) {
throw new Error(
`minVolumeAbsolute must be a number in the range [0, ${this._maxInputAmplitude}]`,
);
}
this._minVolumeAbsolute = volume;
}

/**
* Sets the minimum volume using a decibel measurement. Must be less than or
* equal to 0: 0 indicates the loudest possible sound (see
* `maxInputAmplitude`), -10 is a sound with a tenth of the volume of the
* loudest possible sound, etc.
*
* Volume is calculated as the RMS (root mean square) of the input samples.
*
* @param db {number} the minimum volume in decibels, with 0 being the loudest
* sound
*/
set minVolumeDecibels(db) {
if (!Number.isFinite(db) || db > 0) {
throw new Error("minVolumeDecibels must be a number <= 0");
}
this._minVolumeAbsolute = this._maxInputAmplitude * 10 ** (db / 10);
}

/**
* Sets the maximum amplitude of an input reading. Must be greater than 0.
*
* @param amplitude {number} the maximum amplitude (absolute value) of an input reading
*/
set maxInputAmplitude(amplitude) {
if (!Number.isFinite(amplitude) || amplitude <= 0) {
throw new Error("maxInputAmplitude must be a number > 0");
}
this._maxInputAmplitude = amplitude;
}

/**
* Returns the pitch detected using McLeod Pitch Method (MPM) along with a
* measure of its clarity.
Expand All @@ -325,9 +395,15 @@ export class PitchDetector {
* @param input {ArrayLike<number>} the time-domain input data
* @param sampleRate {number} the sample rate at which the input data was
* collected
* @returns {[number, number]} the detected pitch, in Hz, followed by the clarity
* @returns {[number, number]} the detected pitch, in Hz, followed by the
* clarity. If a pitch cannot be determined from the input, such as if the
* volume is too low (see `minVolumeAbsolute` and `minVolumeDecibels`), this
* will be `[0, 0]`.
*/
findPitch(input, sampleRate) {
// If the highest key maximum is less than the minimum volume, we don't need
// to bother detecting the pitch, as the sample is too quiet.
if (this._belowMinimumVolume(input)) return [0, 0];
this._nsdf(input);
const keyMaximumIndices = getKeyMaximumIndices(this._nsdfBuffer);
if (keyMaximumIndices.length === 0) {
Expand Down Expand Up @@ -355,6 +431,23 @@ export class PitchDetector {
return [sampleRate / refinedResultIndex, Math.min(clarity, 1.0)];
}

/**
* Returns whether the input audio data is below the minimum volume allowed by
* the pitch detector.
*
* @private
* @param input {ArrayLike<number>}
* @returns {boolean}
*/
_belowMinimumVolume(input) {
if (this._minVolumeAbsolute === 0) return false;
let squareSum = 0;
for (let i = 0; i < input.length; i++) {
squareSum += input[i] ** 2;
}
return Math.sqrt(squareSum / input.length) < this._minVolumeAbsolute;
}

/**
* Computes the NSDF of the input and stores it in the internal buffer. This
* is equation (9) in the McLeod pitch method paper.
Expand Down
77 changes: 76 additions & 1 deletion test/pitch-detector.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,71 @@ test("inputLength returns the configured input length", () => {
assert.equal(PitchDetector.forFloat32Array(10).inputLength, 10);
});

test("set clarityThreshold throws an error if the value is invalid", () => {
const detector = PitchDetector.forFloat32Array(8);
assert.throws(
() => (detector.clarityThreshold = NaN),
"clarityThreshold must be a number in the range (0, 1]",
);
assert.throws(
() => (detector.clarityThreshold = Infinity),
"clarityThreshold must be a number in the range (0, 1]",
);
assert.throws(
() => (detector.clarityThreshold = -1),
"clarityThreshold must be a number in the range (0, 1]",
);
assert.throws(
() => (detector.clarityThreshold = 0),
"clarityThreshold must be a number in the range (0, 1]",
);
assert.throws(
() => (detector.clarityThreshold = 2),
"clarityThreshold must be a number in the range (0, 1]",
);
});

test("set minVolumeAbsolute throws an error if the value is invalid", () => {
const detector = PitchDetector.forFloat32Array(8);
assert.throws(
() => (detector.minVolumeAbsolute = NaN),
"minVolumeAbsolute must be a number in the range [0, 1]",
);
assert.throws(
() => (detector.minVolumeAbsolute = Infinity),
"minVolumeAbsolute must be a number in the range [0, 1]",
);
assert.throws(
() => (detector.minVolumeAbsolute = -1),
"minVolumeAbsolute must be a number in the range [0, 1]",
);
assert.throws(
() => (detector.minVolumeAbsolute = 2),
"minVolumeAbsolute must be a number in the range [0, 1]",
);
detector.maxInputAmplitude = 5;
assert.throws(
() => (detector.minVolumeAbsolute = 10),
"minVolumeAbsolute must be a number in the range [0, 5]",
);
});

test("set minVolumeDecibels throws an error if the value is invalid", () => {
const detector = PitchDetector.forFloat32Array(8);
assert.throws(
() => (detector.minVolumeDecibels = NaN),
"minVolumeDecibels must be a number <= 0",
);
assert.throws(
() => (detector.minVolumeDecibels = Infinity),
"minVolumeDecibels must be a number <= 0",
);
assert.throws(
() => (detector.minVolumeDecibels = 1),
"minVolumeDecibels must be a number <= 0",
);
});

test("findPitch throws an error if the input is not of the configured input length", () => {
const detector = PitchDetector.forFloat32Array(8);
assert.throws(
Expand All @@ -131,6 +196,15 @@ test("findPitch returns a clarity of 0 when given an array of zeros", () => {
assert.equal(detector.findPitch(zeros, 48000)[1], 0);
});

test("findPitch returns a clarity of 0 when given an input with low volume, if configured", () => {
const detector = float32InputType.supplier(2048);
detector.minVolumeAbsolute = 0.1;
const input = float32InputType.arrayConverter(
sineWave(2048, 440, 0.1, 44100),
);
assert.equal(detector.findPitch(input, 44100)[1], 0);
});

/**
* Runs a parameterized test case for the pitch detection function.
*
Expand Down Expand Up @@ -158,6 +232,7 @@ function runTests(
*/
const findPitch = (input, sampleRate) => {
const detector = bufferType.supplier(input.length);
detector.minVolumeDecibels = -10;
return detector.findPitch(input, sampleRate);
};

Expand Down Expand Up @@ -202,7 +277,7 @@ for (const inputType of [float64InputType, numberArrayInputType]) {
}

for (const waveform of waveforms) {
for (const amplitude of [0.5, 1.0, 2.0]) {
for (const amplitude of [0.25, 0.5, 1.0]) {
for (const frequency of [
440, // A4
880, // A5
Expand Down

0 comments on commit d830636

Please sign in to comment.