prettier + other stuff
This commit is contained in:
152
tuner.js
152
tuner.js
@@ -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";
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user