mirror of
https://github.com/meowarex/TidaLuna-Plugins.git
synced 2026-06-18 03:43:10 +10:00
Overhaul Audio Visualizer & RL UI Improvements
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
import type { AudioData } from "../audio";
|
||||
import type { Visualizer } from "./types";
|
||||
import { hexToRGB } from "../webgl";
|
||||
|
||||
const GATE_ABSOLUTE = -70;
|
||||
const GATE_RELATIVE_OFFSET = -10;
|
||||
const GAINS = [1.0, 1.0];
|
||||
|
||||
interface LUFSState {
|
||||
momentaryBlocks: number[];
|
||||
shortTermBlocks: number[];
|
||||
integratedPowers: number[];
|
||||
momentary: number;
|
||||
shortTerm: number;
|
||||
integrated: number;
|
||||
blockBuffer: Float32Array[];
|
||||
blockPos: number;
|
||||
blockSize: number;
|
||||
hopSize: number;
|
||||
hopPos: number;
|
||||
displayMomentary: number;
|
||||
displayShortTerm: number;
|
||||
displayIntegrated: number;
|
||||
}
|
||||
|
||||
const createLUFSState = (sampleRate: number): LUFSState => {
|
||||
const blockSize = Math.floor(sampleRate * 0.4);
|
||||
const hopSize = Math.floor(sampleRate * 0.1);
|
||||
return {
|
||||
momentaryBlocks: [],
|
||||
shortTermBlocks: [],
|
||||
integratedPowers: [],
|
||||
momentary: -Infinity,
|
||||
shortTerm: -Infinity,
|
||||
integrated: -Infinity,
|
||||
blockBuffer: [new Float32Array(blockSize), new Float32Array(blockSize)],
|
||||
blockPos: 0,
|
||||
blockSize,
|
||||
hopSize,
|
||||
hopPos: 0,
|
||||
displayMomentary: -60,
|
||||
displayShortTerm: -60,
|
||||
displayIntegrated: -60,
|
||||
};
|
||||
};
|
||||
|
||||
const computeBlockLoudness = (left: Float32Array, right: Float32Array, len: number): number => {
|
||||
let sumL = 0, sumR = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
sumL += left[i] * left[i];
|
||||
sumR += right[i] * right[i];
|
||||
}
|
||||
const powerL = sumL / len;
|
||||
const powerR = sumR / len;
|
||||
const weighted = GAINS[0] * powerL + GAINS[1] * powerR;
|
||||
if (weighted <= 0) return -Infinity;
|
||||
return -0.691 + 10 * Math.log10(weighted);
|
||||
};
|
||||
|
||||
const computeGatedIntegrated = (powers: number[]): number => {
|
||||
if (powers.length === 0) return -Infinity;
|
||||
|
||||
const aboveAbsolute = powers.filter(p => p > GATE_ABSOLUTE);
|
||||
if (aboveAbsolute.length === 0) return -Infinity;
|
||||
|
||||
const meanAbsolute = aboveAbsolute.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveAbsolute.length;
|
||||
const relativeThreshold = 10 * Math.log10(meanAbsolute) + GATE_RELATIVE_OFFSET;
|
||||
const aboveRelative = aboveAbsolute.filter(p => p > relativeThreshold);
|
||||
if (aboveRelative.length === 0) return -Infinity;
|
||||
|
||||
const meanRelative = aboveRelative.reduce((s, v) => s + Math.pow(10, v / 10), 0) / aboveRelative.length;
|
||||
return 10 * Math.log10(meanRelative);
|
||||
};
|
||||
|
||||
const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
|
||||
|
||||
export const createLoudnessMeter = (): Visualizer => {
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let w = 0, h = 0;
|
||||
let state: LUFSState | null = null;
|
||||
let lastSampleRate = 0;
|
||||
|
||||
const SMOOTHING_FAST = 0.25;
|
||||
const SMOOTHING_SLOW = 0.08;
|
||||
|
||||
return {
|
||||
name: "Loudness (LUFS)",
|
||||
id: "loudness-meter",
|
||||
|
||||
init(canvas, _color) {
|
||||
ctx = canvas.getContext("2d")!;
|
||||
w = canvas.width;
|
||||
h = canvas.height;
|
||||
state = null;
|
||||
lastSampleRate = 0;
|
||||
},
|
||||
|
||||
render(data: AudioData, color: string) {
|
||||
if (!ctx) return;
|
||||
|
||||
if (!state || data.sampleRate !== lastSampleRate) {
|
||||
state = createLUFSState(data.sampleRate);
|
||||
lastSampleRate = data.sampleRate;
|
||||
}
|
||||
|
||||
const left = data.leftTimeDomain;
|
||||
const right = data.rightTimeDomain;
|
||||
const len = Math.min(left.length, right.length);
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
state.blockBuffer[0][state.blockPos] = left[i];
|
||||
state.blockBuffer[1][state.blockPos] = right[i];
|
||||
state.blockPos++;
|
||||
state.hopPos++;
|
||||
|
||||
if (state.blockPos >= state.blockSize) {
|
||||
const loudness = computeBlockLoudness(state.blockBuffer[0], state.blockBuffer[1], state.blockSize);
|
||||
|
||||
state.momentaryBlocks.push(loudness);
|
||||
if (state.momentaryBlocks.length > 4) state.momentaryBlocks.shift();
|
||||
state.momentary = Math.max(...state.momentaryBlocks);
|
||||
|
||||
state.shortTermBlocks.push(loudness);
|
||||
if (state.shortTermBlocks.length > 30) state.shortTermBlocks.shift();
|
||||
const stPowers = state.shortTermBlocks.filter(v => v > -Infinity);
|
||||
if (stPowers.length > 0) {
|
||||
const stMean = stPowers.reduce((s, v) => s + Math.pow(10, v / 10), 0) / stPowers.length;
|
||||
state.shortTerm = 10 * Math.log10(stMean);
|
||||
}
|
||||
|
||||
state.integratedPowers.push(loudness);
|
||||
if (state.integratedPowers.length > 3000) state.integratedPowers.shift();
|
||||
state.integrated = computeGatedIntegrated(state.integratedPowers);
|
||||
|
||||
const keep = state.blockSize - state.hopSize;
|
||||
state.blockBuffer[0].copyWithin(0, state.hopSize);
|
||||
state.blockBuffer[1].copyWithin(0, state.hopSize);
|
||||
state.blockPos = keep;
|
||||
state.hopPos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const clamp = (v: number) => (v === -Infinity ? -60 : Math.max(-60, Math.min(0, v)));
|
||||
state.displayMomentary = lerp(state.displayMomentary, clamp(state.momentary), SMOOTHING_FAST);
|
||||
state.displayShortTerm = lerp(state.displayShortTerm, clamp(state.shortTerm), SMOOTHING_FAST);
|
||||
state.displayIntegrated = lerp(state.displayIntegrated, clamp(state.integrated), SMOOTHING_SLOW);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const [cr, cg, cb] = hexToRGB(color);
|
||||
|
||||
const minLUFS = -60;
|
||||
const maxLUFS = 0;
|
||||
const range = maxLUFS - minLUFS;
|
||||
const norm = (v: number) => Math.max(0, Math.min(1, (v - minLUFS) / range));
|
||||
|
||||
const labels = ["M", "S", "I"];
|
||||
const rawValues = [state.momentary, state.shortTerm, state.integrated];
|
||||
const displayValues = [state.displayMomentary, state.displayShortTerm, state.displayIntegrated];
|
||||
const barH = (h - 4) / 3;
|
||||
const labelW = 12;
|
||||
const valueW = 36;
|
||||
const barX = labelW;
|
||||
const barW = w - labelW - valueW;
|
||||
|
||||
ctx.font = `bold ${Math.min(9, barH - 1)}px monospace`;
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = 1 + i * (barH + 1);
|
||||
const n = norm(displayValues[i]);
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillText(labels[i], 1, y + barH / 2);
|
||||
|
||||
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.15)`;
|
||||
ctx.fillRect(barX, y, barW, barH);
|
||||
|
||||
ctx.fillStyle = `rgba(${cr * 255}, ${cg * 255}, ${cb * 255}, 0.7)`;
|
||||
ctx.fillRect(barX, y, barW * n, barH);
|
||||
|
||||
ctx.fillStyle = "rgba(255,255,255,0.8)";
|
||||
ctx.textAlign = "right";
|
||||
const raw = rawValues[i];
|
||||
const txt = raw > -Infinity ? raw.toFixed(1) : "-inf";
|
||||
ctx.fillText(txt, w - 1, y + barH / 2);
|
||||
}
|
||||
},
|
||||
|
||||
resize(width, height) {
|
||||
w = width;
|
||||
h = height;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
ctx = null;
|
||||
state = null;
|
||||
lastSampleRate = 0;
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user