Home] [About] [Posts] [Resources] [Applets]
dir: Home /
Applets /
audio spectrogram
published-date: 23 May 2025 16:58 +0700
1window.onload = function()
2{
3
4const canvas = document.getElementById("viewport");
5const ctx = canvas.getContext("2d", {"alpha":false});
6const w = canvas.width;
7const h = canvas.height;
8const buf = Array.from({length:h}, _=>[0,0,0,0]);
9const timer = new cTimer();
10const inputProxy = new cInputProxy();
11const states = {};
12const ftoi = (v) => ~~v;
13var i=0, x=0, y=0, r, l, f, n, d, a, b;
14var audioCtx = null;
15var audioSource = null;
16var audioAnalyzer = null;
17var audioAnalyzerBuffer = null;
18states.running = false;
19states.ping_flag = false;
20states.ping_toggle = false;
21states.pause = false;
22states.speed_double = false;
23states.stats_visible = true;
24const y_ax_width = 26;
25
26function f_p(samples, p) {
27 const n = p.length;
28 let sums = 0;
29 return samples.map(
30 (s)=>{
31 sums = 0;
32 for (let m=0; m<n; m++) {
33 sums += s**(n-m-1) * p[m];
34 }
35 sums = sums > 1 ? 1 : sums;
36 sums = sums < 0 ? 0 : sums;
37 return sums
38 }
39 )
40};
41
42
43class cColorMap {
44 constructor(p_r, p_g, p_b, n_samples=512) {
45 this.n_samples = n_samples;
46 this.samples = Array.from({length:n_samples}, (_, k) => k/(n_samples-1));
47 this.cmap = [
48 f_p(this.samples, p_r),
49 f_p(this.samples, p_g),
50 f_p(this.samples, p_b),
51 [1],
52 ];
53 };
54 c(v, pbuf) {
55 pbuf[0] = ftoi(arrLinterp(this.cmap[0], v)*255);
56 pbuf[1] = ftoi(arrLinterp(this.cmap[1], v)*255);
57 pbuf[2] = ftoi(arrLinterp(this.cmap[2], v)*255);
58 pbuf[3] = ftoi(arrLinterp(this.cmap[3], v)*255);
59 };
60};
61
62
63const cmaps = {};
64cmaps["inferno"] = new cColorMap(
65 [ 1.6557762312002666e+00, -5.0765628176037012e+00, 3.6114437522600484e+00, 7.5501780705212251e-01, -1.0159038301211065e-02],
66 [-2.0627850606099467e+00, 4.4300104345314564e+00, -1.8488324386347577e+00, 5.0539609607001779e-01, -1.5872300948496232e-03],
67 [ 7.0917066940883133e+00, -6.6777021038402200e+00, -2.3104640769293701e+00, 2.5883795816309192e+00, 2.6268954622100660e-03],
68 512
69);
70cmaps["viridis"] = new cColorMap(
71 [-4.1531892247064173e+00, 1.1181547457244568e+01, -7.4602078643667138e+00, 1.2239095368718611e+00, 2.3595679795613189e-01],
72 [-1.2192037749060591e+00, 2.2674937766924388e+00, -1.7332559429197238e+00, 1.5827705985707894e+00, 1.7022394516756911e-03],
73 [ 8.9964455707624591e-01, -1.6228840302847096e+00, -5.5542585551383983e-01, 9.7008011432531771e-01, 3.5734815371570305e-01],
74 512
75);
76cmaps["plasma"] = new cColorMap(
77 [-9.6273453788123020e-01, 1.5159757165361241e+00, -1.7631980996589673e+00, 2.0864096598141320e+00, 6.4327924293011707e-02],
78 [ 2.0752391227084925e+00, -5.1505954072860423e+00, 5.0866969551198995e+00, -1.0903805618152209e+00, 5.8351240571633545e-02],
79 [-4.8259898901274623e-01, 3.2492644170332690e+00, -4.6331725031006119e+00, 1.4881970078755509e+00, 5.1847720178966550e-01],
80 512
81);
82cmaps["jet"] = new cColorMap(
83 [-8.2849271833795886e+00, 7.0881885991722209e+00, 2.9846822373834803e+00, -1.4354238409364075e+00, 7.5255535550637132e-02],
84 [ 2.3359465451418828e+01, -4.7806657343420532e+01, 2.6510474721705169e+01, -2.0878131237437185e+00, 1.1843610455975373e-02],
85 [-8.2849271833794944e+00, 2.6051520134345967e+01, -2.5460315065377277e+01, 7.3412023021711059e+00, 4.2777534779034276e-01],
86 512
87);
88cmaps["gnuplot"] = new cColorMap(
89 [-1.9535174042023593e+00, 4.8319508808781322e+00, -4.4768773653099547e+00, 2.4924728468434454e+00, 9.5225312839073650e-02],
90 [-1.3250959501781973e-15, 1.0000000000000027e+00, -1.8564461224891389e-15, 4.3224304427169264e-16, -2.7755575615628914e-17],
91 [-2.6572077195938601e+01, 6.4671486770697314e+01, -5.1953117837091135e+01, 1.4049166004537248e+01, -2.4144312918029698e-01],
92 512
93);
94cmaps["hot"] = new cColorMap(
95 [ 3.6222211750231050e+00, -4.6076535867206063e+00, -1.4142170137358256e+00, 3.4563502219789628e+00, -4.2580126325212908e-03],
96 [-6.9729705138142206e+00, 8.0851782013482758e+00, 5.3252185214198289e-01, -8.1979054130229345e-01, 5.0279492097186518e-02],
97 [ 5.0265294088989556e+00, -5.2167995225531216e+00, 1.3223930853779435e+00, -1.6971718038553192e-02, -6.6372020237642770e-03],
98 512
99);
100cmaps["turbo"] = new cColorMap(
101 [ 5.8137453451132089e+01, -1.5056663492057038e+02, 1.3058871182450739e+02, -4.2327689751909915e+01, 4.5973637196275892e+00, 1.3572137988693811e-01],
102 [ 2.7747311504642140e+00, 4.2108563550809315e+00, -1.4019450960349118e+01, 4.8052047964775859e+00, 2.1856173378635897e+00, 9.1402612359582219e-02],
103 [ 2.6818260967508394e+01, -8.8506582506478409e+01, 1.0907449945380316e+02, -6.0109675515821372e+01, 1.2592563476452922e+01, 1.0667330048676682e-01],
104 512
105);
106cmaps["cubehelix"] = new cColorMap(
107 [ 4.8656254248331940e+01, -1.1125717035559705e+02, 8.3775484585921745e+01, -2.2283644630448034e+01, 2.2214657823001218e+00, 1.2622588239722177e-02],
108 [-3.0829519810075045e+01, 7.5036654525369528e+01, -6.2165754730774651e+01, 1.9757491405838131e+01, -8.7008357867253394e-01, 2.2104721942214134e-02],
109 [ 3.2660119095476965e+01, -9.9042334615301982e+01, 1.0495732857887293e+02, -4.5198901073315973e+01, 7.6992286492741036e+00, -1.5298769585405037e-01],
110 512
111);
112
113
114// custom math func - linear interpolation
115
116function linterp(a, b, t) {
117 return a + ((b - a) * t)
118};
119
120
121function arrLinterp(arr, t) {
122 l = arr.length || arr.byteLength;
123 f = (l-1)*t;
124 n = ftoi(f);
125 d = f % 1;
126 a = arr[n];
127 b = n+1 > l-1 ? a : arr[n+1];
128 return linterp(a, b, d);
129};
130
131// custom structs/classes
132
133
134function cTimer () {
135 this.f = 0;
136 this.fps = 0;
137 this.t = 0;
138 this.pt = 0;
139 this.dt = 0;
140 this.update = () => {
141 this.f += 1;
142 this.t = Date.now();
143 this.dt = this.t - this.pt;
144 this.pt = this.t;
145 this.fps = this.dt > 0 ? ftoi(1 / this.dt * 1000) : NaN;
146 }
147 this.progress = (n_loop) => this.f % n_loop / Math.max(n_loop-1, 1)
148 this.progress_t = (t_loop) => this.t % t_loop / Math.max(t_loop-1, 1)
149};
150
151
152function cInputProxy () {
153 this.values = {};
154 this.target = {};
155 this.display = {};
156 this.defaults = {};
157 this.register = (name, _default=null, cast=(v)=>v) => {
158 this.target[name] = document.getElementById(name);
159 this.display[name] = this.target[name].parentElement.querySelector("#" + name + "_value");
160 this.values[name] = cast(this.target[name].value);
161 this.defaults[name] = _default;
162 this.target[name].addEventListener("input", (event) => {
163 this.values[name] = cast(event.target.value);
164 if (this.display[name] !== null) {
165 this.display[name].innerHTML = "" + event.target.value;
166 }
167 });
168 if (_default !== null) {
169 this.target[name].value = "" + _default;
170 this.values[name] = cast(_default);
171 if (this.display[name] !== null) {
172 this.display[name].innerHTML = "" + _default;
173 }
174 }
175 return this.target[name];
176 };
177};
178
179
180// routines
181
182function appInit() {
183 ctx.fillStyle = "black";
184 timer.update();
185 canvasClear();
186 canvasDrawYAx();
187 inputProxy.register('appInitControl').addEventListener("click", (e)=>{
188 if (states.running) {
189 states.running = false;
190 inputProxy.target.appInitControl.value = "connect device";
191 appClose();
192
193 } else {
194 states.running = true;
195 inputProxy.target.appInitControl.value = "disconnect device";
196 appConnectDevice();
197 }
198 });
199 inputProxy.target.appInitControl.value = "connect device";
200 inputProxy.register('clearCanvas').addEventListener("click", (e)=>{
201 canvasClear();
202 canvasDrawYAx(inputProxy.values.yZoom / 100);
203 });
204 inputProxy.register('pauseCanvas').addEventListener("click", (e)=>{
205 states.pause = !states.pause;
206 if (states.pause) {
207 inputProxy.target.pauseCanvas.value = "resume";
208 if (audioCtx !== null) {
209 audioCtx.suspend()
210 };
211 } else {
212 inputProxy.target.pauseCanvas.value = "pause";
213 if (audioCtx !== null) {
214 audioCtx.resume()
215 };
216 }
217 });
218 inputProxy.register('showStatsCanvas').addEventListener("click", (e)=>{
219 states.stats_visible = !states.stats_visible;
220 if (states.stats_visible) {
221 inputProxy.target.showStatsCanvas.value = "hide stats";
222 } else {
223 inputProxy.target.showStatsCanvas.value = "show stats";
224 }
225 });
226 inputProxy.register('captureCanvas').addEventListener("click", (e)=>{
227 let link = document.createElement('a');
228 link.href = canvas.toDataURL('image/png');
229 link.download = 'spectrogram.png';
230 link.click();
231 });
232 inputProxy.register('yZoom', 100).addEventListener("input", (e)=>{
233 canvasDrawYAx(inputProxy.values.yZoom / 100);
234 });
235 inputProxy.register('yZoomReset').addEventListener("click", (e)=>{
236 inputProxy.target.yZoom.value = 100;
237 inputProxy.values.yZoom = 100;
238 inputProxy.display.yZoom.innerHTML = "100";
239 canvasDrawYAx(inputProxy.values.yZoom / 100);
240 });
241 inputProxy.register('mindB', -120, parseInt).addEventListener("input", (e)=>{
242 if (inputProxy.values.mindB > inputProxy.values.maxdB + 10) {
243 inputProxy.target.maxdB.value = "" + (inputProxy.values.mindB + 10);
244 inputProxy.display.maxdB.innerHTML = "" + inputProxy.target.maxdB.value;
245 }
246 });
247 inputProxy.register('maxdB', -20, parseInt).addEventListener("input", (e)=>{
248 if (inputProxy.values.mindB > inputProxy.values.maxdB + 10) {
249 inputProxy.target.mindB.value = "" + (inputProxy.values.maxdB - 10);
250 inputProxy.display.mindB.innerHTML = "" + inputProxy.target.mindB.value;
251 }
252 });
253 inputProxy.register('dBReset').addEventListener("click", (e)=>{
254 inputProxy.values.mindB = inputProxy.defaults.mindB;
255 inputProxy.target.mindB.value = "" + inputProxy.defaults.mindB;
256 inputProxy.display.mindB.innerHTML = "" + inputProxy.defaults.mindB;
257 inputProxy.values.maxdB = inputProxy.defaults.maxdB;
258 inputProxy.target.maxdB.value = "" + inputProxy.defaults.maxdB;
259 inputProxy.display.maxdB.innerHTML = "" + inputProxy.defaults.maxdB;
260 });
261 inputProxy.register('cmapSelect');
262 inputProxy.register('pingDisplay').addEventListener("click", (e)=>{
263 states.ping_toggle = inputProxy.target.pingDisplay.checked;
264 });
265 inputProxy.register('canvasSpeedDouble').addEventListener("click", (e)=>{
266 states.speed_double = inputProxy.target.canvasSpeedDouble.checked;
267 });
268 inputProxy.target.canvasSpeedDouble.checked = false;
269 inputProxy.target.pingDisplay.checked = false;
270};
271
272
273function appConnectDevice() {
274 navigator.mediaDevices.getUserMedia({audio: true})
275 .then((stream)=>{
276 audioCtx = new (window.AudioContext || window.webkitAudioContext)();
277 audioSource = audioCtx.createMediaStreamSource(stream);
278 audioAnalyzer = audioCtx.createAnalyser();
279 audioSource.connect(audioAnalyzer);
280 audioAnalyzer.fftSize = 4096;
281 audioAnalyzer.smoothingTimeConstant = .0;
282 audioAnalyzerBuffer = new Uint8Array(audioAnalyzer.frequencyBinCount);
283 appUpdate();
284 }).catch((err)=>{
285 audioCtx = null;
286 console.error(err)
287 })
288}
289
290
291function appUpdate() {
292 if (audioCtx === null || audioCtx.state === "closed") {
293 return
294 };
295 audioAnalyzer.minDecibels = Math.min(inputProxy.values.mindB, inputProxy.values.maxdB-5);
296 audioAnalyzer.maxDecibels = Math.max(inputProxy.values.mindB+5, inputProxy.values.maxdB);
297
298 audioAnalyzer.getByteFrequencyData(audioAnalyzerBuffer);
299 for (let k=0; k<h; k++) {
300 r = k/(h-1);
301 r /= inputProxy.values.yZoom / 100;
302 cmaps[inputProxy.values.cmapSelect].c(arrLinterp(audioAnalyzerBuffer, r)/255, buf[h-k-1]);
303 }
304 timer.update();
305 canvasDraw();
306 canvasDrawInfo();
307 canvasDrawSecondPing();
308 if (audioCtx !== null && audioCtx.state === "suspended") {
309 window.requestAnimationFrame(appUpdate);
310 return
311 };
312 window.requestAnimationFrame(appUpdate);
313}
314
315
316function canvasClear() {
317 let fill_c = "black";
318 ctx.save();
319 ctx.clearRect(0,0,w,h);
320 ctx.fillStyle = "black";
321 ctx.fillRect(0,0,w,h);
322 ctx.restore();
323};
324
325
326function canvasDrawRoutinePutBuffer(offset=1) {
327 const imd = ctx.getImageData(w-1,0,1,h);
328 for (i=0; i<imd.data.length; i+=4) {
329 y = Math.floor(i/4);
330 imd.data[i+0] = buf[y][0];
331 imd.data[i+1] = buf[y][1];
332 imd.data[i+2] = buf[y][2];
333 imd.data[i+3] = buf[y][3];
334 };
335 ctx.putImageData(imd, w-offset, 0);
336}
337
338
339function canvasDrawRoutineShiftFrame(offset=1) {
340 const imd = ctx.getImageData(1,0,w-offset,h);
341 ctx.putImageData(imd, 0, 0);
342}
343
344
345function canvasDraw() {
346 if (states.pause) {
347 return
348 }
349 canvasDrawRoutinePutBuffer(y_ax_width+1);
350 canvasDrawRoutineShiftFrame(y_ax_width+1);
351 if (states.speed_double) {
352 canvasDrawRoutineShiftFrame(y_ax_width+1);
353 }
354};
355
356
357function canvasDrawSecondPing() {
358 if (!states.ping_toggle) {
359 return
360 };
361 r = timer.progress_t(1000);
362 if (r < .10 & states.ping_flag) {
363 states.ping_flag = false;
364 ctx.save();
365 ctx.fillStyle = "white";
366 ctx.fillRect(w-2-y_ax_width,22,2,10);
367 ctx.fillStyle = "rgba(77, 77, 77, 0.38)";
368 ctx.fillRect(w-2-y_ax_width,22,2,h-22);
369 ctx.restore();
370 };
371 if (r > .90 & !states.ping_flag) {
372 states.ping_flag = true;
373 };
374};
375
376
377function canvasDrawInfo() {
378 if (!states.stats_visible) {
379 return
380 }
381 const iw = 120;
382 const iy = 18;
383 ctx.save();
384 ctx.fillStyle = "black";
385 ctx.fillRect(0, 0, iw, iy);
386 ctx.fillStyle = "white";
387 ctx.font = "16px monospace";
388 ctx.textBaseline = "middle";
389 ctx.fillText(timer.dt + "ms", 8, iy/2);
390 ctx.fillText("fps:" + timer.fps, 50, iy/2);
391 ctx.restore();
392};
393
394
395const y_ax_freq = {};
396y_ax_freq[20] = "20";
397y_ax_freq[100] = "100";
398y_ax_freq[500] = "500";
399y_ax_freq[1000] = "1k";
400y_ax_freq[2000] = "2k";
401y_ax_freq[3000] = "3k";
402y_ax_freq[4000] = "4k";
403y_ax_freq[5000] = "5k";
404y_ax_freq[6000] = "6k";
405y_ax_freq[8000] = "8k";
406y_ax_freq[12000] = "12k";
407y_ax_freq[16000] = "16k";
408y_ax_freq[20000] = "20k";
409y_ax_freq[24000] = "24k";
410
411function canvasDrawYAx(scale=1, offset=0) {
412 ctx.save();
413 ctx.fillStyle = "black";
414 ctx.fillRect(w-y_ax_width, 0, y_ax_width, h);
415 ctx.font = "12px monospace";
416 ctx.textBaseline = "middle";
417 for (i in y_ax_freq) {
418 r = i / 24000 * scale + .001;
419 if (!(r < .005) && !(r > .99)) {
420 y = ftoi((1-r)*h);
421 ctx.fillStyle = "white";
422 ctx.fillText(y_ax_freq[i], w-y_ax_width+6, y);
423 ctx.fillStyle = "gray";
424 ctx.fillRect(w-y_ax_width, y, 5, 1);
425 }
426 }
427 ctx.restore();
428}
429
430
431function appClose() {
432 if (audioCtx !== null && audioCtx.state !== "closed") {
433 audioCtx.close().then(() => {
434 audioCtx = null;
435 })
436 }
437};
438
439
440window.onbeforeunload = (event) => {
441 appClose();
442};
443
444
445appInit();
446
447}
Built with Hugo | previoip (c) 2025