SNR of Antarctic blue whale ABZ calls — Common Ground (Miller et al. in press)
Recomputes SNR for all detections in the multi-observer capture history from Miller et al. (in press, Methods in Ecology and Evolution), using bsnr's spectrogramSlices method with calibrated acoustic levels.
The capture history contains adjudicated detections from five observers: Observers 1-3 — human analysts (Raven Pro annotations) Observer 4 — Ishmael SCC automated detector (250 Hz recordings) Observer 5 — Koogu DNN automated detector (250 Hz recordings)
For each row, SNR is computed separately for each observer that detected the call, then averaged (nanmean) across observers. The key figure shows SNR as a function of the number of observers with positive detections, split by adjudicated verdict (true/false positive).
CANONICAL CONFIGURATION snrType = spectrogramSlices freq = [25 29] Hz (ABZ unit-A band) noiseLocation = beforeAndAfter noiseDelay = 1 s nfft = 1024 (1000 Hz SR → 1.024 s window, ~1 Hz resolution) nOverlap = 768 (75% overlap, matching annotationSNR default) calibration = metaDataCasey2019 (included in examples/) useLurton = false (simple power ratio)
REFERENCE Miller, B.S. et al. (in press). Common ground: efficient, consistent, observer-independent bioacoustic call density estimation with adjudicated ground truth and capture-recapture detection functions. Methods in Ecology and Evolution.
DATA Capture history: included in examples/ (this script's directory) Recordings: Australian Antarctic Data Centre https://data.aad.gov.au/metadata/AAS_4102_longTermAcousticRecordings
Contents
User configuration
Edit the paths below to match your local installation. Recordings are not publicly available; contact aadcwebqueries@aad.gov.au.
wavRoot = 'w:\annotatedLibrary\BAFAAL\Casey2019\wav'; captureHistoryFile = fullfile(fileparts(mfilename('fullpath')), ... 'MultiObserverCaptureHistory_Casey2019_Bm-Ant-ABZ-calls_analysts123sd_judgedBSM_simpleSnr.csv');
Load capture history
fprintf('Loading capture history...\n'); ch = readtable(captureHistoryFile, 'VariableNamingRule', 'preserve'); fprintf(' Total rows: %d\n', height(ch)); % Restrict to adjudicated rows only chJ = ch(logical(ch.judged), :); nRows = height(chJ); fprintf(' Judged rows: %d (true: %d false: %d)\n', nRows, ... sum(chJ.verdict == 1), sum(chJ.verdict == 0));
Loading capture history... Total rows: 10312 Judged rows: 1289 (true: 681 false: 608)
Calibration metadata
metadata = metaDataCasey2019();
SNR parameters
p = struct(); p.snrType = 'spectrogramSlices'; p.useLurton = false; p.noiseLocation = 'beforeAndAfter'; p.noiseDelay = 1; p.nfft = 1024; p.nOverlap = floor(1024 * 0.75); % 768 — matches annotationSNR overlap=0.75 p.freq = [25 29]; p.metadata = metadata; p.showClips = false; p.verbose = false;
Compute SNR per observer
nObs = 5; snrBsnr = nan(nRows, nObs); for obs = 1:nObs fprintf('Computing SNR for observer %d...\n', obs); obsStr = num2str(obs); detectCol = ['detect_observer' obsStr]; t0Col = ['t0_observer' obsStr]; tEndCol = ['tEnd_observer' obsStr]; durCol = ['duration_observer' obsStr]; % Build annotation table for rows where this observer detected detMask = logical(chJ.(detectCol)); nDet = sum(detMask); if nDet == 0, continue; end annots = table(); annots.soundFolder = repmat({wavRoot}, nDet, 1); annots.t0 = chJ.(t0Col)(detMask); annots.duration = chJ.(durCol)(detMask); annots.tEnd = chJ.(tEndCol)(detMask); annots.freq = repmat(p.freq, nDet, 1); annots.channel = ones(nDet, 1); annots.classification = repmat({'Bm-Ant-ABZ'}, nDet, 1); res = snrEstimate(annots, p); % Store back into full-length vector snrBsnr(detMask, obs) = res.snr; end
Computing SNR for observer 1... (parallel) 0 25 50 75 100% |-----------|-----------|-----------|------------| ################################################## Computing SNR for observer 2... (parallel) 0 25 50 75 100% |-----------|-----------|-----------|------------| ################################################## Computing SNR for observer 3... SNR analysis started: 29-Apr-2026 13:59:13 1167 annotations to process (parallel) 0 25 50 75 100% |-----------|-----------|-----------|------------| ################################################## SNR analysis completed: 29-Apr-2026 13:59:17 Computing SNR for observer 4... (parallel) 0 25 50 75 100% |-----------|-----------|-----------|------------| ################################################## Computing SNR for observer 5... (parallel) 0 25 50 75 100% |-----------|-----------|-----------|------------| ##################################################
Aggregate SNR — nanmean across observers per row
chJ.snr_bsnr = mean(snrBsnr, 2, 'omitnan'); chJ.numPositiveObs = sum(chJ{:, strcat('detect_observer', string(1:5))}, 2); fprintf('\nSNR computed. Finite values: %d / %d rows\n', ... sum(isfinite(chJ.snr_bsnr)), nRows); fprintf('bsnr SNR: median=%.1f dB range=[%.1f %.1f] dB\n', ... median(chJ.snr_bsnr(isfinite(chJ.snr_bsnr))), ... prctile(chJ.snr_bsnr(isfinite(chJ.snr_bsnr)), 5), ... prctile(chJ.snr_bsnr(isfinite(chJ.snr_bsnr)), 95)); % Compare against stored SNR from original analysis snrPaper = mean(chJ{:, strcat('snr_observer', string(1:5))}, 2, 'omitnan'); ok = isfinite(chJ.snr_bsnr) & isfinite(snrPaper); r = corr(chJ.snr_bsnr(ok), snrPaper(ok)); fprintf('Correlation with paper SNR: r=%.3f bias=%+.1f dB n=%d\n', ... r, mean(chJ.snr_bsnr(ok) - snrPaper(ok)), sum(ok));
SNR computed. Finite values: 1289 / 1289 rows bsnr SNR: median=1.8 dB range=[-0.9 5.2] dB Correlation with paper SNR: r=0.961 bias=+0.4 dB n=1289
Figure: SNR by number of positive observers × verdict
nPosVals = 1:5;
verdictLabels = {'False positive', 'True positive'};
colours = [0.8 0.3 0.2; 0.2 0.5 0.8];
% Precompute group sizes for proportional width scaling
maxN = 0;
for v = [0 1]
for np = nPosVals
mask = chJ.numPositiveObs == np & chJ.verdict == v & isfinite(chJ.snr_bsnr);
maxN = max(maxN, sum(mask));
end
end
fig = figure('Name', 'Common Ground — SNR by observer agreement', ...
'Units', 'pixels', 'Position', [50 50 700 480]);
ax = axes(fig);
hold(ax, 'on');
offsets = [-0.0 0.0];
legHandles = [];
for v = [0 1]
col = colours(v+1, :);
firstPatch = true;
for np = nPosVals
mask = chJ.numPositiveObs == np & chJ.verdict == v & isfinite(chJ.snr_bsnr);
y = chJ.snr_bsnr(mask);
if numel(y) < 3, continue; end
[f, xi] = ksdensity(y, 'Bandwidth', 1.5);
f = f / max(f) * 0.4 * (numel(y) / maxN);
xOff = np + offsets(v+1);
xPos = [xOff + f*(-1)^(v+1), fliplr(xOff + zeros(size(f)))];
yPos = [xi, fliplr(xi)];
h = fill(ax, xPos, yPos, col, 'FaceAlpha', 0.5, 'EdgeColor', col, ...
'HandleVisibility', 'off');
if firstPatch
legHandles(end+1) = h; %#ok<AGROW>
firstPatch = false;
end
% Median line
plot(ax, xOff + [-0.15 0.15]*(-1)^(v+1), median(y)*[1 1], '-', ...
'Color', col*0.7, 'LineWidth', 2, 'HandleVisibility', 'off');
end
end
hold(ax, 'off');
set(ax, 'XTick', nPosVals, 'XTickLabel', string(nPosVals));
xlabel(ax, 'Number of observers with positive detections');
ylabel(ax, 'SNR (dB re 1 µPa²)');
title(ax, 'Antarctic blue whale ABZ calls — Casey 2019');
legend(ax, legHandles, verdictLabels, 'Location', 'northwest');
grid(ax, 'on'); box(ax, 'on');
Save results
outFile = fullfile(fileparts(mfilename('fullpath')), ... 'snr_abw_casey2019_commonground_results.csv'); writetable(chJ(:, {'t0', 'verdict', 'judged', 'numPositiveObs', 'snr_bsnr', ... 'snr_observer1','snr_observer2','snr_observer3','snr_observer4','snr_observer5', ... 'detect_observer1','detect_observer2','detect_observer3','detect_observer4','detect_observer5'}), ... outFile); fprintf('\nResults saved to: %s\n', outFile);
Results saved to: C:\analysis\bsnr\examples\snr_abw_casey2019_commonground_results.csv