diff --git a/tuner.js b/tuner.js index c21717e..de9800c 100644 --- a/tuner.js +++ b/tuner.js @@ -18,14 +18,16 @@ const NOTE_NAMES = [ // We don't care about fundamentals above 4kHz, so setting a lower sample rate // gives us finer-grained FFT buckets const TARGET_SAMPLE_RATE = 8000; +const TIMEOUT = 120; // 2-minute screen timeout +const NOISE_THRESHOLD = -60; // dBFS threshold for valid pitch detection +const SMOOTHING_FACTOR = 0.8; // Smoothing factor for frequency stability +const MAX_HISTORY = 5; // Moving median filter window size -// 2 minute screen timeout -const TIMEOUT = 120; +let dom_frequency, dom_rate, dom_note, dom_tune; +let lastFrequency = null; +const history = []; -let dom_frequency; -let dom_rate; -let dom_note; -let dom_tune; +let wakeLock = null; const setup = () => { document.body.onclick = undefined; @@ -38,49 +40,59 @@ const setup = () => { if (navigator?.mediaDevices?.getUserMedia) { navigator.mediaDevices - .getUserMedia({ - audio: true, - }) - .then(handleStream, (err) => { - console.error("Error calling getUserMedia", err); - }) - .then(aquireWakeLock); + .getUserMedia({ audio: true }) + .then(handleStream) + .then(aquireWakeLock) + .catch((err) => console.error("Error getting user media:", err)); } }; -const aquireWakeLock = ({ interval, stream }) => { +// Function to request wake lock +const requestWakeLock = async () => { + try { + wakeLock = await navigator.wakeLock.request("screen"); + wakeLock.addEventListener("release", () => + console.log("Wake Lock released") + ); + } catch (err) { + console.error("Failed to acquire wake lock:", err); + } +}; + +// Function to acquire wake lock and re-request if lost +const aquireWakeLock = async ({ interval, stream }) => { if (navigator?.wakeLock?.request) { - try { - navigator.wakeLock.request("screen").then( - (wakeLock) => - setTimeout(() => { - clearInterval(interval); - wakeLock.release(); - stream.getTracks().forEach((track) => track.stop()); - dom_note.innerHTML = "Tap to Start"; - document.body.onclick = setup; - dom_tune.innerHTML = ""; - dom_frequency.innerHTML = ""; - }, TIMEOUT * 1000), - (err) => console.error("Error requesting wakeLock", err) - ); - } catch (err) {} + await requestWakeLock(); + + document.addEventListener("visibilitychange", async () => { + if (wakeLock !== null && document.visibilityState === "visible") { + await requestWakeLock(); + } + }); + + setTimeout(() => { + clearInterval(interval); + if (wakeLock) wakeLock.release(); + stream.getTracks().forEach((track) => track.stop()); + dom_note.innerHTML = "Tap to Start"; + document.body.onclick = setup; + dom_tune.innerHTML = ""; + dom_frequency.innerHTML = ""; + }, TIMEOUT * 1000); } }; +// Handle incoming audio stream const handleStream = (stream) => { - const audioContext = new AudioContext({ - sampleRate: TARGET_SAMPLE_RATE, - }); - + const audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE }); const analyser = audioContext.createAnalyser(); analyser.fftSize = 32768; analyser.minDecibels = -90; analyser.maxDecibels = -10; analyser.smoothingTimeConstant = 0; + const bufferLength = analyser.frequencyBinCount; const data = new Uint8Array(bufferLength); - const source = audioContext.createMediaStreamSource(stream); source.connect(analyser); @@ -89,9 +101,9 @@ const handleStream = (stream) => { return { interval, stream }; }; +// Tuning function const tune = (analyser, data) => () => { analyser.getByteFrequencyData(data); - const rate = analyser.context.sampleRate; dom_rate.innerText = rate / 1000; @@ -99,26 +111,53 @@ const tune = (analyser, data) => () => { let max = 0; let maxBucket = -1; + // Use harmonic sum instead of product for better fundamental detection data.forEach((value, bucket) => { - let j = 2; - let product = value; - while (bucket > 1 && j * bucket < data.length && j < 8) { - product *= data[j * bucket]; - j += 1; + let sum = value; + for (let j = 2; j < 8 && j * bucket < data.length; j++) { + sum += data[j * bucket]; // Sum harmonics instead of multiplying } - const geoMean = Math.pow(product, 1 / (j - 1)); - if (geoMean > max) { - max = geoMean; + if (sum > max) { + max = sum; maxBucket = bucket; } }); - if (maxBucket === -1) { - return; + if (maxBucket === -1) return; + + // Ignore weak signals (noise threshold) + let maxDb = 20 * Math.log10(max); + if (maxDb < NOISE_THRESHOLD) return; + + // Quadratic Peak Interpolation + let delta = 0; + if (maxBucket > 0 && maxBucket < data.length - 1) { + let left = data[maxBucket - 1]; + let center = data[maxBucket]; + let right = data[maxBucket + 1]; + + delta = (0.5 * (right - left)) / (2 * center - left - right); } - const frequency = maxBucket * bucketWidth; + let frequency = (maxBucket + delta) * bucketWidth; + + // Apply exponential smoothing + if (lastFrequency !== null) { + frequency = + SMOOTHING_FACTOR * lastFrequency + (1 - SMOOTHING_FACTOR) * frequency; + } + lastFrequency = frequency; + + // Moving Median Filter + history.push(frequency); + if (history.length > MAX_HISTORY) { + history.shift(); + } + frequency = history.slice().sort((a, b) => a - b)[ + Math.floor(history.length / 2) + ]; + dom_frequency.innerText = `${Number.parseFloat(frequency).toFixed(2)} Hz`; const semitones = frequencyToSemitones(frequency); @@ -129,36 +168,31 @@ const tune = (analyser, data) => () => { document.body.className = semitonesToClassname(semitones, margin); }; +// Converts frequency to MIDI semitone number const frequencyToSemitones = (frequency) => 12 * Math.log2(frequency / 440) + 69; +// Converts semitones to a note name const semitonesToNote = (semitones) => { - const rounded = Math.round(semitones - 69); - - const index = rounded >= 0 ? rounded % 12 : (12 + (rounded % 12)) % 12; - - return NOTE_NAMES[index]; + let noteIndex = Math.round(semitones) % 12; + if (noteIndex < 0) noteIndex += 12; + return NOTE_NAMES[noteIndex]; }; +// Calculates tuning error in cents const errorPercentage = (semitones, margin) => { const rounded = Math.round(semitones); - const cents = Math.round((semitones - rounded) * 100); const accuracy = Number.parseFloat(margin * 100).toFixed(1); const sign = cents > 0 ? "+" : ""; - return `${sign}${cents} cents ± ${accuracy}`; }; +// Determines if the note is flat or sharp const semitonesToClassname = (semitones, margin) => { const rounded = Math.round(semitones); const error = Math.abs(semitones - rounded); - const ok = margin > 0.05 ? margin : 0.05; - - if (error <= ok) { - return ""; - } - + if (error <= ok) return ""; return Math.round(semitones) > semitones ? "flat" : "sharp"; };