diff options
Diffstat (limited to 'tools/mq_editor/index.html')
| -rw-r--r-- | tools/mq_editor/index.html | 832 |
1 files changed, 100 insertions, 732 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html index a2daff5..efbd73d 100644 --- a/tools/mq_editor/index.html +++ b/tools/mq_editor/index.html @@ -3,252 +3,7 @@ <head> <meta charset="utf-8"> <title>MQ Spectral Editor</title> - <style> - body { - font-family: monospace; - font-size: 14px; - margin: 20px; - 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; - background: #2a2a2a; - border-radius: 4px; - } - button { - background: #3a3a3a; - color: #ddd; - border: 1px solid #555; - padding: 8px 16px; - margin-right: 8px; - cursor: pointer; - border-radius: 4px; - } - 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; - margin-left: 20px; - } - label { - margin-right: 8px; - } - input[type="number"], select { - width: 80px; - background: #3a3a3a; - color: #ddd; - border: 1px solid #555; - 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; - flex-shrink: 0; - } - .right-panel { - background: #2a2a2a; - border: 1px solid #555; - border-radius: 4px; - padding: 12px; - min-width: 260px; - max-width: 260px; - max-height: 700px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 6px; - box-sizing: border-box; - } - .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; - align-items: center; - 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; - padding: 8px; - background: #2a2a2a; - border-radius: 4px; - min-height: 20px; - } - .info { color: #4af; } - .warn { color: #fa4; } - .error { color: #f44; } - </style> + <link rel="stylesheet" href="style.css"> </head> <body> <div class="page-title"> @@ -258,53 +13,99 @@ <div class="toolbar"> <input type="file" id="wavFile" accept=".wav"> - <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> - - <div class="params"> - <label>Hop:</label> - <input type="number" id="hopSize" value="256" min="64" max="1024" step="64"> - - <label>Threshold (dB):</label> - <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;"> - <span id="keepPctLabel" style="margin-left:4px;">100%</span> - </div> + <span class="toolbar-group"> + <button id="chooseFileBtn">📂 Open WAV</button> + </span> + <span class="toolbar-sep"></span> + <span class="toolbar-group"> + <button id="extractBtn" disabled>Extract Partials</button> + <button id="autoSpreadAllBtn" disabled>Auto Spread All</button> + </span> + <span class="toolbar-sep"></span> + <span class="toolbar-group"> + <button id="playBtn" disabled>▶ Play</button> + <button id="stopBtn" disabled>■ Stop</button> + </span> + <span class="toolbar-sep"></span> + <span class="toolbar-group"> + <button id="newPartialBtn" disabled>+ Partial</button> + <button id="clearAllBtn" disabled>✕ Clear All</button> + </span> + <span class="toolbar-sep"></span> + <span class="toolbar-group"> + <button id="exploreBtn" disabled>⊕ Explore</button> + <button id="contourBtn" disabled>≋ Contour</button> + </span> + <span class="toolbar-sep"></span> + <span class="toolbar-group"> + <button id="undoBtn" disabled>↩ Undo</button> + <button id="redoBtn" disabled>↪ Redo</button> + </span> + <span class="toolbar-sep"></span> + <span class="toolbar-wrap"> + <button id="paramsBtn">⚙ Params</button> + <div id="paramsPanel"> + <div class="param-group"> + <span class="group-label">STFT</span> + <label title="STFT hop size in samples. Smaller = finer time resolution, more frames, slower.">Hop</label> + <input type="number" id="hopSize" value="256" min="64" max="1024" step="64" style="width:60px;"> + </div> + <div class="param-group"> + <span class="group-label">Peak Detect</span> + <label title="Minimum spectral peak amplitude in dB. Peaks below this are ignored.">Threshold (dB)</label> + <input type="number" id="threshold" value="-20" step="any"> + <label title="Minimum prominence in dB: how much a peak must stand above its surrounding valley. Suppresses weak shoulders.">Prominence (dB)</label> + <input type="number" id="prominence" value="1.0" step="0.1" min="0"> + <label title="Weight spectrum by frequency before peak detection: f × Power(f). Boosts high-frequency peaks relative to low-frequency ones."> + <input type="checkbox" id="freqWeight"> f·Power + </label> + </div> + <div class="param-group"> + <span class="group-label">Tracking</span> + <label title="Frames a candidate must persist consecutively before being promoted to a tracked partial. Higher = fewer spurious short bursts.">Birth</label> + <input type="number" id="birthPersistence" value="3" min="1" max="10" step="1" style="width:50px;"> + <label title="Frames a partial can go unmatched before it is terminated. Higher = bridges short gaps; lower = cuts off quickly.">Death</label> + <input type="number" id="deathAge" value="5" min="1" max="20" step="1" style="width:50px;"> + <label title="Weight of phase prediction error in the peak-matching cost function. Higher = stricter phase coherence required to continue a partial.">Phase Wt</label> + <input type="number" id="phaseErrorWeight" value="2.0" min="0" max="10" step="0.5" style="width:55px;"> + <label title="Minimum number of frames a tracked partial must span. Shorter partials are discarded after tracking.">Min Len</label> + <input type="number" id="minLength" value="10" min="1" max="50" step="1" style="width:50px;"> + </div> + </div> + </span> </div> <div class="main-area"> - <div style="display:flex; flex-direction:column; gap:6px; flex-shrink:0;"> - <div style="position: relative;"> + <div class="canvas-col"> + <div class="canvas-wrap"> <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> + <canvas id="cursorCanvas" width="1400" height="600"></canvas> + <canvas id="playheadCanvas" width="1400" height="600"></canvas> + <!-- Partial spectrum viewer (bottom-right overlay, left of main spectrum) --> + <div id="partialSpectrumViewer"> + <canvas id="partialSpectrumCanvas" width="200" height="100"></canvas> + </div> <!-- 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;"> + <div id="spectrumViewer"> <canvas id="spectrumCanvas" width="400" height="100"></canvas> </div> + <!-- Keep slider (bottom-left overlay) --> + <div id="keepOverlay"> + <span title="Keep only the strongest N% of extracted partials, ranked by peak amplitude.">Keep</span> + <input type="range" id="keepPct" min="1" max="100" value="100"> + <span id="keepPctLabel">100%</span> + </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;"> + <div id="ampEditPanel"> + <div class="amp-edit-header"> <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> + <span id="ampEditTitle"></span> + <span class="amp-edit-hint">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> + <canvas id="ampEditCanvas" width="1400" height="120"></canvas> </div> </div> @@ -313,7 +114,7 @@ <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> + <span id="propSwatch"></span> </div> <div class="prop-row"> <span class="prop-label">Peak</span> @@ -339,11 +140,11 @@ </div> <div class="partial-actions"> <button id="mutePartialBtn">Mute</button> - <button id="deletePartialBtn">Delete</button> + <button id="deletePartialBtn">Delete <kbd>Del</kbd></button> </div> </div> - <div id="noSelMsg" style="color:#555;font-size:13px;padding:2px 0;">Click a partial to select</div> + <div id="noSelMsg">Click a partial to select</div> <!-- Synthesis options (always at bottom) --> <div class="synth-section"> @@ -352,478 +153,45 @@ <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."> + <div id="globalResParams" style="display:none;"> + <label 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> + <input type="range" id="globalR" min="0.75" max="0.9999" step="0.0001" value="0.995"> + <span id="globalRVal" class="slider-val">0.9950</span> </label> - <label style="display:flex;align-items:center;gap:6px;" title="Global gain compensation applied to all partials in resonator mode."> + <label 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> + <input type="range" id="globalGain" min="0.0" max="4.0" step="0.01" value="1.0"> + <span id="globalGainVal" class="slider-val">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."> + <label 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> + <input type="range" id="lpK1" min="0.001" max="1.0" step="0.001" value="1.0"> + <span id="lpK1Val" class="slider-val">bypass</span> </label> - <label style="display:flex;align-items:center;gap:6px;" title="HP filter coefficient k2 in (0,1]. 1.0 = bypass."> + <label 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> + <input type="range" id="hpK2" min="0.001" max="1.0" step="0.001" value="1.0"> + <span id="hpK2Val" class="slider-val">bypass</span> </label> </div> </div> </div> </div> - <div id="tooltip" style="position: fixed; display: none; background: #2a2a2a; padding: 4px 8px; border: 1px solid #555; border-radius: 3px; pointer-events: none; font-size: 11px; z-index: 1000;"></div> + <div id="tooltip"></div> <div id="status">Load a WAV file to begin...</div> + <script src="utils.js"></script> <script src="fft.js"></script> <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; - let currentSource = null; - let extractedPartials = null; - let stftCache = null; - - 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'); - const status = document.getElementById('status'); - const fileLabel = document.getElementById('fileLabel'); - - 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 - - function getKeepCount() { - return Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100)); - } - - keepPct.addEventListener('input', () => { - keepPctLabel.textContent = keepPct.value + '%'; - 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) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - } - - // Shared: initialize editor from an AudioBuffer - function loadAudioBuffer(buffer, label) { - audioBuffer = buffer; - initAudioContext(); - extractBtn.disabled = false; - 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); - } - - // 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(); - const ctx = new AudioContext(); - const buf = await ctx.decodeAudioData(arrayBuffer); - loadAudioBuffer(buf, `Loaded: ${file.name}`); - } catch (err) { - setStatus('Error loading WAV: ' + err.message, 'error'); - console.error(err); - } - }); - - // Test WAV: generate synthetic signal (two sine waves) in-memory - document.getElementById('testWavBtn').addEventListener('click', () => { - initAudioContext(); - const SR = 32000; - const duration = 2.0; - const numSamples = SR * duration; - - // Two sine waves: 440 Hz (A4) + 660 Hz (E5, perfect fifth), equal amplitude - const buf = audioContext.createBuffer(1, numSamples, SR); - const data = buf.getChannelData(0); - for (let i = 0; i < numSamples; ++i) { - data[i] = 0.5 * Math.sin(2 * Math.PI * 440 * i / SR) - + 0.5 * Math.sin(2 * Math.PI * 660 * i / SR); - } - - fileLabel.textContent = 'test-440+660hz.wav'; - loadAudioBuffer(buf, 'Test WAV: 440Hz + 660Hz (2s, 32kHz)'); - }); - - // Update cache when hop size changes - hopSize.addEventListener('change', () => { - const val = Math.max(64, parseInt(hopSize.value) || 64); - hopSize.value = val; - if (stftCache) { - setStatus('Updating STFT cache...', 'info'); - setTimeout(() => { - stftCache.setHopSize(val); - setStatus(`Cache updated (${stftCache.getNumFrames()} frames)`, 'info'); - if (viewer) viewer.render(); - }, 10); - } - }); - - function runExtraction() { - if (!stftCache) return; - - setStatus('Extracting partials...', 'info'); - extractBtn.disabled = true; - - setTimeout(() => { - try { - const params = { - fftSize: fftSize, - hopSize: parseInt(hopSize.value), - threshold: parseFloat(threshold.value), - prominence: parseFloat(prominence.value), - freqWeight: freqWeightCb.checked, - sampleRate: audioBuffer.sampleRate - }; - - const result = extractPartials(params, stftCache); - - // Sort by decreasing peak amplitude - result.partials.sort((a, b) => { - const peakA = a.amps.reduce((m, v) => Math.max(m, v), 0); - const peakB = b.amps.reduce((m, v) => Math.max(m, v), 0); - return peakB - peakA; - }); - - 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); - } - - 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(); - currentSource.buffer = buffer; - 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(statusMsg, 'info'); - function tick() { - if (!currentSource) return; - viewer.setPlayheadTime(audioContext.currentTime - startTime); - requestAnimationFrame(tick); - } - tick(); - } - - function stopAudio() { - if (currentSource) { - try { currentSource.stop(); } catch (e) {} - currentSource = null; - } - if (viewer) viewer.setPlayheadTime(-1); - playBtn.disabled = false; - stopBtn.disabled = true; - setStatus('Stopped', 'info'); - } - - // Play audio - playBtn.addEventListener('click', () => { - if (!audioBuffer || !audioContext) return; - stopAudio(); - playAudioBuffer(audioBuffer, 'Playing...'); - }); - - // Stop audio - stopBtn.addEventListener('click', () => { - stopAudio(); - }); - - function setStatus(msg, type = '') { - status.innerHTML = msg; - status.className = type; - } - - // Play synthesized audio - function playSynthesized() { - if (!extractedPartials || extractedPartials.length === 0) { - setStatus('No partials extracted yet', 'warn'); - return; - } - if (!audioBuffer || !audioContext) return; - - stopAudio(); - - setStatus('Synthesizing...', 'info'); - - const keepCount = getKeepCount(); - 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 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, forceResonator, - forceRGain, globalR, globalGain, k1, k2}); - - if (viewer) { - viewer.setSynthStftCache(new STFTCache(pcm, audioBuffer.sampleRate, fftSize, parseInt(hopSize.value))); - } - - const synthBuffer = audioContext.createBuffer(1, pcm.length, audioBuffer.sampleRate); - synthBuffer.getChannelData(0).set(pcm); - playAudioBuffer(synthBuffer, `Playing synthesized (${partialsToUse.length}/${extractedPartials.length} partials, ${keepPct.value}%)...`); - } - - // Keyboard shortcuts - document.addEventListener('keydown', (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - if (e.code === 'Digit1') { - e.preventDefault(); - playSynthesized(); - } else if (e.code === 'Digit2') { - e.preventDefault(); - if (!playBtn.disabled) { - playBtn.click(); - } - } else if (e.code === 'KeyP') { - e.preventDefault(); - if (viewer) viewer.togglePeaks(); - } else if (e.code === 'KeyA') { - e.preventDefault(); - if (viewer) { - 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; - const N = cache.fftSize; - const binWidth = SR / N; // Hz per bin - const numBins = N / 2; - const numBars = 100; // mini-spectrum bar count - - // Use a mid-signal frame (avoid edge effects) - const midFrame = cache.frames[Math.floor(cache.frames.length / 2)]; - if (!midFrame) { console.error('[TestWAV] No frames computed'); return; } - const sq = midFrame.squaredAmplitude; - const t = midFrame.time; - - console.group('[TestWAV] Peak validation @ t=' + t.toFixed(3) + 's'); - - // Top 5 bins by magnitude - const ranked = Array.from(sq) - .map((v, i) => ({ bin: i, freq: i * binWidth, db: 10 * Math.log10(Math.max(v, 1e-20)) })) - .sort((a, b) => b.db - a.db); - console.log('Top 5 FFT bins:'); - ranked.slice(0, 5).forEach(x => - console.log(` bin ${x.bin.toString().padStart(3)}: ${x.freq.toFixed(1).padStart(7)}Hz ${x.db.toFixed(1)}dB`)); - - // Expected bins for 440/660 Hz - const bin440 = Math.round(440 / binWidth); - const bin660 = Math.round(660 / binWidth); - const db440 = 10 * Math.log10(Math.max(sq[bin440], 1e-20)); - const db660 = 10 * Math.log10(Math.max(sq[bin660], 1e-20)); - console.log(`440Hz → bin ${bin440} (${(bin440 * binWidth).toFixed(1)}Hz): ${db440.toFixed(1)}dB`); - console.log(`660Hz → bin ${bin660} (${(bin660 * binWidth).toFixed(1)}Hz): ${db660.toFixed(1)}dB`); - - // Validate: 440/660 Hz must be in top-10 - const top10Freqs = ranked.slice(0, 10).map(x => x.freq); - const pass440 = top10Freqs.some(f => Math.abs(f - 440) < binWidth * 2); - const pass660 = top10Freqs.some(f => Math.abs(f - 660) < binWidth * 2); - console.log('Peak check: 440Hz ' + (pass440 ? 'PASS ✓' : 'FAIL ✗') + - ', 660Hz ' + (pass660 ? 'PASS ✓' : 'FAIL ✗')); - - // Mini-spectrum: which bar do these peaks land in? - const bar440 = Math.floor(bin440 * numBars / numBins); - const bar660 = Math.floor(bin660 * numBars / numBins); - const sampledBin440 = Math.floor(bar440 * numBins / numBars); - const sampledBin660 = Math.floor(bar660 * numBars / numBars); - console.log('Mini-spectrum (linear scale, 100 bars):'); - console.log(` 440Hz (bin ${bin440}) → bar ${bar440}/100 [bar samples bin ${sampledBin440} = ${(sampledBin440 * binWidth).toFixed(1)}Hz]`); - console.log(` 660Hz (bin ${bin660}) → bar ${bar660}/100 [bar samples bin ${Math.floor(bar660 * numBins / numBars)} = ${(Math.floor(bar660 * numBins / numBars) * binWidth).toFixed(1)}Hz]`); - if (bar440 < 5 || bar660 < 5) { - console.warn(' ⚠ BUG: peaks fall in bars ' + bar440 + ' and ' + bar660 + - ' (leftmost ~' + Math.max(bar440, bar660) * 2 + 'px of 200px canvas)' + - ' — linear scale hides low-frequency peaks. Need log-scale bar mapping.'); - } - - // Main spectrogram: confirm bins are in draw range - const mainFreqStart = 20, mainFreqEnd = 16000; - const inRange440 = 440 >= mainFreqStart && 440 <= mainFreqEnd; - const inRange660 = 660 >= mainFreqStart && 660 <= mainFreqEnd; - const norm440 = (Math.log2(440) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); - const norm660 = (Math.log2(660) - Math.log2(mainFreqStart)) / (Math.log2(mainFreqEnd) - Math.log2(mainFreqStart)); - console.log('Main spectrogram (log Y-axis, 600px):'); - console.log(` 440Hz: in range=${inRange440}, y=${Math.round(600 * (1 - norm440))}px, db=${db440.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db440 + 80) / 80), 2)).toFixed(2)}`); - console.log(` 660Hz: in range=${inRange660}, y=${Math.round(600 * (1 - norm660))}px, db=${db660.toFixed(1)}dB → intensity=${Math.min(1, Math.pow(Math.max(0, (db660 + 80) / 80), 2)).toFixed(2)}`); - - console.groupEnd(); - } - </script> + <script src="app.js"></script> </body> </html> |
