Files
2026-05-09 13:23:13 +02:00

990 lines
39 KiB
JavaScript

// ComfyUI-StemMixer — FX module
// Per-track effects chain: Reverb / Delay / Chorus / Distortion / Filter / Phaser / Compressor / Stereo Widener
// All effects are implemented with native Web Audio nodes (no dependencies)
// and mirror exactly the Python implementation in effects.py.
//
// Stereo Widener is always processed LAST in the chain regardless of its
// position in the user-defined order (see _rewire).
// ===========================================================================
// Utility
// ===========================================================================
function fxId() {
return "fx_" + Math.random().toString(36).slice(2, 10);
}
function escFx(s) {
return String(s).replace(/[<>&"]/g, c => ({
"<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;",
}[c]));
}
function makeReverbIR(ctx, durationSec, decay) {
const sr = ctx.sampleRate;
const len = Math.max(1, Math.floor(sr * durationSec));
const buf = ctx.createBuffer(2, len, sr);
const exponent = 0.5 + decay * 5.5;
for (let ch = 0; ch < 2; ch++) {
const data = buf.getChannelData(ch);
for (let i = 0; i < len; i++) {
const t = i / len;
data[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, exponent);
}
}
return buf;
}
function makeDistortionCurve(drive) {
const n = 4096;
const curve = new Float32Array(n);
const k = drive;
const deg = Math.PI / 180;
for (let i = 0; i < n; i++) {
const x = (i * 2) / n - 1;
curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x));
}
return curve;
}
// ===========================================================================
// FX Registry
// Each entry: label, defaultParams, uiParams, build(ctx, params)
// Optional: customLayout (string) — when set, the popup uses a custom
// HTML layout instead of the default vertical slider list.
// Currently only "widener" uses this.
// ===========================================================================
export const FX_REGISTRY = {
reverb: {
label: "Reverb",
defaultParams: { roomSize: 0.5, decay: 0.5, dryWet: 0.3 },
uiParams: [
{ key: "roomSize", label: "Room", min: 0, max: 1, step: 0.01, format: v => v.toFixed(2) },
{ key: "decay", label: "Decay", min: 0, max: 1, step: 0.01, format: v => v.toFixed(2) },
{ key: "dryWet", label: "Mix", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
],
build(ctx, params) {
const input = ctx.createGain();
const output = ctx.createGain();
const dry = ctx.createGain();
const wet = ctx.createGain();
const conv = ctx.createConvolver();
input.connect(dry); input.connect(conv); conv.connect(wet);
dry.connect(output); wet.connect(output);
const apply = (p) => {
const dur = 0.3 + p.roomSize * 5.7;
conv.buffer = makeReverbIR(ctx, dur, p.decay);
dry.gain.value = 1 - p.dryWet;
wet.gain.value = p.dryWet;
};
apply(params);
return { input, output, update: apply };
},
},
delay: {
label: "Delay",
defaultParams: { time: 0.35, feedback: 0.4, dryWet: 0.3 },
uiParams: [
{ key: "time", label: "Time", min: 0.01, max: 2, step: 0.01, format: v => `${(v * 1000).toFixed(0)}ms` },
{ key: "feedback", label: "Feedback", min: 0, max: 0.95, step: 0.01, format: v => `${Math.round(v * 100)}%` },
{ key: "dryWet", label: "Mix", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
const dry = ctx.createGain(), wet = ctx.createGain();
const dl = ctx.createDelay(2.0), fb = ctx.createGain();
input.connect(dry); input.connect(dl);
dl.connect(fb); fb.connect(dl); dl.connect(wet);
dry.connect(output); wet.connect(output);
const apply = (p) => {
dl.delayTime.value = p.time;
fb.gain.value = p.feedback;
dry.gain.value = 1 - p.dryWet;
wet.gain.value = p.dryWet;
};
apply(params);
return { input, output, update: apply };
},
},
chorus: {
label: "Chorus",
defaultParams: { rate: 1.5, depth: 0.005, dryWet: 0.4 },
uiParams: [
{ key: "rate", label: "Rate", min: 0.1, max: 8, step: 0.05, format: v => `${v.toFixed(2)} Hz` },
{ key: "depth", label: "Depth", min: 0.001, max: 0.02, step: 0.0005, format: v => `${(v * 1000).toFixed(2)}ms` },
{ key: "dryWet", label: "Mix", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
const dry = ctx.createGain(), wet = ctx.createGain();
const dl = ctx.createDelay(0.05);
const lfo = ctx.createOscillator(), lfoG = ctx.createGain();
const baseDelay = 0.015;
lfo.type = "sine";
lfo.connect(lfoG); lfoG.connect(dl.delayTime);
dl.delayTime.value = baseDelay;
lfo.start();
input.connect(dry); input.connect(dl); dl.connect(wet);
dry.connect(output); wet.connect(output);
const apply = (p) => {
lfo.frequency.value = p.rate;
lfoG.gain.value = p.depth;
dry.gain.value = 1 - p.dryWet;
wet.gain.value = p.dryWet;
};
apply(params);
const dispose = () => { try { lfo.stop(); } catch (_) {} };
return { input, output, update: apply, dispose };
},
},
distortion: {
label: "Distortion",
defaultParams: { drive: 20, tone: 4000, dryWet: 0.5 },
uiParams: [
{ key: "drive", label: "Drive", min: 1, max: 100, step: 0.5, format: v => v.toFixed(0) },
{ key: "tone", label: "Tone", min: 200, max: 8000, step: 50, format: v => `${v.toFixed(0)} Hz` },
{ key: "dryWet", label: "Mix", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
const dry = ctx.createGain(), wet = ctx.createGain();
const shaper = ctx.createWaveShaper();
const lp = ctx.createBiquadFilter();
shaper.oversample = "4x";
lp.type = "lowpass";
input.connect(dry); input.connect(shaper);
shaper.connect(lp); lp.connect(wet);
dry.connect(output); wet.connect(output);
const apply = (p) => {
shaper.curve = makeDistortionCurve(p.drive);
lp.frequency.value = p.tone;
dry.gain.value = 1 - p.dryWet;
wet.gain.value = p.dryWet;
};
apply(params);
return { input, output, update: apply };
},
},
filter: {
label: "Filter",
defaultParams: { type: "lowpass", frequency: 1000, resonance: 0.707 },
uiParams: [
// Type is rendered via a select, not a slider — special-cased in renderer
{ key: "type", label: "Type", isSelect: true,
options: [
{ value: "lowpass", label: "Low-Pass" },
{ value: "highpass", label: "High-Pass" },
{ value: "bandpass", label: "Band-Pass" },
],
},
{ key: "frequency", label: "Freq", min: 20, max: 20000, step: 1, format: v => v >= 1000 ? `${(v/1000).toFixed(2)} kHz` : `${v.toFixed(0)} Hz`, isLog: true },
{ key: "resonance", label: "Reso", min: 0.1, max: 20, step: 0.1, format: v => `Q ${v.toFixed(1)}` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
const filt = ctx.createBiquadFilter();
input.connect(filt); filt.connect(output);
const apply = (p) => {
filt.type = p.type;
filt.frequency.value = p.frequency;
filt.Q.value = p.resonance;
};
apply(params);
return { input, output, update: apply };
},
},
phaser: {
label: "Phaser",
defaultParams: { rate: 0.5, depth: 0.7, feedback: 0.3, dryWet: 0.5 },
uiParams: [
{ key: "rate", label: "Rate", min: 0.05, max: 8, step: 0.01, format: v => `${v.toFixed(2)} Hz` },
{ key: "depth", label: "Depth", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
{ key: "feedback", label: "Feedback", min: 0, max: 0.95, step: 0.01, format: v => `${Math.round(v * 100)}%` },
{ key: "dryWet", label: "Mix", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
const dry = ctx.createGain(), wet = ctx.createGain();
const stages = [];
const stageBaseFreqs = [350, 600, 1000, 1700];
for (let i = 0; i < 4; i++) {
const ap = ctx.createBiquadFilter();
ap.type = "allpass";
ap.Q.value = 0.7;
ap.frequency.value = stageBaseFreqs[i];
stages.push(ap);
}
// Cascade
input.connect(stages[0]);
for (let i = 0; i < stages.length - 1; i++) {
stages[i].connect(stages[i + 1]);
}
stages[3].connect(wet);
// Feedback with explicit delay
const fb = ctx.createGain();
const fbDelay = ctx.createDelay(0.05);
fbDelay.delayTime.value = 0.001;
fb.gain.value = 0;
stages[3].connect(fbDelay);
fbDelay.connect(fb);
fb.connect(stages[0]);
// Dry path
input.connect(dry);
dry.connect(output); wet.connect(output);
wet.gain.value = 0;
dry.gain.value = 1;
// LFO modulating each stage's frequency, with a per-stage gain
// proportional to that stage's base frequency
const lfo = ctx.createOscillator();
lfo.type = "sine";
const stageLfoGains = stages.map((ap, i) => {
const g = ctx.createGain();
g.gain.value = 0; // init to 0, apply() will set the real depth
lfo.connect(g);
g.connect(ap.frequency);
return g;
});
lfo.start();
const apply = (p) => {
lfo.frequency.value = p.rate;
stages.forEach((ap, i) => {
const baseFreq = stageBaseFreqs[i];
ap.frequency.value = baseFreq;
stageLfoGains[i].gain.value = baseFreq * 0.8 * p.depth;
});
fb.gain.value = p.feedback;
dry.gain.value = 1 - p.dryWet;
wet.gain.value = p.dryWet;
};
apply(params);
const dispose = () => { try { lfo.stop(); } catch (_) {} };
return { input, output, update: apply, dispose };
},
},
compressor: {
label: "Compressor",
defaultParams: {
threshold: -20, ratio: 4, attack: 5, release: 100, makeup: 0, dryWet: 1.0,
},
uiParams: [
{ key: "threshold", label: "Thresh", min: -60, max: 0, step: 0.5, format: v => `${v.toFixed(1)} dB` },
{ key: "ratio", label: "Ratio", min: 1, max: 20, step: 0.1, format: v => `${v.toFixed(1)}:1` },
{ key: "attack", label: "Attack", min: 0.1, max: 100, step: 0.1, format: v => `${v.toFixed(1)} ms` },
{ key: "release", label: "Release", min: 1, max: 1000, step: 1, format: v => `${v.toFixed(0)} ms` },
{ key: "makeup", label: "Makeup", min: 0, max: 24, step: 0.1, format: v => `${v.toFixed(1)} dB` },
{ key: "dryWet", label: "Mix", min: 0, max: 1, step: 0.01, format: v => `${Math.round(v * 100)}%` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
const dry = ctx.createGain(), wet = ctx.createGain();
const makeup = ctx.createGain();
const comp = ctx.createDynamicsCompressor();
comp.knee.value = 6;
input.connect(dry);
input.connect(comp);
comp.connect(makeup);
makeup.connect(wet);
dry.connect(output); wet.connect(output);
const apply = (p) => {
comp.threshold.value = p.threshold;
comp.ratio.value = p.ratio;
comp.attack.value = p.attack / 1000; // ms → s
comp.release.value = p.release / 1000;
makeup.gain.value = Math.pow(10, p.makeup / 20);
dry.gain.value = 1 - p.dryWet;
wet.gain.value = p.dryWet;
};
apply(params);
return { input, output, update: apply };
},
},
widener: {
label: "Stereo Widener",
defaultParams: { width: 1.0 },
customLayout: "widener",
uiParams: [
{ key: "width", label: "Width", min: 0, max: 2, step: 0.01,
format: v => v < 0.05 ? "Mono" : v <= 1.05 ? `${v.toFixed(2)}` : `Wide ${v.toFixed(2)}` },
],
build(ctx, params) {
const input = ctx.createGain(), output = ctx.createGain();
// Build a Mid/Side encoder/decoder using native nodes.
// We use ChannelSplitter / Merger and sum manually.
const splitter = ctx.createChannelSplitter(2);
const merger = ctx.createChannelMerger(2);
const midGain = ctx.createGain();
const sideGain = ctx.createGain();
// Left channel L → mid (+1), side (+1)
// Right channel R → mid (+1), side (-1)
// We build this with intermediate inverters
const leftToMid = ctx.createGain(); leftToMid.gain.value = 0.5;
const rightToMid = ctx.createGain(); rightToMid.gain.value = 0.5;
const leftToSide = ctx.createGain(); leftToSide.gain.value = 0.5;
const rightToSide = ctx.createGain(); rightToSide.gain.value = -0.5;
input.connect(splitter);
splitter.connect(leftToMid, 0); splitter.connect(rightToMid, 1);
splitter.connect(leftToSide, 0); splitter.connect(rightToSide, 1);
leftToMid.connect(midGain); rightToMid.connect(midGain);
leftToSide.connect(sideGain); rightToSide.connect(sideGain);
// Decode: L = mid + side, R = mid - side
const sideInv = ctx.createGain(); sideInv.gain.value = -1;
sideGain.connect(sideInv);
// Out L = mid + side (ch 0), Out R = mid - side (ch 1)
midGain.connect(merger, 0, 0); sideGain.connect(merger, 0, 0);
midGain.connect(merger, 0, 1); sideInv.connect(merger, 0, 1);
merger.connect(output);
const apply = (p) => {
// mid stays at unity, side scales with width
midGain.gain.value = 1.0;
sideGain.gain.value = 1.0 * p.width;
sideInv.gain.value = -1.0;
};
apply(params);
return { input, output, update: apply };
},
},
};
// ===========================================================================
// StemFXChain
// ===========================================================================
export class StemFXChain {
constructor(ctx, mixer, track) {
this.ctx = ctx;
this.mixer = mixer;
this.track = track;
this.fxIn = ctx.createGain();
this.fxOut = ctx.createGain();
this.slots = [];
this._externalEdges = [];
this._rewire();
}
dispose() {
if (this._externalEdges) {
for (const [src, dst] of this._externalEdges) {
try { src.disconnect(dst); } catch (_) {}
}
this._externalEdges = null;
}
for (const s of this.slots) this._disposeSlot(s);
this.slots = [];
}
_disposeSlot(slot) {
if (slot.popup) {
try { slot.popup._cleanup?.(); } catch (_) {}
slot.popup.remove();
slot.popup = null;
}
if (slot.instance) {
try { slot.instance.dispose?.(); } catch (_) {}
slot.instance = null;
}
}
// Order the slots: non-widener first (in user order), widener last.
// Matches the Python `apply_fx_chain` ordering.
_orderedSlots() {
const main = this.slots.filter(s => s.type !== "widener");
const wid = this.slots.filter(s => s.type === "widener");
return [...main, ...wid];
}
_rewire() {
const ctx = this.ctx;
const FADE = 0.005;
const doRewire = () => {
if (this._externalEdges) {
for (const [src, dst] of this._externalEdges) {
try { src.disconnect(dst); } catch (_) {}
}
}
const edges = [];
let prev = this.fxIn;
for (const s of this._orderedSlots()) {
if (!s.on || !s.instance) continue;
edges.push([prev, s.instance.input]);
prev = s.instance.output;
}
edges.push([prev, this.fxOut]);
for (const [src, dst] of edges) {
try { src.connect(dst); } catch (_) {}
}
this._externalEdges = edges;
};
if (ctx.state !== "running") {
doRewire();
return;
}
const now = ctx.currentTime;
try {
this.fxIn.gain.cancelScheduledValues(now);
this.fxIn.gain.setValueAtTime(this.fxIn.gain.value, now);
this.fxIn.gain.linearRampToValueAtTime(0, now + FADE);
} catch (_) {}
setTimeout(() => {
doRewire();
try {
const t = ctx.currentTime;
this.fxIn.gain.cancelScheduledValues(t);
this.fxIn.gain.setValueAtTime(0, t);
this.fxIn.gain.linearRampToValueAtTime(1, t + FADE);
} catch (_) {}
}, FADE * 1000 + 1);
}
isActive() { return this.slots.some(s => s.on); }
addEffect(type, data = null) {
const def = FX_REGISTRY[type];
if (!def) return null;
const params = { ...(def.defaultParams), ...(data?.params || {}) };
const slot = {
id: data?.id ?? fxId(),
type, on: data?.on ?? true,
params,
fxWindow: data?.fxWindow ?? null,
instance: null, popup: null,
};
slot.instance = def.build(this.ctx, slot.params);
this.slots.push(slot);
this._rewire();
return slot;
}
removeEffect(id) {
const idx = this.slots.findIndex(s => s.id === id);
if (idx < 0) return;
this._disposeSlot(this.slots[idx]);
this.slots.splice(idx, 1);
this._rewire();
}
toggleEffect(id) {
const slot = this.slots.find(s => s.id === id);
if (!slot) return;
slot.on = !slot.on;
this._rewire();
}
updateParams(id, params) {
const slot = this.slots.find(s => s.id === id);
if (!slot || !slot.instance) return;
Object.assign(slot.params, params);
slot.instance.update?.(slot.params);
}
serialize() {
return this.slots.map(s => ({
id: s.id, type: s.type, on: s.on,
params: { ...s.params },
fxWindow: s.fxWindow,
}));
}
}
// ===========================================================================
// FX Add Menu
// ===========================================================================
export function showFXMenu(track, anchorBtn, mixer) {
const existing = document.querySelector(".sm-fx-menu");
if (existing) {
const wasForSame = existing.dataset.anchorId === anchorBtn.dataset?.fxAnchorId;
existing.remove();
const oldBackdrop = document.querySelector(".sm-fx-menu-backdrop");
if (oldBackdrop) oldBackdrop.remove();
if (wasForSame) return;
}
if (!anchorBtn.dataset.fxAnchorId) {
anchorBtn.dataset.fxAnchorId = "btn_" + Math.random().toString(36).slice(2, 8);
}
const backdrop = document.createElement("div");
backdrop.className = "sm-fx-menu-backdrop";
document.body.appendChild(backdrop);
const menu = document.createElement("div");
menu.className = "sm-fx-menu";
menu.dataset.anchorId = anchorBtn.dataset.fxAnchorId;
const r = anchorBtn.getBoundingClientRect();
menu.style.left = `${r.left}px`;
menu.style.top = `${r.bottom + 4}px`;
const closeMenu = () => {
menu.remove(); backdrop.remove();
document.removeEventListener("keydown", onKey);
};
const onKey = (e) => { if (e.key === "Escape") closeMenu(); };
document.addEventListener("keydown", onKey);
backdrop.addEventListener("mousedown", closeMenu);
if (track.fxChain && track.fxChain.slots.length > 0) {
const header = document.createElement("div");
header.className = "sm-fx-menu-header";
header.textContent = "Active effects";
menu.appendChild(header);
for (const slot of track.fxChain.slots) {
const def = FX_REGISTRY[slot.type];
const item = document.createElement("div");
item.className = "sm-fx-menu-item sm-fx-menu-active";
item.innerHTML = `
<span class="sm-fx-menu-name ${slot.on ? '' : 'sm-fx-off'}">${escFx(def.label)}</span>
<button class="sm-fx-menu-toggle" title="${slot.on ? 'Bypass' : 'Enable'}">${slot.on ? "ON" : "OFF"}</button>
<button class="sm-fx-menu-remove" title="Remove">✕</button>
`;
item.querySelector(".sm-fx-menu-name").onclick = () => {
closeMenu(); openFXPopup(track, slot, mixer);
};
item.querySelector(".sm-fx-menu-toggle").onclick = (e) => {
e.stopPropagation();
track.fxChain.toggleEffect(slot.id);
mixer._refreshFXButton(track); mixer._saveState();
closeMenu(); showFXMenu(track, anchorBtn, mixer);
};
item.querySelector(".sm-fx-menu-remove").onclick = (e) => {
e.stopPropagation();
if (slot.popup) { try { slot.popup._cleanup?.(); } catch (_) {} slot.popup.remove(); }
track.fxChain.removeEffect(slot.id);
mixer._refreshFXButton(track); mixer._saveState();
closeMenu(); showFXMenu(track, anchorBtn, mixer);
};
menu.appendChild(item);
}
const sep = document.createElement("div");
sep.className = "sm-fx-menu-sep";
menu.appendChild(sep);
}
const addHeader = document.createElement("div");
addHeader.className = "sm-fx-menu-header";
addHeader.textContent = "Add effect";
menu.appendChild(addHeader);
for (const [type, def] of Object.entries(FX_REGISTRY)) {
const item = document.createElement("div");
item.className = "sm-fx-menu-item";
item.textContent = "+ " + def.label;
item.onclick = () => {
const slot = track.fxChain.addEffect(type);
closeMenu();
mixer._refreshFXButton(track); mixer._saveState();
openFXPopup(track, slot, mixer);
};
menu.appendChild(item);
}
const sep2 = document.createElement("div");
sep2.className = "sm-fx-menu-sep";
menu.appendChild(sep2);
const closeItem = document.createElement("div");
closeItem.className = "sm-fx-menu-item sm-fx-menu-close";
closeItem.textContent = "Cancel";
closeItem.onclick = closeMenu;
menu.appendChild(closeItem);
document.body.appendChild(menu);
}
// ===========================================================================
// FX Popup — default layout + custom layouts (widener)
// ===========================================================================
function _renderDefaultBody(slot, def) {
return def.uiParams.map(p => {
if (p.isSelect) {
return `
<div class="sm-fx-param" data-key="${p.key}">
<div class="sm-fx-paramlbl">${escFx(p.label)}</div>
<select class="sm-fx-select">
${p.options.map(o =>
`<option value="${o.value}" ${o.value === slot.params[p.key] ? "selected" : ""}>${escFx(o.label)}</option>`
).join("")}
</select>
</div>
`;
}
return `
<div class="sm-fx-param" data-key="${p.key}">
<div class="sm-fx-paramlbl">${escFx(p.label)}</div>
<div class="sm-fx-paramval">${escFx(p.format(slot.params[p.key]))}</div>
<input class="sm-fx-slider" type="range"
min="${p.min}" max="${p.max}" step="${p.step}"
value="${slot.params[p.key]}" />
</div>
`;
}).join("");
}
function _renderWidenerBody(slot, def) {
const p = def.uiParams[0];
return `
<div class="sm-fx-widener-body">
<canvas class="sm-fx-widener-graph" width="120" height="120"></canvas>
<div class="sm-fx-widener-control">
<div class="sm-fx-paramlbl">${escFx(p.label)}</div>
<div class="sm-fx-paramval sm-fx-widener-val">${escFx(p.format(slot.params[p.key]))}</div>
<input class="sm-fx-slider sm-fx-vslider" data-key="${p.key}"
type="range" orient="vertical"
min="${p.min}" max="${p.max}" step="${p.step}"
value="${slot.params[p.key]}" />
</div>
</div>
`;
}
// Draw the widener visualization: two crossed lines that spread / collapse
// based on width. width=0 → vertical line (mono), width=1 → 90° X,
// width=2 → wide X opening up to ~140°.
function _drawWidenerGraph(canvas, width) {
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
// Background
ctx.fillStyle = "#1a1a1a";
ctx.fillRect(0, 0, W, H);
// Grid: center crosshair
ctx.strokeStyle = "#333";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(W/2, 0); ctx.lineTo(W/2, H);
ctx.moveTo(0, H/2); ctx.lineTo(W, H/2);
ctx.stroke();
// Bounding circle
ctx.beginPath();
ctx.arc(W/2, H/2, Math.min(W, H) / 2 - 8, 0, Math.PI * 2);
ctx.stroke();
// Compute spread angle from width
// width=0 → 0° (collapsed to vertical)
// width=1 → 90° (perfect X)
// width=2 → 140° (wide)
const angle = (Math.PI / 4) * Math.max(0, Math.min(width, 2));
const r = Math.min(W, H) / 2 - 12;
// L line: from center, going up-left
const lx = W/2 - Math.sin(angle) * r;
const ly = H/2 - Math.cos(angle) * r;
const rx = W/2 + Math.sin(angle) * r;
const ry = H/2 - Math.cos(angle) * r;
// Color: orange when widened, blue at unity
const isWide = width > 1.05;
const isMono = width < 0.05;
const color = isMono ? "#ff3b30" : isWide ? "#ff9f0a" : "#4a9eff";
ctx.strokeStyle = color;
ctx.lineWidth = 2;
// Draw L line
ctx.beginPath();
ctx.moveTo(W/2, H/2); ctx.lineTo(lx, ly);
ctx.stroke();
// Draw R line
ctx.beginPath();
ctx.moveTo(W/2, H/2); ctx.lineTo(rx, ry);
ctx.stroke();
// Labels
ctx.fillStyle = "#666";
ctx.font = "10px sans-serif";
ctx.fillText("L", lx - 12, ly + 4);
ctx.fillText("R", rx + 4, ry + 4);
// Status text at bottom
ctx.fillStyle = color;
ctx.font = "10px sans-serif";
ctx.textAlign = "center";
const status = isMono ? "MONO" : isWide ? "WIDE" : "STEREO";
ctx.fillText(status, W/2, H - 4);
ctx.textAlign = "left";
}
export function openFXPopup(track, slot, mixer) {
if (slot.popup) { slot.popup.style.zIndex = String(Date.now()); return; }
const def = FX_REGISTRY[slot.type];
if (!def) return;
const popup = document.createElement("div");
popup.className = "sm-fx-popup";
if (def.customLayout === "widener") popup.classList.add("sm-fx-popup-widener");
const bodyHTML = def.customLayout === "widener"
? _renderWidenerBody(slot, def)
: _renderDefaultBody(slot, def);
popup.innerHTML = `
<div class="sm-fx-titlebar">
<span class="sm-fx-title">${escFx(def.label)} — ${escFx(track.name)}</span>
<div class="sm-fx-titleactions">
<button class="sm-fx-onoff ${slot.on ? 'on' : 'off'}">${slot.on ? "ON" : "OFF"}</button>
<button class="sm-fx-reset" title="Reset to default">Reset</button>
<button class="sm-fx-close" title="Close">✕</button>
</div>
</div>
<div class="sm-fx-body">${bodyHTML}</div>
`;
let x, y;
if (slot.fxWindow && typeof slot.fxWindow.x === "number") {
x = Math.max(0, Math.min(slot.fxWindow.x, window.innerWidth - 100));
y = Math.max(0, Math.min(slot.fxWindow.y, window.innerHeight - 50));
} else {
x = Math.max(20, window.innerWidth / 2 - 220);
y = Math.max(20, window.innerHeight / 2 - 140);
}
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
document.body.appendChild(popup);
slot.popup = popup;
// Drag
const titlebar = popup.querySelector(".sm-fx-titlebar");
let dragOff = null;
titlebar.addEventListener("mousedown", e => {
if (e.target.closest(".sm-fx-close, .sm-fx-reset, .sm-fx-onoff")) return;
const r = popup.getBoundingClientRect();
dragOff = { dx: e.clientX - r.left, dy: e.clientY - r.top };
e.preventDefault();
});
const onMove = e => {
if (!dragOff) return;
const x = Math.max(0, Math.min(e.clientX - dragOff.dx, window.innerWidth - 100));
const y = Math.max(0, Math.min(e.clientY - dragOff.dy, window.innerHeight - 30));
popup.style.left = `${x}px`;
popup.style.top = `${y}px`;
};
const onUp = () => {
if (dragOff) {
dragOff = null;
const r = popup.getBoundingClientRect();
slot.fxWindow = { x: r.left, y: r.top };
mixer._saveState();
}
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
popup._cleanup = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
// Close
popup.querySelector(".sm-fx-close").addEventListener("click", () => {
try { popup._cleanup?.(); } catch (_) {}
popup.remove();
slot.popup = null;
});
// ON/OFF
const onBtn = popup.querySelector(".sm-fx-onoff");
onBtn.addEventListener("click", () => {
track.fxChain.toggleEffect(slot.id);
onBtn.classList.toggle("on", slot.on);
onBtn.classList.toggle("off", !slot.on);
onBtn.textContent = slot.on ? "ON" : "OFF";
mixer._refreshFXButton(track); mixer._saveState();
});
// Reset
popup.querySelector(".sm-fx-reset").addEventListener("click", () => {
const def2 = FX_REGISTRY[slot.type];
const newParams = { ...def2.defaultParams };
track.fxChain.updateParams(slot.id, newParams);
// Refresh UI
for (const p of def2.uiParams) {
const wrap = popup.querySelector(`.sm-fx-param[data-key="${p.key}"]`);
if (wrap) {
if (p.isSelect) {
wrap.querySelector(".sm-fx-select").value = newParams[p.key];
} else {
wrap.querySelector(".sm-fx-slider").value = newParams[p.key];
wrap.querySelector(".sm-fx-paramval").textContent = p.format(newParams[p.key]);
}
}
// Widener custom layout
const vSlider = popup.querySelector(`.sm-fx-vslider[data-key="${p.key}"]`);
if (vSlider) {
vSlider.value = newParams[p.key];
const valEl = popup.querySelector(".sm-fx-widener-val");
if (valEl) valEl.textContent = p.format(newParams[p.key]);
const cv = popup.querySelector(".sm-fx-widener-graph");
if (cv) _drawWidenerGraph(cv, newParams[p.key]);
}
}
mixer._refreshFXButton(track); mixer._saveState();
});
// Hook up sliders / selects
if (def.customLayout === "widener") {
const slider = popup.querySelector(".sm-fx-vslider");
const valEl = popup.querySelector(".sm-fx-widener-val");
const cv = popup.querySelector(".sm-fx-widener-graph");
const p = def.uiParams[0];
_drawWidenerGraph(cv, slot.params.width);
slider.addEventListener("input", e => {
const v = parseFloat(e.target.value);
track.fxChain.updateParams(slot.id, { [p.key]: v });
valEl.textContent = p.format(v);
_drawWidenerGraph(cv, v);
mixer._refreshFXButton(track); mixer._saveState();
});
slider.addEventListener("dblclick", () => {
track.fxChain.updateParams(slot.id, { [p.key]: 1.0 });
slider.value = 1.0;
valEl.textContent = p.format(1.0);
_drawWidenerGraph(cv, 1.0);
mixer._refreshFXButton(track); mixer._saveState();
});
} else {
for (const p of def.uiParams) {
const wrap = popup.querySelector(`.sm-fx-param[data-key="${p.key}"]`);
if (!wrap) continue;
if (p.isSelect) {
const sel = wrap.querySelector(".sm-fx-select");
sel.addEventListener("change", e => {
track.fxChain.updateParams(slot.id, { [p.key]: e.target.value });
mixer._refreshFXButton(track); mixer._saveState();
});
} else {
const slider = wrap.querySelector(".sm-fx-slider");
const label = wrap.querySelector(".sm-fx-paramval");
slider.addEventListener("input", e => {
const v = parseFloat(e.target.value);
track.fxChain.updateParams(slot.id, { [p.key]: v });
label.textContent = p.format(v);
mixer._refreshFXButton(track); mixer._saveState();
});
}
}
}
}
// ===========================================================================
// CSS injection
// ===========================================================================
export function injectFXCSS() {
if (document.getElementById("stem-mixer-fx-css")) return;
const style = document.createElement("style");
style.id = "stem-mixer-fx-css";
style.textContent = `
.sm-btn.fx-engaged {
background: #4a3000; border-color: #ff9f0a; color: #ff9f0a;
}
.sm-fx-menu-backdrop {
position: fixed; inset: 0; z-index: 9998; background: transparent;
}
.sm-fx-menu {
position: fixed; z-index: 9999;
background: #2a2a2a; border: 1px solid #555; border-radius: 5px;
box-shadow: 0 4px 16px rgba(0,0,0,0.6);
color: #ccc; font-family: sans-serif; font-size: 11px;
min-width: 180px; padding: 4px 0; user-select: none;
}
.sm-fx-menu-close { color: #888; text-align: center; font-style: italic; }
.sm-fx-menu-close:hover { background: #3a3a3a; color: #fff; }
.sm-fx-menu-header {
font-size: 9px; color: #777; letter-spacing: 0.5px;
padding: 4px 10px 2px; text-transform: uppercase;
}
.sm-fx-menu-item {
padding: 6px 10px; cursor: pointer;
display: flex; align-items: center; gap: 6px;
}
.sm-fx-menu-item:hover { background: #3a3a3a; }
.sm-fx-menu-active .sm-fx-menu-name { flex: 1; cursor: pointer; }
.sm-fx-menu-name.sm-fx-off { opacity: 0.5; }
.sm-fx-menu-toggle, .sm-fx-menu-remove {
background: #1e1e1e; border: 1px solid #444; border-radius: 3px;
color: #aaa; cursor: pointer; font-size: 9px; padding: 2px 6px;
}
.sm-fx-menu-toggle:hover, .sm-fx-menu-remove:hover { background: #333; color: #fff; }
.sm-fx-menu-sep { height: 1px; background: #444; margin: 4px 0; }
.sm-fx-popup {
position: fixed; width: 460px;
background: #2a2a2a; border: 1px solid #555; border-radius: 6px;
box-shadow: 0 6px 24px rgba(0,0,0,0.6);
z-index: 9999; color: #ccc; font-family: sans-serif; font-size: 12px;
user-select: none;
}
.sm-fx-popup-widener { width: 320px; }
.sm-fx-titlebar {
display: flex; justify-content: space-between; align-items: center;
padding: 6px 10px; background: #1e1e1e; border-bottom: 1px solid #444;
border-radius: 6px 6px 0 0; cursor: move;
}
.sm-fx-title { font-size: 12px; color: #ddd; font-weight: 500; }
.sm-fx-titleactions { display: flex; align-items: center; gap: 4px; }
.sm-fx-onoff {
background: #3a3a3a; border: 1px solid #555; border-radius: 3px;
color: #888; cursor: pointer; font-size: 10px; padding: 2px 8px;
}
.sm-fx-onoff.on { background: #1a6abf; border-color: #4a9eff; color: #fff; }
.sm-fx-onoff.off { opacity: 0.6; }
.sm-fx-reset {
background: #3a3a3a; border: 1px solid #555; border-radius: 3px;
color: #ccc; cursor: pointer; font-size: 10px; padding: 2px 8px;
}
.sm-fx-reset:hover { background: #4a4a4a; border-color: #ff9f0a; color: #ff9f0a; }
.sm-fx-close {
background: transparent; border: none; color: #aaa; font-size: 14px;
cursor: pointer; padding: 0 6px;
}
.sm-fx-close:hover { color: #fff; }
.sm-fx-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
.sm-fx-param {
display: grid; grid-template-columns: 70px 1fr 80px;
align-items: center; gap: 8px;
}
.sm-fx-paramlbl { font-size: 11px; color: #888; letter-spacing: 0.5px; }
.sm-fx-slider {
flex: 1; accent-color: #ff9f0a; cursor: pointer; width: 100%;
}
.sm-fx-paramval {
font-size: 11px; color: #ddd; text-align: right;
font-variant-numeric: tabular-nums;
}
.sm-fx-select {
background: #1e1e1e; border: 1px solid #555; border-radius: 3px;
color: #ddd; font-size: 11px; padding: 2px 6px; grid-column: 2 / span 2;
cursor: pointer;
}
/* Widener-specific layout */
.sm-fx-widener-body {
display: flex; gap: 16px; align-items: center; justify-content: center;
padding: 8px;
}
.sm-fx-widener-graph {
background: #1a1a1a; border-radius: 4px; flex-shrink: 0;
}
.sm-fx-widener-control {
display: flex; flex-direction: column; align-items: center;
gap: 6px;
}
.sm-fx-widener-control .sm-fx-paramlbl {
font-size: 10px; color: #888;
}
.sm-fx-widener-val {
font-size: 11px; color: #ddd; min-height: 14px;
}
.sm-fx-vslider {
appearance: slider-vertical; -webkit-appearance: slider-vertical;
writing-mode: vertical-lr; direction: rtl;
width: 24px; height: 100px;
accent-color: #ff9f0a; cursor: pointer;
}
`;
document.head.appendChild(style);
}