Repository navigation
Conversation
The processor listened for a port message called "STOP". Nothing has ever
sent an Svf that message — dispose() posts { type: "DISPOSE" }, the
vocabulary the other 22 packages handle — so `this.r` stayed true and the
processor kept being scheduled after its node was thrown away. MonoSynth
builds one of these per voice, so the leak is unbounded.
Adds the regression test digital-delay already has, and regenerates the
bundled processor.ts.
…unction
dsp.test.ts measures every response against the analytic bilinear-warped
transfer function rather than against captured output, at six sample rates.
The port already agrees with the closed form to ~6e-9, so the 1e-6 tolerance
is loose and anything failing it is a defect.
Four cases are it.failing, which is the handover to the next two tickets:
- a cutoff at the declared maxValue breaks at 8000, 22050 and 32000 Hz, in
three distinct ways. 22050 and 32000 give a negative g and diverge to
Infinity; 8000 gives tan(2.5*PI) = 3.3e15 and the lowpass silently
degenerates into a bypass, which is why the group asserts attenuation at
0.45*fs and not only finiteness. Ticket 03.
- one Infinity at the input still yields NaN 100 clean blocks later.
Ticket 05.
Also drops filter.test.ts and its three snapshots at sampleRate 20, whose
coverage is now a strict subset; renames index.test.ts's describe("Noise"),
copy-pasted from the noise package; and has worklet.test.ts import
createWorkletTestContext from test-utils instead of redefining it.
…xValue frequency.maxValue is a compile-time 20000 and Nyquist is not. AudioParam clamps to the descriptor's constants and has never known the sample rate, so below a 40 kHz sample rate the parameter's own declared maximum is past the pole of tan: g goes negative, the poles leave the unit circle and the state reaches +/-1e198 within 512 samples. 22050 and 32000 produced Infinity; 8000 produced tan(2.5*PI) = 3.3e15 and a silent bypass. Zavalishin, The Art of VA Filter Design section 3.8 eq. 3.23: continuous-speed bounded prewarping, the tangent continuation rather than eq. 3.22's hard breakpoint, because he says what the breakpoint costs - "a sudden change of the perceived modulation speed as the cutoff traverses through the prewarping breakpoint" - and an LFO on frequency is what this package is for. The ceiling is 0.72 of Nyquist, 15876 Hz at 44.1 kHz, which is his ~16 kHz to within 0.8% and scales. The fraction is ours; he gives no numeric prescription. Below the ceiling g is bit-identical to the expression it replaces, so no existing patch moves. Above it, the top of the range is now positioned better than it was: bounding the prewarp is his fix for a detuning error that exists at 48 kHz today (Fig. 3.18). Deletes the comment that claimed the range was "[16, sampleRate / 2] (clamped by AudioWorklet)", which is the direct reason this survived a phase-0 correctness sweep, a package audit and an automation-rate audit. 02's three it.failing sample rates are now plain it, and the prewarp has its own group: bit-identity below the ceiling, strict monotonicity across the declared range at every sample rate, and a second difference under 1e-6 through the breakpoint - where eq. 3.22 would give 3.7e-4.
… loop update() recomputed m0, m1 and m2 on every sample of an automated block, and none of them depends on the frequency: the seven-arm switch reads only constants and k = 1/Q, both k-rate. It is now two functions, one for the mix and one for the cutoff. Nothing in the sample loop reads `type`. The one hazard is that a1 reads k, so a change in Q has to invalidate the cutoff coefficients even when the cutoff has not moved. updateMixing sets currFreq = NaN, which compares false against every frequency. Output is bit-identical: 70 rendered cases (7 types x 5 Q values x a-rate and k-rate, two seconds each, driven block by block) hash the same before and after. **The 31% in the audit does not reproduce here: measured 2.3%.** 200k blocks of 128 at 48 kHz, minimum of seven runs across four processes, Node 24 / Apple Silicon: 1121 ms -> 1094 ms against a 437 ms k-rate floor. The per-sample cost on this engine is the tangent and the division in a1, and a switch over three stores is a rounding error beside them. The number is in the source comment, because the folder's rule is that a performance claim carries its measurement - including when the measurement is small. Taken anyway on the ticket's structural argument: threading k per sample for ticket 06 is now a change to one function instead of to the loop. Also deletes a `let freq = frequency[i]` that was assigned and never read.
…reset()
_ic1eq and _ic2eq feed back into themselves every sample, so one Infinity
at the input made both NaN and there was no arithmetic path back: 100
clean blocks later the node still emitted NaN, for the life of the
AudioContext.
The filter does not produce this itself - the prewarping in the previous
commit removed the one internal source. This is about what arrives at the
input, which the package does not control. A BiquadFilterNode absorbs it
because a finite delay line flushes; a recursive filter has nothing to
flush.
Checked once a block on the state rather than 128 times on the input: two
comparisons against a loop that already costs ~21 ns a sample, and it
catches every route in rather than only this one. Recovery is a click,
which is the honest answer to a signal that was already broken.
createFilter now returns { filter, reset } rather than a bare function,
matching digital-delay's { compute, update }: the package had no way to
clear state short of discarding the closure, and the guard is written in
terms of reset() rather than open-coding it. Internal to the package.
Turns on 02's last it.failing case, tightened from "within 100 blocks" to
within one, and adds two more: reset() leaves a rung filter identical to a
fresh one, and a 3 kHz full-range sweep at Q=40 is bit-identical with the
guard present, so the guard is shown never to fire on well-formed audio.
The 70-case render from the previous commit still hashes to 8314c0d0.
Resonance now tracks a modulator instead of being sampled once per block. This changes the sound of any patch that automates Q, which is the fix and not a side effect: a resonance envelope was a 344 Hz staircase, and a modulator faster than that was aliased rather than quantised. Q's comment called itself "a bet, and a weak one" and named the obstacle as shape rather than cost. It was right about the cost. It was also the last disagreement between the library's two filters - virtual-analog-filter moved `resonance` a-rate for the same reasons - and "nobody would modulate it" was never one of the two grounds a k-rate declaration may stand on. `type` stays k-rate on the ground that does apply. The loop reads both parameters with the house `length > 1` idiom, hoisted once per parameter. updateCutoff has to run when only Q moves, because a1 reads k; updateMixing NaNs currFreq for exactly that reason, and the switch is skipped for the three responses whose mix does not contain k. Measured, and the audit's +4.6% does not reproduce: **+30%**, 0.201% -> 0.261% of a core with both parameters automated, because a per-sample Q adds a second division and a division is most of what a sample costs here. An unmodulated filter is unchanged at 0.084% - a browser delivers length 1 for a constant. The mix skip above measures at exactly zero and is kept for its shape; that is the third measurement in this package saying the cost is arithmetic, not control flow. The smoothness test the ticket asked for cannot exist: a Q ramp is not measurably smoother per sample, because Q enters through the damping term and the filter's own settling smooths a step in it (2.98e-7 against 5.96e-8, both zero). filter-parity.test.ts instead asserts what the VA filter's cutoff test asserts - that the modulator arrives: a 200 Hz Q modulator through a bandpass at its centre frequency, where the gain is exactly Q, changes the output by 58% RMS. Output is unchanged for a static Q, verified bit for bit against a float32 Q - which is what params.Q[0] has always been.
Two defaults that were defensible in isolation and surprising to anyone arriving from BiquadFilterNode, which the README positions this package against. They ship together because they are one apology to one user. The bandpass had a peak gain of exactly Q - -6.02 dB at Q=0.5, +32.04 dB at Q=40 - so sweeping resonance swept 38 dB of level with it, and once Q tracks a modulator a resonance envelope was also a volume envelope. It is now normalized (m1 = k): 0.000 dB at the centre frequency at every Q, with the -3 dB bandwidth it always had. Simper never flags this - it is implicit in his algebra, where notch = 1 - k*v1 cancels at resonance only because k*v1 = (1/Q)*Q = 1 there. Lazzarini & Timoney section 3.5 say it outright, and Zavalishin treats the normalized bandpass as primary throughout Chapter 4. Q defaulted to 0.5, which measures as a -0.09 dB peak: over-damped, with no -3 dB point at the cutoff at all. Not Butterworth, not BiquadFilterNode's default of 1, and not a decision recorded anywhere in this repository's history - nobody chose it. 0.7071 measures as exactly 0.000 dB with -3 dB at the cutoff. The trap here is that m1 is per-type and it is tempting to normalize v1 itself: Notch, Peak and AllPass all set m1 against the raw v1, and scaling the shared signal would silently break all three. The change is one arm of one switch, and the three are verified bit-identical against a 30-case render from the previous commit. Also: the demo's Q slider started at 0, below the declared minValue of 0.025, and the docs page stated the old default.
Bell = 7, LowShelf = 8, HighShelf = 9, and the fourth parameter they need. The `// Not implemented yet` block has been in SvfType since the package was written; all three are in the paper it ports, and they were left out because nobody added a gain parameter. Coefficients read from the rendered Solve[] cells of Simper's notebook PDF. The code cells are glyph placeholders in the PDF and in the markdown conversion alike, so the solution cells are the only trustworthy source - and they give the high shelf's m1 as -(-1 + A) k A directly, which is the sign a plausible reconstruction gets backwards, turning a +12 dB shelf into a 14.67 dB resonant bump. The monotonicity assertion would have caught it without knowing the answer; the PDF meant it never had to. A = 10^(dB/40), the half-gain convention. The shelves move g by sqrt(A) so the half-gain point stays at the cutoff, which is Simper's prose rather than his algebra; the bell divides the damping by A, which is the same statement as his denominator being s^2 + (k/A)s + 1. gain is k-rate on ground one, and the ground is specific: A feeds both the mix and the cutoff, so a per-sample gain recomputes the prewarping too. That is a ticket, not an impossibility, and params.ts says so rather than claiming nobody would modulate it. **The ticket's "no overshoot at any Q" criterion is not what these filters are.** Q on a shelf is corner resonance: at or below 0.7071 the response stays inside its gain (12.000 dB measured), above it the corner peaks - 21.8 dB at Q=4 and up to 40.9 dB at Q=40 for a +12 dB shelf. Asserting the ticket's criterion would have meant changing the filter rather than the assertion, so the boundary is asserted instead, in both directions, with the measured table in the test. The seven existing responses are bit-identical, verified against a 70-case render from the previous commit.
Ten responses, four parameters, Q a-rate and defaulting to Butterworth, and the paragraph explaining that k-rate Q was "an acknowledged gap rather than a principle" is gone rather than converted into an apology. Two claims gain their measurements. The stability claim is the one thing in this file a reader has no way to check, and it is now a number: a 3 kHz LFO across the full range at Q=40 gives a peak of 1.046 and zero non-finite samples at every sample rate from 8 to 96 kHz. And "an unmodulated filter costs what it always did" is 0.084% of a core against 0.261% with both parameters automated. Adds the two things a user can now be surprised by: the bandpass is unity-gain and the raw tap is a gain of Q away, and a requested cutoff near 20 kHz is a request rather than a promise, because the prewarping is bounded below Nyquist. The site's demo and docs page were updated with the changes that made them wrong, in the two commits that made them wrong.
`type`'s range comment still said [0, 6] after the three new responses made it [0, 9]. This folder's own argument is that a comment which lies costs more than a missing one - a false "clamped by AudioWorklet" is why the cutoff divergence survived three audits - so a stale range here is not a nit. And the NaN guard's cost claim had no number next to it, against a convention that a performance claim carries its measurement. It is +0.62% over 200k blocks, inside the run-to-run spread of the benchmark itself.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Made with Claude