diff options
Diffstat (limited to 'tools/mq_editor')
| -rw-r--r-- | tools/mq_editor/app.js | 14 | ||||
| -rw-r--r-- | tools/mq_editor/editor.js | 30 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 4 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 109 | ||||
| -rw-r--r-- | tools/mq_editor/utils.js | 17 | ||||
| -rw-r--r-- | tools/mq_editor/viewer.js | 60 |
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; } |
