@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/sample.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
|
|
2
|
+
// Native floating scalar stores flush magnitudes below 1e-30. Exact binary
|
|
3
|
+
// scaling preserves meaningful subnormal control/position contributions.
|
|
4
|
+
const CONTROL_SCALE = 2 ** 128;
|
|
5
|
+
const READ_POSITION_SCALE = 2 ** 192;
|
|
6
|
+
function sampleRate(rate) {
|
|
7
|
+
if (!Number.isFinite(rate) || rate < 8000 || rate > 192000)
|
|
8
|
+
throw new RangeError('sample rate must be in [8000,192000] Hz');
|
|
9
|
+
}
|
|
10
|
+
function integer(value, minimum, maximum, name) {
|
|
11
|
+
if (!Number.isInteger(value) || value < minimum || value > maximum)
|
|
12
|
+
throw new RangeError(`${name} must be an integer in [${minimum},${maximum}]`);
|
|
13
|
+
}
|
|
14
|
+
function bounded(value, minimum, maximum, fallback = 0) {
|
|
15
|
+
return select(value.eq(value), f64(value), f64(fallback)).clamp(minimum, maximum);
|
|
16
|
+
}
|
|
17
|
+
function wrap(position, start, end) {
|
|
18
|
+
const length = f64(end.sub(start).max(1));
|
|
19
|
+
const relative = position.sub(f64(start));
|
|
20
|
+
return relative.sub(relative.div(length).floor().mul(length)).add(f64(start));
|
|
21
|
+
}
|
|
22
|
+
/** A fixed-capacity mono sample, initially missing. No asset loader or streaming. */
|
|
23
|
+
export const residentSample = defineSubgraph((config) => {
|
|
24
|
+
integer(config.capacity, 1, 65536, 'sample capacity');
|
|
25
|
+
sampleRate(config.sourceSampleRate);
|
|
26
|
+
const pcm = state.buffer.f32({ size: config.capacity }).expose({ name: 'pcm', snapshot: 'persistent' });
|
|
27
|
+
const length = state.i32(0).named('length'), revision = state.i32(0).named('revision');
|
|
28
|
+
// A native transient scalar captures normalization once per read, including
|
|
29
|
+
// when several independent readers share this resident in the same sample.
|
|
30
|
+
const readPosition = state.f64(0).expose({ name: 'readPosition', snapshot: 'transient' });
|
|
31
|
+
return {
|
|
32
|
+
capacity: config.capacity, sourceSampleRate: config.sourceSampleRate,
|
|
33
|
+
load(data) { pcm.copyFrom(data); length.write(data.length.clamp(0, config.capacity)); revision.write(revision.read().add(1)); },
|
|
34
|
+
length() { return length.read(); }, revision() { return revision.read(); },
|
|
35
|
+
read(position, start, end, loop) {
|
|
36
|
+
const lo = start.clamp(0, config.capacity - 1), hi = end.clamp(0, config.capacity).min(length.read());
|
|
37
|
+
const valid = hi.gt(lo);
|
|
38
|
+
const safePosition = select(position.eq(position), position, f64(lo)).clamp(-2147483648, 2147483647);
|
|
39
|
+
readPosition.write((loop ? wrap(safePosition, lo, hi) : safePosition.clamp(f64(lo), f64(hi.sub(1).max(lo)))).mul(READ_POSITION_SCALE));
|
|
40
|
+
const p = readPosition.read().div(READ_POSITION_SCALE);
|
|
41
|
+
const whole = i32(p.floor()).clamp(0, config.capacity - 1), fraction = p.sub(f64(whole));
|
|
42
|
+
const next = loop ? select(whole.add(1).gte(hi), lo, whole.add(1)) : whole.add(1).min(hi.sub(1).max(lo));
|
|
43
|
+
const rawA = f64(pcm.read(whole)), rawB = f64(pcm.read(next.clamp(0, config.capacity - 1)));
|
|
44
|
+
// Invalid PCM taps are silent, without poisoning adjacent finite samples/state.
|
|
45
|
+
const a = select(rawA.eq(rawA).and(rawA.abs().lte(3.4028234663852886e38)), rawA, f64(0));
|
|
46
|
+
const b = select(rawB.eq(rawB).and(rawB.abs().lte(3.4028234663852886e38)), rawB, f64(0));
|
|
47
|
+
return select(valid, f32(a.add(b.sub(a).mul(fraction))), f32(0));
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
/** Mono, one voice, one-shot or loop; nearest endpoints with linear interpolation. */
|
|
52
|
+
export const samplePlayer = defineSubgraph((config) => {
|
|
53
|
+
sampleRate(config.sampleRate);
|
|
54
|
+
const { sample, startFrame = 0, endFrame = sample.capacity, loop = false, releaseFrames = 0 } = config;
|
|
55
|
+
integer(startFrame, 0, sample.capacity - 1, 'startFrame');
|
|
56
|
+
integer(endFrame, startFrame + 1, sample.capacity, 'endFrame');
|
|
57
|
+
integer(releaseFrames, 0, Math.ceil(config.sampleRate * 10), 'releaseFrames');
|
|
58
|
+
const phase = state.f64(startFrame * CONTROL_SCALE).named('phase'), active = state.bool(false).named('active');
|
|
59
|
+
const previousGate = state.bool(false).named('previousGate'), revision = state.i32(-1).named('revision');
|
|
60
|
+
const remaining = state.i32(0).named('releaseRemaining');
|
|
61
|
+
return {
|
|
62
|
+
tick(c) {
|
|
63
|
+
const end = sample.length().min(endFrame), start = i32(startFrame), available = end.gt(start);
|
|
64
|
+
const speed = bounded(c.rate, -16, 16).mul(sample.sourceSampleRate / config.sampleRate);
|
|
65
|
+
const changed = sample.revision().eq(revision.read()).not();
|
|
66
|
+
const trigger = c.trigger.or(c.gate.and(previousGate.read().not())).and(c.gate).and(c.reset.not()).and(available);
|
|
67
|
+
const alive = select(trigger, bool(true), active.read().and(changed.not())).and(c.reset.not()).and(available);
|
|
68
|
+
const initial = select(speed.lt(0), f64(end.sub(1)), f64(start));
|
|
69
|
+
phase.write(select(trigger, initial, phase.read().div(CONTROL_SCALE)).mul(CONTROL_SCALE));
|
|
70
|
+
const position = phase.read().div(CONTROL_SCALE);
|
|
71
|
+
const releaseLeft = select(trigger, i32(releaseFrames), select(c.gate, remaining.read(), remaining.read().sub(1).max(0)));
|
|
72
|
+
const level = releaseFrames > 0 ? f64(releaseLeft).div(releaseFrames) : select(c.gate, f64(1), f64(0));
|
|
73
|
+
active.write(alive.and(level.gt(0)));
|
|
74
|
+
const playing = active.read();
|
|
75
|
+
const output = select(playing, f32(f64(sample.read(position, start, end, loop)).mul(level)), f32(0));
|
|
76
|
+
const advanced = position.add(speed);
|
|
77
|
+
phase.write(select(c.reset, f64(startFrame), loop ? wrap(advanced, start, end) : advanced.clamp(f64(start).sub(1), f64(end))).mul(CONTROL_SCALE));
|
|
78
|
+
active.write(playing.and(loop ? bool(true) : advanced.gte(f64(start)).and(advanced.lt(f64(end)))));
|
|
79
|
+
remaining.write(select(playing, releaseLeft, i32(0)));
|
|
80
|
+
previousGate.write(c.gate.and(c.reset.not()));
|
|
81
|
+
revision.write(sample.revision());
|
|
82
|
+
return { output, active: playing, position: f32(position), missing: available.not() };
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
// Core 0.4.1 pow uses a shared f32 approximation even for f64 operands.
|
|
87
|
+
// Reduce the bounded pitch exponent to [0,1), evaluate exp(x*ln2) in f64,
|
|
88
|
+
// and scale by exact powers of two. This avoids pitch drift from that bridge.
|
|
89
|
+
function transposeRatio(exponent) {
|
|
90
|
+
const whole = exponent.floor(), x = exponent.sub(whole).mul(Math.LN2);
|
|
91
|
+
let factorial = 1;
|
|
92
|
+
const coefficients = [1];
|
|
93
|
+
for (let n = 1; n <= 12; n++) {
|
|
94
|
+
factorial *= n;
|
|
95
|
+
coefficients.push(1 / factorial);
|
|
96
|
+
}
|
|
97
|
+
let polynomial = f64(coefficients[12]);
|
|
98
|
+
for (let n = 11; n >= 0; n--)
|
|
99
|
+
polynomial = polynomial.mul(x).add(coefficients[n]);
|
|
100
|
+
const index = i32(whole.add(13));
|
|
101
|
+
let scale = f64(2 ** -13);
|
|
102
|
+
for (let bit = 0; bit < 5; bit++)
|
|
103
|
+
scale = scale.mul(select(index.div(2 ** bit).mod(2).eq(1), f64(2 ** (2 ** bit)), f64(1)));
|
|
104
|
+
return polynomial.mul(scale);
|
|
105
|
+
}
|
|
106
|
+
/** Monophonic key/velocity selection; zone/key/velocity latch on note-on. */
|
|
107
|
+
export const multisamplePlayer = defineSubgraph((config) => {
|
|
108
|
+
sampleRate(config.sampleRate);
|
|
109
|
+
integer(config.zones.length, 1, 16, 'zone count');
|
|
110
|
+
const zones = config.zones.map((zone, index) => {
|
|
111
|
+
integer(zone.keyLow, 0, 127, 'keyLow');
|
|
112
|
+
integer(zone.keyHigh, zone.keyLow, 127, 'keyHigh');
|
|
113
|
+
integer(zone.rootKey, 0, 127, 'rootKey');
|
|
114
|
+
const { velocityLow: low, velocityHigh: high, tuneCents = 0, gain = 1 } = zone;
|
|
115
|
+
if (!Number.isFinite(low) || !Number.isFinite(high) || low < 0 || high > 1 || high < low || !Number.isFinite(tuneCents) || Math.abs(tuneCents) > 2400 || !Number.isFinite(gain) || Math.abs(gain) > 4)
|
|
116
|
+
throw new RangeError('invalid velocity zone, tuning or gain');
|
|
117
|
+
return { ...zone, tuneCents, gain, transposition: state.f64(1).named(`zone${index}Transposition`), player: instantiate(samplePlayer, { ...zone, sampleRate: config.sampleRate, releaseFrames: config.releaseFrames }, { name: `zone${index}` }) };
|
|
118
|
+
});
|
|
119
|
+
const selected = state.i32(-1).named('selected'), lastGate = state.bool(false).named('previousGate');
|
|
120
|
+
const keyState = state.f64(60 * CONTROL_SCALE).named('key'), velocityState = state.f64(0).named('velocity');
|
|
121
|
+
const noteOn = state.bool(false).named('noteOn');
|
|
122
|
+
return { tick(c) {
|
|
123
|
+
const key = bounded(c.key, 0, 127, 60), velocity = bounded(c.velocity, 0, 1);
|
|
124
|
+
noteOn.write(c.trigger.or(c.gate.and(lastGate.read().not())).and(c.gate).and(c.reset.not()));
|
|
125
|
+
const start = noteOn.read();
|
|
126
|
+
let match = i32(-1);
|
|
127
|
+
for (let index = zones.length - 1; index >= 0; index--) {
|
|
128
|
+
const z = zones[index];
|
|
129
|
+
match = select(key.gte(z.keyLow).and(key.lte(z.keyHigh)).and(velocity.gte(z.velocityLow)).and(velocity.lte(z.velocityHigh)), i32(index), match);
|
|
130
|
+
}
|
|
131
|
+
selected.write(select(c.reset, i32(-1), select(start, match, selected.read())));
|
|
132
|
+
const current = selected.read();
|
|
133
|
+
keyState.write(select(start, key.mul(CONTROL_SCALE), keyState.read()));
|
|
134
|
+
velocityState.write(select(start, velocity.mul(CONTROL_SCALE), velocityState.read()));
|
|
135
|
+
// Native state reads materialize latched controls before the unrolled bank.
|
|
136
|
+
const note = keyState.read().div(CONTROL_SCALE), level = velocityState.read().div(CONTROL_SCALE);
|
|
137
|
+
let sum = f64(0), active = bool(false), missing = current.lt(0);
|
|
138
|
+
zones.forEach((z, index) => {
|
|
139
|
+
const chosen = current.eq(index);
|
|
140
|
+
// Materialize the latched ratio once. Repeating the polynomial graph inside
|
|
141
|
+
// every readhead expression causes excessive capture/compiler expansion.
|
|
142
|
+
z.transposition.write(select(start, transposeRatio(note.sub(z.rootKey).div(12).add(z.tuneCents / 1200)), z.transposition.read()));
|
|
143
|
+
const ratio = z.transposition.read().mul(bounded(c.rate, -16, 16));
|
|
144
|
+
const result = z.player.tick({ gate: c.gate.and(chosen), trigger: start.and(chosen), reset: c.reset.or(start.and(chosen.not())), rate: f32(ratio) });
|
|
145
|
+
sum = sum.add(f64(result.output).mul(level).mul(z.gain));
|
|
146
|
+
active = active.or(result.active);
|
|
147
|
+
missing = missing.or(chosen.and(result.missing));
|
|
148
|
+
});
|
|
149
|
+
lastGate.write(c.gate.and(c.reset.not()));
|
|
150
|
+
return { output: f32(sum), active, zoneIndex: current, missing };
|
|
151
|
+
} };
|
|
152
|
+
});
|
|
153
|
+
/** Fixed grain pool, triangular endpoint-zero windows; first free slot, drop on full. */
|
|
154
|
+
export const granularSource = defineSubgraph((config) => {
|
|
155
|
+
sampleRate(config.sampleRate);
|
|
156
|
+
integer(config.maxGrains, 1, 32, 'maxGrains');
|
|
157
|
+
const { sample, maxGrains, seed = 1, loop = true } = config;
|
|
158
|
+
integer(seed, 1, 2147483646, 'grain seed');
|
|
159
|
+
const random = state.i32(seed).named('random'), clock = state.f64(0).named('clock');
|
|
160
|
+
const allocatedState = state.bool(false).named('allocated'), onsetState = state.bool(false).named('onset');
|
|
161
|
+
const onsetPosition = state.f64(0).named('onsetPosition'), onsetSpeed = state.f64(0).named('onsetSpeed');
|
|
162
|
+
const onsetDuration = state.i32(3).named('onsetDuration');
|
|
163
|
+
const lastGate = state.bool(false).named('previousGate'), revision = state.i32(-1).named('revision');
|
|
164
|
+
const grains = Array.from({ length: maxGrains }, (_, index) => ({
|
|
165
|
+
position: state.f64(0).named(`grain${index}Position`), age: state.i32(0).named(`grain${index}Age`),
|
|
166
|
+
duration: state.i32(3).named(`grain${index}Duration`), speed: state.f64(0).named(`grain${index}Speed`),
|
|
167
|
+
active: state.bool(false).named(`grain${index}Active`),
|
|
168
|
+
}));
|
|
169
|
+
return { tick(c) {
|
|
170
|
+
const changed = sample.revision().eq(revision.read()).not(), clear = c.reset.or(changed);
|
|
171
|
+
const rising = c.gate.and(lastGate.read().not());
|
|
172
|
+
const phase = select(clear.or(rising), f64(0), clock.read().div(CONTROL_SCALE));
|
|
173
|
+
onsetState.write(c.gate.and(c.reset.not()).and(sample.length().gt(0)).and(rising.or(phase.gte(1))));
|
|
174
|
+
const onset = onsetState.read();
|
|
175
|
+
const rng = select(c.reset, i32(seed), random.read());
|
|
176
|
+
const nextRandom = i32(f64(rng).mul(48271).mod(2147483647));
|
|
177
|
+
random.write(select(onset, nextRandom, rng));
|
|
178
|
+
const jitter = f64(random.read()).div(2147483647).mul(2).sub(1).mul(bounded(c.jitterFrames, 0, sample.capacity - 1));
|
|
179
|
+
onsetPosition.write(bounded(c.positionFrames, 0, sample.capacity - 1).add(jitter).clamp(0, f64(sample.length().sub(1).max(0))).mul(CONTROL_SCALE));
|
|
180
|
+
onsetDuration.write(i32(bounded(c.durationSeconds, 0, 2).mul(config.sampleRate).add(0.5).floor()).clamp(3, Math.round(config.sampleRate * 2)));
|
|
181
|
+
onsetSpeed.write(bounded(c.rate, -16, 16).mul(sample.sourceSampleRate / config.sampleRate).mul(CONTROL_SCALE));
|
|
182
|
+
const center = onsetPosition.read().div(CONTROL_SCALE), duration = onsetDuration.read(), speed = onsetSpeed.read().div(CONTROL_SCALE);
|
|
183
|
+
allocatedState.write(false);
|
|
184
|
+
let sum = f64(0), count = i32(0);
|
|
185
|
+
for (const grain of grains) {
|
|
186
|
+
const existing = grain.active.read().and(grain.age.read().lt(grain.duration.read())).and(clear.not());
|
|
187
|
+
// Keep the first-free scan linear in graph size as well as runtime work.
|
|
188
|
+
const allocated = allocatedState.read();
|
|
189
|
+
const launch = onset.and(allocated.not()).and(existing.not());
|
|
190
|
+
allocatedState.write(allocated.or(launch));
|
|
191
|
+
grain.active.write(existing.or(launch));
|
|
192
|
+
grain.age.write(select(launch, i32(0), grain.age.read()));
|
|
193
|
+
grain.duration.write(select(launch, duration, grain.duration.read()));
|
|
194
|
+
grain.position.write(select(launch, center.mul(CONTROL_SCALE), grain.position.read()));
|
|
195
|
+
grain.speed.write(select(launch, speed.mul(CONTROL_SCALE), grain.speed.read()));
|
|
196
|
+
const active = grain.active.read(), age = grain.age.read(), length = grain.duration.read();
|
|
197
|
+
const position = grain.position.read().div(CONTROL_SCALE), step = grain.speed.read().div(CONTROL_SCALE);
|
|
198
|
+
const window = f64(1).sub(f64(age).div(f64(length.sub(1))).mul(2).sub(1).abs()).max(0);
|
|
199
|
+
const inBounds = position.gte(0).and(position.lt(f64(sample.length())));
|
|
200
|
+
const value = sample.read(position, i32(0), sample.length(), loop);
|
|
201
|
+
sum = sum.add(select(active.and(loop ? bool(true) : inBounds), f64(value).mul(window), f64(0)));
|
|
202
|
+
count = count.add(select(active, i32(1), i32(0)));
|
|
203
|
+
grain.position.write((loop ? wrap(position.add(step), i32(0), sample.length()) : position.add(step).clamp(-sample.capacity * 48, sample.capacity * 48)).mul(CONTROL_SCALE));
|
|
204
|
+
grain.age.write(select(active, age.add(1), i32(0)));
|
|
205
|
+
grain.active.write(active.and(age.add(1).lt(length)));
|
|
206
|
+
}
|
|
207
|
+
clock.write(select(c.gate.and(c.reset.not()), phase.sub(phase.floor()).add(bounded(c.densityHz, 0, 2000).div(config.sampleRate)), f64(0)).mul(CONTROL_SCALE));
|
|
208
|
+
lastGate.write(c.gate.and(c.reset.not()));
|
|
209
|
+
revision.write(sample.revision());
|
|
210
|
+
return { output: f32(sum.div(maxGrains)), activeGrains: count, onset, dropped: onset.and(allocatedState.read().not()), missing: sample.length().eq(0) };
|
|
211
|
+
} };
|
|
212
|
+
});
|
package/dist/source.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface SourceConfig {
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
}
|
|
5
|
+
export interface PhaseModulationControls {
|
|
6
|
+
carrierHz: Node<'f32'>;
|
|
7
|
+
modulatorHz: Node<'f32'>;
|
|
8
|
+
/** Signed phase deviation, in radians, clamped to [-8π,8π]. */
|
|
9
|
+
depthRadians: Node<'f32'>;
|
|
10
|
+
/** Previous carrier output (one sample old) times this value, in radians. */
|
|
11
|
+
feedbackRadians: Node<'f32'>;
|
|
12
|
+
reset: Node<'bool'>;
|
|
13
|
+
}
|
|
14
|
+
export interface FrequencyModulationControls {
|
|
15
|
+
carrierHz: Node<'f32'>;
|
|
16
|
+
modulatorHz: Node<'f32'>;
|
|
17
|
+
/** Signed instantaneous frequency deviation in Hz, not a PM index. */
|
|
18
|
+
deviationHz: Node<'f32'>;
|
|
19
|
+
/** Previous carrier output (one sample old) times this value, in Hz. */
|
|
20
|
+
feedbackHz: Node<'f32'>;
|
|
21
|
+
reset: Node<'bool'>;
|
|
22
|
+
}
|
|
23
|
+
/** Two-operator PM with a sine modulator and one-sample carrier feedback. */
|
|
24
|
+
export declare const phaseModulation: import("@unworklet/core").SubgraphDecl<[config: SourceConfig], {
|
|
25
|
+
/** Current phase is emitted before advance. Held reset emits zero every sample. */
|
|
26
|
+
tick(c: PhaseModulationControls): Node<"f32">;
|
|
27
|
+
}>;
|
|
28
|
+
/** Two-operator linear FM. Negative instantaneous frequency clips to zero. */
|
|
29
|
+
export declare const frequencyModulation: import("@unworklet/core").SubgraphDecl<[config: SourceConfig], {
|
|
30
|
+
tick(c: FrequencyModulationControls): Node<"f32">;
|
|
31
|
+
}>;
|
|
32
|
+
export interface AdditivePartial {
|
|
33
|
+
ratio: number;
|
|
34
|
+
gain: number;
|
|
35
|
+
}
|
|
36
|
+
export interface AdditiveSourceConfig extends SourceConfig {
|
|
37
|
+
partials: readonly AdditivePartial[];
|
|
38
|
+
}
|
|
39
|
+
/** Fixed sine partial bank. Gains are normalized by their absolute sum. */
|
|
40
|
+
export declare const additiveSource: import("@unworklet/core").SubgraphDecl<[config: AdditiveSourceConfig], {
|
|
41
|
+
/** Partials above .45*sampleRate are muted; crossing the limit is not smoothed. */
|
|
42
|
+
tick(frequencyHz: Node<"f32">, reset: Node<"bool">): Node<"f32">;
|
|
43
|
+
}>;
|
|
44
|
+
export interface UnisonVoice {
|
|
45
|
+
detuneCents: number;
|
|
46
|
+
pan: number;
|
|
47
|
+
}
|
|
48
|
+
export interface UnisonSourceConfig extends SourceConfig {
|
|
49
|
+
waveform: 'sine' | 'saw';
|
|
50
|
+
voices: readonly UnisonVoice[];
|
|
51
|
+
}
|
|
52
|
+
/** Fixed detune bank. Linear pan divided by voice count; L+R is the mono average. */
|
|
53
|
+
export declare const unisonSource: import("@unworklet/core").SubgraphDecl<[config: UnisonSourceConfig], {
|
|
54
|
+
tick(frequencyHz: Node<"f32">, reset: Node<"bool">): {
|
|
55
|
+
left: Node<"f32">;
|
|
56
|
+
right: Node<"f32">;
|
|
57
|
+
};
|
|
58
|
+
}>;
|
package/dist/source.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, instantiate, select, state } from '@unworklet/core';
|
|
2
|
+
import { oscillator } from './oscillator.js';
|
|
3
|
+
function maximumFrequency(sampleRate) {
|
|
4
|
+
if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000) {
|
|
5
|
+
throw new RangeError('source sampleRate must be finite and in [8000,192000] Hz');
|
|
6
|
+
}
|
|
7
|
+
return 0.45 * sampleRate;
|
|
8
|
+
}
|
|
9
|
+
// Same degree-13 sine construction as oscillator, with a wrapped phase offset.
|
|
10
|
+
// Pure graph arithmetic; no runtime JS, table asset, or additional state system.
|
|
11
|
+
function sineCycles(cycles) {
|
|
12
|
+
const p = cycles.sub(cycles.floor());
|
|
13
|
+
const folded = select(p.lt(0.25), p, select(p.lt(0.75), f64(0.5).sub(p), p.sub(1)));
|
|
14
|
+
const angle = folded.mul(2 * Math.PI), z = angle.mul(angle);
|
|
15
|
+
const polynomial = f64(1 / 6227020800).mul(z).sub(1 / 39916800).mul(z).add(1 / 362880)
|
|
16
|
+
.mul(z).sub(1 / 5040).mul(z).add(1 / 120).mul(z).sub(1 / 6).mul(z).add(1);
|
|
17
|
+
return f32(angle.mul(polynomial));
|
|
18
|
+
}
|
|
19
|
+
/** Two-operator PM with a sine modulator and one-sample carrier feedback. */
|
|
20
|
+
export const phaseModulation = defineSubgraph((config) => {
|
|
21
|
+
const maximum = maximumFrequency(config.sampleRate);
|
|
22
|
+
const modulator = instantiate(oscillator, { ...config, waveform: 'sine' }, { name: 'modulator' });
|
|
23
|
+
const phase = state.f64(0).named('carrierPhase');
|
|
24
|
+
const previous = state.f32(0).named('previousCarrier');
|
|
25
|
+
return {
|
|
26
|
+
/** Current phase is emitted before advance. Held reset emits zero every sample. */
|
|
27
|
+
tick(c) {
|
|
28
|
+
const p = select(c.reset, f64(0), phase.read());
|
|
29
|
+
const prior = select(c.reset, f32(0), previous.read());
|
|
30
|
+
const mod = modulator.tick(c.modulatorHz, c.reset);
|
|
31
|
+
const offset = f64(c.depthRadians).clamp(-8 * Math.PI, 8 * Math.PI).mul(f64(mod))
|
|
32
|
+
.add(f64(c.feedbackRadians).clamp(-Math.PI, Math.PI).mul(f64(prior))).div(2 * Math.PI);
|
|
33
|
+
const output = sineCycles(p.add(offset));
|
|
34
|
+
phase.write(p.add(f64(c.carrierHz).clamp(0, maximum).div(config.sampleRate)).frac());
|
|
35
|
+
previous.write(output);
|
|
36
|
+
return output;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
/** Two-operator linear FM. Negative instantaneous frequency clips to zero. */
|
|
41
|
+
export const frequencyModulation = defineSubgraph((config) => {
|
|
42
|
+
const maximum = maximumFrequency(config.sampleRate);
|
|
43
|
+
const modulator = instantiate(oscillator, { ...config, waveform: 'sine' }, { name: 'modulator' });
|
|
44
|
+
const phase = state.f64(0).named('carrierPhase');
|
|
45
|
+
const previous = state.f32(0).named('previousCarrier');
|
|
46
|
+
return {
|
|
47
|
+
tick(c) {
|
|
48
|
+
const p = select(c.reset, f64(0), phase.read());
|
|
49
|
+
const prior = select(c.reset, f32(0), previous.read());
|
|
50
|
+
const mod = modulator.tick(c.modulatorHz, c.reset);
|
|
51
|
+
const instantaneous = f64(c.carrierHz).clamp(0, maximum)
|
|
52
|
+
.add(f64(c.deviationHz).clamp(-maximum, maximum).mul(f64(mod)))
|
|
53
|
+
.add(f64(c.feedbackHz).clamp(-maximum, maximum).mul(f64(prior))).clamp(0, maximum);
|
|
54
|
+
const output = sineCycles(p);
|
|
55
|
+
phase.write(p.add(instantaneous.div(config.sampleRate)).frac());
|
|
56
|
+
previous.write(output);
|
|
57
|
+
return output;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
/** Fixed sine partial bank. Gains are normalized by their absolute sum. */
|
|
62
|
+
export const additiveSource = defineSubgraph((config) => {
|
|
63
|
+
const maximum = maximumFrequency(config.sampleRate);
|
|
64
|
+
if (config.partials.length < 1 || config.partials.length > 32)
|
|
65
|
+
throw new RangeError('additiveSource requires 1–32 partials');
|
|
66
|
+
const partials = config.partials.map((p, i) => {
|
|
67
|
+
if (!Number.isFinite(p.ratio) || p.ratio <= 0 || p.ratio > 128 || !Number.isFinite(p.gain) || Math.abs(p.gain) > 16) {
|
|
68
|
+
throw new RangeError('partial ratio must be in (0,128] and gain in [-16,16]');
|
|
69
|
+
}
|
|
70
|
+
return { ...p, source: instantiate(oscillator, { sampleRate: config.sampleRate, waveform: 'sine' }, { name: `partial${i}` }) };
|
|
71
|
+
});
|
|
72
|
+
const normalization = partials.reduce((sum, p) => sum + Math.abs(p.gain), 0) || 1;
|
|
73
|
+
return {
|
|
74
|
+
/** Partials above .45*sampleRate are muted; crossing the limit is not smoothed. */
|
|
75
|
+
tick(frequencyHz, reset) {
|
|
76
|
+
let sum = f64(0);
|
|
77
|
+
for (const p of partials) {
|
|
78
|
+
const frequency = f64(frequencyHz).clamp(0, maximum).mul(p.ratio);
|
|
79
|
+
const sample = p.source.tick(f32(frequency), reset);
|
|
80
|
+
sum = sum.add(f64(select(frequency.lte(maximum), sample, f32(0))).mul(p.gain / normalization));
|
|
81
|
+
}
|
|
82
|
+
return f32(sum);
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
/** Fixed detune bank. Linear pan divided by voice count; L+R is the mono average. */
|
|
87
|
+
export const unisonSource = defineSubgraph((config) => {
|
|
88
|
+
const maximum = maximumFrequency(config.sampleRate);
|
|
89
|
+
if (config.voices.length < 1 || config.voices.length > 8)
|
|
90
|
+
throw new RangeError('unisonSource requires 1–8 voices');
|
|
91
|
+
if (config.waveform !== 'sine' && config.waveform !== 'saw')
|
|
92
|
+
throw new RangeError('unknown unison waveform');
|
|
93
|
+
const voices = config.voices.map((v, i) => {
|
|
94
|
+
if (!Number.isFinite(v.detuneCents) || Math.abs(v.detuneCents) > 1200 || !Number.isFinite(v.pan) || Math.abs(v.pan) > 1) {
|
|
95
|
+
throw new RangeError('unison detune must be in [-1200,1200] cents and pan in [-1,1]');
|
|
96
|
+
}
|
|
97
|
+
return { ratio: 2 ** (v.detuneCents / 1200), left: (1 - v.pan) / (2 * config.voices.length),
|
|
98
|
+
right: (1 + v.pan) / (2 * config.voices.length),
|
|
99
|
+
source: instantiate(oscillator, { sampleRate: config.sampleRate, waveform: config.waveform }, { name: `voice${i}` }) };
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
tick(frequencyHz, reset) {
|
|
103
|
+
let left = f64(0), right = f64(0);
|
|
104
|
+
for (const voice of voices) {
|
|
105
|
+
const frequency = f64(frequencyHz).clamp(0, maximum).mul(voice.ratio).clamp(0, maximum);
|
|
106
|
+
const sample = f64(voice.source.tick(f32(frequency), reset));
|
|
107
|
+
left = left.add(sample.mul(voice.left));
|
|
108
|
+
right = right.add(sample.mul(voice.right));
|
|
109
|
+
}
|
|
110
|
+
return { left: f32(left), right: f32(right) };
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type EveryNSamples, type Node } from '@unworklet/core';
|
|
2
|
+
export interface SpatialChainConfig {
|
|
3
|
+
/** Match the enclosing ctx.sampleRate; integer 8000..192000. */
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
}
|
|
6
|
+
export interface SpatialChainControls {
|
|
7
|
+
/** Finite linear dry/wet, clamped to [0,1]. Zero still feeds the wet paths. */
|
|
8
|
+
mix: Node<'f32'>;
|
|
9
|
+
/** Unity dry, no new wet excitation; all existing tails keep processing. */
|
|
10
|
+
bypass: Node<'bool'>;
|
|
11
|
+
/** Highest priority: silence, discard current input and invalidate history. */
|
|
12
|
+
reset: Node<'bool'>;
|
|
13
|
+
}
|
|
14
|
+
export interface FeedforwardPitchedReverbConfig extends SpatialChainConfig {
|
|
15
|
+
/** Fixed pitch delay excursion: even integer 32..16384, default 2048. */
|
|
16
|
+
windowSamples?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface FeedforwardPitchedReverbControls extends SpatialChainControls {
|
|
19
|
+
/** Reader speed [.5,2]. Invalid values retain the prior ratio, or 1 on reset. */
|
|
20
|
+
ratio: Node<'f32'>;
|
|
21
|
+
/** Finite unpitched/pitched late blend, clamped [0,1]. Readers always advance. */
|
|
22
|
+
pitchMix: Node<'f32'>;
|
|
23
|
+
/** Rephase the current pitch read while retaining history. Can click. */
|
|
24
|
+
retrigger: Node<'bool'>;
|
|
25
|
+
}
|
|
26
|
+
/** CANDIDATE mono-in/stereo-out short FIR + three early taps || damped FDN.
|
|
27
|
+
* Finite normalized input [-1,1]; no limiter. Dry is immediate, early has B8
|
|
28
|
+
* latency plus 7/13/23ms taps, and late begins near 29.7ms with an IIR tail.
|
|
29
|
+
* Call once per sample in stride-1 forSample with its native everyNSamples.
|
|
30
|
+
*/
|
|
31
|
+
export declare const hybridReverb: import("@unworklet/core").SubgraphDecl<[config: SpatialChainConfig], {
|
|
32
|
+
tick(input: Node<"f32">, c: SpatialChainControls, everyNSamples: EveryNSamples): {
|
|
33
|
+
left: Node<"f32">;
|
|
34
|
+
right: Node<"f32">;
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
/** Same hybrid space with pitch applied only to a feedforward copy of its FDN
|
|
38
|
+
* output. No pitched signal enters the FDN or early path. This is
|
|
39
|
+
* not regenerative shimmer. Pitch adds variable 1..W+1-sample read ages, with
|
|
40
|
+
* interpolation aliasing/window coloration; parallel blending can cancel.
|
|
41
|
+
*/
|
|
42
|
+
export declare const feedforwardPitchedReverb: import("@unworklet/core").SubgraphDecl<[config: FeedforwardPitchedReverbConfig], {
|
|
43
|
+
tick(input: Node<"f32">, c: FeedforwardPitchedReverbControls, everyNSamples: EveryNSamples): {
|
|
44
|
+
left: Node<"f32">;
|
|
45
|
+
right: Node<"f32">;
|
|
46
|
+
ratioRejected: Node<"bool">;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, f64, instantiate, select } from '@unworklet/core';
|
|
2
|
+
import { partitionedConvolution } from './convolution.js';
|
|
3
|
+
import { multiTapDelay } from './stereo-delay.js';
|
|
4
|
+
import { algorithmicReverb } from './reverb.js';
|
|
5
|
+
import { windowedPitchShift } from './windowed-pitch-shift.js';
|
|
6
|
+
// Construction-only composition. Each existing module retains its own state,
|
|
7
|
+
// scheduler and native snapshot contract; no new runtime routing is introduced.
|
|
8
|
+
function spatialPaths({ sampleRate }) {
|
|
9
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) {
|
|
10
|
+
throw new RangeError('spatial chains require integer sampleRate [8000,192000]');
|
|
11
|
+
}
|
|
12
|
+
const color = instantiate(partitionedConvolution, {
|
|
13
|
+
blockSize: 8, impulse: [.625, 0, 0, .25, 0, 0, 0, -.125],
|
|
14
|
+
}, { name: 'earlyColor' });
|
|
15
|
+
const reflections = instantiate(multiTapDelay, {
|
|
16
|
+
sampleRate, maxDelaySeconds: .023,
|
|
17
|
+
taps: [
|
|
18
|
+
{ delaySeconds: .007, gainLeft: .5, gainRight: .25 },
|
|
19
|
+
{ delaySeconds: .013, gainLeft: .25, gainRight: -.25 },
|
|
20
|
+
{ delaySeconds: .023, gainLeft: -.125, gainRight: .375 },
|
|
21
|
+
],
|
|
22
|
+
}, { name: 'earlyTaps' });
|
|
23
|
+
const late = instantiate(algorithmicReverb, {
|
|
24
|
+
sampleRate, roomScale: 1, decaySeconds: .6, dampingHz: Math.min(3500, .4 * sampleRate),
|
|
25
|
+
}, { name: 'late' });
|
|
26
|
+
return {
|
|
27
|
+
tick(input, c, everyNSamples) {
|
|
28
|
+
const excitation = select(c.reset.or(c.bypass), f32(0), input);
|
|
29
|
+
const early = reflections.tick(color.tick(excitation, c.reset, everyNSamples), {
|
|
30
|
+
feedback: f32(0), mix: f32(1), bypass: bool(false), reset: c.reset,
|
|
31
|
+
});
|
|
32
|
+
const tail = late.tick(excitation, f32(0), { mix: f32(1), bypass: bool(false), reset: c.reset });
|
|
33
|
+
return { early, tail };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function blend(input, early, late, c) {
|
|
38
|
+
const mix = f64(c.mix).clamp(0, 1), wet = f64(early).add(late).mul(.5);
|
|
39
|
+
return select(c.reset, f32(0), select(c.bypass, input, f32(f64(input).mul(f64(1).sub(mix)).add(wet.mul(mix)))));
|
|
40
|
+
}
|
|
41
|
+
/** CANDIDATE mono-in/stereo-out short FIR + three early taps || damped FDN.
|
|
42
|
+
* Finite normalized input [-1,1]; no limiter. Dry is immediate, early has B8
|
|
43
|
+
* latency plus 7/13/23ms taps, and late begins near 29.7ms with an IIR tail.
|
|
44
|
+
* Call once per sample in stride-1 forSample with its native everyNSamples.
|
|
45
|
+
*/
|
|
46
|
+
export const hybridReverb = defineSubgraph((config) => {
|
|
47
|
+
const paths = spatialPaths(config);
|
|
48
|
+
return {
|
|
49
|
+
tick(input, c, everyNSamples) {
|
|
50
|
+
const { early, tail } = paths.tick(input, c, everyNSamples);
|
|
51
|
+
return { left: blend(input, early.left, f64(tail.left), c), right: blend(input, early.right, f64(tail.right), c) };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
/** Same hybrid space with pitch applied only to a feedforward copy of its FDN
|
|
56
|
+
* output. No pitched signal enters the FDN or early path. This is
|
|
57
|
+
* not regenerative shimmer. Pitch adds variable 1..W+1-sample read ages, with
|
|
58
|
+
* interpolation aliasing/window coloration; parallel blending can cancel.
|
|
59
|
+
*/
|
|
60
|
+
export const feedforwardPitchedReverb = defineSubgraph((config) => {
|
|
61
|
+
const paths = spatialPaths(config);
|
|
62
|
+
const left = instantiate(windowedPitchShift, config, { name: 'pitchLeft' });
|
|
63
|
+
const right = instantiate(windowedPitchShift, config, { name: 'pitchRight' });
|
|
64
|
+
return {
|
|
65
|
+
tick(input, c, everyNSamples) {
|
|
66
|
+
const { early, tail } = paths.tick(input, c, everyNSamples);
|
|
67
|
+
const l = left.tick(tail.left, c), r = right.tick(tail.right, c);
|
|
68
|
+
const mix = f64(c.pitchMix).clamp(0, 1), dry = f64(1).sub(mix);
|
|
69
|
+
return {
|
|
70
|
+
left: blend(input, early.left, f64(tail.left).mul(dry).add(f64(l.output).mul(mix)), c),
|
|
71
|
+
right: blend(input, early.right, f64(tail.right).mul(dry).add(f64(r.output).mul(mix)), c),
|
|
72
|
+
ratioRejected: l.ratioRejected,
|
|
73
|
+
};
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
/** Internal fixed-capacity buffer iteration, using only public native loops.
|
|
3
|
+
* No audio/parameter indexing or sample-clock advancement belongs in this body.
|
|
4
|
+
*/
|
|
5
|
+
export declare function spectralForEach(size: number, body: (index: Node<'i32'>) => void): void;
|
|
6
|
+
/** Internal construction-fixed FFT. Not a separate runtime or public package API. */
|
|
7
|
+
export declare const spectralFft: import("@unworklet/core").SubgraphDecl<[config: {
|
|
8
|
+
size: number;
|
|
9
|
+
}], {
|
|
10
|
+
/** JS functions/loops run during graph capture. Butterfly iteration uses the
|
|
11
|
+
* public fixed-128 native forSample loop over work-buffer indices, nested
|
|
12
|
+
* inside the caller's hop body. It performs no audio I/O or audio-time update.
|
|
13
|
+
* Inputs must not alias these work buffers.
|
|
14
|
+
* Result aliases scratch and must be consumed before the next transform.
|
|
15
|
+
* Forward: sum x[n] exp(-i*2*pi*k*n/N). Inverse: positive sign and 1/N.
|
|
16
|
+
*/
|
|
17
|
+
transform(real: (index: Node<"i32">) => Node<"f64">, imag: (index: Node<"i32">) => Node<"f64">, inverse: boolean): {
|
|
18
|
+
real: (index: number | Node<"i32">) => Node<"f64">;
|
|
19
|
+
imag: (index: number | Node<"i32">) => Node<"f64">;
|
|
20
|
+
};
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { SAMPLES_PER_BLOCK, defineSubgraph, f64, forSample, i32, select, state } from '@unworklet/core';
|
|
2
|
+
/** Internal fixed-capacity buffer iteration, using only public native loops.
|
|
3
|
+
* No audio/parameter indexing or sample-clock advancement belongs in this body.
|
|
4
|
+
*/
|
|
5
|
+
export function spectralForEach(size, body) {
|
|
6
|
+
if (SAMPLES_PER_BLOCK !== 128 || size < 4 || size > 1024 || !Number.isInteger(Math.log2(size))) {
|
|
7
|
+
throw new RangeError('spectral buffer iteration requires fixed-128 loops and power-of-two size [4,1024]');
|
|
8
|
+
}
|
|
9
|
+
const stride = Math.max(1, 128 / size);
|
|
10
|
+
for (let chunk = 0; chunk < Math.max(1, size / 128); chunk++)
|
|
11
|
+
forSample.byN(stride, inner => body(inner.div(stride).add(chunk * 128)));
|
|
12
|
+
}
|
|
13
|
+
/** Internal construction-fixed FFT. Not a separate runtime or public package API. */
|
|
14
|
+
export const spectralFft = defineSubgraph((config) => {
|
|
15
|
+
const { size } = config;
|
|
16
|
+
if (SAMPLES_PER_BLOCK !== 128)
|
|
17
|
+
throw new RangeError('spectral FFT requires the public unworklet fixed-128 loop contract');
|
|
18
|
+
if (!Number.isInteger(size) || size < 8 || size > 1024 || !Number.isInteger(Math.log2(size))) {
|
|
19
|
+
throw new RangeError('spectral FFT size must be a power of two in [8,1024]');
|
|
20
|
+
}
|
|
21
|
+
const stages = Math.log2(size);
|
|
22
|
+
const banks = [0, 1].map(bank => ({
|
|
23
|
+
real: state.buffer.f64({ size }).expose({ name: `real${bank}`, snapshot: 'transient' }),
|
|
24
|
+
imag: state.buffer.f64({ size }).expose({ name: `imag${bank}`, snapshot: 'transient' }),
|
|
25
|
+
}));
|
|
26
|
+
const twiddleReal = state.f64(1).expose({ name: 'twiddleReal', snapshot: 'transient' });
|
|
27
|
+
const twiddleImag = state.f64(0).expose({ name: 'twiddleImag', snapshot: 'transient' });
|
|
28
|
+
const reverse = (index) => {
|
|
29
|
+
let result = i32(0);
|
|
30
|
+
for (let bit = 0; bit < stages; bit++)
|
|
31
|
+
result = result.add(index.div(2 ** bit).mod(2).mul(2 ** (stages - 1 - bit)));
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
/** JS functions/loops run during graph capture. Butterfly iteration uses the
|
|
36
|
+
* public fixed-128 native forSample loop over work-buffer indices, nested
|
|
37
|
+
* inside the caller's hop body. It performs no audio I/O or audio-time update.
|
|
38
|
+
* Inputs must not alias these work buffers.
|
|
39
|
+
* Result aliases scratch and must be consumed before the next transform.
|
|
40
|
+
* Forward: sum x[n] exp(-i*2*pi*k*n/N). Inverse: positive sign and 1/N.
|
|
41
|
+
*/
|
|
42
|
+
transform(real, imag, inverse) {
|
|
43
|
+
spectralForEach(size, index => {
|
|
44
|
+
banks[0].real.write(reverse(index), real(index));
|
|
45
|
+
banks[0].imag.write(reverse(index), imag(index));
|
|
46
|
+
});
|
|
47
|
+
for (let stage = 0; stage < stages; stage++) {
|
|
48
|
+
const width = 2 ** (stage + 1), half = width / 2;
|
|
49
|
+
const source = banks[stage % 2], target = banks[(stage + 1) % 2];
|
|
50
|
+
const angle = (inverse ? 1 : -1) * 2 * Math.PI / width;
|
|
51
|
+
const cosineStep = Math.cos(angle), sineStep = Math.sin(angle);
|
|
52
|
+
spectralForEach(size / 2, index => {
|
|
53
|
+
const j = index.mod(half);
|
|
54
|
+
const a = index.div(half).mul(width).add(j), b = a.add(half);
|
|
55
|
+
// Each butterfly group starts at exactly (1,0); a bounded recurrence
|
|
56
|
+
// advances its twiddle without a table loader or runtime trig calls.
|
|
57
|
+
const cosine = select(j.eq(0), f64(1), twiddleReal.read());
|
|
58
|
+
const sine = select(j.eq(0), f64(0), twiddleImag.read());
|
|
59
|
+
const ar = source.real.read(a), ai = source.imag.read(a);
|
|
60
|
+
const br = source.real.read(b), bi = source.imag.read(b);
|
|
61
|
+
const tr = br.mul(cosine).sub(bi.mul(sine)), ti = br.mul(sine).add(bi.mul(cosine));
|
|
62
|
+
target.real.write(a, ar.add(tr));
|
|
63
|
+
target.imag.write(a, ai.add(ti));
|
|
64
|
+
target.real.write(b, ar.sub(tr));
|
|
65
|
+
target.imag.write(b, ai.sub(ti));
|
|
66
|
+
twiddleReal.write(cosine.mul(cosineStep).sub(sine.mul(sineStep)));
|
|
67
|
+
twiddleImag.write(cosine.mul(sineStep).add(sine.mul(cosineStep)));
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const result = banks[stages % 2], scale = inverse ? 1 / size : 1;
|
|
71
|
+
return { real: (index) => result.real.read(index).mul(scale), imag: (index) => result.imag.read(index).mul(scale) };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type EveryNSamples, type Node } from '@unworklet/core';
|
|
2
|
+
export interface SpectralFreezeConfig {
|
|
3
|
+
/** Construction-fixed power of two in [8,1024]. Reference alignment is size samples. */
|
|
4
|
+
size: number;
|
|
5
|
+
/** Exactly size/2 or size/4. Rebuild to change. */
|
|
6
|
+
hopSize: number;
|
|
7
|
+
}
|
|
8
|
+
/** Bin-centered spectral freeze, periodic sqrt-Hann WOLA. Offline CANDIDATE.
|
|
9
|
+
* Call once per sample inside stride-1 forSample with its everyNSamples.
|
|
10
|
+
* Finite normalized f32 input [-1,1]. Output is not limited/normalized.
|
|
11
|
+
* At committed t=mH, a high freeze captures [t-N,t) if not already held.
|
|
12
|
+
* Held bins advance by +2*pi*k*H/N per frame (no frequency estimation).
|
|
13
|
+
* A low freeze releases at that frame; existing overlap settles within N.
|
|
14
|
+
* Input/history continue while frozen. Controls between frames are ignored.
|
|
15
|
+
* Reset immediately silences/discards input and invalidates the hold, without
|
|
16
|
+
* rephasing. High freeze recaptures at the next non-reset committed frame.
|
|
17
|
+
* No finite drain while held. With freeze low, drain 2N zero input samples
|
|
18
|
+
* after the later of source end and release request. No realtime guarantee.
|
|
19
|
+
*/
|
|
20
|
+
export declare const spectralFreeze: import("@unworklet/core").SubgraphDecl<[config: SpectralFreezeConfig], {
|
|
21
|
+
tick(input: Node<"f32">, freeze: Node<"bool">, reset: Node<"bool">, everyNSamples: EveryNSamples): Node<"f32">;
|
|
22
|
+
}>;
|