831 lines
31 KiB
JavaScript
831 lines
31 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { AudioAnalyzerUI } from "./audio_analyzer_ui.js";
|
|
import { AudioAnalyzerEvents } from "./audio_analyzer_events.js";
|
|
import { AudioAnalyzerVisualization } from "./audio_analyzer_visualization.js";
|
|
import { AudioAnalyzerNodeIntegration } from "./audio_analyzer_node_integration.js";
|
|
|
|
/**
|
|
* Core Audio Wave Analyzer Interface
|
|
* Main class that coordinates all audio wave analyzer functionality
|
|
*/
|
|
export class AudioAnalyzerInterface {
|
|
constructor(node) {
|
|
this.node = node;
|
|
this.canvas = null;
|
|
this.ctx = null;
|
|
this.waveformData = null;
|
|
this.selectedRegions = [];
|
|
this.zoomLevel = 1;
|
|
this.scrollOffset = 0;
|
|
this.isPlaying = false;
|
|
this.currentTime = 0;
|
|
this.audioElement = null;
|
|
this.playbackSpeed = 1;
|
|
this.isDragging = false;
|
|
this.dragStart = null;
|
|
this.dragEnd = null;
|
|
this.selectedStart = null;
|
|
this.selectedEnd = null;
|
|
this.selectedRegionIndices = []; // For tracking multiple regions selected for deletion
|
|
this.highlightedRegionIndex = -1; // For click-to-highlight feedback (persists until next click)
|
|
|
|
// Loop markers
|
|
this.loopStart = null;
|
|
this.loopEnd = null;
|
|
this.isLooping = false;
|
|
|
|
// Amplitude scaling
|
|
this.amplitudeScale = 0.4; // Default scaling factor (0.1 to 1.0)
|
|
this.maxAmplitudeRange = 1.0; // Maximum amplitude range (+/- 1.0)
|
|
|
|
// Color scheme for the interface
|
|
this.colors = {
|
|
background: '#1a1a1a',
|
|
waveform: '#4a9eff',
|
|
rms: '#ff6b6b',
|
|
grid: '#333333',
|
|
selection: 'rgba(255, 255, 0, 0.3)',
|
|
playhead: '#ff0000',
|
|
region: 'rgba(0, 255, 0, 0.2)',
|
|
regionSelected: 'rgba(255, 165, 0, 0.4)',
|
|
regionHovered: 'rgba(0, 255, 0, 0.4)',
|
|
loopMarker: '#ff00ff',
|
|
text: '#ffffff'
|
|
};
|
|
|
|
// Initialize modules
|
|
this.ui = new AudioAnalyzerUI(this);
|
|
this.events = new AudioAnalyzerEvents(this);
|
|
this.visualization = new AudioAnalyzerVisualization(this);
|
|
this.nodeIntegration = new AudioAnalyzerNodeIntegration(this);
|
|
|
|
this.setupInterface();
|
|
}
|
|
|
|
setupInterface() {
|
|
// Create the main interface using UI module
|
|
this.ui.createInterface();
|
|
|
|
// Setup event listeners
|
|
this.events.setupEventListeners();
|
|
|
|
// Setup canvas resize observer
|
|
this.ui.setupCanvasResize();
|
|
|
|
// Setup widget change listeners for bidirectional sync
|
|
this.setupWidgetListeners();
|
|
|
|
// Attempt to load cached data, otherwise show initial message
|
|
this.loadCachedData();
|
|
}
|
|
|
|
setupWidgetListeners() {
|
|
// Store the last known values for comparison
|
|
const manualRegionsWidget = this.node.widgets.find(w => w.name === 'manual_regions');
|
|
const labelsWidget = this.node.widgets.find(w => w.name === 'region_labels');
|
|
|
|
if (manualRegionsWidget) {
|
|
this.lastManualRegionsValue = manualRegionsWidget.value;
|
|
}
|
|
if (labelsWidget) {
|
|
this.lastLabelsValue = labelsWidget.value;
|
|
}
|
|
|
|
// Add canvas click listener to check for text changes
|
|
this.canvas.addEventListener('mousedown', () => {
|
|
this.checkAndSyncTextChanges();
|
|
});
|
|
|
|
// Also check when canvas gets focus (user clicks on interface)
|
|
this.canvas.addEventListener('focus', () => {
|
|
this.checkAndSyncTextChanges();
|
|
});
|
|
}
|
|
|
|
checkAndSyncTextChanges() {
|
|
const manualRegionsWidget = this.node.widgets.find(w => w.name === 'manual_regions');
|
|
const labelsWidget = this.node.widgets.find(w => w.name === 'region_labels');
|
|
|
|
let hasChanges = false;
|
|
|
|
// Check if manual_regions text changed
|
|
if (manualRegionsWidget && manualRegionsWidget.value !== this.lastManualRegionsValue) {
|
|
this.lastManualRegionsValue = manualRegionsWidget.value;
|
|
hasChanges = true;
|
|
}
|
|
|
|
// Check if labels text changed
|
|
if (labelsWidget && labelsWidget.value !== this.lastLabelsValue) {
|
|
this.lastLabelsValue = labelsWidget.value;
|
|
hasChanges = true;
|
|
}
|
|
|
|
// Only sync if there were actual changes
|
|
if (hasChanges && !manualRegionsWidget?._updating) {
|
|
this.parseManualRegionsFromText();
|
|
this.showMessage('Regions synced from text input');
|
|
}
|
|
}
|
|
|
|
async loadCachedData() {
|
|
// Attempts to load persisted analysis data from the last execution.
|
|
if (!this.node?.id || this.node.id < 0) {
|
|
this.visualization.redraw();
|
|
return;
|
|
}
|
|
|
|
const cacheUrl = `/output/audio_analyzer_cache_${this.node.id}.json?t=${Date.now()}`;
|
|
try {
|
|
const response = await fetch(cacheUrl);
|
|
if (!response.ok) {
|
|
throw new Error(`Cache file not found or server error: ${response.status}`);
|
|
}
|
|
const data = await response.json();
|
|
this.nodeIntegration.updateVisualization(data.visualization_data || data);
|
|
console.log(`🌊 Audio Wave Analyzer: Successfully loaded cached data for node ${this.node.id}`);
|
|
} catch (error) {
|
|
// console.log(`🌊 Audio Wave Analyzer: No cache found for node ${this.node.id}. Showing initial message. Details: ${error.message}`); // Debug: cache miss
|
|
this.visualization.redraw();
|
|
}
|
|
}
|
|
|
|
// Utility functions
|
|
pixelToTime(pixel) {
|
|
if (!this.waveformData) return 0;
|
|
|
|
const canvasWidth = this.getCanvasLogicalWidth();
|
|
const visibleDuration = this.waveformData.duration / this.zoomLevel;
|
|
const startTime = this.scrollOffset;
|
|
|
|
return startTime + (pixel / canvasWidth) * visibleDuration;
|
|
}
|
|
|
|
timeToPixel(time) {
|
|
if (!this.waveformData) return 0;
|
|
|
|
const canvasWidth = this.getCanvasLogicalWidth();
|
|
const visibleDuration = this.waveformData.duration / this.zoomLevel;
|
|
const startTime = this.scrollOffset;
|
|
|
|
return ((time - startTime) / visibleDuration) * canvasWidth;
|
|
}
|
|
|
|
// Coordinate transformation utilities for ComfyUI zoom handling
|
|
getCanvasCoordinates(clientX, clientY) {
|
|
if (!this.canvas) return { x: 0, y: 0 };
|
|
|
|
// Get the canvas bounding rect (this gives display size affected by ComfyUI zoom)
|
|
const rect = this.canvas.getBoundingClientRect();
|
|
|
|
// Calculate coordinates relative to canvas in display pixels
|
|
const displayX = clientX - rect.left;
|
|
const displayY = clientY - rect.top;
|
|
|
|
// Convert from display pixels to logical canvas pixels.
|
|
const logicalCanvasWidth = this.getCanvasLogicalWidth();
|
|
const logicalCanvasHeight = this.getCanvasLogicalHeight();
|
|
|
|
// Scale coordinates from display size to logical size
|
|
const x = (displayX / rect.width) * logicalCanvasWidth;
|
|
const y = (displayY / rect.height) * logicalCanvasHeight;
|
|
|
|
return { x, y };
|
|
}
|
|
|
|
getComfyUIZoomLevel() {
|
|
// Try to get ComfyUI's zoom level from various possible sources
|
|
try {
|
|
// Method 1: Check for app canvas zoom
|
|
if (window.app && window.app.canvas && typeof window.app.canvas.ds !== 'undefined') {
|
|
return window.app.canvas.ds.scale || 1;
|
|
}
|
|
|
|
// Method 2: Check for LiteGraph canvas zoom
|
|
if (window.LiteGraph && window.LiteGraph.LGraphCanvas && window.LiteGraph.LGraphCanvas.active_canvas) {
|
|
const canvas = window.LiteGraph.LGraphCanvas.active_canvas;
|
|
return canvas.ds?.scale || 1;
|
|
}
|
|
|
|
// Method 3: Check node graph zoom
|
|
if (this.node && this.node.graph && this.node.graph.canvas) {
|
|
return this.node.graph.canvas.ds?.scale || 1;
|
|
}
|
|
|
|
// Method 4: Check for ComfyUI main canvas element and its transform
|
|
const comfyCanvas = document.querySelector('.litegraph canvas');
|
|
if (comfyCanvas) {
|
|
const style = window.getComputedStyle(comfyCanvas);
|
|
const transform = style.transform;
|
|
if (transform && transform !== 'none') {
|
|
const match = transform.match(/scale\(([^)]+)\)/);
|
|
if (match) {
|
|
return parseFloat(match[1]) || 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
return 1; // Default to no zoom if we can't detect it
|
|
} catch (error) {
|
|
console.warn('Failed to get ComfyUI zoom level:', error);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
getComfyUITransform() {
|
|
// Get the full transformation matrix including zoom and pan offset
|
|
try {
|
|
// Method 1: Check for app canvas transform
|
|
if (window.app && window.app.canvas && window.app.canvas.ds) {
|
|
const ds = window.app.canvas.ds;
|
|
return {
|
|
scale: ds.scale || 1,
|
|
offset: { x: ds.offset?.[0] || 0, y: ds.offset?.[1] || 0 }
|
|
};
|
|
}
|
|
|
|
// Method 2: Check for LiteGraph canvas transform
|
|
if (window.LiteGraph && window.LiteGraph.LGraphCanvas && window.LiteGraph.LGraphCanvas.active_canvas) {
|
|
const canvas = window.LiteGraph.LGraphCanvas.active_canvas;
|
|
const ds = canvas.ds;
|
|
if (ds) {
|
|
return {
|
|
scale: ds.scale || 1,
|
|
offset: { x: ds.offset?.[0] || 0, y: ds.offset?.[1] || 0 }
|
|
};
|
|
}
|
|
}
|
|
|
|
// Method 3: Check node graph transform
|
|
if (this.node && this.node.graph && this.node.graph.canvas && this.node.graph.canvas.ds) {
|
|
const ds = this.node.graph.canvas.ds;
|
|
return {
|
|
scale: ds.scale || 1,
|
|
offset: { x: ds.offset?.[0] || 0, y: ds.offset?.[1] || 0 }
|
|
};
|
|
}
|
|
|
|
return null; // No transform available
|
|
} catch (error) {
|
|
console.warn('Failed to get ComfyUI transform:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
getCanvasPixelScale() {
|
|
const dpr = window.devicePixelRatio || 1;
|
|
const zoom = Math.max(0.1, Math.min(4, this.getComfyUIZoomLevel() || 1));
|
|
return Math.max(1, Math.min(4, dpr * zoom));
|
|
}
|
|
|
|
getCanvasCssWidth() {
|
|
if (!this.canvas) return 0;
|
|
return this.canvas.clientWidth || this.canvas.offsetWidth || 0;
|
|
}
|
|
|
|
getCanvasCssHeight() {
|
|
if (!this.canvas) return 0;
|
|
return this.canvas.clientHeight || this.canvas.offsetHeight || 0;
|
|
}
|
|
|
|
getCanvasLogicalWidth() {
|
|
if (!this.canvas) return 0;
|
|
return this.getCanvasCssWidth() || (this.canvas.width / this.getCanvasPixelScale());
|
|
}
|
|
|
|
getCanvasLogicalHeight() {
|
|
if (!this.canvas) return 0;
|
|
return this.getCanvasCssHeight() || (this.canvas.height / this.getCanvasPixelScale());
|
|
}
|
|
|
|
getPrecisionMode() {
|
|
const precisionWidget = this.node.widgets?.find(w => w.name === 'precision_level');
|
|
return precisionWidget ? precisionWidget.value : 'miliseconds'; // Default to 'miliseconds'
|
|
}
|
|
|
|
formatTime(seconds) {
|
|
const mode = this.getPrecisionMode();
|
|
|
|
if (mode === 'Samples') {
|
|
if (!this.waveformData || !this.waveformData.sampleRate) {
|
|
return 'N/A Samples';
|
|
}
|
|
const sampleIndex = Math.floor(seconds * this.waveformData.sampleRate);
|
|
return `${sampleIndex.toLocaleString()} smp`;
|
|
}
|
|
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
|
|
if (mode === 'seconds') {
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
// Default to 'miliseconds'
|
|
const ms = Math.floor((seconds % 1) * 1000);
|
|
const msString = ms.toString().padStart(3, '0');
|
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${msString}`;
|
|
}
|
|
|
|
formatRegionValue(timeInSeconds) {
|
|
const mode = this.getPrecisionMode();
|
|
|
|
switch (mode) {
|
|
case 'Samples':
|
|
if (!this.waveformData || !this.waveformData.sampleRate) return '0';
|
|
return Math.floor(timeInSeconds * this.waveformData.sampleRate).toString();
|
|
case 'seconds':
|
|
return Math.round(timeInSeconds).toString(); // Use Math.round for whole seconds
|
|
case 'miliseconds':
|
|
default:
|
|
return timeInSeconds.toFixed(3);
|
|
}
|
|
}
|
|
|
|
// Canvas management
|
|
resizeCanvas(redraw = true) {
|
|
const cssWidth = this.getCanvasCssWidth();
|
|
const cssHeight = this.getCanvasCssHeight();
|
|
if (!cssWidth || !cssHeight) return false;
|
|
|
|
const pixelScale = this.getCanvasPixelScale();
|
|
const width = Math.max(1, Math.round(cssWidth * pixelScale));
|
|
const height = Math.max(1, Math.round(cssHeight * pixelScale));
|
|
const changed = this.canvas.width !== width || this.canvas.height !== height;
|
|
if (this.canvas.width !== width || this.canvas.height !== height) {
|
|
this.canvas.width = width;
|
|
this.canvas.height = height;
|
|
}
|
|
this.ctx.setTransform(pixelScale, 0, 0, pixelScale, 0, 0);
|
|
if (redraw) {
|
|
this.visualization.redraw();
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
// Selection management
|
|
setSelection(startTime, endTime) {
|
|
this.selectedStart = startTime;
|
|
this.selectedEnd = endTime;
|
|
this.ui.updateSelectionDisplay();
|
|
this.visualization.redraw();
|
|
}
|
|
|
|
|
|
// Region management
|
|
addSelectedRegion() {
|
|
if (this.selectedStart !== null && this.selectedEnd !== null) {
|
|
const region = {
|
|
start: this.selectedStart,
|
|
end: this.selectedEnd,
|
|
label: `Region ${this.selectedRegions.length + 1}`,
|
|
id: Date.now()
|
|
};
|
|
|
|
this.selectedRegions.push(region);
|
|
|
|
// Sort regions chronologically by start time
|
|
this.selectedRegions.sort((a, b) => a.start - b.start);
|
|
|
|
// Renumber regions to be chronological
|
|
this.selectedRegions.forEach((region, index) => {
|
|
if (region.label.match(/^Region \d+$/)) {
|
|
region.label = `Region ${index + 1}`;
|
|
}
|
|
});
|
|
|
|
this.clearSelection();
|
|
this.visualization.redraw();
|
|
|
|
// Update manual regions in the node
|
|
this.updateManualRegions();
|
|
}
|
|
}
|
|
|
|
clearAllRegions() {
|
|
this.selectedRegions = [];
|
|
this.selectedRegionIndices = [];
|
|
this.highlightedRegionIndex = -1;
|
|
|
|
// Only clear manual regions, keep auto-detected regions
|
|
// (waveformData.regions only contains auto-detected regions after fix above)
|
|
|
|
this.visualization.redraw();
|
|
this.updateManualRegions();
|
|
}
|
|
|
|
deleteSelectedRegion() {
|
|
if (this.selectedRegionIndices.length > 0) {
|
|
// Delete multiple selected regions (orange) - sort indices in reverse order to avoid index shifting
|
|
const sortedIndices = [...this.selectedRegionIndices].sort((a, b) => b - a);
|
|
sortedIndices.forEach(index => {
|
|
if (index >= 0 && index < this.selectedRegions.length) {
|
|
this.selectedRegions.splice(index, 1);
|
|
}
|
|
});
|
|
this.selectedRegionIndices = [];
|
|
this.showMessage(`Deleted ${sortedIndices.length} selected regions`);
|
|
} else if (this.highlightedRegionIndex >= 0 && this.highlightedRegionIndex < this.selectedRegions.length) {
|
|
// Delete single hovered region (green)
|
|
this.selectedRegions.splice(this.highlightedRegionIndex, 1);
|
|
this.highlightedRegionIndex = -1;
|
|
this.showMessage('Deleted hovered region');
|
|
}
|
|
|
|
// Sort regions chronologically by start time (in case they weren't)
|
|
this.selectedRegions.sort((a, b) => a.start - b.start);
|
|
|
|
// Renumber remaining regions chronologically
|
|
this.selectedRegions.forEach((region, index) => {
|
|
if (region.label.match(/^Region \d+$/)) {
|
|
region.label = `Region ${index + 1}`;
|
|
}
|
|
});
|
|
|
|
this.visualization.redraw();
|
|
this.updateManualRegions();
|
|
}
|
|
|
|
selectRegionAtTime(time) {
|
|
// Find which region contains this time
|
|
for (let i = 0; i < this.selectedRegions.length; i++) {
|
|
const region = this.selectedRegions[i];
|
|
if (time >= region.start && time <= region.end) {
|
|
// Toggle selection: if already selected, deselect it; otherwise add it
|
|
const existingIndex = this.selectedRegionIndices.indexOf(i);
|
|
if (existingIndex >= 0) {
|
|
// Deselect: remove from array
|
|
this.selectedRegionIndices.splice(existingIndex, 1);
|
|
this.showMessage(`Region ${i + 1} deselected. ${this.selectedRegionIndices.length} regions selected.`);
|
|
} else {
|
|
// Select: add to array
|
|
this.selectedRegionIndices.push(i);
|
|
this.showMessage(`Region ${i + 1} selected. ${this.selectedRegionIndices.length} regions selected.`);
|
|
}
|
|
this.visualization.redraw();
|
|
return i;
|
|
}
|
|
}
|
|
// Clicked empty space - clear all selections
|
|
this.selectedRegionIndices = [];
|
|
this.visualization.redraw();
|
|
return -1;
|
|
}
|
|
|
|
getRegionAtTime(time) {
|
|
for (let i = 0; i < this.selectedRegions.length; i++) {
|
|
const region = this.selectedRegions[i];
|
|
if (time >= region.start && time <= region.end) {
|
|
return i;
|
|
}
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
updateManualRegions() {
|
|
// Update the manual_regions widget with current selections (multiline format)
|
|
const manualRegionsWidget = this.node.widgets.find(w => w.name === 'manual_regions');
|
|
if (manualRegionsWidget) {
|
|
// Temporarily disable the change listener to prevent infinite loop
|
|
manualRegionsWidget._updating = true;
|
|
const regionsText = this.selectedRegions
|
|
.map(r => `${this.formatRegionValue(r.start)},${this.formatRegionValue(r.end)}`)
|
|
.join('\n'); // Use newline separator for multiline widget
|
|
manualRegionsWidget.value = regionsText;
|
|
manualRegionsWidget._updating = false;
|
|
}
|
|
|
|
// Update labels widget (multiline format)
|
|
const labelsWidget = this.node.widgets.find(w => w.name === 'region_labels');
|
|
if (labelsWidget) {
|
|
// Temporarily disable the change listener to prevent infinite loop
|
|
labelsWidget._updating = true;
|
|
const labelsText = this.selectedRegions
|
|
.map(r => r.label) // Labels don't need precision, but we keep the structure
|
|
.join('\n'); // Use newline separator for multiline widget
|
|
labelsWidget.value = labelsText;
|
|
labelsWidget._updating = false;
|
|
}
|
|
}
|
|
|
|
// Parse manual regions text back into selectedRegions array
|
|
parseManualRegionsFromText() {
|
|
const manualRegionsWidget = this.node.widgets.find(w => w.name === 'manual_regions');
|
|
const labelsWidget = this.node.widgets.find(w => w.name === 'region_labels');
|
|
|
|
if (!manualRegionsWidget || !manualRegionsWidget.value.trim()) {
|
|
this.selectedRegions = [];
|
|
this.visualization.redraw();
|
|
return;
|
|
}
|
|
|
|
const regionsText = manualRegionsWidget.value.trim();
|
|
const labelsText = labelsWidget ? labelsWidget.value.trim() : '';
|
|
|
|
const regionLines = regionsText.split('\n').filter(line => line.trim());
|
|
const labelLines = labelsText ? labelsText.split('\n').filter(line => line.trim()) : [];
|
|
|
|
const newRegions = [];
|
|
|
|
regionLines.forEach((line, index) => {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return;
|
|
|
|
// Parse start,end format
|
|
const parts = trimmed.split(',').map(p => p.trim());
|
|
if (parts.length === 2) {
|
|
const start = parseFloat(parts[0]);
|
|
const end = parseFloat(parts[1]);
|
|
|
|
if (!isNaN(start) && !isNaN(end) && start < end) {
|
|
const label = labelLines[index] || `Region ${index + 1}`;
|
|
newRegions.push({
|
|
start: start,
|
|
end: end,
|
|
label: label,
|
|
id: Date.now() + index // Unique ID
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Sort regions chronologically by start time
|
|
newRegions.sort((a, b) => a.start - b.start);
|
|
|
|
// Update labels to be chronological (Region 1, Region 2, etc.)
|
|
newRegions.forEach((region, index) => {
|
|
if (region.label.match(/^Region \d+$/)) {
|
|
region.label = `Region ${index + 1}`;
|
|
}
|
|
});
|
|
|
|
this.selectedRegions = newRegions;
|
|
this.visualization.redraw();
|
|
|
|
// Update the labels widget to reflect any changes
|
|
this.updateManualRegions();
|
|
}
|
|
|
|
// Loop marker management
|
|
setLoopFromSelection() {
|
|
if (this.selectedStart !== null && this.selectedEnd !== null) {
|
|
this.loopStart = this.selectedStart;
|
|
this.loopEnd = this.selectedEnd;
|
|
this.visualization.redraw();
|
|
this.showMessage(`Loop set: ${this.formatTime(this.loopStart)} - ${this.formatTime(this.loopEnd)}`);
|
|
} else {
|
|
this.showMessage('Please select a region first to set loop markers');
|
|
}
|
|
}
|
|
|
|
setLoopFromRegion(regionIndex) {
|
|
if (regionIndex >= 0 && regionIndex < this.selectedRegions.length) {
|
|
const region = this.selectedRegions[regionIndex];
|
|
this.loopStart = region.start;
|
|
this.loopEnd = region.end;
|
|
this.visualization.redraw();
|
|
this.showMessage(`Loop set from ${region.label}: ${this.formatTime(this.loopStart)} - ${this.formatTime(this.loopEnd)}`);
|
|
}
|
|
}
|
|
|
|
clearLoopMarkers() {
|
|
this.loopStart = null;
|
|
this.loopEnd = null;
|
|
this.isLooping = false;
|
|
this.visualization.redraw();
|
|
this.showMessage('Loop markers cleared');
|
|
}
|
|
|
|
toggleLooping() {
|
|
this.isLooping = !this.isLooping;
|
|
this.showMessage(this.isLooping ? 'Looping enabled' : 'Looping disabled');
|
|
}
|
|
|
|
// Audio playback controls
|
|
togglePlayback() {
|
|
if (this.isPlaying) {
|
|
this.pausePlayback();
|
|
} else {
|
|
this.startPlayback();
|
|
}
|
|
}
|
|
|
|
startPlayback() {
|
|
if (!this.audioElement) return;
|
|
|
|
// If looping is enabled and we have loop markers, start from loop start
|
|
if (this.isLooping && this.loopStart !== null) {
|
|
this.currentTime = this.loopStart;
|
|
}
|
|
|
|
this.audioElement.currentTime = this.currentTime;
|
|
this.audioElement.play();
|
|
this.isPlaying = true;
|
|
this.ui.playButton.textContent = '⏸️ Pause';
|
|
|
|
// Update playhead position
|
|
this.updatePlayhead();
|
|
}
|
|
|
|
pausePlayback() {
|
|
if (this.audioElement) {
|
|
this.audioElement.pause();
|
|
}
|
|
this.isPlaying = false;
|
|
this.stopPlayheadAnimation(); // Stop animation loop
|
|
this.ui.playButton.textContent = '▶️ Play';
|
|
}
|
|
|
|
stopPlayback() {
|
|
if (this.audioElement) {
|
|
this.audioElement.pause();
|
|
this.audioElement.currentTime = 0;
|
|
}
|
|
this.isPlaying = false;
|
|
this.currentTime = 0;
|
|
this.stopPlayheadAnimation(); // Stop animation loop
|
|
this.visualization.stopAnimation(); // Stop visualization animation loop
|
|
this.ui.playButton.textContent = '▶️ Play';
|
|
this.ui.updateTimeDisplay();
|
|
this.visualization.redraw();
|
|
}
|
|
|
|
updatePlayhead() {
|
|
// Double-check both isPlaying and audio element state
|
|
if (!this.isPlaying) {
|
|
return;
|
|
}
|
|
|
|
if (this.playbackSpeed < 0) {
|
|
// Backwards movement: manual time control, silent audio
|
|
const deltaTime = Math.abs(this.playbackSpeed) / 60; // Approximate frame rate adjustment
|
|
this.currentTime -= deltaTime;
|
|
|
|
// Clamp to audio bounds and stop at start (don't loop)
|
|
if (this.currentTime < 0) {
|
|
this.currentTime = 0;
|
|
// Don't stop playing - just clamp position
|
|
}
|
|
|
|
// Handle looping in reverse
|
|
if (this.isLooping && this.loopStart !== null && this.currentTime <= this.loopStart) {
|
|
this.currentTime = this.loopEnd || (this.waveformData ? this.waveformData.duration : 0);
|
|
}
|
|
} else {
|
|
// Normal forward playback
|
|
if (this.audioElement) {
|
|
if (this.audioElement.ended) {
|
|
// Audio ended but we're still "playing" - clamp to end position
|
|
this.currentTime = this.waveformData ? this.waveformData.duration : this.audioElement.duration;
|
|
// Don't stop playing - just clamp position
|
|
} else if (!this.audioElement.paused) {
|
|
this.currentTime = this.audioElement.currentTime;
|
|
|
|
// Check for loop end
|
|
if (this.isLooping && this.loopEnd !== null && this.currentTime >= this.loopEnd) {
|
|
this.currentTime = this.loopStart || 0;
|
|
this.audioElement.currentTime = this.currentTime;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.ui.updateTimeDisplay();
|
|
this.visualization.redraw();
|
|
|
|
// Continue animation if still playing
|
|
if (this.isPlaying) {
|
|
this.playheadAnimationId = requestAnimationFrame(() => this.updatePlayhead());
|
|
}
|
|
}
|
|
|
|
stopPlayheadAnimation() {
|
|
if (this.playheadAnimationId) {
|
|
cancelAnimationFrame(this.playheadAnimationId);
|
|
this.playheadAnimationId = null;
|
|
}
|
|
}
|
|
|
|
// Zoom controls
|
|
zoomIn() {
|
|
this.zoomLevel = Math.min(this.zoomLevel * 2, 100);
|
|
this.visualization.redraw();
|
|
}
|
|
|
|
zoomOut() {
|
|
this.zoomLevel = Math.max(this.zoomLevel / 2, 0.1);
|
|
this.visualization.redraw();
|
|
}
|
|
|
|
resetZoom() {
|
|
this.zoomLevel = 1;
|
|
this.scrollOffset = 0;
|
|
this.amplitudeScale = 0.4; // Reset amplitude scale to default
|
|
|
|
// Reset speed to 1x
|
|
this.playbackSpeed = 1;
|
|
if (this.ui.speedSlider) {
|
|
this.ui.speedSlider.value = '1';
|
|
}
|
|
if (this.ui.speedValue) {
|
|
this.ui.speedValue.textContent = '1.00x';
|
|
}
|
|
if (this.audioElement) {
|
|
this.audioElement.playbackRate = 1;
|
|
if (this.audioElement.volume === 0) {
|
|
this.audioElement.volume = 1; // Restore volume if muted from backwards mode
|
|
}
|
|
}
|
|
|
|
this.visualization.redraw();
|
|
}
|
|
|
|
// Speed control
|
|
setPlaybackSpeed(speed) {
|
|
const wasBackwards = this.playbackSpeed < 0;
|
|
const isNowBackwards = speed < 0;
|
|
|
|
if (isNowBackwards) {
|
|
// Negative speed: pause audio, backwards playhead movement
|
|
this.playbackSpeed = speed;
|
|
if (this.audioElement && this.isPlaying) {
|
|
this.audioElement.pause(); // Pause audio completely
|
|
}
|
|
this.showMessage(`Backwards mode: ${Math.abs(speed)}x (silent)`);
|
|
} else {
|
|
// Positive speed: normal audio playback
|
|
this.playbackSpeed = speed;
|
|
if (this.audioElement) {
|
|
// Always sync position and resume when switching to forward
|
|
if (wasBackwards && !isNowBackwards && this.isPlaying) {
|
|
this.audioElement.currentTime = this.currentTime; // Sync audio to visual playhead
|
|
this.audioElement.playbackRate = speed;
|
|
|
|
// Force resume playback from current position
|
|
this.audioElement.play().catch(e => {
|
|
console.log('Audio play failed:', e);
|
|
});
|
|
} else {
|
|
this.audioElement.playbackRate = speed;
|
|
}
|
|
}
|
|
this.showMessage(`Playback speed set to ${speed}x`);
|
|
}
|
|
}
|
|
|
|
// Export functionality
|
|
exportTiming() {
|
|
if (this.selectedRegions.length === 0) {
|
|
alert('No regions selected. Please select timing regions first.');
|
|
return;
|
|
}
|
|
|
|
const timingData = this.selectedRegions
|
|
.map(r => `${this.formatRegionValue(r.start)},${this.formatRegionValue(r.end)}`)
|
|
.join('\n');
|
|
|
|
// Copy to clipboard
|
|
navigator.clipboard.writeText(timingData).then(() => {
|
|
alert('Timing data copied to clipboard!');
|
|
}).catch(() => {
|
|
// Fallback: show in alert
|
|
alert(`Timing data:\n${timingData}`);
|
|
});
|
|
}
|
|
|
|
// Show message
|
|
showMessage(message, level = 'info') {
|
|
this.ui.showMessage(message, level);
|
|
}
|
|
|
|
// Update visualization with new data
|
|
updateVisualization(data) {
|
|
this.nodeIntegration.updateVisualization(data);
|
|
}
|
|
|
|
// Handle audio file selection
|
|
onAudioFileSelected(filePath) {
|
|
this.nodeIntegration.onAudioFileSelected(filePath);
|
|
}
|
|
|
|
// Handle parameter changes
|
|
onParametersChanged() {
|
|
this.nodeIntegration.onParametersChanged();
|
|
}
|
|
|
|
// Handle audio connection
|
|
onAudioConnected() {
|
|
this.nodeIntegration.onAudioConnected();
|
|
}
|
|
|
|
// Clear current selection (but don't clear region selection)
|
|
clearSelection() {
|
|
this.selectedStart = null;
|
|
this.selectedEnd = null;
|
|
this.dragStart = null;
|
|
this.dragEnd = null;
|
|
this.isDragging = false;
|
|
this.ui.updateSelectionDisplay();
|
|
this.visualization.redraw();
|
|
}
|
|
}
|