@denaudio/den 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +17 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +102 -2
- package/dist/biquad-eq.d.ts +11 -0
- package/dist/biquad-eq.js +35 -0
- package/dist/catalog-filter-math.d.ts +4 -0
- package/dist/catalog-filter-math.js +30 -0
- package/dist/character-filter.d.ts +13 -0
- package/dist/character-filter.js +53 -0
- package/dist/convolution-spectrum.d.ts +25 -0
- package/dist/convolution-spectrum.js +82 -0
- package/dist/convolution.d.ts +15 -0
- package/dist/convolution.js +81 -0
- package/dist/crossover.d.ts +11 -0
- package/dist/crossover.js +17 -0
- package/dist/curve-shaper.d.ts +26 -0
- package/dist/curve-shaper.js +73 -0
- package/dist/delay-fx.d.ts +37 -0
- package/dist/delay-fx.js +84 -0
- package/dist/delay-readhead.d.ts +19 -0
- package/dist/delay-readhead.js +54 -0
- package/dist/delay-settings.d.ts +55 -0
- package/dist/delay-settings.js +20 -0
- package/dist/drive.d.ts +24 -0
- package/dist/drive.js +116 -0
- package/dist/dual-head-delay.d.ts +28 -0
- package/dist/dual-head-delay.js +77 -0
- package/dist/dynamics.d.ts +61 -0
- package/dist/dynamics.js +130 -0
- package/dist/envelope.d.ts +34 -0
- package/dist/envelope.js +62 -0
- package/dist/filter.d.ts +9 -0
- package/dist/filter.js +34 -0
- package/dist/filterbank-vocoder.d.ts +35 -0
- package/dist/filterbank-vocoder.js +52 -0
- package/dist/formant-bank.d.ts +15 -0
- package/dist/formant-bank.js +28 -0
- package/dist/freeze-reverb.d.ts +35 -0
- package/dist/freeze-reverb.js +61 -0
- package/dist/frequency-shifter.d.ts +24 -0
- package/dist/frequency-shifter.js +57 -0
- package/dist/gate.d.ts +2 -0
- package/dist/gate.js +12 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +12 -0
- package/dist/instrument-example.d.ts +12 -0
- package/dist/instrument-example.js +29 -0
- package/dist/instrument-settings.d.ts +95 -0
- package/dist/instrument-settings.js +36 -0
- package/dist/instrument.d.ts +42 -0
- package/dist/instrument.js +166 -0
- package/dist/lfo.d.ts +19 -0
- package/dist/lfo.js +55 -0
- package/dist/live-buffer.d.ts +39 -0
- package/dist/live-buffer.js +52 -0
- package/dist/live-granular.d.ts +41 -0
- package/dist/live-granular.js +64 -0
- package/dist/lookahead-limiter.d.ts +31 -0
- package/dist/lookahead-limiter.js +67 -0
- package/dist/loop-crossfade.d.ts +28 -0
- package/dist/loop-crossfade.js +72 -0
- package/dist/modulation-fx.d.ts +47 -0
- package/dist/modulation-fx.js +58 -0
- package/dist/modulation.d.ts +132 -0
- package/dist/modulation.js +228 -0
- package/dist/mpe-expression.d.ts +36 -0
- package/dist/mpe-expression.js +73 -0
- package/dist/multiband-dynamics.d.ts +50 -0
- package/dist/multiband-dynamics.js +60 -0
- package/dist/musical-controls.d.ts +76 -0
- package/dist/musical-controls.js +125 -0
- package/dist/musical-examples.d.ts +21 -0
- package/dist/musical-examples.js +142 -0
- package/dist/musical-materials.d.ts +110 -0
- package/dist/musical-materials.js +56 -0
- package/dist/oscillator.d.ts +10 -0
- package/dist/oscillator.js +35 -0
- package/dist/oversampled-drive.d.ts +19 -0
- package/dist/oversampled-drive.js +99 -0
- package/dist/performance.d.ts +61 -0
- package/dist/performance.js +241 -0
- package/dist/pitch-quantizer.d.ts +26 -0
- package/dist/pitch-quantizer.js +88 -0
- package/dist/prepared-convolution.d.ts +22 -0
- package/dist/prepared-convolution.js +99 -0
- package/dist/resident-recorder.d.ts +28 -0
- package/dist/resident-recorder.js +68 -0
- package/dist/resident-time-stretch.d.ts +44 -0
- package/dist/resident-time-stretch.js +135 -0
- package/dist/resonator.d.ts +32 -0
- package/dist/resonator.js +63 -0
- package/dist/reverb.d.ts +27 -0
- package/dist/reverb.js +52 -0
- package/dist/sample.d.ts +113 -0
- package/dist/sample.js +212 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +113 -0
- package/dist/spatial-chains.d.ts +48 -0
- package/dist/spatial-chains.js +76 -0
- package/dist/spectral-fft.d.ts +21 -0
- package/dist/spectral-fft.js +74 -0
- package/dist/spectral-freeze.d.ts +22 -0
- package/dist/spectral-freeze.js +74 -0
- package/dist/spectral-gate.d.ts +21 -0
- package/dist/spectral-gate.js +58 -0
- package/dist/spectral-stft.d.ts +13 -0
- package/dist/spectral-stft.js +69 -0
- package/dist/spectral-texture.d.ts +42 -0
- package/dist/spectral-texture.js +141 -0
- package/dist/spectral.d.ts +16 -0
- package/dist/spectral.js +10 -0
- package/dist/state-variable-filter.d.ts +17 -0
- package/dist/state-variable-filter.js +25 -0
- package/dist/stereo-delay.d.ts +45 -0
- package/dist/stereo-delay.js +70 -0
- package/dist/virtual-analog.d.ts +26 -0
- package/dist/virtual-analog.js +66 -0
- package/dist/voice-policy.d.ts +37 -0
- package/dist/voice-policy.js +245 -0
- package/dist/wavetable-bands.d.ts +37 -0
- package/dist/wavetable-bands.js +145 -0
- package/dist/wavetable-read.d.ts +6 -0
- package/dist/wavetable-read.js +11 -0
- package/dist/wavetable.d.ts +29 -0
- package/dist/wavetable.js +49 -0
- package/dist/windowed-pitch-shift.d.ts +34 -0
- package/dist/windowed-pitch-shift.js +51 -0
- package/docs/catalog-audition.md +76 -0
- package/docs/catalog-filters.md +125 -0
- package/docs/catalog-modulation-fx.md +220 -0
- package/docs/catalog-status.md +431 -0
- package/docs/character-filter.md +139 -0
- package/docs/contracts.md +131 -0
- package/docs/control-modulation.md +194 -0
- package/docs/convolution.md +45 -0
- package/docs/curve-shaper.md +117 -0
- package/docs/delay-fx-capture-findings.json +134 -0
- package/docs/delay-fx.md +202 -0
- package/docs/delay-readhead.md +121 -0
- package/docs/delay-settings-candidate.json +333 -0
- package/docs/delay-settings.md +111 -0
- package/docs/dependency-findings.md +61 -0
- package/docs/deployment-status.md +37 -0
- package/docs/drive.md +152 -0
- package/docs/dual-head-delay.md +131 -0
- package/docs/dynamics.md +180 -0
- package/docs/envelope-integer-staging.md +64 -0
- package/docs/filter-materialization-results.json +71 -0
- package/docs/filter-materialization.md +135 -0
- package/docs/filter-oscillator.md +126 -0
- package/docs/filterbank-vocoder-entry.md +58 -0
- package/docs/freeze-reverb.md +179 -0
- package/docs/frequency-shifter.md +133 -0
- package/docs/frontend-audition.md +22 -0
- package/docs/gain-schedule-verification.md +53 -0
- package/docs/initial-acceptance.md +79 -0
- package/docs/instrument-performance.md +191 -0
- package/docs/instrument-settings.md +130 -0
- package/docs/instrument.md +204 -0
- package/docs/integration-candidate.md +114 -0
- package/docs/live-buffer.md +163 -0
- package/docs/live-granular.md +179 -0
- package/docs/lookahead-multiband.md +183 -0
- package/docs/loop-crossfade.md +99 -0
- package/docs/mpe-expression.md +65 -0
- package/docs/musical-clock-seek-boundary.md +31 -0
- package/docs/musical-controls-contract.md +144 -0
- package/docs/musical-examples.md +255 -0
- package/docs/oversampled-drive.md +153 -0
- package/docs/performance.md +53 -0
- package/docs/pitch-quantizer.md +55 -0
- package/docs/prepared-convolution.md +188 -0
- package/docs/realtime-audition.md +63 -0
- package/docs/resident-recorder.md +151 -0
- package/docs/resident-time-stretch.md +190 -0
- package/docs/sample.md +191 -0
- package/docs/sound-audition-headroom.md +103 -0
- package/docs/sources.md +81 -0
- package/docs/spatial-chains-entry.md +250 -0
- package/docs/spectral-freeze-entry.md +130 -0
- package/docs/spectral-gate-entry.md +130 -0
- package/docs/spectral-large-candidate.md +74 -0
- package/docs/spectral-texture-entry.md +174 -0
- package/docs/spectral.md +118 -0
- package/docs/virtual-analog.md +77 -0
- package/docs/voice-policy.md +38 -0
- package/docs/wavetable-bands.md +138 -0
- package/docs/wavetable.md +101 -0
- package/docs/windowed-pitch-shift.md +112 -0
- package/package.json +269 -4
|
@@ -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
|
+
}>;
|
package/dist/dynamics.js
ADDED
|
@@ -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
|
+
}>;
|
package/dist/envelope.js
ADDED
|
@@ -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
|
+
});
|
package/dist/filter.d.ts
ADDED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface FormantBand {
|
|
3
|
+
frequencyHz: number;
|
|
4
|
+
q: number;
|
|
5
|
+
gain: number;
|
|
6
|
+
}
|
|
7
|
+
export interface FormantBankConfig {
|
|
8
|
+
sampleRate: number;
|
|
9
|
+
bands: readonly FormantBand[];
|
|
10
|
+
}
|
|
11
|
+
/** Fixed 1–8 parallel, unity-peak bandpasses. A resonator bank, not a speech synthesizer. */
|
|
12
|
+
export declare const formantBank: import("@unworklet/core").SubgraphDecl<[config: FormantBankConfig], {
|
|
13
|
+
/** Linear frequency ratio [.25,4] and Q scale [.25,4]; per-band cutoff/Q limits still apply. */
|
|
14
|
+
tick(input: Node<"f32">, frequencyRatio: Node<"f32">, resonanceScale: Node<"f32">, reset: Node<"bool">): Node<"f32">;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { defineSubgraph, f32, instantiate } from '@unworklet/core';
|
|
2
|
+
import { stateVariableFilter } from './state-variable-filter.js';
|
|
3
|
+
import { checkedSampleRate } from './catalog-filter-math.js';
|
|
4
|
+
/** Fixed 1–8 parallel, unity-peak bandpasses. A resonator bank, not a speech synthesizer. */
|
|
5
|
+
export const formantBank = defineSubgraph((config) => {
|
|
6
|
+
const maximum = checkedSampleRate(config.sampleRate);
|
|
7
|
+
if (!Array.isArray(config.bands) || config.bands.length < 1 || config.bands.length > 8)
|
|
8
|
+
throw new RangeError('formant bank requires 1–8 fixed bands');
|
|
9
|
+
const bands = config.bands.map((band, index) => {
|
|
10
|
+
if (!Number.isFinite(band.frequencyHz) || band.frequencyHz < 20 || band.frequencyHz > maximum ||
|
|
11
|
+
!Number.isFinite(band.q) || band.q < .5 || band.q > 10 || !Number.isFinite(band.gain) || Math.abs(band.gain) > 1) {
|
|
12
|
+
throw new RangeError('formant band frequency/Q/gain is outside its supported bounds');
|
|
13
|
+
}
|
|
14
|
+
return { frequencyHz: band.frequencyHz, q: band.q, gain: band.gain,
|
|
15
|
+
filter: instantiate(stateVariableFilter, { sampleRate: config.sampleRate }, { name: `band${index}` }) };
|
|
16
|
+
});
|
|
17
|
+
return {
|
|
18
|
+
/** Linear frequency ratio [.25,4] and Q scale [.25,4]; per-band cutoff/Q limits still apply. */
|
|
19
|
+
tick(input, frequencyRatio, resonanceScale, reset) {
|
|
20
|
+
let output = f32(0);
|
|
21
|
+
for (const band of bands) {
|
|
22
|
+
const response = band.filter.tick(input, frequencyRatio.clamp(.25, 4).mul(band.frequencyHz), resonanceScale.clamp(.25, 4).mul(band.q), reset);
|
|
23
|
+
output = output.add(response.bandpass.mul(band.gain));
|
|
24
|
+
}
|
|
25
|
+
return output;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface FreezeReverbConfig {
|
|
3
|
+
/** Match ctx.sampleRate; integer 8000..192000. */
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
/** Fixed delay-length scale, [0.5,2], default 1. Rebuild to change. */
|
|
6
|
+
roomScale?: number;
|
|
7
|
+
/** Fixed nominal loop-loss time, [0.1,10] seconds, default 1.5.
|
|
8
|
+
* Describes the unfrozen network, not a frozen-tail duration. */
|
|
9
|
+
decaySeconds?: number;
|
|
10
|
+
/** Samples from fully live to fully frozen, or back; integer 0..192000.
|
|
11
|
+
* Default 256. Zero switches immediately and may click. Reversals continue
|
|
12
|
+
* from the current integer progress, without restarting the ramp. */
|
|
13
|
+
transitionSamples?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface FreezeReverbControls {
|
|
16
|
+
/** Level control: ramp to muted excitation + unity feedback while true. */
|
|
17
|
+
freeze: Node<'bool'>;
|
|
18
|
+
/** Highest priority: output silence, discard current excitation, invalidate
|
|
19
|
+
* all delay history and set progress to live. Held reset keeps doing this. */
|
|
20
|
+
reset: Node<'bool'>;
|
|
21
|
+
}
|
|
22
|
+
/** Wet-only four-line integer-delay FDN. Call tick exactly once per sample.
|
|
23
|
+
* The fully frozen, unforced real-arithmetic network preserves stored energy.
|
|
24
|
+
* Actual f64 state has rounding and very-small-state limits, not perpetual
|
|
25
|
+
* exact energy. Finite normalized inputs and adequate output headroom required.
|
|
26
|
+
* No interpolation, damping filter, shimmer, limiter, dry path or normalization.
|
|
27
|
+
*/
|
|
28
|
+
export declare const freezeReverb: import("@unworklet/core").SubgraphDecl<[config: FreezeReverbConfig], {
|
|
29
|
+
tick(inputLeft: Node<"f32">, inputRight: Node<"f32">, c: FreezeReverbControls): {
|
|
30
|
+
left: Node<"f32">;
|
|
31
|
+
right: Node<"f32">;
|
|
32
|
+
freezeAmount: Node<"f32">;
|
|
33
|
+
frozen: Node<"bool">;
|
|
34
|
+
};
|
|
35
|
+
}>;
|