summaryrefslogtreecommitdiff
path: root/tools/mq_editor
diff options
context:
space:
mode:
Diffstat (limited to 'tools/mq_editor')
-rw-r--r--tools/mq_editor/app.js14
-rw-r--r--tools/mq_editor/editor.js30
-rw-r--r--tools/mq_editor/mq_extract.js4
-rw-r--r--tools/mq_editor/mq_synth.js109
-rw-r--r--tools/mq_editor/utils.js17
-rw-r--r--tools/mq_editor/viewer.js60
6 files changed, 166 insertions, 68 deletions
diff --git a/tools/mq_editor/app.js b/tools/mq_editor/app.js
index 1e17adf..9df00fb 100644
--- a/tools/mq_editor/app.js
+++ b/tools/mq_editor/app.js
@@ -220,7 +220,9 @@ function loadAudioBuffer(buffer, label) {
if (!extractedPartials) extractedPartials = [];
pushUndo();
const {spread_above, spread_below} = autodetectSpread(partial, stftCache, fftSize, audioBuffer.sampleRate);
- partial.replicas = { ...partial.replicas, spread_above, spread_below };
+ 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;
extractedPartials.unshift(partial);
refreshPartialsView(0);
setStatus(`${exploreMode}: added partial (${extractedPartials.length} total)`, 'info');
@@ -331,7 +333,7 @@ function createNewPartial() {
v0: 440, v1: 440, v2: 440, v3: 440,
a0: 1.0, a1: 1.0, a2: 1.0, a3: 1.0,
},
- replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
};
extractedPartials.unshift(newPartial);
refreshPartialsView(0);
@@ -355,12 +357,12 @@ function autoSpreadAll() {
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 };
+ const defaults = { decay: 0.0, freq_mult: 2.0, 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 (!p.harmonics) p.harmonics = { ...defaults };
+ p.harmonics.spread_above = spread_above;
+ p.harmonics.spread_below = spread_below;
}
if (viewer) viewer.render();
const sel = viewer ? viewer.selectedPartial : -1;
diff --git a/tools/mq_editor/editor.js b/tools/mq_editor/editor.js
index 6ea6d73..b07664e 100644
--- a/tools/mq_editor/editor.js
+++ b/tools/mq_editor/editor.js
@@ -124,8 +124,8 @@ class PartialEditor {
const grid = this._synthGrid;
grid.innerHTML = '';
- const repDefaults = { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
- const resDefaults = { r: 0.995, gainComp: 1.0 };
+ const harmDefaults = { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 };
+ const resDefaults = { r: 0.995, gainComp: 1.0 };
const isResonator = !!(partial.resonator && partial.resonator.enabled);
@@ -156,16 +156,17 @@ class PartialEditor {
sinSection.style.cssText = 'display:contents;';
sinSection.dataset.section = 'sinusoid';
- const rep = partial.replicas || {};
+ const harm = partial.harmonics || {};
const sinParams = [
- { key: 'decay_alpha', label: 'decay', 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_above', label: 'spread ↑', step: '0.001' },
{ key: 'spread_below', label: 'spread ↓', step: '0.001' },
];
const sinInputs = {};
for (const p of sinParams) {
- const val = rep[p.key] != null ? rep[p.key] : repDefaults[p.key];
+ const val = harm[p.key] != null ? harm[p.key] : harmDefaults[p.key];
const lbl = document.createElement('span');
lbl.textContent = p.label;
const inp = document.createElement('input');
@@ -173,22 +174,25 @@ class PartialEditor {
inp.value = val.toFixed(3);
inp.step = p.step;
inp.min = '0';
+ if (p.max) inp.max = p.max;
inp.addEventListener('change', (e) => {
if (!this.partials) return;
- const v = parseFloat(e.target.value);
+ let v = parseFloat(e.target.value);
if (isNaN(v)) return;
- if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults };
- this.partials[index].replicas[p.key] = v;
+ if (p.max) v = Math.min(v, parseFloat(p.max));
+ if (!this.partials[index].harmonics) this.partials[index].harmonics = { ...harmDefaults };
+ this.partials[index].harmonics[p.key] = v;
if (this.viewer) this.viewer.render();
});
sinInputs[p.key] = inp;
const jog = this._makeJogSlider(inp, {
step: parseFloat(p.step),
+ max: p.max ? parseFloat(p.max) : undefined,
onUpdate: (newVal) => {
if (!this.partials || !this.partials[index]) return;
- if (!this.partials[index].replicas) this.partials[index].replicas = { ...repDefaults };
- this.partials[index].replicas[p.key] = newVal;
+ if (!this.partials[index].harmonics) this.partials[index].harmonics = { ...harmDefaults };
+ this.partials[index].harmonics[p.key] = newVal;
if (this.viewer) this.viewer.render();
}
});
@@ -213,9 +217,9 @@ class PartialEditor {
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);
- if (!p.replicas) p.replicas = { ...repDefaults };
- p.replicas.spread_above = spread_above;
- p.replicas.spread_below = spread_below;
+ 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);
});
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 42215d3..47c21b9 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -428,7 +428,7 @@ function trackFromSeed(frames, seedTime, seedFreq, params) {
return {
times: allTimes, freqs: allFreqs, amps: allAmps, phases: allPhases,
muted: false, freqCurve,
- replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.02, spread_below: 0.02 },
};
}
@@ -522,7 +522,7 @@ function trackIsoContour(stftCache, seedTime, seedFreq, params) {
times: allTimes, freqs: allFreqs, amps: allAmps,
phases: new Array(allTimes.length).fill(0),
muted: false, freqCurve,
- replicas: { decay_alpha: 0.1, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 },
+ harmonics: { decay: 0.0, freq_mult: 2.0, jitter: 0.05, spread_above: 0.15, spread_below: 0.15 },
};
}
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
index a9f387c..e5f7e1a 100644
--- a/tools/mq_editor/mq_synth.js
+++ b/tools/mq_editor/mq_synth.js
@@ -1,5 +1,5 @@
// MQ Synthesizer
-// Replica oscillator bank for sinusoidal synthesis, plus two-pole resonator mode
+// Harmonic oscillator bank for sinusoidal synthesis, plus two-pole resonator mode
// Deterministic LCG PRNG
function randFloat(seed, min, max) {
@@ -7,9 +7,26 @@ function randFloat(seed, min, max) {
return min + (seed / 0x100000000) * (max - min);
}
+// Build harmonic list from harmonics config.
+// Fundamental (ratio=1.0, ampMult=1.0) is always first.
+// Then harmonics at n*freq_mult for n=1,2,... with ampMult=decay^n (added on top).
+function buildHarmonics(harmonics) {
+ const decay = Math.min(harmonics.decay ?? 0.0, 0.90);
+ const freqMult = harmonics.freq_mult ?? 2.0;
+ const result = [{ ratio: 1.0, ampMult: 1.0 }]; // fundamental always
+ if (decay > 0) {
+ for (let n = 1; ; ++n) {
+ const ampMult = Math.pow(decay, n);
+ if (ampMult < 0.001) break;
+ result.push({ ratio: n * freqMult, ampMult });
+ }
+ }
+ return result;
+}
+
// Synthesize audio from MQ partials
-// partials: array of {freqCurve (with a0-a3 for amp), replicas?, resonator?}
-// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}
+// partials: array of {freqCurve (with a0-a3 for amp), harmonics?, resonator?}
+// harmonics: {decay, freq_mult, jitter, spread_above, spread_below}
// 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)
@@ -19,15 +36,12 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
const numSamples = Math.floor(sampleRate * duration);
const pcm = new Float32Array(numSamples);
- const jitterMult = options.disableJitter ? 0 : 1;
- const spreadMult = options.disableSpread ? 0 : 1;
-
- const defaultReplicas = {
- offsets: [1.0],
- decay_alpha: 0.1,
- jitter: 0.05,
- spread_above: 0.02,
- spread_below: 0.02
+ const defaultHarmonics = {
+ decay: 0.0,
+ freq_mult: 1.0,
+ jitter: 0.05,
+ spread_above: 0.02,
+ spread_below: 0.02
};
// Pre-build per-partial configs with fixed spread/jitter and phase accumulators
@@ -47,29 +61,36 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
const gainComp = options.forceRGain ? options.globalGain
: (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)
+ const harm = partial.harmonics || defaultHarmonics;
+ const harmonicList = buildHarmonics(harm);
+
configs.push({
mode: 'resonator',
fc,
r, gainComp, gainNorm,
- y1: 0.0, y2: 0.0,
+ harmonicList,
+ y1: new Float64Array(harmonicList.length),
+ y2: new Float64Array(harmonicList.length),
noiseSeed: ((p * 1664525 + 1013904223) & 0xFFFFFFFF) >>> 0
});
} else {
- // --- Sinusoidal (replica) mode ---
- const rep = partial.replicas != null ? partial.replicas : defaultReplicas;
- const offsets = rep.offsets != null ? rep.offsets : [1.0];
- const decay_alpha = rep.decay_alpha != null ? rep.decay_alpha : 0.0;
- const jitter = rep.jitter != null ? rep.jitter : 0.0;
- const spread_above = rep.spread_above != null ? rep.spread_above : 0.0;
- const spread_below = rep.spread_below != null ? rep.spread_below : 0.0;
+ // --- 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 harmonicList = buildHarmonics(harm);
const replicaData = [];
- for (let r = 0; r < offsets.length; ++r) {
- const spread = spreadMult * randFloat(p * 67890 + r * 999, -spread_below, spread_above);
- const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * (jitter * jitterMult) * 2.0 * Math.PI;
- replicaData.push({ratio: offsets[r], spread, phase: initPhase});
+ 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 });
}
- configs.push({ mode: 'sinusoid', fc, decay_alpha, replicaData });
+ configs.push({ mode: 'sinusoid', fc, replicaData });
}
}
@@ -82,34 +103,40 @@ function synthesizeMQ(partials, sampleRate, duration, integratePhase = true, opt
const {fc} = cfg;
if (cfg.mode === 'resonator') {
- if (t < fc.t0 || t > fc.t3) { cfg.y1 = 0.0; cfg.y2 = 0.0; continue; }
+ if (t < fc.t0 || t > fc.t3) {
+ cfg.y1.fill(0.0); cfg.y2.fill(0.0); continue;
+ }
- const f0 = evalBezier(fc, t);
- const A = evalBezierAmp(fc, t);
- const omega = 2.0 * Math.PI * f0 / sampleRate;
- const b1 = 2.0 * cfg.r * Math.cos(omega);
+ const f0 = evalBezier(fc, t);
+ const A = evalBezierAmp(fc, t);
- // LCG noise excitation (deterministic per-partial)
+ // LCG noise excitation (deterministic per-partial, shared across harmonics)
cfg.noiseSeed = (Math.imul(1664525, cfg.noiseSeed) + 1013904223) >>> 0;
const noise = cfg.noiseSeed / 0x100000000 * 2.0 - 1.0;
- const x = A * cfg.gainNorm * noise;
- const y = b1 * cfg.y1 - cfg.r * cfg.r * cfg.y2 + x;
- cfg.y2 = cfg.y1;
- cfg.y1 = y;
- sample += y * cfg.gainComp;
+ for (let h = 0; h < cfg.harmonicList.length; ++h) {
+ const hc = cfg.harmonicList[h];
+ const fh = f0 * hc.ratio;
+ const omega = 2.0 * Math.PI * fh / sampleRate;
+ const b1 = 2.0 * cfg.r * Math.cos(omega);
+
+ const x = A * cfg.gainNorm * noise * hc.ampMult;
+ const y = b1 * cfg.y1[h] - cfg.r * cfg.r * cfg.y2[h] + x;
+ cfg.y2[h] = cfg.y1[h];
+ cfg.y1[h] = y;
+ sample += y * cfg.gainComp;
+ }
} else {
if (t < fc.t0 || t > fc.t3) continue;
const f0 = evalBezier(fc, t);
const A0 = evalBezierAmp(fc, t);
- const {decay_alpha, replicaData} = cfg;
- for (let r = 0; r < replicaData.length; ++r) {
- const rep = replicaData[r];
- const f = f0 * rep.ratio * (1.0 + rep.spread);
- const A = A0 * Math.exp(-decay_alpha * Math.abs(f - f0));
+ for (let h = 0; h < cfg.replicaData.length; ++h) {
+ const rep = cfg.replicaData[h];
+ const f = f0 * rep.ratio * (1.0 + rep.spread);
+ const A = A0 * rep.ampMult;
let phase;
if (integratePhase) {
diff --git a/tools/mq_editor/utils.js b/tools/mq_editor/utils.js
index ed34b8e..7ab274e 100644
--- a/tools/mq_editor/utils.js
+++ b/tools/mq_editor/utils.js
@@ -55,16 +55,29 @@ function getCanvasCoords(e, canvas) {
// Build upper/lower band point arrays for a frequency curve.
// factorAbove/factorBelow are fractional offsets (e.g. 0.02 = ±2%).
+// freqMult: optional frequency scaling for harmonics (default 1.0).
// Returns { upper: [[x,y],...], lower: [[x,y],...] }
-function buildBandPoints(viewer, curve, factorAbove, factorBelow) {
+function buildBandPoints(viewer, curve, factorAbove, factorBelow, freqMult = 1.0) {
const STEPS = 60;
const upper = [], lower = [];
for (let i = 0; i <= STEPS; ++i) {
const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
if (t < viewer.t_view_min - 0.01 || t > viewer.t_view_max + 0.01) continue;
- const f = evalBezier(curve, t);
+ const f = evalBezier(curve, t) * freqMult;
upper.push([viewer.timeToX(t), viewer.freqToY(f * (1 + factorAbove))]);
lower.push([viewer.timeToX(t), viewer.freqToY(f * (1 - factorBelow))]);
}
return { upper, lower };
}
+
+// Build center line points at freq * freqMult along the curve.
+function buildCenterPoints(viewer, curve, freqMult = 1.0) {
+ const STEPS = 60;
+ const pts = [];
+ for (let i = 0; i <= STEPS; ++i) {
+ const t = curve.t0 + (curve.t3 - curve.t0) * i / STEPS;
+ if (t < viewer.t_view_min - 0.01 || t > viewer.t_view_max + 0.01) continue;
+ pts.push([viewer.timeToX(t), viewer.freqToY(evalBezier(curve, t) * freqMult)]);
+ }
+ return pts;
+}
diff --git a/tools/mq_editor/viewer.js b/tools/mq_editor/viewer.js
index 923edcc..677e5b5 100644
--- a/tools/mq_editor/viewer.js
+++ b/tools/mq_editor/viewer.js
@@ -293,10 +293,12 @@ class SpectrogramViewer {
_renderSpreadBand(partial, color) {
const {ctx} = this;
- const curve = partial.freqCurve;
- const rep = partial.replicas || {};
- const sa = rep.spread_above != null ? rep.spread_above : 0.02;
- const sb = rep.spread_below != null ? rep.spread_below : 0.02;
+ 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 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);
if (upper.length < 2) return;
@@ -346,6 +348,56 @@ class SpectrogramViewer {
ctx.setLineDash([]);
}
+ // Harmonic bands (faint, fading with decay^n)
+ if (decay > 0) {
+ for (let n = 1; ; ++n) {
+ const ampMult = Math.pow(decay, n);
+ if (ampMult < 0.001) break;
+ const hRatio = n * freqMult;
+
+ // Center line
+ const cpts = buildCenterPoints(this, curve, hRatio);
+ if (cpts.length >= 2) {
+ ctx.globalAlpha = ampMult * 0.85;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1.5;
+ ctx.setLineDash([3, 4]);
+ ctx.beginPath();
+ ctx.moveTo(cpts[0][0], cpts[0][1]);
+ for (let i = 1; i < cpts.length; ++i) ctx.lineTo(cpts[i][0], cpts[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+
+ // Spread band fill + boundary dashes
+ const {upper: hu, lower: hl} = buildBandPoints(this, curve, sa, sb, hRatio);
+ if (hu.length >= 2) {
+ ctx.beginPath();
+ ctx.moveTo(hu[0][0], hu[0][1]);
+ for (let i = 1; i < hu.length; ++i) ctx.lineTo(hu[i][0], hu[i][1]);
+ for (let i = hl.length - 1; i >= 0; --i) ctx.lineTo(hl[i][0], hl[i][1]);
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.globalAlpha = ampMult * 0.12;
+ ctx.fill();
+
+ ctx.globalAlpha = ampMult * 0.55;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([3, 5]);
+ ctx.beginPath();
+ ctx.moveTo(hu[0][0], hu[0][1]);
+ for (let i = 1; i < hu.length; ++i) ctx.lineTo(hu[i][0], hu[i][1]);
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.moveTo(hl[0][0], hl[0][1]);
+ for (let i = 1; i < hl.length; ++i) ctx.lineTo(hl[i][0], hl[i][1]);
+ ctx.stroke();
+ ctx.setLineDash([]);
+ }
+ }
+ }
+
ctx.globalAlpha = savedAlpha;
}