@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.
- package/CHANGELOG.md +63 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +107 -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 +17 -0
- package/dist/convolution.js +87 -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 +26 -0
- package/dist/prepared-convolution.js +104 -0
- package/dist/resident-recorder.d.ts +28 -0
- package/dist/resident-recorder.js +68 -0
- package/dist/resident-time-stretch.d.ts +46 -0
- package/dist/resident-time-stretch.js +136 -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 +50 -0
- package/dist/spatial-chains.js +76 -0
- package/dist/spectral-fft.d.ts +27 -0
- package/dist/spectral-fft.js +84 -0
- package/dist/spectral-freeze.d.ts +24 -0
- package/dist/spectral-freeze.js +74 -0
- package/dist/spectral-gate.d.ts +23 -0
- package/dist/spectral-gate.js +58 -0
- package/dist/spectral-stft.d.ts +14 -0
- package/dist/spectral-stft.js +70 -0
- package/dist/spectral-texture.d.ts +46 -0
- package/dist/spectral-texture.js +141 -0
- package/dist/spectral.d.ts +18 -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 +433 -0
- package/docs/character-filter.md +139 -0
- package/docs/contracts.md +141 -0
- package/docs/control-modulation.md +194 -0
- package/docs/convolution.md +50 -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 +58 -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 +197 -0
- package/docs/realtime-audition.md +63 -0
- package/docs/resident-recorder.md +151 -0
- package/docs/resident-time-stretch.md +196 -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 +251 -0
- package/docs/spectral-freeze-entry.md +132 -0
- package/docs/spectral-gate-entry.md +134 -0
- package/docs/spectral-large-candidate.md +81 -0
- package/docs/spectral-texture-entry.md +178 -0
- package/docs/spectral.md +127 -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,82 @@
|
|
|
1
|
+
export const PREPARED_BLOCK = 128;
|
|
2
|
+
export const PREPARED_FFT_SIZE = 256;
|
|
3
|
+
export const PREPARED_PARTITIONS = 64;
|
|
4
|
+
export const PREPARED_VALUES = 32768;
|
|
5
|
+
export const PREPARATION_ERROR_LIMIT = 4e-6;
|
|
6
|
+
const CERTIFICATE_GUARD = 1e-8;
|
|
7
|
+
export function validatePreparedConvolutionConfig(config) {
|
|
8
|
+
if (!config || config.blockSize !== PREPARED_BLOCK || config.partitions !== PREPARED_PARTITIONS) {
|
|
9
|
+
throw new RangeError('preparedConvolution requires fixed blockSize 128 and partitions 64');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Host-only preparation. Do not call from an audio callback.
|
|
13
|
+
* Returns a complete native f32 packet; send it unchanged through the caller's
|
|
14
|
+
* explicitly sized native event. The original IR is not normalized or clipped.
|
|
15
|
+
* Quantization is approximate, with a guarded inverse-DFT error certificate.
|
|
16
|
+
*/
|
|
17
|
+
export function prepareConvolutionSpectrum(impulse, config) {
|
|
18
|
+
validatePreparedConvolutionConfig(config);
|
|
19
|
+
if (!(Array.isArray(impulse) || impulse instanceof Float32Array || impulse instanceof Float64Array) || impulse.length < 1 || impulse.length > 8192) {
|
|
20
|
+
throw new RangeError('prepared convolution IR requires 1..8192 real taps');
|
|
21
|
+
}
|
|
22
|
+
const taps = Array.from(impulse);
|
|
23
|
+
let absoluteSum = 0, compensation = 0;
|
|
24
|
+
for (const tap of taps) {
|
|
25
|
+
if (!Number.isFinite(tap) || Math.abs(tap) > 1)
|
|
26
|
+
throw new RangeError('prepared convolution IR taps must be finite in [-1,1]');
|
|
27
|
+
const increment = Math.abs(tap) - compensation, next = absoluteSum + increment;
|
|
28
|
+
compensation = (next - absoluteSum) - increment;
|
|
29
|
+
absoluteSum = next;
|
|
30
|
+
}
|
|
31
|
+
if (absoluteSum > 4)
|
|
32
|
+
throw new RangeError('prepared convolution IR absolute sum must be <=4');
|
|
33
|
+
const B = PREPARED_BLOCK, N = PREPARED_FFT_SIZE;
|
|
34
|
+
const spectrum = new Float32Array(PREPARED_VALUES);
|
|
35
|
+
const cosine = Float64Array.from({ length: N }, (_, n) => Math.cos(2 * Math.PI * n / N));
|
|
36
|
+
const sine = Float64Array.from({ length: N }, (_, n) => Math.sin(2 * Math.PI * n / N));
|
|
37
|
+
// Independent inverse basis, with modulo-reduced arguments. It checks every
|
|
38
|
+
// reconstructed tap, including the half that is ideally zero padding.
|
|
39
|
+
const inverseCos = Float64Array.from({ length: N * N }, (_, index) => {
|
|
40
|
+
const n = Math.floor(index / N), k = index % N;
|
|
41
|
+
return Math.cos(2 * Math.PI * ((n * k) % N) / N);
|
|
42
|
+
});
|
|
43
|
+
const inverseSin = Float64Array.from({ length: N * N }, (_, index) => {
|
|
44
|
+
const n = Math.floor(index / N), k = index % N;
|
|
45
|
+
return Math.sin(2 * Math.PI * ((n * k) % N) / N);
|
|
46
|
+
});
|
|
47
|
+
let error = CERTIFICATE_GUARD;
|
|
48
|
+
for (let p = 0; p < Math.ceil(taps.length / B); p++) {
|
|
49
|
+
const values = Array.from({ length: B }, (_, n) => taps[p * B + n] ?? 0);
|
|
50
|
+
if (values.every(value => value === 0))
|
|
51
|
+
continue;
|
|
52
|
+
for (let k = 0; k <= N / 2; k++) {
|
|
53
|
+
let real = 0, imag = 0;
|
|
54
|
+
for (let n = 0; n < B; n++) {
|
|
55
|
+
real += values[n] * cosine[(k * n) % N];
|
|
56
|
+
imag -= values[n] * sine[(k * n) % N];
|
|
57
|
+
}
|
|
58
|
+
const re = Math.fround(real), im = k === 0 || k === N / 2 ? 0 : Math.fround(imag);
|
|
59
|
+
if (!Number.isFinite(re) || !Number.isFinite(im) || Math.abs(re) > 4 || Math.abs(im) > 4)
|
|
60
|
+
throw new RangeError('prepared convolution spectrum exceeds finite component bounds');
|
|
61
|
+
spectrum[2 * (p * N + k)] = re;
|
|
62
|
+
spectrum[2 * (p * N + k) + 1] = im;
|
|
63
|
+
if (k > 0 && k < N / 2) {
|
|
64
|
+
spectrum[2 * (p * N + N - k)] = re;
|
|
65
|
+
spectrum[2 * (p * N + N - k) + 1] = -im;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (let n = 0; n < N; n++) {
|
|
69
|
+
let real = 0, imag = 0;
|
|
70
|
+
for (let k = 0; k < N; k++) {
|
|
71
|
+
const re = spectrum[2 * (p * N + k)], im = spectrum[2 * (p * N + k) + 1];
|
|
72
|
+
const c = inverseCos[n * N + k], s = inverseSin[n * N + k];
|
|
73
|
+
real += re * c - im * s;
|
|
74
|
+
imag += re * s + im * c;
|
|
75
|
+
}
|
|
76
|
+
error += Math.abs(real / N - (values[n] ?? 0)) + Math.abs(imag / N);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!Number.isFinite(error) || error > PREPARATION_ERROR_LIMIT)
|
|
80
|
+
throw new RangeError('prepared convolution IR exceeds the guarded 4e-6 quantization certificate');
|
|
81
|
+
return { formatVersion: 1, blockSize: B, partitions: PREPARED_PARTITIONS, impulseFrames: taps.length, declaredValues: PREPARED_VALUES, spectrum };
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type EveryNSamples, type Node } from '@unworklet/core';
|
|
2
|
+
export interface PartitionedConvolutionConfig {
|
|
3
|
+
/** Pass ctx.renderQuantumSize; aligns native dispatch with snapshot boundaries. */
|
|
4
|
+
renderQuantumSize: number;
|
|
5
|
+
/** Construction-fixed power of two in [4,32]. FFT size is twice this value. */
|
|
6
|
+
blockSize: number;
|
|
7
|
+
/** Fixed IR, length 1..4*blockSize. Every tap in [-1,1], total absolute gain <=4. */
|
|
8
|
+
impulse: readonly number[];
|
|
9
|
+
}
|
|
10
|
+
/** Bounded uniform-partition convolution candidate, exactly blockSize latency.
|
|
11
|
+
* Call once per sample in stride-1 forSample with its native everyNSamples.
|
|
12
|
+
* Input must be finite normalized audio [-1,1]. No IR loader/swap or dry path.
|
|
13
|
+
* Level reset silences immediately and discards its input without rephasing hops.
|
|
14
|
+
*/
|
|
15
|
+
export declare const partitionedConvolution: import("@unworklet/core").SubgraphDecl<[config: PartitionedConvolutionConfig], {
|
|
16
|
+
tick(input: Node<"f32">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
|
|
2
|
+
import { spectralDispatchPeriod, 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 dispatchPeriod = spectralDispatchPeriod(block, config.renderQuantumSize);
|
|
17
|
+
const impulse = [...config.impulse], size = 2 * block, partitions = Math.ceil(impulse.length / block);
|
|
18
|
+
// Construction-only O(P*N*B) coefficient preparation. No host FFT runtime,
|
|
19
|
+
// asset read, callback allocation or coefficient update on the audio thread.
|
|
20
|
+
const spectra = Array.from({ length: partitions }, (_, p) => Array.from({ length: size }, (_, k) => {
|
|
21
|
+
let real = 0, imag = 0;
|
|
22
|
+
for (let n = 0; n < block; n++) {
|
|
23
|
+
const tap = impulse[p * block + n] ?? 0, angle = -2 * Math.PI * k * n / size;
|
|
24
|
+
real += tap * Math.cos(angle);
|
|
25
|
+
imag += tap * Math.sin(angle);
|
|
26
|
+
}
|
|
27
|
+
return { real, imag };
|
|
28
|
+
}));
|
|
29
|
+
const fft = instantiate(spectralFft, { size }, { name: 'fft' });
|
|
30
|
+
const scale = 2 ** 128;
|
|
31
|
+
const history = state.buffer.f64({ size: block }).expose({ name: 'historyScaled', snapshot: 'persistent' });
|
|
32
|
+
const previousReal = state.buffer.f64({ size: partitions * size }).expose({ name: 'previousReal', snapshot: 'persistent' });
|
|
33
|
+
const previousImag = state.buffer.f64({ size: partitions * size }).expose({ name: 'previousImag', snapshot: 'persistent' });
|
|
34
|
+
const mixedReal = state.buffer.f64({ size }).expose({ name: 'mixedReal', snapshot: 'transient' });
|
|
35
|
+
const mixedImag = state.buffer.f64({ size }).expose({ name: 'mixedImag', snapshot: 'transient' });
|
|
36
|
+
const overlap = state.buffer.f64({ size }).expose({ name: 'overlap', snapshot: 'persistent' });
|
|
37
|
+
const cursor = state.i32(0).named('cursor');
|
|
38
|
+
const spectrumCursor = state.i32(0).named('spectrumCursor');
|
|
39
|
+
const validSamples = state.i32(0).named('validSamples');
|
|
40
|
+
const validBlocks = state.i32(0).named('validBlocks');
|
|
41
|
+
const pendingReset = state.bool(true).named('pendingReset');
|
|
42
|
+
return {
|
|
43
|
+
tick(input, reset, everyNSamples) {
|
|
44
|
+
validSamples.write(select(reset, i32(0), validSamples.read()));
|
|
45
|
+
validBlocks.write(select(reset, i32(0), validBlocks.read()));
|
|
46
|
+
pendingReset.write(pendingReset.read().or(reset));
|
|
47
|
+
// A quantum need not divide blockSize. Persistent cursor phase keeps
|
|
48
|
+
// frame commits exact when native scheduler counters restart on restore.
|
|
49
|
+
everyNSamples(dispatchPeriod, () => {
|
|
50
|
+
const frameDue = cursor.read().mod(block).eq(0);
|
|
51
|
+
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);
|
|
52
|
+
for (let k = 0; k < size; k++) {
|
|
53
|
+
const index = spectrumCursor.read().mul(size).add(k);
|
|
54
|
+
previousReal.write(index, select(frameDue, transformed.real(k), previousReal.read(index)));
|
|
55
|
+
previousImag.write(index, select(frameDue, transformed.imag(k), previousImag.read(index)));
|
|
56
|
+
}
|
|
57
|
+
for (let k = 0; k < size; k++) {
|
|
58
|
+
let real = f64(0), imag = f64(0);
|
|
59
|
+
for (let p = 0; p < partitions; p++) {
|
|
60
|
+
const index = spectrumCursor.read().add(partitions - p).mod(partitions).mul(size).add(k);
|
|
61
|
+
const xr = select(validBlocks.read().gte(p), previousReal.read(index), f64(0));
|
|
62
|
+
const xi = select(validBlocks.read().gte(p), previousImag.read(index), f64(0));
|
|
63
|
+
real = real.add(xr.mul(spectra[p][k].real).sub(xi.mul(spectra[p][k].imag)));
|
|
64
|
+
imag = imag.add(xr.mul(spectra[p][k].imag).add(xi.mul(spectra[p][k].real)));
|
|
65
|
+
}
|
|
66
|
+
mixedReal.write(k, real);
|
|
67
|
+
mixedImag.write(k, imag);
|
|
68
|
+
}
|
|
69
|
+
const frame = fft.transform(n => mixedReal.read(n), n => mixedImag.read(n), true);
|
|
70
|
+
for (let n = 0; n < size; n++) {
|
|
71
|
+
const index = cursor.read().add(n).mod(size);
|
|
72
|
+
const prior = overlap.read(index);
|
|
73
|
+
overlap.write(index, select(frameDue, select(pendingReset.read(), f64(0), prior).add(frame.real(n)), prior));
|
|
74
|
+
}
|
|
75
|
+
spectrumCursor.write(select(frameDue, spectrumCursor.read().add(1).mod(partitions), spectrumCursor.read()));
|
|
76
|
+
validBlocks.write(select(frameDue, validBlocks.read().add(1).min(partitions - 1), validBlocks.read()));
|
|
77
|
+
pendingReset.write(pendingReset.read().and(frameDue.not()));
|
|
78
|
+
});
|
|
79
|
+
const output = select(reset.or(pendingReset.read()), f32(0), f32(overlap.read(cursor.read()).div(scale)));
|
|
80
|
+
overlap.write(cursor.read(), f64(0));
|
|
81
|
+
history.write(cursor.read().mod(block), f64(select(reset, f32(0), input)).mul(scale));
|
|
82
|
+
cursor.write(cursor.read().add(1).mod(size));
|
|
83
|
+
validSamples.write(select(reset, i32(0), validSamples.read().add(1).min(block)));
|
|
84
|
+
return output;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
});
|
|
@@ -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
|
+
}>;
|