[READ-ONLY] Mirror of https://github.com/probablykasper/ferrum. Music library app for Mac, Linux and Windows ferrum.kasper.space
electron linux macos music music-library music-player napi windows
0

Configure Feed

Select the types of activity you want to include in your feed.

Fix GPU/CPU usage leak

Kasper (Oct 2, 2025, 11:46 PM +0200) a63f11ce 4450a142

+304 -153
+15
src/lib/helpers.ts
··· 122 122 export function assert_unreachable(x: never) { 123 123 console.error('Unreachable reached', x) 124 124 } 125 + 126 + /** A requestAnimationFrame instance that prevents multiple simultaneous calls */ 127 + export function create_singular_request_animation_frame() { 128 + let scheduled: number | undefined 129 + 130 + return (callback: FrameRequestCallback): number => { 131 + if (scheduled) return scheduled 132 + 133 + scheduled = requestAnimationFrame((time) => { 134 + scheduled = undefined 135 + callback(time) 136 + }) 137 + return scheduled 138 + } 139 + }
+158 -33
src/lib/visualizer/ShaderToyLite.js
··· 1 1 // github.com/chipweinberger/ShaderToyLite.js/blob/main/ShaderToyLite.js 2 2 3 - export function ShaderToyLite(canvasId) { 3 + import { create_singular_request_animation_frame } from '$lib/helpers' 4 + 5 + export function ShaderToyLite(canvas) { 4 6 var hdr = `#version 300 es 5 7 #ifdef GL_ES 6 8 precision highp float; ··· 65 67 powerPreference: 'high-performance', 66 68 } 67 69 68 - var gl = document.getElementById(canvasId).getContext('webgl2', opts) 70 + var gl = canvas.getContext('webgl2', opts) 69 71 70 72 // timing 71 73 var isPlaying = false ··· 93 95 var aframebuf = {} // front buffer (output) 94 96 var bframebuf = {} // back buffer (output) 95 97 var program = {} // webgl program 98 + var fragmentShaders = {} 99 + var vertexShaders = {} 96 100 var location = {} // uniform location 97 101 var flip = {} // a b flip 98 102 var quadBuffer // two full screen triangles ··· 123 127 124 128 // Set viewport size 125 129 gl.viewport(0, 0, gl.canvas.width, gl.canvas.height) 126 - 127 - var canvas = document.getElementById(canvasId) 128 130 129 131 window.addEventListener('resize', function () { 130 132 gl.canvas.width = canvas.width ··· 179 181 } 180 182 181 183 var compileProgram = (key) => { 184 + // Delete previous program + shaders if they exist 185 + if (program[key]) { 186 + gl.deleteProgram(program[key]) 187 + program[key] = null 188 + } 189 + if (vertexShaders[key]) { 190 + gl.deleteShader(vertexShaders[key]) 191 + vertexShaders[key] = null 192 + } 193 + if (fragmentShaders[key]) { 194 + gl.deleteShader(fragmentShaders[key]) 195 + fragmentShaders[key] = null 196 + } 197 + 182 198 var vert = gl.createShader(gl.VERTEX_SHADER) 183 199 gl.shaderSource(vert, basicVertexShader) 184 200 gl.compileShader(vert) ··· 189 205 return null 190 206 } 191 207 192 - var source = hdr + common + sourcecode[key] 208 + var fragSource = hdr + common + sourcecode[key] 193 209 var frag = gl.createShader(gl.FRAGMENT_SHADER) 194 - gl.shaderSource(frag, source) 210 + gl.shaderSource(frag, fragSource) 195 211 gl.compileShader(frag) 196 212 197 213 if (!gl.getShaderParameter(frag, gl.COMPILE_STATUS)) { 198 214 console.error('Fragment Shader compilation failed: ' + gl.getShaderInfoLog(frag)) 199 - console.error(source) 215 + console.error(fragSource) 200 216 gl.deleteShader(frag) 201 217 return null 202 218 } 203 219 204 - var program = gl.createProgram() 205 - gl.attachShader(program, vert) 206 - gl.attachShader(program, frag) 207 - gl.linkProgram(program) 220 + var newProgram = gl.createProgram() 221 + gl.attachShader(newProgram, vert) 222 + gl.attachShader(newProgram, frag) 223 + gl.linkProgram(newProgram) 208 224 209 - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 210 - console.error('Program initialization failed: ' + gl.getProgramInfoLog(program)) 225 + gl.deleteShader(vert) 226 + gl.deleteShader(frag) 227 + 228 + if (!gl.getProgramParameter(newProgram, gl.LINK_STATUS)) { 229 + console.error('Program initialization failed: ' + gl.getProgramInfoLog(newProgram)) 211 230 return null 212 231 } 213 232 214 233 // uniform locations 215 - location[key]['iResolution'] = gl.getUniformLocation(program, 'iResolution') 216 - location[key]['iTime'] = gl.getUniformLocation(program, 'iTime') 217 - location[key]['iTimeDelta'] = gl.getUniformLocation(program, 'iTimeDelta') 218 - location[key]['iFrameRate'] = gl.getUniformLocation(program, 'iFrameRate') 219 - location[key]['iFrame'] = gl.getUniformLocation(program, 'iFrame') 220 - location[key]['iChannelTime'] = gl.getUniformLocation(program, 'iChannelTime[0]') 221 - location[key]['iChannelResolution'] = gl.getUniformLocation(program, 'iChannelResolution[0]') 222 - location[key]['iChannel0'] = gl.getUniformLocation(program, 'iChannel0') 223 - location[key]['iChannel1'] = gl.getUniformLocation(program, 'iChannel1') 224 - location[key]['iChannel2'] = gl.getUniformLocation(program, 'iChannel2') 225 - location[key]['iChannel3'] = gl.getUniformLocation(program, 'iChannel3') 226 - location[key]['iMouse'] = gl.getUniformLocation(program, 'iMouse') 227 - location[key]['iDate'] = gl.getUniformLocation(program, 'iDate') 228 - location[key]['iSampleRate'] = gl.getUniformLocation(program, 'iSampleRate') 229 - location[key]['iStream'] = gl.getUniformLocation(program, 'iStream') 230 - location[key]['iVolume'] = gl.getUniformLocation(program, 'iVolume') 231 - location[key]['vertexInPosition'] = gl.getAttribLocation(program, 'vertexInPosition') 234 + location[key]['iResolution'] = gl.getUniformLocation(newProgram, 'iResolution') 235 + location[key]['iTime'] = gl.getUniformLocation(newProgram, 'iTime') 236 + location[key]['iTimeDelta'] = gl.getUniformLocation(newProgram, 'iTimeDelta') 237 + location[key]['iFrameRate'] = gl.getUniformLocation(newProgram, 'iFrameRate') 238 + location[key]['iFrame'] = gl.getUniformLocation(newProgram, 'iFrame') 239 + location[key]['iChannelTime'] = gl.getUniformLocation(newProgram, 'iChannelTime[0]') 240 + location[key]['iChannelResolution'] = gl.getUniformLocation(newProgram, 'iChannelResolution[0]') 241 + location[key]['iChannel0'] = gl.getUniformLocation(newProgram, 'iChannel0') 242 + location[key]['iChannel1'] = gl.getUniformLocation(newProgram, 'iChannel1') 243 + location[key]['iChannel2'] = gl.getUniformLocation(newProgram, 'iChannel2') 244 + location[key]['iChannel3'] = gl.getUniformLocation(newProgram, 'iChannel3') 245 + location[key]['iMouse'] = gl.getUniformLocation(newProgram, 'iMouse') 246 + location[key]['iDate'] = gl.getUniformLocation(newProgram, 'iDate') 247 + location[key]['iSampleRate'] = gl.getUniformLocation(newProgram, 'iSampleRate') 248 + location[key]['iStream'] = gl.getUniformLocation(newProgram, 'iStream') 249 + location[key]['iVolume'] = gl.getUniformLocation(newProgram, 'iVolume') 250 + location[key]['vertexInPosition'] = gl.getAttribLocation(newProgram, 'vertexInPosition') 232 251 233 - return program 252 + vertexShaders[key] = vert 253 + fragmentShaders[key] = frag 254 + return newProgram 234 255 } 235 256 236 257 var repeat = (times, arr) => { ··· 242 263 } 243 264 244 265 var setShader = (config, key) => { 266 + // Unbind before deleting 267 + gl.bindTexture(gl.TEXTURE_2D, null) 268 + gl.bindFramebuffer(gl.FRAMEBUFFER, null) 269 + if (atexture[key]) gl.deleteTexture(atexture[key]) 270 + if (btexture[key]) gl.deleteTexture(btexture[key]) 271 + if (aframebuf[key]) gl.deleteFramebuffer(aframebuf[key]) 272 + if (bframebuf[key]) gl.deleteFramebuffer(bframebuf[key]) 273 + 274 + atexture[key] = null 275 + btexture[key] = null 276 + aframebuf[key] = null 277 + bframebuf[key] = null 278 + 245 279 if (config) { 246 280 if (config.source) { 247 281 sourcecode[key] = config.source 248 282 program[key] = compileProgram(key) 249 - if (program[key] == null) { 283 + if (!program[key]) { 250 284 console.error('Failed to compile ' + key) 251 285 } 252 286 } 287 + 288 + // Recreate textures/framebuffers for buffers 289 + if (key !== 'Image') { 290 + atexture[key] = createTexture() 291 + btexture[key] = createTexture() 292 + aframebuf[key] = createFrameBuffer(atexture[key]) 293 + bframebuf[key] = createFrameBuffer(btexture[key]) 294 + flip[key] = false 295 + } 296 + 253 297 for (let i = 0; i < 4; i++) { 254 298 var s = config[`iChannel${i}`] 255 299 if (s == 'A' || s == 'B' || s == 'C' || s == 'D') { ··· 351 395 iFrame++ 352 396 } 353 397 398 + const raf = create_singular_request_animation_frame() 399 + 354 400 // Animation loop 355 401 var animate = () => { 356 402 if (isPlaying) { 357 403 draw() 358 - requestAnimationFrame(animate) 404 + raf(animate) 359 405 } 360 406 } 361 407 ··· 407 453 } 408 454 409 455 this.addTexture = (texture, key) => { 456 + if (atexture[key]) { 457 + gl.deleteTexture(atexture[key]) 458 + atexture[key] = null 459 + } 460 + if (btexture[key]) { 461 + gl.deleteTexture(btexture[key]) 462 + btexture[key] = null 463 + } 464 + if (aframebuf[key]) { 465 + gl.deleteFramebuffer(aframebuf[key]) 466 + aframebuf[key] = null 467 + } 468 + if (bframebuf[key]) { 469 + gl.deleteFramebuffer(bframebuf[key]) 470 + bframebuf[key] = null 471 + } 410 472 atexture[key] = texture 411 473 btexture[key] = texture 412 474 flip[key] = false ··· 477 539 if (!isPlaying) { 478 540 draw() 479 541 } 542 + } 543 + 544 + this.destroy = () => { 545 + // Stop animation loop 546 + isPlaying = false 547 + 548 + // Delete all programs and shaders 549 + ;['A', 'B', 'C', 'D', 'Image'].forEach((key) => { 550 + if (program[key]) { 551 + gl.deleteProgram(program[key]) 552 + program[key] = null 553 + } 554 + if (vertexShaders[key]) { 555 + gl.deleteShader(vertexShaders[key]) 556 + vertexShaders[key] = null 557 + } 558 + if (fragmentShaders[key]) { 559 + gl.deleteShader(fragmentShaders[key]) 560 + fragmentShaders[key] = null 561 + } 562 + }) 563 + 564 + // Delete all textures and framebuffers 565 + ;['A', 'B', 'C', 'D'].forEach((key) => { 566 + if (atexture[key]) { 567 + gl.deleteTexture(atexture[key]) 568 + atexture[key] = null 569 + } 570 + if (btexture[key]) { 571 + gl.deleteTexture(btexture[key]) 572 + btexture[key] = null 573 + } 574 + if (aframebuf[key]) { 575 + gl.deleteFramebuffer(aframebuf[key]) 576 + aframebuf[key] = null 577 + } 578 + if (bframebuf[key]) { 579 + gl.deleteFramebuffer(bframebuf[key]) 580 + bframebuf[key] = null 581 + } 582 + }) 583 + 584 + // Delete quad buffer 585 + if (quadBuffer) { 586 + gl.deleteBuffer(quadBuffer) 587 + quadBuffer = null 588 + } 589 + 590 + // Clear event listeners (store references during setup to remove them) 591 + // Note: These won't be removed since we don't have references to the handlers 592 + // If you need to remove them, store handler references in setup() 593 + 594 + // Lose WebGL context to free GPU memory 595 + const loseContext = gl.getExtension('WEBGL_lose_context') 596 + if (loseContext) { 597 + loseContext.loseContext() 598 + } 599 + 600 + // Clear callback 601 + onDrawCallback = null 602 + 603 + // Nullify gl reference 604 + gl = null 480 605 } 481 606 482 607 setup()
+127 -118
src/lib/visualizer/Visualizer.svelte
··· 1 1 <script lang="ts"> 2 2 import { start_visualizer } from './visualizer' 3 3 import { ShaderToyLite } from './ShaderToyLite.js' 4 - import { onMount } from 'svelte' 4 + import { onDestroy, onMount, untrack } from 'svelte' 5 5 import { audioContext, mediaElementSource } from '$lib/player' 6 6 import Player from '$components/Player.svelte' 7 7 import { fly } from 'svelte/transition' 8 8 import { check_modifiers } from '$lib/helpers' 9 - import { quadInOut } from 'svelte/easing' 10 9 11 10 let { on_close }: { on_close: () => void } = $props() 12 - 13 - let canvas: HTMLCanvasElement | undefined 14 - const canvas_id = 'visualizer' 15 - let toy: ShaderToyLite | undefined 16 - let currentShaderIndex = $state(0) 17 - let transitionToy: ShaderToyLite | undefined 18 - let isTransitioning = $state(false) 19 - let transitionCanvas: HTMLCanvasElement | undefined = $state() 20 - const transition_canvas_id = 'visualizer-transition' 21 - let autoTransitionTimeout: ReturnType<typeof setTimeout> | undefined 22 - let pendingTransition: number | undefined 23 11 24 12 const shaders = [ 25 13 { ··· 116 104 fragColor = vec4(col.rgb, 1.); 117 105 } 118 106 ` 119 - 120 - onMount(() => { 121 - const a = shaders[0].shader 122 - toy = new ShaderToyLite(canvas_id) 107 + function create_toy(canvas: HTMLCanvasElement) { 108 + const toy = new ShaderToyLite(canvas) 123 109 toy.setCommon('') 124 - toy.setBufferA({ source: a }) 125 110 toy.setImage({ source: imageShader, iChannel0: 'A' }) 126 - toy.play() 111 + return toy 112 + } 127 113 128 - const visualizer = start_visualizer(audioContext, mediaElementSource, (info) => { 129 - toy?.setStream(info.stream) 130 - toy?.setVolume(info.volume) 131 - // Also update transition toy if it exists 132 - transitionToy?.setStream(info.stream) 133 - transitionToy?.setVolume(info.volume) 134 - }) 114 + // main visualizer, and a transition visualizer 115 + type Vis = { 116 + is_main: boolean 117 + canvas?: HTMLCanvasElement 118 + toy?: ShaderToyLite 119 + should_resize?: boolean 120 + } 121 + const visualisers: [Vis, Vis] = $state([ 122 + { 123 + id: 'vis-0', 124 + is_main: true, 125 + }, 126 + { 127 + id: 'vis-1', 128 + is_main: false, 129 + }, 130 + ]) 131 + let main_vis = visualisers[0] 132 + let next_vis = visualisers[1] 135 133 136 - return () => { 137 - visualizer.destroy() 138 - toy?.pause() 139 - transitionToy?.pause() 140 - clearTimeout(autoTransitionTimeout) 141 - } 134 + let currentShaderIndex = 0 135 + let isTransitioning = false 136 + let autoTransitionTimeout: ReturnType<typeof setTimeout> | undefined 137 + 138 + const visualizer = start_visualizer(audioContext, mediaElementSource, (info) => { 139 + main_vis.toy?.setStream(info.stream) 140 + main_vis.toy?.setVolume(info.volume) 141 + // Also update transition toy if it exists 142 + next_vis.toy?.setStream(info.stream) 143 + next_vis.toy?.setVolume(info.volume) 142 144 }) 143 145 144 - function transitionToShader(newShaderIndex: number, duration: number = 2000) { 145 - if (currentShaderIndex === newShaderIndex) return 146 + let pendingTransition: number | null = null 146 147 148 + async function transitionToShader(newShaderIndex: number, duration = 2000) { 147 149 if (isTransitioning) { 148 150 pendingTransition = newShaderIndex 149 151 return 150 152 } 153 + if (currentShaderIndex === newShaderIndex) return 154 + 155 + if (!main_vis.canvas || !next_vis.canvas) { 156 + return console.error('Missing canvas') 157 + } 151 158 152 159 console.log('Starting transition to:', newShaderIndex) 153 160 isTransitioning = true 154 - 155 - // Clear any pending auto transition 156 161 clearTimeout(autoTransitionTimeout) 157 162 158 - // Create transition toy 159 - transitionToy = new ShaderToyLite(transition_canvas_id) 160 - transitionToy.setCommon('') 161 - transitionToy.setBufferA({ source: shaders[newShaderIndex].shader }) 162 - transitionToy.setImage({ 163 - source: imageShader, 164 - iChannel0: 'A', 165 - }) 166 - transitionToy.play() 163 + // Start transition visualizer 164 + if (!next_vis.toy) { 165 + next_vis.toy = create_toy(next_vis.canvas) 166 + } 167 + if (next_vis.should_resize) { 168 + next_vis.should_resize = false 169 + schedule_resize(next_vis) 170 + } 171 + next_vis.toy.setBufferA({ source: shaders[newShaderIndex].shader }) 172 + next_vis.toy.play() 167 173 168 - // Animate transition 169 - let startTime = Date.now() 174 + const animation_new = next_vis.canvas.animate( 175 + { opacity: [0, 1] }, 176 + { 177 + duration: duration, 178 + easing: 'cubic-bezier(0.45, 0, 0.55, 1)', // quadInOut 179 + fill: 'forwards', 180 + }, 181 + ) 182 + const animation_old = main_vis.canvas.animate( 183 + { opacity: [1, 0] }, 184 + { 185 + duration: duration, 186 + easing: 'cubic-bezier(0.45, 0, 0.55, 1)', // quadInOut 187 + fill: 'forwards', 188 + }, 189 + ) 190 + await animation_new.finished 191 + await animation_old.finished 170 192 171 - function animateTransition() { 172 - const elapsed = Date.now() - startTime 173 - const linearProgress = Math.min(elapsed / duration, 1) 174 - const progress = quadInOut(linearProgress) 193 + await new Promise((resolve) => setTimeout(resolve, duration)) 175 194 176 - // Fade out old canvas, fade in new canvas 177 - if (canvas) { 178 - canvas.style.opacity = (1 - progress).toString() 179 - } 180 - if (transitionCanvas) { 181 - transitionCanvas.style.opacity = progress.toString() 182 - } 195 + next_vis.is_main = true 196 + main_vis.is_main = false 197 + // Swap 198 + ;[main_vis, next_vis] = [next_vis, main_vis] 183 199 184 - if (linearProgress < 1) { 185 - requestAnimationFrame(animateTransition) 186 - } else { 187 - // Transition complete - clean up properly 188 - toy?.pause() 200 + next_vis.toy?.pause() // Keep the instance for later use 189 201 190 - // Update the main toy with new shader 191 - toy?.setBufferA({ source: shaders[newShaderIndex].shader }) 192 - toy?.play() 202 + currentShaderIndex = newShaderIndex 203 + isTransitioning = false 193 204 194 - // Clean up transition toy 195 - transitionToy?.pause() 196 - transitionToy = undefined 205 + scheduleAutoTransition() 197 206 198 - currentShaderIndex = newShaderIndex 199 - isTransitioning = false 200 - 201 - // Reset canvas opacities 202 - if (canvas) { 203 - canvas.style.opacity = '1' 204 - } 205 - if (transitionCanvas) { 206 - transitionCanvas.style.opacity = '0' 207 - } 208 - 209 - // Schedule next auto transition 210 - scheduleAutoTransition() 211 - 212 - // Handle any pending transition 213 - if (pendingTransition !== undefined) { 214 - const nextIndex = pendingTransition 215 - pendingTransition = undefined 216 - transitionToShader(nextIndex, 500) 217 - } 218 - } 207 + if (pendingTransition !== null) { 208 + const nextIndex = pendingTransition 209 + pendingTransition = null 210 + transitionToShader(nextIndex) 219 211 } 220 - 221 - requestAnimationFrame(animateTransition) 222 212 } 223 213 224 214 function scheduleAutoTransition() { ··· 226 216 autoTransitionTimeout = setTimeout(() => { 227 217 if (!isTransitioning) { 228 218 const nextIndex = (currentShaderIndex + 1) % shaders.length 229 - transitionToShader(nextIndex, 2000) 219 + transitionToShader(nextIndex) 230 220 } 231 - }, 1000) 221 + }, 10000) 232 222 } 233 223 234 224 scheduleAutoTransition() ··· 242 232 show_player = false 243 233 }, 1000) 244 234 } 235 + 236 + function schedule_resize(vis: Vis) { 237 + if (!isTransitioning && !vis.is_main) { 238 + vis.should_resize = true 239 + return 240 + } 241 + if (vis.canvas) { 242 + vis.canvas.width = window.innerWidth 243 + vis.canvas.height = window.innerHeight 244 + vis.toy?.resize() 245 + } 246 + } 247 + 248 + onDestroy(() => { 249 + clearTimeout(autoTransitionTimeout) 250 + visualizer.destroy() 251 + main_vis.toy?.destroy() 252 + next_vis.toy?.destroy() 253 + }) 245 254 </script> 246 255 247 256 <svelte:window 248 257 on:resize={() => { 249 - if (canvas) { 250 - canvas.width = window.innerWidth 251 - canvas.height = window.innerHeight 252 - toy?.resize() 258 + for (const vis of visualisers) { 259 + schedule_resize(vis) 253 260 } 254 261 }} 255 262 /> 256 263 257 264 <dialog 258 - class="max-h-screen max-w-screen outline-none" 265 + class="max-h-screen max-w-screen bg-black outline-none" 259 266 {@attach (e) => { 260 267 e.showModal() 261 268 }} ··· 277 284 }} 278 285 > 279 286 <div class="h-screen w-screen"> 280 - <canvas 281 - bind:this={canvas} 282 - class="fixed inset-0" 283 - id={canvas_id} 284 - width={window.innerWidth} 285 - height={window.innerHeight} 286 - onmousemove={show_player_temporarily} 287 - ></canvas> 288 - <!-- Always render transition canvas, but hidden --> 289 - <canvas 290 - bind:this={transitionCanvas} 291 - class="pointer-events-none fixed inset-0 mix-blend-screen" 292 - id={transition_canvas_id} 293 - width={window.innerWidth} 294 - height={window.innerHeight} 295 - onmousemove={show_player_temporarily} 296 - style:opacity="0" 297 - ></canvas> 287 + {#each visualisers as vis, i (vis)} 288 + <canvas 289 + bind:this={vis.canvas} 290 + class="fixed inset-0" 291 + style:z-index={vis.is_main ? '1' : '2'} 292 + class:mix-blend-screen={!vis.is_main} 293 + width={window.innerWidth} 294 + height={window.innerHeight} 295 + onmousemove={show_player_temporarily} 296 + {@attach (canvas) => { 297 + vis.canvas = canvas 298 + if (vis.is_main && !vis.toy) { 299 + const toy = create_toy(canvas) 300 + toy.setBufferA({ source: shaders[0].shader }) 301 + toy.play() 302 + vis.toy = toy 303 + } 304 + }} 305 + ></canvas> 306 + {/each} 298 307 {#if show_player} 299 308 <!-- svelte-ignore a11y_no_static_element_interactions --> 300 309 <div 301 - class="fixed bottom-0 flex w-full flex-col justify-end" 310 + class="fixed bottom-0 z-10 flex w-full flex-col justify-end" 302 311 transition:fly={{ y: '100%', duration: 500 }} 303 312 onmouseenter={() => { 304 313 show_player = true
+4 -2
src/lib/visualizer/visualizer.ts
··· 1 1 // Based on kaleidosync code 2 2 3 + import { create_singular_request_animation_frame } from '$lib/helpers' 3 4 import { scaleLinear } from 'd3-scale' 4 5 import Meyda from 'meyda' 5 6 export type AudioStream = [number, number] ··· 23 24 const analyser = audioContext.createAnalyser() 24 25 const filter = audioContext.createBiquadFilter() 25 26 const timeBuffer = new Float32Array(BIT_DEPTH) 27 + const raf = create_singular_request_animation_frame() 26 28 27 29 mediaElementSource.connect(filter) 28 30 filter.connect(analyser) ··· 104 106 on_update({ stream, volume }) 105 107 106 108 if (!destroyed) { 107 - raf_id = requestAnimationFrame(measure) 109 + raf_id = raf(measure) 108 110 } 109 111 } 110 112 111 - let raf_id = requestAnimationFrame(measure) 113 + let raf_id = raf(measure) 112 114 113 115 return { 114 116 destroy() {