@denaudio/den 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +102 -2
- package/dist/biquad-eq.d.ts +11 -0
- package/dist/biquad-eq.js +35 -0
- package/dist/catalog-filter-math.d.ts +4 -0
- package/dist/catalog-filter-math.js +30 -0
- package/dist/character-filter.d.ts +13 -0
- package/dist/character-filter.js +53 -0
- package/dist/convolution-spectrum.d.ts +25 -0
- package/dist/convolution-spectrum.js +82 -0
- package/dist/convolution.d.ts +15 -0
- package/dist/convolution.js +81 -0
- package/dist/crossover.d.ts +11 -0
- package/dist/crossover.js +17 -0
- package/dist/curve-shaper.d.ts +26 -0
- package/dist/curve-shaper.js +73 -0
- package/dist/delay-fx.d.ts +37 -0
- package/dist/delay-fx.js +84 -0
- package/dist/delay-readhead.d.ts +19 -0
- package/dist/delay-readhead.js +54 -0
- package/dist/delay-settings.d.ts +55 -0
- package/dist/delay-settings.js +20 -0
- package/dist/drive.d.ts +24 -0
- package/dist/drive.js +116 -0
- package/dist/dual-head-delay.d.ts +28 -0
- package/dist/dual-head-delay.js +77 -0
- package/dist/dynamics.d.ts +61 -0
- package/dist/dynamics.js +130 -0
- package/dist/envelope.d.ts +34 -0
- package/dist/envelope.js +62 -0
- package/dist/filter.d.ts +9 -0
- package/dist/filter.js +34 -0
- package/dist/filterbank-vocoder.d.ts +35 -0
- package/dist/filterbank-vocoder.js +52 -0
- package/dist/formant-bank.d.ts +15 -0
- package/dist/formant-bank.js +28 -0
- package/dist/freeze-reverb.d.ts +35 -0
- package/dist/freeze-reverb.js +61 -0
- package/dist/frequency-shifter.d.ts +24 -0
- package/dist/frequency-shifter.js +57 -0
- package/dist/gate.d.ts +2 -0
- package/dist/gate.js +12 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +12 -0
- package/dist/instrument-example.d.ts +12 -0
- package/dist/instrument-example.js +29 -0
- package/dist/instrument-settings.d.ts +95 -0
- package/dist/instrument-settings.js +36 -0
- package/dist/instrument.d.ts +42 -0
- package/dist/instrument.js +166 -0
- package/dist/lfo.d.ts +19 -0
- package/dist/lfo.js +55 -0
- package/dist/live-buffer.d.ts +39 -0
- package/dist/live-buffer.js +52 -0
- package/dist/live-granular.d.ts +41 -0
- package/dist/live-granular.js +64 -0
- package/dist/lookahead-limiter.d.ts +31 -0
- package/dist/lookahead-limiter.js +67 -0
- package/dist/loop-crossfade.d.ts +28 -0
- package/dist/loop-crossfade.js +72 -0
- package/dist/modulation-fx.d.ts +47 -0
- package/dist/modulation-fx.js +58 -0
- package/dist/modulation.d.ts +132 -0
- package/dist/modulation.js +228 -0
- package/dist/mpe-expression.d.ts +36 -0
- package/dist/mpe-expression.js +73 -0
- package/dist/multiband-dynamics.d.ts +50 -0
- package/dist/multiband-dynamics.js +60 -0
- package/dist/musical-controls.d.ts +76 -0
- package/dist/musical-controls.js +125 -0
- package/dist/musical-examples.d.ts +21 -0
- package/dist/musical-examples.js +142 -0
- package/dist/musical-materials.d.ts +110 -0
- package/dist/musical-materials.js +56 -0
- package/dist/oscillator.d.ts +10 -0
- package/dist/oscillator.js +35 -0
- package/dist/oversampled-drive.d.ts +19 -0
- package/dist/oversampled-drive.js +99 -0
- package/dist/performance.d.ts +61 -0
- package/dist/performance.js +241 -0
- package/dist/pitch-quantizer.d.ts +26 -0
- package/dist/pitch-quantizer.js +88 -0
- package/dist/prepared-convolution.d.ts +22 -0
- package/dist/prepared-convolution.js +99 -0
- package/dist/resident-recorder.d.ts +28 -0
- package/dist/resident-recorder.js +68 -0
- package/dist/resident-time-stretch.d.ts +44 -0
- package/dist/resident-time-stretch.js +135 -0
- package/dist/resonator.d.ts +32 -0
- package/dist/resonator.js +63 -0
- package/dist/reverb.d.ts +27 -0
- package/dist/reverb.js +52 -0
- package/dist/sample.d.ts +113 -0
- package/dist/sample.js +212 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +113 -0
- package/dist/spatial-chains.d.ts +48 -0
- package/dist/spatial-chains.js +76 -0
- package/dist/spectral-fft.d.ts +21 -0
- package/dist/spectral-fft.js +74 -0
- package/dist/spectral-freeze.d.ts +22 -0
- package/dist/spectral-freeze.js +74 -0
- package/dist/spectral-gate.d.ts +21 -0
- package/dist/spectral-gate.js +58 -0
- package/dist/spectral-stft.d.ts +13 -0
- package/dist/spectral-stft.js +69 -0
- package/dist/spectral-texture.d.ts +42 -0
- package/dist/spectral-texture.js +141 -0
- package/dist/spectral.d.ts +16 -0
- package/dist/spectral.js +10 -0
- package/dist/state-variable-filter.d.ts +17 -0
- package/dist/state-variable-filter.js +25 -0
- package/dist/stereo-delay.d.ts +45 -0
- package/dist/stereo-delay.js +70 -0
- package/dist/virtual-analog.d.ts +26 -0
- package/dist/virtual-analog.js +66 -0
- package/dist/voice-policy.d.ts +37 -0
- package/dist/voice-policy.js +245 -0
- package/dist/wavetable-bands.d.ts +37 -0
- package/dist/wavetable-bands.js +145 -0
- package/dist/wavetable-read.d.ts +6 -0
- package/dist/wavetable-read.js +11 -0
- package/dist/wavetable.d.ts +29 -0
- package/dist/wavetable.js +49 -0
- package/dist/windowed-pitch-shift.d.ts +34 -0
- package/dist/windowed-pitch-shift.js +51 -0
- package/docs/catalog-audition.md +76 -0
- package/docs/catalog-filters.md +125 -0
- package/docs/catalog-modulation-fx.md +220 -0
- package/docs/catalog-status.md +431 -0
- package/docs/character-filter.md +139 -0
- package/docs/contracts.md +131 -0
- package/docs/control-modulation.md +194 -0
- package/docs/convolution.md +45 -0
- package/docs/curve-shaper.md +117 -0
- package/docs/delay-fx-capture-findings.json +134 -0
- package/docs/delay-fx.md +202 -0
- package/docs/delay-readhead.md +121 -0
- package/docs/delay-settings-candidate.json +333 -0
- package/docs/delay-settings.md +111 -0
- package/docs/dependency-findings.md +61 -0
- package/docs/deployment-status.md +37 -0
- package/docs/drive.md +152 -0
- package/docs/dual-head-delay.md +131 -0
- package/docs/dynamics.md +180 -0
- package/docs/envelope-integer-staging.md +64 -0
- package/docs/filter-materialization-results.json +71 -0
- package/docs/filter-materialization.md +135 -0
- package/docs/filter-oscillator.md +126 -0
- package/docs/filterbank-vocoder-entry.md +58 -0
- package/docs/freeze-reverb.md +179 -0
- package/docs/frequency-shifter.md +133 -0
- package/docs/frontend-audition.md +22 -0
- package/docs/gain-schedule-verification.md +53 -0
- package/docs/initial-acceptance.md +79 -0
- package/docs/instrument-performance.md +191 -0
- package/docs/instrument-settings.md +130 -0
- package/docs/instrument.md +204 -0
- package/docs/integration-candidate.md +114 -0
- package/docs/live-buffer.md +163 -0
- package/docs/live-granular.md +179 -0
- package/docs/lookahead-multiband.md +183 -0
- package/docs/loop-crossfade.md +99 -0
- package/docs/mpe-expression.md +65 -0
- package/docs/musical-clock-seek-boundary.md +31 -0
- package/docs/musical-controls-contract.md +144 -0
- package/docs/musical-examples.md +255 -0
- package/docs/oversampled-drive.md +153 -0
- package/docs/performance.md +53 -0
- package/docs/pitch-quantizer.md +55 -0
- package/docs/prepared-convolution.md +188 -0
- package/docs/realtime-audition.md +63 -0
- package/docs/resident-recorder.md +151 -0
- package/docs/resident-time-stretch.md +190 -0
- package/docs/sample.md +191 -0
- package/docs/sound-audition-headroom.md +103 -0
- package/docs/sources.md +81 -0
- package/docs/spatial-chains-entry.md +250 -0
- package/docs/spectral-freeze-entry.md +130 -0
- package/docs/spectral-gate-entry.md +130 -0
- package/docs/spectral-large-candidate.md +74 -0
- package/docs/spectral-texture-entry.md +174 -0
- package/docs/spectral.md +118 -0
- package/docs/virtual-analog.md +77 -0
- package/docs/voice-policy.md +38 -0
- package/docs/wavetable-bands.md +138 -0
- package/docs/wavetable.md +101 -0
- package/docs/windowed-pitch-shift.md +112 -0
- package/package.json +269 -4
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
function bounded(value, low, high, fallback) {
|
|
3
|
+
return select(value.eq(value), f64(value), f64(fallback)).clamp(low, high);
|
|
4
|
+
}
|
|
5
|
+
/** Alias-reduced pulse and triangle. Polynomial smoothing is not a brickwall filter. */
|
|
6
|
+
export const virtualAnalogSource = defineSubgraph((config) => {
|
|
7
|
+
const { sampleRate, waveform, phaseCycles = 0 } = config;
|
|
8
|
+
if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000)
|
|
9
|
+
throw new RangeError('VA sampleRate must be in [8000,192000] Hz');
|
|
10
|
+
if (waveform !== 'pulse' && waveform !== 'triangle')
|
|
11
|
+
throw new RangeError('unknown VA waveform');
|
|
12
|
+
if (!Number.isFinite(phaseCycles) || phaseCycles < 0 || phaseCycles >= 1)
|
|
13
|
+
throw new RangeError('phaseCycles must be in [0,1)');
|
|
14
|
+
// Exact power-of-two scaling avoids the native |state|<1e-30 flush,
|
|
15
|
+
// including f64 phaseCycles=Number.MIN_VALUE and smallest f32 Hz.
|
|
16
|
+
// Phase is wrapped below1 before scaling, with >16x finite headroom.
|
|
17
|
+
const scale = 2 ** 1020;
|
|
18
|
+
const phase = state.f64(phaseCycles * scale).named('scaledPhase');
|
|
19
|
+
return { tick(c) {
|
|
20
|
+
const step = bounded(c.frequencyHz, 0, .45 * sampleRate, 0).div(sampleRate);
|
|
21
|
+
const p = select(c.reset, f64(phaseCycles), phase.read().div(scale));
|
|
22
|
+
// Both select branches evaluate: the zero-Hz denominator must remain safe.
|
|
23
|
+
const width = select(step.gt(0), step, f64(1));
|
|
24
|
+
let output;
|
|
25
|
+
if (waveform === 'pulse') {
|
|
26
|
+
const duty = bounded(c.duty, 0, 1, .5);
|
|
27
|
+
// Rising-step residual for the unit-area triangular kernel [-1,1].
|
|
28
|
+
const blep = (q) => {
|
|
29
|
+
const after = f64(1).sub(q.div(width)).max(0);
|
|
30
|
+
const before = f64(1).sub(f64(1).sub(q).div(width)).max(0);
|
|
31
|
+
return before.mul(before).sub(after.mul(after));
|
|
32
|
+
};
|
|
33
|
+
const relative = p.sub(duty), falling = relative.sub(relative.floor());
|
|
34
|
+
const raw = select(p.lt(duty), f64(1), f64(-1));
|
|
35
|
+
output = raw.add(select(step.gt(0), blep(p).sub(blep(falling)), f64(0)));
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const rampResidual = (distance) => {
|
|
39
|
+
const t = f64(1).sub(distance.div(width)).max(0);
|
|
40
|
+
return t.mul(t).mul(t).div(6);
|
|
41
|
+
};
|
|
42
|
+
const raw = f64(1).sub(p.sub(.5).abs().mul(4));
|
|
43
|
+
// Slope jumps +8 at phase0 and -8 at phase.5. Support <.5 cycle,
|
|
44
|
+
// so only phase0 needs the periodic image at phase1.
|
|
45
|
+
const correction = rampResidual(p).add(rampResidual(f64(1).sub(p)))
|
|
46
|
+
.sub(rampResidual(p.sub(.5).abs())).mul(step).mul(8);
|
|
47
|
+
output = raw.add(select(step.gt(0), correction, f64(0)));
|
|
48
|
+
}
|
|
49
|
+
phase.write(p.add(step).frac().mul(scale));
|
|
50
|
+
return f32(output);
|
|
51
|
+
} };
|
|
52
|
+
});
|
|
53
|
+
/** Park–Miller 48271 white-noise candidate; deterministic, not cryptographic. */
|
|
54
|
+
export const seededNoise = defineSubgraph((config = {}) => {
|
|
55
|
+
const { seed = 1 } = config;
|
|
56
|
+
if (!Number.isInteger(seed) || seed < 1 || seed > 2147483646)
|
|
57
|
+
throw new RangeError('noise seed must be an integer in [1,2147483646]');
|
|
58
|
+
const random = state.i32(seed).named('random');
|
|
59
|
+
return { tick(reset) {
|
|
60
|
+
const current = select(reset, i32(seed), random.read());
|
|
61
|
+
// Exact integer product in f64; i32 multiplication would overflow.
|
|
62
|
+
const next = i32(f64(current).mul(48271).mod(2147483647));
|
|
63
|
+
random.write(next);
|
|
64
|
+
return f32(f64(next).mul(2 / 2147483647).sub(1));
|
|
65
|
+
} };
|
|
66
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type MidiInputHandle, type Node } from '@unworklet/core';
|
|
2
|
+
/** Fixed construction capacities, not product-wide polyphony limits. */
|
|
3
|
+
export interface VoicePolicyConfig {
|
|
4
|
+
mode: 'mono' | 'poly';
|
|
5
|
+
/** Poly default 16; mono must use one slot. Maximum 32 limits graph expansion. */
|
|
6
|
+
capacity?: number;
|
|
7
|
+
/** Held note-on identities (including stolen notes). Default 128, maximum 256. */
|
|
8
|
+
heldCapacity?: number;
|
|
9
|
+
/** Mono only: preserve gate without a retrigger when another held note takes priority. */
|
|
10
|
+
legato?: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Voice allocation only. MIDI transport, graph storage, and node control remain unworklet's. */
|
|
13
|
+
export declare const voicePolicy: import("@unworklet/core").SubgraphDecl<[config: VoicePolicyConfig], {
|
|
14
|
+
capacity: number;
|
|
15
|
+
heldCapacity: number;
|
|
16
|
+
noteOn: (key: Node<"i32">, ch: Node<"i32">, vel: Node<"i32">) => void;
|
|
17
|
+
noteOff: (key: Node<"i32">, ch: Node<"i32">, when?: Node<"bool">) => void;
|
|
18
|
+
reset: (when?: Node<"bool">) => void;
|
|
19
|
+
allNotesOff: (ch: Node<"i32">) => void;
|
|
20
|
+
allSoundOff: (ch: Node<"i32">) => void;
|
|
21
|
+
overflowed: () => Node<"bool">;
|
|
22
|
+
/** Call once in declaration scope with an existing unworklet MIDI input. */
|
|
23
|
+
bindMidi(input: MidiInputHandle): void;
|
|
24
|
+
voices: {
|
|
25
|
+
read(): {
|
|
26
|
+
active: Node<"bool">;
|
|
27
|
+
gate: Node<"bool">;
|
|
28
|
+
note: Node<"i32">;
|
|
29
|
+
channel: Node<"i32">;
|
|
30
|
+
velocity: Node<"f32">;
|
|
31
|
+
};
|
|
32
|
+
/** One latched start/retrigger; read once per sample before invoking the envelope. */
|
|
33
|
+
takeRetrigger(): Node<"bool">;
|
|
34
|
+
/** Envelope completion may free only a released voice, never a newly held one. */
|
|
35
|
+
releaseFinished(done: Node<"bool">): void;
|
|
36
|
+
}[];
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, i32, select, state } from '@unworklet/core';
|
|
2
|
+
function capacity(value, maximum, name) {
|
|
3
|
+
if (!Number.isInteger(value) || value < 1 || value > maximum) {
|
|
4
|
+
throw new RangeError(`${name} must be an integer in [1, ${maximum}]`);
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
/** Voice allocation only. MIDI transport, graph storage, and node control remain unworklet's. */
|
|
9
|
+
export const voicePolicy = defineSubgraph((config) => {
|
|
10
|
+
if (config.mode !== 'mono' && config.mode !== 'poly')
|
|
11
|
+
throw new TypeError('mode must be mono or poly');
|
|
12
|
+
const size = capacity(config.capacity ?? (config.mode === 'mono' ? 1 : 16), 32, 'capacity');
|
|
13
|
+
const heldSize = capacity(config.heldCapacity ?? Math.max(128, size), 256, 'heldCapacity');
|
|
14
|
+
if (config.mode === 'mono' && size !== 1)
|
|
15
|
+
throw new RangeError('mono capacity must be 1');
|
|
16
|
+
if (heldSize < size)
|
|
17
|
+
throw new RangeError('heldCapacity must be at least capacity');
|
|
18
|
+
if (config.legato !== undefined && typeof config.legato !== 'boolean')
|
|
19
|
+
throw new TypeError('legato must be boolean');
|
|
20
|
+
const legato = config.legato ?? true;
|
|
21
|
+
const ints = (name, length) => state.buffer.i32({ size: length }).expose({ name, snapshot: 'transient' });
|
|
22
|
+
const note = ints('note', size);
|
|
23
|
+
const channel = ints('channel', size);
|
|
24
|
+
const velocity = state.buffer.f32({ size }).expose({ name: 'velocity', snapshot: 'transient' });
|
|
25
|
+
const age = ints('age', size); // zero means free, otherwise dense allocation order 1..size
|
|
26
|
+
const releaseAge = ints('releaseAge', size); // zero means held, otherwise release order
|
|
27
|
+
const owner = ints('owner', size); // held token 1..heldSize; zero means no key owns the release tail
|
|
28
|
+
const retrigger = ints('retrigger', size);
|
|
29
|
+
const heldNote = ints('heldNote', heldSize + 1);
|
|
30
|
+
const heldChannel = ints('heldChannel', heldSize + 1);
|
|
31
|
+
const heldVelocity = ints('heldVelocity', heldSize + 1);
|
|
32
|
+
// Intrusive held-key order: token 0 is a sentinel; valid tokens are 1..heldSize.
|
|
33
|
+
const occupied = ints('occupied', heldSize + 1);
|
|
34
|
+
const previous = ints('previous', heldSize + 1);
|
|
35
|
+
const next = ints('next', heldSize + 1);
|
|
36
|
+
const head = state.i32(0).expose({ name: 'head', snapshot: 'transient' });
|
|
37
|
+
const tail = state.i32(0).expose({ name: 'tail', snapshot: 'transient' });
|
|
38
|
+
// Materialize loop accumulators: unworklet 0.4.1 analysis recursively expands
|
|
39
|
+
// expression DAGs, so nested selects otherwise grow exponentially.
|
|
40
|
+
const scratch = ints('scratch', 4);
|
|
41
|
+
const overflow = state.bool(false).expose({ name: 'overflow', snapshot: 'transient' });
|
|
42
|
+
const validKey = (key, ch) => key.gte(0).and(key.lte(127)).and(ch.gte(0)).and(ch.lte(15));
|
|
43
|
+
const releaseCount = () => {
|
|
44
|
+
let count = i32(0);
|
|
45
|
+
for (let n = 0; n < size; n++)
|
|
46
|
+
count = count.add(select(releaseAge.read(n).gt(0), i32(1), i32(0)));
|
|
47
|
+
return count;
|
|
48
|
+
};
|
|
49
|
+
function releaseVoice(index, when) {
|
|
50
|
+
const shouldRelease = when.and(age.read(index).gt(0)).and(owner.read(index).gt(0));
|
|
51
|
+
const nextOrder = releaseCount().add(1);
|
|
52
|
+
owner.write(index, select(shouldRelease, i32(0), owner.read(index)));
|
|
53
|
+
releaseAge.write(index, select(shouldRelease, nextOrder, releaseAge.read(index)));
|
|
54
|
+
}
|
|
55
|
+
function freeVoice(index, when) {
|
|
56
|
+
const oldAge = age.read(index);
|
|
57
|
+
const oldRelease = releaseAge.read(index);
|
|
58
|
+
const enabled = when.and(oldAge.gt(0));
|
|
59
|
+
for (let n = 0; n < size; n++) {
|
|
60
|
+
const a = age.read(n);
|
|
61
|
+
const r = releaseAge.read(n);
|
|
62
|
+
age.write(n, select(enabled.and(a.gt(oldAge)), a.sub(1), a));
|
|
63
|
+
releaseAge.write(n, select(enabled.and(oldRelease.gt(0)).and(r.gt(oldRelease)), r.sub(1), r));
|
|
64
|
+
}
|
|
65
|
+
age.write(index, select(enabled, i32(0), age.read(index)));
|
|
66
|
+
releaseAge.write(index, select(enabled, i32(0), releaseAge.read(index)));
|
|
67
|
+
owner.write(index, select(enabled, i32(0), owner.read(index)));
|
|
68
|
+
note.write(index, select(enabled, i32(0), note.read(index)));
|
|
69
|
+
channel.write(index, select(enabled, i32(0), channel.read(index)));
|
|
70
|
+
velocity.write(index, select(enabled, f32(0), velocity.read(index)));
|
|
71
|
+
retrigger.write(index, select(enabled, i32(0), retrigger.read(index)));
|
|
72
|
+
}
|
|
73
|
+
function activate(index, token, when, trigger) {
|
|
74
|
+
const oldAge = age.read(index);
|
|
75
|
+
const oldRelease = releaseAge.read(index);
|
|
76
|
+
let count = i32(0);
|
|
77
|
+
for (let n = 0; n < size; n++)
|
|
78
|
+
count = count.add(select(age.read(n).gt(0), i32(1), i32(0)));
|
|
79
|
+
for (let n = 0; n < size; n++) {
|
|
80
|
+
const a = age.read(n);
|
|
81
|
+
const r = releaseAge.read(n);
|
|
82
|
+
age.write(n, select(when.and(oldAge.gt(0)).and(a.gt(oldAge)), a.sub(1), a));
|
|
83
|
+
releaseAge.write(n, select(when.and(oldRelease.gt(0)).and(r.gt(oldRelease)), r.sub(1), r));
|
|
84
|
+
}
|
|
85
|
+
age.write(index, select(when, select(oldAge.gt(0), count, count.add(1)), age.read(index)));
|
|
86
|
+
releaseAge.write(index, select(when, i32(0), releaseAge.read(index)));
|
|
87
|
+
owner.write(index, select(when, token, owner.read(index)));
|
|
88
|
+
note.write(index, select(when, heldNote.read(token), note.read(index)));
|
|
89
|
+
channel.write(index, select(when, heldChannel.read(token), channel.read(index)));
|
|
90
|
+
velocity.write(index, select(when, f32(heldVelocity.read(token)).div(127), velocity.read(index)));
|
|
91
|
+
retrigger.write(index, select(when.and(trigger), i32(1), retrigger.read(index)));
|
|
92
|
+
}
|
|
93
|
+
function monoPriority(when) {
|
|
94
|
+
const selected = tail.read();
|
|
95
|
+
const newest = selected;
|
|
96
|
+
const changed = owner.read(0).eq(selected).not();
|
|
97
|
+
const trigger = bool(!legato).or(owner.read(0).eq(0));
|
|
98
|
+
activate(i32(0), selected, when.and(newest.gt(0)).and(changed), trigger);
|
|
99
|
+
releaseVoice(0, when.and(newest.eq(0)));
|
|
100
|
+
}
|
|
101
|
+
function removeHeld(token, when) {
|
|
102
|
+
const before = previous.read(token);
|
|
103
|
+
const after = next.read(token);
|
|
104
|
+
next.write(before, select(when.and(before.gt(0)), after, next.read(before)));
|
|
105
|
+
previous.write(after, select(when.and(after.gt(0)), before, previous.read(after)));
|
|
106
|
+
head.write(select(when.and(head.read().eq(token)), after, head.read()));
|
|
107
|
+
tail.write(select(when.and(tail.read().eq(token)), before, tail.read()));
|
|
108
|
+
occupied.write(token, select(when, i32(0), occupied.read(token)));
|
|
109
|
+
previous.write(token, select(when, i32(0), previous.read(token)));
|
|
110
|
+
next.write(token, select(when, i32(0), next.read(token)));
|
|
111
|
+
heldNote.write(token, select(when, i32(0), heldNote.read(token)));
|
|
112
|
+
heldChannel.write(token, select(when, i32(0), heldChannel.read(token)));
|
|
113
|
+
heldVelocity.write(token, select(when, i32(0), heldVelocity.read(token)));
|
|
114
|
+
}
|
|
115
|
+
function noteOff(key, ch, when = bool(true)) {
|
|
116
|
+
scratch.write(0, 0);
|
|
117
|
+
scratch.write(1, head.read());
|
|
118
|
+
for (let n = 0; n < heldSize; n++) {
|
|
119
|
+
const token = scratch.read(0);
|
|
120
|
+
const cursor = scratch.read(1);
|
|
121
|
+
const match = token.eq(0).and(cursor.gt(0)).and(heldNote.read(cursor).eq(key)).and(heldChannel.read(cursor).eq(ch));
|
|
122
|
+
scratch.write(0, select(match, cursor, token));
|
|
123
|
+
scratch.write(1, next.read(cursor));
|
|
124
|
+
}
|
|
125
|
+
const token = scratch.read(0);
|
|
126
|
+
const found = when.and(validKey(key, ch)).and(token.gt(0));
|
|
127
|
+
removeHeld(token, found);
|
|
128
|
+
if (config.mode === 'mono')
|
|
129
|
+
monoPriority(found);
|
|
130
|
+
else
|
|
131
|
+
for (let n = 0; n < size; n++)
|
|
132
|
+
releaseVoice(n, found.and(owner.read(n).eq(token)));
|
|
133
|
+
}
|
|
134
|
+
function noteOn(key, ch, vel) {
|
|
135
|
+
noteOff(key, ch, vel.eq(0));
|
|
136
|
+
const enabled = validKey(key, ch).and(vel.gt(0)).and(vel.lte(127));
|
|
137
|
+
let token = i32(0);
|
|
138
|
+
let found = bool(false);
|
|
139
|
+
for (let n = heldSize; n >= 1; n--) {
|
|
140
|
+
const free = occupied.read(n).eq(0);
|
|
141
|
+
token = select(free, i32(n), token);
|
|
142
|
+
found = found.or(free);
|
|
143
|
+
}
|
|
144
|
+
const accept = enabled.and(found);
|
|
145
|
+
overflow.write(overflow.read().or(enabled.and(found.not())));
|
|
146
|
+
const last = tail.read();
|
|
147
|
+
next.write(last, select(accept.and(last.gt(0)), token, next.read(last)));
|
|
148
|
+
previous.write(token, select(accept, last, previous.read(token)));
|
|
149
|
+
next.write(token, select(accept, i32(0), next.read(token)));
|
|
150
|
+
head.write(select(accept.and(last.eq(0)), token, head.read()));
|
|
151
|
+
tail.write(select(accept, token, tail.read()));
|
|
152
|
+
occupied.write(token, select(accept, i32(1), occupied.read(token)));
|
|
153
|
+
heldNote.write(token, select(accept, key, heldNote.read(token)));
|
|
154
|
+
heldChannel.write(token, select(accept, ch, heldChannel.read(token)));
|
|
155
|
+
heldVelocity.write(token, select(accept, vel, heldVelocity.read(token)));
|
|
156
|
+
if (config.mode === 'mono')
|
|
157
|
+
monoPriority(accept);
|
|
158
|
+
else {
|
|
159
|
+
scratch.write(2, 2 * size + 1);
|
|
160
|
+
scratch.write(3, 0);
|
|
161
|
+
for (let n = 0; n < size; n++) {
|
|
162
|
+
const a = age.read(n);
|
|
163
|
+
const r = releaseAge.read(n);
|
|
164
|
+
const score = select(a.eq(0), i32(0), select(r.gt(0), r, a.add(size)));
|
|
165
|
+
const best = scratch.read(2);
|
|
166
|
+
const chosen = scratch.read(3);
|
|
167
|
+
const better = score.lt(best); // ascending slots break ties deterministically
|
|
168
|
+
scratch.write(3, select(better, i32(n), chosen));
|
|
169
|
+
scratch.write(2, select(better, score, best));
|
|
170
|
+
}
|
|
171
|
+
activate(scratch.read(3), token, accept, bool(true));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function channelOff(ch, immediate, when = bool(true)) {
|
|
175
|
+
const enabled = when.and(ch.gte(0)).and(ch.lte(15));
|
|
176
|
+
// Remove matching identities through bounded, constant-time list unlinking.
|
|
177
|
+
for (let n = 1; n <= heldSize; n++) {
|
|
178
|
+
removeHeld(i32(n), enabled.and(occupied.read(n).gt(0)).and(heldChannel.read(n).eq(ch)));
|
|
179
|
+
}
|
|
180
|
+
if (immediate)
|
|
181
|
+
for (let n = 0; n < size; n++)
|
|
182
|
+
freeVoice(n, enabled.and(channel.read(n).eq(ch)));
|
|
183
|
+
if (config.mode === 'mono')
|
|
184
|
+
monoPriority(enabled);
|
|
185
|
+
else if (!immediate)
|
|
186
|
+
for (let n = 0; n < size; n++)
|
|
187
|
+
releaseVoice(n, enabled.and(channel.read(n).eq(ch)));
|
|
188
|
+
}
|
|
189
|
+
function reset(when = bool(true)) {
|
|
190
|
+
for (let n = 0; n < size; n++)
|
|
191
|
+
freeVoice(n, when);
|
|
192
|
+
for (let n = 0; n <= heldSize; n++) {
|
|
193
|
+
occupied.write(n, select(when, i32(0), occupied.read(n)));
|
|
194
|
+
previous.write(n, select(when, i32(0), previous.read(n)));
|
|
195
|
+
next.write(n, select(when, i32(0), next.read(n)));
|
|
196
|
+
heldNote.write(n, select(when, i32(0), heldNote.read(n)));
|
|
197
|
+
heldChannel.write(n, select(when, i32(0), heldChannel.read(n)));
|
|
198
|
+
heldVelocity.write(n, select(when, i32(0), heldVelocity.read(n)));
|
|
199
|
+
}
|
|
200
|
+
head.write(select(when, i32(0), head.read()));
|
|
201
|
+
tail.write(select(when, i32(0), tail.read()));
|
|
202
|
+
overflow.write(select(when, false, overflow.read()));
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
capacity: size,
|
|
206
|
+
heldCapacity: heldSize,
|
|
207
|
+
noteOn,
|
|
208
|
+
noteOff,
|
|
209
|
+
reset,
|
|
210
|
+
allNotesOff: (ch) => channelOff(ch, false),
|
|
211
|
+
allSoundOff: (ch) => channelOff(ch, true),
|
|
212
|
+
overflowed: () => overflow.read(),
|
|
213
|
+
/** Call once in declaration scope with an existing unworklet MIDI input. */
|
|
214
|
+
bindMidi(input) {
|
|
215
|
+
input.onEvent('noteOn', e => noteOn(e.note, e.channel, e.velocity));
|
|
216
|
+
input.onEvent('noteOff', e => noteOff(e.note, e.channel));
|
|
217
|
+
input.onEvent('cc', e => {
|
|
218
|
+
channelOff(e.channel, false, e.controller.eq(123));
|
|
219
|
+
channelOff(e.channel, true, e.controller.eq(120));
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
voices: Array.from({ length: size }, (_, index) => ({
|
|
223
|
+
read() {
|
|
224
|
+
const active = age.read(index).gt(0);
|
|
225
|
+
return {
|
|
226
|
+
active,
|
|
227
|
+
gate: owner.read(index).gt(0),
|
|
228
|
+
note: select(active, note.read(index), i32(-1)),
|
|
229
|
+
channel: select(active, channel.read(index), i32(-1)),
|
|
230
|
+
velocity: velocity.read(index),
|
|
231
|
+
};
|
|
232
|
+
},
|
|
233
|
+
/** One latched start/retrigger; read once per sample before invoking the envelope. */
|
|
234
|
+
takeRetrigger() {
|
|
235
|
+
const value = retrigger.read(index).gt(0);
|
|
236
|
+
retrigger.write(index, 0);
|
|
237
|
+
return value;
|
|
238
|
+
},
|
|
239
|
+
/** Envelope completion may free only a released voice, never a newly held one. */
|
|
240
|
+
releaseFinished(done) {
|
|
241
|
+
freeVoice(index, done.and(owner.read(index).eq(0)));
|
|
242
|
+
},
|
|
243
|
+
})),
|
|
244
|
+
};
|
|
245
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { WavetableConfig, WavetableControls } from './wavetable.js';
|
|
2
|
+
export interface PreparedWavetableBands {
|
|
3
|
+
/** Band-major, then frame-major complete cycles, without duplicated endpoints. */
|
|
4
|
+
data: Float32Array;
|
|
5
|
+
frameLength: number;
|
|
6
|
+
frameCount: number;
|
|
7
|
+
/** Descending retained positive harmonics; DC is preserved in every band. */
|
|
8
|
+
harmonicLimits: readonly number[];
|
|
9
|
+
}
|
|
10
|
+
export interface WavetableBandsOptions {
|
|
11
|
+
/** Original finite mono cycles. Equal power-of-two lengths 16..4096; 1..16 frames.
|
|
12
|
+
* All derived bands together must fit 65536 samples. Input is never changed. */
|
|
13
|
+
frames: readonly Float32Array[];
|
|
14
|
+
}
|
|
15
|
+
/** Layout is the fixed schedule produced by prepareWavetableBands. */
|
|
16
|
+
export interface BandedWavetableConfig extends WavetableConfig {
|
|
17
|
+
/** Power of two, 16..4096 samples per cycle. */
|
|
18
|
+
frameLength: number;
|
|
19
|
+
/** 1..16 morph frames; frameLength * frameCount * derived band count must
|
|
20
|
+
* fit both the shared resident capacity and the 65536-sample native limit. */
|
|
21
|
+
frameCount: number;
|
|
22
|
+
}
|
|
23
|
+
/** Prepare harmonic-truncated pitch bands outside the audio callback. No per-band
|
|
24
|
+
* normalization, resampling, loader, or provenance/license assumption is added.
|
|
25
|
+
* Reject nonfinite input or reconstruction that overflows float32. DC, phase and
|
|
26
|
+
* retained harmonic gain are preserved within FFT/float32 roundoff. */
|
|
27
|
+
export declare function prepareWavetableBands({ frames }: WavetableBandsOptions): PreparedWavetableBands;
|
|
28
|
+
/** Four periodic cycle interpolations: frame morph in neighboring pitch bands.
|
|
29
|
+
* Fetches eight integer resident reads so tiny local fractions survive offsets.
|
|
30
|
+
* Static harmonic truncation is not a brickwall reconstruction filter and does
|
|
31
|
+
* not remove interpolation images or bandwidth from modulation/reset/asset edits. */
|
|
32
|
+
export declare const bandedWavetableSource: import("@unworklet/core").SubgraphDecl<[config: BandedWavetableConfig], {
|
|
33
|
+
tick(c: WavetableControls): {
|
|
34
|
+
output: import("@unworklet/core").Node<"f32">;
|
|
35
|
+
missing: import("@unworklet/core").Node<"bool">;
|
|
36
|
+
};
|
|
37
|
+
}>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
import { readWavetableCycle } from './wavetable-read.js';
|
|
3
|
+
function layout(length, count) {
|
|
4
|
+
if (!Number.isInteger(length) || length < 16 || length > 4096 || !Number.isInteger(Math.log2(length)) ||
|
|
5
|
+
!Number.isInteger(count) || count < 1 || count > 16)
|
|
6
|
+
throw new RangeError('banded wavetable requires power-of-two frameLength 16..4096 and frameCount 1..16');
|
|
7
|
+
const harmonics = [];
|
|
8
|
+
for (let h = length / 2 - 1; h >= 1; h = Math.floor(h / 2))
|
|
9
|
+
harmonics.push(h);
|
|
10
|
+
const required = length * count * harmonics.length;
|
|
11
|
+
if (required > 65536)
|
|
12
|
+
throw new RangeError('all wavetable bands must fit 65536 samples');
|
|
13
|
+
return { harmonics, required };
|
|
14
|
+
}
|
|
15
|
+
// Host-only radix-2 FFT. This runs before native ingress, never in audio processing.
|
|
16
|
+
function transform(real, imag, inverse) {
|
|
17
|
+
const size = real.length;
|
|
18
|
+
for (let n = 1, j = 0; n < size; n++) {
|
|
19
|
+
let bit = size >> 1;
|
|
20
|
+
for (; j & bit; bit >>= 1)
|
|
21
|
+
j ^= bit;
|
|
22
|
+
j ^= bit;
|
|
23
|
+
if (n < j) {
|
|
24
|
+
const r = real[n], im = imag[n];
|
|
25
|
+
real[n] = real[j];
|
|
26
|
+
imag[n] = imag[j];
|
|
27
|
+
real[j] = r;
|
|
28
|
+
imag[j] = im;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
for (let width = 2; width <= size; width *= 2) {
|
|
32
|
+
for (let base = 0; base < size; base += width)
|
|
33
|
+
for (let k = 0; k < width / 2; k++) {
|
|
34
|
+
const angle = (inverse ? 2 : -2) * Math.PI * k / width, c = Math.cos(angle), s = Math.sin(angle);
|
|
35
|
+
const a = base + k, b = a + width / 2;
|
|
36
|
+
const tr = c * real[b] - s * imag[b], ti = s * real[b] + c * imag[b];
|
|
37
|
+
real[b] = real[a] - tr;
|
|
38
|
+
imag[b] = imag[a] - ti;
|
|
39
|
+
real[a] += tr;
|
|
40
|
+
imag[a] += ti;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (inverse)
|
|
44
|
+
for (let n = 0; n < size; n++) {
|
|
45
|
+
real[n] /= size;
|
|
46
|
+
imag[n] /= size;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Prepare harmonic-truncated pitch bands outside the audio callback. No per-band
|
|
50
|
+
* normalization, resampling, loader, or provenance/license assumption is added.
|
|
51
|
+
* Reject nonfinite input or reconstruction that overflows float32. DC, phase and
|
|
52
|
+
* retained harmonic gain are preserved within FFT/float32 roundoff. */
|
|
53
|
+
export function prepareWavetableBands({ frames }) {
|
|
54
|
+
const frameCount = frames.length, frameLength = frames[0]?.length ?? 0;
|
|
55
|
+
const { harmonics, required } = layout(frameLength, frameCount);
|
|
56
|
+
for (const frame of frames) {
|
|
57
|
+
if (!(frame instanceof Float32Array) || frame.length !== frameLength || frame.some(x => !Number.isFinite(x))) {
|
|
58
|
+
throw new RangeError('wavetable frames must be equal-length finite Float32Arrays');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const data = new Float32Array(required);
|
|
62
|
+
const real = new Float64Array(frameLength), imag = new Float64Array(frameLength);
|
|
63
|
+
const r = new Float64Array(frameLength), im = new Float64Array(frameLength);
|
|
64
|
+
for (let frame = 0; frame < frameCount; frame++) {
|
|
65
|
+
real.set(frames[frame]);
|
|
66
|
+
imag.fill(0);
|
|
67
|
+
transform(real, imag, false);
|
|
68
|
+
for (let band = 0; band < harmonics.length; band++) {
|
|
69
|
+
r.fill(0);
|
|
70
|
+
im.fill(0);
|
|
71
|
+
const limit = harmonics[band];
|
|
72
|
+
r[0] = real[0];
|
|
73
|
+
// Explicit conjugate pairs guarantee a real table despite FFT roundoff.
|
|
74
|
+
for (let h = 1; h <= limit; h++) {
|
|
75
|
+
r[h] = (real[h] + real[frameLength - h]) / 2;
|
|
76
|
+
im[h] = (imag[h] - imag[frameLength - h]) / 2;
|
|
77
|
+
r[frameLength - h] = r[h];
|
|
78
|
+
im[frameLength - h] = -im[h];
|
|
79
|
+
}
|
|
80
|
+
transform(r, im, true);
|
|
81
|
+
const offset = (band * frameCount + frame) * frameLength;
|
|
82
|
+
for (let n = 0; n < frameLength; n++) {
|
|
83
|
+
const value = Math.fround(r[n]);
|
|
84
|
+
if (!Number.isFinite(value))
|
|
85
|
+
throw new RangeError('prepared wavetable exceeds finite float32 PCM range');
|
|
86
|
+
data[offset + n] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { data, frameLength, frameCount, harmonicLimits: Object.freeze(harmonics) };
|
|
91
|
+
}
|
|
92
|
+
function bounded(value, high) {
|
|
93
|
+
return select(value.eq(value), f64(value), f64(0)).clamp(0, high);
|
|
94
|
+
}
|
|
95
|
+
/** Four periodic cycle interpolations: frame morph in neighboring pitch bands.
|
|
96
|
+
* Fetches eight integer resident reads so tiny local fractions survive offsets.
|
|
97
|
+
* Static harmonic truncation is not a brickwall reconstruction filter and does
|
|
98
|
+
* not remove interpolation images or bandwidth from modulation/reset/asset edits. */
|
|
99
|
+
export const bandedWavetableSource = defineSubgraph((config) => {
|
|
100
|
+
const { sampleRate, sample, frameLength, frameCount, phaseCycles = 0 } = config;
|
|
101
|
+
const { harmonics, required } = layout(frameLength, frameCount);
|
|
102
|
+
if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000)
|
|
103
|
+
throw new RangeError('banded wavetable sampleRate must be in [8000,192000] Hz');
|
|
104
|
+
if (!Number.isInteger(sample.capacity) || sample.capacity < required || sample.capacity > 65536)
|
|
105
|
+
throw new RangeError('all wavetable bands must fit resident capacity');
|
|
106
|
+
if (!Number.isFinite(phaseCycles) || phaseCycles < 0 || phaseCycles >= 1)
|
|
107
|
+
throw new RangeError('phaseCycles must be in [0,1)');
|
|
108
|
+
const phaseScale = 2 ** 1020, controlScale = 2 ** 128;
|
|
109
|
+
const phase = state.f64(phaseCycles * phaseScale).named('scaledPhase'), revision = state.i32(-1).named('revision');
|
|
110
|
+
const current = state.f64(phaseCycles * phaseScale).named('scaledCurrentPhase');
|
|
111
|
+
const scan = state.f64(0).named('scaledFrame'), pitch = state.f64(0).named('scaledFrequency'), band = state.f64(0).named('scaledBand');
|
|
112
|
+
const frameLo = state.i32(0).named('frameLo'), frameHi = state.i32(0).named('frameHi');
|
|
113
|
+
const bandLo = state.i32(0).named('bandLo'), bandHi = state.i32(0).named('bandHi');
|
|
114
|
+
const starts = Array.from({ length: 4 }, (_, i) => state.i32(0).named(`start${i}`));
|
|
115
|
+
return { tick(c) {
|
|
116
|
+
current.write(select(c.reset.or(sample.revision().eq(revision.read()).not()), f64(phaseCycles * phaseScale), phase.read()));
|
|
117
|
+
scan.write(bounded(c.frame, frameCount - 1).mul(controlScale));
|
|
118
|
+
pitch.write(bounded(c.frequencyHz, .45 * sampleRate).mul(controlScale));
|
|
119
|
+
const frequency = pitch.read().div(controlScale), frame = scan.read().div(controlScale), p = current.read().div(phaseScale);
|
|
120
|
+
// Nonoverlapping transitions: the bright H band has zero weight by .45*sr/H.
|
|
121
|
+
let position = f64(0);
|
|
122
|
+
for (const h of harmonics.slice(0, -1)) {
|
|
123
|
+
const start = .225 * sampleRate / h;
|
|
124
|
+
position = position.add(frequency.sub(start).div(start).clamp(0, 1));
|
|
125
|
+
}
|
|
126
|
+
band.write(position.mul(controlScale));
|
|
127
|
+
const b = band.read().div(controlScale), frameMix = frame.sub(frame.floor()), bandMix = b.sub(b.floor());
|
|
128
|
+
frameLo.write(i32(frame.floor()));
|
|
129
|
+
frameHi.write(frameLo.read().add(1).min(frameCount - 1));
|
|
130
|
+
bandLo.write(i32(b.floor()));
|
|
131
|
+
bandHi.write(bandLo.read().add(1).min(harmonics.length - 1));
|
|
132
|
+
starts[0].write(bandLo.read().mul(frameCount).add(frameLo.read()).mul(frameLength));
|
|
133
|
+
starts[1].write(bandLo.read().mul(frameCount).add(frameHi.read()).mul(frameLength));
|
|
134
|
+
starts[2].write(bandHi.read().mul(frameCount).add(frameLo.read()).mul(frameLength));
|
|
135
|
+
starts[3].write(bandHi.read().mul(frameCount).add(frameHi.read()).mul(frameLength));
|
|
136
|
+
const values = starts.map(start => readWavetableCycle(sample, p, start.read(), frameLength));
|
|
137
|
+
const a = f64(values[0]).mul(f64(1).sub(frameMix)).add(f64(values[1]).mul(frameMix));
|
|
138
|
+
const z = f64(values[2]).mul(f64(1).sub(frameMix)).add(f64(values[3]).mul(frameMix));
|
|
139
|
+
const missing = sample.length().lt(required);
|
|
140
|
+
const output = select(missing, f32(0), f32(a.mul(f64(1).sub(bandMix)).add(z.mul(bandMix))));
|
|
141
|
+
phase.write(p.add(frequency.div(sampleRate)).frac().mul(phaseScale));
|
|
142
|
+
revision.write(sample.revision());
|
|
143
|
+
return { output, missing };
|
|
144
|
+
} };
|
|
145
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
import type { ResidentSample } from './sample.js';
|
|
3
|
+
/** Construction helper, not a resident/runtime API. Keep the cycle-local fraction
|
|
4
|
+
* separate from the absolute integer offset so tiny phase contributions survive
|
|
5
|
+
* at nonzero frames/bands even against the full finite float32 PCM range. */
|
|
6
|
+
export declare function readWavetableCycle(sample: ResidentSample, phase: Node<'f64'>, start: Node<'i32'>, length: number): Node<'f32'>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { f32, f64, i32 } from '@unworklet/core';
|
|
2
|
+
/** Construction helper, not a resident/runtime API. Keep the cycle-local fraction
|
|
3
|
+
* separate from the absolute integer offset so tiny phase contributions survive
|
|
4
|
+
* at nonzero frames/bands even against the full finite float32 PCM range. */
|
|
5
|
+
export function readWavetableCycle(sample, phase, start, length) {
|
|
6
|
+
const position = phase.mul(length), whole = i32(position.floor()), fraction = position.sub(f64(whole));
|
|
7
|
+
const a = sample.read(f64(start.add(whole)), start, start.add(length), false);
|
|
8
|
+
const b = sample.read(f64(start.add(whole.add(1).mod(length))), start, start.add(length), false);
|
|
9
|
+
// Convex weighting avoids cancellation in a+(b-a)*fraction near the endpoint.
|
|
10
|
+
return f32(f64(a).mul(f64(1).sub(fraction)).add(f64(b).mul(fraction)));
|
|
11
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
import type { ResidentSample } from './sample.js';
|
|
3
|
+
export interface WavetableConfig {
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
/** Shared mono PCM. Concatenate complete cycles, without duplicate end samples. */
|
|
6
|
+
sample: ResidentSample;
|
|
7
|
+
/** Samples per cycle, integer4..4096. */
|
|
8
|
+
frameLength: number;
|
|
9
|
+
/** Fixed number of morph frames, integer1..16; total must fit resident capacity. */
|
|
10
|
+
frameCount: number;
|
|
11
|
+
/** Initial/reset phase in cycles, [0,1). */
|
|
12
|
+
phaseCycles?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface WavetableControls {
|
|
15
|
+
/** Hz clipped to [0,.45*sampleRate], NaN becomes0. */
|
|
16
|
+
frequencyHz: Node<'f32'>;
|
|
17
|
+
/** Fractional frame index, clipped to [0,frameCount−1], NaN becomes0. */
|
|
18
|
+
frame: Node<'f32'>;
|
|
19
|
+
/** Level-sensitive phase reset. A resident revision also restarts phase. */
|
|
20
|
+
reset: Node<'bool'>;
|
|
21
|
+
}
|
|
22
|
+
/** Two periodic linear cycle interpolations and frame morph. No automatic bandlimit. */
|
|
23
|
+
export declare const wavetableSource: import("@unworklet/core").SubgraphDecl<[config: WavetableConfig], {
|
|
24
|
+
tick(c: WavetableControls): {
|
|
25
|
+
output: Node<"f32">;
|
|
26
|
+
missing: Node<"bool">;
|
|
27
|
+
};
|
|
28
|
+
}>;
|
|
29
|
+
export { bandedWavetableSource, prepareWavetableBands, type BandedWavetableConfig, type PreparedWavetableBands, type WavetableBandsOptions } from './wavetable-bands.js';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
import { readWavetableCycle } from './wavetable-read.js';
|
|
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
|
+
function bounded(value, high) {
|
|
8
|
+
return select(value.eq(value), f64(value), f64(0)).clamp(0, high);
|
|
9
|
+
}
|
|
10
|
+
/** Two periodic linear cycle interpolations and frame morph. No automatic bandlimit. */
|
|
11
|
+
export const wavetableSource = defineSubgraph((config) => {
|
|
12
|
+
const { sampleRate, sample, frameLength, frameCount, phaseCycles = 0 } = config;
|
|
13
|
+
if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000)
|
|
14
|
+
throw new RangeError('wavetable sampleRate must be in [8000,192000] Hz');
|
|
15
|
+
integer(sample.capacity, 4, 65536, 'resident capacity');
|
|
16
|
+
integer(frameLength, 4, 4096, 'frameLength');
|
|
17
|
+
integer(frameCount, 1, 16, 'frameCount');
|
|
18
|
+
const required = frameLength * frameCount;
|
|
19
|
+
if (required > sample.capacity)
|
|
20
|
+
throw new RangeError('wavetable frames must fit resident capacity');
|
|
21
|
+
if (!Number.isFinite(phaseCycles) || phaseCycles < 0 || phaseCycles >= 1)
|
|
22
|
+
throw new RangeError('phaseCycles must be in [0,1)');
|
|
23
|
+
// Phase accepts f64 configuration, including Number.MIN_VALUE: scaling
|
|
24
|
+
// by 2^1020 keeps even that value above the native 1e-30 flush. Phase
|
|
25
|
+
// is always <1 before scaling, leaving >16x finite headroom. Morph is
|
|
26
|
+
// f32 input and only needs 2^128; neither scaled value touches PCM.
|
|
27
|
+
const scale = 2 ** 1020, frameScale = 2 ** 128;
|
|
28
|
+
const phase = state.f64(phaseCycles * scale).named('scaledPhase'), revision = state.i32(-1).named('revision');
|
|
29
|
+
// Native materialization bounds capture size even when resident.read expands
|
|
30
|
+
// its four wrap/validation paths. These are ordinary same-schema snapshot slots.
|
|
31
|
+
const current = state.f64(phaseCycles * scale).named('scaledCurrentPhase'), scan = state.f64(0).named('scaledFrame');
|
|
32
|
+
const lower = state.i32(0).named('lowerStart'), upper = state.i32(0).named('upperStart');
|
|
33
|
+
return { tick(c) {
|
|
34
|
+
const changed = sample.revision().eq(revision.read()).not();
|
|
35
|
+
current.write(select(c.reset.or(changed), f64(phaseCycles * scale), phase.read()));
|
|
36
|
+
scan.write(bounded(c.frame, frameCount - 1).mul(frameScale));
|
|
37
|
+
const frame = scan.read().div(frameScale), p = current.read().div(scale);
|
|
38
|
+
lower.write(i32(frame.floor()).mul(frameLength));
|
|
39
|
+
upper.write(i32(frame.floor()).add(1).min(frameCount - 1).mul(frameLength));
|
|
40
|
+
const a = readWavetableCycle(sample, p, lower.read(), frameLength);
|
|
41
|
+
const b = readWavetableCycle(sample, p, upper.read(), frameLength);
|
|
42
|
+
const missing = sample.length().lt(required), mix = frame.sub(frame.floor());
|
|
43
|
+
const output = select(missing, f32(0), f32(f64(a).mul(f64(1).sub(mix)).add(f64(b).mul(mix))));
|
|
44
|
+
phase.write(p.add(bounded(c.frequencyHz, .45 * sampleRate).div(sampleRate)).frac().mul(scale));
|
|
45
|
+
revision.write(sample.revision());
|
|
46
|
+
return { output, missing };
|
|
47
|
+
} };
|
|
48
|
+
});
|
|
49
|
+
export { bandedWavetableSource, prepareWavetableBands } from './wavetable-bands.js';
|