📄 CurveRenderer.ts
import {
    Curve,
    Group,
    LineCurve3,
    MathUtils,
    Mesh,
    MeshPhysicalMaterial,
    TubeGeometry,
    Vector3,
    Vector4,
} from "three";
import { NURBSCurve } from "three/examples/jsm/curves/NURBSCurve.js";

export class CurveRenderer {
    public static audioAnalyser: AnalyserNode | null;
    private static sampleRate = 50;
    private static curveCount = 50;
    private static nurbsDegree = 3;
    private static width = 10;
    private static thickness = 0.05;
    private static segments = 8;
    private static spacing = 0.2;
    private static material = new MeshPhysicalMaterial({
        color: 0xbada55,
        transparent: true,
        opacity: 0.9,
        transmission: 1,
        depthWrite: false,
    });

    private readonly group: Group;
    private readonly curves: Array<Mesh>;

    private previousSampleTime: number | null = null;

    public constructor(group: Group) {
        this.group = group;
        this.curves = CurveRenderer.createStraightLines();
        for (const curve of this.curves) {
            this.group.add(curve);
        }
    }

    private static createStraightLines(): Array<Mesh> {
        const lines = new Array<Mesh>();
        const geometry = new TubeGeometry(
            new LineCurve3(new Vector3(-this.width / 2, 0, 0), new Vector3(this.width / 2, 0, 0)),
            2,
            this.thickness,
            this.segments,
        );
        for (let i = 0; i < this.curveCount; i++) {
            const mesh = new Mesh(geometry, this.material);
            mesh.position.set(0, 0, (this.curveCount / 2) * this.spacing - i * this.spacing);
            lines.push(mesh);
        }
        return lines;
    }

    public update(): void {
        const heights = this.calculateHeights();
        if (heights == null) {
            return;
        }

        this.group.remove(this.curves.pop()!);
        for (let i = 0; i < this.curves.length; i++) {
            this.curves[i].position.setComponent(2, this.curves[i].position.z - CurveRenderer.spacing);
        }

        const geometry = new TubeGeometry(
            this.convertToCurve(heights),
            heights.length * 2,
            CurveRenderer.thickness,
            CurveRenderer.segments,
        );
        const curve = new Mesh(geometry, CurveRenderer.material);
        curve.position.set(0, 0, (CurveRenderer.curveCount / 2) * CurveRenderer.spacing);
        this.group.add(curve);
        this.curves.unshift(curve);
    }

    private calculateHeights(): Float32Array | null {
        if (CurveRenderer.audioAnalyser == null) {
            return null;
        }
        if (
            this.previousSampleTime != null &&
            this.previousSampleTime + 1 / CurveRenderer.sampleRate > CurveRenderer.audioAnalyser.context.currentTime
        ) {
            return null;
        }
        this.previousSampleTime = CurveRenderer.audioAnalyser.context.currentTime;
        const frequencyBuffer = new Uint8Array(CurveRenderer.audioAnalyser.frequencyBinCount);
        CurveRenderer.audioAnalyser.getByteFrequencyData(frequencyBuffer);
        const heights = new Float32Array(frequencyBuffer.length);
        for (let i = 0; i < frequencyBuffer.length; i++) {
            heights[i] =
                (frequencyBuffer[i] - CurveRenderer.audioAnalyser.minDecibels) /
                (CurveRenderer.audioAnalyser.maxDecibels - CurveRenderer.audioAnalyser.minDecibels);
        }
        return heights;
    }

    private convertToCurve(heights: Float32Array): Curve<Vector3> {
        const knots = new Array<number>();
        const controlPoints = new Array<Vector4>();

        for (let i = 0; i < CurveRenderer.nurbsDegree; i++) {
            knots.push(0);
        }

        for (let i = 0; i < heights.length; i++) {
            controlPoints.push(new Vector4((i / heights.length - 0.5) * CurveRenderer.width, heights[i] - 1, 0, 1));
            knots.push(MathUtils.clamp((i + 1) / (heights.length - CurveRenderer.nurbsDegree), 0, 1));
        }

        return new NURBSCurve(CurveRenderer.nurbsDegree, knots, controlPoints);
    }
}