-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtuner.js
164 lines (131 loc) · 4.01 KB
/
tuner.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"use strict";
const NOTE_NAMES = [
"A",
"A#",
"B",
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
];
// 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;
// 2 minute screen timeout
const TIMEOUT = 120;
let dom_frequency;
let dom_rate;
let dom_note;
let dom_tune;
const setup = () => {
document.body.onclick = undefined;
dom_frequency = document.getElementById("frequency");
dom_rate = document.getElementById("rate");
dom_note = document.getElementById("note");
dom_tune = document.getElementById("tune");
dom_note.innerHTML = "Listening...";
if (navigator?.mediaDevices?.getUserMedia) {
navigator.mediaDevices
.getUserMedia({
audio: true,
})
.then(handleStream, (err) => {
console.error("Error calling getUserMedia", err);
})
.then(aquireWakeLock);
}
};
const aquireWakeLock = ({ 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) {}
}
};
const handleStream = (stream) => {
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);
const interval = setInterval(tune(analyser, data), 500);
return { interval, stream };
};
const tune = (analyser, data) => () => {
analyser.getByteFrequencyData(data);
const rate = analyser.context.sampleRate;
dom_rate.innerText = rate / 1000;
const bucketWidth = rate / analyser.fftSize;
let max = 0;
let maxBucket = -1;
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;
}
const geoMean = Math.pow(product, 1 / (j - 1));
if (geoMean > max) {
max = geoMean;
maxBucket = bucket;
}
});
if (maxBucket === -1) {
return;
}
const frequency = maxBucket * bucketWidth;
dom_frequency.innerText = `${Number.parseFloat(frequency).toFixed(2)} Hz`;
const semitones = frequencyToSemitones(frequency);
const margin = frequencyToSemitones(frequency + bucketWidth / 2) - semitones;
dom_note.innerText = semitonesToNote(semitones);
dom_tune.innerText = errorPercentage(semitones, margin);
document.body.className = semitonesToClassname(semitones, margin);
};
const frequencyToSemitones = (frequency) =>
12 * Math.log2(frequency / 440) + 69;
const semitonesToNote = (semitones) => {
const rounded = Math.round(semitones - 69);
const index = rounded >= 0 ? rounded % 12 : (12 + (rounded % 12)) % 12;
return NOTE_NAMES[index];
};
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}`;
};
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 "";
}
return Math.round(semitones) > semitones ? "flat" : "sharp";
};