Skip to content

Commit 8e3cb46

Browse files
committed
audio: eq_iir: tune: add sof_ucm2_eq_generate
Add a tuning script that turns a measured endpoint frequency response into a pair of IIR + FIR blobs and a UCM2 include file laid out the way alsa-ucm-conf expects. The IIR is a five-biquad parametric (HP2 + low-Q mid PN2 + LS2 + two fine PN2 correctors) fit stage-by-stage with fminsearch, and the residual mid-band delta is picked up by a short minimum-phase FIR. The script takes the DMI sys_vendor and product_name from /sys/devices/virtual/dmi/id and an endpoint name, and produces: ucm2_blobs_sof/ipc4/eq_iir/<endpoint>_<vendor>_<product>_iir.{txt,bin} ucm2_blobs_sof/ipc4/eq_fir/<endpoint>_<vendor>_<product>_fir.{txt,bin} ucm2_blobs_sof/product_configs/<SYS_VENDOR>/<PRODUCT_NAME>.conf The blob file names are lowercased and non-alphanumeric runs are folded to underscores; the vendor and product directory names are kept verbatim to match the existing alsa-ucm-conf tree. The .conf file follows the alsa-ucm-conf sof/product_configs style with a biquad summary in comments and the two Define.PostMixer<Endpoint>Playback{Iir,Fir}Blob keys pointing at the installed /usr/share/alsa/ucm2/blobs/sof/... paths. Measurement input is a numeric grid with column 1 = frequency in Hz and one or more magnitude columns in dB that are averaged. Plain text and Excel/ODS workbooks are both accepted, dispatched by file extension. The sof_ucm2_eq_example.{txt,xlsx} files added in the previous commit let the script be run without external data: sof_ucm2_eq_generate('example', 'example', 'speaker', \ 'sof_ucm2_eq_example.txt'); sof_ucm2_eq_generate('example', 'example', 'speaker', \ 'sof_ucm2_eq_example.xlsx'); Signed-off-by: Seppo Ingalsuo <seppo.ingalsuo@linux.intel.com>
1 parent 9cb3d59 commit 8e3cb46

1 file changed

Lines changed: 389 additions & 0 deletions

File tree

Lines changed: 389 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,389 @@
1+
function sof_ucm2_eq_generate(sys_vendor, product_name, endpoint, meas_file)
2+
3+
% SOF_UCM2_EQ_GENERATE Fit IIR + FIR endpoint EQ from a measurement.
4+
%
5+
% sof_ucm2_eq_generate(SYS_VENDOR, PRODUCT_NAME, ENDPOINT, MEAS_FILE)
6+
%
7+
% SYS_VENDOR DMI /sys/devices/virtual/dmi/id/sys_vendor value, e.g.
8+
% 'Acme Ltd.'. Kept verbatim (with spaces and case) for the
9+
% product_configs directory name.
10+
% PRODUCT_NAME DMI product_name value, e.g. 'Model 100'. Kept
11+
% verbatim for the .conf file name.
12+
% ENDPOINT Endpoint being tuned, e.g. 'speaker' or 'headphone'. Used
13+
% lower case in blob file names and capitalized in the
14+
% Define.PostMixer<Endpoint>Playback... UCM keys.
15+
% MEAS_FILE Path to a comma separated numbers text file whose first
16+
% column is the measurement frequency in Hz and whose
17+
% remaining columns are one or more measured magnitude
18+
% traces in dB. Multiple traces are averaged.
19+
% Such a file can be produced with sof_mls_freq_resp.m.
20+
% Excel workbooks (.xls, .xlsx, .xlsm) and OpenDocument
21+
% spreadsheets (.ods) are also accepted; the format is
22+
% detected from the file extension and imported via
23+
% xlsread from the Octave 'io' package. The workbook
24+
% is expected to contain the same [freq, resp...] numeric
25+
% layout with no header row, matching
26+
% sof_ucm2_eq_example.xlsx.
27+
%
28+
% The IIR and FIR blobs are written to
29+
% ./ucm2_blobs_sof/ipc4/eq_iir/<endpoint>_<vendor>_<product>_iir.{txt,bin}
30+
% ./ucm2_blobs_sof/ipc4/eq_fir/<endpoint>_<vendor>_<product>_fir.{txt,bin}
31+
%
32+
% and a UCM include file that will be picked up automatically by UCM is
33+
% written to
34+
% ./ucm2_blobs_sof/product_configs/<SYS_VENDOR>/<PRODUCT_NAME>.conf
35+
%
36+
% Run this to see a design example and produced configuration for ALSA UCMv2
37+
% sof_ucm2_eq_generate('example', 'example', 'speaker', 'sof_ucm2_eq_example.txt');
38+
% or
39+
% sof_ucm2_eq_generate('example', 'example', 'speaker', 'sof_ucm2_eq_example.xlsx');
40+
41+
% SPDX-License-Identifier: BSD-3-Clause
42+
%
43+
% Copyright (c) 2026, Intel Corporation.
44+
45+
if nargin < 4
46+
help sof_ucm2_eq_generate
47+
printf('\n');
48+
error('Usage: sof_ucm2_eq_generate(sys_vendor, product_name, endpoint, meas_file)');
49+
end
50+
51+
%% Load the signal package up front so the script fails cleanly on a
52+
%% system missing it, before any output directories are created. The io
53+
%% package is only pulled in when the measurement file is a spreadsheet
54+
%% (see load_measurement below), so text-only users do not need it.
55+
pkg load signal;
56+
57+
%% Derive blob base name and output paths from the DMI info + endpoint.
58+
endpoint_lc = lower(endpoint);
59+
base = sprintf('%s_%s_%s', endpoint_lc, sanitize_name(sys_vendor), ...
60+
sanitize_name(product_name));
61+
out_root = 'ucm2_blobs_sof';
62+
cpath4 = fullfile(out_root, 'ipc4');
63+
iir_txt = fullfile('eq_iir', [base '_iir.txt']);
64+
iir_bin = fullfile('eq_iir', [base '_iir.bin']);
65+
fir_txt = fullfile('eq_fir', [base '_fir.txt']);
66+
fir_bin = fullfile('eq_fir', [base '_fir.bin']);
67+
conf_dir = fullfile(out_root, 'product_configs', sys_vendor);
68+
conf_file = fullfile(conf_dir, [product_name '.conf']);
69+
ensure_dir(fullfile(cpath4, 'eq_iir'));
70+
ensure_dir(fullfile(cpath4, 'eq_fir'));
71+
ensure_dir(conf_dir);
72+
73+
sof_eq_paths(true);
74+
75+
%% Base equalizer setup
76+
eq = sof_eq_defaults();
77+
eq.fs = 48e3;
78+
eq.enable_iir = 1;
79+
eq.enable_fir = 1;
80+
eq.iir_norm_type = 'loudness';
81+
eq.iir_norm_offs_db = -1;
82+
eq.fir_norm_type = 'loudness';
83+
eq.fir_norm_offs_db = -1;
84+
eq.p_fmin = 20;
85+
eq.p_fmax = 20e3;
86+
87+
%% Load measurement, fit IIR + FIR, compute the final response and export
88+
eq = load_measurement(eq, meas_file);
89+
eq = design_iir_stages(eq);
90+
eq = configure_fir(eq);
91+
eq = sof_eq_compute(eq);
92+
sof_eq_plot(eq, 1);
93+
export_blobs(eq, cpath4, iir_txt, iir_bin, fir_txt, fir_bin);
94+
write_product_conf(conf_file, sys_vendor, product_name, endpoint, base, eq);
95+
96+
sof_eq_paths(false);
97+
98+
end
99+
100+
%% -----------------------------------------------------------------------
101+
%% Measurement loading
102+
%% -----------------------------------------------------------------------
103+
function eq = load_measurement(eq, meas_file)
104+
if ~exist(meas_file, 'file')
105+
error('Measurement file not found: %s', meas_file);
106+
end
107+
[~, ~, ext] = fileparts(meas_file);
108+
switch lower(ext)
109+
case {'.xls', '.xlsx', '.xlsm', '.ods'}
110+
pkg load io;
111+
meas = xlsread(meas_file);
112+
otherwise
113+
%% dlmread with no explicit delimiter auto-detects whitespace or
114+
%% comma separation, which covers both the sof_mls_freq_resp.m
115+
%% output and legacy whitespace-delimited measurement files.
116+
meas = dlmread(meas_file);
117+
end
118+
if size(meas, 2) < 2
119+
error('%s must have at least a frequency column and one response column', ...
120+
meas_file);
121+
end
122+
eq.raw_f = meas(:,1);
123+
if size(meas, 2) == 2
124+
eq.raw_m_db = meas(:,2);
125+
else
126+
%% Average of the response columns
127+
eq.raw_m_db = mean(meas(:, 2:end), 2);
128+
fprintf('Averaged %d response columns from %s\n', size(meas, 2) - 1, meas_file);
129+
end
130+
end
131+
132+
%% -----------------------------------------------------------------------
133+
%% Multi-stage IIR fit
134+
%% -----------------------------------------------------------------------
135+
function eq = design_iir_stages(eq)
136+
137+
%% Cap on the combined response shaping between the mid-band low-Q PN2
138+
% (stage 1) and the bass LS2 (stage 2). If stage 1 pulls the mids down by
139+
% A dB, the LS2 upper bound is reduced so that bass_boost + |mid_atten|
140+
% does not exceed this limit. This keeps the bass-to-mid tilt from
141+
% becoming excessive (e.g. 26 dB total is already an aggressive shape).
142+
max_bass_plus_mid_atten_db = 26;
143+
144+
%% Filter budget (5 biquads max):
145+
% 1) HP2 at 80 Hz to protect the speaker
146+
% 2) PN2 very-low-Q mid shaper -> fc [2000, 5000], gain [-20, +6], Q [0.1, 0.5]
147+
% 3) LS2 bass boost -> fc [120, 1000], gain [0, +12]
148+
% 4) PN2 fine correction (low band) -> fc [200, 4000], gain [-6, +12], Q [0.1, 1.0]
149+
% 5) PN2 fine correction (high band) -> fc [3000, 12000],gain [-6, +12], Q [0.1, 1.0]
150+
% Fitting order matters: the very-low-Q peak/notch is fit first so the
151+
% subsequent low shelf can settle on top of an already-flattened midrange
152+
% instead of chasing a mid bump with bass gain. Biquads 4 and 5 have
153+
% disjoint fc ranges (low-mid vs. high-mid) so they converge on
154+
% complementary biquads by construction rather than relying on the initial
155+
% seed alone.
156+
hp_fc = 80;
157+
158+
opts = optimset('Display', 'notify', 'MaxIter', 300, 'MaxFunEvals', 2000, ...
159+
'TolX', 1e-3, 'TolFun', 1e-3);
160+
161+
peq_fixed = [eq.PEQ_HP2, hp_fc, 0, 0];
162+
163+
%% Stage 1: fit a very-low-Q peak/notch at a mid frequency first, on top of
164+
% the HP2. Doing this before the low shelf keeps the shelf from over-
165+
% compensating for a broad midrange bump. The gain upper bound is kept
166+
% low (+6 dB) so this stage stays a mid *attenuator* rather than a boost.
167+
% Params: [fc, gain, Q]. fc [2000, 5000] Hz, gain [-20, +6] dB, Q [0.1, 0.5].
168+
fmin_fit = 400;
169+
fmax_fit = 4000;
170+
p1_0 = [2500, -6, 0.3];
171+
p1_bounds = [2000, 5000; -20, +6; 0.1, 0.5];
172+
p1 = fminsearch(@(p) stage_rms(peq_fixed, pn_row(p, p1_bounds, eq), eq, fmin_fit, fmax_fit), ...
173+
p1_0, opts);
174+
peq_fixed = [peq_fixed; pn_row(p1, p1_bounds, eq)];
175+
fprintf('Stage 1 (very-low-Q PN2): fc=%.1f Hz g=%.2f dB Q=%.2f\n', ...
176+
clamp(p1(1), p1_bounds(1,1), p1_bounds(1,2)), ...
177+
clamp(p1(2), p1_bounds(2,1), p1_bounds(2,2)), ...
178+
clamp(p1(3), p1_bounds(3,1), p1_bounds(3,2)));
179+
180+
%% Stage 2: fit the low shelf on top of the flattened midrange.
181+
% Params: [ls_fc, ls_g]. fc [120, 1000] Hz, gain [0, +12] dB.
182+
% The upper bass gain bound is shrunk when stage 1 attenuated the mids,
183+
% so the total bass-to-mid shaping stays within max_bass_plus_mid_atten_db.
184+
fmin_fit = 200;
185+
fmax_fit = 2000;
186+
ls0 = [200, 8];
187+
ls_bounds = [120, 1000; 0, 12];
188+
mid_atten_db = min(0, clamp(p1(2), p1_bounds(2,1), p1_bounds(2,2)));
189+
ls_bounds(2,2) = min(ls_bounds(2,2), max(0, max_bass_plus_mid_atten_db - abs(mid_atten_db)));
190+
if ls_bounds(2,2) < ls0(2)
191+
ls0(2) = ls_bounds(2,2);
192+
end
193+
fprintf('Stage 2 bass gain upper bound = %.2f dB (mid atten %.2f dB, cap %.1f dB)\n', ...
194+
ls_bounds(2,2), mid_atten_db, max_bass_plus_mid_atten_db);
195+
ls = fminsearch(@(p) stage_rms(peq_fixed, shelf_row(eq.PEQ_LS2, p, ls_bounds), ...
196+
eq, fmin_fit, fmax_fit), ls0, opts);
197+
peq_fixed = [peq_fixed; shelf_row(eq.PEQ_LS2, ls, ls_bounds)];
198+
fprintf('Stage 2 (LS2): fc=%.1f Hz g=%.2f dB\n', clamp(ls(1), ls_bounds(1,1), ls_bounds(1,2)), ...
199+
clamp(ls(2), ls_bounds(2,1), ls_bounds(2,2)));
200+
201+
%% Widen the fit band for the fine correction stages: below 400 Hz the LS2
202+
% already dominates and above the mid we still want to shape the response
203+
% out to 8 kHz.
204+
fmin_fit = 400;
205+
fmax_fit = 8000;
206+
207+
%% Stage 3: fine correction constrained to the low/mid band (fc <= 4 kHz).
208+
% Seeded at 800 Hz; the fc range prevents it from stealing work from the
209+
% high-band biquad in stage 4.
210+
% Params: [fc, gain, Q]. fc [200, 4000] Hz, gain [-6, +12] dB, Q [0.1, 1.0].
211+
p2_0 = [800, -3, 1.0];
212+
p2_bounds = [200, 4000; -6, 12; 0.1, 1.0];
213+
p2 = fminsearch(@(p) stage_rms(peq_fixed, pn_row(p, p2_bounds, eq), eq, fmin_fit, fmax_fit), ...
214+
p2_0, opts);
215+
peq_fixed = [peq_fixed; pn_row(p2, p2_bounds, eq)];
216+
fprintf('Stage 3 (fine PN2 #1): fc=%.1f Hz g=%.2f dB Q=%.2f\n', ...
217+
clamp(p2(1), p2_bounds(1,1), p2_bounds(1,2)), ...
218+
clamp(p2(2), p2_bounds(2,1), p2_bounds(2,2)), ...
219+
clamp(p2(3), p2_bounds(3,1), p2_bounds(3,2)));
220+
221+
%% Stage 4: second fine correction constrained to the high band (fc >= 3 kHz).
222+
% Seeded at 5000 Hz. The disjoint fc range vs. stage 3 guarantees the two
223+
% biquads land on complementary parts of the spectrum instead of
224+
% duplicating each other.
225+
% Params: [fc, gain, Q]. fc [3000, 12000] Hz, gain [-6, +12] dB, Q [0.1, 1.0].
226+
p3_0 = [5000, -3, 1.0];
227+
p3_bounds = [3000, 12000; -6, 12; 0.1, 1.0];
228+
p3 = fminsearch(@(p) stage_rms(peq_fixed, pn_row(p, p3_bounds, eq), eq, fmin_fit, fmax_fit), ...
229+
p3_0, opts);
230+
peq_fixed = [peq_fixed; pn_row(p3, p3_bounds, eq)];
231+
fprintf('Stage 4 (fine PN2 #2): fc=%.1f Hz g=%.2f dB Q=%.2f\n', ...
232+
clamp(p3(1), p3_bounds(1,1), p3_bounds(1,2)), ...
233+
clamp(p3(2), p3_bounds(2,1), p3_bounds(2,2)), ...
234+
clamp(p3(3), p3_bounds(3,1), p3_bounds(3,2)));
235+
236+
eq.peq = peq_fixed;
237+
end
238+
239+
%% -----------------------------------------------------------------------
240+
%% FIR configuration for mid-band residual correction
241+
%% -----------------------------------------------------------------------
242+
function eq = configure_fir(eq)
243+
%% The IIR takes care of the coarse bass boost, the high-Q anti-resonance
244+
% and two mid-band shapers. Whatever mid-band delta vs. the target is
245+
% left over after the IIR (fir_compensate_iir = 1 in the defaults) is
246+
% picked up here by a short minimum-phase FIR limited to the mid band,
247+
% so it does not spend taps on the LF/HF regions the IIR already handles
248+
% or where the measurement is unreliable.
249+
eq.fir_length = 63;
250+
eq.fir_beta = 10;
251+
eq.fir_minph = 1;
252+
eq.fir_autoband = 0;
253+
eq.fmin_fir = 400;
254+
eq.fmax_fir = 12000;
255+
fprintf('FIR: length=%d taps, mid band [%d, %d] Hz\n', ...
256+
eq.fir_length, eq.fmin_fir, eq.fmax_fir);
257+
end
258+
259+
%% -----------------------------------------------------------------------
260+
%% IIR + FIR blob packing and export
261+
%% -----------------------------------------------------------------------
262+
function export_blobs(eq, cpath, iir_txt, iir_bin, fir_txt, fir_bin)
263+
%% Two-channel blob with a single shared response. Both channels are
264+
%% assigned to response 0, which suits identical L/R drivers on a
265+
%% single endpoint. For endpoints with distinct per-channel tuning,
266+
%% pass num_responses > 1 and adjust assign_response accordingly.
267+
channels_in_config = 2;
268+
num_responses = 1;
269+
assign_response = [0 0];
270+
271+
%% IIR blob
272+
bq_iir = sof_eq_iir_blob_quant(eq.p_z, eq.p_p, eq.p_k);
273+
bm_iir = sof_eq_iir_blob_merge(channels_in_config, num_responses, ...
274+
assign_response, bq_iir);
275+
bp_iir = sof_eq_iir_blob_pack(bm_iir, 4); % IPC4
276+
sof_alsactl_write(fullfile(cpath, iir_txt), bp_iir);
277+
sof_ucm_blob_write(fullfile(cpath, iir_bin), bp_iir);
278+
279+
%% FIR blob
280+
bq_fir = sof_eq_fir_blob_quant(eq.b_fir);
281+
bm_fir = sof_eq_fir_blob_merge(channels_in_config, num_responses, ...
282+
assign_response, bq_fir);
283+
bp_fir = sof_eq_fir_blob_pack(bm_fir, 4); % IPC4
284+
sof_alsactl_write(fullfile(cpath, fir_txt), bp_fir);
285+
sof_ucm_blob_write(fullfile(cpath, fir_bin), bp_fir);
286+
end
287+
288+
%% -----------------------------------------------------------------------
289+
%% Small helpers used by the IIR stages
290+
%% -----------------------------------------------------------------------
291+
function peq = shelf_row(type, p, b)
292+
peq = [type, clamp(p(1), b(1,1), b(1,2)), clamp(p(2), b(2,1), b(2,2)), 0];
293+
end
294+
295+
function peq = pn_row(p, b, eq)
296+
peq = [eq.PEQ_PN2, clamp(p(1), b(1,1), b(1,2)), ...
297+
clamp(p(2), b(2,1), b(2,2)), ...
298+
clamp(p(3), b(3,1), b(3,2))];
299+
end
300+
301+
function e = stage_rms(peq_fixed, new_row, eq, fmin_fit, fmax_fit)
302+
eq.peq = [peq_fixed; new_row];
303+
try
304+
eq2 = sof_eq_compute(eq);
305+
catch
306+
e = 1e6;
307+
return;
308+
end
309+
idx = eq2.f >= fmin_fit & eq2.f <= fmax_fit;
310+
resp = eq2.m_db_s(idx) + eq2.iir_eq_db(idx);
311+
resp = resp - mean(resp); % remove overall level, keep only shape
312+
e = sqrt(mean(resp .^ 2));
313+
end
314+
315+
function y = clamp(x, lo, hi)
316+
y = min(hi, max(lo, x));
317+
end
318+
319+
%% -----------------------------------------------------------------------
320+
%% UCM product .conf generation and misc string / filesystem helpers
321+
%% -----------------------------------------------------------------------
322+
function write_product_conf(conf_file, sys_vendor, product_name, endpoint, base, eq)
323+
ep_cap = capitalize(endpoint);
324+
iir_key = sprintf('Define.PostMixer%sPlaybackIirBlob', ep_cap);
325+
fir_key = sprintf('Define.PostMixer%sPlaybackFirBlob', ep_cap);
326+
iir_path = sprintf('/usr/share/alsa/ucm2/blobs/sof/ipc4/eq_iir/%s_iir.bin', base);
327+
fir_path = sprintf('/usr/share/alsa/ucm2/blobs/sof/ipc4/eq_fir/%s_fir.bin', base);
328+
329+
fid = fopen(conf_file, 'w');
330+
if fid < 0
331+
error('Could not open %s for writing', conf_file);
332+
end
333+
fprintf(fid, '# Add bespoke %s equalizer for %s %s\n', endpoint, sys_vendor, product_name);
334+
fprintf(fid, '#\n');
335+
fprintf(fid, '# IIR is defined as parametric equalizer and FIR carries the mid-band residual\n');
336+
fprintf(fid, '# correction, see:\n');
337+
fprintf(fid, '# https://github.com/thesofproject/sof/tree/main/src/audio/eq_iir/tune\n');
338+
fprintf(fid, '#\n');
339+
for i = 1:size(eq.peq, 1)
340+
fprintf(fid, '#\t%-10s %6.1f %+5.1f %4.2f\n', ...
341+
peq_type_name(eq, eq.peq(i, 1)), eq.peq(i, 2), eq.peq(i, 3), eq.peq(i, 4));
342+
end
343+
fprintf(fid, '\n');
344+
fprintf(fid, '%s "%s"\n', iir_key, iir_path);
345+
fprintf(fid, '%s "%s"\n', fir_key, fir_path);
346+
fclose(fid);
347+
fprintf('Wrote %s\n', conf_file);
348+
end
349+
350+
function s = sanitize_name(s)
351+
% Lower case, replace any non-alphanumeric run with a single underscore, and
352+
% trim leading/trailing underscores. e.g. 'Acme Ltd.' -> 'acme_ltd',
353+
% 'Model 100' -> 'model_100'.
354+
s = lower(s);
355+
s = regexprep(s, '[^a-z0-9]+', '_');
356+
s = regexprep(s, '^_+|_+$', '');
357+
end
358+
359+
function s = capitalize(s)
360+
if isempty(s)
361+
return;
362+
end
363+
s = [upper(s(1)), lower(s(2:end))];
364+
end
365+
366+
function ensure_dir(d)
367+
if ~exist(d, 'dir')
368+
[ok, msg] = mkdir(d);
369+
if ~ok
370+
error('mkdir %s failed: %s', d, msg);
371+
end
372+
end
373+
end
374+
375+
function name = peq_type_name(eq, type_num)
376+
% Look up the PEQ_* field name whose value matches type_num, using the
377+
% constants that sof_eq_defaults() already stored on the eq struct. This
378+
% keeps the mapping in sync with sof_eq_define_parametric_eq.m without
379+
% duplicating the enum here.
380+
fns = fieldnames(eq);
381+
for k = 1:numel(fns)
382+
if strncmp(fns{k}, 'PEQ_', 4) && isnumeric(eq.(fns{k})) && ...
383+
isscalar(eq.(fns{k})) && eq.(fns{k}) == type_num
384+
name = fns{k};
385+
return;
386+
end
387+
end
388+
name = sprintf('PEQ_%d', type_num);
389+
end

0 commit comments

Comments
 (0)