summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tools/mq_editor/README.md2
-rw-r--r--tools/mq_editor/TODO.md17
-rw-r--r--tools/mq_editor/app.js32
-rw-r--r--tools/mq_editor/editor.js33
-rw-r--r--tools/mq_editor/index.html146
-rw-r--r--tools/mq_editor/mq_extract.js19
-rw-r--r--tools/mq_editor/mq_synth.js47
-rw-r--r--tools/mq_editor/style.css24
-rw-r--r--tools/mq_editor/viewer.js176
9 files changed, 356 insertions, 140 deletions
diff --git a/tools/mq_editor/README.md b/tools/mq_editor/README.md
index 7d7d06b..3827d4f 100644
--- a/tools/mq_editor/README.md
+++ b/tools/mq_editor/README.md
@@ -180,7 +180,7 @@ y[n] = 2r·cos(ω₀)·y[n-1] − r²·y[n-2] + A(t)·√(1−r²)·noise[n]
| `gain` | 1.0 | [0, ∞) | Output multiplier on top of power normalization. |
**Coefficient translation from spread:**
-`r = exp(−π · BW / SR)` where `BW = f₀ · (spread_above + spread_below) / 2`.
+`r = exp(−π · BW / SR)` where `BW = f₀ · spread`.
For a partial at 440 Hz with `spread = 0.02`: `BW ≈ 8.8 Hz`, `r ≈ exp(−π·8.8/32000) ≈ 0.9991`.
**When to use:**
diff --git a/tools/mq_editor/TODO.md b/tools/mq_editor/TODO.md
new file mode 100644
index 0000000..d85aa84
--- /dev/null
+++ b/tools/mq_editor/TODO.md
@@ -0,0 +1,17 @@
+# UI revamp
+
+- the Synthesis panel (Integrate Phase, etc, LP, MP) contains global parameters -> Should go under the top "Params" button
+
+- 'Auto Spread All' should go under the 'Params' button too. It's a global action.
+
+- group 'Extract Partials' button with '+Partial' and 'x Clear All' ones.
+
+- add a sine / resonator toggle per partial to toggle between sine-based synth and resonator. 'Resonator (all)' takes precedence.
+
+- the partial's mini-spectrum should go under the right panel, since it's related
+
+- the partial's mini-spectrum should have a fixed dB range ([-40dB,10dB]?) instead of a dynamic one, to see the impact of amplitude modulation per partial
+
+- the messages' color is too dark, make them 80% gray when appropriate
+
+- streamline the CSS and styling, if possible
diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js
index 9df00fb..55b8d25 100644
--- a/tools/mq_editor/app.js
+++ b/tools/mq_editor/app.js
@@ -49,13 +49,18 @@ document.getElementById('hpK2').addEventListener('input', function() {
// Show/hide global resonator params when forceResonator toggled
document.getElementById('forceResonator').addEventListener('change', function() {
document.getElementById('globalResParams').style.display = this.checked ? '' : 'none';
+ if (viewer) viewer.render();
});
document.getElementById('globalR').addEventListener('input', function() {
document.getElementById('globalRVal').textContent = parseFloat(this.value).toFixed(4);
+ if (viewer) viewer.render();
});
document.getElementById('globalGain').addEventListener('input', function() {
document.getElementById('globalGainVal').textContent = parseFloat(this.value).toFixed(2);
+ if (viewer) viewer.render();
});
+document.getElementById('forceRGain').addEventListener('change', () => { if (viewer) viewer.render(); });
+
let audioBuffer = null;
let viewer = null;
let audioContext = null;
@@ -195,7 +200,8 @@ function loadAudioBuffer(buffer, label) {
document.getElementById('exploreBtn').disabled = false;
document.getElementById('contourBtn').disabled = false;
editor.setViewer(viewer);
- viewer.onPartialSelect = (i) => editor.onPartialSelect(i);
+ viewer.onGetSynthOpts = () => getSynthParams().opts;
+ viewer.onPartialSelect = (i) => editor.onPartialSelect(i);
viewer.onRender = () => editor.onRender();
viewer.onBeforeChange = pushUndo;
viewer.onExploreMove = (time, freq) => {
@@ -219,10 +225,9 @@ function loadAudioBuffer(buffer, label) {
viewer.onExploreCommit = (partial) => {
if (!extractedPartials) extractedPartials = [];
pushUndo();
- const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate);
- if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
- partial.harmonics.spread_above = spread_above;
- partial.harmonics.spread_below = spread_below;
+ const {spread} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate);
+ if (!partial.harmonics) partial.harmonics = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 };
+ partial.harmonics.spread = spread;
extractedPartials.unshift(partial);
refreshPartialsView(0);
setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info');
@@ -333,7 +338,7 @@ function createNewPartial() {
v0: 440, v1: 440, v2: 440, v3: 440,
a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0,
},
- harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 },
};
extractedPartials.unshift(newPartial);
refreshPartialsView(0);
@@ -357,12 +362,11 @@ function autoSpreadAll() {
if (!extractedPartials || !stftCache) return;
const fs = stftCache.fftSize;
const sr = audioBuffer.sampleRate;
- const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ const defaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 };
for (const p of extractedPartials) {
- const {spread_above, spread_below} = autodetectSpread(p, stftCache, fs, sr);
+ const {spread} = autodetectSpread(p, stftCache, fs, sr);
if (!p.harmonics) p.harmonics = { ...defaults };
- p.harmonics.spread_above = spread_above;
- p.harmonics.spread_below = spread_below;
+ p.harmonics.spread = spread;
}
if (viewer) viewer.render();
const sel = viewer ? viewer.selectedPartial : -1;
@@ -384,7 +388,7 @@ for (const el of [birthPersistenceEl, deathAgeEl, phaseErrorWeightEl, minLengthE
el.addEventListener('change', () => { if (stftCache) runExtraction(); });
}
-function playAudioBuffer(buffer, statusMsg) {
+function playAudioBuffer(buffer, statusMsg, timeOffset = 0) {
const startTime = audioContext.currentTime;
currentSource = audioContext.createBufferSource();
currentSource.buffer = buffer;
@@ -402,7 +406,7 @@ function playAudioBuffer(buffer, statusMsg) {
setStatus(statusMsg, 'info');
function tick() {
if (!currentSource) return;
- viewer.setPlayheadTime(audioContext.currentTime - startTime);
+ viewer.setPlayheadTime(timeOffset + audioContext.currentTime - startTime);
requestAnimationFrame(tick);
}
tick();
@@ -526,7 +530,9 @@ document.addEventListener('keydown', (e) => {
const partial = extractedPartials[sel];
if (!partial) return;
stopAudio();
- playAudioBuffer(getAudioBuffer([partial], 0.05), `Playing partial #${sel}...`);
+ const tStart = Math.min(...partial.times);
+ const offset = Math.max(0, tStart - 0.05);
+ playAudioBuffer(getAudioBuffer([partial], 0.05), `Playing partial #${sel}...`, offset);
} else if (e.code === 'KeyP') {
e.preventDefault();
if (viewer) viewer.togglePeaks();
diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js
index b07664e..81f571c 100644
--- a/tools/mq_editor/editor.js
+++ b/tools/mq_editor/editor.js
@@ -65,8 +65,9 @@ class PartialEditor {
const color = this.viewer ? this.viewer.partialColor(index) : '#888';
const titleEl = document.getElementById('propTitle');
- const badge = partial.resonator && partial.resonator.enabled
- ? ' <span class="res-badge">RES</span>' : '';
+ const badge = (partial.resonator && partial.resonator.enabled)
+ ? ' <span class="res-badge">RES</span>'
+ : ' <span class="res-badge sine-badge">SINE</span>';
titleEl.innerHTML = 'Partial #' + index + badge;
document.getElementById('propSwatch').style.background = color;
@@ -86,6 +87,7 @@ class PartialEditor {
muteBtn.textContent = partial.muted ? 'Unmute' : 'Mute';
muteBtn.style.color = partial.muted ? '#fa4' : '';
+
this._buildCurveGrid(this._freqGrid, partial, 'freqCurve', 'f', index);
this._buildCurveGrid(this._ampGrid, partial, 'freqCurve', 'a', index, 'a');
this._buildSynthGrid(partial, index);
@@ -124,7 +126,7 @@ class PartialEditor {
const grid = this._synthGrid;
grid.innerHTML = '';
- const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 };
const resDefaults = { r: 0.995, gainComp: 1.0 };
const isResonator = !!(partial.resonator && partial.resonator.enabled);
@@ -158,11 +160,10 @@ class PartialEditor {
const harm = partial.harmonics || {};
const sinParams = [
- { key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' },
- { key: 'freq_mult', label: 'h.freq', step: '0.01' },
- { key: 'jitter', label: 'jitter', step: '0.001' },
- { key: 'spread_above', label: 'spread ↑', step: '0.001' },
- { key: 'spread_below', label: 'spread ↓', step: '0.001' },
+ { key: 'decay', label: 'h.decay', step: '0.01', max: '0.90' },
+ { key: 'freq_mult', label: 'h.freq', step: '0.01' },
+ { key: 'jitter', label: 'jitter', step: '0.001' },
+ { key: 'spread', label: 'spread', step: '0.001' },
];
const sinInputs = {};
for (const p of sinParams) {
@@ -206,22 +207,20 @@ class PartialEditor {
// Auto-detect spread button
const autoLbl = document.createElement('span');
- autoLbl.textContent = 'spread';
+ autoLbl.textContent = '';
const autoBtn = document.createElement('button');
- autoBtn.textContent = 'Auto';
- autoBtn.title = 'Infer spread_above/below from frequency variance around the bezier curve';
+ autoBtn.textContent = 'Auto spread';
+ autoBtn.title = 'Infer spread from half-power bandwidth around the bezier curve';
autoBtn.addEventListener('click', () => {
if (!this.partials) return;
const p = this.partials[index];
const sc = this.viewer ? this.viewer.stftCache : null;
const sr = this.viewer ? this.viewer.audioBuffer.sampleRate : 44100;
const fs = sc ? sc.fftSize : 2048;
- const {spread_above, spread_below} = autodetectSpread(p, sc, fs, sr);
+ const {spread} = autodetectSpread(p, sc, fs, sr);
if (!p.harmonics) p.harmonics = { ...harmDefaults };
- p.harmonics.spread_above = spread_above;
- p.harmonics.spread_below = spread_below;
- sinInputs['spread_above'].value = spread_above.toFixed(4);
- sinInputs['spread_below'].value = spread_below.toFixed(4);
+ p.harmonics.spread = spread;
+ sinInputs['spread'].value = spread.toFixed(4);
});
sinSection.appendChild(autoLbl);
sinSection.appendChild(autoBtn);
@@ -297,12 +296,14 @@ class PartialEditor {
if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
this.partials[index].resonator.enabled = false;
applyMode(false);
+ if (this.viewer) this.viewer.render();
});
btnRes.addEventListener('click', () => {
if (!this.partials) return;
if (!this.partials[index].resonator) this.partials[index].resonator = { ...resDefaults };
this.partials[index].resonator.enabled = true;
applyMode(true);
+ if (this.viewer) this.viewer.render();
});
}
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index 605d91a..0639c8d 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -19,7 +19,8 @@
<span class="toolbar-sep"></span>
<span class="toolbar-group">
<button id="extractBtn" disabled>Extract Partials</button>
- <button id="autoSpreadAllBtn" disabled>Auto Spread All</button>
+ <button id="newPartialBtn" disabled>+ Partial</button>
+ <button id="clearAllBtn" disabled>✕ Clear All</button>
</span>
<span class="toolbar-sep"></span>
<span class="toolbar-group">
@@ -28,11 +29,6 @@
</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>
@@ -71,6 +67,39 @@
<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 class="param-group param-synth">
+ <span class="group-label">Synthesis</span>
+ <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;">
+ <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">
+ <span id="globalRVal" class="slider-val">0.9950</span>
+ </label>
+ <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">
+ <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>
+ <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">
+ <span id="lpK1Val" class="slider-val">bypass</span>
+ </label>
+ <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">
+ <span id="hpK2Val" class="slider-val">bypass</span>
+ </label>
+ <div style="padding-top:4px;">
+ <button id="autoSpreadAllBtn" disabled style="width:100%;padding:4px 8px;font-size:12px;margin:0;">Auto Spread All</button>
+ </div>
+ </div>
</div>
</span>
</div>
@@ -105,75 +134,48 @@
</div>
</div>
- <div class="right-panel">
- <!-- 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"></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 class="right-col">
+ <div class="right-panel">
+ <!-- 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"></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 <kbd>Del</kbd></button>
+ </div>
</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 <kbd>Del</kbd></button>
- </div>
- </div>
- <div id="noSelMsg">Click a partial to select</div>
+ <div id="noSelMsg">Click a partial to select</div>
+ </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;">
- <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">
- <span id="globalRVal" class="slider-val">0.9950</span>
- </label>
- <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">
- <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 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">
- <span id="lpK1Val" class="slider-val">bypass</span>
- </label>
- <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">
- <span id="hpK2Val" class="slider-val">bypass</span>
- </label>
- </div>
+ <!-- Partial spectrum viewer (below right panel, since it's related) -->
+ <div id="partialSpectrumViewer">
+ <canvas id="partialSpectrumCanvas" width="258" height="100"></canvas>
</div>
</div>
</div>
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 47c21b9..0940cf1 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -270,13 +270,12 @@ function expandPartialsLeft(partials, frames) {
}
}
-// Autodetect spread_above / spread_below from the spectrogram.
-// For each (subsampled) STFT frame within the partial, measures the
-// half-power (-3dB) width of the spectral peak above and below the center.
-// spread = half_bandwidth / f0 (fractional).
+// Autodetect spread from the spectrogram.
+// Measures -3dB half-bandwidth above and below the peak in each STFT frame,
+// returns spread = max(above, below) / f0 as a fractional frequency offset.
function autodetectSpread(partial, stftCache, fftSize, sampleRate) {
const curve = partial.freqCurve;
- if (!curve || !stftCache) return {spread_above: 0.02, spread_below: 0.02};
+ if (!curve || !stftCache) return {spread: 0.02};
const numFrames = stftCache.getNumFrames();
const binHz = sampleRate / fftSize;
@@ -331,9 +330,9 @@ function autodetectSpread(partial, stftCache, fftSize, sampleRate) {
++count;
}
- const spread_above = count > 0 ? Math.sqrt(sumAbove / count) : 0.01;
- const spread_below = count > 0 ? Math.sqrt(sumBelow / count) : 0.01;
- return {spread_above, spread_below};
+ const sa = count > 0 ? Math.sqrt(sumAbove / count) : 0.01;
+ const sb = count > 0 ? Math.sqrt(sumBelow / count) : 0.01;
+ return {spread: Math.max(sa, sb)};
}
// Track a single partial starting from a (time, freq) seed position.
@@ -428,7 +427,7 @@ function trackFromSeed(frames, seedTime, seedFreq, params) {
return {
times: allTimes, freqs: allFreqs, amps: allAmps, phases: allPhases,
muted: false, freqCurve,
- harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.02 },
};
}
@@ -522,7 +521,7 @@ function trackIsoContour(stftCache, seedTime, seedFreq, params) {
times: allTimes, freqs: allFreqs, amps: allAmps,
phases: new Array(allTimes.length).fill(0),
muted: false, freqCurve,
- harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread: 0.15 },
};
}
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
index e5f7e1a..1029626 100644
--- a/tools/mq_editor/mq_synth.js
+++ b/tools/mq_editor/mq_synth.js
@@ -26,22 +26,22 @@ function buildHarmonics(harmonics) {
// Synthesize audio from MQ partials
// partials: array of {freqCurve (with a0-a3 for amp), harmonics?, resonator?}
-// harmonics: {decay, freq_mult, jitter, spread_above, spread_below}
+// harmonics: {decay, freq_mult, jitter, spread}
// resonator: {enabled, r, gainComp} — two-pole resonator mode per partial
// integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq)
// false = 2π*f*t (simpler, only correct for constant freq)
// options.k1: LP coefficient in (0,1] — omit to bypass
// options.k2: HP coefficient in (0,1] — omit to bypass
+// options.disableJitter: true = suppress per-sample frequency jitter
function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, options = {}) {
const numSamples = Math.floor(sampleRate * duration);
const pcm = new Float32Array(numSamples);
const defaultHarmonics = {
- decay: 0.0,
- freq_mult: 1.0,
- jitter: 0.05,
- spread_above: 0.02,
- spread_below: 0.02
+ decay: 0.0,
+ freq_mult: 1.0,
+ jitter: 0.05,
+ spread: 0.02
};
// Pre-build per-partial configs with fixed spread/jitter and phase accumulators
@@ -62,35 +62,38 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
: (res.gainComp != null ? res.gainComp : 1.0);
const gainNorm = Math.sqrt(Math.max(0, 1.0 - r * r));
- // Build harmonic list (jitter/spread not applied to resonator)
+ // Build harmonic list (spread not applied to resonator; jitter modulates center freq)
const harm = partial.harmonics || defaultHarmonics;
const harmonicList = buildHarmonics(harm);
+ const jitter = options.disableJitter ? 0.0 : (harm.jitter ?? 0.0);
configs.push({
mode: 'resonator',
fc,
r, gainComp, gainNorm,
harmonicList,
+ jitter,
y1: new Float64Array(harmonicList.length),
y2: new Float64Array(harmonicList.length),
- noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0
+ noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0,
+ jitterSeed: ((p * 6364136223 + 1442695040) & 0xFFFFFFFF) >>> 0
});
} else {
// --- Sinusoidal (harmonic) mode ---
const harm = partial.harmonics || defaultHarmonics;
- const spread_above = harm.spread_above ?? 0.0;
- const spread_below = harm.spread_below ?? 0.0;
- const jitter = harm.jitter ?? 0.0;
+ const spread = harm.spread ?? 0.0;
+ const jitter = options.disableJitter ? 0.0 : (harm.jitter ?? 0.0);
const harmonicList = buildHarmonics(harm);
const replicaData = [];
for (let h = 0; h < harmonicList.length; ++h) {
- const hc = harmonicList[h];
- const spread = randFloat(p * 67890 + h * 999, -spread_below, spread_above);
- const initPhase = randFloat(p * 67890 + h, 0.0, 1.0) * jitter * 2.0 * Math.PI;
- replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread, phase: initPhase });
+ const hc = harmonicList[h];
+ const spreadVal = randFloat(p * 67890 + h * 999, -spread, spread);
+ const initPhase = randFloat(p * 67890 + h, 0.0, 1.0) * 2.0 * Math.PI;
+ const jitterSeed = ((p * 12345 + h * 67890 + 999) & 0xFFFFFFFF) >>> 0;
+ replicaData.push({ ratio: hc.ratio, ampMult: hc.ampMult, spread: spreadVal, phase: initPhase, jitterSeed });
}
- configs.push({ mode: 'sinusoid', fc, replicaData });
+ configs.push({ mode: 'sinusoid', fc, replicaData, jitter });
}
}
@@ -114,9 +117,14 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
cfg.noiseSeed = (Math.imul(1664525, cfg.noiseSeed) + 1013904223) >>> 0;
const noise = cfg.noiseSeed / 0x100000000 * 2.0 - 1.0;
+ // Per-sample frequency jitter on resonator center freq
+ cfg.jitterSeed = (Math.imul(1664525, cfg.jitterSeed) + 1013904223) >>> 0;
+ const jNoise = cfg.jitterSeed / 0x100000000 * 2.0 - 1.0;
+ const f0j = f0 * (1.0 + jNoise * cfg.jitter);
+
for (let h = 0; h < cfg.harmonicList.length; ++h) {
const hc = cfg.harmonicList[h];
- const fh = f0 * hc.ratio;
+ const fh = f0j * hc.ratio;
const omega = 2.0 * Math.PI * fh / sampleRate;
const b1 = 2.0 * cfg.r * Math.cos(omega);
@@ -140,7 +148,10 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
let phase;
if (integratePhase) {
- rep.phase += 2.0 * Math.PI * f / sampleRate;
+ // Per-sample frequency jitter: ±jitter fraction of instantaneous freq
+ rep.jitterSeed = (Math.imul(1664525, rep.jitterSeed) + 1013904223) >>> 0;
+ const jNoise = rep.jitterSeed / 0x100000000 * 2.0 - 1.0;
+ rep.phase += 2.0 * Math.PI * f / sampleRate * (1.0 + jNoise * cfg.jitter);
phase = rep.phase;
} else {
phase = 2.0 * Math.PI * f * t + rep.phase;
diff --git a/tools/mq_editor/style.css b/tools/mq_editor/style.css
index ed518f5..399746c 100644
--- a/tools/mq_editor/style.css
+++ b/tools/mq_editor/style.css
@@ -12,6 +12,7 @@ input[type="file"] { display: none; }
.main-area { display: flex; align-items: flex-start; gap: 10px; margin-top: 10px; }
.canvas-col { display: flex; flex-direction: column; gap: 6px; flex-shrink: 0; }
.canvas-wrap { position: relative; }
+.right-col { display: flex; flex-direction: column; gap: 6px; }
/* === Toolbar === */
.toolbar { margin-bottom: 10px; padding: 10px; background: #2a2a2a; border-radius: 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
@@ -36,6 +37,14 @@ button.contour-active { background: #145; border-color: #0cc; color: #aff; }
.param-group:last-child { border-bottom: none; }
.group-label { grid-column: 1 / -1; font-size: 9px; color: #666; text-transform: uppercase; letter-spacing: 1px; }
+/* Synthesis param group (column layout) */
+.param-synth { display: block; padding: 8px 14px; border-bottom: 1px solid #333; }
+.param-synth:last-child { border-bottom: none; }
+.param-synth .group-label { display: block; margin-bottom: 4px; }
+.param-synth label { display: flex; align-items: center; gap: 6px; font-size: 12px; margin: 2px 0; cursor: pointer; white-space: nowrap; }
+.param-synth label input[type="range"] { flex: 1; min-width: 80px; max-width: 120px; }
+.param-synth #globalResParams { margin-top: 4px; padding: 4px 0 2px 12px; border-left: 2px solid #555; }
+
/* === Canvas & overlays === */
#canvas { border: 1px solid #555; background: #000; cursor: crosshair; display: block; flex-shrink: 0; }
#cursorCanvas, #playheadCanvas { position: absolute; top: 0; left: 0; pointer-events: none; }
@@ -43,6 +52,10 @@ button.contour-active { background: #145; border-color: #0cc; color: #aff; }
#keepOverlay { position: absolute; bottom: 10px; left: 10px; background: rgba(30,30,30,.88); border: 1px solid #555; border-radius: 3px; padding: 4px 8px; display: flex; align-items: center; gap: 6px; font-size: 12px; color: #aaa; user-select: none; }
#keepOverlay input[type="range"] { width: 90px; }
+/* Partial spectrum (below right panel) */
+#partialSpectrumViewer { width: 260px; box-sizing: border-box; background: rgba(30,30,30,.9); border: 1px solid #555; border-radius: 3px; overflow: hidden; }
+#partialSpectrumCanvas { display: block; }
+
/* === Amp edit panel === */
#ampEditPanel { display: none; }
.amp-edit-header { font-size: 10px; color: #555; padding: 2px 0 3px 1px; display: flex; align-items: center; gap: 10px; text-transform: uppercase; letter-spacing: .5px; }
@@ -58,7 +71,7 @@ button.contour-active { background: #145; border-color: #0cc; color: #aff; }
#noSelMsg { color: #555; font-size: 13px; padding: 2px 0; }
/* === Partial properties === */
-.prop-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; padding: 2px 0; }
+.prop-row { display: flex; justify-content: space-between; align-items: center; font-size: 13px; padding: 2px 0; }
.prop-label { color: #777; font-size: 12px; }
#propSwatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
.partial-actions { display: flex; gap: 4px; margin-top: 8px; }
@@ -77,12 +90,10 @@ kbd { font-size: 10px; opacity: 0.55; }
.synth-field-wrap input[type="number"]:focus,
.synth-grid input[type="number"]:focus { border-color: #666; outline: none; }
-/* === Synth section === */
-.synth-section { border-top: 1px solid #444; padding-top: 8px; margin-top: auto; }
+/* === Synth grid (per-partial synth tab) === */
.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; }
-#globalResParams { margin-top: 4px; padding: 4px 0 2px 12px; border-left: 2px solid #555; }
.slider-val { width: 44px; text-align: right; }
/* === Jog slider === */
@@ -93,11 +104,12 @@ kbd { font-size: 10px; opacity: 0.55; }
/* === Resonator badge === */
.res-badge { font-size: 9px; color: #8cf; border: 1px solid #8cf; border-radius: 2px; padding: 0 3px; vertical-align: middle; margin-left: 4px; opacity: .8; }
+.sine-badge { color: #aaa; border-color: #aaa; }
/* === Status & tooltip === */
#fileLabel { font-size: 13px; color: #8af; opacity: .8; }
-#status { margin-top: 10px; padding: 8px; background: #2a2a2a; border-radius: 4px; min-height: 20px; }
-.info { color: #4af; }
+#status { margin-top: 10px; padding: 8px; background: #2a2a2a; border-radius: 4px; min-height: 20px; color: #ccc; }
+.info { color: #ccc; }
.warn { color: #fa4; }
.error { color: #f44; }
#tooltip { 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; }
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 677e5b5..1ac1afd 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -50,6 +50,14 @@ class SpectrogramViewer {
this.showSynthFFT = false; // Toggle: false=original, true=synth
this.synthStftCache = null;
+ // Partial spectrum viewer
+ this.partialSpectrumCanvas = document.getElementById('partialSpectrumCanvas');
+ this.partialSpectrumCtx = this.partialSpectrumCanvas ? this.partialSpectrumCanvas.getContext('2d') : null;
+ this._partialSpecCache = null; // {partialIndex, time, specData?} — see renderPartialSpectrum
+ this._partialRangeCache = null; // {partialIndex, dbMin, dbMax} — scanned across full partial duration
+ this.synthOpts = {}; // synth options forwarded to synthesizeMQ (forceResonator, etc.)
+ this.onGetSynthOpts = null; // callback() → opts; called before each spectrum compute
+
// Selection and editing
this.selectedPartial = -1;
this.dragState = null; // {pointIndex: 0-3}
@@ -128,6 +136,7 @@ class SpectrogramViewer {
if (time >= 0) {
this.spectrumTime = time;
this.renderSpectrum();
+ this.renderPartialSpectrum(time);
} else if (this.mouseX >= 0) {
this.spectrumTime = this.canvasToTime(this.mouseX);
}
@@ -170,6 +179,8 @@ class SpectrogramViewer {
}
selectPartial(index) {
+ this._partialSpecCache = null;
+ this._partialRangeCache = null;
this.selectedPartial = index;
this.render();
if (this.onPartialSelect) this.onPartialSelect(index);
@@ -219,6 +230,7 @@ class SpectrogramViewer {
this.drawAxes();
this.drawPlayhead();
this.renderSpectrum();
+ this.renderPartialSpectrum(this.spectrumTime, true);
if (this.onRender) this.onRender();
}
@@ -295,12 +307,11 @@ class SpectrogramViewer {
const {ctx} = this;
const curve = partial.freqCurve;
const harm = partial.harmonics || {};
- const sa = harm.spread_above != null ? harm.spread_above : 0.02;
- const sb = harm.spread_below != null ? harm.spread_below : 0.02;
+ const spread = harm.spread != null ? harm.spread : 0.02;
const decay = harm.decay != null ? harm.decay : 0.0;
const freqMult = harm.freq_mult != null ? harm.freq_mult : 2.0;
- const {upper, lower} = buildBandPoints(this, curve, sa, sb);
+ const {upper, lower} = buildBandPoints(this, curve, spread, spread);
if (upper.length < 2) return;
const savedAlpha = ctx.globalAlpha;
@@ -370,7 +381,7 @@ class SpectrogramViewer {
}
// Spread band fill + boundary dashes
- const {upper: hu, lower: hl} = buildBandPoints(this, curve, sa, sb, hRatio);
+ const {upper: hu, lower: hl} = buildBandPoints(this, curve, spread, spread, hRatio);
if (hu.length >= 2) {
ctx.beginPath();
ctx.moveTo(hu[0][0], hu[0][1]);
@@ -638,6 +649,162 @@ class SpectrogramViewer {
ctx.fillText(useSynth ? 'SYNTH [a]' : 'ORIG [a]', 4, 10);
}
+ // Draw synthesized power spectrum of the selected partial into partialSpectrumCanvas.
+ // X-axis: log frequency (same scale as main view). Y-axis: dB (normalised to peak).
+ // specTime = mouse time if inside partial's [t0,t3], else center of partial's interval.
+ // Cached on {partialIndex, specTime}; force=true bypasses cache (param changes, synth toggle).
+ renderPartialSpectrum(time, force = false) {
+ const ctx = this.partialSpectrumCtx;
+ if (!ctx) return;
+
+ const canvas = this.partialSpectrumCanvas;
+ const width = canvas.width;
+ const height = canvas.height;
+ const p = this.selectedPartial;
+
+ const showMsg = (msg) => {
+ ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, width, height);
+ ctx.font = '9px monospace'; ctx.fillStyle = '#333';
+ ctx.fillText(msg, 4, height / 2 + 4);
+ this._partialSpecCache = null;
+ };
+
+ if (p < 0 || !this.partials || p >= this.partials.length) { showMsg('no partial'); return; }
+
+ const partial = this.partials[p];
+ const curve = partial.freqCurve;
+ if (!curve) { showMsg('no curve'); return; }
+
+ // Use mouse time if inside partial's window, else center of partial
+ const specTime = (time >= curve.t0 && time <= curve.t3)
+ ? time
+ : (curve.t0 + curve.t3) / 2;
+
+ // Cache check — must happen before clearing the canvas
+ if (!force && this._partialSpecCache &&
+ this._partialSpecCache.partialIndex === p &&
+ this._partialSpecCache.time === specTime) return;
+
+ ctx.fillStyle = '#1e1e1e';
+ ctx.fillRect(0, 0, width, height);
+ ctx.font = '9px monospace';
+
+ // Synthesize window → FFT → power spectrum
+ const specData = this._computePartialSpectrum(partial, specTime);
+ this._partialSpecCache = {partialIndex: p, time: specTime, specData};
+
+ // dB range: scanned across full partial duration, cached per partial
+ if (!this._partialRangeCache || this._partialRangeCache.partialIndex !== p) {
+ this._partialRangeCache = this._computePartialRange(p, partial);
+ }
+ const {dbMin: DB_MIN, dbMax: DB_MAX} = this._partialRangeCache;
+
+ const {squaredAmp, sampleRate, fftSize} = specData;
+ const numBins = fftSize / 2;
+ const binWidth = sampleRate / fftSize;
+ const color = this.partialColor(p);
+ const cr = parseInt(color[1] + color[1], 16);
+ const cg = parseInt(color[2] + color[2], 16);
+ const cb = parseInt(color[3] + color[3], 16);
+
+ for (let px = 0; px < width; ++px) {
+ const fStart = this.normToFreq(px / width);
+ const fEnd = this.normToFreq((px + 1) / width);
+ const bStart = Math.max(0, Math.floor(fStart / binWidth));
+ const bEnd = Math.min(numBins - 1, Math.ceil(fEnd / binWidth));
+ if (bStart > bEnd) continue;
+
+ let maxSq = 0;
+ for (let b = bStart; b <= bEnd; ++b) if (squaredAmp[b] > maxSq) maxSq = squaredAmp[b];
+
+ const magDB = 10 * Math.log10(Math.max(maxSq, 1e-20));
+ const barH = Math.round(Math.max(0, Math.min(1, (magDB - DB_MIN) / (DB_MAX - DB_MIN))) * (height - 12));
+ if (barH <= 0) continue;
+
+ const grad = ctx.createLinearGradient(0, height - barH, 0, height);
+ grad.addColorStop(0, color);
+ grad.addColorStop(1, `rgba(${cr},${cg},${cb},0.53)`);
+ ctx.fillStyle = grad;
+ ctx.fillRect(px, height - barH, 1, barH);
+ }
+
+ ctx.fillStyle = color;
+ ctx.fillText('P#' + p + ' @' + specTime.toFixed(3) + 's', 4, 10);
+
+ const amp = evalBezierAmp(curve, specTime);
+ ctx.fillStyle = '#f44';
+ ctx.textAlign = 'right';
+ ctx.fillText('A=' + amp.toFixed(3), width - 3, 10);
+ ctx.textAlign = 'left';
+ }
+
+ // Synthesise a 2048-sample Hann-windowed frame of `partial` centred on `time`, run FFT,
+ // return {squaredAmp, maxDB, sampleRate, fftSize}. Uses this.synthOpts (forceResonator etc).
+ // freqCurve times are shifted so synthesizeMQ's t=0 aligns with tStart = time − window/2.
+ _computePartialSpectrum(partial, time) {
+ if (this.onGetSynthOpts) this.synthOpts = this.onGetSynthOpts();
+ const sampleRate = this.audioBuffer.sampleRate;
+ const FFT_SIZE = 2048;
+ const windowDuration = FFT_SIZE / sampleRate;
+ const tStart = time - windowDuration / 2;
+
+ // Shift curve times so synthesis window [0, windowDuration] maps to [tStart, tStart+windowDuration]
+ const fc = partial.freqCurve;
+ const shiftedPartial = {
+ ...partial,
+ freqCurve: {
+ t0: fc.t0 - tStart, t1: fc.t1 - tStart,
+ t2: fc.t2 - tStart, t3: fc.t3 - tStart,
+ v0: fc.v0, v1: fc.v1, v2: fc.v2, v3: fc.v3,
+ a0: fc.a0, a1: fc.a1, a2: fc.a2, a3: fc.a3,
+ },
+ };
+
+ const pcm = synthesizeMQ([shiftedPartial], sampleRate, windowDuration, true, this.synthOpts);
+
+ // Hann window
+ for (let i = 0; i < FFT_SIZE; ++i) {
+ pcm[i] *= 0.5 * (1 - Math.cos(2 * Math.PI * i / (FFT_SIZE - 1)));
+ }
+
+ // FFT
+ const real = new Float32Array(FFT_SIZE);
+ const imag = new Float32Array(FFT_SIZE);
+ for (let i = 0; i < FFT_SIZE; ++i) real[i] = pcm[i];
+ fftForward(real, imag, FFT_SIZE);
+
+ // Power spectrum
+ const squaredAmp = new Float32Array(FFT_SIZE / 2);
+ for (let i = 0; i < FFT_SIZE / 2; ++i) {
+ squaredAmp[i] = (real[i] * real[i] + imag[i] * imag[i]) / (FFT_SIZE * FFT_SIZE);
+ }
+
+ // maxDB for normalizing the display
+ let maxSq = 1e-20;
+ for (let i = 0; i < squaredAmp.length; ++i) if (squaredAmp[i] > maxSq) maxSq = squaredAmp[i];
+ const maxDB = 10 * Math.log10(maxSq);
+
+ return {squaredAmp, maxDB, sampleRate, fftSize: FFT_SIZE};
+ }
+
+ // Scan the partial across its full duration to find the peak dB level, then derive
+ // [dbMin, dbMax] as [peak − 60, peak]. Cached per partialIndex; only called once on select.
+ _computePartialRange(partialIndex, partial) {
+ const fc = partial.freqCurve;
+ if (!fc) return {partialIndex, dbMin: -60, dbMax: 0};
+ const N = 8;
+ let globalMaxSq = 1e-20;
+ for (let i = 0; i < N; ++i) {
+ const t = fc.t0 + (fc.t3 - fc.t0) * (i + 0.5) / N;
+ const {squaredAmp} = this._computePartialSpectrum(partial, t);
+ for (let b = 0; b < squaredAmp.length; ++b) {
+ if (squaredAmp[b] > globalMaxSq) globalMaxSq = squaredAmp[b];
+ }
+ }
+ const dbMax = 10 * Math.log10(globalMaxSq);
+ return {partialIndex, dbMin: dbMax - 60, dbMax};
+ }
+
// --- View management ---
updateViewBounds() {
@@ -731,6 +898,7 @@ class SpectrogramViewer {
if (this.playheadTime < 0) {
this.spectrumTime = time;
this.renderSpectrum();
+ this.renderPartialSpectrum(time);
}
// Cursor hint for control points (skip in explore mode)