@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
package/dist/lfo.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, select, state } from '@unworklet/core';
|
|
2
|
+
/**
|
|
3
|
+
* Free-running sine source; tick once per sample. Rate is clamped to [0,20] Hz.
|
|
4
|
+
* Output uses the current phase, then advances by rate/sampleRate. Rate zero
|
|
5
|
+
* holds phase; changing rate never resets phase. Reset emits the supplied phase
|
|
6
|
+
* on that sample and advances from it. Reset phase is in cycles, wrapped to [0,1).
|
|
7
|
+
* f64 phase prevents a small rate from stalling against a large f32 phase.
|
|
8
|
+
* Finite inputs required. No implicit note reset, depth, smoothing or routing.
|
|
9
|
+
*/
|
|
10
|
+
export const lfo = defineSubgraph((config) => {
|
|
11
|
+
if (!Number.isInteger(config.sampleRate) || config.sampleRate < 1) {
|
|
12
|
+
throw new RangeError('lfo sampleRate must be a positive integer');
|
|
13
|
+
}
|
|
14
|
+
const phase = state.f64(0).named('phase');
|
|
15
|
+
return {
|
|
16
|
+
tick(rate, reset, resetPhase) {
|
|
17
|
+
const requested = f64(resetPhase);
|
|
18
|
+
const current = select(reset, requested.sub(requested.floor()), phase.read());
|
|
19
|
+
const next = current.add(f64(rate.clamp(0, 20)).div(config.sampleRate));
|
|
20
|
+
phase.write(next.sub(next.floor()));
|
|
21
|
+
// Reflect cycles into [-1/4,1/4] before polynomial evaluation. The
|
|
22
|
+
// degree-13 sine remainder is <= (pi/2)^15 / 15! < 7e-10.
|
|
23
|
+
// unworklet 0.4.1 sin demotes f64 to its f32 degree-9 approximation;
|
|
24
|
+
// direct f64 arithmetic preserves slow phase changes and the error bound.
|
|
25
|
+
const centered = current.sub(select(current.gt(0.5), f64(1), f64(0)));
|
|
26
|
+
const folded = select(centered.gt(0.25), f64(0.5).sub(centered), select(centered.lt(-0.25), f64(-0.5).sub(centered), centered));
|
|
27
|
+
const x = folded.mul(2 * Math.PI), x2 = x.mul(x);
|
|
28
|
+
const polynomial = f64(1 / 6227020800).mul(x2).sub(1 / 39916800)
|
|
29
|
+
.mul(x2).add(1 / 362880).mul(x2).sub(1 / 5040)
|
|
30
|
+
.mul(x2).add(1 / 120).mul(x2).sub(1 / 6).mul(x2).add(1);
|
|
31
|
+
return f32(x.mul(polynomial)).clamp(-1, 1);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
// The musical octave ratio has a bounded exponent [-8,8]. Evaluate exp(x/16)
|
|
36
|
+
// through degree 8, then square four times: |x/16| <= ln(2)/2, giving < 5e-9
|
|
37
|
+
// relative truncation error. This avoids 0.4.1's approximate f32 pow/log path.
|
|
38
|
+
function octaveRatio(octaves) {
|
|
39
|
+
const x = f64(octaves).mul(Math.LN2 / 16);
|
|
40
|
+
const p = f64(1 / 40320).mul(x).add(1 / 5040).mul(x).add(1 / 720)
|
|
41
|
+
.mul(x).add(1 / 120).mul(x).add(1 / 24).mul(x).add(1 / 6)
|
|
42
|
+
.mul(x).add(0.5).mul(x).add(1).mul(x).add(1);
|
|
43
|
+
const p2 = p.mul(p), p4 = p2.mul(p2), p8 = p4.mul(p4);
|
|
44
|
+
return f32(p8.mul(p8));
|
|
45
|
+
}
|
|
46
|
+
/** Destination-unit depth, with final bounds enforced by the destination. */
|
|
47
|
+
export function modulatePitch(baseHz, signal, semitones, maxHz) {
|
|
48
|
+
return baseHz.mul(octaveRatio(signal.clamp(-1, 1).mul(semitones.clamp(-24, 24)).div(12))).clamp(0, maxHz);
|
|
49
|
+
}
|
|
50
|
+
export function modulateCutoff(baseHz, signal, octaves, maxHz) {
|
|
51
|
+
return baseHz.mul(octaveRatio(signal.clamp(-1, 1).mul(octaves.clamp(-8, 8)))).clamp(20, maxHz);
|
|
52
|
+
}
|
|
53
|
+
export function modulateDelay(baseSeconds, signal, depthSeconds, minSeconds, maxSeconds) {
|
|
54
|
+
return baseSeconds.add(signal.clamp(-1, 1).mul(depthSeconds.clamp(0, 0.05))).clamp(minSeconds, maxSeconds);
|
|
55
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface LiveSampleBufferConfig {
|
|
3
|
+
/** Fixed mono history capacity, 1..65,536 accepted writes. */
|
|
4
|
+
capacity: number;
|
|
5
|
+
/** Incoming processor rate, 8,000..192,000 Hz. No resampling. */
|
|
6
|
+
sampleRate: number;
|
|
7
|
+
}
|
|
8
|
+
export interface LiveSampleBufferControls {
|
|
9
|
+
input: Node<'f32'>;
|
|
10
|
+
/** Accept one frame per tick. False freezes history, including its ages. */
|
|
11
|
+
record: Node<'bool'>;
|
|
12
|
+
/** Level-sensitive logical clear, with priority over record. Not secure erasure. */
|
|
13
|
+
reset: Node<'bool'>;
|
|
14
|
+
}
|
|
15
|
+
export interface LiveSampleBufferRead {
|
|
16
|
+
output: Node<'f32'>;
|
|
17
|
+
/** True only for finite age in [0,length-1], including both interpolation taps. */
|
|
18
|
+
available: Node<'bool'>;
|
|
19
|
+
}
|
|
20
|
+
export interface LiveSampleBufferStatus {
|
|
21
|
+
written: Node<'bool'>;
|
|
22
|
+
full: Node<'bool'>;
|
|
23
|
+
length: Node<'i32'>;
|
|
24
|
+
}
|
|
25
|
+
export interface LiveSampleBuffer {
|
|
26
|
+
readonly capacity: number;
|
|
27
|
+
readonly sampleRate: number;
|
|
28
|
+
tick(controls: LiveSampleBufferControls): LiveSampleBufferStatus;
|
|
29
|
+
/** Age 0 is the latest accepted write at this statement position. Linear
|
|
30
|
+
* interpolation toward older writes; unavailable ages are silent, not wrapped. */
|
|
31
|
+
readAge(ageFrames: Node<'f64'>): LiveSampleBufferRead;
|
|
32
|
+
length(): Node<'i32'>;
|
|
33
|
+
/** Wrapping i32 mutation serial: one increment per accepted write or reset. */
|
|
34
|
+
revision(): Node<'i32'>;
|
|
35
|
+
}
|
|
36
|
+
/** Bounded rolling mono history with explicit accepted-write ages. Intentionally
|
|
37
|
+
* not a ResidentSample: rotating age coordinates must not silently change the
|
|
38
|
+
* meaning of existing sample-player/grain positions. No device or host ingress. */
|
|
39
|
+
export declare const liveSampleBuffer: import("@unworklet/core").SubgraphDecl<[config: LiveSampleBufferConfig], LiveSampleBuffer>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
const PCM_SCALE = 2 ** 128;
|
|
3
|
+
const AGE_SCALE = 2 ** 192;
|
|
4
|
+
const F32_MAX = 3.4028234663852886e38;
|
|
5
|
+
/** Bounded rolling mono history with explicit accepted-write ages. Intentionally
|
|
6
|
+
* not a ResidentSample: rotating age coordinates must not silently change the
|
|
7
|
+
* meaning of existing sample-player/grain positions. No device or host ingress. */
|
|
8
|
+
export const liveSampleBuffer = defineSubgraph((config) => {
|
|
9
|
+
const { capacity, sampleRate } = config;
|
|
10
|
+
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 65536)
|
|
11
|
+
throw new RangeError('live buffer capacity must be an integer in [1,65536]');
|
|
12
|
+
if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000)
|
|
13
|
+
throw new RangeError('sampleRate must be in [8000,192000] Hz');
|
|
14
|
+
// Exact binary scaling preserves every finite f32, including subnormals,
|
|
15
|
+
// across core 0.4.1's 1e-30 scalar-store floor. The extra cell is private.
|
|
16
|
+
const pcm = state.buffer.f64({ size: capacity + 1 }).expose({ name: 'pcm', snapshot: 'persistent' });
|
|
17
|
+
const head = state.i32(0).named('head'), length = state.i32(0).named('length');
|
|
18
|
+
const revision = state.i32(0).named('revision');
|
|
19
|
+
const readAge = state.f64(0).expose({ name: 'readAge', snapshot: 'transient' });
|
|
20
|
+
return {
|
|
21
|
+
capacity, sampleRate,
|
|
22
|
+
tick(c) {
|
|
23
|
+
const written = c.record.and(c.reset.not());
|
|
24
|
+
const finite = c.input.eq(c.input).and(c.input.abs().lte(F32_MAX));
|
|
25
|
+
// select evaluates both branches. Paused/reset ticks touch the sentinel,
|
|
26
|
+
// not exposed history, and no clearing pass runs on reset.
|
|
27
|
+
pcm.write(select(written, head.read(), i32(capacity)), select(written.and(finite), f64(c.input).mul(PCM_SCALE), f64(0)));
|
|
28
|
+
head.write(select(c.reset, i32(0), select(written, head.read().add(1).mod(capacity), head.read())));
|
|
29
|
+
length.write(select(c.reset, i32(0), length.read().add(select(written, i32(1), i32(0))).min(capacity)));
|
|
30
|
+
revision.write(revision.read().add(select(written.or(c.reset), i32(1), i32(0))));
|
|
31
|
+
const current = length.read();
|
|
32
|
+
return { written, full: current.eq(capacity), length: current };
|
|
33
|
+
},
|
|
34
|
+
readAge(ageFrames) {
|
|
35
|
+
const count = length.read();
|
|
36
|
+
const available = ageFrames.gte(0).and(ageFrames.lte(f64(count.sub(1))));
|
|
37
|
+
// Keep invalid ages out of integer conversion and address arithmetic.
|
|
38
|
+
// Scale the cache before its native store so tiny valid fractions still
|
|
39
|
+
// contribute when adjacent samples span the full finite f32 range.
|
|
40
|
+
readAge.write(select(available, ageFrames, f64(0)).mul(AGE_SCALE));
|
|
41
|
+
const age = readAge.read().div(AGE_SCALE), whole = i32(age.floor());
|
|
42
|
+
const fraction = age.sub(f64(whole));
|
|
43
|
+
const older = whole.add(1).min(count.sub(1).max(0));
|
|
44
|
+
const newest = head.read().add(capacity - 1);
|
|
45
|
+
const a = pcm.read(newest.sub(whole).mod(capacity)).div(PCM_SCALE);
|
|
46
|
+
const b = pcm.read(newest.sub(older).mod(capacity)).div(PCM_SCALE);
|
|
47
|
+
return { output: select(available, f32(a.add(b.sub(a).mul(fraction))), f32(0)), available };
|
|
48
|
+
},
|
|
49
|
+
length() { return length.read(); },
|
|
50
|
+
revision() { return revision.read(); },
|
|
51
|
+
};
|
|
52
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
import type { LiveSampleBuffer } from './live-buffer.js';
|
|
3
|
+
export interface LiveGranularConfig {
|
|
4
|
+
/** Shared accepted-write history; its sampleRate must match the processor. */
|
|
5
|
+
history: LiveSampleBuffer;
|
|
6
|
+
/** Immutable first-free pool size, integer 1..8. */
|
|
7
|
+
maxGrains: number;
|
|
8
|
+
}
|
|
9
|
+
export interface LiveGranularControls {
|
|
10
|
+
/** Exactly the immediately preceding history.tick() status.written. */
|
|
11
|
+
written: Node<'bool'>;
|
|
12
|
+
/** The same reset passed to history.tick(); level-sensitive, highest priority. */
|
|
13
|
+
reset: Node<'bool'>;
|
|
14
|
+
/** One request per true sample; held true requests every sample. */
|
|
15
|
+
trigger: Node<'bool'>;
|
|
16
|
+
/** Onset age after the current write, in retained accepted-write frames. */
|
|
17
|
+
ageFrames: Node<'f32'>;
|
|
18
|
+
/** Signed frames toward newer source material per output sample, [-16,16]. */
|
|
19
|
+
rate: Node<'f32'>;
|
|
20
|
+
/** [0,2] seconds, rounded to frames with minimum 3. Zero is valid. */
|
|
21
|
+
durationSeconds: Node<'f32'>;
|
|
22
|
+
}
|
|
23
|
+
export interface LiveGranularResult {
|
|
24
|
+
output: Node<'f32'>;
|
|
25
|
+
/** Valid reads this sample, including both zero-weight window endpoints. */
|
|
26
|
+
activeGrains: Node<'i32'>;
|
|
27
|
+
launched: Node<'bool'>;
|
|
28
|
+
/** Valid request with a full pool; no stealing or queue. */
|
|
29
|
+
dropped: Node<'bool'>;
|
|
30
|
+
/** Invalid controls or unavailable onset. Does not interrupt existing grains. */
|
|
31
|
+
rejected: Node<'bool'>;
|
|
32
|
+
/** Existing grains whose current read became unavailable; excludes reset/EOF. */
|
|
33
|
+
expiredGrains: Node<'i32'>;
|
|
34
|
+
}
|
|
35
|
+
export interface LiveGranularSource {
|
|
36
|
+
tick(controls: LiveGranularControls): LiveGranularResult;
|
|
37
|
+
}
|
|
38
|
+
/** Fixed mono live-history grain reader. Call the writer, then this reader once
|
|
39
|
+
* per sample with the writer's written flag and shared reset. No density clock,
|
|
40
|
+
* source ownership, resampling filter, implicit safety margin or host ingress. */
|
|
41
|
+
export declare const liveGranularSource: import("@unworklet/core").SubgraphDecl<[config: LiveGranularConfig], LiveGranularSource>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
// Preserve small representable scalar values across core 0.4.1's 1e-30
|
|
3
|
+
// state-store floor. This does not extend f64 precision at a large cursor.
|
|
4
|
+
const SCALE = 2 ** 192;
|
|
5
|
+
/** Fixed mono live-history grain reader. Call the writer, then this reader once
|
|
6
|
+
* per sample with the writer's written flag and shared reset. No density clock,
|
|
7
|
+
* source ownership, resampling filter, implicit safety margin or host ingress. */
|
|
8
|
+
export const liveGranularSource = defineSubgraph((config) => {
|
|
9
|
+
const { history, maxGrains } = config;
|
|
10
|
+
if (!Number.isInteger(maxGrains) || maxGrains < 1 || maxGrains > 8)
|
|
11
|
+
throw new RangeError('liveGranularSource maxGrains must be an integer in [1,8]');
|
|
12
|
+
if (!Number.isInteger(history.capacity) || history.capacity < 1 || history.capacity > 65536 || !Number.isFinite(history.sampleRate) || history.sampleRate < 8000 || history.sampleRate > 192000)
|
|
13
|
+
throw new RangeError('liveGranularSource history requires capacity [1,65536] and sampleRate [8000,192000]');
|
|
14
|
+
const allocated = state.bool(false).expose({ name: 'allocated', snapshot: 'transient' });
|
|
15
|
+
const grains = Array.from({ length: maxGrains }, (_, n) => ({
|
|
16
|
+
active: state.bool(false).named(`grain${n}Active`),
|
|
17
|
+
index: state.i32(0).named(`grain${n}Index`),
|
|
18
|
+
duration: state.i32(3).named(`grain${n}Duration`),
|
|
19
|
+
age: state.f64(0).named(`grain${n}AgeScaled`),
|
|
20
|
+
rate: state.f64(0).named(`grain${n}RateScaled`),
|
|
21
|
+
}));
|
|
22
|
+
return { tick(c) {
|
|
23
|
+
const requested = c.trigger.and(c.reset.not());
|
|
24
|
+
const valid = c.ageFrames.gte(0).and(c.ageFrames.lte(history.capacity - 1))
|
|
25
|
+
.and(f64(c.ageFrames).lte(f64(history.length().sub(1))))
|
|
26
|
+
.and(c.rate.gte(-16)).and(c.rate.lte(16))
|
|
27
|
+
.and(c.durationSeconds.gte(0)).and(c.durationSeconds.lte(2));
|
|
28
|
+
const attempt = requested.and(valid);
|
|
29
|
+
// select is eager. Sanitize before conversion even on rejected requests.
|
|
30
|
+
const duration = i32(f64(select(valid, c.durationSeconds, f32(0))).mul(history.sampleRate).add(.5).floor()).clamp(3, Math.round(2 * history.sampleRate));
|
|
31
|
+
const onsetAge = f64(select(valid, c.ageFrames, f32(0))), onsetRate = f64(select(valid, c.rate, f32(0)));
|
|
32
|
+
allocated.write(false);
|
|
33
|
+
let sum = f64(0), count = i32(0), expired = i32(0);
|
|
34
|
+
for (const grain of grains) {
|
|
35
|
+
const activeBefore = grain.active.read().and(c.reset.not());
|
|
36
|
+
// Add the increment as one expression: (tinyAge + 1) - 1 would erase
|
|
37
|
+
// representable tiny onset fractions while continuously writing at rate 1.
|
|
38
|
+
const increment = select(c.written, f64(1), f64(0)).sub(grain.rate.read().div(SCALE));
|
|
39
|
+
const currentAge = grain.age.read().div(SCALE).add(increment);
|
|
40
|
+
const available = currentAge.gte(0).and(currentAge.lte(f64(history.length().sub(1))));
|
|
41
|
+
const existing = activeBefore.and(available);
|
|
42
|
+
expired = expired.add(select(activeBefore.and(available.not()), i32(1), i32(0)));
|
|
43
|
+
const alreadyAllocated = allocated.read();
|
|
44
|
+
const launch = attempt.and(alreadyAllocated.not()).and(existing.not());
|
|
45
|
+
allocated.write(alreadyAllocated.or(launch));
|
|
46
|
+
const active = existing.or(launch);
|
|
47
|
+
grain.index.write(select(c.reset.or(launch), i32(0), grain.index.read()));
|
|
48
|
+
grain.duration.write(select(c.reset, i32(3), select(launch, duration, grain.duration.read())));
|
|
49
|
+
grain.rate.write(select(c.reset, f64(0), select(launch, onsetRate.mul(SCALE), grain.rate.read())));
|
|
50
|
+
grain.age.write(select(active, select(launch, onsetAge, currentAge).mul(SCALE), f64(0)));
|
|
51
|
+
const index = grain.index.read(), length = grain.duration.read();
|
|
52
|
+
const window = f64(1).sub(f64(index).mul(2).div(f64(length.sub(1))).sub(1).abs()).max(0);
|
|
53
|
+
const read = history.readAge(grain.age.read().div(SCALE));
|
|
54
|
+
sum = sum.add(select(active, f64(read.output).mul(window), f64(0)));
|
|
55
|
+
count = count.add(select(active, i32(1), i32(0)));
|
|
56
|
+
grain.index.write(select(active, index.add(1), i32(0)));
|
|
57
|
+
// Natural completion is recorded now, before next sample's age validity.
|
|
58
|
+
grain.active.write(active.and(index.add(1).lt(length)));
|
|
59
|
+
}
|
|
60
|
+
const launched = allocated.read();
|
|
61
|
+
return { output: f32(sum.div(maxGrains)), activeGrains: count, launched,
|
|
62
|
+
dropped: attempt.and(launched.not()), rejected: requested.and(valid.not()), expiredGrains: expired };
|
|
63
|
+
} };
|
|
64
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface LookaheadLimiterConfig {
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
/** Fixed integer latency/allocation, 0..2048 samples. No implicit default. */
|
|
5
|
+
lookaheadSamples: number;
|
|
6
|
+
}
|
|
7
|
+
export interface LookaheadLimiterControls {
|
|
8
|
+
/** Current-output sample ceiling in dBFS, clamped to [-120,0]; nonfinite -> 0. */
|
|
9
|
+
ceilingDb: Node<'f32'>;
|
|
10
|
+
/** Peak-detector release, 0..30 seconds; nonfinite -> 0. Attack is immediate. */
|
|
11
|
+
release: Node<'f32'>;
|
|
12
|
+
/** Invalidate all history before accepting this sample. */
|
|
13
|
+
reset: Node<'bool'>;
|
|
14
|
+
}
|
|
15
|
+
/** Bounded stereo-linked sample-peak limiter. Not a true-peak limiter. */
|
|
16
|
+
export declare const lookaheadLimiter: import("@unworklet/core").SubgraphDecl<[config: LookaheadLimiterConfig], {
|
|
17
|
+
latencySamples: number;
|
|
18
|
+
latencySeconds: number;
|
|
19
|
+
/** Exactly once per sample. Dry outputs have the same fixed latency. */
|
|
20
|
+
tick(left: Node<"f32">, right: Node<"f32">, controls: LookaheadLimiterControls): {
|
|
21
|
+
left: Node<"f32">;
|
|
22
|
+
right: Node<"f32">;
|
|
23
|
+
dryLeft: Node<"f32">;
|
|
24
|
+
dryRight: Node<"f32">;
|
|
25
|
+
envelope: Node<"f32">;
|
|
26
|
+
windowPeak: Node<"f32">;
|
|
27
|
+
ceiling: Node<"f32">;
|
|
28
|
+
/** Diagnostic only: this f32 conversion may underflow for extreme audio. */
|
|
29
|
+
gain: Node<"f32">;
|
|
30
|
+
};
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
|
|
2
|
+
import { envelopeFollower } from './dynamics.js';
|
|
3
|
+
const SCALE = 2 ** 128;
|
|
4
|
+
function finite(input) {
|
|
5
|
+
return select(input.eq(input).and(input.abs().lte(3.4028234663852886e38)), input, f32(0));
|
|
6
|
+
}
|
|
7
|
+
/** Bounded stereo-linked sample-peak limiter. Not a true-peak limiter. */
|
|
8
|
+
export const lookaheadLimiter = defineSubgraph((config) => {
|
|
9
|
+
const { sampleRate, lookaheadSamples: delay } = config;
|
|
10
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) {
|
|
11
|
+
throw new RangeError('lookaheadLimiter sampleRate must be an integer in [8000,192000]');
|
|
12
|
+
}
|
|
13
|
+
if (!Number.isInteger(delay) || delay < 0 || delay > 2048) {
|
|
14
|
+
throw new RangeError('lookaheadLimiter lookaheadSamples must be an integer in [0,2048]');
|
|
15
|
+
}
|
|
16
|
+
const size = delay + 1, capacity = 2 ** Math.ceil(Math.log2(size));
|
|
17
|
+
// Native buffer writes flush magnitudes below 1e-30. Exact binary scaling
|
|
18
|
+
// preserves every finite f32 value, including subnormals, without overflow.
|
|
19
|
+
const leftHistory = state.buffer.f64({ size }).expose({ name: 'leftScaled', snapshot: 'persistent' });
|
|
20
|
+
const rightHistory = state.buffer.f64({ size }).expose({ name: 'rightScaled', snapshot: 'persistent' });
|
|
21
|
+
const peaks = state.buffer.f64({ size: capacity * 2 }).expose({ name: 'peakTreeScaled', snapshot: 'persistent' });
|
|
22
|
+
const cursor = state.i32(0).named('cursor'), valid = state.i32(0).named('valid');
|
|
23
|
+
const follower = instantiate(envelopeFollower, { sampleRate, mode: 'peak' }, { name: 'detector' });
|
|
24
|
+
return {
|
|
25
|
+
latencySamples: delay,
|
|
26
|
+
latencySeconds: delay / sampleRate,
|
|
27
|
+
/** Exactly once per sample. Dry outputs have the same fixed latency. */
|
|
28
|
+
tick(left, right, controls) {
|
|
29
|
+
const write = select(controls.reset, i32(0), cursor.read());
|
|
30
|
+
const available = select(controls.reset, i32(0), valid.read()).add(1).min(size);
|
|
31
|
+
const l = f64(finite(left)).mul(SCALE), r = f64(finite(right)).mul(SCALE);
|
|
32
|
+
leftHistory.write(write, l);
|
|
33
|
+
rightHistory.write(write, r);
|
|
34
|
+
peaks.write(write.add(capacity), l.abs().max(r.abs()));
|
|
35
|
+
// At reset the valid leaves form an initially empty prefix. Sequential
|
|
36
|
+
// refill ensures each included sibling was rebuilt since that reset.
|
|
37
|
+
// Other siblings (including permanently unused leaves) are read as zero.
|
|
38
|
+
// Once full, all real leaves stay valid across arbitrary ring wraps.
|
|
39
|
+
for (let width = 2; width <= capacity; width *= 2) {
|
|
40
|
+
const block = write.div(width), first = block.mul(width), node = block.add(capacity / width);
|
|
41
|
+
const low = select(first.lt(available), peaks.read(node.mul(2)), f64(0));
|
|
42
|
+
const high = select(first.add(width / 2).lt(available), peaks.read(node.mul(2).add(1)), f64(0));
|
|
43
|
+
peaks.write(node, low.max(high));
|
|
44
|
+
}
|
|
45
|
+
const windowPeak = f32(peaks.read(1).div(SCALE));
|
|
46
|
+
const envelope = follower.tick(windowPeak, { attack: f32(0), release: controls.release, reset: controls.reset });
|
|
47
|
+
const ceiling = finite(controls.ceilingDb).clamp(-120, 0).mul(Math.LN10 / 20).exp();
|
|
48
|
+
const gain = select(envelope.gt(0), f64(ceiling).div(select(envelope.gt(0), f64(envelope), f64(1))).min(1), f64(1));
|
|
49
|
+
const read = write.add(1).mod(size);
|
|
50
|
+
const ready = available.eq(size);
|
|
51
|
+
const dryLeft = select(ready, leftHistory.read(read).div(SCALE), f64(0));
|
|
52
|
+
const dryRight = select(ready, rightHistory.read(read).div(SCALE), f64(0));
|
|
53
|
+
cursor.write(read);
|
|
54
|
+
valid.write(available);
|
|
55
|
+
return {
|
|
56
|
+
// Keep the gain f64 through multiplication: full-range f32 input at a
|
|
57
|
+
// -120dB ceiling would otherwise quantize its tiny gain to a few ulps.
|
|
58
|
+
left: f32(dryLeft.mul(gain)).clamp(ceiling.neg(), ceiling),
|
|
59
|
+
right: f32(dryRight.mul(gain)).clamp(ceiling.neg(), ceiling),
|
|
60
|
+
dryLeft: f32(dryLeft), dryRight: f32(dryRight),
|
|
61
|
+
envelope, windowPeak, ceiling,
|
|
62
|
+
/** Diagnostic only: this f32 conversion may underflow for extreme audio. */
|
|
63
|
+
gain: f32(gain),
|
|
64
|
+
};
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
import type { ResidentSample, SamplePlayerControls } from './sample.js';
|
|
3
|
+
export interface CrossfadedLoopConfig {
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
sample: ResidentSample;
|
|
6
|
+
/** Fixed source slice [startFrame,endFrame), intersected with loaded length. */
|
|
7
|
+
startFrame?: number;
|
|
8
|
+
endFrame?: number;
|
|
9
|
+
/** Source-frame overlap, 0..floor(slice length/2). Effective period is L-F,
|
|
10
|
+
* and forward playback starts at startFrame+F. Zero disables the overlap. */
|
|
11
|
+
crossfadeFrames: number;
|
|
12
|
+
/** Linear gate-off ramp in output samples; zero/one stops on first off sample. */
|
|
13
|
+
releaseFrames?: number;
|
|
14
|
+
}
|
|
15
|
+
/** One mono cyclic waveform with a unity-sum linear tail/head overlap. Shortens
|
|
16
|
+
* the source period from L to L-F; reverse traverses that same waveform backward.
|
|
17
|
+
* Not a dry-attack, time-stretching, antialiasing or arbitrary-content seam promise. */
|
|
18
|
+
export declare const crossfadedLoopPlayer: import("@unworklet/core").SubgraphDecl<[config: CrossfadedLoopConfig], {
|
|
19
|
+
tick(c: SamplePlayerControls): {
|
|
20
|
+
output: Node<"f32">;
|
|
21
|
+
active: Node<"bool">;
|
|
22
|
+
position: Node<"f32">;
|
|
23
|
+
phase: Node<"f32">;
|
|
24
|
+
periodFrames: Node<"i32">;
|
|
25
|
+
crossfadeFrames: Node<"i32">;
|
|
26
|
+
missing: Node<"bool">;
|
|
27
|
+
};
|
|
28
|
+
}>;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
const PHASE_SCALE = 2 ** 128;
|
|
3
|
+
function integer(value, low, high, name) {
|
|
4
|
+
if (!Number.isInteger(value) || value < low || value > high)
|
|
5
|
+
throw new RangeError(`${name} must be an integer in [${low},${high}]`);
|
|
6
|
+
}
|
|
7
|
+
// Preserve the local fraction before adding an integer slice/head offset. An
|
|
8
|
+
// absolute position addition would erase tiny phase * full-finite-PCM products.
|
|
9
|
+
// Resident reads retain the existing ingress, bounds and per-tap sanitization.
|
|
10
|
+
function localRead(sample, local, start, end, loop) {
|
|
11
|
+
const whole = i32(local.floor()), fraction = local.sub(f64(whole));
|
|
12
|
+
const first = start.add(whole).min(end.sub(1).max(start));
|
|
13
|
+
const next = select(first.add(1).gte(end), select(loop, start, first), first.add(1));
|
|
14
|
+
const a = sample.read(f64(first), start, end, false), b = sample.read(f64(next), start, end, false);
|
|
15
|
+
return f32(f64(a).mul(f64(1).sub(fraction)).add(f64(b).mul(fraction)));
|
|
16
|
+
}
|
|
17
|
+
/** One mono cyclic waveform with a unity-sum linear tail/head overlap. Shortens
|
|
18
|
+
* the source period from L to L-F; reverse traverses that same waveform backward.
|
|
19
|
+
* Not a dry-attack, time-stretching, antialiasing or arbitrary-content seam promise. */
|
|
20
|
+
export const crossfadedLoopPlayer = defineSubgraph((config) => {
|
|
21
|
+
if (!Number.isFinite(config.sampleRate) || config.sampleRate < 8000 || config.sampleRate > 192000)
|
|
22
|
+
throw new RangeError('sampleRate must be in [8000,192000] Hz');
|
|
23
|
+
const { sample, startFrame = 0, endFrame = sample.capacity, crossfadeFrames, releaseFrames = 0 } = config;
|
|
24
|
+
integer(startFrame, 0, sample.capacity - 1, 'startFrame');
|
|
25
|
+
integer(endFrame, startFrame + 1, sample.capacity, 'endFrame');
|
|
26
|
+
integer(crossfadeFrames, 0, Math.floor((endFrame - startFrame) / 2), 'crossfadeFrames');
|
|
27
|
+
integer(releaseFrames, 0, Math.ceil(config.sampleRate * 10), 'releaseFrames');
|
|
28
|
+
const phase = state.f64(0).named('scaledPhase'), active = state.bool(false).named('active');
|
|
29
|
+
const previousGate = state.bool(false).named('previousGate'), revision = state.i32(-1).named('revision');
|
|
30
|
+
const remaining = state.i32(0).named('releaseRemaining');
|
|
31
|
+
// Transient materialization keeps repeated resident read graphs bounded. Only
|
|
32
|
+
// persistent phase/gate/release/revision state is required across snapshots.
|
|
33
|
+
const current = state.f64(0).expose({ name: 'scaledCurrentPhase', snapshot: 'transient' });
|
|
34
|
+
const speedState = state.f64(0).expose({ name: 'scaledSpeed', snapshot: 'transient' });
|
|
35
|
+
const endState = state.i32(0).expose({ name: 'end', snapshot: 'transient' });
|
|
36
|
+
const overlapState = state.i32(0).expose({ name: 'overlap', snapshot: 'transient' });
|
|
37
|
+
const periodState = state.i32(0).expose({ name: 'period', snapshot: 'transient' });
|
|
38
|
+
return { tick(c) {
|
|
39
|
+
endState.write(sample.length().min(endFrame));
|
|
40
|
+
const end = endState.read(), start = i32(startFrame), length = end.sub(start).max(0), available = length.gt(0);
|
|
41
|
+
overlapState.write(length.div(2).min(crossfadeFrames));
|
|
42
|
+
periodState.write(length.sub(overlapState.read()));
|
|
43
|
+
const overlap = overlapState.read(), period = periodState.read();
|
|
44
|
+
speedState.write(select(c.rate.eq(c.rate), f64(c.rate), f64(0)).clamp(-16, 16).mul(sample.sourceSampleRate / config.sampleRate).mul(PHASE_SCALE));
|
|
45
|
+
const speed = speedState.read().div(PHASE_SCALE), changed = sample.revision().eq(revision.read()).not();
|
|
46
|
+
// A load is NOT a gate rise: evaluate against the actual prior gate first.
|
|
47
|
+
const trigger = c.trigger.or(c.gate.and(previousGate.read().not())).and(c.gate).and(c.reset.not()).and(available);
|
|
48
|
+
const alive = select(trigger, bool(true), active.read().and(changed.not())).and(c.reset.not()).and(available);
|
|
49
|
+
const initial = select(speed.lt(0), f64(period.sub(1).max(0)), f64(0));
|
|
50
|
+
current.write(select(trigger, initial, select(c.reset.or(changed).or(available.not()), f64(0), phase.read().div(PHASE_SCALE))).mul(PHASE_SCALE));
|
|
51
|
+
const q = current.read().div(PHASE_SCALE);
|
|
52
|
+
const releaseLeft = select(trigger, i32(releaseFrames), select(c.gate, remaining.read(), remaining.read().sub(1).max(0)));
|
|
53
|
+
const gain = releaseFrames > 0 ? f64(releaseLeft).div(releaseFrames) : select(c.gate, f64(1), f64(0));
|
|
54
|
+
active.write(alive.and(gain.gt(0)));
|
|
55
|
+
const playing = active.read(), primaryStart = start.add(overlap);
|
|
56
|
+
const tail = localRead(sample, q, primaryStart, end, overlap.eq(0));
|
|
57
|
+
const headPhase = q.sub(f64(period.sub(overlap))).max(0);
|
|
58
|
+
const head = localRead(sample, headPhase, start, end, bool(false));
|
|
59
|
+
const weight = select(overlap.gt(0), headPhase.div(f64(overlap.max(1))), f64(0));
|
|
60
|
+
const mixed = f32(f64(tail).mul(f64(1).sub(weight)).add(f64(head).mul(weight)));
|
|
61
|
+
const output = select(playing, f32(f64(mixed).mul(gain)), f32(0));
|
|
62
|
+
const advanced = q.add(speed), divisor = f64(period.max(1));
|
|
63
|
+
const wrapped = advanced.sub(advanced.div(divisor).floor().mul(divisor));
|
|
64
|
+
// Negative tiny increments can round a remainder to the upper endpoint.
|
|
65
|
+
const next = select(wrapped.gte(divisor), f64(0), wrapped.max(0));
|
|
66
|
+
phase.write(select(playing, next, q).mul(PHASE_SCALE));
|
|
67
|
+
remaining.write(select(playing, releaseLeft, i32(0)));
|
|
68
|
+
previousGate.write(c.gate.and(c.reset.not()));
|
|
69
|
+
revision.write(sample.revision());
|
|
70
|
+
return { output, active: playing, position: f32(f64(primaryStart).add(q)), phase: f32(q), periodFrames: period, crossfadeFrames: overlap, missing: available.not() };
|
|
71
|
+
} };
|
|
72
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface FlangerConfig {
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
/** Fixed capacity in seconds, [one sample, 0.02]. Default 0.02. */
|
|
5
|
+
maxDelaySeconds?: number;
|
|
6
|
+
/** Right LFO phase relative to left, in cycles. Default 0.5. */
|
|
7
|
+
stereoPhaseCycles?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FlangerControls {
|
|
10
|
+
delaySeconds: Node<'f32'>;
|
|
11
|
+
depthSeconds: Node<'f32'>;
|
|
12
|
+
rateHz: Node<'f32'>;
|
|
13
|
+
/** Positive feedback, clamped to [0, 0.95]. */
|
|
14
|
+
feedback: Node<'f32'>;
|
|
15
|
+
mix: Node<'f32'>;
|
|
16
|
+
bypass: Node<'bool'>;
|
|
17
|
+
reset: Node<'bool'>;
|
|
18
|
+
}
|
|
19
|
+
/** Stereo moving-head comb/flanger. Call once per sample; finite inputs required. */
|
|
20
|
+
export declare const flanger: import("@unworklet/core").SubgraphDecl<[config: FlangerConfig], {
|
|
21
|
+
tick(left: Node<"f32">, right: Node<"f32">, c: FlangerControls): {
|
|
22
|
+
left: Node<"f32">;
|
|
23
|
+
right: Node<"f32">;
|
|
24
|
+
timingRejected: Node<"bool">;
|
|
25
|
+
modulationClipped: Node<"bool">;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export interface PhaserConfig {
|
|
29
|
+
sampleRate: number;
|
|
30
|
+
/** Fixed number of normalized first-order allpasses. Default 4. */
|
|
31
|
+
stages?: 2 | 4 | 6 | 8;
|
|
32
|
+
}
|
|
33
|
+
export interface PhaserControls {
|
|
34
|
+
/** Per-stage -90 degree frequency. Clamped to [20, 0.45 * sampleRate]. */
|
|
35
|
+
frequencyHz: Node<'f32'>;
|
|
36
|
+
/** One-sample-delayed wet feedback, clamped to [-0.95, 0.95]. */
|
|
37
|
+
feedback: Node<'f32'>;
|
|
38
|
+
mix: Node<'f32'>;
|
|
39
|
+
bypass: Node<'bool'>;
|
|
40
|
+
reset: Node<'bool'>;
|
|
41
|
+
}
|
|
42
|
+
/** Mono allpass phaser, not a delay preset. Compose two instances for stereo.
|
|
43
|
+
* Normalized lattice scattering preserves per-sample energy under cutoff motion.
|
|
44
|
+
*/
|
|
45
|
+
export declare const phaser: import("@unworklet/core").SubgraphDecl<[config: PhaserConfig], {
|
|
46
|
+
tick(input: Node<"f32">, c: PhaserControls): Node<"f32">;
|
|
47
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, instantiate, select, sqrt, state } from '@unworklet/core';
|
|
2
|
+
import { delayFx } from './delay-fx.js';
|
|
3
|
+
/** Stereo moving-head comb/flanger. Call once per sample; finite inputs required. */
|
|
4
|
+
export const flanger = defineSubgraph((config) => {
|
|
5
|
+
const maxDelaySeconds = config.maxDelaySeconds ?? 0.02;
|
|
6
|
+
if (!Number.isFinite(maxDelaySeconds) || maxDelaySeconds < 1 / config.sampleRate || maxDelaySeconds > 0.02) {
|
|
7
|
+
throw new RangeError('flanger capacity must be between one sample and 0.02 seconds');
|
|
8
|
+
}
|
|
9
|
+
const engine = instantiate(delayFx, { ...config, maxDelaySeconds, tone: 'flat' }, { name: 'delay' });
|
|
10
|
+
return {
|
|
11
|
+
tick(left, right, c) {
|
|
12
|
+
return engine.tick(left, right, {
|
|
13
|
+
timeLeftSeconds: c.delaySeconds, timeRightSeconds: c.delaySeconds,
|
|
14
|
+
sync: f32(0).gt(1), bpm: f32(120), beatsLeft: f32(1), beatsRight: f32(1),
|
|
15
|
+
feedback: c.feedback, cutoffHz: f32(1000), mix: c.mix,
|
|
16
|
+
rateHz: c.rateHz, depthSeconds: c.depthSeconds,
|
|
17
|
+
bypass: c.bypass, reset: c.reset,
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
/** Mono allpass phaser, not a delay preset. Compose two instances for stereo.
|
|
23
|
+
* Normalized lattice scattering preserves per-sample energy under cutoff motion.
|
|
24
|
+
*/
|
|
25
|
+
export const phaser = defineSubgraph((config) => {
|
|
26
|
+
const { sampleRate, stages = 4 } = config;
|
|
27
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000 || ![2, 4, 6, 8].includes(stages)) {
|
|
28
|
+
throw new RangeError('phaser requires an integer sample rate in [8000,192000] and 2/4/6/8 stages');
|
|
29
|
+
}
|
|
30
|
+
const history = Array.from({ length: stages }, (_, i) => state.f64(0).named(`stage${i}`));
|
|
31
|
+
const signal = state.f64(0).expose({ name: 'signal', snapshot: 'transient' });
|
|
32
|
+
const coefficient = state.f64(0).expose({ name: 'coefficient', snapshot: 'transient' });
|
|
33
|
+
const coupling = state.f64(0).expose({ name: 'coupling', snapshot: 'transient' });
|
|
34
|
+
const feedback = state.f64(0).named('feedback');
|
|
35
|
+
return {
|
|
36
|
+
tick(input, c) {
|
|
37
|
+
const angle = f64(c.frequencyHz).clamp(20, 0.45 * sampleRate).mul(Math.PI / sampleRate);
|
|
38
|
+
const z = angle.mul(angle);
|
|
39
|
+
// Degree 13/14 sine/cosine on [0, .45*pi], as in the existing SVF.
|
|
40
|
+
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));
|
|
41
|
+
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);
|
|
42
|
+
coefficient.write(sine.sub(cosine).div(sine.add(cosine)).clamp(-0.999999, 0.999999));
|
|
43
|
+
const a = coefficient.read();
|
|
44
|
+
coupling.write(sqrt(f64(1).sub(a.mul(a)).clamp(0, 1)));
|
|
45
|
+
const b = coupling.read();
|
|
46
|
+
signal.write(f64(select(c.bypass, f32(0), input)).add(select(c.reset, f64(0), feedback.read()).mul(f64(c.feedback).clamp(-0.95, 0.95))));
|
|
47
|
+
for (const slot of history) {
|
|
48
|
+
const x = signal.read(), previous = select(c.reset, f64(0), slot.read());
|
|
49
|
+
signal.write(a.mul(x).add(b.mul(previous)));
|
|
50
|
+
slot.write(b.mul(x).sub(a.mul(previous)));
|
|
51
|
+
}
|
|
52
|
+
const wet = signal.read();
|
|
53
|
+
feedback.write(wet);
|
|
54
|
+
const mix = f64(c.mix).clamp(0, 1);
|
|
55
|
+
return select(c.bypass, input, f32(f64(input).mul(f64(1).sub(mix)).add(wet.mul(mix))));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|