Skip to content

Commit 85928bd

Browse files
committed
gate: hysteresis + look-ahead merged from denoise-gate (delay-line emission, declared latency); deesser: mode 'band' dynamic peaking EQ merged from denoise-deesser — gate 0.1.4, deesser 0.2.3, umbrella 0.2.2
1 parent 30f3d23 commit 85928bd

14 files changed

Lines changed: 215 additions & 154 deletions

File tree

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -105,22 +105,24 @@ limiter(data, { ceiling: -1, lookahead: 10, release: 100 })
105105

106106
## gate
107107

108-
Noise gate with hold-then-close logic. Below threshold, signal is attenuated by `range` dB. A `hold` timer keeps the gate open after a drop-out to avoid chatter; attack/release smooth the gain transitions.
108+
Noise gate with hysteresis, hold-then-close logic and look-ahead. Opens above `threshold`, closes only below `closeThreshold` (hysteresis prevents chatter around a single threshold); below it, signal is attenuated by `range` dB. A `hold` timer keeps the gate open after a drop-out; attack/release smooth the gain transitions. `lookahead` runs detection ahead of emission so the gate is already opening when a transient reaches the output — batch calls stay sample-aligned (no silence prefix, no dropped tail); block hosts get the delay declared as atom `latency`.
109109

110110
```js
111111
import { gate } from '@audio/dynamics'
112112

113113
gate(data, { threshold: -40 })
114-
gate(data, { threshold: -35, range: -80, hold: 20, attack: 1, release: 150 })
114+
gate(data, { threshold: -35, range: -80, hold: 20, attack: 1, release: 150, lookahead: 5 })
115115
```
116116

117117
| Param | Default | |
118118
|---|---|---|
119-
| `threshold` | `-40` | dB |
119+
| `threshold` | `-40` | dB, open above |
120+
| `closeThreshold` | `threshold − 6` | dB, close below (hysteresis) |
120121
| `range` | `-60` | dB attenuation when closed |
121122
| `hold` | `10` | ms |
122123
| `attack` | `0.1` | ms (opening) |
123124
| `release` | `100` | ms (closing) |
125+
| `lookahead` | `0` | ms, detection leads emission |
124126

125127
**Use when:** drum mics, voice dialogue with ambient noise, removing hiss between phrases.<br>
126128
**Not for:** subtle low-level reduction — use [expander](#expander).
@@ -151,26 +153,28 @@ expander(data, { threshold: -30, ratio: 2 })
151153

152154
## deesser
153155

154-
Sibilance compressor. A biquad bandpass drives the envelope follower; the resulting gain reduction is applied broadband. Simple and transparent.
156+
Sibilance reduction, two architectures behind `mode`: **broadband** (default) — a biquad bandpass drives the envelope follower and the gain reduction is applied broadband; simple and transparent. **band** — an HP-filtered sidechain drives a dynamic peaking EQ at `freq`, so only the sibilance band is cut and program below it stays untouched even during deep reduction (wideband/split-band precedent).
155157

156158
```js
157159
import { deesser } from '@audio/dynamics'
158160

159161
deesser(data, { freq: 6500, threshold: -20 })
160162
deesser(data, { freq: 5500, q: 3, threshold: -24, ratio: 6 })
163+
deesser(data, { mode: 'band', freq: 7000, threshold: -30, ratio: 8 })
161164
```
162165

163166
| Param | Default | |
164167
|---|---|---|
168+
| `mode` | `'broadband'` | `'broadband'` \| `'band'` |
165169
| `freq` | `6500` | Hz, sibilance center |
166-
| `q` | `2` | bandpass Q |
170+
| `q` | `2` | bandpass Q (broadband) / `1.4` peaking-cut Q (band) |
167171
| `threshold` | `-20` | dB (on sidechain level) |
168172
| `ratio` | `4` ||
169-
| `knee` | `6` | dB |
173+
| `knee` | `6` | dB (broadband only) |
170174
| `attack` | `1` | ms |
171175
| `release` | `40` | ms |
172176

173-
**Use when:** harsh 's' / 't' / 'sh' in close-miked voice, bright vocal takes.<br>
177+
**Use when:** harsh 's' / 't' / 'sh' in close-miked voice, bright vocal takes; `mode: 'band'` when the voice sits with program that must not pump.<br>
174178
**Not for:** broadband brightness — use an EQ. Generic compression — use [compressor](#compressor).
175179

176180

@@ -254,7 +258,7 @@ compand(data, {
254258

255259
## See also
256260

257-
* [denoise](https://github.com/audiojs/denoise)gate belongs here too; umbrella for everything noise
261+
* [denoise](https://github.com/audiojs/denoise)umbrella for everything noise; its `gate`/`deesser` are seconds-unit adapters over this package (2026-07 near-dupe merge)
258262
* [filter](https://github.com/audiojs/filter) — biquads for deesser sidechain
259263
* [effect](https://github.com/audiojs/effect) — modulation effects
260264
* [stretch](https://github.com/audiojs/stretch) — sibling package

package-lock.json

Lines changed: 17 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@audio/dynamics",
3-
"version": "0.2.1",
3+
"version": "0.2.2",
44
"type": "module",
55
"types": "index.d.ts",
66
"description": "Dynamics processing — umbrella for @audio/dynamics-* atoms (compressor, limiter, gate, expander, de-esser, ducker, softclip, compand, transient shaper)",

packages/dynamics-deesser/audio.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// atom manifest — wraps the de-esser kernel per @audio/compile CONTRACT.
2-
// Bandpass sidechain drives broadband gain reduction; all params seed the
3-
// stream at construction (flags: restart).
2+
// `mode` picks the architecture: broadband (bandpass sidechain → broadband
3+
// gain reduction) or band (HP sidechain → dynamic peaking-EQ cut at freq).
4+
// All params seed the stream at construction (flags: restart).
45

56
import { deesserStream } from './deesser.js'
67

78
export const deesser = (ctx) => {
89
const streams = []
910
const opts = {
1011
sampleRate: ctx.sampleRate,
12+
mode: ctx.params.mode,
1113
freq: ctx.params.freq[0],
1214
q: ctx.params.q[0],
1315
threshold: ctx.params.threshold[0],
@@ -25,6 +27,7 @@ export const deesser = (ctx) => {
2527
}
2628
deesser.channels = 'any'
2729
deesser.params = {
30+
mode: { type: 'enum', values: ['broadband', 'band'], default: 'broadband', flags: ['restart'] },
2831
freq: { type: 'number', min: 2000, max: 16000, default: 6500, unit: 'Hz', flags: ['restart'] },
2932
q: { type: 'number', min: 0.3, max: 10, default: 2, flags: ['restart'] },
3033
threshold: { type: 'number', min: -60, max: 0, default: -20, unit: 'dB', flags: ['restart'] },

packages/dynamics-deesser/deesser.js

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
11
import { envelope } from '@audio/dynamics-envelope'
2-
import { bandpass, step, state } from '@audio/biquad'
2+
import { bandpass, highpass, peaking, step, state, process as biquad } from '@audio/biquad'
33
import { compressorGain } from '@audio/dynamics-compressor'
44
import { writer, concat, db2lin, lin2db } from './util.js'
55

6-
// De-esser: bandpass sidechain (sibilance band) drives a compressor whose gain
7-
// reduction is applied broadband — simple and transparent.
6+
// De-esser — two canonical architectures behind one entry, picked by `mode`:
7+
// - broadband (default): bandpass sidechain (sibilance band) drives a
8+
// compressor whose gain reduction is applied broadband — simple, transparent.
9+
// - band: HP-filtered sidechain drives a dynamic peaking EQ at `freq` — only
10+
// the sibilance band is cut, so program below the band stays untouched even
11+
// during deep reduction (wideband/split-band precedent).
812
export default function deesser(data, opts) {
913
if (!(data instanceof Float32Array)) return writer(deesserStream(data))
1014
let s = deesserStream(opts)
1115
return concat(s.write(data), s.flush())
1216
}
1317

1418
export function deesserStream(opts = {}) {
19+
return (opts.mode ?? 'broadband') === 'band' ? bandStream(opts) : broadbandStream(opts)
20+
}
21+
22+
function broadbandStream(opts = {}) {
1523
let sr = opts.sampleRate || 44100
1624
let freq = opts.freq ?? 6500
1725
let q = opts.q ?? 2
@@ -37,3 +45,58 @@ export function deesserStream(opts = {}) {
3745
flush() { return new Float32Array(0) }
3846
}
3947
}
48+
49+
// Dynamic peaking-EQ de-esser: detection runs on an HP copy (above `freq`);
50+
// when the envelope exceeds threshold, a negative peaking gain at `freq`
51+
// engages on the audio path. EQ gain follows the envelope continuously —
52+
// recomputed every `block` samples for smoothness without per-sample coef
53+
// cost. Unlike a static shelf, the cut only engages on loud 's' / 'sh'
54+
// events, so dark consonants aren't thinned.
55+
function bandStream(opts = {}) {
56+
let sr = opts.sampleRate || 44100
57+
let freq = opts.freq ?? 6500
58+
let q = opts.q ?? 1.4
59+
let threshold = opts.threshold ?? -20
60+
let ratio = opts.ratio ?? 4
61+
let attackMs = opts.attack ?? 1
62+
let releaseMs = opts.release ?? 40
63+
let block = opts.block ?? 64
64+
65+
let scC = highpass(freq, 0.707, sr)
66+
let scS = state()
67+
let eqS = state()
68+
let aA = Math.exp(-1 / (attackMs * 0.001 * sr))
69+
let aR = Math.exp(-1 / (releaseMs * 0.001 * sr))
70+
// EQ-gain smoothing runs once per block, so its coefficients are per-block:
71+
// deepening the cut follows `attack`, recovering follows `release`.
72+
let aBlkA = Math.exp(-block / (attackMs * 0.001 * sr))
73+
let aBlkR = Math.exp(-block / (releaseMs * 0.001 * sr))
74+
let thLin = db2lin(threshold)
75+
let env = 0, eqDb = 0
76+
77+
return {
78+
write(chunk) {
79+
let out = new Float32Array(chunk.length)
80+
out.set(chunk)
81+
let sc = new Float32Array(chunk.length)
82+
sc.set(chunk)
83+
biquad(sc, scC, scS)
84+
for (let pos = 0; pos < out.length; pos += block) {
85+
let end = Math.min(out.length, pos + block)
86+
// update envelope across block, take peak
87+
let peakEnv = env
88+
for (let i = pos; i < end; i++) {
89+
let x = Math.abs(sc[i])
90+
env = x > env ? aA * env + (1 - aA) * x : aR * env + (1 - aR) * x
91+
if (env > peakEnv) peakEnv = env
92+
}
93+
let target = 0
94+
if (peakEnv > thLin) target = -lin2db(peakEnv / thLin) * (1 - 1 / ratio) // negative dB cut
95+
eqDb = target < eqDb ? aBlkA * eqDb + (1 - aBlkA) * target : aBlkR * eqDb + (1 - aBlkR) * target
96+
biquad(out.subarray(pos, end), peaking(freq, q, sr, eqDb), eqS)
97+
}
98+
return out
99+
},
100+
flush() { return new Float32Array(0) }
101+
}
102+
}

packages/dynamics-deesser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@audio/dynamics-deesser",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "De-esser: bandpass sidechain (sibilance band) drives a compressor whose gain",
55
"type": "module",
66
"sideEffects": false,

0 commit comments

Comments
 (0)