I'm attempting to create a guitar tuning app using react and web audio API. Here is the DOM structure of my project
index.js
\__App.js
\__Tuner.js
Here's a brief overview on how the app works...
Essentially.
On the home screen, you click on the start button and we gain access to the user's microphone, then we instantiate an analyserNode using the window.AudioContext class which provides real-time data of the audio frequencies being picked up by the microphone, next our data is converted into a single number value thanks to an algorithm written in the AutoCorrelate.js file, the value is stored into a hook state and finally - through a series of renders of various state - the browser renders the pitch # value, the pitch letter and a gauge/status bar that moves in relation to the pitch's state value.
As you can see from the logs and from l.60 of the code, the updatePitch
func needs to be called every 1 ms in order to update the pitch value being rendered onto the screen. Nested inside our updatePitch
func are various state hooks that are being called every 1ms: setPitchNote
, setPitchScale
, setDetune
, setNotification
. One would assume that that would cause a re-render issue but it actually works perfectly.
File: Tuner.js
import React, { useState, useEffect } from 'react';
import AudioContext from '../contexts/AudioContext.js';
import autoCorrelate from "../libs/AutoCorrelate.js";
import {
noteFromPitch,
centsOffFromPitch,
getDetunePercent,
} from "../libs/Helpers.js";
const audioCtx = AudioContext.getAudioContext();
const analyserNode = AudioContext.getAnalyser();
const bufferlength = 2048;
let buf = new Float32Array(bufferlength);
const noteStrings = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const Tuner = () => {
/*////AUDIO STATE////*/
const [source, setSource] = useState(null);
const [started, setStart] = useState(false);
const [pitchNote, setPitchNote] = useState("C");
const [pitchScale, setPitchScale] = useState("4");
const [pitch, setPitch] = useState("0 Hz");
const [detune, setDetune] = useState("0");
const [notification, setNotification] = useState(false);
/*////UPDATES PITCH////*/
const updatePitch = (time) => {
analyserNode.getFloatTimeDomainData(buf);
var ac = autoCorrelate(buf, audioCtx.sampleRate);
if (ac > -1) {
let note = noteFromPitch(ac);
let sym = noteStrings[note % 12];
let scl = Math.floor(note / 12) - 1;
let dtune = centsOffFromPitch(ac, note);
setPitch(parseFloat(ac).toFixed(2) + " Hz");
setPitchNote(sym);
setPitchScale(scl);
setDetune(dtune);
setNotification(false);
console.log(note, sym, scl, dtune, ac);
}
};
setInterval(updatePitch, 1);
useEffect(() => {
if (source != null) {
source.connect(analyserNode);
}
}, [source]);
const start = async () => {
const input = await getMicInput();
if (audioCtx.state === "suspended") {
await audioCtx.resume();
}
setStart(true);
setNotification(true);
setTimeout(() => setNotification(false), 5000);
setSource(audioCtx.createMediaStreamSource(input));
};
const stop = () => {
source.disconnect(analyserNode);
setStart(false);
};
const getMicInput = () => {
return navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
autoGainControl: false,
noiseSuppression: false,
latency: 0,
},
});
};
return (
<div className='tuner'>
<div className='notification' style={{color: notification ? 'black' : 'white'}}>
Please, bring your instrument near to the microphone!
</div>
<div className ='container'>
<div className='screen'>
<div className='top-half'>
<span className='note-letter'>{pitchNote}</span>
<span className='note-number'>{pitchScale}</span>
</div>
<div className='bottom-half'>
<span className='meter-left' style={{
width: (detune < 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
<span className='dial'>|</span>
<span className='meter-right' style={{
width: (detune > 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
</div>
<div className='text'>
<span>{pitch}</span>
</div>
</div>
</div>
<div className='tuning-btn'>
{!started ?
(<button onClick={() => {start()}}>Start</button>)
:
(<button onClick={() => {stop()}}>Stop</button>)
}
</div>
</div>
)
}
export default Tuner;
Now, I want to make a proper guitar tuner. Meaning that instead of rendering every pitch value returned to the screen. I want to compare the current pitch value to another 'target' value and have the UI elements react differently depending on whether the current pitch matches the target pitch.
A standard guitar has 6 strings...ergo 6 target pitches
const standard = {
E: 82.41,
A: 110,
D: 146.8,
G: 196,
B: 246.9,
e: 329.6
}
I attempted to code out the logic behind this just for the low E string for the time being and this is what I came up with.
Look at l.61- 82 for the lowE string pitch function and l.138 ... for changes, I've made to the JSX element
File: Tuner.js
import React, { useState, useEffect } from 'react';
import AudioContext from '../contexts/AudioContext.js';
import autoCorrelate from "../libs/AutoCorrelate.js";
import {
noteFromPitch,
centsOffFromPitch,
getDetunePercent,
} from "../libs/Helpers.js";
const audioCtx = AudioContext.getAudioContext();
const analyserNode = AudioContext.getAnalyser();
const bufferlength = 2048;
let buf = new Float32Array(bufferlength);
let log = console.log.bind(console);
const noteStrings = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
];
const standardStrings = ['A', 'D', 'G', 'B', 'e'];
const standard = {
E: 82.41,
A: 110,
D: 146.8,
G: 196,
B: 246.9,
e: 329.6
}
const dropD = {
D: 73.42,
A: 110,
D: 146.8,
G: 196,
B: 246.9,
E: 329.6
}
const Tuner = () => {
/*////AUDIO STATE////*/
const [source, setSource] = useState(null);
const [started, setStart] = useState(false);
const [pitchNote, setPitchNote] = useState("C");
const [pitchScale, setPitchScale] = useState("4");
const [pitch, setPitch] = useState("0 Hz");
const [detune, setDetune] = useState("0");
const [notification, setNotification] = useState(false);
/*Low E String */
const [ENote, setENote] = useState("E");
const [Epitch, setEPitchScale] = useState("2");
const [findingE, startFindingE] = useState(false);
const [onKey, isOnKey] = useState('Play');
const isE = () => {
let ac = autoCorrelate(buf, audioCtx.sampleRate);
if (ac > -1) {
let pitchValue = parseFloat(ac).toFixed(2);
log('ac:', ac);
log('pitchValue:', pitchValue);
if (standard.E - .75 <= pitchValue && pitchValue <= standard.E + .75) {
isOnKey('GOOD');
} else if (pitchValue <= standard.E - .75) {
isOnKey('b');
} else if (pitchValue >= standard.E - .75) {
isOnKey('#');
}
}
}
if (findingE) {setInterval(isE, 100)};
/*////UPDATES PITCH////*/
const updatePitch = (time) => {
analyserNode.getFloatTimeDomainData(buf);
var ac = autoCorrelate(buf, audioCtx.sampleRate);
if (ac > -1) {
let note = noteFromPitch(ac);
let sym = noteStrings[note % 12];
let scl = Math.floor(note / 12) - 1;
let dtune = centsOffFromPitch(ac, note);
setPitch(parseFloat(ac).toFixed(2) + " Hz");
setPitchNote(sym);
setPitchScale(scl);
setDetune(dtune);
setNotification(false);
// console.log(note, sym, scl, dtune, ac);
}
};
setInterval(updatePitch, 1);
useEffect(() => {
if (source) {
source.connect(analyserNode);
}
}, [source]);
const start = async () => {
const input = await getMicInput();
if (audioCtx.state === "suspended") {
await audioCtx.resume();
}
setStart(true);
setNotification(true);
setTimeout(() => setNotification(false), 5000);
setSource(audioCtx.createMediaStreamSource(input));
};
const stop = () => {
source.disconnect(analyserNode);
setStart(false);
};
const getMicInput = () => {
return navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
autoGainControl: false,
noiseSuppression: false,
latency: 0,
},
});
};
return (
<div className='tuner'>
<div className='notification' style={ notification ? {color:'white', backgroundColor: 'lightgrey'} : {color: 'white'}}>
Please, bring your instrument near to the microphone!
</div>
<div className ='tuner-container'>
<div className='screen'>
<div className='top-half'>
<span className='note-letter'
style={ (findingE && onKey === 'b' || findingE && onKey === '#' ) ? {color: 'red'} : (findingE && onKey === 'GOOD' ? r: 'lightgreen'} : {color: 'black'} )}>
{!findingE ? (pitchNote) : (ENote)}
</span>
<span style={ (findingE && onKey === 'b' || findingE && onKey === '#' ) ? {color: 'red'} : (findingE && onKey === 'GOOD' lor: 'lightgreen'} : {color: 'black'} )}className='note-number'>{!findingE ? (pitchScale) : (Epitch)}</span>
</div>
<div className='bottom-half'>
<span className='meter-left' style={{
width: (detune < 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
<span style={ (findingE && onKey === 'b' || findingE && onKey === '#' ) ? {color: 'red'} : (findingE && onKey === 'GOOD' lor: 'lightgreen'} : {color: 'black'} )} className='dial'>|</span>
<span className='meter-right' style={{
width: (detune > 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
</div>
<div className='pitch-text'>
<span style={ (findingE && onKey === 'b' || findingE && onKey === '#' ) ? {color: 'red'} : (findingE && onKey === 'GOOD' lor: 'lightgreen'} : {color: 'black'} )}>{!findingE ? (pitch) : (onKey)}</span>
</div>
</div>
</div>
<div className='tuning-btn'>
{!started ?
(<button onClick={() => {start()}}>Start</button>)
:
(<button onClick={() => {stop()}}>Stop</button>)
}
</div>
<div>
<div className='string'>
{!findingE ?
(<button onClick={() => {startFindingE(true)}}>E</button>)
:
(<button onClick={() => {startFindingE(false)}}>E</button>)
}
</div>
</div>
</div>
)
}
export default Tuner;
The logic is pretty similar to the first version of this app, with nested states and everything. And it works just as well
Now the issue is applying this to the remaining 6 target pitches. Obviously, I don't want to write a separate isNote
function for every string. I want to write a function that grabs the innerHTML letter from each 'guitar string' button and renders a different letter on the screen based on which button I've clicked.
This brought me to this
File: Tuner.js
const standardStrings = ['E', 'A', 'D', 'G', 'B', 'e'];
const standard = {
E: 82.41,
A: 110,
D: 146.8,
G: 196,
B: 246.9,
e: 329.6
}
/*////STANDARD TUNING////*/
const [standardNote, setStandardNote] = useState('');
const [standardPitch, setStandardPitch] = useState('');
const [findingStandard, startFindingStandard] = useState({finding: false, note: 'note', pitch: null});
const [onKey, isOnKey] = useState('play');
const standardTuning = (note) => {
const standard = {
E: [82.41, 2],
A: [110, 2],
D: [146.8, 3],
G: [196, 3],
B: [246.9, 3],
e: [329.6, 4]
}
let ac = autoCorrelate(buf, audioCtx.sampleRate);
let pitchValue = parseFloat(ac).toFixed(2);
log('pitchValue:', pitchValue);
log('standard[note]:', standard[note]);
if (ac > -1) {
startFindingStandard({...findingStandard, pitch: standard[note][1]})
if (standard[note][0] - .75 <= ac && ac <= standard[note][0] + .75) {
isOnKey('GOOD');
} else if (ac <= standard[note][0] - .75) {
isOnKey('b');
} else if (ac >= standard[note][0] - .75) {
isOnKey('#');
}
}
}
return (
<div className='tuner'>
<div className='notification' style={ notification ? {color:'white', backgroundColor: 'lightgrey'} : {color: 'white'}}>
Please, bring your instrument near to the microphone!
</div>
<div className ='tuner-container'>
<div className='screen'>
<div className='top-half'>
<span className='note-letter'
style={ (findingStandard.finding && onKey === 'b' || findingStandard.finding && onKey === '#' ) ? {color: 'red'} : (ngStandard.finding && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )}>
{!findingStandard.finding ? (pitchNote) : (findingStandard.note)}
</span>
<span style={ (findingStandard.finding && onKey === 'b' || findingStandard.finding && onKey === '#' ) ? {color: 'red'} : ingStandard.finding && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} ssName='note-number'>{!findingStandard.finding ? (pitchScale) : (findingStandard.pitch)}</span>
</div>
<div className='bottom-half'>
<span className='meter-left' style={{
width: (detune < 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
<span style={ (findingStandard.finding && onKey === 'b' || findingStandard.finding && onKey === '#' ) ? {color: 'red'} : ingStandard.finding && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )} className='dial'>|</span>
<span className='meter-right' style={{
width: (detune > 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
</div>
<div className='pitch-text'>
<span style={ (findingStandard.finding && onKey === 'b' || findingStandard.finding && onKey === '#' ) ? {color: 'red'} : ingStandard.finding && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )}>{!findingStandard.finding ? (pitch) : ()}</span>
</div>
</div>
</div>
<div className='tuning-btn'>
{!started ?
(<button onClick={() => {start()}}>Start</button>)
:
(<button onClick={() => {stop()}}>Stop</button>)
}
</div>
<div>
{standardStrings.map((string) => {
return (
<div className='string'>
{!findingStandard.finding ?
(<button onClick={(e) => {startFindingStandard({...findingStandard, finding: true, note: e.target.innerHTML, pitch: }>{string}</button>)
:
(<button onClick={() => {startFindingStandard({...findingStandard, finding: false, note: 'note', pitch: '' {string}</button>)
}
</div>
)
})}
</div>
</div>
)
Now the app is crashing due to there being too many re-renders.
I'm not quite sure how to handle this. I know that this is an error message that usually occurs when you nest a hook into the useEffect hook and fail to give it a dependency which causes an infinite loop...but I haven't found where the infinite loop is occurring. The logic doesn't really different compared to when I was trying to match the pitch with just the low E string.
Any thoughts? More importantly, does anyone have any advice on how to debug similar issues with this error message in the future?
Please let me know if you need any more information.
I'll also include the code used to convert the audio data into a single pitch value
File: AutoCorrelate.js
const autoCorrelate = (buf, sampleRate) => {
let [SIZE, rms] = [buf.length, 0];
for (let i = 0; i < SIZE; i++) {
let val = buf[i];
rms += val * val;
}
rms = Math.sqrt(rms / SIZE);
if (rms < 0.01) {
// not enough signal
return -1;
}
let [r1, r2, thres] = [0, SIZE - 1, 0.2];
for (let i = 0; i < SIZE / 2; i++)
if (Math.abs(buf[i]) < thres) {
r1 = i;
break;
}
for (let i = 1; i < SIZE / 2; i++)
if (Math.abs(buf[SIZE - i]) < thres) {
r2 = SIZE - i;
break;
}
buf = buf.slice(r1, r2);
SIZE = buf.length;
let c = new Array(SIZE).fill(0);
for (let i = 0; i < SIZE; i++) {
for (let j = 0; j < SIZE - i; j++) {
c[i] = c[i] + buf[j] * buf[j + i];
}
}
let d = 0;
while (c[d] > c[d + 1]) {
d++;
}
let [maxval, maxpos] = [-1, -1];
for (let i = d; i < SIZE; i++) {
if (c[i] > maxval) {
maxval = c[i];
maxpos = i;
}
}
let T0 = maxpos;
let [x1, x2, x3] = [c[T0 - 1], c[T0], c[T0 + 1]];
let [a, b] =[ (x1 + x3 - 2 * x2) / 2, (x3 - x1) / 2]
if (a) {
T0 = T0 - b / (2 * a)
};
return sampleRate / T0;
};
module.exports = autoCorrelate;
I managed to get a working solution for all 6 target pitches, I'm not sure what the issue was. I just redid it starting from scratch. If anyone has insight please feel free to share. Thank you.
/*////STATE & HOOKS////*/
const strings = [['E', 2], ['A', 2], ['D', 3], ['G', 3], ['B', 3], ['e', 4]];
const standard = {
E: 82.41,
A: 110,
D: 146.8,
G: 196,
B: 246.9,
e: 329.6
}
const [note, setNote] = useState("C");
const [Epitch, setEPitchScale] = useState("4");
const [findingPitch, startFindingPitch] = useState(false);
const [onKey, isOnKey] = useState('Play');
const isStandard = () => {
let ac = autoCorrelate(buf, audioCtx.sampleRate);
if (ac > -1) {
let pitchValue = Number(pitch.split('').slice(0, -3).join(''));
log('buf:', buf);
log('audioCtx.sampleRate', audioCtx.sampleRate);
log('ac:', ac);
log('pitchValue:', pitchValue);
if (standard[note] - .75 <= pitchValue && pitchValue <= standard[note] + .75) {
isOnKey('GOOD');
} else if (pitchValue <= standard[note] - .75) {
isOnKey('b');
} else if (pitchValue >= standard[note] - .75) {
isOnKey('#');
}
}
}
if (findingPitch) {setInterval(isStandard, 100)};
/*////JSX////*/
return (
<div className='tuner'>
<div className='notification' style={ notification ? {color:'white', backgroundColor: 'lightgrey'} : {color: 'white'}}>
Please, bring your instrument near to the microphone!
</div>
<div className ='tuner-container'>
<div className='screen'>
<div className='top-half'>
<span className='note-letter'
style={ (findingPitch && onKey === 'b' || findingPitch && onKey === '#' ) ? {color: 'red'} : (findingPitch && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )}>
{!findingPitch ? (pitchNote) : (note)}
</span>
<span style={ (findingPitch && onKey === 'b' || findingPitch && onKey === '#' ) ? {color: 'red'} : (findingPitch && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )}className='note-number'>{!findingPitch ? (pitchScale) : (Epitch)}</span>
</div>
<div className='bottom-half'>
<span className='meter-left' style={{
width: (detune < 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
<span style={ (findingPitch && onKey === 'b' || findingPitch && onKey === '#' ) ? {color: 'red'} : (findingPitch && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )} className='dial'>|</span>
<span className='meter-right' style={{
width: (detune > 0 ? getDetunePercent(detune) : "50") + "%",
}}></span>
</div>
<div className='pitch-text'>
<span style={ (findingPitch && onKey === 'b' || findingPitch && onKey === '#' ) ? {color: 'red'} : (findingPitch && onKey === 'GOOD' ? {color: 'lightgreen'} : {color: 'black'} )}>{!findingPitch ? (pitch) : (onKey)}</span>
</div>
</div>
</div>
<div className='tuning-btn'>
{!started ?
(<button onClick={() => {start()}}>Start</button>)
:
(<button onClick={() => {stop()}}>Stop</button>)
}
</div>
<div>
{strings.map((string) => {
return (
<div className='string'>
{!findingPitch ?
(<button onClick={(e) => {startFindingPitch(true); setNote(string[0]); setPitchScale(string[1])}}> {string[0]} </button>)
:
(<button onClick={() => {startFindingPitch(false)}}>{string[0]}</button>)
}
</div>
)
})}
</div>
</div>
)