// This is the core JavaScript for the Spectrogram Editor. // It handles file loading (.spec), visualization, tool interaction, and saving. // --- Global Variables --- let currentSpecData = null; // Stores the currently displayed/edited spectrogram data let originalSpecData = null; // Stores the pristine, initially loaded spectrogram data let dctSize = 512; // Default DCT size, read from header let undoStack = []; let redoStack = []; const MAX_HISTORY_SIZE = 50; let activeTool = null; // 'line', 'ellipse', 'noise', etc. let isDrawing = false; let startX, startY; // For tracking mouse down position let shapes = []; // Array to store all drawn shapes (lines, ellipses, etc.) // --- File Handling --- const specFileInput = document.getElementById('specFileInput'); specFileInput.addEventListener('change', handleFileSelect); async function handleFileSelect(event) { const file = event.target.files[0]; if (!file) { return; } try { const buffer = await file.arrayBuffer(); const dataView = new DataView(buffer); // Parse SPEC header const header = { magic: String.fromCharCode(...new Uint8Array(buffer.slice(0, 4))), version: dataView.getInt32(4, true), dct_size: dataView.getInt32(8, true), num_frames: dataView.getInt32(12, true) }; if (header.magic !== "SPEC" || header.version !== 1) { console.error("Invalid SPEC file format."); alert("Invalid SPEC file format. Please load a valid .spec file."); return; } dctSize = header.dct_size; const dataStart = 16; const numBytes = header.num_frames * header.dct_size * Float32Array.BYTES_PER_ELEMENT; const spectralDataFloat = new Float32Array(buffer, dataStart, header.num_frames * header.dct_size); originalSpecData = { header: header, data: new Float32Array(spectralDataFloat) }; // Store pristine copy currentSpecData = { header: header, data: new Float32Array(spectralDataFloat) }; // Editable copy shapes = []; // Clear shapes on new file load undoStack = []; // Clear undo history redoStack = []; // Clear redo history console.log("Loaded SPEC file:", header); redrawCanvas(); // Redraw with new data } catch (error) { console.error("Error loading SPEC file:", error); alert("Failed to load SPEC file. Check console for details."); } } // --- Spectrogram Visualization --- const canvas = document.getElementById('spectrogramCanvas'); const ctx = canvas.getContext('2d'); // Add canvas event listeners canvas.addEventListener('mousedown', handleMouseDown); canvas.addEventListener('mousemove', handleMouseMove); canvas.addEventListener('mouseup', handleMouseUp); canvas.addEventListener('mouseout', handleMouseUp); // Treat mouse out as mouse up // Function to get a color based on intensity (0 to 1) function getColorForIntensity(intensity) { // Example: Blue to white/yellow gradient const h = (1 - intensity) * 240; // Hue from blue (240) to red (0), inverse for intensity const s = 100; // Saturation const l = intensity * 50 + 50; // Lightness from 50 to 100 return `hsl(${h}, ${s}%, ${l}%)`; } function drawSpectrogram(specData) { const width = canvas.width; const height = canvas.height; ctx.clearRect(0, 0, width, height); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, width, height); if (!specData || !specData.data || specData.header.num_frames === 0 || specData.data.length === 0) { console.warn("No spectrogram data or invalid header/data to draw."); return; } const numFrames = specData.header.num_frames; const binHeight = height / dctSize; // Height of each frequency bin const frameWidth = width / numFrames; // Width of each time frame // Find max value for normalization (for better visualization) let maxAbsValue = 0; for (let i = 0; i < specData.data.length; i++) { maxAbsValue = Math.max(maxAbsValue, Math.abs(specData.data[i])); } if (maxAbsValue === 0) maxAbsValue = 1; // Avoid division by zero // Draw each frame's spectral data for (let frameIndex = 0; frameIndex < numFrames; frameIndex++) { const frameDataStart = frameIndex * dctSize; const xPos = frameIndex * frameWidth; for (let binIndex = 0; binIndex < dctSize; binIndex++) { const value = specData.data[frameDataStart + binIndex]; const intensity = Math.min(1, Math.abs(value) / maxAbsValue); // Normalized intensity ctx.fillStyle = getColorForIntensity(intensity); ctx.fillRect(xPos, height - (binIndex * binHeight) - binHeight, frameWidth, binHeight); } } // Draw active shapes on top (previews for current drawing tool) shapes.forEach(shape => { drawShape(shape); }); } function drawShape(shape) { // This draws the final, persistent shape. Preview is drawn in handleMouseMove. ctx.strokeStyle = shape.color || 'red'; ctx.lineWidth = shape.width || 2; switch (shape.type) { case 'line': ctx.beginPath(); ctx.moveTo(shape.x1, shape.y1); ctx.lineTo(shape.x2, shape.y2); ctx.stroke(); break; case 'ellipse': ctx.beginPath(); ctx.ellipse(shape.cx, shape.cy, shape.rx, shape.ry, 0, 0, 2 * Math.PI); ctx.stroke(); break; case 'noise_rect': // Noise is visualized as a rectangle ctx.fillStyle = 'rgba(0, 0, 255, 0.2)'; ctx.fillRect(shape.x, shape.y, shape.width, shape.height); ctx.strokeStyle = 'blue'; ctx.strokeRect(shape.x, shape.y, shape.width, shape.height); break; } } // --- Mouse Event Handlers --- function getMousePos(event) { const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; } function handleMouseDown(event) { if (!activeTool || !currentSpecData) return; isDrawing = true; const pos = getMousePos(event); startX = pos.x; startY = pos.y; } function handleMouseMove(event) { if (!isDrawing || !activeTool) return; const pos = getMousePos(event); redrawCanvas(); // Clear and redraw persistent state ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; // Preview color ctx.lineWidth = 1; ctx.setLineDash([5, 5]); // Dashed line for preview switch (activeTool) { case 'line': ctx.beginPath(); ctx.moveTo(startX, startY); ctx.lineTo(pos.x, pos.y); ctx.stroke(); break; case 'ellipse': // Draw preview ellipse based on start and current pos (bounding box) const rx = Math.abs(pos.x - startX) / 2; const ry = Math.abs(pos.y - startY) / 2; const cx = startX + (pos.x - startX) / 2; const cy = startY + (pos.y - startY) / 2; if (rx > 0 && ry > 0) { ctx.beginPath(); ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI); ctx.stroke(); } break; case 'noise': // Draw preview rectangle for noise area const rectX = Math.min(startX, pos.x); const rectY = Math.min(startY, pos.y); const rectW = Math.abs(pos.x - startX); const rectH = Math.abs(pos.y - startY); ctx.strokeRect(rectX, rectY, rectW, rectH); break; } ctx.setLineDash([]); // Reset line dash } function handleMouseUp(event) { if (!isDrawing || !activeTool || !currentSpecData) return; isDrawing = false; const endPos = getMousePos(event); let newShape = null; switch (activeTool) { case 'line': { const startCoords = canvasToSpectrogramCoords(startX, startY, currentSpecData); const endCoords = canvasToSpectrogramCoords(endPos.x, endPos.y, currentSpecData); newShape = { type: 'line', x1: startX, y1: startY, x2: endPos.x, y2: endPos.y, frame1: startCoords.frame, bin1: startCoords.bin, frame2: endCoords.frame, bin2: endCoords.bin, amplitude: 0.5, // Default amplitude width: 2, // Default width (in canvas pixels for drawing, bins for spec) color: 'red', }; break; } case 'ellipse': { const rx = Math.abs(endPos.x - startX) / 2; const ry = Math.abs(endPos.y - startY) / 2; const cx = startX + (endPos.x - startX) / 2; const cy = startY + (endPos.y - startY) / 2; const centerCoords = canvasToSpectrogramCoords(cx, cy, currentSpecData); const radiusXFrames = Math.floor((rx / canvas.width) * currentSpecData.header.num_frames); const radiusYBins = Math.floor((ry / canvas.height) * dctSize); newShape = { type: 'ellipse', cx: cx, cy: cy, rx: rx, ry: ry, frameC: centerCoords.frame, binC: centerCoords.bin, radiusFrames: radiusXFrames, radiusBins: radiusYBins, amplitude: 0.5, color: 'green', }; break; } case 'noise': { const rectX = Math.min(startX, endPos.x); const rectY = Math.min(startY, endPos.y); const rectW = Math.abs(endPos.x - startX); const rectH = Math.abs(endPos.y - startY); const startCoords = canvasToSpectrogramCoords(rectX, rectY, currentSpecData); const endCoords = canvasToSpectrogramCoords(rectX + rectW, rectY + rectH, currentSpecData); newShape = { type: 'noise_rect', x: rectX, y: rectY, width: rectW, height: rectH, frame1: startCoords.frame, bin1: startCoords.bin, frame2: endCoords.frame, bin2: endCoords.bin, amplitude: 0.3, // Default noise amplitude density: 0.5, // Default noise density color: 'blue', }; break; } } if (newShape) { // Capture the state *before* applying the new shape for undo const previousDataSnapshot = new Float32Array(currentSpecData.data); const previousShapesSnapshot = shapes.map(s => ({ ...s })); // Deep copy shapes applyShapeToSpectrogram(newShape, currentSpecData); // Modify currentSpecData directly shapes.push(newShape); addAction({ type: 'add_shape', shape: newShape, undo: () => { // Revert shapes array shapes = previousShapesSnapshot; // Revert currentSpecData.data to the snapshot before this action currentSpecData.data = previousDataSnapshot; }, redo: () => { // Re-apply the shape and update currentSpecData.data shapes.push(newShape); applyShapeToSpectrogram(newShape, currentSpecData); } }); } redrawCanvas(); // Final redraw after action updateUndoRedoButtons(); } function applyShapeToSpectrogram(shape, targetSpecData) { if (!targetSpecData || !targetSpecData.data || targetSpecData.header.num_frames === 0) return; const numFrames = targetSpecData.header.num_frames; switch (shape.type) { case 'line': // Bresenham's-like line drawing to apply to spectrogram data let x0 = shape.frame1, y0 = shape.bin1; let x1 = shape.frame2, y1 = shape.bin2; const dx = Math.abs(x1 - x0); const dy = Math.abs(y1 - y0); const sx = (x0 < x1) ? 1 : -1; const sy = (y0 < y1) ? 1 : -1; let err = dx - dy; let currentX = x0; let currentY = y0; while (true) { if (currentX >= 0 && currentX < numFrames && currentY >= 0 && currentY < dctSize) { const index = currentX * dctSize + currentY; // Apply amplitude and width for (let b = -shape.width; b <= shape.width; b++) { const binToAffect = currentY + b; if (binToAffect >= 0 && binToAffect < dctSize) { targetSpecData.data[currentX * dctSize + binToAffect] += shape.amplitude; // Clamp value targetSpecData.data[currentX * dctSize + binToAffect] = Math.max(-1, Math.min(1, targetSpecData.data[currentX * dctSize + binToAffect])); } } } if (currentX === x1 && currentY === y1) break; const e2 = 2 * err; if (e2 > -dy) { err -= dy; currentX += sx; } if (e2 < dx) { err += dx; currentY += sy; } } break; case 'ellipse': // Apply ellipse to spectrogram data // TODO: Implement ellipse algorithm console.log("Applying ellipse to spectrogram data (TODO)."); break; case 'noise_rect': // Apply noise to a rectangular region const frameStart = Math.max(0, Math.min(numFrames - 1, shape.frame1)); const frameEnd = Math.max(0, Math.min(numFrames - 1, shape.frame2)); const binStart = Math.max(0, Math.min(dctSize - 1, shape.bin1)); const binEnd = Math.max(0, Math.min(dctSize - 1, shape.bin2)); for (let f = frameStart; f <= frameEnd; f++) { for (let b = binStart; b <= binEnd; b++) { if (Math.random() < shape.density) { targetSpecData.data[f * dctSize + b] += (Math.random() * 2 - 1) * shape.amplitude; // Random value between -amp and +amp // Clamp value targetSpecData.data[f * dctSize + b] = Math.max(-1, Math.min(1, targetSpecData.data[f * dctSize + b])); } } } break; } } // --- Tool Interactions (Button Clicks) --- const lineToolButton = document.getElementById('lineTool'); const ellipseToolButton = document.getElementById('ellipseTool'); const noiseToolButton = document.getElementById('noiseTool'); const undoButton = document.getElementById('undoButton'); const redoButton = document.getElementById('redoButton'); // New redo button lineToolButton.addEventListener('click', () => { activeTool = 'line'; console.log('Line tool selected'); }); ellipseToolButton.addEventListener('click', () => { activeTool = 'ellipse'; console.log('Ellipse tool selected'); }); noiseToolButton.addEventListener('click', () => { activeTool = 'noise'; console.log('Noise tool selected'); }); undoButton.addEventListener('click', handleUndo); redoButton.addEventListener('click', handleRedo); // Redo button listener // --- Undo/Redo Logic --- function addAction(action) { undoStack.push(action); if (undoStack.length > MAX_HISTORY_SIZE) { undoStack.shift(); } redoStack = []; updateUndoRedoButtons(); } function handleUndo() { if (undoStack.length === 0) { console.log('Undo stack is empty.'); return; } const actionToUndo = undoStack.pop(); actionToUndo.undo(); // Execute the inverse operation stored in the action redoStack.push(actionToUndo); redrawCanvas(); updateUndoRedoButtons(); } function handleRedo() { if (redoStack.length === 0) { console.log('Redo stack is empty.'); return; } const actionToRedo = redoStack.pop(); actionToRedo.redo(); // Re-apply the action undoStack.push(actionToRedo); redrawCanvas(); updateUndoRedoButtons(); } function redrawCanvas() { console.log('Redrawing canvas...'); if (!originalSpecData) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); return; } // Start with a fresh copy of the original data currentSpecData.data = new Float32Array(originalSpecData.data); // Replay all shapes from the `shapes` array to `currentSpecData` shapes.forEach(shape => { applyShapeToSpectrogram(shape, currentSpecData); }); drawSpectrogram(currentSpecData); } function updateUndoRedoButtons() { undoButton.disabled = undoStack.length === 0; redoButton.disabled = redoStack.length === 0; // Update redo button state } // --- Utility to map canvas coords to spectrogram bins/frames --- function canvasToSpectrogramCoords(canvasX, canvasY, specData) { const canvasWidth = canvas.width; const canvasHeight = canvas.height; const numFrames = specData.header.num_frames; const frameIndex = Math.floor((canvasX / canvasWidth) * numFrames); const binIndex = Math.floor(((canvasHeight - canvasY) / canvasHeight) * dctSize); return { frame: frameIndex, bin: binIndex }; } function spectrogramToCanvasCoords(frameIndex, binIndex, specData) { const canvasWidth = canvas.width; const canvasHeight = canvas.height; const numFrames = specData.header.num_frames; const canvasX = (frameIndex / numFrames) * canvasWidth; const canvasY = canvasHeight - ((binIndex / dctSize) * canvasHeight); return { x: canvasX, y: canvasY }; } // Initial setup for canvas size (can be updated on window resize) window.addEventListener('resize', () => { if (originalSpecData) { canvas.width = window.innerWidth * 0.7; canvas.height = 400; // Fixed height redrawCanvas(); } }); // Initial call to set button states updateUndoRedoButtons();