@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,81 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
|
|
2
|
+
import { spectralFft } from './spectral-fft.js';
|
|
3
|
+
/** Bounded uniform-partition convolution candidate, exactly blockSize latency.
|
|
4
|
+
* Call once per sample in stride-1 forSample with its native everyNSamples.
|
|
5
|
+
* Input must be finite normalized audio [-1,1]. No IR loader/swap or dry path.
|
|
6
|
+
* Level reset silences immediately and discards its input without rephasing hops.
|
|
7
|
+
*/
|
|
8
|
+
export const partitionedConvolution = defineSubgraph((config) => {
|
|
9
|
+
const { blockSize: block } = config;
|
|
10
|
+
if (!Number.isInteger(block) || block < 4 || block > 32 || !Number.isInteger(Math.log2(block)) ||
|
|
11
|
+
!Array.isArray(config.impulse) || config.impulse.length < 1 || config.impulse.length > 4 * block ||
|
|
12
|
+
Array.from(config.impulse).some(tap => !Number.isFinite(tap) || Math.abs(tap) > 1) ||
|
|
13
|
+
config.impulse.reduce((sum, tap) => sum + Math.abs(tap), 0) > 4) {
|
|
14
|
+
throw new RangeError('partitionedConvolution requires power-of-two blockSize [4,32], 1..4*blockSize finite IR taps in [-1,1], and IR absolute sum <=4');
|
|
15
|
+
}
|
|
16
|
+
const impulse = [...config.impulse], size = 2 * block, partitions = Math.ceil(impulse.length / block);
|
|
17
|
+
// Construction-only O(P*N*B) coefficient preparation. No host FFT runtime,
|
|
18
|
+
// asset read, callback allocation or coefficient update on the audio thread.
|
|
19
|
+
const spectra = Array.from({ length: partitions }, (_, p) => Array.from({ length: size }, (_, k) => {
|
|
20
|
+
let real = 0, imag = 0;
|
|
21
|
+
for (let n = 0; n < block; n++) {
|
|
22
|
+
const tap = impulse[p * block + n] ?? 0, angle = -2 * Math.PI * k * n / size;
|
|
23
|
+
real += tap * Math.cos(angle);
|
|
24
|
+
imag += tap * Math.sin(angle);
|
|
25
|
+
}
|
|
26
|
+
return { real, imag };
|
|
27
|
+
}));
|
|
28
|
+
const fft = instantiate(spectralFft, { size }, { name: 'fft' });
|
|
29
|
+
const scale = 2 ** 128;
|
|
30
|
+
const history = state.buffer.f64({ size: block }).expose({ name: 'historyScaled', snapshot: 'persistent' });
|
|
31
|
+
const previousReal = state.buffer.f64({ size: partitions * size }).expose({ name: 'previousReal', snapshot: 'persistent' });
|
|
32
|
+
const previousImag = state.buffer.f64({ size: partitions * size }).expose({ name: 'previousImag', snapshot: 'persistent' });
|
|
33
|
+
const mixedReal = state.buffer.f64({ size }).expose({ name: 'mixedReal', snapshot: 'transient' });
|
|
34
|
+
const mixedImag = state.buffer.f64({ size }).expose({ name: 'mixedImag', snapshot: 'transient' });
|
|
35
|
+
const overlap = state.buffer.f64({ size }).expose({ name: 'overlap', snapshot: 'persistent' });
|
|
36
|
+
const cursor = state.i32(0).named('cursor');
|
|
37
|
+
const spectrumCursor = state.i32(0).named('spectrumCursor');
|
|
38
|
+
const validSamples = state.i32(0).named('validSamples');
|
|
39
|
+
const validBlocks = state.i32(0).named('validBlocks');
|
|
40
|
+
const pendingReset = state.bool(true).named('pendingReset');
|
|
41
|
+
return {
|
|
42
|
+
tick(input, reset, everyNSamples) {
|
|
43
|
+
validSamples.write(select(reset, i32(0), validSamples.read()));
|
|
44
|
+
validBlocks.write(select(reset, i32(0), validBlocks.read()));
|
|
45
|
+
pendingReset.write(pendingReset.read().or(reset));
|
|
46
|
+
everyNSamples(block, () => {
|
|
47
|
+
const transformed = fft.transform(n => select(n.lt(block).and(validSamples.read().gte(i32(block).sub(n))), history.read(cursor.read().add(n).mod(block)), f64(0)), () => f64(0), false);
|
|
48
|
+
for (let k = 0; k < size; k++) {
|
|
49
|
+
previousReal.write(spectrumCursor.read().mul(size).add(k), transformed.real(k));
|
|
50
|
+
previousImag.write(spectrumCursor.read().mul(size).add(k), transformed.imag(k));
|
|
51
|
+
}
|
|
52
|
+
for (let k = 0; k < size; k++) {
|
|
53
|
+
let real = f64(0), imag = f64(0);
|
|
54
|
+
for (let p = 0; p < partitions; p++) {
|
|
55
|
+
const index = spectrumCursor.read().add(partitions - p).mod(partitions).mul(size).add(k);
|
|
56
|
+
const xr = select(validBlocks.read().gte(p), previousReal.read(index), f64(0));
|
|
57
|
+
const xi = select(validBlocks.read().gte(p), previousImag.read(index), f64(0));
|
|
58
|
+
real = real.add(xr.mul(spectra[p][k].real).sub(xi.mul(spectra[p][k].imag)));
|
|
59
|
+
imag = imag.add(xr.mul(spectra[p][k].imag).add(xi.mul(spectra[p][k].real)));
|
|
60
|
+
}
|
|
61
|
+
mixedReal.write(k, real);
|
|
62
|
+
mixedImag.write(k, imag);
|
|
63
|
+
}
|
|
64
|
+
const frame = fft.transform(n => mixedReal.read(n), n => mixedImag.read(n), true);
|
|
65
|
+
for (let n = 0; n < size; n++) {
|
|
66
|
+
const index = cursor.read().add(n).mod(size);
|
|
67
|
+
overlap.write(index, select(pendingReset.read(), f64(0), overlap.read(index)).add(frame.real(n)));
|
|
68
|
+
}
|
|
69
|
+
spectrumCursor.write(spectrumCursor.read().add(1).mod(partitions));
|
|
70
|
+
validBlocks.write(validBlocks.read().add(1).min(partitions - 1));
|
|
71
|
+
pendingReset.write(false);
|
|
72
|
+
});
|
|
73
|
+
const output = select(reset.or(pendingReset.read()), f32(0), f32(overlap.read(cursor.read()).div(scale)));
|
|
74
|
+
overlap.write(cursor.read(), f64(0));
|
|
75
|
+
history.write(cursor.read().mod(block), f64(select(reset, f32(0), input)).mul(scale));
|
|
76
|
+
cursor.write(cursor.read().add(1).mod(size));
|
|
77
|
+
validSamples.write(select(reset, i32(0), validSamples.read().add(1).min(block)));
|
|
78
|
+
return output;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface CrossoverConfig {
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
}
|
|
5
|
+
/** Fourth-order Linkwitz–Riley: cascaded Butterworth LP/HP; positive-polarity sum is allpass. */
|
|
6
|
+
export declare const crossover: import("@unworklet/core").SubgraphDecl<[config: CrossoverConfig], {
|
|
7
|
+
tick(input: Node<"f32">, cutoffHz: Node<"f32">, reset: Node<"bool">): {
|
|
8
|
+
low: Node<"f32">;
|
|
9
|
+
high: Node<"f32">;
|
|
10
|
+
};
|
|
11
|
+
}>;
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
/** Fourth-order Linkwitz–Riley: cascaded Butterworth LP/HP; positive-polarity sum is allpass. */
|
|
5
|
+
export const crossover = defineSubgraph((config) => {
|
|
6
|
+
checkedSampleRate(config.sampleRate);
|
|
7
|
+
const first = instantiate(stateVariableFilter, config, { name: 'first' });
|
|
8
|
+
const low = instantiate(stateVariableFilter, config, { name: 'low' });
|
|
9
|
+
const high = instantiate(stateVariableFilter, config, { name: 'high' });
|
|
10
|
+
return {
|
|
11
|
+
tick(input, cutoffHz, reset) {
|
|
12
|
+
const firstOutput = first.tick(input, cutoffHz, f32(Math.SQRT1_2), reset);
|
|
13
|
+
return { low: low.tick(firstOutput.lowpass, cutoffHz, f32(Math.SQRT1_2), reset).lowpass,
|
|
14
|
+
high: high.tick(firstOutput.highpass, cutoffHz, f32(Math.SQRT1_2), reset).highpass };
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export type CurveShaperQuality = 'direct' | 'adaa';
|
|
3
|
+
export interface CurveShaperConfig {
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
/** Fixed count of equally spaced input knots on [-1,1], integer 2..17. */
|
|
6
|
+
pointCount: number;
|
|
7
|
+
/** Immutable quality. First-order interval averaging is not oversampling. */
|
|
8
|
+
quality?: CurveShaperQuality;
|
|
9
|
+
}
|
|
10
|
+
export interface CurveShaperControls {
|
|
11
|
+
/** Exactly pointCount audio-rate y values. Each clamps to [-1,1], NaN -> 0. */
|
|
12
|
+
ordinates: readonly Node<'f32'>[];
|
|
13
|
+
/** Linear multiplier [0,32]. Changes affect the current driven input. */
|
|
14
|
+
gain: Node<'f32'>;
|
|
15
|
+
/** Immediate blend [0,1]. ADAA dry uses a two-sample average. */
|
|
16
|
+
mix: Node<'f32'>;
|
|
17
|
+
/** Clear histories before processing this sample, including its curve edits. */
|
|
18
|
+
reset: Node<'bool'>;
|
|
19
|
+
}
|
|
20
|
+
/** Bounded editable piecewise-linear LUT, optionally first-order antialiased for
|
|
21
|
+
* fixed curves. Curve edits use current ordinates over the whole input interval;
|
|
22
|
+
* their modulation is not smoothed or guaranteed alias-free. Call once/sample.
|
|
23
|
+
*/
|
|
24
|
+
export declare const curveShaper: import("@unworklet/core").SubgraphDecl<[config: CurveShaperConfig], {
|
|
25
|
+
tick(input: Node<"f32">, controls: CurveShaperControls): Node<"f32">;
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, select, state } from '@unworklet/core';
|
|
2
|
+
const HISTORY_SCALE = 2 ** 128;
|
|
3
|
+
function bounded(value, low, high) {
|
|
4
|
+
const wide = f64(value);
|
|
5
|
+
return select(wide.eq(wide), wide, f64(0)).clamp(low, high);
|
|
6
|
+
}
|
|
7
|
+
/** Bounded editable piecewise-linear LUT, optionally first-order antialiased for
|
|
8
|
+
* fixed curves. Curve edits use current ordinates over the whole input interval;
|
|
9
|
+
* their modulation is not smoothed or guaranteed alias-free. Call once/sample.
|
|
10
|
+
*/
|
|
11
|
+
export const curveShaper = defineSubgraph((config) => {
|
|
12
|
+
const { sampleRate, pointCount, quality = 'adaa' } = config;
|
|
13
|
+
if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000)
|
|
14
|
+
throw new RangeError('curveShaper sampleRate must be in [8000,192000] Hz');
|
|
15
|
+
if (!Number.isInteger(pointCount) || pointCount < 2 || pointCount > 17)
|
|
16
|
+
throw new RangeError('curveShaper pointCount must be an integer in [2,17]');
|
|
17
|
+
if (quality !== 'direct' && quality !== 'adaa')
|
|
18
|
+
throw new RangeError('unknown curveShaper quality');
|
|
19
|
+
const previous = quality === 'adaa' ? state.f64(0).named('previous-driven-scaled') : undefined;
|
|
20
|
+
const previousDry = quality === 'adaa' ? state.f64(0).named('previous-dry-scaled') : undefined;
|
|
21
|
+
const extent = pointCount - 1;
|
|
22
|
+
return { tick(input, controls) {
|
|
23
|
+
if (controls.ordinates.length !== pointCount)
|
|
24
|
+
throw new RangeError('curveShaper ordinates must contain exactly pointCount nodes');
|
|
25
|
+
const y = controls.ordinates.map(value => bounded(value, -1, 1));
|
|
26
|
+
const dry = bounded(input, -8, 8), x = dry.mul(bounded(controls.gain, 0, 32));
|
|
27
|
+
const mix = bounded(controls.mix, 0, 1);
|
|
28
|
+
// Integer knot coordinates avoid asymmetry from rounded fractional knots.
|
|
29
|
+
const coordinate = x.mul(extent);
|
|
30
|
+
const segment = (value, i) => {
|
|
31
|
+
const left = -extent + 2 * i, right = left + 2;
|
|
32
|
+
const difference = y[i + 1].sub(y[i]), slope = difference.mul(.5);
|
|
33
|
+
const nonzero = slope.eq(0).not();
|
|
34
|
+
// Anchor a sign-crossing segment at its zero, preserving tiny input for
|
|
35
|
+
// e.g. the two-point identity LUT. All select branches execute: protect
|
|
36
|
+
// zero slope even when its root is not selected.
|
|
37
|
+
const root = select(y[i].eq(0), f64(left), select(y[i + 1].eq(0), f64(right), y[i + 1].mul(left).sub(y[i].mul(right)).div(select(nonzero, difference, f64(1)))));
|
|
38
|
+
const crossing = y[i].mul(y[i + 1]).lte(0).and(nonzero);
|
|
39
|
+
return select(crossing, value.sub(root).mul(slope), y[i].add(value.sub(left).mul(slope)));
|
|
40
|
+
};
|
|
41
|
+
const shape = (value) => {
|
|
42
|
+
let output = y[pointCount - 1];
|
|
43
|
+
for (let i = pointCount - 2; i >= 0; i--) {
|
|
44
|
+
output = select(value.lt(-extent + 2 * (i + 1)), segment(value, i), output);
|
|
45
|
+
}
|
|
46
|
+
return select(value.lte(-extent), y[0], output);
|
|
47
|
+
};
|
|
48
|
+
let wet, alignedDry = dry;
|
|
49
|
+
if (previous && previousDry) {
|
|
50
|
+
const last = select(controls.reset, f64(0), previous.read().div(HISTORY_SCALE));
|
|
51
|
+
const lastDry = select(controls.reset, f64(0), previousDry.read().div(HISTORY_SCALE));
|
|
52
|
+
const lastCoordinate = last.mul(extent);
|
|
53
|
+
const low = coordinate.min(lastCoordinate), high = coordinate.max(lastCoordinate), distance = high.sub(low);
|
|
54
|
+
// In integer-knot coordinates |dy/dt| <= 1. Mean-vs-midpoint
|
|
55
|
+
// error <= distance/4 <=2.5e-7, including knot crossings.
|
|
56
|
+
const close = distance.lte(1e-6);
|
|
57
|
+
let area = high.min(-extent).sub(low).max(0).mul(y[0])
|
|
58
|
+
.add(high.sub(low.max(extent)).max(0).mul(y[pointCount - 1]));
|
|
59
|
+
for (let i = 0; i < pointCount - 1; i++) {
|
|
60
|
+
const left = -extent + 2 * i, right = left + 2;
|
|
61
|
+
const a = low.clamp(left, right), b = high.clamp(left, right);
|
|
62
|
+
area = area.add(b.sub(a).mul(segment(a.add(b).mul(.5), i)));
|
|
63
|
+
}
|
|
64
|
+
wet = select(close, shape(coordinate.add(lastCoordinate).mul(.5)), area.div(select(close, f64(1), distance)));
|
|
65
|
+
alignedDry = dry.add(lastDry).mul(.5);
|
|
66
|
+
previous.write(x.mul(HISTORY_SCALE));
|
|
67
|
+
previousDry.write(dry.mul(HISTORY_SCALE));
|
|
68
|
+
}
|
|
69
|
+
else
|
|
70
|
+
wet = shape(coordinate);
|
|
71
|
+
return f32(alignedDry.mul(f64(1).sub(mix)).add(wet.mul(mix)));
|
|
72
|
+
} };
|
|
73
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface DelayFxConfig {
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
/** Fixed allocation per channel; eight seconds is a configurable starting default. */
|
|
5
|
+
maxDelaySeconds?: number;
|
|
6
|
+
/** Immutable feedback tone mode. Flat is useful when uncolored repeats are wanted. */
|
|
7
|
+
tone?: 'lowpass' | 'flat';
|
|
8
|
+
/** Right LFO phase relative to left, in cycles. Default 0.5 (opposite motion). */
|
|
9
|
+
stereoPhaseCycles?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface DelayFxControls {
|
|
12
|
+
timeLeftSeconds: Node<'f32'>;
|
|
13
|
+
timeRightSeconds: Node<'f32'>;
|
|
14
|
+
/** When true, use 60 * beats / bpm. One beat is a quarter note. */
|
|
15
|
+
sync: Node<'bool'>;
|
|
16
|
+
bpm: Node<'f32'>;
|
|
17
|
+
beatsLeft: Node<'f32'>;
|
|
18
|
+
beatsRight: Node<'f32'>;
|
|
19
|
+
feedback: Node<'f32'>;
|
|
20
|
+
cutoffHz: Node<'f32'>;
|
|
21
|
+
mix: Node<'f32'>;
|
|
22
|
+
rateHz: Node<'f32'>;
|
|
23
|
+
depthSeconds: Node<'f32'>;
|
|
24
|
+
bypass: Node<'bool'>;
|
|
25
|
+
reset: Node<'bool'>;
|
|
26
|
+
}
|
|
27
|
+
/** One stereo delay FX engine. Tick once per sample; duplicate mono input explicitly.
|
|
28
|
+
* Finite audio/controls required. See docs/delay-fx.md for bounds and acoustic policies.
|
|
29
|
+
*/
|
|
30
|
+
export declare const delayFx: import("@unworklet/core").SubgraphDecl<[config: DelayFxConfig], {
|
|
31
|
+
tick(inputLeft: Node<"f32">, inputRight: Node<"f32">, c: DelayFxControls): {
|
|
32
|
+
left: Node<"f32">;
|
|
33
|
+
right: Node<"f32">;
|
|
34
|
+
timingRejected: Node<"bool">;
|
|
35
|
+
modulationClipped: Node<"bool">;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
package/dist/delay-fx.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { defineSubgraph, instantiate, f32, f64, select, state } from '@unworklet/core';
|
|
2
|
+
import { delayReadhead } from './delay-readhead.js';
|
|
3
|
+
import { filter } from './filter.js';
|
|
4
|
+
import { lfo, modulateDelay } from './lfo.js';
|
|
5
|
+
/** One stereo delay FX engine. Tick once per sample; duplicate mono input explicitly.
|
|
6
|
+
* Finite audio/controls required. See docs/delay-fx.md for bounds and acoustic policies.
|
|
7
|
+
*/
|
|
8
|
+
export const delayFx = defineSubgraph((config) => {
|
|
9
|
+
const { sampleRate, maxDelaySeconds = 8, tone = 'lowpass', stereoPhaseCycles = 0.5 } = config;
|
|
10
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000 ||
|
|
11
|
+
!['lowpass', 'flat'].includes(tone) || !Number.isFinite(stereoPhaseCycles)) {
|
|
12
|
+
throw new RangeError('delayFx requires an integer rate in [8000,192000], lowpass/flat tone and finite stereo phase');
|
|
13
|
+
}
|
|
14
|
+
const left = instantiate(delayReadhead, { sampleRate, maxDelaySeconds }, { name: 'left' });
|
|
15
|
+
const right = instantiate(delayReadhead, { sampleRate, maxDelaySeconds }, { name: 'right' });
|
|
16
|
+
const toneLeft = instantiate(filter, { sampleRate }, { name: 'toneLeft' });
|
|
17
|
+
const toneRight = instantiate(filter, { sampleRate }, { name: 'toneRight' });
|
|
18
|
+
const lfoLeft = instantiate(lfo, { sampleRate }, { name: 'lfoLeft' });
|
|
19
|
+
const lfoRight = instantiate(lfo, { sampleRate }, { name: 'lfoRight' });
|
|
20
|
+
// Materialize per-sample values in existing unworklet scalar slots. Without
|
|
21
|
+
// these boundaries 0.4.1 recursively expands the composed expression DAG.
|
|
22
|
+
// They are overwritten before use each sample and are not snapshot history.
|
|
23
|
+
const signalLeft = state.f32(0).expose({ name: 'signalLeft', snapshot: 'transient' });
|
|
24
|
+
const signalRight = state.f32(0).expose({ name: 'signalRight', snapshot: 'transient' });
|
|
25
|
+
const timeLeft = state.f32(0).expose({ name: 'timeLeft', snapshot: 'transient' });
|
|
26
|
+
const timeRight = state.f32(0).expose({ name: 'timeRight', snapshot: 'transient' });
|
|
27
|
+
const wetLeft = state.f32(0).expose({ name: 'wetLeft', snapshot: 'transient' });
|
|
28
|
+
const wetRight = state.f32(0).expose({ name: 'wetRight', snapshot: 'transient' });
|
|
29
|
+
const timingValid = state.bool(false).expose({ name: 'timingValid', snapshot: 'transient' });
|
|
30
|
+
const toneCutoff = state.f32(0).expose({ name: 'toneCutoff', snapshot: 'transient' });
|
|
31
|
+
const first = state.bool(true).named('first');
|
|
32
|
+
const minimum = Math.fround(1 / sampleRate), maximum = Math.fround(maxDelaySeconds);
|
|
33
|
+
const acceptedLeft = state.f32(minimum).named('acceptedLeft');
|
|
34
|
+
const acceptedRight = state.f32(minimum).named('acceptedRight');
|
|
35
|
+
return {
|
|
36
|
+
tick(inputLeft, inputRight, c) {
|
|
37
|
+
const seconds = (beats) => f32(f64(beats).mul(60).div(f64(c.bpm).clamp(30, 300)));
|
|
38
|
+
const requestedLeft = select(c.sync, seconds(c.beatsLeft), c.timeLeftSeconds);
|
|
39
|
+
const requestedRight = select(c.sync, seconds(c.beatsRight), c.timeRightSeconds);
|
|
40
|
+
const within = (time) => time.gte(minimum).and(time.lte(maximum));
|
|
41
|
+
const tempoValid = c.bpm.gte(30).and(c.bpm.lte(300));
|
|
42
|
+
timingValid.write(within(requestedLeft).and(within(requestedRight)).and(c.sync.not().or(tempoValid)));
|
|
43
|
+
const valid = timingValid.read();
|
|
44
|
+
// Reject the entire stereo request: keep the previous rhythm internally,
|
|
45
|
+
// stop new input and emit dry. Never present clamped time as an exact rhythm.
|
|
46
|
+
acceptedLeft.write(select(valid, requestedLeft, select(c.reset, f32(minimum), acceptedLeft.read())));
|
|
47
|
+
acceptedRight.write(select(valid, requestedRight, select(c.reset, f32(minimum), acceptedRight.read())));
|
|
48
|
+
// Reuse this sample's accepted state instead of expanding its selection again.
|
|
49
|
+
const baseLeft = acceptedLeft.read(), baseRight = acceptedRight.read();
|
|
50
|
+
const phaseReset = c.reset.or(first.read());
|
|
51
|
+
signalLeft.write(lfoLeft.tick(c.rateHz, phaseReset, f32(0)));
|
|
52
|
+
signalRight.write(lfoRight.tick(c.rateHz, phaseReset, f32(stereoPhaseCycles - Math.floor(stereoPhaseCycles))));
|
|
53
|
+
const modulationLeft = signalLeft.read(), modulationRight = signalRight.read();
|
|
54
|
+
first.write(false);
|
|
55
|
+
const rawLeft = baseLeft.add(modulationLeft.mul(c.depthSeconds.clamp(0, 0.05)));
|
|
56
|
+
const rawRight = baseRight.add(modulationRight.mul(c.depthSeconds.clamp(0, 0.05)));
|
|
57
|
+
const modulationClipped = within(rawLeft).and(within(rawRight)).not();
|
|
58
|
+
const delayLeft = modulateDelay(baseLeft, modulationLeft, c.depthSeconds, minimum, maximum);
|
|
59
|
+
const delayRight = modulateDelay(baseRight, modulationRight, c.depthSeconds, minimum, maximum);
|
|
60
|
+
timeLeft.write(delayLeft);
|
|
61
|
+
timeRight.write(delayRight);
|
|
62
|
+
const tapLeft = left.read(timeLeft.read(), c.reset), tapRight = right.read(timeRight.read(), c.reset);
|
|
63
|
+
wetLeft.write(tapLeft.output);
|
|
64
|
+
wetRight.write(tapRight.output);
|
|
65
|
+
// Q=0.5 and g=tan(pi*fc/fs)<1 admit a positive two-stage realization.
|
|
66
|
+
// The 0.24*fs ceiling keeps a margin below g=1, even with coefficient error.
|
|
67
|
+
toneCutoff.write(c.cutoffHz.clamp(20, Math.min(20000, 0.24 * sampleRate)));
|
|
68
|
+
const cutoff = toneCutoff.read();
|
|
69
|
+
const filteredLeft = toneLeft.tick(wetLeft.read(), cutoff, f32(0.5), c.reset);
|
|
70
|
+
const filteredRight = toneRight.tick(wetRight.read(), cutoff, f32(0.5), c.reset);
|
|
71
|
+
const gain = c.feedback.clamp(0, 0.95);
|
|
72
|
+
const dryOnly = c.bypass.or(valid.not());
|
|
73
|
+
tapLeft.write(select(dryOnly, f32(0), inputLeft).add((tone === 'flat' ? wetLeft.read() : filteredLeft).mul(gain)));
|
|
74
|
+
tapRight.write(select(dryOnly, f32(0), inputRight).add((tone === 'flat' ? wetRight.read() : filteredRight).mul(gain)));
|
|
75
|
+
const mix = c.mix.clamp(0, 1);
|
|
76
|
+
return {
|
|
77
|
+
left: select(dryOnly, inputLeft, inputLeft.mul(f32(1).sub(mix)).add(wetLeft.read().mul(mix))),
|
|
78
|
+
right: select(dryOnly, inputRight, inputRight.mul(f32(1).sub(mix)).add(wetRight.read().mul(mix))),
|
|
79
|
+
timingRejected: valid.not(),
|
|
80
|
+
modulationClipped,
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface DelayReadheadConfig {
|
|
3
|
+
/** Use the enclosing processor's ctx.sampleRate. */
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
/** Fixed allocation; defaults to two seconds, not a product limit. */
|
|
6
|
+
maxDelaySeconds?: number;
|
|
7
|
+
}
|
|
8
|
+
/** Mono moving readhead. Per sample, use tick or one read followed by its write. */
|
|
9
|
+
export declare const delayReadhead: import("@unworklet/core").SubgraphDecl<[config: DelayReadheadConfig], {
|
|
10
|
+
read: (delaySeconds: Node<"f32">, reset: Node<"bool">) => {
|
|
11
|
+
output: Node<"f32">;
|
|
12
|
+
outOfRange: Node<"bool">;
|
|
13
|
+
write(input: Node<"f32">): void;
|
|
14
|
+
};
|
|
15
|
+
tick(input: Node<"f32">, delaySeconds: Node<"f32">, reset: Node<"bool">): {
|
|
16
|
+
output: Node<"f32">;
|
|
17
|
+
outOfRange: Node<"bool">;
|
|
18
|
+
};
|
|
19
|
+
}>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { and, clamp, defineSubgraph, f32, f64, floor, i32, select, state } from '@unworklet/core';
|
|
2
|
+
/** Mono moving readhead. Per sample, use tick or one read followed by its write. */
|
|
3
|
+
export const delayReadhead = defineSubgraph((config) => {
|
|
4
|
+
const { sampleRate, maxDelaySeconds = 2 } = config;
|
|
5
|
+
// Compare the reciprocal boundary directly: sr * (1 / sr) may round just
|
|
6
|
+
// below one. Only an already-valid one-sample capacity receives this floor.
|
|
7
|
+
const maximum = Math.max(1, sampleRate * maxDelaySeconds);
|
|
8
|
+
const size = Math.ceil(maximum) + 2;
|
|
9
|
+
if (!Number.isFinite(sampleRate) || sampleRate <= 0 ||
|
|
10
|
+
!Number.isFinite(maxDelaySeconds) || maxDelaySeconds < 1 / sampleRate ||
|
|
11
|
+
!Number.isFinite(maximum) || size > 0x7fffffff ||
|
|
12
|
+
Math.fround(1 / sampleRate) <= 0 || !Number.isFinite(Math.fround(maxDelaySeconds))) {
|
|
13
|
+
throw new RangeError('delayReadhead requires a positive finite sampleRate and capacity of at least one sample fitting i32 indexing');
|
|
14
|
+
}
|
|
15
|
+
const history = state.buffer.f32({ size }).expose({ name: 'history', snapshot: 'persistent' });
|
|
16
|
+
const cursor = state.i32(0).named('cursor');
|
|
17
|
+
const valid = state.i32(0).named('valid');
|
|
18
|
+
function read(delaySeconds, reset) {
|
|
19
|
+
// Reset invalidates history before either tap is read, without an O(size) clear.
|
|
20
|
+
const write = select(reset, i32(0), cursor.read());
|
|
21
|
+
const available = select(reset, i32(0), valid.read());
|
|
22
|
+
const requested = f64(delaySeconds).mul(sampleRate);
|
|
23
|
+
const inRange = and(delaySeconds.gte(Math.fround(1 / sampleRate)), delaySeconds.lte(Math.fround(maxDelaySeconds)));
|
|
24
|
+
// NaN selects minimum; infinities saturate. Report all out-of-range controls.
|
|
25
|
+
const delay = clamp(select(requested.eq(requested), requested, f64(1)), 1, maximum);
|
|
26
|
+
const whole = i32(floor(delay));
|
|
27
|
+
const fraction = f32(delay.sub(f64(whole)));
|
|
28
|
+
const relative = write.sub(whole);
|
|
29
|
+
const recentIndex = select(relative.lt(0), relative.add(size), relative);
|
|
30
|
+
const olderIndex = select(recentIndex.eq(0), i32(size - 1), recentIndex.sub(1));
|
|
31
|
+
const recent = select(available.gte(whole), history.read(recentIndex), f32(0));
|
|
32
|
+
const older = select(available.gt(whole), history.read(olderIndex), f32(0));
|
|
33
|
+
const output = recent.mul(f32(1).sub(fraction)).add(older.mul(fraction));
|
|
34
|
+
return {
|
|
35
|
+
output,
|
|
36
|
+
outOfRange: inRange.not(),
|
|
37
|
+
// This closure runs at graph construction, never on the audio thread.
|
|
38
|
+
// Consume exactly once, after using output to construct the current input.
|
|
39
|
+
write(input) {
|
|
40
|
+
history.write(write, input);
|
|
41
|
+
cursor.write(write.add(1).mod(size));
|
|
42
|
+
valid.write(select(available.lt(size), available.add(1), i32(size)));
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
read,
|
|
48
|
+
tick(input, delaySeconds, reset) {
|
|
49
|
+
const tap = read(delaySeconds, reset);
|
|
50
|
+
tap.write(input);
|
|
51
|
+
return { output: tap.output, outOfRange: tap.outOfRange };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { DelayFxConfig, DelayFxControls } from './delay-fx.js';
|
|
2
|
+
/** Values for existing delayFx inputs, not a serialization or parameter registry. */
|
|
3
|
+
export type DelaySettingsValues = {
|
|
4
|
+
readonly [K in keyof DelayFxControls]: K extends 'sync' | 'bypass' | 'reset' ? boolean : number;
|
|
5
|
+
};
|
|
6
|
+
export interface DelaySettings {
|
|
7
|
+
readonly config: Readonly<Omit<DelayFxConfig, 'sampleRate'>>;
|
|
8
|
+
readonly parameters: DelaySettingsValues;
|
|
9
|
+
}
|
|
10
|
+
/** CANDIDATE: short, opposite-phase motion blended with the original signal. */
|
|
11
|
+
export declare const chorusSettings: {
|
|
12
|
+
readonly config: {
|
|
13
|
+
readonly maxDelaySeconds: 2;
|
|
14
|
+
readonly tone: "flat";
|
|
15
|
+
readonly stereoPhaseCycles: 0.5;
|
|
16
|
+
};
|
|
17
|
+
readonly parameters: {
|
|
18
|
+
readonly timeLeftSeconds: 0.018;
|
|
19
|
+
readonly timeRightSeconds: 0.018;
|
|
20
|
+
readonly sync: false;
|
|
21
|
+
readonly bpm: 120;
|
|
22
|
+
readonly beatsLeft: 1;
|
|
23
|
+
readonly beatsRight: 1;
|
|
24
|
+
readonly feedback: 0;
|
|
25
|
+
readonly cutoffHz: 3200;
|
|
26
|
+
readonly mix: 0.45;
|
|
27
|
+
readonly rateHz: 0.65;
|
|
28
|
+
readonly depthSeconds: 0.003;
|
|
29
|
+
readonly bypass: false;
|
|
30
|
+
readonly reset: false;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** CANDIDATE: dotted eighth left, quarter right; tone acts only on feedback. */
|
|
34
|
+
export declare const rhythmicDelaySettings: {
|
|
35
|
+
readonly config: {
|
|
36
|
+
readonly maxDelaySeconds: 2;
|
|
37
|
+
readonly tone: "lowpass";
|
|
38
|
+
readonly stereoPhaseCycles: 0.5;
|
|
39
|
+
};
|
|
40
|
+
readonly parameters: {
|
|
41
|
+
readonly timeLeftSeconds: 0.375;
|
|
42
|
+
readonly timeRightSeconds: 0.5;
|
|
43
|
+
readonly sync: true;
|
|
44
|
+
readonly bpm: 120;
|
|
45
|
+
readonly beatsLeft: 0.75;
|
|
46
|
+
readonly beatsRight: 1;
|
|
47
|
+
readonly feedback: 0.48;
|
|
48
|
+
readonly cutoffHz: 3200;
|
|
49
|
+
readonly mix: 0.35;
|
|
50
|
+
readonly rateHz: 0;
|
|
51
|
+
readonly depthSeconds: 0;
|
|
52
|
+
readonly bypass: false;
|
|
53
|
+
readonly reset: false;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** CANDIDATE: short, opposite-phase motion blended with the original signal. */
|
|
2
|
+
export const chorusSettings = {
|
|
3
|
+
config: { maxDelaySeconds: 2, tone: 'flat', stereoPhaseCycles: 0.5 },
|
|
4
|
+
parameters: {
|
|
5
|
+
timeLeftSeconds: 0.018, timeRightSeconds: 0.018,
|
|
6
|
+
sync: false, bpm: 120, beatsLeft: 1, beatsRight: 1,
|
|
7
|
+
feedback: 0, cutoffHz: 3200, mix: 0.45,
|
|
8
|
+
rateHz: 0.65, depthSeconds: 0.003, bypass: false, reset: false,
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
/** CANDIDATE: dotted eighth left, quarter right; tone acts only on feedback. */
|
|
12
|
+
export const rhythmicDelaySettings = {
|
|
13
|
+
config: { maxDelaySeconds: 2, tone: 'lowpass', stereoPhaseCycles: 0.5 },
|
|
14
|
+
parameters: {
|
|
15
|
+
timeLeftSeconds: 0.375, timeRightSeconds: 0.5,
|
|
16
|
+
sync: true, bpm: 120, beatsLeft: 0.75, beatsRight: 1,
|
|
17
|
+
feedback: 0.48, cutoffHz: 3200, mix: 0.35,
|
|
18
|
+
rateHz: 0, depthSeconds: 0, bypass: false, reset: false,
|
|
19
|
+
},
|
|
20
|
+
};
|
package/dist/drive.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export type DriveCurve = 'hard' | 'soft' | 'asymmetric' | 'fold';
|
|
3
|
+
export type DriveQuality = 'direct' | 'adaa';
|
|
4
|
+
export interface DriveConfig {
|
|
5
|
+
sampleRate: number;
|
|
6
|
+
curve: DriveCurve;
|
|
7
|
+
/** Immutable quality. ADAA is first-order, not oversampling or a bandlimit. */
|
|
8
|
+
quality?: DriveQuality;
|
|
9
|
+
/** Explicit wet-path DC blocker; zero (default) disables it. Range [0, 200] Hz. */
|
|
10
|
+
dcBlockHz?: number;
|
|
11
|
+
}
|
|
12
|
+
export interface ReductionConfig {
|
|
13
|
+
sampleRate: number;
|
|
14
|
+
}
|
|
15
|
+
/** Fixed analytic memoryless waveshaper, optional first-order ADAA and DC block. */
|
|
16
|
+
export declare const drive: import("@unworklet/core").SubgraphDecl<[config: DriveConfig], {
|
|
17
|
+
/** Once per sample. Input [-8,8], linear drive [0,32], wet mix [0,1]. */
|
|
18
|
+
tick(input: Node<"f32">, gain: Node<"f32">, mix: Node<"f32">, reset: Node<"bool">): Node<"f32">;
|
|
19
|
+
}>;
|
|
20
|
+
/** Signed-PCM quantizer plus sample hold. Aliasing is intentional; no AA claim. */
|
|
21
|
+
export declare const reduction: import("@unworklet/core").SubgraphDecl<[config: ReductionConfig], {
|
|
22
|
+
/** Bits [2,24], hold period [1,4096] samples, both floored at capture. */
|
|
23
|
+
tick(input: Node<"f32">, bits: Node<"f32">, holdSamples: Node<"f32">, mix: Node<"f32">, reset: Node<"bool">): Node<"f32">;
|
|
24
|
+
}>;
|
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
|
+
});
|