Add silence_gap_knn as a built-in; stop tracking the tmp/ offline workspace
7 changed files+148−2
.gitignoremodified+1−0View file
@@ -1,2 +1,3 @@
11 node_modules/
22 dist/
3+tmp/
README.mdmodified+6−1View file
@@ -80,7 +80,7 @@ to listen closely to a cluster of errors; a shift-click clears it.
8080
8181 ## Baseline algorithms
8282
83-Three built-ins seed the comparison and can be edited or reset in place:
83+Four built-ins seed the comparison and can be edited or reset in place:
8484
8585 - **template_matched_filter**: one unit-norm mean template per type,
8686 cross-correlated against the recording via the FFT; the per-type scores
@@ -91,6 +91,11 @@ Three built-ins seed the comparison and can be edited or reset in place:
9191 by distance-weighted k-nearest-neighbours over spectral shape features
9292 (band-energy ratios, centroid, zero-crossing rate). Levels are used only
9393 as ratios, so level variation, when enabled, carries no information.
94+- **silence_gap_knn**: segmentation on runs of nonzero samples (the
95+ synthesized background is digitally silent), then the same kNN
96+ classifier as onset_knn. Developed offline against a downloaded
97+ archive; the silence assumption is its stated limitation, and added
98+ background noise or overlaps defeat the segmentation.
9499 - **naive_threshold**: an amplitude threshold and a classifier that looks
95100 only at event duration. A deliberately weak floor; a real method that
96101 fails to clear it is in trouble.
scripts/smoke-browser.tsmodified+1−1View file
@@ -57,7 +57,7 @@ try {
5757
5858 // accuracy of the two real baselines should beat 0.5 on demo data
5959 const accs = rows.map((r) => Number(r.split('|')[1].trim().split(' ')[0]));
60- if (rows.length !== 3) throw new Error(`expected 3 result rows, got ${rows.length}`);
60+ if (rows.length !== 4) throw new Error(`expected 4 result rows, got ${rows.length}`);
6161 if (!(accs[0] > 0.5 && accs[1] > 0.5)) throw new Error(`accuracies too low: ${accs.join(', ')}`);
6262 if (errors.length) throw new Error(`page errors:\n${errors.join('\n')}`);
6363 console.log('SMOKE PASS');
scripts/test-node.tsmodified+2−0View file
@@ -14,6 +14,7 @@ import { runAlgorithm } from '../src/bench/execute.ts';
1414 import { makeSyntheticLibrary } from './synthetic-lib.ts';
1515 import matchedFilterSrc from '../src/algorithms/template_matched_filter.m?raw';
1616 import onsetKnnSrc from '../src/algorithms/onset_knn.m?raw';
17+import silenceGapKnnSrc from '../src/algorithms/silence_gap_knn.m?raw';
1718 import naiveSrc from '../src/algorithms/naive_threshold.m?raw';
1819
1920 const lib = makeSyntheticLibrary();
@@ -30,6 +31,7 @@ const cfg: SynthConfig = {
3031 const algos = [
3132 { name: 'template_matched_filter', source: matchedFilterSrc },
3233 { name: 'onset_knn', source: onsetKnnSrc },
34+ { name: 'silence_gap_knn', source: silenceGapKnnSrc },
3335 { name: 'naive_threshold', source: naiveSrc },
3436 ];
3537
scripts/test-offline.tsmodified+2−0View file
@@ -20,6 +20,7 @@ import { zipStore } from '../src/export/zip.ts';
2020 import { makeSyntheticLibrary } from './synthetic-lib.ts';
2121 import matchedFilterSrc from '../src/algorithms/template_matched_filter.m?raw';
2222 import onsetKnnSrc from '../src/algorithms/onset_knn.m?raw';
23+import silenceGapKnnSrc from '../src/algorithms/silence_gap_knn.m?raw';
2324 import naiveSrc from '../src/algorithms/naive_threshold.m?raw';
2425
2526 const lib = makeSyntheticLibrary();
@@ -32,6 +33,7 @@ const TOL_MS = 20;
3233 const algos = [
3334 { name: 'template_matched_filter', source: matchedFilterSrc },
3435 { name: 'onset_knn', source: onsetKnnSrc },
36+ { name: 'silence_gap_knn', source: silenceGapKnnSrc },
3537 { name: 'naive_threshold', source: naiveSrc },
3638 ];
3739
src/algorithms/index.tsmodified+8−0View file
@@ -6,6 +6,7 @@
66 import type { AlgorithmDef } from '../bench/types.ts';
77 import matchedFilterSrc from './template_matched_filter.m?raw';
88 import onsetKnnSrc from './onset_knn.m?raw';
9+import silenceGapKnnSrc from './silence_gap_knn.m?raw';
910 import naiveSrc from './naive_threshold.m?raw';
1011
1112 export const BUILTIN_ALGORITHMS: AlgorithmDef[] = [
@@ -23,6 +24,13 @@ export const BUILTIN_ALGORITHMS: AlgorithmDef[] = [
2324 enabled: true,
2425 builtin: true,
2526 },
27+ {
28+ id: 'builtin:silence_gap_knn',
29+ name: 'silence_gap_knn',
30+ source: silenceGapKnnSrc,
31+ enabled: true,
32+ builtin: true,
33+ },
2634 {
2735 id: 'builtin:naive_threshold',
2836 name: 'naive_threshold',
src/algorithms/silence_gap_knn.madded+128−0View file
@@ -0,0 +1,128 @@
1+function [times, labels] = silence_gap_knn(x, fs, training)
2+% Segment on silence, then classify with kNN over spectral shape.
3+%
4+% The synthesized recordings place each event snippet on a digitally
5+% silent background, so an event is exactly one contiguous run of
6+% nonzero samples, one snippet long. Detection therefore reduces to
7+% finding those runs: samples above a tiny threshold (relative to the
8+% recording peak) are grouped into regions, gaps of a few milliseconds
9+% inside a region are closed, sub-10 ms glitches are dropped, and a
10+% region much longer than one snippet is split into snippet-length
11+% events, which handles back-to-back placements. The event time is the
12+% region start plus the pre-onset offset, which lands on the ground
13+% truth exactly. Each event is then classified by distance-weighted
14+% k-nearest-neighbours over level-invariant spectral features of the
15+% post-onset segment, as in onset_knn.
16+%
17+% The silence assumption is the limitation: on a recording with real
18+% background noise, or with overlapping events, the segmentation would
19+% fail and an envelope or matched-filter detector would be needed.
20+nTypes = numel(training.snips);
21+L = size(training.snips{1}, 1);
22+pre = training.pre;
23+n = numel(x);
24+
25+% ---- detection: contiguous nonzero regions ------------------------------
26+eps0 = 1e-4 * max(abs(x));
27+gapClose = round(0.002 * fs);
28+minDur = round(0.010 * fs);
29+
30+starts = zeros(0, 1);
31+ends = zeros(0, 1);
32+i = 1;
33+while i <= n
34+ if abs(x(i)) > eps0
35+ s = i;
36+ e = i;
37+ j = i + 1;
38+ quiet = 0;
39+ while j <= n && quiet <= gapClose
40+ if abs(x(j)) > eps0
41+ e = j;
42+ quiet = 0;
43+ else
44+ quiet = quiet + 1;
45+ end
46+ j = j + 1;
47+ end
48+ if e - s + 1 >= minDur
49+ starts(end+1, 1) = s;
50+ ends(end+1, 1) = e;
51+ end
52+ i = e + gapClose + 1;
53+ else
54+ i = i + 1;
55+ end
56+end
57+
58+% split regions covering several back-to-back snippets
59+ev = zeros(0, 1);
60+for r = 1:numel(starts)
61+ nev = max(1, round((ends(r) - starts(r) + 1) / L));
62+ for q = 1:nev
63+ ev(end+1, 1) = starts(r) + (q - 1) * L;
64+ end
65+end
66+
67+% ---- training features --------------------------------------------------
68+nTrain = 0;
69+for k = 1:nTypes
70+ nTrain = nTrain + size(training.snips{k}, 2);
71+end
72+F = zeros(nTrain, 7);
73+Fl = zeros(nTrain, 1);
74+r = 0;
75+for k = 1:nTypes
76+ S = training.snips{k};
77+ for j = 1:size(S, 2)
78+ r = r + 1;
79+ F(r, :) = spectral_features(S(pre+1:end, j), fs);
80+ Fl(r) = k;
81+ end
82+end
83+mu = mean(F, 1);
84+sd = std(F, 0, 1) + 1e-9;
85+Fz = (F - repmat(mu, nTrain, 1)) ./ repmat(sd, nTrain, 1);
86+
87+% ---- classify each event ------------------------------------------------
88+kNN = min(5, nTrain);
89+times = zeros(0, 1);
90+labels = zeros(0, 1);
91+for e = 1:numel(ev)
92+ s = ev(e);
93+ seg = x(min(n, s + pre):min(n, s + L - 1));
94+ f = (spectral_features(seg, fs) - mu) ./ sd;
95+ d = sqrt(sum((Fz - repmat(f, nTrain, 1)).^2, 2));
96+ [ds, di] = sort(d);
97+ votes = zeros(nTypes, 1);
98+ for v = 1:kNN
99+ votes(Fl(di(v))) = votes(Fl(di(v))) + 1 / (ds(v) + 1e-6);
100+ end
101+ [~, lab] = max(votes);
102+ times(end+1, 1) = (s - 1 + pre) / fs;
103+ labels(end+1, 1) = lab;
104+end
105+end
106+
107+function f = spectral_features(seg, fs)
108+% Six log band-energy ratios (mean removed, so absolute level cancels)
109+% and the log spectral centroid.
110+nfft = 2048;
111+m = min(numel(seg), nfft);
112+s2 = seg(1:m);
113+win = 0.5 - 0.5 * cos(2 * pi * (0:m-1)' / max(m - 1, 1));
114+S = fft(s2 .* win, nfft);
115+p = abs(S(1:nfft/2+1)).^2;
116+hz = fs / nfft;
117+edges = [0 200 500 1000 2000 4000 8000];
118+b = zeros(1, 6);
119+for q = 1:6
120+ k0 = max(2, floor(edges(q) / hz) + 1);
121+ k1 = min(nfft/2 + 1, floor(edges(q+1) / hz));
122+ b(q) = log10(sum(p(k0:k1)) + 1e-12);
123+end
124+b = b - mean(b);
125+kv = (0:numel(p)-1)' * hz;
126+cen = sum(p .* kv) / (sum(p) + 1e-12);
127+f = [b, log10(cen + 1)];
128+end