diff --git a/app/src/main/assets/autoeq_targets.json b/app/src/main/assets/autoeq_targets.json new file mode 100644 index 000000000..0614b6a9f --- /dev/null +++ b/app/src/main/assets/autoeq_targets.json @@ -0,0 +1 @@ +{"frequencies":[20.0,21.19,22.45,23.78,25.2,26.7,28.28,29.97,31.75,33.64,35.64,37.75,40.0,42.38,44.9,47.57,50.4,53.39,56.57,59.93,63.5,67.27,71.27,75.51,80.0,84.76,89.8,95.14,100.79,106.79,113.14,119.86,126.99,134.54,142.54,151.02,160.0,169.51,179.59,190.27,201.59,213.57,226.27,239.73,253.98,269.09,285.09,302.04,320.0,339.03,359.19,380.55,403.17,427.15,452.55,479.46,507.97,538.17,570.18,604.08,640.0,678.06,718.38,761.09,806.35,854.3,905.1,958.92,1015.94,1076.35,1140.35,1208.16,1280.0,1356.11,1436.75,1522.19,1612.7,1708.6,1810.19,1917.83,2031.87,2152.69,2280.7,2416.32,2560.0,2712.23,2873.5,3044.37,3225.4,3417.19,3620.39,3835.67,4063.75,4305.39,4561.4,4832.64,5120.0,5424.45,5747.01,6088.74,6450.8,6834.38,7240.77,7671.33,8127.49,8610.78,9122.8,9665.27,10240.0,10848.9,11494.01,12177.48,12901.59,13668.76,14481.55,15342.66,16254.99,17221.56,18245.61,19330.55,20480.0],"targets":[{"id":"flat","name":"Flat","family":"flat","deltaOverEar":[-3.83,-3.97,-4.01,-4.0,-3.99,-3.98,-3.95,-3.93,-3.89,-3.85,-3.79,-3.74,-3.67,-3.61,-3.51,-3.41,-3.27,-3.13,-3.0,-2.87,-2.68,-2.52,-2.34,-2.14,-1.95,-1.74,-1.52,-1.26,-0.98,-0.7,-0.4,-0.09,0.2,0.49,0.74,0.96,1.19,1.42,1.64,1.84,2.0,2.09,2.12,2.1,2.03,1.92,1.8,1.69,1.54,1.43,1.32,1.22,1.16,1.1,1.03,0.94,0.85,0.75,0.65,0.56,0.46,0.38,0.32,0.26,0.21,0.18,0.14,0.1,0.0,-0.13,-0.33,-0.6,-0.93,-1.33,-1.76,-2.2,-2.71,-3.31,-3.98,-4.69,-5.4,-6.02,-6.6,-7.11,-7.52,-7.87,-8.13,-8.34,-8.49,-8.55,-8.49,-8.31,-8.06,-7.71,-7.24,-6.73,-6.23,-5.77,-5.36,-4.96,-4.47,-3.99,-3.43,-2.81,-2.22,-1.54,-0.76,0.14,1.14,2.18,3.37,4.55,5.66,6.65,7.5,8.4,9.64,11.57,14.68,19.55,22.95],"deltaInEar":[-7.55,-7.6,-7.63,-7.65,-7.66,-7.66,-7.66,-7.66,-7.64,-7.61,-7.55,-7.46,-7.36,-7.22,-7.04,-6.83,-6.57,-6.32,-6.04,-5.79,-5.44,-5.14,-4.8,-4.44,-4.13,-3.79,-3.45,-3.09,-2.7,-2.37,-2.0,-1.62,-1.28,-0.93,-0.61,-0.33,-0.05,0.24,0.53,0.81,1.06,1.26,1.44,1.57,1.66,1.72,1.76,1.77,1.77,1.76,1.73,1.69,1.65,1.61,1.57,1.51,1.44,1.38,1.3,1.24,1.13,1.04,0.92,0.81,0.68,0.55,0.4,0.21,0.0,-0.23,-0.52,-0.87,-1.26,-1.7,-2.16,-2.62,-3.15,-3.75,-4.43,-5.18,-5.94,-6.63,-7.31,-7.93,-8.43,-8.84,-9.11,-9.28,-9.36,-9.32,-9.19,-8.99,-8.76,-8.49,-8.19,-7.86,-7.55,-7.24,-6.94,-6.6,-6.12,-5.6,-4.91,-4.11,-3.27,-2.27,-1.16,0.08,1.32,2.5,3.74,4.93,6.05,7.14,8.16,9.27,10.72,12.68,15.32,18.64,20.67]},{"id":"harman_oe_2018","name":"Harman over-ear 2018","family":"over","deltaOverEar":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"deltaInEar":[-3.73,-3.63,-3.63,-3.66,-3.68,-3.69,-3.71,-3.74,-3.75,-3.76,-3.76,-3.73,-3.69,-3.61,-3.53,-3.41,-3.3,-3.18,-3.05,-2.93,-2.75,-2.62,-2.45,-2.3,-2.18,-2.05,-1.94,-1.83,-1.73,-1.67,-1.6,-1.54,-1.49,-1.42,-1.35,-1.3,-1.25,-1.18,-1.11,-1.03,-0.94,-0.83,-0.69,-0.53,-0.37,-0.2,-0.04,0.08,0.23,0.33,0.41,0.47,0.49,0.51,0.54,0.56,0.59,0.63,0.65,0.67,0.67,0.65,0.61,0.55,0.47,0.37,0.25,0.12,0.0,-0.1,-0.19,-0.27,-0.32,-0.37,-0.4,-0.43,-0.44,-0.44,-0.45,-0.49,-0.54,-0.61,-0.72,-0.82,-0.91,-0.97,-0.98,-0.94,-0.87,-0.77,-0.7,-0.68,-0.7,-0.78,-0.94,-1.13,-1.32,-1.47,-1.57,-1.64,-1.66,-1.6,-1.48,-1.3,-1.05,-0.74,-0.39,-0.06,0.18,0.32,0.37,0.37,0.39,0.49,0.66,0.87,1.08,1.12,0.64,-0.91,-2.29]},{"id":"harman_oe_2018_nobass","name":"Harman over-ear 2018 without bass","family":"over","deltaOverEar":[-5.99,-5.99,-5.99,-5.99,-5.98,-5.98,-5.97,-5.96,-5.95,-5.94,-5.92,-5.9,-5.87,-5.84,-5.8,-5.75,-5.68,-5.61,-5.52,-5.4,-5.27,-5.1,-4.92,-4.7,-4.45,-4.18,-3.88,-3.57,-3.24,-2.9,-2.57,-2.24,-1.94,-1.66,-1.4,-1.17,-0.97,-0.8,-0.65,-0.53,-0.43,-0.34,-0.27,-0.22,-0.18,-0.14,-0.11,-0.09,-0.07,-0.06,-0.04,-0.03,-0.03,-0.02,-0.02,-0.01,-0.01,-0.01,-0.01,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"deltaInEar":[-9.72,-9.62,-9.62,-9.65,-9.66,-9.67,-9.68,-9.7,-9.7,-9.7,-9.68,-9.63,-9.56,-9.45,-9.33,-9.16,-8.98,-8.79,-8.56,-8.33,-8.02,-7.72,-7.37,-7.0,-6.63,-6.24,-5.82,-5.4,-4.96,-4.57,-4.17,-3.78,-3.43,-3.08,-2.75,-2.47,-2.22,-1.97,-1.76,-1.56,-1.37,-1.17,-0.96,-0.76,-0.54,-0.34,-0.15,-0.01,0.16,0.27,0.37,0.44,0.46,0.49,0.52,0.55,0.58,0.62,0.64,0.67,0.67,0.65,0.61,0.55,0.47,0.37,0.25,0.12,0.0,-0.1,-0.19,-0.27,-0.32,-0.37,-0.4,-0.43,-0.44,-0.44,-0.45,-0.49,-0.54,-0.61,-0.72,-0.82,-0.91,-0.97,-0.98,-0.94,-0.87,-0.77,-0.7,-0.68,-0.7,-0.78,-0.94,-1.13,-1.32,-1.47,-1.57,-1.64,-1.66,-1.6,-1.48,-1.3,-1.05,-0.74,-0.39,-0.06,0.18,0.32,0.37,0.37,0.39,0.49,0.66,0.87,1.08,1.12,0.64,-0.91,-2.29]},{"id":"harman_oe_2015","name":"Harman over-ear 2015","family":"over","deltaOverEar":[-0.58,-0.73,-0.82,-0.85,-0.84,-0.81,-0.71,-0.68,-0.58,-0.5,-0.45,-0.39,-0.32,-0.35,-0.26,-0.22,-0.15,-0.1,-0.04,0.01,-0.01,-0.09,-0.16,-0.21,-0.27,-0.38,-0.43,-0.46,-0.51,-0.56,-0.59,-0.62,-0.61,-0.55,-0.56,-0.59,-0.52,-0.47,-0.46,-0.48,-0.47,-0.46,-0.43,-0.4,-0.39,-0.41,-0.44,-0.38,-0.37,-0.32,-0.3,-0.31,-0.3,-0.35,-0.36,-0.36,-0.37,-0.39,-0.41,-0.4,-0.42,-0.42,-0.41,-0.37,-0.25,-0.11,-0.05,-0.01,0.0,0.03,-0.0,-0.03,-0.05,-0.07,-0.09,-0.1,-0.12,0.0,0.08,0.03,0.02,0.4,0.89,1.1,1.35,1.52,1.59,1.69,1.47,1.2,0.95,0.73,0.47,0.25,0.21,0.23,0.24,0.23,0.22,0.21,0.28,0.22,0.16,0.11,0.03,-0.11,-0.17,-0.09,-0.1,-0.21,-0.16,0.03,0.14,-0.03,-0.45,-0.91,-1.18,-0.98,0.39,3.68,6.26],"deltaInEar":[-4.3,-4.36,-4.45,-4.5,-4.51,-4.49,-4.42,-4.41,-4.33,-4.26,-4.2,-4.12,-4.01,-3.95,-3.79,-3.63,-3.45,-3.28,-3.09,-2.92,-2.76,-2.7,-2.62,-2.51,-2.45,-2.43,-2.36,-2.29,-2.23,-2.23,-2.19,-2.15,-2.09,-1.97,-1.91,-1.88,-1.76,-1.65,-1.57,-1.51,-1.41,-1.29,-1.12,-0.93,-0.76,-0.61,-0.48,-0.3,-0.14,0.01,0.11,0.16,0.2,0.16,0.18,0.21,0.22,0.25,0.25,0.27,0.25,0.24,0.2,0.18,0.22,0.25,0.21,0.11,0.0,-0.07,-0.19,-0.3,-0.37,-0.44,-0.49,-0.53,-0.55,-0.43,-0.37,-0.45,-0.52,-0.21,0.18,0.28,0.44,0.56,0.61,0.75,0.6,0.42,0.25,0.05,-0.23,-0.54,-0.73,-0.9,-1.08,-1.23,-1.35,-1.43,-1.37,-1.38,-1.32,-1.18,-1.02,-0.85,-0.57,-0.15,0.09,0.1,0.22,0.4,0.54,0.47,0.21,-0.04,-0.1,0.13,1.03,2.77,3.98]},{"id":"harman_oe_2013","name":"Harman over-ear 2013","family":"over","deltaOverEar":[-2.38,-2.61,-2.72,-2.75,-2.77,-2.8,-2.71,-2.68,-2.58,-2.5,-2.45,-2.39,-2.32,-2.26,-2.23,-2.17,-2.05,-1.99,-1.85,-1.79,-1.75,-1.75,-1.75,-1.71,-1.77,-1.66,-1.58,-1.52,-1.48,-1.35,-1.22,-1.15,-1.11,-1.03,-0.94,-0.89,-0.74,-0.67,-0.61,-0.55,-0.45,-0.36,-0.33,-0.35,-0.39,-0.39,-0.35,-0.38,-0.44,-0.47,-0.46,-0.41,-0.4,-0.37,-0.36,-0.36,-0.34,-0.29,-0.31,-0.3,-0.32,-0.32,-0.31,-0.27,-0.15,-0.09,-0.05,-0.01,0.0,0.03,-0.0,-0.03,-0.05,-0.03,0.01,0.01,0.07,0.12,0.12,0.11,0.15,0.28,0.53,0.63,0.8,0.88,0.85,0.8,0.48,0.07,-0.29,-0.54,-0.73,-0.95,-1.07,-1.07,-1.12,-1.17,-1.19,-1.3,-1.45,-1.45,-1.44,-1.49,-1.55,-1.62,-1.6,-1.49,-1.37,-1.31,-1.28,-1.33,-1.18,-1.28,-1.61,-2.04,-2.37,-1.93,-0.17,3.53,6.34],"deltaInEar":[-6.1,-6.24,-6.35,-6.4,-6.44,-6.48,-6.42,-6.41,-6.33,-6.26,-6.2,-6.12,-6.01,-5.87,-5.76,-5.58,-5.35,-5.17,-4.89,-4.72,-4.5,-4.37,-4.2,-4.01,-3.95,-3.72,-3.51,-3.35,-3.2,-3.02,-2.83,-2.68,-2.59,-2.45,-2.3,-2.19,-1.98,-1.85,-1.72,-1.58,-1.39,-1.19,-1.02,-0.89,-0.76,-0.58,-0.39,-0.29,-0.21,-0.14,-0.05,0.06,0.1,0.15,0.18,0.21,0.25,0.35,0.35,0.37,0.35,0.34,0.3,0.28,0.32,0.27,0.21,0.11,0.0,-0.07,-0.19,-0.3,-0.37,-0.4,-0.39,-0.42,-0.36,-0.31,-0.33,-0.37,-0.39,-0.33,-0.18,-0.19,-0.11,-0.08,-0.13,-0.14,-0.39,-0.71,-0.99,-1.21,-1.43,-1.74,-2.02,-2.2,-2.44,-2.63,-2.77,-2.93,-3.11,-3.05,-2.92,-2.78,-2.61,-2.36,-1.99,-1.55,-1.18,-1.0,-0.91,-0.96,-0.79,-0.78,-0.95,-1.17,-1.28,-0.81,0.47,2.62,4.06]},{"id":"harman_ie_2019","name":"Harman in-ear 2019","family":"in","deltaOverEar":[3.73,3.63,3.63,3.66,3.68,3.69,3.71,3.74,3.75,3.76,3.76,3.73,3.69,3.61,3.53,3.41,3.3,3.18,3.05,2.93,2.75,2.62,2.45,2.3,2.18,2.05,1.94,1.83,1.73,1.67,1.6,1.54,1.49,1.42,1.35,1.3,1.25,1.18,1.11,1.03,0.94,0.83,0.69,0.53,0.37,0.2,0.04,-0.08,-0.23,-0.33,-0.41,-0.47,-0.49,-0.51,-0.54,-0.56,-0.59,-0.63,-0.65,-0.67,-0.67,-0.65,-0.61,-0.55,-0.47,-0.37,-0.25,-0.12,0.0,0.1,0.19,0.27,0.32,0.37,0.4,0.43,0.44,0.44,0.45,0.49,0.54,0.61,0.72,0.82,0.91,0.97,0.98,0.94,0.87,0.77,0.7,0.68,0.7,0.78,0.94,1.13,1.32,1.47,1.57,1.64,1.66,1.6,1.48,1.3,1.05,0.74,0.39,0.06,-0.18,-0.32,-0.37,-0.37,-0.39,-0.49,-0.66,-0.87,-1.08,-1.12,-0.64,0.91,2.29],"deltaInEar":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]},{"id":"harman_ie_2019_nobass","name":"Harman in-ear 2019 without bass","family":"in","deltaOverEar":[-5.26,-5.36,-5.35,-5.32,-5.29,-5.27,-5.24,-5.2,-5.17,-5.14,-5.11,-5.1,-5.1,-5.13,-5.14,-5.18,-5.19,-5.18,-5.17,-5.11,-5.07,-4.97,-4.84,-4.66,-4.43,-4.15,-3.84,-3.48,-3.11,-2.69,-2.27,-1.88,-1.49,-1.14,-0.83,-0.54,-0.29,-0.09,0.07,0.18,0.25,0.28,0.24,0.18,0.08,-0.03,-0.14,-0.22,-0.34,-0.42,-0.48,-0.52,-0.54,-0.55,-0.57,-0.58,-0.61,-0.64,-0.66,-0.68,-0.68,-0.65,-0.61,-0.55,-0.47,-0.37,-0.25,-0.12,0.0,0.1,0.19,0.27,0.32,0.37,0.4,0.43,0.44,0.44,0.45,0.49,0.54,0.61,0.72,0.82,0.91,0.97,0.98,0.94,0.87,0.77,0.7,0.68,0.7,0.78,0.94,1.13,1.32,1.47,1.57,1.64,1.66,1.6,1.48,1.3,1.05,0.74,0.39,0.06,-0.18,-0.32,-0.37,-0.37,-0.39,-0.49,-0.66,-0.87,-1.08,-1.12,-0.64,0.91,2.29],"deltaInEar":[-8.99,-8.99,-8.98,-8.98,-8.97,-8.96,-8.95,-8.93,-8.92,-8.9,-8.87,-8.83,-8.79,-8.74,-8.67,-8.59,-8.49,-8.37,-8.22,-8.04,-7.83,-7.58,-7.29,-6.97,-6.61,-6.2,-5.77,-5.31,-4.84,-4.36,-3.88,-3.41,-2.98,-2.56,-2.18,-1.84,-1.53,-1.27,-1.04,-0.85,-0.69,-0.56,-0.45,-0.36,-0.29,-0.23,-0.18,-0.14,-0.11,-0.09,-0.07,-0.06,-0.04,-0.03,-0.03,-0.02,-0.02,-0.01,-0.01,-0.01,-0.01,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0]},{"id":"harman_ie_2017_1","name":"Harman in-ear 2017-1","family":"in","deltaOverEar":[4.02,3.88,3.9,3.95,3.96,3.97,3.99,3.95,3.96,3.95,3.92,3.9,3.88,3.71,3.62,3.5,3.41,3.29,3.18,2.98,2.84,2.71,2.59,2.39,2.25,2.12,2.01,1.94,1.89,1.84,1.73,1.68,1.64,1.61,1.53,1.42,1.32,1.21,1.1,0.99,0.91,0.76,0.61,0.43,0.27,0.17,0.05,-0.06,-0.07,-0.07,-0.13,-0.23,-0.3,-0.35,-0.36,-0.41,-0.47,-0.49,-0.51,-0.5,-0.59,-0.58,-0.48,-0.37,-0.25,-0.19,-0.08,-0.01,0.0,0.03,0.0,0.12,0.15,0.17,0.27,0.41,0.47,0.45,0.48,0.51,0.62,0.99,1.32,1.57,1.8,1.92,1.96,2.0,1.77,1.5,1.25,1.06,0.9,0.85,0.9,1.05,1.32,1.64,1.97,2.28,2.69,2.97,3.28,3.71,4.06,3.3,-0.75,-2.51,-2.61,-2.11,-1.39,-0.3,0.66,-7.83,-16.54,-18.49,-17.53,-17.54,-13.13,-7.5,-4.1],"deltaInEar":[0.3,0.26,0.28,0.3,0.29,0.29,0.28,0.21,0.21,0.19,0.17,0.18,0.19,0.1,0.09,0.09,0.11,0.11,0.13,0.06,0.09,0.1,0.14,0.08,0.07,0.07,0.08,0.11,0.17,0.17,0.13,0.15,0.16,0.19,0.17,0.12,0.08,0.03,-0.01,-0.04,-0.03,-0.07,-0.08,-0.1,-0.09,-0.03,0.01,0.02,0.16,0.26,0.27,0.24,0.2,0.16,0.18,0.15,0.12,0.15,0.15,0.17,0.08,0.08,0.13,0.17,0.22,0.17,0.17,0.11,0.0,-0.07,-0.19,-0.14,-0.17,-0.2,-0.13,-0.02,0.04,0.02,0.03,0.03,0.08,0.38,0.61,0.75,0.89,0.96,0.99,1.06,0.9,0.72,0.55,0.39,0.21,0.07,-0.05,-0.08,0.0,0.17,0.39,0.65,1.04,1.37,1.8,2.41,3.01,2.56,-1.14,-2.57,-2.43,-1.79,-1.02,0.08,1.05,-7.34,-15.88,-17.62,-16.45,-16.43,-12.5,-8.41,-6.38]},{"id":"harman_ie_2017_2","name":"Harman in-ear 2017-2","family":"in","deltaOverEar":[5.34,5.19,5.16,5.17,5.18,5.19,5.22,5.24,5.27,5.31,5.37,5.43,5.5,5.48,5.56,5.57,5.55,5.53,5.58,5.49,5.37,5.15,4.91,4.7,4.31,4.0,3.68,3.4,3.13,2.67,2.3,1.95,1.63,1.37,1.12,0.84,0.58,0.43,0.28,0.12,0.04,-0.02,-0.09,-0.25,-0.36,-0.37,-0.32,-0.35,-0.34,-0.29,-0.27,-0.28,-0.27,-0.25,-0.23,-0.24,-0.21,-0.19,-0.28,-0.28,-0.3,-0.25,-0.18,-0.14,-0.03,0.03,0.08,0.06,0.0,-0.05,-0.16,-0.28,-0.39,-0.49,-0.58,-0.68,-0.79,-0.82,-0.86,-0.82,-0.71,-0.47,0.01,0.5,0.98,1.36,1.63,1.83,1.76,1.65,1.53,1.41,1.34,1.37,1.5,1.67,1.99,2.4,2.82,3.3,3.72,4.11,4.63,5.25,5.75,5.56,1.73,-0.22,-0.48,0.01,0.73,1.75,2.97,-3.07,-11.34,-15.91,-14.99,-13.07,-9.95,-5.08,-1.68],"deltaInEar":[1.61,1.57,1.53,1.51,1.5,1.5,1.5,1.5,1.53,1.56,1.62,1.7,1.81,1.87,2.02,2.16,2.25,2.35,2.53,2.56,2.61,2.54,2.46,2.39,2.13,1.95,1.75,1.57,1.41,1.0,0.7,0.42,0.14,-0.05,-0.23,-0.45,-0.66,-0.74,-0.83,-0.92,-0.9,-0.86,-0.78,-0.79,-0.73,-0.57,-0.36,-0.27,-0.12,0.04,0.13,0.19,0.22,0.26,0.3,0.33,0.38,0.45,0.37,0.39,0.37,0.41,0.42,0.4,0.44,0.4,0.33,0.18,0.0,-0.15,-0.35,-0.55,-0.71,-0.86,-0.98,-1.11,-1.22,-1.26,-1.31,-1.31,-1.25,-1.08,-0.7,-0.32,0.07,0.39,0.65,0.88,0.89,0.88,0.83,0.74,0.64,0.58,0.55,0.54,0.67,0.93,1.25,1.66,2.06,2.51,3.15,3.95,4.7,4.82,1.33,-0.28,-0.3,0.33,1.11,2.12,3.36,-2.58,-10.68,-15.04,-13.91,-11.95,-9.32,-5.99,-3.97]},{"id":"harman_ie_2016","name":"Harman in-ear 2016","family":"in","deltaOverEar":[3.02,2.95,2.94,3.0,3.09,3.16,3.21,3.3,3.36,3.4,3.45,3.51,3.58,3.64,3.74,3.83,3.95,3.99,3.97,3.93,3.95,3.95,3.94,3.89,3.83,3.72,3.56,3.38,3.16,2.94,2.66,2.39,2.1,1.81,1.49,1.22,0.96,0.87,0.8,0.66,0.52,0.45,0.31,0.13,-0.03,-0.13,-0.25,-0.36,-0.44,-0.47,-0.49,-0.51,-0.5,-0.47,-0.46,-0.46,-0.47,-0.47,-0.4,-0.4,-0.42,-0.42,-0.41,-0.37,-0.25,-0.19,-0.15,-0.07,0.0,0.03,0.0,-0.1,-0.15,-0.13,-0.09,-0.09,-0.03,0.02,0.02,0.01,0.05,0.18,0.43,0.53,0.7,0.78,0.75,0.7,0.38,-0.03,-0.39,-0.64,-0.83,-1.05,-1.17,-1.17,-1.22,-1.27,-1.29,-1.4,-1.55,-1.55,-1.54,-1.59,-1.65,-1.72,-1.69,-1.59,-1.47,-1.4,-1.38,-1.43,-1.28,-1.38,-1.71,-2.14,-2.47,-2.03,-0.27,3.43,6.24],"deltaInEar":[-0.7,-0.67,-0.68,-0.65,-0.58,-0.52,-0.5,-0.44,-0.39,-0.36,-0.3,-0.22,-0.11,0.03,0.21,0.42,0.65,0.81,0.92,1.01,1.2,1.33,1.48,1.59,1.65,1.67,1.63,1.55,1.44,1.27,1.05,0.86,0.62,0.39,0.14,-0.08,-0.28,-0.3,-0.31,-0.37,-0.42,-0.38,-0.38,-0.4,-0.39,-0.33,-0.29,-0.28,-0.21,-0.14,-0.08,-0.04,-0.0,0.05,0.08,0.11,0.12,0.17,0.25,0.27,0.25,0.24,0.2,0.18,0.22,0.18,0.11,0.05,0.0,-0.07,-0.19,-0.37,-0.47,-0.5,-0.49,-0.52,-0.46,-0.41,-0.43,-0.47,-0.49,-0.43,-0.28,-0.29,-0.21,-0.18,-0.22,-0.24,-0.49,-0.81,-1.09,-1.31,-1.53,-1.84,-2.12,-2.3,-2.54,-2.73,-2.87,-3.03,-3.21,-3.15,-3.02,-2.88,-2.71,-2.46,-2.09,-1.65,-1.28,-1.09,-1.01,-1.06,-0.89,-0.88,-1.05,-1.27,-1.38,-0.91,0.37,2.52,3.96]},{"id":"oratory1990_oe","name":"oratory1990 optimum hifi over-ear","family":"over","deltaOverEar":[-5.51,-5.61,-5.56,-5.5,-5.43,-5.35,-5.28,-5.23,-5.22,-5.2,-5.18,-5.14,-5.08,-5.06,-5.0,-4.95,-4.88,-4.81,-4.72,-4.6,-4.46,-4.29,-4.12,-3.9,-3.66,-3.39,-3.13,-2.84,-2.5,-2.18,-1.86,-1.54,-1.26,-0.96,-0.71,-0.49,-0.28,-0.08,0.07,0.17,0.26,0.34,0.35,0.34,0.32,0.29,0.26,0.24,0.17,0.15,0.14,0.13,0.11,0.12,0.11,0.11,0.11,0.1,0.1,0.1,0.09,0.07,0.07,0.11,0.09,0.09,0.07,0.02,0.0,0.0,0.01,0.01,0.04,0.03,0.03,0.03,0.04,0.02,0.01,0.0,-0.0,-0.01,0.01,0.0,0.0,-0.01,-0.03,-0.06,-0.11,-0.15,-0.19,-0.25,-0.3,-0.36,-0.41,-0.45,-0.51,-0.56,-0.62,-0.68,-0.74,-0.8,-0.87,-0.93,-1.01,-1.09,-1.17,-1.13,-1.0,-0.83,-0.5,-0.19,0.05,0.18,0.16,0.19,0.56,1.62,3.87,7.88,10.8],"deltaInEar":[-9.24,-9.24,-9.19,-9.15,-9.1,-9.03,-9.0,-8.97,-8.96,-8.95,-8.93,-8.87,-8.77,-8.66,-8.53,-8.36,-8.18,-7.99,-7.76,-7.53,-7.21,-6.91,-6.57,-6.2,-5.84,-5.44,-5.07,-4.67,-4.22,-3.85,-3.47,-3.08,-2.75,-2.38,-2.06,-1.79,-1.53,-1.25,-1.04,-0.86,-0.68,-0.49,-0.34,-0.19,-0.05,0.09,0.22,0.32,0.4,0.47,0.54,0.6,0.61,0.63,0.64,0.67,0.69,0.73,0.75,0.77,0.76,0.72,0.67,0.66,0.56,0.46,0.32,0.14,0.0,-0.1,-0.18,-0.26,-0.29,-0.34,-0.37,-0.4,-0.4,-0.42,-0.44,-0.49,-0.54,-0.62,-0.71,-0.82,-0.91,-0.98,-1.01,-1.0,-0.98,-0.92,-0.9,-0.93,-1.0,-1.14,-1.35,-1.58,-1.83,-2.03,-2.19,-2.31,-2.4,-2.41,-2.35,-2.23,-2.06,-1.82,-1.56,-1.19,-0.82,-0.51,-0.13,0.19,0.44,0.67,0.82,1.06,1.65,2.74,4.5,6.97,8.51]},{"id":"oratory1990_ie","name":"oratory1990 in-ear","family":"in","deltaOverEar":[4.5,4.36,4.33,4.34,4.28,4.25,4.27,4.29,4.32,4.32,4.3,4.36,4.43,4.48,4.56,4.58,4.65,4.69,4.76,4.82,4.9,4.97,5.0,5.03,5.04,5.09,5.11,5.13,5.14,5.14,5.15,5.15,5.12,5.04,4.83,4.73,4.61,4.45,4.34,4.2,4.0,3.77,3.46,3.15,2.84,2.51,2.16,1.87,1.57,1.34,1.13,0.95,0.84,0.77,0.7,0.61,0.49,0.36,0.3,0.23,0.13,0.08,0.03,-0.02,-0.03,-0.01,0.13,0.03,0.0,-0.02,-0.16,-0.36,-0.59,-0.8,-0.88,-0.91,-0.86,-0.9,-0.97,-1.04,-1.08,-1.04,-1.05,-0.93,-0.83,-0.79,-0.74,-0.75,-0.8,-0.84,-0.88,-0.8,-0.75,-0.58,-0.31,0.02,0.32,0.59,0.81,1.01,1.32,1.51,1.53,1.42,1.02,0.94,0.52,0.4,0.3,0.26,0.47,0.54,0.43,0.46,0.22,0.07,0.23,0.88,2.45,5.3,7.43],"deltaInEar":[0.77,0.74,0.7,0.68,0.6,0.56,0.55,0.55,0.58,0.56,0.55,0.63,0.74,0.88,1.03,1.17,1.35,1.51,1.72,1.89,2.15,2.35,2.55,2.73,2.86,3.04,3.17,3.3,3.41,3.47,3.55,3.61,3.63,3.62,3.48,3.43,3.37,3.27,3.23,3.17,3.06,2.93,2.77,2.62,2.48,2.31,2.11,1.95,1.8,1.67,1.54,1.41,1.34,1.29,1.24,1.17,1.07,1.0,0.95,0.9,0.8,0.73,0.64,0.53,0.44,0.36,0.39,0.15,0.0,-0.12,-0.35,-0.62,-0.91,-1.17,-1.28,-1.34,-1.3,-1.33,-1.42,-1.53,-1.62,-1.65,-1.77,-1.75,-1.74,-1.75,-1.71,-1.7,-1.67,-1.61,-1.58,-1.47,-1.44,-1.36,-1.25,-1.11,-1.0,-0.87,-0.77,-0.62,-0.34,-0.1,0.05,0.13,-0.03,0.2,0.13,0.34,0.48,0.58,0.84,0.91,0.82,0.95,0.88,0.94,1.32,1.99,3.08,4.39,5.14]},{"id":"autoeq_ie","name":"AutoEq in-ear","family":"in","deltaOverEar":[-5.48,-5.63,-5.66,-5.65,-5.64,-5.63,-5.6,-5.58,-5.54,-5.5,-5.45,-5.39,-5.32,-5.26,-5.16,-5.07,-4.92,-4.79,-4.65,-4.52,-4.33,-4.17,-4.0,-3.79,-3.6,-3.39,-3.17,-2.91,-2.63,-2.35,-2.05,-1.74,-1.45,-1.16,-0.91,-0.69,-0.46,-0.25,-0.02,0.17,0.34,0.43,0.46,0.43,0.35,0.25,0.14,0.06,-0.06,-0.14,-0.2,-0.25,-0.27,-0.29,-0.3,-0.34,-0.36,-0.42,-0.44,-0.47,-0.48,-0.48,-0.45,-0.41,-0.35,-0.27,-0.18,-0.08,0.0,0.06,0.11,0.14,0.14,0.13,0.1,0.05,-0.01,-0.1,-0.17,-0.23,-0.27,-0.31,-0.31,-0.31,-0.33,-0.37,-0.45,-0.57,-0.71,-0.85,-0.95,-0.98,-0.94,-0.81,-0.59,-0.32,-0.04,0.22,0.44,0.62,0.76,0.83,0.82,0.74,0.6,0.38,0.13,-0.12,-0.3,-0.36,-0.35,-0.3,-0.27,-0.33,-0.45,-0.63,-0.81,-0.82,-0.32,1.24,2.62],"deltaInEar":[-9.2,-9.25,-9.28,-9.3,-9.31,-9.31,-9.31,-9.31,-9.29,-9.26,-9.2,-9.12,-9.01,-8.87,-8.69,-8.48,-8.22,-7.97,-7.69,-7.44,-7.09,-6.79,-6.45,-6.1,-5.78,-5.44,-5.11,-4.74,-4.36,-4.02,-3.65,-3.28,-2.94,-2.58,-2.26,-1.99,-1.7,-1.42,-1.13,-0.86,-0.6,-0.4,-0.23,-0.11,-0.01,0.05,0.1,0.14,0.17,0.19,0.21,0.22,0.23,0.23,0.24,0.23,0.23,0.22,0.22,0.21,0.19,0.18,0.16,0.14,0.12,0.09,0.07,0.04,0.0,-0.04,-0.08,-0.12,-0.18,-0.24,-0.3,-0.38,-0.45,-0.53,-0.62,-0.71,-0.81,-0.92,-1.02,-1.13,-1.24,-1.34,-1.43,-1.51,-1.58,-1.62,-1.65,-1.65,-1.63,-1.59,-1.53,-1.45,-1.36,-1.25,-1.13,-1.02,-0.89,-0.78,-0.66,-0.55,-0.45,-0.36,-0.27,-0.18,-0.11,-0.05,0.02,0.08,0.13,0.17,0.21,0.24,0.27,0.3,0.32,0.33,0.34]},{"id":"df_iso","name":"Diffuse field ISO 11904-1","family":"over","deltaOverEar":[-8.02,-8.17,-8.2,-8.19,-8.18,-8.17,-8.14,-8.12,-8.09,-8.05,-7.99,-7.93,-7.86,-7.81,-7.7,-7.61,-7.46,-7.33,-7.19,-7.06,-6.88,-6.71,-6.54,-6.33,-6.15,-5.94,-5.71,-5.45,-5.17,-4.84,-4.48,-4.12,-3.78,-3.45,-3.15,-2.88,-2.6,-2.33,-2.06,-1.8,-1.59,-1.44,-1.36,-1.34,-1.35,-1.38,-1.42,-1.47,-1.53,-1.55,-1.56,-1.56,-1.52,-1.42,-1.34,-1.27,-1.2,-1.13,-1.06,-0.96,-0.91,-0.86,-0.81,-0.74,-0.66,-0.48,-0.32,-0.15,0.0,0.23,0.39,0.48,0.58,0.7,0.79,0.86,0.91,1.16,1.34,1.49,1.71,2.2,2.73,3.33,3.62,3.37,3.21,3.11,2.73,2.0,1.38,0.88,0.48,0.23,0.1,0.02,0.06,0.21,0.32,0.42,0.74,1.24,1.82,2.47,2.89,2.88,2.95,3.16,3.42,3.69,4.1,4.51,4.86,5.13,5.26,5.43,5.89,6.9,9.1,13.06,15.95],"deltaInEar":[-11.74,-11.79,-11.82,-11.84,-11.85,-11.85,-11.85,-11.85,-11.83,-11.8,-11.74,-11.66,-11.55,-11.41,-11.24,-11.02,-10.76,-10.51,-10.24,-9.99,-9.63,-9.33,-8.99,-8.64,-8.33,-7.99,-7.65,-7.28,-6.89,-6.51,-6.09,-5.66,-5.27,-4.87,-4.5,-4.18,-3.85,-3.51,-3.17,-2.83,-2.53,-2.27,-2.05,-1.87,-1.72,-1.58,-1.46,-1.39,-1.3,-1.22,-1.15,-1.09,-1.03,-0.91,-0.8,-0.7,-0.61,-0.5,-0.4,-0.29,-0.23,-0.2,-0.2,-0.19,-0.19,-0.11,-0.06,-0.04,0.0,0.13,0.21,0.22,0.25,0.32,0.39,0.44,0.47,0.72,0.89,1.0,1.17,1.59,2.02,2.51,2.71,2.41,2.23,2.16,1.86,1.23,0.68,0.2,-0.22,-0.55,-0.84,-1.11,-1.26,-1.25,-1.25,-1.22,-0.91,-0.36,0.34,1.17,1.84,2.14,2.56,3.1,3.6,4.01,4.48,4.88,5.26,5.62,5.92,6.3,6.97,8.02,9.73,12.15,13.67]},{"id":"df_gras_kemar","name":"Diffuse field GRAS KEMAR","family":"over","deltaOverEar":[-7.74,-7.89,-7.92,-7.91,-7.9,-7.89,-7.87,-7.84,-7.81,-7.77,-7.71,-7.66,-7.59,-7.53,-7.43,-7.33,-7.19,-7.05,-6.92,-6.79,-6.6,-6.44,-6.26,-6.06,-5.87,-5.66,-5.4,-5.11,-4.88,-4.69,-4.54,-4.18,-3.68,-3.23,-3.04,-3.02,-2.91,-2.67,-2.34,-2.05,-1.89,-1.93,-1.93,-1.76,-1.59,-1.62,-1.73,-1.78,-1.76,-1.66,-1.58,-1.6,-1.58,-1.44,-1.29,-1.1,-0.89,-0.8,-0.72,-0.62,-0.57,-0.5,-0.37,-0.19,-0.1,-0.17,-0.25,-0.16,0.0,0.16,0.32,0.44,0.43,0.4,0.46,0.77,1.31,1.85,2.31,2.54,2.64,2.97,3.3,3.48,3.7,3.61,3.24,2.79,2.38,2.05,1.73,1.4,1.07,0.76,0.45,0.39,0.46,0.43,0.33,0.33,0.37,0.08,0.08,1.24,3.5,4.42,3.94,3.88,3.56,3.3,4.74,6.24,6.42,6.18,6.1,6.21,6.7,7.87,10.23,14.52,17.72],"deltaInEar":[-11.47,-11.52,-11.55,-11.57,-11.58,-11.58,-11.58,-11.58,-11.56,-11.53,-11.47,-11.38,-11.28,-11.14,-10.96,-10.75,-10.49,-10.24,-9.96,-9.71,-9.35,-9.06,-8.72,-8.36,-8.05,-7.71,-7.33,-6.94,-6.6,-6.36,-6.14,-5.71,-5.16,-4.65,-4.39,-4.32,-4.15,-3.84,-3.45,-3.08,-2.84,-2.76,-2.62,-2.29,-1.96,-1.82,-1.77,-1.7,-1.53,-1.33,-1.18,-1.14,-1.08,-0.93,-0.75,-0.54,-0.31,-0.16,-0.07,0.05,0.1,0.15,0.24,0.36,0.36,0.2,0.01,-0.04,0.0,0.06,0.13,0.18,0.1,0.03,0.06,0.34,0.88,1.42,1.86,2.06,2.1,2.36,2.58,2.66,2.79,2.65,2.26,1.85,1.51,1.27,1.03,0.73,0.38,-0.02,-0.49,-0.73,-0.86,-1.03,-1.25,-1.31,-1.29,-1.52,-1.4,-0.06,2.45,3.68,3.54,3.82,3.75,3.62,5.11,6.62,6.81,6.67,6.76,7.09,7.78,8.99,10.87,13.61,15.43]}]} \ No newline at end of file diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index 3873f4b57..843a4640c 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -48,6 +48,8 @@ add_library( DirectOutputBridge.cpp DirectOutputStream.cpp DspProcessor.cpp + ParametricEq.cpp + ParametricEqBridge.cpp NativeBridge.cpp SecretsBridge.cpp ) diff --git a/app/src/main/cpp/DspProcessor.cpp b/app/src/main/cpp/DspProcessor.cpp index b28236bcc..b8efa482b 100644 --- a/app/src/main/cpp/DspProcessor.cpp +++ b/app/src/main/cpp/DspProcessor.cpp @@ -195,6 +195,7 @@ void DspProcessor::configure(double sampleRate) noexcept { appliedEqualizerRevision_ = targetEqualizerRevision_.load(std::memory_order_acquire); limiterGain_ = 1.0F; clarityChainActive_ = currentWet_ > 0.0F; + parametricEq_.configure(sampleRate_, 2); reset(); } @@ -209,6 +210,7 @@ void DspProcessor::reset() noexcept { airExciterFilter_.clear(); crossfeed_.clear(); for (auto& band : equalizerBands_) band.clear(); + parametricEq_.reset(); limiterGain_ = 1.0F; microFadePosition_ = 0; dcXPrev_.fill(0.0); @@ -350,7 +352,20 @@ void DspProcessor::process( std::int32_t frameCount, std::int32_t channelCount) noexcept { if (samples == nullptr || frameCount <= 0 || (channelCount != 1 && channelCount != 2)) return; - if (bitPerfectEnabled_.load(std::memory_order_acquire)) return; + const bool parametricApplied = parametricEq_.process(samples, frameCount, channelCount); + const auto clampParametricOutput = [&]() noexcept { + if (!parametricApplied) return; + const std::size_t count = static_cast(frameCount) * + static_cast(channelCount); + for (std::size_t i = 0; i < count; ++i) { + const float value = samples[i]; + samples[i] = std::isfinite(value) ? std::clamp(value, -1.0F, 1.0F) : 0.0F; + } + }; + if (bitPerfectEnabled_.load(std::memory_order_acquire)) { + clampParametricOutput(); + return; + } // Atmos-aware bypass forces the clarity chain off independent of the // on/off toggle, keeping multichannel/spatial content untouched while // preserving the toggle state for stereo afterwards. @@ -395,7 +410,10 @@ void DspProcessor::process( std::abs(limiterGain_ - 1.0F) < 0.00001F; // With every enhancement disabled, decoded PCM stays transparent. This // avoids the old unconditional -1 dB attenuation and limiter/clamp pass. - if (controlsBypassed && stateBypassed) return; + if (controlsBypassed && stateBypassed) { + clampParametricOutput(); + return; + } for (std::int32_t frame = 0; frame < frameCount; ++frame) { if (currentWet_ < target) { diff --git a/app/src/main/cpp/DspProcessor.h b/app/src/main/cpp/DspProcessor.h index 727f989b9..49b2597e9 100644 --- a/app/src/main/cpp/DspProcessor.h +++ b/app/src/main/cpp/DspProcessor.h @@ -7,6 +7,8 @@ #include #include +#include "ParametricEq.h" + namespace lastwave::audio { class DspProcessor final { @@ -212,6 +214,10 @@ class DspProcessor final { Biquad monoBassFilter_{}; Biquad airExciterFilter_{}; Crossfeed crossfeed_{}; + // AutoEQ / manual parametric EQ. Runs first in the chain and, unlike + // the rest of the DSP, also while Bit-Perfect is on: the user asked for + // the correction, so it is applied and the verdict reports "Direct + EQ". + ParametricEq parametricEq_{}; // Rebuilds one trimmed stage from its design spec plus the smoothed // trim value. Peaking/shelf stages keep filter state (coefficients diff --git a/app/src/main/cpp/ParametricEq.cpp b/app/src/main/cpp/ParametricEq.cpp new file mode 100644 index 000000000..957eb5f8e --- /dev/null +++ b/app/src/main/cpp/ParametricEq.cpp @@ -0,0 +1,359 @@ +#include "ParametricEq.h" + +#include +#include +#include +#include + +namespace lastwave::audio { +namespace { + +constexpr double kPi = 3.1415926535897932384626433832795; + +// Process-wide published configuration. Written under the mutex by the +// control thread; read lock-free by audio threads, which re-read when the +// revision moves (a torn read is corrected by the next revision bump). +struct SharedConfig final { + std::atomic revision{0}; + std::atomic enabled{false}; + std::atomic preampDb{0.0F}; + std::atomic count{0}; + std::array, ParametricEq::kMaxFilters> types{}; + std::array, ParametricEq::kMaxFilters> frequencies{}; + std::array, ParametricEq::kMaxFilters> gains{}; + std::array, ParametricEq::kMaxFilters> qs{}; + std::array, ParametricEq::kMaxFilters> filterOn{}; +}; + +SharedConfig& shared() { + static SharedConfig config; + return config; +} + +std::mutex& publishMutex() { + static std::mutex mutex; + return mutex; +} + +double clampFrequency(double sampleRate, double frequency) noexcept { + return std::clamp(frequency, 10.0, sampleRate * 0.49); +} + +float sanitize(float value, float low, float high, float fallback) noexcept { + if (!std::isfinite(value)) return fallback; + return std::clamp(value, low, high); +} + +inline std::int32_t readInt24(const std::uint8_t* p) noexcept { + std::int32_t value = static_cast(p[0]) | + (static_cast(p[1]) << 8) | + (static_cast(static_cast(p[2])) << 16); + return value; +} + +inline void writeInt24(std::uint8_t* p, std::int32_t value) noexcept { + p[0] = static_cast(value & 0xFF); + p[1] = static_cast((value >> 8) & 0xFF); + p[2] = static_cast((value >> 16) & 0xFF); +} + +} // namespace + +void ParametricEq::publish( + bool enabled, + float preampDb, + const std::int32_t* types, + const float* frequenciesHz, + const float* gainsDb, + const float* qs, + const std::uint8_t* filterEnabled, + std::size_t count) noexcept { + std::lock_guard lock(publishMutex()); + auto& config = shared(); + const std::size_t safeCount = (types == nullptr || frequenciesHz == nullptr || + gainsDb == nullptr || qs == nullptr) ? 0U : std::min(count, kMaxFilters); + for (std::size_t i = 0; i < safeCount; ++i) { + const std::int32_t type = (types[i] >= kPeaking && types[i] <= kHighPass) ? types[i] : kPeaking; + config.types[i].store(type, std::memory_order_relaxed); + config.frequencies[i].store(sanitize(frequenciesHz[i], 10.0F, 40000.0F, 1000.0F), std::memory_order_relaxed); + config.gains[i].store(sanitize(gainsDb[i], -30.0F, 30.0F, 0.0F), std::memory_order_relaxed); + config.qs[i].store(sanitize(qs[i], 0.05F, 30.0F, 0.707F), std::memory_order_relaxed); + config.filterOn[i].store(filterEnabled == nullptr || filterEnabled[i] != 0U, std::memory_order_relaxed); + } + config.count.store(static_cast(safeCount), std::memory_order_relaxed); + config.preampDb.store(sanitize(preampDb, -40.0F, 20.0F, 0.0F), std::memory_order_relaxed); + config.enabled.store(enabled, std::memory_order_relaxed); + config.revision.fetch_add(1, std::memory_order_release); +} + +bool ParametricEq::isEngaged() noexcept { + auto& config = shared(); + (void) config.revision.load(std::memory_order_acquire); + if (!config.enabled.load(std::memory_order_relaxed)) return false; + if (std::abs(config.preampDb.load(std::memory_order_relaxed)) >= 0.001F) return true; + const auto count = static_cast(config.count.load(std::memory_order_relaxed)); + for (std::size_t i = 0; i < count && i < kMaxFilters; ++i) { + if (!config.filterOn[i].load(std::memory_order_relaxed)) continue; + const auto type = config.types[i].load(std::memory_order_relaxed); + if (type == kLowPass || type == kHighPass) return true; + if (std::abs(config.gains[i].load(std::memory_order_relaxed)) >= 0.001F) return true; + } + return false; +} + +void ParametricEq::configure(double sampleRate, std::int32_t channels) noexcept { + sampleRate_ = std::max(sampleRate, 8000.0); + channels_ = std::clamp(channels, 1, static_cast(kMaxChannels)); + appliedRevision_ = 0xFFFFFFFFU; + reset(); + syncConfiguration(); +} + +void ParametricEq::reset() noexcept { + for (auto& state : z1_) state.fill(0.0); + for (auto& state : z2_) state.fill(0.0); +} + +void ParametricEq::syncConfiguration() noexcept { + auto& config = shared(); + const std::uint32_t revision = config.revision.load(std::memory_order_acquire); + if (revision == appliedRevision_ && appliedSampleRate_ == sampleRate_) return; + appliedRevision_ = revision; + appliedSampleRate_ = sampleRate_; + enabled_ = config.enabled.load(std::memory_order_relaxed); + const float preampDb = config.preampDb.load(std::memory_order_relaxed); + preampGain_ = std::pow(10.0, static_cast(preampDb) / 20.0); + const auto count = std::min( + static_cast(std::max(0, config.count.load(std::memory_order_relaxed))), + kMaxFilters); + std::size_t active = 0; + for (std::size_t i = 0; i < count; ++i) { + if (!config.filterOn[i].load(std::memory_order_relaxed)) continue; + Spec spec; + spec.type = config.types[i].load(std::memory_order_relaxed); + spec.frequency = config.frequencies[i].load(std::memory_order_relaxed); + spec.gainDb = config.gains[i].load(std::memory_order_relaxed); + spec.q = config.qs[i].load(std::memory_order_relaxed); + const bool passFilter = spec.type == kLowPass || spec.type == kHighPass; + if (!passFilter && std::abs(spec.gainDb) < 0.001) continue; + // A filter above Nyquist at this rate would alias: skip it. + if (spec.frequency >= sampleRate_ * 0.49) continue; + const Coefficients next = design(spec, sampleRate_); + // Slots are compacted; keep a slot's state only while it still runs + // the same configured filter, so editing one gain never resets the + // others (no click), while a toggled filter starts from silence. + if (slotSource_[active] != static_cast(i)) { + z1_[active].fill(0.0); + z2_[active].fill(0.0); + slotSource_[active] = static_cast(i); + } + coefficients_[active++] = next; + } + for (std::size_t i = active; i < kMaxFilters; ++i) { + z1_[i].fill(0.0); + z2_[i].fill(0.0); + slotSource_[i] = -1; + } + activeCount_ = active; + if (!enabled_) activeCount_ = 0; +} + +bool ParametricEq::process(float* samples, std::int32_t frameCount, std::int32_t channelCount) noexcept { + if (samples == nullptr || frameCount <= 0 || channelCount < 1 || + channelCount > static_cast(kMaxChannels)) { + return false; + } + syncConfiguration(); + if (!enabled_ || (activeCount_ == 0 && std::abs(preampGain_ - 1.0) < 1.0e-6)) return false; + const auto channels = static_cast(channelCount); + const auto frames = static_cast(frameCount); + for (std::size_t frame = 0; frame < frames; ++frame) { + float* sample = samples + frame * channels; + for (std::size_t ch = 0; ch < channels; ++ch) { + const float out = tick(sample[ch], ch); + sample[ch] = std::isfinite(out) ? out : 0.0F; + } + } + // Denormal / blow-up guard once per block instead of per sample. + for (std::size_t i = 0; i < activeCount_; ++i) { + for (std::size_t ch = 0; ch < channels; ++ch) { + if (!std::isfinite(z1_[i][ch]) || !std::isfinite(z2_[i][ch])) { + z1_[i][ch] = 0.0; + z2_[i][ch] = 0.0; + } else { + if (std::abs(z1_[i][ch]) < 1.0e-20) z1_[i][ch] = 0.0; + if (std::abs(z2_[i][ch]) < 1.0e-20) z2_[i][ch] = 0.0; + } + } + } + return true; +} + +bool ParametricEq::processPcm(std::uint8_t* data, std::size_t byteCount, std::int32_t encoding) noexcept { + if (data == nullptr || byteCount == 0) return false; + syncConfiguration(); + if (!enabled_ || (activeCount_ == 0 && std::abs(preampGain_ - 1.0) < 1.0e-6)) return false; + const auto channels = static_cast(channels_); + std::size_t bytesPerSample = 0; + switch (encoding) { + case kPcm16: bytesPerSample = 2; break; + case kPcm24Packed: bytesPerSample = 3; break; + case kPcm32: bytesPerSample = 4; break; + case kPcmFloat: bytesPerSample = 4; break; + default: return false; + } + const std::size_t frameBytes = bytesPerSample * channels; + const std::size_t frames = byteCount / frameBytes; + for (std::size_t frame = 0; frame < frames; ++frame) { + std::uint8_t* base = data + frame * frameBytes; + for (std::size_t ch = 0; ch < channels; ++ch) { + std::uint8_t* p = base + ch * bytesPerSample; + switch (encoding) { + case kPcm16: { + std::int16_t raw; + std::memcpy(&raw, p, sizeof(raw)); + const float in = static_cast(raw) / 32768.0F; + float out = tick(in, ch); + // TPDF dither (+-1 LSB) before requantizing to 16 bits. + ditherState_ = ditherState_ * 1664525U + 1013904223U; + const float r1 = static_cast(ditherState_ >> 8) / 16777216.0F; + ditherState_ = ditherState_ * 1664525U + 1013904223U; + const float r2 = static_cast(ditherState_ >> 8) / 16777216.0F; + const float scaled = out * 32768.0F + (r1 - r2); + const float rounded = std::isfinite(scaled) ? std::nearbyint(scaled) : 0.0F; + const auto value = static_cast(std::clamp(rounded, -32768.0F, 32767.0F)); + std::memcpy(p, &value, sizeof(value)); + break; + } + case kPcm24Packed: { + const float in = static_cast(readInt24(p)) / 8388608.0F; + const double scaled = static_cast(tick(in, ch)) * 8388608.0; + const double rounded = std::isfinite(scaled) ? std::nearbyint(scaled) : 0.0; + writeInt24(p, static_cast(std::clamp(rounded, -8388608.0, 8388607.0))); + break; + } + case kPcm32: { + std::int32_t raw; + std::memcpy(&raw, p, sizeof(raw)); + const double in = static_cast(raw) / 2147483648.0; + const double scaled = static_cast(tick(static_cast(in), ch)) * 2147483648.0; + const double rounded = std::isfinite(scaled) ? std::nearbyint(scaled) : 0.0; + const auto value = static_cast(std::clamp(rounded, -2147483648.0, 2147483647.0)); + std::memcpy(p, &value, sizeof(value)); + break; + } + case kPcmFloat: { + float raw; + std::memcpy(&raw, p, sizeof(raw)); + float out = tick(raw, ch); + out = std::isfinite(out) ? std::clamp(out, -1.0F, 1.0F) : 0.0F; + std::memcpy(p, &out, sizeof(out)); + break; + } + default: + break; + } + } + } + for (std::size_t i = 0; i < activeCount_; ++i) { + for (std::size_t ch = 0; ch < channels; ++ch) { + if (!std::isfinite(z1_[i][ch]) || !std::isfinite(z2_[i][ch])) { + z1_[i][ch] = 0.0; + z2_[i][ch] = 0.0; + } + } + } + return true; +} + +ParametricEq::Coefficients ParametricEq::design(const Spec& spec, double sampleRate) noexcept { + Coefficients c; + const double frequency = clampFrequency(sampleRate, spec.frequency); + const double omega = 2.0 * kPi * frequency / sampleRate; + const double cosine = std::cos(omega); + const double sine = std::sin(omega); + const double q = std::max(spec.q, 0.05); + const double alpha = sine / (2.0 * q); + const double amplitude = std::pow(10.0, spec.gainDb / 40.0); + double b0 = 1.0, b1 = 0.0, b2 = 0.0, a0 = 1.0, a1 = 0.0, a2 = 0.0; + switch (spec.type) { + case kLowShelf: { + const double twoSqrtAAlpha = 2.0 * std::sqrt(amplitude) * alpha; + b0 = amplitude * ((amplitude + 1.0) - (amplitude - 1.0) * cosine + twoSqrtAAlpha); + b1 = 2.0 * amplitude * ((amplitude - 1.0) - (amplitude + 1.0) * cosine); + b2 = amplitude * ((amplitude + 1.0) - (amplitude - 1.0) * cosine - twoSqrtAAlpha); + a0 = (amplitude + 1.0) + (amplitude - 1.0) * cosine + twoSqrtAAlpha; + a1 = -2.0 * ((amplitude - 1.0) + (amplitude + 1.0) * cosine); + a2 = (amplitude + 1.0) + (amplitude - 1.0) * cosine - twoSqrtAAlpha; + break; + } + case kHighShelf: { + const double twoSqrtAAlpha = 2.0 * std::sqrt(amplitude) * alpha; + b0 = amplitude * ((amplitude + 1.0) + (amplitude - 1.0) * cosine + twoSqrtAAlpha); + b1 = -2.0 * amplitude * ((amplitude - 1.0) + (amplitude + 1.0) * cosine); + b2 = amplitude * ((amplitude + 1.0) + (amplitude - 1.0) * cosine - twoSqrtAAlpha); + a0 = (amplitude + 1.0) - (amplitude - 1.0) * cosine + twoSqrtAAlpha; + a1 = 2.0 * ((amplitude - 1.0) - (amplitude + 1.0) * cosine); + a2 = (amplitude + 1.0) - (amplitude - 1.0) * cosine - twoSqrtAAlpha; + break; + } + case kLowPass: { + b0 = (1.0 - cosine) * 0.5; + b1 = 1.0 - cosine; + b2 = (1.0 - cosine) * 0.5; + a0 = 1.0 + alpha; + a1 = -2.0 * cosine; + a2 = 1.0 - alpha; + break; + } + case kHighPass: { + b0 = (1.0 + cosine) * 0.5; + b1 = -(1.0 + cosine); + b2 = (1.0 + cosine) * 0.5; + a0 = 1.0 + alpha; + a1 = -2.0 * cosine; + a2 = 1.0 - alpha; + break; + } + case kPeaking: + default: { + b0 = 1.0 + alpha * amplitude; + b1 = -2.0 * cosine; + b2 = 1.0 - alpha * amplitude; + a0 = 1.0 + alpha / amplitude; + a1 = -2.0 * cosine; + a2 = 1.0 - alpha / amplitude; + break; + } + } + c.b0 = b0 / a0; + c.b1 = b1 / a0; + c.b2 = b2 / a0; + c.a1 = a1 / a0; + c.a2 = a2 / a0; + return c; +} + +double ParametricEq::magnitude(const Coefficients& c, double sampleRate, double frequency) noexcept { + const double omega = 2.0 * kPi * clampFrequency(sampleRate, frequency) / sampleRate; + const double cos1 = std::cos(omega), sin1 = std::sin(omega); + const double cos2 = std::cos(2.0 * omega), sin2 = std::sin(2.0 * omega); + const double nr = c.b0 + c.b1 * cos1 + c.b2 * cos2; + const double ni = -c.b1 * sin1 - c.b2 * sin2; + const double dr = 1.0 + c.a1 * cos1 + c.a2 * cos2; + const double di = -c.a1 * sin1 - c.a2 * sin2; + return std::sqrt((nr * nr + ni * ni) / std::max(dr * dr + di * di, 1.0e-24)); +} + +double ParametricEq::responseDb(double sampleRate, double frequency) noexcept { + ParametricEq probe; + probe.configure(sampleRate, 1); + if (!probe.enabled_) return 0.0; + double gain = probe.preampGain_; + for (std::size_t i = 0; i < probe.activeCount_; ++i) { + gain *= magnitude(probe.coefficients_[i], sampleRate, frequency); + } + return 20.0 * std::log10(std::max(gain, 1.0e-12)); +} + +} // namespace lastwave::audio diff --git a/app/src/main/cpp/ParametricEq.h b/app/src/main/cpp/ParametricEq.h new file mode 100644 index 000000000..033808206 --- /dev/null +++ b/app/src/main/cpp/ParametricEq.h @@ -0,0 +1,123 @@ +#pragma once + +#include +#include +#include +#include + +namespace lastwave::audio { + +/** + * Parametric equalizer (AutoEQ / manual PEQ). + * + * One process-wide configuration is published from the control thread + * ([publish]) and picked up lock-free by every instance on its next block: + * the native DSP chain (mixer / converter paths) and the in-place processor + * the Kotlin sink runs on the direct routes (USB exclusive, internal direct, + * bit-perfect AudioTrack). Each instance owns its own filter state and + * rebuilds coefficients when the configuration revision or its sample rate + * changes. Filter state is kept across coefficient changes so edits do not + * reset the signal. + */ +class ParametricEq final { +public: + static constexpr std::size_t kMaxFilters = 20; + static constexpr std::size_t kMaxChannels = 2; + + enum FilterType : std::int32_t { + kPeaking = 0, + kLowShelf = 1, + kHighShelf = 2, + kLowPass = 3, + kHighPass = 4, + }; + + // PCM encodings accepted by processPcm (same values as Kotlin's + // NativePcmEncoding: 16-bit, packed 24-bit, 32-bit int, float). + static constexpr std::int32_t kPcm16 = 0; + static constexpr std::int32_t kPcm24Packed = 1; + static constexpr std::int32_t kPcm32 = 2; + static constexpr std::int32_t kPcmFloat = 3; + + /** Control thread. Arrays hold [count] entries; count is clamped to kMaxFilters. */ + static void publish( + bool enabled, + float preampDb, + const std::int32_t* types, + const float* frequenciesHz, + const float* gainsDb, + const float* qs, + const std::uint8_t* filterEnabled, + std::size_t count) noexcept; + + /** True when a published, enabled configuration has at least one active filter or a preamp. */ + static bool isEngaged() noexcept; + + void configure(double sampleRate, std::int32_t channels) noexcept; + void reset() noexcept; + + /** + * Float interleaved, in place. Returns true when the EQ was applied + * (false = disabled, nothing touched). + */ + bool process(float* interleaved, std::int32_t frameCount, std::int32_t channelCount) noexcept; + + /** + * Integer / float PCM in its own packing, in place (direct routes). + * 16-bit output gets TPDF dither. Returns true when applied. + */ + bool processPcm(std::uint8_t* data, std::size_t byteCount, std::int32_t encoding) noexcept; + + /** Magnitude in dB of the published configuration at [frequency] (preamp included). */ + static double responseDb(double sampleRate, double frequency) noexcept; + +private: + struct Coefficients final { + double b0{1.0}; + double b1{0.0}; + double b2{0.0}; + double a1{0.0}; + double a2{0.0}; + }; + + struct Spec final { + std::int32_t type{kPeaking}; + double frequency{1000.0}; + double gainDb{0.0}; + double q{0.707}; + }; + + static Coefficients design(const Spec& spec, double sampleRate) noexcept; + static double magnitude(const Coefficients& c, double sampleRate, double frequency) noexcept; + void syncConfiguration() noexcept; + + inline float tick(float input, std::size_t channel) noexcept { + double value = static_cast(input); + for (std::size_t i = 0; i < activeCount_; ++i) { + const Coefficients& c = coefficients_[i]; + double& z1 = z1_[i][channel]; + double& z2 = z2_[i][channel]; + const double out = c.b0 * value + z1; + z1 = c.b1 * value - c.a1 * out + z2; + z2 = c.b2 * value - c.a2 * out; + value = out; + } + return static_cast(value * preampGain_); + } + + double sampleRate_{48000.0}; + std::int32_t channels_{2}; + std::uint32_t appliedRevision_{0xFFFFFFFFU}; + double appliedSampleRate_{0.0}; + bool enabled_{false}; + double preampGain_{1.0}; + std::size_t activeCount_{0}; + std::array coefficients_{}; + std::array, kMaxFilters> z1_{}; + std::array, kMaxFilters> z2_{}; + std::array slotSource_{ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; + std::uint32_t ditherState_{0x12345678U}; +}; + +} // namespace lastwave::audio diff --git a/app/src/main/cpp/ParametricEqBridge.cpp b/app/src/main/cpp/ParametricEqBridge.cpp new file mode 100644 index 000000000..4e37091e9 --- /dev/null +++ b/app/src/main/cpp/ParametricEqBridge.cpp @@ -0,0 +1,136 @@ +#include "ParametricEq.h" + +#include + +#include +#include +#include + +namespace { + +using lastwave::audio::ParametricEq; + +ParametricEq* fromHandle(jlong handle) noexcept { + return reinterpret_cast(static_cast(handle)); +} + +} // namespace + +extern "C" JNIEXPORT void JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativePublish( + JNIEnv* env, + jobject, + jboolean enabled, + jfloat preampDb, + jintArray types, + jfloatArray frequencies, + jfloatArray gains, + jfloatArray qs, + jbooleanArray filterEnabled) { + if (types == nullptr || frequencies == nullptr || gains == nullptr || qs == nullptr || + filterEnabled == nullptr) { + ParametricEq::publish(false, 0.0F, nullptr, nullptr, nullptr, nullptr, nullptr, 0); + return; + } + const jsize count = env->GetArrayLength(types); + if (env->GetArrayLength(frequencies) != count || env->GetArrayLength(gains) != count || + env->GetArrayLength(qs) != count || env->GetArrayLength(filterEnabled) != count) { + return; + } + std::vector typeValues(static_cast(count)); + std::vector frequencyValues(static_cast(count)); + std::vector gainValues(static_cast(count)); + std::vector qValues(static_cast(count)); + std::vector onValues(static_cast(count)); + if (count > 0) { + env->GetIntArrayRegion(types, 0, count, typeValues.data()); + env->GetFloatArrayRegion(frequencies, 0, count, frequencyValues.data()); + env->GetFloatArrayRegion(gains, 0, count, gainValues.data()); + env->GetFloatArrayRegion(qs, 0, count, qValues.data()); + env->GetBooleanArrayRegion(filterEnabled, 0, count, onValues.data()); + } + std::vector typeInts(typeValues.begin(), typeValues.end()); + std::vector onBytes(onValues.begin(), onValues.end()); + ParametricEq::publish( + enabled == JNI_TRUE, + preampDb, + typeInts.data(), + frequencyValues.data(), + gainValues.data(), + qValues.data(), + onBytes.data(), + static_cast(count)); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeIsEngaged(JNIEnv*, jobject) { + return ParametricEq::isEngaged() ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jlong JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeCreate(JNIEnv*, jobject) { + return static_cast(reinterpret_cast(new (std::nothrow) ParametricEq())); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeDestroy(JNIEnv*, jobject, jlong handle) { + delete fromHandle(handle); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeConfigure( + JNIEnv*, + jobject, + jlong handle, + jint sampleRate, + jint channels) { + if (auto* eq = fromHandle(handle)) eq->configure(static_cast(sampleRate), channels); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeReset(JNIEnv*, jobject, jlong handle) { + if (auto* eq = fromHandle(handle)) eq->reset(); +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeProcessDirect( + JNIEnv* env, + jobject, + jlong handle, + jobject buffer, + jint offset, + jint length, + jint encoding) { + auto* eq = fromHandle(handle); + if (eq == nullptr || buffer == nullptr || offset < 0 || length <= 0) return JNI_FALSE; + auto* base = static_cast(env->GetDirectBufferAddress(buffer)); + const jlong capacity = env->GetDirectBufferCapacity(buffer); + if (base == nullptr || capacity < 0 || + static_cast(offset) + static_cast(length) > capacity) { + return JNI_FALSE; + } + return eq->processPcm(base + offset, static_cast(length), encoding) ? JNI_TRUE : JNI_FALSE; +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_lastwave_app_playback_eq_ParametricEqNative_nativeProcessArray( + JNIEnv* env, + jobject, + jlong handle, + jbyteArray array, + jint offset, + jint length, + jint encoding) { + auto* eq = fromHandle(handle); + if (eq == nullptr || array == nullptr || offset < 0 || length <= 0) return JNI_FALSE; + const jsize size = env->GetArrayLength(array); + if (static_cast(offset) + static_cast(length) > static_cast(size)) return JNI_FALSE; + jbyte* elements = env->GetByteArrayElements(array, nullptr); + if (elements == nullptr) return JNI_FALSE; + const bool applied = eq->processPcm( + reinterpret_cast(elements) + offset, + static_cast(length), + encoding); + env->ReleaseByteArrayElements(array, elements, applied ? 0 : JNI_ABORT); + return applied ? JNI_TRUE : JNI_FALSE; +} diff --git a/app/src/main/java/com/lastwave/app/data/autoeq/AutoEqCatalog.kt b/app/src/main/java/com/lastwave/app/data/autoeq/AutoEqCatalog.kt new file mode 100644 index 000000000..c823ab683 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/data/autoeq/AutoEqCatalog.kt @@ -0,0 +1,246 @@ +package com.lastwave.app.data.autoeq + +import android.content.Context +import android.util.Log +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.IOException +import java.net.URLDecoder +import java.net.URLEncoder +import java.security.MessageDigest +import javax.inject.Inject +import javax.inject.Singleton + +enum class FormFactor { OVER_EAR, IN_EAR } + +/** One headphone result of the AutoEQ database (results/INDEX.md). */ +@Serializable +data class AutoEqEntry( + val name: String, + val source: String, + val rig: String? = null, + /** Percent-encoded path below results/, as written in INDEX.md. */ + val encodedPath: String, +) { + val formFactor: FormFactor + get() { + val folder = URLDecoder.decode(encodedPath.replace("+", "%2B"), "UTF-8").lowercase() + return if ("in-ear" in folder || "earbud" in folder) FormFactor.IN_EAR else FormFactor.OVER_EAR + } + + /** File name stem: the last path segment (decoded). */ + val folderName: String + get() = URLDecoder.decode(encodedPath.substringAfterLast('/').replace("+", "%2B"), "UTF-8") + + val subtitle: String get() = if (rig.isNullOrBlank()) source else "$source · $rig" +} + +/** Raw measurement + the lab's target (AutoEQ result CSV), normalised for display. */ +data class AutoEqMeasurement( + val frequencies: DoubleArray, + val raw: DoubleArray, + val target: DoubleArray, +) + +/** + * Pure parsing helpers (unit-tested without Android). + */ +object AutoEqIndexParser { + private val LINE = Regex("""^- \[(.+)]\(\./(.+)\) by (.+?)(?: on (.+))?\s*$""") + + fun parse(markdown: String): List = markdown.lineSequence().mapNotNull { line -> + val m = LINE.find(line.trim()) ?: return@mapNotNull null + AutoEqEntry( + name = m.groupValues[1].trim(), + source = m.groupValues[3].trim(), + rig = m.groupValues[4].trim().ifBlank { null }, + encodedPath = m.groupValues[2].trim(), + ) + }.toList() + + /** Every query word must appear in name, source or rig (case-insensitive). */ + fun search(entries: List, query: String, limit: Int = 200): List { + val words = query.lowercase().split(Regex("\\s+")).filter { it.isNotBlank() } + if (words.isEmpty()) return emptyList() + return entries.asSequence() + .filter { e -> + val hay = "${e.name} ${e.source} ${e.rig.orEmpty()}".lowercase() + words.all { it in hay } + } + .sortedWith( + compareBy { !it.name.lowercase().startsWith(words.first()) } + .thenBy { it.name.length } + .thenBy { it.name.lowercase() }, + ) + .take(limit) + .toList() + } + + fun parseMeasurementCsv(csv: String): AutoEqMeasurement? { + val lines = csv.lineSequence().filter { it.isNotBlank() }.toList() + if (lines.size < 3) return null + val header = lines.first().split(',').map { it.trim().lowercase() } + val iF = header.indexOf("frequency") + val iRaw = header.indexOf("raw") + val iTarget = header.indexOf("target") + if (iF < 0 || iRaw < 0) return null + val f = ArrayList(lines.size) + val raw = ArrayList(lines.size) + val target = ArrayList(lines.size) + for (line in lines.drop(1)) { + val cols = line.split(',') + val freq = cols.getOrNull(iF)?.toDoubleOrNull() ?: continue + val r = cols.getOrNull(iRaw)?.toDoubleOrNull() ?: continue + f += freq + raw += r + target += if (iTarget >= 0) cols.getOrNull(iTarget)?.toDoubleOrNull() ?: Double.NaN else Double.NaN + } + if (f.size < 3) return null + return AutoEqMeasurement(f.toDoubleArray(), raw.toDoubleArray(), target.toDoubleArray()) + } + + fun encodeSegment(segment: String): String = + URLEncoder.encode(segment, "UTF-8").replace("+", "%20") +} + +/** + * Downloads the AutoEQ index and individual profiles from the public + * jaakkopasanen/AutoEq repository (MIT), on demand, and keeps them on disk + * so a chosen profile keeps working offline. + */ +@Singleton +class AutoEqCatalog @Inject constructor( + @ApplicationContext private val context: Context, + private val client: OkHttpClient, +) { + private val root get() = File(context.filesDir, "autoeq").apply { mkdirs() } + private val indexFile get() = File(root, "INDEX.md") + private val profileDir get() = File(root, "profiles").apply { mkdirs() } + private val mutex = Mutex() + @Volatile private var cachedEntries: List? = null + + /** Cached index immediately when present; refreshed from GitHub when stale or [forceRefresh]. */ + suspend fun entries(forceRefresh: Boolean = false): List = withContext(Dispatchers.IO) { + mutex.withLock { + val file = indexFile + val stale = !file.exists() || System.currentTimeMillis() - file.lastModified() > INDEX_MAX_AGE_MS + if (forceRefresh || stale) { + runCatching { download("$BASE_URL/INDEX.md") } + .onSuccess { body -> + if (AutoEqIndexParser.parse(body).size > 100) { + file.writeText(body) + cachedEntries = null + } + } + .onFailure { if (!file.exists()) throw it else Log.w(TAG, "Index refresh failed, using cache", it) } + } + cachedEntries ?: AutoEqIndexParser.parse(file.readText()).also { cachedEntries = it } + } + } + + suspend fun search(query: String): List = AutoEqIndexParser.search(entries(), query) + + /** The lab's ParametricEQ.txt for [entry] (cached on disk after the first download). */ + suspend fun profile(entry: AutoEqEntry): PeqSettings = withContext(Dispatchers.IO) { + val cache = File(profileDir, key(entry) + ".txt") + val text = if (cache.exists() && cache.length() > 0) { + cache.readText() + } else { + download(fileUrl(entry, "${entry.folderName} ParametricEQ.txt")).also { cache.writeText(it) } + } + ParametricEqText.parse(text) ?: throw IOException("Profile has no filters") + } + + /** Raw response + target for the chart; null when the lab published no CSV. */ + suspend fun measurement(entry: AutoEqEntry): AutoEqMeasurement? = withContext(Dispatchers.IO) { + val cache = File(profileDir, key(entry) + ".csv") + val missing = File(profileDir, key(entry) + ".nocsv") + if (missing.exists()) return@withContext null + val text = if (cache.exists() && cache.length() > 0) { + cache.readText() + } else { + runCatching { download(fileUrl(entry, "${entry.folderName}.csv")) } + .onSuccess { cache.writeText(it) } + .getOrElse { error -> + if (error is NotFoundException) missing.writeText("") + return@withContext null + } + } + AutoEqIndexParser.parseMeasurementCsv(text) + } + + private fun fileUrl(entry: AutoEqEntry, fileName: String): String = + "$BASE_URL/${entry.encodedPath}/${AutoEqIndexParser.encodeSegment(fileName)}" + + private fun download(url: String): String { + val request = Request.Builder().url(url).header("User-Agent", "LastWave-AutoEQ").build() + client.newCall(request).execute().use { response -> + if (response.code == 404) throw NotFoundException(url) + if (!response.isSuccessful) throw IOException("HTTP ${response.code} for $url") + return response.body?.string() ?: throw IOException("Empty body for $url") + } + } + + private fun key(entry: AutoEqEntry): String = + MessageDigest.getInstance("SHA-1").digest(entry.encodedPath.toByteArray()) + .joinToString("") { "%02x".format(it) }.take(24) + + class NotFoundException(url: String) : IOException("Not found: $url") + + companion object { + private const val TAG = "AutoEqCatalog" + const val BASE_URL = "https://raw.githubusercontent.com/jaakkopasanen/AutoEq/master/results" + private const val INDEX_MAX_AGE_MS = 14L * 24 * 60 * 60 * 1000 + } +} + +/** Bundled target deltas (generated from AutoEQ's targets/ folder, see assets/autoeq_targets.json). */ +@Serializable +data class AutoEqTargetFile( + val frequencies: List, + val targets: List, +) + +@Serializable +data class AutoEqTarget( + val id: String, + val name: String, + val family: String, + val deltaOverEar: List, + val deltaInEar: List, +) { + fun delta(formFactor: FormFactor): List = + if (formFactor == FormFactor.IN_EAR) deltaInEar else deltaOverEar +} + +@Singleton +class AutoEqTargets @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val json = Json { ignoreUnknownKeys = true } + + val file: AutoEqTargetFile by lazy { + runCatching { + context.assets.open(ASSET).bufferedReader().use { json.decodeFromString(AutoEqTargetFile.serializer(), it.readText()) } + }.getOrElse { + Log.w("AutoEqTargets", "Target asset unreadable", it) + AutoEqTargetFile(emptyList(), emptyList()) + } + } + + fun byId(id: String): AutoEqTarget? = file.targets.firstOrNull { it.id == id } + + companion object { + const val ASSET = "autoeq_targets.json" + /** The lab's own target: the published profile is used as-is. */ + const val DEFAULT_TARGET_ID = "default" + } +} diff --git a/app/src/main/java/com/lastwave/app/data/autoeq/AutoEqController.kt b/app/src/main/java/com/lastwave/app/data/autoeq/AutoEqController.kt new file mode 100644 index 000000000..c4bcca9de --- /dev/null +++ b/app/src/main/java/com/lastwave/app/data/autoeq/AutoEqController.kt @@ -0,0 +1,316 @@ +package com.lastwave.app.data.autoeq + +import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.lastwave.app.playback.eq.ParametricEqNative +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Everything that defines the active correction. + * + * [base] is the lab's published profile; [settings] is what actually runs. + * They differ when a target / bass / tilt is applied (refitted, not custom) + * or when the user edits filters by hand ([isCustom]). Choosing the headphone + * again restores the AutoEQ profile (non-custom). + */ +@Serializable +data class AutoEqState( + val entry: AutoEqEntry? = null, + val base: PeqSettings? = null, + val settings: PeqSettings = PeqSettings(), + val targetId: String = AutoEqTargets.DEFAULT_TARGET_ID, + val bassDb: Double = 0.0, + val tiltDbPerOctave: Double = 0.0, + val isCustom: Boolean = false, +) + +/** Current audio output, as the per-device association sees it. */ +data class OutputDevice(val key: String, val label: String) + +private val Context.autoEqDataStore: DataStore by preferencesDataStore(name = "autoeq_prefs") + +@Singleton +class AutoEqController @Inject constructor( + @ApplicationContext private val context: Context, + private val catalog: AutoEqCatalog, + private val targets: AutoEqTargets, +) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val store get() = context.applicationContext.autoEqDataStore + private val deviceMapSerializer = MapSerializer(String.serializer(), AutoEqState.serializer()) + + private val data = store.data.catch { emit(emptyPreferences()) } + + val enabled: StateFlow = data.map { it[KEY_ENABLED] == true } + .stateIn(scope, SharingStarted.Eagerly, false) + + val state: StateFlow = data.map { prefs -> + prefs[KEY_STATE]?.let { raw -> runCatching { json.decodeFromString(AutoEqState.serializer(), raw) }.getOrNull() } + ?: AutoEqState() + }.stateIn(scope, SharingStarted.Eagerly, AutoEqState()) + + val autoPerDevice: StateFlow = data.map { it[KEY_AUTO_DEVICE] != false } + .stateIn(scope, SharingStarted.Eagerly, true) + + val deviceProfiles: StateFlow> = data.map { prefs -> + prefs[KEY_DEVICES]?.let { raw -> runCatching { json.decodeFromString(deviceMapSerializer, raw) }.getOrNull() } + ?: emptyMap() + }.stateIn(scope, SharingStarted.Eagerly, emptyMap()) + + private val deviceLabels = MutableStateFlow>(emptyMap()) + val knownDeviceLabels: StateFlow> = deviceLabels.asStateFlow() + + private val _currentDevice = MutableStateFlow(null) + val currentDevice: StateFlow = _currentDevice.asStateFlow() + + private val _busy = MutableStateFlow(false) + val busy: StateFlow = _busy.asStateFlow() + + private val audioManager = context.getSystemService(AudioManager::class.java) + + init { + scope.launch { + combine(enabled, state) { on, current -> on to current.settings } + .distinctUntilChanged() + .collect { (on, settings) -> publish(on, settings) } + } + scope.launch { + deviceLabels.value = data.first()[KEY_DEVICE_LABELS] + ?.let { raw -> runCatching { json.decodeFromString(MapSerializer(String.serializer(), String.serializer()), raw) }.getOrNull() } + ?: emptyMap() + } + runCatching { + audioManager?.registerAudioDeviceCallback( + object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(addedDevices: Array?) = refreshDevice() + override fun onAudioDevicesRemoved(removedDevices: Array?) = refreshDevice() + }, + Handler(Looper.getMainLooper()), + ) + } + refreshDevice() + } + + private fun publish(on: Boolean, settings: PeqSettings) { + ParametricEqNative.publish( + enabled = on && !settings.isFlat, + preampDb = settings.preampDb.toFloat(), + filters = settings.filters.take(PeqSettings.MAX_FILTERS).map { + ParametricEqNative.NativeFilter( + type = it.type.nativeId, + frequencyHz = it.frequencyHz.toFloat(), + gainDb = it.gainDb.toFloat(), + q = it.q.toFloat(), + enabled = it.enabled, + ) + }, + ) + } + + fun setEnabled(on: Boolean) = scope.launch { store.edit { it[KEY_ENABLED] = on } } + + fun setAutoPerDevice(on: Boolean) = scope.launch { store.edit { it[KEY_AUTO_DEVICE] = on } } + + /** Downloads (or reuses) the lab profile and makes it current, keeping target/bass/tilt choices. */ + suspend fun selectProfile(entry: AutoEqEntry): Result = runBusy { + val base = catalog.profile(entry) + val previous = state.value + val next = previous.copy(entry = entry, base = base, isCustom = false) + writeState(next.copy(settings = computeSettings(next))) + store.edit { it[KEY_ENABLED] = true } + } + + suspend fun setTarget(targetId: String) = updateComputed { it.copy(targetId = targetId) } + suspend fun setBass(db: Double) = updateComputed { it.copy(bassDb = db.coerceIn(-12.0, 12.0)) } + suspend fun setTilt(dbPerOctave: Double) = updateComputed { it.copy(tiltDbPerOctave = dbPerOctave.coerceIn(-1.5, 1.5)) } + + /** Back to the AutoEQ profile (with the chosen target/bass/tilt), dropping manual edits. */ + suspend fun resetToProfile() = updateComputed { it } + + /** Manual edit: whatever the user sets runs as-is and the state becomes "Custom". */ + suspend fun setCustomSettings(settings: PeqSettings) { + val clean = settings.copy(filters = settings.filters.take(PeqSettings.MAX_FILTERS)) + writeState(state.value.copy(settings = clean, isCustom = true)) + } + + /** Manual EQ without any headphone profile. */ + suspend fun startManual() { + val current = state.value + val seed = if (current.settings.filters.isNotEmpty()) current.settings else PeqSettings( + filters = listOf( + PeqFilter(PeqFilterType.LOW_SHELF, 105.0, 0.0, 0.7), + PeqFilter(PeqFilterType.PEAKING, 1000.0, 0.0, 1.0), + PeqFilter(PeqFilterType.HIGH_SHELF, 10000.0, 0.0, 0.7), + ), + ) + writeState(current.copy(settings = seed, isCustom = true)) + store.edit { it[KEY_ENABLED] = true } + } + + suspend fun clearProfile() { + writeState(AutoEqState()) + } + + suspend fun measurement(entry: AutoEqEntry): AutoEqMeasurement? = runCatching { catalog.measurement(entry) }.getOrNull() + + /** Associates the current state with the current output device. */ + suspend fun saveForCurrentDevice() { + val device = currentDevice.value ?: return + val map = deviceProfiles.value.toMutableMap() + map[device.key] = state.value + val labels = deviceLabels.value.toMutableMap().apply { put(device.key, device.label) } + deviceLabels.value = labels + store.edit { + it[KEY_DEVICES] = json.encodeToString(deviceMapSerializer, map) + it[KEY_DEVICE_LABELS] = json.encodeToString(MapSerializer(String.serializer(), String.serializer()), labels) + } + } + + suspend fun forgetDevice(key: String) { + val map = deviceProfiles.value - key + store.edit { it[KEY_DEVICES] = json.encodeToString(deviceMapSerializer, map) } + } + + /** Target curve on the bundled grid, for the chart. */ + fun targetDelta(state: AutoEqState): List? { + val entry = state.entry ?: return null + return targets.byId(state.targetId)?.delta(entry.formFactor) + } + + fun availableTargets(): List = targets.file.targets + + private suspend fun updateComputed(change: (AutoEqState) -> AutoEqState) { + runBusy { + val next = change(state.value).copy(isCustom = false) + writeState(next.copy(settings = computeSettings(next))) + } + } + + /** Lab profile when nothing is adjusted, else a refit of profile + target delta + bass + tilt. */ + private suspend fun computeSettings(s: AutoEqState): PeqSettings = withContext(Dispatchers.Default) { + val base = s.base ?: return@withContext s.settings + val entry = s.entry + val delta = if (entry != null && s.targetId != AutoEqTargets.DEFAULT_TARGET_ID) { + targets.byId(s.targetId)?.delta(entry.formFactor) + } else { + null + } + if (delta == null && s.bassDb == 0.0 && s.tiltDbPerOctave == 0.0) return@withContext base + val grid = PeqMath.LOG_GRID + val desired = DoubleArray(grid.size) { i -> + val f = grid[i] + val value = PeqMath.filtersDb(base.filters, f) + + (delta?.getOrNull(i) ?: 0.0) + + PeqMath.bassShelfDb(s.bassDb, f) + + PeqMath.tiltDb(s.tiltDbPerOctave, f) + value.coerceIn(-15.0, 12.0) + } + PeqFitter.fit(desired, grid) + } + + private suspend fun writeState(next: AutoEqState) { + store.edit { it[KEY_STATE] = json.encodeToString(AutoEqState.serializer(), next) } + } + + private suspend fun runBusy(block: suspend () -> T): Result { + _busy.value = true + return try { + Result.success(block()) + } catch (error: kotlinx.coroutines.CancellationException) { + throw error + } catch (error: Exception) { + Log.w(TAG, "AutoEQ action failed", error) + Result.failure(error) + } finally { + _busy.value = false + } + } + + private fun refreshDevice() { + val device = runCatching { pickOutput() }.getOrNull() + val previous = _currentDevice.value + _currentDevice.value = device + if (device == null || device.key == previous?.key) return + scope.launch { + if (!autoPerDevice.value) return@launch + val saved = deviceProfiles.value[device.key] ?: return@launch + Log.i(TAG, "Output ${device.label}: applying its AutoEQ profile") + writeState(saved) + } + } + + /** Same priority Android uses for media: Bluetooth > USB > wired > speaker. */ + private fun pickOutput(): OutputDevice? { + val devices = audioManager?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) ?: return null + fun rank(type: Int): Int = when (type) { + AudioDeviceInfo.TYPE_BLUETOOTH_A2DP, AudioDeviceInfo.TYPE_BLE_HEADSET, AudioDeviceInfo.TYPE_BLE_SPEAKER -> 0 + AudioDeviceInfo.TYPE_USB_HEADSET, AudioDeviceInfo.TYPE_USB_DEVICE, AudioDeviceInfo.TYPE_USB_ACCESSORY -> 1 + AudioDeviceInfo.TYPE_WIRED_HEADPHONES, AudioDeviceInfo.TYPE_WIRED_HEADSET -> 2 + AudioDeviceInfo.TYPE_BUILTIN_SPEAKER -> 3 + else -> 9 + } + val best = devices.filter { rank(it.type) < 9 }.minByOrNull { rank(it.type) } ?: return null + val name = best.productName?.toString()?.trim().orEmpty() + val kind = when (rank(best.type)) { + 0 -> "bt" + 1 -> "usb" + 2 -> "wired" + else -> "speaker" + } + val label = when (kind) { + "wired" -> name.ifBlank { "Wired headphones" } + "speaker" -> "Phone speaker" + else -> name.ifBlank { kind.uppercase() } + } + // Wired jacks report the phone model as product name: one key for all. + val key = when (kind) { + "wired" -> "wired" + "speaker" -> "speaker" + else -> "$kind:${name.lowercase()}" + } + return OutputDevice(key, label) + } + + companion object { + private const val TAG = "AutoEqController" + private val KEY_ENABLED = booleanPreferencesKey("enabled") + private val KEY_STATE = stringPreferencesKey("state") + private val KEY_AUTO_DEVICE = booleanPreferencesKey("auto_per_device") + private val KEY_DEVICES = stringPreferencesKey("device_profiles") + private val KEY_DEVICE_LABELS = stringPreferencesKey("device_labels") + } +} diff --git a/app/src/main/java/com/lastwave/app/data/autoeq/PeqFitter.kt b/app/src/main/java/com/lastwave/app/data/autoeq/PeqFitter.kt new file mode 100644 index 000000000..afe4b1498 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/data/autoeq/PeqFitter.kt @@ -0,0 +1,236 @@ +package com.lastwave.app.data.autoeq + +import kotlin.math.abs +import kotlin.math.exp +import kotlin.math.ln +import kotlin.math.sqrt + +/** + * Fits a parametric EQ to a desired correction curve, with AutoEQ's default + * layout: a fixed 105 Hz / Q 0.7 low shelf, eight peaking filters + * (20 Hz - 10 kHz) and a high shelf (5 - 12 kHz, Q 0.4 - 0.7). + * + * Used when the user changes the target, bass or tilt of an AutoEQ profile: + * the published profile is turned into a curve, the target delta is added, + * and that curve is refitted. Levenberg-Marquardt on log-frequency / + * log-Q / gain parameters, greedy initialisation on the largest residuals. + */ +object PeqFitter { + private const val PEAKS = 8 + private const val MIN_PK_FC = 20.0 + private const val MAX_PK_FC = 10_000.0 + private const val MIN_Q = 0.18248 + private const val MAX_Q = 6.0 + private const val MAX_GAIN = 20.0 + private const val SHELF_FC = 105.0 + private const val SHELF_Q = 0.7 + private const val HS_MIN_FC = 5_000.0 + private const val HS_MAX_FC = 12_000.0 + private const val HS_MIN_Q = 0.4 + private const val HS_MAX_Q = 0.7 + + /** + * @param desiredDb correction to reproduce on [grid] (dB). + * @return filters plus AutoEQ-style preamp (negative largest boost). + */ + fun fit(desiredDb: DoubleArray, grid: DoubleArray = PeqMath.LOG_GRID, iterations: Int = 80): PeqSettings { + require(desiredDb.size == grid.size) { "curve/grid size mismatch" } + val weights = DoubleArray(grid.size) { i -> + when { + grid[i] > 16_000.0 -> 0.0 + grid[i] > MAX_PK_FC -> 0.35 + else -> 1.0 + } + } + var params = initialGuess(desiredDb, grid) + var lambda = 1e-2 + var error = cost(params, desiredDb, grid, weights) + repeat(iterations) { + val residual = residuals(params, desiredDb, grid, weights) + val jacobian = jacobian(params, desiredDb, grid, weights, residual) + val n = params.size + val jtj = Array(n) { DoubleArray(n) } + val jtr = DoubleArray(n) + for (k in grid.indices) { + for (a in 0 until n) { + val ja = jacobian[k][a] + if (ja == 0.0) continue + jtr[a] += ja * residual[k] + for (b in 0 until n) jtj[a][b] += ja * jacobian[k][b] + } + } + var improved = false + var attempts = 0 + while (!improved && attempts < 6) { + val system = Array(n) { a -> DoubleArray(n) { b -> jtj[a][b] + if (a == b) lambda * (jtj[a][a] + 1e-6) else 0.0 } } + val step = solve(system, DoubleArray(n) { -jtr[it] }) ?: break + val candidate = clampParams(DoubleArray(n) { params[it] + step[it] }) + val candidateError = cost(candidate, desiredDb, grid, weights) + if (candidateError < error) { + params = candidate + error = candidateError + lambda = (lambda * 0.4).coerceAtLeast(1e-7) + improved = true + } else { + lambda = (lambda * 4.0).coerceAtMost(1e6) + } + attempts++ + } + if (!improved) return@repeat + } + val filters = toFilters(params) + return PeqSettings(preampDb = PeqMath.autoPreampDb(filters, grid), filters = filters) + } + + /** Root-mean-square error (dB, weighted) between a settings response and a curve. */ + fun rmsError(settings: PeqSettings, desiredDb: DoubleArray, grid: DoubleArray = PeqMath.LOG_GRID): Double { + var sum = 0.0 + var count = 0 + for (i in grid.indices) { + if (grid[i] > MAX_PK_FC) continue + val d = PeqMath.filtersDb(settings.filters, grid[i]) - desiredDb[i] + sum += d * d + count++ + } + return sqrt(sum / count.coerceAtLeast(1)) + } + + // Parameter layout: [0] low-shelf gain, [1..3] high shelf (ln fc, ln q, gain), + // then per peak (ln fc, ln q, gain). + private fun toFilters(p: DoubleArray): List = buildList { + add(PeqFilter(PeqFilterType.LOW_SHELF, SHELF_FC, round1(p[0]), SHELF_Q)) + for (i in 0 until PEAKS) { + val base = 4 + i * 3 + add( + PeqFilter( + PeqFilterType.PEAKING, + roundFc(exp(p[base])), + round1(p[base + 2]), + round2(exp(p[base + 1])), + ), + ) + } + add(PeqFilter(PeqFilterType.HIGH_SHELF, roundFc(exp(p[1])), round1(p[3]), round2(exp(p[2])))) + } + + private fun responseAt(p: DoubleArray, f: Double): Double { + var total = PeqMath.filterDb(PeqFilter(PeqFilterType.LOW_SHELF, SHELF_FC, p[0], SHELF_Q), f) + total += PeqMath.filterDb(PeqFilter(PeqFilterType.HIGH_SHELF, exp(p[1]), p[3], exp(p[2])), f) + for (i in 0 until PEAKS) { + val base = 4 + i * 3 + total += PeqMath.filterDb(PeqFilter(PeqFilterType.PEAKING, exp(p[base]), p[base + 2], exp(p[base + 1])), f) + } + return total + } + + private fun residuals(p: DoubleArray, desired: DoubleArray, grid: DoubleArray, w: DoubleArray): DoubleArray = + DoubleArray(grid.size) { i -> if (w[i] == 0.0) 0.0 else (responseAt(p, grid[i]) - desired[i]) * w[i] } + + private fun cost(p: DoubleArray, desired: DoubleArray, grid: DoubleArray, w: DoubleArray): Double = + residuals(p, desired, grid, w).sumOf { it * it } + + private fun jacobian( + p: DoubleArray, + desired: DoubleArray, + grid: DoubleArray, + w: DoubleArray, + base: DoubleArray, + ): Array { + val n = p.size + val j = Array(grid.size) { DoubleArray(n) } + for (a in 0 until n) { + val h = if (isGain(a)) 1e-3 else 1e-4 + val shifted = p.copyOf() + shifted[a] += h + val r = residuals(shifted, desired, grid, w) + for (k in grid.indices) j[k][a] = (r[k] - base[k]) / h + } + return j + } + + private fun isGain(index: Int): Boolean = index == 0 || index == 3 || (index >= 4 && (index - 4) % 3 == 2) + + private fun clampParams(p: DoubleArray): DoubleArray { + val out = p.copyOf() + out[0] = out[0].coerceIn(-MAX_GAIN, MAX_GAIN) + out[1] = out[1].coerceIn(ln(HS_MIN_FC), ln(HS_MAX_FC)) + out[2] = out[2].coerceIn(ln(HS_MIN_Q), ln(HS_MAX_Q)) + out[3] = out[3].coerceIn(-MAX_GAIN, MAX_GAIN) + for (i in 0 until PEAKS) { + val b = 4 + i * 3 + out[b] = out[b].coerceIn(ln(MIN_PK_FC), ln(MAX_PK_FC)) + out[b + 1] = out[b + 1].coerceIn(ln(MIN_Q), ln(MAX_Q)) + out[b + 2] = out[b + 2].coerceIn(-MAX_GAIN, MAX_GAIN) + } + return out + } + + private fun initialGuess(desired: DoubleArray, grid: DoubleArray): DoubleArray { + val p = DoubleArray(4 + PEAKS * 3) + val lowIdx = grid.indices.filter { grid[it] <= 60.0 } + p[0] = (lowIdx.map { desired[it] }.average().takeIf { it.isFinite() } ?: 0.0).coerceIn(-12.0, 12.0) + p[1] = ln(10_000.0) + p[2] = ln(0.7) + val highIdx = grid.indices.filter { grid[it] in 11_000.0..16_000.0 } + p[3] = (highIdx.map { desired[it] }.average().takeIf { it.isFinite() } ?: 0.0).coerceIn(-12.0, 12.0) + val current = DoubleArray(grid.size) { i -> responseAt(p, grid[i]) } + for (i in 0 until PEAKS) { + var bestIndex = -1 + var bestValue = 0.0 + for (k in grid.indices) { + if (grid[k] < MIN_PK_FC || grid[k] > MAX_PK_FC) continue + val r = desired[k] - current[k] + if (abs(r) > abs(bestValue)) { + bestValue = r + bestIndex = k + } + } + val b = 4 + i * 3 + if (bestIndex < 0 || abs(bestValue) < 0.05) { + p[b] = ln(1000.0 * (i + 1)) + p[b + 1] = ln(1.0) + p[b + 2] = 0.0 + continue + } + p[b] = ln(grid[bestIndex]) + p[b + 1] = ln(1.4) + p[b + 2] = bestValue.coerceIn(-MAX_GAIN, MAX_GAIN) + val added = PeqFilter(PeqFilterType.PEAKING, grid[bestIndex], p[b + 2], 1.4) + for (k in grid.indices) current[k] += PeqMath.filterDb(added, grid[k]) + } + return clampParams(p) + } + + /** Gaussian elimination with partial pivoting; null when singular. */ + private fun solve(a: Array, b: DoubleArray): DoubleArray? { + val n = b.size + val m = Array(n) { a[it].copyOf() } + val v = b.copyOf() + for (col in 0 until n) { + var pivot = col + for (row in col + 1 until n) if (abs(m[row][col]) > abs(m[pivot][col])) pivot = row + if (abs(m[pivot][col]) < 1e-12) return null + if (pivot != col) { + val t = m[pivot]; m[pivot] = m[col]; m[col] = t + val tv = v[pivot]; v[pivot] = v[col]; v[col] = tv + } + for (row in col + 1 until n) { + val factor = m[row][col] / m[col][col] + if (factor == 0.0) continue + for (k in col until n) m[row][k] -= factor * m[col][k] + v[row] -= factor * v[col] + } + } + val x = DoubleArray(n) + for (row in n - 1 downTo 0) { + var sum = v[row] + for (k in row + 1 until n) sum -= m[row][k] * x[k] + x[row] = sum / m[row][row] + } + return x + } + + private fun round1(v: Double) = PeqMath.roundTo(v, 0.1) + private fun round2(v: Double) = PeqMath.roundTo(v, 0.01) + private fun roundFc(v: Double) = PeqMath.roundTo(v, 1.0) +} diff --git a/app/src/main/java/com/lastwave/app/data/autoeq/PeqModel.kt b/app/src/main/java/com/lastwave/app/data/autoeq/PeqModel.kt new file mode 100644 index 000000000..425c41f99 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/data/autoeq/PeqModel.kt @@ -0,0 +1,209 @@ +package com.lastwave.app.data.autoeq + +import kotlinx.serialization.Serializable +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.log10 +import kotlin.math.max +import kotlin.math.pow +import kotlin.math.sin +import kotlin.math.sqrt + +/** Filter shapes understood by the native EQ (ids match ParametricEq.h). */ +enum class PeqFilterType(val nativeId: Int, val code: String, val hasGain: Boolean) { + PEAKING(0, "PK", true), + LOW_SHELF(1, "LSC", true), + HIGH_SHELF(2, "HSC", true), + LOW_PASS(3, "LPQ", false), + HIGH_PASS(4, "HPQ", false), + ; + + companion object { + fun fromCode(code: String): PeqFilterType? = when (code.uppercase()) { + "PK", "PEQ", "PEAK", "PEAKING" -> PEAKING + "LSC", "LS", "LSQ", "LOWSHELF" -> LOW_SHELF + "HSC", "HS", "HSQ", "HIGHSHELF" -> HIGH_SHELF + "LP", "LPQ", "LOWPASS" -> LOW_PASS + "HP", "HPQ", "HIGHPASS" -> HIGH_PASS + else -> null + } + } +} + +@Serializable +data class PeqFilter( + val type: PeqFilterType = PeqFilterType.PEAKING, + val frequencyHz: Double = 1000.0, + val gainDb: Double = 0.0, + val q: Double = 0.7, + val enabled: Boolean = true, +) + +@Serializable +data class PeqSettings( + val preampDb: Double = 0.0, + val filters: List = emptyList(), +) { + val isFlat: Boolean + get() = kotlin.math.abs(preampDb) < 0.001 && filters.none { + it.enabled && (!it.type.hasGain || kotlin.math.abs(it.gainDb) >= 0.001) + } + + companion object { + const val MAX_FILTERS = 20 + } +} + +/** Biquad magnitude math, identical to the RBJ designs in ParametricEq.cpp. */ +object PeqMath { + const val DEFAULT_SAMPLE_RATE = 48_000.0 + + fun filterDb(filter: PeqFilter, frequency: Double, sampleRate: Double = DEFAULT_SAMPLE_RATE): Double { + if (!filter.enabled) return 0.0 + if (filter.type.hasGain && kotlin.math.abs(filter.gainDb) < 0.001) return 0.0 + if (filter.frequencyHz >= sampleRate * 0.49) return 0.0 + val c = coefficients(filter, sampleRate) + val w = 2.0 * PI * frequency.coerceIn(1.0, sampleRate * 0.49) / sampleRate + val cos1 = cos(w) + val sin1 = sin(w) + val cos2 = cos(2 * w) + val sin2 = sin(2 * w) + val nr = c[0] + c[1] * cos1 + c[2] * cos2 + val ni = -c[1] * sin1 - c[2] * sin2 + val dr = 1.0 + c[3] * cos1 + c[4] * cos2 + val di = -c[3] * sin1 - c[4] * sin2 + val mag = sqrt((nr * nr + ni * ni) / max(dr * dr + di * di, 1e-24)) + return 20.0 * log10(max(mag, 1e-12)) + } + + /** Total response in dB (preamp included). */ + fun responseDb(settings: PeqSettings, frequency: Double, sampleRate: Double = DEFAULT_SAMPLE_RATE): Double = + settings.preampDb + settings.filters.sumOf { filterDb(it, frequency, sampleRate) } + + fun filtersDb(filters: List, frequency: Double, sampleRate: Double = DEFAULT_SAMPLE_RATE): Double = + filters.sumOf { filterDb(it, frequency, sampleRate) } + + /** AutoEQ's rule: negative preamp equal to the largest boost, rounded down to 0.1 dB. */ + fun autoPreampDb(filters: List, grid: DoubleArray = LOG_GRID): Double { + val maxBoost = grid.maxOf { filtersDb(filters, it) } + if (maxBoost <= 0.0) return 0.0 + return -(kotlin.math.ceil(maxBoost * 10.0) / 10.0) + } + + /** b0, b1, b2, a1, a2 normalised by a0. */ + private fun coefficients(filter: PeqFilter, sampleRate: Double): DoubleArray { + val f = filter.frequencyHz.coerceIn(10.0, sampleRate * 0.49) + val w = 2.0 * PI * f / sampleRate + val cs = cos(w) + val alpha = sin(w) / (2.0 * max(filter.q, 0.05)) + val a = 10.0.pow(filter.gainDb / 40.0) + val b0: Double + val b1: Double + val b2: Double + val a0: Double + val a1: Double + val a2: Double + when (filter.type) { + PeqFilterType.LOW_SHELF -> { + val k = 2 * sqrt(a) * alpha + b0 = a * ((a + 1) - (a - 1) * cs + k) + b1 = 2 * a * ((a - 1) - (a + 1) * cs) + b2 = a * ((a + 1) - (a - 1) * cs - k) + a0 = (a + 1) + (a - 1) * cs + k + a1 = -2 * ((a - 1) + (a + 1) * cs) + a2 = (a + 1) + (a - 1) * cs - k + } + PeqFilterType.HIGH_SHELF -> { + val k = 2 * sqrt(a) * alpha + b0 = a * ((a + 1) + (a - 1) * cs + k) + b1 = -2 * a * ((a - 1) + (a + 1) * cs) + b2 = a * ((a + 1) + (a - 1) * cs - k) + a0 = (a + 1) - (a - 1) * cs + k + a1 = 2 * ((a - 1) - (a + 1) * cs) + a2 = (a + 1) - (a - 1) * cs - k + } + PeqFilterType.LOW_PASS -> { + b0 = (1 - cs) / 2 + b1 = 1 - cs + b2 = (1 - cs) / 2 + a0 = 1 + alpha + a1 = -2 * cs + a2 = 1 - alpha + } + PeqFilterType.HIGH_PASS -> { + b0 = (1 + cs) / 2 + b1 = -(1 + cs) + b2 = (1 + cs) / 2 + a0 = 1 + alpha + a1 = -2 * cs + a2 = 1 - alpha + } + PeqFilterType.PEAKING -> { + b0 = 1 + alpha * a + b1 = -2 * cs + b2 = 1 - alpha * a + a0 = 1 + alpha / a + a1 = -2 * cs + a2 = 1 - alpha / a + } + } + return doubleArrayOf(b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0) + } + + /** 1/12-octave grid, 20 Hz .. 20.48 kHz (same as the bundled target deltas). */ + val LOG_GRID: DoubleArray = DoubleArray(121) { 20.0 * 2.0.pow(it / 12.0) } + + /** Low shelf used for the bass adjustment (same shape as AutoEQ's 105 Hz shelf). */ + fun bassShelfDb(gainDb: Double, frequency: Double): Double = + filterDb(PeqFilter(PeqFilterType.LOW_SHELF, 105.0, gainDb, 0.7), frequency) + + /** Tilt around 1 kHz in dB per octave. */ + fun tiltDb(dbPerOctave: Double, frequency: Double): Double = + dbPerOctave * kotlin.math.ln(frequency / 1000.0) / kotlin.math.ln(2.0) + + fun roundTo(value: Double, step: Double): Double = floor(value / step + 0.5) * step +} + +/** Reads and writes the AutoEQ / Equalizer APO "ParametricEQ.txt" format. */ +object ParametricEqText { + private val PREAMP = Regex("""^\s*Preamp:\s*([-+]?\d+(?:\.\d+)?)\s*dB""", RegexOption.IGNORE_CASE) + private val FILTER = Regex( + """^\s*Filter\s*\d*\s*:\s*(ON|OFF)\s+([A-Z]+)\s+Fc\s+([-+]?\d+(?:\.\d+)?)\s*Hz""" + + """(?:\s+Gain\s+([-+]?\d+(?:\.\d+)?)\s*dB)?(?:\s+Q\s+([-+]?\d+(?:\.\d+)?))?""", + RegexOption.IGNORE_CASE, + ) + + fun parse(text: String): PeqSettings? { + var preamp = 0.0 + val filters = mutableListOf() + for (line in text.lineSequence()) { + PREAMP.find(line)?.let { preamp = it.groupValues[1].toDouble() } + val match = FILTER.find(line) ?: continue + val type = PeqFilterType.fromCode(match.groupValues[2]) ?: continue + val fc = match.groupValues[3].toDoubleOrNull() ?: continue + val gain = match.groupValues[4].toDoubleOrNull() ?: 0.0 + val q = match.groupValues[5].toDoubleOrNull() ?: 0.7071 + filters += PeqFilter( + type = type, + frequencyHz = fc, + gainDb = gain, + q = q, + enabled = match.groupValues[1].equals("ON", ignoreCase = true), + ) + if (filters.size >= PeqSettings.MAX_FILTERS) break + } + if (filters.isEmpty()) return null + return PeqSettings(preampDb = preamp, filters = filters) + } + + fun format(settings: PeqSettings): String = buildString { + appendLine("Preamp: %.1f dB".format(java.util.Locale.ROOT, settings.preampDb)) + settings.filters.forEachIndexed { index, f -> + append("Filter ${index + 1}: ${if (f.enabled) "ON" else "OFF"} ${f.type.code} ") + append("Fc %.0f Hz".format(java.util.Locale.ROOT, f.frequencyHz)) + if (f.type.hasGain) append(" Gain %.1f dB".format(java.util.Locale.ROOT, f.gainDb)) + appendLine(" Q %.2f".format(java.util.Locale.ROOT, f.q)) + } + } +} diff --git a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt index 252bbab19..22d3ce332 100644 --- a/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt +++ b/app/src/main/java/com/lastwave/app/playback/MusicPlayer.kt @@ -223,6 +223,8 @@ class MusicPlayer @Inject constructor( private val exclusiveUsbOutput: ExclusiveUsbOutput, private val songPlayStatsRepository: dagger.Lazy, private val internalDirectOutput: InternalDirectOutput, + /** AutoEQ: started with the player so the saved correction is published before playback. */ + private val autoEqController: com.lastwave.app.data.autoeq.AutoEqController, ) { private val appContext = context.applicationContext /** @@ -3539,6 +3541,7 @@ class MusicPlayer @Inject constructor( internalDirectWanted = internalPrefOn, internalDirectFailure = internalFailure, bypassRequested = bitPerfectEnabled, + parametricEqActive = com.lastwave.app.playback.eq.ParametricEqNative.isEngaged(), sourceLabel = srcLabel, sourceRateHz = sourceRateHz, // The source row follows the same shared hi-res rule as the pill diff --git a/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt b/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt index 626cc3cce..5dfaea8b7 100644 --- a/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt +++ b/app/src/main/java/com/lastwave/app/playback/NativeProcessingAudioSink.kt @@ -111,6 +111,15 @@ class NativeProcessingAudioSink( /** Channel count the shared converter emits while it serves internal direct. */ private var converterChannels = 2 + /** + * AutoEQ on routes where PCM never enters the native Float32 DSP (USB + * exclusive, internal direct, Bit-Perfect AudioTrack, platform fallback). + * The converter / processed paths get the same EQ inside the native DSP. + */ + private val directEq = com.lastwave.app.playback.eq.DirectPcmEq() + /** Buffer already equalised: Media3 retries the same object until consumed. */ + private var directEqBuffer: ByteBuffer? = null + var onConfiguredFormat: ((sampleRateHz: Int, pcmEncoding: Int, channelCount: Int) -> Unit)? = null var bitDepthHintProvider: (() -> Int?)? = null @@ -177,6 +186,10 @@ class NativeProcessingAudioSink( if (format.sampleRate > 0) { onConfiguredFormat?.invoke(format.sampleRate, format.pcmEncoding, format.channelCount) } + directEqBuffer = null + if (format.sampleMimeType == MimeTypes.AUDIO_RAW && format.sampleRate > 0) { + directEq.configure(format.sampleRate, format.channelCount, format.pcmEncoding) + } if (exclusiveWanted && internalDirect?.hasOpenStream() == true) { // A direct AudioTrack left open on the DAC by the previous item @@ -461,6 +474,29 @@ class NativeProcessingAudioSink( ): Boolean { maybeAdoptExclusiveUsb() maybeAdoptInternalDirect() + if (directEqBuffer !== buffer && isDirectEqRoute()) { + directEq.process(buffer) + directEqBuffer = buffer + } + val consumed = handleRoutedBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) + // Decoder buffers are recycled objects: once consumed, the next + // payload in the same object must be equalised again. + if (consumed) directEqBuffer = null + return consumed + } + + /** True when this buffer bypasses the native DSP (where the EQ otherwise runs). */ + private fun isDirectEqRoute(): Boolean = when { + usbExclusive -> !isExclusiveConverting() + internalActive -> internalDirect?.requiresResampling() != true + else -> !processingActive + } + + private fun handleRoutedBuffer( + buffer: ByteBuffer, + presentationTimeUs: Long, + encodedAccessUnitCount: Int, + ): Boolean { if (usbExclusive) { if (exclusiveUsb?.isPaused() == true) { return false @@ -1437,6 +1473,8 @@ class NativeProcessingAudioSink( } override fun flush() { + directEq.reset() + directEqBuffer = null clearPending() clearEndOfStream() exclusiveEnded = false @@ -1475,6 +1513,8 @@ class NativeProcessingAudioSink( } override fun reset() { + directEq.reset() + directEqBuffer = null usbOutput?.setFormat(null) if (usbExclusive && exclusiveWanted) { exclusiveUsb?.prepareForNextItem() diff --git a/app/src/main/java/com/lastwave/app/playback/SignalPath.kt b/app/src/main/java/com/lastwave/app/playback/SignalPath.kt index c4565945a..1d175b0d3 100644 --- a/app/src/main/java/com/lastwave/app/playback/SignalPath.kt +++ b/app/src/main/java/com/lastwave/app/playback/SignalPath.kt @@ -74,6 +74,8 @@ data class SignalPathInput( val bypassRequested: Boolean = false, /** USB wire bit depth negotiated (e.g. 16, 24, 32). */ val wireBits: Int = 0, + /** AutoEQ / manual parametric EQ is changing the signal (any route). */ + val parametricEqActive: Boolean = false, ) /** @@ -112,6 +114,8 @@ data class SignalPathReport( val directToHal: Boolean = false, val internalSession: InternalDirectSession? = null, val bypassRequested: Boolean = false, + /** AutoEQ is applied by LastWave: never bit-perfect, but still "Direct + EQ". */ + val parametricEqActive: Boolean = false, // Poweramp pipeline fields: val outputEngineName: String = "Android AudioTrack", val outputRateHz: Int = 0, @@ -259,6 +263,17 @@ fun evaluateSignalPath(i: SignalPathInput): SignalPathReport { ) } + // 3b — AutoEQ / parametric EQ: a correction the user asked for. It runs + // in LastWave before any direct output, so the mixer is still bypassed, + // but the samples are no longer the source's. + if (i.parametricEqActive) { + checks += PathCheck( + R.string.signal_label_autoeq, + R.string.signal_detail_autoeq, + passed = false, + ) + } + // 4 — Tempo/pitch (resampling by definition when != 1x). if (i.speed == 1f) { checks += PathCheck( @@ -618,9 +633,11 @@ private fun finishReport( usbExclusiveActive = i.usbExclusiveActive, clockFallbackResampled = i.clockFallbackResampled || internal?.resampled == true, internalDirectActive = internal != null, - directToHal = internal != null && checks.all { it.passed }, + directToHal = internal != null && + checks.all { it.passed || it.labelRes == R.string.signal_label_autoeq }, internalSession = internal, bypassRequested = i.bypassRequested, + parametricEqActive = i.parametricEqActive, outputEngineName = engine, outputRateHz = outRate, outputBits = outBits, diff --git a/app/src/main/java/com/lastwave/app/playback/eq/ParametricEqNative.kt b/app/src/main/java/com/lastwave/app/playback/eq/ParametricEqNative.kt new file mode 100644 index 000000000..ca1c8ec4f --- /dev/null +++ b/app/src/main/java/com/lastwave/app/playback/eq/ParametricEqNative.kt @@ -0,0 +1,147 @@ +package com.lastwave.app.playback.eq + +import android.util.Log +import androidx.media3.common.C +import java.nio.ByteBuffer + +/** + * JNI surface of the native parametric EQ (ParametricEq.cpp). + * + * [publish] sets the single process-wide configuration that both the native + * DSP chain and every [DirectPcmEq] pick up on their next block. + */ +object ParametricEqNative { + private const val TAG = "ParametricEqNative" + + val isAvailable: Boolean = runCatching { + System.loadLibrary("lastwave_audio") + true + }.getOrElse { error -> + Log.w(TAG, "Native parametric EQ unavailable", error) + false + } + + fun publish(enabled: Boolean, preampDb: Float, filters: List) { + if (!isAvailable) return + runCatching { + nativePublish( + enabled, + preampDb, + IntArray(filters.size) { filters[it].type }, + FloatArray(filters.size) { filters[it].frequencyHz }, + FloatArray(filters.size) { filters[it].gainDb }, + FloatArray(filters.size) { filters[it].q }, + BooleanArray(filters.size) { filters[it].enabled }, + ) + }.onFailure { Log.w(TAG, "publish failed", it) } + } + + /** True when an enabled configuration actually changes the signal. */ + fun isEngaged(): Boolean = isAvailable && runCatching { nativeIsEngaged() }.getOrDefault(false) + + data class NativeFilter( + val type: Int, + val frequencyHz: Float, + val gainDb: Float, + val q: Float, + val enabled: Boolean, + ) + + /** Media3 PCM encoding -> native encoding id (see ParametricEq.h), or null when unsupported. */ + fun nativeEncodingOf(media3Encoding: Int): Int? = when (media3Encoding) { + C.ENCODING_PCM_16BIT -> 0 + C.ENCODING_PCM_24BIT -> 1 + C.ENCODING_PCM_32BIT -> 2 + C.ENCODING_PCM_FLOAT -> 3 + else -> null + } + + @JvmStatic external fun nativePublish( + enabled: Boolean, + preampDb: Float, + types: IntArray, + frequencies: FloatArray, + gains: FloatArray, + qs: FloatArray, + filterEnabled: BooleanArray, + ) + @JvmStatic external fun nativeIsEngaged(): Boolean + @JvmStatic external fun nativeCreate(): Long + @JvmStatic external fun nativeDestroy(handle: Long) + @JvmStatic external fun nativeConfigure(handle: Long, sampleRate: Int, channels: Int) + @JvmStatic external fun nativeReset(handle: Long) + @JvmStatic external fun nativeProcessDirect( + handle: Long, + buffer: ByteBuffer, + offset: Int, + length: Int, + encoding: Int, + ): Boolean + @JvmStatic external fun nativeProcessArray( + handle: Long, + array: ByteArray, + offset: Int, + length: Int, + encoding: Int, + ): Boolean +} + +/** + * In-place parametric EQ for the direct routes (USB exclusive, internal + * direct output, Bit-Perfect AudioTrack), where PCM never enters the native + * Float32 DSP chain. Works in the source packing so the route keeps its format. + * Renderer thread only. + */ +class DirectPcmEq { + private var handle = 0L + private var encoding: Int? = null + private var frameBytes = 0 + + fun configure(sampleRate: Int, channels: Int, media3Encoding: Int) { + if (!ParametricEqNative.isAvailable) return + if (handle == 0L) handle = runCatching { ParametricEqNative.nativeCreate() }.getOrDefault(0L) + if (handle == 0L) return + encoding = ParametricEqNative.nativeEncodingOf(media3Encoding) + frameBytes = when (encoding) { + 0 -> 2 + 1 -> 3 + 2, 3 -> 4 + else -> 0 + } * channels.coerceAtLeast(1) + runCatching { ParametricEqNative.nativeConfigure(handle, sampleRate, channels.coerceIn(1, 2)) } + if (channels !in 1..2) encoding = null + } + + fun reset() { + if (handle != 0L) runCatching { ParametricEqNative.nativeReset(handle) } + } + + /** Applies the EQ to buffer[position, limit). Returns true when samples changed. */ + fun process(buffer: ByteBuffer): Boolean { + val enc = encoding ?: return false + if (handle == 0L || frameBytes <= 0 || !buffer.hasRemaining()) return false + val start = buffer.position() + val length = (buffer.remaining() / frameBytes) * frameBytes + if (length <= 0) return false + return runCatching { + when { + buffer.isDirect -> ParametricEqNative.nativeProcessDirect(handle, buffer, start, length, enc) + buffer.hasArray() && !buffer.isReadOnly -> ParametricEqNative.nativeProcessArray( + handle, + buffer.array(), + buffer.arrayOffset() + start, + length, + enc, + ) + else -> false + } + }.getOrDefault(false) + } + + fun release() { + if (handle != 0L) { + runCatching { ParametricEqNative.nativeDestroy(handle) } + handle = 0L + } + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt index d678c80b3..388227b64 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/PlayerHost.kt @@ -4019,6 +4019,7 @@ private fun PlayerUtilityControls(state: MusicPlayerState, player: MusicPlayer, signalPath.usbExclusiveActive, signalPath.bypassRequested, signalPath.internalSession, + signalPath.parametricEqActive, modeBitPerfect, ) { val base = qualityBadgeLabel(state) @@ -4036,7 +4037,17 @@ private fun PlayerUtilityControls(state: MusicPlayerState, player: MusicPlayer, signalPath.bypassRequested && !signalPath.usbExclusiveActive -> modeMixer else -> null } - if (mode != null) "$withRate · $mode" else withRate + // AutoEQ: the correction is LastWave's own, so name the route + EQ. + val shownMode = if (signalPath.parametricEqActive) { + when { + signalPath.internalDirectActive -> "$modeDirect + EQ" + signalPath.usbExclusiveActive -> "USB + EQ" + else -> "EQ" + } + } else { + mode + } + if (shownMode != null) "$withRate · $shownMode" else withRate } val isSpatialPill = remember(state.audioCodec) { isSpatialAudioCodec(state.audioCodec) } // Atmos gets its own spatial logo — HQ badge is only for lossless, never for spatial. diff --git a/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt b/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt index 7b540cd3c..af86b66ab 100644 --- a/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt +++ b/app/src/main/java/com/lastwave/app/ui/player/SignalPathDialog.kt @@ -499,6 +499,7 @@ fun SignalPathDialog( @Composable private fun verdictText(report: SignalPathReport): String { if (report.bitPerfect) return stringResource(R.string.signal_verdict_ok) + if (report.directToHal && report.parametricEqActive) return stringResource(R.string.signal_verdict_direct_eq) if (report.directToHal) return stringResource(R.string.signal_verdict_direct_hal) val failing = report.checks.firstOrNull { !it.passed } ?: return "" return stringResource(failing.labelRes) + @@ -522,6 +523,7 @@ private fun VerdictPill(report: SignalPathReport) { } val text = when { report.bitPerfect -> stringResource(R.string.signal_bit_perfect) + report.directToHal && report.parametricEqActive -> stringResource(R.string.signal_direct_eq) report.directToHal -> stringResource(R.string.signal_direct_hal) report.clockFallbackResampled && !report.internalDirectActive -> stringResource(R.string.signal_resampled_dac) else -> stringResource(R.string.signal_check_path) diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt index 64edbedcd..e57dfadb8 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsScreen.kt @@ -101,6 +101,7 @@ import androidx.compose.material.icons.filled.Apps import androidx.compose.material.icons.filled.AutoAwesome import androidx.compose.material.icons.filled.BubbleChart import androidx.compose.material.icons.filled.GraphicEq +import androidx.compose.material.icons.filled.Headphones import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Dashboard import androidx.compose.material.icons.filled.Lyrics @@ -494,6 +495,7 @@ fun SettingsScreen( var showQualityDialog by remember { mutableStateOf(false) } var showDownloadQualityDialog by remember { mutableStateOf(false) } var showEqSheet by remember { mutableStateOf(false) } + var showAutoEq by remember { mutableStateOf(false) } var showLyricsProviderDialog by remember { mutableStateOf(false) } var showLyricsOffsetDialog by remember { mutableStateOf(false) } var showLyricsSizeDialog by remember { mutableStateOf(false) } @@ -976,7 +978,7 @@ fun SettingsScreen( LoudnessMode.ALBUM -> "Album \u2022 Keep intentional album dynamics" else -> "Off \u2022 Play tagged tracks at original level" } - SettingsGroup(rowCount = 9) { index, position -> + SettingsGroup(rowCount = 10) { index, position -> when (index) { 0 -> SettingsToggleCard( icon = Icons.Filled.Usb, @@ -1122,6 +1124,16 @@ fun SettingsScreen( position = position, isHighlighted = (highlightedSettingId == "audio.clarity_spatial_bypass"), ) + 9 -> SettingsActionCard( + icon = Icons.Filled.Headphones, + iconContainer = MaterialTheme.colorScheme.primaryContainer, + iconTint = MaterialTheme.colorScheme.onPrimaryContainer, + title = stringResource(R.string.autoeq_title), + subtitle = com.lastwave.app.ui.settings.autoeq.autoEqSummary(), + onClick = { showAutoEq = true }, + position = position, + isHighlighted = (highlightedSettingId == "audio.autoeq"), + ) } } } @@ -2234,6 +2246,10 @@ fun SettingsScreen( ) } + if (showAutoEq) { + com.lastwave.app.ui.settings.autoeq.AutoEqDialog(onDismiss = { showAutoEq = false }) + } + // -- Experimental 15-band equalizer -- if (showEqSheet) { EqualizerSheet( diff --git a/app/src/main/java/com/lastwave/app/ui/settings/SettingsSearchIndex.kt b/app/src/main/java/com/lastwave/app/ui/settings/SettingsSearchIndex.kt index 8cd1a37c3..3be0d37f3 100644 --- a/app/src/main/java/com/lastwave/app/ui/settings/SettingsSearchIndex.kt +++ b/app/src/main/java/com/lastwave/app/ui/settings/SettingsSearchIndex.kt @@ -300,6 +300,23 @@ object SettingsSearchIndex { type = EntryType.ACTION, ) ) + add( + SettingsEntry( + id = "audio.autoeq", + title = "AutoEQ", + subtitle = "Headphone correction from the AutoEQ database, parametric EQ, per-device profiles", + keywords = listOf( + "autoeq", "auto eq", "headphone", "casque", "iem", "harman", "parametric", + "peq", "correction", "oratory1990", "crinacle", "target", + ), + icon = Icons.Filled.GraphicEq, + iconContainer = { MaterialTheme.colorScheme.primaryContainer }, + iconTint = { MaterialTheme.colorScheme.onPrimaryContainer }, + parentTab = SettingsTab.AUDIO, + section = "Output & Loudness", + type = EntryType.ACTION, + ) + ) add( SettingsEntry( id = "audio.equalizer", diff --git a/app/src/main/java/com/lastwave/app/ui/settings/autoeq/AutoEqScreen.kt b/app/src/main/java/com/lastwave/app/ui/settings/autoeq/AutoEqScreen.kt new file mode 100644 index 000000000..bf55fc107 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/settings/autoeq/AutoEqScreen.kt @@ -0,0 +1,740 @@ +package com.lastwave.app.ui.settings.autoeq + +import android.widget.Toast +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Headphones +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.lastwave.app.R +import com.lastwave.app.data.autoeq.AutoEqMeasurement +import com.lastwave.app.data.autoeq.AutoEqState +import com.lastwave.app.data.autoeq.AutoEqTargets +import com.lastwave.app.data.autoeq.PeqFilter +import com.lastwave.app.data.autoeq.PeqFilterType +import com.lastwave.app.data.autoeq.PeqMath +import com.lastwave.app.data.autoeq.PeqSettings +import java.util.Locale +import kotlin.math.ln +import kotlin.math.log10 +import kotlin.math.roundToInt + +/** Full-screen AutoEQ editor, opened from Settings → Audio. */ +@Composable +fun AutoEqDialog(onDismiss: () -> Unit) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false), + ) { + Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + AutoEqScreen(onBack = onDismiss) + } + } +} + +/** One-line status for the Settings row. */ +@Composable +fun autoEqSummary(viewModel: AutoEqViewModel = hiltViewModel()): String { + val enabled by viewModel.enabled.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + return when { + !enabled -> stringResource(R.string.autoeq_row_off) + state.isCustom -> stringResource(R.string.autoeq_row_custom, state.entry?.name ?: stringResource(R.string.autoeq_manual)) + state.entry != null -> stringResource(R.string.autoeq_row_on, state.entry!!.name) + else -> stringResource(R.string.autoeq_row_off) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AutoEqScreen(onBack: () -> Unit, viewModel: AutoEqViewModel = hiltViewModel()) { + val context = LocalContext.current + val enabled by viewModel.enabled.collectAsStateWithLifecycle() + val state by viewModel.state.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + val measurement by viewModel.measurement.collectAsStateWithLifecycle() + val device by viewModel.currentDevice.collectAsStateWithLifecycle() + val deviceProfiles by viewModel.deviceProfiles.collectAsStateWithLifecycle() + val deviceLabels by viewModel.deviceLabels.collectAsStateWithLifecycle() + val autoPerDevice by viewModel.autoPerDevice.collectAsStateWithLifecycle() + var searchOpen by remember { mutableStateOf(false) } + var expandedFilter by remember { mutableStateOf(-1) } + + LaunchedEffect(Unit) { + viewModel.messages.collect { res -> Toast.makeText(context, context.getString(res), Toast.LENGTH_SHORT).show() } + } + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 16.dp, top = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.common_close)) + } + Text( + stringResource(R.string.autoeq_title), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Switch(checked = enabled, onCheckedChange = viewModel::setEnabled) + } + if (busy) LinearProgressIndicator(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)) + } + + item { ProfileCard(state, onSearch = { searchOpen = true }, viewModel = viewModel) } + + item { + SectionCard { + Text(stringResource(R.string.autoeq_curve_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(8.dp)) + ResponseChart(state = state, measurement = measurement, targetDelta = viewModel.targetDelta(state)) + } + } + + if (state.base != null) { + item { TargetCard(state = state, viewModel = viewModel) } + } + + item { + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.autoeq_filters_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + if (state.isCustom) { + AssistChip(onClick = {}, label = { Text(stringResource(R.string.autoeq_custom)) }) + } + } + Text( + stringResource(R.string.autoeq_filters_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + PreampRow(state.settings, viewModel) + if (state.settings.filters.isEmpty()) { + TextButton(onClick = { viewModel.startManual() }) { Text(stringResource(R.string.autoeq_manual_start)) } + } + } + } + itemsIndexed(state.settings.filters) { index, filter -> + FilterRow( + index = index, + filter = filter, + expanded = expandedFilter == index, + onToggleExpanded = { expandedFilter = if (expandedFilter == index) -1 else index }, + onChange = { viewModel.updateFilter(index, it) }, + onDelete = { + expandedFilter = -1 + viewModel.removeFilter(index) + }, + ) + } + if (state.settings.filters.isNotEmpty() && state.settings.filters.size < PeqSettings.MAX_FILTERS) { + item { + OutlinedButton(onClick = { viewModel.addFilter() }, modifier = Modifier.padding(horizontal = 16.dp)) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.autoeq_add_filter)) + } + } + } + + item { + SectionCard { + Text(stringResource(R.string.autoeq_devices_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.autoeq_devices_auto), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Switch(checked = autoPerDevice, onCheckedChange = viewModel::setAutoPerDevice) + } + val current = device + if (current != null) { + FilledTonalButton(onClick = { viewModel.saveForCurrentDevice() }, enabled = state.settings.filters.isNotEmpty()) { + Icon(Icons.Filled.Headphones, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.autoeq_devices_save, current.label), maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + deviceProfiles.forEach { (key, saved) -> + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.weight(1f)) { + Text(deviceLabels[key] ?: key, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + Text( + profileLabel(saved), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = { viewModel.forgetDevice(key) }) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.autoeq_devices_forget)) + } + } + } + } + } + + item { + Text( + stringResource(R.string.autoeq_attribution, state.entry?.subtitle ?: "—"), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + ) + Spacer(Modifier.height(24.dp)) + } + } + + if (searchOpen) { + SearchSheet(viewModel = viewModel, onDismiss = { searchOpen = false }) + } +} + +@Composable +private fun profileLabel(state: AutoEqState): String { + val name = state.entry?.name ?: stringResource(R.string.autoeq_manual) + return if (state.isCustom) "$name · ${stringResource(R.string.autoeq_custom)}" else name +} + +@Composable +private fun SectionCard(content: @Composable () -> Unit) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + ) { + Column(Modifier.padding(16.dp)) { content() } + } +} + +@Composable +private fun ProfileCard(state: AutoEqState, onSearch: () -> Unit, viewModel: AutoEqViewModel) { + SectionCard { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Filled.Headphones, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + state.entry?.name ?: if (state.settings.filters.isNotEmpty()) { + stringResource(R.string.autoeq_manual) + } else { + stringResource(R.string.autoeq_no_profile) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + state.entry?.let { + Text(it.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (state.isCustom) { + Text( + stringResource(R.string.autoeq_custom_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onSearch) { + Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.autoeq_choose)) + } + if (state.isCustom && state.base != null) { + OutlinedButton(onClick = { viewModel.resetToProfile() }) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.autoeq_reset)) + } + } + } + } +} + +@Composable +private fun TargetCard(state: AutoEqState, viewModel: AutoEqViewModel) { + SectionCard { + Text(stringResource(R.string.autoeq_target_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text( + stringResource(R.string.autoeq_target_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + var menuOpen by remember { mutableStateOf(false) } + val currentName = if (state.targetId == AutoEqTargets.DEFAULT_TARGET_ID) { + stringResource(R.string.autoeq_target_default) + } else { + viewModel.targets.firstOrNull { it.id == state.targetId }?.name ?: state.targetId + } + Box { + OutlinedButton(onClick = { menuOpen = true }, modifier = Modifier.fillMaxWidth()) { + Text(currentName, modifier = Modifier.weight(1f), maxLines = 1, overflow = TextOverflow.Ellipsis) + Icon(Icons.Filled.ExpandMore, contentDescription = null) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.autoeq_target_default)) }, + onClick = { + menuOpen = false + viewModel.setTarget(AutoEqTargets.DEFAULT_TARGET_ID) + }, + ) + viewModel.targets.forEach { target -> + DropdownMenuItem( + text = { + Text( + if (target.family == "in") "${target.name} ${stringResource(R.string.autoeq_target_iem_suffix)}" else target.name, + ) + }, + onClick = { + menuOpen = false + viewModel.setTarget(target.id) + }, + ) + } + } + } + Spacer(Modifier.height(10.dp)) + AdjustSlider( + label = stringResource(R.string.autoeq_bass), + value = state.bassDb, + range = -6.0..12.0, + step = 0.5, + format = { "%+.1f dB".format(Locale.ROOT, it) }, + onCommit = { viewModel.setBass(it) }, + ) + AdjustSlider( + label = stringResource(R.string.autoeq_tilt), + value = state.tiltDbPerOctave, + range = -1.0..1.0, + step = 0.1, + format = { "%+.1f dB/oct".format(Locale.ROOT, it) }, + onCommit = { viewModel.setTilt(it) }, + ) + } +} + +@Composable +private fun AdjustSlider( + label: String, + value: Double, + range: ClosedFloatingPointRange, + step: Double, + format: (Double) -> String, + onCommit: (Double) -> Unit, +) { + var local by remember(value) { mutableFloatStateOf(value.toFloat()) } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text(format(local.toDouble()), style = MaterialTheme.typography.labelLarge) + } + Slider( + value = local, + onValueChange = { local = (PeqMath.roundTo(it.toDouble(), step)).toFloat() }, + onValueChangeFinished = { onCommit(local.toDouble()) }, + valueRange = range.start.toFloat()..range.endInclusive.toFloat(), + ) +} + +@Composable +private fun PreampRow(settings: PeqSettings, viewModel: AutoEqViewModel) { + var local by remember(settings.preampDb) { mutableFloatStateOf(settings.preampDb.toFloat()) } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.autoeq_preamp), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + Text("%+.1f dB".format(Locale.ROOT, local), style = MaterialTheme.typography.labelLarge) + TextButton(onClick = { viewModel.autoPreamp() }) { Text(stringResource(R.string.autoeq_preamp_auto)) } + } + Slider( + value = local, + onValueChange = { local = PeqMath.roundTo(it.toDouble(), 0.1).toFloat() }, + onValueChangeFinished = { viewModel.setPreamp(local.toDouble()) }, + valueRange = -24f..6f, + ) +} + +@Composable +private fun FilterRow( + index: Int, + filter: PeqFilter, + expanded: Boolean, + onToggleExpanded: () -> Unit, + onChange: (PeqFilter) -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggleExpanded), + ) { + Text("${index + 1}", style = MaterialTheme.typography.labelLarge, modifier = Modifier.width(24.dp)) + Text( + summary(filter), + style = MaterialTheme.typography.bodyMedium, + color = if (filter.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + Switch(checked = filter.enabled, onCheckedChange = { onChange(filter.copy(enabled = it)) }) + Icon( + if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = null, + modifier = Modifier.padding(start = 4.dp), + ) + } + if (expanded) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + PeqFilterType.entries.forEach { type -> + val selected = type == filter.type + TextButton(onClick = { onChange(filter.copy(type = type)) }) { + Text( + type.code, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + LogSlider( + label = stringResource(R.string.autoeq_frequency), + value = filter.frequencyHz, + min = 20.0, + max = 20_000.0, + format = { formatHz(it) }, + onCommit = { onChange(filter.copy(frequencyHz = PeqMath.roundTo(it, 1.0))) }, + ) + if (filter.type.hasGain) { + var gain by remember(filter.gainDb) { mutableFloatStateOf(filter.gainDb.toFloat()) } + Row { + Text(stringResource(R.string.autoeq_gain), style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text("%+.1f dB".format(Locale.ROOT, gain), style = MaterialTheme.typography.labelMedium) + } + Slider( + value = gain, + onValueChange = { gain = PeqMath.roundTo(it.toDouble(), 0.1).toFloat() }, + onValueChangeFinished = { onChange(filter.copy(gainDb = gain.toDouble())) }, + valueRange = -20f..20f, + ) + } + LogSlider( + label = stringResource(R.string.autoeq_q), + value = filter.q, + min = 0.1, + max = 10.0, + format = { "%.2f".format(Locale.ROOT, it) }, + onCommit = { onChange(filter.copy(q = PeqMath.roundTo(it, 0.01))) }, + ) + TextButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.autoeq_remove_filter)) + } + } + } + } +} + +@Composable +private fun LogSlider( + label: String, + value: Double, + min: Double, + max: Double, + format: (Double) -> String, + onCommit: (Double) -> Unit, +) { + val lnMin = ln(min) + val lnMax = ln(max) + var position by remember(value) { + mutableFloatStateOf(((ln(value.coerceIn(min, max)) - lnMin) / (lnMax - lnMin)).toFloat()) + } + val current = kotlin.math.exp(lnMin + position * (lnMax - lnMin)) + Row { + Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text(format(current), style = MaterialTheme.typography.labelMedium) + } + Slider( + value = position, + onValueChange = { position = it }, + onValueChangeFinished = { onCommit(current) }, + ) +} + +private fun summary(f: PeqFilter): String = buildString { + append(f.type.code) + append(" · ") + append(formatHz(f.frequencyHz)) + if (f.type.hasGain) append(" · %+.1f dB".format(Locale.ROOT, f.gainDb)) + append(" · Q %.2f".format(Locale.ROOT, f.q)) +} + +private fun formatHz(hz: Double): String = + if (hz >= 1000) "%.2f kHz".format(Locale.ROOT, hz / 1000.0).replace(".00", "") else "${hz.roundToInt()} Hz" + +/** + * Correction curve (always) plus, when the lab published its measurement: + * raw response, chosen target and the corrected result. + */ +@Composable +private fun ResponseChart(state: AutoEqState, measurement: AutoEqMeasurement?, targetDelta: List?) { + val eqColor = MaterialTheme.colorScheme.primary + val rawColor = MaterialTheme.colorScheme.outline + val targetColor = MaterialTheme.colorScheme.tertiary + val resultColor = MaterialTheme.colorScheme.secondary + val gridColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f) + val grid = PeqMath.LOG_GRID + + val eqCurve = remember(state.settings) { + grid.map { f -> PeqMath.filtersDb(state.settings.filters, f) } + } + val series = remember(state.settings, measurement, targetDelta) { + val m = measurement ?: return@remember null + val freqs = m.frequencies + val delta = targetDelta?.takeIf { state.targetId != AutoEqTargets.DEFAULT_TARGET_ID } + val target = freqs.indices.map { i -> + val t = m.target[i] + if (t.isNaN()) Double.NaN else t + (delta?.let { interpolate(grid, it, freqs[i]) } ?: 0.0) + + PeqMath.bassShelfDb(state.bassDb, freqs[i]) + PeqMath.tiltDb(state.tiltDbPerOctave, freqs[i]) + } + val corrected = freqs.indices.map { i -> m.raw[i] + PeqMath.filtersDb(state.settings.filters, freqs[i]) } + Triple(m.raw.toList(), target, corrected) + } + + Canvas(modifier = Modifier.fillMaxWidth().height(200.dp)) { + val values = buildList { + addAll(eqCurve) + series?.let { (raw, target, corrected) -> + addAll(raw) + addAll(target.filter { !it.isNaN() }) + addAll(corrected) + } + }.filter { it.isFinite() } + val top = ((values.maxOrNull() ?: 6.0).coerceAtLeast(6.0) + 2.0).coerceAtMost(30.0) + val bottom = ((values.minOrNull() ?: -6.0).coerceAtMost(-6.0) - 2.0).coerceAtLeast(-30.0) + val logMin = log10(20.0) + val logMax = log10(20_000.0) + fun x(f: Double) = ((log10(f) - logMin) / (logMax - logMin) * size.width).toFloat() + fun y(db: Double) = ((top - db) / (top - bottom) * size.height).toFloat() + + listOf(100.0, 1000.0, 10_000.0).forEach { f -> + drawLine(gridColor, Offset(x(f), 0f), Offset(x(f), size.height), strokeWidth = 1f) + } + drawLine(gridColor, Offset(0f, y(0.0)), Offset(size.width, y(0.0)), strokeWidth = 1.5f) + + fun drawSeries(freqs: DoubleArray, values: List, color: Color, width: Float, dashed: Boolean = false) { + val path = Path() + var started = false + freqs.indices.forEach { i -> + val v = values.getOrNull(i) ?: return@forEach + if (!v.isFinite() || freqs[i] < 20.0 || freqs[i] > 20_000.0) return@forEach + val px = x(freqs[i]) + val py = y(v) + if (!started) { + path.moveTo(px, py) + started = true + } else { + path.lineTo(px, py) + } + } + drawPath( + path, + color, + style = Stroke(width = width, pathEffect = if (dashed) PathEffect.dashPathEffect(floatArrayOf(10f, 8f)) else null), + ) + } + + series?.let { (raw, target, corrected) -> + val freqs = measurement!!.frequencies + drawSeries(freqs, raw, rawColor, 2f) + drawSeries(freqs, target, targetColor, 2.5f, dashed = true) + drawSeries(freqs, corrected, resultColor, 2.5f) + } + drawSeries(grid, eqCurve, eqColor, 4f) + } + Spacer(Modifier.height(6.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Legend(stringResource(R.string.autoeq_legend_eq), eqColor) + if (series != null) { + Legend(stringResource(R.string.autoeq_legend_raw), rawColor) + Legend(stringResource(R.string.autoeq_legend_target), targetColor) + Legend(stringResource(R.string.autoeq_legend_result), resultColor) + } + } +} + +@Composable +private fun Legend(label: String, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Canvas(Modifier.size(10.dp)) { drawCircle(color) } + Spacer(Modifier.width(4.dp)) + Text(label, style = MaterialTheme.typography.labelSmall) + } +} + +private fun interpolate(grid: DoubleArray, values: List, f: Double): Double { + if (values.isEmpty()) return 0.0 + if (f <= grid.first()) return values.first() + if (f >= grid.last()) return values.last() + val position = ln(f / grid.first()) / ln(2.0) * 12.0 + val i = position.toInt().coerceIn(0, values.size - 2) + val t = position - i + return values[i] + (values[i + 1] - values[i]) * t +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchSheet(viewModel: AutoEqViewModel, onDismiss: () -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val query by viewModel.query.collectAsStateWithLifecycle() + val results by viewModel.results.collectAsStateWithLifecycle() + val indexState by viewModel.indexState.collectAsStateWithLifecycle() + val busy by viewModel.busy.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { viewModel.loadIndex() } + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp)) { + Text(stringResource(R.string.autoeq_search_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = query, + onValueChange = viewModel::setQuery, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + placeholder = { Text(stringResource(R.string.autoeq_search_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + when (indexState) { + AutoEqViewModel.IndexState.LOADING -> Row(verticalAlignment = Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.autoeq_search_loading), style = MaterialTheme.typography.bodySmall) + } + AutoEqViewModel.IndexState.ERROR -> Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.autoeq_search_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = { viewModel.loadIndex(force = true) }) { Text(stringResource(R.string.autoeq_retry)) } + } + else -> Unit + } + if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(min = 200.dp, max = 520.dp)) { + items(results, key = { it.encodedPath }) { entry -> + Column( + Modifier + .fillMaxWidth() + .clickable(enabled = !busy) { viewModel.select(entry, onDone = onDismiss) } + .padding(vertical = 10.dp), + ) { + Text(entry.name, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium) + Text(entry.subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + HorizontalDivider() + } + } + if (query.isNotBlank() && results.isEmpty() && indexState == AutoEqViewModel.IndexState.READY) { + Text( + stringResource(R.string.autoeq_search_empty), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(vertical = 12.dp), + ) + } + Spacer(Modifier.height(24.dp)) + } + } +} diff --git a/app/src/main/java/com/lastwave/app/ui/settings/autoeq/AutoEqViewModel.kt b/app/src/main/java/com/lastwave/app/ui/settings/autoeq/AutoEqViewModel.kt new file mode 100644 index 000000000..d34965158 --- /dev/null +++ b/app/src/main/java/com/lastwave/app/ui/settings/autoeq/AutoEqViewModel.kt @@ -0,0 +1,163 @@ +package com.lastwave.app.ui.settings.autoeq + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.lastwave.app.R +import com.lastwave.app.data.autoeq.AutoEqCatalog +import com.lastwave.app.data.autoeq.AutoEqController +import com.lastwave.app.data.autoeq.AutoEqEntry +import com.lastwave.app.data.autoeq.AutoEqIndexParser +import com.lastwave.app.data.autoeq.AutoEqMeasurement +import com.lastwave.app.data.autoeq.AutoEqState +import com.lastwave.app.data.autoeq.AutoEqTarget +import com.lastwave.app.data.autoeq.OutputDevice +import com.lastwave.app.data.autoeq.PeqFilter +import com.lastwave.app.data.autoeq.PeqFilterType +import com.lastwave.app.data.autoeq.PeqMath +import com.lastwave.app.data.autoeq.PeqSettings +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AutoEqViewModel @Inject constructor( + private val controller: AutoEqController, + private val catalog: AutoEqCatalog, +) : ViewModel() { + val enabled: StateFlow = controller.enabled + val state: StateFlow = controller.state + val busy: StateFlow = controller.busy + val currentDevice: StateFlow = controller.currentDevice + val deviceProfiles: StateFlow> = controller.deviceProfiles + val deviceLabels: StateFlow> = controller.knownDeviceLabels + val autoPerDevice: StateFlow = controller.autoPerDevice + val targets: List = controller.availableTargets() + + private val _measurement = MutableStateFlow(null) + val measurement: StateFlow = _measurement.asStateFlow() + + private val _query = MutableStateFlow("") + val query: StateFlow = _query.asStateFlow() + private val _results = MutableStateFlow>(emptyList()) + val results: StateFlow> = _results.asStateFlow() + private val _indexState = MutableStateFlow(IndexState.IDLE) + val indexState: StateFlow = _indexState.asStateFlow() + private var entries: List = emptyList() + private var searchJob: Job? = null + + private val _messages = MutableSharedFlow(extraBufferCapacity = 4) + val messages: SharedFlow = _messages.asSharedFlow() + + enum class IndexState { IDLE, LOADING, READY, ERROR } + + init { + viewModelScope.launch { + state.map { it.entry }.distinctUntilChanged().collect { entry -> + _measurement.value = null + if (entry != null) _measurement.value = controller.measurement(entry) + } + } + } + + fun loadIndex(force: Boolean = false) { + if (_indexState.value == IndexState.LOADING) return + if (_indexState.value == IndexState.READY && !force) return + _indexState.value = IndexState.LOADING + viewModelScope.launch { + try { + entries = catalog.entries(forceRefresh = force) + _indexState.value = IndexState.READY + runSearch(_query.value) + } catch (cancel: CancellationException) { + throw cancel + } catch (_: Exception) { + _indexState.value = IndexState.ERROR + } + } + } + + fun setQuery(text: String) { + _query.value = text + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(150) + runSearch(text) + } + } + + private fun runSearch(text: String) { + _results.value = AutoEqIndexParser.search(entries, text) + } + + fun select(entry: AutoEqEntry, onDone: () -> Unit) { + viewModelScope.launch { + val result = controller.selectProfile(entry) + if (result.isSuccess) { + onDone() + _messages.tryEmit(R.string.autoeq_msg_loaded) + } else { + _messages.tryEmit(R.string.autoeq_msg_download_failed) + } + } + } + + fun setEnabled(on: Boolean) { + controller.setEnabled(on) + } + + fun setAutoPerDevice(on: Boolean) { + controller.setAutoPerDevice(on) + } + + fun setTarget(id: String) = viewModelScope.launch { controller.setTarget(id) } + fun setBass(db: Double) = viewModelScope.launch { controller.setBass(db) } + fun setTilt(value: Double) = viewModelScope.launch { controller.setTilt(value) } + fun resetToProfile() = viewModelScope.launch { controller.resetToProfile() } + fun startManual() = viewModelScope.launch { controller.startManual() } + fun clearProfile() = viewModelScope.launch { controller.clearProfile() } + + fun updateFilter(index: Int, filter: PeqFilter) = editSettings { settings -> + if (index !in settings.filters.indices) return@editSettings settings + settings.copy(filters = settings.filters.toMutableList().apply { set(index, filter) }) + } + + fun addFilter() = editSettings { settings -> + if (settings.filters.size >= PeqSettings.MAX_FILTERS) return@editSettings settings + settings.copy(filters = settings.filters + PeqFilter(PeqFilterType.PEAKING, 1000.0, 0.0, 1.0)) + } + + fun removeFilter(index: Int) = editSettings { settings -> + if (index !in settings.filters.indices) return@editSettings settings + settings.copy(filters = settings.filters.toMutableList().apply { removeAt(index) }) + } + + fun setPreamp(db: Double) = editSettings { it.copy(preampDb = PeqMath.roundTo(db, 0.1)) } + + fun autoPreamp() = editSettings { it.copy(preampDb = PeqMath.autoPreampDb(it.filters)) } + + fun saveForCurrentDevice() = viewModelScope.launch { + controller.saveForCurrentDevice() + _messages.tryEmit(R.string.autoeq_msg_device_saved) + } + + fun forgetDevice(key: String) = viewModelScope.launch { controller.forgetDevice(key) } + + fun targetDelta(state: AutoEqState): List? = controller.targetDelta(state) + + private fun editSettings(change: (PeqSettings) -> PeqSettings) = viewModelScope.launch { + val current = state.value.settings + val next = change(current) + if (next != current) controller.setCustomSettings(next) + } +} diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 171c0df34..e058d4054 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1074,4 +1074,54 @@ Citron vert LastWave Sélectionnée, ligne %1$d sur %2$d Ligne %1$d sur %2$d + + AutoEQ + Désactivé \u2022 Corrige ton casque avec la base AutoEQ + Activé \u2022 %1$s + Activé \u2022 %1$s (personnalisé) + EQ manuel + Aucun casque choisi + Custom + Modifié à la main. Re-choisis le casque pour retrouver le profil AutoEQ. + Choisir un casque + Revenir au profil + Réponse en fréquence + Cible + Les autres cibles sont appliquées comme un écart par rapport à la cible Harman du labo, puis réajustées. + Cible du labo (AutoEQ par défaut) + (intra) + Basses + Inclinaison + EQ paramétrique + Touche un filtre pour le modifier. Toute modification passe en Custom. + Démarrer un EQ manuel + Préampli + Auto + Fréquence + Gain + Q + Ajouter un filtre + Supprimer le filtre + Par appareil + Charger le profil de chaque appareil quand il se connecte + Utiliser pour %1$s + Oublier l\'appareil + Profils : AutoEQ par Jaakko Pasanen (MIT), mesures : %1$s. Téléchargés depuis GitHub et gardés hors ligne. + EQ + Mesuré + Cible + Corrigé + Choisis ton casque + Modèle, marque ou labo + Téléchargement de la liste AutoEQ\u2026 + Impossible de télécharger la liste AutoEQ. + Réessayer + Aucun profil trouvé. Essaie avec moins de mots. + Profil AutoEQ appliqué + Impossible de télécharger ce profil + Profil enregistré pour cet appareil + AutoEQ + Correction appliquée par LastWave (modifie les échantillons, mixer Android toujours contourné) + Mixer Android contourné. LastWave applique ta correction AutoEQ, puis les échantillons vont directement au HAL. + DIRECT + EQ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5e079feb..46b95ec87 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1139,4 +1139,54 @@ LastWave Lime Selected, line %1$d of %2$d Line %1$d of %2$d + + AutoEQ + Off \u2022 Correct your headphones with the AutoEQ database + On \u2022 %1$s + On \u2022 %1$s (custom) + Manual EQ + No headphone selected + Custom + Edited by hand. Choose the headphone again to get the AutoEQ profile back. + Choose headphones + Back to profile + Frequency response + Target + Other targets are applied as a difference from the lab\'s Harman-style target, then refitted. + Lab target (AutoEQ default) + (IEM) + Bass + Tilt + Parametric EQ + Tap a filter to edit it. Any change switches to Custom. + Start a manual EQ + Preamp + Auto + Frequency + Gain + Q + Add filter + Remove filter + Per device + Load each device\'s profile when it connects + Use for %1$s + Forget device + Profiles: AutoEQ by Jaakko Pasanen (MIT), measurements: %1$s. Downloaded from GitHub and kept offline. + EQ + Measured + Target + Corrected + Choose your headphones + Model, brand or reviewer + Downloading the AutoEQ list\u2026 + Couldn\'t download the AutoEQ list. + Retry + No profile matches. Try fewer words. + AutoEQ profile applied + Couldn\'t download this profile + Profile saved for this device + AutoEQ + Correction applied by LastWave (changes the samples, Android mixer still bypassed) + Android mixer bypassed. LastWave applies your AutoEQ correction, then the samples go straight to the HAL. + DIRECT + EQ diff --git a/app/src/test/java/com/lastwave/app/data/autoeq/AutoEqTest.kt b/app/src/test/java/com/lastwave/app/data/autoeq/AutoEqTest.kt new file mode 100644 index 000000000..a98fb6f22 --- /dev/null +++ b/app/src/test/java/com/lastwave/app/data/autoeq/AutoEqTest.kt @@ -0,0 +1,151 @@ +package com.lastwave.app.data.autoeq + +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import kotlin.math.abs + +class AutoEqTest { + + private val hd600 = """ + Preamp: -6.3 dB + Filter 1: ON LSC Fc 105 Hz Gain 6.5 dB Q 0.70 + Filter 2: ON PK Fc 125 Hz Gain -2.7 dB Q 0.55 + Filter 3: ON PK Fc 8445 Hz Gain 3.3 dB Q 1.61 + Filter 4: ON PK Fc 522 Hz Gain 0.7 dB Q 1.02 + Filter 5: ON PK Fc 1298 Hz Gain -1.2 dB Q 2.14 + Filter 6: ON HSC Fc 10000 Hz Gain -3.1 dB Q 0.70 + Filter 7: ON PK Fc 3158 Hz Gain -1.8 dB Q 3.67 + Filter 8: ON PK Fc 2166 Hz Gain 0.9 dB Q 3.32 + Filter 9: ON PK Fc 6639 Hz Gain 2.2 dB Q 5.82 + Filter 10: ON PK Fc 5433 Hz Gain -1.2 dB Q 5.70 + """.trimIndent() + + @Test + fun parsesAutoEqParametricFile() { + val settings = ParametricEqText.parse(hd600)!! + assertThat(settings.preampDb).isWithin(1e-9).of(-6.3) + assertThat(settings.filters).hasSize(10) + assertThat(settings.filters.first()).isEqualTo(PeqFilter(PeqFilterType.LOW_SHELF, 105.0, 6.5, 0.70)) + assertThat(settings.filters[5].type).isEqualTo(PeqFilterType.HIGH_SHELF) + // Round trip through the writer. + assertThat(ParametricEqText.parse(ParametricEqText.format(settings))).isEqualTo(settings) + } + + @Test + fun ignoresGarbageAndKeepsOffFilters() { + val text = "Preamp: -1 dB\nFilter 1: OFF PK Fc 1000 Hz Gain 3 dB Q 1\nhello\nFilter 2: ON XX Fc 1 Hz" + val settings = ParametricEqText.parse(text)!! + assertThat(settings.filters).hasSize(1) + assertThat(settings.filters[0].enabled).isFalse() + assertThat(ParametricEqText.parse("nothing here")).isNull() + } + + @Test + fun biquadMagnitudesMatchTheirDesign() { + val pk = PeqFilter(PeqFilterType.PEAKING, 1000.0, 6.0, 1.0) + assertThat(PeqMath.filterDb(pk, 1000.0)).isWithin(0.01).of(6.0) + assertThat(PeqMath.filterDb(pk, 50.0)).isWithin(0.1).of(0.0) + val ls = PeqFilter(PeqFilterType.LOW_SHELF, 105.0, 6.5, 0.7) + assertThat(PeqMath.filterDb(ls, 20.0)).isWithin(0.4).of(6.5) + assertThat(PeqMath.filterDb(ls, 5000.0)).isWithin(0.1).of(0.0) + val hs = PeqFilter(PeqFilterType.HIGH_SHELF, 10000.0, -3.0, 0.7) + assertThat(PeqMath.filterDb(hs, 19000.0)).isWithin(0.4).of(-3.0) + assertThat(PeqMath.filterDb(hs, 500.0)).isWithin(0.1).of(0.0) + // Disabled or flat filters contribute nothing. + assertThat(PeqMath.filterDb(pk.copy(enabled = false), 1000.0)).isEqualTo(0.0) + assertThat(PeqSettings(0.0, listOf(pk.copy(gainDb = 0.0))).isFlat).isTrue() + } + + @Test + fun autoPreampMatchesAutoEqWithinATenth() { + val settings = ParametricEqText.parse(hd600)!! + assertThat(PeqMath.autoPreampDb(settings.filters)).isWithin(0.15).of(-6.3) + } + + @Test + fun fitterReproducesAPublishedProfile() { + val profile = ParametricEqText.parse(hd600)!! + val desired = DoubleArray(PeqMath.LOG_GRID.size) { PeqMath.filtersDb(profile.filters, PeqMath.LOG_GRID[it]) } + val fitted = PeqFitter.fit(desired) + assertThat(fitted.filters).hasSize(10) + assertThat(PeqFitter.rmsError(fitted, desired)).isLessThan(0.5) + assertThat(fitted.preampDb).isAtMost(0.0) + } + + @Test + fun fitterFollowsABassAndTiltChange() { + val profile = ParametricEqText.parse(hd600)!! + val grid = PeqMath.LOG_GRID + val desired = DoubleArray(grid.size) { + PeqMath.filtersDb(profile.filters, grid[it]) + PeqMath.bassShelfDb(4.0, grid[it]) + PeqMath.tiltDb(-0.5, grid[it]) + } + val fitted = PeqFitter.fit(desired) + assertThat(PeqFitter.rmsError(fitted, desired)).isLessThan(0.8) + // Bass really went up at 30 Hz compared to the original profile. + val before = PeqMath.filtersDb(profile.filters, 30.0) + val after = PeqMath.filtersDb(fitted.filters, 30.0) + assertThat(after - before).isGreaterThan(3.0) + } + + @Test + fun parsesIndexLines() { + val md = """ + # Index + - [1MORE Aero (ANC Off)](./HypetheSonics/GRAS%20RA0045%20in-ear/1MORE%20Aero%20(ANC%20Off)) by HypetheSonics on GRAS RA0045 + - [Sennheiser HD 600](./oratory1990/over-ear/Sennheiser%20HD%20600) by oratory1990 + - [AKG K371](./crinacle/GRAS%2043AG-7%20over-ear/AKG%20K371) by crinacle on GRAS 43AG-7 + not a line + """.trimIndent() + val entries = AutoEqIndexParser.parse(md) + assertThat(entries).hasSize(3) + val aero = entries[0] + assertThat(aero.name).isEqualTo("1MORE Aero (ANC Off)") + assertThat(aero.source).isEqualTo("HypetheSonics") + assertThat(aero.rig).isEqualTo("GRAS RA0045") + assertThat(aero.folderName).isEqualTo("1MORE Aero (ANC Off)") + assertThat(aero.formFactor).isEqualTo(FormFactor.IN_EAR) + val hd = entries[1] + assertThat(hd.rig).isNull() + assertThat(hd.formFactor).isEqualTo(FormFactor.OVER_EAR) + assertThat(AutoEqIndexParser.encodeSegment("${hd.folderName} ParametricEQ.txt")) + .isEqualTo("Sennheiser%20HD%20600%20ParametricEQ.txt") + val found = AutoEqIndexParser.search(entries, "hd 600") + assertThat(found.map { it.name }).containsExactly("Sennheiser HD 600") + assertThat(AutoEqIndexParser.search(entries, "crinacle").single().name).isEqualTo("AKG K371") + } + + @Test + fun parsesMeasurementCsv() { + val csv = "frequency,raw,smoothed,error,error_smoothed,equalization,parametric_eq,fixed_band_eq,equalized_raw,equalized_smoothed,target\n" + + "20.00,-7.88,-7.89,-11.31,-11.32,6.00,6.29,2.78,-1.88,-1.89,3.43\n" + + "20.20,-7.85,-7.85,-11.31,-11.31,6.00,6.29,2.86,-1.85,-1.85,3.46\n" + + "20.40,-7.80,-7.85,-11.31,-11.31,6.00,6.29,2.86,-1.85,-1.85,3.50\n" + val m = AutoEqIndexParser.parseMeasurementCsv(csv)!! + assertThat(m.frequencies.toList()).containsExactly(20.0, 20.2, 20.4).inOrder() + assertThat(m.raw[0]).isWithin(1e-9).of(-7.88) + assertThat(m.target[2]).isWithin(1e-9).of(3.50) + } + + @Test + fun logGridMatchesTheBundledTargets() { + assertThat(PeqMath.LOG_GRID).hasLength(121) + assertThat(PeqMath.LOG_GRID.first()).isWithin(1e-9).of(20.0) + assertThat(abs(PeqMath.LOG_GRID[120] - 20480.0)).isLessThan(0.01) + } + + @Test + fun bundledTargetsAreOnTheSameGrid() { + val file = java.io.File("src/main/assets/autoeq_targets.json") + val parsed = kotlinx.serialization.json.Json { ignoreUnknownKeys = true } + .decodeFromString(AutoEqTargetFile.serializer(), file.readText()) + assertThat(parsed.frequencies).hasSize(PeqMath.LOG_GRID.size) + parsed.frequencies.forEachIndexed { i, f -> assertThat(f).isWithin(0.01).of(PeqMath.LOG_GRID[i]) } + assertThat(parsed.targets.size).isAtLeast(10) + parsed.targets.forEach { t -> + assertThat(t.deltaOverEar).hasSize(121) + assertThat(t.deltaInEar).hasSize(121) + } + // The over-ear reference has no delta against itself. + assertThat(parsed.targets.first { it.id == "harman_oe_2018" }.deltaOverEar.maxOf { abs(it) }).isLessThan(0.01) + } +}