multimodal / videoinput /srcts /audioSpinner.ts
jcheng5's picture
Initial checkin
21e6506
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;
}