/ 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>