Files
diodiogod-TTS-Audio-Suite/web/audio_analyzer_visualization.js
T
diodiogodandClaude de4e6e4c85 Implement adjustable peak detection with sensitivity controls
- Add peak_threshold, peak_min_distance, peak_region_size controls to Audio Analyzer node
- Fix broken peak detection algorithm: replace flat envelope with proper RMS downsampling
- Change from 20ms to 5ms windows for better peak resolution
- Add blue/cyan color for detected peak regions (vs green manual regions)
- Peak detection now works for percussion, sound effects, and emphasis detection

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 00:32:20 -03:00

590 lines
23 KiB
JavaScript

/**
* Audio Analyzer Visualization Module
* All canvas drawing and visualization functionality
*/
export class AudioAnalyzerVisualization {
constructor(core) {
this.core = core;
this.animationId = null;
}
redraw() {
if (!this.core.ctx) return;
const canvas = this.core.canvas;
const ctx = this.core.ctx;
const width = canvas.width / devicePixelRatio;
const height = canvas.height / devicePixelRatio;
// Clear canvas
ctx.fillStyle = this.core.colors.background;
ctx.fillRect(0, 0, width, height);
if (!this.core.waveformData) {
this.showInitialMessage();
return;
}
// Draw grid
this.drawGrid(ctx, width, height);
// Draw waveform
this.drawWaveform(ctx, width, height);
// Draw RMS if available
if (this.core.waveformData.rms) {
this.drawRMS(ctx, width, height);
}
// Draw selected regions
this.drawSelectedRegions(ctx, width, height);
// Draw current selection
this.drawCurrentSelection(ctx, width, height);
// Draw loop markers
this.drawLoopMarkers(ctx, width, height);
// Draw playhead
this.drawPlayhead(ctx, width, height);
// Draw analysis results
this.drawAnalysisResults(ctx, width, height);
}
showInitialMessage() {
const canvas = this.core.canvas;
const ctx = this.core.ctx;
const width = canvas.width / devicePixelRatio;
const height = canvas.height / devicePixelRatio;
ctx.fillStyle = this.core.colors.text;
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText('Drop an audio file here or select one using the audio_file widget', width / 2, height / 2);
ctx.font = '12px Arial';
ctx.fillStyle = '#888';
ctx.fillText('Supported formats: WAV, MP3, OGG, FLAC', width / 2, height / 2 + 20);
}
drawGrid(ctx, width, height) {
if (!this.core.waveformData) return;
ctx.strokeStyle = this.core.colors.grid;
ctx.lineWidth = 1;
// Time grid
const visibleDuration = this.core.waveformData.duration / this.core.zoomLevel;
const startTime = this.core.scrollOffset;
const endTime = startTime + visibleDuration;
// Calculate appropriate time interval
let timeInterval = 1; // seconds
if (visibleDuration > 300) timeInterval = 60;
else if (visibleDuration > 60) timeInterval = 10;
else if (visibleDuration > 10) timeInterval = 2;
else if (visibleDuration > 2) timeInterval = 0.5;
else if (visibleDuration > 0.5) timeInterval = 0.1;
else timeInterval = 0.05;
// Draw vertical time lines
const firstLine = Math.ceil(startTime / timeInterval) * timeInterval;
for (let time = firstLine; time <= endTime; time += timeInterval) {
const x = this.core.timeToPixel(time);
if (x >= 0 && x <= width) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
// Draw time labels
ctx.fillStyle = this.core.colors.text;
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText(this.core.formatTime(time), x, 12);
}
}
// Draw horizontal amplitude lines with dynamic scaling
const maxAmp = this.core.maxAmplitudeRange;
const amplitudeLines = [-maxAmp * 0.8, -maxAmp * 0.6, -maxAmp * 0.4, -maxAmp * 0.2, 0, maxAmp * 0.2, maxAmp * 0.4, maxAmp * 0.6, maxAmp * 0.8];
amplitudeLines.forEach(amp => {
const y = height/2 - (amp * height * this.core.amplitudeScale);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
// Draw amplitude labels
if (amp !== 0) {
ctx.fillStyle = this.core.colors.text;
ctx.font = '9px Arial';
ctx.textAlign = 'right';
ctx.fillText(amp.toFixed(1), width - 5, y - 2);
}
});
}
drawWaveform(ctx, width, height) {
if (!this.core.waveformData || !this.core.waveformData.samples) {
console.warn('🎵 No waveform data available');
return;
}
const samples = this.core.waveformData.samples;
const duration = this.core.waveformData.duration;
const sampleRate = this.core.waveformData.sampleRate;
// Ensure we have valid data
if (!samples.length || duration <= 0 || sampleRate <= 0) {
console.warn('🎵 Invalid waveform data:', { samplesLength: samples.length, duration, sampleRate });
return;
}
// Calculate visible time range
const visibleDuration = duration / this.core.zoomLevel;
const startTime = Math.max(0, this.core.scrollOffset);
const endTime = Math.min(duration, startTime + visibleDuration);
if (startTime >= endTime) {
console.warn('🎵 Invalid time range:', { startTime, endTime });
return;
}
// Set drawing style
ctx.save(); // Save current state
ctx.strokeStyle = this.core.colors.waveform; // Blue
ctx.lineWidth = 2; // Make it visible
ctx.globalAlpha = 1.0; // Ensure it's opaque
// Draw the waveform
ctx.beginPath();
let pointsDrawn = 0;
// Debug sample data structure first
if (!this.dataStructureLogged) {
console.log('🎵 Sample data debug:', {
samplesType: typeof samples,
samplesLength: samples.length,
isArray: Array.isArray(samples),
firstSamples: samples.slice(0, 5),
duration,
sampleRate,
calculatedSampleRate: samples.length / duration
});
this.dataStructureLogged = true;
}
// Use the same approach as RMS - iterate through all pixels and check time bounds
let hasStarted = false;
for (let x = 0; x < width; x++) {
const time = startTime + (x / width) * visibleDuration;
// Only draw if the time is within audio duration (same check as RMS)
if (time < 0 || time >= duration) {
continue;
}
// Find corresponding sample index - use proper mapping
const sampleIndex = Math.floor((time / duration) * samples.length);
// Ensure we're within sample bounds
if (sampleIndex < 0 || sampleIndex >= samples.length) {
continue;
}
// Get sample value
const sample = samples[sampleIndex];
// Ensure we have a valid number
if (typeof sample !== 'number' || isNaN(sample)) {
console.warn(`🎵 Invalid sample at index ${sampleIndex}:`, sample);
continue;
}
// Convert to screen coordinates (center line is height/2) using dynamic amplitude scaling
const y = height/2 - (sample * height * this.core.amplitudeScale);
// Draw line (same pattern as RMS)
if (!hasStarted) {
ctx.moveTo(x, y);
hasStarted = true;
} else {
ctx.lineTo(x, y);
}
pointsDrawn++;
}
// Actually draw the path
ctx.stroke();
ctx.restore(); // Restore previous state
// Minimal debug logging (only once every 3 seconds) - but also check if animation should be running
if (!this.lastLogTime || Date.now() - this.lastLogTime > 3000) {
console.log(`🎵 Waveform drawn: ${samples.length} samples, ${duration.toFixed(2)}s, zoom=${this.core.zoomLevel.toFixed(2)}, points=${pointsDrawn}, isPlaying=${this.core.isPlaying}, animationId=${this.animationId}`);
this.lastLogTime = Date.now();
}
}
drawRMS(ctx, width, height) {
if (!this.core.waveformData || !this.core.waveformData.rms) return;
const rmsData = this.core.waveformData.rms;
// Handle both old and new RMS data structures
let rmsValues, rmsTime;
if (rmsData.values && rmsData.time) {
// New structure with separate values and time arrays
rmsValues = rmsData.values;
rmsTime = rmsData.time;
} else if (Array.isArray(rmsData)) {
// Old structure - array of values only
rmsValues = rmsData;
rmsTime = null;
} else {
return; // Invalid structure
}
// Safety check
if (!rmsValues || rmsValues.length === 0) return;
const duration = this.core.waveformData.duration;
const visibleDuration = duration / this.core.zoomLevel;
const startTime = this.core.scrollOffset;
const endTime = startTime + visibleDuration;
ctx.strokeStyle = this.core.colors.rms;
ctx.lineWidth = 2;
ctx.beginPath();
let hasStarted = false;
for (let x = 0; x < width; x++) {
const time = startTime + (x / width) * visibleDuration;
let rmsValue;
if (rmsTime && rmsTime.length > 0) {
// Use time-based indexing (new structure)
let closestIndex = 0;
let minDistance = Math.abs(rmsTime[0] - time);
for (let i = 1; i < rmsTime.length; i++) {
const distance = Math.abs(rmsTime[i] - time);
if (distance < minDistance) {
minDistance = distance;
closestIndex = i;
} else {
break; // Since times are sorted, we can stop
}
}
if (closestIndex < rmsValues.length) {
rmsValue = rmsValues[closestIndex];
} else {
continue;
}
} else {
// Use uniform distribution (old structure)
const rmsIndex = Math.floor((time / duration) * rmsValues.length);
if (rmsIndex >= 0 && rmsIndex < rmsValues.length) {
rmsValue = rmsValues[rmsIndex];
} else {
continue;
}
}
// Only draw if the time is within visible range
if (time >= startTime && time <= endTime) {
const y = height/2 - (rmsValue * height * this.core.amplitudeScale);
if (!hasStarted) {
ctx.moveTo(x, y);
hasStarted = true;
} else {
ctx.lineTo(x, y);
}
}
}
ctx.stroke();
}
drawSelectedRegions(ctx, width, height) {
if (!this.core.selectedRegions || this.core.selectedRegions.length === 0) return;
this.core.selectedRegions.forEach((region, index) => {
const startX = this.core.timeToPixel(region.start);
const endX = this.core.timeToPixel(region.end);
if (endX >= 0 && startX <= width) {
// Choose color based on state
let fillColor = this.core.colors.region;
let strokeColor = '#00ff00';
let lineWidth = 2;
if (this.core.selectedRegionIndices.includes(index)) {
// Selected for deletion (multiple selection)
fillColor = this.core.colors.regionSelected;
strokeColor = '#ff8c00';
lineWidth = 3;
} else if (index === this.core.highlightedRegionIndex) {
// Highlighted (click-to-highlight, persists)
fillColor = this.core.colors.regionHovered;
strokeColor = '#00ff00';
lineWidth = 2;
}
// Draw region background
ctx.fillStyle = fillColor;
ctx.fillRect(Math.max(0, startX), 0, Math.min(width, endX) - Math.max(0, startX), height);
// Draw region borders
ctx.strokeStyle = strokeColor;
ctx.lineWidth = lineWidth;
ctx.beginPath();
if (startX >= 0 && startX <= width) {
ctx.moveTo(startX, 0);
ctx.lineTo(startX, height);
}
if (endX >= 0 && endX <= width) {
ctx.moveTo(endX, 0);
ctx.lineTo(endX, height);
}
ctx.stroke();
// Draw region label with number
const labelX = Math.max(5, Math.min(width - 80, startX + 5));
ctx.fillStyle = strokeColor;
ctx.font = this.core.selectedRegionIndices.includes(index) ? 'bold 12px Arial' : '11px Arial';
ctx.textAlign = 'left';
const labelText = `${index + 1}. ${region.label}`;
ctx.fillText(labelText, labelX, 20 + (index * 15));
// Show deletion hint for selected regions
if (this.core.selectedRegionIndices.includes(index)) {
ctx.fillStyle = '#ff8c00';
ctx.font = '10px Arial';
const hint = this.core.selectedRegionIndices.length > 1 ?
`(${this.core.selectedRegionIndices.length} selected - Delete to remove)` :
'(Press Delete to remove)';
ctx.fillText(hint, labelX, 35 + (index * 15));
}
}
});
}
drawLoopMarkers(ctx, width, height) {
if (this.core.loopStart === null || this.core.loopEnd === null) return;
const startX = this.core.timeToPixel(this.core.loopStart);
const endX = this.core.timeToPixel(this.core.loopEnd);
const markerHeight = 20;
const markerY = height - markerHeight;
// Draw subtle loop region background
if (endX >= 0 && startX <= width) {
ctx.fillStyle = 'rgba(255, 0, 255, 0.1)';
ctx.fillRect(Math.max(0, startX), 0, Math.min(width, endX) - Math.max(0, startX), height);
}
// Draw loop start marker (triangle pointing right)
if (startX >= 0 && startX <= width) {
ctx.fillStyle = this.core.colors.loopMarker;
ctx.beginPath();
ctx.moveTo(startX, height);
ctx.lineTo(startX - 8, markerY);
ctx.lineTo(startX + 8, markerY);
ctx.closePath();
ctx.fill();
// Draw start label
ctx.fillStyle = this.core.colors.loopMarker;
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.fillText('START', startX, markerY - 5);
}
// Draw loop end marker (triangle pointing left)
if (endX >= 0 && endX <= width) {
ctx.fillStyle = this.core.colors.loopMarker;
ctx.beginPath();
ctx.moveTo(endX, height);
ctx.lineTo(endX - 8, markerY);
ctx.lineTo(endX + 8, markerY);
ctx.closePath();
ctx.fill();
// Draw end label
ctx.fillStyle = this.core.colors.loopMarker;
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'center';
ctx.fillText('END', endX, markerY - 5);
}
// Draw loop indicator in corner
if (this.core.isLooping) {
ctx.fillStyle = this.core.colors.loopMarker;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'right';
ctx.fillText('🔄 LOOPING', width - 10, height - 10);
}
}
drawCurrentSelection(ctx, width, height) {
if (this.core.selectedStart === null || this.core.selectedEnd === null) return;
const startX = this.core.timeToPixel(this.core.selectedStart);
const endX = this.core.timeToPixel(this.core.selectedEnd);
if (endX >= 0 && startX <= width) {
// Draw selection background
ctx.fillStyle = this.core.colors.selection;
ctx.fillRect(Math.max(0, startX), 0, Math.min(width, endX) - Math.max(0, startX), height);
// Draw selection borders
ctx.strokeStyle = '#ffff00';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.beginPath();
if (startX >= 0 && startX <= width) {
ctx.moveTo(startX, 0);
ctx.lineTo(startX, height);
}
if (endX >= 0 && endX <= width) {
ctx.moveTo(endX, 0);
ctx.lineTo(endX, height);
}
ctx.stroke();
ctx.setLineDash([]);
}
}
drawPlayhead(ctx, width, height) {
if (!this.core.waveformData) return;
const playheadX = this.core.timeToPixel(this.core.currentTime);
if (playheadX >= 0 && playheadX <= width) {
ctx.strokeStyle = this.core.colors.playhead;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX, height);
ctx.stroke();
// Draw playhead indicator
ctx.fillStyle = this.core.colors.playhead;
ctx.beginPath();
ctx.moveTo(playheadX, 0);
ctx.lineTo(playheadX - 5, 10);
ctx.lineTo(playheadX + 5, 10);
ctx.closePath();
ctx.fill();
}
}
drawAnalysisResults(ctx, width, height) {
if (!this.core.waveformData || !this.core.waveformData.regions) return;
const regions = this.core.waveformData.regions;
// Draw detected regions with different colors
regions.forEach(region => {
const startX = this.core.timeToPixel(region.start);
const endX = this.core.timeToPixel(region.end);
if (endX >= 0 && startX <= width) {
// Color based on region label/type
let color = 'rgba(0, 255, 0, 0.2)'; // Default green
if (region.label === 'silence') {
color = 'rgba(128, 128, 128, 0.3)'; // Gray for silence
} else if (region.label.includes('word_boundary')) {
color = 'rgba(255, 255, 0, 0.2)'; // Yellow for word boundaries
} else if (region.label.includes('peak_')) {
color = 'rgba(0, 150, 255, 0.25)'; // Blue/cyan for detected peaks
} else if (region.label.includes('speech')) {
color = 'rgba(0, 255, 0, 0.2)'; // Green for speech
}
// Draw region background
ctx.fillStyle = color;
ctx.fillRect(Math.max(0, startX), 0, Math.min(width, endX) - Math.max(0, startX), height);
// Draw region border
ctx.strokeStyle = color.replace('0.2', '0.8'); // More opaque border
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(startX, 0);
ctx.lineTo(startX, height);
ctx.moveTo(endX, 0);
ctx.lineTo(endX, height);
ctx.stroke();
// Draw region label
if (startX >= 0 && startX + 50 <= width) {
ctx.fillStyle = '#fff';
ctx.font = '10px Arial';
ctx.textAlign = 'left';
ctx.fillText(`${region.label} (${region.confidence.toFixed(2)})`, startX + 2, 15);
}
}
});
// Energy peaks would be drawn here if available
// Region analysis complete
}
// Animation helpers
startAnimation() {
const animate = () => {
this.redraw();
if (this.core.isPlaying) {
this.animationId = requestAnimationFrame(animate);
}
};
if (this.animationId) {
cancelAnimationFrame(this.animationId);
}
this.animationId = requestAnimationFrame(animate);
}
stopAnimation() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
}
// Utility methods
getColorForFrequency(frequency) {
// Color-code frequency ranges
if (frequency < 200) return '#ff4444'; // Low frequencies - red
if (frequency < 1000) return '#ffaa44'; // Mid-low frequencies - orange
if (frequency < 4000) return '#44ff44'; // Mid frequencies - green
if (frequency < 8000) return '#44aaff'; // Mid-high frequencies - blue
return '#aa44ff'; // High frequencies - purple
}
getIntensityAlpha(intensity) {
// Convert intensity to alpha value (0-1)
return Math.max(0.1, Math.min(1, intensity / 100));
}
// Export visualization as image
exportAsImage() {
if (!this.core.canvas) return null;
try {
return this.core.canvas.toDataURL('image/png');
} catch (e) {
console.error('Failed to export visualization:', e);
return null;
}
}
}