@audio/denoise-detect 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/denoise.js +39 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ denoise(data, { force: 'wiener' }) // skip classifie
26
26
 
27
27
  **Routing (in priority order):**
28
28
  1. tonal hum (Goertzel — ≥2 of first 3 harmonics show 50× line/off-line ratio at 50 or 60 Hz)
29
- 2. impulses (excess kurtosis of AR residual > 12)
29
+ 2. impulses: AR(30) residual samples over 12× the residual RMS within ±10 ms and 2× every residual 2.5–15 ms away (a glottal pulse has its like one pitch period off), more than 1 a second (`CLICK_RATE`)
30
30
  3. sibilance (high/mid band power ratio > 8)
31
31
  4. LF rumble (low/mid band power ratio > 3)
32
32
  5. non-stationary noise (frame-energy CV > 0.6) → omlsa
package/denoise.js CHANGED
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Classification (single STFT sweep over the input):
5
5
  // - hum — narrow peaks at mains harmonics (Goertzel) → dehum
6
- // - click — high kurtosis of AR residual → declick
6
+ // - click — impulses standing out of the AR residual, over 1 a second → declick
7
7
  // - hi — 5–9 kHz / mid energy ratio → deesser
8
8
  // - lf — LF/mid energy ratio → dewind
9
9
  // - stationarity — frame-energy floor CV: stable → wiener, wandering → omlsa
@@ -111,31 +111,10 @@ export function classify(data, fs = 44100) {
111
111
  // HI/MF ratio
112
112
  let hiRatio = hiSum / Math.max(mfSum, 1e-30)
113
113
 
114
- // Click score: max AR-residual excess kurtosis over windows spanning the whole
115
- // signal. Clicks are sparse and impulsive — a fixed prefix window can miss them
116
- // entirely, so scan across and take the peak (the window holding a click spikes).
117
- let clickScore = 0
118
- let cw = 4096
119
- if (data.length >= cw) {
120
- let stride = Math.max(cw, Math.floor((data.length - cw) / 8) || cw)
121
- for (let start = 0; start + cw <= data.length; start += stride) {
122
- let seg = data.subarray(start, start + cw)
123
- try {
124
- let { a } = arFit(seg, 30)
125
- let resid = new Float64Array(cw), mean = 0, m2 = 0, m4 = 0
126
- for (let i = 30; i < cw; i++) {
127
- let s = seg[i]
128
- for (let k = 1; k <= 30; k++) s += a[k] * seg[i - k]
129
- resid[i] = s; mean += s
130
- }
131
- mean /= (cw - 30)
132
- for (let i = 30; i < cw; i++) { let d = resid[i] - mean; m2 += d * d; m4 += d * d * d * d }
133
- m2 /= (cw - 30); m4 /= (cw - 30)
134
- let ex = m2 > 0 ? m4 / (m2 * m2) - 3 : 0 // excess kurtosis
135
- if (ex > clickScore) clickScore = ex
136
- } catch {}
137
- }
138
- }
114
+ // Click score: impulses per second. A click stands far out of the AR(30) prediction error around it; a glottal
115
+ // pulse doesn't, its neighbours 2.5–12 ms away being pulses too (the error's kurtosis can't tell them apart:
116
+ // clean narration read 2.5–401 against a trigger of 12).
117
+ let clickScore = impulseRate(data, fs)
139
118
 
140
119
  // Noise stationarity: CV of the frame-energy FLOOR (rolling minimum over ~0.75 s).
141
120
  // Speech dynamics ride above the floor, so the floor tracks the *noise bed*:
@@ -170,7 +149,7 @@ export function classify(data, fs = 44100) {
170
149
  // humBest is a hit count: ≥2 of the first 3 harmonics show 20× peak-to-median sharpness.
171
150
  let method = 'wiener'
172
151
  if (humBest >= 2) method = 'dehum'
173
- else if (clickScore > 12) method = 'declick'
152
+ else if (clickScore > CLICK_RATE) method = 'declick'
174
153
  else if (hiRatio > 8) method = 'deesser' // white noise scores ~3.9 by bandwidth alone
175
154
  else if (lfRatio > 3) method = 'dewind'
176
155
  else if (floorCV > 0.3) method = 'omlsa' // white ~0.06 · rumble ~0.2 · babble ~0.5
@@ -179,6 +158,39 @@ export function classify(data, fs = 44100) {
179
158
  }
180
159
 
181
160
  // Goertzel power at frequency f.
161
+ /** Impulses per second a recording must carry for declick. Measured: clean narration, music and a sung vowel 0–0.87;
162
+ * the same with clicks at 2.5 a second 1.9 and up, faint ones (0.05) 1.3 and up (Spoken Wikipedia takes, lena). */
163
+ export const CLICK_RATE = 1
164
+
165
+ /** Impulses per second. An impulse is an AR(30) residual sample over 12× the residual RMS within ±10 ms that also
166
+ * towers (2×) over every residual 2.5–15 ms away: a glottal pulse has its like one pitch period off, a click doesn't.
167
+ * Events 5 ms apart, over up to 64 windows of 4096 samples spread across the signal (≈ 6 s at 44.1 kHz). */
168
+ function impulseRate(data, fs) {
169
+ const W = 4096, P = 30, K = 12, M = 2
170
+ const h = Math.round(0.01 * fs), gap = Math.round(0.005 * fs), near = Math.round(0.0025 * fs), far = Math.round(0.015 * fs)
171
+ if (data.length < W || W - P <= 2 * h) return 0
172
+ let count = Math.min(64, Math.floor(data.length / W)), stride = count > 1 ? (data.length - W) / (count - 1) : 0
173
+ let r = new Float64Array(W), events = 0, seconds = 0
174
+ for (let w = 0; w < count; w++) {
175
+ let seg = data.subarray(Math.round(w * stride), Math.round(w * stride) + W), a
176
+ try { ({ a } = arFit(seg, P)) } catch { continue }
177
+ for (let i = P; i < W; i++) { let e = seg[i]; for (let k = 1; k <= P; k++) e += a[k] * seg[i - k]; r[i] = e }
178
+ let e2 = 0, last = -gap
179
+ for (let i = P; i < P + 2 * h; i++) e2 += r[i] * r[i]
180
+ for (let i = P + h; i < W - h; i++) {
181
+ let v = Math.abs(r[i]), rms = Math.sqrt(e2 / (2 * h))
182
+ if (rms > 0 && v > K * rms && i - last > gap) {
183
+ let m = 0
184
+ for (let j = Math.max(P, i - far); j <= Math.min(W - 1, i + far); j++) if (Math.abs(j - i) >= near && Math.abs(r[j]) > m) m = Math.abs(r[j])
185
+ if (v > M * m) { events++; last = i }
186
+ }
187
+ e2 += r[i + h] * r[i + h] - r[i - h] * r[i - h]
188
+ }
189
+ seconds += (W - P - 2 * h) / fs
190
+ }
191
+ return seconds ? events / seconds : 0
192
+ }
193
+
182
194
  function goertzelE(data, f, fs) {
183
195
  let w = 2 * Math.PI * f / fs, c = 2 * Math.cos(w), s1 = 0, s2 = 0
184
196
  for (let i = 0; i < data.length; i++) { let s = data[i] + c * s1 - s2; s2 = s1; s1 = s }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audio/denoise-detect",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
4
4
  "description": "denoise — content-aware auto-selector that classifies the dominant noise type",
5
5
  "type": "module",
6
6
  "sideEffects": false,