@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
package/dist/drive.js ADDED
@@ -0,0 +1,116 @@
1
+ import { defineSubgraph, f32, f64, floor, i32, or, pow, select, state } from '@unworklet/core';
2
+ // Exact binary scaling preserves recoverable f32 subnormals through the upstream
3
+ // state-write |x| < 1e-30 flush. Values and arithmetic remain bounded f64.
4
+ const HISTORY_SCALE = 2 ** 100;
5
+ function bounded(value, min, max, fallback) {
6
+ const x = f64(value);
7
+ return select(x.eq(x), x, f64(fallback)).clamp(min, max);
8
+ }
9
+ function checkRate(rate) {
10
+ if (!Number.isFinite(rate) || rate < 8000 || rate > 192000) {
11
+ throw new RangeError('drive/reduction sampleRate must be finite and in [8000, 192000] Hz');
12
+ }
13
+ }
14
+ function soft(x) {
15
+ const z = x.clamp(-1, 1);
16
+ return z.mul(f64(1.5).sub(z.mul(z).mul(0.5)));
17
+ }
18
+ function softIntegral(x) {
19
+ const a = x.abs(), z = a.min(1), z2 = z.mul(z);
20
+ return z2.mul(f64(0.75).sub(z2.mul(0.125))).add(a.sub(1).max(0));
21
+ }
22
+ function shape(x, curve) {
23
+ if (curve === 'hard')
24
+ return x.clamp(-1, 1);
25
+ if (curve === 'soft')
26
+ return soft(x);
27
+ if (curve === 'asymmetric')
28
+ return select(x.lt(0), soft(x.mul(2)).mul(0.5), soft(x));
29
+ const phase = x.add(1).sub(floor(x.add(1).div(4)).mul(4));
30
+ // Avoid subtracting nearly equal O(1) values in the linear central segment.
31
+ return select(x.abs().lte(1), x, f64(1).sub(phase.sub(2).abs()));
32
+ }
33
+ function integral(x, curve) {
34
+ if (curve === 'hard') {
35
+ const a = x.abs(), z = a.min(1);
36
+ return z.mul(z).mul(0.5).add(a.sub(1).max(0));
37
+ }
38
+ if (curve === 'soft')
39
+ return softIntegral(x);
40
+ if (curve === 'asymmetric')
41
+ return select(x.lt(0), softIntegral(x.mul(2)).mul(0.25), softIntegral(x));
42
+ const phase = x.add(1).sub(floor(x.add(1).div(4)).mul(4));
43
+ const periodic = select(phase.lte(2), phase.mul(phase).mul(0.5).sub(phase), phase.mul(3).sub(phase.mul(phase).mul(0.5)).sub(4)).add(0.5);
44
+ return select(x.abs().lte(1), x.mul(x).mul(0.5), periodic);
45
+ }
46
+ /** Fixed analytic memoryless waveshaper, optional first-order ADAA and DC block. */
47
+ export const drive = defineSubgraph((config) => {
48
+ checkRate(config.sampleRate);
49
+ const { curve, quality = 'adaa', dcBlockHz = 0 } = config;
50
+ if (!['hard', 'soft', 'asymmetric', 'fold'].includes(curve))
51
+ throw new RangeError('unknown drive curve');
52
+ if (quality !== 'direct' && quality !== 'adaa')
53
+ throw new RangeError('unknown drive quality');
54
+ if (!Number.isFinite(dcBlockHz) || dcBlockHz < 0 || dcBlockHz > 200)
55
+ throw new RangeError('drive dcBlockHz must be in [0, 200] Hz');
56
+ const previous = quality === 'adaa' ? state.f64(0).named('previous-driven-scaled') : undefined;
57
+ const dryPrevious = quality === 'adaa' ? state.f64(0).named('previous-dry-scaled') : undefined;
58
+ const dcInput = dcBlockHz > 0 ? state.f64(0).named('dc-input-scaled') : undefined;
59
+ const dcOutput = dcBlockHz > 0 ? state.f64(0).named('dc-output-scaled') : undefined;
60
+ const pole = Math.exp(-2 * Math.PI * dcBlockHz / config.sampleRate);
61
+ return {
62
+ /** Once per sample. Input [-8,8], linear drive [0,32], wet mix [0,1]. */
63
+ tick(input, gain, mix, reset) {
64
+ const dry = bounded(input, -8, 8, 0), x = dry.mul(bounded(gain, 0, 32, 0));
65
+ const blend = bounded(mix, 0, 1, 0);
66
+ let wet, alignedDry;
67
+ if (previous && dryPrevious) {
68
+ const last = select(reset, f64(0), previous.read().div(HISTORY_SCALE));
69
+ const lastDry = select(reset, f64(0), dryPrevious.read().div(HISTORY_SCALE));
70
+ const difference = x.sub(last);
71
+ const close = difference.abs().lte(1e-5);
72
+ // Both select branches execute. Protect the unused quotient as well.
73
+ // A continuous curve with max slope 1.5 has midpoint error <= 3.75e-6
74
+ // on this interval; smooth cubic interiors have O(difference²) error.
75
+ wet = select(close, shape(x.add(last).mul(0.5), curve), integral(x, curve).sub(integral(last, curve)).div(select(close, f64(1), difference)));
76
+ alignedDry = dry.add(lastDry).mul(0.5);
77
+ previous.write(x.mul(HISTORY_SCALE));
78
+ dryPrevious.write(dry.mul(HISTORY_SCALE));
79
+ }
80
+ else {
81
+ wet = shape(x, curve);
82
+ alignedDry = dry;
83
+ }
84
+ if (dcInput && dcOutput) {
85
+ const lastInput = select(reset, f64(0), dcInput.read().div(HISTORY_SCALE));
86
+ const lastOutput = select(reset, f64(0), dcOutput.read().div(HISTORY_SCALE));
87
+ const blocked = wet.sub(lastInput).add(lastOutput.mul(pole));
88
+ dcInput.write(wet.mul(HISTORY_SCALE));
89
+ dcOutput.write(blocked.mul(HISTORY_SCALE));
90
+ wet = blocked;
91
+ }
92
+ return f32(alignedDry.mul(f64(1).sub(blend)).add(wet.mul(blend)));
93
+ },
94
+ };
95
+ });
96
+ /** Signed-PCM quantizer plus sample hold. Aliasing is intentional; no AA claim. */
97
+ export const reduction = defineSubgraph((config) => {
98
+ checkRate(config.sampleRate);
99
+ const held = state.f64(0).named('held-scaled');
100
+ const remaining = state.i32(0).named('remaining');
101
+ return {
102
+ /** Bits [2,24], hold period [1,4096] samples, both floored at capture. */
103
+ tick(input, bits, holdSamples, mix, reset) {
104
+ const x = bounded(input, -1, 1, 0), blend = bounded(mix, 0, 1, 0);
105
+ const count = select(reset, i32(0), remaining.read());
106
+ const capture = or(reset, count.eq(0));
107
+ const steps = pow(f64(2), floor(bounded(bits, 2, 24, 24)).sub(1));
108
+ // Exactly 2^bits signed levels: [-1, 1 - 1/steps]. Ties round upward.
109
+ const quantized = floor(x.mul(steps).add(0.5)).clamp(steps.neg(), steps.sub(1)).div(steps);
110
+ const wet = select(capture, quantized, held.read().div(HISTORY_SCALE));
111
+ held.write(wet.mul(HISTORY_SCALE));
112
+ remaining.write(select(capture, i32(floor(bounded(holdSamples, 1, 4096, 1))).sub(1), count.sub(1)));
113
+ return f32(x.mul(f64(1).sub(blend)).add(wet.mul(blend)));
114
+ },
115
+ };
116
+ });
@@ -0,0 +1,28 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface DualHeadDelayConfig {
3
+ /** Match the enclosing processor's ctx.sampleRate; integer 8000..192000. */
4
+ sampleRate: number;
5
+ /** Fixed history allocation, one sample..8 seconds; default 2. */
6
+ maxDelaySeconds?: number;
7
+ /** Fixed fade length, integer 2..65536; default 256. Includes both endpoints. */
8
+ transitionSamples?: number;
9
+ }
10
+ /** Mono fixed-two-head delay-time crossfade. Once/sample, use tick or one read
11
+ * followed by that read's write. Finite f32 input required. Latest valid requests
12
+ * coalesce until the current fade completes; active read offsets never move.
13
+ * No implicit feedback, mix, bypass, pitch-shifting or time-stretching policy.
14
+ */
15
+ export declare const dualHeadDelay: import("@unworklet/core").SubgraphDecl<[config: DualHeadDelayConfig], {
16
+ read: (timeSeconds: Node<"f32">, reset: Node<"bool">) => {
17
+ output: Node<"f32">;
18
+ timingRejected: Node<"bool">;
19
+ transitioning: Node<"bool">;
20
+ /** Call exactly once, after consuming output for current-sample feedback. */
21
+ write(input: Node<"f32">): void;
22
+ };
23
+ tick(input: Node<"f32">, timeSeconds: Node<"f32">, reset: Node<"bool">): {
24
+ output: Node<"f32">;
25
+ timingRejected: Node<"bool">;
26
+ transitioning: Node<"bool">;
27
+ };
28
+ }>;
@@ -0,0 +1,77 @@
1
+ import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
2
+ const HISTORY_SCALE = 2 ** 128;
3
+ /** Mono fixed-two-head delay-time crossfade. Once/sample, use tick or one read
4
+ * followed by that read's write. Finite f32 input required. Latest valid requests
5
+ * coalesce until the current fade completes; active read offsets never move.
6
+ * No implicit feedback, mix, bypass, pitch-shifting or time-stretching policy.
7
+ */
8
+ export const dualHeadDelay = defineSubgraph((config) => {
9
+ const { sampleRate, maxDelaySeconds = 2, transitionSamples = 256 } = config;
10
+ if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000 ||
11
+ !Number.isFinite(maxDelaySeconds) || maxDelaySeconds < 1 / sampleRate || maxDelaySeconds > 8 ||
12
+ !Number.isInteger(transitionSamples) || transitionSamples < 2 || transitionSamples > 65536) {
13
+ throw new RangeError('dualHeadDelay requires integer sampleRate [8000,192000], capacity [one sample,8 seconds], and integer transitionSamples [2,65536]');
14
+ }
15
+ // The direct reciprocal capacity check above rejects genuinely sub-sample
16
+ // configurations; this floor only repairs multiplication at that exact bound.
17
+ const maximum = Math.max(1, sampleRate * maxDelaySeconds), size = Math.ceil(maximum) + 2;
18
+ const history = state.buffer.f64({ size }).expose({ name: 'historyScaled', snapshot: 'persistent' });
19
+ const cursor = state.i32(0).named('cursor'), valid = state.i32(0).named('valid');
20
+ const initialized = state.i32(0).named('initialized');
21
+ const current = state.f64(1).named('currentDelaySamples');
22
+ const target = state.f64(1).named('targetDelaySamples');
23
+ const queued = state.f64(1).named('queuedDelaySamples');
24
+ const remaining = state.i32(0).named('remaining');
25
+ // Materialize the read result before the write commits a completed fade.
26
+ const wet = state.f64(0).expose({ name: 'wetScaled', snapshot: 'transient' });
27
+ const active = state.i32(0).expose({ name: 'active', snapshot: 'transient' });
28
+ function read(timeSeconds, reset) {
29
+ const fresh = reset.or(initialized.read().eq(0));
30
+ const accepted = timeSeconds.gte(Math.fround(1 / sampleRate)).and(timeSeconds.lte(Math.fround(maxDelaySeconds)));
31
+ // Accept nominal representable endpoints but never read outside physical
32
+ // capacity. Adjacent out-of-range f32 controls are rejected without epsilon.
33
+ const request = f64(timeSeconds).mul(sampleRate).clamp(1, maximum);
34
+ queued.write(select(accepted, request, select(fresh, f64(1), queued.read())));
35
+ current.write(select(fresh, queued.read(), current.read()));
36
+ const start = remaining.read().eq(0).and(queued.read().eq(current.read()).not());
37
+ target.write(select(fresh.or(start), queued.read(), target.read()));
38
+ remaining.write(select(fresh, i32(0), select(start, i32(transitionSamples), remaining.read())));
39
+ active.write(i32(remaining.read().gt(0)));
40
+ const writeIndex = select(reset, i32(0), cursor.read());
41
+ const available = select(reset, i32(0), valid.read());
42
+ const at = (delay) => {
43
+ const whole = i32(delay.floor()), fraction = delay.sub(f64(whole));
44
+ const relative = writeIndex.sub(whole), recentIndex = select(relative.lt(0), relative.add(size), relative);
45
+ const olderIndex = select(recentIndex.eq(0), i32(size - 1), recentIndex.sub(1));
46
+ const recent = select(available.gte(whole), history.read(recentIndex), f64(0));
47
+ const older = select(available.gt(whole), history.read(olderIndex), f64(0));
48
+ return recent.mul(f64(1).sub(fraction)).add(older.mul(fraction));
49
+ };
50
+ const alpha = select(active.read().gt(0), f64(i32(transitionSamples).sub(remaining.read())).div(transitionSamples - 1), f64(0));
51
+ wet.write(at(current.read()).mul(f64(1).sub(alpha)).add(at(target.read()).mul(alpha)));
52
+ return {
53
+ output: f32(wet.read().div(HISTORY_SCALE)),
54
+ timingRejected: accepted.not(),
55
+ transitioning: active.read().gt(0),
56
+ /** Call exactly once, after consuming output for current-sample feedback. */
57
+ write(input) {
58
+ // Exact binary scaling keeps every finite f32 (including subnormals)
59
+ // above native tiny-buffer scrubbing and far below f64 overflow.
60
+ history.write(writeIndex, f64(input).mul(HISTORY_SCALE));
61
+ cursor.write(writeIndex.add(1).mod(size));
62
+ valid.write(available.add(1).min(size));
63
+ current.write(select(remaining.read().eq(1), target.read(), current.read()));
64
+ remaining.write(remaining.read().sub(1).max(0));
65
+ initialized.write(1);
66
+ },
67
+ };
68
+ }
69
+ return {
70
+ read,
71
+ tick(input, timeSeconds, reset) {
72
+ const tap = read(timeSeconds, reset);
73
+ tap.write(input);
74
+ return { output: tap.output, timingRejected: tap.timingRejected, transitioning: tap.transitioning };
75
+ },
76
+ };
77
+ });
@@ -0,0 +1,61 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export type DetectorMode = 'peak' | 'rms';
3
+ export interface EnvelopeFollowerConfig {
4
+ sampleRate: number;
5
+ mode: DetectorMode;
6
+ }
7
+ export interface EnvelopeFollowerControls {
8
+ /** Seconds: zero is immediate; positive values use at least one sample, at most 30 s. */
9
+ attack: Node<'f32'>;
10
+ release: Node<'f32'>;
11
+ reset: Node<'bool'>;
12
+ }
13
+ export interface DynamicsGainControls {
14
+ /** dBFS, clamped to [-120, 24]. */
15
+ thresholdDb: Node<'f32'>;
16
+ /** Input/output slope ratio, clamped to [1, 100]. */
17
+ ratio: Node<'f32'>;
18
+ /** Full knee width in dB, clamped to [0, 48]. Zero is a hard knee. */
19
+ kneeDb: Node<'f32'>;
20
+ /** Maximum attenuation in dB, clamped to [0, 120]. */
21
+ rangeDb: Node<'f32'>;
22
+ }
23
+ export type DynamicsMode = 'compressor' | 'expander' | 'gate' | 'duck';
24
+ export interface DynamicsConfig extends EnvelopeFollowerConfig {
25
+ /** Feedforward, linked stereo, no lookahead. */
26
+ operation: DynamicsMode;
27
+ /** Gate/duck close below threshold minus this amount; fixed at construction. */
28
+ hysteresisDb?: number;
29
+ }
30
+ export interface DynamicsControls extends DynamicsGainControls {
31
+ detectorAttack: Node<'f32'>;
32
+ detectorRelease: Node<'f32'>;
33
+ /** Gain ballistics in seconds, separate from detector ballistics. */
34
+ attack: Node<'f32'>;
35
+ release: Node<'f32'>;
36
+ reset: Node<'bool'>;
37
+ }
38
+ /** Rectified-amplitude peak or exponentially averaged power RMS follower. */
39
+ export declare const envelopeFollower: import("@unworklet/core").SubgraphDecl<[config: EnvelopeFollowerConfig], {
40
+ /** Once per sample. Reset clears history before processing the current input. */
41
+ tick(input: Node<"f32">, controls: EnvelopeFollowerControls): Node<"f32">;
42
+ }>;
43
+ /** Feedforward downward-compression static gain in dB (never positive). */
44
+ export declare function compressorGainDb(levelDb: Node<'f32'>, controls: DynamicsGainControls): Node<"f32">;
45
+ /** Downward-expansion static gain in dB; ratio is the below-threshold slope. */
46
+ export declare function expanderGainDb(levelDb: Node<'f32'>, controls: DynamicsGainControls): Node<"f32">;
47
+ /**
48
+ * Single-band feedforward dynamics with max-absolute stereo sidechain linking.
49
+ * Pass the program L/R as sidechain L/R for internal detection, or supply a key.
50
+ * Gate/duck are hysteretic keyed switches with smoothed attenuation; ratio and
51
+ * knee affect only compressor/expander. There is no makeup gain or limiter.
52
+ */
53
+ export declare const dynamics: import("@unworklet/core").SubgraphDecl<[config: DynamicsConfig], {
54
+ tick(left: Node<"f32">, right: Node<"f32">, sidechainLeft: Node<"f32">, sidechainRight: Node<"f32">, controls: DynamicsControls): {
55
+ left: Node<"f32">;
56
+ right: Node<"f32">;
57
+ envelope: Node<"f32">;
58
+ gain: Node<"f32">;
59
+ gainDb: Node<"f32">;
60
+ };
61
+ }>;
@@ -0,0 +1,130 @@
1
+ import { defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
2
+ function validate(config) {
3
+ if (!Number.isInteger(config.sampleRate) || config.sampleRate < 8000 || config.sampleRate > 192000) {
4
+ throw new RangeError('dynamics sampleRate must be an integer in [8000, 192000] Hz');
5
+ }
6
+ if (config.mode !== 'peak' && config.mode !== 'rms')
7
+ throw new RangeError('dynamics mode must be peak or rms');
8
+ }
9
+ // Reject only nonfinite values. Valid f32 audio, including subnormals and values
10
+ // beyond full scale, is neither clipped nor silently replaced by zero.
11
+ function finite(input, fallback = 0) {
12
+ return select(input.eq(input).and(input.abs().lte(3.4028234663852886e38)), input, f32(fallback));
13
+ }
14
+ function bounded(input, low, high, fallback) {
15
+ return finite(input, fallback).clamp(low, high);
16
+ }
17
+ // 0.4.1 scalar state writes flush magnitudes below 1e-30. Keeping the
18
+ // nonnegative history scaled preserves even squared f32 subnormal amplitudes.
19
+ // The exact power-of-two scale avoids rounding during scale/unscale; the
20
+ // largest scaled f32 power is <2**512, safely inside binary64 range.
21
+ const HISTORY_SCALE = 2 ** 256;
22
+ function smoother(sampleRate, name) {
23
+ const history = state.f64(0).named(`${name}Scaled`);
24
+ const exponent = state.f64(0).named(`${name}Exponent`);
25
+ return (target, rise, fall, reset) => {
26
+ const previous = select(reset, f64(0), history.read().div(HISTORY_SCALE));
27
+ const seconds = select(target.gt(previous), bounded(rise, 0, 30, 0), bounded(fall, 0, 30, 0));
28
+ exponent.write(f64(1).div(f64(seconds).mul(sampleRate).max(1)));
29
+ const x = exponent.read();
30
+ // 1-exp(-x), 0 < x <= 1. Degree 13, absolute remainder <=1/14!.
31
+ // Unlike 1-f32(exp(-x)), it retains accurate long time constants.
32
+ const polynomial = f64(1 / 6227020800).mul(x).sub(1 / 479001600)
33
+ .mul(x).add(1 / 39916800).mul(x).sub(1 / 3628800)
34
+ .mul(x).add(1 / 362880).mul(x).sub(1 / 40320)
35
+ .mul(x).add(1 / 5040).mul(x).sub(1 / 720)
36
+ .mul(x).add(1 / 120).mul(x).sub(1 / 24)
37
+ .mul(x).add(1 / 6).mul(x).sub(1 / 2).mul(x).add(1);
38
+ const coefficient = select(seconds.eq(0), f64(1), x.mul(polynomial));
39
+ const next = previous.mul(f64(1).sub(coefficient)).add(target.mul(coefficient));
40
+ history.write(next.mul(HISTORY_SCALE));
41
+ return history.read().div(HISTORY_SCALE);
42
+ };
43
+ }
44
+ /** Rectified-amplitude peak or exponentially averaged power RMS follower. */
45
+ export const envelopeFollower = defineSubgraph((config) => {
46
+ validate(config);
47
+ const smooth = smoother(config.sampleRate, 'detector');
48
+ return {
49
+ /** Once per sample. Reset clears history before processing the current input. */
50
+ tick(input, controls) {
51
+ const amplitude = f64(finite(input)).abs();
52
+ const target = config.mode === 'rms' ? amplitude.mul(amplitude) : amplitude;
53
+ const level = smooth(target, controls.attack, controls.release, controls.reset);
54
+ return f32(config.mode === 'rms' ? level.max(0).sqrt() : level);
55
+ },
56
+ };
57
+ });
58
+ /** Feedforward downward-compression static gain in dB (never positive). */
59
+ export function compressorGainDb(levelDb, controls) {
60
+ const delta = f64(bounded(levelDb, -900, 900, -900)).sub(f64(bounded(controls.thresholdDb, -120, 24, 0)));
61
+ const width = f64(bounded(controls.kneeDb, 0, 48, 0));
62
+ const slope = f64(1).sub(f64(1).div(f64(bounded(controls.ratio, 1, 100, 1))));
63
+ const curved = delta.add(width.mul(0.5)).max(0);
64
+ const reduction = select(delta.gte(width.mul(0.5)), delta.max(0).mul(slope), curved.mul(curved).mul(slope).div(select(width.gt(0), width, f64(1)).mul(2)));
65
+ return f32(reduction.min(f64(bounded(controls.rangeDb, 0, 120, 120))).neg());
66
+ }
67
+ /** Downward-expansion static gain in dB; ratio is the below-threshold slope. */
68
+ export function expanderGainDb(levelDb, controls) {
69
+ const below = f64(bounded(controls.thresholdDb, -120, 24, 0)).sub(f64(bounded(levelDb, -900, 900, -900)));
70
+ const width = f64(bounded(controls.kneeDb, 0, 48, 0));
71
+ const slope = f64(bounded(controls.ratio, 1, 100, 1)).sub(1);
72
+ const curved = below.add(width.mul(0.5)).max(0);
73
+ const reduction = select(below.gte(width.mul(0.5)), below.max(0).mul(slope), curved.mul(curved).mul(slope).div(select(width.gt(0), width, f64(1)).mul(2)));
74
+ return f32(reduction.min(f64(bounded(controls.rangeDb, 0, 120, 120))).neg());
75
+ }
76
+ /**
77
+ * Single-band feedforward dynamics with max-absolute stereo sidechain linking.
78
+ * Pass the program L/R as sidechain L/R for internal detection, or supply a key.
79
+ * Gate/duck are hysteretic keyed switches with smoothed attenuation; ratio and
80
+ * knee affect only compressor/expander. There is no makeup gain or limiter.
81
+ */
82
+ export const dynamics = defineSubgraph((config) => {
83
+ validate(config);
84
+ if (!['compressor', 'expander', 'gate', 'duck'].includes(config.operation))
85
+ throw new RangeError('invalid dynamics operation');
86
+ const hysteresis = config.hysteresisDb ?? 3;
87
+ if (!Number.isFinite(hysteresis) || hysteresis < 0 || hysteresis > 24)
88
+ throw new RangeError('dynamics hysteresisDb must be in [0, 24]');
89
+ const detector = instantiate(envelopeFollower, { sampleRate: config.sampleRate, mode: config.mode }, { name: 'sidechain' });
90
+ const active = state.i32(0).named('keyActive');
91
+ const levelDb = state.f64(0).named('levelDb');
92
+ const gain = state.f64(1).named('gain');
93
+ const smooth = smoother(config.sampleRate, 'attenuation');
94
+ return {
95
+ tick(left, right, sidechainLeft, sidechainRight, controls) {
96
+ const key = finite(sidechainLeft).abs().max(finite(sidechainRight).abs());
97
+ const envelope = detector.tick(key, { attack: controls.detectorAttack, release: controls.detectorRelease, reset: controls.reset });
98
+ // Floor applies only to the gain-law meter, never the follower or audio.
99
+ // 1e-30 gives -600 dB; all thresholds are >=-120 dB.
100
+ levelDb.write(f64(envelope.max(1e-30).log()).mul(20 / Math.LN10));
101
+ const level = f32(levelDb.read());
102
+ let target;
103
+ if (config.operation === 'compressor')
104
+ target = f64(compressorGainDb(level, controls)).neg();
105
+ else if (config.operation === 'expander')
106
+ target = f64(expanderGainDb(level, controls)).neg();
107
+ else {
108
+ const threshold = bounded(controls.thresholdDb, -120, 24, 0);
109
+ const wasActive = active.read().gt(0).and(controls.reset.not());
110
+ const isActive = level.gte(threshold).or(wasActive.and(level.gt(threshold.sub(hysteresis))));
111
+ active.write(select(isActive, i32(1), i32(0)));
112
+ const attenuate = config.operation === 'duck' ? isActive : isActive.not();
113
+ target = f64(select(attenuate, bounded(controls.rangeDb, 0, 120, 120), f32(0)));
114
+ }
115
+ // Gate/expander attack opens the gain, release closes it. Compressor/duck
116
+ // attack increases attenuation, release returns towards unity.
117
+ const opens = config.operation === 'gate' || config.operation === 'expander';
118
+ const reduction = smooth(target, opens ? controls.release : controls.attack, opens ? controls.attack : controls.release, controls.reset);
119
+ gain.write(f64(f32(reduction.neg().mul(Math.LN10 / 20)).exp()).clamp(0, 1));
120
+ const linear = gain.read();
121
+ return {
122
+ left: f32(f64(finite(left)).mul(linear)),
123
+ right: f32(f64(finite(right)).mul(linear)),
124
+ envelope,
125
+ gain: f32(linear),
126
+ gainDb: f32(reduction.neg()),
127
+ };
128
+ },
129
+ };
130
+ });
@@ -0,0 +1,34 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface EnvelopeConfig {
3
+ sampleRate: number;
4
+ }
5
+ export interface EnvelopeControls {
6
+ gate: Node<'bool'>;
7
+ /** One-sample pulse; ignored when gate is false. */
8
+ retrigger: Node<'bool'>;
9
+ /** Reset wins over all other controls and consumes the current gate level. */
10
+ reset: Node<'bool'>;
11
+ attack: Node<'f32'>;
12
+ decay: Node<'f32'>;
13
+ sustain: Node<'f32'>;
14
+ release: Node<'f32'>;
15
+ }
16
+ /**
17
+ * Linear ADSR, one instance per voice/use. Call tick exactly once per sample.
18
+ * Seconds are clamped to [0,30] and rounded to the nearest sample. Durations and
19
+ * segment targets latch on entry (decay at the preceding attack endpoint);
20
+ * sustain follows its input in the sustain stage.
21
+ * A segment emits its first step on the trigger sample, reaching its endpoint on
22
+ * step N. Zero/sub-half-sample stages collapse immediately, including A+D=0.
23
+ * Retrigger and note-off start from the previously emitted level. Gate-off wins
24
+ * over retrigger. Reset silences immediately; a held gate needs a new edge/pulse.
25
+ * done is true only in idle (initial/reset) or after release reaches zero; sustain
26
+ * at zero does not end a voice. No asymptotic tail or threshold-based termination.
27
+ * Controls must be finite. Parameter smoothing/automation belongs to unworklet.
28
+ */
29
+ export declare const envelope: import("@unworklet/core").SubgraphDecl<[config: EnvelopeConfig], {
30
+ tick(c: EnvelopeControls): {
31
+ level: Node<"f32">;
32
+ done: Node<"bool">;
33
+ };
34
+ }>;
@@ -0,0 +1,62 @@
1
+ import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
2
+ /**
3
+ * Linear ADSR, one instance per voice/use. Call tick exactly once per sample.
4
+ * Seconds are clamped to [0,30] and rounded to the nearest sample. Durations and
5
+ * segment targets latch on entry (decay at the preceding attack endpoint);
6
+ * sustain follows its input in the sustain stage.
7
+ * A segment emits its first step on the trigger sample, reaching its endpoint on
8
+ * step N. Zero/sub-half-sample stages collapse immediately, including A+D=0.
9
+ * Retrigger and note-off start from the previously emitted level. Gate-off wins
10
+ * over retrigger. Reset silences immediately; a held gate needs a new edge/pulse.
11
+ * done is true only in idle (initial/reset) or after release reaches zero; sustain
12
+ * at zero does not end a voice. No asymptotic tail or threshold-based termination.
13
+ * Controls must be finite. Parameter smoothing/automation belongs to unworklet.
14
+ */
15
+ export const envelope = defineSubgraph((config) => {
16
+ if (!Number.isInteger(config.sampleRate) || config.sampleRate < 1 || config.sampleRate * 30 > 2147483646) {
17
+ throw new RangeError('envelope sampleRate must be a positive integer with 30 seconds fitting i32 frames');
18
+ }
19
+ const stage = state.i32(0).named('stage'); // idle, attack, decay, sustain, release
20
+ const level = state.f64(0).named('level');
21
+ const previousGate = state.bool(false).named('previousGate');
22
+ const remaining = state.i32(0).named('remaining');
23
+ const target = state.f64(0).named('target');
24
+ const frames = (seconds) => i32(f64(seconds.clamp(0, 30)).mul(config.sampleRate).add(0.5).floor());
25
+ return {
26
+ tick(c) {
27
+ // Capture old integer history before using its slots as exact temporaries.
28
+ const oldStage = stage.read();
29
+ const oldRemaining = remaining.read();
30
+ const on = c.gate.and(previousGate.read().not().or(c.retrigger));
31
+ const off = c.gate.not().and(previousGate.read()).and(oldStage.eq(0).not());
32
+ const a = frames(c.attack), d = frames(c.decay), r = frames(c.release);
33
+ const s = f64(c.sustain.clamp(0, 1));
34
+ stage.write(select(off, i32(4), select(on, i32(1), oldStage)));
35
+ const active = stage.read();
36
+ const attack = active.eq(1);
37
+ const attackFrames = select(on, a, oldRemaining);
38
+ // Collapse zero attack into decay at this sample; a positive attack ends
39
+ // at 1 and begins decay on the following sample.
40
+ const decay = active.eq(2).or(attack.and(attackFrames.eq(0)));
41
+ const enterDecay = decay.and(oldStage.eq(2).not().or(on));
42
+ const release = active.eq(4);
43
+ remaining.write(select(release, select(off, r, oldRemaining), select(decay, select(enterDecay, d, oldRemaining), attackFrames)));
44
+ const count = remaining.read();
45
+ const destination = select(release, f64(0), select(decay, select(enterDecay, s, target.read()), f64(1)));
46
+ const start = select(attack.and(attackFrames.eq(0)), f64(1), level.read());
47
+ const moving = attack.or(decay).or(release);
48
+ const last = count.lte(1).or(release.and(start.eq(0)));
49
+ const advanced = select(last, destination, start.add(destination.sub(start).div(f64(count.max(1)))));
50
+ const value = select(c.reset, f64(0), select(moving, advanced, select(active.eq(3), s, f64(0)))).clamp(0, 1);
51
+ const nextStage = select(c.reset, i32(0), select(moving.and(last), select(release, i32(0), select(decay, i32(3), i32(2))), select(decay, i32(2), active)));
52
+ // On a positive attack endpoint, prepare the decay without advancing it.
53
+ const prepareDecay = attack.and(decay.not()).and(last);
54
+ remaining.write(select(c.reset, i32(0), select(prepareDecay, d, count.sub(1).max(0))));
55
+ target.write(select(c.reset, f64(0), select(prepareDecay, s, destination)));
56
+ level.write(value);
57
+ stage.write(nextStage);
58
+ previousGate.write(c.gate);
59
+ return { level: f32(value), done: nextStage.eq(0) };
60
+ },
61
+ };
62
+ });
@@ -0,0 +1,9 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface FilterConfig {
3
+ sampleRate: number;
4
+ }
5
+ /** Trapezoidal state-variable low-pass. See docs/filter-oscillator.md. */
6
+ export declare const filter: import("@unworklet/core").SubgraphDecl<[config: FilterConfig], {
7
+ /** Call exactly once per sample. Reset clears history before processing this input. */
8
+ tick(input: Node<"f32">, cutoff: Node<"f32">, resonance: Node<"f32">, reset: Node<"bool">): Node<"f32">;
9
+ }>;
package/dist/filter.js ADDED
@@ -0,0 +1,34 @@
1
+ import { defineSubgraph, f32, f64, select, state } from '@unworklet/core';
2
+ /** Trapezoidal state-variable low-pass. See docs/filter-oscillator.md. */
3
+ export const filter = defineSubgraph((config) => {
4
+ if (!Number.isFinite(config.sampleRate) || config.sampleRate < 8000 || config.sampleRate > 192000) {
5
+ throw new RangeError('filter sampleRate must be finite and in [8000, 192000] Hz');
6
+ }
7
+ const maximum = Math.min(20000, 0.45 * config.sampleRate);
8
+ const band = state.f64(0).named('band');
9
+ const low = state.f64(0).named('low');
10
+ return {
11
+ /** Call exactly once per sample. Reset clears history before processing this input. */
12
+ tick(input, cutoff, resonance, reset) {
13
+ // read() captures a WASM local in 0.4.1: preserve both histories before
14
+ // staging g in band. The final writes below retain the original histories.
15
+ const s1 = select(reset, f64(0), band.read());
16
+ const s2 = select(reset, f64(0), low.read());
17
+ // Bounded-angle Taylor coefficients avoid the f32 transcendental lowering in 0.4.1.
18
+ const angle = f64(cutoff).clamp(20, maximum).mul(Math.PI / config.sampleRate);
19
+ const z = angle.mul(angle);
20
+ const sine = angle.mul(f64(-1 / 6227020800).mul(z).add(1 / 39916800).mul(z).sub(1 / 362880).mul(z).add(1 / 5040).mul(z).sub(1 / 120).mul(z).add(1 / 6).mul(z).neg().add(1));
21
+ const cosine = f64(-1 / 87178291200).mul(z).add(1 / 479001600).mul(z).sub(1 / 3628800).mul(z).add(1 / 40320).mul(z).sub(1 / 720).mul(z).add(1 / 24).mul(z).sub(1 / 2).mul(z).add(1);
22
+ // Materialize only this bounded coefficient (g > 3e-4 over the configured range).
23
+ // No new snapshot slot, and no extra <1e-30 flush of audio intermediates.
24
+ band.write(sine.div(cosine));
25
+ const g = band.read();
26
+ const k = f64(1).div(f64(resonance).clamp(0.5, 10));
27
+ const v1 = s1.add(g.mul(f64(input).sub(s2))).div(g.mul(g.add(k)).add(1));
28
+ const v2 = s2.add(g.mul(v1));
29
+ band.write(v1.mul(2).sub(s1));
30
+ low.write(v2.mul(2).sub(s2));
31
+ return f32(v2);
32
+ },
33
+ };
34
+ });
@@ -0,0 +1,35 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface FilterBankVocoderBand {
3
+ /** Fixed center in [20, min(20000, .45 * sampleRate)] Hz. */
4
+ frequencyHz: number;
5
+ /** Fixed Q in [.5, 10]; each analysis/synthesis bandpass has unity peak. */
6
+ q: number;
7
+ /** Explicit linear synthesis gain in [0, 4], with no band-count normalization. */
8
+ gain: number;
9
+ }
10
+ export interface FilterBankVocoderConfig {
11
+ /** Integer in [8000, 192000] Hz; must match the processing sample rate. */
12
+ sampleRate: number;
13
+ /** 1–8 fixed bands with strictly increasing centers; sparse arrays are invalid. */
14
+ bands: readonly FilterBankVocoderBand[];
15
+ }
16
+ export interface FilterBankVocoderControls {
17
+ /** Envelope time constants in seconds: zero immediate, positive at least one sample, maximum 30 s. */
18
+ attack: Node<'f32'>;
19
+ release: Node<'f32'>;
20
+ /** Clears both banks and every detector before processing this sample. */
21
+ reset: Node<'bool'>;
22
+ }
23
+ export interface FilterBankVocoderOutput {
24
+ output: Node<'f32'>;
25
+ /** Uncalibrated rectified-amplitude envelopes in configuration order. */
26
+ envelopes: readonly Node<'f32'>[];
27
+ }
28
+ /**
29
+ * Mono fixed filter-bank vocoder: sum(gain * carrier bandpass * modulator envelope).
30
+ * No carrier source, dry mix, limiter, automatic normalization, or speech-quality promise.
31
+ */
32
+ export declare const filterBankVocoder: import("@unworklet/core").SubgraphDecl<[config: FilterBankVocoderConfig], {
33
+ /** Exactly once per sample. NaN/±Infinity audio becomes zero; finite audio is not clipped. */
34
+ tick(modulator: Node<"f32">, carrier: Node<"f32">, controls: FilterBankVocoderControls): FilterBankVocoderOutput;
35
+ }>;
@@ -0,0 +1,52 @@
1
+ import { defineSubgraph, f32, f64, instantiate, select } from '@unworklet/core';
2
+ import { stateVariableFilter } from './state-variable-filter.js';
3
+ import { envelopeFollower } from './dynamics.js';
4
+ import { checkedSampleRate } from './catalog-filter-math.js';
5
+ function finiteAudio(input) {
6
+ return select(input.eq(input).and(input.abs().lte(3.4028234663852886e38)), input, f32(0));
7
+ }
8
+ /**
9
+ * Mono fixed filter-bank vocoder: sum(gain * carrier bandpass * modulator envelope).
10
+ * No carrier source, dry mix, limiter, automatic normalization, or speech-quality promise.
11
+ */
12
+ export const filterBankVocoder = defineSubgraph((config) => {
13
+ const maximum = checkedSampleRate(config.sampleRate);
14
+ if (!Number.isInteger(config.sampleRate))
15
+ throw new RangeError('vocoder sampleRate must be an integer');
16
+ if (!Array.isArray(config.bands) || config.bands.length < 1 || config.bands.length > 8) {
17
+ throw new RangeError('vocoder requires 1–8 fixed, densely specified bands');
18
+ }
19
+ let previous = 0;
20
+ // Validate every index before creating any state; Array#map alone skips holes.
21
+ for (const band of config.bands) {
22
+ if (band === null || typeof band !== 'object' ||
23
+ !Number.isFinite(band.frequencyHz) || band.frequencyHz < 20 || band.frequencyHz > maximum || band.frequencyHz <= previous ||
24
+ !Number.isFinite(band.q) || band.q < .5 || band.q > 10 ||
25
+ !Number.isFinite(band.gain) || band.gain < 0 || band.gain > 4) {
26
+ throw new RangeError('vocoder bands require increasing bounded frequencyHz, Q, and gain');
27
+ }
28
+ previous = band.frequencyHz;
29
+ }
30
+ const bands = config.bands.map((band, index) => ({
31
+ frequency: f32(band.frequencyHz), q: f32(band.q), gain: band.gain,
32
+ analysis: instantiate(stateVariableFilter, { sampleRate: config.sampleRate }, { name: `analysis${index}` }),
33
+ synthesis: instantiate(stateVariableFilter, { sampleRate: config.sampleRate }, { name: `synthesis${index}` }),
34
+ detector: instantiate(envelopeFollower, { sampleRate: config.sampleRate, mode: 'peak' }, { name: `envelope${index}` }),
35
+ }));
36
+ return {
37
+ /** Exactly once per sample. NaN/±Infinity audio becomes zero; finite audio is not clipped. */
38
+ tick(modulator, carrier, controls) {
39
+ const analysisInput = finiteAudio(modulator), synthesisInput = finiteAudio(carrier);
40
+ const envelopes = [];
41
+ let sum = f64(0);
42
+ for (const band of bands) {
43
+ const analysis = band.analysis.tick(analysisInput, band.frequency, band.q, controls.reset).bandpass;
44
+ const synthesis = band.synthesis.tick(synthesisInput, band.frequency, band.q, controls.reset).bandpass;
45
+ const envelope = band.detector.tick(analysis, controls);
46
+ envelopes.push(envelope);
47
+ sum = sum.add(f64(synthesis).mul(f64(envelope)).mul(band.gain));
48
+ }
49
+ return { output: f32(sum), envelopes };
50
+ },
51
+ };
52
+ });