From 0c98c830b382d66c420524ff395e12164a566dd8 Mon Sep 17 00:00:00 2001 From: skal Date: Fri, 6 Feb 2026 22:53:29 +0100 Subject: feat(spectral_editor): Add cursor-centered zoom and pan with mouse wheel Implemented zoom and pan system for the spectral editor: Core Features: - Viewport offset system (viewportOffsetX, viewportOffsetY) for panning - Three wheel interaction modes: * Ctrl/Cmd + wheel: Cursor-centered zoom (both axes) * Shift + wheel: Horizontal pan * Normal wheel: Vertical pan - Zoom range: 0.5-20.0x horizontal, 0.1-5.0x vertical - Zoom factor: 0.9/1.1 per wheel notch (10% change) Technical Implementation: - Calculate data position under cursor before zoom - Apply zoom to pixelsPerFrame and pixelsPerBin - Adjust viewport offsets to keep cursor position stable - Clamp offsets to valid ranges (0 to max content size) - Updated all coordinate conversion functions (screenToSpectrogram, spectrogramToScreen) - Updated playhead rendering with visibility check - Reset viewport offsets on file load Algorithm (cursor-centered zoom): 1. Calculate frame and frequency under cursor: pos = (screen + offset) / scale 2. Apply zoom: scale *= zoomFactor 3. Adjust offset: offset = pos * scale - screen 4. Clamp offset to [0, maxOffset] This matches the zoom behavior of the timeline editor, adapted for 2D spectrogram display. handoff(Claude): Spectral editor zoom implementation complete --- tools/editor/dct.js | 168 ----------- tools/editor/index.html | 36 --- tools/editor/script.js | 650 ---------------------------------------- tools/editor/sdf.js | 39 --- tools/editor/style.css | 79 ----- tools/spectral_editor/script.js | 74 ++++- 6 files changed, 69 insertions(+), 977 deletions(-) delete mode 100644 tools/editor/dct.js delete mode 100644 tools/editor/index.html delete mode 100644 tools/editor/script.js delete mode 100644 tools/editor/sdf.js delete mode 100644 tools/editor/style.css (limited to 'tools') diff --git a/tools/editor/dct.js b/tools/editor/dct.js deleted file mode 100644 index c081473..0000000 --- a/tools/editor/dct.js +++ /dev/null @@ -1,168 +0,0 @@ -const dctSize = 512; // Default DCT size, read from header - -// --- Utility Functions for Audio Processing --- - -// Hanning window for smooth audio transitions (JavaScript equivalent) -function hanningWindow(size) { - const window = new Float32Array(size); - const PI = Math.PI; - for (let i = 0; i < size; i++) { - window[i] = 0.5 * (1 - Math.cos((2 * PI * i) / (size - 1))); - } - return window; -} - -const hanningWindowArray = hanningWindow(dctSize); // Pre-calculate window - -// ============================================================================ -// FFT-based DCT/IDCT Implementation -// ============================================================================ - -// Bit-reversal permutation (in-place) -function bitReversePermute(real, imag, N) { - let temp_bits = N; - let num_bits = 0; - while (temp_bits > 1) { - temp_bits >>= 1; - num_bits++; - } - - for (let i = 0; i < N; i++) { - let j = 0; - let temp = i; - for (let b = 0; b < num_bits; b++) { - j = (j << 1) | (temp & 1); - temp >>= 1; - } - - if (j > i) { - const tmp_real = real[i]; - const tmp_imag = imag[i]; - real[i] = real[j]; - imag[i] = imag[j]; - real[j] = tmp_real; - imag[j] = tmp_imag; - } - } -} - -// In-place radix-2 FFT -function fftRadix2(real, imag, N, direction) { - const PI = Math.PI; - - for (let stage_size = 2; stage_size <= N; stage_size *= 2) { - const half_stage = stage_size / 2; - const angle = direction * 2.0 * PI / stage_size; - - let wr = 1.0; - let wi = 0.0; - const wr_delta = Math.cos(angle); - const wi_delta = Math.sin(angle); - - for (let k = 0; k < half_stage; k++) { - for (let group_start = k; group_start < N; group_start += stage_size) { - const i = group_start; - const j = group_start + half_stage; - - const temp_real = real[j] * wr - imag[j] * wi; - const temp_imag = real[j] * wi + imag[j] * wr; - - real[j] = real[i] - temp_real; - imag[j] = imag[i] - temp_imag; - real[i] = real[i] + temp_real; - imag[i] = imag[i] + temp_imag; - } - - const wr_old = wr; - wr = wr_old * wr_delta - wi * wi_delta; - wi = wr_old * wi_delta + wi * wr_delta; - } - } -} - -function fftForward(real, imag, N) { - bitReversePermute(real, imag, N); - fftRadix2(real, imag, N, +1); -} - -function fftInverse(real, imag, N) { - bitReversePermute(real, imag, N); - fftRadix2(real, imag, N, -1); - - const scale = 1.0 / N; - for (let i = 0; i < N; i++) { - real[i] *= scale; - imag[i] *= scale; - } -} - -// DCT-II via FFT using reordering method -function javascript_dct_fft(input, N) { - const PI = Math.PI; - - const real = new Float32Array(N); - const imag = new Float32Array(N); - - for (let i = 0; i < N / 2; i++) { - real[i] = input[2 * i]; - real[N - 1 - i] = input[2 * i + 1]; - } - - fftForward(real, imag, N); - - const output = new Float32Array(N); - for (let k = 0; k < N; k++) { - const angle = -PI * k / (2.0 * N); - const wr = Math.cos(angle); - const wi = Math.sin(angle); - - const dct_value = real[k] * wr - imag[k] * wi; - - if (k === 0) { - output[k] = dct_value * Math.sqrt(1.0 / N); - } else { - output[k] = dct_value * Math.sqrt(2.0 / N); - } - } - - return output; -} - -// IDCT (DCT-III) via FFT using reordering method -function javascript_idct_fft(input, N) { - const PI = Math.PI; - - const real = new Float32Array(N); - const imag = new Float32Array(N); - - for (let k = 0; k < N; k++) { - const angle = PI * k / (2.0 * N); - const wr = Math.cos(angle); - const wi = Math.sin(angle); - - let scaled; - if (k === 0) { - scaled = input[k] / Math.sqrt(1.0 / N); - } else { - scaled = input[k] / Math.sqrt(2.0 / N) * 2.0; - } - - real[k] = scaled * wr; - imag[k] = scaled * wi; - } - - fftInverse(real, imag, N); - - const output = new Float32Array(N); - for (let i = 0; i < N / 2; i++) { - output[2 * i] = real[i]; - output[2 * i + 1] = real[N - 1 - i]; - } - - return output; -} - -// Fast O(N log N) IDCT using FFT -function javascript_idct_512(input) { - return javascript_idct_fft(input, dctSize); -} diff --git a/tools/editor/index.html b/tools/editor/index.html deleted file mode 100644 index 82a11ce..0000000 --- a/tools/editor/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - Spectrogram Editor - - - -

Spectrogram Editor

- - - - -
- -
-

Tools

- - - - - -
-

Playback

- - - -
-
- - - - - - diff --git a/tools/editor/script.js b/tools/editor/script.js deleted file mode 100644 index 06c9bef..0000000 --- a/tools/editor/script.js +++ /dev/null @@ -1,650 +0,0 @@ -// 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 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.) - -// Web Audio Context -const audioContext = new (window.AudioContext || window.webkitAudioContext)(); - -// Audio Constants (should match C++ side) -const SAMPLE_RATE = 32000; -const MAX_FREQ = SAMPLE_RATE / 2; // Nyquist frequency -const MIN_FREQ = 20; // Lower bound for log scale visualization - -const SDF_FALLOFF_FACTOR = 10.0; // Adjust this value to control the softness of SDF edges. - -// --- Button Element Declarations --- -const specFileInput = document.getElementById('specFileInput'); -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'); - -// --- Event Listeners --- -specFileInput.addEventListener('change', handleFileSelect); -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); -listenOriginalButton.addEventListener('click', () => { - if (originalSpecData) { - playSpectrogramData(originalSpecData); - } else { - alert("No original SPEC data loaded."); - } -}); -listenGeneratedButton.addEventListener('click', () => { - if (currentSpecData) { - redrawCanvas(); // Ensure currentSpecData reflects all shapes before playing - playSpectrogramData(currentSpecData); - } else { - alert("No generated SPEC data to play."); - } -}); - - -// --- 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 / 2)) * MAX_FREQ; -} - -// Maps a frequency in Hz to its corresponding linear bin index -function freqToBinIndex(freq) { - return Math.floor((freq / MAX_FREQ) * (dctSize / 2)); -} - -// 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(); - -// --- File Handling Functions --- -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; - } - - if (dctSize != header.dct_size) { - alert("Invalid dctSize in SPEC file"); - return; - } - 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 log_intensity = Math.log(1. + intensity) / Math.log(2.); - const h = (1 - log_intensity) * 240; // Hue from blue (240) to red (0), inverse for intensity - const s = 60.; // Saturation - const l = log_intensity * 60 + 30; // Lightness from 30 to 90 - 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 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; - - // 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; - } -} - -// --- 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 - - // debug the mouse position by draw a white square - ctx.fillStyle = '#fff'; - ctx.fillRect(pos.x - 10, pos.y - 10, 20, 20); -} - -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', - // Canvas coordinates for drawing visual representation (unchanged) - x1: startX, y1: startY, - x2: endPos.x, y2: endPos.y, - // World coordinates for SDF calculations (frame and log-scaled frequency) - frame1_world: startCoords.frame, - freq1_world: binIndexToFreqLog(startCoords.bin), - frame2_world: endCoords.frame, - freq2_world: binIndexToFreqLog(endCoords.bin), - amplitude: 0.5, // Default amplitude - width: 2, // Visual width in canvas pixels, not directly used by SDF, but kept for drawing - color: 'red', - falloff: SDF_FALLOFF_FACTOR, // SDF falloff factor - }; - break; - } - case 'ellipse': { - const rx = Math.abs(endPos.x - startX) / 2; - const ry = Math.abs(endPos.y - startY) / 2; - const cx = startX + (pos.x - startX) / 2; - const cy = startY + (pos.y - startY) / 2; - - const centerCoords = canvasToSpectrogramCoords(cx, cy, currentSpecData); - const halfWidthFrames = Math.floor((rx / canvas.width) * currentSpecData.header.num_frames); - - const startFreq = canvasYToFreqLog(startY, canvas.height); - const endFreq = canvasYToFreqLog(endPos.y, canvas.height); - const centerFreq = (startFreq + endFreq) / 2; - const halfHeightFreq = Math.abs(startFreq - endFreq) / 2; - - - newShape = { - type: 'ellipse', - // Canvas coordinates for drawing visual representation (unchanged) - cx: cx, cy: cy, - rx: rx, ry: ry, - // World coordinates for SDF calculations - center_frame_world: centerCoords.frame, - center_freq_world: centerFreq, - radius_frames_world: halfWidthFrames, - radius_freq_world: halfHeightFreq, - amplitude: 0.5, - color: 'green', - falloff: SDF_FALLOFF_FACTOR, - }; - 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); - - const centerFrame = Math.floor((startCoords.frame + endCoords.frame) / 2); - const centerFreq = (binIndexToFreqLog(startCoords.bin) + binIndexToFreqLog(endCoords.bin)) / 2; - const halfExtentFrames = Math.floor(Math.abs(endCoords.frame - startCoords.frame) / 2); - const halfExtentFreq = Math.abs(binIndexToFreqLog(endCoords.bin) - binIndexToFreqLog(startCoords.bin)) / 2; - - newShape = { - type: 'noise_rect', - // Canvas coordinates for drawing visual representation (unchanged) - x: rectX, y: rectY, - width: rectW, height: rectH, - // World coordinates for SDF calculations - center_frame_world: centerFrame, - center_freq_world: centerFreq, - half_extent_frames_world: halfExtentFrames, - half_extent_freq_world: halfExtentFreq, - amplitude: 0.3, // Default noise amplitude - density: 0.5, // Default noise density - color: 'blue', - falloff: 0.0, // No falloff for pure noise inside rect - }; - 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(); -} - -// --- Spectrogram Data Manipulation --- -function applyShapeToSpectrogram(shape, targetSpecData) { - if (!targetSpecData || !targetSpecData.data || targetSpecData.header.num_frames === 0) return; - - const numFrames = targetSpecData.header.num_frames; - - // Determine a bounding box for optimization (iterate only relevant cells) - let minFrame = 0, maxFrame = numFrames - 1; - let minBin = 0, maxBin = dctSize - 1; - - // Calculate tighter bounding boxes for each shape type - switch (shape.type) { - case 'line': - minFrame = Math.min(shape.frame1_world, shape.frame2_world) - Math.ceil(shape.width / 2); - maxFrame = Math.max(shape.frame1_world, shape.frame2_world) + Math.ceil(shape.width / 2); - // For frequency, approximate by visual width or a fixed range if needed - minBin = freqToBinIndex(Math.min(shape.freq1_world, shape.freq2_world)) - Math.ceil(shape.width / 2); - maxBin = freqToBinIndex(Math.max(shape.freq1_world, shape.freq2_world)) + Math.ceil(shape.width / 2); - break; - case 'ellipse': - minFrame = shape.center_frame_world - shape.radius_frames_world - 1; - maxFrame = shape.center_frame_world + shape.radius_frames_world + 1; - minBin = freqToBinIndex(shape.center_freq_world - shape.radius_freq_world) - 1; // Approx bin range from world freq - maxBin = freqToBinIndex(shape.center_freq_world + shape.radius_freq_world) + 1; // Approx bin range from world freq - break; - case 'noise_rect': - minFrame = shape.center_frame_world - shape.half_extent_frames_world - 1; - maxFrame = shape.center_frame_world + shape.half_extent_frames_world + 1; - minBin = freqToBinIndex(shape.center_freq_world - shape.half_extent_freq_world) - 1; // Approx bin range from world freq - maxBin = freqToBinIndex(shape.center_freq_world + shape.half_extent_freq_world) + 1; // Approx bin range from world freq - break; - } - - minFrame = Math.max(0, minFrame); - maxFrame = Math.min(targetSpecData.header.num_frames - 1, maxFrame); // Use targetSpecData.header.num_frames - minBin = Math.max(0, minBin); - maxBin = Math.min(dctSize - 1, maxBin); - - for (let f = minFrame; f <= maxFrame; f++) { - for (let b = minBin; b <= maxBin; b++) { - const p_world = vec2(f, binIndexToFreqLog(b)); - let distance = Infinity; - - switch (shape.type) { - case 'line': - const a_line = vec2(shape.frame1_world, shape.freq1_world); - const b_line = vec2(shape.frame2_world, shape.freq2_world); - distance = sdSegment(p_world, a_line, b_line); - break; - case 'ellipse': - const center_ellipse = vec2(shape.center_frame_world, shape.center_freq_world); - const r_ellipse = vec2(shape.radius_frames_world, shape.radius_freq_world); - distance = sdEllipse(sub(p_world, center_ellipse), r_ellipse); - break; - case 'noise_rect': - const center_box = vec2(shape.center_frame_world, shape.center_freq_world); - const r_box = vec2(shape.half_extent_frames_world, shape.half_extent_freq_world); - distance = sdBox(sub(p_world, center_box), r_box); - if (distance <= 0 && Math.random() < shape.density) { // Only add noise inside the box with density - targetSpecData.data[f * dctSize + b] += (Math.random() * 2 - 1) * shape.amplitude; - } - break; - } - - if (shape.type !== 'noise_rect') { // Noise is handled differently for amplitude - const attenuation = Math.exp(-distance * distance * shape.falloff); - targetSpecData.data[f * dctSize + b] += shape.amplitude * attenuation; - } - - // Clamp final value - targetSpecData.data[f * dctSize + b] = Math.max(-1, Math.min(1, targetSpecData.data[f * dctSize + b])); - } - } -} - -// --- 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(); - redoStack.push(actionToUndo); - redrawCanvas(); - updateUndoRedoButtons(); -} - -function handleRedo() { - if (redoStack.length === 0) { - console.log('Redo stack is empty.'); - return; - } - - 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; - } - - // 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; -} - -// 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 Functions --- -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; - } - - if (currentAudioSource) { - currentAudioSource.stop(); - currentAudioSource.disconnect(); - currentAudioSource = null; - } - - const sampleRate = SAMPLE_RATE; // Fixed sample rate - const numFrames = specData.header.num_frames; - const totalAudioSamples = numFrames * dctSize; // Total samples in time domain - - const audioBuffer = audioContext.createBuffer(1, totalAudioSamples, sampleRate); - const audioData = audioBuffer.getChannelData(0); // Mono channel - - // Convert spectrogram frames (frequency domain) to audio samples (time domain) - for (let frameIndex = 0; frameIndex < numFrames; frameIndex++) { - const spectralFrame = specData.data.slice(frameIndex * dctSize, (frameIndex + 1) * dctSize); - - // IDCT (no windowing - window is only for analysis, not synthesis) - const timeDomainFrame = javascript_idct_512(spectralFrame); - - // Apply Hanning window for smooth transitions between frames - for (let i = 0; i < dctSize; i++) { - audioData[frameIndex * dctSize + i] = timeDomainFrame[i] * hanningWindowArray[i]; - } - } - - 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)`); -} diff --git a/tools/editor/sdf.js b/tools/editor/sdf.js deleted file mode 100644 index c68d79a..0000000 --- a/tools/editor/sdf.js +++ /dev/null @@ -1,39 +0,0 @@ -// --- Signed Distance Functions (SDFs) --- -// Generic 2D vector operations -function vec2(x, y) { return { x: x, y: y }; } -function length(v) { return Math.sqrt(v.x * v.x + v.y * v.y); } -function dot(v1, v2) { return v1.x * v2.x + v1.y * v2.y; } -function sub(v1, v2) { return vec2(v1.x - v2.x, v1.y - v2.y); } -function mul(v, s) { return vec2(v.x * s, v.y * s); } -function div(v, s) { return vec2(v.x / s, v.y / s); } -function normalize(v) { return div(v, length(v)); } -function clamp(x, minVal, maxVal) { return Math.max(minVal, Math.min(x, maxVal)); } -function abs(v) { return vec2(Math.abs(v.x), Math.abs(v.y)); } -function max(v1, v2) { return vec2(Math.max(v1.x, v2.x), Math.max(v1.y, v2.y)); } -function sign(x) { return (x > 0) ? 1 : ((x < 0) ? -1 : 0); } - -// sdSegment(p, a, b) - signed distance to a line segment -// p: point, a: segment start, b: segment end -function sdSegment(p, a, b) { - const pa = sub(p, a); - const ba = sub(b, a); - const h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); - return length(sub(pa, mul(ba, h))); -} - -// sdEllipse(p, r) - signed distance to an ellipse (p relative to center, r is half-extents) -// p: point relative to ellipse center, r: half-extents (rx, ry) -function sdEllipse(p, r) { - const k0 = vec2(1, length(div(p, r))); - const k1 = vec2(length(div(p, r)), 1); - const f = ((dot(div(mul(p, p), k0), vec2(1, 1)) < dot(div(mul(p, p), k1), vec2(1, 1))) ? k0 : k1); - return length(sub(p, mul(r, normalize(mul(f, p))))) * sign(length(p) - r.x); // Simplified, original has length(p)-r.x which is only for circular -} - -// sdBox(p, r) - signed distance to a rectangle (p relative to center, r is half-extents) -// p: point relative to box center, r: half-extents (hx, hy) -function sdBox(p, r) { - const q = sub(abs(p), r); - return length(max(q, vec2(0, 0))) + Math.min(0.0, Math.max(q.x, q.y)); -} - diff --git a/tools/editor/style.css b/tools/editor/style.css deleted file mode 100644 index e0014cf..0000000 --- a/tools/editor/style.css +++ /dev/null @@ -1,79 +0,0 @@ -body { - font-family: sans-serif; - margin: 20px; - background-color: #f4f4f4; -} - -h1, h2 { - color: #333; -} - -#editorContainer { - display: flex; - margin-top: 20px; -} - -#spectrogramCanvas { - border: 1px solid #ccc; - background-color: #fff; - margin-right: 20px; -} - -#controls { - border: 1px solid #ccc; - padding: 15px; - background-color: #eee; - min-width: 200px; -} - -#controls button { - display: block; - width: 100%; - margin-bottom: 10px; - 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; -} - -/* New styles for playback buttons */ -#listenOriginalButton, -#listenGeneratedButton { - background-color: #5bc0de; - color: white; - border: none; - border-radius: 4px; - margin-top: 5px; -} - -#listenOriginalButton:hover, -#listenGeneratedButton:hover { - background-color: #31b0d5; -} - -hr { - border: 0; - height: 1px; - background-color: #ccc; - margin: 20px 0; -} diff --git a/tools/spectral_editor/script.js b/tools/spectral_editor/script.js index 6c6dd49..024005e 100644 --- a/tools/spectral_editor/script.js +++ b/tools/spectral_editor/script.js @@ -30,6 +30,8 @@ const state = { canvasHeight: 0, pixelsPerFrame: 2.0, // Zoom level (pixels per frame) pixelsPerBin: 1.0, // Vertical scale (pixels per frequency bin) + viewportOffsetX: 0, // Horizontal pan offset (pixels) + viewportOffsetY: 0, // Vertical pan offset (pixels) // Audio playback audioContext: null, @@ -94,6 +96,9 @@ function initCanvas() { // Mouse hover handlers (for crosshair) canvas.addEventListener('mousemove', onCanvasHover); canvas.addEventListener('mouseleave', onCanvasLeave); + + // Mouse wheel: zoom (with Ctrl/Cmd) or pan + canvas.addEventListener('wheel', onCanvasWheel, { passive: false }); } function initUI() { @@ -404,6 +409,9 @@ function onReferenceLoaded(fileName) { // Adjust zoom to fit state.pixelsPerFrame = Math.max(1.0, state.canvasWidth / state.referenceNumFrames); + state.pixelsPerBin = 1.0; // Reset vertical scale + state.viewportOffsetX = 0; // Reset pan + state.viewportOffsetY = 0; updateCurveUI(); updateUndoRedoButtons(); @@ -849,12 +857,65 @@ function onCanvasLeave(e) { render(); } +function onCanvasWheel(e) { + e.preventDefault(); + + const canvas = e.target; + const rect = canvas.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + + // Zoom mode: Ctrl/Cmd + wheel + if (e.ctrlKey || e.metaKey) { + // Calculate frame and frequency under cursor BEFORE zoom + const frameUnderCursor = (mouseX + state.viewportOffsetX) / state.pixelsPerFrame; + const freqUnderCursor = (mouseY + state.viewportOffsetY) / state.pixelsPerBin; + + // Calculate new zoom level + const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; // Wheel down = zoom out, wheel up = zoom in + const oldPixelsPerFrame = state.pixelsPerFrame; + const oldPixelsPerBin = state.pixelsPerBin; + + state.pixelsPerFrame = Math.max(0.5, Math.min(20.0, state.pixelsPerFrame * zoomFactor)); + state.pixelsPerBin = Math.max(0.1, Math.min(5.0, state.pixelsPerBin * zoomFactor)); + + // Adjust viewport offset so frame/freq under cursor stays in same screen position + // After zoom: new_offset = frame * newPixelsPerFrame - mouseX + state.viewportOffsetX = frameUnderCursor * state.pixelsPerFrame - mouseX; + state.viewportOffsetY = freqUnderCursor * state.pixelsPerBin - mouseY; + + // Clamp viewport offset to valid range + const maxOffsetX = Math.max(0, state.referenceNumFrames * state.pixelsPerFrame - state.canvasWidth); + const maxOffsetY = Math.max(0, DCT_SIZE * state.pixelsPerBin - state.canvasHeight); + state.viewportOffsetX = Math.max(0, Math.min(maxOffsetX, state.viewportOffsetX)); + state.viewportOffsetY = Math.max(0, Math.min(maxOffsetY, state.viewportOffsetY)); + + render(); + return; + } + + // Pan mode: Shift + wheel (horizontal/vertical pan) + if (e.shiftKey) { + state.viewportOffsetX += e.deltaY; + const maxOffsetX = Math.max(0, state.referenceNumFrames * state.pixelsPerFrame - state.canvasWidth); + state.viewportOffsetX = Math.max(0, Math.min(maxOffsetX, state.viewportOffsetX)); + render(); + return; + } + + // Normal mode: pan vertically + state.viewportOffsetY += e.deltaY; + const maxOffsetY = Math.max(0, DCT_SIZE * state.pixelsPerBin - state.canvasHeight); + state.viewportOffsetY = Math.max(0, Math.min(maxOffsetY, state.viewportOffsetY)); + render(); +} + // ============================================================================ // Coordinate Conversion // ============================================================================ function screenToSpectrogram(screenX, screenY) { - const frame = Math.round(screenX / state.pixelsPerFrame); + const frame = Math.round((screenX + state.viewportOffsetX) / state.pixelsPerFrame); let freqHz; if (USE_LOG_SCALE) { @@ -881,7 +942,7 @@ function screenToSpectrogram(screenX, screenY) { } function spectrogramToScreen(frame, freqHz) { - const x = frame * state.pixelsPerFrame; + const x = frame * state.pixelsPerFrame - state.viewportOffsetX; let y; if (USE_LOG_SCALE) { @@ -891,11 +952,11 @@ function spectrogramToScreen(frame, freqHz) { const clampedFreq = Math.max(FREQ_MIN, Math.min(FREQ_MAX, freqHz)); const logFreq = Math.log10(clampedFreq); const normalizedY = (logFreq - logMin) / (logMax - logMin); - y = state.canvasHeight * (1.0 - normalizedY); // Flip Y back to screen coords + y = state.canvasHeight * (1.0 - normalizedY) - state.viewportOffsetY; // Flip Y back to screen coords } else { // Linear frequency mapping (old behavior) const bin = (freqHz / (SAMPLE_RATE / 2)) * state.referenceDctSize; - y = state.canvasHeight - (bin * state.pixelsPerBin); + y = state.canvasHeight - (bin * state.pixelsPerBin) - state.viewportOffsetY; } return {x, y}; @@ -943,7 +1004,10 @@ function render() { function drawPlayhead(ctx) { if (!state.isPlaying || state.playbackCurrentFrame < 0) return; - const x = state.playbackCurrentFrame * state.pixelsPerFrame; + const x = state.playbackCurrentFrame * state.pixelsPerFrame - state.viewportOffsetX; + + // Only draw if playhead is visible in viewport + if (x < 0 || x > state.canvasWidth) return; // Draw vertical line ctx.strokeStyle = '#ff3333'; // Bright red -- cgit v1.2.3