/ audio_radial_graph.html
audio_radial_graph.html
  1  <!DOCTYPE html>
  2  <html lang="en">
  3  <head>
  4      <meta charset="utf-8">
  5      <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
  6  
  7      <title>Analyser Radial Graph — Space.js</title>
  8  
  9      <link rel="preconnect" href="https://fonts.gstatic.com">
 10      <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@300&family=Gothic+A1:wght@500;700">
 11      <link rel="stylesheet" href="assets/css/style.css">
 12  
 13      <script type="module">
 14          import { PanelItem, RadialGraphSegments, UI, WebAudio, clamp, mapLinear, median, rms, ticker, tween } from './src/index.js';
 15  
 16          const store = {
 17              sound: true
 18          };
 19  
 20          class AudioController {
 21              static init(ui, graph) {
 22                  this.ui = ui;
 23                  this.graph = graph;
 24  
 25                  this.context = WebAudio.context;
 26  
 27                  // Median downsample
 28                  this.array = [];
 29                  this.chunkSizes = [];
 30                  this.chunkSize = 1;
 31  
 32                  // Peak levels for ghost graph
 33                  this.peakInterval = 3; // 3 seconds
 34                  this.lastTime = 0;
 35                  this.highs = 0;
 36                  this.mids = 0;
 37                  this.lows = 0;
 38  
 39                  // Bars
 40                  this.highsRange = [0.4, 0.6];
 41                  this.midsRange = [0, 1];
 42                  this.lowsRange = [0.1, 0.3];
 43  
 44                  // Oscilloscope
 45                  this.multiplier = 0.5;
 46  
 47                  this.initSounds();
 48                  this.initAnalyser();
 49                  this.setGraphSegments(this.chunkSize);
 50  
 51                  this.addListeners();
 52              }
 53  
 54              static initSounds() {
 55                  this.cyberspace = WebAudio.get('cyberspace');
 56                  this.cyberspace.gain.set(1);
 57              }
 58  
 59              static initAnalyser() {
 60                  // Delay the output to sync with the analyser
 61                  this.analyserDelay = this.context.createDelay();
 62                  this.analyserDelay.delayTime.value = 4 / 60; // seconds
 63                  this.analyserDelay.connect(this.cyberspace.parent.input);
 64  
 65                  this.analyser = this.context.createAnalyser();
 66                  this.analyser.fftSize = 4096;
 67  
 68                  this.bufferLength = this.analyser.frequencyBinCount;
 69                  this.data = new Uint8Array(this.bufferLength);
 70  
 71                  // Connect the source to be analyzed (directly without output)
 72                  // this.cyberspace.source.connect(this.analyser);
 73  
 74                  // Reconnect the output to be analyzed (with analyser output)
 75                  this.cyberspace.output.disconnect();
 76                  this.cyberspace.output.connect(this.analyser);
 77                  this.analyser.connect(this.analyserDelay);
 78  
 79                  // Find all the chunk sizes evenly divisible by 3
 80                  const segmentSizes = [];
 81  
 82                  for (let i = 1; i < this.bufferLength; i++) {
 83                      const arrayLength = Math.floor(this.bufferLength / i);
 84                      const segmentSize = Math.floor(arrayLength / 3);
 85  
 86                      if (arrayLength - segmentSize * 3 === 0 && segmentSize >= 5 && !segmentSizes.includes(segmentSize)) {
 87                          segmentSizes.push(segmentSize);
 88  
 89                          this.chunkSizes.push(i);
 90                      }
 91                  }
 92  
 93                  this.chunkSizes.unshift(1, 2, 3, 4, 5, 6, 7, 8, 9);
 94              }
 95  
 96              static setGraphSegments(chunkSize) {
 97                  this.chunkSize = chunkSize;
 98  
 99                  this.arrayLength = Math.floor(this.bufferLength / this.chunkSize);
100  
101                  const segmentSize = Math.floor(this.arrayLength / 3);
102  
103                  this.graph.segments = [segmentSize, segmentSize, this.arrayLength - segmentSize * 2];
104                  this.graph.array.length = this.arrayLength;
105                  this.graph.ghostArray.length = this.arrayLength;
106  
107                  this.segmentPositions = [segmentSize, segmentSize * 2, this.arrayLength];
108              }
109  
110              static addListeners() {
111                  document.addEventListener('visibilitychange', this.onVisibility);
112                  document.addEventListener('click', this.onClick);
113  
114                  this.ui.instructions.animateIn();
115              }
116  
117              static getAverageFrequency(data, start = 0, end = data.length) {
118                  // Calculate the root median square (RMS)
119                  return rms(data.slice(start, end).map(v => v / 256));
120              }
121  
122              // Event handlers
123  
124              static onVisibility = () => {
125                  if (document.hidden) {
126                      WebAudio.mute();
127                  } else {
128                      WebAudio.unmute();
129                  }
130              };
131  
132              static onClick = () => {
133                  document.removeEventListener('click', this.onClick);
134  
135                  WebAudio.resume();
136  
137                  this.ui.instructions.animateOut();
138  
139                  if (!store.sound) {
140                      this.ui.audioButton.onClick();
141                  }
142  
143                  this.cyberspace.play();
144              };
145  
146              // Public methods
147  
148              static update = time => {
149                  if (time - this.lastTime > this.peakInterval) {
150                      this.lastTime = time;
151                      this.highs = 0;
152                      this.mids = 0;
153                      this.lows = 0;
154                  }
155  
156                  const data = this.data;
157                  const bufferLength = this.bufferLength;
158                  const chunkSize = this.chunkSize;
159                  const arrayLength = this.arrayLength;
160                  const segmentPositions = this.segmentPositions;
161  
162                  // Bars
163                  this.analyser.getByteFrequencyData(data);
164  
165                  // Median downsample
166                  this.array.length = 0;
167  
168                  for (let i = 0; i < bufferLength; i += chunkSize) {
169                      this.array.push(median(data.slice(i, i + chunkSize)));
170                  }
171  
172                  const highs = this.getAverageFrequency(this.array, 0, Math.floor(arrayLength * 0.4));
173                  const mids = this.getAverageFrequency(this.array, Math.floor(arrayLength * 0.4), Math.floor(arrayLength * 0.6));
174                  const lows = this.getAverageFrequency(this.array, Math.floor(arrayLength * 0.6), arrayLength);
175  
176                  this.highs = Math.max(this.highs, highs);
177                  this.mids = Math.max(this.mids, mids);
178                  this.lows = Math.max(this.lows, lows);
179  
180                  this.graph.ghostArray.fill(clamp(mapLinear(this.highs, this.highsRange[0], this.highsRange[1], 0, 1), 0, 1), 0, segmentPositions[0]);
181                  this.graph.ghostArray.fill(clamp(mapLinear(this.mids, this.midsRange[0], this.midsRange[1], 0, 1), 0, 1), segmentPositions[0], segmentPositions[1]);
182                  this.graph.ghostArray.fill(clamp(mapLinear(this.lows, this.lowsRange[0], this.lowsRange[1], 0, 1), 0, 1), segmentPositions[1], segmentPositions[2]);
183  
184                  this.graph.array.fill(clamp(mapLinear(highs, this.highsRange[0], this.highsRange[1], 0, 1), 0, 1), 0, segmentPositions[0]);
185                  this.graph.array.fill(clamp(mapLinear(mids, this.midsRange[0], this.midsRange[1], 0, 1), 0, 1), segmentPositions[0], segmentPositions[1]);
186                  this.graph.array.fill(clamp(mapLinear(lows, this.lowsRange[0], this.lowsRange[1], 0, 1), 0, 1), segmentPositions[1], segmentPositions[2]);
187  
188                  // Oscilloscope
189                  this.analyser.getByteTimeDomainData(data);
190  
191                  // Median downsample
192                  this.array.length = 0;
193  
194                  for (let i = 0; i < bufferLength; i += chunkSize) {
195                      this.array.push(median(data.slice(i, i + chunkSize)));
196                  }
197  
198                  for (let i = 0; i < arrayLength; i++) {
199                      const v = this.array[i] / 128;
200                      const y = clamp(mapLinear(v, 0, 2, -0.5, 0.5), -0.5, 0.5);
201  
202                      this.graph.array[i] = this.graph.array[i] + y * this.multiplier;
203                  }
204  
205                  this.graph.needsUpdate = true;
206              };
207  
208              static mute = () => {
209                  tween(this.cyberspace.gain, { value: 0 }, 500, 'easeOutSine');
210              };
211  
212              static unmute = () => {
213                  tween(this.cyberspace.gain, { value: 1 }, 500, 'easeOutSine');
214              };
215          }
216  
217          class PanelController {
218              static init(ui) {
219                  this.ui = ui;
220  
221                  this.initPanel();
222              }
223  
224              static initPanel() {
225                  const { cyberspace, analyserDelay } = AudioController;
226  
227                  const items = [
228                      {
229                          name: 'FPS'
230                      },
231                      {
232                          type: 'divider'
233                      },
234                      {
235                          type: 'slider',
236                          name: 'Volume',
237                          min: 0,
238                          max: 1,
239                          step: 0.01,
240                          value: cyberspace.gain.value,
241                          callback: value => {
242                              cyberspace.gain.value = value;
243                          }
244                      },
245                      {
246                          type: 'slider',
247                          name: 'Delay',
248                          min: 0,
249                          max: 60,
250                          step: 1,
251                          value: analyserDelay.delayTime.value * 60,
252                          callback: value => {
253                              analyserDelay.delayTime.value = value / 60;
254                          }
255                      },
256                      {
257                          type: 'divider'
258                      },
259                      {
260                          type: 'slider',
261                          name: 'Highs Min',
262                          min: 0,
263                          max: 1,
264                          step: 0.01,
265                          value: AudioController.highsRange[0],
266                          callback: value => {
267                              AudioController.highsRange[0] = value;
268                          }
269                      },
270                      {
271                          type: 'slider',
272                          name: 'Highs Max',
273                          min: 0,
274                          max: 1,
275                          step: 0.01,
276                          value: AudioController.highsRange[1],
277                          callback: value => {
278                              AudioController.highsRange[1] = value;
279                          }
280                      },
281                      {
282                          type: 'divider'
283                      },
284                      {
285                          type: 'slider',
286                          name: 'Mids Min',
287                          min: 0,
288                          max: 1,
289                          step: 0.01,
290                          value: AudioController.midsRange[0],
291                          callback: value => {
292                              AudioController.midsRange[0] = value;
293                          }
294                      },
295                      {
296                          type: 'slider',
297                          name: 'Mids Max',
298                          min: 0,
299                          max: 1,
300                          step: 0.01,
301                          value: AudioController.midsRange[1],
302                          callback: value => {
303                              AudioController.midsRange[1] = value;
304                          }
305                      },
306                      {
307                          type: 'divider'
308                      },
309                      {
310                          type: 'slider',
311                          name: 'Lows Min',
312                          min: 0,
313                          max: 1,
314                          step: 0.01,
315                          value: AudioController.lowsRange[0],
316                          callback: value => {
317                              AudioController.lowsRange[0] = value;
318                          }
319                      },
320                      {
321                          type: 'slider',
322                          name: 'Lows Max',
323                          min: 0,
324                          max: 1,
325                          step: 0.01,
326                          value: AudioController.lowsRange[1],
327                          callback: value => {
328                              AudioController.lowsRange[1] = value;
329                          }
330                      },
331                      {
332                          type: 'divider'
333                      },
334                      {
335                          type: 'slider',
336                          name: 'Oscope',
337                          min: 0,
338                          max: 1,
339                          step: 0.01,
340                          value: AudioController.multiplier,
341                          callback: value => {
342                              AudioController.multiplier = value;
343                          }
344                      },
345                      {
346                          type: 'slider',
347                          name: 'Chunk',
348                          min: 0,
349                          max: AudioController.chunkSizes.length - 1,
350                          step: 1,
351                          value: AudioController.chunkSizes.indexOf(AudioController.chunkSize),
352                          callback: value => {
353                              AudioController.setGraphSegments(AudioController.chunkSizes[value]);
354                          }
355                      }
356                  ];
357  
358                  items.forEach(data => {
359                      this.ui.addPanel(new PanelItem(data));
360                  });
361              }
362          }
363  
364          class App {
365              static async init() {
366                  const sound = localStorage.getItem('sound');
367                  store.sound = sound ? JSON.parse(sound) : true;
368  
369                  this.initViews();
370                  this.initAudio();
371                  this.initPanel();
372  
373                  this.addListeners();
374                  this.onResize();
375  
376                  this.animateIn();
377              }
378  
379              static initViews() {
380                  this.ui = new UI({
381                      fps: true,
382                      instructions: {
383                          content: `${navigator.maxTouchPoints ? 'Tap' : 'Click'} for sound`
384                      },
385                      audioButton: {
386                          sound: store.sound
387                      }
388                  });
389                  this.ui.css({
390                      minHeight: '100%',
391                      display: 'flex',
392                      justifyContent: 'center',
393                      alignItems: 'center'
394                  });
395                  document.body.appendChild(this.ui.element);
396  
397                  this.ui.audioButton.setData({
398                      name: 'Cyberspace',
399                      title: 'cyberspace.app',
400                      link: 'https://cyberspace.app/'
401                  });
402  
403                  // Radial graph with 3 segments, ghost and labels
404                  this.graph = new RadialGraphSegments({
405                      value: new Array(2048).fill(0),
406                      ghost: true,
407                      start: -90,
408                      precision: 2,
409                      lookupPrecision: 100,
410                      segments: [1, 1, 1],
411                      labels: ['Highs', 'Mids', 'Lows'],
412                      noHover: true
413                  });
414                  this.ui.add(this.graph);
415              }
416  
417              static initAudio() {
418                  WebAudio.init({ sampleRate: 48000 });
419                  WebAudio.load({ cyberspace: 'https://icecast.cyberspace.app/dive.ogg' });
420  
421                  AudioController.init(this.ui, this.graph);
422              }
423  
424              static initPanel() {
425                  PanelController.init(this.ui);
426              }
427  
428              static addListeners() {
429                  this.ui.audioButton.events.on('update', this.onAudio);
430                  document.addEventListener('dblclick', this.preventZoom);
431                  window.addEventListener('resize', this.onResize);
432                  ticker.add(this.onUpdate);
433                  ticker.start();
434              }
435  
436              // Event handlers
437  
438              static preventZoom = e => {
439                  e.preventDefault();
440              };
441  
442              static onAudio = ({ sound }) => {
443                  if (sound) {
444                      AudioController.unmute();
445                  } else {
446                      AudioController.mute();
447                  }
448  
449                  localStorage.setItem('sound', JSON.stringify(sound));
450  
451                  store.sound = sound;
452              };
453  
454              static onResize = () => {
455                  const width = document.documentElement.clientWidth;
456                  const height = document.documentElement.clientHeight;
457  
458                  if (width < height) {
459                      const size = document.documentElement.clientWidth * 0.74;
460  
461                      this.graph.setSize(size, size);
462                  } else {
463                      const size = document.documentElement.clientHeight * 0.74;
464  
465                      this.graph.setSize(size, size);
466                  }
467  
468                  this.ui.instructions.css({ bottom: Math.round(height / 2) - 16 });
469              };
470  
471              static onUpdate = time => {
472                  AudioController.update(time);
473                  this.graph.update();
474                  this.ui.update();
475              };
476  
477              // Public methods
478  
479              static animateIn = () => {
480                  this.graph.animateIn();
481                  this.ui.animateIn();
482              };
483          }
484  
485          App.init();
486      </script>
487  </head>
488  <body>
489  </body>
490  </html>