From 844c208d9ac685365fe1395c3f7c25950eba49d2 Mon Sep 17 00:00:00 2001 From: Seppo Ingalsuo Date: Thu, 9 Jul 2026 14:52:20 +0300 Subject: [PATCH 1/2] audio: eq_iir: tune: update sof_mls_freq_resp.m Clean up a handful of latent bugs and rough edges in the MLS response measurement helper without touching its behavior in the common paths. The cell-array labels[] slots were being assigned with (), which wraps the string into a nested cell instead of using it as the label under Octave. The sync-marker overlay was calling plot(ts(si), r(si), 'g') on a matrix r, so with more than one capture channel the linear indexing wrapped into the wrong channel. addpath() used a CWD-relative path and the matching rmpath at end-of-function never ran on error; both are now resolved from mfilename('fullpath') and paired via onCleanup. The system() wrappers now quote every path argument (new shq helper, verified round-trippable) and go through a run_shell() wrapper that raises on non-zero status instead of letting a broken scp/aplay/arecord silently propagate as NaNs later on. Smaller items: y is preallocated and asserted for consistent nt across playback channels; the 1 kHz normalization frequency becomes a named f_align_hz constant with an empty-bin guard; get_calibration() uses onCleanup(fclose), handles an empty description, and prefers ~isempty over length()>0; local variable dir renamed to tmp_dir to stop shadowing the builtin; capture_level_min_db comment typo fixed; the docstring header now matches the actual function name; get_config() carries a warning comment documenting that config files are executed via eval and must be locally trusted. Signed-off-by: Seppo Ingalsuo --- src/audio/eq_iir/tune/sof_mls_freq_resp.m | 117 ++++++++++++++++------ 1 file changed, 88 insertions(+), 29 deletions(-) diff --git a/src/audio/eq_iir/tune/sof_mls_freq_resp.m b/src/audio/eq_iir/tune/sof_mls_freq_resp.m index a30864d93a3b..343f862924f7 100644 --- a/src/audio/eq_iir/tune/sof_mls_freq_resp.m +++ b/src/audio/eq_iir/tune/sof_mls_freq_resp.m @@ -1,7 +1,7 @@ function [f, m_db] = sof_mls_freq_resp(id) %% Measure frequency response with MLS test signal % -% [f, m] = mls_freq_resp(id) +% [f, m] = sof_mls_freq_resp(id) % % Input parameters % id - A string identifier for test case. An id 'selftest' is for special @@ -30,15 +30,16 @@ np = 1024; % Number of frequency points to use f_lo = 100; % Lower frequency limit for analysis f_hi = 20e3; % Upper frequency limit for analysis -t_tot = 10e-3; % MLS analysis window length in s +f_align_hz = 1000; % Response is normalized to 0 dB at this frequency +t_tot = 10e-3; % MLS analysis window length in s (480 samples @ 48 kHz) t_mls_s = 1.0; % MLS test signal length in s a_mls_db = -10; % MLS test signal amplitude in dB fs = 48e3; % Sample rate in Hz bits = 16; % Audio format to use (bits) fmt = 'S16_LE'; % Audio format to use (ALSA) -dir = '/tmp'; % Directory for temporary files +tmp_dir = '/tmp'; % Directory for temporary files capture_level_max_db = -1; % Expected max. level -capture_level_min_db = -30; % Expacted min. level +capture_level_min_db = -30; % Expected min. level %% Get device identifier to use if nargin < 1 @@ -61,7 +62,14 @@ csvfn = sprintf('mls-%s.txt', id); %% Paths -addpath('../../../../tools/test/audio/test_utils'); +% Resolve the test_utils dir from the script location so the script works +% independent of the caller's current working directory. onCleanup makes +% sure rmpath still runs if an error is raised later on. +script_dir = fileparts(mfilename('fullpath')); +test_utils_dir = fullfile(script_dir, '..', '..', '..', '..', ... + 'tools', 'test', 'audio', 'test_utils'); +addpath(test_utils_dir); +path_cleanup = onCleanup(@() rmpath(test_utils_dir)); %#ok %% MLS n_mls = round(fs*t_mls_s); @@ -99,12 +107,14 @@ %% Capture MLS from all playback channel at time mixfn = 'mlsmix.wav'; recfn = 'recch.wav'; -y = []; +nrec = play_cfg.nch * rec_cfg.nch; +y = []; % Preallocated once nt is known from the first find_test_signal +nt_expected = []; if selftest - labels = cell(play_cfg.nch * rec_cfg.nch + 1, 1); - labels(end) = 'Reference'; + labels = cell(nrec + 1, 1); + labels{end} = 'Reference'; else - labels = cell(play_cfg.nch * rec_cfg.nch, 1); + labels = cell(nrec, 1); end label_idx = 1; for i=1:play_cfg.nch @@ -119,7 +129,7 @@ else x = zeros(length(z), play_cfg.nch); x(:,i) = z; - mixdfn = sprintf('%s/%s', dir, mixfn); + mixdfn = sprintf('%s/%s', tmp_dir, mixfn); audiowrite(mixdfn, x, fs, 'BitsPerSample', bits); copy_playback(mixdfn, play_cfg); tcap = floor(6 + t_mls_s); % Capture for MLS +6s @@ -130,7 +140,7 @@ r = get_recording(recfn, rec_cfg); end for j = 1:rec_cfg.nch - labels(label_idx) = sprintf('p%d-r%d', i, j); + labels{label_idx} = sprintf('p%d-r%d', i, j); label_idx = label_idx + 1; end [d, nt] = find_test_signal(r(:,1), fnd); @@ -148,11 +158,21 @@ m_db = []; return else - si = d:d + nt; + % nt is the sample count of the test signal, so the range + % highlighted here must is r(d:d+nt-1, :). + si = d:d + nt - 1; hold on - plot(ts(si), r(si), 'g'); + plot(ts(si), r(si, 1), 'g'); hold off end + if isempty(nt_expected) + nt_expected = nt; + y = zeros(nt, nrec); + elseif nt ~= nt_expected + error(['find_test_signal returned length %d on playback channel ' ... + '%d but %d on the first channel; captures are inconsistent'], ... + nt, i, nt_expected); + end for j = 1:rec_cfg.nch y(:, rec_cfg.nch*(i-1) + j) = r(d:d + nt -1, j); end @@ -179,7 +199,10 @@ [f, m_db] = apply_mic_calibration(f, m_db, rec_cfg); figure -idx = find(f>1e3, 1, 'first') - 1; +idx = find(f > f_align_hz, 1, 'first') - 1; +if isempty(idx) || idx < 1 + error('No frequency bin at or below %.0f Hz for alignment', f_align_hz); +end m_db_align = m_db - m_db(idx); semilogx(f, m_db_align); ax=axis(); axis([f_lo f_hi ax(3:4)]); @@ -221,15 +244,16 @@ fprintf('Response RMS error is %4.1f dB.\n', e_db); end -rmpath('../../../../tools/test/audio/test_utils'); +% path_cleanup / onCleanup takes care of rmpath on both normal return and +% error paths. end function copy_playback(fn, cfg) if cfg.ssh - cmd = sprintf('scp %s %s:%s/', fn, cfg.user, cfg.dir); + cmd = sprintf('scp %s %s:%s/', shq(fn), cfg.user, shq(cfg.dir)); fprintf('Remote copy: %s\n', cmd); - system(cmd); + run_shell(cmd); else %cmd = sprintf('cp %s %s/', fn, cfg.dir); %fprintf('Local copy: %s\n', cmd); @@ -238,39 +262,68 @@ function copy_playback(fn, cfg) function y = get_recording(fn, cfg) if cfg.ssh - cmd = sprintf('scp %s:%s/%s %s', cfg.user, cfg.dir, fn, fn); + cmd = sprintf('scp %s:%s/%s %s', cfg.user, shq(cfg.dir), ... + shq(fn), shq(fn)); fprintf('Remote copy: %s\n', cmd); else - cmd = sprintf('cp %s/%s %s', cfg.dir, fn, fn); + cmd = sprintf('cp %s/%s %s', shq(cfg.dir), shq(fn), shq(fn)); fprintf('Local copy: %s\n', cmd); end - system(cmd); + run_shell(cmd); y = audioread(fn); delete(fn); end function remote_play(fn, cfg) if cfg.ssh - cmd = sprintf('ssh %s aplay -D%s %s/%s', cfg.user, cfg.dev, cfg.dir, fn); + cmd = sprintf('ssh %s aplay -D%s %s/%s', cfg.user, cfg.dev, ... + shq(cfg.dir), shq(fn)); fprintf('Remote play: %s\n', cmd); else - cmd = sprintf('aplay -D%s %s/%s', cfg.dev, cfg.dir, fn); + cmd = sprintf('aplay -D%s %s/%s', cfg.dev, shq(cfg.dir), shq(fn)); fprintf('Local play: %s\n', cmd); end - system(cmd); + run_shell(cmd); end function remote_capture(fn, cfg, t) if cfg.ssh cmd = sprintf('ssh %s arecord -q -D%s %s -d %d %s/%s &', ... - cfg.user, cfg.dev, cfg.fmt, t, cfg.dir, fn); + cfg.user, cfg.dev, cfg.fmt, t, ... + shq(cfg.dir), shq(fn)); fprintf('Remote capture: %s\n', cmd); else cmd = sprintf('arecord -q -D%s %s -d %d %s/%s &', ... - cfg.dev, cfg.fmt, t, cfg.dir, fn); + cfg.dev, cfg.fmt, t, shq(cfg.dir), shq(fn)); fprintf('Local capture: %s\n', cmd); end - system(cmd); + % Backgrounded capture: system() returns immediately with the shell's + % own exit code (0 for a successful fork), so a non-zero return here + % is a startup failure worth surfacing. + run_shell(cmd); +end + +function run_shell(cmd) +% Wrapper around system() that fails loudly instead of letting a broken +% aplay/arecord/scp step propagate as silent NaNs later on. + status = system(cmd); + if status ~= 0 + error('Shell command failed (status %d): %s', status, cmd); + end +end + +function q = shq(s) +% Wrap a string in POSIX single quotes for use in a shell command line, +% escaping any embedded single quotes. Cheap defense against paths that +% contain spaces or shell metacharacters. Any ' in s becomes the standard +% POSIX-shell '\'' close/escape/reopen sequence. + sq = char(39); % single quote + esc = [sq, '\', sq, sq]; % '\'' + if isempty(s) + q = [sq, sq]; + return; + end + q = [sq, strrep(s, sq, esc), sq]; end function play = meas_remote_play_config() @@ -326,6 +379,7 @@ function remote_capture(fn, cfg, t) if fh < 0 error('Cannot open calibration data file'); end + fh_cleanup = onCleanup(@() fclose(fh)); %#ok n = 1; f = []; m = []; @@ -333,13 +387,13 @@ function remote_capture(fn, cfg, t) desc = ''; str = fgets(fh); idx = strfind(str, '"'); - while length(idx) > 0 + while ~isempty(idx) line = str(idx(1)+1:idx(2)-1); desc = sprintf('%s%s ', desc, line); str = fgets(fh); idx = strfind(str, '"'); end - if length(strfind(str, 'Sens')) + if ~isempty(strfind(str, 'Sens')) desc = str; str = fgets(fh); end @@ -352,7 +406,7 @@ function remote_capture(fn, cfg, t) end % Strip possible linefeed from description end - if double(desc(end)) == 10 + if ~isempty(desc) && double(desc(end)) == 10 desc = desc(1:end-1); end fprintf('Calibration Info : %s\n', desc); @@ -426,6 +480,11 @@ function remote_capture(fn, cfg, t) end function ret = get_config(fn, var) +% WARNING: this reads `fn` and executes it as Octave/MATLAB source via +% eval(). The config files (mls_play_config.txt / mls_rec_config.txt) are +% assumed to be locally owned and trusted; do not point this at anything +% you did not write yourself. Kept as-is for backward compatibility with +% existing user config files that rely on full script syntax. s = fileread(fn); eval(s); cmd = sprintf('ret = %s;', var); From 72a7b24aafbd60f3cbfcf7ed167e427d17e5ede0 Mon Sep 17 00:00:00 2001 From: Seppo Ingalsuo Date: Thu, 9 Jul 2026 15:12:49 +0300 Subject: [PATCH 2/2] audio: eq_iir: tune: add sof_mls_freq_resp_local.sh Add a bash driver that turns the older manually-edited mls_play_config.txt / mls_rec_config.txt workflow into a one-shot "detect + configure + optionally measure" script for the local host. All artifacts (configs, MLS reference, per-channel captures, CSV, PNG plot) land under a per-run data subdirectory so the tune directory does not fill up with per-machine files. aplay -l and arecord -l are parsed to enumerate PCM devices. Speaker preference is: descriptor named "Speaker" first (SOF SDW cards advertise literal endpoint names), then SOF+Analog, then any Analog, then the first non-HDMI/DP/Deepbuffer device. Mic preference is a known calibrated measurement mic (UMM-6, UMIK, MiniDSP, EMM-6, ECM8000) first, then any non-webcam USB card, then SOF DMIC, then analog. --speaker / --mic override both. The auto id is sanitize(DMI vendor + product) plus a mic tag, and gets a -2/-3/... suffix when a prior run's outputs are already in the data dir so re-measuring at a new mic position never clobbers old captures. Capture format and channel count are probed with arecord --dump-hw-params. S32_LE is preferred over S16_LE so the SOF DMIC PCM (which only exposes S32_LE) works out of the box, and the default channel count is the mic's advertised maximum. Optional --cal PATH threads a two-column [freq_hz, mag_db] microphone calibration file into rec.cal for sof_mls_freq_resp.m. Opt-in --prep loads IPC4 pass-through blobs into the speaker card's IIR/FIR bytes controls and turns DRC/MBDRC/TDFB switches off. Both amixer and sof-ctl are invoked via the iface=MIXER,name='...' / -c name='...' forms because numid-based access misreports max sizes and trips a snd_ctl_elem_info() assertion on current alsa-utils. Every external ioctl is wrapped in timeout(1), and each bytes control's actual max size is probed before writing so 1-byte topology stubs get skipped instead of crashing sof-ctl. sof_mls_freq_resp.m gains three optional arguments (play_config_fn, rec_config_fn, data_dir), a `pkg load signal;` up front so distros where octave-signal is separate fail cleanly, and an mls-.png plot next to the CSV. Existing callers using the old positional signature keep working via defaults. Signed-off-by: Seppo Ingalsuo --- src/audio/eq_iir/tune/sof_mls_freq_resp.m | 109 ++- .../eq_iir/tune/sof_mls_freq_resp_local.sh | 883 ++++++++++++++++++ 2 files changed, 972 insertions(+), 20 deletions(-) create mode 100755 src/audio/eq_iir/tune/sof_mls_freq_resp_local.sh diff --git a/src/audio/eq_iir/tune/sof_mls_freq_resp.m b/src/audio/eq_iir/tune/sof_mls_freq_resp.m index 343f862924f7..ccb67c756102 100644 --- a/src/audio/eq_iir/tune/sof_mls_freq_resp.m +++ b/src/audio/eq_iir/tune/sof_mls_freq_resp.m @@ -1,13 +1,18 @@ -function [f, m_db] = sof_mls_freq_resp(id) +function [f, m_db] = sof_mls_freq_resp(id, play_config_fn, rec_config_fn, data_dir) %% Measure frequency response with MLS test signal % -% [f, m] = sof_mls_freq_resp(id) +% [f, m] = sof_mls_freq_resp(id, play_config_fn, rec_config_fn, data_dir) % % Input parameters -% id - A string identifier for test case. An id 'selftest' is for special -% usage. It calculates response of filtered MLS signal and computes -% the measurement vs. known. Deviation is reported as error. It can -% be useful if the internal MLS measurement parameters are adjusted. +% id - A string identifier for test case. An id 'selftest' is for +% special usage. It calculates response of filtered MLS signal +% and computes the measurement vs. known. Deviation is +% reported as error. It can be useful if the internal MLS +% measurement parameters are adjusted. +% play_config_fn - Playback config file (default 'mls_play_config.txt'). +% rec_config_fn - Capture config file (default 'mls_rec_config.txt'). +% data_dir - Directory for the mls-.{wav,txt} outputs and the +% mls-ref.wav reference (default '.'). Created if missing. % % Output parameters % f - Frequency vector in Hz @@ -17,7 +22,8 @@ % mls_play_config.txt % mls_rec_config.txt % -% The script will return also a text CSV format file with name mls-.txt. +% The script will also write a text CSV format file with name mls-.txt +% and a PNG plot of the aligned response as mls-.png into data_dir. % % SPDX-License-Identifier: BSD-3-Clause @@ -26,6 +32,13 @@ % % Author: Seppo Ingalsuo +%% Octave dependencies: mlsp12/sync_chirp and the resampling path pull in +%% functions from the signal package. On most distros it ships separately +%% from core Octave, so load it explicitly rather than fail cryptically. +if exist('OCTAVE_VERSION', 'builtin') + pkg load signal; +end + %% Settings np = 1024; % Number of frequency points to use f_lo = 100; % Lower frequency limit for analysis @@ -45,6 +58,16 @@ if nargin < 1 id = 'unknown'; end +if nargin < 2 || isempty(play_config_fn) + play_config_fn = 'mls_play_config.txt'; +end +if nargin < 3 || isempty(rec_config_fn) + rec_config_fn = 'mls_rec_config.txt'; +end +if nargin < 4 || isempty(data_dir) + data_dir = '.'; +end +ensure_dir(data_dir); if strcmp(id, 'selftest') selftest = 1; @@ -58,8 +81,8 @@ else selftest = 0; end -measfn = sprintf('mls-%s.wav', id); -csvfn = sprintf('mls-%s.txt', id); +measfn = fullfile(data_dir, sprintf('mls-%s.wav', id)); +csvfn = fullfile(data_dir, sprintf('mls-%s.txt', id)); %% Paths % Resolve the test_utils dir from the script location so the script works @@ -74,7 +97,7 @@ %% MLS n_mls = round(fs*t_mls_s); mls = 10^(a_mls_db/20) * (2 * mlsp12(1, n_mls) - 1); -mlsfn = 'mls-ref.wav'; +mlsfn = fullfile(data_dir, 'mls-ref.wav'); audiowrite(mlsfn, mls, fs); %% Chip markers and parameters for find sync @@ -101,8 +124,8 @@ z(i2 + 1:end) = x2; %% Get config -rec_cfg = meas_remote_rec_config(fs, fmt); -play_cfg = meas_remote_play_config; +rec_cfg = meas_remote_rec_config(fs, fmt, rec_config_fn); +play_cfg = meas_remote_play_config(play_config_fn); %% Capture MLS from all playback channel at time mixfn = 'mlsmix.wav'; @@ -129,7 +152,16 @@ else x = zeros(length(z), play_cfg.nch); x(:,i) = z; - mixdfn = sprintf('%s/%s', tmp_dir, mixfn); + % For an ssh play target we stage the WAV under tmp_dir and let + % copy_playback scp it over to play_cfg.dir. For a local play + % target copy_playback is a no-op, so write the WAV directly + % into play_cfg.dir where remote_play (which is local aplay in + % that case) will look for it. + if play_cfg.ssh + mixdfn = fullfile(tmp_dir, mixfn); + else + mixdfn = fullfile(play_cfg.dir, mixfn); + end audiowrite(mixdfn, x, fs, 'BitsPerSample', bits); copy_playback(mixdfn, play_cfg); tcap = floor(6 + t_mls_s); % Capture for MLS +6s @@ -159,7 +191,7 @@ return else % nt is the sample count of the test signal, so the range - % highlighted here must is r(d:d+nt-1, :). + % highlighted here must be r(d:d+nt-1, :). si = d:d + nt - 1; hold on plot(ts(si), r(si, 1), 'g'); @@ -198,7 +230,7 @@ [f, m_db] = apply_mic_calibration(f, m_db, rec_cfg); -figure +main_fig = figure; idx = find(f > f_align_hz, 1, 'first') - 1; if isempty(idx) || idx < 1 error('No frequency bin at or below %.0f Hz for alignment', f_align_hz); @@ -218,10 +250,28 @@ hold on; plot(f, ref_db_align, 'r--'); hold off; +else + % Interpreter 'none' so underscores in the id are drawn literally + % instead of being taken as TeX subscript markers. + title(sprintf('Measured frequency response: %s', id), ... + 'Interpreter', 'none'); end legend(labels); +%% Save the plot as PNG next to the CSV so the response is easy to share +%% without pulling the raw capture through Octave again. print() picks +%% whichever graphics toolkit is active (qt on desktop, gnuplot in +%% --no-window-system runs), so both interactive and headless invocations +%% produce a file. +plot_fn = fullfile(data_dir, sprintf('mls-%s.png', id)); +try + print(main_fig, plot_fn, '-dpng', '-r150'); + fprintf('Wrote %s\n', plot_fn); +catch err + warning('Could not save %s: %s', plot_fn, err.message); +end + if selftest idx = find(f < f_hi); idx = find(f(idx) > f_lo); @@ -326,9 +376,19 @@ function run_shell(cmd) q = [sq, strrep(s, sq, esc), sq]; end -function play = meas_remote_play_config() - play = get_config('mls_play_config.txt', 'play'); +function ensure_dir(d) + if ~exist(d, 'dir') + [ok, msg] = mkdir(d); + if ~ok + error('mkdir %s failed: %s', d, msg); + end + end +end + +function play = meas_remote_play_config(fn) + play = get_config(fn, 'play'); fprintf('\nThe settings for remote playback are\n'); + fprintf('Config : %s\n', fn); fprintf('Use ssh : %d\n', play.ssh); fprintf('User : %s\n', play.user); fprintf('Directory : %s\n', play.dir); @@ -336,12 +396,21 @@ function run_shell(cmd) fprintf('Channels : %d\n', play.nch); end -function rec = meas_remote_rec_config(fs, fmt) - rec = get_config('mls_rec_config.txt', 'rec'); +function rec = meas_remote_rec_config(fs, fmt, fn) + rec = get_config(fn, 'rec'); + % Let the config file override the ALSA format: some capture + % devices (e.g. SOF DMIC) only expose S32_LE, so the shell driver + % probes --dump-hw-params and writes rec.fmt_name / rec.bits in. + if isfield(rec, 'fmt_name') && ~isempty(rec.fmt_name) + fmt_use = rec.fmt_name; + else + fmt_use = fmt; + end rec.fmt = sprintf('-t wav -c %d -f %s -r %d', ... - rec.nch, fmt, fs); + rec.nch, fmt_use, fs); fprintf('\nThe settings for remote capture are\n'); + fprintf('Config : %s\n', fn); fprintf('Use ssh : %d\n', rec.ssh); fprintf('User : %s\n', rec.user); fprintf('Directory : %s\n', rec.dir); diff --git a/src/audio/eq_iir/tune/sof_mls_freq_resp_local.sh b/src/audio/eq_iir/tune/sof_mls_freq_resp_local.sh new file mode 100755 index 000000000000..eb03acce0a65 --- /dev/null +++ b/src/audio/eq_iir/tune/sof_mls_freq_resp_local.sh @@ -0,0 +1,883 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: BSD-3-Clause +# +# Copyright (c) 2026, Intel Corporation. +# +# sof_mls_freq_resp_local.sh +# +# Detect a suitable local ALSA speaker (aplay -l) + microphone +# (arecord -l, preferring a calibrated USB measurement mic), generate the +# mls_play_config.txt and mls_rec_config.txt files that sof_mls_freq_resp.m +# expects, and optionally run the Octave measurement into a data +# subdirectory. The id string is derived from DMI vendor + product so the +# output filenames identify the host that produced them. +# +# Typical use: +# ./sof_mls_freq_resp_local.sh # detect + write configs +# ./sof_mls_freq_resp_local.sh --run # + invoke Octave right away +# +# The Octave measurement can also be launched manually later with the +# printed command line. + +set -euo pipefail + +# -------------------------------------------------------------------------- +# Defaults & CLI parsing +# -------------------------------------------------------------------------- + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +data_dir="sof_mls_freq_resp_data" +id="" +id_auto=1 # 0 once --id STR was supplied by the user +append_mic_tag=1 # 0 with --no-mic-tag +speaker_override="" +mic_override="" +mic_channels="" # empty = auto-probe max from arecord +speaker_channels=2 +rec_fmt_override="" # non-empty with --rec-fmt to skip auto-probe +rec_cal="" # optional mic calibration file passed to rec.cal +do_run=0 +dry_run=0 +do_prep=0 +iir_pass_blob="$script_dir/../../../../tools/ctl/ipc4/eq_iir/pass.txt" +fir_pass_blob="$script_dir/../../../../tools/ctl/ipc4/eq_fir/pass.txt" + +usage() { + cat < S16_LE. + --cal PATH Path to a two-column [freq_hz, mag_db] microphone + calibration file. Written verbatim into rec.cal + so sof_mls_freq_resp.m compensates the measured + response with it. + --prep Before measuring, program pass-through IIR/FIR blobs + and switch DRC/MBDRC/TDFB controls off on the + speaker card so the response is not shaped by any + leftover DSP state. Uses amixer + sof-ctl. + --iir-blob PATH Override the IIR pass-through blob used by --prep. + (default: tools/ctl/ipc4/eq_iir/pass.txt) + --fir-blob PATH Override the FIR pass-through blob used by --prep. + (default: tools/ctl/ipc4/eq_fir/pass.txt) + --run Invoke Octave to run sof_mls_freq_resp() right after + writing the configs. + --dry-run Detect and print what would be written; do not touch + the filesystem or DSP controls. + -h, --help Show this help. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --dir) data_dir="$2"; shift 2 ;; + --id) id="$2"; id_auto=0; shift 2 ;; + --no-mic-tag) append_mic_tag=0; shift ;; + --speaker) speaker_override="$2"; shift 2 ;; + --mic) mic_override="$2"; shift 2 ;; + --mic-ch) mic_channels="$2"; shift 2 ;; + --spk-ch) speaker_channels="$2"; shift 2 ;; + --rec-fmt) rec_fmt_override="$2"; shift 2 ;; + --cal) rec_cal="$2"; shift 2 ;; + --prep) do_prep=1; shift ;; + --iir-blob) iir_pass_blob="$2"; shift 2 ;; + --fir-blob) fir_pass_blob="$2"; shift 2 ;; + --run) do_run=1; shift ;; + --dry-run) dry_run=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; + esac +done + +# -------------------------------------------------------------------------- +# Dependencies +# -------------------------------------------------------------------------- + +need() { + command -v "$1" >/dev/null 2>&1 || { + echo "Required command '$1' not found in PATH" >&2 + exit 3 + } +} +need aplay +need arecord +need awk +# octave is only invoked when --run is given; skip the check in the +# default "generate configs only" mode so the script works on hosts +# without Octave installed. +if (( do_run )); then + need octave +fi + +extract_card() { + # "hw:0,0" -> "0". Used both by the prep step and by --dry-run. + echo "$1" | sed -n 's/^hw:\([0-9]\+\).*/\1/p' +} + +probe_capture_format() { + # Ask arecord which sample formats the device advertises via + # --dump-hw-params and pick one we know how to write in the rec + # config, preferring S32_LE over S16_LE (the DMIC PCM on many SOF + # platforms only exposes S32_LE). Falls back to S16_LE if the probe + # returns nothing usable (device busy, unknown output format) so + # the config file always has a definite value. + local dev="$1" formats want f + formats=$(arecord -D "$dev" --dump-hw-params 2>&1 \ + | sed -n 's/^FORMAT:[[:space:]]*//p' | head -1) + if [[ -n "$formats" ]]; then + for want in S32_LE S16_LE; do + for f in $formats; do + if [[ "$f" == "$want" ]]; then + echo "$want" + return 0 + fi + done + done + # Nothing on our preference list; take the first advertised. + echo "$formats" | awk '{print $1}' + return 0 + fi + echo "S16_LE" +} + +format_to_bits() { + case "$1" in + S32_LE|S32_BE|FLOAT_LE|FLOAT_BE) echo 32 ;; + S24_LE|S24_BE|S24_3LE|S24_3BE) echo 24 ;; + S16_LE|S16_BE) echo 16 ;; + *) echo 16 ;; + esac +} + +probe_capture_channels() { + # Return the max channel count arecord --dump-hw-params reports + # for the device, or empty string on failure. Handles both the + # single-value ("CHANNELS: 2") and range ("CHANNELS: [1 4]") forms. + local dev="$1" line n max=0 + line=$(arecord -D "$dev" --dump-hw-params 2>&1 \ + | sed -n 's/^CHANNELS:[[:space:]]*//p' | head -1) + [[ -z "$line" ]] && return 0 + line=${line//[/} + line=${line//]/} + for n in $line; do + if [[ "$n" =~ ^[0-9]+$ ]] && (( n > max )); then + max=$n + fi + done + (( max > 0 )) && echo "$max" +} + +# -------------------------------------------------------------------------- +# DMI-derived id +# -------------------------------------------------------------------------- + +dmi_read() { + # Read /sys/devices/virtual/dmi/id/, or empty on failure. + local key="$1" val="" + if [[ -r "/sys/devices/virtual/dmi/id/$key" ]]; then + val=$(cat "/sys/devices/virtual/dmi/id/$key" 2>/dev/null | tr -d '\n') + fi + printf '%s' "$val" +} + +sanitize_id() { + # Lower case, keep [a-z0-9], collapse everything else into a single _ + # and trim leading/trailing underscores. + echo "$1" | tr '[:upper:]' '[:lower:]' \ + | sed -e 's/[^a-z0-9]\+/_/g' -e 's/^_\+//' -e 's/_\+$//' +} + +# Read the raw DMI strings once so both the sanitized id below and the +# report at the end can show the exact values that ALSA UCM and the +# UCM blob generator expect (they must match /sys/devices/virtual/dmi/id +# verbatim, including case and whitespace). +dmi_vendor_raw=$(dmi_read sys_vendor) +dmi_product_raw=$(dmi_read product_name) + +if [[ -z "$id" ]]; then + vendor=$(sanitize_id "$dmi_vendor_raw") + product=$(sanitize_id "$dmi_product_raw") + if [[ -n "$vendor" && -n "$product" ]]; then + id="${vendor}_${product}" + elif [[ -n "$product" ]]; then + id="$product" + elif [[ -n "$vendor" ]]; then + id="$vendor" + else + id="unknown" + fi +fi + +# -------------------------------------------------------------------------- +# ALSA device discovery +# -------------------------------------------------------------------------- + +# Parse `aplay -l` / `arecord -l` lines of the form: +# card N: SHORT [LONG], device D: DESCR (...) [SUBNAME] +# Emits: "N|D|SHORT|LONG|DESCR" per PCM device. +# +# Pure POSIX-ERE sed (invoked with -E) instead of awk. gawk's 3-argument +# match() form is not portable to mawk (Ubuntu default /usr/bin/awk) or +# busybox awk (Alpine), so a sed-only extractor keeps the script working +# regardless of which awk implementation is installed. +parse_alsa_list() { + sed -nE ' + /^card [0-9]+: [^ ]+ \[[^]]+\], device [0-9]+: / { + # Rewrite the line as c|d|sn|ln|desc, then peel off the trailing + # " [SUBNAME]" and then the " (...)" annotation from desc. + # Bracket-strip has to run first so a line like + # ... device 0: Jack Out (*) [] + # does not leave the parenthesised part dangling at end-of-line. + s/^card ([0-9]+): ([^ ]+) \[([^]]+)\], device ([0-9]+): (.*)$/\1|\4|\2|\3|\5/ + s/ *\[[^]]*\] *$// + s/ *\([^)]*\) *$// + p + } + ' +} + +playback_devices=$(aplay -l 2>/dev/null | parse_alsa_list) +capture_devices=$(arecord -l 2>/dev/null | parse_alsa_list) + +if [[ -z "$playback_devices" ]]; then + echo "aplay -l returned no PCM devices" >&2 + exit 4 +fi +if [[ -z "$capture_devices" ]]; then + echo "arecord -l returned no PCM devices" >&2 + exit 4 +fi + +# -------------------------------------------------------------------------- +# Speaker selection +# -------------------------------------------------------------------------- +# +# Preference order: +# 1) Any PCM whose descriptor contains "Speaker" (common on SOF+SDW, +# where the aplay -l descriptors are literal endpoint names such as +# "Jack Out", "Speaker", "HDMI1", "Deepbuffer Jack Out"). This has +# to run before the analog rules because SDW cards do not advertise +# "Analog" in their descriptors and would otherwise fall through to +# the "first non-HDMI/DP" pass and pick "Jack Out". +# 2) SOF card ("sof*" short name) with an "Analog" descriptor that is +# not "Deepbuffer". +# 3) Any card device whose descriptor contains "Analog" but not "HDMI", +# "DP" or "Deepbuffer". +# 4) First non-HDMI/DP/Deepbuffer device. +# HDMI/DP outputs are always skipped so the mic never accidentally captures +# monitor speaker output. + +pick_speaker() { + local line c d sn ln desc lc_ln lc_desc + + # Pass 1: descriptor explicitly named "Speaker" + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_desc" == *speaker* ]] \ + && [[ "$lc_desc" != *hdmi* ]] \ + && [[ "$lc_desc" != *dp* ]]; then + echo "hw:$c,$d|$sn|$ln|$desc|speaker" + return + fi + done <<<"$playback_devices" + + # Pass 2: SOF analog + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + lc_ln=$(echo "$ln" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_ln" == sof* ]] \ + && [[ "$lc_desc" == *analog* ]] \ + && [[ "$lc_desc" != *deepbuffer* ]] \ + && [[ "$lc_desc" != *hdmi* ]] \ + && [[ "$lc_desc" != *dp* ]]; then + echo "hw:$c,$d|$sn|$ln|$desc|sof-analog" + return + fi + done <<<"$playback_devices" + + # Pass 3: any analog, still skipping HDMI, DP and Deepbuffer. + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_desc" == *analog* ]] \ + && [[ "$lc_desc" != *deepbuffer* ]] \ + && [[ "$lc_desc" != *hdmi* ]] \ + && [[ "$lc_desc" != *dp* ]]; then + echo "hw:$c,$d|$sn|$ln|$desc|analog" + return + fi + done <<<"$playback_devices" + + # Pass 4: first non-hdmi/dp/deepbuffer + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_desc" != *hdmi* ]] \ + && [[ "$lc_desc" != *dp* ]] \ + && [[ "$lc_desc" != *deepbuffer* ]]; then + echo "hw:$c,$d|$sn|$ln|$desc|first-non-hdmi" + return + fi + done <<<"$playback_devices" + + return 1 +} + +# -------------------------------------------------------------------------- +# Microphone selection +# -------------------------------------------------------------------------- +# +# Preference order: +# 1) A known calibrated measurement mic by long-name substring +# (UMM-6, UMIK, MiniDSP, EMM-6, ECM8000). +# 2) Any USB-Audio-class card that is not obviously a webcam or a full +# interface (best-effort heuristic on the long name). +# 3) A SOF DMIC device (internal digital mic array). +# 4) The SOF analog line-in / any first analog capture. + +is_measurement_mic() { + local lc="$1" + case "$lc" in + *umm-6*|*umm6*|*umik*|*minidsp*|*emm-6*|*emm6*|*ecm8000*) + return 0 ;; + *) return 1 ;; + esac +} + +is_webcam() { + local lc="$1" + case "$lc" in + *webcam*|*c920*|*c930*|*brio*|*logitech*camera*|*camera*) + return 0 ;; + *) return 1 ;; + esac +} + +pick_mic() { + local line c d sn ln desc lc_ln lc_desc lc_sn + + # Pass 1: known measurement mics + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_ln=$(echo "$ln" | tr '[:upper:]' '[:lower:]') + lc_sn=$(echo "$sn" | tr '[:upper:]' '[:lower:]') + if is_measurement_mic "$lc_ln" || is_measurement_mic "$lc_sn"; then + echo "hw:$c,$d|$sn|$ln|$desc|measurement-usb" + return + fi + done <<<"$capture_devices" + + # Pass 2: generic USB audio card, not a webcam + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_ln=$(echo "$ln" | tr '[:upper:]' '[:lower:]') + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + lc_sn=$(echo "$sn" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_sn" == usb* || "$lc_desc" == *usb* ]] \ + && ! is_webcam "$lc_ln"; then + echo "hw:$c,$d|$sn|$ln|$desc|usb-generic" + return + fi + done <<<"$capture_devices" + + # Pass 3: SOF DMIC (built-in digital mic array) + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_ln=$(echo "$ln" | tr '[:upper:]' '[:lower:]') + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_ln" == sof* ]] && [[ "$lc_desc" == *dmic* ]]; then + echo "hw:$c,$d|$sn|$ln|$desc|sof-dmic" + return + fi + done <<<"$capture_devices" + + # Pass 4: SOF / first analog capture + while IFS='|' read -r c d sn ln desc; do + [[ -z "$c" ]] && continue + lc_desc=$(echo "$desc" | tr '[:upper:]' '[:lower:]') + if [[ "$lc_desc" == *analog* ]]; then + echo "hw:$c,$d|$sn|$ln|$desc|analog-capture" + return + fi + done <<<"$capture_devices" + + # Pass 5: give up and take the very first entry + IFS='|' read -r c d sn ln desc <<<"$(echo "$capture_devices" | head -n 1)" + echo "hw:$c,$d|$sn|$ln|$desc|first-listed" +} + +# -------------------------------------------------------------------------- +# Resolve devices +# -------------------------------------------------------------------------- + +if [[ -n "$speaker_override" ]]; then + speaker_hw="$speaker_override" + speaker_info="override" + speaker_name="$speaker_override" +else + IFS='|' read -r speaker_hw _spk_sn speaker_name _spk_desc speaker_info \ + <<<"$(pick_speaker || true)" + if [[ -z "${speaker_hw:-}" ]]; then + echo "Could not identify a suitable speaker device from aplay -l" >&2 + aplay -l >&2 + exit 5 + fi +fi + +if [[ -n "$mic_override" ]]; then + mic_hw="$mic_override" + mic_info="override" + # Try to look the override up in the capture list so the mic tag is + # still descriptive (e.g. dmic_raw) instead of just the hw address. + ov_card=$(extract_card "$mic_override") + ov_dev=$(echo "$mic_override" | sed -n 's/^hw:[0-9]\+,\([0-9]\+\).*/\1/p') + mic_name="$mic_override" + mic_desc="$mic_override" + if [[ -n "$ov_card" && -n "$ov_dev" ]]; then + while IFS='|' read -r c d _sn ln desc; do + if [[ "$c" == "$ov_card" && "$d" == "$ov_dev" ]]; then + mic_name="$ln" + mic_desc="$desc" + break + fi + done <<<"$capture_devices" + fi +else + IFS='|' read -r mic_hw _mic_sn mic_name mic_desc mic_info \ + <<<"$(pick_mic)" + if [[ -z "${mic_hw:-}" ]]; then + echo "Could not identify any capture device from arecord -l" >&2 + arecord -l >&2 + exit 5 + fi +fi + +# -------------------------------------------------------------------------- +# Microphone tag for the id +# -------------------------------------------------------------------------- +# +# For USB / measurement mics the long name ("MiniDSP UMM-6", "HD Pro +# Webcam C920") is the most identifiable label. For the SOF card the +# long name collapses to "sof-hda-dsp" for every capture PCM on the +# card, so fall back to the PCM descriptor ("DMIC Raw", "HDA Analog") +# to distinguish e.g. the DMIC array from a wired line-in. If both are +# generic, fall back to the hw:C,D address so we always get something +# unique. + +mic_tag_from() { + local ln="$1" desc="$2" hw="$3" base lc_ln + lc_ln=$(printf '%s' "$ln" | tr '[:upper:]' '[:lower:]') + if [[ -n "$ln" ]] && [[ "$lc_ln" != sof* ]] && [[ "$lc_ln" != hda* ]]; then + base="$ln" + elif [[ -n "$desc" ]]; then + base="$desc" + else + base="$hw" + fi + sanitize_id "$base" +} + +mic_tag=$(mic_tag_from "$mic_name" "$mic_desc" "$mic_hw") +if (( id_auto )) && (( append_mic_tag )) && [[ -n "$mic_tag" ]]; then + id="${id}_${mic_tag}" +fi + +# -------------------------------------------------------------------------- +# Avoid overwriting a previous measurement +# -------------------------------------------------------------------------- +# +# The Octave side writes mls-.{wav,txt,png} into $data_dir. If any of +# them already exists (typical when re-measuring the same box at a new mic +# location), suffix the id with -2, -3, ... until nothing would collide. +# Only applied when the id was auto-derived; a user-supplied --id is +# honored verbatim so scripted overwrites still work. +uniquify_id() { + local base="$1" candidate="$1" n=2 + while [[ -e "$data_dir/mls-${candidate}.wav" \ + || -e "$data_dir/mls-${candidate}.txt" \ + || -e "$data_dir/mls-${candidate}.png" ]]; do + candidate="${base}-${n}" + n=$(( n + 1 )) + done + printf '%s' "$candidate" +} + +if (( id_auto )); then + new_id=$(uniquify_id "$id") + if [[ "$new_id" != "$id" ]]; then + echo "Note: prior measurement for '$id' found in $data_dir;" \ + "using id '$new_id' to avoid overwrite" >&2 + id="$new_id" + fi +fi + +# Resolve the ALSA capture format. Honor --rec-fmt if the user forced +# one; otherwise probe the mic and prefer S32_LE over S16_LE. +if [[ -n "$rec_fmt_override" ]]; then + rec_fmt="$rec_fmt_override" +else + rec_fmt=$(probe_capture_format "$mic_hw") +fi +rec_bits=$(format_to_bits "$rec_fmt") + +# Resolve the capture channel count similarly: if --mic-ch was given, +# use it verbatim; otherwise probe the max the mic advertises. Fall +# back to 1 when the probe returns nothing (device busy, etc.). +if [[ -z "$mic_channels" ]]; then + probed_ch=$(probe_capture_channels "$mic_hw") + if [[ -n "$probed_ch" ]]; then + mic_channels="$probed_ch" + else + mic_channels=1 + echo "Note: could not probe channel count for $mic_hw, using 1" >&2 + fi +fi + +# -------------------------------------------------------------------------- +# Report + write configs +# -------------------------------------------------------------------------- + +echo "DMI vendor : ${dmi_vendor_raw:-}" +echo "DMI product : ${dmi_product_raw:-}" +echo "Mic tag : ${mic_tag:-}" +if (( id_auto )); then + echo "DMI id : $id (sanitized, used in blob file names)" +else + echo "DMI id : $id (user-supplied via --id, used verbatim)" +fi +echo "Speaker : $speaker_hw ($speaker_name, $speaker_info)" +echo "Microphone : $mic_hw ($mic_name, $mic_info)" +echo "Rec format : $rec_fmt (${rec_bits}-bit)" +echo "Mic cal file : ${rec_cal:-}" +echo "Data dir : $data_dir" +echo "Speaker chs : $speaker_channels" +echo "Mic chs : $mic_channels" + +if [[ -n "$rec_cal" ]] && [[ ! -f "$rec_cal" ]]; then + echo "Warning: calibration file '$rec_cal' does not exist;" \ + "sof_mls_freq_resp.m will error out." >&2 +fi + +if is_webcam "$(echo "$mic_name" | tr '[:upper:]' '[:lower:]')"; then + echo "Warning: selected mic looks like a webcam; measurements are unlikely" \ + "to be usable. Consider passing --mic hw:C,D explicitly." >&2 +fi +# Base the calibrated-mic note on the actual device name/description so that +# a user forcing --mic hw:C,D on a real UMM-6 does not get a misleading +# "no calibrated mic" warning. mic_info can be "override" in that case, so +# it is not a reliable signal on its own. +lc_mic_name=$(echo "$mic_name" | tr '[:upper:]' '[:lower:]') +lc_mic_desc=$(echo "$mic_desc" | tr '[:upper:]' '[:lower:]') +if ! is_measurement_mic "$lc_mic_name" && ! is_measurement_mic "$lc_mic_desc"; then + echo "Note: no calibrated measurement microphone (UMM-6/UMIK/MiniDSP)" \ + "detected; results will not be calibrated." >&2 +fi + +play_conf="$data_dir/mls_play_config.txt" +rec_conf="$data_dir/mls_rec_config.txt" + +if (( dry_run )); then + echo + echo "-- Would write $play_conf --" + echo "-- Would write $rec_conf --" + if (( do_prep )); then + echo "-- Would prep card $(extract_card "$speaker_hw"):" + echo " IIR blob = $iir_pass_blob" + echo " FIR blob = $fir_pass_blob" + echo " DRC/MBDRC/TDFB switches -> off" + fi + exit 0 +fi + +mkdir -p -- "$data_dir" + +cat > "$play_conf" < "$rec_conf" <&2 + return 1 + fi + if [[ ! -f "$fir_blob" ]]; then + echo "FIR pass-through blob not found: $fir_blob" >&2 + return 1 + fi + + need amixer + need sof-ctl + need timeout + + echo + echo "Prep: card=$ctldev" + echo " IIR pass blob = $iir_blob" + echo " FIR pass blob = $fir_blob" + + # probe_bytes_size + # + # Return the on-device max byte-buffer size of the given BYTES + # control by asking sof-ctl to read it (no -s) and parsing the + # "Control size is N." line sof-ctl prints on stdout. amixer cannot + # read large bytes controls (they use the TLV path), so sof-ctl is + # the only usable probe. + # + # sof-ctl CLI: `-c name='...'` looks up the control by name; this + # is essential because `-n numid=N` for the same control on some + # SOF topologies returns "Control size is 1." (wrong max), for the + # same reason amixer's numid= path is buggy on these controls. + # `-i 4` is intentionally omitted here — we are only reading and + # do not care about the ABI type for size reporting. + # + # Empty on any failure (sof-ctl missing, control not found, ioctl + # error, timeout). + probe_bytes_size() { + local card="$1" name="$2" out + out=$(timeout 5 sof-ctl -D "hw:$card" -c name="$name" 2>&1) \ + || true + printf '%s\n' "$out" \ + | sed -nE 's/^Control size is ([0-9]+)\..*/\1/p' \ + | head -1 + } + + # probe_type_desc + # + # Return a short human-readable diagnostic line from sof-ctl's + # combined stderr+stdout, used only for the skip message. Prefers + # the "Control size is N." line, then a "hdr: magic ..." line, + # then any error-looking line, else the first line. Empty on total + # probe failure. + probe_type_desc() { + local card="$1" name="$2" out line + out=$(timeout 5 sof-ctl -D "hw:$card" -c name="$name" 2>&1) \ + || true + line=$(printf '%s\n' "$out" | grep -E '^Control size is ' | head -1) + if [[ -z "$line" ]]; then + line=$(printf '%s\n' "$out" | grep -E '^hdr: ' | head -1) + fi + if [[ -z "$line" ]]; then + line=$(printf '%s\n' "$out" | grep -Ei 'error|failed|not ' | head -1) + fi + if [[ -z "$line" ]]; then + line=$(printf '%s\n' "$out" | head -1) + fi + printf '%s' "$line" + } + + # Blob writes below the minimum size are refused; the SOF pass- + # through IIR blob is a few hundred bytes and FIR is >= 1 KiB, so + # anything under 64 bytes is definitely not a real EQ buffer. + local min_blob_bytes=64 + + # Snapshot the matching controls into an array so the loop body + # runs in the parent shell (not a pipeline subshell). This makes + # any hang or failure inside the body visible instead of silently + # terminating the subshell mid-loop. The grep filter is wrapped in + # `{ ... || true; }` so that a card without any IIR/FIR/DRC/MBDRC/ + # TDFB controls does not trip `set -euo pipefail`. + local ctl_lines line numid name size rc reason + mapfile -t ctl_lines < <(amixer -c "$card" controls \ + | { grep -E "IIR|FIR|DRC|MBDRC|TDFB" || true; }) + + if (( ${#ctl_lines[@]} == 0 )); then + echo " (no IIR/FIR/DRC/MBDRC/TDFB controls found on card $card)" + return 0 + fi + echo " ${#ctl_lines[@]} matching control(s) to process" + + for line in "${ctl_lines[@]}"; do + numid=$(printf '%s\n' "$line" \ + | sed -n 's/^numid=\([0-9]\+\),.*/\1/p') + name=$(printf '%s\n' "$line" \ + | sed -n "s/.*name='\\(.*\\)'[[:space:]]*$/\\1/p") + [[ -z "$numid" || -z "$name" ]] && continue + + case "$name" in + # Switches: DRC / MBDRC / TDFB -> off + *[Dd][Rr][Cc]*switch \ + | *[Tt][Dd][Ff][Bb]*switch \ + | *[Mm][Bb][Dd][Rr][Cc]*switch) + printf ' switch off numid=%s %s\n' \ + "$numid" "$name" + # amixer >= 1.2.13 has a known assertion + # failure ("info->id.name[0] || info->id.numid") + # in snd_ctl_elem_info() when cset is invoked + # with numid= for some switch controls; the + # process aborts with a core dump. Use the + # iface=MIXER,name='...' form instead, which + # takes the name-lookup path and sidesteps the + # buggy branch. `timeout` guards against a + # hung ioctl (e.g. DSP not responding). + rc=0 + timeout 5 amixer -q -c "$card" cset \ + iface=MIXER,name="$name" off || rc=$? + if (( rc != 0 )); then + echo " ...amixer failed (exit=$rc)" >&2 + fi + ;; + # IIR bytes / IIR Eq -> pass-through + *[Ii][Ii][Rr]*[Bb]ytes \ + | *[Ii][Ii][Rr]" Eq") + # `|| size=""` is REQUIRED here: under `set -e`, + # an assignment `x=$(cmd)` where the command + # substitution's pipeline fails (e.g. sof-ctl + # returns non-zero, and pipefail propagates it) + # will silently kill the entire script. Neutralise + # it so probe_bytes_size is treated as best-effort. + size=$(probe_bytes_size "$card" "$name") || size="" + if [[ -z "$size" ]]; then + reason=$(probe_type_desc "$card" "$name") + printf ' iir skip numid=%s %s (%s)\n' \ + "$numid" "$name" "${reason:-sof-ctl probe failed}" + continue + fi + if (( size < min_blob_bytes )); then + printf ' iir skip numid=%s %s (bytes size=%s, not a real EQ buffer)\n' \ + "$numid" "$name" "$size" + continue + fi + printf ' iir pass numid=%s %s (%s bytes)\n' \ + "$numid" "$name" "$size" + rc=0 + # Set via -c name= for the same reason the probe + # does: -n numid= can report wrong max sizes and + # is prone to the same buggy code path amixer + # hits with numid= access. + timeout 5 sof-ctl -i 4 -D "$ctldev" -c name="$name" \ + -s "$iir_blob" >/dev/null || rc=$? + if (( rc != 0 )); then + echo " ...sof-ctl failed (exit=$rc)" >&2 + fi + ;; + # FIR bytes / FIR Eq -> pass-through + *[Ff][Ii][Rr]*[Bb]ytes \ + | *[Ff][Ii][Rr]" Eq") + size=$(probe_bytes_size "$card" "$name") || size="" + if [[ -z "$size" ]]; then + reason=$(probe_type_desc "$card" "$name") + printf ' fir skip numid=%s %s (%s)\n' \ + "$numid" "$name" "${reason:-sof-ctl probe failed}" + continue + fi + if (( size < min_blob_bytes )); then + printf ' fir skip numid=%s %s (bytes size=%s, not a real EQ buffer)\n' \ + "$numid" "$name" "$size" + continue + fi + printf ' fir pass numid=%s %s (%s bytes)\n' \ + "$numid" "$name" "$size" + rc=0 + timeout 5 sof-ctl -i 4 -D "$ctldev" -c name="$name" \ + -s "$fir_blob" >/dev/null || rc=$? + if (( rc != 0 )); then + echo " ...sof-ctl failed (exit=$rc)" >&2 + fi + ;; + # Bytes controls we don't have a canonical pass-through + # for are just reported so the user sees what was left + # alone. + *[Bb]ytes|*enum|*[Ee]num) + printf ' skipped numid=%s %s\n' \ + "$numid" "$name" + ;; + esac + done +} + +if (( do_prep )); then + spk_card=$(extract_card "$speaker_hw") + if [[ -z "$spk_card" ]]; then + echo "Cannot extract card number from speaker '$speaker_hw'" >&2 + exit 6 + fi + prep_controls_passthrough "$spk_card" \ + "$iir_pass_blob" "$fir_pass_blob" +fi + +# -------------------------------------------------------------------------- +# Optionally invoke Octave +# -------------------------------------------------------------------------- + +# Escape single quotes so they survive being embedded in an Octave +# single-quoted string literal (the doubling rule Octave uses for ''). +# In practice id is sanitized to [a-z0-9_] and the paths come from our +# own $TMPDIR / script location, but a --dir or --id with a stray ' in +# it would otherwise break the string or inject additional Octave code. +oct_squote() { + printf "%s" "${1//\'/\'\'}" +} + +q_script_dir=$(oct_squote "$script_dir") +q_id=$(oct_squote "$id") +q_play_conf=$(oct_squote "$play_conf") +q_rec_conf=$(oct_squote "$rec_conf") +q_data_dir=$(oct_squote "$data_dir") + +octave_eval="cd('$q_script_dir'); sof_mls_freq_resp('$q_id', '$q_play_conf', '$q_rec_conf', '$q_data_dir');" + +echo +echo "To run the measurement manually:" +echo " octave --no-gui --no-window-system --eval \"$octave_eval\"" + +if (( do_run )); then + echo + echo "Running measurement..." + octave --no-gui --no-window-system --eval "$octave_eval" +fi