From 105c817021a84bfacffa1553d6bcd536808b9f23 Mon Sep 17 00:00:00 2001 From: skal Date: Wed, 18 Feb 2026 06:59:32 +0100 Subject: feat(mq_editor): UI improvements and partial detection enhancements - Right panel with synthesis checkboxes (integrate phase, disable jitter, disable spread) - Style file chooser as button; show filename next to page title - Second backward pass in extractPartials to recover partial onsets - Cursor line drawn on overlay canvas (no full redraw on mousemove) handoff(Claude): UI + algo improvements complete --- tools/mq_editor/index.html | 100 +++++++++++++++++++++++++++++++++--------- tools/mq_editor/mq_extract.js | 38 ++++++++++++++++ tools/mq_editor/mq_synth.js | 9 ++-- tools/mq_editor/viewer.js | 23 +++++++++- 4 files changed, 146 insertions(+), 24 deletions(-) (limited to 'tools/mq_editor') diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index b51a988..345d2b9 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -10,6 +10,18 @@ background: #1a1a1a; color: #ddd; } + .page-title { + display: flex; + align-items: baseline; + gap: 16px; + margin-bottom: 10px; + } + .page-title h2 { margin: 0; } + #fileLabel { + font-size: 13px; + color: #8af; + opacity: 0.8; + } .toolbar { margin-bottom: 10px; padding: 10px; @@ -27,7 +39,7 @@ } button:hover { background: #4a4a4a; } button:disabled { opacity: 0.5; cursor: not-allowed; } - input[type="file"] { margin-right: 16px; } + input[type="file"] { display: none; } .params { display: inline-block; margin-left: 20px; @@ -43,12 +55,45 @@ padding: 4px; border-radius: 3px; } + .main-area { + display: flex; + align-items: flex-start; + gap: 10px; + margin-top: 10px; + } #canvas { border: 1px solid #555; background: #000; cursor: crosshair; display: block; - margin-top: 10px; + flex-shrink: 0; + } + .right-panel { + background: #2a2a2a; + border: 1px solid #555; + border-radius: 4px; + padding: 12px; + min-width: 160px; + display: flex; + flex-direction: column; + gap: 10px; + } + .right-panel .panel-title { + font-size: 11px; + color: #888; + text-transform: uppercase; + letter-spacing: 1px; + border-bottom: 1px solid #444; + padding-bottom: 6px; + margin-bottom: 2px; + } + .right-panel label { + display: flex; + align-items: center; + gap: 6px; + margin: 0; + cursor: pointer; + font-size: 13px; } #status { margin-top: 10px; @@ -57,22 +102,20 @@ border-radius: 4px; min-height: 20px; } - .info { - color: #4af; - } - .warn { - color: #fa4; - } - .error { - color: #f44; - } + .info { color: #4af; } + .warn { color: #fa4; } + .error { color: #f44; } -

MQ Spectral Editor

+
+

MQ Spectral Editor

+ +
+ @@ -85,20 +128,28 @@ - - 100%
-
- +
+
+ + - -
- + +
+ +
+
+ +
+
Synthesis
+ + +
@@ -119,11 +170,13 @@ let stftCache = null; const wavFile = document.getElementById('wavFile'); + const chooseFileBtn = document.getElementById('chooseFileBtn'); const extractBtn = document.getElementById('extractBtn'); const playBtn = document.getElementById('playBtn'); const stopBtn = document.getElementById('stopBtn'); const canvas = document.getElementById('canvas'); const status = document.getElementById('status'); + const fileLabel = document.getElementById('fileLabel'); const hopSize = document.getElementById('hopSize'); const threshold = document.getElementById('threshold'); @@ -162,11 +215,15 @@ }, 10); } + // File chooser button + chooseFileBtn.addEventListener('click', () => wavFile.click()); + // Load WAV file wavFile.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; + fileLabel.textContent = file.name; setStatus('Loading WAV...', 'info'); try { const arrayBuffer = await file.arrayBuffer(); @@ -194,6 +251,7 @@ + 0.5 * Math.sin(2 * Math.PI * 660 * i / SR); } + fileLabel.textContent = 'test-440+660hz.wav'; loadAudioBuffer(buf, 'Test WAV: 440Hz + 660Hz (2s, 32kHz)'); }); @@ -339,7 +397,9 @@ const partialsToUse = extractedPartials.slice(0, keepCount); setStatus(`Synthesizing ${keepCount}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); const integratePhase = document.getElementById('integratePhase').checked; - const pcm = synthesizeMQ(partialsToUse, sampleRate, duration, integratePhase); + const disableJitter = document.getElementById('disableJitter').checked; + const disableSpread = document.getElementById('disableSpread').checked; + const pcm = synthesizeMQ(partialsToUse, sampleRate, duration, integratePhase, {disableJitter, disableSpread}); // Build STFT cache for synth signal (for FFT comparison via key 'a') if (viewer) { diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js index 2293d52..8a0ea0e 100644 --- a/tools/mq_editor/mq_extract.js +++ b/tools/mq_editor/mq_extract.js @@ -16,6 +16,9 @@ function extractPartials(params, stftCache) { const partials = trackPartials(frames); + // Second pass: extend partials leftward to recover onset frames + expandPartialsLeft(partials, frames); + for (const partial of partials) { partial.freqCurve = fitBezier(partial.times, partial.freqs); partial.ampCurve = fitBezier(partial.times, partial.amps); @@ -144,6 +147,41 @@ function trackPartials(frames) { return partials; } +// Second pass: extend each partial leftward to recover onset frames missed +// by the birthPersistence requirement in the forward pass. +function expandPartialsLeft(partials, frames) { + const trackingRatio = 0.05; + const minTrackingHz = 20; + + // Build time → frame index map + const timeToIdx = new Map(); + for (let i = 0; i < frames.length; ++i) timeToIdx.set(frames[i].time, i); + + for (const partial of partials) { + let startIdx = timeToIdx.get(partial.times[0]); + if (startIdx == null || startIdx === 0) continue; + + for (let i = startIdx - 1; i >= 0; --i) { + const frame = frames[i]; + const refFreq = partial.freqs[0]; + const tol = Math.max(refFreq * trackingRatio, minTrackingHz); + + let bestIdx = -1, bestDist = Infinity; + for (let j = 0; j < frame.peaks.length; ++j) { + const dist = Math.abs(frame.peaks[j].freq - refFreq); + if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = j; } + } + + if (bestIdx < 0) break; + + const pk = frame.peaks[bestIdx]; + partial.times.unshift(frame.time); + partial.freqs.unshift(pk.freq); + partial.amps.unshift(pk.amp); + } + } +} + // Fit cubic bezier to trajectory using samples at ~1/3 and ~2/3 as control points function fitBezier(times, values) { const n = times.length - 1; diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js index 6fa2a09..1eec709 100644 --- a/tools/mq_editor/mq_synth.js +++ b/tools/mq_editor/mq_synth.js @@ -25,10 +25,13 @@ function randFloat(seed, min, max) { // replicas: {offsets, decay_alpha, jitter, spread_above, spread_below} // integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq) // false = 2π*f*t (simpler, only correct for constant freq) -function synthesizeMQ(partials, sampleRate, duration, integratePhase = true) { +function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, options = {}) { const numSamples = Math.floor(sampleRate * duration); const pcm = new Float32Array(numSamples); + const jitterMult = options.disableJitter ? 0 : 1; + const spreadMult = options.disableSpread ? 0 : 1; + const defaultReplicas = { offsets: [1.0], decay_alpha: 0.1, @@ -50,8 +53,8 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true) { const replicaData = []; for (let r = 0; r < offsets.length; ++r) { // Fixed per-replica spread (frequency detuning) and initial phase (jitter) - const spread = randFloat(p * 67890 + r * 999, -spread_below, spread_above); - const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * jitter * 2.0 * Math.PI; + const spread = spreadMult * randFloat(p * 67890 + r * 999, -spread_below, spread_above); + const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI; replicaData.push({ratio: offsets[r], spread, phase: initPhase}); } diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js index e19ec3a..ebf4fab 100644 --- a/tools/mq_editor/viewer.js +++ b/tools/mq_editor/viewer.js @@ -33,6 +33,10 @@ class SpectrogramViewer { // Partial keep count (Infinity = all kept) this.keepCount = Infinity; + // Mouse cursor overlay + this.cursorCanvas = document.getElementById('cursorCanvas'); + this.cursorCtx = this.cursorCanvas ? this.cursorCanvas.getContext('2d') : null; + // Playhead this.playheadTime = -1; // -1 = not playing @@ -122,6 +126,20 @@ class SpectrogramViewer { this.renderSpectrum(); } + drawMouseCursor(x) { + if (!this.cursorCtx) return; + const ctx = this.cursorCtx; + const h = this.cursorCanvas.height; + ctx.clearRect(0, 0, this.cursorCanvas.width, h); + if (x < 0) return; + ctx.strokeStyle = 'rgba(255, 60, 60, 0.7)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + drawPlayhead() { if (this.playheadTime < 0) return; if (this.playheadTime < this.t_view_min || this.playheadTime > this.t_view_max) return; @@ -380,12 +398,14 @@ class SpectrogramViewer { setupMouseHandlers() { const {canvas, tooltip} = this; - // Mouse move (tooltip) + // Mouse move (tooltip + cursor) canvas.addEventListener('mousemove', (e) => { const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; + this.drawMouseCursor(x); + const time = this.canvasToTime(x); const freq = this.canvasToFreq(y); const intensity = this.getIntensityAt(time, freq); @@ -403,6 +423,7 @@ class SpectrogramViewer { }); canvas.addEventListener('mouseleave', () => { + this.drawMouseCursor(-1); tooltip.style.display = 'none'; }); -- cgit v1.2.3