diff options
Diffstat (limited to 'tools/mq_editor/index.html')
| -rw-r--r-- | tools/mq_editor/index.html | 380 |
1 files changed, 357 insertions, 23 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index 60076b3..a2daff5 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -6,6 +6,7 @@ <style> body { font-family: monospace; + font-size: 14px; margin: 20px; background: #1a1a1a; color: #ddd; @@ -39,6 +40,7 @@ } button:hover { background: #4a4a4a; } button:disabled { opacity: 0.5; cursor: not-allowed; } + #extractBtn { background: #666; color: #fff; font-weight: bold; border-color: #888; } input[type="file"] { display: none; } .params { display: inline-block; @@ -73,19 +75,26 @@ border: 1px solid #555; border-radius: 4px; padding: 12px; - min-width: 160px; + min-width: 260px; + max-width: 260px; + max-height: 700px; + overflow-y: auto; display: flex; flex-direction: column; - gap: 10px; + gap: 6px; + box-sizing: border-box; } - .right-panel .panel-title { - font-size: 11px; + .panel-title { + font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 1px; border-bottom: 1px solid #444; padding-bottom: 6px; margin-bottom: 2px; + display: flex; + align-items: center; + gap: 6px; } .right-panel label { display: flex; @@ -93,7 +102,141 @@ gap: 6px; margin: 0; cursor: pointer; + font-size: 14px; + } + /* Partial properties */ + .prop-row { + display: flex; + justify-content: space-between; + align-items: baseline; font-size: 13px; + padding: 2px 0; + } + .prop-label { color: #777; font-size: 12px; } + .curve-tabs { + display: flex; + gap: 2px; + margin-top: 8px; + margin-bottom: 4px; + } + .tab-btn { + flex: 1; + padding: 4px 0; + font-size: 12px; + margin: 0; + background: #222; + border-color: #444; + color: #888; + } + .tab-btn.active { + background: #3a3a3a; + border-color: #666; + color: #ddd; + } + .curve-grid { + display: grid; + grid-template-columns: 18px 1fr 1fr; + gap: 3px 4px; + align-items: center; + } + .curve-grid span { color: #666; font-size: 11px; } + .curve-grid input[type="number"] { + width: 100%; + background: #333; + color: #ccc; + border: 1px solid #444; + padding: 2px 4px; + border-radius: 2px; + font-size: 11px; + font-family: monospace; + box-sizing: border-box; + } + .curve-grid input[type="number"]:focus { + border-color: #666; + outline: none; + } + .partial-actions { + display: flex; + gap: 4px; + margin-top: 8px; + } + .partial-actions button { + flex: 1; + padding: 4px 6px; + font-size: 12px; + margin: 0; + } + .synth-grid { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 8px; + align-items: center; + } + .synth-grid span { color: #888; font-size: 12px; } + .synth-field-wrap { + display: flex; + align-items: center; + gap: 4px; + } + .synth-field-wrap input[type="number"] { + flex: 1; + min-width: 0; + background: #333; + color: #ccc; + border: 1px solid #444; + padding: 2px 4px; + border-radius: 2px; + font-size: 11px; + font-family: monospace; + box-sizing: border-box; + } + .synth-field-wrap input[type="number"]:focus { border-color: #666; outline: none; } + .jog-slider { + width: 44px; + height: 16px; + background: #1e1e1e; + border: 1px solid #444; + border-radius: 3px; + cursor: ew-resize; + position: relative; + flex-shrink: 0; + user-select: none; + overflow: hidden; + } + .jog-slider::before { + content: ''; + position: absolute; + left: 50%; top: 3px; bottom: 3px; + width: 1px; + background: #484848; + transform: translateX(-50%); + } + .jog-thumb { + position: absolute; + top: 3px; bottom: 3px; + width: 6px; + background: #888; + border-radius: 2px; + left: calc(50% - 3px); + transition: left 0.12s ease; + } + .jog-slider:hover .jog-thumb { background: #aaa; } + .synth-grid input[type="number"]:focus { border-color: #666; outline: none; } + .synth-section { + border-top: 1px solid #444; + padding-top: 8px; + margin-top: auto; + } + /* resonator mode badge shown in partial header color swatch area */ + .res-badge { + font-size: 9px; + color: #8cf; + border: 1px solid #8cf; + border-radius: 2px; + padding: 0 3px; + vertical-align: middle; + margin-left: 4px; + opacity: 0.8; } #status { margin-top: 10px; @@ -118,6 +261,7 @@ <button id="chooseFileBtn">📂 Open WAV</button> <button id="testWavBtn">⚗ Test WAV</button> <button id="extractBtn" disabled>Extract Partials</button> + <button id="autoSpreadAllBtn" disabled>Auto Spread All</button> <button id="playBtn" disabled>▶ Play</button> <button id="stopBtn" disabled>■ Stop</button> @@ -126,7 +270,14 @@ <input type="number" id="hopSize" value="256" min="64" max="1024" step="64"> <label>Threshold (dB):</label> - <input type="number" id="threshold" value="-60" step="any"> + <input type="number" id="threshold" value="-20" step="any"> + + <label>Prominence (dB):</label> + <input type="number" id="prominence" value="1.0" step="0.1" min="0"> + + <label style="margin-left:16px;" title="Weight spectrum by frequency before peak detection (f * FFT_Power(f)), accentuates high-frequency peaks"> + <input type="checkbox" id="freqWeight"> f·Power + </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;"> @@ -135,21 +286,98 @@ </div> <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> + <div style="display:flex; flex-direction:column; gap:6px; flex-shrink:0;"> + <div style="position: relative;"> + <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: 400px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;"> - <canvas id="spectrumCanvas" width="400" height="100"></canvas> + <!-- Mini spectrum viewer (bottom-right overlay) --> + <div id="spectrumViewer" style="position: absolute; bottom: 10px; right: 10px; width: 400px; height: 100px; background: rgba(30, 30, 30, 0.9); border: 1px solid #555; border-radius: 3px; pointer-events: none;"> + <canvas id="spectrumCanvas" width="400" height="100"></canvas> + </div> + </div> + + <!-- Amplitude bezier editor (shown when partial selected) --> + <div id="ampEditPanel" style="display:none;"> + <div style="font-size:10px; color:#555; padding:2px 0 3px 1px; display:flex; align-items:center; gap:10px; text-transform:uppercase; letter-spacing:0.5px;"> + <span>Amplitude</span> + <span id="ampEditTitle" style="color:#777; text-transform:none; letter-spacing:0;"></span> + <span style="color:#333; text-transform:none; letter-spacing:0;">drag control points · Esc to deselect</span> + </div> + <canvas id="ampEditCanvas" width="1400" height="120" style="border:1px solid #2a2a2a; background:#0e0e0e; cursor:crosshair; display:block;"></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> + <!-- Partial properties (visible when a partial is selected) --> + <div id="partialProps" style="display:none;"> + <div class="panel-title"> + <span id="propTitle">Partial #—</span> + <span id="propSwatch" style="display:inline-block;width:10px;height:10px;border-radius:2px;flex-shrink:0;"></span> + </div> + <div class="prop-row"> + <span class="prop-label">Peak</span> + <span id="propPeak">—</span> + </div> + <div class="prop-row"> + <span class="prop-label">Time</span> + <span id="propTime">—</span> + </div> + <div class="curve-tabs"> + <button class="tab-btn active" data-tab="Freq">Freq</button> + <button class="tab-btn" data-tab="Amp">Amp</button> + <button class="tab-btn" data-tab="Synth">Synth</button> + </div> + <div class="tab-pane" id="tabFreq"> + <div class="curve-grid" id="freqCurveGrid"></div> + </div> + <div class="tab-pane" id="tabAmp" style="display:none;"> + <div class="curve-grid" id="ampCurveGrid"></div> + </div> + <div class="tab-pane" id="tabSynth" style="display:none;"> + <div class="synth-grid" id="synthGrid"></div> + </div> + <div class="partial-actions"> + <button id="mutePartialBtn">Mute</button> + <button id="deletePartialBtn">Delete</button> + </div> + </div> + + <div id="noSelMsg" style="color:#555;font-size:13px;padding:2px 0;">Click a partial to select</div> + + <!-- Synthesis options (always at bottom) --> + <div class="synth-section"> + <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> + <label title="Test mode: force resonator synthesis for all partials (ignores per-partial mode setting)"><input type="checkbox" id="forceResonator"> Resonator (all)</label> + <div id="globalResParams" style="display:none;margin-top:4px;padding:4px 0 2px 12px;border-left:2px solid #555;"> + <label style="display:flex;align-items:center;gap:6px;" title="Global pole radius r in (0,1). Applied to all partials in resonator mode."> + r (pole) + <input type="range" id="globalR" min="0.75" max="0.9999" step="0.0001" value="0.995" style="flex:1;min-width:0;"> + <span id="globalRVal" style="width:44px;text-align:right;">0.9950</span> + </label> + <label style="display:flex;align-items:center;gap:6px;" title="Global gain compensation applied to all partials in resonator mode."> + gain + <input type="range" id="globalGain" min="0.0" max="4.0" step="0.01" value="1.0" style="flex:1;min-width:0;"> + <span id="globalGainVal" style="width:44px;text-align:right;">1.00</span> + </label> + <label title="Override per-partial r/gain with global values during playback"><input type="checkbox" id="forceRGain"> force r/gain</label> + </div> + <div style="margin-top:6px;"> + <label style="display:flex;align-items:center;gap:6px;" title="LP filter coefficient k1 in (0,1]. 1.0 = bypass."> + LP k1 + <input type="range" id="lpK1" min="0.001" max="1.0" step="0.001" value="1.0" style="flex:1;min-width:0;"> + <span id="lpK1Val" style="width:44px;text-align:right;">bypass</span> + </label> + <label style="display:flex;align-items:center;gap:6px;" title="HP filter coefficient k2 in (0,1]. 1.0 = bypass."> + HP k2 + <input type="range" id="hpK2" min="0.001" max="1.0" step="0.001" value="1.0" style="flex:1;min-width:0;"> + <span id="hpK2Val" style="width:44px;text-align:right;">bypass</span> + </label> + </div> + </div> </div> </div> @@ -161,7 +389,47 @@ <script src="mq_extract.js"></script> <script src="mq_synth.js"></script> <script src="viewer.js"></script> + <script src="editor.js"></script> <script> + // LP: y[n] = k*x[n] + (1-k)*y[n-1] => -3dB at cos(w) = (2-2k-k²)/(2(1-k)) + function k1ToHz(k, sr) { + if (k >= 1.0) return sr / 2; + const cosW = (2 - 2*k - k*k) / (2*(1 - k)); + return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI); + } + // HP: y[n] = k*(y[n-1]+x[n]-x[n-1]) => -3dB from peak at cos(w) = 2k/(1+k²) + function k2ToHz(k, sr) { + if (k >= 1.0) return 0; + const cosW = 2*k / (1 + k*k); + return Math.acos(Math.max(-1, Math.min(1, cosW))) * sr / (2 * Math.PI); + } + function fmtHz(f) { + return f >= 1000 ? (f/1000).toFixed(1) + 'k' : Math.round(f) + 'Hz'; + } + function getSR() { return (typeof audioBuffer !== 'undefined' && audioBuffer) ? audioBuffer.sampleRate : 44100; } + + // LP/HP slider live display + document.getElementById('lpK1').addEventListener('input', function() { + const k = parseFloat(this.value); + const f = k1ToHz(k, getSR()); + document.getElementById('lpK1Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f); + }); + document.getElementById('hpK2').addEventListener('input', function() { + const k = parseFloat(this.value); + const f = k2ToHz(k, getSR()); + document.getElementById('hpK2Val').textContent = k >= 1.0 ? 'bypass' : fmtHz(f); + }); + + // Show/hide global resonator params when forceResonator toggled + document.getElementById('forceResonator').addEventListener('change', function() { + document.getElementById('globalResParams').style.display = this.checked ? '' : 'none'; + }); + document.getElementById('globalR').addEventListener('input', function() { + document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4); + }); + document.getElementById('globalGain').addEventListener('input', function() { + document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2); + }); let audioBuffer = null; let viewer = null; let audioContext = null; @@ -172,6 +440,7 @@ const wavFile = document.getElementById('wavFile'); const chooseFileBtn = document.getElementById('chooseFileBtn'); const extractBtn = document.getElementById('extractBtn'); + const autoSpreadAllBtn = document.getElementById('autoSpreadAllBtn'); const playBtn = document.getElementById('playBtn'); const stopBtn = document.getElementById('stopBtn'); const canvas = document.getElementById('canvas'); @@ -180,6 +449,8 @@ const hopSize = document.getElementById('hopSize'); const threshold = document.getElementById('threshold'); + const prominence = document.getElementById('prominence'); + const freqWeightCb = document.getElementById('freqWeight'); const keepPct = document.getElementById('keepPct'); const keepPctLabel = document.getElementById('keepPctLabel'); const fftSize = 1024; // Fixed @@ -193,6 +464,13 @@ if (viewer && extractedPartials) viewer.setKeepCount(getKeepCount()); }); + // --- Editor --- + const editor = new PartialEditor(); + editor.onPartialDeleted = () => { + if (viewer && extractedPartials) + viewer.setKeepCount(extractedPartials.length > 0 ? getKeepCount() : 0); + }; + // Initialize audio context function initAudioContext() { if (!audioContext) { @@ -208,11 +486,18 @@ playBtn.disabled = false; setStatus('Computing STFT cache...', 'info'); + // Reset partials from previous file + extractedPartials = null; + editor.setPartials(null); + setTimeout(() => { const signal = audioBuffer.getChannelData(0); stftCache = new STFTCache(signal, audioBuffer.sampleRate, fftSize, Math.max(64, parseInt(hopSize.value) || 64)); setStatus(`${label} — ${audioBuffer.duration.toFixed(2)}s, ${audioBuffer.sampleRate}Hz, ${audioBuffer.numberOfChannels}ch (${stftCache.getNumFrames()} frames cached)`, 'info'); viewer = new SpectrogramViewer(canvas, audioBuffer, stftCache); + editor.setViewer(viewer); + viewer.onPartialSelect = (i) => editor.onPartialSelect(i); + viewer.onRender = () => editor.onRender(); if (label.startsWith('Test WAV')) validateTestWAVPeaks(stftCache); }, 10); } @@ -283,6 +568,8 @@ fftSize: fftSize, hopSize: parseInt(hopSize.value), threshold: parseFloat(threshold.value), + prominence: parseFloat(prominence.value), + freqWeight: freqWeightCb.checked, sampleRate: audioBuffer.sampleRate }; @@ -296,29 +583,52 @@ }); extractedPartials = result.partials; + editor.setPartials(result.partials); viewer.setFrames(result.frames); setStatus(`Extracted ${result.partials.length} partials`, 'info'); viewer.setPartials(result.partials); viewer.setKeepCount(getKeepCount()); + viewer.selectPartial(-1); } catch (err) { setStatus('Extraction error: ' + err.message, 'error'); console.error(err); } extractBtn.disabled = false; + autoSpreadAllBtn.disabled = false; }, 50); } - // Extract partials extractBtn.addEventListener('click', () => { if (!audioBuffer) return; runExtraction(); }); + autoSpreadAllBtn.addEventListener('click', () => { + if (!extractedPartials || !stftCache) return; + const fs = stftCache.fftSize; + const sr = audioBuffer.sampleRate; + const defaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 }; + for (const p of extractedPartials) { + const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr); + if (!p.replicas) p.replicas = { ...defaults }; + p.replicas.spread_above = spread_above; + p.replicas.spread_below = spread_below; + } + if (viewer) viewer.render(); + const sel = viewer ? viewer.selectedPartial : -1; + if (sel >= 0) editor.onPartialSelect(sel); + setStatus(`Auto-spread applied to ${extractedPartials.length} partials`, 'info'); + }); + threshold.addEventListener('change', () => { if (stftCache) runExtraction(); }); + freqWeightCb.addEventListener('change', () => { + if (stftCache) runExtraction(); + }); + function playAudioBuffer(buffer, statusMsg) { const startTime = audioContext.currentTime; currentSource = audioContext.createBufferSource(); @@ -384,14 +694,23 @@ setStatus('Synthesizing...', 'info'); const keepCount = getKeepCount(); - const partialsToUse = extractedPartials.slice(0, keepCount); - setStatus(`Synthesizing ${keepCount}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); + const partialsToUse = extractedPartials.slice(0, keepCount).filter(p => !p.muted); + setStatus(`Synthesizing ${partialsToUse.length}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info'); - const integratePhase = document.getElementById('integratePhase').checked; - const disableJitter = document.getElementById('disableJitter').checked; - const disableSpread = document.getElementById('disableSpread').checked; + const integratePhase = document.getElementById('integratePhase').checked; + const disableJitter = document.getElementById('disableJitter').checked; + const disableSpread = document.getElementById('disableSpread').checked; + const forceResonator = document.getElementById('forceResonator').checked; + const lpK1Raw = parseFloat(document.getElementById('lpK1').value); + const hpK2Raw = parseFloat(document.getElementById('hpK2').value); + const k1 = lpK1Raw < 1.0 ? lpK1Raw : null; + const k2 = hpK2Raw < 1.0 ? hpK2Raw : null; + const forceRGain = forceResonator && document.getElementById('forceRGain').checked; + const globalR = parseFloat(document.getElementById('globalR').value); + const globalGain = parseFloat(document.getElementById('globalGain').value); const pcm = synthesizeMQ(partialsToUse, audioBuffer.sampleRate, audioBuffer.duration, - integratePhase, {disableJitter, disableSpread}); + integratePhase, {disableJitter, disableSpread, forceResonator, + forceRGain, globalR, globalGain, k1, k2}); if (viewer) { viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value))); @@ -399,7 +718,7 @@ const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); synthBuffer.getChannelData(0).set(pcm); - playAudioBuffer(synthBuffer, `Playing synthesized (${keepCount}/${extractedPartials.length} partials, ${keepPct.value}%)...`); + playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`); } // Keyboard shortcuts @@ -422,9 +741,24 @@ viewer.showSynthFFT = !viewer.showSynthFFT; viewer.renderSpectrum(); } + } else if (e.code === 'KeyE') { + e.preventDefault(); + if (!extractBtn.disabled) extractBtn.click(); + } else if (e.code === 'Escape') { + if (viewer) viewer.selectPartial(-1); } }); + // Curve tab switching + document.querySelectorAll('.tab-btn').forEach(btn => { + btn.addEventListener('click', () => { + document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('.tab-pane').forEach(p => p.style.display = 'none'); + document.getElementById('tab' + btn.dataset.tab).style.display = ''; + }); + }); + // --- Test WAV peak validation --- function validateTestWAVPeaks(cache) { const SR = cache.sampleRate; |
