From 5951650903b228bb01171f8d47965e22a949a518 Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 28 Jan 2026 12:00:50 +0100 Subject: feat(editor): Implement drawing tools and advanced undo/redo This commit significantly enhances the web spectrogram editor by implementing core drawing tools (line, ellipse, noise) and a robust undo/redo system. - and : Added redo button and styling. - : Implemented canvas event handling, dynamic shape previews, and the logic for lines and noise rectangles. - : Now reconstructs the spectrogram from a clean base, allowing proper undo/redo. - : Uses an improved color gradient for better visualization. - : Stores original spectrogram data for persistent state management. --- tools/editor/index.html | 2 + tools/editor/script.js | 419 +++++++++++++++++++++++++++++++++++++++++------- tools/editor/style.css | 22 +++ 3 files changed, 381 insertions(+), 62 deletions(-) (limited to 'tools') diff --git a/tools/editor/index.html b/tools/editor/index.html index 73356bd..914ac93 100644 --- a/tools/editor/index.html +++ b/tools/editor/index.html @@ -19,6 +19,8 @@ + + diff --git a/tools/editor/script.js b/tools/editor/script.js index 737a9e6..1d6ca18 100644 --- a/tools/editor/script.js +++ b/tools/editor/script.js @@ -2,13 +2,20 @@ // It handles file loading (.spec), visualization, tool interaction, and saving. // --- Global Variables --- -let currentSpecData = null; // Stores the parsed spectrogram data +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); @@ -42,13 +49,15 @@ async function handleFileSelect(event) { const numBytes = header.num_frames * header.dct_size * Float32Array.BYTES_PER_ELEMENT; const spectralDataFloat = new Float32Array(buffer, dataStart, header.num_frames * header.dct_size); - currentSpecData = { - header: header, - data: spectralDataFloat - }; + 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); - drawSpectrogram(currentSpecData); + redrawCanvas(); // Redraw with new data } catch (error) { console.error("Error loading SPEC file:", error); @@ -60,72 +69,329 @@ async function handleFileSelect(event) { const canvas = document.getElementById('spectrogramCanvas'); const ctx = canvas.getContext('2d'); -function drawSpectrogram(specData) { - if (!specData || !specData.data) { - console.warn("No spectrogram data to draw."); - return; - } +// 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}%)`; +} - const width = canvas.width = window.innerWidth * 0.7; // Example dimensions - const height = canvas.height = 400; // Example dimensions +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 binsPerFrame = specData.data.length / numFrames; + const binHeight = height / dctSize; // Height of each frequency bin + const frameWidth = width / numFrames; // Width of each time frame - if (numFrames === 0 || binsPerFrame === 0) { - console.warn("Spectrogram has no frames or invalid data."); - return; + // 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); + } } - // Simplified visualization: draw a few lines to represent frames - const frameWidth = width / numFrames; - ctx.strokeStyle = '#000000'; - ctx.lineWidth = 1; - ctx.beginPath(); + // 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; - // Draw a simplified representation of the first frame - const frameIndex = 0; - const frameDataStart = frameIndex * dctSize; - const maxVal = 1.0; // Assume normalization or known range for now - - for (let i = 0; i < dctSize; ++i) { - const value = specData.data[frameDataStart + i]; - const x = (i / dctSize) * width; - const y = height - (Math.abs(value) / maxVal) * height * 0.5; // Simplified scaling - - if (i === 0) { - ctx.moveTo(x, y); - } else { - ctx.lineTo(x, y); + 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); + } + }); } - ctx.stroke(); + redrawCanvas(); // Final redraw after action + updateUndoRedoButtons(); } -// --- Tool Interactions --- +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', () => console.log('Line tool selected')); -ellipseToolButton.addEventListener('click', () => console.log('Ellipse tool selected')); -noiseToolButton.addEventListener('click', () => console.log('Noise tool selected')); +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); - // Limit history size if (undoStack.length > MAX_HISTORY_SIZE) { - undoStack.shift(); // Remove oldest action + undoStack.shift(); } - redoStack = []; // Clear redo stack on new action + redoStack = []; updateUndoRedoButtons(); } @@ -134,11 +400,10 @@ function handleUndo() { console.log('Undo stack is empty.'); return; } - const actionToUndo = undoStack.pop(); - actionToUndo.undo(); // Execute the inverse operation + actionToUndo.undo(); // Execute the inverse operation stored in the action redoStack.push(actionToUndo); - redrawCanvas(); // Redraw canvas to reflect the undo operation + redrawCanvas(); updateUndoRedoButtons(); } @@ -151,36 +416,66 @@ function handleRedo() { const actionToRedo = redoStack.pop(); actionToRedo.redo(); // Re-apply the action undoStack.push(actionToRedo); - redrawCanvas(); // Redraw canvas to reflect the redo operation + redrawCanvas(); updateUndoRedoButtons(); } function redrawCanvas() { - // This function needs to be implemented to redraw the entire canvas state - // based on the current undoStack. For now, it's a placeholder. console.log('Redrawing canvas...'); - if (currentSpecData) { - drawSpectrogram(currentSpecData); - } else { - // Clear canvas if no data is loaded - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = '#ffffff'; - ctx.fillRect(0, 0, canvas.width, canvas.height); + 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() { - // Enable/disable buttons based on stack emptiness undoButton.disabled = undoStack.length === 0; - // redoButton.disabled = redoStack.length === 0; // If redo button exists + 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 (currentSpecData) { - drawSpectrogram(currentSpecData); + if (originalSpecData) { + canvas.width = window.innerWidth * 0.7; + canvas.height = 400; // Fixed height + redrawCanvas(); } }); -// Initial call to set button states and potentially draw initial state if any is loaded +// Initial call to set button states updateUndoRedoButtons(); \ No newline at end of file diff --git a/tools/editor/style.css b/tools/editor/style.css index f610969..c02eb4a 100644 --- a/tools/editor/style.css +++ b/tools/editor/style.css @@ -33,3 +33,25 @@ h1, h2 { padding: 10px; cursor: pointer; } + +#undoButton { + background-color: #d9534f; + color: white; + border: none; + border-radius: 4px; +} + +#undoButton:hover { + background-color: #c9302c; +} + +#redoButton { + background-color: #5cb85c; + color: white; + border: none; + border-radius: 4px; +} + +#redoButton:hover { + background-color: #4cae4c; +} -- cgit v1.2.3