dumb by default


dir: Home / Applets / audio spectrogram
published-date: 23 May 2025 16:58 +0700

audio spectrogram


  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