ChatTASTE-Voice-Bot / frontend /js /components /waveform-visualizer.js
YC-Chen's picture
refactor: modularize frontend audio, components, and tests
2b18d49
/**
* WaveformVisualizer - handles canvas waveform animation for user and agent audio
*/
export class WaveformVisualizer {
constructor(userCanvas, agentCanvas) {
this.userCanvas = userCanvas;
this.agentCanvas = agentCanvas;
this.userCtx = userCanvas.getContext('2d');
this.agentCtx = agentCanvas.getContext('2d');
this.animationId = null;
this.isUserSpeaking = false;
this.isAgentSpeaking = false;
this._initCanvases();
}
_initCanvases() {
this.userCanvas.width = 350;
this.userCanvas.height = 60;
this.agentCanvas.width = 350;
this.agentCanvas.height = 60;
this._clear(this.userCtx, this.userCanvas);
this._clear(this.agentCtx, this.agentCanvas);
}
_clear(ctx, canvas) {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
_draw(ctx, canvas, isActive, color, waveformData) {
this._clear(ctx, canvas);
if (!isActive) return;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
const centerY = canvas.height / 2;
if (waveformData && waveformData.length > 0) {
const sliceWidth = canvas.width / waveformData.length;
let x = 0;
for (let i = 0; i < waveformData.length; i++) {
const v = waveformData[i] / 128.0;
const y = (v * canvas.height) / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
} else {
const frequency = Date.now() * 0.01;
for (let x = 0; x < canvas.width; x++) {
const y = centerY
+ Math.sin((x * 0.02) + frequency) * 12
+ Math.sin((x * 0.05) + frequency * 0.7) * 6
+ Math.sin((x * 0.03) + frequency * 1.3) * 4
+ (Math.random() - 0.5) * 3;
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
}
ctx.stroke();
}
/**
* Start animation loop.
* @param {Function} getUserWaveformData - returns Uint8Array or null
* @param {Function} getAgentWaveformData - returns Uint8Array or null
*/
start(getUserWaveformData, getAgentWaveformData) {
const animate = () => {
this._draw(this.userCtx, this.userCanvas, this.isUserSpeaking, '#2196f3', getUserWaveformData());
this._draw(this.agentCtx, this.agentCanvas, this.isAgentSpeaking, '#9c27b0', getAgentWaveformData());
this.animationId = requestAnimationFrame(animate);
};
animate();
}
stop() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
startUserSpeaking() {
this.isUserSpeaking = true;
}
stopUserSpeaking() {
this.isUserSpeaking = false;
}
startAgentSpeaking() {
this.isAgentSpeaking = true;
}
stopAgentSpeaking() {
this.isAgentSpeaking = false;
}
}