summaryrefslogtreecommitdiff
path: root/tools/mq_editor/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor/index.html')
-rw-r--r--tools/mq_editor/index.html380
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">&#x1F4C2; 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;