summaryrefslogtreecommitdiff
path: root/tools/spectral_editor/script.js
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-15 09:16:06 +0100
committerskal <pascal.massimino@gmail.com>2026-02-15 09:16:06 +0100
commite94ff513689c6477e9c756f58370e4f3490dd675 (patch)
treec0b4deb886efe33e1bd242e0dbd639527656e86f /tools/spectral_editor/script.js
parentc98286860885d1f025cd8cf9da699f174118ccba (diff)
feat(spectral-editor): add waveform intensity viewer for sample offset
Add interactive waveform timeline for determining SAMPLE OFFSET values: Features: - RMS envelope visualization (10ms windows, normalized) - Uses synthesized PCM from spectrogram (not original WAV) - Draggable offset marker with numeric input (0.001s precision) - Snap-to-onset: auto-detects first transient (threshold 0.01) - Copy button: generates `SAMPLE <name> OFFSET <seconds>` command - Top panel (120px) with controls, z-indexed above spectrogram Design rationale: - Offset measured on procedural output (matches runtime behavior) - Interactive workflow: load .spec → inspect → set offset → copy - Supports tracker compile-time SAMPLE OFFSET feature Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/spectral_editor/script.js')
-rw-r--r--tools/spectral_editor/script.js247
1 files changed, 247 insertions, 0 deletions
diff --git a/tools/spectral_editor/script.js b/tools/spectral_editor/script.js
index 70e55ec..4fff23c 100644
--- a/tools/spectral_editor/script.js
+++ b/tools/spectral_editor/script.js
@@ -18,6 +18,8 @@ const state = {
referenceSpectrogram: null, // Float32Array or null
referenceDctSize: DCT_SIZE,
referenceNumFrames: 0,
+ originalAudioData: null, // Float32Array of raw audio samples
+ audioSampleRate: SAMPLE_RATE,
// Procedural curves
curves: [], // Array of {id, controlPoints: [{frame, freqHz, amplitude}], profile: {type, param1, param2}}
@@ -52,6 +54,10 @@ const state = {
mouseFrame: 0,
mouseFreq: 0,
+ // Sample offset
+ sampleOffset: 0.0, // Offset in seconds
+ isDraggingOffset: false,
+
// Undo/Redo
history: [],
historyIndex: -1,
@@ -64,6 +70,7 @@ const state = {
document.addEventListener('DOMContentLoaded', () => {
initCanvas();
+ initWaveformCanvas();
initUI();
initKeyboardShortcuts();
initAudioContext();
@@ -101,6 +108,26 @@ function initCanvas() {
canvas.addEventListener('wheel', onCanvasWheel, { passive: false });
}
+function initWaveformCanvas() {
+ const canvas = document.getElementById('waveformCanvas');
+ const container = canvas.parentElement;
+
+ const resizeCanvas = () => {
+ canvas.width = container.clientWidth;
+ canvas.height = 100; // Fixed height for waveform display
+ renderWaveform();
+ };
+
+ window.addEventListener('resize', resizeCanvas);
+ resizeCanvas();
+
+ // Mouse handlers for offset marker dragging
+ canvas.addEventListener('mousedown', onWaveformMouseDown);
+ canvas.addEventListener('mousemove', onWaveformMouseMove);
+ canvas.addEventListener('mouseup', onWaveformMouseUp);
+ canvas.addEventListener('mouseleave', onWaveformMouseUp);
+}
+
function initUI() {
// File loading
document.getElementById('loadWavBtn').addEventListener('click', () => {
@@ -132,6 +159,11 @@ function initUI() {
document.getElementById('playOriginalBtn').addEventListener('click', () => playAudio('original'));
document.getElementById('stopBtn').addEventListener('click', stopAudio);
+ // Offset controls
+ document.getElementById('offsetValue').addEventListener('input', onOffsetChanged);
+ document.getElementById('copyOffsetBtn').addEventListener('click', copyOffsetCommand);
+ document.getElementById('snapToOnsetBtn').addEventListener('click', snapToOnset);
+
// Action buttons
document.getElementById('undoBtn').addEventListener('click', undo);
document.getElementById('redoBtn').addEventListener('click', redo);
@@ -417,10 +449,21 @@ function onReferenceLoaded(fileName) {
state.viewportOffsetX = 0; // Reset pan
state.viewportOffsetY = 0;
+ // Reset offset
+ state.sampleOffset = 0.0;
+ updateOffsetUI();
+
+ // Update offset input max based on spectrogram duration
+ const hopSize = state.referenceDctSize / 2;
+ const audioLength = state.referenceNumFrames * hopSize + state.referenceDctSize;
+ const duration = audioLength / SAMPLE_RATE;
+ document.getElementById('offsetValue').max = duration.toFixed(3);
+
updateCurveUI();
updateUndoRedoButtons();
render();
drawSpectrumViewer(); // Show initial spectrum
+ renderWaveform(); // Render waveform intensity view
}
// ============================================================================
@@ -1710,3 +1753,207 @@ function showHelp() {
function hideHelp() {
document.getElementById('helpModal').style.display = 'none';
}
+
+// ============================================================================
+// Waveform Intensity Timeline
+// ============================================================================
+
+function renderWaveform() {
+ const canvas = document.getElementById('waveformCanvas');
+ if (!canvas) return;
+
+ const ctx = canvas.getContext('2d');
+ const width = canvas.width;
+ const height = canvas.height;
+
+ // Clear canvas
+ ctx.fillStyle = '#252526';
+ ctx.fillRect(0, 0, width, height);
+
+ // If no spectrogram loaded, show message
+ if (!state.referenceSpectrogram) {
+ ctx.fillStyle = '#858585';
+ ctx.font = '12px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.fillText('Load .spec file to view intensity envelope', width / 2, height / 2);
+ return;
+ }
+
+ // Synthesize audio from reference spectrogram
+ const audioData = spectrogramToAudio(
+ state.referenceSpectrogram,
+ state.referenceDctSize,
+ state.referenceNumFrames
+ );
+ const sampleRate = SAMPLE_RATE;
+ const duration = audioData.length / sampleRate;
+ const windowSize = Math.floor(sampleRate * 0.01); // 10ms windows
+ const numWindows = Math.ceil(audioData.length / windowSize);
+ const intensityEnvelope = new Float32Array(numWindows);
+
+ for (let i = 0; i < numWindows; ++i) {
+ const start = i * windowSize;
+ const end = Math.min(start + windowSize, audioData.length);
+ let sum = 0;
+ for (let j = start; j < end; ++j) {
+ sum += audioData[j] * audioData[j];
+ }
+ intensityEnvelope[i] = Math.sqrt(sum / (end - start));
+ }
+
+ // Find max for normalization
+ const maxIntensity = Math.max(...intensityEnvelope);
+
+ // Draw intensity curve
+ ctx.strokeStyle = '#4ec9b0';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+
+ for (let i = 0; i < numWindows; ++i) {
+ const x = (i / numWindows) * width;
+ const normalized = maxIntensity > 0 ? intensityEnvelope[i] / maxIntensity : 0;
+ const y = height - (normalized * (height - 20)) - 10;
+
+ if (i === 0) {
+ ctx.moveTo(x, y);
+ } else {
+ ctx.lineTo(x, y);
+ }
+ }
+ ctx.stroke();
+
+ // Draw offset marker
+ const offsetX = (state.sampleOffset / duration) * width;
+ ctx.strokeStyle = '#ff6b6b';
+ ctx.lineWidth = 2;
+ ctx.setLineDash([5, 5]);
+ ctx.beginPath();
+ ctx.moveTo(offsetX, 0);
+ ctx.lineTo(offsetX, height);
+ ctx.stroke();
+ ctx.setLineDash([]);
+
+ // Draw time labels
+ ctx.fillStyle = '#858585';
+ ctx.font = '10px monospace';
+ ctx.textAlign = 'left';
+ const numLabels = 5;
+ for (let i = 0; i <= numLabels; ++i) {
+ const t = (i / numLabels) * duration;
+ const x = (i / numLabels) * width;
+ ctx.fillText(t.toFixed(2) + 's', x + 2, height - 2);
+ }
+}
+
+function onWaveformMouseDown(e) {
+ const canvas = document.getElementById('waveformCanvas');
+ const rect = canvas.getBoundingClientRect();
+ const x = e.clientX - rect.left;
+ const width = canvas.width;
+
+ if (!state.referenceSpectrogram) return;
+
+ // Calculate duration from spectrogram
+ const hopSize = state.referenceDctSize / 2;
+ const audioLength = state.referenceNumFrames * hopSize + state.referenceDctSize;
+ const duration = audioLength / SAMPLE_RATE;
+ const offsetX = (state.sampleOffset / duration) * width;
+
+ // Check if clicking near offset marker (within 5 pixels)
+ if (Math.abs(x - offsetX) < 5) {
+ state.isDraggingOffset = true;
+ } else {
+ // Click anywhere to set offset
+ state.sampleOffset = (x / width) * duration;
+ updateOffsetUI();
+ renderWaveform();
+ }
+}
+
+function onWaveformMouseMove(e) {
+ if (!state.isDraggingOffset || !state.referenceSpectrogram) return;
+
+ const canvas = document.getElementById('waveformCanvas');
+ const rect = canvas.getBoundingClientRect();
+ const x = Math.max(0, Math.min(e.clientX - rect.left, canvas.width));
+
+ // Calculate duration from spectrogram
+ const hopSize = state.referenceDctSize / 2;
+ const audioLength = state.referenceNumFrames * hopSize + state.referenceDctSize;
+ const duration = audioLength / SAMPLE_RATE;
+
+ state.sampleOffset = (x / canvas.width) * duration;
+ updateOffsetUI();
+ renderWaveform();
+}
+
+function onWaveformMouseUp() {
+ state.isDraggingOffset = false;
+}
+
+function onOffsetChanged(e) {
+ const value = parseFloat(e.target.value);
+ if (!isNaN(value) && value >= 0) {
+ state.sampleOffset = value;
+ renderWaveform();
+ }
+}
+
+function updateOffsetUI() {
+ document.getElementById('offsetValue').value = state.sampleOffset.toFixed(4);
+}
+
+function copyOffsetCommand() {
+ if (!state.referenceSpectrogram) {
+ alert('No spectrogram loaded');
+ return;
+ }
+
+ // Extract filename from current file info (if available)
+ const fileInfo = document.getElementById('fileInfo').textContent;
+ const sampleName = fileInfo ? fileInfo.split('.')[0] : 'sample_name';
+
+ const command = `SAMPLE ${sampleName} OFFSET ${state.sampleOffset.toFixed(4)}`;
+
+ navigator.clipboard.writeText(command).then(() => {
+ // Visual feedback
+ const btn = document.getElementById('copyOffsetBtn');
+ const originalText = btn.textContent;
+ btn.textContent = '✓';
+ setTimeout(() => {
+ btn.textContent = originalText;
+ }, 1000);
+ }).catch(err => {
+ alert('Failed to copy to clipboard: ' + err);
+ });
+}
+
+function snapToOnset() {
+ if (!state.referenceSpectrogram) {
+ alert('No spectrogram loaded');
+ return;
+ }
+
+ // Synthesize audio from spectrogram
+ const audioData = spectrogramToAudio(
+ state.referenceSpectrogram,
+ state.referenceDctSize,
+ state.referenceNumFrames
+ );
+ const sampleRate = SAMPLE_RATE;
+
+ // Simple onset detection: find first sample above threshold
+ const threshold = 0.01; // Adjust as needed
+ let onsetSample = 0;
+
+ for (let i = 0; i < audioData.length; ++i) {
+ if (Math.abs(audioData[i]) > threshold) {
+ onsetSample = i;
+ break;
+ }
+ }
+
+ state.sampleOffset = onsetSample / sampleRate;
+ updateOffsetUI();
+ renderWaveform();
+}