diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 6bc5205..4933c54 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -611,6 +611,146 @@ function chainSignature(chainArray) { // The SAME module DSP used in the mastering chain, instantiated per-track for // the [FX] button on track strips. `params` are stored per chain entry so the // unified FX Rack panel edits them declaratively. Returns { input, output, nodes }. +// ── Parametric / Graphic EQ Pro module (graphic_EQ_interactive_module.md) ── +// Logarithmic freq mapping 20Hz–20kHz, ±24dB gain, RBJ biquad response math. +const EQPRO_F_MIN = 20, EQPRO_F_MAX = 20000, EQPRO_MAX_DB = 24, EQPRO_MAX_BANDS = 8; +const EQPRO_BAND_COLORS = [ + { stroke: '#ef4444', fill: 'rgba(239,68,68,0.16)', badge: '#ef4444' }, + { stroke: '#f59e0b', fill: 'rgba(245,158,11,0.16)', badge: '#f59e0b' }, + { stroke: '#a855f7', fill: 'rgba(168,85,247,0.16)', badge: '#a855f7' }, + { stroke: '#38bdf8', fill: 'rgba(56,189,248,0.16)', badge: '#38bdf8' }, + { stroke: '#10b981', fill: 'rgba(16,185,129,0.16)', badge: '#10b981' }, + { stroke: '#ec4899', fill: 'rgba(236,72,153,0.16)', badge: '#ec4899' } +]; +const EQPRO_DEFAULT_BANDS = [ + { type: 'lowshelf', freq: 80, gain: 0, q: 0.7, active: true }, + { type: 'peaking', freq: 250, gain: 0, q: 1.0, active: true }, + { type: 'peaking', freq: 1000, gain: 0, q: 1.0, active: true }, + { type: 'peaking', freq: 4000, gain: 0, q: 1.0, active: true }, + { type: 'highshelf', freq: 10000, gain: 0, q: 0.7, active: true } +]; +function eqproFreqToX(f, w) { return w * (Math.log10(f / EQPRO_F_MIN) / Math.log10(EQPRO_F_MAX / EQPRO_F_MIN)); } +function eqproClamp(v, lo, hi) { return Math.min(hi, Math.max(lo, v)); } +function eqproXToFreq(x, w) { return EQPRO_F_MIN * Math.pow(EQPRO_F_MAX / EQPRO_F_MIN, eqproClamp(x, 0, w) / w); } +function eqproGainToY(g, h) { return (h / 2) - (g * ((h / 2) / EQPRO_MAX_DB)); } +function eqproYToGain(y, h) { return ((h / 2) - y) * (EQPRO_MAX_DB / (h / 2)); } +function eqproQToWing(q) { return Math.max(12, Math.min(80, 110 / Math.sqrt(q))); } +function eqproWingToQ(offset) { return eqproClamp(parseFloat(Math.pow(110 / Math.max(12, offset), 2).toFixed(2)), 0.1, 18); } +// Filter shapes with a real Gain control (dB). highpass/lowpass/notch/bandpass +// ignore gain in WebAudio — the UI keeps them meaningful by anchoring the node +// at the natural −3 dB / notch-dip point of their response curve. +function eqproBandHasGain(type) { return type === 'peaking' || type === 'lowshelf' || type === 'highshelf'; } +// Vertical dB position where the node handle sits for a band (matches the curve). +function eqproNodeDb(b) { + if (eqproBandHasGain(b.type)) return b.gain; + if (b.type === 'notch') return -30; + if (b.type === 'bandpass') return 0; + return -3; // highpass / lowpass at fc +} +// RBJ Audio-EQ-Cookbook biquad magnitude (dB) — analytic band response used for +// band fills + summed master curve rendering (no AudioContext needed). +function eqproBiquadMagDb(type, f, f0, gainDb, q, fs) { + const w0 = 2 * Math.PI * f0 / fs, cw = Math.cos(w0), sw = Math.sin(w0); + const alpha = sw / (2 * Math.max(0.05, q)); + const A = Math.pow(10, eqproClamp(gainDb || 0, -24, 24) / 40); + let b0, b1, b2, a0, a1, a2; + if (type === 'peaking') { + b0 = 1 + alpha * A; b1 = -2 * cw; b2 = 1 - alpha * A; + a0 = 1 + alpha / A; a1 = -2 * cw; a2 = 1 - alpha / A; + } else if (type === 'lowshelf') { + b0 = A * ((A + 1) - (A - 1) * cw + 2 * Math.sqrt(A) * alpha); + b1 = 2 * A * ((A - 1) - (A + 1) * cw); + b2 = A * ((A + 1) - (A - 1) * cw - 2 * Math.sqrt(A) * alpha); + a0 = (A + 1) + (A - 1) * cw + 2 * Math.sqrt(A) * alpha; + a1 = -2 * ((A - 1) + (A + 1) * cw); + a2 = (A + 1) + (A - 1) * cw - 2 * Math.sqrt(A) * alpha; + } else if (type === 'highshelf') { + b0 = A * ((A + 1) + (A - 1) * cw + 2 * Math.sqrt(A) * alpha); + b1 = -2 * A * ((A - 1) + (A + 1) * cw); + b2 = A * ((A + 1) + (A - 1) * cw - 2 * Math.sqrt(A) * alpha); + a0 = (A + 1) - (A - 1) * cw + 2 * Math.sqrt(A) * alpha; + a1 = 2 * ((A - 1) - (A + 1) * cw); + a2 = (A + 1) - (A - 1) * cw - 2 * Math.sqrt(A) * alpha; + } else if (type === 'highpass') { + b0 = (1 + cw) / 2; b1 = -(1 + cw); b2 = (1 + cw) / 2; + a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha; + } else if (type === 'lowpass') { + b0 = (1 - cw) / 2; b1 = 1 - cw; b2 = (1 - cw) / 2; + a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha; + } else if (type === 'notch') { + b0 = 1; b1 = -2 * cw; b2 = 1; + a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha; + } else { // bandpass (constant 0dB peak) + b0 = alpha; b1 = 0; b2 = -alpha; + a0 = 1 + alpha; a1 = -2 * cw; a2 = 1 - alpha; + } + const w = 2 * Math.PI * f / fs, cw1 = Math.cos(w), cw2 = Math.cos(2 * w); + const num = b0 * b0 + b1 * b1 + b2 * b2 + 2 * (b0 * b1 + b1 * b2) * cw1 + 2 * b0 * b2 * cw2; + const den = a0 * a0 + a1 * a1 + a2 * a2 + 2 * (a0 * a1 + a1 * a2) * cw1 + 2 * a0 * a2 * cw2; + return 10 * Math.log10(Math.max(1e-10, num / Math.max(1e-10, den))); +} +// DSP module: serial BiquadFilterNode cascade (input → band1 → … → bandN → +// output), + post-module analyser for the realtime FFT spectrum overlay. +function createEqProModule(ctx, params) { + const input = ctx.createGain(); + const output = ctx.createGain(); + const analyser = ctx.createAnalyser(); + analyser.fftSize = 2048; + output.connect(analyser); + const bands = (params && Array.isArray(params.bands)) ? JSON.parse(JSON.stringify(params.bands)) : JSON.parse(JSON.stringify(EQPRO_DEFAULT_BANDS)); + const filters = []; + let amount = (params && params.amount !== undefined) ? params.amount : 100; + const rebuild = () => { + try { input.disconnect(); } catch (e) { } + filters.forEach(f => { try { f.disconnect(); } catch (e) { } }); + filters.length = 0; + let tail = input; + bands.forEach(b => { + const f = ctx.createBiquadFilter(); + f.type = b.type || 'peaking'; + f.frequency.value = eqproClamp(b.freq !== undefined ? b.freq : 1000, EQPRO_F_MIN, EQPRO_F_MAX); + f.Q.value = eqproClamp(b.q !== undefined ? b.q : 1, 0.1, 18); + f.gain.value = (b.active !== false) ? ((b.gain || 0) * amount / 100) : 0; + tail.connect(f); + tail = f; + filters.push(f); + }); + tail.connect(output); + }; + rebuild(); + const setBand = (i, patch) => { + const b = bands[i]; if (!b) return; + Object.assign(b, patch); + const f = filters[i]; if (!f) return; + const now = ctx.currentTime; + if (patch.type !== undefined) f.type = patch.type; + if (patch.freq !== undefined) f.frequency.setTargetAtTime(eqproClamp(b.freq, EQPRO_F_MIN, EQPRO_F_MAX), now, 0.005); + if (patch.q !== undefined) f.Q.setTargetAtTime(eqproClamp(b.q, 0.1, 18), now, 0.005); + if (patch.gain !== undefined || patch.active !== undefined) f.gain.setTargetAtTime((b.active !== false) ? ((b.gain || 0) * amount / 100) : 0, now, 0.005); + }; + const setAmount = (a) => { + amount = eqproClamp(a, 0, 200); + const now = ctx.currentTime; + filters.forEach((f, i) => { const b = bands[i]; if (b) f.gain.setTargetAtTime((b.active !== false) ? ((b.gain || 0) * amount / 100) : 0, now, 0.005); }); + }; + // Replace the internal band model + rebuild DSP immediately (add/delete/reset + // from the UI) — guarantees the audible result matches the added bands at once. + const syncBands = (newBands) => { + bands.length = 0; + (newBands || []).forEach(b => bands.push({ ...b })); + rebuild(); + }; + return { + type: 'eqpro', + input, output, analyser, + get bands() { return bands; }, + get filters() { return filters; }, + get amount() { return amount; }, + setBand, setAmount, rebuild, syncBands, + destroy() { try { input.disconnect(); output.disconnect(); analyser.disconnect(); } catch (e) { } } + }; +} + function createTrackFxModule(type, ctx, params) { const input = ctx.createGain(); const output = ctx.createGain(); @@ -656,6 +796,8 @@ function createTrackFxModule(type, ctx, params) { gLR.connect(merge, 0, 1); gRR.connect(merge, 0, 1); merge.connect(output); nodes = { gLL, gRL, gLR, gRR }; + } else if (type === 'eqpro') { + return createEqProModule(ctx, params); } else { // 'eq' or default: 4-band EQ (params.g1..g4 = band gains in dB) const f1 = ctx.createBiquadFilter(); f1.type = 'lowshelf'; f1.frequency.value = 100; @@ -672,6 +814,83 @@ function createTrackFxModule(type, ctx, params) { return { input, output, nodes, type }; } +// Offline-export helper: builds a track node IDENTICAL to playback (track FX +// chain + mastering route + legacy chorus/reverb) but inside an OfflineAudioContext, +// keyed in a LOCAL map so the live graph is never touched. Audio clips are then +// scheduled through node.gainNode, and the offline master bus applies the +// mastering chain — the exported file therefore matches what you hear. +function buildOfflineTrackNode(track, ctx, nodeMap) { + if (!track || nodeMap[track.id]) return nodeMap[track.id] || null; + const gainNode = ctx.createGain(); + const volDb = track.volumeDb ?? 0; + gainNode.gain.setValueAtTime(volDb <= -50 ? 0 : Math.pow(10, volDb / 20), 0); + const pannerNode = ctx.createStereoPanner(); + pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, 0); + const analyserNode = ctx.createAnalyser(); + analyserNode.fftSize = 2048; + pannerNode.connect(analyserNode); + if (!masterBus) initMasterBus(ctx); + const route = createMasteringRoute(ctx, track, masterBus); + analyserNode.connect(route.routeGain); + analyserNode.connect(route.dryGain); + let fxStopFn; + const fxEntry = ctx.createGain(); + const fxLegacyIn = ctx.createGain(); + gainNode.connect(fxEntry); + const fxChain = track.fxChain || []; + const fxEnabled = track.fxActive !== false; + const chainMods = fxEnabled ? fxChain.filter(m => m && m.active !== false).map(m => { + try { return createTrackFxModule(m.type || m, ctx, m.params); } catch (e) { return null; } + }).filter(Boolean) : []; + let fxChainTail = fxEntry; + chainMods.forEach(mod => { fxChainTail.connect(mod.input); fxChainTail = mod.output; }); + fxChainTail.connect(fxLegacyIn); + if (fxEnabled && track.fxType === 'chorus') { + const fxInput = ctx.createGain(); + fxLegacyIn.connect(fxInput); + const chorus = createChorusNode(ctx, fxInput, pannerNode); + fxStopFn = chorus.stop; + } else if (fxEnabled && track.fxType === 'reverb') { + const fxInput = ctx.createGain(); + fxLegacyIn.connect(fxInput); + createReverbNode(ctx, fxInput, pannerNode); + } else { + fxLegacyIn.connect(pannerNode); + } + const node = { gainNode, pannerNode, analyserNode, route, fxStopFn, sfEntry: null, sfOut: null, sfRouteGain: null, sfDryGain: null }; + // Soundfont (MIDI cache) chain — mirrors the live node so cached MIDI buffers + // flow through the track FX modules + mastering exactly like playback. + if ((track.midiItems && track.midiItems.length > 0)) { + const sfEntry = ctx.createGain(); + sfEntry.gain.setValueAtTime(1, 0); + const sfOut = ctx.createGain(); + const sfPan = ctx.createStereoPanner(); + sfPan.pan.setValueAtTime((track.pan ?? 0) / 100, 0); + sfOut.connect(sfPan); + const sfRouteGain = ctx.createGain(); + const sfDryGain = ctx.createGain(); + const sfBypass = trackMidiBypassMap[track.id] !== undefined ? !!trackMidiBypassMap[track.id] : !!(track.midiBypass ?? track.masteringBypass); + sfRouteGain.gain.value = sfBypass ? 0 : 1; + sfDryGain.gain.value = sfBypass ? 1 : 0; + sfPan.connect(sfRouteGain); + sfPan.connect(sfDryGain); + sfRouteGain.connect(masterBus.input); + sfDryGain.connect(masterBus.dryInput); + const sfMods = fxEnabled ? fxChain.filter(m => m && m.active !== false).map(m => { + try { return createTrackFxModule(m.type || m, ctx, m.params); } catch (e) { return null; } + }).filter(Boolean) : []; + let sfTail = sfEntry; + sfMods.forEach(mod => { sfTail.connect(mod.input); sfTail = mod.output; }); + sfTail.connect(sfOut); + node.sfEntry = sfEntry; + node.sfOut = sfOut; + node.sfRouteGain = sfRouteGain; + node.sfDryGain = sfDryGain; + } + nodeMap[track.id] = node; + return node; +} + // Track-EQ preset library (unified_fx_rack_panel.md §III.1) — band gains only. const TRACK_EQ_PRESETS = { flat: { name: 'Flat / Reset', g: [0, 0, 0, 0] }, @@ -715,11 +934,23 @@ function rebuildMasteringGraph(activate, chainArray) { // 3. Wire in series: Input → mod[0].input → mod[0].output → mod[1].input → … → Output let prev = masterBus.inputAnalyser; + // Dynamic modules (eqpro): rebuilt on every graph rebuild; instances kept + // in masterBus.eqProInstances so the Mastering panel can patch them live. + const eqProStore = masterBus.eqProInstances = masterBus.eqProInstances || {}; + Object.keys(eqProStore).forEach(k => { try { eqProStore[k].destroy && eqProStore[k].destroy(); } catch (e) { } }); + Object.keys(eqProStore).forEach(k => delete eqProStore[k]); activeMods.forEach(mod => { - const io = MASTER_MODULE_IO[mod.type] || MASTER_MODULE_IO.eq; - const inNode = masterBus[io.input]; - if (inNode) prev.connect(inNode); - prev = masterBus[io.output] || prev; + if (mod.type === 'eqpro') { + const m = createEqProModule(getAudioContext(), mod.params || {}); + eqProStore[mod.id] = m; + prev.connect(m.input); + prev = m.output; + } else { + const io = MASTER_MODULE_IO[mod.type] || MASTER_MODULE_IO.eq; + const inNode = masterBus[io.input]; + if (inNode) prev.connect(inNode); + prev = masterBus[io.output] || prev; + } }); prev.connect(masterBus.outputAnalyser); masterBus.masteringActive = true; @@ -8823,6 +9054,7 @@ const MasteringKnob = ({ param, min, max, value, unit, label, color, onChange, s // ────────────────────────────────────────────── const TRACK_FX_META = { eq: { name: 'EQ 4-Band', icon: 'activity', color: '#22d3ee', sub: '4-Band Peak' }, + eqpro: { name: 'Parametric / Graphic EQ PRO', icon: 'chart-area', color: '#2dd4bf', sub: 'Pro-Q style · 8 bands · interactive' }, compressor: { name: 'Bus Compressor', icon: 'compress', color: '#fbbf24', sub: 'Glue & Punch' }, limiter: { name: 'Brickwall Limiter', icon: 'shield-half', color: '#f43f5e', sub: 'True-Peak 20:1' }, exciter: { name: 'Harmonic Exciter', icon: 'wand-2', color: '#c084fc', sub: 'Saturation & Air' }, @@ -8830,12 +9062,370 @@ const TRACK_FX_META = { }; const TRACK_FX_DEFAULTS = { eq: { g1: 0, g2: 0, g3: 0, g4: 0 }, + eqpro: { amount: 100, bands: EQPRO_DEFAULT_BANDS }, compressor: { threshold: -16, ratio: 3, makeup: 0 }, limiter: { ceiling: -1.0 }, exciter: { drive: 40 }, rebalance: { mid: 0, side: 0 } }; +// EQ Pro canvas frame renderer (graphic_EQ_interactive_module.md §I-II): grid, +// realtime FFT spectrum, per-band fills, summed master white curve, nodes+wings. +function renderEqProFrame(c, w, h, s, modules, fs) { + c.clearRect(0, 0, w, h); + c.font = '9px monospace'; + [20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000].forEach(f => { + const x = eqproFreqToX(f, w); + c.strokeStyle = 'rgba(51,65,85,0.25)'; c.lineWidth = 1; + c.beginPath(); c.moveTo(x, 0); c.lineTo(x, h); c.stroke(); + c.fillStyle = '#475569'; + c.fillText(f >= 1000 ? (f / 1000) + 'k' : '' + f, x + 3, h - 8); + }); + [18, 12, 6, 0, -6, -12, -18].forEach(db => { + const y = eqproGainToY(db, h); + c.strokeStyle = db === 0 ? 'rgba(45,212,191,0.45)' : 'rgba(51,65,85,0.25)'; + c.lineWidth = db === 0 ? 1.5 : 1; + c.beginPath(); c.moveTo(0, y); c.lineTo(w, y); c.stroke(); + c.fillStyle = '#475569'; + c.fillText((db > 0 ? '+' : '') + db, w - 34, y - 3); + }); + // Realtime FFT spectrum — one overlay per live module (audioclip path = sky, + // soundfont/MIDI path = pink), so both sources are visible when playing. + const specStyles = ['rgba(56,189,248,0.20)', 'rgba(236,72,153,0.16)']; + (modules || []).forEach((module, mi) => { + if (!module || !module.analyser) return; + const fft = new Uint8Array(module.analyser.frequencyBinCount); + module.analyser.getByteFrequencyData(fft); + c.fillStyle = specStyles[mi % specStyles.length]; + c.beginPath(); c.moveTo(0, h); + for (let x = 0; x <= w; x++) { + const f = eqproXToFreq(x, w); + const bin = Math.floor((f / (fs / 2)) * fft.length); + const v = (fft[bin] || 0) / 255; + c.lineTo(x, h - v * h * 0.8); + } + c.lineTo(w, h); c.closePath(); c.fill(); + }); + // Per-band translucent fills + summed master curve + const total = new Float32Array(w); + s.bands.forEach((b, bi) => { + if (b.active === false) return; + const col = EQPRO_BAND_COLORS[bi % EQPRO_BAND_COLORS.length]; + c.fillStyle = col.fill; + c.beginPath(); c.moveTo(0, eqproGainToY(0, h)); + for (let x = 0; x < w; x++) { + const db = eqproBiquadMagDb(b.type, eqproXToFreq(x, w), b.freq, b.gain, b.q, fs); + total[x] += db; + c.lineTo(x, eqproGainToY(db, h)); + } + c.lineTo(w, eqproGainToY(0, h)); c.closePath(); c.fill(); + }); + c.strokeStyle = '#ffffff'; c.lineWidth = 2.5; c.beginPath(); + for (let x = 0; x < w; x++) { + const y = eqproGainToY(total[x], h); + if (x === 0) c.moveTo(x, y); else c.lineTo(x, y); + } + c.stroke(); + // Nodes + Q-handle wings + s.bands.forEach((b, bi) => { + const nx = eqproFreqToX(b.freq, w); + const ny = eqproGainToY(eqproNodeDb(b), h); + const col = EQPRO_BAND_COLORS[bi % EQPRO_BAND_COLORS.length]; + const sel = s.selected === bi; + const wo = eqproQToWing(b.q); + if (sel) { + c.strokeStyle = col.stroke; c.lineWidth = 2; + c.beginPath(); c.moveTo(nx - wo, ny); c.lineTo(nx + wo, ny); c.stroke(); + [nx - wo, nx + wo].forEach(wx => { + c.fillStyle = '#0f172a'; c.strokeStyle = col.stroke; c.lineWidth = 2; + c.beginPath(); c.arc(wx, ny, 4, 0, Math.PI * 2); c.fill(); c.stroke(); + }); + } + c.fillStyle = sel ? '#ffffff' : col.stroke; + c.strokeStyle = col.stroke; c.lineWidth = sel ? 3 : 2; + c.beginPath(); c.arc(nx, ny, sel ? 7 : 5, 0, Math.PI * 2); c.fill(); c.stroke(); + if (b.active === false) { + c.strokeStyle = '#ef4444'; c.lineWidth = 2; + c.beginPath(); c.moveTo(nx - 6, ny + 6); c.lineTo(nx + 6, ny - 6); c.stroke(); + } + c.fillStyle = sel ? '#0f172a' : '#ffffff'; + c.font = 'bold 9px monospace'; c.textAlign = 'center'; c.textBaseline = 'middle'; + c.fillText(String(bi + 1), nx, ny); + }); +} + +const InteractiveEqPro = ({ track, params, onChange, getModule, applyTo, spectrumModules }) => { + const canvasRef = React.useRef(null); + const st = React.useRef(null); + if (!st.current) { + st.current = { + bands: JSON.parse(JSON.stringify((params && Array.isArray(params.bands)) ? params.bands : EQPRO_DEFAULT_BANDS)), + amount: (params && params.amount !== undefined) ? params.amount : 100, + selected: null, dragId: null, mode: null, + lastSig: null, + hudPos: null, dragHud: false, hudOffsetX: 0, hudOffsetY: 0 + }; + st.current.lastSig = JSON.stringify(st.current.bands) + '|' + st.current.amount; + } + const [tick, setTick] = React.useState(0); + // External param sync (undo/load/rebuild) — skip while dragging to avoid + // resetting the node mid-gesture (the flicker/“can't move” bug while playing). + React.useEffect(() => { + if (st.current.dragId !== null) return; + if (params && Array.isArray(params.bands)) { + const sig = JSON.stringify(params.bands) + '|' + (params.amount !== undefined ? params.amount : 100); + if (sig !== st.current.lastSig) { + st.current.lastSig = sig; + st.current.bands = JSON.parse(JSON.stringify(params.bands)); + st.current.amount = params.amount !== undefined ? params.amount : 100; + } + } + }, [params]); + // Canvas render loop (rAF) + React.useEffect(() => { + let raf; + const draw = () => { + raf = requestAnimationFrame(draw); + const cv = canvasRef.current; + if (!cv || !cv.clientWidth) return; + const w = cv.clientWidth, h = cv.clientHeight; + if (cv.width !== w * 2) { cv.width = w * 2; cv.height = h * 2; } + const c = cv.getContext('2d'); + c.setTransform(2, 0, 0, 2, 0, 0); + // Spectrum sources: every live module analyser (audioclip path + soundfont + // path) is overlaid, so playing audio clips OR midi both show up. + const specMods = (spectrumModules ? spectrumModules() : null) || (getModule ? [getModule()] : []); + renderEqProFrame(c, w, h, st.current, specMods, (typeof getAudioContext === 'function' && getAudioContext()) ? getAudioContext().sampleRate : 44100); + }; + draw(); + return () => cancelAnimationFrame(raf); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const toLocal = (e) => { const r = canvasRef.current.getBoundingClientRect(); return { x: e.clientX - r.left, y: e.clientY - r.top }; }; + const commit = () => { if (onChange) onChange({ bands: JSON.parse(JSON.stringify(st.current.bands)), amount: st.current.amount }); }; + // Apply a mutation to the live module instance(s). Default: BOTH track module + // instances (audio fxMods + sf sfMods) so the soundfont follows too; mastering + // passes its own applyTo (masterBus.eqProInstances[modId]). + const applyAll = applyTo || ((fn) => { + if (typeof window !== 'undefined' && window.__getTrackFxModule) { const a = window.__getTrackFxModule(track.id, 'eqpro'); if (a) fn(a); } + if (typeof window !== 'undefined' && window.__getTrackSfFxModule) { const b = window.__getTrackSfFxModule(track.id, 'eqpro'); if (b) fn(b); } + }); + const onDown = (e) => { + const cv = canvasRef.current, w = cv.clientWidth, h = cv.clientHeight; + const { x, y } = toLocal(e); + const s = st.current; + let hit = false; + for (let i = 0; i < s.bands.length; i++) { + const b = s.bands[i]; + if (Math.hypot(x - eqproFreqToX(b.freq, w), y - eqproGainToY(eqproNodeDb(b), h)) <= 10) { + s.selected = i; s.dragId = i; s.mode = 'center'; hit = true; break; + } + } + if (!hit && s.selected !== null) { + const b = s.bands[s.selected]; + const nx = eqproFreqToX(b.freq, w), ny = eqproGainToY(eqproNodeDb(b), h), wo = eqproQToWing(b.q); + if (Math.hypot(x - (nx - wo), y - ny) <= 8 || Math.hypot(x - (nx + wo), y - ny) <= 8) { + s.dragId = s.selected; s.mode = 'wing'; hit = true; + } + } + if (!hit) s.selected = null; + try { cv.setPointerCapture(e.pointerId); } catch (err) { } + setTick(t => t + 1); + }; + const onMove = (e) => { + const s = st.current; + if (s.dragId === null) return; + const cv = canvasRef.current, w = cv.clientWidth, h = cv.clientHeight; + const { x, y } = toLocal(e); + const mx = Math.max(0, Math.min(w, x)), my = Math.max(0, Math.min(h, y)); + const b = s.bands[s.dragId]; + if (s.mode === 'center') { + const f = eqproClamp(parseFloat(eqproXToFreq(mx, w).toFixed(1)), EQPRO_F_MIN, EQPRO_F_MAX); + // Filters without a Gain control (highpass/lowpass/notch/bandpass) stay on + // the 0 dB axis — only frequency is draggable for them. + const g = eqproBandHasGain(b.type) ? eqproClamp(parseFloat(eqproYToGain(my, h).toFixed(1)), -EQPRO_MAX_DB, EQPRO_MAX_DB) : 0; + b.freq = f; b.gain = g; + applyAll(mm => mm.setBand(s.dragId, { freq: f, gain: g })); + } else if (s.mode === 'wing') { + const q = eqproWingToQ(Math.abs(mx - eqproFreqToX(b.freq, w))); + b.q = q; + applyAll(mm => mm.setBand(s.dragId, { q })); + } + setTick(t => t + 1); + }; + const onUp = (e) => { + const s = st.current; + if (s.dragId !== null) commit(); + s.dragId = null; s.mode = null; + try { canvasRef.current.releasePointerCapture(e.pointerId); } catch (err) { } + }; + const onWheel = (e) => { + e.preventDefault(); + const s = st.current; + if (s.selected === null) return; + const b = s.bands[s.selected]; + b.q = eqproClamp(parseFloat((b.q + (e.deltaY < 0 ? 0.2 : -0.2)).toFixed(1)), 0.1, 18); + applyAll(mm => mm.setBand(s.selected, { q: b.q })); + setTick(t => t + 1); + }; + const onDblClick = (e) => { + const cv = canvasRef.current, w = cv.clientWidth, h = cv.clientHeight; + const { x, y } = toLocal(e); + const s = st.current; + const hitIdx = s.bands.findIndex(b => Math.hypot(x - eqproFreqToX(b.freq, w), y - eqproGainToY(b.gain, h)) <= 10); + if (hitIdx !== -1) { + s.bands.splice(hitIdx, 1); + if (s.selected === hitIdx) s.selected = null; else if (s.selected !== null && s.selected > hitIdx) s.selected--; + } else { + if (s.bands.length >= EQPRO_MAX_BANDS) return; + s.bands.push({ type: 'peaking', freq: eqproClamp(parseFloat(eqproXToFreq(x, w).toFixed(1)), EQPRO_F_MIN, EQPRO_F_MAX), gain: eqproClamp(parseFloat(eqproYToGain(y, h).toFixed(1)), -EQPRO_MAX_DB, EQPRO_MAX_DB), q: 1.2, active: true }); + s.selected = s.bands.length - 1; + } + applyAll(mm => mm.syncBands(s.bands)); + commit(); + setTick(t => t + 1); + }; + const sel = (st.current.selected !== null) ? st.current.bands[st.current.selected] : null; + const selIdx = st.current.selected; + const cvW = canvasRef.current ? canvasRef.current.clientWidth : 460; + const cvH = 240; + const hudColor = selIdx !== null ? EQPRO_BAND_COLORS[selIdx % EQPRO_BAND_COLORS.length] : EQPRO_BAND_COLORS[0]; + const hudXY = (() => { + if (st.current.hudPos) { + // Clamp so the HUD always stays inside the canvas area (buttons clickable). + return { + hx: Math.max(0, Math.min((canvasRef.current ? canvasRef.current.clientWidth : 460) - 268, st.current.hudPos.hx)), + hy: Math.max(0, Math.min((canvasRef.current ? canvasRef.current.clientHeight : 240) - 225, st.current.hudPos.hy)) + }; + } + if (!sel) return null; + const nx = eqproFreqToX(sel.freq, cvW), ny = eqproGainToY(sel.gain, cvH); + let hx = nx - 130, hy = ny - 195; + if (hx < 8) hx = 8; + if (hx > cvW - 268) hx = cvW - 268; + if (hy < 8) hy = ny + 24; + return { hx, hy }; + })(); + return ( +