@audio/denoise-detect 0.1.2 → 0.1.4

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 (2) hide show
  1. package/denoise.js +59 -34
  2. package/package.json +1 -1
package/denoise.js CHANGED
@@ -2,14 +2,15 @@
2
2
  // in `data` and dispatches to the most suitable single-pass method.
3
3
  //
4
4
  // Classification (single STFT sweep over the input):
5
- // - tonalScore — strong narrow peaks at mains harmonics → dehum
6
- // - clickScore — high kurtosis of AR residual → declick
7
- // - lfScore — LF/MF energy ratio above 4 → dewind
8
- // - sibilanceScore — 5–9 kHz peaks vs. mids → deesser
9
- // - reverbScore — slow late-tail decay (cepstral peak) → dereverb
10
- // - stationary → omlsa (best non-stationary general denoise)
11
- // - otherwise → wiener (transparent broadband)
5
+ // - hum — narrow peaks at mains harmonics (Goertzel) → dehum
6
+ // - click — high kurtosis of AR residual → declick
7
+ // - hi — 5–9 kHz / mid energy ratio → deesser
8
+ // - lf — LF/mid energy ratio → dewind
9
+ // - stationarity — frame-energy floor CV: stable → wiener, wandering → omlsa
10
+ // - otherwise → wiener (transparent broadband)
12
11
  //
12
+ // dereverb has no reliable single-pass signature, so auto-mode never selects it —
13
+ // reach it explicitly via `denoise(data, { force: 'dereverb' })` or `dereverb()`.
13
14
  // Returns { out, plan } so callers can inspect which method ran.
14
15
 
15
16
  import { stftAnalyse } from '@audio/stft'
@@ -49,18 +50,19 @@ export function classify(data, fs = 44100) {
49
50
  let frameVar = [] // for stationarity
50
51
 
51
52
  stftAnalyse(data, mag => {
52
- let lf = 0, mf = 0, hi = 0
53
+ let lf = 0, mf = 0, hi = 0, tot = 0
53
54
  for (let k = 0; k <= half; k++) {
54
55
  let p = mag[k] * mag[k]
55
56
  bins[k] += p
57
+ tot += p
56
58
  let f = k * fs / N
57
59
  if (f < 200) lf += p
58
60
  else if (f < 2000) mf += p
59
- else if (f < 9000) hi += p
61
+ else if (f >= 5000 && f < 9000) hi += p // sibilance band, kept specific (not 2–9 kHz)
60
62
  }
61
63
  lfSum += lf; mfSum += mf; hiSum += hi
62
- frameVar.push(lf + mf + hi)
63
- frames++
64
+ frameVar.push(tot) // full-spectrum energy — band-restricted
65
+ frames++ // sums under-average the floor statistic
64
66
  }, { frameSize: N, hopSize: hop })
65
67
  if (!frames) return { method: 'wiener', scores: {} }
66
68
 
@@ -91,46 +93,69 @@ export function classify(data, fs = 44100) {
91
93
  // HI/MF ratio
92
94
  let hiRatio = hiSum / Math.max(mfSum, 1e-30)
93
95
 
94
- // Click score: AR residual kurtosis on a short window
96
+ // Click score: max AR-residual excess kurtosis over windows spanning the whole
97
+ // signal. Clicks are sparse and impulsive — a fixed prefix window can miss them
98
+ // entirely, so scan across and take the peak (the window holding a click spikes).
95
99
  let clickScore = 0
96
- if (data.length >= 4096) {
97
- let win = data.subarray(0, 4096)
98
- try {
99
- let { a } = arFit(win, 30)
100
- let resid = new Float64Array(4096), mean = 0, m2 = 0, m4 = 0
101
- for (let i = 30; i < 4096; i++) {
102
- let s = win[i]
103
- for (let k = 1; k <= 30; k++) s += a[k] * win[i - k]
104
- resid[i] = s; mean += s
105
- }
106
- mean /= (4096 - 30)
107
- for (let i = 30; i < 4096; i++) { let d = resid[i] - mean; m2 += d * d; m4 += d * d * d * d }
108
- m2 /= (4096 - 30); m4 /= (4096 - 30)
109
- clickScore = m2 > 0 ? m4 / (m2 * m2) - 3 : 0 // excess kurtosis
110
- } catch {}
100
+ let cw = 4096
101
+ if (data.length >= cw) {
102
+ let stride = Math.max(cw, Math.floor((data.length - cw) / 8) || cw)
103
+ for (let start = 0; start + cw <= data.length; start += stride) {
104
+ let seg = data.subarray(start, start + cw)
105
+ try {
106
+ let { a } = arFit(seg, 30)
107
+ let resid = new Float64Array(cw), mean = 0, m2 = 0, m4 = 0
108
+ for (let i = 30; i < cw; i++) {
109
+ let s = seg[i]
110
+ for (let k = 1; k <= 30; k++) s += a[k] * seg[i - k]
111
+ resid[i] = s; mean += s
112
+ }
113
+ mean /= (cw - 30)
114
+ for (let i = 30; i < cw; i++) { let d = resid[i] - mean; m2 += d * d; m4 += d * d * d * d }
115
+ m2 /= (cw - 30); m4 /= (cw - 30)
116
+ let ex = m2 > 0 ? m4 / (m2 * m2) - 3 : 0 // excess kurtosis
117
+ if (ex > clickScore) clickScore = ex
118
+ } catch {}
119
+ }
111
120
  }
112
121
 
113
- // Stationarity: variance-of-variances of frame energy
114
- let mean = 0; for (let v of frameVar) mean += v; mean /= frames
115
- let varE = 0; for (let v of frameVar) varE += (v - mean) ** 2; varE /= frames
116
- let cv = mean > 0 ? Math.sqrt(varE) / mean : 0 // higher = less stationary
122
+ // Noise stationarity: CV of the frame-energy FLOOR (rolling minimum over ~0.75 s).
123
+ // Speech dynamics ride above the floor, so the floor tracks the *noise bed*:
124
+ // stationary noise → stable floor (CV ≈ 0.06 measured on speech+white), babble /
125
+ // wandering beds → drifting floor (CV ≈ 0.5). Raw frame-energy CV can't make this
126
+ // call — speech's own variance trips it regardless of the noise.
127
+ let floorCV = 0
128
+ {
129
+ let D = 64, step = 16, floors = []
130
+ for (let i = D; i < frames; i += step) {
131
+ let mn = Infinity
132
+ for (let j = i - D; j < i; j++) if (frameVar[j] < mn) mn = frameVar[j]
133
+ floors.push(mn)
134
+ }
135
+ if (floors.length >= 3) {
136
+ let m = 0; for (let f of floors) m += f; m /= floors.length
137
+ let v = 0; for (let f of floors) v += (f - m) ** 2; v /= floors.length
138
+ floorCV = m > 0 ? Math.sqrt(v) / m : 0
139
+ }
140
+ }
117
141
 
118
142
  let scores = {
119
143
  hum: humBest, humFreq,
120
144
  click: clickScore,
121
145
  lf: lfRatio,
122
146
  hi: hiRatio,
123
- nonstationarity: cv
147
+ stationarity: floorCV // low = stationary noise bed
124
148
  }
125
149
 
126
- // Priority: tonal hum > impulses > sibilance > rumble > non-stationary > broadband.
150
+ // Priority: tonal hum > impulses > sibilance > rumble > stationary → wiener,
151
+ // non-stationary → omlsa (IMCRA keeps adapting where a frozen profile can't).
127
152
  // humBest is a hit count: ≥2 of the first 3 harmonics show 20× peak-to-median sharpness.
128
153
  let method = 'wiener'
129
154
  if (humBest >= 2) method = 'dehum'
130
155
  else if (clickScore > 12) method = 'declick'
131
156
  else if (hiRatio > 8) method = 'deesser' // white noise scores ~3.9 by bandwidth alone
132
157
  else if (lfRatio > 3) method = 'dewind'
133
- else if (cv > 0.6) method = 'omlsa'
158
+ else if (floorCV > 0.3) method = 'omlsa' // white ~0.06 · rumble ~0.2 · babble ~0.5
134
159
 
135
160
  return { method, scores, humFreq }
136
161
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@audio/denoise-detect",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "denoise — content-aware auto-selector that classifies the dominant noise type",
5
5
  "type": "module",
6
6
  "sideEffects": false,