prettier + other stuff

This commit is contained in:
2025-11-30 11:55:40 -08:00
parent 9f64715a13
commit e976a1f52d

152
tuner.js
View File

@@ -18,14 +18,16 @@ const NOTE_NAMES = [
// We don't care about fundamentals above 4kHz, so setting a lower sample rate // We don't care about fundamentals above 4kHz, so setting a lower sample rate
// gives us finer-grained FFT buckets // gives us finer-grained FFT buckets
const TARGET_SAMPLE_RATE = 8000; 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 let dom_frequency, dom_rate, dom_note, dom_tune;
const TIMEOUT = 120; let lastFrequency = null;
const history = [];
let dom_frequency; let wakeLock = null;
let dom_rate;
let dom_note;
let dom_tune;
const setup = () => { const setup = () => {
document.body.onclick = undefined; document.body.onclick = undefined;
@@ -38,49 +40,59 @@ const setup = () => {
if (navigator?.mediaDevices?.getUserMedia) { if (navigator?.mediaDevices?.getUserMedia) {
navigator.mediaDevices navigator.mediaDevices
.getUserMedia({ .getUserMedia({ audio: true })
audio: true, .then(handleStream)
}) .then(aquireWakeLock)
.then(handleStream, (err) => { .catch((err) => console.error("Error getting user media:", err));
console.error("Error calling getUserMedia", err);
})
.then(aquireWakeLock);
} }
}; };
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) { if (navigator?.wakeLock?.request) {
try { await requestWakeLock();
navigator.wakeLock.request("screen").then(
(wakeLock) => document.addEventListener("visibilitychange", async () => {
setTimeout(() => { if (wakeLock !== null && document.visibilityState === "visible") {
clearInterval(interval); await requestWakeLock();
wakeLock.release(); }
stream.getTracks().forEach((track) => track.stop()); });
dom_note.innerHTML = "Tap to Start";
document.body.onclick = setup; setTimeout(() => {
dom_tune.innerHTML = ""; clearInterval(interval);
dom_frequency.innerHTML = ""; if (wakeLock) wakeLock.release();
}, TIMEOUT * 1000), stream.getTracks().forEach((track) => track.stop());
(err) => console.error("Error requesting wakeLock", err) dom_note.innerHTML = "Tap to Start";
); document.body.onclick = setup;
} catch (err) {} dom_tune.innerHTML = "";
dom_frequency.innerHTML = "";
}, TIMEOUT * 1000);
} }
}; };
// Handle incoming audio stream
const handleStream = (stream) => { const handleStream = (stream) => {
const audioContext = new AudioContext({ const audioContext = new AudioContext({ sampleRate: TARGET_SAMPLE_RATE });
sampleRate: TARGET_SAMPLE_RATE,
});
const analyser = audioContext.createAnalyser(); const analyser = audioContext.createAnalyser();
analyser.fftSize = 32768; analyser.fftSize = 32768;
analyser.minDecibels = -90; analyser.minDecibels = -90;
analyser.maxDecibels = -10; analyser.maxDecibels = -10;
analyser.smoothingTimeConstant = 0; analyser.smoothingTimeConstant = 0;
const bufferLength = analyser.frequencyBinCount; const bufferLength = analyser.frequencyBinCount;
const data = new Uint8Array(bufferLength); const data = new Uint8Array(bufferLength);
const source = audioContext.createMediaStreamSource(stream); const source = audioContext.createMediaStreamSource(stream);
source.connect(analyser); source.connect(analyser);
@@ -89,9 +101,9 @@ const handleStream = (stream) => {
return { interval, stream }; return { interval, stream };
}; };
// Tuning function
const tune = (analyser, data) => () => { const tune = (analyser, data) => () => {
analyser.getByteFrequencyData(data); analyser.getByteFrequencyData(data);
const rate = analyser.context.sampleRate; const rate = analyser.context.sampleRate;
dom_rate.innerText = rate / 1000; dom_rate.innerText = rate / 1000;
@@ -99,26 +111,53 @@ const tune = (analyser, data) => () => {
let max = 0; let max = 0;
let maxBucket = -1; let maxBucket = -1;
// Use harmonic sum instead of product for better fundamental detection
data.forEach((value, bucket) => { data.forEach((value, bucket) => {
let j = 2; let sum = value;
let product = value; for (let j = 2; j < 8 && j * bucket < data.length; j++) {
while (bucket > 1 && j * bucket < data.length && j < 8) { sum += data[j * bucket]; // Sum harmonics instead of multiplying
product *= data[j * bucket];
j += 1;
} }
const geoMean = Math.pow(product, 1 / (j - 1));
if (geoMean > max) { if (sum > max) {
max = geoMean; max = sum;
maxBucket = bucket; maxBucket = bucket;
} }
}); });
if (maxBucket === -1) { if (maxBucket === -1) return;
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`; dom_frequency.innerText = `${Number.parseFloat(frequency).toFixed(2)} Hz`;
const semitones = frequencyToSemitones(frequency); const semitones = frequencyToSemitones(frequency);
@@ -129,36 +168,31 @@ const tune = (analyser, data) => () => {
document.body.className = semitonesToClassname(semitones, margin); document.body.className = semitonesToClassname(semitones, margin);
}; };
// Converts frequency to MIDI semitone number
const frequencyToSemitones = (frequency) => const frequencyToSemitones = (frequency) =>
12 * Math.log2(frequency / 440) + 69; 12 * Math.log2(frequency / 440) + 69;
// Converts semitones to a note name
const semitonesToNote = (semitones) => { const semitonesToNote = (semitones) => {
const rounded = Math.round(semitones - 69); let noteIndex = Math.round(semitones) % 12;
if (noteIndex < 0) noteIndex += 12;
const index = rounded >= 0 ? rounded % 12 : (12 + (rounded % 12)) % 12; return NOTE_NAMES[noteIndex];
return NOTE_NAMES[index];
}; };
// Calculates tuning error in cents
const errorPercentage = (semitones, margin) => { const errorPercentage = (semitones, margin) => {
const rounded = Math.round(semitones); const rounded = Math.round(semitones);
const cents = Math.round((semitones - rounded) * 100); const cents = Math.round((semitones - rounded) * 100);
const accuracy = Number.parseFloat(margin * 100).toFixed(1); const accuracy = Number.parseFloat(margin * 100).toFixed(1);
const sign = cents > 0 ? "+" : ""; const sign = cents > 0 ? "+" : "";
return `${sign}${cents} cents ± ${accuracy}`; return `${sign}${cents} cents ± ${accuracy}`;
}; };
// Determines if the note is flat or sharp
const semitonesToClassname = (semitones, margin) => { const semitonesToClassname = (semitones, margin) => {
const rounded = Math.round(semitones); const rounded = Math.round(semitones);
const error = Math.abs(semitones - rounded); const error = Math.abs(semitones - rounded);
const ok = margin > 0.05 ? margin : 0.05; const ok = margin > 0.05 ? margin : 0.05;
if (error <= ok) return "";
if (error <= ok) {
return "";
}
return Math.round(semitones) > semitones ? "flat" : "sharp"; return Math.round(semitones) > semitones ? "flat" : "sharp";
}; };