diff options
Diffstat (limited to 'tools/editor')
| -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; } + |
