summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tools/mq_editor/index.html5
-rw-r--r--tools/mq_editor/mq_extract.js167
-rw-r--r--tools/mq_editor/mq_synth.js122
3 files changed, 122 insertions, 172 deletions
diff --git a/tools/mq_editor/index.html b/tools/mq_editor/index.html
index 22f8ff9..b51a988 100644
--- a/tools/mq_editor/index.html
+++ b/tools/mq_editor/index.html
@@ -85,6 +85,8 @@
<label>Threshold (dB):</label>
<input type="number" id="threshold" value="-60" step="any">
+ <label style="margin-left:16px;"><input type="checkbox" id="integratePhase" checked> Integrate phase</label>
+
<label style="margin-left:16px;">Keep:</label>
<input type="range" id="keepPct" min="1" max="100" value="100" style="width:100px; vertical-align:middle;">
<span id="keepPctLabel" style="margin-left:4px;">100%</span>
@@ -336,7 +338,8 @@
const keepCount = Math.max(1, Math.ceil(extractedPartials.length * parseInt(keepPct.value) / 100));
const partialsToUse = extractedPartials.slice(0, keepCount);
setStatus(`Synthesizing ${keepCount}/${extractedPartials.length} partials (${keepPct.value}%)...`, 'info');
- const pcm = synthesizeMQ(partialsToUse, sampleRate, duration);
+ const integratePhase = document.getElementById('integratePhase').checked;
+ const pcm = synthesizeMQ(partialsToUse, sampleRate, duration, integratePhase);
// Build STFT cache for synth signal (for FFT comparison via key 'a')
if (viewer) {
diff --git a/tools/mq_editor/mq_extract.js b/tools/mq_editor/mq_extract.js
index 878b2bc..2293d52 100644
--- a/tools/mq_editor/mq_extract.js
+++ b/tools/mq_editor/mq_extract.js
@@ -4,10 +4,9 @@
// Extract partials from audio buffer
function extractPartials(params, stftCache) {
const {fftSize, threshold, sampleRate} = params;
+ const numFrames = stftCache.getNumFrames();
- // Analyze frames from cache
const frames = [];
- const numFrames = stftCache.getNumFrames();
for (let i = 0; i < numFrames; ++i) {
const cachedFrame = stftCache.getFrameAtIndex(i);
const squaredAmp = stftCache.getSquaredAmplitude(cachedFrame.time);
@@ -15,10 +14,8 @@ function extractPartials(params, stftCache) {
frames.push({time: cachedFrame.time, peaks});
}
- // Track trajectories
- const partials = trackPartials(frames, sampleRate);
+ const partials = trackPartials(frames);
- // Fit bezier curves
for (const partial of partials) {
partial.freqCurve = fitBezier(partial.times, partial.freqs);
partial.ampCurve = fitBezier(partial.times, partial.amps);
@@ -27,15 +24,14 @@ function extractPartials(params, stftCache) {
return {partials, frames};
}
-// Detect peaks in FFT frame (squaredAmp is pre-computed cached re*re+im*im)
+// Detect spectral peaks via local maxima + parabolic interpolation
+// squaredAmp: pre-computed re*re+im*im per bin
function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
- // Convert squared amplitude to dB (10*log10 == 20*log10 of magnitude)
const mag = new Float32Array(fftSize / 2);
for (let i = 0; i < fftSize / 2; ++i) {
mag[i] = 10 * Math.log10(Math.max(squaredAmp[i], 1e-20));
}
- // Find local maxima above threshold
const peaks = [];
for (let i = 2; i < mag.length - 2; ++i) {
if (mag[i] > thresholdDB &&
@@ -44,15 +40,13 @@ function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
// Parabolic interpolation for sub-bin accuracy
const alpha = mag[i-1];
- const beta = mag[i];
+ const beta = mag[i];
const gamma = mag[i+1];
const p = 0.5 * (alpha - gamma) / (alpha - 2*beta + gamma);
- const binFreq = (i + p) * sampleRate / fftSize;
+ const freq = (i + p) * sampleRate / fftSize;
const ampDB = beta - 0.25 * (alpha - gamma) * p;
- const ampLin = Math.pow(10, ampDB / 20);
-
- peaks.push({freq: binFreq, amp: ampLin});
+ peaks.push({freq, amp: Math.pow(10, ampDB / 20)});
}
}
@@ -60,152 +54,109 @@ function detectPeaks(squaredAmp, fftSize, sampleRate, thresholdDB) {
}
// Track partials across frames (birth/death/continuation)
-function trackPartials(frames, sampleRate) {
- const partials = [];
- const activePartials = [];
- const candidatePartials = []; // Pre-birth candidates
- const trackingThresholdRatio = 0.05; // 5% frequency tolerance
- const minTrackingHz = 20; // Minimum 20 Hz
- const birthPersistence = 3; // Require 3 consecutive frames to birth
- const deathAge = 5; // Allow 5 frame gap before death
- const minPartialLength = 10; // Minimum 10 frames for valid partial
+function trackPartials(frames) {
+ const partials = [];
+ const activePartials = [];
+ const candidates = []; // pre-birth
+
+ const trackingRatio = 0.05; // 5% frequency tolerance
+ const minTrackingHz = 20;
+ const birthPersistence = 3; // frames before partial is born
+ const deathAge = 5; // frames without match before death
+ const minLength = 10; // frames required to keep partial
for (const frame of frames) {
const matched = new Set();
- // Match peaks to existing partials
+ // Continue active partials
for (const partial of activePartials) {
const lastFreq = partial.freqs[partial.freqs.length - 1];
- const threshold = Math.max(lastFreq * trackingThresholdRatio, minTrackingHz);
-
- let bestPeak = null;
- let bestDist = Infinity;
+ const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bestDist = Infinity;
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
-
- const peak = frame.peaks[i];
- const dist = Math.abs(peak.freq - lastFreq);
-
- if (dist < threshold && dist < bestDist) {
- bestPeak = peak;
- bestDist = dist;
- partial.matchIdx = i;
- }
+ const dist = Math.abs(frame.peaks[i].freq - lastFreq);
+ if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = i; }
}
- if (bestPeak) {
- // Continuation
+ if (bestIdx >= 0) {
+ const pk = frame.peaks[bestIdx];
partial.times.push(frame.time);
- partial.freqs.push(bestPeak.freq);
- partial.amps.push(bestPeak.amp);
+ partial.freqs.push(pk.freq);
+ partial.amps.push(pk.amp);
partial.age = 0;
- matched.add(partial.matchIdx);
+ matched.add(bestIdx);
} else {
- // No match
partial.age++;
}
}
- // Update candidate partials (pre-birth)
- for (let i = candidatePartials.length - 1; i >= 0; --i) {
- const candidate = candidatePartials[i];
- const lastFreq = candidate.freqs[candidate.freqs.length - 1];
- const threshold = Math.max(lastFreq * trackingThresholdRatio, minTrackingHz);
-
- let bestPeak = null;
- let bestDist = Infinity;
-
- for (let i = 0; i < frame.peaks.length; ++i) {
- if (matched.has(i)) continue;
-
- const peak = frame.peaks[i];
- const dist = Math.abs(peak.freq - lastFreq);
+ // Advance candidates
+ for (let i = candidates.length - 1; i >= 0; --i) {
+ const cand = candidates[i];
+ const lastFreq = cand.freqs[cand.freqs.length - 1];
+ const tol = Math.max(lastFreq * trackingRatio, minTrackingHz);
+ let bestIdx = -1, bestDist = Infinity;
- if (dist < threshold && dist < bestDist) {
- bestPeak = peak;
- bestDist = dist;
- candidate.matchIdx = i;
- }
+ for (let j = 0; j < frame.peaks.length; ++j) {
+ if (matched.has(j)) continue;
+ const dist = Math.abs(frame.peaks[j].freq - lastFreq);
+ if (dist < tol && dist < bestDist) { bestDist = dist; bestIdx = j; }
}
- if (bestPeak) {
- candidate.times.push(frame.time);
- candidate.freqs.push(bestPeak.freq);
- candidate.amps.push(bestPeak.amp);
- matched.add(candidate.matchIdx);
-
- // Birth if persistent enough
- if (candidate.times.length >= birthPersistence) {
- activePartials.push(candidate);
- candidatePartials.splice(i, 1);
+ if (bestIdx >= 0) {
+ const pk = frame.peaks[bestIdx];
+ cand.times.push(frame.time);
+ cand.freqs.push(pk.freq);
+ cand.amps.push(pk.amp);
+ matched.add(bestIdx);
+ if (cand.times.length >= birthPersistence) {
+ activePartials.push(cand);
+ candidates.splice(i, 1);
}
} else {
- // Candidate died, remove
- candidatePartials.splice(i, 1);
+ candidates.splice(i, 1);
}
}
- // Create new candidate partials from unmatched peaks
+ // Spawn candidates from unmatched peaks
for (let i = 0; i < frame.peaks.length; ++i) {
if (matched.has(i)) continue;
-
- const peak = frame.peaks[i];
- candidatePartials.push({
- times: [frame.time],
- freqs: [peak.freq],
- amps: [peak.amp],
- age: 0,
- matchIdx: -1
- });
+ const pk = frame.peaks[i];
+ candidates.push({times: [frame.time], freqs: [pk.freq], amps: [pk.amp], age: 0});
}
- // Death old partials
+ // Kill aged-out partials
for (let i = activePartials.length - 1; i >= 0; --i) {
if (activePartials[i].age > deathAge) {
- // Move to finished if long enough
- if (activePartials[i].times.length >= minPartialLength) {
- partials.push(activePartials[i]);
- }
+ if (activePartials[i].times.length >= minLength) partials.push(activePartials[i]);
activePartials.splice(i, 1);
}
}
}
- // Finish remaining active partials
+ // Collect remaining active partials
for (const partial of activePartials) {
- if (partial.times.length >= minPartialLength) {
- partials.push(partial);
- }
+ if (partial.times.length >= minLength) partials.push(partial);
}
return partials;
}
-// Fit cubic bezier curve to trajectory using Catmull-Rom tangents.
-// Estimates end tangents via forward/backward differences, then converts
-// Hermite form to Bezier: B1 = P0 + m0*dt/3, B2 = P3 - m3*dt/3.
-// This guarantees the curve passes through both endpoints.
+// Fit cubic bezier to trajectory using samples at ~1/3 and ~2/3 as control points
function fitBezier(times, values) {
const n = times.length - 1;
- const t0 = times[0], v0 = values[0];
- const t3 = times[n], v3 = values[n];
+ const t0 = times[0], v0 = values[0];
+ const t3 = times[n], v3 = values[n];
const dt = t3 - t0;
if (dt <= 0 || n === 0) {
return {t0, v0, t1: t0, v1: v0, t2: t3, v2: v3, t3, v3};
}
- // Catmull-Rom endpoint tangents (forward diff at start, backward at end)
- const dt0 = times[1] - times[0];
- const m0 = dt0 > 0 ? (values[1] - values[0]) / dt0 : 0;
-
- const dtn = times[n] - times[n - 1];
- const m3 = dtn > 0 ? (values[n] - values[n - 1]) / dtn : 0;
-
- // Hermite -> Bezier control points
- const v1 = v0 + m0 * dt / 3;
- const v2 = v3 - m3 * dt / 3;
+ const v1 = values[Math.round(n / 3)];
+ const v2 = values[Math.round(2 * n / 3)];
return {t0, v0, t1: t0 + dt / 3, v1, t2: t0 + 2 * dt / 3, v2, t3, v3};
}
diff --git a/tools/mq_editor/mq_synth.js b/tools/mq_editor/mq_synth.js
index 8dcb4bd..6fa2a09 100644
--- a/tools/mq_editor/mq_synth.js
+++ b/tools/mq_editor/mq_synth.js
@@ -3,89 +3,89 @@
// Evaluate cubic bezier curve at time t
function evalBezier(curve, t) {
- // Normalize t to [0, 1]
const dt = curve.t3 - curve.t0;
if (dt <= 0) return curve.v0;
let u = (t - curve.t0) / dt;
u = Math.max(0, Math.min(1, u));
-
- // Cubic interpolation
const u1 = 1.0 - u;
return u1*u1*u1 * curve.v0 +
3*u1*u1*u * curve.v1 +
- 3*u1*u*u * curve.v2 +
- u*u*u * curve.v3;
+ 3*u1*u*u * curve.v2 +
+ u*u*u * curve.v3;
}
-// Simple deterministic PRNG (for frequency spread and jitter)
+// Deterministic LCG PRNG
function randFloat(seed, min, max) {
- // LCG parameters
- const a = 1664525;
- const c = 1013904223;
- const m = 0x100000000; // 2^32
-
- seed = (a * seed + c) % m;
- const normalized = seed / m;
- return min + normalized * (max - min);
+ seed = (1664525 * seed + 1013904223) % 0x100000000;
+ return min + (seed / 0x100000000) * (max - min);
}
// Synthesize audio from MQ partials
-// partials: array of {freqCurve, ampCurve, replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}}
-// sampleRate: output sample rate (Hz)
-// duration: output duration (seconds)
-// Returns: Float32Array of PCM samples
-function synthesizeMQ(partials, sampleRate, duration) {
+// partials: array of {freqCurve, ampCurve, replicas?}
+// replicas: {offsets, decay_alpha, jitter, spread_above, spread_below}
+// integratePhase: true = accumulate 2π*f/SR per sample (correct for varying freq)
+// false = 2π*f*t (simpler, only correct for constant freq)
+function synthesizeMQ(partials, sampleRate, duration, integratePhase = true) {
const numSamples = Math.floor(sampleRate * duration);
const pcm = new Float32Array(numSamples);
- // Default replica config
const defaultReplicas = {
- offsets: [1.0], // Just fundamental
- decay_alpha: 0.1,
- jitter: 0.05,
- spread_above: 0.02,
- spread_below: 0.02
+ offsets: [1.0],
+ decay_alpha: 0.1,
+ jitter: 0.05,
+ spread_above: 0.02,
+ spread_below: 0.02
};
- for (let i = 0; i < numSamples; ++i) {
- const t = i / sampleRate;
- let sample = 0.0;
-
- for (let p = 0; p < partials.length; ++p) {
- const partial = partials[p];
- const freqCurve = partial.freqCurve;
- const ampCurve = partial.ampCurve;
+ // Pre-build per-partial configs with fixed spread/jitter and phase accumulators
+ const configs = [];
+ for (let p = 0; p < partials.length; ++p) {
+ const rep = partials[p].replicas != null ? partials[p].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;
- // Skip if outside curve time range
- if (t < freqCurve.t0 || t > freqCurve.t3) continue;
+ const replicaData = [];
+ for (let r = 0; r < offsets.length; ++r) {
+ // Fixed per-replica spread (frequency detuning) and initial phase (jitter)
+ const spread = randFloat(p * 67890 + r * 999, -spread_below, spread_above);
+ const initPhase = randFloat(p * 67890 + r, 0.0, 1.0) * jitter * 2.0 * Math.PI;
+ replicaData.push({ratio: offsets[r], spread, phase: initPhase});
+ }
- const f0 = evalBezier(freqCurve, t);
- const A0 = evalBezier(ampCurve, t);
+ configs.push({
+ fc: partials[p].freqCurve,
+ ac: partials[p].ampCurve,
+ decay_alpha,
+ replicaData
+ });
+ }
- // Use default replicas if not specified
- const replicas = partial.replicas || defaultReplicas;
- const offsets = replicas.offsets || [1.0];
- const decay_alpha = replicas.decay_alpha || 0.1;
- const jitter = replicas.jitter || 0.05;
- const spread_above = replicas.spread_above || 0.02;
- const spread_below = replicas.spread_below || 0.02;
+ for (let i = 0; i < numSamples; ++i) {
+ const t = i / sampleRate;
+ let sample = 0.0;
- // For each replica offset
- for (let r = 0; r < offsets.length; ++r) {
- const ratio = offsets[r];
+ for (let p = 0; p < configs.length; ++p) {
+ const {fc, ac, decay_alpha, replicaData} = configs[p];
+ if (t < fc.t0 || t > fc.t3) continue;
- // Frequency spread (asymmetric randomization)
- const seed1 = i * 12345 + p * 67890 + r;
- const spread = randFloat(seed1, -spread_below, spread_above);
- const f = f0 * ratio * (1.0 + spread);
+ const f0 = evalBezier(fc, t);
+ const A0 = evalBezier(ac, t);
- // Amplitude decay
+ 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));
- // Phase with jitter
- const seed2 = seed1 + 1;
- const jitterPhase = randFloat(seed2, 0.0, 1.0) * jitter * 2.0 * Math.PI;
- const phase = 2.0 * Math.PI * f * t + jitterPhase;
+ let phase;
+ if (integratePhase) {
+ rep.phase += 2.0 * Math.PI * f / sampleRate;
+ phase = rep.phase;
+ } else {
+ phase = 2.0 * Math.PI * f * t + rep.phase;
+ }
sample += A * Math.sin(phase);
}
@@ -94,15 +94,11 @@ function synthesizeMQ(partials, sampleRate, duration) {
pcm[i] = sample;
}
- // Normalize to prevent clipping
+ // Normalize
let maxAbs = 0;
- for (let i = 0; i < numSamples; ++i) {
- maxAbs = Math.max(maxAbs, Math.abs(pcm[i]));
- }
+ for (let i = 0; i < numSamples; ++i) maxAbs = Math.max(maxAbs, Math.abs(pcm[i]));
if (maxAbs > 1.0) {
- for (let i = 0; i < numSamples; ++i) {
- pcm[i] /= maxAbs;
- }
+ for (let i = 0; i < numSamples; ++i) pcm[i] /= maxAbs;
}
return pcm;