From 4607beb85c2fbff9da940af0611b91b8b7ae0a41 Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 28 Jan 2026 12:52:11 +0100 Subject: fix(editor): Resolve 'canvasToSpectrogramCoords is not defined' error (final attempt) Moved all spectrogram coordinate and frequency mapping utility functions to the top of to ensure they are defined before any other functions attempt to use them. This is a crucial scoping fix. --- tools/editor/script.js | 569 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 401 insertions(+), 168 deletions(-) diff --git a/tools/editor/script.js b/tools/editor/script.js index 0c5200b..dcb5478 100644 --- a/tools/editor/script.js +++ b/tools/editor/script.js @@ -507,322 +507,555 @@ function updateUndoRedoButtons() { redoButton.disabled = redoStack.length === 0; } -// --- Utility to map canvas coords to spectrogram bins/frames (LOG SCALE) --- +const hanningWindowArray = hanningWindow(dctSize); // Pre-calculate window +// --- Utility to map canvas coords to spectrogram bins/frames (LOG SCALE) --- // Maps a linear frequency bin index to its corresponding frequency in Hz - function binIndexToFreq(binIndex) { - return (binIndex / dctSize) * MAX_FREQ; - } - - // Maps a frequency in Hz to its corresponding linear bin index - function freqToBinIndex(freq) { - return Math.floor((freq / MAX_FREQ) * dctSize); - } - - // Maps a frequency (Hz) to its corresponding log-scaled bin index - function freqToBinIndexLog(freq) { - if (freq < MIN_FREQ) freq = MIN_FREQ; // Clamp minimum frequency - const logMin = Math.log(MIN_FREQ); - const logMax = Math.log(MAX_FREQ); - const logFreq = Math.log(freq); - const normalizedLog = (logFreq - logMin) / (logMax - logMin); - return Math.floor(normalizedLog * dctSize); - } - - // Maps a log-scaled bin index to its corresponding frequency in Hz - function binIndexToFreqLog(binIndex) { - const normalizedLog = binIndex / dctSize; - const logMin = Math.log(MIN_FREQ); - const logMax = Math.log(MAX_FREQ); - const logFreq = normalizedLog * (logMax - logMin) + logMin; - return Math.exp(logFreq); - } - - // Converts a frequency (Hz) to a Y-coordinate on the canvas (log scale) - function freqToCanvasYLog(freq, canvasHeight) { - if (freq < MIN_FREQ) freq = MIN_FREQ; // Clamp minimum frequency - const logMin = Math.log(MIN_FREQ); - const logMax = Math.log(MAX_FREQ); - const logFreq = Math.log(freq); - const normalizedLog = (logFreq - logMin) / (logMax - logMin); - return canvasHeight * (1 - normalizedLog); // Y-axis is inverted - } - - // Converts a Y-coordinate on the canvas to a frequency (Hz) (log scale) - function canvasYToFreqLog(canvasY, canvasHeight) { - const normalizedLog = 1 - (canvasY / canvasHeight); - const logMin = Math.log(MIN_FREQ); - const logMax = Math.log(MAX_FREQ); - const logFreq = normalizedLog * (logMax - logMin) + logMin; - return Math.exp(logFreq); - } - - // Converts canvas Y-coordinate to log-scaled bin index - function canvasYToBinIndexLog(canvasY, specData) { - const freq = canvasYToFreqLog(canvasY, canvas.height); - return freqToBinIndex(freq); // Use linear bin index from calculated log freq - } - - // Converts log-scaled bin index to canvas Y-coordinate - function binIndexToCanvasYLog(binIndex, specData) { - const freq = binIndexToFreq(binIndex); - return freqToCanvasYLog(freq, canvas.height); - } - - // Helper to get frequency delta from canvas delta (for ellipse radius in freq) - function canvasDeltaYToFreqDeltaLog(canvasDeltaY, canvasHeight) { - // This is an approximation as delta in log scale is not linear - // For small deltas around a center, it can be approximated - const centerCanvasY = canvasHeight / 2; - const freqAtCenter = canvasYToFreqLog(centerCanvasY, canvasHeight); - const freqAtCenterPlusDelta = canvasYToFreqLog(centerCanvasY - canvasDeltaY, canvasHeight); - return Math.abs(freqAtCenterPlusDelta - freqAtCenter); - } - - // 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(); - - -// --- Audio Playback --- - -let currentAudioSource = null; // To stop currently playing audio - - - -async function playSpectrogramData(specData) { - - if (!specData || !specData.data || specData.header.num_frames === 0) { - - alert("No spectrogram data to play."); - - return; - +// --- Utility for sizeof(float) in JS context --- +// This is a workaround since typeof(float) is not directly available. +// Float32Array.BYTES_PER_ELEMENT is used in handleFileSelect. +function sizeof(type) { + if (type === 'float') { + return Float32Array.BYTES_PER_ELEMENT; } + return 0; +} +// --- File Handling --- +const specFileInput = document.getElementById('specFileInput'); +specFileInput.addEventListener('change', handleFileSelect); - if (currentAudioSource) { - - currentAudioSource.stop(); - - currentAudioSource.disconnect(); - - currentAudioSource = null; - +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) + }; - const sampleRate = SAMPLE_RATE; // Fixed sample rate + 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; + } - const numFrames = specData.header.num_frames; + 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); - const totalAudioSamples = numFrames * dctSize; // Total samples in time domain + 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 - const audioBuffer = audioContext.createBuffer(1, totalAudioSamples, sampleRate); + } catch (error) { + console.error("Error loading SPEC file:", error); + alert("Failed to load SPEC file. Check console for details."); + } +} - const audioData = audioBuffer.getChannelData(0); // Mono channel +// --- 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}%)`; +} - const windowArray = hanningWindow(dctSize); // Generate Hanning window for each frame +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; + } - // Convert spectrogram frames (frequency domain) to audio samples (time domain) + const numFrames = specData.header.num_frames; + const frameWidth = width / numFrames; // Width of each time frame + // Draw each frame's spectral data with log frequency scale for (let frameIndex = 0; frameIndex < numFrames; frameIndex++) { + const frameDataStart = frameIndex * dctSize; + const xPos = frameIndex * frameWidth; - const spectralFrame = specData.data.slice(frameIndex * dctSize, (frameIndex + 1) * dctSize); - - const timeDomainFrame = javascript_idct_512(spectralFrame); - - - - // Apply Hanning window for smooth transitions - - for (let i = 0; i < dctSize; i++) { - - const globalIndex = frameIndex * dctSize + i; - - if (globalIndex < totalAudioSamples) { - - audioData[globalIndex] += timeDomainFrame[i] * windowArray[i]; - - } + // To draw with log scale, we iterate over canvas y-coordinates + // and map them back to frequency bins + for (let y = 0; y < height; y++) { + const binIndex = canvasYToBinIndexLog(y, specData); + if (binIndex < 0 || binIndex >= dctSize) continue; // Out of bounds + const value = specData.data[frameDataStart + binIndex]; + const intensity = Math.min(1, Math.abs(value) / 1.0); // Assuming values are normalized to [-1, 1] + + ctx.fillStyle = getColorForIntensity(intensity); + ctx.fillRect(xPos, height - y - 1, frameWidth, 1); // Draw a 1-pixel height line for each y } - } + // 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; + } +} - currentAudioSource = audioContext.createBufferSource(); - - currentAudioSource.buffer = audioBuffer; - - currentAudioSource.connect(audioContext.destination); - - currentAudioSource.start(); - - - - console.log(`Playing audio (Sample Rate: ${sampleRate}, Duration: ${audioBuffer.duration.toFixed(2)}s)`); - +// --- 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 -// --- Playback Button Event Listeners --- + ctx.strokeStyle = 'rgba(0, 0, 0, 0.5)'; // Preview color + ctx.lineWidth = 1; + ctx.setLineDash([5, 5]); // Dashed line for preview -listenOriginalButton.addEventListener('click', () => { + 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 +} - if (originalSpecData) { +function handleMouseUp(event) { + if (!isDrawing || !activeTool || !currentSpecData) return; + isDrawing = false; + const endPos = getMousePos(event); - playSpectrogramData(originalSpecData); + 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; - else { + const centerCoords = canvasToSpectrogramCoords(cx, cy, currentSpecData); + // Map canvas radii to frequency/time spans. Log scale aware. + const radiusXFrames = Math.floor((rx / canvas.width) * currentSpecData.header.num_frames); + const radiusYFreq = canvasDeltaYToFreqDeltaLog(ry, canvas.height); // Delta in Hz + const centerFreq = canvasYToFreqLog(cy, canvas.height); // Center in Hz + const binCRadius = freqToBinIndexLog(centerFreq * Math.pow(2, (radiusY / canvas.height) * Math.log2(MAX_FREQ / MIN_FREQ))); + const binCMin = freqToBinIndexLog(centerFreq / Math.pow(2, (radiusY / canvas.height) * Math.log2(MAX_FREQ / MIN_FREQ))); - alert("No original SPEC data loaded."); + newShape = { + type: 'ellipse', + cx: cx, cy: cy, + rx: rx, ry: ry, + frameC: centerCoords.frame, binC: centerCoords.bin, + radiusFrames: radiusXFrames, + minBin: binCMin, maxBin: binCRadius, + 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); // Copy of actual data + const previousShapesSnapshot = shapes.map(s => ({ ...s })); // Deep copy shapes array + applyShapeToSpectrogram(newShape, currentSpecData); // Modify currentSpecData directly + shapes.push(newShape); + addAction({ + type: 'add_shape', + shape: newShape, + undo: () => { + // To undo, restore previous shapes and previous data + shapes = previousShapesSnapshot; + currentSpecData.data = previousDataSnapshot; + }, + redo: () => { + // To redo, add the shape back and apply it to current data + shapes.push(newShape); + applyShapeToSpectrogram(newShape, currentSpecData); + } + }); + } + redrawCanvas(); // Final redraw after action + updateUndoRedoButtons(); +} -listenGeneratedButton.addEventListener('click', () => { +// --- Spectrogram Data Manipulation --- +function applyShapeToSpectrogram(shape, targetSpecData) { + if (!targetSpecData || !targetSpecData.data || targetSpecData.header.num_frames === 0) return; - if (currentSpecData) { + const numFrames = targetSpecData.header.num_frames; - // Ensure currentSpecData reflects all shapes before playing + 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; - redrawCanvas(); // This updates currentSpecData based on shapes + 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; - playSpectrogramData(currentSpecData); + let currentX = x0; + let currentY = y0; - } + while (true) { + if (currentX >= 0 && currentX < numFrames && currentY >= 0 && currentY < dctSize) { + // 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])); + } + } + } - else { + 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 (log frequency aware) + const centerFrame = shape.frameC; + const centerBin = shape.binC; + const radiusFrames = shape.radiusFrames; + const minBin = shape.minBin; + const maxBin = shape.maxBin; - alert("No generated SPEC data to play."); + for (let f = centerFrame - radiusFrames; f <= centerFrame + radiusFrames; f++) { + if (f < 0 || f >= numFrames) continue; + for (let b = minBin; b <= maxBin; b++) { + if (b < 0 || b >= dctSize) continue; - } + // Check if (f, b) is within the ellipse + const normX = (f - centerFrame) / radiusFrames; + // Calculate relative frequency based on log scale + const currentFreq = binIndexToFreqLog(b); + const centerFreq = binIndexToFreqLog(centerBin); + const minFreq = binIndexToFreqLog(minBin); + const maxFreq = binIndexToFreqLog(maxBin); -}); + const logNormY = (Math.log(currentFreq) - Math.log(centerFreq)) / (Math.log(maxFreq) - Math.log(minFreq)); + if (normX * normX + logNormY * logNormY <= 1) { + targetSpecData.data[f * dctSize + b] += shape.amplitude; + targetSpecData.data[f * dctSize + b] = Math.max(-1, Math.min(1, targetSpecData.data[f * dctSize + b])); + } + } + } + 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; + } +} -// --- Utility for sizeof(float) in JS context --- +// --- 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'); +const listenOriginalButton = document.getElementById('listenOriginalButton'); +const listenGeneratedButton = document.getElementById('listenGeneratedButton'); -// This is a workaround since typeof(float) is not directly available. +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'); }); -// Float32Array.BYTES_PER_ELEMENT is used in handleFileSelect. +// --- Undo/Redo Logic --- +function addAction(action) { + undoStack.push(action); + if (undoStack.length > MAX_HISTORY_SIZE) { + undoStack.shift(); + } + redoStack = []; + updateUndoRedoButtons(); +} -function sizeof(type) { +function handleUndo() { + if (undoStack.length === 0) { + console.log('Undo stack is empty.'); + return; + } + const actionToUndo = undoStack.pop(); + actionToUndo.undo(); + redoStack.push(actionToUndo); + redrawCanvas(); + updateUndoRedoButtons(); +} - if (type === 'float') { +function handleRedo() { + if (redoStack.length === 0) { + console.log('Redo stack is empty.'); + return; + } - return Float32Array.BYTES_PER_ELEMENT; + const actionToRedo = redoStack.pop(); + actionToRedo.redo(); + 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; } - return 0; + // 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; } + -- cgit v1.2.3