@denaudio/den 0.0.0-stage → 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 (191) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE-APACHE +202 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +107 -2
  5. package/dist/biquad-eq.d.ts +11 -0
  6. package/dist/biquad-eq.js +35 -0
  7. package/dist/catalog-filter-math.d.ts +4 -0
  8. package/dist/catalog-filter-math.js +30 -0
  9. package/dist/character-filter.d.ts +13 -0
  10. package/dist/character-filter.js +53 -0
  11. package/dist/convolution-spectrum.d.ts +25 -0
  12. package/dist/convolution-spectrum.js +82 -0
  13. package/dist/convolution.d.ts +17 -0
  14. package/dist/convolution.js +87 -0
  15. package/dist/crossover.d.ts +11 -0
  16. package/dist/crossover.js +17 -0
  17. package/dist/curve-shaper.d.ts +26 -0
  18. package/dist/curve-shaper.js +73 -0
  19. package/dist/delay-fx.d.ts +37 -0
  20. package/dist/delay-fx.js +84 -0
  21. package/dist/delay-readhead.d.ts +19 -0
  22. package/dist/delay-readhead.js +54 -0
  23. package/dist/delay-settings.d.ts +55 -0
  24. package/dist/delay-settings.js +20 -0
  25. package/dist/drive.d.ts +24 -0
  26. package/dist/drive.js +116 -0
  27. package/dist/dual-head-delay.d.ts +28 -0
  28. package/dist/dual-head-delay.js +77 -0
  29. package/dist/dynamics.d.ts +61 -0
  30. package/dist/dynamics.js +130 -0
  31. package/dist/envelope.d.ts +34 -0
  32. package/dist/envelope.js +62 -0
  33. package/dist/filter.d.ts +9 -0
  34. package/dist/filter.js +34 -0
  35. package/dist/filterbank-vocoder.d.ts +35 -0
  36. package/dist/filterbank-vocoder.js +52 -0
  37. package/dist/formant-bank.d.ts +15 -0
  38. package/dist/formant-bank.js +28 -0
  39. package/dist/freeze-reverb.d.ts +35 -0
  40. package/dist/freeze-reverb.js +61 -0
  41. package/dist/frequency-shifter.d.ts +24 -0
  42. package/dist/frequency-shifter.js +57 -0
  43. package/dist/gate.d.ts +2 -0
  44. package/dist/gate.js +12 -0
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +12 -0
  47. package/dist/instrument-example.d.ts +12 -0
  48. package/dist/instrument-example.js +29 -0
  49. package/dist/instrument-settings.d.ts +95 -0
  50. package/dist/instrument-settings.js +36 -0
  51. package/dist/instrument.d.ts +42 -0
  52. package/dist/instrument.js +166 -0
  53. package/dist/lfo.d.ts +19 -0
  54. package/dist/lfo.js +55 -0
  55. package/dist/live-buffer.d.ts +39 -0
  56. package/dist/live-buffer.js +52 -0
  57. package/dist/live-granular.d.ts +41 -0
  58. package/dist/live-granular.js +64 -0
  59. package/dist/lookahead-limiter.d.ts +31 -0
  60. package/dist/lookahead-limiter.js +67 -0
  61. package/dist/loop-crossfade.d.ts +28 -0
  62. package/dist/loop-crossfade.js +72 -0
  63. package/dist/modulation-fx.d.ts +47 -0
  64. package/dist/modulation-fx.js +58 -0
  65. package/dist/modulation.d.ts +132 -0
  66. package/dist/modulation.js +228 -0
  67. package/dist/mpe-expression.d.ts +36 -0
  68. package/dist/mpe-expression.js +73 -0
  69. package/dist/multiband-dynamics.d.ts +50 -0
  70. package/dist/multiband-dynamics.js +60 -0
  71. package/dist/musical-controls.d.ts +76 -0
  72. package/dist/musical-controls.js +125 -0
  73. package/dist/musical-examples.d.ts +21 -0
  74. package/dist/musical-examples.js +142 -0
  75. package/dist/musical-materials.d.ts +110 -0
  76. package/dist/musical-materials.js +56 -0
  77. package/dist/oscillator.d.ts +10 -0
  78. package/dist/oscillator.js +35 -0
  79. package/dist/oversampled-drive.d.ts +19 -0
  80. package/dist/oversampled-drive.js +99 -0
  81. package/dist/performance.d.ts +61 -0
  82. package/dist/performance.js +241 -0
  83. package/dist/pitch-quantizer.d.ts +26 -0
  84. package/dist/pitch-quantizer.js +88 -0
  85. package/dist/prepared-convolution.d.ts +26 -0
  86. package/dist/prepared-convolution.js +104 -0
  87. package/dist/resident-recorder.d.ts +28 -0
  88. package/dist/resident-recorder.js +68 -0
  89. package/dist/resident-time-stretch.d.ts +46 -0
  90. package/dist/resident-time-stretch.js +136 -0
  91. package/dist/resonator.d.ts +32 -0
  92. package/dist/resonator.js +63 -0
  93. package/dist/reverb.d.ts +27 -0
  94. package/dist/reverb.js +52 -0
  95. package/dist/sample.d.ts +113 -0
  96. package/dist/sample.js +212 -0
  97. package/dist/source.d.ts +58 -0
  98. package/dist/source.js +113 -0
  99. package/dist/spatial-chains.d.ts +50 -0
  100. package/dist/spatial-chains.js +76 -0
  101. package/dist/spectral-fft.d.ts +27 -0
  102. package/dist/spectral-fft.js +84 -0
  103. package/dist/spectral-freeze.d.ts +24 -0
  104. package/dist/spectral-freeze.js +74 -0
  105. package/dist/spectral-gate.d.ts +23 -0
  106. package/dist/spectral-gate.js +58 -0
  107. package/dist/spectral-stft.d.ts +14 -0
  108. package/dist/spectral-stft.js +70 -0
  109. package/dist/spectral-texture.d.ts +46 -0
  110. package/dist/spectral-texture.js +141 -0
  111. package/dist/spectral.d.ts +18 -0
  112. package/dist/spectral.js +10 -0
  113. package/dist/state-variable-filter.d.ts +17 -0
  114. package/dist/state-variable-filter.js +25 -0
  115. package/dist/stereo-delay.d.ts +45 -0
  116. package/dist/stereo-delay.js +70 -0
  117. package/dist/virtual-analog.d.ts +26 -0
  118. package/dist/virtual-analog.js +66 -0
  119. package/dist/voice-policy.d.ts +37 -0
  120. package/dist/voice-policy.js +245 -0
  121. package/dist/wavetable-bands.d.ts +37 -0
  122. package/dist/wavetable-bands.js +145 -0
  123. package/dist/wavetable-read.d.ts +6 -0
  124. package/dist/wavetable-read.js +11 -0
  125. package/dist/wavetable.d.ts +29 -0
  126. package/dist/wavetable.js +49 -0
  127. package/dist/windowed-pitch-shift.d.ts +34 -0
  128. package/dist/windowed-pitch-shift.js +51 -0
  129. package/docs/catalog-audition.md +76 -0
  130. package/docs/catalog-filters.md +125 -0
  131. package/docs/catalog-modulation-fx.md +220 -0
  132. package/docs/catalog-status.md +433 -0
  133. package/docs/character-filter.md +139 -0
  134. package/docs/contracts.md +141 -0
  135. package/docs/control-modulation.md +194 -0
  136. package/docs/convolution.md +50 -0
  137. package/docs/curve-shaper.md +117 -0
  138. package/docs/delay-fx-capture-findings.json +134 -0
  139. package/docs/delay-fx.md +202 -0
  140. package/docs/delay-readhead.md +121 -0
  141. package/docs/delay-settings-candidate.json +333 -0
  142. package/docs/delay-settings.md +111 -0
  143. package/docs/dependency-findings.md +58 -0
  144. package/docs/deployment-status.md +37 -0
  145. package/docs/drive.md +152 -0
  146. package/docs/dual-head-delay.md +131 -0
  147. package/docs/dynamics.md +180 -0
  148. package/docs/envelope-integer-staging.md +64 -0
  149. package/docs/filter-materialization-results.json +71 -0
  150. package/docs/filter-materialization.md +135 -0
  151. package/docs/filter-oscillator.md +126 -0
  152. package/docs/filterbank-vocoder-entry.md +58 -0
  153. package/docs/freeze-reverb.md +179 -0
  154. package/docs/frequency-shifter.md +133 -0
  155. package/docs/frontend-audition.md +22 -0
  156. package/docs/gain-schedule-verification.md +53 -0
  157. package/docs/initial-acceptance.md +79 -0
  158. package/docs/instrument-performance.md +191 -0
  159. package/docs/instrument-settings.md +130 -0
  160. package/docs/instrument.md +204 -0
  161. package/docs/integration-candidate.md +114 -0
  162. package/docs/live-buffer.md +163 -0
  163. package/docs/live-granular.md +179 -0
  164. package/docs/lookahead-multiband.md +183 -0
  165. package/docs/loop-crossfade.md +99 -0
  166. package/docs/mpe-expression.md +65 -0
  167. package/docs/musical-clock-seek-boundary.md +31 -0
  168. package/docs/musical-controls-contract.md +144 -0
  169. package/docs/musical-examples.md +255 -0
  170. package/docs/oversampled-drive.md +153 -0
  171. package/docs/performance.md +53 -0
  172. package/docs/pitch-quantizer.md +55 -0
  173. package/docs/prepared-convolution.md +197 -0
  174. package/docs/realtime-audition.md +63 -0
  175. package/docs/resident-recorder.md +151 -0
  176. package/docs/resident-time-stretch.md +196 -0
  177. package/docs/sample.md +191 -0
  178. package/docs/sound-audition-headroom.md +103 -0
  179. package/docs/sources.md +81 -0
  180. package/docs/spatial-chains-entry.md +251 -0
  181. package/docs/spectral-freeze-entry.md +132 -0
  182. package/docs/spectral-gate-entry.md +134 -0
  183. package/docs/spectral-large-candidate.md +81 -0
  184. package/docs/spectral-texture-entry.md +178 -0
  185. package/docs/spectral.md +127 -0
  186. package/docs/virtual-analog.md +77 -0
  187. package/docs/voice-policy.md +38 -0
  188. package/docs/wavetable-bands.md +138 -0
  189. package/docs/wavetable.md +101 -0
  190. package/docs/windowed-pitch-shift.md +112 -0
  191. package/package.json +269 -4
@@ -0,0 +1,84 @@
1
+ import { defineSubgraph, f64, forIndex, i32, select, state } from '@unworklet/core';
2
+ /** Internal fixed-capacity buffer iteration, using only public native loops.
3
+ * No audio/parameter indexing or sample-clock advancement belongs in this body.
4
+ */
5
+ export function spectralForEach(size, body) {
6
+ if (size < 4 || size > 1024 || !Number.isInteger(Math.log2(size))) {
7
+ throw new RangeError('spectral buffer iteration requires power-of-two size [4,1024]');
8
+ }
9
+ forIndex(size, body);
10
+ }
11
+ /** Internal sample dispatch period. Native scheduler phase is not snapshotted.
12
+ * A divisor of both the hop and quantum realigns a fresh scheduler at every
13
+ * block-atomic restore; persistent module cursors decide when to commit frames.
14
+ * Keep the historical 128-sample maximum dispatch period at ordinary targets.
15
+ */
16
+ export function spectralDispatchPeriod(hopSize, renderQuantumSize) {
17
+ if (!Number.isInteger(renderQuantumSize) || renderQuantumSize < 1) {
18
+ throw new RangeError('spectral renderQuantumSize must be the positive integer ctx.renderQuantumSize');
19
+ }
20
+ let divisor = Math.min(hopSize, 128), remaining = renderQuantumSize;
21
+ while (remaining !== 0)
22
+ [divisor, remaining] = [remaining, divisor % remaining];
23
+ return divisor;
24
+ }
25
+ /** Internal construction-fixed FFT. Not a separate runtime or public package API. */
26
+ export const spectralFft = defineSubgraph((config) => {
27
+ const { size } = config;
28
+ if (!Number.isInteger(size) || size < 8 || size > 1024 || !Number.isInteger(Math.log2(size))) {
29
+ throw new RangeError('spectral FFT size must be a power of two in [8,1024]');
30
+ }
31
+ const stages = Math.log2(size);
32
+ const banks = [0, 1].map(bank => ({
33
+ real: state.buffer.f64({ size }).expose({ name: `real${bank}`, snapshot: 'transient' }),
34
+ imag: state.buffer.f64({ size }).expose({ name: `imag${bank}`, snapshot: 'transient' }),
35
+ }));
36
+ const twiddleReal = state.f64(1).expose({ name: 'twiddleReal', snapshot: 'transient' });
37
+ const twiddleImag = state.f64(0).expose({ name: 'twiddleImag', snapshot: 'transient' });
38
+ const reverse = (index) => {
39
+ let result = i32(0);
40
+ for (let bit = 0; bit < stages; bit++)
41
+ result = result.add(index.div(2 ** bit).mod(2).mul(2 ** (stages - 1 - bit)));
42
+ return result;
43
+ };
44
+ return {
45
+ /** JS functions/loops run during graph capture. Butterfly iteration uses the
46
+ * public fixed-count native forIndex loop over work-buffer indices, nested
47
+ * inside the caller's hop body. It performs no audio I/O or audio-time update.
48
+ * Inputs must not alias these work buffers.
49
+ * Result aliases scratch and must be consumed before the next transform.
50
+ * Forward: sum x[n] exp(-i*2*pi*k*n/N). Inverse: positive sign and 1/N.
51
+ */
52
+ transform(real, imag, inverse) {
53
+ spectralForEach(size, index => {
54
+ banks[0].real.write(reverse(index), real(index));
55
+ banks[0].imag.write(reverse(index), imag(index));
56
+ });
57
+ for (let stage = 0; stage < stages; stage++) {
58
+ const width = 2 ** (stage + 1), half = width / 2;
59
+ const source = banks[stage % 2], target = banks[(stage + 1) % 2];
60
+ const angle = (inverse ? 1 : -1) * 2 * Math.PI / width;
61
+ const cosineStep = Math.cos(angle), sineStep = Math.sin(angle);
62
+ spectralForEach(size / 2, index => {
63
+ const j = index.mod(half);
64
+ const a = index.div(half).mul(width).add(j), b = a.add(half);
65
+ // Each butterfly group starts at exactly (1,0); a bounded recurrence
66
+ // advances its twiddle without a table loader or runtime trig calls.
67
+ const cosine = select(j.eq(0), f64(1), twiddleReal.read());
68
+ const sine = select(j.eq(0), f64(0), twiddleImag.read());
69
+ const ar = source.real.read(a), ai = source.imag.read(a);
70
+ const br = source.real.read(b), bi = source.imag.read(b);
71
+ const tr = br.mul(cosine).sub(bi.mul(sine)), ti = br.mul(sine).add(bi.mul(cosine));
72
+ target.real.write(a, ar.add(tr));
73
+ target.imag.write(a, ai.add(ti));
74
+ target.real.write(b, ar.sub(tr));
75
+ target.imag.write(b, ai.sub(ti));
76
+ twiddleReal.write(cosine.mul(cosineStep).sub(sine.mul(sineStep)));
77
+ twiddleImag.write(cosine.mul(sineStep).add(sine.mul(cosineStep)));
78
+ });
79
+ }
80
+ const result = banks[stages % 2], scale = inverse ? 1 / size : 1;
81
+ return { real: (index) => result.real.read(index).mul(scale), imag: (index) => result.imag.read(index).mul(scale) };
82
+ },
83
+ };
84
+ });
@@ -0,0 +1,24 @@
1
+ import { type EveryNSamples, type Node } from '@unworklet/core';
2
+ export interface SpectralFreezeConfig {
3
+ /** Pass ctx.renderQuantumSize; aligns native dispatch with snapshot boundaries. */
4
+ renderQuantumSize: number;
5
+ /** Construction-fixed power of two in [8,1024]. Reference alignment is size samples. */
6
+ size: number;
7
+ /** Exactly size/2 or size/4. Rebuild to change. */
8
+ hopSize: number;
9
+ }
10
+ /** Bin-centered spectral freeze, periodic sqrt-Hann WOLA. Offline CANDIDATE.
11
+ * Call once per sample inside stride-1 forSample with its everyNSamples.
12
+ * Finite normalized f32 input [-1,1]. Output is not limited/normalized.
13
+ * At committed t=mH, a high freeze captures [t-N,t) if not already held.
14
+ * Held bins advance by +2*pi*k*H/N per frame (no frequency estimation).
15
+ * A low freeze releases at that frame; existing overlap settles within N.
16
+ * Input/history continue while frozen. Controls between frames are ignored.
17
+ * Reset immediately silences/discards input and invalidates the hold, without
18
+ * rephasing. High freeze recaptures at the next non-reset committed frame.
19
+ * No finite drain while held. With freeze low, drain 2N zero input samples
20
+ * after the later of source end and release request. No realtime guarantee.
21
+ */
22
+ export declare const spectralFreeze: import("@unworklet/core").SubgraphDecl<[config: SpectralFreezeConfig], {
23
+ tick(input: Node<"f32">, freeze: Node<"bool">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
24
+ }>;
@@ -0,0 +1,74 @@
1
+ import { defineSubgraph, f64, i32, select, state } from '@unworklet/core';
2
+ import { spectralForEach } from './spectral-fft.js';
3
+ import { createStftIdentity } from './spectral-stft.js';
4
+ /** Bin-centered spectral freeze, periodic sqrt-Hann WOLA. Offline CANDIDATE.
5
+ * Call once per sample inside stride-1 forSample with its everyNSamples.
6
+ * Finite normalized f32 input [-1,1]. Output is not limited/normalized.
7
+ * At committed t=mH, a high freeze captures [t-N,t) if not already held.
8
+ * Held bins advance by +2*pi*k*H/N per frame (no frequency estimation).
9
+ * A low freeze releases at that frame; existing overlap settles within N.
10
+ * Input/history continue while frozen. Controls between frames are ignored.
11
+ * Reset immediately silences/discards input and invalidates the hold, without
12
+ * rephasing. High freeze recaptures at the next non-reset committed frame.
13
+ * No finite drain while held. With freeze low, drain 2N zero input samples
14
+ * after the later of source end and release request. No realtime guarantee.
15
+ */
16
+ export const spectralFreeze = defineSubgraph((config) => {
17
+ // Keep the reviewed framing and FFT unchanged. This persistent modulo-H clock
18
+ // mirrors its cursor%H, including snapshots and reset (which never rephases).
19
+ const framing = createStftIdentity(config, 1024, spectrum => {
20
+ const due = frameClock.read().eq(0);
21
+ const requested = freezeControl.read().and(resetControl.read().not());
22
+ captureNow.write(due.and(requested).and(active.read().not()));
23
+ heldPhase.write(select(due, select(requested, select(active.read(), heldPhase.read().add(1).mod(period), i32(0)), i32(0)), heldPhase.read()));
24
+ active.write(select(due, requested, active.read()));
25
+ const captureBin = (k) => {
26
+ heldReal.write(k, select(captureNow.read(), spectrum.real(k), heldReal.read(k)));
27
+ heldImag.write(k, select(captureNow.read(), select(k.eq(0).or(k.eq(half)), f64(0), spectrum.imag(k)), heldImag.read(k)));
28
+ };
29
+ spectralForEach(half, captureBin);
30
+ captureBin(i32(half));
31
+ const bin = (k) => {
32
+ const index = typeof k === 'number' ? i32(k) : k;
33
+ const pair = index.min(i32(size).sub(index));
34
+ // H=N/2 or N/4: the exact unit rotations need no trig or recurrence.
35
+ const quadrant = pair.mul(heldPhase.read()).mul(4 / period).mod(4);
36
+ const re = heldReal.read(pair), im = heldImag.read(pair);
37
+ const real = select(quadrant.eq(0), re, select(quadrant.eq(1), im.neg(), select(quadrant.eq(2), re.neg(), im)));
38
+ const imag = select(quadrant.eq(0), im, select(quadrant.eq(1), re, select(quadrant.eq(2), im.neg(), re.neg())));
39
+ return { index, pair, real, imag };
40
+ };
41
+ return {
42
+ real(k) {
43
+ const value = bin(k);
44
+ return select(active.read(), value.real, spectrum.real(value.index));
45
+ },
46
+ imag(k) {
47
+ const value = bin(k);
48
+ const mirrored = select(value.index.gt(half), value.imag.neg(), value.imag);
49
+ const realOnly = select(value.pair.eq(0).or(value.pair.eq(half)), f64(0), mirrored);
50
+ return select(active.read(), realOnly, spectrum.imag(value.index));
51
+ },
52
+ };
53
+ });
54
+ const { size, hopSize } = config, half = size / 2, period = size / hopSize;
55
+ const heldReal = state.buffer.f64({ size: half + 1 }).expose({ name: 'heldRealScaled', snapshot: 'persistent' });
56
+ const heldImag = state.buffer.f64({ size: half + 1 }).expose({ name: 'heldImagScaled', snapshot: 'persistent' });
57
+ const frameClock = state.i32(0).named('frameClock');
58
+ const heldPhase = state.i32(0).named('heldPhase');
59
+ const active = state.bool(false).named('active');
60
+ const freezeControl = state.bool(false).expose({ name: 'freezeControl', snapshot: 'transient' });
61
+ const resetControl = state.bool(false).expose({ name: 'resetControl', snapshot: 'transient' });
62
+ const captureNow = state.bool(false).expose({ name: 'captureNow', snapshot: 'transient' });
63
+ return {
64
+ tick(input, freeze, reset, everyNSamples) {
65
+ freezeControl.write(freeze);
66
+ resetControl.write(reset);
67
+ active.write(active.read().and(reset.not()));
68
+ heldPhase.write(select(reset, i32(0), heldPhase.read()));
69
+ const output = framing.tick(input, reset, everyNSamples);
70
+ frameClock.write(frameClock.read().add(1).mod(hopSize));
71
+ return output;
72
+ },
73
+ };
74
+ });
@@ -0,0 +1,23 @@
1
+ import { type EveryNSamples, type Node } from '@unworklet/core';
2
+ export interface SpectralGateConfig {
3
+ /** Pass ctx.renderQuantumSize; aligns native dispatch with snapshot boundaries. */
4
+ renderQuantumSize: number;
5
+ /** Construction-fixed power of two in [8,1024]. Reference alignment is size samples. */
6
+ size: number;
7
+ /** Exactly size/2 or size/4. Rebuild to change. */
8
+ hopSize: number;
9
+ }
10
+ /** Framewise hard spectral gate; periodic sqrt-Hann WOLA, no ballistics/limiter.
11
+ * Call once per sample inside stride-1 forSample with its everyNSamples.
12
+ * Input must be finite normalized f32 [-1,1]; attenuation can raise output peaks.
13
+ * Threshold: single-sided coherent-window bin amplitude, not RMS or dB.
14
+ * Floor: linear gain. Controls are sampled only on committed frame boundaries.
15
+ * NaN threshold becomes 0, NaN floor becomes 1 (all-pass); infinities clamp.
16
+ * Reference delay is N, but processed transients may spread across each frame.
17
+ * Drain at least 2*size zero input samples after the source ends.
18
+ * Reset immediately silences/discards input, without rephasing frame boundaries.
19
+ * Offline CANDIDATE. No browser/realtime guarantee.
20
+ */
21
+ export declare const spectralGate: import("@unworklet/core").SubgraphDecl<[config: SpectralGateConfig], {
22
+ tick(input: Node<"f32">, threshold: Node<"f32">, floorGain: Node<"f32">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
23
+ }>;
@@ -0,0 +1,58 @@
1
+ import { defineSubgraph, f64, i32, select, state } from '@unworklet/core';
2
+ import { spectralForEach } from './spectral-fft.js';
3
+ import { createStftIdentity } from './spectral-stft.js';
4
+ /** Framewise hard spectral gate; periodic sqrt-Hann WOLA, no ballistics/limiter.
5
+ * Call once per sample inside stride-1 forSample with its everyNSamples.
6
+ * Input must be finite normalized f32 [-1,1]; attenuation can raise output peaks.
7
+ * Threshold: single-sided coherent-window bin amplitude, not RMS or dB.
8
+ * Floor: linear gain. Controls are sampled only on committed frame boundaries.
9
+ * NaN threshold becomes 0, NaN floor becomes 1 (all-pass); infinities clamp.
10
+ * Reference delay is N, but processed transients may spread across each frame.
11
+ * Drain at least 2*size zero input samples after the source ends.
12
+ * Reset immediately silences/discards input, without rephasing frame boundaries.
13
+ * Offline CANDIDATE. No browser/realtime guarantee.
14
+ */
15
+ export const spectralGate = defineSubgraph((config) => {
16
+ const framing = createStftIdentity(config, 1024, spectrum => {
17
+ const writeGain = (k) => {
18
+ const real = spectrum.real(k), imag = spectrum.imag(k);
19
+ const factor = select(k.eq(0).or(k.eq(half)), f64(1), f64(4));
20
+ const power = real.mul(real).add(imag.mul(imag)).mul(factor);
21
+ gains.write(k, select(power.gte(thresholdSquared.read()), f64(scale), floor.read()));
22
+ };
23
+ spectralForEach(half, writeGain);
24
+ writeGain(i32(half));
25
+ const allPass = thresholdSquared.read().eq(0).or(floor.read().eq(scale));
26
+ const canonical = (k) => k.min(i32(size).sub(k));
27
+ return {
28
+ real: (k) => {
29
+ const index = typeof k === 'number' ? i32(k) : k, pair = canonical(index);
30
+ const gated = spectrum.real(pair).mul(gains.read(pair).div(scale));
31
+ return select(allPass, spectrum.real(index), gated);
32
+ },
33
+ imag: (k) => {
34
+ const index = typeof k === 'number' ? i32(k) : k, pair = canonical(index);
35
+ const sign = select(index.gt(half), f64(-1), f64(1));
36
+ const gated = select(pair.eq(0).or(pair.eq(half)), f64(0), spectrum.imag(pair).mul(sign).mul(gains.read(pair).div(scale)));
37
+ return select(allPass, spectrum.imag(index), gated);
38
+ },
39
+ };
40
+ });
41
+ const { size } = config, half = size / 2, scale = 2 ** 128;
42
+ const maxF32 = 3.4028234663852886e38;
43
+ // Sum of periodic sqrt-Hann: sum_{n=0}^{N-1} sin(pi*n/N).
44
+ const windowSum = 1 / Math.tan(Math.PI / (2 * size));
45
+ const thresholdSquared = state.f64(0).expose({ name: 'thresholdSquaredScaled', snapshot: 'transient' });
46
+ const floor = state.f64(scale).expose({ name: 'floorScaled', snapshot: 'transient' });
47
+ const gains = state.buffer.f64({ size: half + 1 }).expose({ name: 'gainsScaled', snapshot: 'transient' });
48
+ return {
49
+ tick(input, threshold, floorGain, reset, everyNSamples) {
50
+ // Materialize outer-sample controls before the nested native buffer loops.
51
+ // Scaling preserves f32 subnormals through 0.4.1's 1e-30 buffer flush.
52
+ const level = select(threshold.eq(threshold), f64(threshold).max(0).min(maxF32), f64(0)).mul(windowSum * scale);
53
+ thresholdSquared.write(level.mul(level));
54
+ floor.write(select(floorGain.eq(floorGain), f64(floorGain).max(0).min(1), f64(1)).mul(scale));
55
+ return framing.tick(input, reset, everyNSamples);
56
+ },
57
+ };
58
+ });
@@ -0,0 +1,14 @@
1
+ import { type EveryNSamples, type Node } from '@unworklet/core';
2
+ /** Internal graph-construction-only bin view. Never a runtime JS callback. */
3
+ export interface StftSpectrum {
4
+ real(index: number | Node<'i32'>): Node<'f64'>;
5
+ imag(index: number | Node<'i32'>): Node<'f64'>;
6
+ }
7
+ /** Internal construction only. Public wrappers choose their reviewed capacity. */
8
+ export declare function createStftIdentity(config: {
9
+ size: number;
10
+ hopSize: number;
11
+ renderQuantumSize: number;
12
+ }, maximumSize: number, processSpectrum?: (spectrum: StftSpectrum) => StftSpectrum): {
13
+ tick(input: Node<"f32">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
14
+ };
@@ -0,0 +1,70 @@
1
+ import { f32, f64, i32, instantiate, select, state } from '@unworklet/core';
2
+ import { spectralDispatchPeriod, spectralFft, spectralForEach } from './spectral-fft.js';
3
+ /** Internal construction only. Public wrappers choose their reviewed capacity. */
4
+ export function createStftIdentity(config, maximumSize, processSpectrum) {
5
+ const { size, hopSize } = config;
6
+ if (!Number.isInteger(size) || size < 8 || size > maximumSize || !Number.isInteger(Math.log2(size)) ||
7
+ (hopSize !== size / 2 && hopSize !== size / 4)) {
8
+ throw new RangeError(`stftIdentity requires power-of-two size [8,${maximumSize}] and hopSize size/2 or size/4`);
9
+ }
10
+ const dispatchPeriod = spectralDispatchPeriod(hopSize, config.renderQuantumSize);
11
+ const fft = instantiate(spectralFft, { size }, { name: 'fft' });
12
+ // Public 0.4.1 flushes buffer writes below 1e-30. Exact power-of-two
13
+ // scaling retains finite f32 subnormals without adding an audio threshold.
14
+ const scale = 2 ** 128;
15
+ const history = state.buffer.f64({ size }).expose({ name: 'historyScaled', snapshot: 'persistent' });
16
+ const overlap = state.buffer.f64({ size }).expose({ name: 'overlap', snapshot: 'persistent' });
17
+ // Separate spectrum avoids aliasing FFT input with its bit-reversal workspace.
18
+ const real = state.buffer.f64({ size }).expose({ name: 'spectrumReal', snapshot: 'transient' });
19
+ const imag = state.buffer.f64({ size }).expose({ name: 'spectrumImag', snapshot: 'transient' });
20
+ const cursor = state.i32(0).named('cursor');
21
+ const valid = state.i32(0).named('valid');
22
+ const pendingReset = state.bool(true).named('pendingReset');
23
+ const window = state.buffer.f64({ size }).expose({ name: 'window', snapshot: 'transient' });
24
+ const windowReal = state.f64(1).expose({ name: 'windowReal', snapshot: 'transient' });
25
+ const windowImag = state.f64(0).expose({ name: 'windowImag', snapshot: 'transient' });
26
+ const cosineStep = Math.cos(Math.PI / size), sineStep = Math.sin(Math.PI / size);
27
+ const normalization = 2 * hopSize / size;
28
+ return {
29
+ tick(input, reset, everyNSamples) {
30
+ valid.write(select(reset, i32(0), valid.read()));
31
+ pendingReset.write(pendingReset.read().or(reset));
32
+ // Native scheduler counters are not snapshot state. The dispatch period
33
+ // divides both the hop and actual quantum, so a fresh scheduler aligns
34
+ // after any block-atomic restore. The persistent ring cursor carries H
35
+ // phase; off-phase FFT results are computed eagerly but never committed.
36
+ // No CPU-saving claim is attached to the select-based commit condition.
37
+ everyNSamples(dispatchPeriod, () => {
38
+ const frameDue = cursor.read().mod(hopSize).eq(0);
39
+ // sin(pi*n/N) equals the nonnegative periodic sqrt-Hann window.
40
+ // Reanchor per frame; one native buffer loop replaces N code copies.
41
+ windowReal.write(1);
42
+ windowImag.write(0);
43
+ spectralForEach(size, n => {
44
+ const cosine = windowReal.read(), sine = windowImag.read();
45
+ window.write(n, sine);
46
+ windowReal.write(cosine.mul(cosineStep).sub(sine.mul(sineStep)));
47
+ windowImag.write(cosine.mul(sineStep).add(sine.mul(cosineStep)));
48
+ });
49
+ const analysis = fft.transform(n => select(valid.read().gte(i32(size).sub(n)), history.read(cursor.read().add(n).mod(size)), f64(0)).mul(window.read(n)), () => f64(0), false);
50
+ // The absent hook captures exactly the pre-hook identity graph and state.
51
+ const spectrum = processSpectrum ? processSpectrum(analysis) : analysis;
52
+ spectralForEach(size, n => { real.write(n, spectrum.real(n)); imag.write(n, spectrum.imag(n)); });
53
+ const frame = fft.transform(n => real.read(n), n => imag.read(n), true);
54
+ spectralForEach(size, n => {
55
+ const at = cursor.read().add(n).mod(size);
56
+ const previous = overlap.read(at);
57
+ overlap.write(at, select(frameDue, select(pendingReset.read(), f64(0), previous).add(frame.real(n).mul(window.read(n)).mul(normalization)), previous));
58
+ });
59
+ pendingReset.write(pendingReset.read().and(frameDue.not()));
60
+ });
61
+ const output = select(reset.or(pendingReset.read()), f32(0), f32(overlap.read(cursor.read()).div(scale)));
62
+ // Reads in this DSL are captured at use, before the corresponding writes.
63
+ overlap.write(cursor.read(), f64(0));
64
+ history.write(cursor.read(), f64(select(reset, f32(0), input)).mul(scale));
65
+ cursor.write(cursor.read().add(1).mod(size));
66
+ valid.write(select(reset, i32(0), valid.read().add(1).min(size)));
67
+ return output;
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,46 @@
1
+ import { type EveryNSamples, type Node } from '@unworklet/core';
2
+ export interface SpectralBlurConfig {
3
+ /** Pass ctx.renderQuantumSize; aligns native dispatch with snapshot boundaries. */
4
+ renderQuantumSize: number;
5
+ /** Construction-fixed power of two in [8,256]; live reference delay is size. */
6
+ size: number;
7
+ /** Exactly size/2 or size/4. */
8
+ hopSize: number;
9
+ /** Circular triangular magnitude kernel radius, integer [1,min(8,size/2-1)]. */
10
+ radius: number;
11
+ }
12
+ export interface SpectralCrossSynthesisConfig {
13
+ /** Pass ctx.renderQuantumSize; aligns native dispatch with snapshot boundaries. */
14
+ renderQuantumSize: number;
15
+ /** Construction-fixed power of two in [8,256]; live reference delay is size. */
16
+ size: number;
17
+ /** Exactly size/2 or size/4. */
18
+ hopSize: number;
19
+ /** Maximum per-bin carrier gain; finite construction constant in [1,16]. */
20
+ maxGain: number;
21
+ }
22
+ /** Frequency-magnitude blur, periodic sqrt-Hann WOLA. Offline CANDIDATE.
23
+ * Call once per sample in stride-1 forSample with its everyNSamples.
24
+ * Finite normalized f32 input [-1,1]. Amount clamps [0,1], NaN=>0.
25
+ * Amount is frame-sampled and interpolates raw two-sided magnitudes linearly.
26
+ * Bins >=2^-20 of the frame peak retain current phase; weaker/zero bins use
27
+ * phase zero. This can jump at the floor and is not a phase-vocoder/smoothing.
28
+ * Amount=0 uses the untouched spectrum; previous processed overlap must settle.
29
+ * Reset immediately silences/discards input, without rephasing. Drain 2N zeros.
30
+ * Peaks can grow; no limiter, sound approval, browser or realtime guarantee.
31
+ */
32
+ export declare const spectralBlur: import("@unworklet/core").SubgraphDecl<[config: SpectralBlurConfig], {
33
+ tick(input: Node<"f32">, blend: Node<"f32">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
34
+ }>;
35
+ /** Gain-bounded carrier-phase magnitude replacement. Offline CANDIDATE.
36
+ * Same call/framing/input/reset/amount/drain contract as spectralBlur.
37
+ * Amount=1 targets modulator magnitudes, capped at maxGain*carrier magnitude.
38
+ * Zero carrier stays silent; near-zero carrier gain is bounded by maxGain.
39
+ * Zero modulator at amount=1 suppresses all newly committed carrier bins.
40
+ * The modulator and carrier are analyzed synchronously; current input excluded.
41
+ * Full same-graph/config/rate native restore only; never restore half the pair.
42
+ * No speech-quality, transparent reconstruction, browser or realtime guarantee.
43
+ */
44
+ export declare const spectralCrossSynthesis: import("@unworklet/core").SubgraphDecl<[config: SpectralCrossSynthesisConfig], {
45
+ tick(carrier: Node<"f32">, inputModulator: Node<"f32">, blend: Node<"f32">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
46
+ }>;
@@ -0,0 +1,141 @@
1
+ import { defineSubgraph, f64, i32, instantiate, select, state } from '@unworklet/core';
2
+ import { spectralForEach } from './spectral-fft.js';
3
+ import { createStftIdentity } from './spectral-stft.js';
4
+ const scale = 2 ** 128;
5
+ const phaseFloor = 2 ** -20;
6
+ const boundedAmount = (amount) => select(amount.eq(amount), f64(amount).max(0).min(1), f64(0)).mul(scale);
7
+ /** Frequency-magnitude blur, periodic sqrt-Hann WOLA. Offline CANDIDATE.
8
+ * Call once per sample in stride-1 forSample with its everyNSamples.
9
+ * Finite normalized f32 input [-1,1]. Amount clamps [0,1], NaN=>0.
10
+ * Amount is frame-sampled and interpolates raw two-sided magnitudes linearly.
11
+ * Bins >=2^-20 of the frame peak retain current phase; weaker/zero bins use
12
+ * phase zero. This can jump at the floor and is not a phase-vocoder/smoothing.
13
+ * Amount=0 uses the untouched spectrum; previous processed overlap must settle.
14
+ * Reset immediately silences/discards input, without rephasing. Drain 2N zeros.
15
+ * Peaks can grow; no limiter, sound approval, browser or realtime guarantee.
16
+ */
17
+ export const spectralBlur = defineSubgraph((config) => {
18
+ const { size, radius } = config, half = size / 2;
19
+ if (!Number.isInteger(radius) || radius < 1 || radius > Math.min(8, half - 1)) {
20
+ throw new RangeError('spectralBlur radius must be an integer in [1,min(8,size/2-1)]');
21
+ }
22
+ const framing = createStftIdentity(config, 256, spectrum => {
23
+ peak.write(0);
24
+ const storeMagnitude = (k) => {
25
+ const re = spectrum.real(k), im = select(k.eq(0).or(k.eq(half)), f64(0), spectrum.imag(k));
26
+ const magnitude = re.mul(re).add(im.mul(im)).sqrt();
27
+ magnitudes.write(k, magnitude);
28
+ peak.write(peak.read().max(magnitude));
29
+ };
30
+ spectralForEach(half, storeMagnitude);
31
+ storeMagnitude(i32(half));
32
+ const canonical = (index) => index.min(i32(size).sub(index));
33
+ const processBin = (k) => {
34
+ let blurred = f64(0);
35
+ for (let j = -radius; j <= radius; j++) {
36
+ const at = canonical(k.add(size + j).mod(size));
37
+ blurred = blurred.add(magnitudes.read(at).mul((radius + 1 - Math.abs(j)) / (radius + 1) ** 2));
38
+ }
39
+ const magnitude = magnitudes.read(k), mix = amount.read().div(scale);
40
+ const target = magnitude.mul(f64(1).sub(mix)).add(blurred.mul(mix));
41
+ const retained = magnitude.gt(0).and(magnitude.gte(peak.read().mul(phaseFloor)));
42
+ // select evaluates both branches: sanitize the divisor even for zero bins.
43
+ const denominator = select(magnitude.gt(0), magnitude, f64(1));
44
+ real.write(k, select(retained, spectrum.real(k).div(denominator), f64(1)).mul(target));
45
+ const imaginary = select(k.eq(0).or(k.eq(half)), f64(0), spectrum.imag(k));
46
+ imag.write(k, select(retained, imaginary.div(denominator), f64(0)).mul(target));
47
+ };
48
+ spectralForEach(half, processBin);
49
+ processBin(i32(half));
50
+ return {
51
+ real(k) {
52
+ const index = typeof k === 'number' ? i32(k) : k;
53
+ return select(amount.read().eq(0), spectrum.real(index), real.read(canonical(index)));
54
+ },
55
+ imag(k) {
56
+ const index = typeof k === 'number' ? i32(k) : k;
57
+ const value = imag.read(canonical(index));
58
+ return select(amount.read().eq(0), spectrum.imag(index), select(index.gt(half), value.neg(), value));
59
+ },
60
+ };
61
+ });
62
+ const amount = state.f64(0).expose({ name: 'amountScaled', snapshot: 'transient' });
63
+ const peak = state.f64(0).expose({ name: 'peakScaled', snapshot: 'transient' });
64
+ const magnitudes = state.buffer.f64({ size: half + 1 }).expose({ name: 'magnitudesScaled', snapshot: 'transient' });
65
+ const real = state.buffer.f64({ size: half + 1 }).expose({ name: 'blurRealScaled', snapshot: 'transient' });
66
+ const imag = state.buffer.f64({ size: half + 1 }).expose({ name: 'blurImagScaled', snapshot: 'transient' });
67
+ return {
68
+ tick(input, blend, reset, everyNSamples) {
69
+ amount.write(boundedAmount(blend));
70
+ return framing.tick(input, reset, everyNSamples);
71
+ },
72
+ };
73
+ });
74
+ // A private, separately named native STFT instance preserves all frame statements
75
+ // and tap writes even though its synthesized audio output is unused by the caller.
76
+ const magnitudeTap = defineSubgraph((config) => {
77
+ const { size } = config, half = size / 2;
78
+ const framing = createStftIdentity(config, 256, spectrum => {
79
+ const capture = (k) => {
80
+ const re = spectrum.real(k), im = select(k.eq(0).or(k.eq(half)), f64(0), spectrum.imag(k));
81
+ magnitudes.write(k, re.mul(re).add(im.mul(im)).sqrt());
82
+ };
83
+ spectralForEach(half, capture);
84
+ capture(i32(half));
85
+ return spectrum;
86
+ });
87
+ const magnitudes = state.buffer.f64({ size: half + 1 }).expose({ name: 'magnitudesScaled', snapshot: 'transient' });
88
+ return {
89
+ tick: framing.tick,
90
+ magnitude(k) { return magnitudes.read(k); },
91
+ };
92
+ });
93
+ /** Gain-bounded carrier-phase magnitude replacement. Offline CANDIDATE.
94
+ * Same call/framing/input/reset/amount/drain contract as spectralBlur.
95
+ * Amount=1 targets modulator magnitudes, capped at maxGain*carrier magnitude.
96
+ * Zero carrier stays silent; near-zero carrier gain is bounded by maxGain.
97
+ * Zero modulator at amount=1 suppresses all newly committed carrier bins.
98
+ * The modulator and carrier are analyzed synchronously; current input excluded.
99
+ * Full same-graph/config/rate native restore only; never restore half the pair.
100
+ * No speech-quality, transparent reconstruction, browser or realtime guarantee.
101
+ */
102
+ export const spectralCrossSynthesis = defineSubgraph((config) => {
103
+ const { size, maxGain } = config, half = size / 2;
104
+ if (!Number.isFinite(maxGain) || maxGain < 1 || maxGain > 16) {
105
+ throw new RangeError('spectralCrossSynthesis maxGain must be finite in [1,16]');
106
+ }
107
+ const modulator = instantiate(magnitudeTap, config, { name: 'modulator' });
108
+ const framing = createStftIdentity(config, 256, spectrum => {
109
+ const calculateGain = (k) => {
110
+ const re = spectrum.real(k), im = select(k.eq(0).or(k.eq(half)), f64(0), spectrum.imag(k));
111
+ const magnitude = re.mul(re).add(im.mul(im)).sqrt();
112
+ const denominator = select(magnitude.gt(0), magnitude, f64(1));
113
+ const ratio = select(magnitude.gt(0), modulator.magnitude(k).div(denominator).min(maxGain), f64(0));
114
+ const mix = amount.read().div(scale);
115
+ gains.write(k, f64(1).sub(mix).add(mix.mul(ratio)).mul(scale));
116
+ };
117
+ spectralForEach(half, calculateGain);
118
+ calculateGain(i32(half));
119
+ const canonical = (index) => index.min(i32(size).sub(index));
120
+ return {
121
+ real(k) {
122
+ const index = typeof k === 'number' ? i32(k) : k, pair = canonical(index);
123
+ return select(amount.read().eq(0), spectrum.real(index), spectrum.real(pair).mul(gains.read(pair).div(scale)));
124
+ },
125
+ imag(k) {
126
+ const index = typeof k === 'number' ? i32(k) : k, pair = canonical(index);
127
+ const value = select(pair.eq(0).or(pair.eq(half)), f64(0), spectrum.imag(pair).mul(gains.read(pair).div(scale)));
128
+ return select(amount.read().eq(0), spectrum.imag(index), select(index.gt(half), value.neg(), value));
129
+ },
130
+ };
131
+ });
132
+ const amount = state.f64(0).expose({ name: 'amountScaled', snapshot: 'transient' });
133
+ const gains = state.buffer.f64({ size: half + 1 }).expose({ name: 'gainsScaled', snapshot: 'transient' });
134
+ return {
135
+ tick(carrier, inputModulator, blend, reset, everyNSamples) {
136
+ amount.write(boundedAmount(blend));
137
+ modulator.tick(inputModulator, reset, everyNSamples);
138
+ return framing.tick(carrier, reset, everyNSamples);
139
+ },
140
+ };
141
+ });
@@ -0,0 +1,18 @@
1
+ export interface StftIdentityConfig {
2
+ /** Pass ctx.renderQuantumSize; aligns native dispatch with snapshot boundaries. */
3
+ renderQuantumSize: number;
4
+ /** Construction-fixed power of two in [8,1024]. Latency is size samples. */
5
+ size: number;
6
+ /** Exactly size/2 or size/4. Rebuild to change. */
7
+ hopSize: number;
8
+ }
9
+ /** Graph-native FFT/IFFT identity candidate, periodic sqrt-Hann WOLA.
10
+ * Call exactly once inside stride-1 forSample, supplying its everyNSamples.
11
+ * Finite f32 input required. Reset silences immediately and discards that sample.
12
+ * Reset does not rephase frames. FFT dispatch divides hop, quantum and 128;
13
+ * the persistent cursor controls frame commits and snapshot phase.
14
+ * No realtime guarantee.
15
+ */
16
+ export declare const stftIdentity: import("@unworklet/core").SubgraphDecl<[config: StftIdentityConfig], {
17
+ tick(input: import("@unworklet/core").Node<"f32">, reset: import("@unworklet/core").Node<"bool">, everyNSamples: import("@unworklet/core").EveryNSamples): import("@unworklet/core").Node<"f32">;
18
+ }>;
@@ -0,0 +1,10 @@
1
+ import { defineSubgraph } from '@unworklet/core';
2
+ import { createStftIdentity } from './spectral-stft.js';
3
+ /** Graph-native FFT/IFFT identity candidate, periodic sqrt-Hann WOLA.
4
+ * Call exactly once inside stride-1 forSample, supplying its everyNSamples.
5
+ * Finite f32 input required. Reset silences immediately and discards that sample.
6
+ * Reset does not rephase frames. FFT dispatch divides hop, quantum and 128;
7
+ * the persistent cursor controls frame commits and snapshot phase.
8
+ * No realtime guarantee.
9
+ */
10
+ export const stftIdentity = defineSubgraph((config) => createStftIdentity(config, 1024));
@@ -0,0 +1,17 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface StateVariableFilterConfig {
3
+ sampleRate: number;
4
+ }
5
+ export interface StateVariableFilterOutput {
6
+ lowpass: Node<'f32'>;
7
+ /** Constant-peak bandpass: unity at center, independent of Q. */
8
+ bandpass: Node<'f32'>;
9
+ highpass: Node<'f32'>;
10
+ notch: Node<'f32'>;
11
+ allpass: Node<'f32'>;
12
+ }
13
+ /** Linear TPT SVF. One shared two-integrator history produces five responses. */
14
+ export declare const stateVariableFilter: import("@unworklet/core").SubgraphDecl<[config: StateVariableFilterConfig], {
15
+ /** Exactly one tick/sample. Cutoff Hz and Q are clamped; reset clears history before this input. */
16
+ tick(input: Node<"f32">, cutoffHz: Node<"f32">, q: Node<"f32">, reset: Node<"bool">): StateVariableFilterOutput;
17
+ }>;
@@ -0,0 +1,25 @@
1
+ import { defineSubgraph, f32, f64, select, state } from '@unworklet/core';
2
+ import { checkedSampleRate, tangentHalfAngle } from './catalog-filter-math.js';
3
+ /** Linear TPT SVF. One shared two-integrator history produces five responses. */
4
+ export const stateVariableFilter = defineSubgraph((config) => {
5
+ const maximum = checkedSampleRate(config.sampleRate);
6
+ // Fixed scaling keeps f32 subnormal histories above the upstream <1e-30 state-write flush.
7
+ // Even full-scale f32 inputs remain far below f64 overflow after scaling.
8
+ const historyScale = 2 ** 128;
9
+ const band = state.f64(0).named('band'), low = state.f64(0).named('low');
10
+ return {
11
+ /** Exactly one tick/sample. Cutoff Hz and Q are clamped; reset clears history before this input. */
12
+ tick(input, cutoffHz, q, reset) {
13
+ const s1 = select(reset, f64(0), band.read().div(historyScale)), s2 = select(reset, f64(0), low.read().div(historyScale));
14
+ // This bounded coefficient is scratch storage only, overwritten before use.
15
+ band.write(tangentHalfAngle(cutoffHz, config.sampleRate, maximum));
16
+ const g = band.read(), k = f64(1).div(f64(q).clamp(.5, 10)), x = f64(input);
17
+ const v1 = s1.add(g.mul(x.sub(s2))).div(g.mul(g.add(k)).add(1));
18
+ const v2 = s2.add(g.mul(v1));
19
+ band.write(v1.mul(2).sub(s1).mul(historyScale));
20
+ low.write(v2.mul(2).sub(s2).mul(historyScale));
21
+ const normalizedBand = k.mul(v1);
22
+ return { lowpass: f32(v2), bandpass: f32(normalizedBand), highpass: f32(x.sub(normalizedBand).sub(v2)), notch: f32(x.sub(normalizedBand)), allpass: f32(x.sub(normalizedBand.mul(2))) };
23
+ },
24
+ };
25
+ });