beqforge 0.1.0__py3-none-any.whl

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 (72) hide show
  1. beq_common/AGENTS.md +34 -0
  2. beq_common/__init__.py +6 -0
  3. beq_common/biquad.py +427 -0
  4. beq_common/provenance.py +44 -0
  5. beq_common/publication.py +20 -0
  6. beq_common/types.py +30 -0
  7. beqforge/AGENTS.md +530 -0
  8. beqforge/__init__.py +79 -0
  9. beqforge/accept.py +739 -0
  10. beqforge/beqd.py +202 -0
  11. beqforge/biquad.py +27 -0
  12. beqforge/cache.py +616 -0
  13. beqforge/charts.py +375 -0
  14. beqforge/cli.py +61 -0
  15. beqforge/design.py +394 -0
  16. beqforge/designer.py +624 -0
  17. beqforge/diagnose.py +763 -0
  18. beqforge/explain.py +248 -0
  19. beqforge/extraction.py +469 -0
  20. beqforge/filters.py +1161 -0
  21. beqforge/harness.py +457 -0
  22. beqforge/identify.py +295 -0
  23. beqforge/material.py +187 -0
  24. beqforge/pipeline.py +1995 -0
  25. beqforge/record.py +525 -0
  26. beqforge/reference.py +153 -0
  27. beqforge/rolloff.py +138 -0
  28. beqforge/verify.py +486 -0
  29. beqforge-0.1.0.dist-info/METADATA +399 -0
  30. beqforge-0.1.0.dist-info/RECORD +72 -0
  31. beqforge-0.1.0.dist-info/WHEEL +4 -0
  32. beqforge-0.1.0.dist-info/entry_points.txt +4 -0
  33. beqforge-0.1.0.dist-info/licenses/LICENCE.md +21 -0
  34. beqforge_device_check/AGENTS.md +68 -0
  35. beqforge_device_check/__init__.py +3 -0
  36. beqforge_device_check/analyse.py +788 -0
  37. beqforge_device_check/audio.py +205 -0
  38. beqforge_device_check/catalogue.py +359 -0
  39. beqforge_device_check/cli.py +956 -0
  40. beqforge_device_check/coefficients.py +100 -0
  41. beqforge_device_check/engines.py +531 -0
  42. beqforge_device_check/evidence.py +245 -0
  43. beqforge_device_check/manifest.py +337 -0
  44. beqforge_device_check/measurement.py +353 -0
  45. beqforge_device_check/profiles.py +117 -0
  46. beqforge_device_check/qualification.py +548 -0
  47. beqforge_device_check/report.py +727 -0
  48. beqforge_device_check/transactions.py +708 -0
  49. beqforge_device_check/verify.py +250 -0
  50. beqoptimiser/AGENTS.md +74 -0
  51. beqoptimiser/__init__.py +34 -0
  52. beqoptimiser/cache.py +358 -0
  53. beqoptimiser/cli.py +184 -0
  54. beqoptimiser/core.py +356 -0
  55. beqoptimiser/data/seed-manifest.json +40 -0
  56. beqoptimiser/data/seed.json.gz +0 -0
  57. tools/__init__.py +0 -0
  58. tools/design_beq.py +499 -0
  59. tools/designer_server.py +413 -0
  60. tools/extract.py +223 -0
  61. tools/ledger_template.html +416 -0
  62. tools/negative_corpus.py +192 -0
  63. tools/optimiser_catalogue_report.py +550 -0
  64. tools/prepare_device_check_helper.py +140 -0
  65. tools/render_ledger.py +297 -0
  66. tools/replay.py +109 -0
  67. tools/seed_optimiser_cache.py +190 -0
  68. tools/smoke_test_device_check.py +97 -0
  69. tools/smoke_test_exe.py +281 -0
  70. tools/smoke_test_install.py +163 -0
  71. tools/summarise.py +124 -0
  72. tools/validate_evidence.py +40 -0
beq_common/AGENTS.md ADDED
@@ -0,0 +1,34 @@
1
+ # Shared primitives
2
+
3
+ Read the repository [AGENTS.md](../AGENTS.md) first. Commands and paths below are relative to
4
+ the repository root. This package imports no workflow package and contains only neutral
5
+ primitives needed across workflows.
6
+
7
+ | File | Responsibility |
8
+ | --- | --- |
9
+ | `types.py` | Shared filter specifications and design types |
10
+ | `biquad.py` | Canonical RBJ biquad arithmetic |
11
+ | `publication.py` | Canonical publication rounding |
12
+ | `provenance.py` | Shared source/build revision handling |
13
+ | `__init__.py` | The single distribution version and public shared types |
14
+
15
+ Changes can reach multiple workflows. Read the affected packages' instructions before editing;
16
+ designer-reachable changes require the regression procedure in
17
+ [beqforge/AGENTS.md](../beqforge/AGENTS.md). Shared modules reached by the designer must remain
18
+ covered by its record and stage source fingerprints. Optimiser cache implementation identity
19
+ and frozen device-check provenance must continue to reflect their numerical dependencies.
20
+
21
+ RBJ arithmetic also exists in the designer's vectorised `beqforge.filters._sos_from_parameters`.
22
+ When changing the equations, update both implementations together and retain their equivalence
23
+ test. `beqforge/biquad.py` and legacy designer type exports are compatibility paths to shared
24
+ primitives; do not turn them into independent implementations.
25
+
26
+ The version in `beq_common.__version__` is consumed by Hatch, every workflow and provenance.
27
+ A version change can invalidate recorded/cache identities, including the bundled optimiser
28
+ seed; account for that when preparing a release. Frozen builds have no checkout source to
29
+ hash, so preserve their baked revision stamps.
30
+
31
+ Check affected numerical tests, fingerprint/invalidation tests and
32
+ `tests/test_package_boundaries.py`. Shared-code changes must retain isolated dependency
33
+ profiles and avoid importing SciPy, plotting or device libraries merely to expose types or
34
+ RBJ arithmetic.
beq_common/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Shared filter primitives; no workflow package dependencies."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .types import BiquadSpec as BiquadSpec
6
+ from .types import BiquadType as BiquadType
beq_common/biquad.py ADDED
@@ -0,0 +1,427 @@
1
+ import math
2
+ from abc import ABC, abstractmethod
3
+
4
+ import numpy as np
5
+
6
+
7
+ class SOS(ABC):
8
+ def __init__(self, f_id=-1, fs=48000):
9
+ self.__id = f_id
10
+ self.fs = fs
11
+
12
+ @property
13
+ def id(self):
14
+ return self.__id
15
+
16
+ @id.setter
17
+ def id(self, id):
18
+ self.__id = id
19
+
20
+ @abstractmethod
21
+ def get_sos(self) -> list[list[float]] | None:
22
+ pass
23
+
24
+
25
+ class Biquad(SOS):
26
+ def __init__(self, fs, f_id=-1):
27
+ super().__init__(f_id=f_id, fs=fs)
28
+ self.a, self.b = self._compute_coeffs()
29
+ self.__transfer_function = None
30
+
31
+ def __eq__(self, o: object) -> bool:
32
+ equal = self.__class__.__name__ == o.__class__.__name__
33
+ equal &= self.fs == o.fs
34
+ return equal
35
+
36
+ def __repr__(self):
37
+ return self.description
38
+
39
+ @property
40
+ def description(self):
41
+ description = ""
42
+ if hasattr(self, "display_name"):
43
+ description += self.display_name
44
+ return description
45
+
46
+ @property
47
+ def filter_type(self):
48
+ return self.__class__.__name__
49
+
50
+ def __len__(self):
51
+ return 1
52
+
53
+ @abstractmethod
54
+ def _compute_coeffs(self):
55
+ pass
56
+
57
+ @abstractmethod
58
+ def sort_key(self):
59
+ pass
60
+
61
+ @staticmethod
62
+ def __format_index(prefix, idx, show_index):
63
+ if show_index:
64
+ return f"{prefix}{idx}="
65
+ else:
66
+ return ""
67
+
68
+ def get_sos(self) -> list[list[float]] | None:
69
+ return [np.concatenate((self.b, self.a)).tolist()]
70
+
71
+
72
+ class Gain(Biquad):
73
+ def __init__(self, fs, gain, f_id=-1):
74
+ self.gain = gain
75
+ super().__init__(fs, f_id=f_id)
76
+
77
+ @property
78
+ def filter_type(self):
79
+ return "Gain"
80
+
81
+ @property
82
+ def display_name(self):
83
+ return "Gain"
84
+
85
+ def _compute_coeffs(self):
86
+ return np.array([1.0, 0.0, 0.0]), np.array(
87
+ [10.0 ** (self.gain / 20.0), 0.0, 0.0]
88
+ )
89
+
90
+ def resample(self, new_fs):
91
+ """
92
+ Creates a filter at the specified fs.
93
+ :param new_fs: the new fs.
94
+ :return: the new filter.
95
+ """
96
+ return Gain(new_fs, self.gain, f_id=self.id)
97
+
98
+ def sort_key(self):
99
+ return f"00000{self.gain:05}{self.filter_type}"
100
+
101
+ def to_json(self):
102
+ return {"_type": self.__class__.__name__, "fs": self.fs, "gain": self.gain}
103
+
104
+
105
+ class BiquadWithQ(Biquad):
106
+ def __init__(self, fs, freq, q, f_id=-1):
107
+ self.freq = round(float(freq), 2)
108
+ self.q = float(q)
109
+ self.w0 = 2.0 * math.pi * freq / fs
110
+ self.cos_w0 = math.cos(self.w0)
111
+ self.sin_w0 = math.sin(self.w0)
112
+ self.alpha = self.sin_w0 / (2.0 * self.q)
113
+ super().__init__(fs, f_id=f_id)
114
+
115
+ def __eq__(self, o: object) -> bool:
116
+ return super().__eq__(o) and self.freq == o.freq
117
+
118
+ @property
119
+ def description(self):
120
+ return super().description + f" {self.freq}/{self.q:.4g}"
121
+
122
+ def sort_key(self):
123
+ return f"{self.freq:05}00000{self.filter_type}"
124
+
125
+
126
+ class Passthrough(Gain):
127
+ def __init__(self, fs=1000, f_id=-1):
128
+ super().__init__(fs, 0, f_id=f_id)
129
+
130
+ @property
131
+ def display_name(self):
132
+ return "Passthrough"
133
+
134
+ @property
135
+ def description(self):
136
+ return "Passthrough"
137
+
138
+ def sort_key(self):
139
+ return "ZZZZZZZZZZZZZZ"
140
+
141
+ def resample(self, new_fs):
142
+ return Passthrough(fs=new_fs, f_id=self.id)
143
+
144
+ def to_json(self):
145
+ return {"_type": self.__class__.__name__, "fs": self.fs}
146
+
147
+
148
+ class BiquadWithQGain(BiquadWithQ):
149
+ def __init__(self, fs, freq, q, gain, f_id=-1):
150
+ self.gain = round(float(gain), 3)
151
+ super().__init__(fs, freq, q, f_id=f_id)
152
+
153
+ def __eq__(self, o: object) -> bool:
154
+ return super().__eq__(o) and self.gain == o.gain
155
+
156
+ @property
157
+ def description(self):
158
+ return super().description + f"/{self.gain}dB"
159
+
160
+ def sort_key(self):
161
+ return f"{self.freq:05}{self.gain:05}{self.filter_type}"
162
+
163
+
164
+ class PeakingEQ(BiquadWithQGain):
165
+ """
166
+ H(s) = (s^2 + s*(A/Q) + 1) / (s^2 + s/(A*Q) + 1)
167
+
168
+ b0 = 1 + alpha*A
169
+ b1 = -2*cos(w0)
170
+ b2 = 1 - alpha*A
171
+ a0 = 1 + alpha/A
172
+ a1 = -2*cos(w0)
173
+ a2 = 1 - alpha/A
174
+ """
175
+
176
+ def __init__(self, fs, freq, q, gain, f_id=-1):
177
+ super().__init__(fs, freq, q, gain, f_id=f_id)
178
+
179
+ @property
180
+ def filter_type(self):
181
+ return "PEQ"
182
+
183
+ @property
184
+ def display_name(self):
185
+ return "PEQ"
186
+
187
+ def _compute_coeffs(self):
188
+ A = 10.0 ** (self.gain / 40.0)
189
+ a = np.array(
190
+ [1.0 + self.alpha / A, -2.0 * self.cos_w0, 1.0 - self.alpha / A],
191
+ dtype=np.float64,
192
+ )
193
+ b = np.array(
194
+ [1.0 + self.alpha * A, -2.0 * self.cos_w0, 1.0 - self.alpha * A],
195
+ dtype=np.float64,
196
+ )
197
+ return a / a[0], b / a[0]
198
+
199
+ def resample(self, new_fs):
200
+ """
201
+ Creates a filter at the specified fs.
202
+ :param new_fs: the new fs.
203
+ :return: the new filter.
204
+ """
205
+ return PeakingEQ(new_fs, self.freq, self.q, self.gain, f_id=self.id)
206
+
207
+ def to_json(self):
208
+ return {
209
+ "_type": self.__class__.__name__,
210
+ "fs": self.fs,
211
+ "fc": self.freq,
212
+ "q": self.q,
213
+ "gain": self.gain,
214
+ }
215
+
216
+
217
+ def q_to_s(q, gain):
218
+ """
219
+ translates Q to S for a shelf filter.
220
+ :param q: the Q.
221
+ :param gain: the gain.
222
+ :return: the S.
223
+ """
224
+ return 1.0 / (
225
+ (
226
+ ((1.0 / q) ** 2.0 - 2.0)
227
+ / ((10.0 ** (gain / 40.0)) + 1.0 / (10.0 ** (gain / 40.0)))
228
+ )
229
+ + 1.0
230
+ )
231
+
232
+
233
+ def s_to_q(s, gain):
234
+ """
235
+ translates S to Q for a shelf filter.
236
+ :param s: the S.
237
+ :param gain: the gain.
238
+ :return: the Q.
239
+ """
240
+ A = 10.0 ** (gain / 40.0)
241
+ return 1.0 / math.sqrt(((A + 1.0 / A) * (1.0 / s - 1.0)) + 2.0)
242
+
243
+
244
+ class Shelf(BiquadWithQGain):
245
+ def __init__(self, fs, freq, q, gain, count, f_id=-1):
246
+ self.A = 10.0 ** (gain / 40.0)
247
+ super().__init__(fs, freq, q, gain, f_id=f_id)
248
+ self.count = count
249
+ self.__cached_cascade = None
250
+
251
+ def q_to_s(self):
252
+ """
253
+ :return: the filter Q as S
254
+ """
255
+ return q_to_s(self.q, self.gain)
256
+
257
+ def __len__(self):
258
+ return self.count
259
+
260
+ def flatten(self):
261
+ """
262
+ :return: an iterable of length count of this shelf where each shelf has count=1
263
+ """
264
+ if self.count == 1:
265
+ return [self]
266
+ else:
267
+ return [
268
+ self.__class__(self.fs, self.freq, self.q, self.gain, 1)
269
+ ] * self.count
270
+
271
+ def format_biquads(
272
+ self,
273
+ minidsp_style,
274
+ separator=",\n",
275
+ show_index=True,
276
+ to_hex=False,
277
+ fixed_point=False,
278
+ ):
279
+ single = super().format_biquads(
280
+ minidsp_style,
281
+ separator=separator,
282
+ show_index=show_index,
283
+ to_hex=to_hex,
284
+ fixed_point=fixed_point,
285
+ )
286
+ if self.count == 1:
287
+ return single
288
+ elif self.count > 1:
289
+ return single * self.count
290
+ else:
291
+ raise ValueError("Shelf must have non zero count")
292
+
293
+ def get_sos(self):
294
+ return super().get_sos() * self.count
295
+
296
+ def to_json(self):
297
+ return {
298
+ "_type": self.__class__.__name__,
299
+ "fs": self.fs,
300
+ "fc": self.freq,
301
+ "q": self.q,
302
+ "gain": self.gain,
303
+ "count": self.count,
304
+ }
305
+
306
+ @property
307
+ def description(self):
308
+ if self.count > 1:
309
+ return super().description + f" x{self.count}"
310
+ else:
311
+ return super().description
312
+
313
+
314
+ class LowShelf(Shelf):
315
+ """
316
+ lowShelf: H(s) = A * (s^2 + (sqrt(A)/Q)*s + A)/(A*s^2 + (sqrt(A)/Q)*s + 1)
317
+
318
+ b0 = A*( (A+1) - (A-1)*cos(w0) + 2*sqrt(A)*alpha )
319
+ b1 = 2*A*( (A-1) - (A+1)*cos(w0) )
320
+ b2 = A*( (A+1) - (A-1)*cos(w0) - 2*sqrt(A)*alpha )
321
+ a0 = (A+1) + (A-1)*cos(w0) + 2*sqrt(A)*alpha
322
+ a1 = -2*( (A-1) + (A+1)*cos(w0) )
323
+ a2 = (A+1) + (A-1)*cos(w0) - 2*sqrt(A)*alpha
324
+ """
325
+
326
+ def __init__(self, fs, freq, q, gain, count=1, f_id=-1):
327
+ super().__init__(fs, freq, q, gain, count, f_id=f_id)
328
+
329
+ @property
330
+ def filter_type(self):
331
+ return "LS"
332
+
333
+ @property
334
+ def display_name(self):
335
+ return "Low Shelf"
336
+
337
+ def _compute_coeffs(self):
338
+ A = 10.0 ** (self.gain / 40.0)
339
+ a = np.array(
340
+ [
341
+ (A + 1) + ((A - 1) * self.cos_w0) + (2.0 * math.sqrt(A) * self.alpha),
342
+ -2.0 * ((A - 1) + ((A + 1) * self.cos_w0)),
343
+ (A + 1) + ((A - 1) * self.cos_w0) - (2.0 * math.sqrt(A) * self.alpha),
344
+ ],
345
+ dtype=np.float64,
346
+ )
347
+ b = np.array(
348
+ [
349
+ A
350
+ * (
351
+ (A + 1)
352
+ - ((A - 1) * self.cos_w0)
353
+ + (2.0 * math.sqrt(A) * self.alpha)
354
+ ),
355
+ 2.0 * A * ((A - 1) - ((A + 1) * self.cos_w0)),
356
+ A
357
+ * ((A + 1) - ((A - 1) * self.cos_w0) - (2 * math.sqrt(A) * self.alpha)),
358
+ ],
359
+ dtype=np.float64,
360
+ )
361
+ return a / a[0], b / a[0]
362
+
363
+ def resample(self, new_fs):
364
+ """
365
+ Creates a filter at the specified fs.
366
+ :param new_fs: the new fs.
367
+ :return: the new filter.
368
+ """
369
+ return LowShelf(new_fs, self.freq, self.q, self.gain, self.count, f_id=self.id)
370
+
371
+
372
+ class HighShelf(Shelf):
373
+ """
374
+ highShelf: H(s) = A * (A*s^2 + (sqrt(A)/Q)*s + 1)/(s^2 + (sqrt(A)/Q)*s + A)
375
+
376
+ b0 = A*( (A+1) + (A-1)*cos(w0) + 2*sqrt(A)*alpha )
377
+ b1 = -2*A*( (A-1) + (A+1)*cos(w0) )
378
+ b2 = A*( (A+1) + (A-1)*cos(w0) - 2*sqrt(A)*alpha )
379
+ a0 = (A+1) - (A-1)*cos(w0) + 2*sqrt(A)*alpha
380
+ a1 = 2*( (A-1) - (A+1)*cos(w0) )
381
+ a2 = (A+1) - (A-1)*cos(w0) - 2*sqrt(A)*alpha
382
+
383
+ """
384
+
385
+ def __init__(self, fs, freq, q, gain, count=1, f_id=-1):
386
+ super().__init__(fs, freq, q, gain, count, f_id=f_id)
387
+
388
+ def __eq__(self, o: object) -> bool:
389
+ return super().__eq__(o) and self.count == o.count
390
+
391
+ @property
392
+ def filter_type(self):
393
+ return "HS"
394
+
395
+ @property
396
+ def display_name(self):
397
+ return "High Shelf"
398
+
399
+ def _compute_coeffs(self):
400
+ A = self.A
401
+ cos_w0 = self.cos_w0
402
+ alpha = self.alpha
403
+ a = np.array(
404
+ [
405
+ (A + 1) - ((A - 1) * cos_w0) + (2.0 * math.sqrt(A) * alpha),
406
+ 2.0 * ((A - 1) - ((A + 1) * cos_w0)),
407
+ (A + 1) - ((A - 1) * cos_w0) - (2.0 * math.sqrt(A) * alpha),
408
+ ],
409
+ dtype=np.float64,
410
+ )
411
+ b = np.array(
412
+ [
413
+ A * ((A + 1) + ((A - 1) * cos_w0) + (2.0 * math.sqrt(A) * alpha)),
414
+ -2.0 * A * ((A - 1) + ((A + 1) * cos_w0)),
415
+ A * ((A + 1) + ((A - 1) * cos_w0) - (2.0 * math.sqrt(A) * alpha)),
416
+ ],
417
+ dtype=np.float64,
418
+ )
419
+ return a / a[0], b / a[0]
420
+
421
+ def resample(self, new_fs):
422
+ """
423
+ Creates a filter at the specified fs.
424
+ :param new_fs: the new fs.
425
+ :return: the new filter.
426
+ """
427
+ return HighShelf(new_fs, self.freq, self.q, self.gain, self.count, f_id=self.id)
@@ -0,0 +1,44 @@
1
+ """Distribution build identity, without importing any workflow package."""
2
+
3
+ import hashlib
4
+ import subprocess
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+
10
+ BUILD_REVISION_FILE = "BUILD_REVISION"
11
+ PACKAGES = ("beq_common", "beqforge", "beqforge_device_check", "beqoptimiser")
12
+
13
+
14
+ def revision() -> str:
15
+ root = Path(__file__).resolve().parent.parent
16
+ if getattr(sys, "frozen", False):
17
+ try:
18
+ return (
19
+ Path(__file__).parent / BUILD_REVISION_FILE
20
+ ).read_text().strip() + " (frozen build)"
21
+ except OSError:
22
+ return "unknown (frozen build without BUILD_REVISION)"
23
+ digest = hashlib.sha256()
24
+ try:
25
+ for package in PACKAGES:
26
+ for path in sorted((root / package).rglob("*.py")):
27
+ digest.update(path.relative_to(root).as_posix().encode())
28
+ digest.update(b"\0")
29
+ digest.update(hashlib.sha256(path.read_bytes()).digest())
30
+ except OSError:
31
+ return "unknown (sources unreadable)"
32
+ try:
33
+ result = subprocess.run(
34
+ ["git", "describe", "--always", "--dirty", "--abbrev=12"],
35
+ cwd=root,
36
+ capture_output=True,
37
+ text=True,
38
+ timeout=5,
39
+ check=False,
40
+ )
41
+ label = result.stdout.strip() or __version__
42
+ except (OSError, subprocess.SubprocessError):
43
+ label = __version__
44
+ return f"{label}+src:{digest.hexdigest()[:12]}"
@@ -0,0 +1,20 @@
1
+ """Canonical published parameter precision, shared across workflows."""
2
+
3
+ from .types import BiquadSpec
4
+
5
+
6
+ def publication_filters(filters: list[BiquadSpec]) -> list[BiquadSpec]:
7
+ """Canonical text parameters: Hz to 2 decimals, dB to 3, Q to 4.
8
+
9
+ Independent of the device coefficient format and the hypothetical jitter diagnostic.
10
+ Both judging and external export must use these same parameters.
11
+ """
12
+ return [
13
+ BiquadSpec(
14
+ s.type,
15
+ round(float(s.freq_hz), 2),
16
+ round(float(s.gain_db), 3),
17
+ round(float(s.q), 4),
18
+ )
19
+ for s in filters
20
+ ]
beq_common/types.py ADDED
@@ -0,0 +1,30 @@
1
+ """Rate-independent published filter descriptions."""
2
+
3
+ import math
4
+ from dataclasses import dataclass
5
+ from typing import Literal
6
+
7
+ BiquadType = Literal["peaking_eq", "low_shelf", "high_shelf"]
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class BiquadSpec:
12
+ """One publishable biquad section — designer-interface.md v1.0 §3.
13
+
14
+ Deliberately carries no sample rate: `freq_hz`/`gain_db`/`q` fully determine an RBJ
15
+ section independent of the rate it is eventually realised at. `gain_db` follows the RBJ
16
+ convention `A = 10 ** (gain_db / 40)`, not `/20`.
17
+ """
18
+
19
+ type: BiquadType
20
+ freq_hz: float
21
+ gain_db: float
22
+ q: float
23
+
24
+ def __post_init__(self) -> None:
25
+ if self.freq_hz <= 0:
26
+ raise ValueError(f"freq_hz must be > 0, got {self.freq_hz}")
27
+ if self.q <= 0:
28
+ raise ValueError(f"q must be > 0, got {self.q}")
29
+ if not math.isfinite(self.gain_db):
30
+ raise ValueError(f"gain_db must be finite, got {self.gain_db}")