diff options
| -rw-r--r-- | tools/mq_editor/index.html | 85 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 59 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 29 |
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); |
