Files
diodiogod-TTS-Audio-Suite/web/character_voices_waveform.js

205 lines
7.9 KiB
JavaScript

const WAVEFORM_BUCKETS = 512;
const MAX_CACHE_ENTRIES = 12;
const NORMALIZATION_PERCENTILE = 0.95;
const VISUAL_COMPRESSION = 0.45;
const waveformCache = new Map();
function cacheEnvelope(url, envelope) {
waveformCache.delete(url);
waveformCache.set(url, envelope);
while (waveformCache.size > MAX_CACHE_ENTRIES) {
waveformCache.delete(waveformCache.keys().next().value);
}
}
function buildEnvelope(audioBuffer) {
const channels = Array.from(
{ length: audioBuffer.numberOfChannels },
(_, index) => audioBuffer.getChannelData(index),
);
const sampleCount = audioBuffer.length;
const envelope = new Float32Array(WAVEFORM_BUCKETS);
for (let bucket = 0; bucket < WAVEFORM_BUCKETS; bucket += 1) {
const from = Math.floor((bucket / WAVEFORM_BUCKETS) * sampleCount);
const to = Math.max(from + 1, Math.floor(((bucket + 1) / WAVEFORM_BUCKETS) * sampleCount));
const stride = Math.max(1, Math.floor((to - from) / 128));
let sumSquares = 0;
let values = 0;
for (let sample = from; sample < to; sample += stride) {
for (const channel of channels) {
const value = channel[sample] || 0;
sumSquares += value * value;
values += 1;
}
}
envelope[bucket] = values ? Math.sqrt(sumSquares / values) : 0;
}
const nonZero = Array.from(envelope).filter((value) => value > 0).sort((a, b) => a - b);
// Normalize against a robust peak and compress the visual range so quiet
// speech remains readable without a single transient flattening the display.
const reference = nonZero[Math.floor(nonZero.length * NORMALIZATION_PERCENTILE)] || 1;
for (let index = 0; index < envelope.length; index += 1) {
envelope[index] = Math.pow(
Math.min(1, envelope[index] / reference),
VISUAL_COMPRESSION,
);
}
return envelope;
}
async function decodeEnvelope(url, signal) {
const cached = waveformCache.get(url);
if (cached) {
cacheEnvelope(url, cached);
return cached;
}
const response = await fetch(url, { signal, cache: "no-store" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (!AudioContextClass) throw new Error("Web Audio API is unavailable");
const context = new AudioContextClass();
try {
const audioBuffer = await context.decodeAudioData(await response.arrayBuffer());
const envelope = buildEnvelope(audioBuffer);
cacheEnvelope(url, envelope);
return envelope;
} finally {
context.close().catch(() => {});
}
}
export function createWaveformRenderer(canvas) {
let envelope = null;
let requestId = 0;
let controller = null;
let playback = { current: 0, start: 0, end: 0, duration: 0 };
const draw = () => {
// clientWidth/clientHeight stay in layout pixels while ComfyUI zooms the
// DOM overlay with a CSS transform. Screen-space bounds do not, which
// made every playback/click redraw change the bitmap and bar count.
const logicalWidth = canvas.clientWidth;
const logicalHeight = canvas.clientHeight;
if (!logicalWidth || !logicalHeight) return;
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
const width = Math.round(logicalWidth * pixelRatio);
const height = Math.round(logicalHeight * pixelRatio);
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
const context = canvas.getContext("2d");
context.clearRect(0, 0, width, height);
if (!envelope) return;
context.save();
context.scale(pixelRatio, pixelRatio);
const center = logicalHeight / 2;
const barCount = Math.min(envelope.length, Math.max(48, Math.floor(logicalWidth / 4)));
const spacing = logicalWidth / barCount;
context.lineWidth = Math.min(2.25, Math.max(1.25, spacing * 0.48));
context.lineCap = "round";
const drawBars = (strokeStyle, clipFrom = 0, clipTo = logicalWidth) => {
context.save();
context.beginPath();
context.rect(clipFrom, 0, Math.max(0, clipTo - clipFrom), logicalHeight);
context.clip();
context.strokeStyle = strokeStyle;
for (let bar = 0; bar < barCount; bar += 1) {
const from = Math.floor((bar / barCount) * envelope.length);
const to = Math.max(from + 1, Math.floor(((bar + 1) / barCount) * envelope.length));
let level = 0;
for (let index = from; index < to; index += 1) {
level = Math.max(level, envelope[index]);
}
const halfHeight = Math.max(1, level * (logicalHeight / 2 - 5));
const x = (bar + 0.5) * spacing;
context.beginPath();
context.moveTo(x, center - halfHeight);
context.lineTo(x, center + halfHeight);
context.stroke();
}
context.restore();
};
const baseGradient = context.createLinearGradient(0, 3, 0, logicalHeight - 3);
baseGradient.addColorStop(0, "rgba(125, 211, 252, 0.76)");
baseGradient.addColorStop(0.5, "rgba(100, 116, 139, 0.52)");
baseGradient.addColorStop(1, "rgba(125, 211, 252, 0.76)");
drawBars(baseGradient);
if (playback.duration > 0) {
const progressStart = Math.max(0, Math.min(playback.start, playback.duration));
const progressLimit = Math.max(progressStart, Math.min(playback.end, playback.duration));
const progressEnd = Math.max(progressStart, Math.min(playback.current, progressLimit));
if (progressEnd > progressStart) {
const playedGradient = context.createLinearGradient(0, 2, 0, logicalHeight - 2);
playedGradient.addColorStop(0, "rgba(186, 230, 253, 1)");
playedGradient.addColorStop(0.5, "rgba(56, 189, 248, 0.96)");
playedGradient.addColorStop(1, "rgba(186, 230, 253, 1)");
drawBars(
playedGradient,
(progressStart / playback.duration) * logicalWidth,
(progressEnd / playback.duration) * logicalWidth,
);
}
}
context.restore();
};
const resizeObserver = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(draw);
resizeObserver?.observe(canvas);
return {
async load(url) {
const currentRequest = ++requestId;
controller?.abort();
controller = new AbortController();
envelope = null;
draw();
if (!url) return;
try {
const decoded = await decodeEnvelope(url, controller.signal);
if (currentRequest !== requestId) return;
envelope = decoded;
draw();
} catch (error) {
if (error.name !== "AbortError") {
console.warn("Character Voices waveform could not be rendered:", error);
}
}
},
clear() {
requestId += 1;
controller?.abort();
controller = null;
envelope = null;
playback = { current: 0, start: 0, end: 0, duration: 0 };
draw();
},
setPlayback(current, start, end, duration) {
playback = {
current: Number.isFinite(current) ? current : 0,
start: Number.isFinite(start) ? start : 0,
end: Number.isFinite(end) ? end : 0,
duration: Number.isFinite(duration) ? duration : 0,
};
draw();
},
destroy() {
this.clear();
resizeObserver?.disconnect();
},
};
}