Spaces:
Runtime error
Runtime error
| class AudioSpinnerElement extends HTMLElement { | |
| #audio!: HTMLAudioElement; | |
| #canvas!: HTMLCanvasElement; | |
| #ctx2d!: CanvasRenderingContext2D; | |
| #analyzer!: AnalyserNode; | |
| #dataArray!: Float32Array; | |
| #smoother!: Smoother<Float32Array>; | |
| constructor() { | |
| super(); | |
| this.attachShadow({ mode: "open" }); | |
| this.shadowRoot!.innerHTML = ` | |
| <style> | |
| :host { | |
| display: block; | |
| position: relative; | |
| } | |
| ::slotted(canvas) { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| } | |
| ::slotted(audio) { | |
| display: none; | |
| } | |
| </style> | |
| <slot name="audio"></slot> | |
| <slot name="canvas"></slot> | |
| `; | |
| } | |
| connectedCallback() { | |
| // Create <audio>. This will play the sound. | |
| const audioSlot = this.shadowRoot!.querySelector( | |
| "slot[name=audio]" | |
| )! as HTMLSlotElement; | |
| this.#audio = this.ownerDocument.createElement("audio"); | |
| this.#audio.autoplay = true; | |
| this.#audio.controls = false; | |
| this.#audio.src = this.getAttribute("src")!; | |
| this.#audio.slot = "audio"; | |
| audioSlot.assign(this.#audio); | |
| this.#audio.addEventListener("play", () => { | |
| this.#draw(); | |
| }); | |
| this.#audio.onpause = () => { | |
| this.style.transition = "opacity 0.5s 1s"; | |
| this.classList.add("fade"); | |
| this.addEventListener("transitionend", () => { | |
| this.remove(); | |
| }); | |
| }; | |
| // Create <canvas>. This will be the target of our vizualization. | |
| const canvasSlot = this.shadowRoot!.querySelector( | |
| "slot[name=canvas]" | |
| )! as HTMLSlotElement; | |
| this.#canvas = this.ownerDocument.createElement("canvas"); | |
| this.#canvas.slot = "canvas"; | |
| this.#canvas.width = this.clientWidth * window.devicePixelRatio; | |
| this.#canvas.height = this.clientHeight * window.devicePixelRatio; | |
| this.#canvas.style.width = this.clientWidth + "px"; | |
| this.#canvas.style.height = this.clientHeight + "px"; | |
| this.appendChild(this.#canvas); | |
| canvasSlot.assign(this.#canvas); | |
| this.#ctx2d = this.#canvas.getContext("2d")!; | |
| this.#ctx2d.scale(window.devicePixelRatio, window.devicePixelRatio); | |
| new ResizeObserver(() => { | |
| this.#canvas.width = this.clientWidth; | |
| this.#canvas.height = this.clientHeight; | |
| }).observe(this); | |
| // Initialize analyzer | |
| const audioCtx = new AudioContext(); | |
| const source = audioCtx.createMediaElementSource(this.#audio); | |
| this.#analyzer = new AnalyserNode(audioCtx, { | |
| fftSize: 2048, | |
| }); | |
| this.#dataArray = new Float32Array(this.#analyzer.frequencyBinCount); | |
| source.connect(this.#analyzer); | |
| this.#analyzer.connect(audioCtx.destination); | |
| // Initialize persistent data structures needed for vizualization | |
| const dataArray2 = new Float32Array(this.#analyzer.frequencyBinCount); | |
| this.#smoother = new Smoother<Float32Array>(5, (samples) => { | |
| for (let i = 0; i < dataArray2.length; i++) { | |
| dataArray2[i] = 0; | |
| for (let j = 0; j < samples.length; j++) { | |
| dataArray2[i] += samples[j][i]; | |
| } | |
| dataArray2[i] /= samples.length; | |
| } | |
| return dataArray2; | |
| }); | |
| this.#draw(); | |
| } | |
| #draw() { | |
| if (!this.isConnected) { | |
| return; | |
| } | |
| requestAnimationFrame(() => this.#draw()); | |
| const width = this.#canvas.width; | |
| const height = this.#canvas.height; | |
| this.#ctx2d.clearRect(0, 0, width, height); | |
| this.#analyzer.getFloatTimeDomainData(this.#dataArray); | |
| const smoothed = this.#smoother.add(new Float32Array(this.#dataArray)); | |
| const { | |
| spinVelocity, | |
| gap, | |
| thickness, | |
| minRadius, | |
| radiusFactor, | |
| steps, | |
| blades, | |
| } = this.#getSettings(width, height); | |
| const avg = | |
| (smoothed.reduce((a, b) => a + Math.abs(b), 0) / smoothed.length) * 4; | |
| const radius = minRadius + (avg * (height - minRadius)) / radiusFactor; | |
| for (let step = 0; step < steps; step++) { | |
| const this_radius = radius - step * (radius / (steps + 1)); | |
| if (step === steps - 1) { | |
| this.#drawPie(width, height, 0, Math.PI * 2, this_radius, thickness); | |
| } else { | |
| const seconds = new Date().getTime() / 1000; | |
| const startAngle = (seconds * spinVelocity) % (Math.PI * 2); | |
| for (let blade = 0; blade < blades; blade++) { | |
| const angleOffset = ((Math.PI * 2) / blades) * blade; | |
| const sweep = (Math.PI * 2) / blades - gap; | |
| this.#drawPie( | |
| width, | |
| height, | |
| startAngle + angleOffset, | |
| sweep, | |
| this_radius, | |
| thickness | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| #drawPie( | |
| width: number, | |
| height: number, | |
| startAngle: number, | |
| sweep: number, | |
| radius: number, | |
| thickness?: number | |
| ) { | |
| this.#ctx2d.beginPath(); | |
| this.#ctx2d.fillStyle = this.#canvas | |
| .computedStyleMap() | |
| .get("color") | |
| ?.toString()!; | |
| if (!thickness) { | |
| this.#ctx2d.moveTo(width / 2, height / 2); | |
| } | |
| this.#ctx2d.arc( | |
| width / 2, | |
| height / 2, | |
| radius, | |
| startAngle, | |
| startAngle + sweep | |
| ); | |
| if (!thickness) { | |
| this.#ctx2d.lineTo(width / 2, height / 2); | |
| } else { | |
| this.#ctx2d.arc( | |
| width / 2, | |
| height / 2, | |
| radius - thickness, | |
| startAngle + sweep, | |
| startAngle, | |
| true | |
| ); | |
| } | |
| this.#ctx2d.fill(); | |
| } | |
| #getSettings(width: number, height: number) { | |
| // Visualization settings | |
| const settings = { | |
| spinVelocity: 5, | |
| gap: Math.PI / 5, | |
| thickness: 2.5, | |
| minRadius: Math.min(width, height) / 6, | |
| radiusFactor: 1.8, | |
| steps: 3, | |
| blades: 3, | |
| }; | |
| for (const key in settings) { | |
| const value = tryParseFloat(this.dataset[key]); | |
| if (typeof value !== "undefined") { | |
| Object.assign(settings, { [key]: value }); | |
| } | |
| } | |
| return settings; | |
| } | |
| } | |
| window.customElements.define("audio-spinner", AudioSpinnerElement); | |
| class Smoother<T> { | |
| #samples: T[] = []; | |
| #smooth: (samples: T[]) => T; | |
| #size: number; | |
| #pos: number; | |
| constructor(size: number, smooth: (samples: T[]) => T) { | |
| this.#size = size; | |
| this.#pos = 0; | |
| this.#smooth = smooth; | |
| } | |
| add(sample: T): T { | |
| this.#samples[this.#pos] = sample; | |
| this.#pos = (this.#pos + 1) % this.#size; | |
| return this.smoothed(); | |
| } | |
| smoothed(): T { | |
| return this.#smooth(this.#samples); | |
| } | |
| } | |
| function tryParseFloat(str?: string): number | undefined { | |
| if (typeof str === "undefined") { | |
| return undefined; | |
| } | |
| const parsed = parseFloat(str); | |
| return isNaN(parsed) ? undefined : parsed; | |
| } | |