486 lines
18 KiB
JavaScript
486 lines
18 KiB
JavaScript
/**
|
|
* Audio Wave Analyzer Node Integration Module
|
|
* Handles communication with ComfyUI AudioAnalyzerNode
|
|
*/
|
|
export class AudioAnalyzerNodeIntegration {
|
|
constructor(core) {
|
|
this.core = core;
|
|
this.lastAnalysisParams = null;
|
|
this.isAnalyzing = false;
|
|
}
|
|
|
|
// Handle visualization data updates from node execution
|
|
updateVisualization(data) {
|
|
// console.log('🌊 Audio Wave Analyzer: Received visualization data from node execution'); // Debug: data reception
|
|
|
|
if (data.error) {
|
|
console.error('❌ Audio Wave Analyzer: Analysis error:', data.error);
|
|
this.core.showMessage(`Analysis error: ${data.error}`);
|
|
this.core.ui.updateStatus('Analysis failed');
|
|
return;
|
|
}
|
|
|
|
// Check if we have waveform data
|
|
if (!data.waveform || !data.waveform.samples || data.waveform.samples.length === 0) {
|
|
console.warn('⚠️ Audio Wave Analyzer: No waveform data received');
|
|
this.core.showMessage('No audio data received - check if audio file loaded correctly');
|
|
this.core.ui.updateStatus('No audio data');
|
|
return;
|
|
}
|
|
|
|
// console.log(`✅ Audio Wave Analyzer: Loaded ${data.waveform.samples.length} audio samples, duration: ${data.duration}s`); // Debug: data loading
|
|
|
|
// Update waveform data - handle the correct data structure from Python
|
|
this.core.canvasWarningMessage = null;
|
|
this.core.waveformData = {
|
|
samples: data.waveform?.samples || [],
|
|
time: data.waveform?.time || [],
|
|
rms: data.rms?.values || [],
|
|
rmsTime: data.rms?.time || [],
|
|
peaks: data.peaks || [],
|
|
duration: data.duration || 0,
|
|
sampleRate: data.sample_rate || 44100,
|
|
regions: data.regions || [],
|
|
analysisResults: data.analysis_results || {}
|
|
};
|
|
|
|
// Create audio element for playback if we have file path or web audio filename
|
|
if (data.file_path) {
|
|
this.core.node.setupAudioPlayback(data.file_path);
|
|
} else if (data.web_audio_filename) {
|
|
this.core.node.setupAudioPlayback(data.web_audio_filename);
|
|
}
|
|
|
|
// Update UI status
|
|
this.core.ui.updateStatus(`Analyzed: ${this.core.formatTime(this.core.waveformData.duration)}`);
|
|
|
|
// Show analysis results summary
|
|
this.showAnalysisResults(data.analysis_results);
|
|
|
|
// Make hasConnectedAudio available to core for playback checks
|
|
this.core.hasConnectedAudio = () => this.hasConnectedAudio();
|
|
|
|
// Restore manual regions from analysis results back to interface
|
|
if (data.regions) {
|
|
const manualRegions = data.regions.filter(r => r.metadata && r.metadata.type === "manual");
|
|
this.core.selectedRegions = manualRegions.map((r, index) => ({
|
|
start: r.start,
|
|
end: r.end,
|
|
label: r.label,
|
|
id: Date.now() + index
|
|
}));
|
|
|
|
// Remove manual regions from visualization data to prevent duplication
|
|
this.core.waveformData.regions = data.regions.filter(r => !(r.metadata && r.metadata.type === "manual"));
|
|
|
|
// Update the manual regions widget to reflect current state
|
|
this.core.updateManualRegions();
|
|
}
|
|
|
|
// Redraw visualization
|
|
this.core.visualization.redraw();
|
|
|
|
// Reset analysis state
|
|
this.isAnalyzing = false;
|
|
|
|
this.core.showMessage('Analysis complete');
|
|
}
|
|
|
|
// Handle audio file selection
|
|
async onAudioFileSelected(filePath) {
|
|
console.log('Audio file selected:', filePath);
|
|
|
|
if (!filePath || filePath.trim() === '') {
|
|
this.core.showMessage('No audio file selected');
|
|
return;
|
|
}
|
|
|
|
// Clear previous data
|
|
this.core.waveformData = null;
|
|
this.core.selectedRegions = [];
|
|
this.core.clearSelection();
|
|
|
|
// Update UI
|
|
this.core.ui.updateStatus('Loading audio file...');
|
|
this.core.visualization.redraw();
|
|
|
|
if (this.hasConnectedAudio()) {
|
|
this.showConnectedAudioWarning();
|
|
return;
|
|
}
|
|
|
|
this.core.showMessage(`Loading: ${filePath}`);
|
|
await this.runFilePreviewAnalysis();
|
|
}
|
|
|
|
// Handle parameter changes (now works like manual refresh)
|
|
async onParametersChanged() {
|
|
// console.log('Analysis requested'); // Debug: analysis trigger
|
|
|
|
if (!this.hasAudioSource()) {
|
|
this.core.showMessage('No audio source available for analysis');
|
|
return;
|
|
}
|
|
|
|
if (!this.hasConnectedAudio()) {
|
|
await this.runFilePreviewAnalysis();
|
|
return;
|
|
}
|
|
|
|
// Always perform analysis when user clicks Analyze button
|
|
// No need to check if parameters changed - user explicitly requested analysis
|
|
|
|
// Clear current audio setup to allow new setup
|
|
if (this.core.node) {
|
|
this.core.node.currentAudioFile = null;
|
|
}
|
|
|
|
// Update current params for future comparisons
|
|
this.lastAnalysisParams = this.getCurrentAnalysisParams();
|
|
|
|
// Clear previous analysis results
|
|
if (this.core.waveformData) {
|
|
this.core.waveformData.analysisResults = {};
|
|
}
|
|
|
|
// Clear interface manual regions since they'll be included in analysis results
|
|
this.core.selectedRegions = [];
|
|
|
|
// Update UI
|
|
this.core.ui.updateStatus('Analyzing audio...');
|
|
this.core.visualization.redraw();
|
|
|
|
if (!(await this.hasPersistentVisualizationCache())) {
|
|
this.core.node.audioAnalyzerForceRun = Date.now();
|
|
}
|
|
|
|
// Trigger node execution (same as manual refresh)
|
|
this.core.node.lastExecutionTime = Date.now();
|
|
|
|
// Queue execution and check for results
|
|
window.app.queuePrompt();
|
|
|
|
// Check for results after execution
|
|
setTimeout(() => this.core.node.checkForResults(), 3000);
|
|
setTimeout(() => this.core.node.checkForResults(), 6000);
|
|
|
|
this.core.showMessage('Analyzing audio...');
|
|
}
|
|
|
|
async runFilePreviewAnalysis() {
|
|
const payload = this.getPreviewPayload();
|
|
if (!payload.audio_file) {
|
|
this.core.showMessage('No audio file selected for preview analysis');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.isAnalyzing = true;
|
|
this.core.canvasWarningMessage = null;
|
|
this.core.ui.updateStatus('Analyzing audio preview...');
|
|
this.core.showMessage('Analyzing audio preview...');
|
|
this.core.visualization.redraw();
|
|
|
|
const response = await fetch('/api/tts-audio-suite/audio-analyzer-preview', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
cache: 'no-store'
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (!response.ok) {
|
|
throw new Error(data?.error || `HTTP ${response.status}`);
|
|
}
|
|
|
|
this.updateVisualization(data.visualization_data || data);
|
|
} catch (error) {
|
|
console.error('Audio analyzer preview failed:', error);
|
|
this.core.ui.updateStatus('Preview analysis failed');
|
|
this.core.showMessage(`Preview analysis failed: ${error.message}`);
|
|
} finally {
|
|
this.isAnalyzing = false;
|
|
}
|
|
}
|
|
|
|
getPreviewPayload() {
|
|
const getWidgetValue = (name, fallback = '') => {
|
|
const widget = this.core.node.widgets?.find(w => w.name === name);
|
|
return widget?.value ?? fallback;
|
|
};
|
|
|
|
return {
|
|
node_id: String(this.core.node.id),
|
|
audio_file: String(getWidgetValue('audio_file', '') || ''),
|
|
analysis_method: getWidgetValue('analysis_method', 'silence'),
|
|
precision_level: getWidgetValue('precision_level', 'milliseconds'),
|
|
visualization_points: Number(getWidgetValue('visualization_points', 2000)) || 2000,
|
|
manual_regions: String(getWidgetValue('manual_regions', '') || ''),
|
|
region_labels: String(getWidgetValue('region_labels', '') || ''),
|
|
export_format: getWidgetValue('export_format', 'f5tts'),
|
|
};
|
|
}
|
|
|
|
async hasPersistentVisualizationCache() {
|
|
if (!this.core.node?.id || this.core.node.id < 0) return false;
|
|
|
|
try {
|
|
const response = await fetch(`/output/audio_analyzer_cache_${this.core.node.id}.json?t=${Date.now()}`, {
|
|
cache: 'no-store'
|
|
});
|
|
return response.ok;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
showConnectedAudioWarning() {
|
|
const warning = 'Dropped file was loaded, but connected AUDIO takes priority.';
|
|
this.core.ui.updateStatus('Connected AUDIO input active');
|
|
this.core.showMessage(`${warning} Run the workflow to analyze connected audio, or disconnect the AUDIO input to preview files.`, 'error');
|
|
|
|
if (!this.core.waveformData) {
|
|
this.core.canvasWarningMessage = warning;
|
|
this.core.visualization.redraw();
|
|
}
|
|
}
|
|
|
|
// Handle audio connection
|
|
onAudioConnected() {
|
|
console.log('Audio input connected');
|
|
|
|
// Clear file-based data
|
|
this.core.waveformData = null;
|
|
this.core.selectedRegions = [];
|
|
this.core.clearSelection();
|
|
|
|
// Update UI
|
|
this.core.ui.updateStatus('Audio connected, analyzing...');
|
|
this.core.visualization.redraw();
|
|
|
|
// Trigger node execution
|
|
this.triggerNodeExecution();
|
|
|
|
this.core.showMessage('Audio connected, analyzing...');
|
|
}
|
|
|
|
// Trigger node execution
|
|
async triggerNodeExecution() {
|
|
if (this.isAnalyzing) {
|
|
console.log('Analysis already in progress');
|
|
return;
|
|
}
|
|
|
|
this.isAnalyzing = true;
|
|
|
|
try {
|
|
// Queue backend execution through ComfyUI. Legacy LiteGraph execution
|
|
// methods do not execute Python nodes and are being removed upstream.
|
|
this.core.node.audioAnalyzerForceRun = Date.now();
|
|
this.core.node.lastExecutionTime = Date.now();
|
|
await window.app.queuePrompt();
|
|
|
|
// Check for results after execution in case the normal execution hook
|
|
// does not deliver the output to the analyzer interface immediately.
|
|
setTimeout(() => this.core.node.checkForResults(), 3000);
|
|
setTimeout(() => this.core.node.checkForResults(), 6000);
|
|
} catch (error) {
|
|
console.error('Failed to trigger node execution:', error);
|
|
this.core.showMessage(`Execution error: ${error.message}`);
|
|
this.isAnalyzing = false;
|
|
}
|
|
}
|
|
|
|
// Check if we have an audio source
|
|
hasAudioSource() {
|
|
// Check for file input
|
|
const audioFileWidget = this.core.node.widgets?.find(w => w.name === 'audio_file');
|
|
if (audioFileWidget && audioFileWidget.value && audioFileWidget.value.trim()) {
|
|
return true;
|
|
}
|
|
|
|
// Check for audio input connection by name (not hardcoded slot index)
|
|
if (this.core.node.inputs) {
|
|
const audioInput = this.core.node.inputs.find(input => input.name === 'audio');
|
|
if (audioInput && audioInput.link) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Check if the node has a connected AUDIO input. Backend execution gives
|
|
// connected audio priority over audio_file, so preview must not bypass it.
|
|
hasConnectedAudio() {
|
|
if (this.core.node.inputs) {
|
|
const audioInput = this.core.node.inputs.find(input => input.name === 'audio');
|
|
return !!(audioInput && audioInput.link);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Get current analysis parameters
|
|
getCurrentAnalysisParams() {
|
|
const params = {};
|
|
|
|
// Get widget values
|
|
this.core.node.widgets?.forEach(widget => {
|
|
if (widget.name === 'analysis_method' ||
|
|
widget.name === 'silence_threshold' ||
|
|
widget.name === 'silence_min_duration' ||
|
|
widget.name === 'energy_sensitivity') {
|
|
params[widget.name] = widget.value;
|
|
}
|
|
});
|
|
|
|
return params;
|
|
}
|
|
|
|
// Compare parameter objects
|
|
paramsEqual(params1, params2) {
|
|
if (!params1 || !params2) return false;
|
|
|
|
const keys1 = Object.keys(params1);
|
|
const keys2 = Object.keys(params2);
|
|
|
|
if (keys1.length !== keys2.length) return false;
|
|
|
|
for (let key of keys1) {
|
|
if (params1[key] !== params2[key]) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Setup audio playback
|
|
setupAudioPlayback(filePath) {
|
|
try {
|
|
// Remove existing audio element
|
|
if (this.core.audioElement) {
|
|
this.core.audioElement.removeEventListener('ended', this.handleAudioEnded);
|
|
this.core.audioElement.removeEventListener('timeupdate', this.handleTimeUpdate);
|
|
}
|
|
|
|
// Create new audio element
|
|
this.core.audioElement = new Audio();
|
|
this.core.audioElement.src = filePath;
|
|
this.core.audioElement.preload = 'metadata';
|
|
|
|
// Audio event listeners
|
|
this.core.audioElement.addEventListener('ended', () => {
|
|
console.log('🔊 Audio ended - stopping all animations');
|
|
this.core.isPlaying = false;
|
|
this.core.ui.playButton.textContent = '▶️ Play';
|
|
this.core.currentTime = 0;
|
|
this.core.ui.updateTimeDisplay();
|
|
this.core.stopPlayheadAnimation(); // Stop the core playhead animation loop
|
|
this.core.visualization.stopAnimation(); // Stop the visualization animation loop
|
|
this.core.visualization.redraw(); // Final redraw
|
|
console.log('🔊 All animations stopped, isPlaying:', this.core.isPlaying);
|
|
});
|
|
|
|
this.core.audioElement.addEventListener('timeupdate', () => {
|
|
if (this.core.isPlaying) {
|
|
this.core.currentTime = this.core.audioElement.currentTime;
|
|
this.core.ui.updateTimeDisplay();
|
|
}
|
|
});
|
|
|
|
this.core.audioElement.addEventListener('loadedmetadata', () => {
|
|
console.log('Audio metadata loaded');
|
|
this.core.ui.updateStatus('Audio loaded and ready for playback');
|
|
});
|
|
|
|
this.core.audioElement.addEventListener('error', (e) => {
|
|
console.error('Audio loading error:', e);
|
|
this.core.showMessage('Failed to load audio for playback');
|
|
});
|
|
|
|
} catch (error) {
|
|
console.error('Failed to setup audio playback:', error);
|
|
this.core.showMessage('Audio playback unavailable');
|
|
}
|
|
}
|
|
|
|
// Show analysis results summary
|
|
showAnalysisResults(results) {
|
|
if (!results) return;
|
|
|
|
const messages = [];
|
|
|
|
if (results.silence_regions) {
|
|
messages.push(`Found ${results.silence_regions.length} silence regions`);
|
|
}
|
|
|
|
if (results.speech_regions) {
|
|
messages.push(`Found ${results.speech_regions.length} speech regions`);
|
|
}
|
|
|
|
if (results.energy_peaks) {
|
|
messages.push(`Found ${results.energy_peaks.length} energy peaks`);
|
|
}
|
|
|
|
if (results.timing_markers) {
|
|
messages.push(`Found ${results.timing_markers.length} timing markers`);
|
|
}
|
|
|
|
if (messages.length > 0) {
|
|
this.core.showMessage(messages.join(', '));
|
|
}
|
|
}
|
|
|
|
// Export analysis results
|
|
exportAnalysisResults() {
|
|
if (!this.core.waveformData || !this.core.waveformData.analysisResults) {
|
|
this.core.showMessage('No analysis results to export');
|
|
return null;
|
|
}
|
|
|
|
const results = {
|
|
duration: this.core.waveformData.duration,
|
|
sample_rate: this.core.waveformData.sampleRate,
|
|
analysis_results: this.core.waveformData.analysisResults,
|
|
selected_regions: this.core.selectedRegions,
|
|
analysis_params: this.getCurrentAnalysisParams(),
|
|
export_timestamp: new Date().toISOString()
|
|
};
|
|
|
|
return JSON.stringify(results, null, 2);
|
|
}
|
|
|
|
// Import analysis results
|
|
importAnalysisResults(jsonData) {
|
|
try {
|
|
const results = JSON.parse(jsonData);
|
|
|
|
if (results.analysis_results) {
|
|
this.core.waveformData.analysisResults = results.analysis_results;
|
|
}
|
|
|
|
if (results.selected_regions) {
|
|
this.core.selectedRegions = results.selected_regions;
|
|
this.core.updateManualRegions();
|
|
}
|
|
|
|
this.core.visualization.redraw();
|
|
this.core.showMessage('Analysis results imported successfully');
|
|
|
|
} catch (error) {
|
|
console.error('Failed to import analysis results:', error);
|
|
this.core.showMessage('Failed to import analysis results');
|
|
}
|
|
}
|
|
|
|
// Get node execution status
|
|
getExecutionStatus() {
|
|
return {
|
|
isAnalyzing: this.isAnalyzing,
|
|
hasAudioSource: this.hasAudioSource(),
|
|
hasResults: !!(this.core.waveformData && this.core.waveformData.analysisResults),
|
|
currentParams: this.getCurrentAnalysisParams()
|
|
};
|
|
}
|
|
}
|