From 60be0ca60be958881f08f56fac37d703a6c41234 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Tue, 31 Mar 2026 08:14:41 -0700 Subject: [PATCH 001/136] Added pink noise EQ functions w/ Cursor --- config.js | 3 +- graphtool.js | 302 +++++++++++++++++++++++++++++++++++++++++++++----- style-alt.css | 66 ++++++++++- 3 files changed, 344 insertions(+), 27 deletions(-) diff --git a/config.js b/config.js index 1c1cfcc..1b94e1b 100644 --- a/config.js +++ b/config.js @@ -39,7 +39,8 @@ const init_phones = ["BKF"], // Optional. Which graphs to display on extraEQEnabled = true, // Enable parametic eq function extraEQBands = 10, // Default EQ bands available extraEQBandsMax = 20, // Max EQ bands available - extraToneGeneratorEnabled = true; // Enable tone generator function + extraToneGeneratorEnabled = true, // Enable tone generator function + extraPinkNoiseEnabled = true; // Pink noise through parametric EQ (Equalizer tab) // Specify which targets to display const targets = [ diff --git a/graphtool.js b/graphtool.js index bb24b5c..136c921 100644 --- a/graphtool.js +++ b/graphtool.js @@ -130,13 +130,28 @@ doc.html(` From 4ec6f272f263d689efe98fa3671d5349988ea779 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sun, 19 Apr 2026 21:43:55 -0700 Subject: [PATCH 064/136] Ignore comp targets for EQ --- graphtool.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/graphtool.js b/graphtool.js index b26f0a7..fd41440 100644 --- a/graphtool.js +++ b/graphtool.js @@ -3179,6 +3179,9 @@ function addExtra() { // EQ Function let eqPhoneSelect = document.querySelector("div.extra-eq select[name='phone']"); let eqPhoneTargetSelect = document.querySelector("div.extra-eq select[name='eq-target']"); + let eqTargetExcludedByCompTxtFileName = (p) => { + return p && String(p.fileName || "").indexOf("Comp.txt") !== -1; + }; /** EQ model row: explicit model dropdown match, else first graphed IEM (not target, not "* EQ" child name). */ let resolveEqModelPhone = () => { let sel = eqPhoneSelect && eqPhoneSelect.value; @@ -3196,12 +3199,13 @@ function addExtra() { let fromSel = tsel ? activePhones.filter((p) => p.fullName === tsel)[0] : null; - if (fromSel) { + if (fromSel && !eqTargetExcludedByCompTxtFileName(fromSel)) { return fromSel; } let eqP = modelP.eq || null; - return activePhones.filter((p) => p.isTarget)[0] - || activePhones.filter((p) => p !== modelP && p !== eqP && !p.isTarget)[0] || null; + return activePhones.filter((p) => p.isTarget && !eqTargetExcludedByCompTxtFileName(p))[0] + || activePhones.filter((p) => p !== modelP && p !== eqP && !p.isTarget + && !eqTargetExcludedByCompTxtFileName(p))[0] || null; }; let prevParametricFocusActive = false; applyParametricEqGraphTraceFocus = () => { @@ -4670,9 +4674,11 @@ function addExtra() { return; } let phoneSelected = eqPhoneSelect.value; - let targs = activePhones.filter((p) => p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)); + let targs = activePhones.filter((p) => p.isTarget && p.fullName && !p.fullName.match(/ EQ$/) + && !eqTargetExcludedByCompTxtFileName(p)); let others = activePhones.filter((p) => !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/) - && (!phoneSelected || p.fullName !== phoneSelected)); + && (!phoneSelected || p.fullName !== phoneSelected) + && !eqTargetExcludedByCompTxtFileName(p)); let byName = (a, b) => String(a.fullName).localeCompare(String(b.fullName)); targs.sort(byName); others.sort(byName); From 48021d5fad3288e3a080fc5acdbf097993001264 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sun, 19 Apr 2026 22:14:38 -0700 Subject: [PATCH 065/136] Reduced line height on EQ filters --- style-alt.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/style-alt.css b/style-alt.css index 31f1a17..2aaa100 100644 --- a/style-alt.css +++ b/style-alt.css @@ -1199,10 +1199,10 @@ div.extra-panel .eq-constraint-preset-stack > select { calc(100% - 9px) 50%; } -/* EQ only: 36px total with bordered shell (10 + 14 + 10 + 2); 14px line avoids tight 1em clipping */ +/* EQ model / target selects: 12px text and line box */ div.extra-panel > div.extra-eq select { font-size: 12px; - line-height: 14px; + line-height: 12px; padding: 10px 0; } From 3d17ea0c1ced7007560fd745626a56c8c54d4617 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Mon, 20 Apr 2026 13:56:15 -0700 Subject: [PATCH 066/136] Fixed comp target exclusion --- graphtool.js | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/graphtool.js b/graphtool.js index fd41440..95aa6f8 100644 --- a/graphtool.js +++ b/graphtool.js @@ -3179,8 +3179,16 @@ function addExtra() { // EQ Function let eqPhoneSelect = document.querySelector("div.extra-eq select[name='phone']"); let eqPhoneTargetSelect = document.querySelector("div.extra-eq select[name='eq-target']"); - let eqTargetExcludedByCompTxtFileName = (p) => { - return p && String(p.fileName || "").indexOf("Comp.txt") !== -1; + /** EQ target list / implicit target: omit compensation targets ("…Comp Target" / "…Comp Target.txt"). */ + let eqTargetExcludedCompTargetTrace = (p) => { + if (!p) { + return true; + } + let fn = String(p.fileName || "").trim(); + let full = String(p.fullName || "").trim(); + /* Built-in/uploaded targets often store basename without .txt (see fileFR upload). */ + let re = /comp target(\.txt)?$/i; + return re.test(fn) || re.test(full); }; /** EQ model row: explicit model dropdown match, else first graphed IEM (not target, not "* EQ" child name). */ let resolveEqModelPhone = () => { @@ -3199,13 +3207,13 @@ function addExtra() { let fromSel = tsel ? activePhones.filter((p) => p.fullName === tsel)[0] : null; - if (fromSel && !eqTargetExcludedByCompTxtFileName(fromSel)) { + if (fromSel && !eqTargetExcludedCompTargetTrace(fromSel)) { return fromSel; } let eqP = modelP.eq || null; - return activePhones.filter((p) => p.isTarget && !eqTargetExcludedByCompTxtFileName(p))[0] + return activePhones.filter((p) => p.isTarget && !eqTargetExcludedCompTargetTrace(p))[0] || activePhones.filter((p) => p !== modelP && p !== eqP && !p.isTarget - && !eqTargetExcludedByCompTxtFileName(p))[0] || null; + && !eqTargetExcludedCompTargetTrace(p))[0] || null; }; let prevParametricFocusActive = false; applyParametricEqGraphTraceFocus = () => { @@ -4675,10 +4683,10 @@ function addExtra() { } let phoneSelected = eqPhoneSelect.value; let targs = activePhones.filter((p) => p.isTarget && p.fullName && !p.fullName.match(/ EQ$/) - && !eqTargetExcludedByCompTxtFileName(p)); + && !eqTargetExcludedCompTargetTrace(p)); let others = activePhones.filter((p) => !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/) && (!phoneSelected || p.fullName !== phoneSelected) - && !eqTargetExcludedByCompTxtFileName(p)); + && !eqTargetExcludedCompTargetTrace(p)); let byName = (a, b) => String(a.fullName).localeCompare(String(b.fullName)); targs.sort(byName); others.sort(byName); From b0177ed547356d1420c205885b780630c2f5bf49 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Mon, 20 Apr 2026 14:50:30 -0700 Subject: [PATCH 067/136] Icon improvements --- graphtool.js | 27 ++++------- style-alt.css | 123 ++++++++++++++++++++++++++++++++++++++------------ 2 files changed, 101 insertions(+), 49 deletions(-) diff --git a/graphtool.js b/graphtool.js index 95aa6f8..4fe48a1 100644 --- a/graphtool.js +++ b/graphtool.js @@ -137,12 +137,10 @@ doc.html(`
- +
@@ -295,7 +293,7 @@ doc.html(` Pink Noise
- +
@@ -303,7 +301,7 @@ doc.html(` Tone Generator
- +
@@ -320,7 +318,7 @@ doc.html(` @@ -237,6 +249,23 @@ doc.html(`
+
Type @@ -4137,8 +4166,7 @@ function addExtra() { return filters; }; /* Clamp to Equalizer.config ranges for audio / export only; never mutates DOM. */ - let elemToFiltersClampedForEqualizerApply = (includeAll) => { - let raw = elemToFilters(includeAll); + let elemToFiltersClampedRowsForEqualizerApply = (raw, includeAll) => { let [fLo, fHi] = getEqConstraintFreqLoHi(); let [qLo, qHi] = getEqConstraintQLoHi(); let [gLo, gHi] = getEqConstraintGainLoHi(); @@ -4161,6 +4189,8 @@ function addExtra() { return { ...f, type, freq, q, gain }; }); }; + let elemToFiltersClampedForEqualizerApply = (includeAll) => + elemToFiltersClampedRowsForEqualizerApply(elemToFilters(includeAll), includeAll); let filtersToElem = (filters) => { // Set filters to ui let filtersCopy = filters.map(f => f); @@ -4183,7 +4213,251 @@ function addExtra() { applyEqConstraintAttributesToFilterInputs(); refreshEqFilterConstraintViolationStyles(); refreshEqFilterInactiveStateForMaxBands(); + if (isEqTwoChannelSupportEnabled()) { + eq2chBankData[eq2chActiveBank] = filtersCopy.map((f) => ({ + disabled: !!f.disabled, + type: f.type, + freq: f.freq, + q: f.q, + gain: f.gain + })); + } + }; + let eq2chConstraintToggle = document.querySelector("input.eq-constraint-2ch-toggle"); + let eq2chBankTabsEl = document.getElementById("eq-2ch-bank-tabs"); + let eq2chBankData = { both: [], L: [], R: [] }; + let eq2chActiveBank = "both"; + const EQ_2CH_BANK_SWAP_ANIM_MS = 300; + const EQ_2CH_BANK_SWAP_APPLY_AT_MS = 95; + let eq2chBankSwapSeq = 0; + let eq2chBankSwapApplyTimer = null; + let eq2chBankSwapCleanupTimer = null; + let eq2chBankSwapAnimEndFn = null; + let clearEq2chBankSwapAnimation = () => { + if (eq2chBankSwapApplyTimer !== null) { + clearTimeout(eq2chBankSwapApplyTimer); + eq2chBankSwapApplyTimer = null; + } + if (eq2chBankSwapCleanupTimer !== null) { + clearTimeout(eq2chBankSwapCleanupTimer); + eq2chBankSwapCleanupTimer = null; + } + if (filtersContainer && eq2chBankSwapAnimEndFn) { + filtersContainer.removeEventListener("animationend", eq2chBankSwapAnimEndFn); + eq2chBankSwapAnimEndFn = null; + } + if (filtersContainer) { + filtersContainer.classList.remove("eq-2ch-bank-filters-swap-anim"); + } + }; + let isEqTwoChannelSupportEnabled = () => + !!(eq2chConstraintToggle && eq2chConstraintToggle.checked); + let eq2chDefaultEmptyRow = () => ({ + disabled: false, + type: "PK", + freq: 0, + q: 0, + gain: 0 + }); + let eq2chPadBankToEqBands = (arr) => { + let filtersCopy = (arr || []).map((f) => ({ ...f })); + while (filtersCopy.length < eqBands) { + filtersCopy.push(eq2chDefaultEmptyRow()); + } + if (filtersCopy.length > eqBands) { + filtersCopy.length = eqBands; + } + return filtersCopy; + }; + let eq2chFlushDomToActiveBankCore = () => { + eq2chBankData[eq2chActiveBank] = elemToFilters(true).map((f) => ({ + disabled: !!f.disabled, + type: f.type, + freq: f.freq, + q: f.q, + gain: f.gain + })); + }; + let eq2chFlushDomToActiveBank = () => { + if (!isEqTwoChannelSupportEnabled()) { + return; + } + eq2chFlushDomToActiveBankCore(); + }; + let eq2chRowsToApplySpecs = (rows) => { + let clamped = elemToFiltersClampedRowsForEqualizerApply(eq2chPadBankToEqBands(rows), true); + return clamped.filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + .map((f) => ({ type: f.type, freq: f.freq, q: f.q, gain: f.gain })); + }; + let eq2chMergedSpecsForChannelIndex = (chIdx) => { + let bothS = eq2chRowsToApplySpecs(eq2chBankData.both); + if (!LR || !LR.length) { + return bothS; + } + let lab = LR[Math.min(chIdx, LR.length - 1)]; + let out = bothS.slice(); + if (lab === "L") { + out.push(...eq2chRowsToApplySpecs(eq2chBankData.L)); + } else if (lab === "R") { + out.push(...eq2chRowsToApplySpecs(eq2chBankData.R)); + } + return out; + }; + let eq2chGraphPreviewChannelIndex = () => { + if (!isEqTwoChannelSupportEnabled()) { + return 0; + } + if (eq2chActiveBank === "R") { + let ix = LR.indexOf("R"); + return ix >= 0 ? ix : 1; + } + if (eq2chActiveBank === "L") { + let ixL = LR.indexOf("L"); + return ixL >= 0 ? ixL : 0; + } + return 0; + }; + let eq2chSyncBankTabStyles = () => { + if (!eq2chBankTabsEl) { + return; + } + let pos = eq2chActiveBank === "L" ? "0" : eq2chActiveBank === "R" ? "2" : "1"; + let track = eq2chBankTabsEl.querySelector(".eq-2ch-bank-seg-track"); + if (track) { + track.setAttribute("data-eq-2ch-pos", pos); + } + eq2chBankTabsEl.querySelectorAll(".eq-2ch-bank-seg-btn").forEach((btn) => { + let b = btn.getAttribute("data-eq-2ch-bank"); + let on = b === eq2chActiveBank; + btn.setAttribute("aria-selected", on ? "true" : "false"); + btn.tabIndex = on ? 0 : -1; + }); + }; + let eq2chSwitchBank = (next) => { + if (!isEqTwoChannelSupportEnabled() || (next !== "both" && next !== "L" && next !== "R")) { + return; + } + if (next === eq2chActiveBank) { + return; + } + let finishBankSwitch = () => { + eq2chFlushDomToActiveBank(); + eq2chActiveBank = next; + filtersToElem(eq2chPadBankToEqBands(eq2chBankData[eq2chActiveBank])); + eq2chSyncBankTabStyles(); + cancelDeferredApplyEQ(); + applyEQExec(); + scheduleLiveEqSync(); + }; + let prefersReducedMotion = typeof window.matchMedia === "function" + && window.matchMedia("(prefers-reduced-motion: reduce)").matches; + if (!filtersContainer || prefersReducedMotion) { + finishBankSwitch(); + return; + } + clearEq2chBankSwapAnimation(); + let mySeq = ++eq2chBankSwapSeq; + let animClass = "eq-2ch-bank-filters-swap-anim"; + void filtersContainer.offsetWidth; + filtersContainer.classList.add(animClass); + eq2chBankSwapAnimEndFn = (e) => { + if (e.target !== filtersContainer || e.animationName !== "eq2chBankFiltersSwap") { + return; + } + if (mySeq !== eq2chBankSwapSeq) { + return; + } + filtersContainer.removeEventListener("animationend", eq2chBankSwapAnimEndFn); + eq2chBankSwapAnimEndFn = null; + filtersContainer.classList.remove(animClass); + }; + filtersContainer.addEventListener("animationend", eq2chBankSwapAnimEndFn); + eq2chBankSwapApplyTimer = setTimeout(() => { + eq2chBankSwapApplyTimer = null; + if (mySeq !== eq2chBankSwapSeq) { + return; + } + finishBankSwitch(); + }, EQ_2CH_BANK_SWAP_APPLY_AT_MS); + eq2chBankSwapCleanupTimer = setTimeout(() => { + eq2chBankSwapCleanupTimer = null; + if (mySeq !== eq2chBankSwapSeq) { + return; + } + if (filtersContainer && eq2chBankSwapAnimEndFn) { + filtersContainer.removeEventListener("animationend", eq2chBankSwapAnimEndFn); + eq2chBankSwapAnimEndFn = null; + } + if (filtersContainer) { + filtersContainer.classList.remove(animClass); + } + }, EQ_2CH_BANK_SWAP_ANIM_MS + 80); + }; + let eq2chInitBanksFromCurrentDom = () => { + let base = elemToFilters(true).map((f) => ({ + disabled: !!f.disabled, + type: f.type, + freq: f.freq, + q: f.q, + gain: f.gain + })); + if (!base.length) { + base.push(eq2chDefaultEmptyRow()); + } + eq2chBankData.both = base.slice(); + eq2chBankData.L = eq2chPadBankToEqBands([eq2chDefaultEmptyRow()]); + eq2chBankData.R = eq2chPadBankToEqBands([eq2chDefaultEmptyRow()]); + eq2chActiveBank = "both"; + }; + let eq2chSetTabsVisibility = (show) => { + if (eq2chBankTabsEl) { + eq2chBankTabsEl.hidden = !show; + } + if (show) { + eq2chSyncBankTabStyles(); + } + }; + let eq2chOnConstraintToggleChange = () => { + if (isEqTwoChannelSupportEnabled()) { + eq2chInitBanksFromCurrentDom(); + eq2chSetTabsVisibility(true); + filtersToElem(eq2chPadBankToEqBands(eq2chBankData.both)); + } else { + eq2chFlushDomToActiveBank(); + if (eq2chActiveBank !== "both") { + eq2chActiveBank = "both"; + } + eq2chBankData.L = []; + eq2chBankData.R = []; + filtersToElem(eq2chPadBankToEqBands(eq2chBankData.both)); + eq2chSetTabsVisibility(false); + } + eq2chSyncBankTabStyles(); + cancelDeferredApplyEQ(); + applyEQExec(); + scheduleLiveEqSync(); }; + let eq2chResetAllBanksToDefaultRow = () => { + eq2chBankData.both = [eq2chDefaultEmptyRow()]; + eq2chBankData.L = [eq2chDefaultEmptyRow()]; + eq2chBankData.R = [eq2chDefaultEmptyRow()]; + eq2chActiveBank = "both"; + eq2chSetTabsVisibility(isEqTwoChannelSupportEnabled()); + eq2chSyncBankTabStyles(); + }; + if (eq2chBankTabsEl) { + eq2chBankTabsEl.addEventListener("click", (e) => { + let btn = e.target && e.target.closest && e.target.closest("button.eq-2ch-bank-seg-btn"); + if (!btn || !eq2chBankTabsEl.contains(btn)) { + return; + } + let bank = btn.getAttribute("data-eq-2ch-bank"); + eq2chSwitchBank(bank); + }); + } + if (eq2chConstraintToggle) { + eq2chConstraintToggle.addEventListener("change", eq2chOnConstraintToggleChange); + } /* Graphic auto-rows: Q max "0"/blank = unlimited (config hi 10); use nominal Q 1 unless min Q forces higher. */ let eqGraphicModeTemplateQHz = () => { let qMaxEl = document.querySelector("div.extra-eq input[name='eq-constraint-q-max']"); @@ -4532,20 +4806,29 @@ function addExtra() { return null; } let eqPhone = phoneObj.eq; - let eqCh = eqPhone && firstPresentChannel(eqPhone.rawChannels); + let chIx = eq2chGraphPreviewChannelIndex(); + let eqCh = eqPhone && eqPhone.rawChannels && eqPhone.rawChannels[chIx] + ? eqPhone.rawChannels[chIx] + : (eqPhone && firstPresentChannel(eqPhone.rawChannels)); if (eqCh) { return { tracePhone: eqPhone, traceCh: eqCh, strokePhone: eqPhone }; } - let filters = elemToFiltersClampedForEqualizerApply(false); + let chPv = chIx; + let rawChUse = (phoneObj.rawChannels && phoneObj.rawChannels[chPv]) + ? phoneObj.rawChannels[chPv] + : rawCh; + let filters = isEqTwoChannelSupportEnabled() + ? eq2chMergedSpecsForChannelIndex(chPv) + : elemToFiltersClampedForEqualizerApply(false); if (filters.length && typeof Equalizer !== "undefined" && Equalizer.apply) { try { - let fr = Equalizer.apply(rawCh, filters); + let fr = Equalizer.apply(rawChUse, filters); if (fr) { return { tracePhone: phoneObj, traceCh: fr, strokePhone: phoneObj }; } } catch (err) { /* noop */ } } - return { tracePhone: phoneObj, traceCh: rawCh, strokePhone: phoneObj }; + return { tracePhone: phoneObj, traceCh: rawChUse, strokePhone: phoneObj }; }; let computeEqNodePreviewAtMouse = (m) => { if (!m || m.length < 2) { @@ -4653,7 +4936,10 @@ function addExtra() { let pts = baseline.fn(traceCh); let yOff = y(getOffset(tracePhone)) - y(0); let strokeCol = getCurveColor(strokePhone.id, 0); - let phoneRaw0 = firstPresentChannel(phoneObj.rawChannels); + let pvCh = eq2chGraphPreviewChannelIndex(); + let phoneRaw0 = (phoneObj.rawChannels && phoneObj.rawChannels[pvCh]) + ? phoneObj.rawChannels[pvCh] + : firstPresentChannel(phoneObj.rawChannels); let rows = []; let maxA = getEffectiveEqMaxBands(); for (let i = 0; i < eqBands; i++) { @@ -4850,11 +5136,18 @@ function addExtra() { let applyEQExec = (execOpt) => { execOpt = execOpt || {}; refreshEqFilterConstraintViolationStyles(); + eq2chFlushDomToActiveBank(); // Create and show phone with eq applied let activeElem = document.activeElement; let phoneSelected = eqPhoneSelect.value; let filters = elemToFiltersClampedForEqualizerApply(); - if (filters.length && !phoneSelected) { + let hasEqSpecs = filters.length > 0; + if (isEqTwoChannelSupportEnabled()) { + hasEqSpecs = eq2chRowsToApplySpecs(eq2chBankData.both).length > 0 + || eq2chRowsToApplySpecs(eq2chBankData.L).length > 0 + || eq2chRowsToApplySpecs(eq2chBankData.R).length > 0; + } + if (hasEqSpecs && !phoneSelected) { let firstPhone = eqPhoneSelect.querySelectorAll("option")[1]; if (firstPhone) { phoneSelected = eqPhoneSelect.value = firstPhone.value; @@ -4863,12 +5156,23 @@ function addExtra() { } let phoneObj = phoneSelected && activePhones.filter( p => p.fullName == phoneSelected)[0]; - if (!phoneObj || (!filters.length && !phoneObj.eq)) { + if (!phoneObj || (!hasEqSpecs && !phoneObj.eq)) { updateEqFilterMarkers(); return; // Allow empty filters if eq is applied before } - let nextEqChannels = phoneObj.rawChannels.map( - (c) => (c ? Equalizer.apply(c, filters) : null)); + let nextEqChannels; + if (isEqTwoChannelSupportEnabled()) { + nextEqChannels = phoneObj.rawChannels.map((c, chIdx) => { + if (!c) { + return null; + } + let merged = eq2chMergedSpecsForChannelIndex(chIdx); + return Equalizer.apply(c, merged); + }); + } else { + nextEqChannels = phoneObj.rawChannels.map( + (c) => (c ? Equalizer.apply(c, filters) : null)); + } let liveGraphEqDrag = Boolean(execOpt.liveGraphEqDrag && eqGraphPointerState && eqGraphPointerState.mode === "eq"); if (liveGraphEqDrag && phoneObj.eq && activePhones.indexOf(phoneObj.eq) !== -1) { @@ -5154,6 +5458,7 @@ function addExtra() { }; window.eqResetParametricAfterBaseModelRemoved = () => { eqFiltersUserHasEdited = false; + eq2chResetAllBanksToDefaultRow(); filtersToElem([{ disabled: false, type: "PK", freq: 0, q: 0, gain: 0 }]); eqFiltersUserHasEdited = false; if (eqPhoneSelect) { @@ -5181,6 +5486,7 @@ function addExtra() { removePhone(eqP); } eqFiltersUserHasEdited = false; + eq2chResetAllBanksToDefaultRow(); filtersToElem([{ disabled: false, type: "PK", freq: 0, q: 0, gain: 0 }]); eqFiltersUserHasEdited = false; } @@ -5269,6 +5575,11 @@ function addExtra() { } syncEqConstraintDomToEqualizerConfig(); } + if (eq2chConstraintToggle) { + eq2chConstraintToggle.checked = false; + } + eq2chResetAllBanksToDefaultRow(); + eq2chSetTabsVisibility(false); commitEqMaxBandsFromInput({ writeBackDom: true }); }; document.querySelector("div.extra-eq button.extra-eq-reset-btn").addEventListener("click", () => { @@ -5351,33 +5662,75 @@ function addExtra() { } let reader = new FileReader(); reader.onload = (e) => { - let settings = e.target.result; - let filters = settings.split("\n").map(l => { - let r = String(l == null ? "" : l).match(/Filter\s*\d+:\s*(\S+)\s*(\S+)\s*Fc\s*(\S+)\s*Hz\s*Gain\s*(\S+)\s*dB(\s*Q\s*(\S+))?/); - if (!r) { return undefined; } - let disabled = (r[1] !== "ON"); - let type = r[2]; - let freq = parseInt(r[3]) || 0; - let gain = parseFloat(r[4]) || 0; - let q = parseFloat(r[6]) || 0; - if (type === "LS" || type === "HS") { - type += "Q"; - q = q || 0.707; - } else if (type === "LSC" || type === "HSC") { - // Equalizer APO use LSC/HSC instead of LSQ/HSQ - type = type.substr(0, 2) + "Q"; + let settings = String(e.target.result || ""); + let parseFilterLineObjects = (blob) => { + let filters = blob.split("\n").map(l => { + let r = String(l == null ? "" : l).match(/Filter\s*\d+:\s*(\S+)\s*(\S+)\s*Fc\s*(\S+)\s*Hz\s*Gain\s*(\S+)\s*dB(\s*Q\s*(\S+))?/); + if (!r) { return undefined; } + let disabled = (r[1] !== "ON"); + let type = r[2]; + let freq = parseInt(r[3]) || 0; + let gain = parseFloat(r[4]) || 0; + let q = parseFloat(r[6]) || 0; + if (type === "LS" || type === "HS") { + type += "Q"; + q = q || 0.707; + } else if (type === "LSC" || type === "HSC") { + type = type.substr(0, 2) + "Q"; + } + return { disabled, type, freq, q, gain }; + }).filter(f => f); + while (filters.length > 0) { + let lastFilter = filters[filters.length - 1]; + if (!lastFilter.freq && !lastFilter.q && !lastFilter.gain) { + filters.pop(); + } else { + break; + } } - return { disabled, type, freq, q, gain }; - }).filter(f => f); - while (filters.length > 0) { - // Remove empty tail filters - let lastFilter = filters[filters.length-1]; - if (!lastFilter.freq && !lastFilter.q && !lastFilter.gain) { - filters.pop(); - } else { - break; + return filters; + }; + if (isEqTwoChannelSupportEnabled() && /^\s*Channel:\s*[LR]/im.test(settings)) { + let curKey = null; + let buf = []; + let flush = (key) => { + if (!key) { + return; + } + let filters = parseFilterLineObjects(buf.join("\n")); + if (key === "L") { + eq2chBankData.L = filters.length ? filters : [eq2chDefaultEmptyRow()]; + } else if (key === "R") { + eq2chBankData.R = filters.length ? filters : [eq2chDefaultEmptyRow()]; + } + buf = []; + }; + settings.split(/\r?\n/).forEach((line) => { + let chm = line.match(/^\s*Channel:\s*([LR])\s*$/i); + if (chm) { + flush(curKey); + curKey = chm[1].toUpperCase(); + return; + } + buf.push(line); + }); + flush(curKey); + eq2chBankData.both = [eq2chDefaultEmptyRow()]; + if (!eq2chBankData.L || !eq2chBankData.L.length) { + eq2chBankData.L = [eq2chDefaultEmptyRow()]; } + if (!eq2chBankData.R || !eq2chBankData.R.length) { + eq2chBankData.R = [eq2chDefaultEmptyRow()]; + } + eq2chBankData.L = eq2chPadBankToEqBands(eq2chBankData.L); + eq2chBankData.R = eq2chPadBankToEqBands(eq2chBankData.R); + eq2chActiveBank = "both"; + filtersToElem(eq2chPadBankToEqBands(eq2chBankData.both)); + applyEQ(); + scheduleLiveEqSync(); + return; } + let filters = parseFilterLineObjects(settings); if (filters.length > 0) { filtersToElem(filters); applyEQ(); @@ -5392,31 +5745,79 @@ function addExtra() { document.querySelector("div.extra-eq button.export-filters").addEventListener("click", () => { let phoneSelected = eqPhoneSelect.value; let phoneObj = phoneSelected && activePhones.filter( - p => p.fullName == phoneSelected && p.eq)[0]; - let filters = elemToFiltersClampedForEqualizerApply(true); - if (!phoneObj || !filters.length) { + p => p.fullName == phoneSelected)[0]; + if (!phoneObj) { alert("Please select model and add atleast one filter before export."); return; } - let preamp = Equalizer.calc_preamp( - phoneObj.rawChannels.filter(c => c)[0], - phoneObj.eq.rawChannels.filter(c => c)[0]); - let settings = "Preamp: " + preamp.toFixed(1) + " dB\r\n"; - filters.forEach((f, i) => { - let filterValid = f.freq != 0 && f.q != 0 && f.gain != 0 ? true : false; - - if (filterValid) { + eq2chFlushDomToActiveBank(); + let appendExportFilterLines = (settings, filtersArr) => { + let fi = 0; + filtersArr.forEach((f) => { + let filterValid = f.freq != 0 && f.q != 0 && f.gain != 0; + if (!filterValid) { + return; + } + fi++; let on = (!f.disabled && f.type && f.freq && f.gain && f.q) ? "ON" : "OFF"; let type = f.type; if (type === "LSQ" || type === "HSQ") { - // Equalizer APO use LSC/HSC instead of LSQ/HSQ type = type.substr(0, 2) + "C"; } - settings += ("Filter " + (i+1) + ": " + on + " " + type + " Fc " + + settings += ("Filter " + fi + ": " + on + " " + type + " Fc " + f.freq.toFixed(0) + " Hz Gain " + f.gain.toFixed(1) + " dB Q " + f.q.toFixed(3) + "\r\n"); + }); + return settings; + }; + let settings; + if (isEqTwoChannelSupportEnabled()) { + let has2 = eq2chRowsToApplySpecs(eq2chBankData.both).length > 0 + || eq2chRowsToApplySpecs(eq2chBankData.L).length > 0 + || eq2chRowsToApplySpecs(eq2chBankData.R).length > 0; + if (!has2) { + alert("Please add atleast one filter before export."); + return; } - }); + settings = ""; + for (let ci = 0; ci < LR.length && ci < phoneObj.rawChannels.length; ci++) { + let raw = phoneObj.rawChannels[ci]; + if (!raw) { + continue; + } + let lab = LR[ci] || ("Ch" + (ci + 1)); + let specs = eq2chMergedSpecsForChannelIndex(ci); + let eqCh = Equalizer.apply(raw, specs); + let preamp = Equalizer.calc_preamp(raw, eqCh); + let rowsForFile = elemToFiltersClampedRowsForEqualizerApply( + specs.map((s) => ({ + disabled: false, + type: s.type, + freq: s.freq, + q: s.q, + gain: s.gain + })), true); + settings += "Channel: " + lab + "\r\n"; + settings += "Preamp: " + preamp.toFixed(1) + " dB\r\n"; + settings = appendExportFilterLines(settings, rowsForFile); + settings += "\r\n"; + } + if (!String(settings).trim()) { + alert("Please select model with at least one measured channel before export."); + return; + } + } else { + let filters = elemToFiltersClampedForEqualizerApply(true); + if (!phoneObj.eq || !filters.length) { + alert("Please select model and add atleast one filter before export."); + return; + } + let preamp = Equalizer.calc_preamp( + phoneObj.rawChannels.filter(c => c)[0], + phoneObj.eq.rawChannels.filter(c => c)[0]); + settings = "Preamp: " + preamp.toFixed(1) + " dB\r\n"; + settings = appendExportFilterLines(settings, filters); + } let exportElem = document.querySelector("#file-filters-export"); exportElem.href && URL.revokeObjectURL(exportElem.href); exportElem.href = URL.createObjectURL(new Blob([settings])); @@ -5428,7 +5829,13 @@ function addExtra() { let phoneSelected = eqPhoneSelect.value; let phoneObj = phoneSelected && activePhones.filter( p => p.fullName == phoneSelected && p.eq)[0] || { fullName: "Unnamed" }; - let filters = elemToFiltersClampedForEqualizerApply(); + eq2chFlushDomToActiveBank(); + let filters; + if (isEqTwoChannelSupportEnabled()) { + filters = eq2chMergedSpecsForChannelIndex(0); + } else { + filters = elemToFiltersClampedForEqualizerApply(); + } if (!filters.length) { alert("Please add atleast one filter before export."); return; @@ -5549,6 +5956,7 @@ function addExtra() { if (!pkEl || !lsqEl || !hsqEl || !mb || !fMin || !fMax || !gMin || !gMax || !qMinEl || !qMaxEl) { return; } + let prevTwoCh = isEqTwoChannelSupportEnabled(); let strOrNum = (val, def) => { if (val === undefined || val === null) { return def; @@ -5590,11 +5998,30 @@ function addExtra() { gMax.value = strOrNum(preset.gainMax, "0"); qMinEl.value = strOrNum(preset.qMin, "0"); qMaxEl.value = strOrNum(preset.qMax, "0"); + if (eq2chConstraintToggle) { + eq2chConstraintToggle.checked = preset.twoChannelSupport === true; + } syncEqConstraintDomToEqualizerConfig(); let mbDoc = document.querySelector("div.extra-eq input[name='eq-constraint-max-bands']"); if (mbDoc && !mbDoc.disabled) { commitEqMaxBandsFromInput({ writeBackDom: true }); } + if (isEqTwoChannelSupportEnabled()) { + if (!eq2chBankData.both.length) { + eq2chInitBanksFromCurrentDom(); + } + eq2chSetTabsVisibility(true); + } else { + if (prevTwoCh) { + eq2chFlushDomToActiveBankCore(); + } + eq2chActiveBank = "both"; + eq2chBankData.L = []; + eq2chBankData.R = []; + eq2chSetTabsVisibility(false); + filtersToElem(eq2chPadBankToEqBands(eq2chBankData.both)); + } + eq2chSyncBankTabStyles(); }; let readUserEqConstraintPresetsFromStorage = () => { try { @@ -5695,6 +6122,8 @@ function addExtra() { if (graphic && gList && String(gList.value || "").trim()) { o.freqGraphicList = String(gList.value || "").trim(); } + let ch2 = cRoot.querySelector("input.eq-constraint-2ch-toggle"); + o.twoChannelSupport = !!(ch2 && ch2.checked); return o; }; let eqConstraintPresetDisplayPrefix = "Constraints: "; @@ -6524,11 +6953,27 @@ function addExtra() { let pinkNoiseAnalyser = null; let pinkNoiseBiquads = []; let pinkNoiseBandFilters = []; + let pinkNoiseBiquadsLeft = []; + let pinkNoiseBiquadsRight = []; + let pinkNoiseBandFiltersLeft = []; + let pinkNoiseBandFiltersRight = []; + let pinkNoiseMerger = null; let toneGeneratorBiquads = []; + let toneGeneratorBiquadsLeft = []; + let toneGeneratorBiquadsRight = []; + let toneGeneratorBandFiltersLeft = []; + let toneGeneratorBandFiltersRight = []; + let toneGeneratorMerger = null; let toneGeneratorMasterGain = null; let toneGeneratorAnalyser = null; let musicBiquads = []; let musicBandFilters = []; + let musicBiquadsLeft = []; + let musicBiquadsRight = []; + let musicBandFiltersLeft = []; + let musicBandFiltersRight = []; + let musicStereoSplitter = null; + let musicStereoMerger = null; let musicContext = null; let musicAudio = null; let musicMediaSourceNode = null; @@ -6678,28 +7123,79 @@ function addExtra() { (t === "LSQ" ? "lowshelf" : t === "HSQ" ? "highshelf" : "peaking"); /* Same bands as live biquads; independent of the Apply EQ toggle (used for preamp + A/B level match when EQ is bypassed). */ - let elemToLiveEqSpecsClamped = () => - elemToFilters().map((f) => ({ + let elemToLiveEqSpecsClamped = () => { + let rows; + if (isEqTwoChannelSupportEnabled()) { + eq2chFlushDomToActiveBank(); + rows = elemToFiltersClampedRowsForEqualizerApply( + eq2chPadBankToEqBands(eq2chBankData.both), false).filter( + (f) => !f.disabled && f.type && f.freq && f.q && f.gain); + } else { + rows = elemToFilters(); + } + return rows.map((f) => ({ type: f.type, freq: Math.min(20000, Math.max(20, f.freq)), q: Math.max(1e-4, Math.min(1000, f.q)), gain: Math.max(-40, Math.min(40, f.gain)), })); + }; let computeLiveEqSpecs = () => { if (!isLivePlaybackEqEnabled()) { return []; } return elemToLiveEqSpecsClamped(); }; + let liveStereoEqActive = () => + isEqTwoChannelSupportEnabled() && isLivePlaybackEqEnabled(); + let liveStereoEqChannelIndices = () => { + let li = LR.indexOf("L"); + let ri = LR.indexOf("R"); + if (li < 0) { + li = 0; + } + if (ri < 0) { + ri = LR.length > 1 ? 1 : 0; + } + return { li, ri }; + }; + let computeLiveEqSpecsForStereoPaths = () => { + eq2chFlushDomToActiveBank(); + let { li, ri } = liveStereoEqChannelIndices(); + return { + specL: eq2chMergedSpecsForChannelIndex(li), + specR: eq2chMergedSpecsForChannelIndex(ri) + }; + }; let getLiveMusicEqFrAnalysis = (sampleRate) => { - let specs = elemToLiveEqSpecsClamped(); - if (!specs.length) { - return null; - } let phoneObj = resolveEqGraphPhoneObj(); if (!phoneObj || !phoneObj.rawChannels) { return null; } + if (liveStereoEqActive()) { + let { specL, specR } = computeLiveEqSpecsForStereoPaths(); + if (!specL.length && !specR.length) { + return null; + } + let { li, ri } = liveStereoEqChannelIndices(); + let rawL = phoneObj.rawChannels[li]; + let rawR = phoneObj.rawChannels[ri]; + if (!rawL || !rawL.length) { + return null; + } + let frEqL = specL.length ? Equalizer.apply(rawL, specL, sampleRate) : rawL; + let preL = specL.length ? Equalizer.calc_preamp(rawL, frEqL) : 0; + let preR = preL; + if (rawR && rawR.length && specR.length) { + let frEqR = Equalizer.apply(rawR, specR, sampleRate); + preR = Equalizer.calc_preamp(rawR, frEqR); + } + return { raw: rawL, frEq: frEqL, preDb: (preL + preR) / 2 }; + } + let specs = elemToLiveEqSpecsClamped(); + if (!specs.length) { + return null; + } let raw = phoneObj.rawChannels.filter(Boolean)[0]; if (!raw || !raw.length) { return null; @@ -6794,12 +7290,46 @@ function addExtra() { try { b.disconnect(); } catch (e) { /* noop */ } }); pinkNoiseBandFilters.length = 0; + pinkNoiseBandFiltersLeft.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + pinkNoiseBandFiltersLeft.length = 0; + pinkNoiseBandFiltersRight.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + pinkNoiseBandFiltersRight.length = 0; + if (pinkNoiseMerger) { + try { + pinkNoiseMerger.disconnect(); + } catch (e) { /* noop */ } + pinkNoiseMerger = null; + } }; let disconnectMusicBandFilters = () => { musicBandFilters.forEach((b) => { try { b.disconnect(); } catch (e) { /* noop */ } }); musicBandFilters.length = 0; + musicBandFiltersLeft.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + musicBandFiltersLeft.length = 0; + musicBandFiltersRight.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + musicBandFiltersRight.length = 0; + if (musicStereoMerger) { + try { + musicStereoMerger.disconnect(); + } catch (e) { /* noop */ } + musicStereoMerger = null; + } + if (musicStereoSplitter) { + try { + musicStereoSplitter.disconnect(); + } catch (e) { /* noop */ } + musicStereoSplitter = null; + } }; let rebuildLiveEqChain = (sourceNode, audioContext, masterGain, biquadsArr) => { let specs = computeLiveEqSpecs(); @@ -6824,7 +7354,61 @@ function addExtra() { } last.connect(masterGain); }; - let rebuildPinkNoiseEqChain = () => { + let rebuildPinkNoiseEqChainStereo = () => { + if (!pinkNoisePlaying || !pinkNoiseContext || !pinkNoiseProcessor || !pinkNoiseMasterGain) { + return; + } + let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let { specL, specR } = computeLiveEqSpecsForStereoPaths(); + if (pinkNoiseBandFiltersLeft.length === 2 && pinkNoiseBandFiltersRight.length === 2 + && specL.length === pinkNoiseBiquadsLeft.length + && specR.length === pinkNoiseBiquadsRight.length + && syncBandShelfFiltersInPlace(pinkNoiseContext, pinkNoiseBandFiltersLeft, fromHz, toHz) + && syncBandShelfFiltersInPlace(pinkNoiseContext, pinkNoiseBandFiltersRight, fromHz, toHz)) { + if ((specL.length === 0 || syncEqBiquadsInPlace(pinkNoiseContext, pinkNoiseBiquadsLeft, specL)) + && (specR.length === 0 || syncEqBiquadsInPlace(pinkNoiseContext, pinkNoiseBiquadsRight, specR))) { + return; + } + } + pinkNoiseProcessor.disconnect(); + disconnectEqBiquads(pinkNoiseBiquads); + disconnectEqBiquads(pinkNoiseBiquadsLeft); + disconnectEqBiquads(pinkNoiseBiquadsRight); + disconnectPinkBandFilters(); + pinkNoiseMerger = pinkNoiseContext.createChannelMerger(2); + let wireSide = (specs, bandArr, bqArr, mergerCh) => { + let last = pinkNoiseProcessor; + let hp = pinkNoiseContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + bandArr.push(hp); + let lp = pinkNoiseContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + bandArr.push(lp); + specs.forEach((s) => { + let bf = pinkNoiseContext.createBiquadFilter(); + bf.type = mapFilterTypeToBiquad(s.type); + bf.frequency.value = s.freq; + bf.Q.value = s.q; + bf.gain.value = s.gain; + last.connect(bf); + last = bf; + bqArr.push(bf); + }); + last.connect(pinkNoiseMerger, 0, mergerCh); + }; + wireSide(specL, pinkNoiseBandFiltersLeft, pinkNoiseBiquadsLeft, 0); + wireSide(specR, pinkNoiseBandFiltersRight, pinkNoiseBiquadsRight, 1); + pinkNoiseMerger.connect(pinkNoiseMasterGain); + }; + let rebuildPinkNoiseEqChainMono = () => { if (!pinkNoisePlaying || !pinkNoiseContext || !pinkNoiseProcessor || !pinkNoiseMasterGain) { return; } @@ -6839,6 +7423,8 @@ function addExtra() { } pinkNoiseProcessor.disconnect(); disconnectEqBiquads(pinkNoiseBiquads); + disconnectEqBiquads(pinkNoiseBiquadsLeft); + disconnectEqBiquads(pinkNoiseBiquadsRight); disconnectPinkBandFilters(); let last = pinkNoiseProcessor; let hp = pinkNoiseContext.createBiquadFilter(); @@ -6867,13 +7453,173 @@ function addExtra() { }); last.connect(pinkNoiseMasterGain); }; - let rebuildToneGeneratorEqChain = () => { + let rebuildPinkNoiseEqChain = () => { + if (!pinkNoisePlaying || !pinkNoiseContext || !pinkNoiseProcessor || !pinkNoiseMasterGain) { + return; + } + if (liveStereoEqActive()) { + rebuildPinkNoiseEqChainStereo(); + } else { + rebuildPinkNoiseEqChainMono(); + } + }; + let rebuildToneGeneratorEqChainStereo = () => { + if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { + return; + } + let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let { specL, specR } = computeLiveEqSpecsForStereoPaths(); + if (toneGeneratorBandFiltersLeft.length === 2 && toneGeneratorBandFiltersRight.length === 2 + && specL.length === toneGeneratorBiquadsLeft.length + && specR.length === toneGeneratorBiquadsRight.length + && syncBandShelfFiltersInPlace(toneGeneratorContext, toneGeneratorBandFiltersLeft, fromHz, toHz) + && syncBandShelfFiltersInPlace(toneGeneratorContext, toneGeneratorBandFiltersRight, fromHz, toHz)) { + if ((specL.length === 0 || syncEqBiquadsInPlace(toneGeneratorContext, toneGeneratorBiquadsLeft, specL)) + && (specR.length === 0 || syncEqBiquadsInPlace(toneGeneratorContext, toneGeneratorBiquadsRight, specR))) { + return; + } + } + toneGeneratorOsc.disconnect(); + disconnectEqBiquads(toneGeneratorBiquads); + disconnectEqBiquads(toneGeneratorBiquadsLeft); + disconnectEqBiquads(toneGeneratorBiquadsRight); + toneGeneratorBandFiltersLeft.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersLeft.length = 0; + toneGeneratorBandFiltersRight.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersRight.length = 0; + if (toneGeneratorMerger) { + try { + toneGeneratorMerger.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorMerger = null; + } + toneGeneratorMerger = toneGeneratorContext.createChannelMerger(2); + let wireSide = (specs, bandArr, bqArr, mergerCh) => { + let last = toneGeneratorOsc; + let hp = toneGeneratorContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + bandArr.push(hp); + let lp = toneGeneratorContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + bandArr.push(lp); + specs.forEach((s) => { + let bf = toneGeneratorContext.createBiquadFilter(); + bf.type = mapFilterTypeToBiquad(s.type); + bf.frequency.value = s.freq; + bf.Q.value = s.q; + bf.gain.value = s.gain; + last.connect(bf); + last = bf; + bqArr.push(bf); + }); + last.connect(toneGeneratorMerger, 0, mergerCh); + }; + wireSide(specL, toneGeneratorBandFiltersLeft, toneGeneratorBiquadsLeft, 0); + wireSide(specR, toneGeneratorBandFiltersRight, toneGeneratorBiquadsRight, 1); + toneGeneratorMerger.connect(toneGeneratorMasterGain); + }; + let rebuildToneGeneratorEqChainMono = () => { if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { return; } rebuildLiveEqChain(toneGeneratorOsc, toneGeneratorContext, toneGeneratorMasterGain, toneGeneratorBiquads); }; - let rebuildMusicEqChain = () => { + let rebuildToneGeneratorEqChain = () => { + if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { + return; + } + if (liveStereoEqActive()) { + rebuildToneGeneratorEqChainStereo(); + } else { + disconnectEqBiquads(toneGeneratorBiquadsLeft); + disconnectEqBiquads(toneGeneratorBiquadsRight); + toneGeneratorBandFiltersLeft.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersLeft.length = 0; + toneGeneratorBandFiltersRight.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersRight.length = 0; + if (toneGeneratorMerger) { + try { + toneGeneratorMerger.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorMerger = null; + } + rebuildToneGeneratorEqChainMono(); + } + }; + let rebuildMusicEqChainStereo = () => { + if (!musicMediaSourceNode || !musicContext || !musicMasterGain) { + return; + } + let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let { specL, specR } = computeLiveEqSpecsForStereoPaths(); + if (musicBandFiltersLeft.length === 2 && musicBandFiltersRight.length === 2 + && specL.length === musicBiquadsLeft.length + && specR.length === musicBiquadsRight.length + && syncBandShelfFiltersInPlace(musicContext, musicBandFiltersLeft, fromHz, toHz) + && syncBandShelfFiltersInPlace(musicContext, musicBandFiltersRight, fromHz, toHz)) { + if ((specL.length === 0 || syncEqBiquadsInPlace(musicContext, musicBiquadsLeft, specL)) + && (specR.length === 0 || syncEqBiquadsInPlace(musicContext, musicBiquadsRight, specR))) { + syncMusicOutputGain(musicContext); + return; + } + } + musicMediaSourceNode.disconnect(); + disconnectEqBiquads(musicBiquads); + disconnectEqBiquads(musicBiquadsLeft); + disconnectEqBiquads(musicBiquadsRight); + disconnectMusicBandFilters(); + musicStereoSplitter = musicContext.createChannelSplitter(2); + musicStereoMerger = musicContext.createChannelMerger(2); + musicMediaSourceNode.connect(musicStereoSplitter); + let wireSide = (splitOut, specs, bandArr, bqArr, mergerCh) => { + let hp = musicContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + musicStereoSplitter.connect(hp, splitOut); + let last = hp; + bandArr.push(hp); + let lp = musicContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + bandArr.push(lp); + specs.forEach((s) => { + let bf = musicContext.createBiquadFilter(); + bf.type = mapFilterTypeToBiquad(s.type); + bf.frequency.value = s.freq; + bf.Q.value = s.q; + bf.gain.value = s.gain; + last.connect(bf); + last = bf; + bqArr.push(bf); + }); + last.connect(musicStereoMerger, 0, mergerCh); + }; + wireSide(0, specL, musicBandFiltersLeft, musicBiquadsLeft, 0); + wireSide(1, specR, musicBandFiltersRight, musicBiquadsRight, 1); + musicStereoMerger.connect(musicMasterGain); + syncMusicOutputGain(musicContext); + }; + let rebuildMusicEqChainMono = () => { if (!musicMediaSourceNode || !musicContext || !musicMasterGain) { return; } @@ -6889,6 +7635,8 @@ function addExtra() { } musicMediaSourceNode.disconnect(); disconnectEqBiquads(musicBiquads); + disconnectEqBiquads(musicBiquadsLeft); + disconnectEqBiquads(musicBiquadsRight); disconnectMusicBandFilters(); let last = musicMediaSourceNode; let hp = musicContext.createBiquadFilter(); @@ -6918,6 +7666,16 @@ function addExtra() { last.connect(musicMasterGain); syncMusicOutputGain(musicContext); }; + let rebuildMusicEqChain = () => { + if (!musicMediaSourceNode || !musicContext || !musicMasterGain) { + return; + } + if (liveStereoEqActive()) { + rebuildMusicEqChainStereo(); + } else { + rebuildMusicEqChainMono(); + } + }; let scheduleLiveEqSync = () => { if (!pinkNoisePlaying && !toneGeneratorOsc && !musicMediaSourceNode) { return; @@ -7026,6 +7784,8 @@ function addExtra() { pinkNoiseProcessor = null; } disconnectEqBiquads(pinkNoiseBiquads); + disconnectEqBiquads(pinkNoiseBiquadsLeft); + disconnectEqBiquads(pinkNoiseBiquadsRight); disconnectPinkBandFilters(); if (pinkNoiseMasterGain) { pinkNoiseMasterGain.disconnect(); @@ -7335,6 +8095,22 @@ function addExtra() { }; let toneGeneratorGraphTeardown = () => { disconnectEqBiquads(toneGeneratorBiquads); + disconnectEqBiquads(toneGeneratorBiquadsLeft); + disconnectEqBiquads(toneGeneratorBiquadsRight); + toneGeneratorBandFiltersLeft.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersLeft.length = 0; + toneGeneratorBandFiltersRight.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersRight.length = 0; + if (toneGeneratorMerger) { + try { + toneGeneratorMerger.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorMerger = null; + } if (toneGeneratorMasterGain) { try { toneGeneratorMasterGain.disconnect(); @@ -8497,6 +9273,8 @@ function addExtra() { musicFileInput.value = ""; } disconnectEqBiquads(musicBiquads); + disconnectEqBiquads(musicBiquadsLeft); + disconnectEqBiquads(musicBiquadsRight); disconnectMusicBandFilters(); if (musicMediaSourceNode) { try { diff --git a/style-alt.css b/style-alt.css index f57f440..03e1cb3 100644 --- a/style-alt.css +++ b/style-alt.css @@ -1436,6 +1436,38 @@ div.extra-panel div.settings-row { margin: 0 0 0.5em 0; } +/* 2ch bank tabs: dim filter rows, swap bank data mid-hold, fade back (~200ms of the return leg) */ +div.extra-panel > div.extra-eq > div.filters.eq-2ch-bank-filters-swap-anim { + pointer-events: none; + animation: eq2chBankFiltersSwap 0.3s ease-in-out forwards; +} + +@keyframes eq2chBankFiltersSwap { + 0% { + opacity: 1; + filter: grayscale(0); + } + 30% { + opacity: 0.36; + filter: grayscale(1) brightness(0.88); + } + 38% { + opacity: 0.36; + filter: grayscale(1) brightness(0.88); + } + 100% { + opacity: 1; + filter: grayscale(0); + } +} + +@media (prefers-reduced-motion: reduce) { + div.extra-panel > div.extra-eq > div.filters.eq-2ch-bank-filters-swap-anim { + animation: none; + pointer-events: auto; + } +} + /* Constraints: outer only animates max-height + opacity (+ margin) like Music’s panel shell; card padding/background live on .extra-eq-constraints-inner so they aren’t stripped mid-transition */ div.extra-panel > div.extra-eq > div.extra-eq-constraints { @@ -1610,6 +1642,143 @@ div.extra-panel > div.extra-eq .eq-constraint-top-stack { min-width: 0; } +div.extra-panel > div.extra-eq .eq-constraint-2ch-row { + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + min-width: 0; + padding-top: 2px; +} + +div.extra-panel > div.extra-eq .eq-constraint-2ch-toggle-label { + width: 100%; + justify-content: space-between; +} + +div.extra-panel > div.extra-eq .select-eq-phone-model-target .eq-2ch-bank-tabs { + width: 100%; + min-width: 0; + margin-top: 8px; + margin-bottom: 2px; +} + +/* Full-width three-position bank switch (L | L + R | R), same pill/track vocabulary as EQ constraint toggles. + Fixed track height so the sliding thumb always gets a real vertical size (avoids squashed / odd shapes). */ +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track { + position: relative; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + box-sizing: border-box; + width: 100%; + height: var(--eq-switch-track-h); + min-height: var(--eq-switch-track-h); + max-height: var(--eq-switch-track-h); + padding: var(--eq-switch-gap); + border: var(--eq-switch-border) solid var(--background-color-contrast); + border-radius: 9999px; + background-color: var(--background-color-inputs); + transition: background-color 200ms ease; +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-thumb { + position: absolute; + left: var(--eq-switch-gap); + top: var(--eq-switch-gap); + width: calc((100% - 2 * var(--eq-switch-gap)) / 3); + height: calc(var(--eq-switch-track-h) - 2 * var(--eq-switch-border) - 2 * var(--eq-switch-gap)); + box-sizing: border-box; + border-radius: 9999px; + background-color: var(--accent-color); + z-index: 1; + pointer-events: none; + transform: translate3d(0, 0, 0); + transform-origin: left center; + transition: transform 200ms ease, background-color 200ms ease; + -webkit-backface-visibility: hidden; + backface-visibility: hidden; +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track[data-eq-2ch-pos="0"] .eq-2ch-bank-seg-thumb { + transform: translate3d(0, 0, 0); +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track[data-eq-2ch-pos="1"] .eq-2ch-bank-seg-thumb { + transform: translate3d(100%, 0, 0); +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track[data-eq-2ch-pos="2"] .eq-2ch-bank-seg-thumb { + transform: translate3d(200%, 0, 0); +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-slot { + position: relative; + flex: 1 1 0; + min-width: 0; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-label { + position: relative; + z-index: 4; + pointer-events: none; + box-sizing: border-box; + display: inline-block; + max-width: 100%; + font-family: var(--font-secondary); + font-size: 11px; + font-weight: 700; + line-height: 1.15; + letter-spacing: 0.02em; + color: var(--background-color-contrast-more); + transition: color 200ms ease, text-shadow 200ms ease; +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track[data-eq-2ch-pos="0"] .eq-2ch-bank-seg-slot[data-slot="L"] .eq-2ch-bank-seg-label, +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track[data-eq-2ch-pos="1"] .eq-2ch-bank-seg-slot[data-slot="both"] .eq-2ch-bank-seg-label, +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track[data-eq-2ch-pos="2"] .eq-2ch-bank-seg-slot[data-slot="R"] .eq-2ch-bank-seg-label { + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +/* Invisible hit targets: div.extra-panel button uses !important bg/border/padding — must override here. */ +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track button.eq-2ch-bank-seg-btn { + position: absolute; + inset: 0; + order: 0; + flex: none; + margin: 0 !important; + padding: 0 !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + background-color: transparent !important; + background-image: none !important; + color: transparent; + font-size: 0; + line-height: 0; + text-transform: none; + white-space: normal; + cursor: pointer; + z-index: 3; + -webkit-appearance: none; + appearance: none; +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track button.eq-2ch-bank-seg-btn:focus { + outline: none; +} + +div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track:has(.eq-2ch-bank-seg-btn:focus-visible) { + box-shadow: 0 0 0 2px var(--background-color-graph), 0 0 0 4px var(--accent-color-contrast); +} + div.extra-panel > div.extra-eq .eq-constraint-top-headings, div.extra-panel > div.extra-eq .eq-constraint-top-controls { display: flex; @@ -2148,12 +2317,14 @@ div.extra-panel .live-sound-eq-switch-track { } div.extra-panel .live-sound-eq-switch-track:has(.live-sound-eq-toggle:checked), -div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-type-toggle:checked) { +div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-type-toggle:checked), +div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-2ch-toggle:checked) { background-color: var(--background-color-inputs); } div.extra-panel .live-sound-eq-toggle, -div.extra-panel .eq-constraint-type-toggle { +div.extra-panel .eq-constraint-type-toggle, +div.extra-panel .eq-constraint-2ch-toggle { grid-column: 1; grid-row: 1; z-index: 2; @@ -2171,18 +2342,23 @@ div.extra-panel .eq-constraint-type-toggle { } div.extra-panel .live-sound-eq-toggle:focus, -div.extra-panel .eq-constraint-type-toggle:focus { +div.extra-panel .eq-constraint-type-toggle:focus, +div.extra-panel .eq-constraint-2ch-toggle:focus { outline: none; } div.extra-panel .live-sound-eq-switch-track:has(.live-sound-eq-toggle:focus-visible), -div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-type-toggle:focus-visible) { +div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-type-toggle:focus-visible), +div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-2ch-toggle:focus-visible) { box-shadow: 0 0 0 2px var(--background-color-graph), 0 0 0 4px var(--accent-color-contrast); } @media (prefers-reduced-motion: reduce) { div.extra-panel .live-sound-eq-switch-track, - div.extra-panel .live-sound-eq-switch-thumb { + div.extra-panel .live-sound-eq-switch-thumb, + div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track, + div.extra-panel > div.extra-eq .eq-2ch-bank-seg-thumb, + div.extra-panel > div.extra-eq .eq-2ch-bank-seg-label { transition: none; } } @@ -2201,7 +2377,8 @@ div.extra-panel .live-sound-eq-switch-thumb { } div.extra-panel .live-sound-eq-toggle:checked + .live-sound-eq-switch-thumb, -div.extra-panel .eq-constraint-type-toggle:checked + .live-sound-eq-switch-thumb { +div.extra-panel .eq-constraint-type-toggle:checked + .live-sound-eq-switch-thumb, +div.extra-panel .eq-constraint-2ch-toggle:checked + .live-sound-eq-switch-thumb { transform: translateX(var(--eq-switch-travel)); background-color: var(--accent-color); } @@ -2746,7 +2923,8 @@ body.theme-contrast div.extra-panel .live-sound-tools .extra-music .music-play-r } body.theme-contrast div.extra-panel .live-sound-tools .live-sound-eq-switch-track, -body.theme-contrast div.extra-panel > div.extra-eq .live-sound-eq-switch-track { +body.theme-contrast div.extra-panel > div.extra-eq .live-sound-eq-switch-track, +body.theme-contrast div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track { border-color: var(--background-color-contrast); } From f4116bb038d39ab25258e237eca8b63921f33f59 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Tue, 21 Apr 2026 21:11:15 -0700 Subject: [PATCH 084/136] Fixed: XM6 constraints not cascading --- graphtool.js | 222 ++++++++++++++++++++++++++++++++++++-------------- style-alt.css | 3 +- 2 files changed, 164 insertions(+), 61 deletions(-) diff --git a/graphtool.js b/graphtool.js index f1f963f..75c0f00 100644 --- a/graphtool.js +++ b/graphtool.js @@ -249,7 +249,7 @@ doc.html(`
- @@ -3443,6 +3453,11 @@ function addExtra() { } if (!extraEQEnabled) { document.querySelector("div.extra-panel > div.extra-eq").style["display"] = "none"; + } else { + let eqHistWrap = document.getElementById("extra-eq-change-history"); + if (eqHistWrap) { + eqHistWrap.hidden = false; + } } if (!extraToneGeneratorEnabled) { document.querySelector("div.extra-panel div.extra-tone-generator").style["display"] = "none"; @@ -3470,11 +3485,11 @@ function addExtra() { // Upload function let uploadType = null; let fileFR = document.querySelector("#file-fr"); - document.querySelector("div.extra-upload > button.upload-fr").addEventListener("click", () => { + document.querySelector("div.extra-upload button.upload-fr").addEventListener("click", () => { uploadType = "fr"; fileFR.click(); }); - document.querySelector("div.extra-upload > button.upload-target").addEventListener("click", () => { + document.querySelector("div.extra-upload button.upload-target").addEventListener("click", () => { uploadType = "target"; fileFR.click(); }); @@ -3643,18 +3658,61 @@ function addExtra() { /* Until the user edits filter rows or drags graph nodes, graphic EQ may auto-sync rows from constraints. */ let eqFiltersUserHasEdited = false; let eqFilterSelectedRow = null; + /* Skip debounced history only for freq/gain/Q inputs while an EQ graph drag is active — those + update continuously from the pointer; type / enable changes must still notify. */ + let eqHistorySkipNotifyForLiveGraphFilterInput = (t) => { + let stPtr = eqGraphPointerState; + return !!(stPtr && stPtr.mode === "eq" && stPtr.dragging && t && t.matches + && t.matches("input[name='freq'], input[name='q'], input[name='gain']")); + }; if (filtersContainer) { filtersContainer.addEventListener("input", (e) => { let t = e.target; if (t && t.closest && filtersContainer.contains(t) && t.closest("div.filter")) { eqFiltersUserHasEdited = true; + /* Type / band-enable use discrete history on `change` only; debounced notify here would + double-commit or coalesce with freq edits after MIN_GAP deferral. */ + if (t.matches && (t.matches("select[name='type']") || t.matches("input[name='enabled']"))) { + return; + } + if (!eqHistorySkipNotifyForLiveGraphFilterInput(t)) { + eqHistoryNotifyChange(); + } } }, true); filtersContainer.addEventListener("change", (e) => { let t = e.target; if (t && t.closest && filtersContainer.contains(t) && t.closest("div.filter")) { eqFiltersUserHasEdited = true; + if (t.matches && (t.matches("select[name='type']") || t.matches("input[name='enabled']"))) { + if (t.matches("select[name='type']")) { + eqHistoryDebugLog("filtersContainer change (capture) type select", { + value: t.value, + activeEl: document.activeElement && document.activeElement.getAttribute + ? document.activeElement.getAttribute("name") + : null + }); + } + /* History for type/enable is recorded from applyEQExec (runs after applyEQ), not here. */ + return; + } + if (!eqHistorySkipNotifyForLiveGraphFilterInput(t)) { + eqHistoryNotifyChange(); + } + } + }, true); + filtersContainer.addEventListener("focusin", (e) => { + let t = e.target; + if (!t || !t.matches || !t.matches("input[name='freq'], input[name='q'], input[name='gain'], select[name='type']")) { + return; + } + if (!t.closest || !t.closest("div.filter") || !filtersContainer.contains(t.closest("div.filter"))) { + return; + } + if (eqHistoryRestoring || eqHistoryChain.length > 0 || eqHistoryPendingPreEditSnap) { + return; } + eqHistoryPendingPreEditSnap = eqHistoryTakeSnapshot(); }, true); } let updateEqFilterRowSelectionStyles = () => { @@ -4132,9 +4190,11 @@ function addExtra() { filterFreqInput = filtersContainer.querySelectorAll("input[name='freq']"); filterQInput = filtersContainer.querySelectorAll("input[name='q']"); filterGainInput = filtersContainer.querySelectorAll("input[name='gain']"); - filtersContainer.querySelectorAll("input,select").forEach(el => { + filtersContainer.querySelectorAll("input,select").forEach((el) => { el.removeEventListener("input", applyEQ); + el.removeEventListener("change", applyEQ); el.addEventListener("input", applyEQ); + el.addEventListener("change", applyEQ); }); if (eqFilterSelectedRow !== null && eqFilterSelectedRow >= filtersContainer.querySelectorAll("div.filter").length) { @@ -4284,6 +4344,663 @@ function addExtra() { } eq2chFlushDomToActiveBankCore(); }; + /* --- EQ undo / redo history (Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y) --- */ + let eqHistoryChain = []; + let eqHistoryHead = -1; + let eqHistoryRestoring = false; + let eqHistoryDebounceTimer = null; + let eqHistoryGapWaitTimer = null; + let eqHistoryLastCommitAt = 0; + let eqHistoryTimeTicker = null; + let eqHistoryListClickBound = false; + let eqHistoryPendingPreEditSnap = null; + let eqHistoryInitBaselineSnap = null; + const EQ_HISTORY_CAP = 100; + const EQ_HISTORY_DEBOUNCE_MS = 500; + const EQ_HISTORY_MIN_GAP_MS = 1000; + /* Set `window.__EQ_HISTORY_DEBUG = true` in the console to trace EQ change history (filter type, push, skips). */ + let eqHistoryDebugLog = (...a) => { + if (typeof window !== "undefined" && window.__EQ_HISTORY_DEBUG) { + console.log("[EQ hist]", ...a); + } + }; + /* applyEQExec compares this to DOM so filter type / band-enable edits always log even if - + + + + + @@ -3479,6 +3481,7 @@ function addExtra() { } applyParametricEqGraphTraceFocus(); updateEqTraceOpacity(); + updateEqFilterMarkers(); eqSoundRangeUiHooks.syncBrushFromInputs(); }; extraButton.addEventListener("click", showExtraPanel); @@ -4353,6 +4356,8 @@ function addExtra() { let eqHistoryLastCommitAt = 0; let eqHistoryTimeTicker = null; let eqHistoryListClickBound = false; + /** Pinned change-history body (one at a time); ghost trace on graph. Cleared on EQ model switch. */ + let eqPinnedSnapshotBody = null; let eqHistoryPendingPreEditSnap = null; let eqHistoryInitBaselineSnap = null; const EQ_HISTORY_CAP = 100; @@ -4464,6 +4469,39 @@ function addExtra() { } return eqHistoryRowsEqualArr(a.rows || [], b.rows || []); }; + /* Same as eqHistorySnapDataEqual but ignores historyEntry (for pin vs chain row). */ + let eqHistorySnapshotBodyEqual = (a, b) => { + if (!a || !b) { + return false; + } + if (a.bandCount !== b.bandCount || !!a.twoCh !== !!b.twoCh) { + return false; + } + if (a.activeBank !== b.activeBank) { + return false; + } + if (a.twoCh && b.twoCh && a.banks && b.banks) { + return ["both", "L", "R"].every((k) => + eqHistoryRowsEqualArr(a.banks[k] || [], b.banks[k] || [])); + } + return eqHistoryRowsEqualArr(a.rows || [], b.rows || []); + }; + let eqHistoryCloneSnapshotBody = (snap) => { + if (!snap) { + return null; + } + return { + bandCount: snap.bandCount, + twoCh: !!snap.twoCh, + activeBank: snap.activeBank, + rows: eqHistoryCloneBank(snap.rows || []), + banks: snap.banks ? { + both: eqHistoryCloneBank(snap.banks.both || []), + L: eqHistoryCloneBank(snap.banks.L || []), + R: eqHistoryCloneBank(snap.banks.R || []) + } : null + }; + }; let eqHistoryRowEmpty = (r) => { let x = r || eqHistoryEmptyRow(); return !(+x.freq || 0) && !(+x.q || 0) && !(+x.gain || 0); @@ -4730,9 +4768,23 @@ function addExtra() { if (!Number.isFinite(ix) || ix < 0 || ix >= eqHistoryChain.length) { return; } + if (e.target.closest(".extra-eq-change-history-col-pin")) { + let body = eqHistoryCloneSnapshotBody(eqHistoryChain[ix]); + if (eqPinnedSnapshotBody && eqHistorySnapshotBodyEqual(eqPinnedSnapshotBody, body)) { + eqPinnedSnapshotBody = null; + } else { + eqPinnedSnapshotBody = body; + } + eqHistoryRenderLog(); + updateEqFilterMarkers(); + scheduleLiveEqSync(); + e.preventDefault(); + e.stopPropagation(); + return; + } eqHistoryHead = ix; eqHistoryRestore(eqHistoryChain[ix]); - }); + }, true); }; let eqHistoryRenderLog = () => { let list = document.getElementById("extra-eq-change-history-list"); @@ -4761,8 +4813,31 @@ function addExtra() { : (i < eqHistoryHead ? "extra-eq-change-history-row--past" : "extra-eq-change-history-row--future"); row.className = "extra-eq-change-history-row " + stateClass; + let pinBody = eqHistoryCloneSnapshotBody(snap); + let isCurrent = i === eqHistoryHead; + let rowPinned = !!(eqPinnedSnapshotBody && eqHistorySnapshotBodyEqual(eqPinnedSnapshotBody, pinBody)); + if (rowPinned) { + row.className += " extra-eq-change-history-row--pinned"; + } + let showPinSlot = isCurrent || rowPinned; row.setAttribute("data-eq-history-idx", String(i)); row.setAttribute("role", "listitem"); + let pinCol = document.createElement("span"); + pinCol.className = "extra-eq-change-history-col"; + if (showPinSlot) { + pinCol.className += " extra-eq-change-history-col-pin"; + if (rowPinned) { + pinCol.innerHTML = "A"; + pinCol.title = "Unpin"; + } else { + pinCol.classList.add("extra-eq-change-history-col-pin--outline"); + pinCol.innerHTML = "A"; + pinCol.title = "Pin this state (unequalized trace shows this EQ)"; + } + } else { + pinCol.className += " extra-eq-change-history-col-pin-reserved"; + pinCol.setAttribute("aria-hidden", "true"); + } let freqEl = document.createElement("span"); freqEl.className = "extra-eq-change-history-col extra-eq-change-history-col-freq"; freqEl.textContent = desc.startFreq; @@ -4780,6 +4855,7 @@ function addExtra() { } else { timeEl.textContent = "—"; } + row.appendChild(pinCol); row.appendChild(freqEl); row.appendChild(iconWrap); row.appendChild(midEl); @@ -5826,7 +5902,203 @@ function addExtra() { } return best; }; + let eq2chPadPinnedBankRowsForGhost = (arr, bandCount) => { + let filtersCopy = (arr || []).map((f) => ({ + disabled: !!f.disabled, + type: f.type, + freq: f.freq, + q: f.q, + gain: f.gain + })); + let bc = Math.max(1, bandCount || 1); + while (filtersCopy.length < bc) { + filtersCopy.push(eq2chDefaultEmptyRow()); + } + if (filtersCopy.length > bc) { + filtersCopy.length = bc; + } + return filtersCopy; + }; + let pinnedBankRowsToApplySpecs = (bankRows, bandCount) => { + let padded = eq2chPadPinnedBankRowsForGhost(bankRows, bandCount); + let clamped = elemToFiltersClampedRowsForEqualizerApply(padded, true); + return clamped.filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + .map((f) => ({ type: f.type, freq: f.freq, q: f.q, gain: f.gain })); + }; + let eq2chMergedPinnedSpecs = (banks, chIdx, bandCount) => { + let bothS = pinnedBankRowsToApplySpecs(banks.both || [], bandCount); + if (!LR || !LR.length) { + return bothS; + } + let lab = LR[Math.min(chIdx, LR.length - 1)]; + let out = bothS.slice(); + if (lab === "L") { + out.push(...pinnedBankRowsToApplySpecs(banks.L || [], bandCount)); + } else if (lab === "R") { + out.push(...pinnedBankRowsToApplySpecs(banks.R || [], bandCount)); + } + return out; + }; + /* Biquad specs from the pinned history snapshot (for live A/B when Compare is off / A). */ + let elemToPinnedLivePlaybackSpecs = () => { + if (!eqPinnedSnapshotBody || !extraEnabled || !extraEQEnabled) { + return []; + } + if (!resolveEqGraphPhoneObj()) { + return []; + } + let pin = eqPinnedSnapshotBody; + let pinBc = pin.bandCount || 0; + let rows; + if (isEqTwoChannelSupportEnabled() && pin.twoCh && pin.banks) { + rows = eq2chPadBankToEqBands(eq2chPadPinnedBankRowsForGhost(pin.banks.both || [], pinBc)); + } else { + rows = eqHistoryPadSnapRows({ rows: pin.rows || [], bandCount: pinBc }, pinBc); + } + return elemToFiltersClampedRowsForEqualizerApply(rows, false) + .filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + .map((f) => ({ + type: f.type, + freq: Math.min(20000, Math.max(20, f.freq)), + q: Math.max(1e-4, Math.min(1000, f.q)), + gain: Math.max(-40, Math.min(40, f.gain)), + })); + }; + let eqGhostRawForSide = (phoneObj, sideLi) => { + let raws = phoneObj.rawChannels; + if (!raws || !LR || !LR.length || raws.length % LR.length !== 0) { + return firstPresentChannel(raws); + } + let nPerSide = raws.length / LR.length; + let bucket = []; + for (let s = 0; s < nPerSide; s++) { + let c = raws[sideLi * nPerSide + s]; + if (c) { + bucket.push(c); + } + } + if (!bucket.length) { + return null; + } + return bucket.length === 1 ? bucket[0] : avgCurves(bucket); + }; + let cloneEqFrPoints = (fr) => (fr && fr.length ? fr.map((pt) => [pt[0], pt[1]]) : null); + let computePinnedEqFrForModel = (modelP, pin) => { + if (!modelP || !pin || typeof Equalizer === "undefined" || !Equalizer.apply) { + return { ok: false }; + } + let pinBc = pin.bandCount || 0; + let tryPinnedRowsOnRaw = (raw) => { + if (!raw) { + return null; + } + let padRows = eqHistoryPadSnapRows({ rows: pin.rows || [], bandCount: pinBc }, pinBc); + let specs = elemToFiltersClampedRowsForEqualizerApply(padRows.map((r) => ({ + disabled: !!r.disabled, + type: r.type, + freq: r.freq, + q: r.q, + gain: r.gain + })), true).filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + .map((f) => ({ type: f.type, freq: f.freq, q: f.q, gain: f.gain })); + if (!specs.length) { + return null; + } + return Equalizer.apply(raw, specs); + }; + let frBySide = null; + let frSingle = null; + try { + if (isEqTwoChannelSupportEnabled() && pin.twoCh && pin.banks && LR && LR.length >= 2 + && modelP.rawChannels && modelP.rawChannels.length % LR.length === 0) { + frBySide = []; + for (let li = 0; li < LR.length; li++) { + let rawSide = eqGhostRawForSide(modelP, li); + let specs = eq2chMergedPinnedSpecs(pin.banks, li, pinBc); + frBySide[li] = (rawSide && specs.length) ? Equalizer.apply(rawSide, specs) : null; + } + let curves = frBySide.filter(Boolean); + if (curves.length >= 2) { + frSingle = avgCurves(curves); + } else if (curves.length === 1) { + frSingle = curves[0]; + } + } + if (!frSingle) { + let raw = eq2chSharedMeasurementBaseRaw(modelP) || firstPresentChannel(modelP.rawChannels); + frSingle = tryPinnedRowsOnRaw(raw); + } + } catch (err) { + return { ok: false }; + } + if (!frSingle) { + return { ok: false }; + } + let usableSides = frBySide && frBySide.some(Boolean); + return { ok: true, frSingle, frBySide: usableSides ? frBySide : null }; + }; + let syncEqPinnedParentTrace = () => { + let pinGloballyActive = !!(extraEnabled && extraEQEnabled && eqPinnedSnapshotBody); + let modelP = resolveEqGraphPhoneObj(); + let didRestore = false; + activePhones.forEach((p) => { + if (p.isTarget || !p._eqPinParentOverride) { + return; + } + let keep = pinGloballyActive && modelP && !modelP.isTarget && p === modelP; + if (!keep) { + p._eqPinParentOverride = false; + setCurves(p, p.avg, undefined, p.ssamp); + normalizePhone(p); + didRestore = true; + } + }); + if (didRestore) { + rebindGraphPathSelectionAndRedraw(); + } + if (!pinGloballyActive || !modelP || modelP.isTarget) { + return; + } + let frPack = computePinnedEqFrForModel(modelP, eqPinnedSnapshotBody); + if (!frPack.ok) { + return; + } + let ac = modelP.activeCurves; + if (!ac || !ac.length) { + return; + } + modelP._eqPinParentOverride = true; + if (modelP.avg && ac.length === 1) { + let c0 = cloneEqFrPoints(frPack.frSingle); + if (c0) { + ac[0].l = c0; + } + } else if (!modelP.avg && frPack.frBySide && LR && LR.length) { + ac.forEach((curve) => { + let sideFr = typeof curve.o === "number" ? frPack.frBySide[curve.o] : null; + let src = sideFr || frPack.frSingle; + let cpy = cloneEqFrPoints(src); + if (cpy) { + curve.l = cpy; + } + }); + } else { + ac.forEach((curve) => { + let cpy = cloneEqFrPoints(frPack.frSingle); + if (cpy) { + curve.l = cpy; + } + }); + } + normalizePhone(modelP); + if (baseline.p === modelP) { + baseline = getBaseline(modelP); + updateYCenter(); + } + gpath.selectAll("path").call(redrawLine); + }; updateEqFilterMarkers = () => { + syncEqPinnedParentTrace(); let layout = buildEqGraphMarkerLayout(); if (!layout || !layout.rows.length) { gEqFilterMarkers.selectAll("circle.eq-filter-marker").remove(); @@ -6298,6 +6570,7 @@ function addExtra() { eq2chResetAllBanksToDefaultRow(); filtersToElem([{ disabled: false, type: "PK", freq: 0, q: 0, gain: 0 }]); eqFiltersUserHasEdited = false; + eqPinnedSnapshotBody = null; if (eqPhoneSelect) { eqPhoneSelect.dataset.eqLastModel = eqPhoneSelect.value || ""; } @@ -6308,6 +6581,7 @@ function addExtra() { applyParametricEqGraphTraceFocus(); updateEqTraceOpacity(); updateEqFilterMarkers(); + eqHistoryRenderLog(); }; updateFilterElements(); updateEqFilterMarkers(); @@ -6326,6 +6600,8 @@ function addExtra() { eq2chResetAllBanksToDefaultRow(); filtersToElem([{ disabled: false, type: "PK", freq: 0, q: 0, gain: 0 }]); eqFiltersUserHasEdited = false; + eqPinnedSnapshotBody = null; + eqHistoryRenderLog(); } eqPhoneSelect.dataset.eqLastModel = next; setEqFilterSelectedRow(null); @@ -7989,7 +8265,7 @@ function addExtra() { !livePlaybackEqToggle || livePlaybackEqToggle.checked; let mapFilterTypeToBiquad = (t) => (t === "LSQ" ? "lowshelf" : t === "HSQ" ? "highshelf" : "peaking"); - /* Same bands as live biquads; independent of the Apply EQ toggle (used for + /* Same bands as live biquads; independent of the Compare toggle (used for preamp + A/B level match when EQ is bypassed). */ let elemToLiveEqSpecsClamped = () => { let rows; @@ -8010,7 +8286,7 @@ function addExtra() { }; let computeLiveEqSpecs = () => { if (!isLivePlaybackEqEnabled()) { - return []; + return elemToPinnedLivePlaybackSpecs(); } return elemToLiveEqSpecsClamped(); }; @@ -8068,7 +8344,9 @@ function addExtra() { } return { raw: rawL, frEq: frEqL, preDb: (preL + preR) / 2 }; } - let specs = elemToLiveEqSpecsClamped(); + let specs = isLivePlaybackEqEnabled() + ? elemToLiveEqSpecsClamped() + : elemToPinnedLivePlaybackSpecs(); if (!specs.length) { return null; } diff --git a/style-alt.css b/style-alt.css index cf00e3d..70a43b3 100644 --- a/style-alt.css +++ b/style-alt.css @@ -1141,7 +1141,7 @@ div.miscTools button#theme:active { div.extra-panel { flex-direction: column; overflow: auto; - /* Apply EQ + constraint type toggles (shared track / thumb) */ + /* Compare (live EQ) + constraint type toggles (shared track / thumb) */ --eq-switch-track-w: 54px; --eq-switch-track-h: 24px; --eq-switch-thumb: 14px; @@ -1251,7 +1251,7 @@ div.extra-eq-change-history-list { div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row { display: grid !important; /* Fixed tracks: "20000Hz" (no commas), time compact (e.g. 9999h); middle flexes */ - grid-template-columns: 10ch 14px minmax(0, 1fr) 6ch; + grid-template-columns: 16px 9ch 14px minmax(0, 1fr) 4.2ch; align-items: center; column-gap: 8px; flex: none !important; @@ -1291,6 +1291,16 @@ div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history- color: var(--background-color-contrast-more) !important; } +div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row--pinned.extra-eq-change-history-row--past, +div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row--pinned.extra-eq-change-history-row--current, +div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row--pinned.extra-eq-change-history-row--future { + color: var(--font-color-primary) !important; +} + +div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row--pinned .extra-eq-change-history-col-time { + color: var(--font-color-primary) !important; +} + .extra-eq-change-history-col { min-width: 0; margin: 0; @@ -1352,12 +1362,86 @@ div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history- text-align: right; font-size: 12px; justify-self: stretch; + min-width: 0; + max-width: 4.2ch; + overflow: hidden; + text-overflow: ellipsis; } div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row .extra-eq-change-history-col-time { color: var(--background-color-contrast-more) !important; } +.extra-eq-change-history-col-pin-reserved { + min-width: 16px; + width: 16px; + pointer-events: none; +} + +.extra-eq-change-history-col-pin { + display: flex; + align-items: center; + justify-content: center; + line-height: 0; + cursor: pointer; +} + +/* 14px circle + “A”: same letter styling as .eq-2ch-bank-seg-label (font-secondary 11px bold). */ +div.extra-panel .extra-eq-change-history-pin-ab { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + min-width: 14px; + min-height: 14px; + border-radius: 50%; + flex-shrink: 0; +} + +div.extra-panel .extra-eq-change-history-pin-ab--outline { + border: var(--eq-switch-border) solid var(--background-color-contrast-more); + background-color: transparent; +} + +div.extra-panel .extra-eq-change-history-pin-ab--filled { + border: var(--eq-switch-border) solid transparent; + background-color: var(--font-color-primary); +} + +div.extra-panel .extra-eq-change-history-pin-ab-letter, +div.extra-panel .live-sound-eq-switch-thumb--compare .live-sound-eq-switch-thumb-letter, +div.extra-panel .live-sound-eq-switch-ab-placeholder-letter { + font-family: var(--font-secondary); + font-size: 11px; + font-weight: 700; + line-height: 1.15; + letter-spacing: 0.02em; + transition: color 200ms ease, text-shadow 200ms ease; +} + +div.extra-panel .live-sound-eq-switch-thumb--compare .live-sound-eq-switch-thumb-letter { + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +div.extra-panel .live-sound-eq-switch-ab-placeholder-letter { + color: var(--background-color-contrast-more); + text-shadow: none; +} + +/* Beat row `span { color: inherit !important }` for pin “A” colors */ +div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row .extra-eq-change-history-pin-ab--outline .extra-eq-change-history-pin-ab-letter { + color: var(--background-color-contrast-more) !important; + text-shadow: none !important; +} + +div.extra-panel div.extra-eq-change-history-list button.extra-eq-change-history-row .extra-eq-change-history-pin-ab--filled .extra-eq-change-history-pin-ab-letter { + color: var(--background-color) !important; + text-shadow: none !important; +} + .extra-eq-change-history-svg { display: block; width: 16px; @@ -2527,6 +2611,7 @@ div.extra-panel .live-sound-eq-switch-track:has(.eq-constraint-2ch-toggle:focus- @media (prefers-reduced-motion: reduce) { div.extra-panel .live-sound-eq-switch-track, div.extra-panel .live-sound-eq-switch-thumb, + div.extra-panel .live-sound-eq-switch-ab-placeholder, div.extra-panel > div.extra-eq .eq-2ch-bank-seg-track, div.extra-panel > div.extra-eq .eq-2ch-bank-seg-thumb, div.extra-panel > div.extra-eq .eq-2ch-bank-seg-label { @@ -2547,13 +2632,86 @@ div.extra-panel .live-sound-eq-switch-thumb { pointer-events: none; } -div.extra-panel .live-sound-eq-toggle:checked + .live-sound-eq-switch-thumb, +div.extra-panel .live-sound-eq-switch-thumb--compare { + display: flex; + align-items: center; + justify-content: center; +} + +div.extra-panel .live-sound-eq-switch-track--compare:has(input.live-sound-eq-toggle:not(:checked)) .live-sound-eq-switch-thumb--compare .live-sound-eq-switch-thumb-letter--b { + display: none; +} + +div.extra-panel .live-sound-eq-switch-track--compare:has(input.live-sound-eq-toggle:checked) .live-sound-eq-switch-thumb--compare .live-sound-eq-switch-thumb-letter--a { + display: none; +} + div.extra-panel .eq-constraint-type-toggle:checked + .live-sound-eq-switch-thumb, div.extra-panel .eq-constraint-2ch-toggle:checked + .live-sound-eq-switch-thumb { transform: translateX(var(--eq-switch-travel)); background-color: var(--accent-color); } +div.extra-panel .live-sound-eq-switch-track--compare:has(input.live-sound-eq-toggle:checked) .live-sound-eq-switch-thumb--compare { + transform: translateX(var(--eq-switch-travel)); + background-color: var(--accent-color); +} + +div.extra-panel .live-sound-eq-switch-track--compare:not(:has(input.live-sound-eq-toggle:checked)) .live-sound-eq-switch-thumb--compare { + background-color: var(--font-color-primary); +} + +div.extra-panel .live-sound-eq-switch-track--compare:not(:has(input.live-sound-eq-toggle:checked)) .live-sound-eq-switch-thumb--compare .live-sound-eq-switch-thumb-letter--a { + color: var(--background-color); + text-shadow: none; +} + +/* Outline A/B at idle end of Compare track (same ring as history pin outline; track is already position: relative). */ +div.extra-panel .live-sound-eq-switch-ab-placeholder { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 0; + width: 14px; + height: 14px; + pointer-events: none; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 150ms ease; +} + +div.extra-panel .live-sound-eq-switch-ab-placeholder--a { + left: var(--eq-switch-gap); +} + +div.extra-panel .live-sound-eq-switch-ab-placeholder--b { + right: var(--eq-switch-gap); +} + +div.extra-panel .live-sound-eq-switch-ab-placeholder-ring { + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 14px; + height: 14px; + min-width: 14px; + min-height: 14px; + border-radius: 50%; + border: none; + background-color: transparent; +} + +div.extra-panel .live-sound-eq-switch-track--compare:has(input.live-sound-eq-toggle:checked) .live-sound-eq-switch-ab-placeholder--a { + opacity: 1; +} + +div.extra-panel .live-sound-eq-switch-track--compare:not(:has(input.live-sound-eq-toggle:checked)) .live-sound-eq-switch-ab-placeholder--b { + opacity: 1; +} + div.extra-panel .live-sound-band { display: flex; flex-direction: row; From 2df5de1b7a7dbbf2175426b14fde2821e3a23bac Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 23 Apr 2026 14:41:53 -0700 Subject: [PATCH 093/136] Tone gen add filter 0.1dB --- graphtool.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphtool.js b/graphtool.js index 9939ba8..0ee4c65 100644 --- a/graphtool.js +++ b/graphtool.js @@ -10107,7 +10107,7 @@ function addExtra() { }, true); toneGeneratorAddFilterButton.addEventListener("click", () => { let hz = parseInt(toneGeneratorText.innerText, 10) || 0; - addPeakingFilterFromHz(hz); + addPeakingFilterFromHz(hz, EQ_GRAPH_BASE_GAIN); }); pinkNoisePlayButton.addEventListener("click", () => { if (pinkNoisePlaying) { From 8cd901a84b445c45266c2354eff54750748c630b Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 23 Apr 2026 15:14:35 -0700 Subject: [PATCH 094/136] New auto icon --- graphtool.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/graphtool.js b/graphtool.js index 0ee4c65..633251c 100644 --- a/graphtool.js +++ b/graphtool.js @@ -4675,10 +4675,11 @@ function addExtra() { + "" ); let eqHistorySvgAutoEq = () => ( - "" + "" + "" - + "" - + "" + + "" + + "" + + "" + "" ); let eqHistoryRowIconHtml = (iconKind) => { From 82829d8947b12392f56119aaba102179545b4c3d Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 23 Apr 2026 16:12:04 -0700 Subject: [PATCH 095/136] Start w/ 5 filters and auto create new ones --- graphtool.js | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/graphtool.js b/graphtool.js index 633251c..b7c7a10 100644 --- a/graphtool.js +++ b/graphtool.js @@ -6213,10 +6213,44 @@ function addExtra() { applyEQRafId = null; } }; + let filterRowIsAllZeros = (i) => { + let f = parseInt(filterFreqInput[i].value, 10) || 0; + let q = parseFloat(filterQInput[i].value) || 0; + let g = parseFloat(filterGainInput[i].value) || 0; + return f === 0 && q === 0 && g === 0; + }; + /** When every visible row has freq/q/gain set, append one blank row (capped like graph add-filter). */ + let maybeAutoGrowEqBandsForTrailingBlank = (execOpt) => { + if (!filtersContainer || !filterFreqInput || !filterFreqInput.length + || !extraEQEnabled || eqHistoryRestoring) { + return; + } + if (execOpt && execOpt.liveGraphEqDrag) { + return; + } + let maxCap = getEffectiveEqMaxBands(); + if (eqBands >= maxCap) { + return; + } + for (let i = 0; i < eqBands; i++) { + if (filterRowIsAllZeros(i)) { + return; + } + } + eqFiltersUserHasEdited = true; + eqBands = Math.min(eqBands + 1, maxCap); + updateFilterElements(); + if (isEqTwoChannelSupportEnabled()) { + eq2chFlushDomToActiveBank(); + } + scheduleLiveEqSync(); + eqHistoryNotifyChange(); + }; let applyEQExec = (execOpt) => { execOpt = execOpt || {}; - refreshEqFilterConstraintViolationStyles(); eq2chFlushDomToActiveBank(); + maybeAutoGrowEqBandsForTrailingBlank(execOpt); + refreshEqFilterConstraintViolationStyles(); let typeEnableSigNow = eqHistoryCaptureTypeEnableSigFromDom(); if (typeEnableSigNow != null && eqHistoryLastApplyTypeEnableSig !== null && typeEnableSigNow !== eqHistoryLastApplyTypeEnableSig) { @@ -9329,12 +9363,6 @@ function addExtra() { toneGeneratorGraphTeardown(); }, ms); }; - let filterRowIsAllZeros = (i) => { - let f = parseInt(filterFreqInput[i].value, 10) || 0; - let q = parseFloat(filterQInput[i].value) || 0; - let g = parseFloat(filterGainInput[i].value) || 0; - return f === 0 && q === 0 && g === 0; - }; const EQ_GRAPH_BASE_GAIN = 0.1; /* Movement past this (px) starts a drag; first motion picks freq-only vs gain-only by |dx| vs |dy|. */ const EQ_GRAPH_DRAG_THRESHOLD_PX = 5; From 7c480dd2dfa4b8eb739eb0db1b6ee4be1bce6851 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 23 Apr 2026 21:51:36 -0700 Subject: [PATCH 096/136] Pick all models from EQ screen --- graphtool.js | 531 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 448 insertions(+), 83 deletions(-) diff --git a/graphtool.js b/graphtool.js index b7c7a10..5464b4c 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1048,6 +1048,9 @@ function loadFiles(p, callback) { normalizePhone(p); updatePaths(); updatePhoneTable(); + if (typeof eqAfterMultiSampleRawRefined === "function") { + eqAfterMultiSampleRawRefined(p); + } }); return; } @@ -1380,8 +1383,10 @@ let gEqSoundRangeBrush = gr.insert("g", ".eq-hover-preview") let eqSoundRangeUiHooks = { syncBrushFromInputs: () => {} }; let updateEqFilterMarkers = () => {}; let updateEqTraceOpacity = () => {}; -/** When Parametric EQ tab is active, dims all graph traces except model / EQ / target (see addExtra). */ +/** When Parametric EQ tab is active, hides graph traces except model / EQ / target (see addExtra). */ let applyParametricEqGraphTraceFocus = () => {}; +/** Set in addExtra: after multi-sample FR refine, sync EQ trace (loadFiles late branch has no callback). */ +let eqAfterMultiSampleRawRefined = null; /** @type {d3.Selection|null} */ let graphPlotHitRect = null; /** Equalizer-tab graph: pointer gesture for add + vertical gain drag */ @@ -1872,7 +1877,17 @@ function setModeEmbed() { function rebindGraphPathSelectionAndRedraw() { let c = curvesTargetsFirstForPaint(d3.merge(activePhones.map(p => p.activeCurves || []))), p = gpath.selectAll("path").data(c, d=>d.id); - let joined = p.join("path").attr("opacity", (c) => graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null)) + let joined = p.join("path").attr("opacity", (c) => { + /* Parametric EQ tab: apply “focus set” opacity here so join never paints compare at full + opacity before applyParametricEqGraphTraceFocus (that one-frame step read as flashing). */ + if (typeof window !== "undefined" && typeof window.__eqParametricPathOpacity === "function") { + let po = window.__eqParametricPathOpacity(c); + if (po !== undefined) { + return po; + } + } + return graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null); + }) .classed("sample", c=>c.p.samp) .attr("stroke", getColor_AC).call(redrawLine); if (typeof joined.order === "function") { @@ -1899,6 +1914,15 @@ function rebindGraphPathSelectionAndRedraw() { } function updatePaths(trigger) { + /* EQ model dropdown: removePhone + showPhone + applyEQExec each call updatePaths; every full + redraw briefly rebinds opacities and can paint the compare IEM twice. Batch to one draw. */ + if (typeof window !== "undefined" && (window.__eqGraphBatchSuppressDepth | 0) > 0) { + window.__eqGraphBatchPathsPending = true; + return; + } + if (typeof window !== "undefined") { + window.__eqGraphBatchPathsPending = false; + } reorderActivePhonesByInitOrder(); clusterTargetsFirstInActivePhones(); refreshTargetStyleSlots(); @@ -2527,9 +2551,26 @@ function showPhone(p, exclusive, suppressVariant, trigger) { } else { document.activeElement.blur(); } + if (extraEnabled && extraEQEnabled && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)) { + let intent = (typeof window !== "undefined" && window.eqDropdownModelIntent) + ? String(window.eqDropdownModelIntent).trim() + : ""; + /* Avoid late async showPhone() for the *previous* model overwriting EQ focus while a new + model is loading from the EQ dropdown (eqDropdownModelIntent). */ + if (!intent || p.fullName === intent) { + window.eqLastGraphModelForEq = p.fullName; + } + } + if (extraEnabled && extraEQEnabled && p.isTarget && p.fullName && !isCompensationTargetNameMatch(p)) { + window.eqLastGraphTargetForEq = p.fullName; + } if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { window.updateEQPhoneSelect(); } + if (p._eqNudgeApplyFromSelect && typeof window.eqOnPhoneDataReadyForEqUi === "function") { + window.eqOnPhoneDataReadyForEqUi(p); + p._eqNudgeApplyFromSelect = false; + } if (!p.isTarget && alt_augment ) { augmentList(p); } // Apply user config view settings @@ -2548,6 +2589,10 @@ function removeCopies(p) { function removePhone(p) { let hadEqChild = Boolean(!p.isTarget && p.eq); + /* Bump load generation so any in-flight loadFiles() for this pool object bails before + calling showPhone() — avoids the previous EQ model flashing back when its fetch + completes after the user switched away. */ + p._lfGen = (p._lfGen || 0) + 1; if (p.eqParent) { p.eqParent.eq = null; p.eqParent = null; @@ -2575,7 +2620,30 @@ function removePhone(p) { if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { window.updateEQPhoneSelect(); if (hadEqChild && typeof window.eqResetParametricAfterBaseModelRemoved === "function") { - window.eqResetParametricAfterBaseModelRemoved(); + /* EQ model dropdown: removing the previous base model already ran filter reset + apply + in the select handler. eqReset would run applyEQ again, clear eqDropdownModelIntent, + and produce an extra OG frame — targets never hit this path (hadEqChild is false). */ + let skipEqResetForModelHandoff = false; + let selEq = ""; + let intentEq = ""; + if (!p.isTarget && p.fullName && !String(p.fullName).match(/ EQ$/)) { + let eqSel = document.querySelector("div.extra-eq div.select-eq-phone-model-target select[name='phone']") + || document.querySelector("div.extra-eq select[name='phone']"); + selEq = eqSel && String(eqSel.value || "").trim(); + intentEq = (typeof window !== "undefined" && window.eqDropdownModelIntent) + ? String(window.eqDropdownModelIntent).trim() + : ""; + if ((selEq && selEq !== p.fullName) + || (intentEq && intentEq !== p.fullName)) { + skipEqResetForModelHandoff = true; + } + } + if (skipEqResetForModelHandoff) { + applyParametricEqGraphTraceFocus(); + updateEqTraceOpacity(); + } else { + window.eqResetParametricAfterBaseModelRemoved(); + } } } } @@ -3557,33 +3625,117 @@ function addExtra() { }; reader.readAsText(file); }); - // EQ Function - let eqPhoneSelect = document.querySelector("div.extra-eq select[name='phone']"); - let eqPhoneTargetSelect = document.querySelector("div.extra-eq select[name='eq-target']"); + // EQ Function (prefer model row so we never bind the wrong
Sound Tools
-
+
+
@@ -8994,6 +9010,20 @@ function addExtra() { let livePinkNoisePlaybackGain = 0.5; let liveToneGeneratorPlaybackGain = 0.2; let liveMusicPlaybackGain = 1; + /** User-facing Sound Tools master (1 = 100%); multiplies each source after its app-level trim / music preamp math. */ + let liveSoundToolsUserVolume = 1; + let applyLiveSoundToolsUserVolumeToAudioNodes = () => { + let v = liveSoundToolsUserVolume; + if (pinkNoiseUserGain) { + pinkNoiseUserGain.gain.value = v; + } + if (toneGeneratorUserGain) { + toneGeneratorUserGain.gain.value = v; + } + if (musicUserGain) { + musicUserGain.gain.value = v; + } + }; let lastEqPlaybackSource = "pink"; // Pink noise (parametric EQ in audio path) let pinkNoisePlayButton = document.querySelector("div.extra-pink-noise .play"); @@ -9001,6 +9031,8 @@ function addExtra() { let pinkNoiseContext = null; let pinkNoiseProcessor = null; let pinkNoiseMasterGain = null; + /** Multiplies app trim (pink/tone/music) without disturbing sweep/fade automation on master gains. */ + let pinkNoiseUserGain = null; let pinkNoiseAnalyser = null; let pinkNoiseBiquads = []; let pinkNoiseBandFilters = []; @@ -9016,6 +9048,7 @@ function addExtra() { let toneGeneratorBandFiltersRight = []; let toneGeneratorMerger = null; let toneGeneratorMasterGain = null; + let toneGeneratorUserGain = null; let toneGeneratorAnalyser = null; let musicBiquads = []; let musicBandFilters = []; @@ -9029,6 +9062,7 @@ function addExtra() { let musicAudio = null; let musicMediaSourceNode = null; let musicMasterGain = null; + let musicUserGain = null; let musicAnalyser = null; let musicObjectUrl = null; let musicFileLoaded = false; @@ -9938,6 +9972,12 @@ function addExtra() { pinkNoiseMasterGain.disconnect(); pinkNoiseMasterGain = null; } + if (pinkNoiseUserGain) { + try { + pinkNoiseUserGain.disconnect(); + } catch (e) { /* noop */ } + pinkNoiseUserGain = null; + } if (pinkNoiseAnalyser) { try { pinkNoiseAnalyser.disconnect(); @@ -9979,6 +10019,45 @@ function addExtra() { let toneGeneratorAddFilterButton = document.querySelector( "div.extra-tone-generator button.tone-generator-add-filter"); const TONE_GENERATOR_DEFAULT_HZ = 1000; + let liveSoundMasterVolumeInput = document.querySelector("div.live-sound-tools input[name='live-sound-master-volume']"); + let liveSoundVolumePctText = document.querySelector("div.live-sound-tools .live-sound-volume-pct-text"); + let syncLiveSoundMasterVolumeTrackFill = () => { + let el = liveSoundMasterVolumeInput; + if (!el) { + return; + } + let min = parseFloat(el.min) || 0; + let max = parseFloat(el.max) || 1; + let v = parseFloat(el.value); + if (!Number.isFinite(v)) { + v = min; + } + let pct = max > min ? ((v - min) / (max - min)) * 100 : 0; + el.style.setProperty("--live-sound-vol-pct", pct + "%"); + }; + if (liveSoundMasterVolumeInput) { + liveSoundMasterVolumeInput.addEventListener("input", () => { + liveSoundToolsUserVolume = Math.min(1, Math.max(0, parseFloat(liveSoundMasterVolumeInput.value) || 0)); + applyLiveSoundToolsUserVolumeToAudioNodes(); + syncLiveSoundMasterVolumeTrackFill(); + if (liveSoundVolumePctText) { + liveSoundVolumePctText.textContent = String(Math.round(liveSoundToolsUserVolume * 100)); + } + }); + syncLiveSoundMasterVolumeTrackFill(); + } + (() => { + let stCard = document.querySelector("div.live-sound-tools-settings"); + let stGear = document.querySelector("div.live-sound-tools button.live-sound-tools-settings-gear"); + let stBody = document.getElementById("live-sound-tools-settings-body"); + if (stGear && stCard && stBody) { + stGear.addEventListener("click", () => { + let exp = stCard.classList.toggle("live-sound-tools-settings-expanded"); + stGear.setAttribute("aria-expanded", exp ? "true" : "false"); + stBody.setAttribute("aria-hidden", exp ? "false" : "true"); + }); + } + })(); if (toneGeneratorSlider && toneGeneratorFromInput && toneGeneratorToInput && toneGeneratorText) { let from = Math.min(Math.max(parseInt(toneGeneratorFromInput.value, 10) || 20, 20), 20000); let to = Math.min(Math.max(parseInt(toneGeneratorToInput.value, 10) || from, from), 20000); @@ -10258,6 +10337,12 @@ function addExtra() { } catch (e) { /* noop */ } toneGeneratorMerger = null; } + if (toneGeneratorUserGain) { + try { + toneGeneratorUserGain.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorUserGain = null; + } if (toneGeneratorMasterGain) { try { toneGeneratorMasterGain.disconnect(); @@ -11097,13 +11182,16 @@ function addExtra() { pinkNoiseProcessor = createPinkNoiseProcessor(pinkNoiseContext); pinkNoiseMasterGain = pinkNoiseContext.createGain(); pinkNoiseMasterGain.gain.value = livePinkNoisePlaybackGain; + pinkNoiseUserGain = pinkNoiseContext.createGain(); + pinkNoiseUserGain.gain.value = liveSoundToolsUserVolume; // rebuildPinkNoiseEqChain requires pinkNoisePlaying — set before first build pinkNoisePlaying = true; rebuildPinkNoiseEqChain(); pinkNoiseAnalyser = pinkNoiseAnalyser || pinkNoiseContext.createAnalyser(); configureLiveSpectrumAnalyser(pinkNoiseAnalyser); pinkNoiseMasterGain.disconnect(); - pinkNoiseMasterGain.connect(pinkNoiseAnalyser); + pinkNoiseMasterGain.connect(pinkNoiseUserGain); + pinkNoiseUserGain.connect(pinkNoiseAnalyser); pinkNoiseAnalyser.connect(pinkNoiseContext.destination); pinkNoisePlayButton.classList.add("playback-active"); lastEqPlaybackSource = "pink"; @@ -11162,11 +11250,14 @@ function addExtra() { toneGeneratorOsc.frequency.value = parseInt(toneGeneratorText.innerText, 10) || TONE_GENERATOR_DEFAULT_HZ; toneGeneratorMasterGain = toneGeneratorContext.createGain(); toneGeneratorMasterGain.gain.setValueAtTime(0, tA); + toneGeneratorUserGain = toneGeneratorContext.createGain(); + toneGeneratorUserGain.gain.value = liveSoundToolsUserVolume; rebuildToneGeneratorEqChain(); toneGeneratorAnalyser = toneGeneratorAnalyser || toneGeneratorContext.createAnalyser(); configureLiveSpectrumAnalyser(toneGeneratorAnalyser); toneGeneratorMasterGain.disconnect(); - toneGeneratorMasterGain.connect(toneGeneratorAnalyser); + toneGeneratorMasterGain.connect(toneGeneratorUserGain); + toneGeneratorUserGain.connect(toneGeneratorAnalyser); toneGeneratorAnalyser.connect(toneGeneratorContext.destination); toneGeneratorOsc.start(tA); toneGeneratorMasterGain.gain.linearRampToValueAtTime(liveToneGeneratorPlaybackGain, tA + TONE_GEN_FADE_IN_SEC); @@ -11448,6 +11539,12 @@ function addExtra() { } catch (err) { /* noop */ } musicMasterGain = null; } + if (musicUserGain) { + try { + musicUserGain.disconnect(); + } catch (err) { /* noop */ } + musicUserGain = null; + } if (musicAnalyser) { try { musicAnalyser.disconnect(); @@ -11484,10 +11581,13 @@ function addExtra() { musicMediaSourceNode = musicContext.createMediaElementSource(musicAudio); musicMasterGain = musicContext.createGain(); musicMasterGain.gain.value = liveMusicPlaybackGain; + musicUserGain = musicContext.createGain(); + musicUserGain.gain.value = liveSoundToolsUserVolume; rebuildMusicEqChain(); musicAnalyser = musicContext.createAnalyser(); configureLiveSpectrumAnalyser(musicAnalyser); - musicMasterGain.connect(musicAnalyser); + musicMasterGain.connect(musicUserGain); + musicUserGain.connect(musicAnalyser); musicAnalyser.connect(musicContext.destination); musicSpectrumViz.syncSpectrumViz(); return true; @@ -12113,7 +12213,7 @@ function addExtra() { return; } if (t.closest && t.closest("div.extra-panel button") && !t.closest("button.play")) { - if (t.closest("button.extra-eq-constraints-gear")) { + if (t.closest("button.extra-eq-constraints-gear") || t.closest("button.live-sound-tools-settings-gear")) { /* Gear keeps focus after open/close; native Space would toggle the panel instead of play/pause. */ } else if (t.closest("button.extra-eq-reset-btn") || t.closest("button.live-sound-range-reset-btn")) { /* Same as gear: keep global Space → play/pause; avoid trapping focus on reset. */ diff --git a/style-alt.css b/style-alt.css index 70a43b3..f0eeb59 100644 --- a/style-alt.css +++ b/style-alt.css @@ -2505,7 +2505,7 @@ div.extra-panel .live-sound-tools-head { display: flex; flex-direction: row; flex-wrap: wrap; - align-items: baseline; + align-items: center; justify-content: space-between; column-gap: 12px; row-gap: 6px; @@ -2513,12 +2513,166 @@ div.extra-panel .live-sound-tools-head { width: 100%; } +div.extra-panel .live-sound-tools-head-trailing { + flex: 0 0 auto; + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 2px; + margin-left: auto; +} + +/* Collapsible settings (output volume); same expand/collapse pattern as Parametric EQ constraints */ +div.extra-panel .live-sound-tools > div.live-sound-tools-settings { + box-sizing: border-box; + overflow: hidden; + max-height: 0; + opacity: 0; + margin-top: 0; + margin-bottom: 0; + padding: 0; + background: transparent; + border-radius: 0; + pointer-events: none; + transition: max-height 200ms ease, opacity 200ms ease, margin-bottom 200ms ease; +} + +div.extra-panel .live-sound-tools > div.live-sound-tools-settings.live-sound-tools-settings-expanded { + max-height: 200px; + opacity: 1; + margin-top: 0; + margin-bottom: 10px; + pointer-events: auto; +} + +div.extra-panel .live-sound-tools .live-sound-tools-settings-inner { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + min-width: 0; + padding: 12px 12px 10px; + border-radius: 10px; + background-color: var(--background-color-graph); +} + +/* Match Parametric EQ settings gear; height aligns with Compare A/B switch track (24px) */ +div.extra-panel .live-sound-tools-head-trailing .live-sound-tools-settings-gear { + box-sizing: border-box; + flex: 0 0 auto; + align-self: center; + order: 0; + position: relative; + width: auto; + min-width: 0; + height: var(--eq-switch-track-h); + min-height: var(--eq-switch-track-h); + margin: 0; + /* Override div.extra-panel button { margin-bottom: 4px !important } — no chip wrap here */ + margin-bottom: 0 !important; + padding: 0 0 0 10px; + display: inline-flex; + align-items: center; + justify-content: flex-end; + border: none !important; + border-radius: 0; + background: transparent !important; + box-shadow: none; + color: var(--background-color-contrast-more); + font-family: var(--font-primary); + font-size: 0; + font-weight: 400; + line-height: 1; + text-transform: none; + white-space: nowrap; + cursor: pointer; + outline: none; + -webkit-appearance: none; + appearance: none; + transition: color 200ms ease; +} + +div.extra-panel .live-sound-tools-head-trailing .live-sound-tools-settings-gear-char { + box-sizing: border-box; + display: block; + width: 20px; + height: 20px; + flex-shrink: 0; + background-color: currentColor; + mask: var(--icon-eq-settings); + -webkit-mask: var(--icon-eq-settings); + mask-repeat: no-repeat; + -webkit-mask-repeat: no-repeat; + mask-position: center; + -webkit-mask-position: center; + mask-size: 16px 16px; + -webkit-mask-size: 16px 16px; +} + +div.extra-panel .live-sound-tools-head-trailing .live-sound-tools-settings-gear:hover { + color: var(--accent-color); +} + +div.extra-panel .live-sound-tools-head-trailing .live-sound-tools-settings-gear:focus, +div.extra-panel .live-sound-tools-head-trailing .live-sound-tools-settings-gear:focus-visible { + outline: none; + box-shadow: none; +} + +div.extra-panel .live-sound-volume-row { + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + width: 100%; + margin: 0; +} + +div.extra-panel .live-sound-volume-icon { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: var(--background-color-contrast-more); +} + +div.extra-panel .live-sound-volume-icon svg { + display: block; + width: 20px; + height: 20px; +} + +div.extra-panel .live-sound-volume-slider-row { + flex: 1 1 auto; + min-width: 0; + width: 100%; + margin: 0; + padding-top: 0; + display: flex; + flex-direction: row; + align-items: center; +} + +div.extra-panel .live-sound-volume-pct { + flex: 0 0 auto; + min-width: 3.5ch; + font-family: var(--font-secondary); + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--background-color-contrast-more); + text-align: right; +} + div.extra-panel .live-sound-tools-head > h5.live-sound-tools-title { margin: 0; padding: 0; font-family: var(--font-primary); font-size: 13px; - line-height: 1.2; + line-height: 20px; font-weight: 600; color: var(--background-color-contrast-more); } @@ -2988,7 +3142,8 @@ div.extra-panel .live-sound-tools .live-sound-slider-row.tone-generator-slider-r padding-top: 0; } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq'] { +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq'], +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume'] { box-sizing: border-box; display: block; width: 100%; @@ -2999,11 +3154,13 @@ div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-genera background: transparent; } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']:focus { +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']:focus, +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']:focus { outline: none; } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-moz-focus-outer { +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-moz-focus-outer, +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-moz-focus-outer { border: 0; } @@ -3017,7 +3174,24 @@ div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-genera background-color: var(--background-color-inputs); } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-webkit-slider-thumb { +/* Volume: left of knob = rail (inputs); right of knob = dimmed (--background-color); --live-sound-vol-pct set in JS */ +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-webkit-slider-runnable-track { + height: 18px; + box-sizing: border-box; + padding: 0px 2px; + border: 1px solid var(--background-color-contrast-more); + border-radius: 100px; + background: linear-gradient( + to right, + var(--background-color-inputs) 0%, + var(--background-color-inputs) var(--live-sound-vol-pct, 100%), + var(--background-color) var(--live-sound-vol-pct, 100%), + var(--background-color) 100% + ); +} + +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-webkit-slider-thumb, +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; box-sizing: border-box; @@ -3036,6 +3210,13 @@ div.extra-panel .live-sound-tools .tone-generator-slider-row input[name='tone-ge width: auto; } +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume'] { + flex: 1 1 auto; + min-width: 0; + width: 100%; + max-width: 100%; +} + div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-moz-range-track { height: 16px; box-sizing: border-box; @@ -3045,7 +3226,23 @@ div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-genera background-color: var(--background-color-inputs); } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-moz-range-thumb { +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-moz-range-track { + height: 16px; + box-sizing: border-box; + padding: 1px 2px; + border: 1px solid var(--background-color-contrast); + border-radius: 100px; + background: linear-gradient( + to right, + var(--background-color-inputs) 0%, + var(--background-color-inputs) var(--live-sound-vol-pct, 100%), + var(--background-color) var(--live-sound-vol-pct, 100%), + var(--background-color) 100% + ); +} + +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-moz-range-thumb, +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-moz-range-thumb { box-sizing: border-box; width: 12px; height: 12px; @@ -3055,7 +3252,8 @@ div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-genera cursor: pointer; } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-ms-track { +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-ms-track, +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-ms-track { height: 16px; box-sizing: border-box; padding: 0 2px; @@ -3072,7 +3270,20 @@ div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-genera border-radius: 100px; } -div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-ms-thumb { +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-ms-fill-lower { + background-color: var(--background-color-inputs); + border: none; + border-radius: 100px; +} + +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-ms-fill-upper { + background-color: var(--background-color); + border: none; + border-radius: 100px; +} + +div.extra-panel .live-sound-tools .live-sound-slider-row input[name='tone-generator-freq']::-ms-thumb, +div.extra-panel .live-sound-tools .live-sound-volume-slider-row input[name='live-sound-master-volume']::-ms-thumb { width: 12px; height: 12px; border-radius: 50%; @@ -3127,13 +3338,15 @@ div.extra-panel .live-sound-tools .extra-music.music-file-loaded .music-segment- border-color: var(--background-color-contrast-more); } +/* Dimmed regions outside the loop (same fill as volume track right of thumb) */ div.extra-panel .live-sound-tools .extra-music .music-segment-outside { position: absolute; top: 0; bottom: 0; box-sizing: border-box; border-radius: 100px; - background-color: var(--background-color-contrast); + background: none; + background-color: var(--background-color); z-index: 1; pointer-events: none; } From 1130ac11a91c2e3f7c016786875e92af46166ec1 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 2 May 2026 18:35:26 -0700 Subject: [PATCH 106/136] Scroll Q while dragging --- graphtool.js | 47 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/graphtool.js b/graphtool.js index 3d14327..454b1b6 100644 --- a/graphtool.js +++ b/graphtool.js @@ -11006,7 +11006,7 @@ function addExtra() { + (1 - EQ_GRAPH_WHEEL_Q_SENS_FLOOR) * Math.pow(t, EQ_GRAPH_WHEEL_Q_SENS_GAMMA); }; function eqGraphPlotWheel(e) { - if (eqGraphPointerState || interactInspect) { + if (interactInspect) { return; } let tab = document.querySelector("div.select"); @@ -11014,16 +11014,27 @@ function addExtra() { || tab.getAttribute("data-selected") !== "extra") { return; } - let m = clientToGraphPlotXY(e.clientX, e.clientY); - if (!m) { + let stWheel = eqGraphPointerState; + let wheelDuringEqDrag = Boolean(stWheel && stWheel.mode === "eq" + && stWheel.filterIndex !== null); + if (stWheel && !wheelDuringEqDrag) { return; } - let hit = findEqGraphMarkerHit(m); - if (!hit) { - return; + let i = -1; + if (wheelDuringEqDrag) { + i = stWheel.filterIndex; + } else { + let m = clientToGraphPlotXY(e.clientX, e.clientY); + if (!m) { + return; + } + let hit = findEqGraphMarkerHit(m); + if (!hit) { + return; + } + i = hit.rowIndex; } e.preventDefault(); - let i = hit.rowIndex; let qDisplay = parseFloat(filterQInput[i].value); if (!Number.isFinite(qDisplay)) { qDisplay = 1; @@ -11078,17 +11089,23 @@ function addExtra() { ? q.toFixed(2) : String(q); cancelDeferredApplyEQ(); - applyEQExec(); + if (wheelDuringEqDrag) { + applyEQExec({ skipRestoreFocus: true, liveGraphEqDrag: true }); + } else { + applyEQExec(); + } scheduleLiveEqSync(); eqHistoryNotifyChange(); setEqFilterSelectedRow(i); - requestAnimationFrame(() => { - let qEl = filterQInput[i]; - if (qEl) { - qEl.focus(); - qEl.select(); - } - }); + if (!wheelDuringEqDrag) { + requestAnimationFrame(() => { + let qEl = filterQInput[i]; + if (qEl) { + qEl.focus(); + qEl.select(); + } + }); + } } function eqGraphPlotContextMenu(e) { if (interactInspect || eqGraphPointerState) { From 79eb9a21890f796cce12cd68a5f565fc70534c0d Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 2 May 2026 18:55:24 -0700 Subject: [PATCH 107/136] Shift + drag multiple ranges for sound tools --- graphtool.js | 515 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 418 insertions(+), 97 deletions(-) diff --git a/graphtool.js b/graphtool.js index 454b1b6..6fd4ffa 100644 --- a/graphtool.js +++ b/graphtool.js @@ -9046,6 +9046,8 @@ function addExtra() { let toneGeneratorBiquadsRight = []; let toneGeneratorBandFiltersLeft = []; let toneGeneratorBandFiltersRight = []; + /** Mono tone path: HP/LP (or parallel band branches) before parametric EQ when range limits apply. */ + let toneGeneratorBandFiltersMono = []; let toneGeneratorMerger = null; let toneGeneratorMasterGain = null; let toneGeneratorUserGain = null; @@ -9399,6 +9401,148 @@ function addExtra() { let toHz = Math.min(Math.max(parseInt(toEl && toEl.value) || 0, fromHz), 20000); return { fromHz, toHz }; }; + let liveSoundBandDatasetRoot = () => + document.querySelector("div.live-sound-tools div.live-sound-band"); + function normalizeLiveSoundIntervalPair(lo, hi) { + let [fLo, fHi] = getEqConstraintFreqLoHi(); + lo = Math.round(Math.min(fHi, Math.max(fLo, lo))); + hi = Math.round(Math.min(fHi, Math.max(fLo, hi))); + if (hi <= lo) { + hi = Math.min(fHi, lo + 1); + } + return { lo, hi }; + } + function mergeLiveSoundIntervalsSorted(intervals) { + if (!intervals.length) { + return []; + } + let sorted = intervals.slice().sort((a, b) => a.lo - b.lo); + let out = []; + let cur = { lo: sorted[0].lo, hi: sorted[0].hi }; + for (let i = 1; i < sorted.length; i++) { + let n = sorted[i]; + if (n.lo <= cur.hi) { + cur.hi = Math.max(cur.hi, n.hi); + } else { + out.push(cur); + cur = { lo: n.lo, hi: n.hi }; + } + } + out.push(cur); + return out; + } + function readLiveSoundBandIntervals() { + let root = liveSoundBandDatasetRoot(); + let raw = root && root.dataset && root.dataset.liveSoundIntervals; + if (raw) { + try { + let parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length) { + let ivs = []; + for (let i = 0; i < parsed.length; i++) { + let o = parsed[i]; + if (!o || typeof o !== "object") { + continue; + } + let lo = Number(o.lo); + let hi = Number(o.hi); + if (!Number.isFinite(lo) || !Number.isFinite(hi)) { + continue; + } + ivs.push(normalizeLiveSoundIntervalPair(lo, hi)); + } + ivs = mergeLiveSoundIntervalsSorted(ivs); + if (ivs.length) { + return ivs; + } + } + } catch (e) { /* noop */ } + } + let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let lo = Math.min(fromHz, toHz); + let hi = Math.max(fromHz, toHz); + return [normalizeLiveSoundIntervalPair(lo, hi)]; + } + function writeLiveSoundIntervalsState(intervals) { + if (!toneGeneratorFromInput || !toneGeneratorToInput) { + return; + } + let merged = mergeLiveSoundIntervalsSorted(intervals.map((iv) => + normalizeLiveSoundIntervalPair(iv.lo, iv.hi))); + if (!merged.length) { + merged.push(normalizeLiveSoundIntervalPair(20, 20000)); + } + let loMin = merged.reduce((m, iv) => Math.min(m, iv.lo), merged[0].lo); + let hiMax = merged.reduce((m, iv) => Math.max(m, iv.hi), merged[0].hi); + toneGeneratorFromInput.value = String(loMin); + toneGeneratorToInput.value = String(hiMax); + let root = liveSoundBandDatasetRoot(); + if (root) { + if (merged.length <= 1) { + delete root.dataset.liveSoundIntervals; + } else { + root.dataset.liveSoundIntervals = JSON.stringify(merged); + } + } + } + function clearLiveSoundIntervalsDatasetIfPresent() { + let root = liveSoundBandDatasetRoot(); + if (root && root.dataset.liveSoundIntervals) { + delete root.dataset.liveSoundIntervals; + } + } + /** Sum of parallel HP→LP branches (optional gain per branch); bandStorageArr owns hp, lp, norm for each branch plus summer. */ + function connectParallelHpLpBandBranches(ctx, sourceNode, bandStorageArr, intervals) { + let summer = ctx.createGain(); + summer.gain.value = 1; + let n = intervals.length; + let normMul = n > 1 ? 1 / Math.sqrt(n) : 1; + intervals.forEach((iv) => { + let norm = ctx.createGain(); + norm.gain.value = normMul; + let hp = ctx.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = iv.lo; + hp.Q.value = 0.707; + let lp = ctx.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = iv.hi; + lp.Q.value = 0.707; + sourceNode.connect(hp); + hp.connect(lp); + lp.connect(norm); + norm.connect(summer); + bandStorageArr.push(hp, lp, norm); + }); + bandStorageArr.push(summer); + return summer; + } + /** Same as connectParallelHpLpBandBranches but input is a stereo splitter channel. */ + function connectParallelHpLpMusicSide(ctx, splitter, splitChannel, bandStorageArr, intervals) { + let summer = ctx.createGain(); + summer.gain.value = 1; + let n = intervals.length; + let normMul = n > 1 ? 1 / Math.sqrt(n) : 1; + intervals.forEach((iv) => { + let norm = ctx.createGain(); + norm.gain.value = normMul; + let hp = ctx.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = iv.lo; + hp.Q.value = 0.707; + let lp = ctx.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = iv.hi; + lp.Q.value = 0.707; + splitter.connect(hp, splitChannel); + hp.connect(lp); + lp.connect(norm); + norm.connect(summer); + bandStorageArr.push(hp, lp, norm); + }); + bandStorageArr.push(summer); + return summer; + } let disconnectEqBiquads = (biquadsArr) => { biquadsArr.forEach((b) => { try { b.disconnect(); } catch (e) { /* noop */ } @@ -9478,9 +9622,13 @@ function addExtra() { if (!pinkNoisePlaying || !pinkNoiseContext || !pinkNoiseProcessor || !pinkNoiseMasterGain) { return; } - let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let intervals = readLiveSoundBandIntervals(); + let multiBand = intervals.length > 1; + let iv0 = intervals[0]; + let fromHz = iv0.lo; + let toHz = iv0.hi; let { specL, specR } = computeLiveEqSpecsForStereoPaths(); - if (pinkNoiseBandFiltersLeft.length === 2 && pinkNoiseBandFiltersRight.length === 2 + if (!multiBand && pinkNoiseBandFiltersLeft.length === 2 && pinkNoiseBandFiltersRight.length === 2 && specL.length === pinkNoiseBiquadsLeft.length && specR.length === pinkNoiseBiquadsRight.length && syncBandShelfFiltersInPlace(pinkNoiseContext, pinkNoiseBandFiltersLeft, fromHz, toHz) @@ -9497,21 +9645,26 @@ function addExtra() { disconnectPinkBandFilters(); pinkNoiseMerger = pinkNoiseContext.createChannelMerger(2); let wireSide = (specs, bandArr, bqArr, mergerCh) => { - let last = pinkNoiseProcessor; - let hp = pinkNoiseContext.createBiquadFilter(); - hp.type = "highpass"; - hp.frequency.value = fromHz; - hp.Q.value = 0.707; - last.connect(hp); - last = hp; - bandArr.push(hp); - let lp = pinkNoiseContext.createBiquadFilter(); - lp.type = "lowpass"; - lp.frequency.value = toHz; - lp.Q.value = 0.707; - last.connect(lp); - last = lp; - bandArr.push(lp); + let last; + if (multiBand) { + last = connectParallelHpLpBandBranches(pinkNoiseContext, pinkNoiseProcessor, bandArr, intervals); + } else { + last = pinkNoiseProcessor; + let hp = pinkNoiseContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + bandArr.push(hp); + let lp = pinkNoiseContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + bandArr.push(lp); + } specs.forEach((s) => { let bf = pinkNoiseContext.createBiquadFilter(); bf.type = mapFilterTypeToBiquad(s.type); @@ -9532,9 +9685,13 @@ function addExtra() { if (!pinkNoisePlaying || !pinkNoiseContext || !pinkNoiseProcessor || !pinkNoiseMasterGain) { return; } - let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let intervals = readLiveSoundBandIntervals(); + let multiBand = intervals.length > 1; + let iv0 = intervals[0]; + let fromHz = iv0.lo; + let toHz = iv0.hi; let specs = computeLiveEqSpecs(); - if (pinkNoiseBandFilters.length === 2 + if (!multiBand && pinkNoiseBandFilters.length === 2 && specs.length === pinkNoiseBiquads.length && syncBandShelfFiltersInPlace(pinkNoiseContext, pinkNoiseBandFilters, fromHz, toHz)) { if (specs.length === 0 || syncEqBiquadsInPlace(pinkNoiseContext, pinkNoiseBiquads, specs)) { @@ -9546,21 +9703,26 @@ function addExtra() { disconnectEqBiquads(pinkNoiseBiquadsLeft); disconnectEqBiquads(pinkNoiseBiquadsRight); disconnectPinkBandFilters(); - let last = pinkNoiseProcessor; - let hp = pinkNoiseContext.createBiquadFilter(); - hp.type = "highpass"; - hp.frequency.value = fromHz; - hp.Q.value = 0.707; - last.connect(hp); - last = hp; - pinkNoiseBandFilters.push(hp); - let lp = pinkNoiseContext.createBiquadFilter(); - lp.type = "lowpass"; - lp.frequency.value = toHz; - lp.Q.value = 0.707; - last.connect(lp); - last = lp; - pinkNoiseBandFilters.push(lp); + let last; + if (multiBand) { + last = connectParallelHpLpBandBranches(pinkNoiseContext, pinkNoiseProcessor, pinkNoiseBandFilters, intervals); + } else { + last = pinkNoiseProcessor; + let hp = pinkNoiseContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + pinkNoiseBandFilters.push(hp); + let lp = pinkNoiseContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + pinkNoiseBandFilters.push(lp); + } specs.forEach((s) => { let bf = pinkNoiseContext.createBiquadFilter(); bf.type = mapFilterTypeToBiquad(s.type); @@ -9587,9 +9749,17 @@ function addExtra() { if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { return; } - let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + toneGeneratorBandFiltersMono.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersMono.length = 0; + let intervals = readLiveSoundBandIntervals(); + let multiBand = intervals.length > 1; + let iv0 = intervals[0]; + let fromHz = iv0.lo; + let toHz = iv0.hi; let { specL, specR } = computeLiveEqSpecsForStereoPaths(); - if (toneGeneratorBandFiltersLeft.length === 2 && toneGeneratorBandFiltersRight.length === 2 + if (!multiBand && toneGeneratorBandFiltersLeft.length === 2 && toneGeneratorBandFiltersRight.length === 2 && specL.length === toneGeneratorBiquadsLeft.length && specR.length === toneGeneratorBiquadsRight.length && syncBandShelfFiltersInPlace(toneGeneratorContext, toneGeneratorBandFiltersLeft, fromHz, toHz) @@ -9619,21 +9789,26 @@ function addExtra() { } toneGeneratorMerger = toneGeneratorContext.createChannelMerger(2); let wireSide = (specs, bandArr, bqArr, mergerCh) => { - let last = toneGeneratorOsc; - let hp = toneGeneratorContext.createBiquadFilter(); - hp.type = "highpass"; - hp.frequency.value = fromHz; - hp.Q.value = 0.707; - last.connect(hp); - last = hp; - bandArr.push(hp); - let lp = toneGeneratorContext.createBiquadFilter(); - lp.type = "lowpass"; - lp.frequency.value = toHz; - lp.Q.value = 0.707; - last.connect(lp); - last = lp; - bandArr.push(lp); + let last; + if (multiBand) { + last = connectParallelHpLpBandBranches(toneGeneratorContext, toneGeneratorOsc, bandArr, intervals); + } else { + last = toneGeneratorOsc; + let hp = toneGeneratorContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + bandArr.push(hp); + let lp = toneGeneratorContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + bandArr.push(lp); + } specs.forEach((s) => { let bf = toneGeneratorContext.createBiquadFilter(); bf.type = mapFilterTypeToBiquad(s.type); @@ -9654,7 +9829,65 @@ function addExtra() { if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { return; } - rebuildLiveEqChain(toneGeneratorOsc, toneGeneratorContext, toneGeneratorMasterGain, toneGeneratorBiquads); + let intervals = readLiveSoundBandIntervals(); + let multiBand = intervals.length > 1; + let iv0 = intervals[0]; + let singleFullBand = intervals.length === 1 && iv0.lo <= 20 && iv0.hi >= 20000; + if (!multiBand && singleFullBand) { + toneGeneratorBandFiltersMono.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersMono.length = 0; + rebuildLiveEqChain(toneGeneratorOsc, toneGeneratorContext, toneGeneratorMasterGain, toneGeneratorBiquads); + return; + } + let fromHz = iv0.lo; + let toHz = iv0.hi; + let specs = computeLiveEqSpecs(); + if (!multiBand && toneGeneratorBandFiltersMono.length === 2 + && specs.length === toneGeneratorBiquads.length + && syncBandShelfFiltersInPlace(toneGeneratorContext, toneGeneratorBandFiltersMono, fromHz, toHz)) { + if (specs.length === 0 || syncEqBiquadsInPlace(toneGeneratorContext, toneGeneratorBiquads, specs)) { + return; + } + } + toneGeneratorOsc.disconnect(); + disconnectEqBiquads(toneGeneratorBiquads); + toneGeneratorBandFiltersMono.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersMono.length = 0; + let last; + if (multiBand) { + last = connectParallelHpLpBandBranches(toneGeneratorContext, toneGeneratorOsc, toneGeneratorBandFiltersMono, intervals); + } else { + last = toneGeneratorOsc; + let hp = toneGeneratorContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + toneGeneratorBandFiltersMono.push(hp); + let lp = toneGeneratorContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + toneGeneratorBandFiltersMono.push(lp); + } + specs.forEach((s) => { + let bf = toneGeneratorContext.createBiquadFilter(); + bf.type = mapFilterTypeToBiquad(s.type); + bf.frequency.value = s.freq; + bf.Q.value = s.q; + bf.gain.value = s.gain; + last.connect(bf); + last = bf; + toneGeneratorBiquads.push(bf); + }); + last.connect(toneGeneratorMasterGain); }; let rebuildToneGeneratorEqChain = () => { if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { @@ -9686,9 +9919,13 @@ function addExtra() { if (!musicMediaSourceNode || !musicContext || !musicMasterGain) { return; } - let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let intervals = readLiveSoundBandIntervals(); + let multiBand = intervals.length > 1; + let iv0 = intervals[0]; + let fromHz = iv0.lo; + let toHz = iv0.hi; let { specL, specR } = computeLiveEqSpecsForStereoPaths(); - if (musicBandFiltersLeft.length === 2 && musicBandFiltersRight.length === 2 + if (!multiBand && musicBandFiltersLeft.length === 2 && musicBandFiltersRight.length === 2 && specL.length === musicBiquadsLeft.length && specR.length === musicBiquadsRight.length && syncBandShelfFiltersInPlace(musicContext, musicBandFiltersLeft, fromHz, toHz) @@ -9708,20 +9945,25 @@ function addExtra() { musicStereoMerger = musicContext.createChannelMerger(2); musicMediaSourceNode.connect(musicStereoSplitter); let wireSide = (splitOut, specs, bandArr, bqArr, mergerCh) => { - let hp = musicContext.createBiquadFilter(); - hp.type = "highpass"; - hp.frequency.value = fromHz; - hp.Q.value = 0.707; - musicStereoSplitter.connect(hp, splitOut); - let last = hp; - bandArr.push(hp); - let lp = musicContext.createBiquadFilter(); - lp.type = "lowpass"; - lp.frequency.value = toHz; - lp.Q.value = 0.707; - last.connect(lp); - last = lp; - bandArr.push(lp); + let last; + if (multiBand) { + last = connectParallelHpLpMusicSide(musicContext, musicStereoSplitter, splitOut, bandArr, intervals); + } else { + let hp = musicContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + musicStereoSplitter.connect(hp, splitOut); + last = hp; + bandArr.push(hp); + let lp = musicContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + bandArr.push(lp); + } specs.forEach((s) => { let bf = musicContext.createBiquadFilter(); bf.type = mapFilterTypeToBiquad(s.type); @@ -9743,9 +9985,13 @@ function addExtra() { if (!musicMediaSourceNode || !musicContext || !musicMasterGain) { return; } - let { fromHz, toHz } = readLiveSoundBandEdgeHz(); + let intervals = readLiveSoundBandIntervals(); + let multiBand = intervals.length > 1; + let iv0 = intervals[0]; + let fromHz = iv0.lo; + let toHz = iv0.hi; let specs = computeLiveEqSpecs(); - if (musicBandFilters.length === 2 + if (!multiBand && musicBandFilters.length === 2 && specs.length === musicBiquads.length && syncBandShelfFiltersInPlace(musicContext, musicBandFilters, fromHz, toHz)) { if (specs.length === 0 || syncEqBiquadsInPlace(musicContext, musicBiquads, specs)) { @@ -9758,21 +10004,26 @@ function addExtra() { disconnectEqBiquads(musicBiquadsLeft); disconnectEqBiquads(musicBiquadsRight); disconnectMusicBandFilters(); - let last = musicMediaSourceNode; - let hp = musicContext.createBiquadFilter(); - hp.type = "highpass"; - hp.frequency.value = fromHz; - hp.Q.value = 0.707; - last.connect(hp); - last = hp; - musicBandFilters.push(hp); - let lp = musicContext.createBiquadFilter(); - lp.type = "lowpass"; - lp.frequency.value = toHz; - lp.Q.value = 0.707; - last.connect(lp); - last = lp; - musicBandFilters.push(lp); + let last; + if (multiBand) { + last = connectParallelHpLpBandBranches(musicContext, musicMediaSourceNode, musicBandFilters, intervals); + } else { + last = musicMediaSourceNode; + let hp = musicContext.createBiquadFilter(); + hp.type = "highpass"; + hp.frequency.value = fromHz; + hp.Q.value = 0.707; + last.connect(hp); + last = hp; + musicBandFilters.push(hp); + let lp = musicContext.createBiquadFilter(); + lp.type = "lowpass"; + lp.frequency.value = toHz; + lp.Q.value = 0.707; + last.connect(lp); + last = lp; + musicBandFilters.push(lp); + } specs.forEach((s) => { let bf = musicContext.createBiquadFilter(); bf.type = mapFilterTypeToBiquad(s.type); @@ -10323,6 +10574,10 @@ function addExtra() { disconnectEqBiquads(toneGeneratorBiquads); disconnectEqBiquads(toneGeneratorBiquadsLeft); disconnectEqBiquads(toneGeneratorBiquadsRight); + toneGeneratorBandFiltersMono.forEach((b) => { + try { b.disconnect(); } catch (e) { /* noop */ } + }); + toneGeneratorBandFiltersMono.length = 0; toneGeneratorBandFiltersLeft.forEach((b) => { try { b.disconnect(); } catch (e) { /* noop */ } }); @@ -10524,7 +10779,8 @@ function addExtra() { let eqGraphPerformDragCleanup = (st, endEvent) => { if (st.mode === "soundRange") { if (st.soundRangeActive) { - applyLiveSoundRangeFromHzPair(st.soundRangeAnchorHz, st.soundRangeLastHz); + applyLiveSoundRangeFromHzPair(st.soundRangeAnchorHz, st.soundRangeLastHz, + st.soundRangeAppend); let bandEl = document.querySelector("div.live-sound-tools .live-sound-band"); if (bandEl && typeof bandEl.scrollIntoView === "function") { bandEl.scrollIntoView({ block: "nearest", behavior: "smooth" }); @@ -10664,7 +10920,12 @@ function addExtra() { st.soundRangeActive = true; } if (st.soundRangeActive) { - renderEqSoundRangeBrush(st.soundRangeAnchorHz, st.soundRangeLastHz); + if (st.soundRangeAppend) { + renderEqSoundRangeBrushFromIntervals(readLiveSoundBandIntervals(), + st.soundRangeAnchorHz, st.soundRangeLastHz); + } else { + renderEqSoundRangeBrush(st.soundRangeAnchorHz, st.soundRangeLastHz); + } gEqSoundRangeBrush.raise(); gEqFilterMarkers.raise(); gEqHoverPreview.raise(); @@ -10932,6 +11193,7 @@ function addExtra() { mode: soundRangeSelect ? "soundRange" : "eq", soundRangeAnchorHz: soundRangeSelect ? freqAtPointer : null, soundRangeLastHz: soundRangeSelect ? freqAtPointer : null, + soundRangeAppend: Boolean(soundRangeSelect && e.shiftKey), soundRangeActive: false, startClientX: e.clientX, startClientY: startClientYVal, @@ -11219,6 +11481,8 @@ function addExtra() { updateEqTraceOpacity(); }); // Tone Generator + toneGeneratorFromInput.addEventListener("change", clearLiveSoundIntervalsDatasetIfPresent); + toneGeneratorToInput.addEventListener("change", clearLiveSoundIntervalsDatasetIfPresent); toneGeneratorFromInput.addEventListener("input", scheduleLiveEqSync); toneGeneratorToInput.addEventListener("input", scheduleLiveEqSync); toneGeneratorFromInput.addEventListener("input", syncEqSoundRangeBrushFromLiveSoundInputs); @@ -12026,6 +12290,7 @@ function addExtra() { if (!toneGeneratorFromInput || !toneGeneratorToInput) { return; } + clearLiveSoundIntervalsDatasetIfPresent(); toneGeneratorFromInput.value = "20"; toneGeneratorToInput.value = "20000"; let midHz = Math.round(Math.exp((Math.log(20) + Math.log(20000)) / 2)); @@ -12085,6 +12350,55 @@ function addExtra() { eqSoundRangeBrushDismissLast = { xRight: xa + w, yTop: 20 }; syncEqSoundRangeBrushDismissOverlay(); } + function renderEqSoundRangeBrushFromIntervals(intervals, liveHz1, liveHz2) { + gEqSoundRangeBrush.selectAll("*").remove(); + hideEqSoundRangeBrushDismissOverlay(); + let [fLo, fHi] = getEqConstraintFreqLoHi(); + let maxXRight = 0; + let any = false; + let yTop = 20; + let pushRect = (lo, hi) => { + if (lo <= fLo && hi >= fHi) { + return; + } + if (hi <= lo) { + return; + } + let xa = Math.min(x(lo), x(hi)); + let xb = Math.max(x(lo), x(hi)); + let w = Math.max(0.5, xb - xa); + let inner = gEqSoundRangeBrush.append("g").attr("class", "eq-sound-range-brush-inner"); + inner.append("rect") + .attr("class", "eq-sound-range-brush-rect") + .attr("x", xa) + .attr("y", yTop) + .attr("width", w) + .attr("height", 302); + maxXRight = Math.max(maxXRight, xa + w); + any = true; + }; + if (intervals && intervals.length) { + intervals.forEach((iv) => { + pushRect(iv.lo, iv.hi); + }); + } + if (liveHz1 !== undefined && liveHz2 !== undefined + && Number.isFinite(liveHz1) && Number.isFinite(liveHz2)) { + let lo = Math.min(liveHz1, liveHz2); + let hi = Math.max(liveHz1, liveHz2); + lo = Math.min(fHi, Math.max(fLo, lo)); + hi = Math.min(fHi, Math.max(fLo, hi)); + if (hi > lo) { + pushRect(lo, hi); + } + } + if (!any) { + return; + } + ensureEqSoundRangeBrushDismissOverlay(); + eqSoundRangeBrushDismissLast = { xRight: maxXRight, yTop: yTop }; + syncEqSoundRangeBrushDismissOverlay(); + } function syncEqSoundRangeBrushFromLiveSoundInputs() { let tab = document.querySelector("div.select"); if (!extraEnabled || !extraEQEnabled || !tab @@ -12096,12 +12410,10 @@ function addExtra() { clearEqSoundRangeBrush(); return; } - let from = Math.min(Math.max(parseInt(toneGeneratorFromInput.value, 10) || 20, 20), 20000); - let to = Math.min(Math.max(parseInt(toneGeneratorToInput.value, 10) || 20, 20), 20000); - let lo = Math.min(from, to); - let hi = Math.max(from, to); - if (lo > 20 || hi < 20000) { - renderEqSoundRangeBrush(lo, hi); + let intervals = readLiveSoundBandIntervals(); + let full = intervals.length === 1 && intervals[0].lo <= 20 && intervals[0].hi >= 20000; + if (!full) { + renderEqSoundRangeBrushFromIntervals(intervals); gEqSoundRangeBrush.raise(); gEqFilterMarkers.raise(); gEqHoverPreview.raise(); @@ -12109,7 +12421,7 @@ function addExtra() { clearEqSoundRangeBrush(); } } - function applyLiveSoundRangeFromHzPair(hz1, hz2) { + function applyLiveSoundRangeFromHzPair(hz1, hz2, appendRange) { if (!toneGeneratorFromInput || !toneGeneratorToInput) { return; } @@ -12132,9 +12444,18 @@ function addExtra() { if (hi <= lo) { hi = Math.min(fHi, lo + 1); } - toneGeneratorFromInput.value = String(lo); - toneGeneratorToInput.value = String(hi); - let midHz = Math.round(Math.exp((Math.log(Math.max(lo, 20.001)) + Math.log(hi)) / 2)); + let newIv = normalizeLiveSoundIntervalPair(lo, hi); + let next; + if (appendRange) { + let existing = readLiveSoundBandIntervals(); + next = mergeLiveSoundIntervalsSorted(existing.concat([newIv])); + } else { + next = [newIv]; + } + writeLiveSoundIntervalsState(next); + let loAg = next.reduce((m, iv) => Math.min(m, iv.lo), next[0].lo); + let hiAg = next.reduce((m, iv) => Math.max(m, iv.hi), next[0].hi); + let midHz = Math.round(Math.exp((Math.log(Math.max(loAg, 20.001)) + Math.log(hiAg)) / 2)); syncToneGeneratorToEqFrequencyHz(midHz); scheduleLiveEqSync(); } From e83347a66d4cee1a94c1e1117ff4c3dc8c52934c Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 2 May 2026 18:58:39 -0700 Subject: [PATCH 108/136] Settings icon style --- style-alt.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/style-alt.css b/style-alt.css index f0eeb59..c56e901 100644 --- a/style-alt.css +++ b/style-alt.css @@ -1759,7 +1759,7 @@ div.extra-panel .extra-eq-head-trailing .extra-eq-constraints-gear { height: 20px; margin: 0; /* Hit target extends left only so the glyph stays flush with the column’s right edge */ - padding: 0 0 0 10px; + padding: 0 0 0 8px; display: inline-flex; align-items: center; justify-content: flex-end; @@ -2571,7 +2571,7 @@ div.extra-panel .live-sound-tools-head-trailing .live-sound-tools-settings-gear margin: 0; /* Override div.extra-panel button { margin-bottom: 4px !important } — no chip wrap here */ margin-bottom: 0 !important; - padding: 0 0 0 10px; + padding: 0 0 0 8px; display: inline-flex; align-items: center; justify-content: flex-end; From 76010b6567915108d35cdceba2b2df0e2c9f0a1e Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 2 May 2026 19:44:32 -0700 Subject: [PATCH 109/136] EQ pre-selected target stability --- graphtool.js | 187 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 152 insertions(+), 35 deletions(-) diff --git a/graphtool.js b/graphtool.js index 6fd4ffa..773693d 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1977,16 +1977,43 @@ function phoneCurveDataReadyForEq(p) { return !!(p && p.rawChannels && Array.isArray(p.rawChannels) && p.rawChannels.some(c => c)); } -function manageTableRows() { +/** Same phone ordering as the manage table (before Eq-tab row filter): unique phones in curve-walk order, + then targets clustered first; with a share/config `initPhoneOrderIndex`, each segment (targets, then IEMs) + is sorted by `initOrderRankForPhone` so order matches the URL. */ +function getManageTableBasePhoneOrder() { let curvesAll = d3.merge(activePhones.map(p => p.activeCurves || [])), phoneOrder = [], seenP = new Set(); curvesAll.forEach(c => { - if (!c || !c.p || seenP.has(c.p)) return; + if (!c || !c.p || seenP.has(c.p)) { + return; + } seenP.add(c.p); phoneOrder.push(c.p); }); - phoneOrder = phonesClusteredTargetsFirst(phoneOrder); + let clustered = phonesClusteredTargetsFirst(phoneOrder); + if (!initPhoneOrderIndex.size) { + return clustered; + } + let sortSeg = (seg) => seg.slice().sort((a, b) => { + let ra = initOrderRankForPhone(a), + rb = initOrderRankForPhone(b); + if (ra == null) { + ra = 1e6 + phoneManageIdentity(a) * 1e-6; + } + if (rb == null) { + rb = 1e6 + phoneManageIdentity(b) * 1e-6; + } + if (ra !== rb) { + return ra - rb; + } + return phoneManageIdentity(a) - phoneManageIdentity(b); + }); + return sortSeg(clustered.filter((p) => p && p.isTarget)) + .concat(sortSeg(clustered.filter((p) => p && !p.isTarget))); +} +function manageTableRows() { + let phoneOrder = getManageTableBasePhoneOrder(); let rows = []; phoneOrder.forEach(p => { let pid = phoneManageIdentity(p), @@ -2661,7 +2688,13 @@ function showPhone(p, exclusive, suppressVariant, trigger) { let suppressModelStickyForTargetMeas = !!(bypass && bypass === p.fullName); /* Avoid late async showPhone() for the *previous* model overwriting EQ focus while a new model is loading from the EQ dropdown (eqDropdownModelIntent). */ - if (!suppressModelStickyForTargetMeas && (!intent || p.fullName === intent)) { + /* Parallel init loads can finish out of order; do not let later fetches stomp sticky during + bulk config/share/embed once another model is already on-graph. */ + let otherModels = activePhones.filter((q) => + q && q !== p && !q.isTarget && q.fullName && !String(q.fullName).match(/ EQ$/)); + let initBulk = trigger === "config" || trigger === "share" || trigger === "embed"; + if (!suppressModelStickyForTargetMeas && (!intent || p.fullName === intent) + && !(initBulk && otherModels.length > 0)) { window.eqLastGraphModelForEq = p.fullName; } if (typeof window !== "undefined" && suppressModelStickyForTargetMeas) { @@ -2669,7 +2702,16 @@ function showPhone(p, exclusive, suppressVariant, trigger) { } } if (extraEnabled && extraEQEnabled && p.isTarget && p.fullName && !isCompensationTargetNameMatch(p)) { - window.eqLastGraphTargetForEq = p.fullName; + /* init `inits.map(... showPhone(..., initMode))` can load several targets in parallel. Each + async showPhone() would otherwise stomp `eqLastGraphTargetForEq` — whichever network fetch + completes last “wins” instead of config/init order. Skip sticky updates when this target is + joining one or more targets already on-graph during bulk init (config/share/embed). */ + let otherTargets = activePhones.filter((q) => + q && q !== p && q.isTarget && q.fullName && !isCompensationTargetNameMatch(q)); + let initBulk = trigger === "config" || trigger === "share" || trigger === "embed"; + if (!(initBulk && otherTargets.length > 0)) { + window.eqLastGraphTargetForEq = p.fullName; + } } if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { window.updateEQPhoneSelect(); @@ -3962,8 +4004,14 @@ function addExtra() { applyParametricEqGraphTraceFocus briefly treated it as the EQ model — "flash twice". */ return null; } - return activePhones.filter((p) => - !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/))[0] || null; + let ord = getManageTableBasePhoneOrder(); + for (let i = 0; i < ord.length; i++) { + let p = ord[i]; + if (p && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)) { + return p; + } + } + return null; }; /** After synthesizing `USRMT_*`, drop the source measurement trace unless it is the active EQ model row. */ let removeMeasurementIfSupersededByUserTarget = (meas) => { @@ -3994,8 +4042,17 @@ function addExtra() { return graphCanon || fromSel; } let catT = eqCatalogTargetsForEqUi().slice().sort((a, b) => String(a.fullName).localeCompare(String(b.fullName))); - /* Prefer a target already on the graph (e.g. Diffuse field) over an arbitrary catalog row. */ - let onGraphT = activePhones.filter((p) => p.isTarget && !isCompensationTargetNameMatch(p))[0]; + /* Prefer a target already on the graph (manage-table order: targets first in row order). */ + let onGraphT = (() => { + let ord = getManageTableBasePhoneOrder(); + for (let i = 0; i < ord.length; i++) { + let p = ord[i]; + if (p && p.isTarget && !isCompensationTargetNameMatch(p)) { + return p; + } + } + return null; + })(); return onGraphT || catT[0] || null; }; let getParametricEqTraceFocusContext = () => { @@ -4033,6 +4090,17 @@ function addExtra() { let hit = eqMeasurementObjForSelect(fullName); return !!(hit && activePhones.indexOf(hit) !== -1 && phoneCurveDataReadyForEq(hit)); }; + /** Same as renderable but does not wait for rawChannels — avoids races when parallel loads finish out of order. */ + let eqModelOnGraphInOptionList = (fullName, optionValues) => { + if (!fullName || !optionValues || optionValues.indexOf(fullName) < 0) { + return false; + } + if (typeof window !== "undefined" && window._eqPendingModelFullName === fullName) { + return true; + } + let hit = eqMeasurementObjForSelect(fullName); + return !!(hit && activePhones.indexOf(hit) !== -1); + }; let eqTargetDropdownCandidateRenderable = (fullName, allOpts) => { if (!fullName || !allOpts || !allOpts.some((row) => row.fullName === fullName)) { return false; @@ -4043,6 +4111,16 @@ function addExtra() { let hit = eqFindByFullNameAny(fullName); return !!(hit && activePhones.indexOf(hit) !== -1 && phoneCurveDataReadyForEq(hit)); }; + let eqTargetOnGraphInOptionList = (fullName, allOpts) => { + if (!fullName || !allOpts || !allOpts.some((row) => row.fullName === fullName)) { + return false; + } + if (typeof window !== "undefined" && window._eqPendingTargetFullName === fullName) { + return true; + } + let hit = eqFindByFullNameAny(fullName); + return !!(hit && activePhones.indexOf(hit) !== -1); + }; window.publishEqUiState = (reason) => { let tab = document.querySelector("div.select"); let onEqTab = !!(extraEnabled && extraEQEnabled && tab @@ -7146,27 +7224,49 @@ function addExtra() { let lastGraphT = (typeof window !== "undefined" && window.eqLastGraphTargetForEq) ? String(window.eqLastGraphTargetForEq).trim() : ""; - let firstActiveTarget = (() => { - let ht = activePhones.filter((q) => q && q.isTarget && q.fullName - && !isCompensationTargetNameMatch(q))[0]; - return ht ? ht.fullName : ""; + let manageTopTargetFn = (() => { + let ord = getManageTableBasePhoneOrder(); + for (let i = 0; i < ord.length; i++) { + let p = ord[i]; + if (!p || !p.isTarget || !p.fullName || isCompensationTargetNameMatch(p)) { + continue; + } + if (eqTargetOnGraphInOptionList(p.fullName, allOpts)) { + return p.fullName; + } + } + return ""; })(); - /* Graph target UI (reviewer targets) updates `eqLastGraphTargetForEq` on each showPhone; the - + + +
+
@@ -384,7 +395,10 @@ doc.html(`
- +
+ + +
@@ -9595,6 +9609,16 @@ function addExtra() { let musicAnalyser = null; let musicObjectUrl = null; let musicFileLoaded = false; + /** Inline Apple preview search replaces the Music play slot when opened (no track loaded yet). */ + let musicAppleSearchModeOpen = false; + let appleMusicSearchDebounceTimer = null; + let isExtraTabSelectedForShortcuts = () => { + let tab = document.querySelector("div.select"); + return !!(typeof extraEnabled !== "undefined" && extraEnabled && tab + && tab.getAttribute("data-selected") === "extra"); + }; + let suppressEqExtraGlobalShortcutsForAppleSearch = () => + musicAppleSearchModeOpen && isExtraTabSelectedForShortcuts(); let musicSeekDragging = false; let musicSegStartU = 0; let musicSegEndU = 1; @@ -10848,8 +10872,13 @@ function addExtra() { } let musicPlayButton = document.querySelector("div.extra-music .play"); let musicAddRemoveButton = document.querySelector("div.extra-music button.music-add-remove"); + let musicSearchAppleButton = document.querySelector("div.extra-music button.music-search-apple"); + let musicFileActionsRow = document.querySelector("div.extra-music .music-file-actions-row"); let musicFileInput = document.querySelector("div.extra-music input.music-file-input"); let musicCard = document.querySelector("div.extra-music"); + let appleMusicInlineWrap = musicCard && musicCard.querySelector(".apple-music-search-inline"); + let appleMusicSearchInput = musicCard && musicCard.querySelector("#apple-music-preview-search"); + let appleMusicResultsUl = appleMusicInlineWrap && appleMusicInlineWrap.querySelector("ul.apple-music-preview-results"); let musicPlaybackPanel = document.querySelector("div.extra-music .music-playback-panel"); let musicSegmentSliderEl = musicCard && musicCard.querySelector(".music-segment-slider"); let musicSegmentTrackEl = musicSegmentSliderEl && musicSegmentSliderEl.querySelector(".music-segment-track"); @@ -10861,6 +10890,70 @@ function addExtra() { let musicSegmentHandleStart = musicSegmentTrackEl && musicSegmentTrackEl.querySelector(".music-segment-handle-start"); let musicSegmentHandleEnd = musicSegmentTrackEl && musicSegmentTrackEl.querySelector(".music-segment-handle-end"); let musicSegmentHandleInsetPx = 8; + /** Stops preview search UX; when `musicFileLoaded` is still false and `collapseEmptyPlaybackPanel`, collapse playback strip. */ + let resetAppleMusicSearchUi = (opts) => { + opts = opts || {}; + let collapseEmptyPlaybackPanel = opts.collapseEmptyPlaybackPanel === true; + musicAppleSearchModeOpen = false; + if (appleMusicSearchDebounceTimer !== null) { + clearTimeout(appleMusicSearchDebounceTimer); + appleMusicSearchDebounceTimer = null; + } + if (musicCard) { + musicCard.classList.remove("music-apple-search-mode"); + } + if (appleMusicInlineWrap) { + appleMusicInlineWrap.hidden = true; + } + if (appleMusicResultsUl) { + appleMusicResultsUl.hidden = true; + appleMusicResultsUl.innerHTML = ""; + } + if (appleMusicSearchInput) { + appleMusicSearchInput.value = ""; + } + if (musicPlayButton) { + musicPlayButton.hidden = false; + } + if (musicFileActionsRow) { + musicFileActionsRow.hidden = false; + } + if (!musicFileLoaded && collapseEmptyPlaybackPanel && musicPlaybackPanel) { + musicPlaybackPanel.setAttribute("aria-hidden", "true"); + } + }; + let openAppleMusicSearchMode = () => { + if (musicFileLoaded || musicAppleSearchModeOpen) { + return; + } + if (!musicCard || !musicPlaybackPanel || !musicPlayButton || !appleMusicInlineWrap || !appleMusicSearchInput + || !appleMusicResultsUl || !musicFileActionsRow) { + return; + } + musicAppleSearchModeOpen = true; + musicCard.classList.add("music-apple-search-mode"); + musicPlaybackPanel.setAttribute("aria-hidden", "false"); + musicPlayButton.hidden = true; + appleMusicInlineWrap.hidden = false; + musicFileActionsRow.hidden = true; + appleMusicResultsUl.hidden = true; + let focusSearch = () => { + if (!musicAppleSearchModeOpen || !appleMusicSearchInput) { + return; + } + appleMusicSearchInput.focus({ preventScroll: true }); + try { + appleMusicSearchInput.select(); + } catch (err) { /* noop */ } + }; + if (typeof requestAnimationFrame === "function") { + requestAnimationFrame(() => { + requestAnimationFrame(focusSearch); + }); + } else { + focusSearch(); + } + }; let getMusicDuration = () => { if (!musicAudio) { return 0; @@ -12230,6 +12323,9 @@ function addExtra() { if (!extraEnabled || !tab || tab.getAttribute("data-selected") !== "extra") { return; } + if (suppressEqExtraGlobalShortcutsForAppleSearch()) { + return; + } toneSweepLoopSpaceHeld = true; }, true); document.addEventListener("keyup", (e) => { @@ -12378,6 +12474,8 @@ function addExtra() { } musicContext = new (window.AudioContext || window.webkitAudioContext)(); musicAudio = new Audio(); + /* Required for preview URLs (and harmless for blob: local files) so Web Audio can process the stream. */ + musicAudio.crossOrigin = "anonymous"; musicAudio.loop = false; musicAudio.preload = "auto"; musicAudio.addEventListener("timeupdate", musicAudioTimeUpdateHandler); @@ -12467,19 +12565,30 @@ function addExtra() { updateEqTraceOpacity(); }); }; - let wireMusicLoadedFromBlob = (blob, segOpt, loadOpts) => { + let wireMusicLoadedFromSource = (src, segOpt, loadOpts) => { loadOpts = loadOpts || {}; let autoPlayAfterLoad = loadOpts.autoPlay === true; if (!musicAudio || !musicPlayButton || !musicCard || !musicSegmentSliderEl || !musicAddRemoveButton) { return; } + resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: false }); + let isBlob = typeof Blob !== "undefined" && src instanceof Blob; + if (!isBlob && (src == null || String(src).trim() === "")) { + return; + } if (musicObjectUrl) { URL.revokeObjectURL(musicObjectUrl); + musicObjectUrl = null; } musicAudio.pause(); musicSpectrumViz.stop(); musicPlayButton.classList.remove("playback-active"); - musicObjectUrl = URL.createObjectURL(blob); + if (isBlob) { + musicObjectUrl = URL.createObjectURL(src); + musicAudio.src = musicObjectUrl; + } else { + musicAudio.src = String(src).trim(); + } if (segOpt && typeof segOpt.segStartU === "number" && typeof segOpt.segEndU === "number") { musicSegStartU = segOpt.segStartU; musicSegEndU = segOpt.segEndU; @@ -12487,13 +12596,13 @@ function addExtra() { musicSegStartU = 0; musicSegEndU = 1; } - musicAudio.src = musicObjectUrl; musicAudio.load(); musicFileLoaded = true; musicCard.classList.add("music-file-loaded"); if (musicPlaybackPanel) { musicPlaybackPanel.setAttribute("aria-hidden", "false"); } + musicPlayButton.hidden = false; musicPlayButton.disabled = false; musicSegmentSliderEl.classList.remove("music-segment-slider-disabled"); let onMusicMeta = () => { @@ -12518,6 +12627,135 @@ function addExtra() { } } }; + let wireMusicLoadedFromBlob = (blob, segOpt, loadOpts) => + wireMusicLoadedFromSource(blob, segOpt, loadOpts); + let appleMusicCatalogBaseResolved = () => { + let b = typeof appleMusicCatalogApiBase !== "undefined" ? String(appleMusicCatalogApiBase || "").trim() : ""; + return b ? b.replace(/\/+$/, "") : "https://api.music.apple.com"; + }; + let appleMusicStorefrontResolved = () => { + let s = typeof appleMusicStorefront !== "undefined" ? String(appleMusicStorefront || "").trim() : ""; + return (s || "us").toLowerCase(); + }; + let appleMusicTokenCache = { token: "", expMs: 0 }; + let decodeAppleMusicJwtExpMs = (jwt) => { + try { + let p = String(jwt || "").split("."); + if (p.length < 2) { + return 0; + } + let bod = p[1].replace(/-/g, "+").replace(/_/g, "/"); + while (bod.length % 4) { + bod += "="; + } + let payload = JSON.parse(atob(bod)); + return ((payload.exp | 0) || 0) * 1000; + } catch (e) { + return 0; + } + }; + let appleMusicFetchDeveloperToken = () => { + let url = typeof appleMusicDeveloperTokenUrl !== "undefined" ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; + if (!url) { + return Promise.reject(new Error("Apple Music developer token URL is not configured")); + } + let now = Date.now(); + if (appleMusicTokenCache.token && now < appleMusicTokenCache.expMs - 45000) { + return Promise.resolve(appleMusicTokenCache.token); + } + return fetch(url, { credentials: "omit" }).then((r) => { + if (!r.ok) { + throw new Error("Developer token endpoint HTTP " + r.status); + } + return r.text(); + }).then((text) => { + let t = String(text || "").trim(); + if (!t) { + throw new Error("Empty developer token response"); + } + appleMusicTokenCache.token = t; + let expMs = decodeAppleMusicJwtExpMs(t); + appleMusicTokenCache.expMs = expMs || (now + 50 * 60 * 1000); + return t; + }); + }; + let parseAppleMusicSearchSongsPayload = (json) => { + let out = []; + let songs = json && json.results && json.results.songs && json.results.songs.data; + if (!Array.isArray(songs)) { + return out; + } + for (let i = 0; i < songs.length; i++) { + let a = songs[i] && songs[i].attributes; + if (!a) { + continue; + } + let pv = Array.isArray(a.previews) && a.previews.length ? a.previews[0].url : ""; + if (!pv) { + continue; + } + out.push({ title: a.name || "", artist: a.artistName || "", previewUrl: pv }); + } + return out; + }; + let appleMusicSearchCatalog = (term) => { + let q = String(term || "").trim(); + if (!q) { + return Promise.resolve([]); + } + let base = appleMusicCatalogBaseResolved(); + let sf = appleMusicStorefrontResolved(); + let searchUrl = base + "/v1/catalog/" + encodeURIComponent(sf) + "/search?term=" + + encodeURIComponent(q) + "&types=songs&limit=12"; + return appleMusicFetchDeveloperToken().then((token) => fetch(searchUrl, { + headers: { Authorization: "Bearer " + token } + })).then((r) => { + if (!r.ok) { + throw new Error("Apple catalog search HTTP " + r.status); + } + return r.json(); + }).then(parseAppleMusicSearchSongsPayload); + }; + /* Public iTunes Search API (no auth) — same preview URLs many demos use; not api.music.apple.com. */ + let parseItunesSearchSongsPayload = (json) => { + let out = []; + let results = json && json.results; + if (!Array.isArray(results)) { + return out; + } + for (let i = 0; i < results.length; i++) { + let r = results[i]; + let pv = r && r.previewUrl; + if (!pv) { + continue; + } + out.push({ title: r.trackName || "", artist: r.artistName || "", previewUrl: pv }); + } + return out; + }; + let itunesSearchSongs = (term) => { + let q = String(term || "").trim(); + if (!q) { + return Promise.resolve([]); + } + let country = appleMusicStorefrontResolved(); + let searchUrl = "https://itunes.apple.com/search?term=" + encodeURIComponent(q) + + "&entity=song&limit=12&country=" + encodeURIComponent(country); + return fetch(searchUrl, { credentials: "omit" }).then((r) => { + if (!r.ok) { + throw new Error("iTunes search HTTP " + r.status); + } + return r.json(); + }).then(parseItunesSearchSongsPayload); + }; + let applePreviewSearch = (term) => { + let tokenUrl = typeof appleMusicDeveloperTokenUrl !== "undefined" + ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; + if (tokenUrl) { + return appleMusicSearchCatalog(term); + } + return itunesSearchSongs(term); + }; if (musicPlayButton && musicSegmentSliderEl && musicSegmentTrackEl && musicSegmentSeekEl && musicSegmentHandleStart && musicSegmentHandleEnd && musicAddRemoveButton && musicFileInput && musicCard) { musicAddRemoveButton.addEventListener("click", () => { @@ -12550,6 +12788,157 @@ function addExtra() { musicFileInput.blur(); }, 0); }); + if (typeof extraMusicEnabled !== "undefined" && extraMusicEnabled + && appleMusicInlineWrap && musicSearchAppleButton && appleMusicSearchInput && appleMusicResultsUl + && musicFileActionsRow) { + /* Return/Enter still blurs the field in some WebKit paths; focusout was dismissing search. */ + let appleMusicSearchIgnoreFocusOutUntil = 0; + let appleMusicBlockEnterKeydown = (e) => { + if (e.isComposing) { + return; + } + if (e.code !== "Enter" && e.code !== "NumpadEnter" && e.key !== "Enter" && e.keyCode !== 13) { + return; + } + e.preventDefault(); + e.stopImmediatePropagation(); + appleMusicSearchIgnoreFocusOutUntil = performance.now() + 600; + }; + let appleMusicRenderResults = (rows) => { + appleMusicResultsUl.innerHTML = ""; + if (!rows || !rows.length) { + appleMusicResultsUl.hidden = true; + return; + } + rows.forEach((row) => { + let li = document.createElement("li"); + li.setAttribute("role", "presentation"); + let bt = document.createElement("button"); + bt.type = "button"; + bt.setAttribute("role", "option"); + bt.setAttribute("aria-label", (row.title || "Track") + " — " + (row.artist || "")); + let titleEl = document.createElement("span"); + titleEl.className = "apple-music-preview-title"; + titleEl.textContent = row.title || ""; + bt.appendChild(titleEl); + let meta = document.createElement("span"); + meta.className = "apple-music-preview-meta"; + meta.textContent = row.artist || ""; + bt.appendChild(meta); + bt.addEventListener("click", () => { + appleMusicResultsUl.hidden = true; + if (!window.AudioContext && !window.webkitAudioContext) { + alert("Web audio API is disabled; music playback is unavailable."); + return; + } + musicRestoreCancelToken++; + if (!initMusicAudioGraph()) { + return; + } + wireMusicLoadedFromSource(row.previewUrl, null, { autoPlay: true }); + }); + li.appendChild(bt); + appleMusicResultsUl.appendChild(li); + }); + appleMusicResultsUl.hidden = false; + }; + let appleMusicPreviewForm = appleMusicInlineWrap.querySelector("form.apple-music-preview-form"); + if (appleMusicPreviewForm) { + appleMusicPreviewForm.addEventListener("submit", (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + }); + appleMusicPreviewForm.addEventListener("keydown", appleMusicBlockEnterKeydown, true); + } + appleMusicSearchInput.addEventListener("keydown", appleMusicBlockEnterKeydown, true); + let appleMusicOutsidePointerDismiss = (e) => { + if (!musicAppleSearchModeOpen) { + return; + } + let t = e.target; + if (t && t.closest && t.closest(".apple-music-search-inline")) { + return; + } + resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: true }); + }; + document.addEventListener("pointerdown", appleMusicOutsidePointerDismiss, true); + let appleMusicEscapeDismiss = (e) => { + if (!musicAppleSearchModeOpen || e.code !== "Escape") { + return; + } + if (!appleMusicInlineWrap.contains(document.activeElement)) { + return; + } + e.preventDefault(); + resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: true }); + }; + document.addEventListener("keydown", appleMusicEscapeDismiss, true); + appleMusicSearchInput.addEventListener("input", () => { + let v = appleMusicSearchInput.value.trim(); + if (appleMusicSearchDebounceTimer !== null) { + clearTimeout(appleMusicSearchDebounceTimer); + appleMusicSearchDebounceTimer = null; + } + if (v.length < 2) { + appleMusicResultsUl.hidden = true; + appleMusicResultsUl.innerHTML = ""; + return; + } + appleMusicSearchDebounceTimer = setTimeout(() => { + appleMusicSearchDebounceTimer = null; + applePreviewSearch(v).then((rows) => { + appleMusicRenderResults(rows); + }).catch((err) => { + console.warn(err); + appleMusicResultsUl.innerHTML = ""; + let li = document.createElement("li"); + let msg = document.createElement("div"); + msg.style.padding = "10px 16px"; + msg.style.fontSize = "12px"; + msg.style.lineHeight = "1.3"; + let tokenUrl = typeof appleMusicDeveloperTokenUrl !== "undefined" + ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; + msg.textContent = tokenUrl + ? "Apple Music catalog search failed (often CORS from the browser). " + + "Point appleMusicCatalogApiBase at a same-origin proxy that forwards to " + + "api.music.apple.com with the Authorization header." + : "iTunes search failed (network, rate limits, or browser restrictions). " + + "Set appleMusicDeveloperTokenUrl to use Apple Music catalog search instead."; + li.appendChild(msg); + appleMusicResultsUl.appendChild(li); + appleMusicResultsUl.hidden = false; + }); + }, 380); + }); + appleMusicInlineWrap.addEventListener("focusout", (ev) => { + let rel = ev.relatedTarget; + if (rel && appleMusicInlineWrap.contains(rel)) { + return; + } + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (performance.now() < appleMusicSearchIgnoreFocusOutUntil) { + if (appleMusicSearchInput && document.activeElement !== appleMusicSearchInput) { + try { + appleMusicSearchInput.focus({ preventScroll: true }); + } catch (err) { /* noop */ } + } + return; + } + if (!musicAppleSearchModeOpen) { + return; + } + if (appleMusicInlineWrap.contains(document.activeElement)) { + return; + } + resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: true }); + }); + }); + }); + musicSearchAppleButton.addEventListener("click", () => { + openAppleMusicSearchMode(); + }); + } musicPlayButton.addEventListener("click", () => { if (!musicFileLoaded || !musicAudio || !musicContext) { return; @@ -13043,6 +13432,9 @@ function addExtra() { if (!e.metaKey && !e.altKey && !e.ctrlKey) { return; } + if (suppressEqExtraGlobalShortcutsForAppleSearch()) { + return; + } let tab = document.querySelector("div.select"); if (!extraEnabled || !extraEQEnabled || !tab || tab.getAttribute("data-selected") !== "extra") { @@ -13073,6 +13465,9 @@ function addExtra() { if (!selectEl || selectEl.getAttribute("data-selected") !== "extra") { return; } + if (suppressEqExtraGlobalShortcutsForAppleSearch()) { + return; + } if (t && t.nodeType === 1 && typeof t.matches === "function" && t.matches("input[name='eq-constraint-freq-min'], input[name='eq-constraint-freq-max'], input[name='eq-constraint-freq-graphic-list']")) { return; @@ -13144,6 +13539,7 @@ function addExtra() { document.addEventListener("keydown", (e) => { if (e.key !== "\\") return; + if (suppressEqExtraGlobalShortcutsForAppleSearch()) return; if (!livePlaybackEqToggle) return; e.preventDefault(); if (e.repeat) return; @@ -13152,6 +13548,7 @@ function addExtra() { }); document.addEventListener("keyup", (e) => { if (e.key !== "\\") return; + if (suppressEqExtraGlobalShortcutsForAppleSearch()) return; if (!livePlaybackEqToggle) return; livePlaybackEqToggle.checked = true; livePlaybackEqToggle.dispatchEvent(new Event("change")); diff --git a/style-alt.css b/style-alt.css index c56e901..fb0457a 100644 --- a/style-alt.css +++ b/style-alt.css @@ -3100,11 +3100,32 @@ div.extra-panel .live-sound-tools .extra-music .music-play-row button.play.playb -webkit-mask: var(--icon-player-stop); } -div.extra-panel .live-sound-tools .extra-tone-generator .tone-generator-play-row, -div.extra-panel .live-sound-tools .extra-music .music-play-row { +div.extra-panel .live-sound-tools .extra-tone-generator .tone-generator-play-row { margin: 8px 0 0 0; } +/* Music play row is the first thing inside .music-playback-panel-inner — no extra top gutter */ +div.extra-panel .live-sound-tools .extra-music .music-play-row { + margin: 0; +} + +div.extra-panel .live-sound-tools .extra-music .music-play-or-search-slot { + flex: 100% 0 0; + width: 100%; + min-width: 0; + position: relative; + box-sizing: border-box; +} + +/* Our `display: flex` on the play button and actions row beats the UA `[hidden]` rule; force real collapse. */ +div.extra-panel .live-sound-tools .live-sound-music-file .music-file-actions-row[hidden] { + display: none !important; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-search-inline[hidden] { + display: none !important; +} + div.extra-panel .live-sound-tools .extra-music .music-playback-panel { max-height: 0; opacity: 0; @@ -3119,6 +3140,27 @@ div.extra-panel .live-sound-tools .extra-music.music-file-loaded .music-playback pointer-events: auto; } +/* Apple preview search replaces play control; keep panel compact until a track is loaded */ +div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-playback-panel { + max-height: 58px; + opacity: 1; + pointer-events: auto; + overflow: visible; +} + +div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-playback-panel-inner { + overflow: visible; +} + +/* Search-open only: hide play + seek (panel `button` rules override `[hidden]`; seek row is otherwise visible without a track). */ +div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-play-row button.play { + display: none !important; +} + +div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .live-sound-slider-row.music-slider-row { + display: none !important; +} + div.extra-panel .live-sound-tools .extra-music .music-playback-panel-inner { box-sizing: border-box; min-width: 0; @@ -3503,8 +3545,13 @@ div.extra-panel .live-sound-tools .live-sound-tone-create-filter .tone-generator div.extra-panel .live-sound-tools .live-sound-music-file { position: relative; display: flex; + flex-direction: column; + align-items: center; + gap: 6px; justify-content: center; margin: 6px 0 0 0; + width: 100%; + box-sizing: border-box; } div.extra-panel .live-sound-tools .live-sound-music-file .music-file-input { @@ -3520,7 +3567,22 @@ div.extra-panel .live-sound-tools .live-sound-music-file .music-file-input { opacity: 0; } -div.extra-panel .live-sound-tools .live-sound-music-file .music-add-remove { +div.extra-panel .live-sound-tools .live-sound-music-file .music-file-actions-row { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 4px 10px; + width: 100%; +} + +div.extra-panel .live-sound-tools .extra-music.music-file-loaded .music-search-apple { + display: none !important; +} + +div.extra-panel .live-sound-tools .live-sound-music-file .music-add-remove, +div.extra-panel .live-sound-tools .live-sound-music-file .music-search-apple { box-sizing: border-box; width: auto; min-width: 0; @@ -3539,11 +3601,170 @@ div.extra-panel .live-sound-tools .live-sound-music-file .music-add-remove { cursor: pointer; } -div.extra-panel .live-sound-tools .live-sound-music-file .music-add-remove:hover { +div.extra-panel .live-sound-tools .live-sound-music-file .music-add-remove:hover, +div.extra-panel .live-sound-tools .live-sound-music-file .music-search-apple:hover { color: var(--accent-color-contrast) !important; text-decoration: underline; } +div.extra-panel .live-sound-tools .extra-music .apple-music-search-inline { + position: relative; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-form { + margin: 0; + padding: 0; + border: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} + +/* Empty vs filled: softer border until there is typed text (:placeholder-shown) */ +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search { + box-sizing: border-box; + width: 100%; + max-width: 100%; + height: 36px; + min-height: 36px; + margin: 0; + padding: 10px 16px; + font-family: var(--font-secondary); + font-size: 11px; + line-height: 1em; + text-transform: uppercase; + background-color: var(--background-color-inputs); + border: 1px solid var(--background-color-contrast); + border-radius: 6px; + outline: none; + color: var(--font-color-primary); +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-search-inline:has(ul.apple-music-preview-results:not([hidden])) .apple-music-preview-search { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search:not(:placeholder-shown) { + border-color: var(--background-color-contrast-more); +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search::placeholder { + color: var(--background-color-contrast-more); + opacity: 1; + text-transform: uppercase; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search::-webkit-input-placeholder { + color: var(--background-color-contrast-more); + text-transform: uppercase; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search::-moz-placeholder { + color: var(--background-color-contrast-more); + opacity: 1; + text-transform: uppercase; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search:focus, +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search:focus-visible { + outline: none; + box-shadow: none; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results { + position: absolute; + left: 0; + right: 0; + top: 100%; + z-index: 50; + list-style: none; + margin: 6px 0 0 0; + padding: 0; + max-height: 9.5rem; + overflow-y: auto; + border: 1px solid var(--background-color-contrast-faint, rgba(127, 127, 127, 0.25)); + border-radius: 4px; + background: var(--background-color-inputs); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results:not([hidden]) { + margin-top: -1px; + border-top: none; + border-top-left-radius: 0; + border-top-right-radius: 0; + border-left-color: var(--background-color-contrast); + border-right-color: var(--background-color-contrast); + border-bottom-color: var(--background-color-contrast); + box-shadow: none; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-search-inline:has(.apple-music-preview-search:not(:placeholder-shown)) .apple-music-preview-results:not([hidden]) { + border-left-color: var(--background-color-contrast-more); + border-right-color: var(--background-color-contrast-more); + border-bottom-color: var(--background-color-contrast-more); +} + +/* Rows override global `div.extra-panel button`: margin-bottom:4px !important, accent color, bg !important, capitalize, nowrap */ +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button { + box-sizing: border-box !important; + width: 100%; + max-width: 100%; + order: initial; + flex: none !important; + margin: 0 !important; + margin-bottom: 0 !important; + padding: 10px 16px !important; + text-align: left !important; + font-family: var(--font-primary); + font-size: 12px !important; + font-weight: 400 !important; + line-height: 1.25; + text-transform: none !important; + white-space: normal !important; + border: none !important; + border-radius: 0 !important; + border-bottom: 1px solid var(--background-color-contrast-faint, rgba(127, 127, 127, 0.15)); + background: transparent !important; + background-color: transparent !important; + color: var(--font-color-primary); + cursor: pointer; + outline: none; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button .apple-music-preview-title { + display: block; + color: var(--font-color-primary) !important; + font-weight: inherit; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results li:last-child button { + border-bottom: none; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button:hover, +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button:focus-visible { + background: var(--background-color-contrast-faint, rgba(127, 127, 127, 0.08)); + outline: none; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-meta { + display: block; + margin-top: 2px; + font-size: 12px; + text-transform: none; + color: var(--background-color-contrast-more) !important; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results > li { + margin: 0; + padding: 0; +} + /***** Targets styles *****/ From 9c87781e59b67f43f7a375bb388bd3cf383f1fe3 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 17:21:29 -0700 Subject: [PATCH 117/136] Apple Music sharing --- graphtool.js | 268 ++++++++++++++++++++++++++++++++++++++++++++------ style-alt.css | 34 ++++++- 2 files changed, 267 insertions(+), 35 deletions(-) diff --git a/graphtool.js b/graphtool.js index a6c163a..6545ea8 100644 --- a/graphtool.js +++ b/graphtool.js @@ -2037,6 +2037,28 @@ function parseEqUrlShareParams(href) { return null; } } +/** Share URL: Apple Music catalog / iTunes store song id (preview loads via catalog or lookup). */ +let MUSIC_URL_PARAM_APPLE_SONG = "amSong"; +function parseAppleMusicSongIdFromHref(href) { + try { + let u = new URL(href); + let raw = u.searchParams.get(MUSIC_URL_PARAM_APPLE_SONG) + || u.searchParams.get("appleMusicSong"); + if (raw === null || raw === "") { + return null; + } + let id = String(raw).trim(); + if (!id || id.length > 64) { + return null; + } + if (!/^[a-zA-Z0-9._-]+$/.test(id)) { + return null; + } + return id; + } catch (e) { + return null; + } +} /** `share=` payload: commas between filenames stay unescaped; each stem is encoded (spaces → "_" first). Avoids `%2C` separators from URLSearchParams. */ function shareQueryValueForUrl(namesArr) { return namesArr.map((fn) => encodeURIComponent(String(fn).replace(/ /g, "_"))).join(","); @@ -2063,10 +2085,15 @@ function addPhonesToUrl() { let ref = baseURL || targetWindow.location.pathname; let u; try { - u = new URL(ref, targetWindow.location.href); + /* Start from the live location so deep-link params (EQ, amSong, …) survive music/graph updates. + `baseURL` omits `?…`, so `new URL(baseURL)` would drop every existing query param. */ + u = new URL(targetWindow.location.href); } catch (e) { return; } + /* Drop Apple song id first so we can re-append it after EQ/share logic (`amSong` stays last in the query string). */ + u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); + u.searchParams.delete("appleMusicSong"); /* Never stash `share` on URLSearchParams (encodes commas as %2C). Build explicit `share=…` with literal commas instead. */ u.searchParams.delete("share"); let shareQueryPair = ""; @@ -2098,6 +2125,12 @@ function addPhonesToUrl() { ["eq", "eqModel", "eqTarget", "eqFilters", "eq_model", "eq_target", "eq_filters"].forEach((k) => u.searchParams.delete(k)); } + if (ifURL && typeof window._appendMusicShareParamsToUrlSearch === "function") { + window._appendMusicShareParamsToUrlSearch(u); + } else { + u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); + u.searchParams.delete("appleMusicSong"); + } let qsRest = u.searchParams.toString(); let outUrl = u.pathname + (shareQueryPair && qsRest ? ("?" + shareQueryPair + "&" + qsRest) @@ -9609,6 +9642,8 @@ function addExtra() { let musicAnalyser = null; let musicObjectUrl = null; let musicFileLoaded = false; + /** Set when the playing track is identified by Apple catalog / iTunes store id (share URL `amSong`). */ + let musicAppleShareSongId = null; /** Inline Apple preview search replaces the Music play slot when opened (no track loaded yet). */ let musicAppleSearchModeOpen = false; let appleMusicSearchDebounceTimer = null; @@ -10912,9 +10947,6 @@ function addExtra() { if (appleMusicSearchInput) { appleMusicSearchInput.value = ""; } - if (musicPlayButton) { - musicPlayButton.hidden = false; - } if (musicFileActionsRow) { musicFileActionsRow.hidden = false; } @@ -10933,7 +10965,6 @@ function addExtra() { musicAppleSearchModeOpen = true; musicCard.classList.add("music-apple-search-mode"); musicPlaybackPanel.setAttribute("aria-hidden", "false"); - musicPlayButton.hidden = true; appleMusicInlineWrap.hidden = false; musicFileActionsRow.hidden = true; appleMusicResultsUl.hidden = true; @@ -12398,6 +12429,7 @@ function addExtra() { musicObjectUrl = null; } musicFileLoaded = false; + musicAppleShareSongId = null; musicSeekDragging = false; musicTrimDragging = null; musicSpectrumViz.stop(); @@ -12464,6 +12496,9 @@ function addExtra() { lastEqPlaybackSource = "pink"; } musicSpectrumViz.syncSpectrumViz(); + if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { + addPhonesToUrl(); + } }; let initMusicAudioGraph = () => { if (musicContext) { @@ -12571,11 +12606,18 @@ function addExtra() { if (!musicAudio || !musicPlayButton || !musicCard || !musicSegmentSliderEl || !musicAddRemoveButton) { return; } - resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: false }); let isBlob = typeof Blob !== "undefined" && src instanceof Blob; if (!isBlob && (src == null || String(src).trim() === "")) { return; } + musicAppleShareSongId = null; + if (loadOpts.appleCatalogSongId != null) { + let sid = String(loadOpts.appleCatalogSongId).trim(); + if (sid) { + musicAppleShareSongId = sid; + } + } + resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: false }); if (musicObjectUrl) { URL.revokeObjectURL(musicObjectUrl); musicObjectUrl = null; @@ -12602,7 +12644,6 @@ function addExtra() { if (musicPlaybackPanel) { musicPlaybackPanel.setAttribute("aria-hidden", "false"); } - musicPlayButton.hidden = false; musicPlayButton.disabled = false; musicSegmentSliderEl.classList.remove("music-segment-slider-disabled"); let onMusicMeta = () => { @@ -12626,6 +12667,9 @@ function addExtra() { musicAudio.addEventListener("canplay", autoPlayWhenReady, { once: true }); } } + if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { + addPhonesToUrl(); + } }; let wireMusicLoadedFromBlob = (blob, segOpt, loadOpts) => wireMusicLoadedFromSource(blob, segOpt, loadOpts); @@ -12686,7 +12730,8 @@ function addExtra() { return out; } for (let i = 0; i < songs.length; i++) { - let a = songs[i] && songs[i].attributes; + let res = songs[i]; + let a = res && res.attributes; if (!a) { continue; } @@ -12694,7 +12739,8 @@ function addExtra() { if (!pv) { continue; } - out.push({ title: a.name || "", artist: a.artistName || "", previewUrl: pv }); + let songId = res.id != null ? String(res.id) : ""; + out.push({ id: songId, title: a.name || "", artist: a.artistName || "", previewUrl: pv }); } return out; }; @@ -12716,6 +12762,50 @@ function addExtra() { return r.json(); }).then(parseAppleMusicSearchSongsPayload); }; + let appleMusicFetchPreviewBySongId = (songId) => { + let id = String(songId || "").trim(); + if (!id) { + return Promise.reject(new Error("empty song id")); + } + let tokenUrl = typeof appleMusicDeveloperTokenUrl !== "undefined" + ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; + if (tokenUrl) { + return appleMusicFetchDeveloperToken().then((token) => { + let base = appleMusicCatalogBaseResolved(); + let sf = appleMusicStorefrontResolved(); + let url = base + "/v1/catalog/" + encodeURIComponent(sf) + "/songs/" + encodeURIComponent(id); + return fetch(url, { headers: { Authorization: "Bearer " + token } }); + }).then((r) => { + if (!r.ok) { + throw new Error("Apple catalog song HTTP " + r.status); + } + return r.json(); + }).then((json) => { + let data = json && json.data && json.data[0]; + let a = data && data.attributes; + let pv = a && Array.isArray(a.previews) && a.previews.length ? a.previews[0].url : ""; + if (!pv) { + throw new Error("no preview on catalog song"); + } + return { previewUrl: pv, title: (a && a.name) || "", artist: (a && a.artistName) || "" }; + }); + } + return fetch("https://itunes.apple.com/lookup?id=" + encodeURIComponent(id) + "&entity=song", { + credentials: "omit" + }).then((r) => { + if (!r.ok) { + throw new Error("iTunes lookup HTTP " + r.status); + } + return r.json(); + }).then((json) => { + let r0 = json && json.results && json.results[0]; + let pv = r0 && r0.previewUrl; + if (!pv) { + throw new Error("no preview from iTunes lookup"); + } + return { previewUrl: pv, title: r0.trackName || "", artist: r0.artistName || "" }; + }); + }; /* Public iTunes Search API (no auth) — same preview URLs many demos use; not api.music.apple.com. */ let parseItunesSearchSongsPayload = (json) => { let out = []; @@ -12729,7 +12819,8 @@ function addExtra() { if (!pv) { continue; } - out.push({ title: r.trackName || "", artist: r.artistName || "", previewUrl: pv }); + let songId = r.trackId != null ? String(r.trackId) : ""; + out.push({ id: songId, title: r.trackName || "", artist: r.artistName || "", previewUrl: pv }); } return out; }; @@ -12793,11 +12884,84 @@ function addExtra() { && musicFileActionsRow) { /* Return/Enter still blurs the field in some WebKit paths; focusout was dismissing search. */ let appleMusicSearchIgnoreFocusOutUntil = 0; - let appleMusicBlockEnterKeydown = (e) => { + let appleMusicSearchHighlightIx = -1; + let appleMusicSearchLastRows = []; + let appleMusicApplySearchHighlight = () => { + if (!appleMusicResultsUl) { + return; + } + let btns = appleMusicResultsUl.querySelectorAll("li > button[role=\"option\"]"); + btns.forEach((bt, i) => { + let on = i === appleMusicSearchHighlightIx; + bt.classList.toggle("apple-music-preview-highlight", on); + bt.setAttribute("aria-selected", on ? "true" : "false"); + }); + if (appleMusicSearchHighlightIx >= 0 && btns[appleMusicSearchHighlightIx]) { + try { + btns[appleMusicSearchHighlightIx].scrollIntoView({ block: "nearest" }); + } catch (err) { /* noop */ } + } + }; + let appleMusicActivatePreviewRow = (row) => { + appleMusicResultsUl.hidden = true; + if (!window.AudioContext && !window.webkitAudioContext) { + alert("Web audio API is disabled; music playback is unavailable."); + return; + } + musicRestoreCancelToken++; + if (!initMusicAudioGraph()) { + return; + } + wireMusicLoadedFromSource(row.previewUrl, null, { + autoPlay: true, + appleCatalogSongId: row.id || "" + }); + }; + let appleMusicSearchFieldKeydown = (e) => { + if (!musicAppleSearchModeOpen || document.activeElement !== appleMusicSearchInput) { + return; + } if (e.isComposing) { return; } - if (e.code !== "Enter" && e.code !== "NumpadEnter" && e.key !== "Enter" && e.keyCode !== 13) { + let optBtns = appleMusicResultsUl ? appleMusicResultsUl.querySelectorAll("li > button[role=\"option\"]") : []; + let n = optBtns.length; + let listOpen = appleMusicResultsUl && !appleMusicResultsUl.hidden && n > 0; + if (e.code === "ArrowDown" && listOpen) { + e.preventDefault(); + e.stopImmediatePropagation(); + if (appleMusicSearchHighlightIx < 0) { + appleMusicSearchHighlightIx = 0; + } else { + appleMusicSearchHighlightIx = (appleMusicSearchHighlightIx + 1) % n; + } + appleMusicApplySearchHighlight(); + appleMusicSearchIgnoreFocusOutUntil = performance.now() + 600; + return; + } + if (e.code === "ArrowUp" && listOpen) { + e.preventDefault(); + e.stopImmediatePropagation(); + if (appleMusicSearchHighlightIx < 0) { + appleMusicSearchHighlightIx = n - 1; + } else if (appleMusicSearchHighlightIx === 0) { + appleMusicSearchHighlightIx = -1; + } else { + appleMusicSearchHighlightIx--; + } + appleMusicApplySearchHighlight(); + appleMusicSearchIgnoreFocusOutUntil = performance.now() + 600; + return; + } + let isEnter = e.code === "Enter" || e.code === "NumpadEnter" || e.key === "Enter" || e.keyCode === 13; + if (!isEnter) { + return; + } + if (listOpen && appleMusicSearchHighlightIx >= 0 && appleMusicSearchLastRows[appleMusicSearchHighlightIx]) { + e.preventDefault(); + e.stopImmediatePropagation(); + appleMusicSearchIgnoreFocusOutUntil = performance.now() + 600; + appleMusicActivatePreviewRow(appleMusicSearchLastRows[appleMusicSearchHighlightIx]); return; } e.preventDefault(); @@ -12806,16 +12970,25 @@ function addExtra() { }; let appleMusicRenderResults = (rows) => { appleMusicResultsUl.innerHTML = ""; + appleMusicSearchLastRows = []; + appleMusicSearchHighlightIx = -1; if (!rows || !rows.length) { appleMusicResultsUl.hidden = true; return; } + appleMusicSearchLastRows = rows.map((r) => ({ + id: r.id || "", + title: r.title, + artist: r.artist, + previewUrl: r.previewUrl + })); rows.forEach((row) => { let li = document.createElement("li"); li.setAttribute("role", "presentation"); let bt = document.createElement("button"); bt.type = "button"; bt.setAttribute("role", "option"); + bt.setAttribute("aria-selected", "false"); bt.setAttribute("aria-label", (row.title || "Track") + " — " + (row.artist || "")); let titleEl = document.createElement("span"); titleEl.className = "apple-music-preview-title"; @@ -12826,21 +12999,14 @@ function addExtra() { meta.textContent = row.artist || ""; bt.appendChild(meta); bt.addEventListener("click", () => { - appleMusicResultsUl.hidden = true; - if (!window.AudioContext && !window.webkitAudioContext) { - alert("Web audio API is disabled; music playback is unavailable."); - return; - } - musicRestoreCancelToken++; - if (!initMusicAudioGraph()) { - return; - } - wireMusicLoadedFromSource(row.previewUrl, null, { autoPlay: true }); + appleMusicActivatePreviewRow(row); }); li.appendChild(bt); appleMusicResultsUl.appendChild(li); }); appleMusicResultsUl.hidden = false; + appleMusicSearchHighlightIx = 0; + appleMusicApplySearchHighlight(); }; let appleMusicPreviewForm = appleMusicInlineWrap.querySelector("form.apple-music-preview-form"); if (appleMusicPreviewForm) { @@ -12848,9 +13014,8 @@ function addExtra() { e.preventDefault(); e.stopImmediatePropagation(); }); - appleMusicPreviewForm.addEventListener("keydown", appleMusicBlockEnterKeydown, true); } - appleMusicSearchInput.addEventListener("keydown", appleMusicBlockEnterKeydown, true); + appleMusicSearchInput.addEventListener("keydown", appleMusicSearchFieldKeydown, true); let appleMusicOutsidePointerDismiss = (e) => { if (!musicAppleSearchModeOpen) { return; @@ -12880,6 +13045,8 @@ function addExtra() { appleMusicSearchDebounceTimer = null; } if (v.length < 2) { + appleMusicSearchLastRows = []; + appleMusicSearchHighlightIx = -1; appleMusicResultsUl.hidden = true; appleMusicResultsUl.innerHTML = ""; return; @@ -12890,6 +13057,8 @@ function addExtra() { appleMusicRenderResults(rows); }).catch((err) => { console.warn(err); + appleMusicSearchLastRows = []; + appleMusicSearchHighlightIx = -1; appleMusicResultsUl.innerHTML = ""; let li = document.createElement("li"); let msg = document.createElement("div"); @@ -13107,7 +13276,33 @@ function addExtra() { /* private mode / quota / unsupported */ }); }; - tryRestorePersistedMusic(); + let pendingAppleSongFromUrl = window.__pendingAppleMusicCatalogSongId; + if (pendingAppleSongFromUrl && typeof extraMusicEnabled !== "undefined" && extraMusicEnabled + && musicPlayButton && musicCard) { + window.__pendingAppleMusicCatalogSongId = null; + if (!window.AudioContext && !window.webkitAudioContext) { + tryRestorePersistedMusic(); + } else { + appleMusicFetchPreviewBySongId(pendingAppleSongFromUrl).then((meta) => { + if (musicFileLoaded) { + return; + } + musicRestoreCancelToken++; + if (!initMusicAudioGraph()) { + return; + } + wireMusicLoadedFromSource(meta.previewUrl, null, { + autoPlay: true, + appleCatalogSongId: pendingAppleSongFromUrl + }); + }).catch((err) => { + console.warn("Shared Apple Music track could not be loaded", err); + tryRestorePersistedMusic(); + }); + } + } else { + tryRestorePersistedMusic(); + } } let syncToneGeneratorToEqFrequencyHz = (hz) => { /* EQ freq edits normally retune the tone preview; skip while a sweep is running so the ramp is not cancelled. */ @@ -13608,6 +13803,19 @@ function addExtra() { config }); } + window._appendMusicShareParamsToUrlSearch = (u) => { + if (typeof extraMusicEnabled === "undefined" || !extraMusicEnabled) { + u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); + u.searchParams.delete("appleMusicSong"); + return; + } + if (musicAppleShareSongId) { + u.searchParams.set(MUSIC_URL_PARAM_APPLE_SONG, musicAppleShareSongId); + } else { + u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); + u.searchParams.delete("appleMusicSong"); + } + }; window._appendEqShareParamsToUrlSearch = (u) => { if (!extraEQEnabled) { return; @@ -13615,13 +13823,8 @@ function addExtra() { let tab = document.querySelector("div.select"); let onEq = tab && tab.getAttribute("data-selected") === "extra"; if (!onEq) { - u.searchParams.delete("eq"); - u.searchParams.delete(EQ_URL_PARAM_MODEL); - u.searchParams.delete(EQ_URL_PARAM_TARGET); - u.searchParams.delete(EQ_URL_PARAM_FILTERS); - u.searchParams.delete("eq_model"); - u.searchParams.delete("eq_target"); - u.searchParams.delete("eq_filters"); + /* Keep EQ params already on the URL (e.g. shared links opened on Graph tab). Stripping them + here broke combined EQ + amSong loads when music sync ran before the Extra tab opened. */ return; } u.searchParams.delete("eq_model"); @@ -13729,6 +13932,7 @@ function addExtra() { } }; } +window.__pendingAppleMusicCatalogSongId = parseAppleMusicSongIdFromHref(targetWindow.location.href); addExtra(); // Add accessories to the bottom of the page, if configured diff --git a/style-alt.css b/style-alt.css index fb0457a..d21fbae 100644 --- a/style-alt.css +++ b/style-alt.css @@ -3117,6 +3117,17 @@ div.extra-panel .live-sound-tools .extra-music .music-play-or-search-slot { box-sizing: border-box; } +/* No track: play stays in layout flow (smooth panel collapse) but is invisible */ +div.extra-panel .live-sound-tools .extra-music:not(.music-file-loaded) .music-play-row button.play { + opacity: 0 !important; + pointer-events: none !important; +} + +div.extra-panel .live-sound-tools .extra-music.music-file-loaded .music-play-row button.play { + opacity: 1 !important; + pointer-events: auto !important; +} + /* Our `display: flex` on the play button and actions row beats the UA `[hidden]` rule; force real collapse. */ div.extra-panel .live-sound-tools .live-sound-music-file .music-file-actions-row[hidden] { display: none !important; @@ -3152,9 +3163,15 @@ div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.musi overflow: visible; } -/* Search-open only: hide play + seek (panel `button` rules override `[hidden]`; seek row is otherwise visible without a track). */ -div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-play-row button.play { - display: none !important; +/* Search overlays the invisible play pill (play stays in flow at opacity 0) */ +div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-play-or-search-slot .apple-music-search-inline:not([hidden]) { + position: absolute; + left: 0; + right: 0; + top: 0; + z-index: 2; + width: 100%; + max-width: 100%; } div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .live-sound-slider-row.music-slider-row { @@ -3752,6 +3769,17 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results butt outline: none; } +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button.apple-music-preview-highlight { + background: var(--background-color-contrast) !important; + background-color: var(--background-color-contrast) !important; +} + +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button.apple-music-preview-highlight:hover, +div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results button.apple-music-preview-highlight:focus-visible { + background: var(--background-color-contrast) !important; + background-color: var(--background-color-contrast) !important; +} + div.extra-panel .live-sound-tools .extra-music .apple-music-preview-meta { display: block; margin-top: 2px; From 9f1ca189987ee42bc70275cf8f09cfce91701356 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 17:49:54 -0700 Subject: [PATCH 118/136] Share AM song times --- graphtool.js | 143 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 114 insertions(+), 29 deletions(-) diff --git a/graphtool.js b/graphtool.js index 6545ea8..9d98639 100644 --- a/graphtool.js +++ b/graphtool.js @@ -335,29 +335,6 @@ doc.html(`
-
-
- Pink Noise -
-
- -
-
-
-
- Tone Generator -
-
- -
-
- - 1000 Hz -
-
- -
-
Music @@ -403,6 +380,29 @@ doc.html(` accept="audio/mpeg,audio/mp4,audio/x-m4a,audio/x-aac,audio/aac,audio/wav,audio/x-wav,audio/flac,audio/ogg,audio/opus,audio/webm,.mp3,.m4a,.aac,.wav,.caf,.flac,.ogg,.opus,.webm,audio/*" />
+
+
+ Pink Noise +
+
+ +
+
+
+
+ Tone Generator +
+
+ +
+
+ + 1000 Hz +
+
+ +
+
Range @@ -2059,6 +2059,33 @@ function parseAppleMusicSongIdFromHref(href) { return null; } } +/** Normalized loop/trim range (`musicSegStartU` / `musicSegEndU`, 0–1). Omitted when full track; URL order: … `amSong`, `amIn`, `amOut`. */ +let MUSIC_URL_PARAM_IN = "amIn"; +let MUSIC_URL_PARAM_OUT = "amOut"; +let MUSIC_URL_SEG_PARSE_MIN_SPAN_U = 1e-5; +function parseAppleMusicSegmentFromHref(href) { + try { + let u = new URL(href); + let rs = u.searchParams.get(MUSIC_URL_PARAM_IN) || u.searchParams.get("amSegStart"); + let re = u.searchParams.get(MUSIC_URL_PARAM_OUT) || u.searchParams.get("amSegEnd"); + if (rs === null || rs === "" || re === null || re === "") { + return null; + } + let segStartU = parseFloat(String(rs).trim()); + let segEndU = parseFloat(String(re).trim()); + if (!Number.isFinite(segStartU) || !Number.isFinite(segEndU)) { + return null; + } + segStartU = Math.max(0, Math.min(1, segStartU)); + segEndU = Math.max(0, Math.min(1, segEndU)); + if (segEndU - segStartU < MUSIC_URL_SEG_PARSE_MIN_SPAN_U || segStartU >= segEndU) { + return null; + } + return { segStartU, segEndU }; + } catch (e) { + return null; + } +} /** `share=` payload: commas between filenames stay unescaped; each stem is encoded (spaces → "_" first). Avoids `%2C` separators from URLSearchParams. */ function shareQueryValueForUrl(namesArr) { return namesArr.map((fn) => encodeURIComponent(String(fn).replace(/ /g, "_"))).join(","); @@ -2091,9 +2118,13 @@ function addPhonesToUrl() { } catch (e) { return; } - /* Drop Apple song id first so we can re-append it after EQ/share logic (`amSong` stays last in the query string). */ + /* Drop Apple music share keys first so we can re-append after EQ/share (`amSong` then `amIn` / `amOut` at end). */ u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); u.searchParams.delete("appleMusicSong"); + u.searchParams.delete(MUSIC_URL_PARAM_IN); + u.searchParams.delete(MUSIC_URL_PARAM_OUT); + u.searchParams.delete("amSegStart"); + u.searchParams.delete("amSegEnd"); /* Never stash `share` on URLSearchParams (encodes commas as %2C). Build explicit `share=…` with literal commas instead. */ u.searchParams.delete("share"); let shareQueryPair = ""; @@ -2130,6 +2161,10 @@ function addPhonesToUrl() { } else { u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); u.searchParams.delete("appleMusicSong"); + u.searchParams.delete(MUSIC_URL_PARAM_IN); + u.searchParams.delete(MUSIC_URL_PARAM_OUT); + u.searchParams.delete("amSegStart"); + u.searchParams.delete("amSegEnd"); } let qsRest = u.searchParams.toString(); let outUrl = u.pathname + (shareQueryPair && qsRest @@ -3248,7 +3283,6 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK initReq = typeof init_phones !== "undefined" ? [init_phones].flat() : false; loadFromShare = 0; - window.__pendingEqUrlShareParsed = parseEqUrlShareParams(targetWindow.location.href); if (ifURL) { let url = targetWindow.location.href, par = "share="; @@ -9765,6 +9799,9 @@ function addExtra() { segEndU: musicSegEndU })); } catch (e) { /* quota / private mode */ } + if (typeof ifURL !== "undefined" && ifURL && musicAppleShareSongId && typeof addPhonesToUrl === "function") { + addPhonesToUrl(); + } }; let persistMusicFileToIndexedDb = (file) => { if (!window.indexedDB || !file) { @@ -13277,9 +13314,11 @@ function addExtra() { }); }; let pendingAppleSongFromUrl = window.__pendingAppleMusicCatalogSongId; + let pendingAppleSegFromUrl = window.__pendingAppleMusicSegment; if (pendingAppleSongFromUrl && typeof extraMusicEnabled !== "undefined" && extraMusicEnabled && musicPlayButton && musicCard) { window.__pendingAppleMusicCatalogSongId = null; + window.__pendingAppleMusicSegment = null; if (!window.AudioContext && !window.webkitAudioContext) { tryRestorePersistedMusic(); } else { @@ -13291,7 +13330,7 @@ function addExtra() { if (!initMusicAudioGraph()) { return; } - wireMusicLoadedFromSource(meta.previewUrl, null, { + wireMusicLoadedFromSource(meta.previewUrl, pendingAppleSegFromUrl || null, { autoPlay: true, appleCatalogSongId: pendingAppleSongFromUrl }); @@ -13301,6 +13340,7 @@ function addExtra() { }); } } else { + window.__pendingAppleMusicSegment = null; tryRestorePersistedMusic(); } } @@ -13698,7 +13738,7 @@ function addExtra() { pinkNoisePlayButton.click(); } else { let order = hasMusicSlot - ? ["pink", "tone", "music"] + ? ["music", "pink", "tone"] : ["pink", "tone"]; let idx = order.indexOf(lastEqPlaybackSource); if (idx < 0) { @@ -13727,6 +13767,9 @@ function addExtra() { toneGeneratorPlayButton.click(); } else if (lastEqPlaybackSource === "music" && musicFileLoaded && musicPlayButton) { musicPlayButton.click(); + } else if (musicFileLoaded && musicPlayButton && !pinkNoisePlaying && !toneGeneratorOsc) { + /* Pink/tone idle: prefer starting music when a track is loaded (otherwise pink). */ + musicPlayButton.click(); } else { pinkNoisePlayButton.click(); } @@ -13807,13 +13850,42 @@ function addExtra() { if (typeof extraMusicEnabled === "undefined" || !extraMusicEnabled) { u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); u.searchParams.delete("appleMusicSong"); + u.searchParams.delete(MUSIC_URL_PARAM_IN); + u.searchParams.delete(MUSIC_URL_PARAM_OUT); + u.searchParams.delete("amSegStart"); + u.searchParams.delete("amSegEnd"); + return; + } + /* Same idea as EQ share params: only keep Apple preview links on the URL while Equalizer is selected. */ + let tab = document.querySelector("div.select"); + let onExtraTab = tab && tab.getAttribute("data-selected") === "extra"; + if (!onExtraTab) { + u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); + u.searchParams.delete("appleMusicSong"); + u.searchParams.delete(MUSIC_URL_PARAM_IN); + u.searchParams.delete(MUSIC_URL_PARAM_OUT); + u.searchParams.delete("amSegStart"); + u.searchParams.delete("amSegEnd"); return; } + let fmtSegU = (x) => String(Math.round(x * 1e6) / 1e6); + let segIsCustom = musicSegStartU > 1e-5 || musicSegEndU < 1 - 1e-5; if (musicAppleShareSongId) { u.searchParams.set(MUSIC_URL_PARAM_APPLE_SONG, musicAppleShareSongId); + if (segIsCustom) { + u.searchParams.set(MUSIC_URL_PARAM_IN, fmtSegU(musicSegStartU)); + u.searchParams.set(MUSIC_URL_PARAM_OUT, fmtSegU(musicSegEndU)); + } else { + u.searchParams.delete(MUSIC_URL_PARAM_IN); + u.searchParams.delete(MUSIC_URL_PARAM_OUT); + } } else { u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); u.searchParams.delete("appleMusicSong"); + u.searchParams.delete(MUSIC_URL_PARAM_IN); + u.searchParams.delete(MUSIC_URL_PARAM_OUT); + u.searchParams.delete("amSegStart"); + u.searchParams.delete("amSegEnd"); } }; window._appendEqShareParamsToUrlSearch = (u) => { @@ -13823,8 +13895,19 @@ function addExtra() { let tab = document.querySelector("div.select"); let onEq = tab && tab.getAttribute("data-selected") === "extra"; if (!onEq) { - /* Keep EQ params already on the URL (e.g. shared links opened on Graph tab). Stripping them - here broke combined EQ + amSong loads when music sync ran before the Extra tab opened. */ + /* Drop EQ from the URL when browsing Graph/Models (restores pre-share behavior). While a shared + EQ link is still waiting for applyPendingEqUrlShare, keep params so early music/URL sync does + not strip them before the handler runs. */ + if (window.__pendingEqUrlShareParsed) { + return; + } + u.searchParams.delete("eq"); + u.searchParams.delete(EQ_URL_PARAM_MODEL); + u.searchParams.delete(EQ_URL_PARAM_TARGET); + u.searchParams.delete(EQ_URL_PARAM_FILTERS); + u.searchParams.delete("eq_model"); + u.searchParams.delete("eq_target"); + u.searchParams.delete("eq_filters"); return; } u.searchParams.delete("eq_model"); @@ -13932,7 +14015,9 @@ function addExtra() { } }; } +window.__pendingEqUrlShareParsed = parseEqUrlShareParams(targetWindow.location.href); window.__pendingAppleMusicCatalogSongId = parseAppleMusicSongIdFromHref(targetWindow.location.href); +window.__pendingAppleMusicSegment = parseAppleMusicSegmentFromHref(targetWindow.location.href); addExtra(); // Add accessories to the bottom of the page, if configured From 184e36fd921630b05d03341e81f213a02638c332 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 17:55:43 -0700 Subject: [PATCH 119/136] Fixed variants dropdown not apeparing --- graphtool.js | 42 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/graphtool.js b/graphtool.js index 9d98639..b63a5cd 100644 --- a/graphtool.js +++ b/graphtool.js @@ -2512,8 +2512,16 @@ function updatePhoneTable(trigger) { enter.merge(trJoin).select(".manage-sample-label") .text(r => !isManageMainRow(r) && r.p.activeCurves[r.sub] ? r.p.activeCurves[r.sub].id : ""); - enter.merge(trJoin).filter(isManageMainRow).select("td.item-line .phonename") - .text(r => r.eqManageDispOverride != null ? r.eqManageDispOverride : r.p.dispName); + /* Replacing `.phonename` text while the variant picker is open clears the picker DOM and leaves + `selectInProgress` true (blur may not fire) — fixes double-click + blank key/channel column. */ + enter.merge(trJoin).filter(isManageMainRow).each(function (r) { + let p = r.p; + if (p.selectInProgress) { + return; + } + d3.select(this).select("td.item-line .phonename") + .text(r.eqManageDispOverride != null ? r.eqManageDispOverride : p.dispName); + }); } function addKey(s) { @@ -3036,12 +3044,6 @@ function showPhone(p, exclusive, suppressVariant, trigger) { d3.selectAll("#phones .phone-item,.target") .filter((p) => p != null && p.id !== undefined) .call(setPhoneTr); - //Displays variant pop-up when phone displayed - if (!suppressVariant && p.fileNames && !p.copyOf && window.innerWidth > 1000) { - table.selectAll("tr").filter(r => r.p === p && (r.sub === null || r.sub === 0)).select(".variants").node().focus(); - } else { - document.activeElement.blur(); - } if (extraEnabled && extraEQEnabled && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)) { let intent = (typeof window !== "undefined" && window.eqDropdownModelIntent) ? String(window.eqDropdownModelIntent).trim() @@ -3087,6 +3089,30 @@ function showPhone(p, exclusive, suppressVariant, trigger) { sticky + dropdown reconcile (otherwise an extra target click leaves the old row up). */ updatePhoneTable(trigger); } + /* Variant picker: focus after EQ/dropdown pass so a second updatePhoneTable does not wipe picker + DOM (was breaking first open + blanking the channel cell). Same for Models tab as EQ tab. */ + if (!suppressVariant && p.fileNames && !p.copyOf) { + let openVariantPickerLater = () => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + let vNode = table.selectAll("tr") + .filter(r => r.p === p && (r.sub === null || r.sub === 0)) + .select(".variants").node(); + if (!vNode) { + return; + } + try { + vNode.focus({ preventScroll: true }); + } catch (err) { + try { + vNode.focus(); + } catch (e2) { /* noop */ } + } + }); + }); + }; + openVariantPickerLater(); + } if (p._eqNudgeApplyFromSelect && typeof window.eqOnPhoneDataReadyForEqUi === "function") { window.eqOnPhoneDataReadyForEqUi(p); p._eqNudgeApplyFromSelect = false; From 0744770f4d015d04e0decc880d48e1973f849c07 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 18:34:45 -0700 Subject: [PATCH 120/136] EQ target fixes --- graphtool.js | 217 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 186 insertions(+), 31 deletions(-) diff --git a/graphtool.js b/graphtool.js index b63a5cd..981378d 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1288,11 +1288,11 @@ function isCompensationTargetNameMatch(p) { /** Dash + stroke width per slot. `w` is the target trace stroke width in SVG user units (absolute, not added to NORMAL/SAMPLE). */ const TARGET_TRACE_DOT_SPECS = [ - { dash: "6 3", w: 2.8, cap: "butt" }, - { dash: "18 9", w: 1.25, cap: "round" }, - { dash: "2 6", w: 1.45, cap: "round" }, - { dash: "2 3", w: 1.55, cap: "round" }, - { dash: "6 4", w: 1.75, cap: "round" }, + { dash: "6 3", w: 2.5, cap: "butt" }, + { dash: "18 9", w: 1.5, cap: "round" }, + { dash: "3 6", w: 2.0, cap: "round" }, + { dash: "2 3", w: 2.0, cap: "round" }, + { dash: "8 6", w: 1.0, cap: "round" }, { dash: "10 5", w: 1.3, cap: "round" }, { dash: "14 4 2 4", w: 1.65, cap: "round" }, { dash: "1 5", w: 1.95, cap: "round" }, @@ -1912,6 +1912,16 @@ try { } let ifURL = typeof share_url !== "undefined" && share_url; +/** First `location.search` seen at startup — survives `history.replaceState` stripping EQ params before phone_book loads. */ +let eqUrlShareBootstrapSearch = ""; +try { + eqUrlShareBootstrapSearch = targetWindow && targetWindow.location + ? String(targetWindow.location.search || "") + : ""; +} catch (e) { + eqUrlShareBootstrapSearch = ""; +} +window.__eqUrlShareBootstrapSearch = eqUrlShareBootstrapSearch; let baseTitle = typeof page_title !== "undefined" ? page_title : "CrinGraph"; let baseDescription = typeof page_description !== "undefined" ? page_description : "View and compare frequency response graphs"; let baseURL; // Set by setInitPhones @@ -1995,7 +2005,9 @@ function eqShareFiltersDeserialize(s) { } )); } -/** Encode model/target fullName for `eqModel` / `eqTarget`: spaces as underscores (like `share=`), not `+`. */ +/** Encode model/target fullName for `eqModel` / `eqTarget`: `%20` → `_` for readable URLs (same idea as `share=`). + * Do not decode with a global `_`→space — names like `B_Media` need `applyPendingEqUrlShare` resolution + * (`eqResolveShareFullNameFromParam` + legacy segment match). */ function eqShareFullNameToUrlParam(fullName) { return encodeURIComponent(String(fullName || "").trim()).replace(/%20/g, "_"); } @@ -2003,7 +2015,31 @@ function eqShareUrlParamToFullName(seg) { if (seg == null || seg === "") { return ""; } - return String(seg).replace(/_/g, " "); + /* `URLSearchParams.get` already decodes `%XX`; do not map `_`→space — breaks `B_Media`-style names. */ + return String(seg).trim(); +} +/** `URLSearchParams` only percent-decodes once. Pasted / redirected links often double-encode (e.g. `%2520` + * → `%20` left inside the value); decode until no `%HH` remains or string stabilizes. */ +function eqShareFullyDecodeQueryValue(val) { + if (val == null || val === "") { + return ""; + } + let s = String(val).trim(); + for (let n = 0; n < 8; n++) { + if (!/%[0-9A-Fa-f]{2}/i.test(s)) { + return s; + } + try { + let d = decodeURIComponent(s.replace(/\+/g, " ")); + if (d === s) { + return s; + } + s = d; + } catch (e) { + return s; + } + } + return s; } /** Share URL query keys (short camelCase). Legacy snake_case still accepted when parsing. */ let EQ_URL_PARAM_MODEL = "eqModel"; @@ -2016,6 +2052,15 @@ function parseEqUrlShareParams(href) { let eqm = u.searchParams.get(EQ_URL_PARAM_MODEL) || u.searchParams.get("eq_model"); let eqt = u.searchParams.get(EQ_URL_PARAM_TARGET) || u.searchParams.get("eq_target"); let eqf = u.searchParams.get(EQ_URL_PARAM_FILTERS) || u.searchParams.get("eq_filters"); + if (eqm) { + eqm = eqShareFullyDecodeQueryValue(eqm); + } + if (eqt) { + eqt = eqShareFullyDecodeQueryValue(eqt); + } + if (eqf) { + eqf = eqShareFullyDecodeQueryValue(eqf); + } if (!eqm && !eqt && !eqf) { return null; } @@ -3308,7 +3353,16 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK inits = [], initReq = typeof init_phones !== "undefined" ? [init_phones].flat() : false; loadFromShare = 0; - + /* If early URL sync stripped the bar before pending EQ was captured, re-parse from bootstrap ?… */ + if (!window.__pendingEqUrlShareParsed && window.__eqUrlShareBootstrapSearch + && window.__eqUrlShareBootstrapSearch.length > 1) { + try { + let bootHref = targetWindow.location.origin + targetWindow.location.pathname + + window.__eqUrlShareBootstrapSearch; + window.__pendingEqUrlShareParsed = parseEqUrlShareParams(bootHref); + } catch (e) { /* noop */ } + } + if (ifURL) { let url = targetWindow.location.href, par = "share="; @@ -4393,6 +4447,33 @@ function addExtra() { } return activePhones.filter((p) => p.fullName === fullName)[0] || null; }; + /** Legacy share URLs used `encodeURIComponent(name).replace(/%20/g,"_")` — ambiguous with literal `_`. + * Resolve param string to catalog `fullName` by exact match, `_`→space, or matching legacy segment. */ + let eqLegacyShareUrlSegment = (fullName) => + encodeURIComponent(String(fullName || "").trim()).replace(/%20/g, "_"); + let eqResolveShareFullNameFromParam = (raw) => { + if (!raw) { + return ""; + } + let s = String(raw).trim(); + let hit = eqFindByFullNameAny(s); + if (hit) { + return hit.fullName; + } + let relaxed = s.replace(/_/g, " "); + hit = eqFindByFullNameAny(relaxed); + if (hit) { + return hit.fullName; + } + let pool = eqAllPhonesPool().concat(eqBrandTargetPhoneObjs()); + for (let i = 0; i < pool.length; i++) { + let p = pool[i]; + if (p && p.fullName && eqLegacyShareUrlSegment(p.fullName) === s) { + return p.fullName; + } + } + return relaxed; + }; /** Measurement (non-target, not parametric-EQ child) by full name from the full catalog or active list. */ let eqMeasurementObjForSelect = (fullName) => { if (!fullName) { @@ -7562,11 +7643,26 @@ function addExtra() { return; } let phoneSelected = eqPhoneSelect.value; + /* Dropdown omits measurements whose fullName equals the EQ model so the same IEM is not + listed twice — but when that measurement is the chosen EQ *target*, optValOk(intent) failed, + targetPick reverted, and a second click appeared to "fix" it. */ + let intentSticky = (typeof window !== "undefined" && window.eqDropdownTargetIntent) + ? String(window.eqDropdownTargetIntent).trim() + : ""; + let pendingSticky = (typeof window !== "undefined" && window._eqPendingTargetFullName) + ? String(window._eqPendingTargetFullName).trim() + : ""; + let keepMeasDespiteSameModel = (fullName) => { + if (!fullName || !phoneSelected || fullName !== phoneSelected) { + return false; + } + return fullName === intentSticky || fullName === pendingSticky; + }; let pool = eqAllPhonesPool(); let userT = eqUserCatalogTargetsForEqUi().slice(); let builtins = eqBuiltinCatalogTargetsForEqUi().slice(); let meas = pool.filter((p) => !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/) - && (!phoneSelected || p.fullName !== phoneSelected) + && (!phoneSelected || p.fullName !== phoneSelected || keepMeasDespiteSameModel(p.fullName)) && !isCompensationTargetNameMatch(p)); let byName = (a, b) => String(a.fullName).localeCompare(String(b.fullName)); userT.sort(byName); @@ -7599,7 +7695,7 @@ function addExtra() { /* Active graph measurements (same eligibility as target dropdown) listed under Active for quick target picks. */ let activeMeasQuick = activePhones.filter((p) => p && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/) - && (!phoneSelected || p.fullName !== phoneSelected) + && (!phoneSelected || p.fullName !== phoneSelected || keepMeasDespiteSameModel(p.fullName)) && !isCompensationTargetNameMatch(p)); activeMeasQuick.sort(byName); let activeMeasQuickNames = new Set(activeMeasQuick.map((p) => p.fullName)); @@ -7644,6 +7740,23 @@ function addExtra() { userSectionList.push(p); } }); + [intentSticky, pendingSticky].forEach((sticky) => { + if (!sticky) { + return; + } + let pooled = userSectionList.concat(builtins).concat(meas); + if (pooled.some((row) => row.fullName === sticky)) { + return; + } + let p = eqFindByFullNameAny(sticky); + if (!p || isCompensationTargetNameMatch(p)) { + return; + } + if (!p.isTarget && p.fullName && !String(p.fullName).match(/ EQ$/)) { + meas.push(p); + meas.sort(byName); + } + }); let allOpts = userSectionList.concat(builtins).concat(meas); let oldVal = eqPhoneTargetSelect.value; Array.from(eqPhoneTargetSelect.children).slice(1).forEach((c) => eqPhoneTargetSelect.removeChild(c)); @@ -7769,13 +7882,27 @@ function addExtra() { if (!tgt) { return; } + /* Measurement FR may load async; this block runs once data exists. Before superseding the + source measurement, commit sticky intent to `USRMT_*`. Leaving intent on the raw + measurement name caused targetPick() to fail after removePhone(meas) — dropdown reverted; + a second pick worked because synthesis had already run. */ + if (typeof window !== "undefined") { + window.eqDropdownTargetIntent = tgt.fullName; + window.eqLastGraphTargetForEq = tgt.fullName; + window._eqPendingTargetFullName = (activePhones.indexOf(tgt) === -1 + || !phoneCurveDataReadyForEq(tgt)) + ? tgt.fullName + : ""; + } + eqPhoneTargetSelect.dataset.eqLastTarget = tgt.fullName; if (activePhones.indexOf(tgt) === -1) { showPhone(tgt, 0, true, false); } - removeMeasurementIfSupersededByUserTarget(p); - if (typeof window !== "undefined") { - window.eqLastGraphTargetForEq = tgt.fullName; + if (typeof window !== "undefined" && activePhones.indexOf(tgt) !== -1 + && phoneCurveDataReadyForEq(tgt)) { + window._eqPendingTargetFullName = ""; } + removeMeasurementIfSupersededByUserTarget(p); let domVal = String(eqPhoneTargetSelect.value || "").trim(); if (domVal !== String(tgt.fullName).trim() && typeof window.updateEQPhoneSelect === "function") { window.updateEQPhoneSelect(); @@ -8050,9 +8177,20 @@ function addExtra() { if (typeof window !== "undefined") { window.eqLastGraphTargetForEq = canonTarget || ""; } - window._eqPendingTargetFullName = (toShow && !toShow.rawChannels) - ? (toShow.fullName || v) - : ""; + /* Pending must cover any target about to join the graph, not only `!rawChannels`. + USRMT and other in-memory rows skipped `_eqPending` before showPhone() finished, so + intermediate rebuilds saw no intent match in optgroups and no pending — dropdown reverted. */ + window._eqPendingTargetFullName = ""; + if (toShow && activePhones.indexOf(toShow) === -1) { + window._eqPendingTargetFullName = (toShow.fullName || v) || ""; + } + /* Before showPhone(): it calls updateEQPhoneSelect → updateEQPhoneTargetSelect, which + prefers eqDropdownTargetIntent. Setting intent after showPhone left stale intent and + the dropdown rebuilt to the previous target (second click “worked”). */ + eqPhoneTargetSelect.dataset.eqLastTarget = canonTarget; + if (typeof window !== "undefined") { + window.eqDropdownTargetIntent = canonTarget || ""; + } if (toShow && activePhones.indexOf(toShow) === -1) { if (!toShow.rawChannels) { toShow._eqNudgeApplyFromSelect = true; @@ -8061,6 +8199,9 @@ function addExtra() { window._eqModelStickyBypassForShownPhoneFullName = stickyBypass; showPhone(toShow, 0, true, false); window._eqTargetActivatedByDropdown = toShow.fullName || ""; + if (activePhones.indexOf(toShow) !== -1 && phoneCurveDataReadyForEq(toShow)) { + window._eqPendingTargetFullName = ""; + } } else { window._eqTargetActivatedByDropdown = null; window._eqModelStickyBypassForShownPhoneFullName = ""; @@ -8073,11 +8214,9 @@ function addExtra() { window._eqPendingTargetFullName = ""; if (typeof window !== "undefined") { window.eqLastGraphTargetForEq = ""; + window.eqDropdownTargetIntent = ""; } - } - eqPhoneTargetSelect.dataset.eqLastTarget = canonTarget; - if (typeof window !== "undefined") { - window.eqDropdownTargetIntent = canonTarget || ""; + eqPhoneTargetSelect.dataset.eqLastTarget = ""; } /* Measurement option value stays on the dropdown until rebuild; reconcile to User `USRMT_*` sticky. */ if (extraEnabled && extraEQEnabled && v && canonTarget && String(v).trim() !== String(canonTarget).trim()) { @@ -10739,13 +10878,20 @@ function addExtra() { || (typeof window !== "undefined" ? String(window.eqLastGraphModelForEq || "").trim() : ""); let targ = (eqPhoneTargetSelect && String(eqPhoneTargetSelect.value || "").trim()) || (typeof window !== "undefined" ? String(window.eqLastGraphTargetForEq || "").trim() : ""); - if ((model && p.fullName === model) || (targ && p.fullName === targ)) { + let intentT = (typeof window !== "undefined" && window.eqDropdownTargetIntent) + ? String(window.eqDropdownTargetIntent).trim() + : ""; + let targetRowReady = (targ && p.fullName === targ) || (intentT && p.fullName === intentT); + if ((model && p.fullName === model) || targetRowReady) { if (model && p.fullName === model) { window.eqDropdownModelIntent = ""; window._eqPendingModelFullName = ""; } - if (targ && p.fullName === targ) { + if (targetRowReady) { window._eqPendingTargetFullName = ""; + if (intentT && p.fullName === intentT) { + window.eqDropdownTargetIntent = ""; + } } cancelDeferredApplyEQ(); applyEQExec(); @@ -13970,12 +14116,21 @@ function addExtra() { window.__pendingEqUrlShareParsed = null; return; } - if (isEqConstraintGraphicModeActive()) { - window.__pendingEqUrlShareParsed = null; - return; + /* Graphic band mode conflicts with parametric eqFilters; still apply model/target from URL. */ + if (isEqConstraintGraphicModeActive() && pending.filters && pending.filters.length) { + console.warn("eqFilters in URL were skipped because graphic EQ band mode is active; model/target still apply."); + pending = { + openEqTab: pending.openEqTab, + model: pending.model, + target: pending.target, + filters: null + }; + window.__pendingEqUrlShareParsed = pending; } attempt = attempt | 0; let maxAttempts = 50; + let modelCanon = eqResolveShareFullNameFromParam(pending.model); + let targetCanon = eqResolveShareFullNameFromParam(pending.target); let ensurePhoneOnGraphForEqShare = (fullName) => { if (!fullName) { return true; @@ -13989,17 +14144,17 @@ function addExtra() { } return true; }; - if (pending.model && !ensurePhoneOnGraphForEqShare(pending.model) && attempt < maxAttempts) { + if (pending.model && !ensurePhoneOnGraphForEqShare(modelCanon) && attempt < maxAttempts) { setTimeout(() => window.applyPendingEqUrlShare(attempt + 1), 100); return; } - if (pending.target && !ensurePhoneOnGraphForEqShare(pending.target) && attempt < maxAttempts) { + if (pending.target && !ensurePhoneOnGraphForEqShare(targetCanon) && attempt < maxAttempts) { setTimeout(() => window.applyPendingEqUrlShare(attempt + 1), 100); return; } - let modelP = pending.model ? eqMeasurementObjForSelect(pending.model) : null; + let modelP = modelCanon ? eqMeasurementObjForSelect(modelCanon) : null; let modelReady = !pending.model || !!(modelP && phoneCurveDataReadyForEq(modelP)); - let targetP = pending.target ? eqFindByFullNameAny(pending.target) : null; + let targetP = targetCanon ? eqFindByFullNameAny(targetCanon) : null; let targetReady = !pending.target || !!(targetP && phoneCurveDataReadyForEq(targetP)); if ((!modelReady || !targetReady) && attempt < maxAttempts) { setTimeout(() => window.applyPendingEqUrlShare(attempt + 1), 100); @@ -14011,8 +14166,8 @@ function addExtra() { } window.__pendingEqUrlShareParsed = null; window.__eqUrlShareApplied = true; - window.eqDropdownModelIntent = pending.model ? String(pending.model) : ""; - window.eqDropdownTargetIntent = pending.target ? String(pending.target) : ""; + window.eqDropdownModelIntent = modelCanon ? String(modelCanon) : ""; + window.eqDropdownTargetIntent = targetCanon ? String(targetCanon) : ""; if (pending.openEqTab && typeof showExtraPanel === "function") { showExtraPanel(); } else if (typeof window.updateEQPhoneSelect === "function") { From b077ebf4def3ab552368d0e4bd286b2e086836e6 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 19:04:13 -0700 Subject: [PATCH 121/136] Reduce OG trace opacity --- graphtool.js | 298 +++++++++++++++++++++------------------------------ 1 file changed, 122 insertions(+), 176 deletions(-) diff --git a/graphtool.js b/graphtool.js index 981378d..4eab7f2 100644 --- a/graphtool.js +++ b/graphtool.js @@ -2250,7 +2250,14 @@ function rebindGraphPathSelectionAndRedraw() { return po; } } - return graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null); + let base = graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null); + if (c && c.p && !c.p.hide && typeof window !== "undefined" + && typeof window.__eqComposeListeningOpacityForCurve === "function" + && (c.p.eqParent || c.p.eq)) { + let b = (base == null || !Number.isFinite(base)) ? 1 : base; + return window.__eqComposeListeningOpacityForCurve(c, b); + } + return base; }) .classed("sample", c=>c.p.samp) .attr("stroke", getColor_AC).call(redrawLine); @@ -3130,6 +3137,10 @@ function showPhone(p, exclusive, suppressVariant, trigger) { if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { window.updateEQPhoneSelect(); applyParametricEqGraphTraceFocus(); + /* Parametric focus sets base opacity on paths; updatePaths() already ran updateEqTraceOpacity + earlier in showPhone — this pass must run again after applyParametric or parent/EQ A-B dims + stay cleared until something else (e.g. live A-B toggle) calls updateEqTraceOpacity. */ + updateEqTraceOpacity(); /* manageTable Eq-tab filter reads getParametricEqTraceFocusContext — must run *after* sticky + dropdown reconcile (otherwise an extra target click leaves the old row up). */ updatePhoneTable(trigger); @@ -4671,7 +4682,13 @@ function addExtra() { return undefined; } if (ctx.showSet.has(curve.p)) { - return graphPathOpacityForCurve(curve) ?? (curve.p.hide ? 0 : null); + let baseG = graphPathOpacityForCurve(curve) ?? (curve.p.hide ? 0 : null); + if (curve.p.hide) { + return 0; + } + let b = (baseG == null || !Number.isFinite(baseG)) ? 1 : baseG; + /* Compose listening A/B dimming in the join callback so rebind never flashes full opacity. */ + return eqComposeListeningOpacityForCurve(curve, b); } return 0; }; @@ -4710,7 +4727,11 @@ function addExtra() { } return; } - el.attr("opacity", graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null)); + { + let baseRaw = graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null); + let b = (baseRaw == null || !Number.isFinite(baseRaw)) ? 1 : baseRaw; + el.attr("opacity", eqComposeListeningOpacityForCurve(c, b)); + } /* Never paint the parametric EQ trace as the "target" line (gray); fallback targetP can equal eqP when the target dropdown is empty. */ if (targetP && c.p === targetP && !c.p.isTarget && c.p !== eqP) { el.classed("eq-graph-focus-target", true); @@ -4739,6 +4760,10 @@ function addExtra() { } } }); + /* This pass assigns full (or focus) opacity on every visible curve. Must run + updateEqTraceOpacity afterwards so parent vs EQ trace dimming is not reset until the next + time something remembered to call it (e.g. EQ filter edits only hit applyParametric here). */ + updateEqTraceOpacity(); }; let filtersContainer = document.querySelector("div.extra-eq > div.filters"); let fileFiltersImport = document.querySelector("#file-filters-import"); @@ -7229,33 +7254,40 @@ function addExtra() { syncEqHoverPreview(mResync); } } - return; - } - let mk = gEqFilterMarkers.selectAll("circle.eq-filter-marker") - .data(layout.rows, d => d.rowIndex); - mk.exit().remove(); - mk = mk.enter().append("circle") - .attr("class", "eq-filter-marker") - .attr("r", EQ_GRAPH_MARKER_R_BASE) - .merge(mk) - .attr("cx", d => d.cx) - .attr("cy", d => d.cy) - .attr("stroke", layout.strokeCol) - .attr("stroke-width", EQ_GRAPH_MARKER_STROKE_W); - let dragIx = eqGraphPointerState != null && eqGraphPointerState.filterIndex !== null - ? eqGraphPointerState.filterIndex - : null; - applyEqFilterMarkerFillAndSize(dragIx); - gEqSoundRangeBrush.raise(); - gEqFilterMarkers.raise(); - gEqHoverPreview.raise(); - if (!eqGraphPointerState && lastGraphPlotPointerClient) { - let lp = lastGraphPlotPointerClient; - let mResync = clientToGraphPlotXY(lp.x, lp.y); - if (mResync) { - syncEqHoverPreview(mResync); + } else { + let mk = gEqFilterMarkers.selectAll("circle.eq-filter-marker") + .data(layout.rows, d => d.rowIndex); + mk.exit().remove(); + mk = mk.enter().append("circle") + .attr("class", "eq-filter-marker") + .attr("r", EQ_GRAPH_MARKER_R_BASE) + .merge(mk) + .attr("cx", d => d.cx) + .attr("cy", d => d.cy) + .attr("stroke", layout.strokeCol) + .attr("stroke-width", EQ_GRAPH_MARKER_STROKE_W); + let dragIx = eqGraphPointerState != null && eqGraphPointerState.filterIndex !== null + ? eqGraphPointerState.filterIndex + : null; + applyEqFilterMarkerFillAndSize(dragIx); + gEqSoundRangeBrush.raise(); + gEqFilterMarkers.raise(); + gEqHoverPreview.raise(); + if (!eqGraphPointerState && lastGraphPlotPointerClient) { + let lp = lastGraphPlotPointerClient; + let mResync = clientToGraphPlotXY(lp.x, lp.y); + if (mResync) { + syncEqHoverPreview(mResync); + } } } + /* Last step in applyEQExec (after showPhone/updatePaths); syncEqPinnedParentTrace may rebind + or redrawLine paths — without this, parent/EQ opacity sometimes stayed at join defaults until + the next event (e.g. A/B toggle). */ + if (extraEnabled && extraEQEnabled) { + applyParametricEqGraphTraceFocus(); + updateEqTraceOpacity(); + } }; syncEqHoverPreview = (m) => { if (!m) { @@ -9994,6 +10026,46 @@ function addExtra() { let livePlaybackEqToggle = document.querySelector("input.live-sound-eq-toggle"); let isLivePlaybackEqEnabled = () => !livePlaybackEqToggle || livePlaybackEqToggle.checked; + /** Single source for parent vs EQ trace dimming (L/R bank, live A/B, no-audio case). `baseNum` + * is the opacity factor from graphPathOpacityForCurve / parametric focus (1 when null). */ + let eqComposeListeningOpacityForCurve = (curve, baseNum) => { + if (!curve || !curve.p || curve.p.hide) { + return 0; + } + let p = curve.p; + let b = (typeof baseNum === "number" && Number.isFinite(baseNum)) ? baseNum : 1; + let audioPlaying = pinkNoisePlaying || !!toneGeneratorOsc + || (musicAudio && !musicAudio.paused); + let eqOn = isLivePlaybackEqEnabled(); + if (p.eqParent) { + let bankDim = 1; + if (isEqTwoChannelSupportEnabled() + && eq2chActiveBank !== "both" + && LR && LR.length > 1 + && p.activeCurves && p.activeCurves.length > 1) { + let ix = p.activeCurves.indexOf(curve); + if (ix >= 0 && ix < LR.length) { + let side = LR[ix]; + if ((eq2chActiveBank === "L" && side !== "L") + || (eq2chActiveBank === "R" && side !== "R")) { + bankDim = 0.5; + } + } + } + let aud = (audioPlaying && !eqOn) ? 0.5 : 1; + let op = b * bankDim * aud; + return Math.abs(op - 1) < 1e-9 ? null : op; + } + if (p.eq) { + let dimParent = !audioPlaying || eqOn; + let op = b * (dimParent ? 0.5 : 1); + return Math.abs(op - 1) < 1e-9 ? null : op; + } + return Math.abs(b - 1) < 1e-9 ? null : b; + }; + if (typeof window !== "undefined") { + window.__eqComposeListeningOpacityForCurve = eqComposeListeningOpacityForCurve; + } let mapFilterTypeToBiquad = (t) => (t === "LSQ" ? "lowshelf" : t === "HSQ" ? "highshelf" : "peaking"); /* Same bands as live biquads; independent of the Compare toggle (used for @@ -10923,26 +10995,14 @@ function addExtra() { let isEqTrace = !!c.p.eqParent; let isParentTrace = !!c.p.eq; if (isEqTrace) { - let bankDim = 1; - if (isEqTwoChannelSupportEnabled() - && eq2chActiveBank !== "both" - && LR && LR.length > 1 - && c.p.activeCurves && c.p.activeCurves.length > 1) { - let ix = c.p.activeCurves.indexOf(c); - if (ix >= 0 && ix < LR.length) { - let side = LR[ix]; - if ((eq2chActiveBank === "L" && side !== "L") - || (eq2chActiveBank === "R" && side !== "R")) { - bankDim = 0.5; - } - } - } - let aud = (audioPlaying && !eqOn) ? 0.5 : 1; - let op = bankDim * aud; - el.attr("opacity", op === 1 ? null : op); + let base = graphPathOpacityForCurve(c); + let b = (base == null || !Number.isFinite(base)) ? 1 : base; + el.attr("opacity", eqComposeListeningOpacityForCurve(c, b)); if (stateChanged && audioPlaying && eqOn) emphTargets.push(this); } else if (isParentTrace) { - el.attr("opacity", audioPlaying && eqOn ? 0.5 : null); + let base = graphPathOpacityForCurve(c); + let b = (base == null || !Number.isFinite(base)) ? 1 : base; + el.attr("opacity", eqComposeListeningOpacityForCurve(c, b)); if (stateChanged && audioPlaying && !eqOn) emphTargets.push(this); } }); @@ -11750,6 +11810,14 @@ function addExtra() { lastGraphPlotPointerClient = null; } } + /* applyEqGraphTraceStrokeEmphasis + input focus scheduling can paint after applyEQExec; run + trace dimming on the next frame so parent vs EQ opacity does not flash full on release. */ + if (extraEnabled && extraEQEnabled && st.mode === "eq") { + requestAnimationFrame(() => { + applyParametricEqGraphTraceFocus(); + updateEqTraceOpacity(); + }); + } }; function eqGraphOnPointerLockChange() { if (document.pointerLockElement || !eqGraphPointerState) { @@ -12882,123 +12950,16 @@ function addExtra() { }; let wireMusicLoadedFromBlob = (blob, segOpt, loadOpts) => wireMusicLoadedFromSource(blob, segOpt, loadOpts); - let appleMusicCatalogBaseResolved = () => { - let b = typeof appleMusicCatalogApiBase !== "undefined" ? String(appleMusicCatalogApiBase || "").trim() : ""; - return b ? b.replace(/\/+$/, "") : "https://api.music.apple.com"; - }; - let appleMusicStorefrontResolved = () => { + let itunesStorefrontForSearch = () => { let s = typeof appleMusicStorefront !== "undefined" ? String(appleMusicStorefront || "").trim() : ""; return (s || "us").toLowerCase(); }; - let appleMusicTokenCache = { token: "", expMs: 0 }; - let decodeAppleMusicJwtExpMs = (jwt) => { - try { - let p = String(jwt || "").split("."); - if (p.length < 2) { - return 0; - } - let bod = p[1].replace(/-/g, "+").replace(/_/g, "/"); - while (bod.length % 4) { - bod += "="; - } - let payload = JSON.parse(atob(bod)); - return ((payload.exp | 0) || 0) * 1000; - } catch (e) { - return 0; - } - }; - let appleMusicFetchDeveloperToken = () => { - let url = typeof appleMusicDeveloperTokenUrl !== "undefined" ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; - if (!url) { - return Promise.reject(new Error("Apple Music developer token URL is not configured")); - } - let now = Date.now(); - if (appleMusicTokenCache.token && now < appleMusicTokenCache.expMs - 45000) { - return Promise.resolve(appleMusicTokenCache.token); - } - return fetch(url, { credentials: "omit" }).then((r) => { - if (!r.ok) { - throw new Error("Developer token endpoint HTTP " + r.status); - } - return r.text(); - }).then((text) => { - let t = String(text || "").trim(); - if (!t) { - throw new Error("Empty developer token response"); - } - appleMusicTokenCache.token = t; - let expMs = decodeAppleMusicJwtExpMs(t); - appleMusicTokenCache.expMs = expMs || (now + 50 * 60 * 1000); - return t; - }); - }; - let parseAppleMusicSearchSongsPayload = (json) => { - let out = []; - let songs = json && json.results && json.results.songs && json.results.songs.data; - if (!Array.isArray(songs)) { - return out; - } - for (let i = 0; i < songs.length; i++) { - let res = songs[i]; - let a = res && res.attributes; - if (!a) { - continue; - } - let pv = Array.isArray(a.previews) && a.previews.length ? a.previews[0].url : ""; - if (!pv) { - continue; - } - let songId = res.id != null ? String(res.id) : ""; - out.push({ id: songId, title: a.name || "", artist: a.artistName || "", previewUrl: pv }); - } - return out; - }; - let appleMusicSearchCatalog = (term) => { - let q = String(term || "").trim(); - if (!q) { - return Promise.resolve([]); - } - let base = appleMusicCatalogBaseResolved(); - let sf = appleMusicStorefrontResolved(); - let searchUrl = base + "/v1/catalog/" + encodeURIComponent(sf) + "/search?term=" - + encodeURIComponent(q) + "&types=songs&limit=12"; - return appleMusicFetchDeveloperToken().then((token) => fetch(searchUrl, { - headers: { Authorization: "Bearer " + token } - })).then((r) => { - if (!r.ok) { - throw new Error("Apple catalog search HTTP " + r.status); - } - return r.json(); - }).then(parseAppleMusicSearchSongsPayload); - }; - let appleMusicFetchPreviewBySongId = (songId) => { + /** Resolve preview + title for a shared link (`amSong`); iTunes lookup only (no MusicKit). */ + let itunesLookupPreviewByTrackId = (songId) => { let id = String(songId || "").trim(); if (!id) { return Promise.reject(new Error("empty song id")); } - let tokenUrl = typeof appleMusicDeveloperTokenUrl !== "undefined" - ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; - if (tokenUrl) { - return appleMusicFetchDeveloperToken().then((token) => { - let base = appleMusicCatalogBaseResolved(); - let sf = appleMusicStorefrontResolved(); - let url = base + "/v1/catalog/" + encodeURIComponent(sf) + "/songs/" + encodeURIComponent(id); - return fetch(url, { headers: { Authorization: "Bearer " + token } }); - }).then((r) => { - if (!r.ok) { - throw new Error("Apple catalog song HTTP " + r.status); - } - return r.json(); - }).then((json) => { - let data = json && json.data && json.data[0]; - let a = data && data.attributes; - let pv = a && Array.isArray(a.previews) && a.previews.length ? a.previews[0].url : ""; - if (!pv) { - throw new Error("no preview on catalog song"); - } - return { previewUrl: pv, title: (a && a.name) || "", artist: (a && a.artistName) || "" }; - }); - } return fetch("https://itunes.apple.com/lookup?id=" + encodeURIComponent(id) + "&entity=song", { credentials: "omit" }).then((r) => { @@ -13015,7 +12976,7 @@ function addExtra() { return { previewUrl: pv, title: r0.trackName || "", artist: r0.artistName || "" }; }); }; - /* Public iTunes Search API (no auth) — same preview URLs many demos use; not api.music.apple.com. */ + /* Public iTunes Search API (no auth). */ let parseItunesSearchSongsPayload = (json) => { let out = []; let results = json && json.results; @@ -13038,7 +12999,7 @@ function addExtra() { if (!q) { return Promise.resolve([]); } - let country = appleMusicStorefrontResolved(); + let country = itunesStorefrontForSearch(); let searchUrl = "https://itunes.apple.com/search?term=" + encodeURIComponent(q) + "&entity=song&limit=12&country=" + encodeURIComponent(country); return fetch(searchUrl, { credentials: "omit" }).then((r) => { @@ -13048,14 +13009,6 @@ function addExtra() { return r.json(); }).then(parseItunesSearchSongsPayload); }; - let applePreviewSearch = (term) => { - let tokenUrl = typeof appleMusicDeveloperTokenUrl !== "undefined" - ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; - if (tokenUrl) { - return appleMusicSearchCatalog(term); - } - return itunesSearchSongs(term); - }; if (musicPlayButton && musicSegmentSliderEl && musicSegmentTrackEl && musicSegmentSeekEl && musicSegmentHandleStart && musicSegmentHandleEnd && musicAddRemoveButton && musicFileInput && musicCard) { musicAddRemoveButton.addEventListener("click", () => { @@ -13262,7 +13215,7 @@ function addExtra() { } appleMusicSearchDebounceTimer = setTimeout(() => { appleMusicSearchDebounceTimer = null; - applePreviewSearch(v).then((rows) => { + itunesSearchSongs(v).then((rows) => { appleMusicRenderResults(rows); }).catch((err) => { console.warn(err); @@ -13274,14 +13227,7 @@ function addExtra() { msg.style.padding = "10px 16px"; msg.style.fontSize = "12px"; msg.style.lineHeight = "1.3"; - let tokenUrl = typeof appleMusicDeveloperTokenUrl !== "undefined" - ? String(appleMusicDeveloperTokenUrl || "").trim() : ""; - msg.textContent = tokenUrl - ? "Apple Music catalog search failed (often CORS from the browser). " - + "Point appleMusicCatalogApiBase at a same-origin proxy that forwards to " - + "api.music.apple.com with the Authorization header." - : "iTunes search failed (network, rate limits, or browser restrictions). " - + "Set appleMusicDeveloperTokenUrl to use Apple Music catalog search instead."; + msg.textContent = "iTunes search failed (network, rate limits, or browser restrictions)."; li.appendChild(msg); appleMusicResultsUl.appendChild(li); appleMusicResultsUl.hidden = false; @@ -13494,7 +13440,7 @@ function addExtra() { if (!window.AudioContext && !window.webkitAudioContext) { tryRestorePersistedMusic(); } else { - appleMusicFetchPreviewBySongId(pendingAppleSongFromUrl).then((meta) => { + itunesLookupPreviewByTrackId(pendingAppleSongFromUrl).then((meta) => { if (musicFileLoaded) { return; } From c97c2155c6d89311b28ed41564a89f6552ee33c6 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 19:27:05 -0700 Subject: [PATCH 122/136] Shareable uploads bruhhh --- graphtool.js | 253 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 250 insertions(+), 3 deletions(-) diff --git a/graphtool.js b/graphtool.js index 4eab7f2..c7811b0 100644 --- a/graphtool.js +++ b/graphtool.js @@ -2045,6 +2045,129 @@ function eqShareFullyDecodeQueryValue(val) { let EQ_URL_PARAM_MODEL = "eqModel"; let EQ_URL_PARAM_TARGET = "eqTarget"; let EQ_URL_PARAM_FILTERS = "eqFilters"; +let EQ_URL_PARAM_MODEL_DATA = "eqModelData"; +let EQ_URL_PARAM_TARGET_DATA = "eqTargetData"; +/** Decimated FR samples (48 points along the internal `f_values` axis) for URL-safe uploads. */ +let EQ_SHARE_FR_DECIM_STEPS = 48; +let EQ_SHARE_FR_DATA_MAX_CHARS = 16384; +/* dB·10 integers; must be wide enough for absolute SPL (e.g. 60–90 dB) from REW uploads — ±40 dB was + * clamping everything to "40.0 dB" and URLs looked flat. */ +let EQ_SHARE_FR_TENTHS_MIN = -6000; +let EQ_SHARE_FR_TENTHS_MAX = 6000; +function eqShareClampFrTenths(n) { + return Math.max(EQ_SHARE_FR_TENTHS_MIN, Math.min(EQ_SHARE_FR_TENTHS_MAX, n)); +} +function eqShareFrCurveChannelForPack(p) { + if (!p || !phoneCurveDataReadyForEq(p)) { + return null; + } + let rc = p.rawChannels; + if (!rc || !rc.length) { + return null; + } + let ch = rc.filter(Boolean)[0]; + if (!ch || ch.length < 2) { + return null; + } + /* Prefer full `f_values` grid; sparse uploads are re-interpolated like the upload path. */ + if (ch.length !== f_values.length) { + try { + ch = Equalizer.interp(f_values, ch); + } catch (e) { + return null; + } + } + return ch; +} +function eqShareDecimateFValuesSamples(fvCurve) { + let L = fvCurve.length; + let N = EQ_SHARE_FR_DECIM_STEPS; + let tenths = []; + for (let k = 0; k < N; k++) { + let ix = Math.round(k * (L - 1) / Math.max(1, N - 1)); + let db = fvCurve[ix][1]; + if (!Number.isFinite(db)) { + db = 0; + } + tenths.push(eqShareClampFrTenths(Math.round(db * 10))); + } + return tenths; +} +function eqShareFrDataSerializeFromPhone(p) { + let ch = eqShareFrCurveChannelForPack(p); + if (!ch) { + return ""; + } + let tenths = eqShareDecimateFValuesSamples(ch); + let body = "v4;" + tenths.join(","); + try { + return btoa(body).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + } catch (e) { + return ""; + } +} +function eqShareFrDataDeserializeToTenths(b64url) { + let pad = String(b64url || "").replace(/-/g, "+").replace(/_/g, "/"); + while (pad.length % 4) { + pad += "="; + } + let bin; + try { + bin = atob(pad); + } catch (e) { + return null; + } + if (bin.indexOf("v4;") !== 0) { + return null; + } + let parts = bin.slice(3).split(",").map((x) => x.trim()).filter(Boolean); + if (parts.length < 8) { + return null; + } + let raw = parts.map((x) => { + let n = parseInt(x, 10); + if (!Number.isFinite(n)) { + return 0; + } + return eqShareClampFrTenths(n); + }); + if (raw.length === EQ_SHARE_FR_DECIM_STEPS) { + return raw; + } + /* Proxies / long URLs may truncate the param — resample any reasonable count back to 48. */ + if (raw.length > 96) { + return null; + } + let out = []; + for (let k = 0; k < EQ_SHARE_FR_DECIM_STEPS; k++) { + let u = k * (raw.length - 1) / (EQ_SHARE_FR_DECIM_STEPS - 1); + let i = Math.min(Math.floor(u), raw.length - 2); + let t = u - i; + let a = raw[i]; + let b = raw[Math.min(i + 1, raw.length - 1)]; + out.push(eqShareClampFrTenths(Math.round(a + (b - a) * t))); + } + return out; +} +function eqShareExpandTenthsToFValuesChannel(tenths) { + if (!tenths || tenths.length !== EQ_SHARE_FR_DECIM_STEPS) { + return null; + } + let L = f_values.length; + let N = tenths.length; + let out = []; + for (let j = 0; j < L; j++) { + let u = (j / Math.max(1, L - 1)) * (N - 1); + let k = Math.min(Math.floor(u), N - 1); + let t = u - k; + let k1 = Math.min(k + 1, N - 1); + let d0 = tenths[k] / 10; + let d1 = tenths[k1] / 10; + let db = d0 + (d1 - d0) * t; + out.push([f_values[j], db]); + } + return out; +} /** Read Equalizer share params from a full page URL (`eqModel` / `eqTarget` / `eqFilters`; no `eq` flag). */ function parseEqUrlShareParams(href) { try { @@ -2052,6 +2175,8 @@ function parseEqUrlShareParams(href) { let eqm = u.searchParams.get(EQ_URL_PARAM_MODEL) || u.searchParams.get("eq_model"); let eqt = u.searchParams.get(EQ_URL_PARAM_TARGET) || u.searchParams.get("eq_target"); let eqf = u.searchParams.get(EQ_URL_PARAM_FILTERS) || u.searchParams.get("eq_filters"); + let eqmd = u.searchParams.get(EQ_URL_PARAM_MODEL_DATA) || u.searchParams.get("eq_model_data"); + let eqtd = u.searchParams.get(EQ_URL_PARAM_TARGET_DATA) || u.searchParams.get("eq_target_data"); if (eqm) { eqm = eqShareFullyDecodeQueryValue(eqm); } @@ -2061,7 +2186,19 @@ function parseEqUrlShareParams(href) { if (eqf) { eqf = eqShareFullyDecodeQueryValue(eqf); } - if (!eqm && !eqt && !eqf) { + if (eqmd) { + eqmd = eqShareFullyDecodeQueryValue(eqmd); + if (eqmd.length > EQ_SHARE_FR_DATA_MAX_CHARS) { + eqmd = ""; + } + } + if (eqtd) { + eqtd = eqShareFullyDecodeQueryValue(eqtd); + if (eqtd.length > EQ_SHARE_FR_DATA_MAX_CHARS) { + eqtd = ""; + } + } + if (!eqm && !eqt && !eqf && !eqmd && !eqtd) { return null; } let filters = null; @@ -2076,6 +2213,8 @@ function parseEqUrlShareParams(href) { openEqTab: true, model: eqm ? eqShareUrlParamToFullName(eqm) : "", target: eqt ? eqShareUrlParamToFullName(eqt) : "", + modelData: eqmd || "", + targetData: eqtd || "", filters: (filters && filters.length) ? filters : null }; } catch (e) { @@ -2198,8 +2337,9 @@ function addPhonesToUrl() { if (ifURL && typeof window._appendEqShareParamsToUrlSearch === "function") { window._appendEqShareParamsToUrlSearch(u); } else { - ["eq", "eqModel", "eqTarget", "eqFilters", - "eq_model", "eq_target", "eq_filters"].forEach((k) => u.searchParams.delete(k)); + ["eq", "eqModel", "eqTarget", "eqFilters", "eqModelData", "eqTargetData", + "eq_model", "eq_target", "eq_filters", "eq_model_data", "eq_target_data"].forEach( + (k) => u.searchParams.delete(k)); } if (ifURL && typeof window._appendMusicShareParamsToUrlSearch === "function") { window._appendMusicShareParamsToUrlSearch(u); @@ -4499,6 +4639,73 @@ function addExtra() { return activePhones.filter((p) => p.fullName === fullName && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/))[0] || null; }; + let eqPhoneWantsInlineFrInShareUrl = (p) => + !!(p && p.isDynamic && phoneCurveDataReadyForEq(p) + && (p.isTarget || (p.brand && p.brand.name === "Uploaded"))); + let eqInjectFrFromUrlDataIntoModel = (modelFullNameStr, dataB64) => { + let tenths = eqShareFrDataDeserializeToTenths(dataB64); + if (!tenths) { + return false; + } + let ch = eqShareExpandTenthsToFValuesChannel(tenths); + if (!ch) { + return false; + } + /* eqShareFullNameToUrlParam replaces %20 with "_" so the query value may read + * "Uploaded_Moondrop_…" — same underscore→space trick as eqResolveShareFullNameFromParam. */ + let fn = String(modelFullNameStr || "").trim().replace(/_/g, " "); + if (!fn || !/^Uploaded\s+/i.test(fn)) { + return false; + } + let stem = fn.replace(/^Uploaded\s+/i, "").trim() || "Upload"; + let phoneObj = addOrUpdatePhone(brandMap.Uploaded, { name: stem }, [ch]); + showPhone(phoneObj, false); + return true; + }; + let eqInjectFrFromUrlDataIntoTarget = (targetFullNameStr, dataB64) => { + let tenths = eqShareFrDataDeserializeToTenths(dataB64); + if (!tenths) { + return false; + } + let ch = eqShareExpandTenthsToFValuesChannel(tenths); + if (!ch) { + return false; + } + let fullName = String(targetFullNameStr || "").trim().replace(/_/g, " "); + if (!fullName) { + return false; + } + let bt = typeof window !== "undefined" ? window.brandTarget : null; + if (!bt || !Array.isArray(bt.phoneObjs)) { + return false; + } + let base = fullName.replace(/\s+Target$/i, "").trim() || "Target"; + let existing = bt.phoneObjs.filter((q) => q && q.fullName === fullName)[0]; + if (existing) { + if (!existing.isDynamic && !existing.userTargetFromMeasurement) { + console.warn("eqTargetData skipped: \"" + fullName + "\" is a built-in catalog target."); + return false; + } + existing.rawChannels = [ch]; + existing.isDynamic = true; + showPhone(existing, true); + } else { + let row = { + isTarget: true, + brand: bt, + dispName: base, + phone: base, + fullName: fullName, + fileName: fullName, + rawChannels: [ch], + isDynamic: true, + id: -bt.phoneObjs.length + }; + bt.phoneObjs.push(row); + showPhone(row, true); + } + return true; + }; /** EQ model row: explicit model dropdown match, else first graphed IEM (not target, not "* EQ" child name). */ let resolveEqModelPhone = () => { let sel = eqPhoneSelect && String(eqPhoneSelect.value || "").trim(); @@ -14023,9 +14230,13 @@ function addExtra() { u.searchParams.delete(EQ_URL_PARAM_MODEL); u.searchParams.delete(EQ_URL_PARAM_TARGET); u.searchParams.delete(EQ_URL_PARAM_FILTERS); + u.searchParams.delete(EQ_URL_PARAM_MODEL_DATA); + u.searchParams.delete(EQ_URL_PARAM_TARGET_DATA); u.searchParams.delete("eq_model"); u.searchParams.delete("eq_target"); u.searchParams.delete("eq_filters"); + u.searchParams.delete("eq_model_data"); + u.searchParams.delete("eq_target_data"); return; } u.searchParams.delete("eq_model"); @@ -14041,6 +14252,32 @@ function addExtra() { } else { u.searchParams.delete(EQ_URL_PARAM_TARGET); } + let modelPForShare = eqPhoneSelect && eqPhoneSelect.value + ? eqMeasurementObjForSelect(String(eqPhoneSelect.value).trim()) + : null; + if (modelPForShare && eqPhoneWantsInlineFrInShareUrl(modelPForShare) && !modelPForShare.isTarget) { + let s = eqShareFrDataSerializeFromPhone(modelPForShare); + if (s) { + u.searchParams.set(EQ_URL_PARAM_MODEL_DATA, s); + } else { + u.searchParams.delete(EQ_URL_PARAM_MODEL_DATA); + } + } else { + u.searchParams.delete(EQ_URL_PARAM_MODEL_DATA); + } + let targetPForShare = eqPhoneTargetSelect && eqPhoneTargetSelect.value + ? eqFindByFullNameAny(String(eqPhoneTargetSelect.value).trim()) + : null; + if (targetPForShare && eqPhoneWantsInlineFrInShareUrl(targetPForShare) && targetPForShare.isTarget) { + let s = eqShareFrDataSerializeFromPhone(targetPForShare); + if (s) { + u.searchParams.set(EQ_URL_PARAM_TARGET_DATA, s); + } else { + u.searchParams.delete(EQ_URL_PARAM_TARGET_DATA); + } + } else { + u.searchParams.delete(EQ_URL_PARAM_TARGET_DATA); + } if (isEqTwoChannelSupportEnabled()) { u.searchParams.delete(EQ_URL_PARAM_FILTERS); return; @@ -14069,10 +14306,20 @@ function addExtra() { openEqTab: pending.openEqTab, model: pending.model, target: pending.target, + modelData: pending.modelData, + targetData: pending.targetData, filters: null }; window.__pendingEqUrlShareParsed = pending; } + let pModelData = pending.modelData || ""; + let pTargetData = pending.targetData || ""; + if (pModelData && pending.model) { + eqInjectFrFromUrlDataIntoModel(pending.model, pModelData); + } + if (pTargetData && pending.target) { + eqInjectFrFromUrlDataIntoTarget(pending.target, pTargetData); + } attempt = attempt | 0; let maxAttempts = 50; let modelCanon = eqResolveShareFullNameFromParam(pending.model); From 02023a82e50d0ee3798a5de2f3ebe20bb9b75075 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 19:30:34 -0700 Subject: [PATCH 123/136] Update URL when trace uploaded --- graphtool.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/graphtool.js b/graphtool.js index c7811b0..ee8b213 100644 --- a/graphtool.js +++ b/graphtool.js @@ -4436,6 +4436,18 @@ function addExtra() { } showPhone(phoneObj, true); } + if (eqTabActive) { + /* Upload should update EQ share URL immediately, without waiting for a filter edit. + Rebuild EQ selects first so _appendEqShareParams sees the new model/target values. */ + requestAnimationFrame(() => { + if (typeof window.updateEQPhoneSelect === "function") { + window.updateEQPhoneSelect(); + } + if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { + addPhonesToUrl(); + } + }); + } } finally { /* Same path selected twice does not fire `change` unless the input is cleared. */ fileFR.value = ""; From 1e85795d9d8cabd870dfcaa71f20dfb8a1e093ac Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Wed, 6 May 2026 19:48:17 -0700 Subject: [PATCH 124/136] Skip init phones when loading EQ vals --- graphtool.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/graphtool.js b/graphtool.js index ee8b213..8f8e468 100644 --- a/graphtool.js +++ b/graphtool.js @@ -3538,9 +3538,17 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK setModeEmbed(); } } + let eqShareInitOnly = !!(ifURL && window.__pendingEqUrlShareParsed); + if (eqShareInitOnly) { + /* EQ share links should bootstrap from URL params only; skip config `init_phones` + so old defaults do not pre-populate and fight pending EQ model/target apply. */ + initReq = []; + } // Apply user config to inits - userConfigAppendInits(initReq); + if (!eqShareInitOnly) { + userConfigAppendInits(initReq); + } setInitPhoneOrderFromReq(Array.isArray(initReq) ? initReq : null); let isInit = initReq ? f => initReq.indexOf(f) !== -1 From 2e76ffb42a298d10902f12c0f14b38d68a08eb93 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 7 May 2026 12:53:10 -0700 Subject: [PATCH 125/136] Fix: Constraints were acting as a low-pass filter --- graphtool.js | 56 +++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/graphtool.js b/graphtool.js index 8f8e468..b1e82f4 100644 --- a/graphtool.js +++ b/graphtool.js @@ -8562,10 +8562,19 @@ function addExtra() { eqHistoryCommitTransaction(undefined, { historyEntry: { kind: "reset" } }); }; document.querySelector("div.extra-eq button.extra-eq-reset-btn").addEventListener("click", () => { - if (!window.confirm("Reset all EQ band values (frequency, Q, and gain) to flat? Constraint presets and limits stay as they are.")) { - return; - } - resetParametricEqFilterValuesOnly(); + /* iOS suspends media during sync `confirm()`; capture intent first, defer dialog one frame, then + resume WebAudio / HTMLMediaElement after dismiss so UI and playback stay aligned. */ + let wasMusicPlaying = !!(musicFileLoaded && musicAudio && !musicAudio.paused); + let wasPinkActive = !!pinkNoisePlaying; + let wasToneActive = !!toneGeneratorOsc; + requestAnimationFrame(() => { + if (!window.confirm("Reset all EQ band values (frequency, Q, and gain) to flat? Constraint presets and limits stay as they are.")) { + resumeLiveSoundAfterSyncNativeDialog(wasMusicPlaying, wasPinkActive, wasToneActive); + return; + } + resetParametricEqFilterValuesOnly(); + resumeLiveSoundAfterSyncNativeDialog(wasMusicPlaying, wasPinkActive, wasToneActive); + }); }); // Add new filter document.querySelector("div.extra-eq button.add-filter").addEventListener("click", () => { @@ -10490,8 +10499,13 @@ function addExtra() { }; let liveSoundBandDatasetRoot = () => document.querySelector("div.live-sound-tools div.live-sound-band"); + /* Sound Tools playback band (HP/LP) must stay full-range unless the user trims it in the Range + * fields — not the parametric EQ constraint min/max (those only govern filter rows / AutoEQ). */ + const LIVE_SOUND_BAND_HZ_MIN = 20; + const LIVE_SOUND_BAND_HZ_MAX = 20000; function normalizeLiveSoundIntervalPair(lo, hi) { - let [fLo, fHi] = getEqConstraintFreqLoHi(); + let fLo = LIVE_SOUND_BAND_HZ_MIN; + let fHi = LIVE_SOUND_BAND_HZ_MAX; lo = Math.round(Math.min(fHi, Math.max(fLo, lo))); hi = Math.round(Math.min(fHi, Math.max(fLo, hi))); if (hi <= lo) { @@ -13104,6 +13118,23 @@ function addExtra() { updateEqTraceOpacity(); }); }; + /** After sync `alert`/`confirm`, iOS often leaves `HTMLMediaElement` paused and AudioContexts suspended. */ + let resumeLiveSoundAfterSyncNativeDialog = (wantMusic, wantPink, wantTone) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (wantMusic && musicFileLoaded && musicAudio && musicContext) { + startMusicPlayback().catch(() => {}); + } + if (wantPink && pinkNoisePlaying && pinkNoiseContext && pinkNoiseContext.state === "suspended") { + void pinkNoiseContext.resume(); + } + if (wantTone && toneGeneratorOsc && toneGeneratorContext + && toneGeneratorContext.state === "suspended") { + void toneGeneratorContext.resume(); + } + }); + }); + }; let wireMusicLoadedFromSource = (src, segOpt, loadOpts) => { loadOpts = loadOpts || {}; let autoPlayAfterLoad = loadOpts.autoPlay === true; @@ -13181,15 +13212,22 @@ function addExtra() { let s = typeof appleMusicStorefront !== "undefined" ? String(appleMusicStorefront || "").trim() : ""; return (s || "us").toLowerCase(); }; + /** iTunes API sometimes returns a cached ACAO for a different origin; add nonce + no-store + to avoid cross-origin cache poisoning between localhost/staging/prod hosts. */ + let itunesUrlNoCache = (url) => { + let u = new URL(url); + u.searchParams.set("_", Date.now().toString(36) + Math.random().toString(36).slice(2, 7)); + return u.href; + }; /** Resolve preview + title for a shared link (`amSong`); iTunes lookup only (no MusicKit). */ let itunesLookupPreviewByTrackId = (songId) => { let id = String(songId || "").trim(); if (!id) { return Promise.reject(new Error("empty song id")); } - return fetch("https://itunes.apple.com/lookup?id=" + encodeURIComponent(id) + "&entity=song", { - credentials: "omit" - }).then((r) => { + let lookupUrl = itunesUrlNoCache( + "https://itunes.apple.com/lookup?id=" + encodeURIComponent(id) + "&entity=song"); + return fetch(lookupUrl, { credentials: "omit", cache: "no-store" }).then((r) => { if (!r.ok) { throw new Error("iTunes lookup HTTP " + r.status); } @@ -13229,7 +13267,7 @@ function addExtra() { let country = itunesStorefrontForSearch(); let searchUrl = "https://itunes.apple.com/search?term=" + encodeURIComponent(q) + "&entity=song&limit=12&country=" + encodeURIComponent(country); - return fetch(searchUrl, { credentials: "omit" }).then((r) => { + return fetch(itunesUrlNoCache(searchUrl), { credentials: "omit", cache: "no-store" }).then((r) => { if (!r.ok) { throw new Error("iTunes search HTTP " + r.status); } From 3adc57bddafb72652d5bab5a13d1a63c8199ec19 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 7 May 2026 13:06:49 -0700 Subject: [PATCH 126/136] Fixed player order --- graphtool.js | 105 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 43 deletions(-) diff --git a/graphtool.js b/graphtool.js index b1e82f4..a092ee3 100644 --- a/graphtool.js +++ b/graphtool.js @@ -4334,6 +4334,26 @@ function addExtra() { if (typeof extraMusicEnabled !== "undefined" && !extraMusicEnabled) { document.querySelector("div.extra-panel div.extra-music").style["display"] = "none"; } + /** Space toggles this source; Shift+Space cycles Music → Pink → Tone (skips Music if no track). */ + let activeLiveSoundPlayer = "pink"; + let liveSoundPlayersCycleOrder = () => { + let hasMusic = !!(typeof musicFileLoaded !== "undefined" && musicFileLoaded + && typeof musicPlayButton !== "undefined" && musicPlayButton + && typeof musicAudio !== "undefined" && musicAudio); + return hasMusic ? ["music", "pink", "tone"] : ["pink", "tone"]; + }; + let ensureActiveLiveSoundPlayerValid = () => { + let order = liveSoundPlayersCycleOrder(); + if (order.indexOf(activeLiveSoundPlayer) < 0) { + activeLiveSoundPlayer = order[0]; + } + }; + let cycleActiveLiveSoundPlayerShiftSpace = () => { + ensureActiveLiveSoundPlayerValid(); + let order = liveSoundPlayersCycleOrder(); + let i = Math.max(0, order.indexOf(activeLiveSoundPlayer)); + activeLiveSoundPlayer = order[(i + 1) % order.length]; + }; // Show and hide extra panel window.showExtraPanel = () => { document.querySelector("div.select > div.selector-panel").style["display"] = "none"; @@ -4354,6 +4374,13 @@ function addExtra() { if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { addPhonesToUrl(); } + if (typeof musicFileLoaded !== "undefined" && musicFileLoaded + && typeof musicPlayButton !== "undefined" && musicPlayButton + && typeof musicAudio !== "undefined" && musicAudio) { + activeLiveSoundPlayer = "music"; + } else { + ensureActiveLiveSoundPlayerValid(); + } }; extraButton.addEventListener("click", showExtraPanel); // Upload function @@ -12643,6 +12670,7 @@ function addExtra() { pinkNoiseAnalyser.connect(pinkNoiseContext.destination); pinkNoisePlayButton.classList.add("playback-active"); lastEqPlaybackSource = "pink"; + activeLiveSoundPlayer = "pink"; if (pinkNoiseContext.state !== "running") { void pinkNoiseContext.resume(); } @@ -12713,6 +12741,7 @@ function addExtra() { toneGeneratorMasterGain.gain.linearRampToValueAtTime(liveToneGeneratorPlaybackGain, tA + TONE_GEN_FADE_IN_SEC); toneGeneratorPlayButton.classList.add("playback-active"); lastEqPlaybackSource = "tone"; + activeLiveSoundPlayer = "tone"; if (toneGeneratorContext.state !== "running") { void toneGeneratorContext.resume(); } @@ -13013,6 +13042,7 @@ function addExtra() { if (lastEqPlaybackSource === "music") { lastEqPlaybackSource = "pink"; } + ensureActiveLiveSoundPlayerValid(); musicSpectrumViz.syncSpectrumViz(); if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { addPhonesToUrl(); @@ -13114,10 +13144,25 @@ function addExtra() { return musicAudio.play().then(() => { musicPlayButton.classList.add("playback-active"); lastEqPlaybackSource = "music"; + activeLiveSoundPlayer = "music"; musicSpectrumViz.syncSpectrumViz(); updateEqTraceOpacity(); }); }; + /** Shift+Space: advance active slot in the cycle, stop every live source, then play the newly active one. */ + let shiftSpaceAdvanceLiveSoundAndPlay = () => { + cycleActiveLiveSoundPlayerShiftSpace(); + pauseMusicForLiveSoundSwitch(); + stopPinkNoisePlayback(); + fadeStopToneGeneratorPlayback(); + if (activeLiveSoundPlayer === "music" && musicFileLoaded && musicAudio && musicContext && musicPlayButton) { + startMusicPlayback().catch(() => {}); + } else if (activeLiveSoundPlayer === "tone" && toneGeneratorPlayButton) { + void startToneGeneratorOscillatorIfStopped(); + } else if (pinkNoisePlayButton) { + pinkNoisePlayButton.click(); + } + }; /** After sync `alert`/`confirm`, iOS often leaves `HTMLMediaElement` paused and AudioContexts suspended. */ let resumeLiveSoundAfterSyncNativeDialog = (wantMusic, wantPink, wantTone) => { requestAnimationFrame(() => { @@ -13175,6 +13220,7 @@ function addExtra() { } musicAudio.load(); musicFileLoaded = true; + activeLiveSoundPlayer = "music"; musicCard.classList.add("music-file-loaded"); if (musicPlaybackPanel) { musicPlaybackPanel.setAttribute("aria-hidden", "false"); @@ -13532,6 +13578,7 @@ function addExtra() { if (!musicFileLoaded || !musicAudio || !musicContext) { return; } + activeLiveSoundPlayer = "music"; if (musicAudio.paused) { startMusicPlayback().catch(() => { alert("Playback could not be started."); @@ -14105,55 +14152,27 @@ function addExtra() { if (e.shiftKey) { e.preventDefault(); lastToneSpaceKeydownTime = 0; - let hasMusicSlot = musicFileLoaded && musicPlayButton && musicAudio; - let playingPink = pinkNoisePlaying; - let playingTone = !!toneGeneratorOsc; - let playingMusic = hasMusicSlot && !musicAudio.paused; - if (playingPink) { - toneGeneratorPlayButton.click(); - } else if (playingTone) { - if (hasMusicSlot) { - musicPlayButton.click(); - } else { - pinkNoisePlayButton.click(); - } - } else if (playingMusic) { - pinkNoisePlayButton.click(); - } else { - let order = hasMusicSlot - ? ["music", "pink", "tone"] - : ["pink", "tone"]; - let idx = order.indexOf(lastEqPlaybackSource); - if (idx < 0) { - idx = 0; - } - let next = order[(idx + 1) % order.length]; - if (next === "pink") { - pinkNoisePlayButton.click(); - } else if (next === "tone") { - toneGeneratorPlayButton.click(); - } else { - musicPlayButton.click(); - } - } + shiftSpaceAdvanceLiveSoundAndPlay(); return; } e.preventDefault(); - let now = performance.now(); - if (lastToneSpaceKeydownTime > 0 && now - lastToneSpaceKeydownTime < toneSpaceDoubleMs) { + ensureActiveLiveSoundPlayerValid(); + if (activeLiveSoundPlayer === "tone") { + let now = performance.now(); + if (lastToneSpaceKeydownTime > 0 && now - lastToneSpaceKeydownTime < toneSpaceDoubleMs) { + lastToneSpaceKeydownTime = 0; + startToneGeneratorSweep(); + return; + } + lastToneSpaceKeydownTime = now; + } else { lastToneSpaceKeydownTime = 0; - startToneGeneratorSweep(); - return; } - lastToneSpaceKeydownTime = now; - if (lastEqPlaybackSource === "tone") { - toneGeneratorPlayButton.click(); - } else if (lastEqPlaybackSource === "music" && musicFileLoaded && musicPlayButton) { + if (activeLiveSoundPlayer === "music" && musicFileLoaded && musicPlayButton && musicAudio) { musicPlayButton.click(); - } else if (musicFileLoaded && musicPlayButton && !pinkNoisePlaying && !toneGeneratorOsc) { - /* Pink/tone idle: prefer starting music when a track is loaded (otherwise pink). */ - musicPlayButton.click(); - } else { + } else if (activeLiveSoundPlayer === "tone" && toneGeneratorPlayButton) { + toneGeneratorPlayButton.click(); + } else if (pinkNoisePlayButton) { pinkNoisePlayButton.click(); } }); From c5f0af1e7933fcb2a3156441dec7f4284d35dda6 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 7 May 2026 13:11:56 -0700 Subject: [PATCH 127/136] Fixed space bar jank on buttons --- graphtool.js | 41 +++++++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/graphtool.js b/graphtool.js index a092ee3..e6d2700 100644 --- a/graphtool.js +++ b/graphtool.js @@ -14123,9 +14123,6 @@ function addExtra() { e.preventDefault(); } } - if (e.repeat) { - return; - } let selectEl = document.querySelector("div.select"); if (!selectEl || selectEl.getAttribute("data-selected") !== "extra") { return; @@ -14137,25 +14134,41 @@ function addExtra() { && t.matches("input[name='eq-constraint-freq-min'], input[name='eq-constraint-freq-max'], input[name='eq-constraint-freq-graphic-list']")) { return; } - if (t.closest && t.closest("div.extra-panel button") && !t.closest("button.play")) { - if (t.closest("button.extra-eq-constraints-gear") || t.closest("button.live-sound-tools-settings-gear")) { - /* Gear keeps focus after open/close; native Space would toggle the panel instead of play/pause. */ - } else if (t.closest("button.extra-eq-reset-btn") || t.closest("button.live-sound-range-reset-btn")) { - /* Same as gear: keep global Space → play/pause; avoid trapping focus on reset. */ - } else if (t.closest && t.closest("button.music-add-remove") && musicFileLoaded) { - e.preventDefault(); - } else { - return; + if (t && t.nodeType === 1 && typeof t.closest === "function") { + let panelBtn = t.closest("div.extra-panel button:not(.play)"); + if (panelBtn) { + let spaceControlsPlayback = + panelBtn.matches( + ".extra-eq-constraints-gear," + + ".live-sound-tools-settings-gear," + + ".extra-eq-reset-btn," + + ".live-sound-range-reset-btn," + + "button.autoeq," + + ".upload-fr," + + ".upload-target," + + ".import-filters," + + ".export-filters," + + ".export-graphic-filters") + || (panelBtn.matches("button.music-add-remove") && musicFileLoaded); + if (panelBtn.matches("button.music-add-remove") && musicFileLoaded) { + e.preventDefault(); + } + if (!spaceControlsPlayback) { + return; + } } } + /* Must run for key repeat too, or a held Space re-triggers native button activation / scroll. */ + e.preventDefault(); + if (e.repeat) { + return; + } resumeAudioContextsFromUserGesture(); if (e.shiftKey) { - e.preventDefault(); lastToneSpaceKeydownTime = 0; shiftSpaceAdvanceLiveSoundAndPlay(); return; } - e.preventDefault(); ensureActiveLiveSoundPlayerValid(); if (activeLiveSoundPlayer === "tone") { let now = performance.now(); From f2d9f8a212498bbe53198b9150405c5e698113ec Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 7 May 2026 13:19:33 -0700 Subject: [PATCH 128/136] Reset EQ when EQ trace is removed --- graphtool.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/graphtool.js b/graphtool.js index e6d2700..dc8adb3 100644 --- a/graphtool.js +++ b/graphtool.js @@ -3329,8 +3329,15 @@ function removeCopies(p) { removePhone(p); } -function removePhone(p) { +function removePhone(p, opts) { + opts = opts || {}; let hadEqChild = Boolean(!p.isTarget && p.eq); + /* Removing the EQ curve row (X on manage table) clears p.eqParent here before hadEqChild-style + cleanup; that path did not run eqResetParametricAfterBaseModelRemoved — filters stayed stale. + Skip when addOrUpdatePhone() replaces the same synthetic "… EQ" row during applyEQExec (old + object still has eqParent → would wrongly full-reset parametric UI). */ + let removingDedicatedEqTrace = !opts.internalEqPhoneReplace + && Boolean(!p.isTarget && p.eqParent); /* Bump load generation so any in-flight loadFiles() for this pool object bails before calling showPhone() — avoids the previous EQ model flashing back when its fetch completes after the user switched away. */ @@ -3379,7 +3386,8 @@ function removePhone(p) { .call(setPhoneTr); if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { window.updateEQPhoneSelect(); - if (hadEqChild && typeof window.eqResetParametricAfterBaseModelRemoved === "function") { + if ((hadEqChild || removingDedicatedEqTrace) + && typeof window.eqResetParametricAfterBaseModelRemoved === "function") { /* EQ model dropdown: removing the previous base model already ran filter reset + apply in the select handler. eqReset would run applyEQ again, clear eqDropdownModelIntent, and produce an extra OG frame — targets never hit this path (hadEqChild is false). */ @@ -4401,7 +4409,7 @@ function addExtra() { let phoneObjs = brand.phoneObjs; let oldPhoneObj = phoneObjs.filter(p => p.phone == phone.name)[0] if (oldPhoneObj) { - oldPhoneObj.active && removePhone(oldPhoneObj); + oldPhoneObj.active && removePhone(oldPhoneObj, { internalEqPhoneReplace: true }); phoneObj.id = oldPhoneObj.id; phoneObjs[phoneObjs.indexOf(oldPhoneObj)] = phoneObj; allPhones[allPhones.indexOf(oldPhoneObj)] = phoneObj; From b1e674195527357bca5e5646d819035f84d9c695 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Thu, 7 May 2026 13:39:00 -0700 Subject: [PATCH 129/136] Fixed: EQ reset with constraints presets --- graphtool.js | 50 +++++++++++++++++++++++--------------------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/graphtool.js b/graphtool.js index dc8adb3..3f58f44 100644 --- a/graphtool.js +++ b/graphtool.js @@ -5254,8 +5254,10 @@ function addExtra() { return out; }; let isEqConstraintGraphicModeActive = () => { + let row = document.querySelector("div.extra-eq .eq-constraint-freq-row"); + let uiGraphic = !!(row && row.classList.contains("eq-constraint-freq-row-graphic")); let bands = Equalizer.config.EqGraphicBandFreqHz; - return Array.isArray(bands) && bands.length >= 2; + return uiGraphic && Array.isArray(bands) && bands.length >= 2; }; let applyEqConstraintFreqRowUiMode = () => { let row = document.querySelector("div.extra-eq .eq-constraint-freq-row"); @@ -5597,7 +5599,7 @@ function addExtra() { let freq = parseInt(filterFreqInput[i].value) || 0; let q = parseFloat(filterQInput[i].value) || 0; let gain = parseFloat(filterGainInput[i].value) || 0; - if (!includeAll && (disabled || !type || !freq || !q || !gain)) { + if (!includeAll && (disabled || !type || !freq || !q)) { continue; } filters.push({ disabled, type, freq, q, gain }); @@ -6470,7 +6472,7 @@ function addExtra() { }, true); let eq2chRowsToApplySpecs = (rows) => { let clamped = elemToFiltersClampedRowsForEqualizerApply(eq2chPadBankToEqBands(rows), true); - return clamped.filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + return clamped.filter((f) => !f.disabled && f.type && f.freq && f.q) .map((f) => ({ type: f.type, freq: f.freq, q: f.q, gain: f.gain })); }; let eq2chMergedSpecsForChannelIndex = (chIdx) => { @@ -6778,7 +6780,12 @@ function addExtra() { opts = opts || {}; let skipApply = !!opts.skipApply; let bands = Equalizer.config.EqGraphicBandFreqHz; - let graphic = Array.isArray(bands) && bands.length >= 2; + let freqRowEl = document.querySelector("div.extra-eq .eq-constraint-freq-row"); + let uiShowsGraphicBands = !!(freqRowEl + && freqRowEl.classList.contains("eq-constraint-freq-row-graphic")); + /* Config alone is not enough: EqGraphicBandFreqHz can stay set after switching the UI back + to parametric min/max — then every constraint edit refills bands from the old template. */ + let graphic = uiShowsGraphicBands && Array.isArray(bands) && bands.length >= 2; let maxBandsEl = document.querySelector("div.extra-eq input[name='eq-constraint-max-bands']"); let qMinEl = document.querySelector("div.extra-eq input[name='eq-constraint-q-min']"); if (!maxBandsEl || !qMinEl) { @@ -6895,6 +6902,11 @@ function addExtra() { let prevG = Equalizer.config.OptimizeGainRange ? Equalizer.config.OptimizeGainRange.slice() : [-40, 40]; let freqRow = document.querySelector("div.extra-eq .eq-constraint-freq-row"); let gListInp = document.querySelector("div.extra-eq input[name='eq-constraint-freq-graphic-list']"); + /* Parametric frequency row: drop stale graphic band list so AutoEQRange follows min/max again + and applyEqGraphicModeAuxUiAndBands does not treat us as graphic-EQ mode. */ + if (freqRow && !freqRow.classList.contains("eq-constraint-freq-row-graphic")) { + Equalizer.config.EqGraphicBandFreqHz = null; + } if (freqRow && freqRow.classList.contains("eq-constraint-freq-row-graphic") && gListInp) { let rawG = (gListInp.value || "").trim(); let listG = parseEqConstraintGraphicFreqList(rawG); @@ -7239,7 +7251,7 @@ function addExtra() { let freq = parseInt(filterFreqInput[i].value, 10) || 0; let q = parseFloat(filterQInput[i].value) || 0; let gain = parseFloat(filterGainInput[i].value) || 0; - if (disabled || !type || !freq || !q || !gain) { + if (disabled || !type || !freq || !q) { continue; } let typeTrim = (type || "").trim(); @@ -7327,7 +7339,7 @@ function addExtra() { let pinnedBankRowsToApplySpecs = (bankRows, bandCount) => { let padded = eq2chPadPinnedBankRowsForGhost(bankRows, bandCount); let clamped = elemToFiltersClampedRowsForEqualizerApply(padded, true); - return clamped.filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + return clamped.filter((f) => !f.disabled && f.type && f.freq && f.q) .map((f) => ({ type: f.type, freq: f.freq, q: f.q, gain: f.gain })); }; let eq2chMergedPinnedSpecs = (banks, chIdx, bandCount) => { @@ -7361,7 +7373,7 @@ function addExtra() { rows = eqHistoryPadSnapRows({ rows: pin.rows || [], bandCount: pinBc }, pinBc); } return elemToFiltersClampedRowsForEqualizerApply(rows, false) - .filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + .filter((f) => !f.disabled && f.type && f.freq && f.q) .map((f) => ({ type: f.type, freq: Math.min(20000, Math.max(20, f.freq)), @@ -7404,7 +7416,7 @@ function addExtra() { freq: r.freq, q: r.q, gain: r.gain - })), true).filter((f) => !f.disabled && f.type && f.freq && f.q && f.gain) + })), true).filter((f) => !f.disabled && f.type && f.freq && f.q) .map((f) => ({ type: f.type, freq: f.freq, q: f.q, gain: f.gain })); if (!specs.length) { return null; @@ -9008,28 +9020,12 @@ function addExtra() { } return null; }; - let zeroEqFilterBandRowInputsIfNotUserEdited = () => { - if (eqFiltersUserHasEdited || !filterFreqInput || !filterFreqInput.length) { - return; - } - for (let i = 0; i < eqBands; i++) { - filterEnabledInput[i].checked = true; - filterTypeSelect[i].value = "PK"; - filterFreqInput[i].value = "0"; - filterGainInput[i].value = "0"; - filterQInput[i].value = "0"; - } - applyEqConstraintAttributesToFilterInputs(); - refreshEqFilterConstraintViolationStyles(); - }; let applyEqConstraintPreset = (preset) => { if (!preset || typeof preset !== "object") { return; } - /* filtersToElem (e.g. after default preset on load) sets this true; graphic presets must still - run eqGraphicModeApplyAutoTemplateFromBands, which skips entirely while the flag is set. */ - eqFiltersUserHasEdited = false; - zeroEqFilterBandRowInputsIfNotUserEdited(); + /* Constraint presets only change limits / allowed types (and graphic grid when graphic). + EQ band frequency, Q, and gain stay as-is; illegality highlighting updates via sync. */ let cRoot = document.querySelector("div.extra-eq .extra-eq-constraints-inner"); if (!cRoot) { return; @@ -10347,7 +10343,7 @@ function addExtra() { eq2chFlushDomToActiveBank(); rows = elemToFiltersClampedRowsForEqualizerApply( eq2chPadBankToEqBands(eq2chBankData.both), false).filter( - (f) => !f.disabled && f.type && f.freq && f.q && f.gain); + (f) => !f.disabled && f.type && f.freq && f.q); } else { rows = elemToFilters(); } From ef1db72dac0a604e9f617bfd8aad3534229b03f8 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Fri, 8 May 2026 21:48:43 -0700 Subject: [PATCH 130/136] Share URL bugs --- graphtool.js | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/graphtool.js b/graphtool.js index 3f58f44..9b37d6f 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1912,7 +1912,7 @@ try { } let ifURL = typeof share_url !== "undefined" && share_url; -/** First `location.search` seen at startup — survives `history.replaceState` stripping EQ params before phone_book loads. */ +/** First `location.search` at startup — survives `history.replaceState` before `phone_book` loads (EQ params, graph `share=`, …). */ let eqUrlShareBootstrapSearch = ""; try { eqUrlShareBootstrapSearch = targetWindow && targetWindow.location @@ -2439,7 +2439,12 @@ function updatePaths(trigger) { refreshTargetStyleSlots(); clearLabels(); rebindGraphPathSelectionAndRedraw(); - if (ifURL && !trigger) addPhonesToUrl(); + /* Bulk init uses a truthy trigger so updatePaths batches redraws; `!trigger` skipped addPhonesToUrl. + Restored music can replaceState away `share=` before phones load — only share/embed deep links need + a post-init URL sync (not `config`: that would append `share=` on every default/config load). */ + if (ifURL && (!trigger || trigger === "share" || trigger === "embed")) { + addPhonesToUrl(); + } if (stickyLabels) drawLabels(); updateEqFilterMarkers(); applyParametricEqGraphTraceFocus(); @@ -3527,7 +3532,18 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK par = "share="; emb = "embed"; baseURL = url.split("?").shift(); - + /* Local music restores from IndexedDB and calls addPhonesToUrl before this callback; replaceState can drop `share=` while activePhones is still empty — rehydrate graph share from the same bootstrap snapshot EQ uses. */ + if (!url.includes(par)) { + let bootSearch = typeof window.__eqUrlShareBootstrapSearch === "string" + ? window.__eqUrlShareBootstrapSearch + : ""; + if (bootSearch && /[?&]share=/.test(bootSearch)) { + try { + url = targetWindow.location.origin + targetWindow.location.pathname + bootSearch; + } catch (e0) { /* noop */ } + } + } + if (url.includes(par) && url.includes(emb)) { initReq = parseSharePhonesFromHref(url); if (!initReq || !initReq.length) { From 8e62b59a7b4a09a347656d0ca95fbf95feeb6da8 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Fri, 8 May 2026 22:42:52 -0700 Subject: [PATCH 131/136] Fixed: share added on init for mutli-sample --- graphtool.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/graphtool.js b/graphtool.js index 9b37d6f..eb20bce 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1922,6 +1922,11 @@ try { eqUrlShareBootstrapSearch = ""; } window.__eqUrlShareBootstrapSearch = eqUrlShareBootstrapSearch; +/* When false, addPhonesToUrl omits graph `share=` (EQ/music/canonical still sync). Prevents + multi-sample updateCurves → updatePaths() from injecting share= during config-only init; + enabled after share/embed navigation or first user gesture (see phone_book callback). */ +window.__graphShareUrlSyncAllowed = !!(typeof window.__eqUrlShareBootstrapSearch === "string" + && /[?&]share=/.test(window.__eqUrlShareBootstrapSearch)); let baseTitle = typeof page_title !== "undefined" ? page_title : "CrinGraph"; let baseDescription = typeof page_description !== "undefined" ? page_description : "View and compare frequency response graphs"; let baseURL; // Set by setInitPhones @@ -2329,7 +2334,7 @@ function addPhonesToUrl() { title = (eqModelTit || eqTargetTit) + " - " + baseTitle; } } else if (names.length) { - if (ifURL) { + if (ifURL && window.__graphShareUrlSyncAllowed) { shareQueryPair = "share=" + shareQueryValueForUrl(names); } title = namesCombined + " - " + baseTitle; @@ -3561,6 +3566,17 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK } else if (url.includes(emb)) { setModeEmbed(); } + if (loadFromShare) { + window.__graphShareUrlSyncAllowed = true; + } else if (!window.__graphShareUrlSyncAllowed) { + let armGraphShareUrlSync = () => { + window.__graphShareUrlSyncAllowed = true; + document.removeEventListener("pointerdown", armGraphShareUrlSync, true); + document.removeEventListener("keydown", armGraphShareUrlSync, true); + }; + document.addEventListener("pointerdown", armGraphShareUrlSync, true); + document.addEventListener("keydown", armGraphShareUrlSync, true); + } } let eqShareInitOnly = !!(ifURL && window.__pendingEqUrlShareParsed); if (eqShareInitOnly) { From bc96abbf80f3014b98057b3c8e6ecf23b25c40e7 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 9 May 2026 07:03:50 -0700 Subject: [PATCH 132/136] Fixed hiding music --- graphtool.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphtool.js b/graphtool.js index eb20bce..97d9255 100644 --- a/graphtool.js +++ b/graphtool.js @@ -4374,6 +4374,9 @@ function addExtra() { if (typeof extraMusicEnabled !== "undefined" && !extraMusicEnabled) { document.querySelector("div.extra-panel div.extra-music").style["display"] = "none"; } + /* Omitted `extraMusicEnabled` = same as true (show music block). Apple search / deep links / URL + sync must follow that; only an explicit `false` turns music off. */ + let extraMusicAllowsAppleFeatures = typeof extraMusicEnabled === "undefined" || !!extraMusicEnabled; /** Space toggles this source; Shift+Space cycles Music → Pink → Tone (skips Music if no track). */ let activeLiveSoundPlayer = "pink"; let liveSoundPlayersCycleOrder = () => { @@ -13388,7 +13391,7 @@ function addExtra() { musicFileInput.blur(); }, 0); }); - if (typeof extraMusicEnabled !== "undefined" && extraMusicEnabled + if (extraMusicAllowsAppleFeatures && appleMusicInlineWrap && musicSearchAppleButton && appleMusicSearchInput && appleMusicResultsUl && musicFileActionsRow) { /* Return/Enter still blurs the field in some WebKit paths; focusout was dismissing search. */ @@ -13781,7 +13784,7 @@ function addExtra() { }; let pendingAppleSongFromUrl = window.__pendingAppleMusicCatalogSongId; let pendingAppleSegFromUrl = window.__pendingAppleMusicSegment; - if (pendingAppleSongFromUrl && typeof extraMusicEnabled !== "undefined" && extraMusicEnabled + if (pendingAppleSongFromUrl && extraMusicAllowsAppleFeatures && musicPlayButton && musicCard) { window.__pendingAppleMusicCatalogSongId = null; window.__pendingAppleMusicSegment = null; @@ -14298,7 +14301,7 @@ function addExtra() { }); } window._appendMusicShareParamsToUrlSearch = (u) => { - if (typeof extraMusicEnabled === "undefined" || !extraMusicEnabled) { + if (typeof extraMusicEnabled !== "undefined" && !extraMusicEnabled) { u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); u.searchParams.delete("appleMusicSong"); u.searchParams.delete(MUSIC_URL_PARAM_IN); From 53114cd33c0d4ab166c4afa0abc5f0e41bce7020 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 9 May 2026 07:43:42 -0700 Subject: [PATCH 133/136] Apple Music history --- graphtool.js | 191 ++++++++++++++++++++++++++++++++++++++++++++++++-- style-alt.css | 28 ++++---- 2 files changed, 198 insertions(+), 21 deletions(-) diff --git a/graphtool.js b/graphtool.js index 97d9255..580c62a 100644 --- a/graphtool.js +++ b/graphtool.js @@ -13394,6 +13394,70 @@ function addExtra() { if (extraMusicAllowsAppleFeatures && appleMusicInlineWrap && musicSearchAppleButton && appleMusicSearchInput && appleMusicResultsUl && musicFileActionsRow) { + const APPLE_MUSIC_RECENT_LS = "cringraph_apple_music_recent_v1"; + const APPLE_MUSIC_RECENT_MAX = 10; + let readAppleMusicRecentStored = () => { + try { + let raw = localStorage.getItem(APPLE_MUSIC_RECENT_LS); + if (!raw) { + return []; + } + let arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch (e) { + return []; + } + }; + let persistAppleMusicRecentPlayed = (row) => { + if (!row || typeof row !== "object") { + return; + } + let id = String(row.id || "").trim(); + let previewUrl = String(row.previewUrl || "").trim(); + if (!id && !previewUrl) { + return; + } + try { + let entry = { + id, + title: String(row.title || "").trim(), + artist: String(row.artist || "").trim(), + previewUrl + }; + let arr = readAppleMusicRecentStored().filter((x) => { + if (!x || typeof x !== "object") { + return false; + } + if (entry.id && String(x.id || "") === entry.id) { + return false; + } + if (!entry.id && String(x.previewUrl || "") === previewUrl) { + return false; + } + return true; + }); + arr.unshift(entry); + localStorage.setItem(APPLE_MUSIC_RECENT_LS, JSON.stringify(arr.slice(0, APPLE_MUSIC_RECENT_MAX))); + } catch (err) { + /* quota / private mode */ + } + }; + let appleMusicNormalizeStoredRow = (x) => { + if (!x || typeof x !== "object") { + return null; + } + let id = String(x.id || "").trim(); + let previewUrl = String(x.previewUrl || "").trim(); + if (!id && !previewUrl) { + return null; + } + return { + id, + title: String(x.title || "").trim(), + artist: String(x.artist || "").trim(), + previewUrl + }; + }; /* Return/Enter still blurs the field in some WebKit paths; focusout was dismissing search. */ let appleMusicSearchIgnoreFocusOutUntil = 0; let appleMusicSearchHighlightIx = -1; @@ -13414,20 +13478,58 @@ function addExtra() { } catch (err) { /* noop */ } } }; - let appleMusicActivatePreviewRow = (row) => { + let appleMusicActivatePreviewRow = (row, refreshPreviewFromCatalog) => { appleMusicResultsUl.hidden = true; if (!window.AudioContext && !window.webkitAudioContext) { alert("Web audio API is disabled; music playback is unavailable."); return; } - musicRestoreCancelToken++; - if (!initMusicAudioGraph()) { + let norm = { + id: String(row.id || "").trim(), + title: row.title || "", + artist: row.artist || "", + previewUrl: String(row.previewUrl || "").trim() + }; + let startPlay = (url) => { + musicRestoreCancelToken++; + if (!initMusicAudioGraph()) { + return; + } + persistAppleMusicRecentPlayed({ ...norm, previewUrl: url }); + wireMusicLoadedFromSource(url, null, { + autoPlay: true, + appleCatalogSongId: norm.id || "" + }); + }; + if (refreshPreviewFromCatalog === true && norm.id) { + itunesLookupPreviewByTrackId(norm.id).then((meta) => { + norm = { + ...norm, + previewUrl: meta.previewUrl, + title: meta.title || norm.title, + artist: meta.artist || norm.artist + }; + startPlay(meta.previewUrl); + }).catch(() => { + if (norm.previewUrl) { + startPlay(norm.previewUrl); + } else { + alert("Could not load Apple Music preview."); + } + }); return; } - wireMusicLoadedFromSource(row.previewUrl, null, { - autoPlay: true, - appleCatalogSongId: row.id || "" - }); + if (norm.previewUrl) { + startPlay(norm.previewUrl); + return; + } + if (norm.id) { + itunesLookupPreviewByTrackId(norm.id).then((meta) => startPlay(meta.previewUrl)).catch(() => { + alert("Could not load Apple Music preview."); + }); + return; + } + alert("Could not load Apple Music preview."); }; let appleMusicSearchFieldKeydown = (e) => { if (!musicAppleSearchModeOpen || document.activeElement !== appleMusicSearchInput) { @@ -13520,6 +13622,66 @@ function addExtra() { appleMusicSearchHighlightIx = 0; appleMusicApplySearchHighlight(); }; + let appleMusicShowRecentIfInputEmpty = () => { + if (!musicAppleSearchModeOpen || !appleMusicSearchInput || !appleMusicResultsUl) { + return; + } + if ((appleMusicSearchInput.value || "").trim() !== "") { + return; + } + let rawList = readAppleMusicRecentStored() + .map(appleMusicNormalizeStoredRow) + .filter(Boolean); + if (!rawList.length) { + appleMusicSearchLastRows = []; + appleMusicSearchHighlightIx = -1; + appleMusicResultsUl.innerHTML = ""; + appleMusicResultsUl.hidden = true; + return; + } + appleMusicResultsUl.innerHTML = ""; + let headLi = document.createElement("li"); + headLi.setAttribute("role", "presentation"); + let headDiv = document.createElement("div"); + headDiv.className = "apple-music-recent-heading"; + headDiv.textContent = "Recent"; + headLi.appendChild(headDiv); + appleMusicResultsUl.appendChild(headLi); + appleMusicSearchLastRows = rawList.map((r) => ({ + id: r.id, + title: r.title, + artist: r.artist, + previewUrl: r.previewUrl + })); + rawList.forEach((r) => { + let li = document.createElement("li"); + li.setAttribute("role", "presentation"); + let bt = document.createElement("button"); + bt.type = "button"; + bt.setAttribute("role", "option"); + bt.setAttribute("aria-selected", "false"); + bt.setAttribute("aria-label", (r.title || "Track") + " — " + (r.artist || "")); + let titleEl = document.createElement("span"); + titleEl.className = "apple-music-preview-title"; + titleEl.textContent = r.title || ""; + bt.appendChild(titleEl); + let meta = document.createElement("span"); + meta.className = "apple-music-preview-meta"; + meta.textContent = r.artist || ""; + bt.appendChild(meta); + bt.addEventListener("click", () => { + /* Match search-results path when we already have previewUrl — avoids an extra + lookup round-trip before wireMusicLoadedFromSource (felt jumpier: dropdown + hides, then waits on network). Refresh-from-catalog only if URL missing. */ + appleMusicActivatePreviewRow(r, !r.previewUrl); + }); + li.appendChild(bt); + appleMusicResultsUl.appendChild(li); + }); + appleMusicResultsUl.hidden = false; + appleMusicSearchHighlightIx = 0; + appleMusicApplySearchHighlight(); + }; let appleMusicPreviewForm = appleMusicInlineWrap.querySelector("form.apple-music-preview-form"); if (appleMusicPreviewForm) { appleMusicPreviewForm.addEventListener("submit", (e) => { @@ -13528,6 +13690,11 @@ function addExtra() { }); } appleMusicSearchInput.addEventListener("keydown", appleMusicSearchFieldKeydown, true); + appleMusicSearchInput.addEventListener("focus", () => { + if ((appleMusicSearchInput.value || "").trim() === "") { + appleMusicShowRecentIfInputEmpty(); + } + }); let appleMusicOutsidePointerDismiss = (e) => { if (!musicAppleSearchModeOpen) { return; @@ -13556,6 +13723,10 @@ function addExtra() { clearTimeout(appleMusicSearchDebounceTimer); appleMusicSearchDebounceTimer = null; } + if (v.length === 0) { + appleMusicShowRecentIfInputEmpty(); + return; + } if (v.length < 2) { appleMusicSearchLastRows = []; appleMusicSearchHighlightIx = -1; @@ -13611,6 +13782,12 @@ function addExtra() { }); musicSearchAppleButton.addEventListener("click", () => { openAppleMusicSearchMode(); + setTimeout(() => { + if (musicAppleSearchModeOpen && appleMusicSearchInput + && (appleMusicSearchInput.value || "").trim() === "") { + appleMusicShowRecentIfInputEmpty(); + } + }, 0); }); } musicPlayButton.addEventListener("click", () => { diff --git a/style-alt.css b/style-alt.css index d21fbae..5edea2d 100644 --- a/style-alt.css +++ b/style-alt.css @@ -3640,7 +3640,6 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-form { box-sizing: border-box; } -/* Empty vs filled: softer border until there is typed text (:placeholder-shown) */ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search { box-sizing: border-box; width: 100%; @@ -3654,7 +3653,7 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search { line-height: 1em; text-transform: uppercase; background-color: var(--background-color-inputs); - border: 1px solid var(--background-color-contrast); + border: 1px solid var(--background-color-contrast-more); border-radius: 6px; outline: none; color: var(--font-color-primary); @@ -3665,10 +3664,6 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-search-inline:has(ul border-bottom-right-radius: 0; } -div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search:not(:placeholder-shown) { - border-color: var(--background-color-contrast-more); -} - div.extra-panel .live-sound-tools .extra-music .apple-music-preview-search::placeholder { color: var(--background-color-contrast-more); opacity: 1; @@ -3703,7 +3698,7 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results { padding: 0; max-height: 9.5rem; overflow-y: auto; - border: 1px solid var(--background-color-contrast-faint, rgba(127, 127, 127, 0.25)); + border: 1px solid var(--background-color-contrast-more); border-radius: 4px; background: var(--background-color-inputs); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); @@ -3714,16 +3709,10 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results:not( border-top: none; border-top-left-radius: 0; border-top-right-radius: 0; - border-left-color: var(--background-color-contrast); - border-right-color: var(--background-color-contrast); - border-bottom-color: var(--background-color-contrast); - box-shadow: none; -} - -div.extra-panel .live-sound-tools .extra-music .apple-music-search-inline:has(.apple-music-preview-search:not(:placeholder-shown)) .apple-music-preview-results:not([hidden]) { border-left-color: var(--background-color-contrast-more); border-right-color: var(--background-color-contrast-more); border-bottom-color: var(--background-color-contrast-more); + box-shadow: none; } /* Rows override global `div.extra-panel button`: margin-bottom:4px !important, accent color, bg !important, capitalize, nowrap */ @@ -3793,6 +3782,17 @@ div.extra-panel .live-sound-tools .extra-music .apple-music-preview-results > li padding: 0; } +div.extra-panel .live-sound-tools .extra-music .apple-music-recent-heading { + padding: 8px 12px 4px; + margin: 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--background-color-contrast-more); + border-bottom: 1px solid var(--background-color-contrast-more); +} + /***** Targets styles *****/ From 5811cd6bbebfe5d1baee6f65d8620839f79d9256 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 9 May 2026 08:56:39 -0700 Subject: [PATCH 134/136] Apple Search style updates --- graphtool.js | 21 +++++++++++++++++++-- style-alt.css | 6 ++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/graphtool.js b/graphtool.js index 580c62a..be1f5a6 100644 --- a/graphtool.js +++ b/graphtool.js @@ -13478,6 +13478,17 @@ function addExtra() { } catch (err) { /* noop */ } } }; + let appleMusicPointerHighlightRow = (ix) => { + if (!musicAppleSearchModeOpen || !appleMusicResultsUl || appleMusicResultsUl.hidden) { + return; + } + let n = appleMusicSearchLastRows.length; + if (ix < 0 || ix >= n) { + return; + } + appleMusicSearchHighlightIx = ix; + appleMusicApplySearchHighlight(); + }; let appleMusicActivatePreviewRow = (row, refreshPreviewFromCatalog) => { appleMusicResultsUl.hidden = true; if (!window.AudioContext && !window.webkitAudioContext) { @@ -13596,7 +13607,7 @@ function addExtra() { artist: r.artist, previewUrl: r.previewUrl })); - rows.forEach((row) => { + rows.forEach((row, ix) => { let li = document.createElement("li"); li.setAttribute("role", "presentation"); let bt = document.createElement("button"); @@ -13612,6 +13623,9 @@ function addExtra() { meta.className = "apple-music-preview-meta"; meta.textContent = row.artist || ""; bt.appendChild(meta); + bt.addEventListener("pointerenter", () => { + appleMusicPointerHighlightRow(ix); + }); bt.addEventListener("click", () => { appleMusicActivatePreviewRow(row); }); @@ -13653,7 +13667,7 @@ function addExtra() { artist: r.artist, previewUrl: r.previewUrl })); - rawList.forEach((r) => { + rawList.forEach((r, ix) => { let li = document.createElement("li"); li.setAttribute("role", "presentation"); let bt = document.createElement("button"); @@ -13669,6 +13683,9 @@ function addExtra() { meta.className = "apple-music-preview-meta"; meta.textContent = r.artist || ""; bt.appendChild(meta); + bt.addEventListener("pointerenter", () => { + appleMusicPointerHighlightRow(ix); + }); bt.addEventListener("click", () => { /* Match search-results path when we already have previewUrl — avoids an extra lookup round-trip before wireMusicLoadedFromSource (felt jumpier: dropdown diff --git a/style-alt.css b/style-alt.css index 5edea2d..9dac9f6 100644 --- a/style-alt.css +++ b/style-alt.css @@ -3152,6 +3152,12 @@ div.extra-panel .live-sound-tools .extra-music.music-file-loaded .music-playback } /* Apple preview search replaces play control; keep panel compact until a track is loaded */ +div.extra-panel .live-sound-tools .live-sound-source.extra-music.music-apple-search-mode:not(.music-file-loaded) { + /* +Add Music / Search Apple hide immediately while playback expands; hold at least a typical idle + footprint (head + action links + padding) so the card doesn't dip before max-height catches up. */ + min-height: 6.5rem; +} + div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-playback-panel { max-height: 58px; opacity: 1; From 766acc56a04d94a169f4b229cee11fc99c9073e2 Mon Sep 17 00:00:00 2001 From: Mark Ryan Sallee Date: Sat, 9 May 2026 09:17:05 -0700 Subject: [PATCH 135/136] Apple search style --- style-alt.css | 53 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 7 deletions(-) diff --git a/style-alt.css b/style-alt.css index 9dac9f6..74fd394 100644 --- a/style-alt.css +++ b/style-alt.css @@ -3117,6 +3117,45 @@ div.extra-panel .live-sound-tools .extra-music .music-play-or-search-slot { box-sizing: border-box; } +div.extra-panel .live-sound-tools .live-sound-source.extra-music { + min-height: 0; + transition: min-height 200ms ease-in-out; +} + +/* + * Loaded music card height (~157px in DevTools) as a calc of literals already defined elsewhere in this sheet. + */ +div.extra-panel .live-sound-tools .extra-music { + --music-playback-panel-expand-max: 200px; + --music-loaded-source-vertical-padding: calc(12px + 10px); /* .live-sound-source padding */ + --music-loaded-head-below-headline: calc(8px + 13px); /* .live-sound-source-head margin-bottom + title line (.live-sound-source-title 13px / 1em) */ + /* .music-play-or-search-slot is a div:has(> button.play) → div.extra-panel div:has(> button.play) { margin-top: 8px } */ + --music-loaded-play-slot-margin-top: 8px; + --music-loaded-play-min-height: 44px; /* music .music-play-row button.play min-height (border-box) */ + /* div.extra-panel button { margin-bottom: 4px !important } — play chip does not override (file link buttons use margin: 0 !important) */ + --music-loaded-play-chip-margin-bottom: 4px; + --music-loaded-slider-row-margin-top: 8px; /* .extra-music .live-sound-slider-row.music-slider-row */ + --music-loaded-segment-track-height: 18px; /* .music-segment-track */ + --music-loaded-file-margin-top: 6px; /* .live-sound-music-file margin-top */ + /* .music-add-remove / .music-search-apple vertical padding + line box (font-size 12 × line-height 1.2) */ + --music-loaded-file-actions-block: calc(6px + 6px + (12px * 1.2)); + --music-loaded-playback-body-height: calc( + var(--music-loaded-play-slot-margin-top) + var(--music-loaded-play-min-height) + + var(--music-loaded-play-chip-margin-bottom) + var(--music-loaded-slider-row-margin-top) + + var(--music-loaded-segment-track-height) + ); + --music-loaded-card-min-height: calc( + var(--music-loaded-source-vertical-padding) + var(--music-loaded-head-below-headline) + + var(--music-loaded-playback-body-height) + var(--music-loaded-file-margin-top) + + var(--music-loaded-file-actions-block) + ); + /* Apple-search open state: animate max-height near real content height (dropdown is absolute); avoids 200px “dead band” where expand finishes visually long before collapse starts moving. */ + --music-playback-panel-apple-search-max: min( + var(--music-playback-panel-expand-max), + calc(var(--music-loaded-playback-body-height) + 1.5rem) + ); +} + /* No track: play stays in layout flow (smooth panel collapse) but is invisible */ div.extra-panel .live-sound-tools .extra-music:not(.music-file-loaded) .music-play-row button.play { opacity: 0 !important; @@ -3141,25 +3180,25 @@ div.extra-panel .live-sound-tools .extra-music .music-playback-panel { max-height: 0; opacity: 0; overflow: hidden; - transition: max-height 200ms ease, opacity 200ms ease; + /* ease-in-out: symmetric in/out vs ease (slow–fast can make expand feel snappier than collapse with a tall max-height cap) */ + transition: max-height 200ms ease-in-out, opacity 200ms ease-in-out; pointer-events: none; } div.extra-panel .live-sound-tools .extra-music.music-file-loaded .music-playback-panel { - max-height: 200px; + max-height: var(--music-playback-panel-expand-max); opacity: 1; pointer-events: auto; } -/* Apple preview search replaces play control; keep panel compact until a track is loaded */ +/* Same loaded layout footprint as `--music-loaded-card-*` (actions row collapses while search is open). */ div.extra-panel .live-sound-tools .live-sound-source.extra-music.music-apple-search-mode:not(.music-file-loaded) { - /* +Add Music / Search Apple hide immediately while playback expands; hold at least a typical idle - footprint (head + action links + padding) so the card doesn't dip before max-height catches up. */ - min-height: 6.5rem; + min-height: var(--music-loaded-card-min-height); } +/* Apple preview search replaces play control; tight max-height keeps open/close max-height tween visually balanced */ div.extra-panel .live-sound-tools .extra-music.music-apple-search-mode:not(.music-file-loaded) .music-playback-panel { - max-height: 58px; + max-height: var(--music-playback-panel-apple-search-max); opacity: 1; pointer-events: auto; overflow: visible; From 352650fdbee1806a00f2bd5db796f8e54d934d4f Mon Sep 17 00:00:00 2001 From: Jerome O'Flaherty Date: Wed, 20 May 2026 21:55:37 +0100 Subject: [PATCH 136/136] Refactored code --- config.js | 26 +- graph_free.html | 9 +- graph_hp.html | 9 +- graphtool.js | 14027 +----------------------------------- index.html | 14 +- src/app-core.js | 1591 ++++ src/apple-music-plugin.js | 459 ++ src/audio-engine.js | 1142 +++ src/eq-manager.js | 4321 +++++++++++ src/eq-panel.js | 2850 ++++++++ src/graph-renderer.js | 1456 ++++ src/live-sound.js | 2574 +++++++ src/phone-catalog.js | 1540 ++++ src/tilt-plugin.js | 323 + style-alt.css | 119 + 15 files changed, 16649 insertions(+), 13811 deletions(-) create mode 100644 src/app-core.js create mode 100644 src/apple-music-plugin.js create mode 100644 src/audio-engine.js create mode 100644 src/eq-manager.js create mode 100644 src/eq-panel.js create mode 100644 src/graph-renderer.js create mode 100644 src/live-sound.js create mode 100644 src/phone-catalog.js create mode 100644 src/tilt-plugin.js diff --git a/config.js b/config.js index dd65fae..cf470b8 100644 --- a/config.js +++ b/config.js @@ -46,11 +46,35 @@ const init_phones = ["BKF"], // Optional. Which graphs to display on // Specify which targets to display const targets = [ - { type:"Neutral", files:["Diffuse Field","Etymotic","Free Field","Innerfidelity ID"] }, + { type:"Neutral", files:["KEMAR DF","Diffuse Field","Etymotic","Free Field","Innerfidelity ID"] }, { type:"Reviewer", files:["Antdroid","Bad Guy","Banbeucmas","Crinacle","Precogvision","Super Review"] }, { type:"Preference", files:["Harman","Rtings","Sonarworks"] } ]; +// Tilt / Preference Adjustments +const + default_y_scale = "40db", // Default Y scale; values: ["20db", "30db", "40db", "50db", "crin"] + default_DF_name = "KEMAR DF", // Default RAW DF name + dfBaseline = true, // If true, DF is used as baseline when custom df tilt is on + default_bass_shelf = 8, // Default Custom DF bass shelf value + default_tilt = -0.8, // Default Custom DF tilt value + default_ear = 0, // Default Custom DF ear gain value + default_treble = 0, + tiltableTargets = [], // Targets that are allowed to be tilted + compTargets = ["KEMAR DF"], // Targets that are allowed to be used for compensation + preference_bounds_name = "Bounds", // Preference bounds file prefix (null to disable) + preference_bounds_dir = "data/pref_bounds/",// Directory containing bounds files + preference_bounds_startup = false; // Show bounds curve on startup + +const harmanFilters = [ + { name: "Harman C1 2024 IE", tilt: -0.9, bass_shelf: 1, ear: 0, treble: 0.5 }, + { name: "Harman C2 2024 IE", tilt: -0.3, bass_shelf: 0.5, ear: -0.2, treble: 1 }, + { name: "Harman C3 2024 IE", tilt: -2.1, bass_shelf: 0, ear: 0, treble: 10 }, + { name: "Harman C4 2024 IE", tilt: -2.1, bass_shelf: 0, ear: 0.5, treble: 3.7 }, + { name: "Harman 2013 OE", tilt: 0, bass_shelf: 4.8, ear: 0, treble: -4.4 }, + { name: "Harman 2015 OE", tilt: 0, bass_shelf: 6.6, ear: 0, treble: -1.4 }, + { name: "Harman 2018 OE", tilt: 0, bass_shelf: 6, ear: -1, treble: -4 }, +]; // ************************************************************* diff --git a/graph_free.html b/graph_free.html index af9a2f5..e350d74 100644 --- a/graph_free.html +++ b/graph_free.html @@ -18,8 +18,13 @@ const disallow_target = true; const premium_html = "

You gonna pay for that?

To use target curves, or more than two graphs, upgrade to Patreon Silver tier and switch to the premium tool.

"; - - + + + + + + + diff --git a/graph_hp.html b/graph_hp.html index bcf46af..dce373c 100644 --- a/graph_hp.html +++ b/graph_hp.html @@ -13,8 +13,13 @@ - - + + + + + + + diff --git a/graphtool.js b/graphtool.js index be1f5a6..c32f481 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1,450 +1,10 @@ -let doc = d3.select(".graphtool"); -doc.html(` - - - - BASE - -LINE - - - - - - - - - - PIN - - - - - -

-
-
-
- -
- -
-
- - -
- -
- Zoom: - - - -
- -
- Normalize: -
- - dB -
-
- - Hz -
- - ?Choose a dB value to normalize to a target listening level, or a Hz value to make all curves match at that frequency. - -
- -
- Smooth: - -
- -
- - - - -
- -
- -
- - - - - -
-
- -
- - - - - - - - - - - - - - - - - -
(or middle/ctrl-click when selecting; or pin other IEMs)LOCK
-
- -
- - -
- -
-
-
-
- - - -
- -
- - - - - - - - - -
-
-
-
-
+// Config defaults moved to src/app-core.js (loads before graphtool.js) - -
-
-
- -
-`); +let doc = d3.select(".graphtool"); +renderGraphToolShell(doc); +if (typeof setupLabelUi === "function") { setupLabelUi(); } +if (typeof setupSmoothingUi === "function") { setupSmoothingUi(); } +if (typeof setupAddPhoneUi === "function") { setupAddPhoneUi(); } let pad = { l:15, r:15, t:10, b:36 }; @@ -469,6 +29,12 @@ let yD = [29.5,85], // Decibels yR = [pad.t+H,pad.t+10]; let y = d3.scaleLinear().domain(yD).range(yR); +Object.defineProperty(window, 'x', { get: () => x, configurable: true }); +Object.defineProperty(window, 'y', { get: () => y, configurable: true }); +Object.defineProperty(window, 'pad', { get: () => pad, configurable: true }); +Object.defineProperty(window, 'W', { get: () => W, configurable: true }); +Object.defineProperty(window, 'H', { get: () => H, configurable: true }); + // y axis defs.append("filter").attr("id","blur").attr("filterUnits","userSpaceOnUse") @@ -576,7 +142,7 @@ let line = d3.line() .x(d=>x(d[0])) .y(d=>y(d[1])) .curve(d3.curveNatural); - +window.line = line; // Range buttons let selectedRange = 3; // Full range @@ -608,6 +174,7 @@ let dB = { max: pad.t+H, tr: _ => "translate("+(pad.l-9)+","+dB.y+")" }; +window.dB = dB; dB.all = gr.append("g").attr("class","dBScaler"), dB.trans = dB.all.append("g").attr("transform", dB.tr()), dB.scale = dB.trans.append("g").attr("transform", "scale(1,1)"); @@ -674,482 +241,40 @@ dB.updatey = function (dom) { } -// Label drawing and screenshot -let getFullName = p => p.dispBrand+" "+p.dispName, - getChannelName = p => n => getFullName(p) + " ("+n+")"; - -let labelButton = doc.select("#label"), - labelsShown = false; -function setLabelButton(l) { - labelButton.classed("selected", labelsShown = l); -} -function clearLabels() { - gr.selectAll(".lineLabel").remove(); - setLabelButton(false); -} - -function drawLabels() { - let curves = d3.merge( - activePhones.filter(p=>!p.hide).map(p => - p.isTarget||!p.samp||p.avg ? p.activeCurves - : LR.map((l,i) => ({ - p:p, o:getO(i), id:getChannelName(p)(l), multi:true, - l:(n=>p.channels.slice(i*n,(i+1)*n))(sampnums.length) - .filter(c=>c!==null) - })) - ) - ); - if (!curves.length) return; - - let bcurves = curves.slice(), - bp = baseline.p; - if (bp && bp.hide) { - bcurves.push({ - p:bp, o:0, - id:"Baseline: "+(bp.isTarget?bp.fullName:getFullName(bp)) - }); - } - - gr.selectAll(".lineLabel").remove(); - let g = gr.selectAll(".lineLabel").data(bcurves) - .join("g").attr("class","lineLabel").attr("opacity", 0) - .attr("pointer-events", "none"); - let t = g.append("text") - .attrs({x:0, y:0, fill:c=>getTooltipColor(c)}) - .text(c=>c.id); - g.datum(function(){return this.getBBox();}); - g.select("text").attrs(b=>({x:3-b.x, y:3-b.y})); - g.insert("rect", "text") - .attrs(b=>({x:2, y:2, width:b.width+2, height:b.height+2})); - let boxes = g.data(), - w = boxes.map(b=>b.width +6), - h = boxes.map(b=>b.height+6); - - // Slice to fit in range - let r = x.domain().map(v => d3.bisectLeft(f_values, v)); - rsl = a => a.slice(Math.max(r[0],0), r[1]+1); - let rf_values = rsl(f_values); - let v = curves.map(c => { - let o = getOffset(c.p); - return (c.multi?c.l:[c.l]) - .map(l => rsl(baseline.fn(l).map(d=>d[1]+o))); - }); - let tr; - - if (curves.length === 1) { - let x0 = 50, y0 = 10, - sl = range_to_slice([0,w[0]], o=>x0+o), - e = d3.extent(d3.merge(v[0].map(sl)).map(y)); - if (y0+h[0] >= e[0]) { y0 = Math.max(y0, e[1]); } - tr = [[x0,y0]]; - } else { - let n = v.length; - let invd = (sc,d) => sc.invert(d)-sc.invert(0), - xr = x.range(), - yd = y.domain(), - wind = w => Math.ceil((w/(xr[1]-xr[0]))*rf_values.length), - mw = wind(d3.min(w)); - let winReduce = (l,w,d0,fn) => { - l = l.slice(); - for (let d=d0; d { - let r = c => c.reduce((a,b)=>a.map((ai,i)=>f(ai,b[i]))); - let t = v.map(c => winReduce(r(c), mw, 1, f)); - return w => t.map(c => winReduce(c, w, mw, f)); - }); - let top = 0; // Use top left if we can't find a spot - tr = v.map((_,j) => { - let we = wind(w[j]), - he = -invd(y,h[j]), - range = d3.transpose(rangeGetters.map(r => r(we))), - ds; - ds = range[j].map(function (r,ri) { - let le = r.length, - s = [[-he,0],[0,he]][ri].map(o=>r.map(d=>d+o)), - d = r.map(_=>1e10); - for (let k=0; k x/Math.sqrt(1+x*x); - d = 4*clip(d/4) + clip((ii-i)/3); - i = Math.floor((i+ii)/2); - let dl = drow.length, - r = i/dl; - d *= Math.sqrt((0.8+r)*Math.sqrt(1-r)); - d *= clip(0.2+Math.max(0,(i>=15?drow[i-15]:0)+(isep) { - let dy = range[j][k][i]+(k?he:0), - yd = y.domain(); - if (yd[0]+he<=dy && dy<=yd[1]) { sep=d; pos=[i,dy]; } - } - } - }); - return pos ? [x(rf_values[pos[0]]), y(pos[1])] - : [60, 20+30*top++]; - }); - } - for (let j=curves.length; j"translate("+tr[i].join(",")+")"); - g.attr("opacity",null); - setLabelButton(true); - gEqFilterMarkers.raise(); - gEqHoverPreview.raise(); -} - -labelButton.on("click", () => (labelsShown?clearLabels:drawLabels)()); - -function saveGraph(ext) { - let fn = {png:saveSvgAsPng, svg:saveSvg}[ext]; - let showControls = s => dB.all.attr("visibility",s?null:"hidden"); - gpath.selectAll("path").classed("highlight",false); - drawLabels(); - showControls(false); - fn(gr.node(), "graph."+ext, {scale:3}) - .then(()=>showControls(true)); - - // Analytics event - if (analyticsEnabled) { pushEventTag("clicked_download", targetWindow); } -} -doc.select("#download") - .on("click", () => saveGraph("png")) - .on("contextmenu", function () { - d3.event.returnValue=false; - let b = d3.select(this); - let choice = b.selectAll("div") - .data(["png","svg"]).join("div") - .styles({position:"absolute", left:0, top:(_,i)=>i*1.3+"em", - background:"inherit", padding:"0.1em 1em"}) - .text(d => "As ."+d) - .on("click", function (d) { - saveGraph(d); - choice.remove(); - d3.event.stopPropagation(); - }); - b.on("blur", ()=>choice.remove()); - }); - - -// Graph smoothing -let pair = (arr,fn) => arr.slice(1).map((v,i)=>fn(v,arr[i])); - -function smooth_prep(h, d) { - let rh = h.map(d=>1/d), - G = [ rh.slice(0,rh.length-1), - pair(rh, (a,b)=>-(a+b)), - rh.slice(1) ], - dv = d3.range(rh.length+1).map(i=>d(i)), - dG = G.map((r,j) => r.map((e,i) => e*dv[i+j])), - d2 = dv.map(e=>e*e), - h6 = h.map(d=>d/6), - M = [ pair(h6, (a,b)=>2*(a+b)), - h6.slice(1,h6.length-1), - h6.slice(3).map(_=>0) ]; - dG.forEach((_,k) => - dG.slice(k).forEach((g,i) => - dG[i].slice(k).forEach((a,j) => M[k][j] += a*g[j]) - ) - ); - - // Diagonal LDL decomposition of M - let md = [M[0][0]], - ml = M.slice(1).map(m=>[m[0]/md]); - d3.range(1,M[0].length).forEach(j => { - let n = ml.length, - p = md.slice(-n).reverse().map((d,i)=>d*ml[i][j-1-i]), - a = M.map((m,k) => m[j] - d3.sum(p.slice(0,n-k), - (a,i) => a*ml[k+i][j-1-i])); - md.push(a[0]); - ml.forEach((l,j)=>l.push(a[j+1]/a[0])); - }); - - return { G:G, md:md, ml:ml, d2:d2 }; -} - -function smooth_eval(p, y) { - let Gy = p.G[0].map(_=>0), - n = Gy.length; - p.G.forEach((r,j) => r.forEach((e,i) => Gy[i] += e*y[i+j])); - // Forward substitution and multiply by p.md - for (let i=0; i { let j=i+k+1; if (j { let j=i-k-1; if (j>=0) Gy[j] -= m[j]*yi; }); - } - let u = y.slice(); - p.G.forEach((r,j) => r.forEach((e,i) => u[i+j] -= e*p.d2[i+j]*Gy[i])); - return u; -} - -let smooth_level = 5, - smooth_scale = 0.01*(typeof scale_smoothing !== "undefined" ? scale_smoothing : 1), - smooth_param = undefined; -function smooth(y, c) { - if (smooth_level === 0) { return y; } - let get_param = fv => { - let x = fv.map(f=>Math.log(f)), - h = pair(x, (a,b)=>a-b), - s = smooth_level*smooth_scale, - d = i => s*Math.pow(1/80,Math.pow(i/x.length,2)); - return smooth_prep(h, d); - } - let p; - if (y.length!==f_values.length) { - p = get_param(c.map(d=>d[0])); - } else { - if (!smooth_param) { smooth_param = get_param(f_values); } - p = smooth_param; - } - return smooth_eval(p, y); -} - -function smoothPhone(p) { - if (!p.rawChannels) return; - if (p.smooth !== smooth_level) { - p.channels = p.rawChannels.map( - c=>c?smooth(c.map(d=>d[1]),c).map((d,i)=>[c[i][0],d]):c - ); - p.smooth = smooth_level; - setCurves(p); - } -} - -doc.select("#smooth-level").on("change input", function () { - if (!this.checkValidity()) return; - smooth_level = +this.value; - smooth_param = undefined; - line.curve(smooth_level ? d3.curveNatural : d3.curveCardinal.tension(0.5)); - activePhones.forEach(smoothPhone); - updatePaths(); -}); - - -// Normalization with target loudness -const iso223_params = { // :2003 - f : [ 20, 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 1000, 1250, 1600, 2000, 2500, 3150, 4000, 5000, 6300, 8000, 10000, 12500], - a_f: [0.532, 0.506, 0.48, 0.455, 0.432, 0.409, 0.387, 0.367, 0.349, 0.33, 0.315, 0.301, 0.288, 0.276, 0.267, 0.259, 0.253, 0.25, 0.246, 0.244, 0.243, 0.243, 0.243, 0.242, 0.242, 0.245, 0.254, 0.271, 0.301], - L_U: [-31.6, -27.2, -23, -19.1, -15.9, -13, -10.3, -8.1, -6.2, -4.5, -3.1, -2, -1.1, -0.4, 0, 0.3, 0.5, 0, -2.7, -4.1, -1, 1.7, 2.5, 1.2, -2.1, -7.1, -11.2, -10.7, -3.1], - T_f: [ 78.5, 68.7, 59.5, 51.1, 44, 37.5, 31.5, 26.5, 22.1, 17.9, 14.4, 11.4, 8.6, 6.2, 4.4, 3, 2.2, 2.4, 3.5, 1.7, -1.3, -4.2, -6, -5.4, -1.5, 6, 12.6, 13.9, 12.3] +// y-scale presets (matches PublicGraphTool) +const defY = dB.y; +const scales = { + "20db": {name:"20dB", h:152, y:defY}, + "30db": {name:"30dB", h:101.33, y:defY}, + "40db": {name:"40dB", h:dB.H, y:defY}, + "50db": {name:"50dB", h:60.79, y:defY}, + "crin": {name:"Crin", h:54.77, y:defY}, }; -const free_field = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0.0725,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.0896,0,0,0,0,0,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.0967,0,0,0,0,0,0,0,0.0886,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.0656,0,0,0,0,0,0.024,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.045,0,0,0,0,0,0,0.029,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1,0.1524,0.2,0.2,0.2386,0.3395,0.4,0.437,0.5,0.5287,0.6225,0.7,0.7063,0.7962,0.8,0.8941,0.9,0.9863,1,1.0729,1.1,1.1544,1.2,1.2504,1.3,1.3,1.3,1.3,1.3163,1.4,1.4,1.4,1.4,1.4017,1.4846,1.5,1.5,1.5748,1.6,1.6,1.653,1.7,1.7,1.7487,1.8,1.8341,1.9,1.9,1.9229,2,2,2,2.1,2.1,2.1897,2.2,2.2,2.2674,2.3,2.3,2.3567,2.4,2.4,2.4446,2.5,2.5262,2.6,2.6234,2.7149,2.8,2.8038,2.9011,2.9969,3.0913,3.1845,3.2762,3.3757,3.4649,3.5617,3.657,3.751,3.8,3.8432,3.9332,4,4,4,4.0121,4.1,4.1,4.1,4.0079,4,4,4,4,3.9334,3.9,3.9,3.9,3.8541,3.8,3.8,3.768,3.7,3.6761,3.6,3.6,3.5927,3.5,3.5,3.5,3.5,3.5,3.5761,3.6,3.6,3.6604,3.7,3.7514,3.8,3.8,3.8349,3.9,3.9218,4.0199,4.1123,4.2076,4.3016,4.3985,4.6816,5.0515,5.4222,5.8036,6.1097,6.4656,6.8461,7.3316,7.9083,8.4305,8.9369,9.5105,10.0759,10.6024,11.0027,11.4847,12.0482,12.5152,12.8994,13.2776,13.7381,14.1303,14.5168,14.8858,15.273,15.6547,15.9731,16.2596,16.542,16.7857,17.0111,17.2325,17.3532,17.522,17.6,17.6,17.6,17.6,17.5044,17.41,17.3145,17.2205,17.1255,17.0318,16.9373,16.784,16.6459,16.4536,16.2578,16.1234,15.967,15.8736,15.7552,15.566,15.3879,15.2881,15.0958,14.9064,14.8099,14.6287,14.5201,14.3477,14.2307,14.0709,13.9399,13.7916,13.6514,13.5552,13.4604,13.367,13.2718,13.1766,13.0812,12.9743,12.7916,12.6975,12.602,12.5078,12.3247,12.0547,11.7686,11.4154,11.1009,10.9385,10.7344,10.3998,10.0163,9.6382,9.2957,8.9799,8.6248,8.3404,8.0424,7.674,7.3851,7.0061,6.5307,6.1484,5.7696,5.4662,5.1084,4.7302,4.3498,3.971,3.6455,3.4075,3.1343,2.7917,2.5376,2.3484,2.1585,1.9849,1.9107,2,2,2,2.0894,2.1844,2.2787,2.374,2.6057,2.8265,3.0161,3.2057,3.3954,3.5851,3.8122,4.0967,4.354,4.5651,4.8509,5.1459,5.5259,5.9041,6.1881,6.5643,6.8561,7.1418,7.4251,7.7093,8.0593,8.3192,8.4541,8.5493,8.6437,8.7,8.7336,8.8,8.8,8.8,8.8,8.7926,8.7,8.7,8.6079,8.5133,8.5,8.4237,8.1863,7.968,7.7786,7.4219,6.948,6.4299,5.8212,5.1563,4.4634,3.7042,2.8897,1.9005,1.2368,0.5651,-0.2856,-0.8593,-2.9].map(v=>v-7); - -function init_normalize(fv) { // Interpolate values for find_offset - let par = [], ff = []; - par.free_field = ff; - const p = iso223_params; - let i = 0; - fv.forEach(function (f) { - if (f >= p.f[i]) { i++; } - let i0 = Math.max(0,i-1), - i1 = Math.min(i,p.f.length-1), - g; - if (i0===i1) { - g = n => p[n][i0]; - } else { - let ll= [p.f[i0],p.f[i1],f].map(x=>Math.log(x)), - l = (ll[2]-ll[0])/(ll[1]-ll[0]); - g = n => { let v=p[n]; return v[i0]+l*(v[i1]-v[i0]); }; - } - let a = g("a_f"), - m = a * (Math.log10(4)-10 + g("L_U")/10), - k = (0.005076/Math.pow(10,m)) - Math.pow(10, a*g("T_f")/10), - c = Math.pow(10, 9.4 + 4*m) / fv.length; - par.push({a:a, k:k, c:c}); - ffi = Math.floor(0.5+48*Math.log2(f/19.4806)); - ff.push(free_field[Math.max(0,Math.min(479,ffi))]); - }); - return par; -} - -// Find the appropriate offset (in dB) for fr so that the total loudness -// is equal to target (in phon) -let norm_par = []; // Cached interpolated ISO parameters -function find_offset(c, target) { - let par; - if (c.length!==f_values.length) { - par = init_normalize(c.map(d=>d[0])); - } else { - if (!norm_par.length) { norm_par = init_normalize(f_values); } - par = norm_par; - } - let fr = c.map(v=>v[1]); - let x = 0; // Initial offset - function getStep(o) { - const l10 = Math.log(10)/10; - let v=0, d=0; - par.forEach(function (p,i) { - let a=p.a, k=p.k, c=p.c, ds,v0,v1; - v0 = Math.exp(l10*(fr[i]+o-par.free_field[i])); - ds = l10 * v0; - v1 = k + Math.pow(v0,a); - ds *= a * Math.pow(v0,a-1); - v += c * Math.pow(v1,4); - ds *= c * 4 * Math.pow(v1,3); - d += ds; - }); - // value: Math.log(v)/l10 - // deriv: d / (l10*v) - return (Math.log(v) - target*l10) * (v/d); - } - let dx; - do { - dx = getStep(x); - x -= dx; - } while (Math.abs(dx) > 0.01); - return x; -} - - -// File loading and channel management -const LR = typeof default_channels !== "undefined" ? default_channels - : ["L","R"]; -let getO = i => LR.length>1 ? -1+i*2/(LR.length-1) : 0; -const sampnums = typeof num_samples !== "undefined" ? d3.range(1,num_samples+1) - : [""]; -window._measurementCalibrationPromise = Promise.resolve(); -window._measurementCalibrationCurve = null; -function loadFiles(p, callback) { - let gen = (p._lfGen = (p._lfGen||0) + 1); - let fetchTxt = base => d3.text(DIR+base+".txt").catch(()=>null); - let parseFr = f => { - if (!f) return null; - try { return Equalizer.interp(f_values, tsvParse(f)); } - catch (e) { return null; } - }; - let deliver = function (ch) { - Promise.resolve(window._measurementCalibrationPromise || Promise.resolve()).then(function () { - if (gen !== p._lfGen) { - return; - } - callback(applyMeasurementCalibrationToChannels(ch, p)); - }); - }; - let f = p.isTarget ? [fetchTxt(p.fileName)] - : d3.merge(LR.map(s => - sampnums.map(n => fetchTxt(p.fileName+" "+s+n)))); - let nSamp = sampnums.length; - - if (!p.isTarget && nSamp > 1) { - let s1 = d3.range(LR.length).map(i => i * nSamp); - let early = false; - - Promise.all(s1.map(i => f[i])).then(function (res) { - if (gen !== p._lfGen) return; - if (!res.some(x => x !== null)) return; - early = true; - let ch = new Array(LR.length * nSamp).fill(null); - s1.forEach((idx, j) => { ch[idx] = parseFr(res[j]); }); - deliver(ch); - }); - - Promise.all(f).then(function (frs) { - if (gen !== p._lfGen) return; - if (!frs.some(x => x !== null)) return; - let ch = frs.map(parseFr); - Promise.resolve(window._measurementCalibrationPromise || Promise.resolve()).then(function () { - if (gen !== p._lfGen) return; - ch = applyMeasurementCalibrationToChannels(ch, p); - if (!early) { - callback(ch); - return; - } - let hasNew = ch.some((c, i) => - c !== null && (!p.rawChannels || p.rawChannels[i] === null)); - if (!hasNew || !p.rawChannels) return; - p.rawChannels = ch; - p.smooth = undefined; - if (p.vars) p.vars[p.fileName] = ch; - smoothPhone(p); - normalizePhone(p); - updatePaths(); - updatePhoneTable(); - if (typeof eqAfterMultiSampleRawRefined === "function") { - eqAfterMultiSampleRawRefined(p); - } - }); - }); - return; - } - - Promise.all(f).then(function (frs) { - if (gen !== p._lfGen) return; - if (!frs.some(f=>f!==null)) return; - let ch = frs.map(parseFr); - deliver(ch); - }); -} -let validChannels = p => (p.channels || []).filter(c=>c!==null); -let firstPresentChannel = chs => chs && chs.find(c => c != null); -let numChannels = p => p.channels ? d3.sum(p.channels, c=>c!==null) : 0; -let notMultichannel = LR.length===1 ? p=>true : p=>p.isTarget; -let hasChannelSel = p => !notMultichannel(p) && numChannels(p)>1; -let keyExt = LR.length===1 ? 16 : 0; -let keyLeft= keyExt ? 0 : sampnums.length>1 ? 11 : 0; -if (keyLeft) d3.select(".key").style("width","17%") +function changeScaling(to) { + let btn = document.querySelector("#yscalebtn"); + let s = scales[to.toLowerCase()]; + if (!s) return; + let sc = s.h / dB.H; + dB.h = 15 * sc; + dB.y = s.y; + dB.circ.attr("cy", sm => s.h * sm); + dB.scale.attr("transform", "scale(1," + sc + ")"); + dB.mid.attrs({y: dB.y - dB.h, height: 2 * dB.h}); + dB.trans.attr("transform", dB.tr()); + if (btn) { btn.className = s.name.toLowerCase(); btn.innerHTML = s.name; } + dB.updatey(); +} +window.changeScaling = changeScaling; +doc.select("#yscalebtn").on("click", function() { + let keys = Object.keys(scales); + let i = keys.indexOf(this.className); + changeScaling(keys[(i + 1) % keys.length]); +}); -function avgCurves(curves) { - if (!curves.length) return null; - if (curves.length === 1) return curves[0]; - return curves - .map(c=>c.map(d=>Math.pow(10,d[1]/20))) - .reduce((as,bs) => as.map((a,i) => a+bs[i])) - .map((x,i) => [curves[0][i][0], 20*Math.log10(x/curves.length)]); -} -function getAvg(p) { - if (p.avg) { - return p.activeCurves && p.activeCurves[0] ? p.activeCurves[0].l : null; - } - let v = validChannels(p); - if (!v.length) return null; - return v.length===1 ? v[0] : avgCurves(v); -} -function hasImbalance(p) { - if (!p.channels || !hasChannelSel(p)) return false; - let nSide = sampnums.length; - let as = p.channels[0], bs = LR.length > 1 ? p.channels[nSide] : p.channels[1]; - if (!as || !bs) return false; - let s0=0, s1=0; - return as.some((a,i) => { - let d = a[1]-bs[i][1]; - d *= 1/(50 * Math.sqrt(1+Math.pow(a[0]/1e4,6))); - s0 = Math.max(s0+d,0); - s1 = Math.max(s1-d,0); - return Math.max(s0,s1) > max_channel_imbalance; - }); -} +// File loading, channel management, measurement init moved to src/graph-renderer.js let activePhones = []; +window.activePhones = activePhones; /** Maps init / share `fileName` to ordinal so `activePhones` matches init order after async loads. */ let initPhoneOrderIndex = new Map(); function setInitPhoneOrderFromReq(req) { @@ -1256,6 +381,7 @@ function phoneManageIdentity(p) { } let baseline0 = { p:null, l:null, fn:l=>l }, baseline = baseline0; +Object.defineProperty(window, 'baseline', { get: () => baseline, set: v => { baseline = v; }, configurable: true }); /* EQ graph markers + FR curve strokes. Marker UNSEL_/SEL_* fill/stroke: "trace", "graph", or CSS. */ const EQ_GRAPH_MARKER_HIT_PX = 28; @@ -1275,116 +401,14 @@ const EQ_GRAPH_TRACE_STROKE_SAMPLE = 1.9; const EQ_GRAPH_TRACE_STROKE_NORMAL = 2.3; const EQ_GRAPH_TRACE_STROKE_EMPH_MULT = 2; -/** Baseline compensation targets (*Comp Target / *.txt); hidden from graph by default; omitted from EQ target picks. */ -function isCompensationTargetNameMatch(p) { - if (!p) { - return false; - } - let fn = String(p.fileName || "").trim(), - full = String(p.fullName || "").trim(), - re = /comp target(\.txt)?$/i; - return re.test(fn) || re.test(full); -} - -/** Dash + stroke width per slot. `w` is the target trace stroke width in SVG user units (absolute, not added to NORMAL/SAMPLE). */ -const TARGET_TRACE_DOT_SPECS = [ - { dash: "6 3", w: 2.5, cap: "butt" }, - { dash: "18 9", w: 1.5, cap: "round" }, - { dash: "3 6", w: 2.0, cap: "round" }, - { dash: "2 3", w: 2.0, cap: "round" }, - { dash: "8 6", w: 1.0, cap: "round" }, - { dash: "10 5", w: 1.3, cap: "round" }, - { dash: "14 4 2 4", w: 1.65, cap: "round" }, - { dash: "1 5", w: 1.95, cap: "round" }, - { dash: "8 3 2 3", w: 1.4, cap: "round" }, - { dash: "4 2 1 2 8 2", w: 1.7, cap: "round" }, -]; -/** Dash styles follow list position: 1st non–comp-target in `activePhones` = slot 0, 2nd = slot 1, … */ -function refreshTargetStyleSlots() { - activePhones.forEach((q) => { - if (q && q.isTarget) { - delete q._targetStyleSlotCache; - } - }); - let n = 0; - activePhones.forEach((q) => { - if (q && q.isTarget && !isCompensationTargetNameMatch(q)) { - q._targetStyleSlotCache = n++; - } - }); - activePhones.forEach((q) => { - if (q && q.isTarget && isCompensationTargetNameMatch(q)) { - q._targetStyleSlotCache = 0; - } - }); -} -/** Graph-only stroke fade for 2nd+ targets (table/key colors unchanged). Tweak here to taste. */ -const TARGET_TRACE_OPACITY_SECOND = 0.52; -const TARGET_TRACE_OPACITY_REST = 0.38; -function graphPathOpacityForCurve(c) { - if (!c || !c.p) { - return null; - } - if (c.p.hide) { - return 0; - } - if (!c.p.isTarget) { - return null; - } - let slot = c.p._targetStyleSlotCache; - if (!Number.isFinite(slot) || slot <= 0) { - return null; - } - if (slot === 1) { - return TARGET_TRACE_OPACITY_SECOND; - } - return TARGET_TRACE_OPACITY_REST; -} -function targetTraceDotSpecSlotForPhone(phone) { - if (!phone || !phone.isTarget) { - return 0; - } - if (phone._targetStyleSlotCache != null && Number.isFinite(phone._targetStyleSlotCache)) { - return phone._targetStyleSlotCache; - } - return 0; -} -function targetTraceDotSpecForPhone(phone) { - if (!phone) { - return TARGET_TRACE_DOT_SPECS[0]; - } - let slot = targetTraceDotSpecSlotForPhone(phone); - return TARGET_TRACE_DOT_SPECS[slot % TARGET_TRACE_DOT_SPECS.length]; -} -function targetTraceStrokeWidthFromSpec(spec) { - let w = spec && typeof spec.w === "number" && Number.isFinite(spec.w) - ? spec.w - : TARGET_TRACE_DOT_SPECS[0].w; - return Math.max(0.02, w); -} -function targetTraceStrokeWidthForPhone(phone) { - return targetTraceStrokeWidthFromSpec(targetTraceDotSpecForPhone(phone)); -} -function applyTargetCurveStrokePattern(pathSel, phone) { - let spec = targetTraceDotSpecForPhone(phone); - let cap = spec.cap || "round"; - pathSel - .style("stroke-dasharray", spec.dash) - .attr("stroke-linecap", cap) - .attr("stroke-linejoin", cap === "round" ? "round" : "miter"); -} -function clearNonTargetCurveStrokePattern(pathSel) { - pathSel - .style("stroke-dasharray", null) - .attr("stroke-linecap", null) - .attr("stroke-linejoin", null); -} +// isCompensationTargetNameMatch, TARGET_TRACE_DOT_SPECS, target trace functions, color helpers moved to src/graph-renderer.js let gpath = gr.insert("g",".dBScaler") .attr("fill","none") .attr("stroke-width", EQ_GRAPH_TRACE_STROKE_NORMAL) .attr("class", "curves-g") .attr("mask","url(#graphFade)"); +window.gpath = gpath; function eqMarkerResolvePaint(spec, traceCol) { if (spec === "trace") { /* d3 / browser color parsing may call .match on strings; null/empty breaks updates. */ @@ -1418,34 +442,75 @@ let gEqFilterMarkers = gr.append("g") .attr("class", "eq-filter-markers") .attr("pointer-events", "none") .attr("mask", "url(#graphFade)"); +window.gEqFilterMarkers = gEqFilterMarkers; let gEqHoverPreview = gr.append("g") .attr("class", "eq-hover-preview") .attr("pointer-events", "none") .attr("mask", "url(#graphFade)"); +window.gEqHoverPreview = gEqHoverPreview; let gEqSoundRangeBrush = gr.insert("g", ".eq-hover-preview") .attr("class", "eq-sound-range-brush") .attr("pointer-events", "none") .attr("mask", "url(#graphFade)"); +window.gEqSoundRangeBrush = gEqSoundRangeBrush; /** Set in addExtra: redraw Sound Tools range band on graph after zoom / input changes. */ let eqSoundRangeUiHooks = { syncBrushFromInputs: () => {} }; -let updateEqFilterMarkers = () => {}; -let updateEqTraceOpacity = () => {}; -/** When Parametric EQ tab is active, hides graph traces except model / EQ / target (see addExtra). */ -let applyParametricEqGraphTraceFocus = () => {}; +window.updateEqTraceOpacity = () => {}; /** Set in addExtra: after multi-sample FR refine, sync EQ trace (loadFiles late branch has no callback). */ -let eqAfterMultiSampleRawRefined = null; -/** @type {d3.Selection|null} */ -let graphPlotHitRect = null; +window.eqAfterMultiSampleRawRefined = null; +window.scheduleLiveEqSync = () => {}; +// Graph hit-rect (mousemove/click from graphInteract; pointerdown/wheel attached below in addExtra). +// graphInteract is defined in graph-renderer.js and exposed as window.graphInteract. +let graphPlotHitRect = gr.append("rect") + .attr("class", "graph-plot-hit") + .style("touch-action", "none") + .attrs({x:pad.l,y:pad.t,width:W,height:H,opacity:0}) + .on("mousemove", graphInteract()) + .on("mouseout", () => { + if (eqGraphPointerState) { + return; + } + let plot = graphPlotHitRect && graphPlotHitRect.node(); + let ev = d3.event; + let cx = ev && typeof ev.clientX === "number" ? ev.clientX : NaN; + let cy = ev && typeof ev.clientY === "number" ? ev.clientY : NaN; + requestAnimationFrame(() => { + if (eqGraphPointerState) { + return; + } + if (plot && Number.isFinite(cx) && Number.isFinite(cy)) { + let r = plot.getBoundingClientRect(); + if (cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom) { + lastGraphPlotPointerClient = { x: cx, y: cy }; + let m = clientToGraphPlotXY(cx, cy); + if (m) { + syncEqHoverPreview(m); + } + return; + } + } + syncEqHoverPreview(null); + interactInspect ? stopInspect() : pathHL(false); + }); + }) + .on("click", graphInteract(true)); +window.graphPlotHitRect = graphPlotHitRect; /** Equalizer-tab graph: pointer gesture for add + vertical gain drag */ let eqGraphPointerState = null; +Object.defineProperty(window, 'eqGraphPointerState', { get: () => eqGraphPointerState, set: v => { eqGraphPointerState = v; }, configurable: true }); /** Last viewport client position over the graph (mousemove / drag); used to re-apply EQ hover after updateEqFilterMarkers(), e.g. when focusin on a filter field runs in a later frame. */ let lastGraphPlotPointerClient = null; +Object.defineProperty(window, 'lastGraphPlotPointerClient', { get: () => lastGraphPlotPointerClient, set: v => { lastGraphPlotPointerClient = v; }, configurable: true }); let eqGraphSkipNextClick = false; +Object.defineProperty(window, 'eqGraphSkipNextClick', { get: () => eqGraphSkipNextClick, set: v => { eqGraphSkipNextClick = v; }, configurable: true }); /** After touch on the plot, browsers emit a synthetic click; skip click-to-add so EQ graph edits are mouse-only. */ let eqGraphSuppressClickAddFromTouch = false; +Object.defineProperty(window, 'eqGraphSuppressClickAddFromTouch', { get: () => eqGraphSuppressClickAddFromTouch, set: v => { eqGraphSuppressClickAddFromTouch = v; }, configurable: true }); let eqGraphTouchSuppressClearTimer = null; +Object.defineProperty(window, 'eqGraphTouchSuppressClearTimer', { get: () => eqGraphTouchSuppressClearTimer, set: v => { eqGraphTouchSuppressClearTimer = v; }, configurable: true }); let eqGraphSkipClickClearTimer = null; +Object.defineProperty(window, 'eqGraphSkipClickClearTimer', { get: () => eqGraphSkipClickClearTimer, set: v => { eqGraphSkipClickClearTimer = v; }, configurable: true }); let eqGraphApplyEqDragTimer = null; /** Saved inline styles while EQ graph drag disables text/image selection (Safari + trackpad). */ let eqGraphDragSelectSaved = null; @@ -1492,12 +557,8 @@ function eqGraphRemoveDragSelectLock() { } /** @type {(m: number[]) => boolean} */ let tryEqGraphClickAddFilter = (_m) => false; +Object.defineProperty(window, 'tryEqGraphClickAddFilter', { get: () => tryEqGraphClickAddFilter, configurable: true }); /** @type {(m: number[] | null) => void} */ -let syncEqHoverPreview = (m) => { - if (!m && graphPlotHitRect && graphPlotHitRect.node()) { - graphPlotHitRect.node().style.cursor = ""; - } -}; let gSpectrum = gr.insert("g", ".curves-g") .attr("class", "music-spectrum-viz") .attr("pointer-events", "none") @@ -1552,6 +613,10 @@ let musicSpectrumViz = { this.rafId = requestAnimationFrame(() => this.tick()); } }; +if (typeof initMusicGraphLifecycle === "function") { + initMusicGraphLifecycle(); +} +window.musicSpectrumViz = musicSpectrumViz; /* gamma < 1: fewer polyline vertices in the lowest decades so the fill does not trace FFT leakage point-by-point (looks too gradual on a log frequency axis). */ let spectrumPathLogSampleGamma = 0.63; @@ -1630,39 +695,7 @@ function hl(p, h, sub) { } let table = doc.select(".curves"); -let ld_p1 = 1.1673039782614187; -function getCurveColor(id, o) { - let p1 = ld_p1, - p2 = p1*p1, - p3 = p2*p1; - let t = o/32; - let i=id/p3+0.76, j=id/p2+0.79, k=id/p1+0.32; - if (id < 0) { return d3.hcl(360*(1-(-i)%1),5,66); } // Target - let th = 2*Math.PI*i; - i += Math.cos(th-0.3)/24 + Math.cos(6*th)/32; - let s = Math.sin(2*Math.PI*i); - return d3.hcl(360*((i + t/p2)%1), - 88+30*(j%1 + 1.3*s - t/p3), - 36+22*(k%1 + 1.1*s + 6*t*(1-s))); -} -let getColor_AC = c => getCurveColor(c.p.id, c.o); -let getColor_ph = (p,i) => getCurveColor(p.id, p.activeCurves[i].o); -function getDivColor(id, active) { - let c = getCurveColor(id,0); - c.l = 100-(80-Math.min(c.l,60))/(active?1.5:3); - c.c = (c.c-20)/(active?3:4); - return c; -} -function color_curveToText(c) { - return c; -} -let getTooltipColor = curve => color_curveToText(getColor_AC(curve)); -let getTextColor = p => color_curveToText(getCurveColor(p.id,0)); -let getBgColor = p => { - let c=getCurveColor(p.id,0).rgb(); - ['r','g','b'].forEach(p=>c[p]=255-(255-Math.max(0,c[p]))*0.85); - return c; -} +// ld_p1, getCurveColor, color helpers moved to src/graph-renderer.js let cantCompare; let noTargets = typeof disallow_target !== "undefined" && disallow_target; @@ -1768,131 +801,6 @@ function setPhoneTr(phtr) { .on("click", p => { d3.event.stopPropagation(); removeCopies(p); }); } -let channelbox_x = c => c?-86:-36, - channelbox_tr = c => "translate("+channelbox_x(c)+",0)"; -/** Legend / lineLabel text for user-derived targets (`USRMT_*` stays internal in fullName/fileName). */ -function graphCurveLabelForPhone(p) { - if (!p || !p.userTargetFromMeasurement) { - return p ? p.fullName : ""; - } - let brand = String(p.dispBrand || "").trim(), - nm = String(p.phone || "").trim() || String(p.dispName || "").trim(); - let core = (brand && nm) ? `${brand} ${nm}`.trim() : (nm || "").trim(); - if (!core) { - core = String(p.fullName || "").replace(/^USRMT_[a-z0-9]+$/i, "").trim(); - } - if (!core) { - core = "Target"; - } - if (!/\sTarget$/i.test(core)) { - core = `${core} Target`; - } - return core; -} -function setCurves(p, avg, lr, samp) { - if (!p.channels || !p.channels.length) { - p.activeCurves = p.activeCurves || []; - return; - } - if (avg ===undefined) avg = p.avg; - if (samp===undefined) samp = avg ? false : LR.length===1||p.ssamp||false; - else { p.ssamp = samp; if (samp) avg = false; } - let dx = +avg - +p.avg, - n = LR.length ? p.channels.length / LR.length : p.channels.length, - selCh = (l,i) => l.slice(i*n,(i+1)*n); - p.avg = avg; - p.samp = samp = n>1 && samp; - if (!p.isTarget) { - let id = getChannelName(p), - v = cs => cs.filter(c=>c!==null), - cs = p.channels, - cv = v(cs), - mc = cv.length>1, - pc = (idstr, l, oi) => ({id:id(idstr), l:l, p:p, - o:oi===undefined?0:getO(oi)}); - p.activeCurves - = avg && mc ? [pc("AVG", avgCurves(cv))] - : !samp && mc ? LR.map((l,i) => pc(l, avgCurves(v(selCh(cs,i))), i)).filter(c => c.l) - : cs.map((l,i) => { - let j = Math.floor(i/n); - return pc(LR[j]+sampnums[i%n], l, j); - }).filter(c => c.l); - } else { - p.activeCurves = [{id: graphCurveLabelForPhone(p), l:p.channels[0], p:p, o:0}]; - } - let y = 0; - let k = d3.selectAll(".keyLine").filter(q=>q===p); - let ksb = k.select(".keySelBoth").attr("display","none"); - p.lr = lr; - if (lr!==undefined) { - p.activeCurves = p.samp ? selCh(p.activeCurves, lr) : [p.activeCurves[lr]]; - y = [-1,1][lr]; - ksb.attr("display",null).attr("y", [0,-12][lr]); - } - k.select(".keyMask") - .transition().duration(400) - .attr("x", channelbox_x(avg)) - .attrTween("y", function () { - let y0 = +this.getAttribute("y"), - y1 = 12*(-1+y); - if (!dx) { return d3.interpolateNumber(y0,y1); } - let ym = y0 + (y1-y0)*(3-2*dx)/6; - y0-=ym; y1-=ym; - return t => { t-=1/2; return ym+(t<0?y0:y1)*Math.pow(2,20*(Math.abs(t)-1/2)); }; - }); - k.select(".keySel").attr("transform", channelbox_tr(avg)); - k.selectAll(".keySamp").attr("opacity",(_,i)=>i===+samp?1:0.6); -} -function updateCurves() { - setCurves.apply(null, arguments); - updatePaths(); -} - -let drawLine = d => line(baseline.fn(d.l)); -function redrawLine(p) { - let getTr = o => o ? "translate(0,"+(y(o)-y(0))+")" : null; - p.attr("transform", c => getTr(getOffset(c.p))).attr("d", drawLine); -} -function updateYCenter() { - let c = yCenter; - yCenter = baseline.p ? 0 : norm_sel ? 60 : norm_phon; - y.domain(y.domain().map(d=>d+(yCenter-c))); - yAxisObj.call(fmtY); -} -function setBaseline(b, no_transition) { - baseline = b; - updateYCenter(); - if (no_transition) { - gpath.selectAll("path").attr("d", drawLine); - updateEqFilterMarkers(); - return; - } - gpath.selectAll("path") - .transition().duration(500).ease(d3.easeQuad) - .attr("d", drawLine) - .on("end", () => updateEqFilterMarkers()); - table.selectAll("tr").select(".button-baseline") - .classed("selected", d => d.p === baseline.p); - - // Analytics event - if (analyticsEnabled && b.p) { pushPhoneTag("baseline_set", b.p); } -} -function getBaseline(p) { - let b = getAvg(p).map(d => d[1]+getOffset(p)); - return { p:p, fn:l=>l.map((e,i)=>[e[0],e[1]-b[Math.min(i,b.length-1)]]) }; -} - -function setOffset(p, o) { - p.offset = +o; - if (baseline.p === p) { baseline = getBaseline(p); } - updatePaths(); -} -let getOffset = p => p.offset + p.norm; - -function setHover(elt, h) { - elt.on("mouseover", h(true)).on("mouseout", h(false)); -} - // See if iframe gets CORS error when interacting with window.top try { let emb = window.location.href.includes('embed'); @@ -1911,401 +819,43 @@ try { accessDocumentTop = false; } +if (typeof toggleExpandCollapse === "function" && typeof expandable !== "undefined" + && expandable && accessDocumentTop) { + toggleExpandCollapse(); +} + let ifURL = typeof share_url !== "undefined" && share_url; /** First `location.search` at startup — survives `history.replaceState` before `phone_book` loads (EQ params, graph `share=`, …). */ -let eqUrlShareBootstrapSearch = ""; +let __eqUrlShareBootstrapSearch = ""; try { - eqUrlShareBootstrapSearch = targetWindow && targetWindow.location + __eqUrlShareBootstrapSearch = targetWindow && targetWindow.location ? String(targetWindow.location.search || "") : ""; } catch (e) { - eqUrlShareBootstrapSearch = ""; + __eqUrlShareBootstrapSearch = ""; } -window.__eqUrlShareBootstrapSearch = eqUrlShareBootstrapSearch; /* When false, addPhonesToUrl omits graph `share=` (EQ/music/canonical still sync). Prevents multi-sample updateCurves → updatePaths() from injecting share= during config-only init; enabled after share/embed navigation or first user gesture (see phone_book callback). */ -window.__graphShareUrlSyncAllowed = !!(typeof window.__eqUrlShareBootstrapSearch === "string" - && /[?&]share=/.test(window.__eqUrlShareBootstrapSearch)); +let __graphShareUrlSyncAllowed = !!(typeof __eqUrlShareBootstrapSearch === "string" + && /[?&]share=/.test(__eqUrlShareBootstrapSearch)); let baseTitle = typeof page_title !== "undefined" ? page_title : "CrinGraph"; let baseDescription = typeof page_description !== "undefined" ? page_description : "View and compare frequency response graphs"; let baseURL; // Set by setInitPhones -/** Parametric EQ types in share URLs: index → Equalizer type string (v2 row column 2). */ -let eqShareTypeFromIx = (ix) => { - let n = Math.floor(Number(ix)); - return (n === 1) ? "LSQ" : (n === 2) ? "HSQ" : "PK"; -}; -let eqShareIxFromType = (t) => { - let s = String(t || "PK"); - if (s === "LSQ") { - return 1; - } - if (s === "HSQ") { - return 2; - } - return 0; -}; -/** ASCII `v2;` rows (much smaller than JSON before base64). Legacy `[` JSON still decoded. */ -function eqShareFiltersToV2Ascii(filters) { - let rows = filters.map((f) => { - let ti = eqShareIxFromType(f.type); - return [f.disabled ? 1 : 0, ti, f.freq, f.q, f.gain].join(","); - }); - return "v2;" + rows.join(";"); -} -function eqShareFiltersParseV2Ascii(bin) { - if (bin.indexOf("v2;") !== 0) { - return null; - } - let body = bin.slice(3); - if (!body) { - return []; - } - return body.split(";").map((row) => { - let p = row.split(","); - return { - disabled: !!Number(p[0]), - type: eqShareTypeFromIx(p[1]), - freq: Number(p[2]) || 0, - q: Number(p[3]) || 0, - gain: Number(p[4]) || 0 - }; - }); -} -/** Compact base64url for parametric EQ bands in share URLs (`eqFilters`). Prefer v2 text; legacy JSON array still supported. */ -function eqShareFiltersSerialize(filters) { - let s = eqShareFiltersToV2Ascii(filters); - let b = btoa(s); - return b.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} -function eqShareFiltersDeserialize(s) { - let pad = String(s || "").replace(/-/g, "+").replace(/_/g, "/"); - while (pad.length % 4) { - pad += "="; - } - let bin = atob(pad); - let v2 = eqShareFiltersParseV2Ascii(bin); - if (v2) { - return v2; - } - let arr = JSON.parse(bin); - if (!Array.isArray(arr)) { - return []; - } - return arr.map((x) => ( - x && typeof x === "object" && "t" in x - ? { - disabled: !!x.d, - type: x.t || "PK", - freq: Number(x.f) || 0, - q: Number(x.q) || 0, - gain: Number(x.g) || 0 - } - : { - disabled: !!x[0], - type: eqShareTypeFromIx(x[1]), - freq: Number(x[2]) || 0, - q: Number(x[3]) || 0, - gain: Number(x[4]) || 0 - } - )); -} -/** Encode model/target fullName for `eqModel` / `eqTarget`: `%20` → `_` for readable URLs (same idea as `share=`). - * Do not decode with a global `_`→space — names like `B_Media` need `applyPendingEqUrlShare` resolution - * (`eqResolveShareFullNameFromParam` + legacy segment match). */ -function eqShareFullNameToUrlParam(fullName) { - return encodeURIComponent(String(fullName || "").trim()).replace(/%20/g, "_"); -} -function eqShareUrlParamToFullName(seg) { - if (seg == null || seg === "") { - return ""; - } - /* `URLSearchParams.get` already decodes `%XX`; do not map `_`→space — breaks `B_Media`-style names. */ - return String(seg).trim(); -} -/** `URLSearchParams` only percent-decodes once. Pasted / redirected links often double-encode (e.g. `%2520` - * → `%20` left inside the value); decode until no `%HH` remains or string stabilizes. */ -function eqShareFullyDecodeQueryValue(val) { - if (val == null || val === "") { - return ""; - } - let s = String(val).trim(); - for (let n = 0; n < 8; n++) { - if (!/%[0-9A-Fa-f]{2}/i.test(s)) { - return s; - } - try { - let d = decodeURIComponent(s.replace(/\+/g, " ")); - if (d === s) { - return s; - } - s = d; - } catch (e) { - return s; - } - } - return s; -} -/** Share URL query keys (short camelCase). Legacy snake_case still accepted when parsing. */ -let EQ_URL_PARAM_MODEL = "eqModel"; -let EQ_URL_PARAM_TARGET = "eqTarget"; -let EQ_URL_PARAM_FILTERS = "eqFilters"; -let EQ_URL_PARAM_MODEL_DATA = "eqModelData"; -let EQ_URL_PARAM_TARGET_DATA = "eqTargetData"; -/** Decimated FR samples (48 points along the internal `f_values` axis) for URL-safe uploads. */ -let EQ_SHARE_FR_DECIM_STEPS = 48; -let EQ_SHARE_FR_DATA_MAX_CHARS = 16384; -/* dB·10 integers; must be wide enough for absolute SPL (e.g. 60–90 dB) from REW uploads — ±40 dB was - * clamping everything to "40.0 dB" and URLs looked flat. */ -let EQ_SHARE_FR_TENTHS_MIN = -6000; -let EQ_SHARE_FR_TENTHS_MAX = 6000; -function eqShareClampFrTenths(n) { - return Math.max(EQ_SHARE_FR_TENTHS_MIN, Math.min(EQ_SHARE_FR_TENTHS_MAX, n)); -} -function eqShareFrCurveChannelForPack(p) { - if (!p || !phoneCurveDataReadyForEq(p)) { - return null; - } - let rc = p.rawChannels; - if (!rc || !rc.length) { - return null; - } - let ch = rc.filter(Boolean)[0]; - if (!ch || ch.length < 2) { - return null; - } - /* Prefer full `f_values` grid; sparse uploads are re-interpolated like the upload path. */ - if (ch.length !== f_values.length) { - try { - ch = Equalizer.interp(f_values, ch); - } catch (e) { - return null; - } - } - return ch; -} -function eqShareDecimateFValuesSamples(fvCurve) { - let L = fvCurve.length; - let N = EQ_SHARE_FR_DECIM_STEPS; - let tenths = []; - for (let k = 0; k < N; k++) { - let ix = Math.round(k * (L - 1) / Math.max(1, N - 1)); - let db = fvCurve[ix][1]; - if (!Number.isFinite(db)) { - db = 0; - } - tenths.push(eqShareClampFrTenths(Math.round(db * 10))); - } - return tenths; -} -function eqShareFrDataSerializeFromPhone(p) { - let ch = eqShareFrCurveChannelForPack(p); - if (!ch) { - return ""; - } - let tenths = eqShareDecimateFValuesSamples(ch); - let body = "v4;" + tenths.join(","); - try { - return btoa(body).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); - } catch (e) { - return ""; - } -} -function eqShareFrDataDeserializeToTenths(b64url) { - let pad = String(b64url || "").replace(/-/g, "+").replace(/_/g, "/"); - while (pad.length % 4) { - pad += "="; - } - let bin; - try { - bin = atob(pad); - } catch (e) { - return null; - } - if (bin.indexOf("v4;") !== 0) { - return null; - } - let parts = bin.slice(3).split(",").map((x) => x.trim()).filter(Boolean); - if (parts.length < 8) { - return null; - } - let raw = parts.map((x) => { - let n = parseInt(x, 10); - if (!Number.isFinite(n)) { - return 0; - } - return eqShareClampFrTenths(n); - }); - if (raw.length === EQ_SHARE_FR_DECIM_STEPS) { - return raw; - } - /* Proxies / long URLs may truncate the param — resample any reasonable count back to 48. */ - if (raw.length > 96) { - return null; - } - let out = []; - for (let k = 0; k < EQ_SHARE_FR_DECIM_STEPS; k++) { - let u = k * (raw.length - 1) / (EQ_SHARE_FR_DECIM_STEPS - 1); - let i = Math.min(Math.floor(u), raw.length - 2); - let t = u - i; - let a = raw[i]; - let b = raw[Math.min(i + 1, raw.length - 1)]; - out.push(eqShareClampFrTenths(Math.round(a + (b - a) * t))); - } - return out; -} -function eqShareExpandTenthsToFValuesChannel(tenths) { - if (!tenths || tenths.length !== EQ_SHARE_FR_DECIM_STEPS) { - return null; - } - let L = f_values.length; - let N = tenths.length; - let out = []; - for (let j = 0; j < L; j++) { - let u = (j / Math.max(1, L - 1)) * (N - 1); - let k = Math.min(Math.floor(u), N - 1); - let t = u - k; - let k1 = Math.min(k + 1, N - 1); - let d0 = tenths[k] / 10; - let d1 = tenths[k1] / 10; - let db = d0 + (d1 - d0) * t; - out.push([f_values[j], db]); - } - return out; -} -/** Read Equalizer share params from a full page URL (`eqModel` / `eqTarget` / `eqFilters`; no `eq` flag). */ -function parseEqUrlShareParams(href) { - try { - let u = new URL(href); - let eqm = u.searchParams.get(EQ_URL_PARAM_MODEL) || u.searchParams.get("eq_model"); - let eqt = u.searchParams.get(EQ_URL_PARAM_TARGET) || u.searchParams.get("eq_target"); - let eqf = u.searchParams.get(EQ_URL_PARAM_FILTERS) || u.searchParams.get("eq_filters"); - let eqmd = u.searchParams.get(EQ_URL_PARAM_MODEL_DATA) || u.searchParams.get("eq_model_data"); - let eqtd = u.searchParams.get(EQ_URL_PARAM_TARGET_DATA) || u.searchParams.get("eq_target_data"); - if (eqm) { - eqm = eqShareFullyDecodeQueryValue(eqm); - } - if (eqt) { - eqt = eqShareFullyDecodeQueryValue(eqt); - } - if (eqf) { - eqf = eqShareFullyDecodeQueryValue(eqf); - } - if (eqmd) { - eqmd = eqShareFullyDecodeQueryValue(eqmd); - if (eqmd.length > EQ_SHARE_FR_DATA_MAX_CHARS) { - eqmd = ""; - } - } - if (eqtd) { - eqtd = eqShareFullyDecodeQueryValue(eqtd); - if (eqtd.length > EQ_SHARE_FR_DATA_MAX_CHARS) { - eqtd = ""; - } - } - if (!eqm && !eqt && !eqf && !eqmd && !eqtd) { - return null; - } - let filters = null; - if (eqf) { - try { - filters = eqShareFiltersDeserialize(eqf); - } catch (e) { - console.warn("eqFilters in URL could not be parsed", e); - } - } - return { - openEqTab: true, - model: eqm ? eqShareUrlParamToFullName(eqm) : "", - target: eqt ? eqShareUrlParamToFullName(eqt) : "", - modelData: eqmd || "", - targetData: eqtd || "", - filters: (filters && filters.length) ? filters : null - }; - } catch (e) { - return null; - } -} -/** Share URL: Apple Music catalog / iTunes store song id (preview loads via catalog or lookup). */ -let MUSIC_URL_PARAM_APPLE_SONG = "amSong"; -function parseAppleMusicSongIdFromHref(href) { - try { - let u = new URL(href); - let raw = u.searchParams.get(MUSIC_URL_PARAM_APPLE_SONG) - || u.searchParams.get("appleMusicSong"); - if (raw === null || raw === "") { - return null; - } - let id = String(raw).trim(); - if (!id || id.length > 64) { - return null; - } - if (!/^[a-zA-Z0-9._-]+$/.test(id)) { - return null; - } - return id; - } catch (e) { - return null; - } -} -/** Normalized loop/trim range (`musicSegStartU` / `musicSegEndU`, 0–1). Omitted when full track; URL order: … `amSong`, `amIn`, `amOut`. */ -let MUSIC_URL_PARAM_IN = "amIn"; -let MUSIC_URL_PARAM_OUT = "amOut"; -let MUSIC_URL_SEG_PARSE_MIN_SPAN_U = 1e-5; -function parseAppleMusicSegmentFromHref(href) { - try { - let u = new URL(href); - let rs = u.searchParams.get(MUSIC_URL_PARAM_IN) || u.searchParams.get("amSegStart"); - let re = u.searchParams.get(MUSIC_URL_PARAM_OUT) || u.searchParams.get("amSegEnd"); - if (rs === null || rs === "" || re === null || re === "") { - return null; - } - let segStartU = parseFloat(String(rs).trim()); - let segEndU = parseFloat(String(re).trim()); - if (!Number.isFinite(segStartU) || !Number.isFinite(segEndU)) { - return null; - } - segStartU = Math.max(0, Math.min(1, segStartU)); - segEndU = Math.max(0, Math.min(1, segEndU)); - if (segEndU - segStartU < MUSIC_URL_SEG_PARSE_MIN_SPAN_U || segStartU >= segEndU) { - return null; - } - return { segStartU, segEndU }; - } catch (e) { - return null; - } -} -/** `share=` payload: commas between filenames stay unescaped; each stem is encoded (spaces → "_" first). Avoids `%2C` separators from URLSearchParams. */ -function shareQueryValueForUrl(namesArr) { - return namesArr.map((fn) => encodeURIComponent(String(fn).replace(/ /g, "_"))).join(","); -} -/** Inverse of share line for initial load (`URLSearchParams` gives decoded commas). */ -function parseSharePhonesFromHref(href) { - try { - let s = new URL(href).searchParams.get("share"); - if (s === null || s === "") { - return null; - } - return String(s).split(",").map((t) => - decodeURIComponent(t.trim()).replace(/_/g, " ")); - } catch (e) { - return null; - } -} -function addPhonesToUrl() { - let names = activePhones.filter(p => !p.isDynamic).map(p => p.fileName), - namesCombined = names.join(", "); - let sel = document.querySelector("div.select"); - let onEqTab = typeof extraEQEnabled !== "undefined" && extraEQEnabled && sel - && sel.getAttribute("data-selected") === "extra"; - let ref = baseURL || targetWindow.location.pathname; - let u; - try { - /* Start from the live location so deep-link params (EQ, amSong, …) survive music/graph updates. - `baseURL` omits `?…`, so `new URL(baseURL)` would drop every existing query param. */ - u = new URL(targetWindow.location.href); - } catch (e) { - return; +function addPhonesToUrl() { + let names = activePhones.filter(p => !p.isDynamic).map(p => p.fileName), + namesCombined = names.join(", "); + let sel = document.querySelector("div.select"); + let onEqTab = typeof extraEQEnabled !== "undefined" && extraEQEnabled && sel + && sel.getAttribute("data-selected") === "extra"; + let ref = baseURL || targetWindow.location.pathname; + let u; + try { + /* Start from the live location so deep-link params (EQ, amSong, …) survive music/graph updates. + `baseURL` omits `?…`, so `new URL(baseURL)` would drop every existing query param. */ + u = new URL(targetWindow.location.href); + } catch (e) { + return; } /* Drop Apple music share keys first so we can re-append after EQ/share (`amSong` then `amIn` / `amOut` at end). */ u.searchParams.delete(MUSIC_URL_PARAM_APPLE_SONG); @@ -2334,7 +884,7 @@ function addPhonesToUrl() { title = (eqModelTit || eqTargetTit) + " - " + baseTitle; } } else if (names.length) { - if (ifURL && window.__graphShareUrlSyncAllowed) { + if (ifURL && __graphShareUrlSyncAllowed) { shareQueryPair = "share=" + shareQueryValueForUrl(names); } title = namesCombined + " - " + baseTitle; @@ -2420,6 +970,8 @@ function rebindGraphPathSelectionAndRedraw() { } if (c.p.isTarget) { applyTargetCurveStrokePattern(n, c.p); + } else if (c.p.isPrefBounds) { + n.style("stroke-dasharray", "6, 3"); } else { clearNonTargetCurveStrokePattern(n); } @@ -2432,12 +984,12 @@ function rebindGraphPathSelectionAndRedraw() { function updatePaths(trigger) { /* EQ model dropdown: removePhone + showPhone + applyEQExec each call updatePaths; every full redraw briefly rebinds opacities and can paint the compare IEM twice. Batch to one draw. */ - if (typeof window !== "undefined" && (window.__eqGraphBatchSuppressDepth | 0) > 0) { - window.__eqGraphBatchPathsPending = true; + if (typeof window !== "undefined" && (window.__eqCoord.batchSuppressDepth | 0) > 0) { + window.__eqCoord.batchPathsPending = true; return; } if (typeof window !== "undefined") { - window.__eqGraphBatchPathsPending = false; + window.__eqCoord.batchPathsPending = false; } reorderActivePhonesByInitOrder(); clusterTargetsFirstInActivePhones(); @@ -2466,540 +1018,8 @@ function phoneCurveDataReadyForEq(p) { /** Same phone ordering as the manage table (before Eq-tab row filter): unique phones in curve-walk order, then targets clustered first; with a share/config `initPhoneOrderIndex`, each segment (targets, then IEMs) is sorted by `initOrderRankForPhone` so order matches the URL. */ -function getManageTableBasePhoneOrder() { - let curvesAll = d3.merge(activePhones.map(p => p.activeCurves || [])), - phoneOrder = [], - seenP = new Set(); - curvesAll.forEach(c => { - if (!c || !c.p || seenP.has(c.p)) { - return; - } - seenP.add(c.p); - phoneOrder.push(c.p); - }); - let clustered = phonesClusteredTargetsFirst(phoneOrder); - if (!initPhoneOrderIndex.size) { - return clustered; - } - let sortSeg = (seg) => seg.slice().sort((a, b) => { - let ra = initOrderRankForPhone(a), - rb = initOrderRankForPhone(b); - if (ra == null) { - ra = 1e6 + phoneManageIdentity(a) * 1e-6; - } - if (rb == null) { - rb = 1e6 + phoneManageIdentity(b) * 1e-6; - } - if (ra !== rb) { - return ra - rb; - } - return phoneManageIdentity(a) - phoneManageIdentity(b); - }); - return sortSeg(clustered.filter((p) => p && p.isTarget)) - .concat(sortSeg(clustered.filter((p) => p && !p.isTarget))); -} -function manageTableRows() { - let phoneOrder = getManageTableBasePhoneOrder(); - let rows = []; - phoneOrder.forEach(p => { - let pid = phoneManageIdentity(p), - ac = p.activeCurves || []; - if (p.samp && ac.length > 1) { - ac.forEach((curve, i) => { - rows.push({ - p, sub: i, - key: pid + "\t" + String(curve.id) + "\ts" + i - }); - }); - } else { - let cid = ac[0] ? String(ac[0].id) : String(p.fileName || p.dispName || ""); - rows.push({ - p, sub: null, - key: pid + "\t" + cid + "\tm" - }); - } - }); - return rows; -} - -function updatePhoneTable(trigger) { - let rows = manageTableRows(); - let selTab = document.querySelector("div.select"); - let onEqManageTab = extraEnabled && extraEQEnabled && selTab - && selTab.getAttribute("data-selected") === "extra"; - if (onEqManageTab && typeof window.__getEqParametricFocusContext === "function") { - let ctx = window.__getEqParametricFocusContext(); - if (ctx && ctx.showSet) { - rows = rows.filter(r => ctx.showSet.has(r.p)); - let rank = (p) => { - if (p === ctx.targetP) { - return 0; - } - if (p === ctx.modelP) { - return 1; - } - if (p === ctx.eqP) { - return 2; - } - return 3; - }; - rows.sort((a, b) => { - let d = rank(a.p) - rank(b.p); - if (d !== 0) { - return d; - } - /* Stable tie-break: targets / models cluster */ - let ta = a.p.isTarget ? 0 : 1; - let tb = b.p.isTarget ? 0 : 1; - if (ta !== tb) { - return ta - tb; - } - return String(a.p.fullName || "").localeCompare(String(b.p.fullName || "")); - }); - rows = rows.map((r) => { - if (r.sub != null && r.sub !== 0) { - return r; - } - let out = { ...r }; - delete out.eqManageDispOverride; - if (ctx.targetP && r.p === ctx.targetP && !r.p.isTarget) { - let lab = (r.p.dispName != null && String(r.p.dispName).trim() !== "") - ? String(r.p.dispName) - : r.p.fullName; - out.eqManageDispOverride = "Target: " + lab; - } - return out; - }); - } else { - rows = []; - } - } - let trJoin = table.selectAll("tr").data(rows, r => r.key); - trJoin.exit().remove(); - - function isManageMainRow(r) { - return r.sub === null || r.sub === 0; - } - function removeRowClicked() { - let r = d3.select(d3.event.currentTarget.closest("tr")).datum(), - ac = r.p.activeCurves || []; - if (r.p.samp && ac.length > 1 && typeof r.sub === "number" && r.sub > 0) { - removeSampleRow(r.p, r.sub); - } else { - removePhone(r.p); - } - } - - let enter = trJoin.enter().append("tr") - .attr("data-filename", r => r.p.fileName) - .attr("data-phone-id", r => r.p.id) - .attr("data-manage-main", r => isManageMainRow(r) ? "1" : null); - - let nRest = 7 + (exportableGraphs ? 1 : 0), - subOnly = enter.filter(r => !isManageMainRow(r)); - subOnly.call(setHover, h => r => hl(r.p, h, r.sub)) - .style("color", r => getDivColor(r.p.id, true)); - subOnly.append("td").attr("class", "remove").text("⊗") - .attr("title", "Remove this sample") - .on("click", removeRowClicked) - .style("background-image", r => colorBar(r.p)) - .filter(r => !r.p.isTarget).append("svg").call(addColorPicker); - subOnly.append("td").attr("class", "manage-sample-row-label").attr("colspan", nRest) - .append("span").attr("class", "manage-sample-label") - .text(r => (r.p.activeCurves[r.sub] && r.p.activeCurves[r.sub].id) || ""); - - let f = enter.filter(isManageMainRow), - td = () => f.append("td"); - f.call(setHover, h => r => hl(r.p, h, r.sub)) - .style("color", r => getDivColor(r.p.id, true)); - - td().attr("class", "remove").text("⊗") - .attr("title", "Remove graph") - .on("click", removeRowClicked) - .style("background-image", colorBar) - .filter(r => !r.p.isTarget).append("svg").call(addColorPicker); - td().attr("class", "item-line item-target") - .call(s => s.filter(r => !r.p.isTarget).attr("class", "item-line item-phone") - .append("span").attr("class", "brand").text(r => r.p.dispBrand)) - .call(addModel); - td().attr("class", "curve-color").append("button") - .style("background-color", r => getCurveColor(r.p.id, 0)) - .filter(r => !r.p.isTarget).call(makeColorPicker); - td().attr("class", "channels").append("svg").datum(r => r.p).call(addKey); - td().attr("class", "levels").append("input") - .attrs({ type: "number", step: "any", value: 0 }) - .property("value", r => r.p.offset) - .on("change input", function (r) { setOffset(r.p, +this.value); }); - if (exportableGraphs) { - td().attr("class", "button button-export") - .attr("title", "Export graph") - .on("click", function (r) { - let phoneName = r.p.fullName, - channels = r.p.rawChannels, - exportContainer = document.querySelector("body"); - - channels.forEach(function (channel, i) { - if (!channel) return; - let channelNum = i + 1, - text = channel.reduce((acc, c) => { - return acc.concat([Object.values(c).join("\t")]); - }, []).join("\n"), - blob = new Blob([text], { type: "text/plain" }), - url = URL.createObjectURL(blob), - exportLink = document.createElement("a"); - - exportLink.download = phoneName + " [" + channelNum + "]" + ".txt"; - exportLink.href = url; - exportContainer.appendChild(exportLink); - exportLink.click(); - exportLink.remove(); - }); - }); - } - td().attr("class", "button button-baseline") - .attr("title", "Set as baseline") - .html("") - .on("click", r => setBaseline(r.p === baseline.p ? baseline0 - : getBaseline(r.p))); - function toggleHide(p) { - let h = p.hide, - t = table.selectAll("tr").filter(q => q.p === p && (q.sub === null || q.sub === 0)); - t.select(".keyLine").on("click", h ? null : toggleHide) - .selectAll("path,.imbalance").attr("opacity", h ? null : 0.5); - t.select(".hideIcon").classed("selected", !h); - gpath.selectAll("path").filter(c => c.p === p) - .attr("opacity", h ? null : 0); - p.hide = !h; - if (p.isTarget && isCompensationTargetNameMatch(p)) { - p.compTargetUserToggledHide = true; - } - if (labelsShown) { - clearLabels(); - drawLabels(); - } - } - td().attr("class", "button hideIcon") - .attr("title", "Hide graph") - .html("") - .on("click", r => toggleHide(r.p)); - td().attr("class", "button button-pin") - .attr("title", "Pin graph") - .attr("data-pinned", "false") - .html("") - .on("click", function (r) { - if (cantCompare(activePhones.filter(p => p.pin), 1)) return; - - if (r.p.pin) { - r.p.pin = false; - this.setAttribute("data-pinned", "false"); - } else { - r.p.pin = true; nextPN = null; - this.setAttribute("data-pinned", "true"); - } - - r.p.pin = true; nextPN = null; - d3.select(this) - .text(null).classed("button", false).on("click", null) - .insert("svg").attr("class", "pinMark") - .attr("viewBox", "0 0 280 145") - .insert("path").attrs({ - fill: "none", - "stroke-width": 30, - "stroke-linecap": "round", - d: "M265 110V25q0 -10 -10 -10H105q-24 0 -48 20l-24 20q-24 20 -2 40l18 15q24 20 42 20h100" - }); - if (!userConfigApplicationActive) setUserConfig(); - }); - - enter.merge(trJoin).select(".manage-sample-label") - .text(r => !isManageMainRow(r) && r.p.activeCurves[r.sub] - ? r.p.activeCurves[r.sub].id : ""); - /* Replacing `.phonename` text while the variant picker is open clears the picker DOM and leaves - `selectInProgress` true (blur may not fire) — fixes double-click + blank key/channel column. */ - enter.merge(trJoin).filter(isManageMainRow).each(function (r) { - let p = r.p; - if (p.selectInProgress) { - return; - } - d3.select(this).select("td.item-line .phonename") - .text(r.eqManageDispOverride != null ? r.eqManageDispOverride : p.dispName); - }); -} - -function addKey(s) { - let dim={x:-19-keyLeft, y:-12, width:65+keyLeft, height:24} - s.attr("class","keyLine").attr("viewBox",[dim.x,dim.y,dim.width,dim.height].join(" ")); - let defs = s.append("defs"); - defs.append("linearGradient").attr("id", p=>"chgrad"+p.id) - .attrs({x1:0,y1:0, x2:0,y2:1}) - .selectAll().data(p=>[0.1,0.4,0.6,0.9].map(o => - [o, getCurveColor(p.id, o<0.3?-1:o<0.7?0:1)] - )).join("stop") - .attr("offset",i=>i[0]) - .attr("stop-color",i=>i[1]); - defs.append("linearGradient").attr("id","blgrad") - .selectAll().data([0,0.25,0.31,0.69,0.75,1]).join("stop") - .attr("offset",o=>o) - .attr("stop-color",(o,i) => i==2||i==3?"white":"#333"); - let m = defs.append("mask").attr("id",p=>"chmask"+p.id); - m.append("rect").attrs(dim).attr("fill","#333"); - m.append("rect").attrs({"class":"keyMask", x:p=>channelbox_x(p.avg), y:-12, width:120, height:24, fill:"url(#blgrad)"}); - let t = s.append("g"); - t.append("path") - .attr("stroke", p => notMultichannel(p) ? getCurveColor(p.id,0) - : "url(#chgrad"+p.id+")"); - t.selectAll().data(p=>p.isTarget?[]:LR) - .join("text").attr("class","keyCLabel") - .attrs({x:17+keyExt, y:(_,i)=>12*(i-(LR.length-1)/2), - dy:"0.32em", "text-anchor":"start", "font-size":10.5}) - .text(t=>t); - t.filter(p=>p.isTarget).append("text") - .attrs(keyExt?{x:7,y:6,"text-anchor":"middle"} - :{x:17,y:0,"text-anchor":"start"}) - .attrs({dy:"0.32em", "font-size":8, fill:p=>getCurveColor(p.id,0)}) - .text("Target"); - let uchl = f => function (p) { - updateCurves(p, f(p)); hl(p,true); - } - s.append("rect").attr("class","keySelBoth") - .attrs({x:40+channelbox_x(0), width:40, height:12, - opacity:0, display:"none"}) - .on("click", uchl(p=>0)); - s.append("g").attr("class","keySel") - .attr("transform",p=>channelbox_tr(p.avg)) - .on("click", uchl(p=>!p.avg)) - .selectAll().data([0,80]).join("rect") - .attrs({x:d=>d, y:-12, width:40, height:24, opacity:0}); - let o = s.filter(p=>!notMultichannel(p)) - .selectAll().data(p=>[[p,0],[p,1]]) - .join("g").attr("class","keyOnly") - .attr("transform",pi=>"translate(25,"+[-6,6][pi[1]]+")") - .call(setHover, h => function (pi) { - let p = pi[0], cs = p.activeCurves; - if (!p.hide && cs.length===2) { - d3.event.stopPropagation(); - hl(p, h ? (c=>c===cs[pi[1]]) : true); - clearLabels(); - gpath.selectAll("path").filter(c=>c.p===p).attr("opacity",h ? (c=>c!==cs[pi[1]]?0.7:null) : null); - } - }) - .on("click", pi => updateCurves(pi[0], false, pi[1])); - o.append("rect").attrs({x:0,y:-6,width:30,height:12,opacity:0}); - o.append("text").attrs({x:0, y:0, dy:"0.28em", "text-anchor":"start", - "font-size":7.5 }) - .text("only"); - s.append("text").attr("class","imbalance") - .attrs({x:8,y:0,dy:"0.35em","font-size":10.5}) - .text("!"); - if (sampnums.length>1) { - let a = s.filter(p=>!p.isTarget); - let f = LR.length>1 ? (n=>"all "+n) : (n=>n+" samples"); - let t = a.selectAll() - .data(p=>["AVG",f(Math.floor(validChannels(p).length/LR.length))] - .map((t,i)=>[t,i===+p.samp?1:0.6])) - .join("text").attr("class","keySamp") - .attrs({x:-18.5-keyLeft, y:(_,i)=>12*(i-1/2), dy:"0.33em", - "text-anchor":"start", "font-size":7, opacity:t=>t[1] }) - .text(t=>t[0]); - a.append("rect") - .attrs({x:-19-keyLeft, y:-12, width:keyLeft?16:38, height:24, opacity:0}) - .on("click", p=>updateCurves(p, undefined, p.lr, !p.samp)); - } - updateKey(s); -} - -function updateKey(s) { - let disp = fn => e => e.attr("display",p=>fn(p)?null:"none"), - cs = hasChannelSel; - s.select(".imbalance").call(disp(hasImbalance)); - s.select(".keySel").call(disp(p=>cs(p))); - s.selectAll(".keyOnly").call(disp(pi=>cs(pi[0]))); - s.selectAll(".keyCLabel").data(p => p.channels || []).call(disp(c=>c)); - s.select("g").attr("mask",p=>cs(p)?"url(#chmask"+p.id+")":null); - let l=-17-(keyLeft?8:0); - s.select("path").attr("d", p => { - if (notMultichannel(p) || !p.channels) { - return "M"+(15+keyExt)+" 0H"+l; - } - let segs = ["M15 -6H9C0 -6,0 0,-9 0H"+l,"M"+l+" 0H-9C0 0,0 6,9 6H15"] - .filter((_,i) => p.channels[i ? sampnums.length : 0]); - return segs.length ? segs.reduce((a,b) => a+b.slice(6)) : "M"+(15+keyExt)+" 0H"+l; - }); -} - -function addModel(t) { - let n = t.append("div").attr("class","phonename") - .text(r => r.eqManageDispOverride != null ? r.eqManageDispOverride : r.p.dispName); - t.filter(r=>r.p.fileNames) - .append("div").attr("class","variants") - .call(function (s) { - s.append("svg").attr("viewBox","0 -2 10 11") - .append("path").attr("fill","currentColor") - .attr("d","M1 2L5 6L9 2L8 1L6 3Q5 4 4 3L2 1Z"); - }) - .attr("tabindex",0) // Make focusable - .on("focus", function (r) { - let p = r.p; - if (p.selectInProgress) return; - p.selectInProgress = true; - p._variantFocusStartFile = p.fileName; - if (!p.vars) p.vars = {}; - p.vars[p.fileName] = p.rawChannels; - d3.select(this) - .on("mousedown", function () { - d3.event.preventDefault(); - this.blur(); - }) - .select("path").attr("transform","translate(0,7)scale(1,-1)"); - let n = d3.select(this.parentElement).select(".phonename"); - n.text(""); - let q = p.copyOf || p, - o = q.objs || [p], - active_fns = o.map(v=>v.fileName), - vars = p.fileNames.map((f,i) => { - let j = active_fns.indexOf(f); - return j!==-1 ? o[j] : - {fileName:f, dispName:q.dispNames[i]}; - }); - let nVariantNames = n.append("div").attr("class","variant-names"); - let nVariantPopouts = n.append("div").attr("class","variant-popouts"); - let d = nVariantNames.selectAll().data(vars).join("div") - .attr("class","variantName").text(v=>v.dispName), - w = d3.max(d.nodes(), d=>d.getBoundingClientRect().width); - d.style("width",w+"px"); - d.filter(v=>v.active) - .style("cursor","initial") - .style("color", getTextColor) - .call(setHover, h => () => - table.selectAll("tr").filter(row => row.p === r.p) - .classed("highlight", h) - ); - let c = nVariantPopouts.selectAll().data(vars).join("span") - .html(" + ").attr("class","variantPopout") - .style("left",(w+5)+"px") - .style("display",v=>v.active?"none":null); - [d,c].forEach(e=>e.transition().style("top",(_,i)=>i*1.3+"em")); - /* Do not Object.assign(p,v): v can be a full on-graph variant (copyOf, id, …) and would - overwrite p.fileName etc., collapsing manage rows that key on fileName. */ - d.filter(v=>!v.active).on("mousedown", v => { - p.fileName = v.fileName; - p.dispName = v.dispName; - }); - c.on("mousedown", function (v) { - showVariant(q, v); - }); - }) - .on("blur", function endSelect(r) { - let p = r.p; - if (document.activeElement === this) return; - p.selectInProgress = false; - d3.select(this) - .on("mousedown", null) - .select("path").attr("transform", null); - let n = d3.select(this.parentElement).select(".phonename"); - n.selectAll("div") - .call(setHover, h=>p=>null) - .transition().style("top",0+"em").remove() - .end().then(()=>n.text(()=>p.dispName)); - /* Avoid changeVariant when nothing changed: blur runs on every close (e.g. picking - another model) and would re-smooth + full updatePhoneTable for no reason. */ - let startF = p._variantFocusStartFile; - delete p._variantFocusStartFile; - if (startF !== undefined && p.fileName !== startF) { - changeVariant(p, updateVariant); - } else { - updateKey(table.selectAll("tr").filter(row => row.p === p && (row.sub === null || row.sub === 0)).select(".keyLine")); - } - table.selectAll("tr").classed("highlight", false); // Prevents some glitches - }); - t.filter(r=>r.p.isTarget).append("span").text(" Target"); -} - -function updateVariant(p) { - updateKey(table.selectAll("tr").filter(r => r.p === p && (r.sub === null || r.sub === 0)).select(".keyLine")); - normalizePhone(p); - updatePaths(); - updatePhoneTable(); - d3.selectAll("#phones .phone-item,.target") - .filter((q) => q != null && q.id !== undefined) - .call(setPhoneTr); - if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - } -} -function changeVariant(p, update, trigger) { - if (!p.vars) p.vars = {}; - let fn = p.fileName, - ch = p.vars[fn]; - function set(ch) { - p.rawChannels = ch; - p.smooth = undefined; - p.vars[p.fileName] = ch; - smoothPhone(p); - /* setCurves already runs inside smoothPhone after rawChannels change */ - update(p, 0, 0, trigger); - } - if (ch) { - set(ch); - } else { - loadFiles(p, set); - } -} -function showVariant(p, c, trigger) { - if (cantCompare(activePhones)) return; - if (!p.objs) { p.objs = [p]; } - if (c !== p) { - delete c.objs; - } - p.objs.push(c); - c.active=true; c.copyOf=p; - ["brand","dispBrand","fileNames","vars","phone","fullName"].map(k=>c[k]=p[k]); - changeVariant(c, showPhone, trigger); -} - -function cpCircles(svg) { - svg.selectAll("circle") - .data(d => [[3,3,2],[6.6,4,1]].map(([cx,cy,r])=>({cx,cy,r,fill:getBgColor(d.p||d)}))) - .join("circle").attrs(d=>d); -} -function addColorPicker(svg) { - svg.attr("viewBox","0 0 9 5.3"); - svg.append("rect").attrs({x:0,y:0,width:9,height:5.3,fill:"none"}); - svg.call(cpCircles); - makeColorPicker(svg); -} -function makeColorPicker(elt) { - elt.on("click", function (d) { - let p = d.p || d; - p.id = getPhoneNumber(); - colorPhones(); - d3.event.stopPropagation(); - }); -} -function colorPhones() { - updatePaths(); - let c = p=>p.active?getDivColor(p.id,true):null; - doc.select("#phones").selectAll("div.phone-item") - .style("background",c).style("border-color",c); - let t = table.selectAll("tr").filter(r => !r.p.isTarget) - .style("color", r => c(r.p)); - t.select("button").style("background-color", r => getCurveColor(r.p.id, 0)); - t = t.call(s => s.select(".remove").style("background-image", r => colorBar(r.p)) - .select("svg").call(cpCircles)) - .filter(r => r.sub === null || r.sub === 0) - .select("td.channels"); - t.select("svg").remove(); - t.append("svg").datum(r => r.p).call(addKey); -} - -let f_values = (function() { - // Standard frequencies, all phone need to interpolate to this - let f = [20]; - let step = Math.pow(2, 1/48); // 1/48 octave - while (f[f.length-1] < 20000) { f.push(f[f.length-1] * step) } - return f; -})(); +// f_values moved to src/graph-renderer.js (function initMeasurementCalibrationFromConfig() { window._measurementCalibrationPromise = Promise.resolve(); window._measurementCalibrationCurve = null; @@ -3083,15 +1103,11 @@ function applyMeasurementCalibrationToChannels(ch, p) { }); }); } -let fr_to_ind = fr => d3.bisect(f_values, fr, 0, f_values.length-1); -function range_to_slice(xs, fn) { - let r = xs.map(v => d3.bisectLeft(f_values, x.invert(fn(v)))); - return a => a.slice(Math.max(r[0],0), r[1]+1); -} +// fr_to_ind, range_to_slice moved to src/graph-renderer.js -let norm_sel = ( default_normalization.toLowerCase() === "db" ) ? 0:1, - norm_fr = default_norm_hz, - norm_phon = default_norm_db; +let norm_sel = ( (typeof default_normalization !== "undefined" ? default_normalization : "dB").toLowerCase() === "db" ) ? 0:1, + norm_fr = typeof default_norm_hz !== "undefined" ? default_norm_hz : 500, + norm_phon = typeof default_norm_db !== "undefined" ? default_norm_db : 60; function normalizePhone(p) { let vc = validChannels(p); @@ -3136,441 +1152,63 @@ norms.select("input") }); norms.select("span").on("click", (_,i)=>setNorm(_,i,false)); -let addPhoneSet = false, // Whether add phone button was clicked - addPhoneLock= false; -function setAddButton(a) { - if (a && cantCompare(activePhones)) return false; - if (addPhoneSet !== a) { - addPhoneSet = a; - doc.select(".addPhone").classed("selected", a) - .classed("locked", addPhoneLock &= a); - } - return true; -} -doc.select(".addPhone").selectAll("td") - .on("click", ()=>setAddButton(!addPhoneSet)); -doc.select(".addLock").on("click", function () { - d3.event.preventDefault(); - let on = !addPhoneLock; - if (!setAddButton(on)) return; - if (on) { - doc.select(".addPhone").classed("locked", addPhoneLock=true); - } -}); +// Late-bound wrapper: phone-catalog.js populates window.__tableToggleHide in updatePhoneTable. +let toggleHide = (p) => { if (window.__tableToggleHide) window.__tableToggleHide(p); }; -function showPhone(p, exclusive, suppressVariant, trigger) { - if (p.isTarget && activePhones.indexOf(p)!==-1) { - removePhone(p); - return; - } - if (p.isTarget) { - exclusive = false; - } - if (addPhoneSet) { - exclusive = false; - if (!addPhoneLock || cantCompare(activePhones,1,null,true)) { - setAddButton(false); - } - } - let keep = !exclusive ? (q=>true) - : (q => q.copyOf===p || q.pin || q.isTarget!==p.isTarget); - if (cantCompare(activePhones.filter(keep),0, p)) return; - if (!p.rawChannels) { - /* User measurement clones (`USRMT_*`) always carry in-memory FR; never fetch from DIR. */ - if (p.isTarget && p.userTargetFromMeasurement && /^USRMT_/i.test(String(p.fileName || ""))) { - return; - } - let pid = p.id != null ? p.id : nextPhoneNumber(); - let items = doc.select("#phones").selectAll(".phone-item"); - let item = items.filter(q => q === p); - item.style("background", getDivColor(pid, true)) - .style("border-color", getDivColor(pid, 1)); - item.select(".phone-item-add").classed("loading", true); - if (exclusive) { - items.filter(q => q.active && q.copyOf !== p && !q.pin - && q.isTarget === p.isTarget) - .style("background", null) - .style("border-color", null); - } - loadFiles(p, function (ch) { - if (p.rawChannels) return; - item.select(".phone-item-add").classed("loading", false); - p.rawChannels = ch; - showPhone(p, exclusive, suppressVariant, trigger); - - // Scroll to selected - if (trigger) { scrollToActive(); } - - // Analytics event - if (analyticsEnabled) { pushPhoneTag("phone_displayed", p, trigger); } - }); - return; - } - smoothPhone(p); - if (p.id == null) { p.id = getPhoneNumber(); } - normalizePhone(p); p.offset=p.offset||0; - if (p.isTarget && isCompensationTargetNameMatch(p) && !p.compTargetUserToggledHide) { - p.hide = true; +loadPhoneBookCatalog().then(function (brands) { + let brandMap = window.brandMap = {}, + inits = [], + initReq = typeof init_phones !== "undefined" ? [init_phones].flat() : false; + loadFromShare = 0; + /* If early URL sync stripped the bar before pending EQ was captured, re-parse from bootstrap ?… */ + if (!window.__pendingEqUrlShareParsed && __eqUrlShareBootstrapSearch + && __eqUrlShareBootstrapSearch.length > 1) { + try { + let bootHref = targetWindow.location.origin + targetWindow.location.pathname + + __eqUrlShareBootstrapSearch; + window.__pendingEqUrlShareParsed = parseEqUrlShareParams(bootHref); + } catch (e) { /* noop */ } } - if (exclusive) { - /* Must use removePhone (not only active=false) so EQ children / p.eq links clear and - extra-EQ reset runs — the old filter-assignment left orphan EQ traces on the graph. */ - activePhones.filter((q) => !keep(q)).forEach(removePhone); - activePhones.forEach((q) => { - if (keep(q)) { - q.active = true; + + if (ifURL) { + let url = targetWindow.location.href, + par = "share="; + emb = "embed"; + baseURL = url.split("?").shift(); + /* Local music restores from IndexedDB and calls addPhonesToUrl before this callback; replaceState can drop `share=` while activePhones is still empty — rehydrate graph share from the same bootstrap snapshot EQ uses. */ + if (!url.includes(par)) { + let bootSearch = typeof __eqUrlShareBootstrapSearch === "string" + ? __eqUrlShareBootstrapSearch + : ""; + if (bootSearch && /[?&]share=/.test(bootSearch)) { + try { + url = targetWindow.location.origin + targetWindow.location.pathname + bootSearch; + } catch (e0) { /* noop */ } } - }); - if (baseline.p && !baseline.p.active) { - setBaseline(baseline0, 1); } - } - let blockedFromCompareList = !suppressVariant && !p.copyOf && p.objs && p.objs.length; - if (activePhones.indexOf(p)===-1 && !blockedFromCompareList) { - let avg = false; - if (!p.isTarget) { - let ap = activePhones.filter(p => !p.isTarget); - avg = ap.length >= 1; - if (ap.length===1 && ap[0].activeCurves.length!==1) { - setCurves(ap[0], true); + + if (url.includes(par) && url.includes(emb)) { + initReq = parseSharePhonesFromHref(url); + if (!initReq || !initReq.length) { + initReq = decodeURIComponent(url.replace(/_/g," ").split(par).pop()).split(","); } - activePhones.push(p); - } else { - activePhones.unshift(p); - } - p.active = true; - setCurves(p, avg); - } - updatePaths(trigger); - updatePhoneTable(trigger); - d3.selectAll("#phones .phone-item,.target") - .filter((p) => p != null && p.id !== undefined) - .call(setPhoneTr); - if (extraEnabled && extraEQEnabled && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)) { - let intent = (typeof window !== "undefined" && window.eqDropdownModelIntent) - ? String(window.eqDropdownModelIntent).trim() - : ""; - /* Measurements added only as EQ *target* (Target dropdown » Measurements) are still - !isTarget — do not overwrite eqLastGraphModelForEq or the model dropdown steals the - target and updateEQPhoneTargetSelect drops it from optgroups. */ - let bypass = (typeof window !== "undefined" && window._eqModelStickyBypassForShownPhoneFullName) - ? String(window._eqModelStickyBypassForShownPhoneFullName).trim() - : ""; - let suppressModelStickyForTargetMeas = !!(bypass && bypass === p.fullName); - /* Avoid late async showPhone() for the *previous* model overwriting EQ focus while a new - model is loading from the EQ dropdown (eqDropdownModelIntent). */ - /* Parallel init loads can finish out of order; do not let later fetches stomp sticky during - bulk config/share/embed once another model is already on-graph. */ - let otherModels = activePhones.filter((q) => - q && q !== p && !q.isTarget && q.fullName && !String(q.fullName).match(/ EQ$/)); - let initBulk = trigger === "config" || trigger === "share" || trigger === "embed"; - if (!suppressModelStickyForTargetMeas && (!intent || p.fullName === intent) - && !(initBulk && otherModels.length > 0)) { - window.eqLastGraphModelForEq = p.fullName; - } - if (typeof window !== "undefined" && suppressModelStickyForTargetMeas) { - window._eqModelStickyBypassForShownPhoneFullName = ""; - } - } - if (extraEnabled && extraEQEnabled && p.isTarget && p.fullName && !isCompensationTargetNameMatch(p)) { - /* init `inits.map(... showPhone(..., initMode))` can load several targets in parallel. Each - async showPhone() would otherwise stomp `eqLastGraphTargetForEq` — whichever network fetch - completes last “wins” instead of config/init order. Skip sticky updates when this target is - joining one or more targets already on-graph during bulk init (config/share/embed). */ - let otherTargets = activePhones.filter((q) => - q && q !== p && q.isTarget && q.fullName && !isCompensationTargetNameMatch(q)); - let initBulk = trigger === "config" || trigger === "share" || trigger === "embed"; - if (!(initBulk && otherTargets.length > 0)) { - window.eqLastGraphTargetForEq = p.fullName; - } - } - if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - applyParametricEqGraphTraceFocus(); - /* Parametric focus sets base opacity on paths; updatePaths() already ran updateEqTraceOpacity - earlier in showPhone — this pass must run again after applyParametric or parent/EQ A-B dims - stay cleared until something else (e.g. live A-B toggle) calls updateEqTraceOpacity. */ - updateEqTraceOpacity(); - /* manageTable Eq-tab filter reads getParametricEqTraceFocusContext — must run *after* - sticky + dropdown reconcile (otherwise an extra target click leaves the old row up). */ - updatePhoneTable(trigger); - } - /* Variant picker: focus after EQ/dropdown pass so a second updatePhoneTable does not wipe picker - DOM (was breaking first open + blanking the channel cell). Same for Models tab as EQ tab. */ - if (!suppressVariant && p.fileNames && !p.copyOf) { - let openVariantPickerLater = () => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - let vNode = table.selectAll("tr") - .filter(r => r.p === p && (r.sub === null || r.sub === 0)) - .select(".variants").node(); - if (!vNode) { - return; - } - try { - vNode.focus({ preventScroll: true }); - } catch (err) { - try { - vNode.focus(); - } catch (e2) { /* noop */ } - } - }); - }); - }; - openVariantPickerLater(); - } - if (p._eqNudgeApplyFromSelect && typeof window.eqOnPhoneDataReadyForEqUi === "function") { - window.eqOnPhoneDataReadyForEqUi(p); - p._eqNudgeApplyFromSelect = false; - } - if (!p.isTarget && alt_augment ) { augmentList(p); } - - // Apply user config view settings - if (typeof trigger !== "undefined") { - userConfigApplyViewSettings(p.fileName); - } -} - -function removeCopies(p) { - if (p.objs) { - p.objs.forEach(q=>q.active=false); - delete p.objs; - } - removePhone(p); -} - -function removePhone(p, opts) { - opts = opts || {}; - let hadEqChild = Boolean(!p.isTarget && p.eq); - /* Removing the EQ curve row (X on manage table) clears p.eqParent here before hadEqChild-style - cleanup; that path did not run eqResetParametricAfterBaseModelRemoved — filters stayed stale. - Skip when addOrUpdatePhone() replaces the same synthetic "… EQ" row during applyEQExec (old - object still has eqParent → would wrongly full-reset parametric UI). */ - let removingDedicatedEqTrace = !opts.internalEqPhoneReplace - && Boolean(!p.isTarget && p.eqParent); - /* Bump load generation so any in-flight loadFiles() for this pool object bails before - calling showPhone() — avoids the previous EQ model flashing back when its fetch - completes after the user switched away. */ - p._lfGen = (p._lfGen || 0) + 1; - if (p.eqParent) { - p.eqParent.eq = null; - p.eqParent = null; - } - if (p.eq) { - let eqP = p.eq; - p.eq = null; - eqP.eqParent = null; - eqP.active = false; - } - p.active = p.pin = false; nextPN = null; - if (typeof window !== "undefined" && p.fullName) { - if (window._eqPendingModelFullName === p.fullName) { - window._eqPendingModelFullName = ""; - } - if (window._eqPendingTargetFullName === p.fullName) { - window._eqPendingTargetFullName = ""; - } - if (window._eqModelStickyBypassForShownPhoneFullName === p.fullName) { - window._eqModelStickyBypassForShownPhoneFullName = ""; - } - } - if (p.userTargetFromMeasurement && typeof window !== "undefined" && window.brandTarget - && Array.isArray(window.brandTarget.phoneObjs)) { - let i = window.brandTarget.phoneObjs.indexOf(p); - if (i >= 0) { - window.brandTarget.phoneObjs.splice(i, 1); - } - } - activePhones = activePhones.filter(q => q.active); - if (!p.isTarget) { - let ap = activePhones.filter(p => !p.isTarget); - if (ap.length === 1) { - setCurves(ap[0], false); - } - } - updatePaths(); - if (baseline.p && !baseline.p.active) { setBaseline(baseline0); } - updatePhoneTable(); - d3.selectAll("#phones div,.target") - .filter(q=>q===(p.copyOf||p)) - .call(setPhoneTr); - if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - if ((hadEqChild || removingDedicatedEqTrace) - && typeof window.eqResetParametricAfterBaseModelRemoved === "function") { - /* EQ model dropdown: removing the previous base model already ran filter reset + apply - in the select handler. eqReset would run applyEQ again, clear eqDropdownModelIntent, - and produce an extra OG frame — targets never hit this path (hadEqChild is false). */ - let skipEqResetForModelHandoff = false; - let selEq = ""; - let intentEq = ""; - if (!p.isTarget && p.fullName && !String(p.fullName).match(/ EQ$/)) { - let eqSel = document.querySelector("div.extra-eq div.select-eq-phone-model-target select[name='phone']") - || document.querySelector("div.extra-eq select[name='phone']"); - selEq = eqSel && String(eqSel.value || "").trim(); - intentEq = (typeof window !== "undefined" && window.eqDropdownModelIntent) - ? String(window.eqDropdownModelIntent).trim() - : ""; - if ((selEq && selEq !== p.fullName) - || (intentEq && intentEq !== p.fullName)) { - skipEqResetForModelHandoff = true; - } - } - if (skipEqResetForModelHandoff) { - applyParametricEqGraphTraceFocus(); - updateEqTraceOpacity(); - } else { - window.eqResetParametricAfterBaseModelRemoved(); - } - } else { - /* updatePaths() ran applyParametricEqGraphTraceFocus *before* updateEQPhoneSelect() - rebuilt the EQ target dropdown; refresh focus once selections match remaining graph. */ - applyParametricEqGraphTraceFocus(); - updateEqTraceOpacity(); - } - updatePhoneTable(); - } -} - -function removeSampleRow(p, sub) { - if (sub === null || sub === undefined) { - removePhone(p); - return; - } - let curve = p.activeCurves[sub], - chIdx = curve ? p.channels.indexOf(curve.l) : -1; - if (chIdx >= 0) { - p.channels[chIdx] = null; - } - if (!validChannels(p).length) { - removePhone(p); - return; - } - setCurves(p, p.avg, undefined, p.ssamp); - updatePaths(); - updatePhoneTable(); - d3.selectAll("#phones div,.target") - .filter(q => q === (p.copyOf || p)) - .call(setPhoneTr); - if (baseline.p && !baseline.p.active) { - setBaseline(baseline0); - } - if (extraEnabled && extraEQEnabled && typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - } -} - -function asPhoneObj(b, p, isInit, inits) { - if (!isInit) { - isInit = _ => false; - } - let r = { brand:b, dispBrand:b.name }; - if (typeof p === "string") { - r.phone = r.fileName = p; - if (isInit(p)) inits.push(r); - } else { - r.phone = p.name; - if (p.collab) { - r.dispBrand += " x "+p.collab; - r.collab = brandMap[p.collab]; - } - let f = p.file || p.name; - if (typeof f === "string") { - r.fileName = f; - if (isInit(f)) inits.push(r); - } else { - r.fileNames = f; - r.vars = {}; - let dns = f; - if (p.suffix) { - dns = p.suffix.map( - s => p.name + (s ? " "+s : "") - ); - } else if (p.prefix) { - let reg = new RegExp("^"+p.prefix+"\s*", "i"); - dns = f.map(n => { - n = n.replace(reg, ""); - return p.name + (n.length ? " "+n : n); - }); - } - r.dispNames = dns; - r.fileName = f[0]; - r.dispName = dns[0]; - let c = r; - f.map((fn,i) => { - if (!isInit(fn)) return; - c.fileName=fn; c.dispName=dns[i]; - inits.push(c); - c = {copyOf:r}; - }); - } - } - r.dispName = r.dispName || r.phone; - r.fullName = r.dispBrand + " " + r.phone; - if (alt_augment) { - r.reviewScore = p.reviewScore; - r.reviewLink = p.reviewLink; - r.shopLink = p.shopLink; - r.price = p.price; - } - return r; -} - -d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK - : DIR+"phone_book.json?"+ new Date().getTime()).then(function (brands) { - let brandMap = window.brandMap = {}, - inits = [], - initReq = typeof init_phones !== "undefined" ? [init_phones].flat() : false; - loadFromShare = 0; - /* If early URL sync stripped the bar before pending EQ was captured, re-parse from bootstrap ?… */ - if (!window.__pendingEqUrlShareParsed && window.__eqUrlShareBootstrapSearch - && window.__eqUrlShareBootstrapSearch.length > 1) { - try { - let bootHref = targetWindow.location.origin + targetWindow.location.pathname - + window.__eqUrlShareBootstrapSearch; - window.__pendingEqUrlShareParsed = parseEqUrlShareParams(bootHref); - } catch (e) { /* noop */ } - } - - if (ifURL) { - let url = targetWindow.location.href, - par = "share="; - emb = "embed"; - baseURL = url.split("?").shift(); - /* Local music restores from IndexedDB and calls addPhonesToUrl before this callback; replaceState can drop `share=` while activePhones is still empty — rehydrate graph share from the same bootstrap snapshot EQ uses. */ - if (!url.includes(par)) { - let bootSearch = typeof window.__eqUrlShareBootstrapSearch === "string" - ? window.__eqUrlShareBootstrapSearch - : ""; - if (bootSearch && /[?&]share=/.test(bootSearch)) { - try { - url = targetWindow.location.origin + targetWindow.location.pathname + bootSearch; - } catch (e0) { /* noop */ } - } - } - - if (url.includes(par) && url.includes(emb)) { - initReq = parseSharePhonesFromHref(url); - if (!initReq || !initReq.length) { - initReq = decodeURIComponent(url.replace(/_/g," ").split(par).pop()).split(","); - } - loadFromShare = 2; - - setModeEmbed(); - } else if (url.includes(par)) { - initReq = parseSharePhonesFromHref(url); - if (!initReq || !initReq.length) { - initReq = decodeURIComponent(url.replace(/_/g," ").split(par).pop()).split(","); - } - loadFromShare = 1; - } else if (url.includes(emb)) { - setModeEmbed(); + loadFromShare = 2; + + setModeEmbed(); + } else if (url.includes(par)) { + initReq = parseSharePhonesFromHref(url); + if (!initReq || !initReq.length) { + initReq = decodeURIComponent(url.replace(/_/g," ").split(par).pop()).split(","); + } + loadFromShare = 1; + } else if (url.includes(emb)) { + setModeEmbed(); } if (loadFromShare) { - window.__graphShareUrlSyncAllowed = true; - } else if (!window.__graphShareUrlSyncAllowed) { + __graphShareUrlSyncAllowed = true; + } else if (!__graphShareUrlSyncAllowed) { let armGraphShareUrlSync = () => { - window.__graphShareUrlSyncAllowed = true; + __graphShareUrlSyncAllowed = true; document.removeEventListener("pointerdown", armGraphShareUrlSync, true); document.removeEventListener("keydown", armGraphShareUrlSync, true); }; @@ -3611,71 +1249,17 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK }); }); - let allPhones = window.allPhones = d3.merge(brands.map(b=>b.phoneObjs)), - currentBrands = []; + let allPhones = window.allPhones = d3.merge(brands.map(b=>b.phoneObjs)); if (!initReq) inits.push(allPhones[0]); - function setClicks(fn) { return function (elt) { - elt .on("mousedown", () => d3.event.preventDefault()) - .on("click", p => fn(p,!d3.event.ctrlKey)) - .on("auxclick", p => d3.event.button===1 ? fn(p,0) : 0); - }; } - - let brandSel = doc.select("#brands").selectAll() - .data(brands).join("div") - .text(b => b.name + (b.suffix?" "+b.suffix:"")) - .call(setClicks(setBrand)); - - let bg = (h,fn) => function (p) { - d3.select(this).style("background", fn(p)); - (p.objs||[p]).forEach(q=>hl(q,h)); - } - window.updatePhoneSelect = () => { - doc.select("#phones").selectAll("div.phone-item") - .data(allPhones) - .join((enter) => { - let phoneDiv = enter.append("div") - .attr("class","phone-item") - .attr("name", p=>p.fullName) - .on("mouseover", bg(true, p => getDivColor(p.id===undefined?nextPhoneNumber():p.id, true))) - .on("mouseout" , bg(false,p => p.id!==undefined?getDivColor(p.id,p.active):null)) - .call(setClicks(showPhone)); - phoneDiv.append("span").text(p=>p.fullName); - // Adding the + selection button - phoneDiv.append("div") - .attr("class", "phone-item-add") - .on("click", p => { - d3.event.stopPropagation(); - showPhone(p, 0); - }); - }); - }; - updatePhoneSelect(); - - if (targets) { - let b = window.brandTarget = { name:"Targets", active:false }, - ti = -targets.length, - ph = t => ({ - isTarget:true, brand:b, - dispName:t, phone:t, fullName:t+" Target", fileName:t+" Target" - }); - d3.select(".manage").insert("div",".manageTable") - .attr("class", "targets collapseTools"); - let l = (text,c) => s => s.append("div").attr("class","targetLabel").append("span").text(text); - let ts = b.phoneObjs = doc.select(".targets").call(l("Targets")) - .selectAll().data(targets).join("div").call(l(t=>t.type)) - .style("flex-grow", t => t.files.length) - .attr("class","targetClass") - .attr("data-target-type", t => t.type) - .selectAll().data(t=>t.files.map(ph)) - .join("div").text(t=>t.dispName).attr("class","target") - .call(setClicks(showPhone)) - .data(); - ts.forEach((t,i) => { - t.id = i-ts.length; - if (isInit(t.fileName)) inits.push(t); - }); - } + initPhoneSelectorUi({ + brands: brands, + allPhones: allPhones, + targets: typeof targets !== "undefined" ? targets : null, + isInit: isInit, + inits: inits, + showPhone: showPhone + }); if (initReq && Array.isArray(initReq) && initReq.length) { inits.sort((a, b) => { @@ -3687,83 +1271,12 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK }); } - inits.map(p => p.copyOf ? showVariant(p.copyOf, p, initMode) - : showPhone(p,0,1, initMode)); - - function setBrand(b, exclusive) { - let phoneSel = doc.select("#phones").selectAll("div.phone-item"); - let incl = currentBrands.indexOf(b) !== -1; - let hasBrand = (p,b) => p.brand===b || p.collab===b; - if (exclusive || currentBrands.length===0) { - currentBrands.forEach(br => br.active = false); - if (incl) { - currentBrands = []; - phoneSel.style("display", null); - phoneSel.select("span").text(p=>p.fullName); - } else { - currentBrands = [b]; - phoneSel.style("display", p => hasBrand(p,b)?null:"none"); - phoneSel.filter(p => hasBrand(p,b)).select("span").text(p=>p.phone); - } - } else { - if (incl) return; - if (currentBrands.length === 1) { - phoneSel.select("span").text(p=>p.fullName); - } - currentBrands.push(b); - phoneSel.filter(p => hasBrand(p,b)).style("display", null); - } - if (!incl) b.active = true; - brandSel.classed("active", br => br.active); + if (typeof default_y_scale !== "undefined" && default_y_scale && scales[default_y_scale.toLowerCase()]) { + changeScaling(default_y_scale); } - let phoneSearch = new Fuse( - allPhones, - { - shouldSort: false, - tokenize: false, - threshold: 0.2, - minMatchCharLength: 2, - keys: [ - {weight:0.3, name:"dispBrand"}, - {weight:0.1, name:"brand.suffix"}, - {weight:0.6, name:"phone"} - ] - } - ); - let brandSearch = new Fuse( - brands, - { - shouldSort: false, - tokenize: false, - threshold: 0.05, - minMatchCharLength: 3, - keys: [ - {weight:0.9, name:"name"}, - {weight:0.1, name:"suffix"}, - ] - } - ); - doc.select(".search").on("input", function () { - //d3.select(this).attr("placeholder",null); - let fn, bl = brands; - let c = currentBrands; - let test = p => c.indexOf(p.brand )!==-1 - || c.indexOf(p.collab)!==-1; - if (this.value.length > 1) { - let s = phoneSearch.search(this.value), - t = c.length ? s.filter(test) : s; - if (t.length) s = t; - fn = p => s.indexOf(p)!==-1; - let b = brandSearch.search(this.value); - if (b.length) bl = b; - } else { - fn = c.length ? test : (p=>true); - } - let phoneSel = doc.select("#phones").selectAll("div.phone-item"); - phoneSel.style("display", p => fn(p)?null:"none"); - brandSel.style("display", b => bl.indexOf(b)!==-1?null:"none"); - }); + inits.map(p => p.copyOf ? showVariant(p.copyOf, p, initMode) + : showPhone(p,0,1, initMode)); doc.select("#recolor").on("click", function () { allPhones.forEach(p => { if (!p.isTarget) { delete p.id; } }); @@ -3782,234 +1295,32 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK window.applyPendingEqUrlShare(0); } }, 0); -}); -let pathHoverTimeout; -function pathHL(c, m, imm) { - gpath.selectAll("path").classed("highlight", c ? d=>d===c : false); - table.selectAll("tr").classed("highlight", c ? r => { - if (r.p !== c.p) return false; - if (r.sub === null || r.sub === undefined) return true; - return c === r.p.activeCurves[r.sub]; - } : false); - if (pathHoverTimeout) { clearTimeout(pathHoverTimeout); } - if(!stickyLabels) { - clearLabels(); - pathHoverTimeout = - imm ? pathTooltip(c, m) : - c ? setTimeout(pathTooltip, 400, c, m) : - undefined; - } -} -function pathTooltip(c, m) { - let g = gr.selectAll(".lineLabel").data([c.id]) - .join("g").attr("class","lineLabel") - .attr("pointer-events", "none"); - let t = g.append("text") - .attrs({x:m[0], y:m[1]-6, fill:getTooltipColor(c)}) - .text(t=>t); - let b = t.node().getBBox(), - o = pad.l+W - b.width; - if (o < b.x) { t.attr("x",o); b.x=o; } - // Background - g.insert("rect", "text") - .attrs({x:b.x-1, y:b.y-1, width:b.width+2, height:b.height+2}); -} -let interactInspect = false; -let graphInteract = imm => function () { - /* EQ graph drag uses Pointer Events on document + pointer capture; d3 mousemove still fires on - Safari/trackpad with coordinates that can disagree with the pointer stream, which fights - syncEqHoverPreview and pathHL (strobe on nearest-curve highlight). Ignore synthetic mouse path - for the whole gesture. */ - if (eqGraphPointerState) { - return; - } - let ev = d3.event; - if (ev && typeof ev.clientX === "number" && typeof ev.clientY === "number") { - lastGraphPlotPointerClient = { x: ev.clientX, y: ev.clientY }; - } - let cs = curvesPhonesFirstForPointer(d3.merge(activePhones.map(p=>p.hide?[]:(p.activeCurves||[])))); - let m = d3.mouse(this); - if (!cs.length) { - syncEqHoverPreview(null); - return; - } - if (imm && eqGraphSkipNextClick) { - eqGraphSkipNextClick = false; - if (eqGraphSkipClickClearTimer) { - clearTimeout(eqGraphSkipClickClearTimer); - eqGraphSkipClickClearTimer = null; - } - return; - } - if (imm && eqGraphSuppressClickAddFromTouch) { - eqGraphSuppressClickAddFromTouch = false; - if (eqGraphTouchSuppressClearTimer) { - clearTimeout(eqGraphTouchSuppressClearTimer); - eqGraphTouchSuppressClearTimer = null; - } - } else if (imm && !interactInspect && tryEqGraphClickAddFilter(m)) { - syncEqHoverPreview(m); - return; - } - syncEqHoverPreview(m); - if (interactInspect) { - let ind = fr_to_ind(x.invert(m[0])), - x1 = x(f_values[ind]), - x0 = ind>0 ? x(f_values[ind-1]) : x1, - sel= m[0]-x0 < x1-m[0], - xv = sel ? x0 : x1; - ind -= sel; - function init(e) { - e.attr("class","inspector"); - e.append("line").attrs({x1:0,x2:0, y1:pad.t,y2:pad.t+H}); - e.append("text").attr("class","insp_dB").attr("x",2); - } - let insp = gr.selectAll(".inspector").data([xv]) - .join(enter => enter.append("g").call(init)) - .attr("transform",xv=>"translate("+xv+",0)"); - let dB = insp.select(".insp_dB").text(f_values[ind]+" Hz"); - let cy = cs.map(c => [c, baseline.fn(c.l)[ind][1]+getOffset(c.p)]); - cy.sort((d,e) => d[1]-e[1]); - function newTooltip(t) { - t.attr("class","lineLabel") - .attr("pointer-events", "none") - .attr("fill",d=>getTooltipColor(d)); - t.append("text").attr("x",2).text(d=>d.id); - t.append("g").selectAll().data([0,1]) - .join("text") - .attr("x",-16) - .attr("text-anchor",i=>i?"start":"end"); - t.datum(function(){return this.getBBox();}); - t.insert("rect", "text") - .attrs(b=>({x:b.x-1, y:b.y-1, width:b.width+2, height:b.height+2})); - } - let tt = insp.selectAll(".lineLabel").data(cy.map(d=>d[0]), d=>d.id) - .join(enter => enter.insert("g","line").call(newTooltip)); - let start = tt.select("g").datum((_,i) => cy[i][1]) - .selectAll("text").data(d => { - let s=d<-0.05?"-":""; d=Math.abs(d)+0.05; - return [s+Math.floor(d)+".",Math.floor((d%1)*10)]; - }) - .text(t=>t) - .filter((_,i)=>i===0) - .nodes().map(n=>n.getBBox().x-2); - tt.select("rect") - .attrs((b,i)=>({x:b.x+start[i]-1, width:b.width-start[i]+2})); - // Now compute heights - let hm = d3.max(tt.data().map(b=>b.height)), - hh = (y.invert(0)-y.invert(hm-1))/2, - stack = []; - cy.map(d=>d[1]).forEach(function (h,i) { - let n = 1; - let overlap = s => h/n - s.h/s.n <= hh*(s.n+n); - let l = stack.length; - while (l && overlap(stack[--l])) { - let s = stack.pop(); - h += s.h; n += s.n; - } - stack.push({h:h, n:n}); - }); - let ch = d3.merge(stack.map((s,i) => { - let h = s.h/s.n - (s.n-1)*hh; - return d3.range(s.n).map(k => h+k*2*hh); - })); - tt.attr("transform",(_,i) => "translate(0,"+(y(ch[i])+5)+")"); - dB.attr("y", y(ch[ch.length-1]+2*hh)+1); - } else { - let d = 30 * W0 / gr.node().getBoundingClientRect().width, - sl= range_to_slice([-1,1],s=>m[0]+d*s); - let ind = cs - .map(c => - sl(baseline.fn(c.l)) - .map(p => Math.hypot(x(p[0])-m[0], y(p[1]+getOffset(c.p))-m[1])) - .reduce((a,b)=>Math.min(a,b), d) - ) - .reduce((a,b,i) => b { - if (eqGraphPointerState) { - return; - } - /* After pointer capture release, some browsers emit mouseout even though the cursor is - still over the plot; defer and re-hit-test so EQ hover / path highlight stay in sync. */ - let plot = graphPlotHitRect && graphPlotHitRect.node(); - let ev = d3.event; - let cx = ev && typeof ev.clientX === "number" ? ev.clientX : NaN; - let cy = ev && typeof ev.clientY === "number" ? ev.clientY : NaN; - requestAnimationFrame(() => { - if (eqGraphPointerState) { - return; - } - if (plot && Number.isFinite(cx) && Number.isFinite(cy)) { - let r = plot.getBoundingClientRect(); - if (cx >= r.left && cx <= r.right && cy >= r.top && cy <= r.bottom) { - lastGraphPlotPointerClient = { x: cx, y: cy }; - let m = clientToGraphPlotXY(cx, cy); - if (m) { - syncEqHoverPreview(m); - } - return; - } - } - syncEqHoverPreview(null); - interactInspect ? stopInspect() : pathHL(false); + if (typeof tiltableTargets !== "undefined" && tiltableTargets && tiltableTargets.length > 0 + && window.brandTarget) { + GraphToolPlugin._call('tiltReady', { + doc: doc, + showPhone: showPhone, + removePhone: removePhone, + setBaseline: setBaseline, + getBaseline: getBaseline, + baseline0: baseline0, + setCurves: setCurves, + updatePaths: updatePaths, + toggleHide: toggleHide, + drawLabels: drawLabels, + smoothPhone: smoothPhone, + normalizePhone: normalizePhone, + loadFiles: loadFiles, + activePhones: () => activePhones, + baseline: () => baseline, + f_values: f_values, + Equalizer: Equalizer, + LR: LR, + tsvParse: tsvParse, }); - }) - .on("click", graphInteract(true)); -gEqSoundRangeBrush.raise(); -gEqFilterMarkers.raise(); -gEqHoverPreview.raise(); - -/** SVG user-space [x,y] matching d3.mouse(plot rect); works with native event listeners (d3.mouse does not). */ -function clientToGraphPlotXY(clientX, clientY) { - let plot = graphPlotHitRect && graphPlotHitRect.node(); - if (!plot) { - return null; - } - let svg = plot.ownerSVGElement || (plot.closest && plot.closest("svg")); - if (!svg || !svg.createSVGPoint) { - return null; } - let ctm = svg.getScreenCTM(); - if (!ctm) { - return null; - } - let pt = svg.createSVGPoint(); - pt.x = clientX; - pt.y = clientY; - let p = pt.matrixTransform(ctm.inverse()); - return [p.x, p.y]; -} - -/** Inverse of clientToGraphPlotXY: graph SVG coords → viewport client pixels. */ -function graphPlotXYToClient(svgX, svgY) { - let plot = graphPlotHitRect && graphPlotHitRect.node(); - if (!plot) { - return null; - } - let svg = plot.ownerSVGElement || (plot.closest && plot.closest("svg")); - if (!svg || !svg.createSVGPoint) { - return null; - } - let ctm = svg.getScreenCTM(); - if (!ctm) { - return null; - } - let pt = svg.createSVGPoint(); - pt.x = svgX; - pt.y = svgY; - let p = pt.matrixTransform(ctm); - return [p.x, p.y]; -} +}); doc.select("#inspector").on("click", function () { clearLabels(); @@ -4094,7 +1405,7 @@ function themeChooser(command) { themeButton.setAttribute("current-theme", themePref); } -if ( themingEnabled ) { +if ( typeof themingEnabled !== "undefined" && themingEnabled ) { let themeButton = document.createElement("button"), miscTools = document.querySelector("div.miscTools"); @@ -4348,10897 +1659,7 @@ blurFocus(); // Add extra feature function addExtra() { - let extraButton = document.querySelector("div.select > div.selector-tabs > button.extra"); - // Disable functions by config - if (!extraEnabled) { - extraButton.remove(); - return; - } - if (!extraUploadEnabled) { - document.querySelector("div.extra-panel > div.extra-upload").style["display"] = "none"; - } - if (!extraEQEnabled) { - document.querySelector("div.extra-panel > div.extra-eq").style["display"] = "none"; - } else { - let eqHistWrap = document.getElementById("extra-eq-change-history"); - if (eqHistWrap) { - eqHistWrap.hidden = false; - } - } - if (!extraToneGeneratorEnabled) { - document.querySelector("div.extra-panel div.extra-tone-generator").style["display"] = "none"; - } - if (typeof extraPinkNoiseEnabled !== "undefined" && !extraPinkNoiseEnabled) { - document.querySelector("div.extra-panel div.extra-pink-noise").style["display"] = "none"; - } - if (typeof extraMusicEnabled !== "undefined" && !extraMusicEnabled) { - document.querySelector("div.extra-panel div.extra-music").style["display"] = "none"; - } - /* Omitted `extraMusicEnabled` = same as true (show music block). Apple search / deep links / URL - sync must follow that; only an explicit `false` turns music off. */ - let extraMusicAllowsAppleFeatures = typeof extraMusicEnabled === "undefined" || !!extraMusicEnabled; - /** Space toggles this source; Shift+Space cycles Music → Pink → Tone (skips Music if no track). */ - let activeLiveSoundPlayer = "pink"; - let liveSoundPlayersCycleOrder = () => { - let hasMusic = !!(typeof musicFileLoaded !== "undefined" && musicFileLoaded - && typeof musicPlayButton !== "undefined" && musicPlayButton - && typeof musicAudio !== "undefined" && musicAudio); - return hasMusic ? ["music", "pink", "tone"] : ["pink", "tone"]; - }; - let ensureActiveLiveSoundPlayerValid = () => { - let order = liveSoundPlayersCycleOrder(); - if (order.indexOf(activeLiveSoundPlayer) < 0) { - activeLiveSoundPlayer = order[0]; - } - }; - let cycleActiveLiveSoundPlayerShiftSpace = () => { - ensureActiveLiveSoundPlayerValid(); - let order = liveSoundPlayersCycleOrder(); - let i = Math.max(0, order.indexOf(activeLiveSoundPlayer)); - activeLiveSoundPlayer = order[(i + 1) % order.length]; - }; - // Show and hide extra panel - window.showExtraPanel = () => { - document.querySelector("div.select > div.selector-panel").style["display"] = "none"; - document.querySelector("div.select > div.extra-panel").style["display"] = "flex"; - document.querySelector("div.select").setAttribute("data-selected", "extra"); - if (analyticsEnabled) { pushEventTag("clicked_equalizerTab", targetWindow); } - if (typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - } - applyParametricEqGraphTraceFocus(); - updateEqTraceOpacity(); - updateEqFilterMarkers(); - eqSoundRangeUiHooks.syncBrushFromInputs(); - updatePhoneTable(); - if (typeof window.publishEqUiState === "function") { - window.publishEqUiState("showExtraPanel"); - } - if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { - addPhonesToUrl(); - } - if (typeof musicFileLoaded !== "undefined" && musicFileLoaded - && typeof musicPlayButton !== "undefined" && musicPlayButton - && typeof musicAudio !== "undefined" && musicAudio) { - activeLiveSoundPlayer = "music"; - } else { - ensureActiveLiveSoundPlayerValid(); - } - }; - extraButton.addEventListener("click", showExtraPanel); - // Upload function - let uploadType = null; - let fileFR = document.querySelector("#file-fr"); - document.querySelector("div.extra-upload button.upload-fr").addEventListener("click", () => { - uploadType = "fr"; - fileFR.click(); - }); - document.querySelector("div.extra-upload button.upload-target").addEventListener("click", () => { - uploadType = "target"; - fileFR.click(); - }); - let addOrUpdatePhone = (brand, phone, ch) => { - let phoneObj = asPhoneObj(brand, phone); - phoneObj.rawChannels = ch; - phoneObj.isDynamic = true; - let phoneObjs = brand.phoneObjs; - let oldPhoneObj = phoneObjs.filter(p => p.phone == phone.name)[0] - if (oldPhoneObj) { - oldPhoneObj.active && removePhone(oldPhoneObj, { internalEqPhoneReplace: true }); - phoneObj.id = oldPhoneObj.id; - phoneObjs[phoneObjs.indexOf(oldPhoneObj)] = phoneObj; - allPhones[allPhones.indexOf(oldPhoneObj)] = phoneObj; - } else { - brand.phones.push(phone); - phoneObjs.push(phoneObj); - allPhones.push(phoneObj); - } - updatePhoneSelect(); - return phoneObj; - }; - fileFR.addEventListener("change", (e) => { - let file = e.target.files[0]; - if (!file) { - return; - } - let reader = new FileReader(); - reader.onload = (ev) => { - try { - let name = file.name.replace(/\.[^\.]+$/, ""); - let phone = { name: name }; - let ch = [tsvParse(ev.target.result)]; - if (ch[0].length < 128) { - alert("Parse frequence response file failed: invalid format."); - return; - } - ch[0] = Equalizer.interp(f_values, ch[0]); - let selTabUpload = document.querySelector("div.select"); - let eqTabActive = extraEnabled && extraEQEnabled && selTabUpload - && selTabUpload.getAttribute("data-selected") === "extra"; - if (uploadType === "fr") { - name.match(/ R$/) && ch.splice(0, 0, null); - let phoneObj = addOrUpdatePhone(brandMap.Uploaded, phone, ch); - if (eqTabActive && typeof window !== "undefined") { - window.eqDropdownModelIntent = phoneObj.fullName; - } - showPhone(phoneObj, false); - } else if (uploadType === "target") { - let bt = typeof window !== "undefined" ? window.brandTarget : null; - if (!bt || !Array.isArray(bt.phoneObjs)) { - alert("Target catalog is not available."); - return; - } - let fullName = name + (name.match(/ Target$/i) ? "" : " Target"); - let existsTargets = (typeof targets !== "undefined" && targets) - ? targets.reduce((a, b) => a.concat(b.files), []).map((f) => f += " Target") - : []; - if (existsTargets.indexOf(fullName) >= 0 - || bt.phoneObjs.some((p) => p && p.fullName === fullName)) { - alert("This target already exists on this tool, please select it instead of upload."); - return; - } - let phoneObj = { - isTarget: true, - brand: bt, - dispName: name, - phone: name, - fullName: fullName, - fileName: fullName, - rawChannels: ch, - isDynamic: true, - id: -bt.phoneObjs.length - }; - bt.phoneObjs.push(phoneObj); - if (eqTabActive && typeof window !== "undefined") { - window.eqDropdownTargetIntent = phoneObj.fullName; - } - showPhone(phoneObj, true); - } - if (eqTabActive) { - /* Upload should update EQ share URL immediately, without waiting for a filter edit. - Rebuild EQ selects first so _appendEqShareParams sees the new model/target values. */ - requestAnimationFrame(() => { - if (typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - } - if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { - addPhonesToUrl(); - } - }); - } - } finally { - /* Same path selected twice does not fire `change` unless the input is cleared. */ - fileFR.value = ""; - } - }; - reader.onerror = () => { - fileFR.value = ""; - }; - reader.readAsText(file); - }); - // EQ Function (prefer model row so we never bind the wrong option rebuild the DOM value can be empty for a beat; - intent/sticky are set synchronously on input and must win. */ - let key = intent || sel || sticky; - if (key) { - let hit = eqMeasurementObjForSelect(key); - if (hit) { - return hit; - } - /* Do not fall back to activePhones[0]. With compare + EQ-only models on graph, a miss - (or empty sel) used to resolve to the first graphed IEM (e.g. original 634ears) and - applyParametricEqGraphTraceFocus briefly treated it as the EQ model — "flash twice". */ - return null; - } - let ord = getManageTableBasePhoneOrder(); - for (let i = 0; i < ord.length; i++) { - let p = ord[i]; - if (p && !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)) { - return p; - } - } - return null; - }; - /** After synthesizing `USRMT_*`, drop the source measurement trace unless it is the active EQ model row. */ - let removeMeasurementIfSupersededByUserTarget = (meas) => { - if (!meas || meas.isTarget || (meas.fullName && String(meas.fullName).match(/ EQ$/))) { - return; - } - if (activePhones.indexOf(meas) === -1) { - return; - } - let mdl = resolveEqModelPhone(); - if (mdl && meas.fullName === mdl.fullName) { - return; - } - removePhone(meas); - }; - /** Comparison trace: explicit `eq-target` / catalog / graphed target — never another on-graph IEM as a fake "target". */ - let resolveEqTargetPhone = (modelP, tsel) => { - if (!modelP) { - return null; - } - let tselTrim = tsel ? String(tsel).trim() : ""; - let fromSel = tselTrim ? eqFindByFullNameAny(tselTrim) : null; - if (fromSel && !isCompensationTargetNameMatch(fromSel)) { - /* Path curves use whatever object instance is bound in activePhones — catalog pool hits - from `window.allPhones` can be a different reference with the same fullName, so parametric focus misses. */ - let key = String(fromSel.fullName || "").trim(), - graphCanon = activePhones.filter((q) => q && q.fullName === key)[0]; - return graphCanon || fromSel; - } - let catT = eqCatalogTargetsForEqUi().slice().sort((a, b) => String(a.fullName).localeCompare(String(b.fullName))); - /* Prefer a target already on the graph (manage-table order: targets first in row order). */ - let onGraphT = (() => { - let ord = getManageTableBasePhoneOrder(); - for (let i = 0; i < ord.length; i++) { - let p = ord[i]; - if (p && p.isTarget && !isCompensationTargetNameMatch(p)) { - return p; - } - } - return null; - })(); - return onGraphT || catT[0] || null; - }; - let getParametricEqTraceFocusContext = () => { - let tab = document.querySelector("div.select"); - if (!extraEnabled || !extraEQEnabled || !tab || tab.getAttribute("data-selected") !== "extra") { - return null; - } - let explicitModel = eqPhoneSelect && String(eqPhoneSelect.value || "").trim(); - let pendM = (typeof window !== "undefined" && window._eqPendingModelFullName) - ? String(window._eqPendingModelFullName).trim() - : ""; - if (!explicitModel && !pendM) { - return null; - } - let modelP = resolveEqModelPhone(); - if (!modelP) { - return null; - } - let tsel = (eqPhoneTargetSelect && String(eqPhoneTargetSelect.value || "").trim()) - || (eqPhoneTargetSelect && String(eqPhoneTargetSelect.dataset.eqLastTarget || "").trim()) - || (typeof window !== "undefined" ? String(window.eqLastGraphTargetForEq || "").trim() : ""); - let targetP = resolveEqTargetPhone(modelP, tsel); - let eqP = modelP && modelP.eq; - let showSet = new Set([modelP, eqP, targetP].filter(Boolean)); - return { showSet, targetP, eqP, modelP, tsel }; - }; - window.__getEqParametricFocusContext = getParametricEqTraceFocusContext; - let eqModelDropdownCandidateRenderable = (fullName, optionValues) => { - if (!fullName || !optionValues || optionValues.indexOf(fullName) < 0) { - return false; - } - if (typeof window !== "undefined" && window._eqPendingModelFullName === fullName) { - return true; - } - let hit = eqMeasurementObjForSelect(fullName); - return !!(hit && activePhones.indexOf(hit) !== -1 && phoneCurveDataReadyForEq(hit)); - }; - /** Same as renderable but does not wait for rawChannels — avoids races when parallel loads finish out of order. */ - let eqModelOnGraphInOptionList = (fullName, optionValues) => { - if (!fullName || !optionValues || optionValues.indexOf(fullName) < 0) { - return false; - } - if (typeof window !== "undefined" && window._eqPendingModelFullName === fullName) { - return true; - } - let hit = eqMeasurementObjForSelect(fullName); - return !!(hit && activePhones.indexOf(hit) !== -1); - }; - let eqTargetDropdownCandidateRenderable = (fullName, allOpts) => { - if (!fullName || !allOpts || !allOpts.some((row) => row.fullName === fullName)) { - return false; - } - if (typeof window !== "undefined" && window._eqPendingTargetFullName === fullName) { - return true; - } - let hit = eqFindByFullNameAny(fullName); - return !!(hit && activePhones.indexOf(hit) !== -1 && phoneCurveDataReadyForEq(hit)); - }; - let eqTargetOnGraphInOptionList = (fullName, allOpts) => { - if (!fullName || !allOpts || !allOpts.some((row) => row.fullName === fullName)) { - return false; - } - if (typeof window !== "undefined" && window._eqPendingTargetFullName === fullName) { - return true; - } - let hit = eqFindByFullNameAny(fullName); - return !!(hit && activePhones.indexOf(hit) !== -1); - }; - window.publishEqUiState = (reason) => { - let tab = document.querySelector("div.select"); - let onEqTab = !!(extraEnabled && extraEQEnabled && tab - && tab.getAttribute("data-selected") === "extra"); - let loadedModels = []; - let loadedTargets = []; - activePhones.forEach((p) => { - if (!p || !p.fullName) { - return; - } - if (p.isTarget) { - loadedTargets.push(p.fullName); - } else if (!p.fullName.match(/ EQ$/)) { - loadedModels.push(p.fullName); - } - }); - let ctx = onEqTab ? getParametricEqTraceFocusContext() : null; - let snap = { - reason, - onEqTab, - loadedModelsFullNames: loadedModels, - loadedTargetsFullNames: loadedTargets, - eqModelSelectValue: eqPhoneSelect ? eqPhoneSelect.value : "", - eqTargetSelectValue: eqPhoneTargetSelect ? eqPhoneTargetSelect.value : "", - resolvedEqModelFullName: ctx && ctx.modelP ? ctx.modelP.fullName : "", - resolvedEqTargetFullName: ctx && ctx.targetP ? ctx.targetP.fullName : "", - eqTraceParentFullName: ctx && ctx.eqP ? ctx.eqP.fullName : "", - pendingModelFullName: (typeof window !== "undefined" && window._eqPendingModelFullName) || "", - pendingTargetFullName: (typeof window !== "undefined" && window._eqPendingTargetFullName) || "", - eqDropdownModelIntent: (typeof window !== "undefined" && window.eqDropdownModelIntent) || "", - stickyLastGraphModelForEq: (typeof window !== "undefined" && window.eqLastGraphModelForEq) || "", - stickyLastGraphTargetForEq: (typeof window !== "undefined" && window.eqLastGraphTargetForEq) || "" - }; - window.__eqUiState = snap; - }; - window.__eqParametricPathOpacity = (curve) => { - if (!curve || !curve.p) { - return undefined; - } - let ctx = getParametricEqTraceFocusContext(); - if (!ctx) { - return undefined; - } - if (ctx.showSet.has(curve.p)) { - let baseG = graphPathOpacityForCurve(curve) ?? (curve.p.hide ? 0 : null); - if (curve.p.hide) { - return 0; - } - let b = (baseG == null || !Number.isFinite(baseG)) ? 1 : baseG; - /* Compose listening A/B dimming in the join callback so rebind never flashes full opacity. */ - return eqComposeListeningOpacityForCurve(curve, b); - } - return 0; - }; - let prevParametricFocusActive = false; - applyParametricEqGraphTraceFocus = () => { - let tab = document.querySelector("div.select"); - let active = extraEnabled && extraEQEnabled && tab && tab.getAttribute("data-selected") === "extra"; - if (!active) { - if (prevParametricFocusActive) { - prevParametricFocusActive = false; - updatePaths(false); - } - return; - } - let ctx = getParametricEqTraceFocusContext(); - if (!ctx) { - if (prevParametricFocusActive) { - prevParametricFocusActive = false; - updatePaths(false); - } - return; - } - prevParametricFocusActive = true; - let { showSet, targetP, eqP } = ctx; - gpath.selectAll("path").each(function (c) { - if (!c || !c.p) { - return; - } - let el = d3.select(this); - let vis = showSet.has(c.p); - if (!vis) { - el.attr("opacity", 0); - el.classed("eq-graph-focus-target", false); - if (!c.p.isTarget) { - el.style("stroke-dasharray", null); - } - return; - } - { - let baseRaw = graphPathOpacityForCurve(c) ?? (c.p.hide ? 0 : null); - let b = (baseRaw == null || !Number.isFinite(baseRaw)) ? 1 : baseRaw; - el.attr("opacity", eqComposeListeningOpacityForCurve(c, b)); - } - /* Never paint the parametric EQ trace as the "target" line (gray); fallback targetP can equal eqP when the target dropdown is empty. */ - if (targetP && c.p === targetP && !c.p.isTarget && c.p !== eqP) { - el.classed("eq-graph-focus-target", true); - let spec = targetP.isTarget - ? targetTraceDotSpecForPhone(targetP) - : TARGET_TRACE_DOT_SPECS[0]; - let cap = spec.cap || "round"; - el.style("stroke-dasharray", spec.dash) - .attr("stroke-linecap", cap) - .attr("stroke-linejoin", cap === "round" ? "round" : "miter"); - el.attr("stroke-width", targetTraceStrokeWidthFromSpec(spec)); - if (typeof targetColorCustom !== "undefined" && targetColorCustom) { - el.attr("stroke", targetColorCustom); - } else { - el.attr("stroke", "var(--background-color-contrast-more)"); - } - } else { - el.classed("eq-graph-focus-target", false); - if (!c.p.isTarget) { - el.style("stroke-dasharray", null); - el.attr("stroke-linecap", null); - el.attr("stroke-linejoin", null); - el.attr("stroke", getColor_AC(c)); - } else { - applyTargetCurveStrokePattern(el, c.p); - } - } - }); - /* This pass assigns full (or focus) opacity on every visible curve. Must run - updateEqTraceOpacity afterwards so parent vs EQ trace dimming is not reset until the next - time something remembered to call it (e.g. EQ filter edits only hit applyParametric here). */ - updateEqTraceOpacity(); - }; - let filtersContainer = document.querySelector("div.extra-eq > div.filters"); - let fileFiltersImport = document.querySelector("#file-filters-import"); - let filterEnabledInput, filterTypeSelect, - filterFreqInput, filterQInput, filterGainInput; - let eqBands = extraEQBands; - /* Until the user edits filter rows or drags graph nodes, graphic EQ may auto-sync rows from constraints. */ - let eqFiltersUserHasEdited = false; - let eqFilterSelectedRow = null; - /* Skip debounced history only for freq/gain/Q inputs while an EQ graph drag is active — those - update continuously from the pointer; type / enable changes must still notify. */ - let eqHistorySkipNotifyForLiveGraphFilterInput = (t) => { - let stPtr = eqGraphPointerState; - return !!(stPtr && stPtr.mode === "eq" && stPtr.dragging && t && t.matches - && t.matches("input[name='freq'], input[name='q'], input[name='gain']")); - }; - if (filtersContainer) { - filtersContainer.addEventListener("input", (e) => { - let t = e.target; - if (t && t.closest && filtersContainer.contains(t) && t.closest("div.filter")) { - eqFiltersUserHasEdited = true; - /* Type / band-enable use discrete history on `change` only; debounced notify here would - double-commit or coalesce with freq edits after MIN_GAP deferral. */ - if (t.matches && (t.matches("select[name='type']") || t.matches("input[name='enabled']"))) { - return; - } - if (!eqHistorySkipNotifyForLiveGraphFilterInput(t)) { - eqHistoryNotifyChange(); - } - } - }, true); - filtersContainer.addEventListener("change", (e) => { - let t = e.target; - if (t && t.closest && filtersContainer.contains(t) && t.closest("div.filter")) { - eqFiltersUserHasEdited = true; - if (t.matches && (t.matches("select[name='type']") || t.matches("input[name='enabled']"))) { - if (t.matches("select[name='type']")) { - eqHistoryDebugLog("filtersContainer change (capture) type select", { - value: t.value, - activeEl: document.activeElement && document.activeElement.getAttribute - ? document.activeElement.getAttribute("name") - : null - }); - } - /* History for type/enable is recorded from applyEQExec (runs after applyEQ), not here. */ - return; - } - if (!eqHistorySkipNotifyForLiveGraphFilterInput(t)) { - eqHistoryNotifyChange(); - } - } - }, true); - filtersContainer.addEventListener("focusin", (e) => { - let t = e.target; - if (!t || !t.matches || !t.matches("input[name='freq'], input[name='q'], input[name='gain'], select[name='type']")) { - return; - } - if (!t.closest || !t.closest("div.filter") || !filtersContainer.contains(t.closest("div.filter"))) { - return; - } - if (eqHistoryRestoring || eqHistoryChain.length > 0 || eqHistoryPendingPreEditSnap) { - return; - } - eqHistoryPendingPreEditSnap = eqHistoryTakeSnapshot(); - }, true); - } - let updateEqFilterRowSelectionStyles = () => { - if (!filtersContainer) { - return; - } - filtersContainer.querySelectorAll("div.filter").forEach((el, i) => { - el.classList.remove("eq-filter-row-selected", "eq-filter-row-dimmed"); - if (eqFilterSelectedRow === null) { - return; - } - if (i === eqFilterSelectedRow) { - el.classList.add("eq-filter-row-selected"); - } - }); - }; - /* hoverHighlightRow: drag band or mouse-near marker; null = no pointer hover. */ - let applyEqFilterMarkerFillAndSize = (hoverHighlightRow) => { - gEqFilterMarkers.selectAll("circle.eq-filter-marker").each(function (d) { - if (!d) { - return; - } - let c = d3.select(this); - let hoverOn = hoverHighlightRow !== null && hoverHighlightRow !== undefined - && d.rowIndex === hoverHighlightRow; - let selOn = eqFilterSelectedRow !== null && d.rowIndex === eqFilterSelectedRow; - let traceCol = c.attr("stroke"); - let strokeW = selOn - ? EQ_GRAPH_MARKER_STROKE_W * EQ_GRAPH_MARKER_STROKE_HOVER_MULT - : (hoverOn ? EQ_GRAPH_MARKER_STROKE_W * EQ_GRAPH_MARKER_STROKE_HOVER_MULT - : EQ_GRAPH_MARKER_STROKE_W); - let rUse = selOn - ? EQ_GRAPH_MARKER_R_BASE * EQ_GRAPH_MARKER_SEL_SCALE - * (hoverOn ? EQ_GRAPH_MARKER_SEL_HOVER_SCALE : 1) - : EQ_GRAPH_MARKER_R_BASE * EQ_GRAPH_MARKER_UNSEL_SCALE - * (hoverOn ? EQ_GRAPH_MARKER_UNSEL_HOVER_SCALE : 1); - c.classed("eq-filter-marker-hover", hoverOn) - .classed("eq-filter-marker-selected", selOn) - .attr("r", rUse) - .attr("stroke-width", strokeW); - if (selOn) { - c.style("fill", eqMarkerResolvePaint(EQ_GRAPH_MARKER_SEL_FILL, traceCol)) - .style("stroke", eqMarkerResolvePaint(EQ_GRAPH_MARKER_SEL_STROKE, traceCol)); - } else { - if (EQ_GRAPH_MARKER_UNSEL_STROKE === "trace") { - c.style("stroke", null); - } else { - c.style("stroke", eqMarkerResolvePaint(EQ_GRAPH_MARKER_UNSEL_STROKE, traceCol)); - } - if (EQ_GRAPH_MARKER_UNSEL_FILL === "graph") { - c.attr("fill", null) - .style("fill", null); - } else { - c.attr("fill", null) - .style("fill", eqMarkerResolvePaint(EQ_GRAPH_MARKER_UNSEL_FILL, traceCol)); - } - } - }); - }; - /* Gap below panel top when snapping extra-eq into view (scroll slightly less than flush). */ - const EQ_FILTER_SCROLL_EQ_TOP_INSET_PX = 10; - /* If the filter row already fits in the panel, don’t smooth-scroll a few px just to nudge EQ inset. */ - const EQ_FILTER_SCROLL_IGNORE_INSET_NUDGE_PX = 28; - let scrollEqFilterRowIntoView = (row) => { - if (row === null || !filtersContainer) { - return; - } - let tab = document.querySelector("div.select"); - if (!tab || tab.getAttribute("data-selected") !== "extra") { - return; - } - let rows = filtersContainer.querySelectorAll("div.filter"); - let el = rows[row]; - if (!el) { - return; - } - requestAnimationFrame(() => { - let panel = document.querySelector("div.select > div.extra-panel"); - let eqRoot = filtersContainer.closest("div.extra-eq"); - if (panel && eqRoot) { - let pr = panel.getBoundingClientRect(); - let deltaEq = eqRoot.getBoundingClientRect().top - pr.top - - EQ_FILTER_SCROLL_EQ_TOP_INSET_PX; - let deltaRow = el.getBoundingClientRect().bottom - pr.bottom; - let delta = Math.max(deltaEq, deltaRow); - if (delta <= 0) { - return; - } - if (deltaRow <= 0 && delta < EQ_FILTER_SCROLL_IGNORE_INSET_NUDGE_PX) { - return; - } - let maxTop = Math.max(0, panel.scrollHeight - panel.clientHeight); - let nextTop = Math.max(0, Math.min(maxTop, panel.scrollTop + delta)); - if (Math.abs(nextTop - panel.scrollTop) < 1) { - return; - } - panel.scrollTo({ top: nextTop, behavior: "smooth" }); - } else { - el.scrollIntoView({ block: "nearest", behavior: "smooth" }); - } - }); - }; - let setEqFilterSelectedRow = (row, scrollBandIntoView) => { - if (row !== null && (typeof row !== "number" || row < 0 || row >= eqBands)) { - row = null; - } - eqFilterSelectedRow = row; - updateEqFilterRowSelectionStyles(); - updateEqFilterMarkers(); - if (row !== null && scrollBandIntoView) { - scrollEqFilterRowIntoView(row); - } - }; - window.hideExtraPanel = (selectedList) => { - document.querySelector("div.select > div.selector-panel").style["display"] = "flex"; - document.querySelector("div.select > div.extra-panel").style["display"] = "none"; - document.querySelector("div.select").setAttribute("data-selected", selectedList); - setEqFilterSelectedRow(null); - syncEqHoverPreview(null); - applyParametricEqGraphTraceFocus(); - updateEqTraceOpacity(); - /* Match showExtraPanel: table was filtered to EQ focus context; restore full rows when leaving. */ - updatePhoneTable(); - }; - let parseEqConstraintGraphicFreqList = (raw) => { - if (raw == null || typeof raw !== "string") { - return []; - } - let s = raw.trim(); - if (s === "") { - return []; - } - /* Trailing comma(s) while typing the next band (e.g. "32, 64,") would otherwise yield one token. */ - while (/(?:,|,)\s*$/.test(s)) { - s = s.replace(/(?:,|,)\s*$/, "").trim(); - } - if (s === "") { - return []; - } - /* Comma must be followed by whitespace so "2,000" is not read as two bands. */ - let parts = s.split(/(?:,|,)\s+/); - let out = []; - let seen = new Set(); - for (let p of parts) { - let t = p.trim(); - if (t === "") { - continue; - } - if (/[,,]/.test(t)) { - continue; - } - let v = Math.round(parseFloat(t)); - if (!Number.isFinite(v) || v < 20 || v > 20000) { - continue; - } - if (!seen.has(v)) { - seen.add(v); - out.push(v); - } - } - out.sort((a, b) => a - b); - return out; - }; - let isEqConstraintGraphicModeActive = () => { - let row = document.querySelector("div.extra-eq .eq-constraint-freq-row"); - let uiGraphic = !!(row && row.classList.contains("eq-constraint-freq-row-graphic")); - let bands = Equalizer.config.EqGraphicBandFreqHz; - return uiGraphic && Array.isArray(bands) && bands.length >= 2; - }; - let applyEqConstraintFreqRowUiMode = () => { - let row = document.querySelector("div.extra-eq .eq-constraint-freq-row"); - let par = document.querySelector("div.extra-eq .eq-constraint-freq-parametric-cells"); - let gf = document.querySelector("div.extra-eq .eq-constraint-freq-graphic-full"); - let gIn = document.querySelector("div.extra-eq input[name='eq-constraint-freq-graphic-list']"); - if (!row || !par || !gf || !gIn) { - return; - } - let bands = Equalizer.config.EqGraphicBandFreqHz; - let graphic = Array.isArray(bands) && bands.length >= 2; - row.classList.toggle("eq-constraint-freq-row-graphic", graphic); - par.hidden = graphic; - gf.hidden = !graphic; - if (graphic) { - gIn.value = bands.join(", "); - } - applyEqGraphicModeAuxUiAndBands(); - }; - let clearEqConstraintGraphicFreqMode = () => { - Equalizer.config.EqGraphicBandFreqHz = null; - let g = document.querySelector("div.extra-eq input[name='eq-constraint-freq-graphic-list']"); - if (g) { - g.value = ""; - } - applyEqConstraintFreqRowUiMode(); - }; - let tryEnterEqConstraintGraphicFreqModeFromTyping = () => { - let row = document.querySelector("div.extra-eq .eq-constraint-freq-row"); - let minEl = document.querySelector("div.extra-eq input[name='eq-constraint-freq-min']"); - let maxEl = document.querySelector("div.extra-eq input[name='eq-constraint-freq-max']"); - if (!row || !minEl || !maxEl || row.classList.contains("eq-constraint-freq-row-graphic")) { - return false; - } - /* List lives in one field; that field is not "" / "0", so require only the *other* side unlimited. */ - let uVal = (s) => { - let t = (s || "").trim(); - return t === "" || t === "0"; - }; - let minRaw = (minEl.value || "").trim(); - let maxRaw = (maxEl.value || "").trim(); - let hasGraphicCommaSpace = (s) => /(?:,|,)\s/.test(s); - let minHasSep = hasGraphicCommaSpace(minRaw); - let maxHasSep = hasGraphicCommaSpace(maxRaw); - let rawPrefer = ""; - if (minHasSep) { - if (!uVal(maxEl.value)) { - return false; - } - rawPrefer = minEl.value; - } else if (maxHasSep) { - if (!uVal(minEl.value)) { - return false; - } - rawPrefer = maxEl.value; - } else { - return false; - } - if (!rawPrefer) { - return false; - } - let list = parseEqConstraintGraphicFreqList(rawPrefer); - if (list.length < 2) { - return false; - } - Equalizer.config.EqGraphicBandFreqHz = list.slice(); - Equalizer.config.AutoEQRange = [list[0], list[list.length - 1]]; - minEl.value = "0"; - maxEl.value = "0"; - applyEqConstraintFreqRowUiMode(); - requestAnimationFrame(() => { - let gInAfter = document.querySelector("div.extra-eq input[name='eq-constraint-freq-graphic-list']"); - if (gInAfter) { - gInAfter.focus(); - let n = gInAfter.value.length; - gInAfter.setSelectionRange(n, n); - } - }); - return true; - }; - let snapEqFilterFreqToGraphicBands = (hz) => { - let bands = Equalizer.config.EqGraphicBandFreqHz; - if (!bands || bands.length < 2 || !hz || hz <= 0) { - return hz; - } - let best = bands[0]; - let bestScore = Infinity; - for (let b of bands) { - let s = Math.abs(Math.log(hz / b)); - if (s < bestScore) { - bestScore = s; - best = b; - } - } - return best; - }; - let getEqConstraintFreqLoHi = () => { - let lo = Equalizer.config.AutoEQRange[0]; - let hi = Equalizer.config.AutoEQRange[1]; - if (!Number.isFinite(lo)) { - lo = 20; - } - if (!Number.isFinite(hi)) { - hi = 20000; - } - lo = Math.min(20000, Math.max(20, lo)); - hi = Math.min(20000, Math.max(20, hi)); - if (lo > hi) { - let t = lo; - lo = hi; - hi = t; - } - return [lo, hi]; - }; - let getEqConstraintQLoHi = () => { - let lo = Equalizer.config.OptimizeQRange[0]; - let hi = Equalizer.config.OptimizeQRange[1]; - if (!Number.isFinite(lo)) { - lo = 0.1; - } - if (!Number.isFinite(hi)) { - hi = 10; - } - lo = Math.min(10, Math.max(0.1, lo)); - hi = Math.min(10, Math.max(0.1, hi)); - if (lo > hi) { - let t = lo; - lo = hi; - hi = t; - } - return [lo, hi]; - }; - let getEqConstraintGainLoHi = () => { - let lo = Equalizer.config.OptimizeGainRange[0]; - let hi = Equalizer.config.OptimizeGainRange[1]; - if (!Number.isFinite(lo)) { - lo = -40; - } - if (!Number.isFinite(hi)) { - hi = 40; - } - lo = Math.min(40, Math.max(-40, lo)); - hi = Math.min(40, Math.max(-40, hi)); - if (lo > hi) { - let t = lo; - lo = hi; - hi = t; - } - return [lo, hi]; - }; - /* Mid-edit numeric strings (e.g. "0." before "1") — don’t rewrite the field or snap config from them. */ - let eqConstraintNumericInputIncomplete = (raw) => { - if (raw == null) { - return false; - } - let s = String(raw); - if (/[eE]$/.test(s)) { - return true; - } - if (/\.$/.test(s)) { - return true; - } - let t = s.trim(); - return t === "-" || t === "+" || t === "-." || t === "."; - }; - let getEffectiveEqMaxBands = () => { - let n = Math.floor(Number(Equalizer.config.EqMaxBands)); - if (!Number.isFinite(n) || n <= 0) { - return extraEQBandsMax; - } - return Math.min(Math.max(1, n), extraEQBandsMax); - }; - let firstAllowedEqFilterType = () => { - let a = Equalizer.config.EqAllowedTypes || {}; - if (a.PK) { - return "PK"; - } - if (a.LSQ) { - return "LSQ"; - } - if (a.HSQ) { - return "HSQ"; - } - return "PK"; - }; - let refreshEqFilterInactiveStateForMaxBands = () => { - if (!filtersContainer) { - return; - } - let maxA = getEffectiveEqMaxBands(); - filtersContainer.querySelectorAll("div.filter").forEach((row, i) => { - let inactive = i >= maxA; - row.classList.toggle("eq-filter-row-inactive", inactive); - row.querySelectorAll("input, select").forEach((el) => { - el.disabled = inactive; - }); - }); - }; - let applyEqConstraintAttributesToFilterInputs = () => { - if (!filterFreqInput || !filterFreqInput.length) { - return; - } - let [fLo, fHi] = getEqConstraintFreqLoHi(); - let [qLo, qHi] = getEqConstraintQLoHi(); - let [gLo, gHi] = getEqConstraintGainLoHi(); - let allowed = Equalizer.config.EqAllowedTypes || { PK: true, LSQ: true, HSQ: true }; - for (let i = 0; i < eqBands; i++) { - let fi = filterFreqInput[i]; - let qi = filterQInput[i]; - let gi = filterGainInput[i]; - let si = filterTypeSelect[i]; - if (fi) { - fi.setAttribute("min", String(fLo)); - fi.setAttribute("max", String(fHi)); - } - if (qi) { - qi.setAttribute("min", String(qLo)); - qi.setAttribute("max", String(qHi)); - } - if (gi) { - gi.setAttribute("min", String(gLo)); - gi.setAttribute("max", String(gHi)); - } - if (si) { - si.querySelectorAll("option").forEach((opt) => { - let v = opt.value; - let ok = (v === "PK" && allowed.PK) || (v === "LSQ" && allowed.LSQ) - || (v === "HSQ" && allowed.HSQ); - opt.disabled = !ok; - }); - } - } - }; - const EQ_CONSTRAINT_Q_FINE_MAX = 0.3; - let refreshEqFilterConstraintViolationStyles = () => { - if (!filterFreqInput || !filterFreqInput.length) { - return; - } - let [fLo, fHi] = getEqConstraintFreqLoHi(); - let [qLo, qHi] = getEqConstraintQLoHi(); - let [gLo, gHi] = getEqConstraintGainLoHi(); - let allowed = Equalizer.config.EqAllowedTypes || { PK: true, LSQ: true, HSQ: true }; - let maxA = getEffectiveEqMaxBands(); - let vClass = "eq-filter-value-constraint-violation"; - for (let i = 0; i < eqBands; i++) { - let fi = filterFreqInput[i]; - let qi = filterQInput[i]; - let gi = filterGainInput[i]; - let si = filterTypeSelect[i]; - [fi, qi, gi, si].forEach((el) => { - if (el) { - el.classList.remove(vClass); - } - }); - if (i >= maxA) { - continue; - } - let f = parseInt(fi.value, 10) || 0; - let q = parseFloat(qi.value) || 0; - let g = parseFloat(gi.value) || 0; - let emptyish = f === 0 && q === 0 && g === 0; - if (!emptyish) { - let bands = Equalizer.config.EqGraphicBandFreqHz; - let graphicFreq = Array.isArray(bands) && bands.length >= 2; - if (f !== 0) { - if (graphicFreq) { - let onBand = bands.some((b) => Math.abs(b - f) < 0.51); - if (!onBand) { - fi.classList.add(vClass); - } - } else if (f < fLo || f > fHi) { - fi.classList.add(vClass); - } - } - let qStrictRange = graphicFreq && eqFiltersUserHasEdited; - let qBad = qStrictRange - ? (q < qLo || q > qHi) - : (q !== 0 && (q < qLo || q > qHi)); - if (qBad) { - qi.classList.add(vClass); - } - if ((g !== 0 || f !== 0 || q !== 0) && (g < gLo || g > gHi)) { - gi.classList.add(vClass); - } - } - let sel = si.value; - if (!allowed[sel]) { - si.classList.add(vClass); - } - } - }; - let updateFilterElements = () => { - if (!filtersContainer) { - return; - } - let node = filtersContainer.querySelector("div.filter"); - while (filtersContainer.childElementCount < eqBands) { - let clone = node.cloneNode(true); - clone.querySelector("input[name='enabled']").value = "true"; - clone.querySelector("select[name='type']").value = "PK"; - clone.querySelector("input[name='freq']").value = "0"; - clone.querySelector("input[name='q']").value = "0"; - clone.querySelector("input[name='gain']").value = "0"; - filtersContainer.appendChild(clone); - } - while (filtersContainer.childElementCount > eqBands) { - filtersContainer.children[filtersContainer.childElementCount-1].remove(); - } - filterEnabledInput = filtersContainer.querySelectorAll("input[name='enabled']"); - filterTypeSelect = filtersContainer.querySelectorAll("select[name='type']"); - filterFreqInput = filtersContainer.querySelectorAll("input[name='freq']"); - filterQInput = filtersContainer.querySelectorAll("input[name='q']"); - filterGainInput = filtersContainer.querySelectorAll("input[name='gain']"); - filtersContainer.querySelectorAll("input,select").forEach((el) => { - el.removeEventListener("input", applyEQ); - el.removeEventListener("change", applyEQ); - el.addEventListener("input", applyEQ); - el.addEventListener("change", applyEQ); - }); - if (eqFilterSelectedRow !== null - && eqFilterSelectedRow >= filtersContainer.querySelectorAll("div.filter").length) { - eqFilterSelectedRow = null; - } - updateEqFilterRowSelectionStyles(); - applyEqConstraintAttributesToFilterInputs(); - refreshEqFilterConstraintViolationStyles(); - refreshEqFilterInactiveStateForMaxBands(); - }; - let elemToFilters = (includeAll) => { - // Collect filters from ui - let filters = []; - let maxA = getEffectiveEqMaxBands(); - for (let i = 0; i < eqBands; ++i) { - if (!includeAll && i >= maxA) { - continue; - } - let disabled = !filterEnabledInput[i].checked; - let type = filterTypeSelect[i].value; - let freq = parseInt(filterFreqInput[i].value) || 0; - let q = parseFloat(filterQInput[i].value) || 0; - let gain = parseFloat(filterGainInput[i].value) || 0; - if (!includeAll && (disabled || !type || !freq || !q)) { - continue; - } - filters.push({ disabled, type, freq, q, gain }); - } - return filters; - }; - /* Clamp to Equalizer.config ranges for audio / export only; never mutates DOM. */ - let elemToFiltersClampedRowsForEqualizerApply = (raw, includeAll) => { - let [fLo, fHi] = getEqConstraintFreqLoHi(); - let [qLo, qHi] = getEqConstraintQLoHi(); - let [gLo, gHi] = getEqConstraintGainLoHi(); - let allowed = Equalizer.config.EqAllowedTypes || { PK: true, LSQ: true, HSQ: true }; - return raw.map((f) => { - let type = allowed[f.type] ? f.type : firstAllowedEqFilterType(); - let freq = f.freq ? Math.min(fHi, Math.max(fLo, f.freq)) : 0; - if (freq) { - freq = snapEqFilterFreqToGraphicBands(freq); - } - let q = f.q ? (() => { - let qc = Math.min(qHi, Math.max(qLo, f.q)); - return qc <= EQ_CONSTRAINT_Q_FINE_MAX + 1e-9 - ? Math.round(qc * 100) / 100 - : Math.round(qc * 10) / 10; - })() : 0; - let gain = (f.gain !== 0 || f.freq || f.q) - ? Math.round(Math.min(gHi, Math.max(gLo, f.gain)) * 10) / 10 - : 0; - return { ...f, type, freq, q, gain }; - }); - }; - let elemToFiltersClampedForEqualizerApply = (includeAll) => - elemToFiltersClampedRowsForEqualizerApply(elemToFilters(includeAll), includeAll); - /* Range clamp + freq snap only (no gain/Q step quantization) so exports match Equalizer fields. */ - let elemToFiltersBoundedRowsForExport = (raw, includeAll) => { - let [fLo, fHi] = getEqConstraintFreqLoHi(); - let [qLo, qHi] = getEqConstraintQLoHi(); - let [gLo, gHi] = getEqConstraintGainLoHi(); - let allowed = Equalizer.config.EqAllowedTypes || { PK: true, LSQ: true, HSQ: true }; - return raw.map((f) => { - let type = allowed[f.type] ? f.type : firstAllowedEqFilterType(); - let freq = f.freq ? Math.min(fHi, Math.max(fLo, f.freq)) : 0; - if (freq) { - freq = snapEqFilterFreqToGraphicBands(freq); - } - let q = f.q ? Math.min(qHi, Math.max(qLo, f.q)) : 0; - let gain = (f.gain !== 0 || f.freq || f.q) - ? Math.min(gHi, Math.max(gLo, f.gain)) - : 0; - return { ...f, type, freq, q, gain }; - }); - }; - let elemToFiltersBoundedForExport = (includeAll) => - elemToFiltersBoundedRowsForExport(elemToFilters(includeAll), includeAll); - let filtersToElem = (filters) => { - // Set filters to ui - let filtersCopy = filters.map(f => f); - while (filtersCopy.length < eqBands) { - filtersCopy.push({ type: "PK", freq: 0, q: 0, gain: 0 }); - } - if (filtersCopy.length > eqBands) { - eqBands = Math.min(filtersCopy.length, extraEQBandsMax); - filtersCopy = filtersCopy.slice(0, eqBands); - updateFilterElements(); - } - filtersCopy.forEach((f, i) => { - filterEnabledInput[i].checked = !f.disabled; - filterTypeSelect[i].value = f.type; - filterFreqInput[i].value = f.freq; - filterQInput[i].value = f.q; - filterGainInput[i].value = f.gain; - }); - eqFiltersUserHasEdited = true; - applyEqConstraintAttributesToFilterInputs(); - refreshEqFilterConstraintViolationStyles(); - refreshEqFilterInactiveStateForMaxBands(); - if (isEqTwoChannelSupportEnabled()) { - eq2chBankData[eq2chActiveBank] = filtersCopy.map((f) => ({ - disabled: !!f.disabled, - type: f.type, - freq: f.freq, - q: f.q, - gain: f.gain - })); - } - }; - let eq2chConstraintToggle = document.querySelector("input.eq-constraint-2ch-toggle"); - let eq2chBankTabsEl = document.getElementById("eq-2ch-bank-tabs"); - let eq2chBankData = { both: [], L: [], R: [] }; - let eq2chActiveBank = "both"; - const EQ_2CH_BANK_SWAP_ANIM_MS = 300; - const EQ_2CH_BANK_SWAP_APPLY_AT_MS = 95; - let eq2chBankSwapSeq = 0; - let eq2chBankSwapApplyTimer = null; - let eq2chBankSwapCleanupTimer = null; - let eq2chBankSwapAnimEndFn = null; - let clearEq2chBankSwapAnimation = () => { - if (eq2chBankSwapApplyTimer !== null) { - clearTimeout(eq2chBankSwapApplyTimer); - eq2chBankSwapApplyTimer = null; - } - if (eq2chBankSwapCleanupTimer !== null) { - clearTimeout(eq2chBankSwapCleanupTimer); - eq2chBankSwapCleanupTimer = null; - } - if (filtersContainer && eq2chBankSwapAnimEndFn) { - filtersContainer.removeEventListener("animationend", eq2chBankSwapAnimEndFn); - eq2chBankSwapAnimEndFn = null; - } - if (filtersContainer) { - filtersContainer.classList.remove("eq-2ch-bank-filters-swap-anim"); - } - }; - let isEqTwoChannelSupportEnabled = () => - !!(eq2chConstraintToggle && eq2chConstraintToggle.checked); - let eq2chDefaultEmptyRow = () => ({ - disabled: false, - type: "PK", - freq: 0, - q: 0, - gain: 0 - }); - let eq2chPadBankToEqBands = (arr) => { - let filtersCopy = (arr || []).map((f) => ({ ...f })); - while (filtersCopy.length < eqBands) { - filtersCopy.push(eq2chDefaultEmptyRow()); - } - if (filtersCopy.length > eqBands) { - filtersCopy.length = eqBands; - } - return filtersCopy; - }; - let eq2chFlushDomToActiveBankCore = () => { - eq2chBankData[eq2chActiveBank] = elemToFilters(true).map((f) => ({ - disabled: !!f.disabled, - type: f.type, - freq: f.freq, - q: f.q, - gain: f.gain - })); - }; - let eq2chFlushDomToActiveBank = () => { - if (!isEqTwoChannelSupportEnabled()) { - return; - } - eq2chFlushDomToActiveBankCore(); - }; - /* --- EQ undo / redo history (Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Cmd/Ctrl+Y) --- */ - let eqHistoryChain = []; - let eqHistoryHead = -1; - let eqHistoryRestoring = false; - let eqHistoryDebounceTimer = null; - let eqHistoryGapWaitTimer = null; - let eqHistoryLastCommitAt = 0; - let eqHistoryTimeTicker = null; - let eqHistoryListClickBound = false; - /** Pinned change-history body (one at a time); ghost trace on graph. Cleared on EQ model switch. */ - let eqPinnedSnapshotBody = null; - let eqHistoryPendingPreEditSnap = null; - let eqHistoryInitBaselineSnap = null; - const EQ_HISTORY_CAP = 100; - const EQ_HISTORY_DEBOUNCE_MS = 500; - const EQ_HISTORY_MIN_GAP_MS = 1000; - /* Set `window.__EQ_HISTORY_DEBUG = true` in the console to trace EQ change history (filter type, push, skips). */ - let eqHistoryDebugLog = (...a) => { - if (typeof window !== "undefined" && window.__EQ_HISTORY_DEBUG) { - console.log("[EQ hist]", ...a); - } - }; - /* applyEQExec compares this to DOM so filter type / band-enable edits always log even if value is still a catalog measurement, synthesize `USRMT_*` even when - the user never fires change/input (same-option pick or implicit nextTV from share URL). */ - (function materializeUserMeasurementEqTargetIfNeeded() { - if (!eqPhoneTargetSelect) { - return; - } - let v = String(eqPhoneTargetSelect.value || "").trim(); - if (!v || typeof window.eqEnsureUserMeasurementBrandTarget !== "function") { - return; - } - let p = eqFindByFullNameAny(v); - if (!p || p.isTarget || (p.fullName && String(p.fullName).match(/ EQ$/))) { - return; - } - let tgt = window.eqEnsureUserMeasurementBrandTarget(p); - if (!tgt) { - return; - } - /* Measurement FR may load async; this block runs once data exists. Before superseding the - source measurement, commit sticky intent to `USRMT_*`. Leaving intent on the raw - measurement name caused targetPick() to fail after removePhone(meas) — dropdown reverted; - a second pick worked because synthesis had already run. */ - if (typeof window !== "undefined") { - window.eqDropdownTargetIntent = tgt.fullName; - window.eqLastGraphTargetForEq = tgt.fullName; - window._eqPendingTargetFullName = (activePhones.indexOf(tgt) === -1 - || !phoneCurveDataReadyForEq(tgt)) - ? tgt.fullName - : ""; - } - eqPhoneTargetSelect.dataset.eqLastTarget = tgt.fullName; - if (activePhones.indexOf(tgt) === -1) { - showPhone(tgt, 0, true, false); - } - if (typeof window !== "undefined" && activePhones.indexOf(tgt) !== -1 - && phoneCurveDataReadyForEq(tgt)) { - window._eqPendingTargetFullName = ""; - } - removeMeasurementIfSupersededByUserTarget(p); - let domVal = String(eqPhoneTargetSelect.value || "").trim(); - if (domVal !== String(tgt.fullName).trim() && typeof window.updateEQPhoneSelect === "function") { - window.updateEQPhoneSelect(); - } - })(); - }; - window.updateEQPhoneSelect = () => { - let oldValue = eqPhoneSelect.value; - let list = eqAllPhonesPool().filter((p) => - !p.isTarget && p.fullName && !p.fullName.match(/ EQ$/)); - list.sort((a, b) => String(a.fullName).localeCompare(String(b.fullName))); - /* Active IEMs on the graph under "Active" (same idea as the target dropdown); full catalog below. */ - let activeModelsQuick = [], - seenActive = new Set(); - getManageTableBasePhoneOrder().forEach((p) => { - if (!p || p.isTarget || !p.fullName || String(p.fullName).match(/ EQ$/)) { - return; - } - if (activePhones.indexOf(p) === -1) { - return; - } - if (seenActive.has(p.fullName)) { - return; - } - seenActive.add(p.fullName); - activeModelsQuick.push(p); - }); - let listRest = list.filter((p) => !seenActive.has(p.fullName)); - let optionValues = activeModelsQuick.concat(listRest).map((p) => p.fullName); - Array.from(eqPhoneSelect.children).slice(1).forEach(c => eqPhoneSelect.removeChild(c)); - let appendModelOptgroup = (label, arr) => { - if (!arr.length) { - return; - } - let og = document.createElement("optgroup"); - og.label = label; - arr.forEach((p) => { - let optionElem = document.createElement("option"); - optionElem.setAttribute("value", p.fullName); - optionElem.innerText = p.fullName; - og.appendChild(optionElem); - }); - eqPhoneSelect.appendChild(og); - }; - appendModelOptgroup("Active", activeModelsQuick); - appendModelOptgroup("All models", listRest); - let intent = (typeof window !== "undefined" && window.eqDropdownModelIntent) - ? String(window.eqDropdownModelIntent).trim() - : ""; - let lastGraph = (typeof window !== "undefined" && window.eqLastGraphModelForEq) - ? String(window.eqLastGraphModelForEq).trim() - : ""; - let manageTopModel = (() => { - let ord = getManageTableBasePhoneOrder(); - for (let i = 0; i < ord.length; i++) { - let p = ord[i]; - if (!p || p.isTarget || !p.fullName || String(p.fullName).match(/ EQ$/)) { - continue; - } - if (eqModelOnGraphInOptionList(p.fullName, optionValues)) { - return p.fullName; - } - } - return ""; - })(); - let nextSel = ""; - /* Match dropdown to graph reality: only names that are on-graph (or loading from pick). - Prefer manage-table row order (same object order as manageTableRows) over async sticky so - parallel loads do not reshuffle the default; keep graph sticky last for clicks after load. */ - if (intent && eqModelDropdownCandidateRenderable(intent, optionValues)) { - nextSel = intent; - } else if (oldValue && eqModelDropdownCandidateRenderable(oldValue, optionValues)) { - nextSel = oldValue; - } else if (manageTopModel && eqModelDropdownCandidateRenderable(manageTopModel, optionValues)) { - nextSel = manageTopModel; - } else if (lastGraph && eqModelDropdownCandidateRenderable(lastGraph, optionValues)) { - nextSel = lastGraph; - } - eqPhoneSelect.value = nextSel; - if (!nextSel && intent && !eqModelDropdownCandidateRenderable(intent, optionValues)) { - window.eqDropdownModelIntent = ""; - } - let autoFilledModel = Boolean(nextSel && ( - !oldValue || optionValues.indexOf(oldValue) < 0 || nextSel !== oldValue - )); - updateEQPhoneTargetSelect(); - updateEqFilterMarkers(); - if (autoFilledModel) { - applyEQ(); - scheduleLiveEqSync(); - } - let phPlaceholder = eqPhoneSelect.querySelector("option[value='']"); - if (phPlaceholder) { - phPlaceholder.textContent = optionValues.length === 0 - ? "Add a model to the graph" - : "Choose EQ model"; - phPlaceholder.hidden = !!eqPhoneSelect.value; - } - eqPhoneSelect.dataset.eqLastModel = eqPhoneSelect.value || ""; - if (typeof window !== "undefined") { - window.eqLastGraphModelForEq = eqPhoneSelect.value - || (window._eqPendingModelFullName || ""); - if (!eqPhoneSelect.value && !window._eqPendingModelFullName) { - window.eqDropdownModelIntent = ""; - } - } - if (typeof window.publishEqUiState === "function") { - window.publishEqUiState("updateEQPhoneSelect"); - } - }; - window.eqResetParametricAfterBaseModelRemoved = () => { - window._eqModelActivatedByDropdown = null; - window._eqTargetActivatedByDropdown = null; - window._eqPendingModelFullName = ""; - window._eqPendingTargetFullName = ""; - window._eqModelStickyBypassForShownPhoneFullName = ""; - window.eqDropdownModelIntent = ""; - window.eqDropdownTargetIntent = ""; - eqFiltersUserHasEdited = false; - eq2chResetAllBanksToDefaultRow(); - filtersToElem([{ disabled: false, type: "PK", freq: 0, q: 0, gain: 0 }]); - eqFiltersUserHasEdited = false; - eqPinnedSnapshotBody = null; - if (eqPhoneSelect) { - eqPhoneSelect.dataset.eqLastModel = eqPhoneSelect.value || ""; - } - setEqFilterSelectedRow(null); - updateEQPhoneTargetSelect(); - applyEQ(); - scheduleLiveEqSync(); - applyParametricEqGraphTraceFocus(); - updateEqTraceOpacity(); - updateEqFilterMarkers(); - updatePhoneTable(); - eqHistoryRenderLog(); - }; - updateFilterElements(); - updateEqFilterMarkers(); - if (eqPhoneSelect) { - /* Coalesce input+change in one tick (both can fire on the same user pick in Chromium). */ - let eqPhoneSelectCoalesce = false; - function runEqPhoneSelectHandler() { - let prev = eqPhoneSelect.dataset.eqLastModel || ""; - let next = eqPhoneSelect.value; - /* showPhone → updateEQPhoneSelect rebuilds