summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-18 06:59:32 +0100
committerskal <pascal.massimino@gmail.com>2026-02-18 06:59:32 +0100
commit105c817021a84bfacffa1553d6bcd536808b9f23 (patch)
tree36bc576d4605c5057e1604640f0fa1679866d6b6 /tools
parent65cd99553cd688c5ad2cfd64d79c6434fe694a33 (diff)
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
Diffstat (limited to 'tools')
-rw-r--r--tools/mq_editor/index.html100
-rw-r--r--tools/mq_editor/mq_extract.js38
-rw-r--r--tools/mq_editor/mq_synth.js9
-rw-r--r--tools/mq_editor/viewer.js23
4 files changed, 146 insertions, 24 deletions
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; }
</style>
</head>
<body>
- <h2>MQ Spectral Editor</h2>
+ <div class="page-title">
+ <h2>MQ Spectral Editor</h2>
+ <span id="fileLabel"></span>
+ </div>
<div class="toolbar">
<input type="file" id="wavFile" accept=".wav">
+ <button id="chooseFileBtn">&#x1F4C2; Open WAV</button>
<button id="testWavBtn">⚗ Test WAV</button>
<button id="extractBtn" disabled>Extract Partials</button>
<button id="playBtn" disabled>▶ Play</button>
@@ -85,20 +128,28 @@
<label>Threshold (dB):</label>
<input type="number" id="threshold" value="-60" step="any">
- <label style="margin-left:16px;"><input type="checkbox" id="integratePhase" checked> Integrate phase</label>
-
<label style="margin-left:16px;">Keep:</label>
<input type="range" id="keepPct" min="1" max="100" value="100" style="width:100px; vertical-align:middle;">
<span id="keepPctLabel" style="margin-left:4px;">100%</span>
</div>
</div>
- <div style="position: relative;">
- <canvas id="canvas" width="1400" height="600"></canvas>
+ <div class="main-area">
+ <div style="position: relative; flex-shrink: 0;">
+ <canvas id="canvas" width="1400" height="600"></canvas>
+ <canvas id="cursorCanvas" width="1400" height="600" style="position:absolute;top:0;left:0;pointer-events:none;"></canvas>
- <!-- Mini spectrum viewer (bottom-right overlay) -->
- <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 200px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;">
- <canvas id="spectrumCanvas" width="200" height="100"></canvas>
+ <!-- Mini spectrum viewer (bottom-right overlay) -->
+ <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 200px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;">
+ <canvas id="spectrumCanvas" width="200" height="100"></canvas>
+ </div>
+ </div>
+
+ <div class="right-panel">
+ <div class="panel-title">Synthesis</div>
+ <label><input type="checkbox" id="integratePhase" checked> Integrate phase</label>
+ <label><input type="checkbox" id="disableJitter"> Disable jitter</label>
+ <label><input type="checkbox" id="disableSpread"> Disable spread</label>
</div>
</div>
@@ -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';
});