summaryrefslogtreecommitdiff
path: root/tools
diff options
context:
space:
mode:
authorskal <pascal.massimino@gmail.com>2026-02-17 19:37:10 +0100
committerskal <pascal.massimino@gmail.com>2026-02-17 19:37:10 +0100
commit94aa832ef673338865b28e5886537c85d6b6d876 (patch)
treee064949bfde2626fc90a5fa5b8e2261072f1e5b5 /tools
parent9416e4ed202d66b20649fb445b6a352f804efd8c (diff)
feat(mq_editor): Improve partial tracking and add audio playback
Tracking improvements: - Frequency-dependent threshold (5% of freq, min 20 Hz) - Candidate system requiring 3-frame persistence before birth - Extended death tolerance (5 frames) for robust trajectories - Minimum 10-frame length filter for valid partials - Result: cleaner, less scattered partial trajectories Audio playback: - Web Audio API integration for original WAV playback - Play/Stop buttons with proper state management - Animated red playhead bar during playback - Keyboard shortcuts: '2' plays original, '1' reserved for synthesis Visualization: - Power law (gamma=0.3) for improved spectrogram contrast Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Diffstat (limited to 'tools')
-rw-r--r--tools/mq_editor/index.html85
-rw-r--r--tools/mq_editor/mq_extract.js59
-rw-r--r--tools/mq_editor/viewer.js29
3 files changed, 165 insertions, 8 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index c1d7bc9..1a07b61 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -74,6 +74,8 @@
<div class="toolbar">
<input type="file" id="wavFile" accept=".wav">
<button id="extractBtn" disabled>Extract Partials</button>
+ <button id="playBtn" disabled>▶ Play</button>
+ <button id="stopBtn" disabled>■ Stop</button>
<div class="params">
<label>Hop:</label>
@@ -96,9 +98,13 @@
<script>
let audioBuffer = null;
let viewer = null;
+ let audioContext = null;
+ let currentSource = null;
const wavFile = document.getElementById('wavFile');
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');
@@ -106,6 +112,13 @@
const threshold = document.getElementById('threshold');
const fftSize = 1024; // Fixed
+ // Initialize audio context
+ function initAudioContext() {
+ if (!audioContext) {
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
+ }
+ }
+
// Load WAV file
wavFile.addEventListener('change', async (e) => {
const file = e.target.files[0];
@@ -117,7 +130,9 @@
const audioContext = new AudioContext();
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
+ initAudioContext();
extractBtn.disabled = false;
+ playBtn.disabled = false;
setStatus(`Loaded: ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch`, 'info');
// Create viewer
@@ -159,10 +174,80 @@
}, 50);
});
+ // Play audio
+ playBtn.addEventListener('click', () => {
+ if (!audioBuffer || !audioContext) return;
+
+ stopAudio();
+
+ const startTime = audioContext.currentTime;
+ currentSource = audioContext.createBufferSource();
+ currentSource.buffer = audioBuffer;
+ currentSource.connect(audioContext.destination);
+ currentSource.start();
+
+ currentSource.onended = () => {
+ currentSource = null;
+ playBtn.disabled = false;
+ stopBtn.disabled = true;
+ viewer.setPlayheadTime(-1);
+ setStatus('Stopped', 'info');
+ };
+
+ playBtn.disabled = true;
+ stopBtn.disabled = false;
+ setStatus('Playing...', 'info');
+
+ // Animate playhead
+ function updatePlayhead() {
+ if (!currentSource) return;
+ const elapsed = audioContext.currentTime - startTime;
+ viewer.setPlayheadTime(elapsed);
+ requestAnimationFrame(updatePlayhead);
+ }
+ updatePlayhead();
+ });
+
+ // Stop audio
+ stopBtn.addEventListener('click', () => {
+ stopAudio();
+ });
+
+ function stopAudio() {
+ if (currentSource) {
+ try {
+ currentSource.stop();
+ } catch (e) {
+ // Already stopped
+ }
+ currentSource = null;
+ }
+ if (viewer) {
+ viewer.setPlayheadTime(-1);
+ }
+ playBtn.disabled = false;
+ stopBtn.disabled = true;
+ setStatus('Stopped', 'info');
+ }
+
function setStatus(msg, type = '') {
status.innerHTML = msg;
status.className = type;
}
+
+ // Keyboard shortcuts
+ document.addEventListener('keydown', (e) => {
+ if (e.code === 'Digit1') {
+ e.preventDefault();
+ // TODO: Play synthesized (Phase 2)
+ setStatus('Synthesized playback not yet implemented', 'warn');
+ } else if (e.code === 'Digit2') {
+ e.preventDefault();
+ if (!playBtn.disabled) {
+ playBtn.click();
+ }
+ }
+ });
</script>
</body>
</html>
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 62b275c..c7ead4d 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -97,7 +97,12 @@ function detectPeaks(frame, fftSize, sampleRate, thresholdDB) {
function trackPartials(frames, sampleRate) {
const partials = [];
const activePartials = [];
- const trackingThreshold = 50; // Hz
+ const candidatePartials = []; // Pre-birth candidates
+ const trackingThresholdRatio = 0.05; // 5% frequency tolerance
+ const minTrackingHz = 20; // Minimum 20 Hz
+ const birthPersistence = 3; // Require 3 consecutive frames to birth
+ const deathAge = 5; // Allow 5 frame gap before death
+ const minPartialLength = 10; // Minimum 10 frames for valid partial
for (const frame of frames) {
const matched = new Set();
@@ -105,6 +110,7 @@ function trackPartials(frames, sampleRate) {
// Match peaks to existing partials
for (const partial of activePartials) {
const lastFreq = partial.freqs[partial.freqs.length - 1];
+ const threshold = Math.max(lastFreq * trackingThresholdRatio, minTrackingHz);
let bestPeak = null;
let bestDist = Infinity;
@@ -115,7 +121,7 @@ function trackPartials(frames, sampleRate) {
const peak = frame.peaks[i];
const dist = Math.abs(peak.freq - lastFreq);
- if (dist < trackingThreshold && dist < bestDist) {
+ if (dist < threshold && dist < bestDist) {
bestPeak = peak;
bestDist = dist;
partial.matchIdx = i;
@@ -135,12 +141,51 @@ function trackPartials(frames, sampleRate) {
}
}
- // Birth new partials from unmatched peaks
+ // Update candidate partials (pre-birth)
+ for (let i = candidatePartials.length - 1; i >= 0; --i) {
+ const candidate = candidatePartials[i];
+ const lastFreq = candidate.freqs[candidate.freqs.length - 1];
+ const threshold = Math.max(lastFreq * trackingThresholdRatio, minTrackingHz);
+
+ let bestPeak = null;
+ let bestDist = Infinity;
+
+ for (let i = 0; i < frame.peaks.length; ++i) {
+ if (matched.has(i)) continue;
+
+ const peak = frame.peaks[i];
+ const dist = Math.abs(peak.freq - lastFreq);
+
+ if (dist < threshold && dist < bestDist) {
+ bestPeak = peak;
+ bestDist = dist;
+ candidate.matchIdx = i;
+ }
+ }
+
+ if (bestPeak) {
+ candidate.times.push(frame.time);
+ candidate.freqs.push(bestPeak.freq);
+ candidate.amps.push(bestPeak.amp);
+ matched.add(candidate.matchIdx);
+
+ // Birth if persistent enough
+ if (candidate.times.length >= birthPersistence) {
+ activePartials.push(candidate);
+ candidatePartials.splice(i, 1);
+ }
+ } else {
+ // Candidate died, remove
+ candidatePartials.splice(i, 1);
+ }
+ }
+
+ // Create new candidate partials from unmatched peaks
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
const peak = frame.peaks[i];
- activePartials.push({
+ candidatePartials.push({
times: [frame.time],
freqs: [peak.freq],
amps: [peak.amp],
@@ -151,9 +196,9 @@ function trackPartials(frames, sampleRate) {
// Death old partials
for (let i = activePartials.length - 1; i >= 0; --i) {
- if (activePartials[i].age > 2) {
+ if (activePartials[i].age > deathAge) {
// Move to finished if long enough
- if (activePartials[i].times.length >= 4) {
+ if (activePartials[i].times.length >= minPartialLength) {
partials.push(activePartials[i]);
}
activePartials.splice(i, 1);
@@ -163,7 +208,7 @@ function trackPartials(frames, sampleRate) {
// Finish remaining active partials
for (const partial of activePartials) {
- if (partial.times.length >= 4) {
+ if (partial.times.length >= minPartialLength) {
partials.push(partial);
}
}
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 6ebf7e0..459cd9e 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -27,6 +27,9 @@ class SpectrogramViewer {
// Tooltip
this.tooltip = document.getElementById('tooltip');
+ // Playhead
+ this.playheadTime = -1; // -1 = not playing
+
// Setup event handlers
this.setupMouseHandlers();
@@ -35,6 +38,11 @@ class SpectrogramViewer {
this.render();
}
+ setPlayheadTime(time) {
+ this.playheadTime = time;
+ this.render();
+ }
+
setPartials(partials) {
this.partials = partials;
this.render();
@@ -79,6 +87,23 @@ class SpectrogramViewer {
this.renderSpectrogram();
this.renderPartials();
this.drawAxes();
+ this.drawPlayhead();
+ }
+
+ drawPlayhead() {
+ if (this.playheadTime < 0) return;
+ if (this.playheadTime < this.t_view_min || this.playheadTime > this.t_view_max) return;
+
+ const {ctx, canvas} = this;
+ const timeDuration = this.t_view_max - this.t_view_min;
+ const x = (this.playheadTime - this.t_view_min) / timeDuration * canvas.width;
+
+ ctx.strokeStyle = '#f00';
+ ctx.lineWidth = 2;
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, canvas.height);
+ ctx.stroke();
}
// Render spectrogram background
@@ -144,7 +169,9 @@ class SpectrogramViewer {
const magDB = 20 * Math.log10(Math.max(mag, 1e-10));
const normalized = (magDB + 80) / 80;
- const intensity = Math.max(0, Math.min(1, normalized));
+ const clamped = Math.max(0, Math.min(1, normalized));
+ // Power law for better peak visibility
+ const intensity = Math.pow(clamped, 0.3);
const freqNorm0 = (freq - this.freqStart) / (this.freqEnd - this.freqStart);
const freqNorm1 = (freqNext - this.freqStart) / (this.freqEnd - this.freqStart);