diff --git a/.gitignore b/.gitignore index 753c3d9..7a79d3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ data_mrs/* .DS_Store /devicePEQ/ +dev.sh diff --git a/Documentation.md b/Documentation.md index 6b9b3c3..c3d275d 100644 --- a/Documentation.md +++ b/Documentation.md @@ -24,9 +24,10 @@ Most parts of the interface are arranged using flexboxes, and rearranged with CSS media queries to detect screen width and aspect ratio. There are three main layouts: -* The desktop layout places the graph window at the top with the selector and manager side by side below it. -* The mobile layout (for narrow screens) stacks everything vertically with the selector above the manager. -* When the screen is very wide relative to its height, the selector and manager are stacked as in the mobile layout but placed right of the graph window. + +- The desktop layout places the graph window at the top with the selector and manager side by side below it. +- The mobile layout (for narrow screens) stacks everything vertically with the selector above the manager. +- When the screen is very wide relative to its height, the selector and manager are stacked as in the mobile layout but placed right of the graph window. If the screen is narrow enough, the toolbar below the graph window will collapse to avoid clutter. The entire toolbar can be shown by clicking @@ -120,6 +121,7 @@ natural spline tends to emphasize little bumps in the data, making it worse even than linear interpolation. Mathematically, a smoothing spline minimizes a weighted sum of: + 1. All the square differences between the original and smoothed values, and 2. The integral of the square of the second derivative of the smoothed function. @@ -324,9 +326,10 @@ cross-section along the ring, like a thick washer. Three modifications are made to this ring in order to account for human perception, or maybe imperfect perceptual uniformity of HCL space, or even unsuitability for lines rather than color fields. -* Hues are shifted so cool colors like blues and greens appear less often, and reds and yellows more often. -* Hues are shifted towards six colors with evenly spaced hues—the primary and secondary colors red, yellow, green, cyan, blue, and purple. -* Chroma and luminance are shifted so that yellows are brighter and bolder, and blues darker. + +- Hues are shifted so cool colors like blues and greens appear less often, and reds and yellows more often. +- Hues are shifted towards six colors with evenly spaced hues—the primary and secondary colors red, yellow, green, cyan, blue, and purple. +- Chroma and luminance are shifted so that yellows are brighter and bolder, and blues darker. Channels are separated from one another primarily by adjusting hue and chroma. Channels with different luminance don't look related. The @@ -355,4 +358,4 @@ it does not and they may overlap. If there is only one label, or if a suitable position for a label can't be found, it's placed at the top left corner. If there is a hidden -baseline curve, its label is placed at the bottom of the graph. +baseline curve, its label is placed at the bottom of the graph. \ No newline at end of file diff --git a/config.js b/config.js index 1c1cfcc..cf470b8 100644 --- a/config.js +++ b/config.js @@ -1,12 +1,13 @@ // Configuration options const init_phones = ["BKF"], // Optional. Which graphs to display on initial load. Note: Share URLs will override this set - DIR = "data/", // Directory where graph files are stored + DIR = "/data/", // Directory where graph files are stored +// DIR = "https://squig.link/headphones/data/", // Directory where graph files are stored +// num_samples = 3, default_channels = ["L","R"], // Which channels to display. Avoid javascript errors if loading just one channel per phone default_normalization = "dB", // Sets default graph normalization mode. Accepts "dB" or "Hz" default_norm_db = 60, // Sets default dB normalization point default_norm_hz = 500, // Sets default Hz normalization point (500Hz is recommended by IEC) max_channel_imbalance = 5, // Channel imbalance threshold to show ! in the channel selector - alt_layout = true, // Toggle between classic and alt layouts alt_sticky_graph = true, // If active graphs overflows the viewport, does the graph scroll with the page or stick to the viewport? alt_animated = false, // Determines if new graphs are drawn with a 1-second animation, or appear instantly alt_header = true, // Display a configurable header at the top of the alt layout @@ -30,7 +31,7 @@ const init_phones = ["BKF"], // Optional. Which graphs to display on targetDashed = false, // If true, makes target curves dashed lines targetColorCustom = false, // If false, targets appear as a random gray value. Can replace with a fixed color value to make all targets the specified color, e.g. "black" targetRestoreLastUsed = false, // Restore user's last-used target settings on load - labelsPosition = "default", // Up to four labels will be grouped in a specified corner. Accepts "top-left," bottom-left," "bottom-right," and "default" + labelsPosition = "bottom-left", // Up to four labels will be grouped in a specified corner. Accepts "top-left," bottom-left," "bottom-right," and "default" stickyLabels = true, // "Sticky" labels analyticsEnabled = true, // Enables Google Analytics 4 measurement of site usage exportableGraphs = true, // Enables export graph button @@ -39,15 +40,41 @@ 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) + extraMusicEnabled = true; // Local file music player through parametric EQ (Equalizer tab) // 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 }, +]; // ************************************************************* @@ -88,7 +115,8 @@ function tsvParse(fr) { .filter(t => !isNaN(t[0]) && !isNaN(t[1])); } -// Apply stylesheet based layout options above +// Main app uses style-alt (+ theme) only. Legacy classic layout lives in style.css for old +// standalone pages (e.g. graph_hp.html) that link it directly — not loaded here. function setLayout() { function applyStylesheet(styleSheet) { var docHead = document.querySelector("head"), @@ -101,12 +129,8 @@ function setLayout() { docHead.append(linkTag); } - if ( !alt_layout ) { - applyStylesheet("style.css"); - } else { - applyStylesheet("style-alt.css"); - applyStylesheet("style-alt-theme.css"); - } + applyStylesheet("style-alt.css"); + applyStylesheet("style-alt-theme.css"); } setLayout(); diff --git a/data/IEF Cal.txt b/data/IEF Cal.txt new file mode 100644 index 0000000..3fe7d39 --- /dev/null +++ b/data/IEF Cal.txt @@ -0,0 +1,495 @@ +* Measurement data measured by REW V5.20.13 +* Source: Trace Arithmetic result A / B +* Format: Trace Arithmetic result A / B +* Dated: Jul 27, 2023 3:43:43 PM +* REW Settings: +* C-weighting compensation: Off +* Target level: 75.0 dB +* Note: ; Trace Arithmetic A over B A = MRS 10dB B = Crin BETA +* Measurement: MRS ∆ +* Smoothing: 1/24 octave +* Frequency Step: 1/48 octave +* Start Frequency: 20.000 Hz +* +* Freq(Hz) SPL(dB) +20.000000 1.407 +20.299999 1.392 +20.600000 1.368 +20.900000 1.342 +21.200001 1.316 +21.400000 1.299 +21.799999 1.266 +22.000000 1.251 +22.400000 1.223 +22.700001 1.202 +23.000000 1.183 +23.299999 1.164 +23.600000 1.147 +24.000000 1.125 +24.299999 1.110 +24.700001 1.091 +25.000000 1.078 +25.400000 1.061 +25.799999 1.046 +26.200001 1.031 +26.500000 1.021 +26.900000 1.009 +27.200001 1.000 +27.600000 0.990 +28.000000 0.980 +28.500000 0.969 +29.000000 0.960 +29.500000 0.951 +30.000000 0.943 +30.400000 0.938 +30.700001 0.935 +31.100000 0.930 +31.500000 0.927 +32.000000 0.923 +32.500000 0.920 +33.000000 0.917 +33.500000 0.915 +34.000000 0.914 +34.500000 0.912 +35.000000 0.911 +35.500000 0.909 +36.000000 0.909 +36.500000 0.908 +37.000000 0.908 +37.500000 0.907 +38.200001 0.905 +38.700001 0.905 +39.500000 0.904 +40.000000 0.903 +40.599998 0.902 +41.200001 0.900 +41.799999 0.899 +42.500000 0.897 +43.099998 0.894 +43.700001 0.892 +44.400002 0.889 +45.000000 0.886 +45.500000 0.884 +46.200001 0.881 +46.799999 0.877 +47.500000 0.873 +48.200001 0.869 +48.700001 0.866 +49.400002 0.861 +50.000000 0.857 +50.799999 0.851 +51.500000 0.846 +52.200001 0.841 +53.000000 0.835 +53.799999 0.829 +54.500000 0.824 +55.200001 0.819 +56.000000 0.812 +57.000000 0.804 +58.000000 0.797 +59.000000 0.788 +60.000000 0.781 +60.799999 0.774 +61.500000 0.770 +62.200001 0.765 +63.000000 0.760 +64.000000 0.753 +65.000000 0.746 +66.000000 0.739 +67.000000 0.732 +68.000000 0.727 +69.000000 0.721 +70.000000 0.715 +71.000000 0.710 +72.000000 0.705 +73.000000 0.700 +74.000000 0.695 +75.000000 0.691 +76.199997 0.686 +77.500000 0.682 +78.800003 0.678 +80.000000 0.675 +81.199997 0.672 +82.500000 0.669 +83.800003 0.667 +85.000000 0.664 +86.199997 0.663 +87.500000 0.661 +88.500000 0.660 +90.000000 0.659 +91.199997 0.659 +92.500000 0.658 +93.800003 0.658 +95.000000 0.658 +96.199997 0.658 +97.500000 0.658 +98.800003 0.659 +100.000000 0.659 +101.500000 0.659 +103.000000 0.661 +104.400002 0.661 +106.000000 0.662 +107.500000 0.662 +109.000000 0.663 +110.599998 0.664 +112.000000 0.664 +113.800003 0.665 +115.000000 0.666 +117.199997 0.667 +118.000000 0.667 +120.599998 0.668 +122.000000 0.668 +124.099998 0.668 +125.000000 0.668 +126.500000 0.668 +128.000000 0.668 +131.500000 0.667 +132.000000 0.667 +134.000000 0.667 +136.000000 0.667 +138.000000 0.665 +140.000000 0.664 +143.000000 0.662 +145.000000 0.661 +147.500000 0.658 +150.000000 0.656 +152.500000 0.654 +155.000000 0.652 +157.500000 0.649 +160.000000 0.646 +162.500000 0.644 +165.000000 0.642 +167.500000 0.638 +170.000000 0.635 +172.500000 0.633 +175.000000 0.630 +177.500000 0.627 +180.000000 0.623 +182.500000 0.620 +185.000000 0.617 +187.500000 0.615 +190.000000 0.612 +192.500000 0.609 +195.000000 0.606 +197.500000 0.603 +200.000000 0.600 +203.000000 0.597 +206.000000 0.594 +209.000000 0.590 +212.000000 0.587 +214.000000 0.584 +218.000000 0.580 +220.000000 0.578 +224.000000 0.573 +227.000000 0.570 +230.000000 0.567 +233.000000 0.564 +236.000000 0.561 +240.000000 0.557 +243.000000 0.554 +247.000000 0.550 +250.000000 0.547 +254.000000 0.542 +258.000000 0.538 +262.000000 0.534 +265.000000 0.530 +269.000000 0.527 +272.000000 0.524 +276.000000 0.519 +280.000000 0.515 +285.000000 0.510 +290.000000 0.505 +295.000000 0.500 +300.000000 0.495 +304.000000 0.490 +307.000000 0.487 +311.000000 0.483 +315.000000 0.478 +320.000000 0.472 +325.000000 0.467 +330.000000 0.461 +335.000000 0.455 +340.000000 0.449 +345.000000 0.443 +350.000000 0.438 +355.000000 0.432 +360.000000 0.425 +365.000000 0.419 +370.000000 0.413 +375.000000 0.407 +382.000000 0.399 +387.000000 0.392 +395.000000 0.381 +400.000000 0.375 +406.000000 0.367 +412.000000 0.359 +418.000000 0.350 +425.000000 0.340 +431.000000 0.331 +437.000000 0.323 +444.000000 0.312 +450.000000 0.303 +455.000000 0.295 +462.000000 0.284 +468.000000 0.274 +475.000000 0.263 +482.000000 0.252 +487.000000 0.243 +494.000000 0.232 +500.000000 0.221 +508.000000 0.207 +515.000000 0.195 +522.000000 0.183 +530.000000 0.169 +538.000000 0.155 +545.000000 0.142 +552.000000 0.130 +560.000000 0.116 +570.000000 0.098 +580.000000 0.082 +590.000000 0.066 +600.000000 0.050 +608.000000 0.039 +615.000000 0.030 +622.000000 0.021 +630.000000 0.012 +640.000000 0.003 +650.000000 -0.004 +660.000000 -0.010 +670.000000 -0.014 +680.000000 -0.017 +690.000000 -0.017 +700.000000 -0.015 +710.000000 -0.011 +720.000000 -0.007 +730.000000 -0.001 +740.000000 0.005 +750.000000 0.011 +762.000000 0.020 +775.000000 0.029 +788.000000 0.039 +800.000000 0.049 +812.000000 0.058 +825.000000 0.069 +838.000000 0.081 +850.000000 0.092 +862.000000 0.103 +875.000000 0.116 +885.000000 0.127 +900.000000 0.144 +912.000000 0.157 +925.000000 0.171 +938.000000 0.183 +950.000000 0.192 +962.000000 0.199 +975.000000 0.202 +988.000000 0.199 +1000.000000 0.191 +1015.000000 0.173 +1030.000000 0.145 +1044.000000 0.112 +1060.000000 0.065 +1075.000000 0.013 +1090.000000 -0.044 +1106.000000 -0.109 +1120.000000 -0.169 +1138.000000 -0.248 +1150.000000 -0.302 +1172.000000 -0.400 +1180.000000 -0.435 +1206.000000 -0.546 +1220.000000 -0.604 +1241.000000 -0.685 +1250.000000 -0.718 +1265.000000 -0.769 +1280.000000 -0.817 +1315.000000 -0.913 +1320.000000 -0.925 +1340.000000 -0.970 +1360.000000 -1.009 +1380.000000 -1.041 +1400.000000 -1.066 +1430.000000 -1.097 +1450.000000 -1.113 +1475.000000 -1.128 +1500.000000 -1.139 +1525.000000 -1.147 +1550.000000 -1.153 +1575.000000 -1.158 +1600.000000 -1.164 +1625.000000 -1.171 +1650.000000 -1.180 +1675.000000 -1.191 +1700.000000 -1.204 +1725.000000 -1.219 +1750.000000 -1.236 +1775.000000 -1.256 +1800.000000 -1.278 +1825.000000 -1.302 +1850.000000 -1.327 +1875.000000 -1.354 +1900.000000 -1.382 +1925.000000 -1.411 +1950.000000 -1.441 +1975.000000 -1.473 +2000.000000 -1.505 +2030.000000 -1.543 +2060.000000 -1.580 +2090.000000 -1.615 +2120.000000 -1.648 +2140.000000 -1.668 +2180.000000 -1.705 +2200.000000 -1.720 +2240.000000 -1.745 +2270.000000 -1.760 +2300.000000 -1.770 +2330.000000 -1.778 +2360.000000 -1.784 +2400.000000 -1.787 +2430.000000 -1.787 +2470.000000 -1.785 +2500.000000 -1.783 +2540.000000 -1.778 +2580.000000 -1.772 +2620.000000 -1.764 +2650.000000 -1.756 +2690.000000 -1.744 +2720.000000 -1.733 +2760.000000 -1.716 +2800.000000 -1.696 +2850.000000 -1.666 +2900.000000 -1.630 +2950.000000 -1.589 +3000.000000 -1.542 +3040.000000 -1.500 +3070.000000 -1.466 +3110.000000 -1.418 +3150.000000 -1.367 +3200.000000 -1.301 +3250.000000 -1.231 +3300.000000 -1.160 +3350.000000 -1.087 +3400.000000 -1.011 +3450.000000 -0.935 +3500.000000 -0.858 +3550.000000 -0.780 +3600.000000 -0.703 +3650.000000 -0.624 +3700.000000 -0.546 +3750.000000 -0.469 +3820.000000 -0.362 +3870.000000 -0.287 +3950.000000 -0.171 +4000.000000 -0.103 +4060.000000 -0.024 +4120.000000 0.048 +4180.000000 0.113 +4250.000000 0.176 +4310.000000 0.219 +4370.000000 0.251 +4440.000000 0.273 +4500.000000 0.279 +4550.000000 0.276 +4620.000000 0.263 +4680.000000 0.247 +4750.000000 0.226 +4820.000000 0.209 +4870.000000 0.202 +4940.000000 0.199 +5000.000000 0.206 +5080.000000 0.228 +5150.000000 0.258 +5220.000000 0.296 +5300.000000 0.347 +5380.000000 0.404 +5450.000000 0.456 +5520.000000 0.508 +5600.000000 0.566 +5700.000000 0.634 +5800.000000 0.694 +5900.000000 0.746 +6000.000000 0.788 +6080.000000 0.815 +6150.000000 0.832 +6220.000000 0.844 +6300.000000 0.853 +6400.000000 0.855 +6500.000000 0.849 +6600.000000 0.835 +6700.000000 0.814 +6800.000000 0.788 +6900.000000 0.759 +7000.000000 0.728 +7100.000000 0.699 +7200.000000 0.673 +7300.000000 0.653 +7400.000000 0.640 +7500.000000 0.638 +7620.000000 0.652 +7750.000000 0.690 +7880.000000 0.755 +8000.000000 0.842 +8120.000000 0.956 +8250.000000 1.109 +8380.000000 1.291 +8500.000000 1.484 +8620.000000 1.696 +8750.000000 1.943 +8850.000000 2.140 +9000.000000 2.435 +9120.000000 2.658 +9250.000000 2.875 +9380.000000 3.059 +9500.000000 3.195 +9620.000000 3.298 +9750.000000 3.374 +9880.000000 3.414 +10000.000000 3.417 +10150.000000 3.375 +10300.000000 3.281 +10440.000000 3.160 +10600.000000 3.038 +10750.000000 3.019 +10900.000000 3.142 +11060.000000 3.394 +11200.000000 3.635 +11380.000000 3.888 +11500.000000 4.000 +11720.000000 4.110 +11800.000000 4.118 +12060.000000 4.029 +12200.000000 3.886 +12410.000000 3.542 +12500.000000 3.363 +12650.000000 3.041 +12800.000000 2.714 +13150.000000 2.007 +13200.000000 1.917 +13400.000000 1.602 +13600.000000 1.362 +13800.000000 1.196 +14000.000000 1.105 +14300.000000 1.086 +14500.000000 1.131 +14750.000000 1.221 +15000.000000 1.307 +15250.000000 1.351 +15500.000000 1.331 +15750.000000 1.234 +16000.000000 1.065 +16250.000000 0.840 +16500.000000 0.578 +16750.000000 0.296 +17000.000000 0.009 +17250.000000 -0.274 +17500.000000 -0.541 +17750.000000 -0.777 +18000.000000 -0.968 +18250.000000 -1.095 +18500.000000 -1.142 +18750.000000 -1.103 +19000.000000 -0.984 +19250.000000 -0.813 +19500.000000 -0.689 +19750.000000 -0.350 +20000.000000 -0.350 diff --git a/data/phone_book.json b/data/phone_book.json index dd76dc9..b0055fa 100644 --- a/data/phone_book.json +++ b/data/phone_book.json @@ -15,20 +15,6 @@ "reviewLink":"https://www.youtube.com/", "shopLink":"https://www.amazon.com/", "price":"¥100" - }, - {"name":["Variations 2"], - "file":["Combo Variant 1", - "Combo Variant 2", - "Combo Variant 3", - "Combo Variant 4"], - "suffix":["var1", - "var2", - "var3", - "var4"], - "reviewScore":"A+", - "reviewLink":"https://www.head-fi.org/forums/", - "shopLink":"https://www.aliexpress.com/", - "price":"$100" } ] }, diff --git a/equalizer-constraints.json b/equalizer-constraints.json new file mode 100644 index 0000000..d0901b1 --- /dev/null +++ b/equalizer-constraints.json @@ -0,0 +1,93 @@ +{ + "presets": [ + { + "id": "default", + "label": "Default", + "twoChannelSupport": false, + "maxBands": 0, + "allowPk": true, + "allowLsq": true, + "allowHsq": true, + "freqMin": "20", + "freqMax": "20000", + "gainMin": "0", + "gainMax": "0", + "qMin": "0.1", + "qMax": "10" + }, + { + "id": "auto-eq", + "kind": "system", + "label": "Auto EQ", + "twoChannelSupport": false, + "maxBands": 0, + "allowPk": true, + "allowLsq": true, + "allowHsq": true, + "freqMin": "20", + "freqMax": "6000", + "gainMin": "0", + "gainMax": "0", + "qMin": "0.1", + "qMax": "10" + }, + { + "id": "generic-10-band", + "label": "Generic 10-band graphic EQ", + "twoChannelSupport": false, + "maxBands": 10, + "allowPk": true, + "allowLsq": false, + "allowHsq": false, + "freqGraphicList": "31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000", + "gainMin": "-10", + "gainMax": "10", + "qMin": "0", + "qMax": "1.4" + }, + { + "id": "fiio-eh11", + "label": "Fiio EH11", + "twoChannelSupport": false, + "maxBands": 10, + "allowPk": true, + "allowLsq": true, + "allowHsq": true, + "freqMin": "20", + "freqMax": "20000", + "gainMin": "-12", + "gainMax": "12", + "qMin": "0.25", + "qMax": "8" + }, + { + "id": "sennheiser-hdb-630", + "label": "Sennheiser HDB 630", + "twoChannelSupport": false, + "maxBands": 5, + "allowPk": true, + "allowLsq": true, + "allowHsq": true, + "freqMin": "20", + "freqMax": "20000", + "gainMin": "-6", + "gainMax": "6", + "qMin": "0.25", + "qMax": "8" + }, + { + "id": "sony-wh-1000xm6", + "label": "Sony WH-1000XM6", + "twoChannelSupport": false, + "maxBands": 10, + "allowPk": true, + "allowLsq": false, + "allowHsq": false, + "freqGraphicList": "31, 63, 125, 250, 500, 1000, 2000, 4000, 8000, 16000", + "gainMin": "-6", + "gainMax": "6", + "qMin": "0", + "qMax": "1" + } + ] +} diff --git a/equalizer.js b/equalizer.js index a306b4e..95fdd53 100644 --- a/equalizer.js +++ b/equalizer.js @@ -14,11 +14,17 @@ Equalizer = (function() { // Avoid filters close to nyquist frequency by default, because the behavior is implementation dependent // https://github.com/jaakkopasanen/AutoEq/issues/240 // https://github.com/jaakkopasanen/AutoEq/issues/411 - AutoEQRange: [20, 15000], + AutoEQRange: [20, 20000], + // null = use AutoEQRange min/max; else sorted Hz list for fixed-band (graphic) headphone EQ UI + EqGraphicBandFreqHz: null, + // 0 = no cap (graphtool); >0 caps active bands / AutoEQ + EqMaxBands: 0, + // Which filter types are allowed in UI / strip (AutoEQ currently emits PK only) + EqAllowedTypes: { PK: true, LSQ: true, HSQ: true }, // Minimum and maximum Q for AutoEQ feature - OptimizeQRange: [0.5, 2], - // Minimum and maximum Gain for AutoEQ feature - OptimizeGainRange: [-12, 12], + OptimizeQRange: [0.1, 10], + // Minimum and maximum Gain for AutoEQ feature (graphtool may widen via constraints) + OptimizeGainRange: [-40, 40], // Delta and step of Freq, Q and Gain used for AutoEQ optimizing OptimizeDeltas: [ [10, 10, 10, 5, 0.1, 0.5], @@ -263,12 +269,37 @@ Equalizer = (function() { // Make freq, q and gain look better and more compatible to some DSP device let [minQ, maxQ] = config.OptimizeQRange; let [minGain, maxGain] = config.OptimizeGainRange; - return filters.map(f => ({ - type: f.type, - freq: Math.floor(f.freq - f.freq % freq_unit(f.freq)), - q: Math.min(Math.max(Math.floor(f.q * 10) / 10, minQ), maxQ), - gain: Math.min(Math.max(Math.floor(f.gain * 10) / 10, minGain), maxGain) - })); + let [minFreq, maxFreq] = config.AutoEQRange; + if (minFreq > maxFreq) { + let t = minFreq; + minFreq = maxFreq; + maxFreq = t; + } + let allowed = config.EqAllowedTypes || { PK: true, LSQ: true, HSQ: true }; + let fallbackType = () => (allowed.PK ? "PK" : (allowed.LSQ ? "LSQ" : "HSQ")); + return filters.map(f => { + let t = f.type; + if (t !== "PK" && t !== "LSQ" && t !== "HSQ") { + t = "PK"; + } + if (!allowed[t]) { + t = fallbackType(); + } + let fq = f.freq; + let snapped; + if (!Number.isFinite(fq) || fq <= 0) { + snapped = minFreq; + } else { + snapped = Math.floor(fq - fq % freq_unit(fq)); + } + let freq = Math.min(Math.max(snapped, minFreq), maxFreq); + return { + type: t, + freq, + q: Math.min(Math.max(Math.floor(f.q * 10) / 10, minQ), maxQ), + gain: Math.min(Math.max(Math.floor(f.gain * 10) / 10, minGain), maxGain) + }; + }); }; let optimize = function (fr, frTarget, filters, iteration, dir) { @@ -363,6 +394,10 @@ Equalizer = (function() { let autoeq = function (fr, frTarget, maxFilters) { // 2 steps manual optimized algorithm // fr, frTarget should has same resolution and normalized + maxFilters = Math.floor(Number(maxFilters)) || 1; + if (config.EqMaxBands > 0) { + maxFilters = Math.max(1, Math.min(maxFilters, config.EqMaxBands)); + } let firstBatchSize = Math.max(Math.floor(maxFilters / 2) - 1, 1); let firstCandidates = search_candidates(fr, frTarget, 1); let firstFilters = (firstCandidates 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 bb24b5c..c32f481 100644 --- a/graphtool.js +++ b/graphtool.js @@ -1,211 +1,10 @@ +// Config defaults moved to src/app-core.js (loads before graphtool.js) + 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
-
- -
- - -
- -
-
-
-
- - - -
- -
- - - - - - - - - -
-
-
-
-
- - -
-
-
- -
-`); +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 }; @@ -230,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") @@ -325,11 +130,19 @@ let fade = defs.append("mask") fade.append("rect").attrs({ x:0, y:0, width:W, height:H, fill:"white" }); let fadeEdge = fade.selectAll().data([0,1]).join("rect") .attrs(i=>({ x:i?W-fW:0, width:fW, y:0,height:H, fill:"url(#grad"+i+")" })); +let spectrumClipBleed = 4; +defs.append("clipPath").attr("id", "spectrum-clip-inner") + .append("rect").attrs({ + x: -spectrumClipBleed, + y: -spectrumClipBleed, + width: W + 2 * spectrumClipBleed, + height: H + 2 * spectrumClipBleed + }); 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 @@ -344,7 +157,8 @@ rangeSel.on("click", function (_,i) { // More time to go between bass and treble let dur = Math.min(r,s)===0 && Math.max(r,s)===2 ? 1100 : 700; clearLabels(); - gpath.selectAll("path").transition().duration(dur).attr("d", drawLine); + gpath.selectAll("path").transition().duration(dur).attr("d", drawLine) + .on("end", () => updateEqFilterMarkers()); let e = edgeWs[s]; fadeEdge.transition().duration(dur).attrs(i=>({x:i?W-e[i]:0, width:e[i]})); xAxisObj.transition().duration(dur).call(fmtX); @@ -360,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)"); @@ -422,466 +237,465 @@ dB.updatey = function (dom) { let getTr = o => o ? "translate(0,"+(y(o)-y(0))+")" : null; clearLabels(); gpath.selectAll("path").call(redrawLine); + updateEqFilterMarkers(); } -// Label drawing and screenshot -let getFullName = p => p.dispBrand+" "+p.dispName, - getChannelName = p => n => getFullName(p) + " ("+n+")"; +// 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}, +}; +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]); +}); -let labelButton = doc.select("#label"), - labelsShown = false; -function setLabelButton(l) { - labelButton.classed("selected", labelsShown = l); -} -function clearLabels() { - gr.selectAll(".lineLabel").remove(); - setLabelButton(false); -} +// File loading, channel management, measurement init moved to src/graph-renderer.js -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)) - }); +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) { + initPhoneOrderIndex.clear(); + if (!req || !Array.isArray(req)) { + return; } - - gr.selectAll(".lineLabel").remove(); - let g = gr.selectAll(".lineLabel").data(bcurves) - .join("g").attr("class","lineLabel").attr("opacity", 0); - 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 k = String(name || "").trim(); + if (k && !initPhoneOrderIndex.has(k)) { + initPhoneOrderIndex.set(k, i); } - let rangeGetters = [Math.min, Math.max].map(f => { - 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); -} - -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; +function initOrderRankForPhone(p) { + if (!p) { + return null; + } + let fn = String(p.fileName || "").trim(); + if (initPhoneOrderIndex.has(fn)) { + return initPhoneOrderIndex.get(fn); + } + if (p.copyOf) { + let root = p.copyOf, + pf = String(root.fileName || "").trim(); + if (initPhoneOrderIndex.has(pf)) { + let base = initPhoneOrderIndex.get(pf), + objs = root.objs || [root], + j = objs.indexOf(p); + if (j < 0) { + j = objs.length; + } + return base + j * 1e-4; + } } - return smooth_eval(p, y); + return null; } - -function smoothPhone(p) { - 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); +function reorderActivePhonesByInitOrder() { + if (!initPhoneOrderIndex.size) { + return; } + let orig = new Map(); + activePhones.forEach((q, i) => orig.set(q, i)); + activePhones.sort((a, b) => { + let ra = initOrderRankForPhone(a), + rb = initOrderRankForPhone(b); + if (ra == null) { + ra = 1e6 + orig.get(a) * 1e-6; + } + if (rb == null) { + rb = 1e6 + orig.get(b) * 1e-6; + } + if (ra !== rb) { + return ra - rb; + } + return orig.get(a) - orig.get(b); + }); } - -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] -}; -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]; +/** Targets first, then non-targets; relative order preserved within each group (table + graph). */ +function phonesClusteredTargetsFirst(list) { + return list.filter((p) => p && p.isTarget).concat(list.filter((p) => p && !p.isTarget)); +} +/** Curve draw order: targets first in DOM so they paint under IEM traces (SVG paint = document order). */ +function curvesTargetsFirstForPaint(curves) { + let t = [], + o = []; + (curves || []).forEach((c) => { + if (c && c.p && c.p.isTarget) { + t.push(c); } 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]); }; + o.push(c); } - 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) - : [""]; -function loadFiles(p, callback) { - let l = f => d3.text(DIR+f+".txt").catch(()=>null); - let f = p.isTarget ? [l(p.fileName)] - : d3.merge(LR.map(s => - sampnums.map(n => l(p.fileName+" "+s+n)))); - Promise.all(f).then(function (frs) { - if (!frs.some(f=>f!==null)) { - alert("Headphone not found!"); + return t.concat(o); +} +/** IEMs first (for pointer hit-tests so ties pick the measurement over a target). */ +function curvesPhonesFirstForPointer(curves) { + let t = [], + o = []; + (curves || []).forEach((c) => { + if (c && c.p && c.p.isTarget) { + t.push(c); } else { - let ch = frs.map(f => f && Equalizer.interp(f_values, tsvParse(f))); - ch = ch.filter(c => c !== null); - callback(ch); + o.push(c); } }); + return o.concat(t); } -let validChannels = p => p.channels.filter(c=>c!==null); -let numChannels = p => d3.sum(p.channels, c=>c!==null); -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 avgCurves(curves) { - 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 clusterTargetsFirstInActivePhones() { + let next = phonesClusteredTargetsFirst(activePhones); + activePhones.length = 0; + activePhones.push(...next); } -function getAvg(p) { - if (p.avg) return p.activeCurves[0].l; - let v = validChannels(p); - return v.length===1 ? v[0] : avgCurves(v); -} -function hasImbalance(p) { - if (!hasChannelSel(p)) return false; - let as = p.channels[0], bs = p.channels[1]; - 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; - }); +let phoneManageIdentityMap = new WeakMap(), + phoneManageIdentitySeq = 1; +function phoneManageIdentity(p) { + if (p == null) return 0; + let v = phoneManageIdentityMap.get(p); + if (v == null) { + v = phoneManageIdentitySeq++; + phoneManageIdentityMap.set(p, v); + } + return v; } - -let activePhones = []; 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; +const EQ_GRAPH_MARKER_R_BASE = 2.5; +const EQ_GRAPH_MARKER_UNSEL_SCALE = 1; +const EQ_GRAPH_MARKER_UNSEL_STROKE = "trace"; +const EQ_GRAPH_MARKER_UNSEL_FILL = "graph"; +const EQ_GRAPH_MARKER_UNSEL_HOVER_SCALE = 1; +const EQ_GRAPH_MARKER_SEL_SCALE = 1.8; +const EQ_GRAPH_MARKER_SEL_STROKE = "graph"; +const EQ_GRAPH_MARKER_SEL_FILL = "trace"; +const EQ_GRAPH_MARKER_SEL_HOVER_SCALE = 1.2; +const EQ_GRAPH_MARKER_STROKE_W = 4; +const EQ_GRAPH_MARKER_STROKE_HOVER_MULT = 2; +/** Base stroke width in SVG user units (sample vs main traces). Not overridden by CSS when scoped in style.css. */ +const EQ_GRAPH_TRACE_STROKE_SAMPLE = 1.9; +const EQ_GRAPH_TRACE_STROKE_NORMAL = 2.3; +const EQ_GRAPH_TRACE_STROKE_EMPH_MULT = 2; + +// 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",2.3) + .attr("stroke-width", EQ_GRAPH_TRACE_STROKE_NORMAL) .attr("class", "curves-g") .attr("mask","url(#graphFade)"); -function hl(p, h) { - gpath.selectAll("path").filter(c=>c.p===p).classed("highlight",h); -} -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; +window.gpath = gpath; +function eqMarkerResolvePaint(spec, traceCol) { + if (spec === "trace") { + /* d3 / browser color parsing may call .match on strings; null/empty breaks updates. */ + if (traceCol != null && traceCol !== "" && traceCol !== "none") { + return traceCol; + } + return "#888888"; + } + if (spec === "graph") { + return "var(--background-color-graph)"; + } + return spec; +} +/** Named d3 transition for EQ trace emphasis only (do not interrupt path "d" tweens). */ +const EQ_GRAPH_TRACE_EM_TNAME = "eq-trace-em"; +/** After updatePaths join, paths must carry explicit stroke-width; inherited + d3.attr tween + from missing attribute can interpolate from 0 → hairline traces after Q/EQ updates. */ +function resetGraphPathStrokesToBase() { + gpath.selectAll("path").each(function () { + let n = d3.select(this); + n.interrupt(EQ_GRAPH_TRACE_EM_TNAME); + let c = n.datum(); + let base = this.classList.contains("sample") + ? EQ_GRAPH_TRACE_STROKE_SAMPLE + : EQ_GRAPH_TRACE_STROKE_NORMAL; + let sw = (c && c.p && c.p.isTarget) ? targetTraceStrokeWidthForPhone(c.p) : base; + n.attr("stroke-width", sw); + }); } -function color_curveToText(c) { - if (!alt_layout) { - c.l = c.l/5 + 10; - c.c /= 3; +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: () => {} }; +window.updateEqTraceOpacity = () => {}; +/** Set in addExtra: after multi-sample FR refine, sync EQ trace (loadFiles late branch has no callback). */ +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; +function eqGraphDragSelectBlock(ev) { + ev.preventDefault(); +} +function eqGraphInstallDragSelectLock() { + if (eqGraphDragSelectSaved !== null) { + eqGraphRemoveDragSelectLock(); + } + let de = document.documentElement; + let b = document.body; + eqGraphDragSelectSaved = { + deUser: de.style.userSelect, + deWebkit: de.style.webkitUserSelect || "", + bUser: b.style.userSelect, + bWebkit: b.style.webkitUserSelect || "", + }; + de.style.userSelect = "none"; + de.style.webkitUserSelect = "none"; + b.style.userSelect = "none"; + b.style.webkitUserSelect = "none"; + document.addEventListener("selectstart", eqGraphDragSelectBlock, true); + document.addEventListener("dragstart", eqGraphDragSelectBlock, true); + let sel = typeof window.getSelection === "function" ? window.getSelection() : null; + if (sel && sel.rangeCount > 0) { + sel.removeAllRanges(); + } +} +function eqGraphRemoveDragSelectLock() { + if (eqGraphDragSelectSaved === null) { + return; + } + let s = eqGraphDragSelectSaved; + eqGraphDragSelectSaved = null; + let de = document.documentElement; + let b = document.body; + de.style.userSelect = s.deUser; + de.style.webkitUserSelect = s.deWebkit; + b.style.userSelect = s.bUser; + b.style.webkitUserSelect = s.bWebkit; + document.removeEventListener("selectstart", eqGraphDragSelectBlock, true); + document.removeEventListener("dragstart", eqGraphDragSelectBlock, true); +} +/** @type {(m: number[]) => boolean} */ +let tryEqGraphClickAddFilter = (_m) => false; +Object.defineProperty(window, 'tryEqGraphClickAddFilter', { get: () => tryEqGraphClickAddFilter, configurable: true }); +/** @type {(m: number[] | null) => void} */ +let gSpectrum = gr.insert("g", ".curves-g") + .attr("class", "music-spectrum-viz") + .attr("pointer-events", "none") + .attr("transform", "translate(" + pad.l + "," + pad.t + ")") + .attr("clip-path", "url(#spectrum-clip-inner)"); +let musicSpectrumPathSel = gSpectrum.append("path") + .attr("class", "music-spectrum-fill"); +let musicSpectrumViz = { + analyser: null, + context: null, + floatBuffer: null, + rafId: null, + pathSel: musicSpectrumPathSel, + isActive: () => false, + syncSpectrumViz: () => {}, + ensureBuffer: function () { + if (!this.analyser) { + return; + } + let n = this.analyser.frequencyBinCount; + if (!this.floatBuffer || this.floatBuffer.length !== n) { + this.floatBuffer = new Float32Array(n); + } + }, + stop: function () { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + if (this.pathSel) { + this.pathSel.attr("d", ""); + } + }, + tick: function () { + let self = musicSpectrumViz; + self.rafId = null; + if (!self.analyser || !self.floatBuffer || !self.pathSel || !self.context || !self.isActive()) { + if (self.pathSel) { + self.pathSel.attr("d", ""); + } + return; + } + self.analyser.getFloatFrequencyData(self.floatBuffer); + self.pathSel.attr("d", buildMusicSpectrumPath(self.floatBuffer)); + self.rafId = requestAnimationFrame(() => self.tick()); + }, + start: function () { + if (!this.analyser || !this.pathSel) { + return; + } + this.stop(); + 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; +function buildMusicSpectrumPath(floatFreq) { + let ctx = musicSpectrumViz.context; + if (!ctx || !floatFreq || !floatFreq.length) { + return ""; + } + let sr = ctx.sampleRate; + let nyquist = sr / 2; + let binCount = floatFreq.length; + let hzPerBin = nyquist / binCount; + let magAtHz = (f) => { + if (f <= 0) { + f = 1; + } + let idx = f / hzPerBin; + let i0 = Math.floor(idx); + let i1 = Math.min(i0 + 1, binCount - 1); + let t = idx - i0; + return floatFreq[i0] * (1 - t) + floatFreq[i1] * t; + }; + let x0 = x.domain()[0]; + let x1 = Math.min(x.domain()[1], nyquist * 0.995); + if (x1 <= x0 * 1.02) { + return ""; + } + let nPoints = 128; + let yBottomLocal = H; + let d0 = y.domain()[0]; + let d1 = y.domain()[1]; + let dbs = []; + let freqs = []; + for (let i = 0; i <= nPoints; i++) { + let u = i / nPoints; + let uEff = u <= 0 ? 0 : Math.pow(u, spectrumPathLogSampleGamma); + let f = x0 * Math.pow(x1 / x0, uEff); + freqs.push(f); + dbs.push(magAtHz(f)); + } + let hi = -Infinity; + let lo = Infinity; + for (let j = 0; j < dbs.length; j++) { + let v = dbs[j]; + if (Number.isFinite(v)) { + hi = Math.max(hi, v); + lo = Math.min(lo, v); + } } - return c; + if (!Number.isFinite(hi) || !Number.isFinite(lo)) { + return ""; + } + let minSpanDb = 42; + let spanDb = Math.max(minSpanDb, hi - lo); + let baseDb = hi - spanDb; + let pts = []; + for (let i = 0; i <= nPoints; i++) { + let n = (dbs[i] - baseDb) / spanDb; + n = Math.max(0, Math.min(1, n)); + let spl = d0 + n * (d1 - d0) * 0.92; + pts.push([x(freqs[i]) - pad.l, y(spl) - pad.t]); + } + let d = "M" + pts[0][0] + "," + yBottomLocal; + pts.forEach((p) => { + d += " L" + p[0] + "," + p[1]; + }); + d += " L" + pts[pts.length - 1][0] + "," + yBottomLocal + " Z"; + return d; } -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; +function hl(p, h, sub) { + gpath.selectAll("path").filter(c => { + if (c.p !== p) return false; + if (sub === undefined || sub === null) return true; + return c === p.activeCurves[sub]; + }).classed("highlight", h); } +let table = doc.select(".curves"); + +// ld_p1, getCurveColor, color helpers moved to src/graph-renderer.js let cantCompare; let noTargets = typeof disallow_target !== "undefined" && disallow_target; @@ -987,103 +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)"; -function setCurves(p, avg, lr, samp) { - 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 = p.channels.length/2, - 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)) - : 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:p.fullName, 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) return; - gpath.selectAll("path") - .transition().duration(500).ease(d3.easeQuad) - .attr("d", drawLine); - table.selectAll("tr").select(".button-baseline") - .classed("selected", p=>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'); @@ -1102,416 +819,309 @@ 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 = ""; +try { + __eqUrlShareBootstrapSearch = targetWindow && targetWindow.location + ? String(targetWindow.location.search || "") + : ""; +} catch (e) { + __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). */ +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 function addPhonesToUrl() { - let title = baseTitle, - url = baseURL, - names = activePhones.filter(p => !p.isDynamic).map(p => p.fileName), + let names = activePhones.filter(p => !p.isDynamic).map(p => p.fileName), namesCombined = names.join(", "); - - if (names.length) { - url += "?share=" + encodeURI(names.join().replace(/ /g,"_")); - title = namesCombined + " - " + title; + 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); + 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 = ""; + let eqModelTit = "", + eqTargetTit = ""; + let title = baseTitle; + if (ifURL && onEqTab) { + /* EQ tab: omit `share=` so the URL lists only EQ model/target/filters — no unrelated graph traces. */ + let eqSel = document.querySelector("div.extra-eq div.select-eq-phone-model-target select[name='phone']") + || document.querySelector("div.extra-eq select[name='phone']"); + let eqTgt = document.querySelector("div.extra-eq div.select-eq-phone-model-target select[name='eq-target']") + || document.querySelector("div.extra-eq select[name='eq-target']"); + eqModelTit = eqSel ? String(eqSel.value || "").trim() : ""; + eqTargetTit = eqTgt ? String(eqTgt.value || "").trim() : ""; + if (eqModelTit && eqTargetTit) { + title = eqModelTit + " → " + eqTargetTit + " - " + baseTitle; + } else if (eqModelTit || eqTargetTit) { + title = (eqModelTit || eqTargetTit) + " - " + baseTitle; + } + } else if (names.length) { + if (ifURL && __graphShareUrlSyncAllowed) { + shareQueryPair = "share=" + shareQueryValueForUrl(names); + } + title = namesCombined + " - " + baseTitle; } - if (names.length === 1) { - targetWindow.document.querySelector("link[rel='canonical']").setAttribute("href",url) + if (ifURL && typeof window._appendEqShareParamsToUrlSearch === "function") { + window._appendEqShareParamsToUrlSearch(u); } else { - targetWindow.document.querySelector("link[rel='canonical']").setAttribute("href",baseURL) + ["eq", "eqModel", "eqTarget", "eqFilters", "eqModelData", "eqTargetData", + "eq_model", "eq_target", "eq_filters", "eq_model_data", "eq_target_data"].forEach( + (k) => u.searchParams.delete(k)); } - targetWindow.history.replaceState("", title, url); + if (ifURL && typeof window._appendMusicShareParamsToUrlSearch === "function") { + window._appendMusicShareParamsToUrlSearch(u); + } 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 + ? ("?" + shareQueryPair + "&" + qsRest) + : (shareQueryPair ? ("?" + shareQueryPair) : (qsRest ? ("?" + qsRest) : ""))); + let canonicalHref = baseURL || ref; + if (ifURL && onEqTab && (u.searchParams.get(EQ_URL_PARAM_MODEL) || u.searchParams.get("eq_model"))) { + canonicalHref = outUrl; + } else if (!onEqTab && names.length === 1) { + canonicalHref = outUrl; + } + targetWindow.document.querySelector("link[rel='canonical']").setAttribute("href", canonicalHref); + targetWindow.history.replaceState("", title, outUrl); targetWindow.document.title = title; - targetWindow.document.querySelector("meta[name='description']").setAttribute("content",baseDescription + ", including " + namesCombined +"."); + let metaDesc = baseDescription; + if (ifURL && onEqTab && (eqModelTit || eqTargetTit)) { + metaDesc += " Parametric EQ: " + [eqModelTit, eqTargetTit].filter(Boolean).join(" → ") + "."; + } else { + metaDesc += ", including " + namesCombined + "."; + } + targetWindow.document.querySelector("meta[name='description']").setAttribute("content", metaDesc); } function setModeEmbed() { document.querySelector("body").setAttribute("embed-mode", "true"); } -function updatePaths(trigger) { - clearLabels(); - let c = d3.merge(activePhones.map(p => p.activeCurves)), +/** Rejoin path elements to active curve data and redraw (no clearLabels / URL / sticky labels). */ +function rebindGraphPathSelectionAndRedraw() { + let c = curvesTargetsFirstForPaint(d3.merge(activePhones.map(p => p.activeCurves || []))), p = gpath.selectAll("path").data(c, d=>d.id); - let t = p.join("path").attr("opacity", 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; + } + } + 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) - .filter(c=>c.p.isTarget) + .attr("stroke", getColor_AC).call(redrawLine); + if (typeof joined.order === "function") { + joined.order(); + } + let t = joined.filter(c=>c.p.isTarget) .attr("data-phone-name", c=>c.p.fullName) .attr("class", "target"); - if (targetDashed) t.style("stroke-dasharray", "6, 3"); - if (targetColorCustom) t.attr("stroke", targetColorCustom); - if (ifURL && !trigger) addPhonesToUrl(); - if (stickyLabels) drawLabels(); -} -let colorBar = p=>'url(\'data:image/svg+xml,\')'; -function updatePhoneTable(trigger) { - let c = table.selectAll("tr").data(activePhones, p=>p.fileName); - c.exit().remove(); - - let f = c.enter().append("tr").attr("data-filename", p=>p.fileName), - td = () => f.append("td"); - f .call(setHover, h => p => hl(p,h)) - .style("color", p => getDivColor(p.id,true)); - - td().attr("class","remove").text("⊗") - .attr("title", "Remove graph") - .on("click", removePhone) - .style("background-image",colorBar) - .filter(p=>!p.isTarget).append("svg").call(addColorPicker); - td().attr("class","item-line item-target") - .call(s=>s.filter(p=>!p.isTarget).attr("class","item-line item-phone") - .append("span").attr("class","brand").text(p=>p.dispBrand)) - .call(addModel); - td().attr("class","curve-color").append("button") - .style("background-color",p=>getCurveColor(p.id,0)) - .filter(p=>!p.isTarget).call(makeColorPicker); - td().attr("class","channels").append("svg").call(addKey) - td().attr("class","levels").append("input") - .attrs({type:"number",step:"any",value:0}) - .property("value", p=>p.offset) - .on("change input",function(p){ setOffset(p, +this.value); }); - if (exportableGraphs) { - td().attr("class","button button-export") - .attr("title", "Export graph") - .on("click", function(p) { - let phoneName = p.fullName, - channels = p.rawChannels, - exportContainer = document.querySelector('body'); - - channels.forEach(function(channel, i) { - 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", p => setBaseline(p===baseline.p ? baseline0 - : getBaseline(p))); - function toggleHide(p) { - let h = p.hide; - let t = table.selectAll("tr").filter(q=>q===p); - 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 (labelsShown) { - clearLabels(); - drawLabels(); + resetGraphPathStrokesToBase(); + gpath.selectAll("path").each(function (c) { + let n = d3.select(this); + if (!c || !c.p) { + return; + } + if (c.p.isTarget) { + applyTargetCurveStrokePattern(n, c.p); + } else if (c.p.isPrefBounds) { + n.style("stroke-dasharray", "6, 3"); + } else { + clearNonTargetCurveStrokePattern(n); } + }); + if (targetColorCustom) { + t.attr("stroke", targetColorCustom); } - td().attr("class","button hideIcon") - .attr("title", "Hide graph") - .html("") - .on("click", toggleHide); - td().attr("class","button button-pin") - .attr("title", "Pin graph") - .attr("data-pinned","false") - .html("") - .on("click",function(p){ - if (cantCompare(activePhones.filter(p=>p.pin),1)) return; - - if ( p.pin ) { - p.pin = false; - this.setAttribute("data-pinned","false"); - } else { - p.pin = true; nextPN = null; - this.setAttribute("data-pinned","true"); - } - - 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(); - }); } -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); +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.__eqCoord.batchSuppressDepth | 0) > 0) { + window.__eqCoord.batchPathsPending = true; + return; } - 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)); + if (typeof window !== "undefined") { + window.__eqCoord.batchPathsPending = false; + } + reorderActivePhonesByInitOrder(); + clusterTargetsFirstInActivePhones(); + refreshTargetStyleSlots(); + clearLabels(); + rebindGraphPathSelectionAndRedraw(); + /* 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(); } - updateKey(s); + if (stickyLabels) drawLabels(); + updateEqFilterMarkers(); + applyParametricEqGraphTraceFocus(); + updateEqTraceOpacity(); + eqSoundRangeUiHooks.syncBrushFromInputs(); } +let colorBar = p=>'url(\'data:image/svg+xml,\')'; -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 => - notMultichannel(p) ? "M"+(15+keyExt)+" 0H"+l : - ["M15 -6H9C0 -6,0 0,-9 0H"+l,"M"+l+" 0H-9C0 0,0 6,9 6H15"] - .filter((_,i) => p.channels[i]) - .reduce((a,b) => a+b.slice(6)) - ); +/** Raw FR usable for EQ / Auto EQ (channels present). */ +function phoneCurveDataReadyForEq(p) { + return !!(p && p.rawChannels && Array.isArray(p.rawChannels) && p.rawChannels.some(c => c)); } -function addModel(t) { - let n = t.append("div").attr("class","phonename").text(p=>p.dispName); - t.filter(p=>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 (p) { - if (p.selectInProgress) return; - p.selectInProgress = true; - 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 => p => - table.selectAll("tr").filter(q=>q===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")); - d.filter(v=>!v.active).on("mousedown", v => Object.assign(p,v)); - c.on("mousedown", function (v) { - showVariant(q, v); - }); - }) - .on("blur", function endSelect(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=>p.dispName)); - changeVariant(p, updateVariant); - table.selectAll("tr").classed("highlight", false); // Prevents some glitches - }); - t.filter(p=>p.isTarget).append("span").text(" Target"); -} +/** 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 updateVariant(p) { - updateKey(table.selectAll("tr").filter(q=>q===p).select(".keyLine")); - normalizePhone(p); - updatePaths(); -} -function changeVariant(p, update, trigger) { - let fn = p.fileName, - ch = p.vars[fn]; - function set(ch) { - p.rawChannels = ch; p.smooth = undefined; - smoothPhone(p); - setCurves(p); - update(p, 0, 0, trigger); +// f_values moved to src/graph-renderer.js +(function initMeasurementCalibrationFromConfig() { + window._measurementCalibrationPromise = Promise.resolve(); + window._measurementCalibrationCurve = null; + if (typeof measurement_calibration_file === "undefined" || !measurement_calibration_file) { + return; + } + let raw = String(measurement_calibration_file).trim(); + if (!raw) { + return; } - if (ch) { - set(ch); + /* Same convention as measurement loads: stem without ".txt" → DIR+stem+".txt". + Use URL() so spaces (e.g. "IEF Cal.txt") become valid fetch URLs. */ + let url; + if (/^https?:\/\//i.test(raw)) { + try { + let u = new URL(raw); + if (!/\.txt$/i.test(u.pathname)) { + u.pathname += u.pathname.endsWith("/") ? "calibration.txt" : ".txt"; + } + url = u.href; + } catch (e) { + url = /\.txt$/i.test(raw) ? raw : raw + ".txt"; + } } else { - loadFiles(p, set); + let stem = raw.replace(/^\/+/, ""); + if (!/\.txt$/i.test(stem)) { + stem += ".txt"; + } + let baseDir = String(DIR || ""); + if (/^https?:\/\//i.test(baseDir) && !baseDir.endsWith("/")) { + baseDir += "/"; + } + try { + url = new URL(stem, baseDir).href; + } catch (e2) { + url = baseDir.replace(/\/?$/, "/") + stem.replace(/^\/+/, ""); + url = url.replace(/ /g, "%20"); + } } -} -function showVariant(p, c, trigger) { - if (cantCompare(activePhones)) return; - if (!p.objs) { p.objs = [p]; } - p.objs.push(c); - c.active=true; c.copyOf=p; - ["brand","dispBrand","fileNames","vars"].map(k=>c[k]=p[k]); - changeVariant(c, showPhone, trigger); -} - -function cpCircles(svg) { - svg.selectAll("circle") - .data(p => [[3,3,2],[6.6,4,1]].map(([cx,cy,r])=>({cx,cy,r,fill:getBgColor(p)}))) - .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 (p) { - p.id = getPhoneNumber(); - colorPhones(); - d3.event.stopPropagation(); + window._measurementCalibrationPromise = d3.text(url).then(function (txt) { + if (!txt) { + return; + } + try { + window._measurementCalibrationCurve = Equalizer.interp(f_values, tsvParse(txt)); + } catch (e) { + window._measurementCalibrationCurve = null; + } + }).catch(function () { + window._measurementCalibrationCurve = null; }); -} - -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(p=>!p.isTarget) - .style("color", c); - t.select("button").style("background-color",p=>getCurveColor(p.id,0)); - t= t.call(s => s.select(".remove").style("background-image",colorBar) - .select("svg").call(cpCircles)) - .select("td.channels"); // Key line - t.select("svg").remove(); - t.append("svg").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; })(); -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); +function shouldApplyMeasurementCalibration(p) { + if (!p || !window._measurementCalibrationCurve || !window._measurementCalibrationCurve.length) { + return false; + } + if (p.isTarget) { + return false; + } + if (p.brand && p.brand.name === "Uploaded") { + return false; + } + if (p.isDynamic) { + return false; + } + return true; +} +function applyMeasurementCalibrationToChannels(ch, p) { + if (!ch || !shouldApplyMeasurementCalibration(p)) { + return ch; + } + let cal = window._measurementCalibrationCurve; + return ch.map(function (c) { + if (!c) { + return c; + } + return c.map(function (pt, i) { + let calPt = cal[i]; + let dbCal = calPt && calPt.length >= 2 && Number.isFinite(calPt[1]) ? calPt[1] : 0; + return [pt[0], pt[1] - dbCal]; + }); + }); } +// 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); + if (!vc.length) return; if (norm_sel) { // fr let i = fr_to_ind(norm_fr); let avg = l => 20*Math.log10(d3.mean(l, d=>Math.pow(10,d/20))); - p.norm = 60 - avg(validChannels(p).map(l=>l[i][1])); + p.norm = 60 - avg(vc.map(l=>l[i][1])); } else { // phon - p.norm = find_offset(getAvg(p), norm_phon); - } - if (p.eq) { - p.eq.norm = p.norm; // copy parent's norm to child - } else if (p.eqParent) { - p.norm = p.eqParent.norm; // set child's norm from parent + let g = getAvg(p); + if (!g) return; + p.norm = find_offset(g, norm_phon); } + if (p.eq) normalizePhone(p.eq); } let norms = doc.select(".normalize").selectAll("div"); @@ -1542,215 +1152,82 @@ 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) { - loadFiles(p, function (ch) { - if (p.rawChannels) return; - 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 === undefined) { p.id = getPhoneNumber(); } - normalizePhone(p); p.offset=p.offset||0; - if (exclusive) { - activePhones = activePhones.filter(q => q.active = keep(q)); - if (baseline.p && !baseline.p.active) setBaseline(baseline0,1); +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 (activePhones.indexOf(p)===-1 && (suppressVariant || !p.objs)) { - 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 (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 */ } } - 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.id!==undefined) - .call(setPhoneTr); - //Displays variant pop-up when phone displayed - if (!suppressVariant && p.fileNames && !p.copyOf && window.innerWidth > 1000) { - table.selectAll("tr").filter(q=>q===p).select(".variants").node().focus(); - } else { - document.activeElement.blur(); - } - if (extraEnabled && extraEQEnabled) { - updateEQPhoneSelect(); - } - 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); -} + 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; -function removePhone(p) { - p.active = p.pin = false; nextPN = null; - activePhones = activePhones.filter(q => q.active); - if (!p.isTarget) { - let ap = activePhones.filter(p => !p.isTarget); - if (ap.length === 1) { - setCurves(ap[0], false); + 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) { + __graphShareUrlSyncAllowed = true; + } else if (!__graphShareUrlSyncAllowed) { + let armGraphShareUrlSync = () => { + __graphShareUrlSyncAllowed = true; + document.removeEventListener("pointerdown", armGraphShareUrlSync, true); + document.removeEventListener("keydown", armGraphShareUrlSync, true); + }; + document.addEventListener("pointerdown", armGraphShareUrlSync, true); + document.addEventListener("keydown", armGraphShareUrlSync, true); } } - 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) { - 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 (ifURL) { - let url = targetWindow.location.href, - par = "share="; - emb = "embed"; - baseURL = url.split("?").shift(); - - if (url.includes(par) && url.includes(emb)) { - initReq = decodeURIComponent(url.replace(/_/g," ").split(par).pop()).split(","); - loadFromShare = 2; - - setModeEmbed(); - } else if (url.includes(par)) { - initReq = decodeURIComponent(url.replace(/_/g," ").split(par).pop()).split(","); - loadFromShare = 1; - } else if (url.includes(emb)) { - 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 : _ => false; @@ -1772,147 +1249,34 @@ 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)); + initPhoneSelectorUi({ + brands: brands, + allPhones: allPhones, + targets: typeof targets !== "undefined" ? targets : null, + isInit: isInit, + inits: inits, + showPhone: showPhone + }); - 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") - .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); + if (initReq && Array.isArray(initReq) && initReq.length) { + inits.sort((a, b) => { + let ia = initReq.indexOf(String(a.fileName || "").trim()); + let ib = initReq.indexOf(String(b.fileName || "").trim()); + ia = ia < 0 ? 1e9 : ia; + ib = ib < 0 ? 1e9 : ib; + return ia - ib; }); } - 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; } }); @@ -1926,120 +1290,37 @@ d3.json(typeof PHONE_BOOK !== "undefined" ? PHONE_BOOK }); userConfigApplyNormalization(); -}); - -let pathHoverTimeout; -function pathHL(c, m, imm) { - gpath.selectAll("path").classed("highlight", c ? d=>d===c : false); - table.selectAll("tr") .classed("highlight", c ? p=>p===c.p : 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"); - 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 () { - let cs = d3.merge(activePhones.map(p=>p.hide?[]:p.activeCurves)); - if (!cs.length) return; - let m = d3.mouse(this); - 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("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})); + setTimeout(() => { + if (typeof window.applyPendingEqUrlShare === "function") { + window.applyPendingEqUrlShare(0); } - 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}); + }, 0); + + 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, }); - 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) => binteractInspect?stopInspect():pathHL(false)) - .on("click", graphInteract(true)); +}); doc.select("#inspector").on("click", function () { clearLabels(); @@ -2124,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"); @@ -2339,1003 +1620,46 @@ function blurFocus() { } 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(); +/* Over number inputs: cancel native wheel value stepping only; apply the same delta to the extra panel scroll. */ +(() => { + let extraPanel = document.querySelector("div.select > div.extra-panel"); + if (!extraPanel) { 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"; - } - if (!extraToneGeneratorEnabled) { - document.querySelector("div.extra-panel > div.extra-tone-generator").style["display"] = "none"; - } - // 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); } - }; - 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); - }; - 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); - 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) { + extraPanel.addEventListener("wheel", (e) => { + if (e.ctrlKey || e.metaKey) { return; } - let reader = new FileReader(); - reader.onload = (e) => { - let name = file.name.replace(/\.[^\.]+$/, ""); - let phone = { name: name }; - let ch = [tsvParse(e.target.result)]; - if (ch[0].length < 128) { - alert("Parse frequence response file failed: invalid format."); - return; - } - ch[0] = Equalizer.interp(f_values, ch[0]); - if (uploadType === "fr") { - name.match(/ R$/) && ch.splice(0, 0, null); - let phoneObj = addOrUpdatePhone(brandMap.Uploaded, phone, ch); - showPhone(phoneObj, false); - } else if (uploadType === "target") { - let fullName = name + (name.match(/ Target$/i) ? "" : " Target"); - let existsTargets = targets.reduce((a, b) => a.concat(b.files), []).map(f => f += " Target"); - if (existsTargets.indexOf(fullName) >= 0) { - alert("This target already exists on this tool, please select it instead of upload."); - return; - } - let phoneObj = { - isTarget: true, - brand: brandTarget, - dispName: name, - phone: name, - fullName: fullName, - fileName: fullName, - rawChannels: ch, - isDynamic: true, - id: -brandTarget.phoneObjs.length - }; - showPhone(phoneObj, true); - } - }; - reader.readAsText(file); - }); - // EQ Function - let eqPhoneSelect = document.querySelector("div.extra-eq select[name='phone']"); - 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; - let updateFilterElements = () => { - 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.addEventListener("input", applyEQ); - }); - }; - let elemToFilters = (includeAll) => { - // Collect filters from ui - let filters = []; - for (let i = 0; i < eqBands; ++i) { - 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 || !gain)) { - continue; - } - filters.push({ disabled, type, freq, q, gain }); - } - return filters; - }; - 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; - }); - }; - let applyEQHandle = null; - let applyEQExec = () => { - // Create and show phone with eq applied - let activeElem = document.activeElement; - let phoneSelected = eqPhoneSelect.value; - let filters = elemToFilters(); - if (filters.length && !phoneSelected) { - let firstPhone = eqPhoneSelect.querySelectorAll("option")[1]; - if (firstPhone) { - phoneSelected = eqPhoneSelect.value = firstPhone.value; - } - } - let phoneObj = phoneSelected && activePhones.filter( - p => p.fullName == phoneSelected)[0]; - if (!phoneObj || (!filters.length && !phoneObj.eq)) { - return; // Allow empty filters if eq is applied before - } - let phoneEQ = { name: phoneObj.phone + " EQ" }; - let phoneObjEQ = addOrUpdatePhone(phoneObj.brand, phoneEQ, - phoneObj.rawChannels.map(c => c ? Equalizer.apply(c, filters) : null)); - phoneObj.eq = phoneObjEQ; - phoneObjEQ.eqParent = phoneObj; - showPhone(phoneObjEQ, false); - activeElem.focus(); - }; - let applyEQ = () => { - clearTimeout(applyEQHandle); - applyEQHandle = setTimeout(applyEQExec, 100); - }; - window.updateEQPhoneSelect = () => { - let oldValue = eqPhoneSelect.value; - let optionValues = activePhones.filter(p => - !p.isTarget && !p.fullName.match(/ EQ$/)).map(p => p.fullName); - Array.from(eqPhoneSelect.children).slice(1).forEach(c => eqPhoneSelect.removeChild(c)); - optionValues.forEach(value => { - let optionElem = document.createElement("option"); - optionElem.setAttribute("value", value); - optionElem.innerText = value; - eqPhoneSelect.appendChild(optionElem); - }); - eqPhoneSelect.value = (optionValues.indexOf(oldValue) >= 0) ? oldValue : ""; - }; - updateFilterElements(); - eqPhoneSelect.addEventListener("input", applyEQ); - // Add new filter - document.querySelector("div.extra-eq button.add-filter").addEventListener("click", () => { - eqBands = Math.min(eqBands + 1, extraEQBandsMax); - updateFilterElements(); - }); - // Remove last filter - document.querySelector("div.extra-eq button.remove-filter").addEventListener("click", () => { - eqBands = Math.max(eqBands - 1, 1); - updateFilterElements(); - applyEQ(); // May removed effective filter - }); - // Sort filters by frequency - document.querySelector("div.extra-eq button.sort-filters").addEventListener("click", () => { - filtersToElem(elemToFilters(true).sort((a, b) => - (a.freq || Infinity) - (b.freq || Infinity))); - }); - // Import filters - document.querySelector("div.extra-eq button.import-filters").addEventListener("click", () => { - fileFiltersImport.click(); - }); - fileFiltersImport.addEventListener("change", (e) => { - // Import filters callback - let file = e.target.files[0]; - if (!file) { + let t = e.target; + if (!t || t.nodeType !== 1 || t.tagName !== "INPUT") { return; } - let reader = new FileReader(); - reader.onload = (e) => { - let settings = e.target.result; - let filters = settings.split("\n").map(l => { - let r = 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"; - } - 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; - } - } - if (filters.length > 0) { - filtersToElem(filters); - applyEQ(); - } else { - alert("Parse filters file failed: no filter found."); - } - }; - reader.readAsText(file); - }); - // Export filters - 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 = elemToFilters(true); - if (!phoneObj || !filters.length) { - alert("Please select model and add atleast one filter before export."); + if (t.getAttribute("type") !== "number") { 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) { - 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 " + - f.freq.toFixed(0) + " Hz Gain " + f.gain.toFixed(1) + " dB Q " + - f.q.toFixed(3) + "\r\n"); - } - }); - let exportElem = document.querySelector("#file-filters-export"); - exportElem.href && URL.revokeObjectURL(exportElem.href); - exportElem.href = URL.createObjectURL(new Blob([settings])); - exportElem.download = phoneObj.fullName.replace(/^Uploaded /, "") + " Filters.txt"; - exportElem.click(); - }); - // Export filters as graphic eq (for wavelet) - document.querySelector("div.extra-eq button.export-graphic-filters").addEventListener("click", () => { - let phoneSelected = eqPhoneSelect.value; - let phoneObj = phoneSelected && activePhones.filter( - p => p.fullName == phoneSelected && p.eq)[0] || { fullName: "Unnamed" }; - let filters = elemToFilters(); - if (!filters.length) { - alert("Please add atleast one filter before export."); + if (!extraPanel.contains(t)) { return; } - let graphicEQ = Equalizer.as_graphic_eq(filters); - let settings = "GraphicEQ: " + graphicEQ.map(([f, gain]) => - f.toFixed(0) + " " + gain.toFixed(1)).join("; "); - let exportElem = document.querySelector("#file-filters-export"); - exportElem.href && URL.revokeObjectURL(exportElem.href); - exportElem.href = URL.createObjectURL(new Blob([settings])); - exportElem.download = phoneObj.fullName.replace(/^Uploaded /, "") + " Graphic Filters.txt"; - exportElem.click(); - }); - // Readme - document.querySelector("div.extra-eq button.readme").addEventListener("click", () => { - alert("1. If you want to AutoEQ model A to B, display A B and remove target\n" + - "2. Add/Remove bands before AutoEQ may give you a better result\n" + - "3. Curve of PK filter close to 20K is implementation dependent, avoid such filter if you're not sure how your DSP software works\n" + - "4. EQ treble require resonant peak matching and fine tune by ear, keep treble untouched if you're not sure how to do that\n" + - "5. Tone generator is useful to find actual location of peaks and dips, notice the web version may not work on some platform\n"); - }); - // AutoEQ - let autoEQFromInput = document.querySelector("div.extra-eq input[name='autoeq-from']"); - let autoEQToInput = document.querySelector("div.extra-eq input[name='autoeq-to']"); - autoEQFromInput.value = Equalizer.config.AutoEQRange[0].toFixed(0); - autoEQToInput.value = Equalizer.config.AutoEQRange[1].toFixed(0); - document.querySelector("div.extra-eq button.autoeq").addEventListener("click", () => { - // Generate filters automatically - let phoneSelected = eqPhoneSelect.value; - if (!phoneSelected) { - let firstPhone = eqPhoneSelect.querySelectorAll("option")[1]; - if (firstPhone) { - phoneSelected = eqPhoneSelect.value = firstPhone.value; - } + let dy = e.deltaY; + if (e.deltaMode === 1) { + dy *= 16; + } else if (e.deltaMode === 2) { + dy *= extraPanel.clientHeight || 0; } - let phoneObj = phoneSelected && activePhones.filter( - p => p.fullName == phoneSelected)[0]; - let targetObj = (activePhones.filter(p => p.isTarget)[0] || - activePhones.filter(p => p !== phoneObj && !p.isTarget)[0]); - if (!phoneObj || !targetObj) { - alert("Please select model and target, if there are no target and multiple models are displayed then the second one will be selected as target."); + if (Math.abs(e.deltaX) > Math.abs(dy)) { return; } - let autoEQOverlay = document.querySelector(".extra-eq-overlay"); - autoEQOverlay.style.display = "block"; - setTimeout(() => { - let autoEQFrom = Math.min(Math.max(parseInt(autoEQFromInput.value) || 0, 20), 20000); - let autoEQTo = Math.min(Math.max(parseInt(autoEQToInput.value) || 0, autoEQFrom), 20000); - Equalizer.config.AutoEQRange = [autoEQFrom, autoEQTo]; - let phoneCHs = (phoneObj.rawChannels.filter(c => c) - .map(ch => ch.map(([f, v]) => [f, v + phoneObj.norm]))); - let phoneCH = (phoneCHs.length > 1) ? avgCurves(phoneCHs) : phoneCHs[0]; - let targetCH = targetObj.rawChannels.filter(c => c)[0].map(([f, v]) => [f, v + targetObj.norm]); - let filters = Equalizer.autoeq(phoneCH, targetCH, eqBands); - filtersToElem(filters); - applyEQ(); - autoEQOverlay.style.display = "none"; - }, 100); - }); - // Tone Generator - let toneGeneratorFromInput = document.querySelector("div.extra-tone-generator input[name='tone-generator-from']"); - let toneGeneratorToInput = document.querySelector("div.extra-tone-generator input[name='tone-generator-to']"); - let toneGeneratorSlider = document.querySelector("div.extra-tone-generator input[name='tone-generator-freq']"); - let toneGeneratorPlayButton = document.querySelector("div.extra-tone-generator .play"); - let toneGeneratorText = document.querySelector("div.extra-tone-generator .freq-text"); - let toneGeneratorContext = null; - let toneGeneratorOsc = null; - let toneGeneratorTimeoutHandle = null - toneGeneratorSlider.addEventListener("input", () => { - let from = Math.min(Math.max(parseInt(toneGeneratorFromInput.value) || 0, 20), 20000); - let to = Math.min(Math.max(parseInt(toneGeneratorToInput.value) || 0, from), 20000); - let position = parseFloat(toneGeneratorSlider.value) || 0; - let freq = Math.round(Math.exp( // Slider move in log scale - Math.log(from) + (Math.log(to) - Math.log(from)) * position)); - toneGeneratorText.innerText = freq; - if (toneGeneratorOsc) { - let t = toneGeneratorContext.currentTime; - toneGeneratorOsc.frequency.cancelScheduledValues(t); - toneGeneratorOsc.frequency.setTargetAtTime(freq, t, 0.2); // Smoother transition but also delay - } - }); - toneGeneratorPlayButton.addEventListener("click", () => { - if (toneGeneratorOsc) { - toneGeneratorOsc.stop(); - toneGeneratorOsc = null; - toneGeneratorPlayButton.innerText = "Play"; - } else { - if (!toneGeneratorContext) { - if (!window.AudioContext) { - alert("Web audio api is disabled, please enable it if you want to use tone generator."); - return; - } - toneGeneratorContext = new AudioContext(); - } - toneGeneratorOsc = toneGeneratorContext.createOscillator(); - toneGeneratorOsc.type = "sine"; - toneGeneratorOsc.frequency.value = parseInt(toneGeneratorText.innerText); - toneGeneratorOsc.connect(toneGeneratorContext.destination); - toneGeneratorOsc.start(); - toneGeneratorPlayButton.innerText = "Stop"; + if (!dy) { + return; } - }); - - // Wrap up preamp Calculation Function for plugin - let calcEqDevPreamp = (filters) => { - const phoneSelected = eqPhoneSelect.value; - const phoneObj = phoneSelected && - context.activePhones.find( - (p) => p.fullName === phoneSelected && p.eq - ); - - return context.Equalizer.calc_preamp( - phoneObj.rawChannels.filter(Boolean)[0], - phoneObj.eq.rawChannels.filter(Boolean)[0] - ); - } - - /** - * Dynamically load a plugin from a sub-folder passing it the useful context - * @param pluginsToLoad - * @param context - * @returns {Promise} - */ - async function loadPlugins(pluginsToLoad, context) { - for (const pluginPath of pluginsToLoad) { - try { - let initializePlugin; - - if (typeof module !== 'undefined' && module.exports) { - // CommonJS environment (e.g., Node.js) - initializePlugin = require(pluginPath); - } else { - // ES Module environment (e.g., modern browsers) - const module = await import(pluginPath); - initializePlugin = module.default; - } + e.preventDefault(); + extraPanel.scrollTop += dy; + }, { capture: true, passive: false }); +})(); - // Call the plugin function with the provided context - await initializePlugin(context); - console.log(`Successfully loaded plugin: ${pluginPath}`); - } catch (error) { - console.error(`Error loading plugin ${pluginPath}:`, error.message); - } - } - } - // Might come from the config.js - let config = {showNetwork:false}; // Hide the extra selection of network based devices for now - - // Load the plugin with the provided functions - if (typeof extraEQplugins !== "undefined") { - loadPlugins(extraEQplugins, { - filtersToElem, // Put Filters back to Html Elements - elemToFilters, // Get Filters from Html Elements - calcEqDevPreamp,// Reuse existing gain calculations - applyEQ, // Apply EQ - config - }); - } +// Add extra feature +function addExtra() { + if (typeof initEqPanelExtra === "function") initEqPanelExtra(); + if (typeof initLiveSoundExtra === "function") initLiveSoundExtra(); } addExtra(); - -// Add accessories to the bottom of the page, if configured -function addAccessories() { - let accessoriesBar = document.querySelector("div.accessories"), - accessoriesContainer = document.createElement("aside"); - - accessoriesContainer.innerHTML = whichAccessoriesToUse; - accessoriesBar.append(accessoriesContainer); -} -if (accessories) { addAccessories(); } - -// Add header to alt layout -function addHeader() { - let graphToolContainer = document.querySelector("div.graphtool"), - altHeaderElem = document.createElement("header"), - headerButton = document.createElement("button"), - headerLogoElem = document.createElement("div"), - headerLogoLink = document.createElement("a"), - headerLogoImg = document.createElement("img"), - headerLogoSpan = document.createElement("span"), - linksList = document.createElement("ul"); - - headerButton.className = "header-button"; - headerLogoElem.className = "logo"; - headerLogoLink.setAttribute('href', site_url); - if (headerLogoText) { - headerLogoSpan.innerText = headerLogoText; - headerLogoLink.append(headerLogoSpan); - } else if (headerLogoImgUrl) { - headerLogoImg.setAttribute("src", headerLogoImgUrl); - headerLogoLink.append(headerLogoImg); - } - - altHeaderElem.append(headerButton); - headerLogoElem.append(headerLogoLink); - altHeaderElem.setAttribute("data-links", ""); - altHeaderElem.append(headerLogoElem); - - altHeaderElem.className = "header"; - graphToolContainer.prepend(altHeaderElem); - - linksList.className = "header-links"; - altHeaderElem.append(linksList); - - headerLinks.forEach(function(link) { - let linkContainerElem = document.createElement("li"), - linkElem = document.createElement("a"); - - linkElem.setAttribute("href", link.url); - if ( alt_header_new_tab ) { linkElem.setAttribute("target", "_blank"); } - if ( link.external ) { linkElem.setAttribute("target", "_blank"); linkElem.classList.add('external'); } - linkElem.textContent = link.name; - linkContainerElem.append(linkElem); - linksList.append(linkContainerElem); - }) - - headerButton.addEventListener("click", function() { - let headerLinksState = altHeaderElem.getAttribute("data-links"); - - if (headerLinksState === "expanded") { - altHeaderElem.setAttribute("data-links", "collapsed"); - } else { - altHeaderElem.setAttribute("data-links", "expanded"); - } - }); -} -if (alt_layout && alt_header) { addHeader(); } - -// Add external links to bar at bottom of page, if configured -function addExternalLinks() { - const externalLinksBar = document.querySelector("div.external-links"); - - linkSets.forEach(function(set) { - let setLabelHtml = document.createElement("span"), - setLabelText = set.label, - links = set.links; - - setLabelHtml.textContent = setLabelText; - externalLinksBar.append(setLabelHtml); - - links.forEach(function(link) { - let linkHtml = document.createElement("a"), - linkName = link.name, - linkUrl = link.url; - - linkHtml.textContent = linkName; - linkHtml.setAttribute("href", linkUrl); - externalLinksBar.append(linkHtml); - }); - }); -} -if (externalLinksBar) { addExternalLinks(); } - -// Add tutorial to alt layout -function addTutorial() { - let partsPrimary = document.querySelector("section.parts-primary") - graphContainer = document.querySelector("div.graph-sizer"), - manageContainer = document.querySelector("div.manage"), - overlayContainer = document.createElement("div"), - buttonContainer = document.createElement("div"), - descriptionContainer = document.createElement("div"), - zoomButtons = document.querySelectorAll("div.zoom button"); - - overlayContainer.className = "tutorial-overlay"; - graphContainer.prepend(overlayContainer); - - buttonContainer.className = "tutorial-buttons"; - descriptionContainer.className = "tutorial-description"; - - manageContainer.prepend(descriptionContainer); - manageContainer.prepend(buttonContainer); - - tutorialDefinitions.forEach(function(def) { - let defOverlay = document.createElement("div"), - defButton = document.createElement("button"), - defDescription = document.createElement("article"), - defDescriptionCopy = document.createElement("p"); - - defOverlay.setAttribute("tutorial-def", def.name); - defOverlay.setAttribute("tutorial-on", "false"); - defOverlay.className = "overlay-segment"; - defOverlay.setAttribute("style", "flex-basis: "+ def.width +";") - overlayContainer.append(defOverlay); - - defButton.setAttribute("tutorial-def", def.name); - defButton.setAttribute("tutorial-on", "false"); - defButton.className = "button-segment"; - defButton.textContent = def.name; - buttonContainer.append(defButton); - - defDescription.setAttribute("tutorial-def", def.name); - defDescription.setAttribute("tutorial-on", "false"); - defDescription.className = "description-segment"; - defDescriptionCopy.innerHTML = def.description; - defDescription.append(defDescriptionCopy); - descriptionContainer.append(defDescription); - - defButton.addEventListener("click", function() { - let activeStatus = defButton.getAttribute("tutorial-on"), - activeTutorialElements = document.querySelectorAll("[tutorial-on='true']"), - activeOverlay = document.querySelector("div.overlay-segment[tutorial-on='true']"), - activeButton = document.querySelector("button.button-segment[tutorial-on='true']"), - activeDescription = document.querySelector("article.description-segment[tutorial-on='true']"); - - if (activeOverlay) { activeOverlay.setAttribute("tutorial-on", "false"); } - if (activeButton) { activeButton.setAttribute("tutorial-on", "false"); } - - if (activeStatus === "false") { - if (activeDescription) { activeDescription.setAttribute("tutorial-on", "false"); } - - defOverlay.setAttribute("tutorial-on", "true"); - defButton.setAttribute("tutorial-on", "true"); - defDescription.setAttribute("tutorial-on", "true"); - - partsPrimary.setAttribute("tutorial-active", "true"); - disableZoom(); - - // Analytics event - if (analyticsEnabled) { pushEventTag("tutorial_activated", targetWindow, def.name); } - } else { - partsPrimary.setAttribute("tutorial-active", "false"); - } - }); - - defButton.addEventListener("mouseover", function() { - defOverlay.setAttribute("tutorial-hover", "true"); - }); - - defButton.addEventListener("mouseout", function() { - defOverlay.setAttribute("tutorial-hover", "false"); - }); - - defButton.addEventListener("touchend", function() { - defOverlay.setAttribute("tutorial-hover", "false"); - }); - }); - - // Disable zoom if tutorial is engaged - function disableZoom() { - let activeZoomButton = document.querySelector("div.zoom button.selected"); - - if (activeZoomButton) { activeZoomButton.click(); } - } - - // Disable tutorial if zoom is engaged - zoomButtons.forEach(function(button) { - button.addEventListener("click", function() { - let tutorialState = document.querySelector("section.parts-primary").getAttribute("tutorial-active"); - - if (button.classList.contains("selected") && tutorialState === "true") { - let activeOverlay = document.querySelector("div.overlay-segment[tutorial-on='true']"), - activeButton = document.querySelector("button.button-segment[tutorial-on='true']"), - activeDescription = document.querySelector("article.description-segment[tutorial-on='true']"); - - document.querySelector("section.parts-primary").setAttribute("tutorial-active","false"); - activeOverlay.setAttribute("tutorial-on", "false"); - activeButton.setAttribute("tutorial-on", "false"); - } - }); - }); -} -if (alt_tutorial) { addTutorial(); } - -// Set active graph site link -function setActiveDatabase() { - let url = targetWindow.location.href, - dbLinks = document.querySelectorAll("div.external-links a"); - - dbLinks.forEach(function(link) { - let linkUrl = link.getAttribute("href"); - - if ( url.includes(linkUrl) ) { - link.setAttribute("class", "active"); - } - }); -} -setActiveDatabase(); - -// Expand / collapse function -function toggleExpandCollapse() { - const graphIsIframe = (window.top !== window.self) ? true:false, - graphBody = document.querySelector("body"), - parentBody = window.top.document.querySelector("body"), - expandCollapseButton = document.querySelector("button#expand-collapse"); - - - if ( graphIsIframe) { graphBody.setAttribute("data-graph-frame", "collapsed"); } - - - if ( graphIsIframe && expandableOnly ) { - const expandOnlyMax = ( expandableOnly === true ) ? 1000000:expandableOnly, - expandOnlyStyle = document.createElement("style"), - expandOnlyCss = ` - @media ( max-width: `+ expandOnlyMax +`px ) { - body[data-expandable="only"][data-graph-frame="collapsed"] { - overflow: hidden; - } - - body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse { - position: fixed; - top: 0; - left: 0; - - display: flex; - justify-content: center; - align-items: center; - - width: 100%; - height: 100%; - padding: 0; - - background-color: var(--background-color); - background-color: transparent; - border: none; - } - - body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse:after { - position: absolute; - - content: 'Tap to launch graph tool'; - - color: var(--font-color-primary); - font-family: var(--font-secondary); - font-size: 11px; - line-height: 1em; - text-transform: uppercase; - - pointer-events: none; - } - - body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse button#expand-collapse { - display: flex; - justify-content: center; - align-items: center; - - width: 100%; - height: 100%; - - background-color: transparent; - } - - body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse button#expand-collapse:before { - position: relative; - z-index: 1; - - transform: scale(7); - } - - body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse button#expand-collapse:after { - position: absolute; - top: 0; - left: 0; - - content: ''; - - display: block; - width: 100%; - height: 100%; - - background-color: var(--background-color); - - opacity: 0.9; - } - - body[data-expandable="only"][data-graph-frame="collapsed"] section.parts-primary { - flex: 100% 1 1; - overflow: hidden; - } - - body[data-expandable="only"][data-graph-frame="collapsed"] section.parts-secondary { - display: none; - } - } - `; - - expandOnlyStyle.textContent = expandOnlyCss; - expandOnlyStyle.setAttribute("type", "text/css"); - document.querySelector("body").append(expandOnlyStyle); - - graphBody.setAttribute("data-expandable", "only"); - } else if ( graphIsIframe && expandable ) { - graphBody.setAttribute("data-expandable", "true"); - } - - const parentStyle = window.top.document.createElement("style"), - parentCss = ` - :root { - --header-height: `+ headerHeight +`; - } - - body[data-graph-frame="expanded"] { - width: 100%; - height: 100%; - max-height: -webkit-fill-available; - overflow: hidden; - } - - body[data-graph-frame="expanded"] button.graph-frame-collapse { - display: inherit; - } - - body[data-graph-frame="expanded"] iframe#GraphTool { - position: fixed; - top: var(--header-height); - left: 0; - - width: 100% !important; - height: calc(100% - var(--header-height)) !important; - - animation-name: graph-tool-expand; - animation-duration: 0.15s; - animation-iteration-count: 1; - animation-timing-function: ease-out; - animation-fill-mode: forwards; - } - - @keyframes graph-tool-expand { - 0% { - position: relative; - opacity: 1.0; - transform: scale(1.0); - } - 48% { - position: relative; - opacity: 0.0; - transform: scale(0.9); - } - 50% { - position: fixed; - opacity: 0.0; - transform: scale(0.9); - } - 52% { - position: fixed; - opacity: 0.0; - transform: scale(0.9); - } - 100% { - position: fixed; - opacity: 1.0; - transform: scale(1.0); - } - }`; - - parentStyle.textContent = parentCss; - parentStyle.setAttribute("type", "text/css"); - parentBody.append(parentStyle); - - expandCollapseButton.addEventListener("click", function(e) { - let frameState = document.querySelector("body").getAttribute("data-graph-frame"); - - if ( frameState === "expanded" ) { - graphBody.setAttribute("data-graph-frame", "collapsed"); - parentBody.setAttribute("data-graph-frame", "collapsed"); - } else { - graphBody.setAttribute("data-graph-frame", "expanded"); - parentBody.setAttribute("data-graph-frame", "expanded"); - } - - e.stopPropagation(); - }); - -} - -if ( expandable && accessDocumentTop ) { toggleExpandCollapse(); } - -// Update user config for target + baseline -function setUserConfig() { - let urlObj = new URL(document.URL), - pathClean = urlObj.pathname.replace(/\W/g, ""), - configName = pathClean.length > 0 ? "_" + pathClean + "_a" : "_a", - configJson = { - "phones": [], - "normalMode": (norm_sel === 1) ? "Hz" : "dB", - "normalValue": (norm_sel === 1) ? norm_fr : norm_phon - }, - activeBaseline = baseline.p ? baseline.p.fileName : 0; - - activePhones.forEach(function(phone) { - let phoneJson = {}, - fullName = phone.fullName, - fileName = phone.fileName, - isTarget = phone.isTarget ? phone.isTarget : false, - isHidden = phone.hide ? phone.hide : false, - isBaseline = fileName === activeBaseline ? true : false, - isPinned = phone.pin ? phone.pin : false; - - if (isTarget || isBaseline) { - phoneJson.fullName = fullName; - phoneJson.fileName = fileName; - phoneJson.isTarget = isTarget; - phoneJson.isHidden = isHidden; - phoneJson.isBaseline = isBaseline; - phoneJson.isPinned = isPinned; - - configJson.phones.push(phoneJson); - } - }); - - localStorage.setItem("userConfig" + configName, JSON.stringify(configJson)); -} - -// Insert user config phones to inits -function userConfigAppendInits(initReq) { - if (targetRestoreLastUsed) { - let urlObj = new URL(document.URL), - pathClean = urlObj.pathname.replace(/\W/g, ""), - configName = pathClean.length > 0 ? "_" + pathClean + "_a" : "_a", - configJson = JSON.parse(localStorage.getItem("userConfig" + configName)), - configNumOfPhones = configJson ? configJson.phones.length : 0; - - if (configJson && configNumOfPhones) { - initReq.slice(0).forEach(function(item) { - if (item.endsWith(' Target')) { - initReq.splice(initReq.indexOf(item), 1); - } - }); - - configJson.phones.forEach(function(phone) { - if (!initReq.includes(phone.fileName)) { - initReq.push(phone.fileName); - } - }); - } - } -} - -// Apply baseline and hide settings -function userConfigApplyViewSettings(phoneInTable) { - if (targetRestoreLastUsed) { - userConfigApplicationActive = 1; - - let urlObj = new URL(document.URL), - pathClean = urlObj.pathname.replace(/\W/g, ""), - configName = pathClean.length > 0 ? "_" + pathClean + "_a" : "_a", - configJson = JSON.parse(localStorage.getItem("userConfig" + configName)); - - if (configJson) { - let phone = configJson.phones.find(item => item.fileName === phoneInTable); - - if (typeof phone !== "undefined") { - let row = document.querySelector("tr[data-filename='"+ phone.fileName +"']"), - hideButton = row.querySelector("td.hideIcon"), - baselineButton = row.querySelector("td.button-baseline"), - pinButton = row.querySelector("td.button-pin"); - - if (phone.isHidden && !hideButton.classList.contains("selected")) { - hideButton.click(); - } - - if (phone.isBaseline && !baselineButton.classList.contains("selected")) { - baselineButton.click(); - } - - if (phone.isPinned && pinButton.getAttribute('data-pinned') !== "true") { - pinButton.click(); - } - } - } - - userConfigApplicationActive = 0; - } -}; - -// Apply normalization config -function userConfigApplyNormalization() { - userConfigApplicationActive = 1; - - let urlObj = new URL(document.URL), - pathClean = urlObj.pathname.replace(/\W/g, ""), - configName = pathClean.length > 0 ? "_" + pathClean + "_a" : "_a", - configJson = JSON.parse(localStorage.getItem("userConfig" + configName)); - - if ( configJson && configJson.normalMode === "Hz" ) { - document.querySelector("input#norm-fr").value = configJson.normalValue; - document.querySelector("input#norm-fr").dispatchEvent(new Event("change")); - } else if ( configJson && configJson.normalMode === "dB" ) { - document.querySelector("input#norm-phon").value = configJson.normalValue; - document.querySelector("input#norm-phon").dispatchEvent(new Event("change")); - } - - userConfigApplicationActive = 0; -} diff --git a/index.html b/index.html index c636520..e825dcd 100644 --- a/index.html +++ b/index.html @@ -13,6 +13,7 @@ + @@ -29,9 +30,16 @@ - - - + + + + + + + + + + diff --git a/src/app-core.js b/src/app-core.js new file mode 100644 index 0000000..efaa65c --- /dev/null +++ b/src/app-core.js @@ -0,0 +1,1591 @@ +// ============================================================ +// === GraphToolPlugin registry === +// ============================================================ +/* Minimal hook bus — lets extracted plugins register for lifecycle events + * without the main graphtool.js closure needing feature-flag branches. */ +const GraphToolPlugin = (function () { + const hooks = {}; + return { + on: function (event, fn) { (hooks[event] = hooks[event] || []).push(fn); }, + _call: function (event) { + var args = Array.prototype.slice.call(arguments, 1); + (hooks[event] || []).forEach(function (fn) { fn.apply(null, args); }); + }, + }; +}()); +window.GraphToolPlugin = GraphToolPlugin; + +// Config defaults — runs early so all src/ modules see these before graphtool.js loads +if (typeof extraEnabled === "undefined") window.extraEnabled = false; +if (typeof extraEQEnabled === "undefined") window.extraEQEnabled = false; +if (typeof extraUploadEnabled === "undefined") window.extraUploadEnabled = false; +if (typeof extraMusicEnabled === "undefined") window.extraMusicEnabled = false; +if (typeof extraToneGeneratorEnabled=== "undefined") window.extraToneGeneratorEnabled = false; +if (typeof extraPinkNoiseEnabled === "undefined") window.extraPinkNoiseEnabled = false; +if (typeof extraEQBands === "undefined") window.extraEQBands = 10; +if (typeof extraEQBandsMax === "undefined") window.extraEQBandsMax = 10; +if (typeof exportableGraphs === "undefined") window.exportableGraphs = false; +if (typeof alt_augment === "undefined") window.alt_augment = false; +if (typeof alt_header === "undefined") window.alt_header = false; +if (typeof alt_header_new_tab === "undefined") window.alt_header_new_tab = false; +if (typeof alt_tutorial === "undefined") window.alt_tutorial = false; +if (typeof analyticsEnabled === "undefined") window.analyticsEnabled = false; +if (typeof applyStylesheet === "undefined") window.applyStylesheet = null; +if (typeof disallow_target === "undefined") window.disallow_target = false; +if (typeof restrict_target === "undefined") window.restrict_target = false; +if (typeof max_compare === "undefined") window.max_compare = Infinity; +if (typeof share_url === "undefined") window.share_url = false; +if (typeof stickyLabels === "undefined") window.stickyLabels = false; +if (typeof targetColorCustom === "undefined") window.targetColorCustom = null; +if (typeof targetDashed === "undefined") window.targetDashed = false; +if (typeof targetRestoreLastUsed === "undefined") window.targetRestoreLastUsed = false; +if (typeof themingEnabled === "undefined") window.themingEnabled = false; +if (typeof premium_html === "undefined") window.premium_html = ""; +if (typeof tiltableTargets === "undefined") window.tiltableTargets = null; +if (typeof customTiltName === "undefined") window.customTiltName = typeof default_DF_name !== "undefined" ? default_DF_name : null; +if (typeof dfBaseline === "undefined") window.dfBaseline = false; +if (typeof default_bass_shelf === "undefined") window.default_bass_shelf = 0; +if (typeof default_tilt === "undefined") window.default_tilt = 0; +if (typeof default_ear === "undefined") window.default_ear = 0; +if (typeof default_treble === "undefined") window.default_treble = 0; +if (typeof harmanFilters === "undefined") window.harmanFilters = null; +if (typeof preference_bounds_name === "undefined") window.preference_bounds_name = null; +if (typeof preference_bounds_dir === "undefined") window.preference_bounds_dir = "data/pref_bounds/"; +if (typeof preference_bounds_startup === "undefined") window.preference_bounds_startup = false; + +// No-op stubs for EQ-visual functions defined inside addExtra() in graphtool.js. +// These are called from paths that run even when extraEnabled=false (e.g. zoom, y-axis rescale, +// updatePaths). addExtra() replaces them with real implementations when extra is enabled. +if (typeof syncEqPinnedParentTrace === "undefined") window.syncEqPinnedParentTrace = () => {}; +if (typeof buildEqGraphMarkerLayout === "undefined") window.buildEqGraphMarkerLayout = () => null; +if (typeof applyEqFilterMarkerFillAndSize === "undefined") window.applyEqFilterMarkerFillAndSize = () => {}; +if (typeof applyEqGraphTraceStrokeEmphasis === "undefined") window.applyEqGraphTraceStrokeEmphasis = () => {}; +if (typeof applyParametricEqGraphTraceFocus === "undefined") window.applyParametricEqGraphTraceFocus = () => {}; +if (typeof computeEqNodePreviewAtMouse === "undefined") window.computeEqNodePreviewAtMouse = () => null; + +// ============================================================ +// === src/shell/render.js === +// ============================================================ +/* Static DOM shell for the graph tool. + * Keep this as a classic script so the graph can run without ES modules or a bundler. */ +function renderGraphToolShell(doc) { + 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
+
+ +
+ + +
+ +
+
+
+
+ + + +
+ +
+ + + + + + + + + +
+
+
+
+
+ + +
+
+
+ +
+ `); +} + +// ============================================================ +// === src/url/share.js === +// ============================================================ +/* URL serialization/parsing helpers for graph, EQ, and music share links. + * Classic script by design: graphtool.js and extra-panel code call these globals directly. */ +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; + } +} + +// ============================================================ +// === src/config/user-config-core.js === +// ============================================================ +/* Pure helpers for user-config persistence. + * Keep these free of browser APIs so they can be tested with `node --test`. */ +function userConfigStorageSuffixFromPath(pathname) { + let pathClean = String(pathname || "").replace(/\W/g, ""); + return pathClean.length > 0 ? "_" + pathClean + "_a" : "_a"; +} + +function userConfigStorageKeyFromPath(pathname) { + return "userConfig" + userConfigStorageSuffixFromPath(pathname); +} + +function userConfigDataFromPhones(opts) { + opts = opts || {}; + let pathname = opts.pathname || ""; + let normalMode = opts.normalMode || "dB"; + let normalValue = opts.normalValue; + let activePhones = Array.isArray(opts.activePhones) ? opts.activePhones : []; + let activeBaselineFileName = opts.activeBaselineFileName || 0; + let phones = []; + + activePhones.forEach(function(phone) { + if (!phone) { + return; + } + let fullName = phone.fullName; + let fileName = phone.fileName; + let isTarget = !!phone.isTarget; + let isHidden = !!phone.hide; + let isBaseline = fileName === activeBaselineFileName; + let isPinned = !!phone.pin; + + if (isTarget || isBaseline) { + phones.push({ + fullName: fullName, + fileName: fileName, + isTarget: isTarget, + isHidden: isHidden, + isBaseline: isBaseline, + isPinned: isPinned + }); + } + }); + + return { + key: userConfigStorageKeyFromPath(pathname), + data: { + phones: phones, + normalMode: normalMode, + normalValue: normalValue + } + }; +} + +function userConfigAppendInitReqFromData(initReq, configJson) { + let out = Array.isArray(initReq) ? initReq : []; + if (!configJson || !Array.isArray(configJson.phones) || !configJson.phones.length) { + return out; + } + + out.slice(0).forEach(function(item) { + if (item && typeof item.endsWith === "function" && item.endsWith(" Target")) { + out.splice(out.indexOf(item), 1); + } + }); + + configJson.phones.forEach(function(phone) { + if (phone && !out.includes(phone.fileName)) { + out.push(phone.fileName); + } + }); + + return out; +} + +if (typeof window !== "undefined") { + window.UserConfigCore = { + userConfigStorageSuffixFromPath: userConfigStorageSuffixFromPath, + userConfigStorageKeyFromPath: userConfigStorageKeyFromPath, + userConfigDataFromPhones: userConfigDataFromPhones, + userConfigAppendInitReqFromData: userConfigAppendInitReqFromData + }; +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { + userConfigStorageSuffixFromPath: userConfigStorageSuffixFromPath, + userConfigStorageKeyFromPath: userConfigStorageKeyFromPath, + userConfigDataFromPhones: userConfigDataFromPhones, + userConfigAppendInitReqFromData: userConfigAppendInitReqFromData + }; +} + +// ============================================================ +// === src/config/user-config.js === +// ============================================================ +/* Browser-side user-config actions. Depends on `UserConfigCore` for the pure logic. */ +function getUserConfigCore() { + return (typeof window !== "undefined" && window.UserConfigCore) ? window.UserConfigCore : null; +} + +function getUserConfigStorageKey() { + let core = getUserConfigCore(); + if (!core) { + return "userConfig_a"; + } + return core.userConfigStorageKeyFromPath(new URL(document.URL).pathname); +} + +// Update user config for target + baseline +function setUserConfig() { + let core = getUserConfigCore(); + if (!core) { + return; + } + let keyAndData = core.userConfigDataFromPhones({ + pathname: new URL(document.URL).pathname, + normalMode: (norm_sel === 1) ? "Hz" : "dB", + normalValue: (norm_sel === 1) ? norm_fr : norm_phon, + activePhones: activePhones, + activeBaselineFileName: baseline.p ? baseline.p.fileName : 0 + }); + + localStorage.setItem(keyAndData.key, JSON.stringify(keyAndData.data)); +} + +// Insert user config phones to inits +function userConfigAppendInits(initReq) { + if (typeof targetRestoreLastUsed === "undefined" || !targetRestoreLastUsed) { + return; + } + let core = getUserConfigCore(); + if (!core) { + return; + } + let configJson = JSON.parse(localStorage.getItem(getUserConfigStorageKey())); + core.userConfigAppendInitReqFromData(initReq, configJson); +} + +// Apply baseline and hide settings +function userConfigApplyViewSettings(phoneInTable) { + if (typeof targetRestoreLastUsed === "undefined" || !targetRestoreLastUsed) { + return; + } + userConfigApplicationActive = 1; + + let configJson = JSON.parse(localStorage.getItem(getUserConfigStorageKey())); + if (configJson) { + let phone = configJson.phones.find(item => item.fileName === phoneInTable); + if (typeof phone !== "undefined") { + let row = document.querySelector("tr[data-filename='"+ phone.fileName +"'][data-manage-main='1']"), + hideButton = row && row.querySelector("td.hideIcon"), + baselineButton = row && row.querySelector("td.button-baseline"), + pinButton = row && row.querySelector("td.button-pin"); + + if (phone.isHidden && hideButton + && !hideButton.classList.contains("selected")) { + hideButton.click(); + } + + if (phone.isBaseline && baselineButton + && !baselineButton.classList.contains("selected")) { + baselineButton.click(); + } + + if (phone.isPinned && pinButton + && pinButton.getAttribute("data-pinned") !== "true") { + pinButton.click(); + } + } + } + + userConfigApplicationActive = 0; +} + +// Apply normalization config +function userConfigApplyNormalization() { + userConfigApplicationActive = 1; + + let configJson = JSON.parse(localStorage.getItem(getUserConfigStorageKey())); + if ( configJson && configJson.normalMode === "Hz" ) { + document.querySelector("input#norm-fr").value = configJson.normalValue; + document.querySelector("input#norm-fr").dispatchEvent(new Event("change")); + } else if ( configJson && configJson.normalMode === "dB" ) { + document.querySelector("input#norm-phon").value = configJson.normalValue; + document.querySelector("input#norm-phon").dispatchEvent(new Event("change")); + } + + userConfigApplicationActive = 0; +} + +// ============================================================ +// === src/chrome/layout.js === +// ============================================================ +/* Page chrome and layout helpers: accessories, header, external links, tutorial, and iframe expansion. */ +function addAccessories() { + let accessoriesBar = document.querySelector("div.accessories"), + accessoriesContainer = document.createElement("aside"); + + if (!accessoriesBar) { + return; + } + + accessoriesContainer.innerHTML = whichAccessoriesToUse; + accessoriesBar.append(accessoriesContainer); +} + +function addHeader() { + let graphToolContainer = document.querySelector("div.graphtool"), + altHeaderElem = document.createElement("header"), + headerButton = document.createElement("button"), + headerLogoElem = document.createElement("div"), + headerLogoLink = document.createElement("a"), + headerLogoImg = document.createElement("img"), + headerLogoSpan = document.createElement("span"), + linksList = document.createElement("ul"); + + if (!graphToolContainer) { + return; + } + + headerButton.className = "header-button"; + headerLogoElem.className = "logo"; + headerLogoLink.setAttribute("href", site_url); + if (headerLogoText) { + headerLogoSpan.innerText = headerLogoText; + headerLogoLink.append(headerLogoSpan); + } else if (headerLogoImgUrl) { + headerLogoImg.setAttribute("src", headerLogoImgUrl); + headerLogoLink.append(headerLogoImg); + } + + altHeaderElem.append(headerButton); + headerLogoElem.append(headerLogoLink); + altHeaderElem.setAttribute("data-links", ""); + altHeaderElem.append(headerLogoElem); + altHeaderElem.className = "header"; + graphToolContainer.prepend(altHeaderElem); + + linksList.className = "header-links"; + altHeaderElem.append(linksList); + + headerLinks.forEach(function(link) { + let linkContainerElem = document.createElement("li"), + linkElem = document.createElement("a"); + + linkElem.setAttribute("href", link.url); + if (alt_header_new_tab) { linkElem.setAttribute("target", "_blank"); } + if (link.external) { linkElem.setAttribute("target", "_blank"); linkElem.classList.add("external"); } + linkElem.textContent = link.name; + linkContainerElem.append(linkElem); + linksList.append(linkContainerElem); + }); + + headerButton.addEventListener("click", function() { + let headerLinksState = altHeaderElem.getAttribute("data-links"); + if (headerLinksState === "expanded") { + altHeaderElem.setAttribute("data-links", "collapsed"); + } else { + altHeaderElem.setAttribute("data-links", "expanded"); + } + }); +} + +function addExternalLinks() { + const externalLinksBar = document.querySelector("div.external-links"); + + if (!externalLinksBar) { + return; + } + + linkSets.forEach(function(set) { + let setLabelHtml = document.createElement("span"), + links = set.links; + + setLabelHtml.textContent = set.label; + externalLinksBar.append(setLabelHtml); + + links.forEach(function(link) { + let linkHtml = document.createElement("a"); + linkHtml.textContent = link.name; + linkHtml.setAttribute("href", link.url); + externalLinksBar.append(linkHtml); + }); + }); +} + +function addTutorial() { + let partsPrimary = document.querySelector("section.parts-primary"), + graphContainer = document.querySelector("div.graph-sizer"), + manageContainer = document.querySelector("div.manage"), + overlayContainer = document.createElement("div"), + buttonContainer = document.createElement("div"), + descriptionContainer = document.createElement("div"), + zoomButtons = document.querySelectorAll("div.zoom button"); + + if (!partsPrimary || !graphContainer || !manageContainer) { + return; + } + + overlayContainer.className = "tutorial-overlay"; + graphContainer.prepend(overlayContainer); + + buttonContainer.className = "tutorial-buttons"; + descriptionContainer.className = "tutorial-description"; + + manageContainer.prepend(descriptionContainer); + manageContainer.prepend(buttonContainer); + + tutorialDefinitions.forEach(function(def) { + let defOverlay = document.createElement("div"), + defButton = document.createElement("button"), + defDescription = document.createElement("article"), + defDescriptionCopy = document.createElement("p"); + + defOverlay.setAttribute("tutorial-def", def.name); + defOverlay.setAttribute("tutorial-on", "false"); + defOverlay.className = "overlay-segment"; + defOverlay.setAttribute("style", "flex-basis: " + def.width + ";"); + overlayContainer.append(defOverlay); + + defButton.setAttribute("tutorial-def", def.name); + defButton.setAttribute("tutorial-on", "false"); + defButton.className = "button-segment"; + defButton.textContent = def.name; + buttonContainer.append(defButton); + + defDescription.setAttribute("tutorial-def", def.name); + defDescription.setAttribute("tutorial-on", "false"); + defDescription.className = "description-segment"; + defDescriptionCopy.innerHTML = def.description; + defDescription.append(defDescriptionCopy); + descriptionContainer.append(defDescription); + + defButton.addEventListener("click", function() { + let activeStatus = defButton.getAttribute("tutorial-on"), + activeOverlay = document.querySelector("div.overlay-segment[tutorial-on='true']"), + activeButton = document.querySelector("button.button-segment[tutorial-on='true']"), + activeDescription = document.querySelector("article.description-segment[tutorial-on='true']"); + + if (activeOverlay) { activeOverlay.setAttribute("tutorial-on", "false"); } + if (activeButton) { activeButton.setAttribute("tutorial-on", "false"); } + + if (activeStatus === "false") { + if (activeDescription) { activeDescription.setAttribute("tutorial-on", "false"); } + + defOverlay.setAttribute("tutorial-on", "true"); + defButton.setAttribute("tutorial-on", "true"); + defDescription.setAttribute("tutorial-on", "true"); + + partsPrimary.setAttribute("tutorial-active", "true"); + disableZoom(); + + if (analyticsEnabled) { pushEventTag("tutorial_activated", targetWindow, def.name); } + } else { + partsPrimary.setAttribute("tutorial-active", "false"); + } + }); + + defButton.addEventListener("mouseover", function() { + defOverlay.setAttribute("tutorial-hover", "true"); + }); + + defButton.addEventListener("mouseout", function() { + defOverlay.setAttribute("tutorial-hover", "false"); + }); + + defButton.addEventListener("touchend", function() { + defOverlay.setAttribute("tutorial-hover", "false"); + }); + }); + + function disableZoom() { + let activeZoomButton = document.querySelector("div.zoom button.selected"); + if (activeZoomButton) { activeZoomButton.click(); } + } + + zoomButtons.forEach(function(button) { + button.addEventListener("click", function() { + let tutorialState = document.querySelector("section.parts-primary").getAttribute("tutorial-active"); + if (button.classList.contains("selected") && tutorialState === "true") { + let activeOverlay = document.querySelector("div.overlay-segment[tutorial-on='true']"), + activeButton = document.querySelector("button.button-segment[tutorial-on='true']"), + activeDescription = document.querySelector("article.description-segment[tutorial-on='true']"); + + document.querySelector("section.parts-primary").setAttribute("tutorial-active", "false"); + if (activeOverlay) { activeOverlay.setAttribute("tutorial-on", "false"); } + if (activeButton) { activeButton.setAttribute("tutorial-on", "false"); } + if (activeDescription) { activeDescription.setAttribute("tutorial-on", "false"); } + } + }); + }); +} + +function setActiveDatabase() { + let url = (typeof targetWindow !== "undefined" && targetWindow && targetWindow.location) + ? targetWindow.location.href + : window.location.href, + dbLinks = document.querySelectorAll("div.external-links a"); + + if (!dbLinks || !dbLinks.length) { + return; + } + + dbLinks.forEach(function(link) { + let linkUrl = link.getAttribute("href"); + if (url.includes(linkUrl)) { + link.setAttribute("class", "active"); + } + }); +} + +function toggleExpandCollapse() { + const graphIsIframe = (window.top !== window.self) ? true : false, + graphBody = document.querySelector("body"), + parentBody = window.top.document.querySelector("body"), + expandCollapseButton = document.querySelector("button#expand-collapse"); + + if (!graphBody || !parentBody || !expandCollapseButton) { + return; + } + + if (graphIsIframe) { graphBody.setAttribute("data-graph-frame", "collapsed"); } + + if (graphIsIframe && expandableOnly) { + const expandOnlyMax = (expandableOnly === true) ? 1000000 : expandableOnly, + expandOnlyStyle = document.createElement("style"), + expandOnlyCss = ` + @media ( max-width: ` + expandOnlyMax + `px ) { + body[data-expandable="only"][data-graph-frame="collapsed"] { + overflow: hidden; + } + + body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse { + position: fixed; + top: 0; + left: 0; + + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 100%; + padding: 0; + + background-color: var(--background-color); + background-color: transparent; + border: none; + } + + body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse:after { + position: absolute; + + content: 'Tap to launch graph tool'; + + color: var(--font-color-primary); + font-family: var(--font-secondary); + font-size: 11px; + line-height: 1em; + text-transform: uppercase; + + pointer-events: none; + } + + body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse button#expand-collapse { + display: flex; + justify-content: center; + align-items: center; + + width: 100%; + height: 100%; + + background-color: transparent; + } + + body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse button#expand-collapse:before { + position: relative; + z-index: 1; + + transform: scale(7); + } + + body[data-expandable="only"][data-graph-frame="collapsed"] div.expand-collapse button#expand-collapse:after { + position: absolute; + top: 0; + left: 0; + + content: ''; + + display: block; + width: 100%; + height: 100%; + + background-color: var(--background-color); + + opacity: 0.9; + } + + body[data-expandable="only"][data-graph-frame="collapsed"] section.parts-primary { + flex: 100% 1 1; + overflow: hidden; + } + + body[data-expandable="only"][data-graph-frame="collapsed"] section.parts-secondary { + display: none; + } + } + `; + + expandOnlyStyle.textContent = expandOnlyCss; + expandOnlyStyle.setAttribute("type", "text/css"); + document.querySelector("body").append(expandOnlyStyle); + graphBody.setAttribute("data-expandable", "only"); + } else if (graphIsIframe && expandable) { + graphBody.setAttribute("data-expandable", "true"); + } + + const parentStyle = window.top.document.createElement("style"), + parentCss = ` + :root { + --header-height: ` + headerHeight + `; + } + + body[data-graph-frame="expanded"] { + width: 100%; + height: 100%; + max-height: -webkit-fill-available; + overflow: hidden; + } + + body[data-graph-frame="expanded"] button.graph-frame-collapse { + display: inherit; + } + + body[data-graph-frame="expanded"] iframe#GraphTool { + position: fixed; + top: var(--header-height); + left: 0; + + width: 100% !important; + height: calc(100% - var(--header-height)) !important; + + animation-name: graph-tool-expand; + animation-duration: 0.15s; + animation-iteration-count: 1; + animation-timing-function: ease-out; + animation-fill-mode: forwards; + } + + @keyframes graph-tool-expand { + 0% { + position: relative; + opacity: 1.0; + transform: scale(1.0); + } + 48% { + position: relative; + opacity: 0.0; + transform: scale(0.9); + } + 50% { + position: fixed; + opacity: 0.0; + transform: scale(0.9); + } + 52% { + position: fixed; + opacity: 0.0; + transform: scale(0.9); + } + 100% { + position: fixed; + opacity: 1.0; + transform: scale(1.0); + } + }`; + + parentStyle.textContent = parentCss; + parentStyle.setAttribute("type", "text/css"); + parentBody.append(parentStyle); + + expandCollapseButton.addEventListener("click", function(e) { + let frameState = document.querySelector("body").getAttribute("data-graph-frame"); + + if (frameState === "expanded") { + graphBody.setAttribute("data-graph-frame", "collapsed"); + parentBody.setAttribute("data-graph-frame", "collapsed"); + } else { + graphBody.setAttribute("data-graph-frame", "expanded"); + parentBody.setAttribute("data-graph-frame", "expanded"); + } + + e.stopPropagation(); + }); +} + +if (typeof accessories !== "undefined" && accessories) { addAccessories(); } +if (typeof alt_header !== "undefined" && alt_header) { addHeader(); } +if (typeof externalLinksBar !== "undefined" && externalLinksBar) { addExternalLinks(); } +if (typeof alt_tutorial !== "undefined" && alt_tutorial) { addTutorial(); } +setActiveDatabase(); + +// ============================================================ +// === src/extra/panel.js === +// ============================================================ +/* Extra panel and live-sound mode controls. + * Kept as a classic script so graphtool.js can stay non-module. */ +let activeLiveSoundPlayer = "pink"; +Object.defineProperty(window, 'activeLiveSoundPlayer', { get: () => activeLiveSoundPlayer, set: v => { activeLiveSoundPlayer = v; }, configurable: true }); +let extraPanelCtx = null; + +function liveSoundPlayersCycleOrder() { + let hasMusic = !!(typeof musicFileLoaded !== "undefined" && musicFileLoaded + && typeof musicPlayButton !== "undefined" && musicPlayButton + && typeof musicAudio !== "undefined" && musicAudio); + return hasMusic ? ["music", "pink", "tone"] : ["pink", "tone"]; +} + +function ensureActiveLiveSoundPlayerValid() { + let order = liveSoundPlayersCycleOrder(); + if (order.indexOf(activeLiveSoundPlayer) < 0) { + activeLiveSoundPlayer = order[0]; + } +} + +function cycleActiveLiveSoundPlayerShiftSpace() { + ensureActiveLiveSoundPlayerValid(); + let order = liveSoundPlayersCycleOrder(); + let i = Math.max(0, order.indexOf(activeLiveSoundPlayer)); + activeLiveSoundPlayer = order[(i + 1) % order.length]; +} + +function showExtraPanel() { + let ctx = extraPanelCtx || {}; + 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(); + } + if (typeof ctx.applyParametricEqGraphTraceFocus === "function") { ctx.applyParametricEqGraphTraceFocus(); } + if (typeof ctx.updateEqTraceOpacity === "function") { ctx.updateEqTraceOpacity(); } + if (typeof ctx.updateEqFilterMarkers === "function") { ctx.updateEqFilterMarkers(); } + if (ctx.eqSoundRangeUiHooks && typeof ctx.eqSoundRangeUiHooks.syncBrushFromInputs === "function") { + ctx.eqSoundRangeUiHooks.syncBrushFromInputs(); + } + if (typeof ctx.updatePhoneTable === "function") { ctx.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(); + } +} + +function hideExtraPanel(selectedList) { + let ctx = extraPanelCtx || {}; + 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); + if (typeof ctx.setEqFilterSelectedRow === "function") { ctx.setEqFilterSelectedRow(null); } + if (typeof ctx.syncEqHoverPreview === "function") { ctx.syncEqHoverPreview(null); } + if (typeof ctx.applyParametricEqGraphTraceFocus === "function") { ctx.applyParametricEqGraphTraceFocus(); } + if (typeof ctx.updateEqTraceOpacity === "function") { ctx.updateEqTraceOpacity(); } + /* Match showExtraPanel: table was filtered to EQ focus context; restore full rows when leaving. */ + if (typeof ctx.updatePhoneTable === "function") { ctx.updatePhoneTable(); } +} + +function initExtraPanel(ctx) { + extraPanelCtx = ctx || {}; + if (!extraPanelCtx.extraButton) { + return; + } + if (extraPanelCtx.extraButton.__extraPanelBound) { + return; + } + extraPanelCtx.extraButton.__extraPanelBound = true; + extraPanelCtx.extraButton.addEventListener("click", showExtraPanel); +} + +// ============================================================ +// === src/extra/index.js === +// ============================================================ +/* Extra panel feature orchestration. Keep this as a classic script for isolated, ordered loading. */ +function initExtraModules(app) { + app = app || {}; + if (typeof initExtraPanel === "function") { + initExtraPanel(app.extraButton); + } + if (typeof initExtraUpload === "function") { + initExtraUpload(app); + } +} diff --git a/src/apple-music-plugin.js b/src/apple-music-plugin.js new file mode 100644 index 0000000..30a9e23 --- /dev/null +++ b/src/apple-music-plugin.js @@ -0,0 +1,459 @@ +/* Apple Music preview search — self-contained plugin registered via GraphToolPlugin. + * No feature flags required in the core graphtool.js entry point. + * Reads music playback state through GraphToolPlugin.isMusicFileLoaded() / + * incrementMusicRestoreCancelToken() so it never touches graphtool.js's closure directly. */ +(function () { + if (typeof extraMusicAllowsAppleFeatures !== "undefined" && !extraMusicAllowsAppleFeatures) return; + if (typeof GraphToolPlugin === "undefined") return; + + // Plugin-owned mutable state (moved out of graphtool.js closure) + let musicAppleSearchModeOpen = false; + let appleMusicSearchDebounceTimer = null; + + // ── iTunes network utilities (moved from graphtool.js) ────────────────────── + let itunesStorefrontForSearch = () => { + let s = typeof appleMusicStorefront !== "undefined" ? String(appleMusicStorefront || "").trim() : ""; + return (s || "us").toLowerCase(); + }; + /** Add nonce + no-store to avoid cross-origin cache poisoning between 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; + }; + let itunesLookupPreviewByTrackId = (songId) => { + let id = String(songId || "").trim(); + if (!id) return Promise.reject(new Error("empty song id")); + 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); + 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 || "" }; + }); + }; + 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; + let songId = r.trackId != null ? String(r.trackId) : ""; + out.push({ id: songId, 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 = itunesStorefrontForSearch(); + let searchUrl = "https://itunes.apple.com/search?term=" + encodeURIComponent(q) + + "&entity=song&limit=12&country=" + encodeURIComponent(country); + return fetch(itunesUrlNoCache(searchUrl), { credentials: "omit", cache: "no-store" }).then((r) => { + if (!r.ok) throw new Error("iTunes search HTTP " + r.status); + return r.json(); + }).then(parseItunesSearchSongsPayload); + }; + + // ── UI initialisation ──────────────────────────────────────────────────────── + let _initialized = false; + + function initAppleMusicPlugin() { + // Plugin queries its own DOM — no variables borrowed from graphtool.js + 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 musicSearchAppleButton= document.querySelector("div.extra-music button.music-search-apple"); + let musicFileActionsRow = document.querySelector("div.extra-music .music-file-actions-row"); + let musicPlayButton = document.querySelector("div.extra-music .play"); + let musicPlaybackPanel = document.querySelector("div.extra-music .music-playback-panel"); + window.musicPlaybackPanel = musicPlaybackPanel; + + if (!appleMusicInlineWrap || !musicSearchAppleButton || !appleMusicSearchInput + || !appleMusicResultsUl || !musicFileActionsRow) { + return; + } + if (_initialized) return; + _initialized = true; + + 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 + }; + }; + let appleMusicSearchIgnoreFocusOutUntil = 0; + 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 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) { + alert("Web audio API is disabled; music playback is unavailable."); + return; + } + let norm = { + id: String(row.id || "").trim(), + title: row.title || "", + artist: row.artist || "", + previewUrl: String(row.previewUrl || "").trim() + }; + let startPlay = (url) => { + GraphToolPlugin.incrementMusicRestoreCancelToken(); + 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; + } + 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) return; + if (e.isComposing) return; + 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(); + appleMusicSearchHighlightIx = appleMusicSearchHighlightIx < 0 ? 0 : (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; + e.preventDefault(); + e.stopImmediatePropagation(); + if (listOpen && appleMusicSearchHighlightIx >= 0 && appleMusicSearchLastRows[appleMusicSearchHighlightIx]) { + appleMusicSearchIgnoreFocusOutUntil = performance.now() + 600; + appleMusicActivatePreviewRow(appleMusicSearchLastRows[appleMusicSearchHighlightIx]); + return; + } + appleMusicSearchIgnoreFocusOutUntil = performance.now() + 600; + appleMusicShowRecentIfInputEmpty(); + }; + let appleMusicRenderResults = (rows) => { + appleMusicResultsUl.innerHTML = ""; + let safeRows = (rows || []).filter((r) => !!r && (r.title || r.artist || r.previewUrl)); + appleMusicSearchLastRows = safeRows.map((r) => ({ + id: String(r.id || "").trim(), + title: String(r.title || "").trim(), + artist: String(r.artist || "").trim(), + previewUrl: String(r.previewUrl || "").trim() + })); + if (!appleMusicSearchLastRows.length) { + appleMusicSearchHighlightIx = -1; + appleMusicResultsUl.hidden = true; + return; + } + appleMusicSearchLastRows.forEach((row, ix) => { + let li = document.createElement("li"); + let bt = document.createElement("button"); + bt.type = "button"; + bt.setAttribute("role", "option"); + bt.className = "apple-music-preview-row"; + bt.textContent = row.title || row.artist || row.id || row.previewUrl || "Preview"; + bt.addEventListener("pointermove", () => appleMusicPointerHighlightRow(ix)); + bt.addEventListener("click", () => appleMusicActivatePreviewRow(row, !row.previewUrl)); + li.appendChild(bt); + appleMusicResultsUl.appendChild(li); + }); + appleMusicResultsUl.hidden = false; + appleMusicSearchHighlightIx = 0; + appleMusicApplySearchHighlight(); + }; + let appleMusicShowRecentIfInputEmpty = () => { + if (!musicAppleSearchModeOpen || !appleMusicSearchInput || !appleMusicResultsUl) return; + let recent = readAppleMusicRecentStored().map(appleMusicNormalizeStoredRow).filter(Boolean); + if (!recent.length) { + appleMusicResultsUl.innerHTML = ""; + appleMusicResultsUl.hidden = true; + return; + } + appleMusicResultsUl.innerHTML = ""; + appleMusicSearchLastRows = recent; + appleMusicSearchHighlightIx = -1; + let headLi = document.createElement("li"); + let head = document.createElement("div"); + head.className = "apple-music-preview-recent-heading"; + head.textContent = "Recent"; + headLi.appendChild(head); + appleMusicResultsUl.appendChild(headLi); + recent.forEach((r, ix) => { + let li = document.createElement("li"); + let bt = document.createElement("button"); + bt.type = "button"; + bt.setAttribute("role", "option"); + bt.className = "apple-music-preview-row"; + bt.textContent = r.title || r.artist || r.id || r.previewUrl || "Preview"; + bt.addEventListener("pointermove", () => appleMusicPointerHighlightRow(ix)); + bt.addEventListener("click", () => appleMusicActivatePreviewRow(r, !r.previewUrl)); + li.appendChild(bt); + appleMusicResultsUl.appendChild(li); + }); + appleMusicResultsUl.hidden = false; + }; + 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 (musicFileActionsRow) musicFileActionsRow.hidden = false; + if (!GraphToolPlugin.isMusicFileLoaded() && collapseEmptyPlaybackPanel && musicPlaybackPanel) { + musicPlaybackPanel.setAttribute("aria-hidden", "true"); + } + }; + let openAppleMusicSearchMode = () => { + if (GraphToolPlugin.isMusicFileLoaded() || 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"); + 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 appleMusicPreviewForm = appleMusicInlineWrap.querySelector("form.apple-music-preview-form"); + if (appleMusicPreviewForm) { + appleMusicPreviewForm.addEventListener("submit", (e) => { + e.preventDefault(); + e.stopImmediatePropagation(); + }); + } + appleMusicSearchInput.addEventListener("keydown", appleMusicSearchFieldKeydown, true); + appleMusicSearchInput.addEventListener("focus", () => { + if ((appleMusicSearchInput.value || "").trim() === "") { + appleMusicShowRecentIfInputEmpty(); + } + }); + 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 === 0) { appleMusicShowRecentIfInputEmpty(); return; } + if (v.length < 2) { + appleMusicSearchLastRows = []; + appleMusicSearchHighlightIx = -1; + appleMusicResultsUl.hidden = true; + appleMusicResultsUl.innerHTML = ""; + return; + } + appleMusicSearchDebounceTimer = setTimeout(() => { + appleMusicSearchDebounceTimer = null; + itunesSearchSongs(v).then((rows) => { + appleMusicRenderResults(rows); + }).catch((err) => { + console.warn(err); + appleMusicSearchLastRows = []; + appleMusicSearchHighlightIx = -1; + 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"; + msg.textContent = "iTunes search failed (network, rate limits, or browser restrictions)."; + 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(); + setTimeout(() => { + if (musicAppleSearchModeOpen && appleMusicSearchInput + && (appleMusicSearchInput.value || "").trim() === "") { + appleMusicShowRecentIfInputEmpty(); + } + }, 0); + }); + } + + // ── URL restore handler ────────────────────────────────────────────────────── + function handleAppleMusicUrlRestore(songId, segment, fallback) { + itunesLookupPreviewByTrackId(songId).then((meta) => { + if (GraphToolPlugin.isMusicFileLoaded()) { fallback && fallback(); return; } + GraphToolPlugin.incrementMusicRestoreCancelToken(); + if (!initMusicAudioGraph()) { fallback && fallback(); return; } + wireMusicLoadedFromSource(meta.previewUrl, segment || null, { + autoPlay: true, + appleCatalogSongId: songId + }); + }).catch((err) => { + console.warn("Shared Apple Music track could not be loaded", err); + fallback && fallback(); + }); + } + + // ── Self-register with core ────────────────────────────────────────────────── + GraphToolPlugin.on('musicPanelReady', initAppleMusicPlugin); + GraphToolPlugin.on('appleMusicUrlRestore', handleAppleMusicUrlRestore); +}()); diff --git a/src/audio-engine.js b/src/audio-engine.js new file mode 100644 index 0000000..2e626fd --- /dev/null +++ b/src/audio-engine.js @@ -0,0 +1,1142 @@ +// ============================================================ +// === src/audio/live-sound.js === +// ============================================================ +/* Live-sound audio helpers. Classic script for ordered loading. */ +configureLiveSpectrumAnalyser = (a) => { + a.fftSize = 2048; + a.smoothingTimeConstant = 0.82; + /* getFloatFrequencyData() is clamped to [minDecibels, maxDecibels]; a low max + flattens loud bass (many bins hit the ceiling -> horizontal line on the graph). */ + a.minDecibels = -100; + a.maxDecibels = 0; +}; + +disconnectToneGeneratorAnalyser = () => { + if (toneGeneratorAnalyser) { + try { + toneGeneratorAnalyser.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorAnalyser = null; + } +}; + +stopPinkNoisePlayback = () => { + if (!pinkNoisePlaying) { + return; + } + pinkNoisePlaying = false; + pinkNoisePlayButton.classList.remove("playback-active"); + if (liveEqSyncRafId !== null) { + cancelAnimationFrame(liveEqSyncRafId); + liveEqSyncRafId = null; + } + if (pinkNoiseProcessor) { + pinkNoiseProcessor.disconnect(); + pinkNoiseProcessor.onaudioprocess = null; + pinkNoiseProcessor = null; + } + disconnectEqBiquads(pinkNoiseBiquads); + disconnectEqBiquads(pinkNoiseBiquadsLeft); + disconnectEqBiquads(pinkNoiseBiquadsRight); + disconnectPinkBandFilters(); + if (pinkNoiseMasterGain) { + pinkNoiseMasterGain.disconnect(); + pinkNoiseMasterGain = null; + } + if (pinkNoiseUserGain) { + try { + pinkNoiseUserGain.disconnect(); + } catch (e) { /* noop */ } + pinkNoiseUserGain = null; + } + if (pinkNoiseAnalyser) { + try { + pinkNoiseAnalyser.disconnect(); + } catch (e) { /* noop */ } + pinkNoiseAnalyser = null; + } + musicSpectrumViz.syncSpectrumViz(); + updateEqTraceOpacity(); +}; + +createPinkNoiseProcessor = (audioContext) => { + let bufferSize = 4096; + /* WebKit/Safari: ScriptProcessor with 0 inputs often never runs onaudioprocess (silent output + while the node graph still looks "active" to the browser). One input channel fixes it; + we only write the output buffer and do not use the input. */ + let processor = audioContext.createScriptProcessor(bufferSize, 1, 1); + let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0; + processor.onaudioprocess = (e) => { + let output = e.outputBuffer.getChannelData(0); + for (let i = 0; i < output.length; i++) { + let white = Math.random() * 2 - 1; + b0 = 0.99886 * b0 + white * 0.0555179; + b1 = 0.99332 * b1 + white * 0.0750759; + b2 = 0.96900 * b2 + white * 0.1538520; + b3 = 0.86650 * b3 + white * 0.3104856; + b4 = 0.55000 * b4 + white * 0.5329522; + b5 = -0.7616 * b5 - white * 0.0168980; + let pink = b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362; + b6 = white * 0.115926; + output[i] = pink * 0.11; + } + }; + return processor; +}; + +// ============================================================ +// === src/audio/live-controls.js === +// ============================================================ +/* Live-sound control bindings. Classic script for ordered loading. */ +/* DOM elements are null here — shell not rendered yet. initAudioEngineUiBindings() resolves them. */ +let toneGeneratorFromInput = null; +let toneGeneratorToInput = null; +let toneGeneratorSlider = null; +let toneGeneratorPlayButton = null; +let toneGeneratorText = null; +let toneGeneratorAddFilterButton = null; +const TONE_GENERATOR_DEFAULT_HZ = 1000; +let liveSoundMasterVolumeInput = null; +let liveSoundVolumePctText = null; +Object.defineProperty(window, 'toneGeneratorFromInput', { get: () => toneGeneratorFromInput, set: v => { toneGeneratorFromInput = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorToInput', { get: () => toneGeneratorToInput, set: v => { toneGeneratorToInput = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorSlider', { get: () => toneGeneratorSlider, set: v => { toneGeneratorSlider = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorPlayButton', { get: () => toneGeneratorPlayButton, set: v => { toneGeneratorPlayButton = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorText', { get: () => toneGeneratorText, set: v => { toneGeneratorText = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorAddFilterButton',{ get: () => toneGeneratorAddFilterButton,set: v => { toneGeneratorAddFilterButton = v; },configurable: true }); +Object.defineProperty(window, 'liveSoundMasterVolumeInput', { get: () => liveSoundMasterVolumeInput, set: v => { liveSoundMasterVolumeInput = v; }, configurable: true }); +Object.defineProperty(window, 'liveSoundVolumePctText', { get: () => liveSoundVolumePctText, set: v => { liveSoundVolumePctText = v; }, configurable: true }); + +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 + "%"); +}; + +/* Called from initLiveSoundExtra() after the shell HTML exists in the DOM. */ +function initAudioEngineUiBindings() { + toneGeneratorFromInput = document.querySelector("div.live-sound-tools input[name='tone-generator-from']"); + toneGeneratorToInput = document.querySelector("div.live-sound-tools input[name='tone-generator-to']"); + toneGeneratorSlider = document.querySelector("div.live-sound-tools input[name='tone-generator-freq']"); + toneGeneratorPlayButton = document.querySelector("div.extra-tone-generator .play"); + toneGeneratorText = document.querySelector("div.extra-tone-generator .freq-text"); + toneGeneratorAddFilterButton= document.querySelector("div.extra-tone-generator button.tone-generator-add-filter"); + liveSoundMasterVolumeInput = document.querySelector("div.live-sound-tools input[name='live-sound-master-volume']"); + liveSoundVolumePctText = document.querySelector("div.live-sound-tools .live-sound-volume-pct-text"); + + 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); + let target = Math.min(Math.max(TONE_GENERATOR_DEFAULT_HZ, from), to); + if (from < to) { + let u = (Math.log(target) - Math.log(from)) / (Math.log(to) - Math.log(from)); + toneGeneratorSlider.value = String(Math.min(1, Math.max(0, u))); + } + toneGeneratorText.innerText = String(target); + } +} + +// ============================================================ +// === src/audio/music.js === +// ============================================================ +/* Music playback segment helpers. Classic script for ordered loading. */ +let getMusicDuration = () => { + if (!musicAudio) { + return 0; + } + let d = musicAudio.duration; + return Number.isFinite(d) && d > 0 ? d : 0; +}; + +let musicSegMinGapU = () => { + let d = getMusicDuration(); + if (d <= 0) { + return 0.001; + } + let minSec = Math.min(0.15, d * 0.1); + return minSec / d; +}; + +let clampMusicSegmentBounds = () => { + let gap = musicSegMinGapU(); + musicSegStartU = Math.max(0, Math.min(1, musicSegStartU)); + musicSegEndU = Math.max(0, Math.min(1, musicSegEndU)); + if (musicSegEndU - musicSegStartU >= gap) { + return; + } + if (musicTrimDragging === "start") { + musicSegEndU = Math.min(1, musicSegStartU + gap); + } else if (musicTrimDragging === "end") { + musicSegStartU = Math.max(0, musicSegEndU - gap); + } else { + musicSegEndU = Math.min(1, musicSegStartU + gap); + } +}; + +let syncMusicSegmentVisuals = () => { + if (!musicSegmentTrackEl) { + return; + } + let su = musicSegStartU; + let eu = musicSegEndU; + let inset = musicSegmentHandleInsetPx; + let innerSpan = `(100% - ${2 * inset}px)`; + let innerLeft = (u) => `calc(${inset}px + ${innerSpan} * ${u})`; + if (musicSegmentOutsideLeftEl) { + musicSegmentOutsideLeftEl.style.width = innerLeft(su); + } + if (musicSegmentOutsideRightEl) { + musicSegmentOutsideRightEl.style.left = innerLeft(eu); + musicSegmentOutsideRightEl.style.width = `calc(100% - ${inset}px - ${innerSpan} * ${eu})`; + } + if (musicSegmentLoopedEl) { + musicSegmentLoopedEl.style.left = innerLeft(su); + musicSegmentLoopedEl.style.width = `calc(${innerSpan} * ${eu - su})`; + } + if (musicSegmentHandleStart) { + musicSegmentHandleStart.style.left = innerLeft(su); + } + if (musicSegmentHandleEnd) { + musicSegmentHandleEnd.style.left = innerLeft(eu); + } + let d = getMusicDuration(); + let spanU = Math.max(0, eu - su); + let uSeg = 0; + if (d > 0 && spanU > 0 && musicAudio) { + let t0 = su * d; + let t1 = eu * d; + let ct = Math.min(t1, Math.max(t0, musicAudio.currentTime)); + uSeg = (ct - t0) / (t1 - t0); + } + if (musicSegmentProgressEl) { + musicSegmentProgressEl.style.left = innerLeft(su); + musicSegmentProgressEl.style.width = `calc(${innerSpan} * ${uSeg * spanU})`; + } +}; + +let pointerToMusicSegmentU = (clientX) => { + if (!musicSegmentTrackEl) { + return 0; + } + let rect = musicSegmentTrackEl.getBoundingClientRect(); + if (rect.width <= 0) { + return 0; + } + return Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); +}; + +let pointerToMusicHandleU = (clientX) => { + if (!musicSegmentTrackEl) { + return 0; + } + let rect = musicSegmentTrackEl.getBoundingClientRect(); + let inset = musicSegmentHandleInsetPx; + let usable = rect.width - 2 * inset; + if (usable <= 0) { + return 0.5; + } + return Math.min(1, Math.max(0, (clientX - rect.left - inset) / usable)); +}; + +let seekMusicToClientX = (clientX) => { + if (!musicFileLoaded || !musicAudio || !musicSegmentTrackEl) { + return; + } + let d = getMusicDuration(); + if (d <= 0) { + return; + } + let u = pointerToMusicSegmentU(clientX); + let t = u * d; + let t0 = musicSegStartU * d; + let t1 = musicSegEndU * d; + let margin = Math.min(1e-3, (t1 - t0) * 0.01); + musicAudio.currentTime = Math.min(t1 - margin, Math.max(t0, t)); + syncMusicSegmentVisuals(); +}; + +let musicAudioTimeUpdateHandler = () => { + if (!musicFileLoaded || !musicAudio) { + return; + } + let d = getMusicDuration(); + if (d <= 0) { + return; + } + let t0 = musicSegStartU * d; + let t1 = musicSegEndU * d; + if (!musicAudio.paused && t1 > t0) { + let span = t1 - t0; + let wrapEps = Math.min(0.05, Math.max(0.001, span * 0.05)); + if (musicAudio.currentTime >= t1 - wrapEps) { + musicAudio.currentTime = t0; + } else if (musicAudio.currentTime + 0.001 < t0) { + musicAudio.currentTime = t0; + } + } + if (!musicSeekDragging) { + syncMusicSegmentVisuals(); + } +}; + +let musicAudioEndedHandler = () => { + if (!musicFileLoaded || !musicAudio) { + return; + } + let d = getMusicDuration(); + if (d <= 0) { + return; + } + musicAudio.currentTime = musicSegStartU * d; + void startMusicPlayback().catch(() => { + /* restart after natural EOF may fail without gesture */ + }); +}; + +let pauseMusicForLiveSoundSwitch = () => { + if (!musicAudio || musicAudio.paused) { + return; + } + musicAudio.pause(); + if (musicPlayButton) { + musicPlayButton.classList.remove("playback-active"); + } + musicSpectrumViz.syncSpectrumViz(); + updateEqTraceOpacity(); +}; + +// ============================================================ +// === src/audio/music-graph.js === +// ============================================================ +/* Music playback graph lifecycle. Classic script for ordered loading. */ +function initMusicGraphLifecycle() { + musicSpectrumViz.isActive = function () { + let v = musicSpectrumViz; + if (v.analyser === musicAnalyser && musicContext) { + return musicFileLoaded && musicAudio && !musicAudio.paused; + } + if (v.analyser === pinkNoiseAnalyser) { + return !!pinkNoisePlaying; + } + if (v.analyser === toneGeneratorAnalyser) { + return !!toneGeneratorOsc; + } + return false; + }; + + musicSpectrumViz.syncSpectrumViz = function () { + musicSpectrumViz.stop(); + if (musicFileLoaded && musicAudio && !musicAudio.paused && musicContext && musicAnalyser) { + musicSpectrumViz.analyser = musicAnalyser; + musicSpectrumViz.context = musicContext; + musicSpectrumViz.ensureBuffer(); + musicSpectrumViz.start(); + return; + } + if (pinkNoisePlaying && pinkNoiseContext && pinkNoiseAnalyser) { + musicSpectrumViz.analyser = pinkNoiseAnalyser; + musicSpectrumViz.context = pinkNoiseContext; + musicSpectrumViz.ensureBuffer(); + musicSpectrumViz.start(); + return; + } + if (toneGeneratorOsc && toneGeneratorContext && toneGeneratorAnalyser) { + musicSpectrumViz.analyser = toneGeneratorAnalyser; + musicSpectrumViz.context = toneGeneratorContext; + musicSpectrumViz.ensureBuffer(); + musicSpectrumViz.start(); + return; + } + musicSpectrumViz.analyser = null; + musicSpectrumViz.context = null; + }; +} + +let removeMusicTrack = () => { + musicRestoreCancelToken++; + void clearPersistedMusic(); + if (musicAudio) { + musicAudio.removeEventListener("timeupdate", musicAudioTimeUpdateHandler); + musicAudio.removeEventListener("ended", musicAudioEndedHandler); + musicAudio.pause(); + musicAudio.removeAttribute("src"); + try { + musicAudio.load(); + } catch (err) { /* noop */ } + } + if (musicObjectUrl) { + URL.revokeObjectURL(musicObjectUrl); + musicObjectUrl = null; + } + musicFileLoaded = false; + musicAppleShareSongId = null; + musicSeekDragging = false; + musicTrimDragging = null; + musicSpectrumViz.stop(); + if (musicTrimIdleTimer !== null) { + clearTimeout(musicTrimIdleTimer); + musicTrimIdleTimer = null; + } + musicSegStartU = 0; + musicSegEndU = 1; + if (musicPlayButton) { + musicPlayButton.disabled = true; + musicPlayButton.classList.remove("playback-active"); + } + if (musicSegmentSliderEl) { + musicSegmentSliderEl.classList.add("music-segment-slider-disabled"); + } + syncMusicSegmentVisuals(); + if (musicPlaybackPanel) { + musicPlaybackPanel.setAttribute("aria-hidden", "true"); + } + if (musicCard) { + musicCard.classList.remove("music-file-loaded"); + } + if (musicAddRemoveButton) { + musicAddRemoveButton.textContent = "+ Add Music"; + } + if (musicFileInput) { + musicFileInput.value = ""; + } + disconnectEqBiquads(musicBiquads); + disconnectEqBiquads(musicBiquadsLeft); + disconnectEqBiquads(musicBiquadsRight); + disconnectMusicBandFilters(); + if (musicMediaSourceNode) { + try { + musicMediaSourceNode.disconnect(); + } catch (err) { /* noop */ } + musicMediaSourceNode = null; + } + if (musicMasterGain) { + try { + musicMasterGain.disconnect(); + } catch (err) { /* noop */ } + musicMasterGain = null; + } + if (musicUserGain) { + try { + musicUserGain.disconnect(); + } catch (err) { /* noop */ } + musicUserGain = null; + } + if (musicAnalyser) { + try { + musicAnalyser.disconnect(); + } catch (err) { /* noop */ } + musicAnalyser = null; + } + if (musicContext && musicContext.state !== "closed") { + void musicContext.close(); + } + musicContext = null; + musicAudio = null; + if (lastEqPlaybackSource === "music") { + lastEqPlaybackSource = "pink"; + } + ensureActiveLiveSoundPlayerValid(); + musicSpectrumViz.syncSpectrumViz(); + if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { + addPhonesToUrl(); + } +}; + +let initMusicAudioGraph = () => { + if (musicContext) { + return true; + } + if (!window.AudioContext && !window.webkitAudioContext) { + return false; + } + 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); + musicAudio.addEventListener("ended", musicAudioEndedHandler); + musicAudio.addEventListener("error", () => { + alert("Could not load or play this audio file."); + removeMusicTrack(); + }); + 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(musicUserGain); + musicUserGain.connect(musicAnalyser); + musicAnalyser.connect(musicContext.destination); + musicSpectrumViz.syncSpectrumViz(); + return true; +}; + +let stopPinkAndToneForExclusiveMusic = () => { + stopPinkNoisePlayback(); + if (toneGeneratorOsc) { + fadeStopToneGeneratorPlayback(); + } +}; + +let startMusicPlayback = () => { + if (!musicFileLoaded || !musicAudio || !musicContext || !musicPlayButton) { + return Promise.reject(new Error("music not ready")); + } + let d = getMusicDuration(); + if (d > 0) { + let t0 = musicSegStartU * d; + let t1 = musicSegEndU * d; + let ct = musicAudio.currentTime; + if (ct < t0 || ct >= t1 - 0.02) { + musicAudio.currentTime = t0; + } + } + stopPinkAndToneForExclusiveMusic(); + void musicContext.resume(); + return musicAudio.play().then(() => { + musicPlayButton.classList.add("playback-active"); + lastEqPlaybackSource = "music"; + activeLiveSoundPlayer = "music"; + musicSpectrumViz.syncSpectrumViz(); + updateEqTraceOpacity(); + }); +}; + +let wireMusicLoadedFromSource = (src, segOpt, loadOpts) => { + loadOpts = loadOpts || {}; + let autoPlayAfterLoad = loadOpts.autoPlay === true; + if (!musicAudio || !musicPlayButton || !musicCard || !musicSegmentSliderEl || !musicAddRemoveButton) { + return; + } + 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; + } + } + if (typeof resetAppleMusicSearchUi === "function") { + resetAppleMusicSearchUi({ collapseEmptyPlaybackPanel: false }); + } + if (musicObjectUrl) { + URL.revokeObjectURL(musicObjectUrl); + musicObjectUrl = null; + } + musicAudio.pause(); + musicSpectrumViz.stop(); + musicPlayButton.classList.remove("playback-active"); + 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; + } else { + musicSegStartU = 0; + musicSegEndU = 1; + } + musicAudio.load(); + musicFileLoaded = true; + activeLiveSoundPlayer = "music"; + musicCard.classList.add("music-file-loaded"); + if (musicPlaybackPanel) { + musicPlaybackPanel.setAttribute("aria-hidden", "false"); + } + musicPlayButton.disabled = false; + musicSegmentSliderEl.classList.remove("music-segment-slider-disabled"); + let onMusicMeta = () => { + clampMusicSegmentBounds(); + syncMusicSegmentVisuals(); + persistMusicSegmentToLocalStorage(); + }; + musicAudio.addEventListener("loadedmetadata", onMusicMeta, { once: true }); + syncMusicSegmentVisuals(); + musicAddRemoveButton.textContent = "- Remove Music"; + rebuildMusicEqChain(); + if (autoPlayAfterLoad) { + let autoPlayWhenReady = () => { + startMusicPlayback().catch(() => { + /* autoplay may be blocked without further gesture; user can press play */ + }); + }; + if (musicAudio.readyState >= 2) { + autoPlayWhenReady(); + } else { + musicAudio.addEventListener("canplay", autoPlayWhenReady, { once: true }); + } + } + if (typeof ifURL !== "undefined" && ifURL && typeof addPhonesToUrl === "function") { + addPhonesToUrl(); + } +}; + +let wireMusicLoadedFromBlob = (blob, segOpt, loadOpts) => + wireMusicLoadedFromSource(blob, segOpt, loadOpts); + +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(); + } + }); + }); +}; + +// ============================================================ +// === src/audio/tone-core.js === +// ============================================================ +/* Tone generator core. Classic script for ordered loading. */ +let toneGeneratorContext = null; +let toneGeneratorOsc = null; +let toneGeneratorTimeoutHandle = null; +let toneSweepRafId = null; +// Bridge these let vars onto window so graphtool.js can read/write them as bare names +Object.defineProperty(window, 'toneGeneratorContext', { get: () => toneGeneratorContext, set: v => { toneGeneratorContext = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorOsc', { get: () => toneGeneratorOsc, set: v => { toneGeneratorOsc = v; }, configurable: true }); +Object.defineProperty(window, 'toneGeneratorTimeoutHandle',{ get: () => toneGeneratorTimeoutHandle,set: v => { toneGeneratorTimeoutHandle = v; },configurable: true }); +Object.defineProperty(window, 'toneSweepRafId', { get: () => toneSweepRafId, set: v => { toneSweepRafId = v; }, configurable: true }); +let toneSweepDurationSec = 30; +/** log(20k/20); sweep uses log-frequency interpolation — partial ranges use the same share of wall time as on a full 20–20k sweep. */ +const TONE_SWEEP_FULL_LOG_SPAN = Math.log(20000 / 20); +const TONE_SWEEP_MIN_DURATION_SEC = 6; +/** Last Space keydown in Extra tab (any live sound source); two within `toneSpaceDoubleMs` → sine sweep. */ +let lastToneSpaceKeydownTime = 0; +let toneSpaceDoubleMs = 200; +/** While true, tone sweep restarts from range low when a pass completes (Space held, or 2nd press held past TONE_PLAY_BTN_HOLD_MS on play). */ +let toneSweepLoopSpaceHeld = false; +let toneSweepLoopPointerHeld = false; +/** For touch double-press sweep: pointerdown does not get a meaningful UIEvent.detail (unlike mousedown). */ +let tonePlayBtnTouchPrevDownTs = 0; +const TONE_PLAY_BTN_TOUCH_DOUBLE_MS = 450; +/** Second click of a double-click: wait this long before treating as "hold" -> loop sweep; shorter = normal double-click sweep on mouseup (click detail 2). */ +const TONE_PLAY_BTN_HOLD_MS = 100; +let tonePlayBtnHoldSweepTimer = null; +/** Skip mousedown hold-detect when touch just armed the same gesture (both fire on many devices). */ +let tonePlayBtnTouchArmTs = 0; +/** Master-gain fade to avoid output discontinuities when starting/stopping the sine. */ +const TONE_GEN_FADE_IN_SEC = 0.022; +const TONE_GEN_FADE_OUT_SEC = 0.042; +let toneGenFadeCleanupTimer = null; + +let toneSweepPointerHoldUp = () => { + toneSweepLoopPointerHeld = false; + document.removeEventListener("pointerup", toneSweepPointerHoldUp, true); + document.removeEventListener("pointercancel", toneSweepPointerHoldUp, true); +}; + +let tonePlayBtnClearHoldSweepTimer = () => { + if (tonePlayBtnHoldSweepTimer !== null) { + clearTimeout(tonePlayBtnHoldSweepTimer); + tonePlayBtnHoldSweepTimer = null; + } +}; + +/** + * Second press of a double-click / double-tap: defer loop sweep until hold exceeds TONE_PLAY_BTN_HOLD_MS. + * Shorter press: cancel timer — mouse gets a normal click(detail=2) for sweep on mouseup; touch runs onEarlyTapSweep. + */ +let tonePlayBtnArmLoopHoldAfterSecondPress = (onEarlyTapSweep) => { + tonePlayBtnClearHoldSweepTimer(); + let settled = false; + let cancelEarlyUp = (ev) => { + if (ev.button !== 0) { + return; + } + if (settled) { + return; + } + settled = true; + document.removeEventListener("pointerup", cancelEarlyUp, true); + document.removeEventListener("pointercancel", cancelEarlyUp, true); + tonePlayBtnClearHoldSweepTimer(); + if (typeof onEarlyTapSweep === "function") { + onEarlyTapSweep(); + } + }; + document.addEventListener("pointerup", cancelEarlyUp, true); + document.addEventListener("pointercancel", cancelEarlyUp, true); + tonePlayBtnHoldSweepTimer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + tonePlayBtnHoldSweepTimer = null; + document.removeEventListener("pointerup", cancelEarlyUp, true); + document.removeEventListener("pointercancel", cancelEarlyUp, true); + toneSweepLoopPointerHeld = true; + document.addEventListener("pointerup", toneSweepPointerHoldUp, true); + document.addEventListener("pointercancel", toneSweepPointerHoldUp, true); + startToneGeneratorSweep(); + }, TONE_PLAY_BTN_HOLD_MS); +}; + +let clearToneGenFadeCleanupTimer = () => { + if (toneGenFadeCleanupTimer !== null) { + clearTimeout(toneGenFadeCleanupTimer); + toneGenFadeCleanupTimer = null; + } +}; + +let toneGeneratorGraphTeardown = () => { + 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 */ } + }); + 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 (toneGeneratorUserGain) { + try { + toneGeneratorUserGain.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorUserGain = null; + } + if (toneGeneratorMasterGain) { + try { + toneGeneratorMasterGain.disconnect(); + } catch (e) { /* noop */ } + toneGeneratorMasterGain = null; + } + disconnectToneGeneratorAnalyser(); +}; + +/** Ramp output gain to zero, stop the oscillator shortly after, then disconnect the graph after the ramp. */ +let fadeStopToneGeneratorPlayback = () => { + if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { + clearToneGenFadeCleanupTimer(); + toneGeneratorGraphTeardown(); + return; + } + if (toneSweepRafId !== null) { + cancelAnimationFrame(toneSweepRafId); + toneSweepRafId = null; + } + toneSweepPointerHoldUp(); + let ctx = toneGeneratorContext; + let osc = toneGeneratorOsc; + let g = toneGeneratorMasterGain.gain; + let t = ctx.currentTime; + let rel = TONE_GEN_FADE_OUT_SEC; + g.cancelScheduledValues(t); + g.setValueAtTime(g.value, t); + g.linearRampToValueAtTime(0, t + rel); + let tStop = t + rel + 0.001; + try { + osc.stop(tStop); + } catch (e) { + try { + osc.stop(); + } catch (e2) { /* noop */ } + } + toneGeneratorOsc = null; + toneGeneratorPlayButton.classList.remove("playback-active"); + musicSpectrumViz.syncSpectrumViz(); + updateEqTraceOpacity(); + clearToneGenFadeCleanupTimer(); + let ms = Math.ceil(rel * 1000) + 30; + toneGenFadeCleanupTimer = setTimeout(() => { + toneGenFadeCleanupTimer = null; + toneGeneratorGraphTeardown(); + }, ms); +}; + +let startToneGeneratorOscillatorIfStopped = () => { + if (toneGeneratorOsc) { + return true; + } + clearToneGenFadeCleanupTimer(); + if (toneGeneratorMasterGain || toneGeneratorBiquads.length) { + toneGeneratorGraphTeardown(); + } + stopPinkNoisePlayback(); + pauseMusicForLiveSoundSwitch(); + if (!toneGeneratorContext) { + if (!window.AudioContext && !window.webkitAudioContext) { + alert("Web audio api is disabled, please enable it if you want to use tone generator."); + return false; + } + toneGeneratorContext = new (window.AudioContext || window.webkitAudioContext)(); + } + let tA = toneGeneratorContext.currentTime; + toneGeneratorOsc = toneGeneratorContext.createOscillator(); + toneGeneratorOsc.type = "sine"; + 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(toneGeneratorUserGain); + toneGeneratorUserGain.connect(toneGeneratorAnalyser); + toneGeneratorAnalyser.connect(toneGeneratorContext.destination); + toneGeneratorOsc.start(tA); + 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(); + } + musicSpectrumViz.syncSpectrumViz(); + updateEqTraceOpacity(); + return true; +}; + +let startToneGeneratorSweep = () => { + let fromHz = Math.min(Math.max(parseInt(toneGeneratorFromInput.value) || 0, 20), 20000); + let toHz = Math.min(Math.max(parseInt(toneGeneratorToInput.value) || 0, fromHz), 20000); + if (fromHz > toHz) { + let swap = fromHz; + fromHz = toHz; + toHz = swap; + } + if (!toneGeneratorOsc) { + toneGeneratorSlider.value = "0"; + toneGeneratorText.innerText = String(fromHz); + if (!startToneGeneratorOscillatorIfStopped()) { + return; + } + } + if (toneSweepRafId !== null) { + cancelAnimationFrame(toneSweepRafId); + toneSweepRafId = null; + } + let sweepDurationSec = 0; + if (fromHz !== toHz && TONE_SWEEP_FULL_LOG_SPAN > 1e-9) { + let logSpan = Math.log(toHz / fromHz); + if (logSpan > 1e-9) { + sweepDurationSec = Math.max(TONE_SWEEP_MIN_DURATION_SEC, + toneSweepDurationSec * (logSpan / TONE_SWEEP_FULL_LOG_SPAN)); + } else { + sweepDurationSec = TONE_SWEEP_MIN_DURATION_SEC; + } + } + /** Looped sweeps (Space held / play-button hold) alternate low->high then high->low. Each new startToneGeneratorSweep() begins upward. */ + let sweepForward = true; + void toneGeneratorContext.resume(); + let t0 = toneGeneratorContext.currentTime; + toneGeneratorOsc.frequency.cancelScheduledValues(t0); + let curF = toneGeneratorOsc.frequency.value; + let leadInSec = 0; + if (fromHz !== toHz) { + let cross = TONE_GEN_FADE_IN_SEC; + if (Math.abs(curF - fromHz) > 0.5) { + leadInSec = cross; + toneGeneratorOsc.frequency.setValueAtTime(Math.max(20, curF), t0); + toneGeneratorOsc.frequency.exponentialRampToValueAtTime(fromHz, t0 + cross); + t0 += cross; + } else { + toneGeneratorOsc.frequency.setValueAtTime(fromHz, t0); + } + toneGeneratorOsc.frequency.exponentialRampToValueAtTime(toHz, t0 + sweepDurationSec); + } else { + toneGeneratorOsc.frequency.setValueAtTime(fromHz, t0); + } + /** Wall-clock span between loops: fade out (same as tone stop) + fade in (same as tone start), silent in between. */ + const loopGapWallMs = (TONE_GEN_FADE_OUT_SEC + TONE_GEN_FADE_IN_SEC) * 1000; + let loopNextSweepPerfAnchorMs = null; + let scheduleLoopSilenceThenNextSweep = () => { + if (!toneGeneratorOsc || !toneGeneratorContext || !toneGeneratorMasterGain) { + return; + } + let t = toneGeneratorContext.currentTime; + let outSec = TONE_GEN_FADE_OUT_SEC; + let inSec = TONE_GEN_FADE_IN_SEC; + let tSilent = t + outSec; + let tSweepStart = tSilent + inSec; + let g = toneGeneratorMasterGain.gain; + g.cancelScheduledValues(t); + g.setValueAtTime(g.value, t); + g.linearRampToValueAtTime(0, tSilent); + g.setValueAtTime(0, tSilent); + g.linearRampToValueAtTime(liveToneGeneratorPlaybackGain, tSweepStart); + let f = toneGeneratorOsc.frequency; + let sweepLo = fromHz; + let sweepHi = toHz; + let rampStart = sweepForward ? sweepLo : sweepHi; + let rampEnd = sweepForward ? sweepHi : sweepLo; + f.cancelScheduledValues(tSilent); + f.setValueAtTime(rampStart, tSilent); + f.setValueAtTime(rampStart, tSweepStart); + f.exponentialRampToValueAtTime(rampEnd, tSweepStart + sweepDurationSec); + /* Approximate wall time when the next frequency ramp begins (audio clock ≈ real time). */ + loopNextSweepPerfAnchorMs = performance.now() + (tSweepStart - t) * 1000; + }; + let sweepStartMs = performance.now(); + let sweepDurationMs = (sweepDurationSec + leadInSec) * 1000; + let sweepPhase = "sweep"; + let sweepTick = () => { + if (sweepPhase === "gap") { + let uGap = Math.min(1, (performance.now() - sweepStartMs) / sweepDurationMs); + toneGeneratorSlider.value = "0"; + toneGeneratorText.innerText = String(fromHz); + if (uGap < 1) { + toneSweepRafId = requestAnimationFrame(sweepTick); + return; + } + sweepPhase = "sweep"; + sweepStartMs = loopNextSweepPerfAnchorMs != null + ? loopNextSweepPerfAnchorMs + : performance.now(); + loopNextSweepPerfAnchorMs = null; + sweepDurationMs = sweepDurationSec * 1000; + toneSweepRafId = requestAnimationFrame(sweepTick); + return; + } + let u = Math.min(1, (performance.now() - sweepStartMs) / sweepDurationMs); + let freq = sweepForward + ? Math.round(Math.exp( + Math.log(fromHz) + (Math.log(toHz) - Math.log(fromHz)) * u)) + : Math.round(Math.exp( + Math.log(toHz) + (Math.log(fromHz) - Math.log(toHz)) * u)); + toneGeneratorSlider.value = String(sweepForward ? u : (1 - u)); + toneGeneratorText.innerText = String(freq); + if (u < 1) { + toneSweepRafId = requestAnimationFrame(sweepTick); + } else { + let loopHeld = toneSweepLoopSpaceHeld || toneSweepLoopPointerHeld; + if (loopHeld && fromHz !== toHz && toneGeneratorOsc && sweepDurationSec > 0) { + sweepForward = !sweepForward; + scheduleLoopSilenceThenNextSweep(); + sweepPhase = "gap"; + sweepStartMs = performance.now(); + sweepDurationMs = loopGapWallMs; + toneGeneratorSlider.value = "0"; + toneGeneratorText.innerText = String(fromHz); + toneSweepRafId = requestAnimationFrame(sweepTick); + return; + } + toneSweepRafId = null; + toneGeneratorSlider.value = "0"; + toneGeneratorText.innerText = String(fromHz); + if (toneGeneratorOsc) { + toneGeneratorPlayButton.click(); + } + } + }; + toneSweepRafId = requestAnimationFrame(sweepTick); +}; + +// ============================================================ +// === src/audio/tone-ui.js === +// ============================================================ +/* Tone generator UI bindings. Classic script for ordered loading. */ +function initToneControls() { + if (!toneGeneratorAddFilterButton || !toneGeneratorFromInput || !toneGeneratorToInput + || !toneGeneratorSlider || !toneGeneratorPlayButton || !toneGeneratorText) { + return; + } + toneGeneratorAddFilterButton.addEventListener("click", () => { + let hz = parseInt(toneGeneratorText.innerText, 10) || 0; + addPeakingFilterFromHz(hz, EQ_GRAPH_BASE_GAIN); + }); + toneGeneratorFromInput.addEventListener("change", clearLiveSoundIntervalsDatasetIfPresent); + toneGeneratorToInput.addEventListener("change", clearLiveSoundIntervalsDatasetIfPresent); + toneGeneratorFromInput.addEventListener("input", scheduleLiveEqSync); + toneGeneratorToInput.addEventListener("input", scheduleLiveEqSync); + toneGeneratorFromInput.addEventListener("input", syncEqSoundRangeBrushFromLiveSoundInputs); + toneGeneratorToInput.addEventListener("input", syncEqSoundRangeBrushFromLiveSoundInputs); + toneGeneratorFromInput.addEventListener("change", syncEqSoundRangeBrushFromLiveSoundInputs); + toneGeneratorToInput.addEventListener("change", syncEqSoundRangeBrushFromLiveSoundInputs); + toneGeneratorSlider.addEventListener("input", () => { + if (toneSweepRafId !== null) { + cancelAnimationFrame(toneSweepRafId); + toneSweepRafId = null; + } + toneSweepPointerHoldUp(); + let from = Math.min(Math.max(parseInt(toneGeneratorFromInput.value) || 0, 20), 20000); + let to = Math.min(Math.max(parseInt(toneGeneratorToInput.value) || 0, from), 20000); + let position = parseFloat(toneGeneratorSlider.value) || 0; + let freq = Math.round(Math.exp( + Math.log(from) + (Math.log(to) - Math.log(from)) * position)); + toneGeneratorText.innerText = freq; + if (toneGeneratorOsc) { + let t = toneGeneratorContext.currentTime; + toneGeneratorOsc.frequency.cancelScheduledValues(t); + toneGeneratorOsc.frequency.setTargetAtTime(freq, t, 0.2); + } + }); + document.addEventListener("keydown", (e) => { + if (e.code !== "Space" || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) { + return; + } + let t = e.target; + if (t && t.nodeType === 1 && t.tagName === "INPUT" && t.getAttribute("type") === "number") { + if ((t.closest && t.closest("div.extra-eq")) || (t.closest && t.closest("div.live-sound-tools"))) { + e.preventDefault(); + } + } + if (e.repeat) { + return; + } + let tab = document.querySelector("div.select"); + if (!extraEnabled || !tab || tab.getAttribute("data-selected") !== "extra") { + return; + } + if (suppressEqExtraGlobalShortcutsForAppleSearch()) { + return; + } + toneSweepLoopSpaceHeld = true; + }, true); + document.addEventListener("keyup", (e) => { + if (e.code !== "Space") { + return; + } + toneSweepLoopSpaceHeld = false; + }, true); + toneGeneratorPlayButton.addEventListener("mousedown", (e) => { + if (e.button !== 0 || e.detail !== 2) { + return; + } + if (performance.now() - tonePlayBtnTouchArmTs < 40) { + return; + } + tonePlayBtnArmLoopHoldAfterSecondPress(null); + }); + toneGeneratorPlayButton.addEventListener("pointerdown", (e) => { + if (e.button !== 0 || e.pointerType !== "touch") { + return; + } + let now = performance.now(); + let isQuickSecondDown = tonePlayBtnTouchPrevDownTs > 0 + && (now - tonePlayBtnTouchPrevDownTs) <= TONE_PLAY_BTN_TOUCH_DOUBLE_MS; + tonePlayBtnTouchPrevDownTs = now; + if (!isQuickSecondDown) { + return; + } + tonePlayBtnTouchPrevDownTs = 0; + tonePlayBtnTouchArmTs = now; + tonePlayBtnArmLoopHoldAfterSecondPress(() => { + if (toneSweepRafId !== null) { + return; + } + startToneGeneratorSweep(); + }); + }); + toneGeneratorPlayButton.addEventListener("click", (e) => { + if (e.detail === 2) { + e.preventDefault(); + if (toneSweepRafId === null) { + startToneGeneratorSweep(); + } + return; + } + if (toneGeneratorOsc) { + fadeStopToneGeneratorPlayback(); + } else { + if (!startToneGeneratorOscillatorIfStopped()) { + return; + } + } + }); +} + +let syncToneGeneratorToEqFrequencyHz = (hz) => { + /* EQ freq edits normally retune the tone preview; skip while a sweep is running so the ramp is not cancelled. */ + if (toneSweepRafId !== null) { + return; + } + let [fLo, fHi] = getEqConstraintFreqLoHi(); + hz = Math.min(fHi, Math.max(fLo, Math.round(Number(hz)) || fLo)); + let from = Math.min(Math.max(parseInt(toneGeneratorFromInput.value) || 0, 20), 20000); + let to = Math.min(Math.max(parseInt(toneGeneratorToInput.value) || 0, from), 20000); + let logSpan = Math.log(to) - Math.log(from); + let position = logSpan > 0 + ? (Math.log(hz) - Math.log(from)) / logSpan + : 0; + position = Math.min(1, Math.max(0, position)); + toneGeneratorSlider.value = String(position); + toneGeneratorText.innerText = String(hz); + if (toneGeneratorOsc && toneGeneratorContext) { + let t = toneGeneratorContext.currentTime; + toneGeneratorOsc.frequency.cancelScheduledValues(t); + toneGeneratorOsc.frequency.setTargetAtTime(hz, t, 0.2); + } +}; +// Expose let-scoped functions onto window so graphtool.js can call them as bare names +window.fadeStopToneGeneratorPlayback = fadeStopToneGeneratorPlayback; +window.startToneGeneratorOscillatorIfStopped = startToneGeneratorOscillatorIfStopped; +window.startToneGeneratorSweep = startToneGeneratorSweep; +window.pauseMusicForLiveSoundSwitch = pauseMusicForLiveSoundSwitch; +window.removeMusicTrack = removeMusicTrack; +window.initMusicAudioGraph = initMusicAudioGraph; +window.stopPinkAndToneForExclusiveMusic = stopPinkAndToneForExclusiveMusic; +window.startMusicPlayback = startMusicPlayback; +window.resumeLiveSoundAfterSyncNativeDialog = resumeLiveSoundAfterSyncNativeDialog; +window.syncToneGeneratorToEqFrequencyHz = syncToneGeneratorToEqFrequencyHz; +window.syncLiveSoundMasterVolumeTrackFill = syncLiveSoundMasterVolumeTrackFill; +window.seekMusicToClientX = seekMusicToClientX; +window.syncMusicSegmentVisuals = syncMusicSegmentVisuals; +window.getMusicDuration = getMusicDuration; +window.clampMusicSegmentBounds = clampMusicSegmentBounds; diff --git a/src/eq-manager.js b/src/eq-manager.js new file mode 100644 index 0000000..7e07d3d --- /dev/null +++ b/src/eq-manager.js @@ -0,0 +1,4321 @@ +// EQ cross-module coordination state — single object replaces 12 separate window.* properties +window.__eqCoord = { + modelIntent: "", + lastGraphModel: "", + targetIntent: "", + lastGraphTarget: "", + pendingModel: "", + pendingTarget: "", + modelStickyBypass: "", + modelActivatedByDropdown: false, + targetActivatedByDropdown: false, + suppressTargetSelect: false, + batchSuppressDepth: 0, + batchPathsPending: false, +}; + +// ============================================================ +// === equalizer.js === +// ============================================================ +/* +Biquad algorithms are taken from: +https://github.com/jaakkopasanen/AutoEq/blob/master/biquad.py +https://github.com/mohayonao/biquad-coeffs/tree/master/packages/biquad-coeffs-cookbook +*/ + +Equalizer = (function() { + let config = { + // Change sample rate will affect the curve of filters close to nyquist frequency + // Here I choosed a common used value, but not all DSP software use this sample rate for EQ + DefaultSampleRate: 48000, + // AutoEQ will avoid filters above this frequency at first batch + TrebleStartFrom: 7000, + // Avoid filters close to nyquist frequency by default, because the behavior is implementation dependent + // https://github.com/jaakkopasanen/AutoEq/issues/240 + // https://github.com/jaakkopasanen/AutoEq/issues/411 + AutoEQRange: [20, 20000], + // null = use AutoEQRange min/max; else sorted Hz list for fixed-band (graphic) headphone EQ UI + EqGraphicBandFreqHz: null, + // 0 = no cap (graphtool); >0 caps active bands / AutoEQ + EqMaxBands: 0, + // Which filter types are allowed in UI / strip (AutoEQ currently emits PK only) + EqAllowedTypes: { PK: true, LSQ: true, HSQ: true }, + // Minimum and maximum Q for AutoEQ feature + OptimizeQRange: [0.1, 10], + // Minimum and maximum Gain for AutoEQ feature (graphtool may widen via constraints) + OptimizeGainRange: [-40, 40], + // Delta and step of Freq, Q and Gain used for AutoEQ optimizing + OptimizeDeltas: [ + [10, 10, 10, 5, 0.1, 0.5], + [10, 10, 10, 2, 0.1, 0.2], + [10, 10, 10, 1, 0.1, 0.1], + ], + // Use to get response diff by EQ before smoothing + GraphicEQRawFrequences: ( // ~= 1/96 octave + new Array(Math.ceil(Math.log(20000 / 20) / Math.log(1.0072))).fill(null) + .map((_, i) => 20 * Math.pow(1.0072, i))), + // Smoothed 127 bands frequencies for graphic eq (wavelet) + GraphicEQFrequences: Array.from(new Set( + new Array(Math.ceil(Math.log(20000 / 20) / Math.log(1.0563))).fill(null) + .map((_, i) => Math.floor(20 * Math.pow(1.0563, i))))).sort((a, b) => a - b) + }; + + let interp = function (fv, fr) { + let i = 0; + return fv.map(f => { + for (; i < fr.length-1; ++i) { + let [f0, v0] = fr[i]; + let [f1, v1] = fr[i+1]; + if (i == 0 && f < f0) { + return [f, v0]; + } else if (f >= f0 && f < f1) { + let v = v0 + (v1 - v0) * (f - f0) / (f1 - f0); + return [f, v]; + } + } + return [f, fr[fr.length-1][1]]; + }); + }; + + let lowshelf = function (freq, q, gain, sampleRate) { + freq = freq / (sampleRate || config.DefaultSampleRate); + freq = Math.max(1e-6, Math.min(freq, 1)); + q = Math.max(1e-4, Math.min(q, 1000)); + gain = Math.max(-40, Math.min(gain, 40)); + + let w0 = 2 * Math.PI * freq; + let sin = Math.sin(w0); + let cos = Math.cos(w0); + let a = Math.pow(10, (gain / 40)); + let alpha = sin / (2 * q); + let alphamod = (2 * Math.sqrt(a) * alpha) || 0; + + let a0 = ((a+1) + (a-1) * cos + alphamod); + let a1 = -2 * ((a-1) + (a+1) * cos ); + let a2 = ((a+1) + (a-1) * cos - alphamod); + let b0 = a * ((a+1) - (a-1) * cos + alphamod); + let b1 = 2 * a * ((a-1) - (a+1) * cos ); + let b2 = a * ((a+1) - (a-1) * cos - alphamod); + + return [ 1.0, a1/a0, a2/a0, b0/a0, b1/a0, b2/a0 ]; + }; + + let highshelf = function (freq, q, gain, sampleRate) { + freq = freq / (sampleRate || config.DefaultSampleRate); + freq = Math.max(1e-6, Math.min(freq, 1)); + q = Math.max(1e-4, Math.min(q, 1000)); + gain = Math.max(-40, Math.min(gain, 40)); + + let w0 = 2 * Math.PI * freq; + let sin = Math.sin(w0); + let cos = Math.cos(w0); + let a = Math.pow(10, (gain / 40)); + let alpha = sin / (2 * q); + let alphamod = (2 * Math.sqrt(a) * alpha) || 0; + + let a0 = ((a+1) - (a-1) * cos + alphamod); + let a1 = 2 * ((a-1) - (a+1) * cos ); + let a2 = ((a+1) - (a-1) * cos - alphamod); + let b0 = a * ((a+1) + (a-1) * cos + alphamod); + let b1 = -2 * a * ((a-1) + (a+1) * cos ); + let b2 = a * ((a+1) + (a-1) * cos - alphamod); + + return [ 1.0, a1/a0, a2/a0, b0/a0, b1/a0, b2/a0 ]; + }; + + let peaking = function (freq, q, gain, sampleRate) { + freq = freq / (sampleRate || config.DefaultSampleRate); + freq = Math.max(1e-6, Math.min(freq, 1)); + q = Math.max(1e-4, Math.min(q, 1000)); + gain = Math.max(-40, Math.min(gain, 40)); + + let w0 = 2 * Math.PI * freq; + let sin = Math.sin(w0); + let cos = Math.cos(w0); + let a = Math.pow(10, (gain / 40)); + let alpha = sin / (2 * q); + + let a0 = 1 + alpha / a; + let a1 = -2 * cos; + let a2 = 1 - alpha / a; + let b0 = 1 + alpha * a; + let b1 = -2 * cos; + let b2 = 1 - alpha * a; + + return [ 1.0, a1/a0, a2/a0, b0/a0, b1/a0, b2/a0 ]; + }; + + let calc_gains = function (freqs, coeffs, sampleRate) { + sampleRate = sampleRate || config.DefaultSampleRate; + let gains = new Array(freqs.length).fill(0); + + for (let i = 0; i < coeffs.length; ++i) { + let [ a0, a1, a2, b0, b1, b2] = coeffs[i]; + for (let j = 0; j < freqs.length; ++j) { + let w = 2 * Math.PI * freqs[j] / sampleRate; + let phi = 4 * Math.pow(Math.sin(w / 2), 2); + let c = ( + 10 * Math.log10(Math.pow(b0 + b1 + b2, 2) + + (b0 * b2 * phi - (b1 * (b0 + b2) + 4 * b0 * b2)) * phi) - + 10 * Math.log10(Math.pow(a0 + a1 + a2, 2) + + (a0 * a2 * phi - (a1 * (a0 + a2) + 4 * a0 * a2)) * phi)); + gains[j] += c; + } + } + return gains; + }; + + let calc_preamp = function (fr1, fr2) { + let maxGain = -Infinity; + for (let i = 0; i < fr1.length; ++i) { + maxGain = Math.max(maxGain, fr2[i][1] - fr1[i][1]); + } + return -maxGain; + }; + + let calc_distance = function (fr1, fr2) { + let distance = 0; + for (let i = 0; i < fr1.length; ++i) { + let d = Math.abs(fr1[i][1] - fr2[i][1]); + distance += (d >= 0.1 ? d : 0); + } + return distance / fr1.length; + }; + + let filters_to_coeffs = function (filters, sampleRate) { + return filters.map(f => { + if (!f.freq || !f.gain || !f.q) { + return null; + } else if (f.type === "LSQ") { + return lowshelf(f.freq, f.q, f.gain, sampleRate); + } else if (f.type === "HSQ") { + return highshelf(f.freq, f.q, f.gain, sampleRate); + } else if (f.type === "PK") { + return peaking(f.freq, f.q, f.gain, sampleRate); + } + return null; + }).filter(f => f); + }; + + let apply = function (fr, filters, sampleRate) { + let freqs = new Array(fr.length).fill(null); + for (let i = 0; i < fr.length; ++i) { + freqs[i] = fr[i][0]; + } + let coeffs = filters_to_coeffs(filters, sampleRate); + let gains = calc_gains(freqs, coeffs, sampleRate); + let fr_eq = new Array(fr.length).fill(null); + for (let i = 0; i < fr.length; ++i) { + fr_eq[i] = [fr[i][0], fr[i][1] + gains[i]]; + } + return fr_eq; + }; + + let as_graphic_eq = function (filters, sampleRate) { + let rawFS = config.GraphicEQRawFrequences, fs = config.GraphicEQFrequences; + let coeffs = filters_to_coeffs(filters, sampleRate); + let gains = calc_gains(rawFS, coeffs, sampleRate); + let rawFR = rawFS.map((f, i) => [f, gains[i]]); + // Interpolate and smoothing with moving average + let i = 0; + let resultFR = fs.map((f, j) => { + let freqTo = (j < fs.length-1) ? Math.sqrt(f * fs[j+1]) : 20000; + let points = []; + for (; i < rawFS.length; ++i) { + if (rawFS[i] < freqTo) { + points.push(rawFR[i][1]); + } else { + break + } + } + let avg = points.reduce((a, b) => a + b, 0) / points.length; + return [f, avg]; + }); + // Normalize (apply preamp) + let maxGain = resultFR.reduce((a, b) => a > b[1] ? a : b[1], -Infinity); + resultFR = resultFR.map(([f, v]) => [f, v-maxGain]); + return resultFR; + }; + + let search_candidates = function (fr, frTarget, threshold) { + let state = 0; // 1: peak, 0: matched, -1: dip + let startIndex = -1; + let candidates = []; + let [minFreq, maxFreq] = config.AutoEQRange; + for (let i = 0; i < fr.length; ++i) { + let [f, v0] = fr[i]; + let v1 = frTarget[i][1]; + let delta = v0 - v1; + let deltaAbs = Math.abs(delta); + let nextState = (deltaAbs < threshold) ? 0 : (delta / deltaAbs); + if (nextState === state) { + continue; + } + if (startIndex >= 0) { + if (state != 0) { + let start = fr[startIndex][0]; + let end = f; + let center = Math.sqrt(start * end); + let gain = ( + interp([center], frTarget.slice(startIndex, i))[0][1] - + interp([center], fr.slice(startIndex, i))[0][1]); + let q = center / (end - start); + if (center >= minFreq && center <= maxFreq) { + candidates.push({ type: "PK", freq: center, q, gain }); + } + } + startIndex = -1; + } else { + startIndex = i; + } + state = nextState; + } + return candidates; + }; + + let freq_unit = function (freq) { + if (freq < 100) { + return 1; + } else if (freq < 1000) { + return 10; + } else if (freq < 10000) { + return 100; + } + return 1000; + }; + + let strip = function (filters) { + // Make freq, q and gain look better and more compatible to some DSP device + let [minQ, maxQ] = config.OptimizeQRange; + let [minGain, maxGain] = config.OptimizeGainRange; + let [minFreq, maxFreq] = config.AutoEQRange; + if (minFreq > maxFreq) { + let t = minFreq; + minFreq = maxFreq; + maxFreq = t; + } + let allowed = config.EqAllowedTypes || { PK: true, LSQ: true, HSQ: true }; + let fallbackType = () => (allowed.PK ? "PK" : (allowed.LSQ ? "LSQ" : "HSQ")); + return filters.map(f => { + let t = f.type; + if (t !== "PK" && t !== "LSQ" && t !== "HSQ") { + t = "PK"; + } + if (!allowed[t]) { + t = fallbackType(); + } + let fq = f.freq; + let snapped; + if (!Number.isFinite(fq) || fq <= 0) { + snapped = minFreq; + } else { + snapped = Math.floor(fq - fq % freq_unit(fq)); + } + let freq = Math.min(Math.max(snapped, minFreq), maxFreq); + return { + type: t, + freq, + q: Math.min(Math.max(Math.floor(f.q * 10) / 10, minQ), maxQ), + gain: Math.min(Math.max(Math.floor(f.gain * 10) / 10, minGain), maxGain) + }; + }); + }; + + let optimize = function (fr, frTarget, filters, iteration, dir) { + filters = strip(filters); + let combinations = []; + let [minFreq, maxFreq] = config.AutoEQRange; + let [minQ, maxQ] = config.OptimizeQRange; + let [minGain, maxGain] = config.OptimizeGainRange; + let [maxDF, maxDQ, maxDG, stepDF, stepDQ, stepDG] = ( + config.OptimizeDeltas[iteration]); + let [begin, end, step] = (dir ? + [filters.length-1, -1, -1] : [0, filters.length, 1]); + // Optimize freq, q, gain + for (let i = begin; i != end; i += step) { + let f = filters[i]; + let fr1 = apply(fr, filters.filter((f, fi) => fi !== i)); + let fr2 = apply(fr1, [f]); + let fr3 = apply(fr, filters); + let bestFilter = f; + let bestDistance = calc_distance(fr2, frTarget); + let testNewFilter = (df, dq, dg) => { + let freq = f.freq + df * freq_unit(f.freq) * stepDF; + let q = f.q + dq * stepDQ; + let gain = f.gain + dg * stepDG; + if (freq < minFreq || freq > maxFreq || q < minQ || + q > maxQ || gain < minGain || gain > maxGain) { + return false; + } + let newFilter = { type: f.type, freq, q, gain }; + let newFR = apply(fr1, [newFilter]); + let newDistance = calc_distance(newFR, frTarget); + if (newDistance < bestDistance) { + bestFilter = newFilter; + bestDistance = newDistance; + return true; + } + return false; + } + for (let df = -maxDF; df < maxDF; ++df) { + // Use smaller Q as possible + for (let dq = maxDQ-1; dq >= -maxDQ; --dq) { + for (let dg = 1; dg < maxDG; ++dg) { + if (!testNewFilter(df, dq, dg)) { + break; + } + } + for (let dg = -1; dg >= -maxDG; --dg) { + if (!testNewFilter(df, dq, dg)) { + break; + } + } + } + } + filters[i] = bestFilter; + } + if (!dir) { + return optimize(fr, frTarget, filters, iteration, 1); + } else { + filters = filters.sort((a, b) => a.freq - b.freq); + // Merge closed filters + for (let i = 0; i < filters.length-1;) { + let f1 = filters[i]; + let f2 = filters[i+1]; + if (Math.abs(f1.freq - f2.freq) <= freq_unit(f1.freq) && + Math.abs(f1.q - f2.q) <= 0.1) { + f1.gain += f2.gain; + filters.splice(i+1, 1); + } else { + ++i; + } + } + // Remove unnecessary filters + let bestDistance = calc_distance(apply(fr, filters), frTarget); + for (let i = 0; i < filters.length;) { + if (Math.abs(filters[i].gain) <= 0.1) { + filters.splice(i, 1); + continue; + } + let newDistance = calc_distance(apply(fr, + filters.filter((f, fi) => fi !== i)), frTarget); + if (newDistance < bestDistance) { + filters.splice(i, 1); + bestDistance = newDistance; + } else { + ++i; + } + } + return filters; + } + }; + + let autoeq = function (fr, frTarget, maxFilters) { + // 2 steps manual optimized algorithm + // fr, frTarget should has same resolution and normalized + maxFilters = Math.floor(Number(maxFilters)) || 1; + if (config.EqMaxBands > 0) { + maxFilters = Math.max(1, Math.min(maxFilters, config.EqMaxBands)); + } + let firstBatchSize = Math.max(Math.floor(maxFilters / 2) - 1, 1); + let firstCandidates = search_candidates(fr, frTarget, 1); + let firstFilters = (firstCandidates + // Dont adjust treble in the first batch + .filter(c => c.freq <= config.TrebleStartFrom) + // Wider bandwidth (smaller Q) come first + .sort((a, b) => a.q - b.q) + .slice(0, firstBatchSize) + .sort((a, b) => a.freq - b.freq)); + for (let i = 0; i < config.OptimizeDeltas.length; ++i) { + firstFilters = optimize(fr, frTarget, firstFilters, i); + } + let secondFR = apply(fr, firstFilters); + let secondBatchSize = maxFilters - firstFilters.length; + let secondCandidates = search_candidates(secondFR, frTarget, 0.5); + let secondFilters = (secondCandidates + .sort((a, b) => a.q - b.q) + .slice(0, secondBatchSize) + .sort((a, b) => a.freq - b.freq)); + for (let i = 0; i < config.OptimizeDeltas.length; ++i) { + secondFilters = optimize(secondFR, frTarget, secondFilters, i); + } + let allFilters = firstFilters.concat(secondFilters); + for (let i = 0; i < config.OptimizeDeltas.length; ++i) { + allFilters = optimize(fr, frTarget, allFilters, i); + } + return strip(allFilters); + }; + + return { + config, + interp, + lowshelf, + highshelf, + peaking, + calc_gains, + calc_preamp, + apply, + as_graphic_eq, + autoeq + } +})(); + + +// ============================================================ +// === src/extra/eq/state.js === +// ============================================================ +/* Shared EQ state, selection, and target synthesis helpers. + * This remains a classic script so graphtool.js can keep ordered script loading. */ +let eqPhoneSelect = null; +let eqPhoneTargetSelect = null; + +function eqCloneRawChannelsForUserTarget(rc) { + if (!rc || !Array.isArray(rc)) { + return null; + } + return rc.map((ch) => (!ch ? ch : ch.map((pt) => (pt && pt.slice) ? pt.slice() : [...pt]))); +} + +function eqFrArrayForUserMeasurementTarget(meas) { + if (!meas) { + return null; + } + let rc = meas.rawChannels; + if (rc && Array.isArray(rc) && rc.some((c) => c)) { + return rc; + } + let ch = meas.channels; + if (ch && Array.isArray(ch) && ch.some((c) => c && c.length)) { + return ch; + } + let ac = meas.activeCurves; + if (ac && ac.length) { + let hit = ac.filter((c) => c && c.l && c.l.length >= 2)[0]; + if (hit && hit.l) { + return [hit.l.map((pt) => (pt && pt.slice) ? pt.slice() : [...pt])]; + } + } + return null; +} + +function userTargetStemKey(meas) { + return `${String(meas.fileName || "")}\t${String(meas.fullName || "").trim()}`; +} + +function userTargetHashStem(s) { + let h = 0; + for (let i = 0; i < s.length; i++) { + h = Math.imul(31, h) + s.charCodeAt(i) | 0; + } + return (h >>> 0).toString(36); +} + +function allocNextUserBrandTargetNegId() { + let bt = typeof window !== "undefined" ? window.brandTarget : null; + if (!bt || !Array.isArray(bt.phoneObjs)) { + return -1; + } + let m = 0; + bt.phoneObjs.forEach((q) => { + if (q && typeof q.id === "number" && q.id < m) { + m = q.id; + } + }); + return m - 1; +} + +function eqDispNameTargetFromMeasurement(meas) { + let brand = String(meas.dispBrand || "").trim(), + tail = String(meas.dispName || meas.phone || "").trim(), + body = `${brand}${brand && tail ? " " : ""}${tail}`.trim() + || String(meas.fullName || "").replace(/\sEQ$/i, "").trim() + || "target"; + /* Manage row + CSS append " Target"; dropdown Active optgroup uses this as the label. */ + return body.replace(/^Target:\s*/i, "").trim(); +} + +function eqAllPhonesPool() { + return ((typeof window !== "undefined" && window.allPhones) + ? window.allPhones + : activePhones); +} + +function eqBrandTargetPhoneObjs() { + return ((typeof window !== "undefined" && window.brandTarget && window.brandTarget.phoneObjs) + ? window.brandTarget.phoneObjs + : []); +} + +function eqBuiltinCatalogTargetsForEqUi() { + return eqBrandTargetPhoneObjs().filter((p) => p && p.isTarget && p.fullName + && !p.fullName.match(/ EQ$/) && !isCompensationTargetNameMatch(p) && !p.userTargetFromMeasurement); +} + +function eqUserCatalogTargetsForEqUi() { + return eqBrandTargetPhoneObjs().filter((p) => p && p.fullName && p.userTargetFromMeasurement); +} + +function eqCatalogTargetsForEqUi() { + return eqUserCatalogTargetsForEqUi().concat(eqBuiltinCatalogTargetsForEqUi()); +} + +function eqFindByFullNameAny(fullName) { + if (!fullName) { + return null; + } + let hit = eqAllPhonesPool().filter((p) => p.fullName === fullName)[0]; + if (hit) { + return hit; + } + hit = eqBrandTargetPhoneObjs().filter((p) => p.fullName === fullName)[0]; + if (hit) { + return hit; + } + return activePhones.filter((p) => p.fullName === fullName)[0] || null; +} + +function eqLegacyShareUrlSegment(fullName) { + return encodeURIComponent(String(fullName || "").trim()).replace(/%20/g, "_"); +} + +function 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; +} + +function eqMeasurementObjForSelect(fullName) { + if (!fullName) { + return null; + } + let pool = eqAllPhonesPool(); + let hit = pool.filter((p) => p.fullName === fullName && !p.isTarget + && p.fullName && !p.fullName.match(/ EQ$/))[0]; + if (hit) { + return hit; + } + return activePhones.filter((p) => p.fullName === fullName && !p.isTarget + && p.fullName && !p.fullName.match(/ EQ$/))[0] || null; +} + +function eqPhoneWantsInlineFrInShareUrl(p) { + return !!(p && p.isDynamic && phoneCurveDataReadyForEq(p) + && (p.isTarget || (p.brand && p.brand.name === "Uploaded"))); +} + +function 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 addPhone = (typeof window !== "undefined" && typeof window.eqAddOrUpdatePhone === "function") + ? window.eqAddOrUpdatePhone + : null; + if (!addPhone) { + return false; + } + let phoneObj = addPhone(brandMap.Uploaded, { name: stem }, [ch]); + showPhone(phoneObj, false); + return true; +} + +function 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; +} + +function eqEnsureUserMeasurementBrandTarget(meas) { + if (!meas || meas.isTarget || (meas.fullName && String(meas.fullName).match(/ EQ$/))) { + return null; + } + let srcFr = eqFrArrayForUserMeasurementTarget(meas); + if (!srcFr) { + return null; + } + let b = typeof window !== "undefined" ? window.brandTarget : null; + if (!b || !Array.isArray(b.phoneObjs)) { + return null; + } + let stemKey = userTargetStemKey(meas); + let exists = b.phoneObjs.filter((u) => u && u.userTargetStemKey === stemKey)[0]; + if (exists) { + let cl = eqCloneRawChannelsForUserTarget(srcFr); + if (cl && cl.some((c) => c)) { + exists.rawChannels = cl; + } + exists.norm = meas.norm != null ? meas.norm : exists.norm; + exists.offset = meas.offset || exists.offset || 0; + exists.dispName = eqDispNameTargetFromMeasurement(meas); + exists.dispBrand = String(meas.dispBrand || "").trim(); + let synthTail = String(meas.dispName || meas.phone || "").trim(); + exists.phone = String(synthTail || exists.phone).slice(0, 120); + if (exists.activeCurves && exists.activeCurves[0]) { + exists.activeCurves[0].id = graphCurveLabelForPhone(exists); + } + return exists; + } + let fn = "USRMT_" + userTargetHashStem(stemKey), + synthBrand = String(meas.dispBrand || "").trim(), + synthTail = String(meas.dispName || meas.phone || "").trim(), + synthPhone = synthTail + || String(meas.fullName || meas.fileName || "UserTarget").trim().replace(/\sEQ$/i, ""), + row = { + isTarget: true, + isDynamic: true, + userTargetFromMeasurement: true, + userTargetStemKey: stemKey, + brand: b, + dispBrand: synthBrand, + dispName: eqDispNameTargetFromMeasurement(meas), + phone: String(synthPhone).slice(0, 120), + fullName: fn, + fileName: fn, + rawChannels: null, + norm: meas.norm, + offset: meas.offset || 0, + id: allocNextUserBrandTargetNegId() + }; + let clonedFr = eqCloneRawChannelsForUserTarget(srcFr); + if (!clonedFr || !clonedFr.some((c) => c)) { + return null; + } + row.rawChannels = clonedFr; + b.phoneObjs.push(row); + return row; +} + +function resolveEqModelPhone() { + let sel = eqPhoneSelect && String(eqPhoneSelect.value || "").trim(); + let intent = (typeof window !== "undefined" && window.__eqCoord.modelIntent) + ? String(window.__eqCoord.modelIntent).trim() + : ""; + let sticky = (typeof window !== "undefined" && window.__eqCoord.lastGraphModel) + ? String(window.__eqCoord.lastGraphModel).trim() + : ""; + /* Intent before sel: during 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.__eqCoord.targetIntent = tgt.fullName; + window.__eqCoord.lastGraphTarget = tgt.fullName; + window.__eqCoord.pendingTarget = (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.__eqCoord.pendingTarget = ""; + } + removeMeasurementIfSupersededByUserTarget(p); + let domVal = String(eqPhoneTargetSelect.value || "").trim(); + if (domVal !== String(tgt.fullName).trim() && typeof window.updateEQPhoneSelect === "function") { + window.updateEQPhoneSelect(); + } + })(); + }; + function updateEQPhoneSelect() { + if (!eqPhoneSelect) return; + 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.__eqCoord.modelIntent) + ? String(window.__eqCoord.modelIntent).trim() + : ""; + let lastGraph = (typeof window !== "undefined" && window.__eqCoord.lastGraphModel) + ? String(window.__eqCoord.lastGraphModel).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.__eqCoord.modelIntent = ""; + } + 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.__eqCoord.lastGraphModel = eqPhoneSelect.value + || (window.__eqCoord.pendingModel || ""); + if (!eqPhoneSelect.value && !window.__eqCoord.pendingModel) { + window.__eqCoord.modelIntent = ""; + } + } + if (typeof window.publishEqUiState === "function") { + window.publishEqUiState("updateEQPhoneSelect"); + } + }; + function eqResetParametricAfterBaseModelRemoved() { + window.__eqCoord.modelActivatedByDropdown = null; + window.__eqCoord.targetActivatedByDropdown = null; + window.__eqCoord.pendingModel = ""; + window.__eqCoord.pendingTarget = ""; + window.__eqCoord.modelStickyBypass = ""; + window.__eqCoord.modelIntent = ""; + window.__eqCoord.targetIntent = ""; + 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(); + }; +function initEqApply() { + 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