diff options
| -rw-r--r-- | tools/mq_editor/index.html | 5 | ||||
| -rw-r--r-- | tools/mq_editor/mq_extract.js | 167 | ||||
| -rw-r--r-- | tools/mq_editor/mq_synth.js | 122 |
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; |
