diff options
| author | skal <pascal.massimino@gmail.com> | 2026-01-28 12:52:11 +0100 |
|---|---|---|
| committer | skal <pascal.massimino@gmail.com> | 2026-01-28 12:52:11 +0100 |
| commit | 4607beb85c2fbff9da940af0611b91b8b7ae0a41 (patch) | |
| tree | d099ee932af2a71147e760c1fe3241771f309534 | |
| parent | 488bee7b1424efbf78f60984f59baba889dbb15b (diff) | |
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.
| -rw-r--r-- | tools/editor/script.js | 561 |
1 files changed, 397 insertions, 164 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(); +// --- 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 - console.log(`Playing audio (Sample Rate: ${sampleRate}, Duration: ${audioBuffer.duration.toFixed(2)}s)`); + 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; -// --- Playback Button Event Listeners --- + 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; -listenOriginalButton.addEventListener('click', () => { + 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))); - if (originalSpecData) { + 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); - playSpectrogramData(originalSpecData); + 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; + } } - else { - - alert("No original SPEC data loaded."); + 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(); +} -}); +// --- Spectrogram Data Manipulation --- +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; -listenGeneratedButton.addEventListener('click', () => { + 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; - if (currentSpecData) { + let currentX = x0; + let currentY = y0; - // Ensure currentSpecData reflects all shapes before playing + 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])); + } + } + } - redrawCanvas(); // This updates currentSpecData based on shapes + 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; - playSpectrogramData(currentSpecData); + 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); - else { + const logNormY = (Math.log(currentFreq) - Math.log(centerFreq)) / (Math.log(maxFreq) - Math.log(minFreq)); - alert("No generated SPEC data to play."); + 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; } +} -}); - +// --- 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'); +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'); }); -// --- Utility for sizeof(float) in JS context --- +// --- Undo/Redo Logic --- +function addAction(action) { + undoStack.push(action); + if (undoStack.length > MAX_HISTORY_SIZE) { + undoStack.shift(); + } + redoStack = []; + updateUndoRedoButtons(); +} -// This is a workaround since typeof(float) is not directly available. +function handleUndo() { + if (undoStack.length === 0) { + console.log('Undo stack is empty.'); + return; + } + const actionToUndo = undoStack.pop(); + actionToUndo.undo(); + redoStack.push(actionToUndo); + redrawCanvas(); + updateUndoRedoButtons(); +} -// Float32Array.BYTES_PER_ELEMENT is used in handleFileSelect. +function handleRedo() { + if (redoStack.length === 0) { + console.log('Redo stack is empty.'); + return; + } -function sizeof(type) { + const actionToRedo = redoStack.pop(); + actionToRedo.redo(); + undoStack.push(actionToRedo); + redrawCanvas(); + updateUndoRedoButtons(); +} - if (type === 'float') { +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 Float32Array.BYTES_PER_ELEMENT; + // 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); + }); - return 0; + drawSpectrogram(currentSpecData); +} +function updateUndoRedoButtons() { + undoButton.disabled = undoStack.length === 0; + redoButton.disabled = redoStack.length === 0; } + |
