@denaudio/den 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +63 -0
- package/LICENSE-APACHE +202 -0
- package/LICENSE-MIT +21 -0
- package/README.md +107 -2
- package/dist/biquad-eq.d.ts +11 -0
- package/dist/biquad-eq.js +35 -0
- package/dist/catalog-filter-math.d.ts +4 -0
- package/dist/catalog-filter-math.js +30 -0
- package/dist/character-filter.d.ts +13 -0
- package/dist/character-filter.js +53 -0
- package/dist/convolution-spectrum.d.ts +25 -0
- package/dist/convolution-spectrum.js +82 -0
- package/dist/convolution.d.ts +17 -0
- package/dist/convolution.js +87 -0
- package/dist/crossover.d.ts +11 -0
- package/dist/crossover.js +17 -0
- package/dist/curve-shaper.d.ts +26 -0
- package/dist/curve-shaper.js +73 -0
- package/dist/delay-fx.d.ts +37 -0
- package/dist/delay-fx.js +84 -0
- package/dist/delay-readhead.d.ts +19 -0
- package/dist/delay-readhead.js +54 -0
- package/dist/delay-settings.d.ts +55 -0
- package/dist/delay-settings.js +20 -0
- package/dist/drive.d.ts +24 -0
- package/dist/drive.js +116 -0
- package/dist/dual-head-delay.d.ts +28 -0
- package/dist/dual-head-delay.js +77 -0
- package/dist/dynamics.d.ts +61 -0
- package/dist/dynamics.js +130 -0
- package/dist/envelope.d.ts +34 -0
- package/dist/envelope.js +62 -0
- package/dist/filter.d.ts +9 -0
- package/dist/filter.js +34 -0
- package/dist/filterbank-vocoder.d.ts +35 -0
- package/dist/filterbank-vocoder.js +52 -0
- package/dist/formant-bank.d.ts +15 -0
- package/dist/formant-bank.js +28 -0
- package/dist/freeze-reverb.d.ts +35 -0
- package/dist/freeze-reverb.js +61 -0
- package/dist/frequency-shifter.d.ts +24 -0
- package/dist/frequency-shifter.js +57 -0
- package/dist/gate.d.ts +2 -0
- package/dist/gate.js +12 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +12 -0
- package/dist/instrument-example.d.ts +12 -0
- package/dist/instrument-example.js +29 -0
- package/dist/instrument-settings.d.ts +95 -0
- package/dist/instrument-settings.js +36 -0
- package/dist/instrument.d.ts +42 -0
- package/dist/instrument.js +166 -0
- package/dist/lfo.d.ts +19 -0
- package/dist/lfo.js +55 -0
- package/dist/live-buffer.d.ts +39 -0
- package/dist/live-buffer.js +52 -0
- package/dist/live-granular.d.ts +41 -0
- package/dist/live-granular.js +64 -0
- package/dist/lookahead-limiter.d.ts +31 -0
- package/dist/lookahead-limiter.js +67 -0
- package/dist/loop-crossfade.d.ts +28 -0
- package/dist/loop-crossfade.js +72 -0
- package/dist/modulation-fx.d.ts +47 -0
- package/dist/modulation-fx.js +58 -0
- package/dist/modulation.d.ts +132 -0
- package/dist/modulation.js +228 -0
- package/dist/mpe-expression.d.ts +36 -0
- package/dist/mpe-expression.js +73 -0
- package/dist/multiband-dynamics.d.ts +50 -0
- package/dist/multiband-dynamics.js +60 -0
- package/dist/musical-controls.d.ts +76 -0
- package/dist/musical-controls.js +125 -0
- package/dist/musical-examples.d.ts +21 -0
- package/dist/musical-examples.js +142 -0
- package/dist/musical-materials.d.ts +110 -0
- package/dist/musical-materials.js +56 -0
- package/dist/oscillator.d.ts +10 -0
- package/dist/oscillator.js +35 -0
- package/dist/oversampled-drive.d.ts +19 -0
- package/dist/oversampled-drive.js +99 -0
- package/dist/performance.d.ts +61 -0
- package/dist/performance.js +241 -0
- package/dist/pitch-quantizer.d.ts +26 -0
- package/dist/pitch-quantizer.js +88 -0
- package/dist/prepared-convolution.d.ts +26 -0
- package/dist/prepared-convolution.js +104 -0
- package/dist/resident-recorder.d.ts +28 -0
- package/dist/resident-recorder.js +68 -0
- package/dist/resident-time-stretch.d.ts +46 -0
- package/dist/resident-time-stretch.js +136 -0
- package/dist/resonator.d.ts +32 -0
- package/dist/resonator.js +63 -0
- package/dist/reverb.d.ts +27 -0
- package/dist/reverb.js +52 -0
- package/dist/sample.d.ts +113 -0
- package/dist/sample.js +212 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +113 -0
- package/dist/spatial-chains.d.ts +50 -0
- package/dist/spatial-chains.js +76 -0
- package/dist/spectral-fft.d.ts +27 -0
- package/dist/spectral-fft.js +84 -0
- package/dist/spectral-freeze.d.ts +24 -0
- package/dist/spectral-freeze.js +74 -0
- package/dist/spectral-gate.d.ts +23 -0
- package/dist/spectral-gate.js +58 -0
- package/dist/spectral-stft.d.ts +14 -0
- package/dist/spectral-stft.js +70 -0
- package/dist/spectral-texture.d.ts +46 -0
- package/dist/spectral-texture.js +141 -0
- package/dist/spectral.d.ts +18 -0
- package/dist/spectral.js +10 -0
- package/dist/state-variable-filter.d.ts +17 -0
- package/dist/state-variable-filter.js +25 -0
- package/dist/stereo-delay.d.ts +45 -0
- package/dist/stereo-delay.js +70 -0
- package/dist/virtual-analog.d.ts +26 -0
- package/dist/virtual-analog.js +66 -0
- package/dist/voice-policy.d.ts +37 -0
- package/dist/voice-policy.js +245 -0
- package/dist/wavetable-bands.d.ts +37 -0
- package/dist/wavetable-bands.js +145 -0
- package/dist/wavetable-read.d.ts +6 -0
- package/dist/wavetable-read.js +11 -0
- package/dist/wavetable.d.ts +29 -0
- package/dist/wavetable.js +49 -0
- package/dist/windowed-pitch-shift.d.ts +34 -0
- package/dist/windowed-pitch-shift.js +51 -0
- package/docs/catalog-audition.md +76 -0
- package/docs/catalog-filters.md +125 -0
- package/docs/catalog-modulation-fx.md +220 -0
- package/docs/catalog-status.md +433 -0
- package/docs/character-filter.md +139 -0
- package/docs/contracts.md +141 -0
- package/docs/control-modulation.md +194 -0
- package/docs/convolution.md +50 -0
- package/docs/curve-shaper.md +117 -0
- package/docs/delay-fx-capture-findings.json +134 -0
- package/docs/delay-fx.md +202 -0
- package/docs/delay-readhead.md +121 -0
- package/docs/delay-settings-candidate.json +333 -0
- package/docs/delay-settings.md +111 -0
- package/docs/dependency-findings.md +58 -0
- package/docs/deployment-status.md +37 -0
- package/docs/drive.md +152 -0
- package/docs/dual-head-delay.md +131 -0
- package/docs/dynamics.md +180 -0
- package/docs/envelope-integer-staging.md +64 -0
- package/docs/filter-materialization-results.json +71 -0
- package/docs/filter-materialization.md +135 -0
- package/docs/filter-oscillator.md +126 -0
- package/docs/filterbank-vocoder-entry.md +58 -0
- package/docs/freeze-reverb.md +179 -0
- package/docs/frequency-shifter.md +133 -0
- package/docs/frontend-audition.md +22 -0
- package/docs/gain-schedule-verification.md +53 -0
- package/docs/initial-acceptance.md +79 -0
- package/docs/instrument-performance.md +191 -0
- package/docs/instrument-settings.md +130 -0
- package/docs/instrument.md +204 -0
- package/docs/integration-candidate.md +114 -0
- package/docs/live-buffer.md +163 -0
- package/docs/live-granular.md +179 -0
- package/docs/lookahead-multiband.md +183 -0
- package/docs/loop-crossfade.md +99 -0
- package/docs/mpe-expression.md +65 -0
- package/docs/musical-clock-seek-boundary.md +31 -0
- package/docs/musical-controls-contract.md +144 -0
- package/docs/musical-examples.md +255 -0
- package/docs/oversampled-drive.md +153 -0
- package/docs/performance.md +53 -0
- package/docs/pitch-quantizer.md +55 -0
- package/docs/prepared-convolution.md +197 -0
- package/docs/realtime-audition.md +63 -0
- package/docs/resident-recorder.md +151 -0
- package/docs/resident-time-stretch.md +196 -0
- package/docs/sample.md +191 -0
- package/docs/sound-audition-headroom.md +103 -0
- package/docs/sources.md +81 -0
- package/docs/spatial-chains-entry.md +251 -0
- package/docs/spectral-freeze-entry.md +132 -0
- package/docs/spectral-gate-entry.md +134 -0
- package/docs/spectral-large-candidate.md +81 -0
- package/docs/spectral-texture-entry.md +178 -0
- package/docs/spectral.md +127 -0
- package/docs/virtual-analog.md +77 -0
- package/docs/voice-policy.md +38 -0
- package/docs/wavetable-bands.md +138 -0
- package/docs/wavetable.md +101 -0
- package/docs/windowed-pitch-shift.md +112 -0
- package/package.json +269 -4
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
const PHASE_SCALE = 2 ** 128;
|
|
3
|
+
function integer(value, low, high, name) {
|
|
4
|
+
if (!Number.isInteger(value) || value < low || value > high)
|
|
5
|
+
throw new RangeError(`${name} must be an integer in [${low},${high}]`);
|
|
6
|
+
}
|
|
7
|
+
// Preserve the local fraction before adding an integer slice/head offset. An
|
|
8
|
+
// absolute position addition would erase tiny phase * full-finite-PCM products.
|
|
9
|
+
// Resident reads retain the existing ingress, bounds and per-tap sanitization.
|
|
10
|
+
function localRead(sample, local, start, end, loop) {
|
|
11
|
+
const whole = i32(local.floor()), fraction = local.sub(f64(whole));
|
|
12
|
+
const first = start.add(whole).min(end.sub(1).max(start));
|
|
13
|
+
const next = select(first.add(1).gte(end), select(loop, start, first), first.add(1));
|
|
14
|
+
const a = sample.read(f64(first), start, end, false), b = sample.read(f64(next), start, end, false);
|
|
15
|
+
return f32(f64(a).mul(f64(1).sub(fraction)).add(f64(b).mul(fraction)));
|
|
16
|
+
}
|
|
17
|
+
/** One mono cyclic waveform with a unity-sum linear tail/head overlap. Shortens
|
|
18
|
+
* the source period from L to L-F; reverse traverses that same waveform backward.
|
|
19
|
+
* Not a dry-attack, time-stretching, antialiasing or arbitrary-content seam promise. */
|
|
20
|
+
export const crossfadedLoopPlayer = defineSubgraph((config) => {
|
|
21
|
+
if (!Number.isFinite(config.sampleRate) || config.sampleRate < 8000 || config.sampleRate > 192000)
|
|
22
|
+
throw new RangeError('sampleRate must be in [8000,192000] Hz');
|
|
23
|
+
const { sample, startFrame = 0, endFrame = sample.capacity, crossfadeFrames, releaseFrames = 0 } = config;
|
|
24
|
+
integer(startFrame, 0, sample.capacity - 1, 'startFrame');
|
|
25
|
+
integer(endFrame, startFrame + 1, sample.capacity, 'endFrame');
|
|
26
|
+
integer(crossfadeFrames, 0, Math.floor((endFrame - startFrame) / 2), 'crossfadeFrames');
|
|
27
|
+
integer(releaseFrames, 0, Math.ceil(config.sampleRate * 10), 'releaseFrames');
|
|
28
|
+
const phase = state.f64(0).named('scaledPhase'), active = state.bool(false).named('active');
|
|
29
|
+
const previousGate = state.bool(false).named('previousGate'), revision = state.i32(-1).named('revision');
|
|
30
|
+
const remaining = state.i32(0).named('releaseRemaining');
|
|
31
|
+
// Transient materialization keeps repeated resident read graphs bounded. Only
|
|
32
|
+
// persistent phase/gate/release/revision state is required across snapshots.
|
|
33
|
+
const current = state.f64(0).expose({ name: 'scaledCurrentPhase', snapshot: 'transient' });
|
|
34
|
+
const speedState = state.f64(0).expose({ name: 'scaledSpeed', snapshot: 'transient' });
|
|
35
|
+
const endState = state.i32(0).expose({ name: 'end', snapshot: 'transient' });
|
|
36
|
+
const overlapState = state.i32(0).expose({ name: 'overlap', snapshot: 'transient' });
|
|
37
|
+
const periodState = state.i32(0).expose({ name: 'period', snapshot: 'transient' });
|
|
38
|
+
return { tick(c) {
|
|
39
|
+
endState.write(sample.length().min(endFrame));
|
|
40
|
+
const end = endState.read(), start = i32(startFrame), length = end.sub(start).max(0), available = length.gt(0);
|
|
41
|
+
overlapState.write(length.div(2).min(crossfadeFrames));
|
|
42
|
+
periodState.write(length.sub(overlapState.read()));
|
|
43
|
+
const overlap = overlapState.read(), period = periodState.read();
|
|
44
|
+
speedState.write(select(c.rate.eq(c.rate), f64(c.rate), f64(0)).clamp(-16, 16).mul(sample.sourceSampleRate / config.sampleRate).mul(PHASE_SCALE));
|
|
45
|
+
const speed = speedState.read().div(PHASE_SCALE), changed = sample.revision().eq(revision.read()).not();
|
|
46
|
+
// A load is NOT a gate rise: evaluate against the actual prior gate first.
|
|
47
|
+
const trigger = c.trigger.or(c.gate.and(previousGate.read().not())).and(c.gate).and(c.reset.not()).and(available);
|
|
48
|
+
const alive = select(trigger, bool(true), active.read().and(changed.not())).and(c.reset.not()).and(available);
|
|
49
|
+
const initial = select(speed.lt(0), f64(period.sub(1).max(0)), f64(0));
|
|
50
|
+
current.write(select(trigger, initial, select(c.reset.or(changed).or(available.not()), f64(0), phase.read().div(PHASE_SCALE))).mul(PHASE_SCALE));
|
|
51
|
+
const q = current.read().div(PHASE_SCALE);
|
|
52
|
+
const releaseLeft = select(trigger, i32(releaseFrames), select(c.gate, remaining.read(), remaining.read().sub(1).max(0)));
|
|
53
|
+
const gain = releaseFrames > 0 ? f64(releaseLeft).div(releaseFrames) : select(c.gate, f64(1), f64(0));
|
|
54
|
+
active.write(alive.and(gain.gt(0)));
|
|
55
|
+
const playing = active.read(), primaryStart = start.add(overlap);
|
|
56
|
+
const tail = localRead(sample, q, primaryStart, end, overlap.eq(0));
|
|
57
|
+
const headPhase = q.sub(f64(period.sub(overlap))).max(0);
|
|
58
|
+
const head = localRead(sample, headPhase, start, end, bool(false));
|
|
59
|
+
const weight = select(overlap.gt(0), headPhase.div(f64(overlap.max(1))), f64(0));
|
|
60
|
+
const mixed = f32(f64(tail).mul(f64(1).sub(weight)).add(f64(head).mul(weight)));
|
|
61
|
+
const output = select(playing, f32(f64(mixed).mul(gain)), f32(0));
|
|
62
|
+
const advanced = q.add(speed), divisor = f64(period.max(1));
|
|
63
|
+
const wrapped = advanced.sub(advanced.div(divisor).floor().mul(divisor));
|
|
64
|
+
// Negative tiny increments can round a remainder to the upper endpoint.
|
|
65
|
+
const next = select(wrapped.gte(divisor), f64(0), wrapped.max(0));
|
|
66
|
+
phase.write(select(playing, next, q).mul(PHASE_SCALE));
|
|
67
|
+
remaining.write(select(playing, releaseLeft, i32(0)));
|
|
68
|
+
previousGate.write(c.gate.and(c.reset.not()));
|
|
69
|
+
revision.write(sample.revision());
|
|
70
|
+
return { output, active: playing, position: f32(f64(primaryStart).add(q)), phase: f32(q), periodFrames: period, crossfadeFrames: overlap, missing: available.not() };
|
|
71
|
+
} };
|
|
72
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Node } from '@unworklet/core';
|
|
2
|
+
export interface FlangerConfig {
|
|
3
|
+
sampleRate: number;
|
|
4
|
+
/** Fixed capacity in seconds, [one sample, 0.02]. Default 0.02. */
|
|
5
|
+
maxDelaySeconds?: number;
|
|
6
|
+
/** Right LFO phase relative to left, in cycles. Default 0.5. */
|
|
7
|
+
stereoPhaseCycles?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FlangerControls {
|
|
10
|
+
delaySeconds: Node<'f32'>;
|
|
11
|
+
depthSeconds: Node<'f32'>;
|
|
12
|
+
rateHz: Node<'f32'>;
|
|
13
|
+
/** Positive feedback, clamped to [0, 0.95]. */
|
|
14
|
+
feedback: Node<'f32'>;
|
|
15
|
+
mix: Node<'f32'>;
|
|
16
|
+
bypass: Node<'bool'>;
|
|
17
|
+
reset: Node<'bool'>;
|
|
18
|
+
}
|
|
19
|
+
/** Stereo moving-head comb/flanger. Call once per sample; finite inputs required. */
|
|
20
|
+
export declare const flanger: import("@unworklet/core").SubgraphDecl<[config: FlangerConfig], {
|
|
21
|
+
tick(left: Node<"f32">, right: Node<"f32">, c: FlangerControls): {
|
|
22
|
+
left: Node<"f32">;
|
|
23
|
+
right: Node<"f32">;
|
|
24
|
+
timingRejected: Node<"bool">;
|
|
25
|
+
modulationClipped: Node<"bool">;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
export interface PhaserConfig {
|
|
29
|
+
sampleRate: number;
|
|
30
|
+
/** Fixed number of normalized first-order allpasses. Default 4. */
|
|
31
|
+
stages?: 2 | 4 | 6 | 8;
|
|
32
|
+
}
|
|
33
|
+
export interface PhaserControls {
|
|
34
|
+
/** Per-stage -90 degree frequency. Clamped to [20, 0.45 * sampleRate]. */
|
|
35
|
+
frequencyHz: Node<'f32'>;
|
|
36
|
+
/** One-sample-delayed wet feedback, clamped to [-0.95, 0.95]. */
|
|
37
|
+
feedback: Node<'f32'>;
|
|
38
|
+
mix: Node<'f32'>;
|
|
39
|
+
bypass: Node<'bool'>;
|
|
40
|
+
reset: Node<'bool'>;
|
|
41
|
+
}
|
|
42
|
+
/** Mono allpass phaser, not a delay preset. Compose two instances for stereo.
|
|
43
|
+
* Normalized lattice scattering preserves per-sample energy under cutoff motion.
|
|
44
|
+
*/
|
|
45
|
+
export declare const phaser: import("@unworklet/core").SubgraphDecl<[config: PhaserConfig], {
|
|
46
|
+
tick(input: Node<"f32">, c: PhaserControls): Node<"f32">;
|
|
47
|
+
}>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineSubgraph, f32, f64, instantiate, select, sqrt, state } from '@unworklet/core';
|
|
2
|
+
import { delayFx } from './delay-fx.js';
|
|
3
|
+
/** Stereo moving-head comb/flanger. Call once per sample; finite inputs required. */
|
|
4
|
+
export const flanger = defineSubgraph((config) => {
|
|
5
|
+
const maxDelaySeconds = config.maxDelaySeconds ?? 0.02;
|
|
6
|
+
if (!Number.isFinite(maxDelaySeconds) || maxDelaySeconds < 1 / config.sampleRate || maxDelaySeconds > 0.02) {
|
|
7
|
+
throw new RangeError('flanger capacity must be between one sample and 0.02 seconds');
|
|
8
|
+
}
|
|
9
|
+
const engine = instantiate(delayFx, { ...config, maxDelaySeconds, tone: 'flat' }, { name: 'delay' });
|
|
10
|
+
return {
|
|
11
|
+
tick(left, right, c) {
|
|
12
|
+
return engine.tick(left, right, {
|
|
13
|
+
timeLeftSeconds: c.delaySeconds, timeRightSeconds: c.delaySeconds,
|
|
14
|
+
sync: f32(0).gt(1), bpm: f32(120), beatsLeft: f32(1), beatsRight: f32(1),
|
|
15
|
+
feedback: c.feedback, cutoffHz: f32(1000), mix: c.mix,
|
|
16
|
+
rateHz: c.rateHz, depthSeconds: c.depthSeconds,
|
|
17
|
+
bypass: c.bypass, reset: c.reset,
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
/** Mono allpass phaser, not a delay preset. Compose two instances for stereo.
|
|
23
|
+
* Normalized lattice scattering preserves per-sample energy under cutoff motion.
|
|
24
|
+
*/
|
|
25
|
+
export const phaser = defineSubgraph((config) => {
|
|
26
|
+
const { sampleRate, stages = 4 } = config;
|
|
27
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000 || ![2, 4, 6, 8].includes(stages)) {
|
|
28
|
+
throw new RangeError('phaser requires an integer sample rate in [8000,192000] and 2/4/6/8 stages');
|
|
29
|
+
}
|
|
30
|
+
const history = Array.from({ length: stages }, (_, i) => state.f64(0).named(`stage${i}`));
|
|
31
|
+
const signal = state.f64(0).expose({ name: 'signal', snapshot: 'transient' });
|
|
32
|
+
const coefficient = state.f64(0).expose({ name: 'coefficient', snapshot: 'transient' });
|
|
33
|
+
const coupling = state.f64(0).expose({ name: 'coupling', snapshot: 'transient' });
|
|
34
|
+
const feedback = state.f64(0).named('feedback');
|
|
35
|
+
return {
|
|
36
|
+
tick(input, c) {
|
|
37
|
+
const angle = f64(c.frequencyHz).clamp(20, 0.45 * sampleRate).mul(Math.PI / sampleRate);
|
|
38
|
+
const z = angle.mul(angle);
|
|
39
|
+
// Degree 13/14 sine/cosine on [0, .45*pi], as in the existing SVF.
|
|
40
|
+
const sine = angle.mul(f64(-1 / 6227020800).mul(z).add(1 / 39916800).mul(z).sub(1 / 362880).mul(z).add(1 / 5040).mul(z).sub(1 / 120).mul(z).add(1 / 6).mul(z).neg().add(1));
|
|
41
|
+
const cosine = f64(-1 / 87178291200).mul(z).add(1 / 479001600).mul(z).sub(1 / 3628800).mul(z).add(1 / 40320).mul(z).sub(1 / 720).mul(z).add(1 / 24).mul(z).sub(1 / 2).mul(z).add(1);
|
|
42
|
+
coefficient.write(sine.sub(cosine).div(sine.add(cosine)).clamp(-0.999999, 0.999999));
|
|
43
|
+
const a = coefficient.read();
|
|
44
|
+
coupling.write(sqrt(f64(1).sub(a.mul(a)).clamp(0, 1)));
|
|
45
|
+
const b = coupling.read();
|
|
46
|
+
signal.write(f64(select(c.bypass, f32(0), input)).add(select(c.reset, f64(0), feedback.read()).mul(f64(c.feedback).clamp(-0.95, 0.95))));
|
|
47
|
+
for (const slot of history) {
|
|
48
|
+
const x = signal.read(), previous = select(c.reset, f64(0), slot.read());
|
|
49
|
+
signal.write(a.mul(x).add(b.mul(previous)));
|
|
50
|
+
slot.write(b.mul(x).sub(a.mul(previous)));
|
|
51
|
+
}
|
|
52
|
+
const wet = signal.read();
|
|
53
|
+
feedback.write(wet);
|
|
54
|
+
const mix = f64(c.mix).clamp(0, 1);
|
|
55
|
+
return select(c.bypass, input, f32(f64(input).mul(f64(1).sub(mix)).add(wet.mul(mix))));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { type MidiOutputHandle, type Node } from '@unworklet/core';
|
|
2
|
+
export type ClockMode = 'free' | 'tempo';
|
|
3
|
+
export interface MusicalClockConfig {
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
mode: ClockMode;
|
|
6
|
+
/** Fixed cycle length, 1..64 steps. */
|
|
7
|
+
steps: number;
|
|
8
|
+
/** Tempo mode only: power of two in [1/64,64], default 1. */
|
|
9
|
+
stepsPerBeat?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface MusicalClockControls {
|
|
12
|
+
/** Free mode: steps/second. Tempo mode: BPM. Clamped to [0,1000]. */
|
|
13
|
+
rate: Node<'f32'>;
|
|
14
|
+
/** Hold at step/phase zero with no tick. First reset-low sample ticks. */
|
|
15
|
+
reset: Node<'bool'>;
|
|
16
|
+
/** Rising edge seeks; held high does not pin or retrigger. Reset consumes it. */
|
|
17
|
+
seek: Node<'bool'>;
|
|
18
|
+
/** Steps, clamped to +/-1048576 then wrapped into the fixed cycle. */
|
|
19
|
+
position: Node<'f32'>;
|
|
20
|
+
}
|
|
21
|
+
export interface MusicalClockOutput {
|
|
22
|
+
step: Node<'f32'>;
|
|
23
|
+
/** Fraction of the current step, in [0,1). */
|
|
24
|
+
phase: Node<'f32'>;
|
|
25
|
+
/** Initial sample, reset release, seek edge, or a step boundary. */
|
|
26
|
+
tick: Node<'bool'>;
|
|
27
|
+
}
|
|
28
|
+
export interface SequenceStep {
|
|
29
|
+
value: number;
|
|
30
|
+
gate: number;
|
|
31
|
+
}
|
|
32
|
+
export interface StepSequenceConfig extends Omit<MusicalClockConfig, 'steps'> {
|
|
33
|
+
/** Fixed 1..64 steps; values [-1,1], gate fractions [0,1]. */
|
|
34
|
+
steps: readonly SequenceStep[];
|
|
35
|
+
}
|
|
36
|
+
export interface StepSequenceOutput extends MusicalClockOutput {
|
|
37
|
+
value: Node<'f32'>;
|
|
38
|
+
gate: Node<'bool'>;
|
|
39
|
+
}
|
|
40
|
+
export type MsegCurve = 'linear' | 'easeIn' | 'easeOut' | 'smoothstep';
|
|
41
|
+
export interface MsegSegment {
|
|
42
|
+
seconds: number;
|
|
43
|
+
target: number;
|
|
44
|
+
curve: MsegCurve;
|
|
45
|
+
}
|
|
46
|
+
export interface MsegConfig {
|
|
47
|
+
sampleRate: number;
|
|
48
|
+
initial: number;
|
|
49
|
+
/** Fixed 1..16 segments; durations [0,30] s, targets [-1,1]. */
|
|
50
|
+
segments: readonly MsegSegment[];
|
|
51
|
+
/** Whole envelope repeats; the final target need not match initial. */
|
|
52
|
+
loop?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export interface MsegControls {
|
|
55
|
+
trigger: Node<'bool'>;
|
|
56
|
+
reset: Node<'bool'>;
|
|
57
|
+
}
|
|
58
|
+
export interface MsegOutput {
|
|
59
|
+
value: Node<'f32'>;
|
|
60
|
+
done: Node<'bool'>;
|
|
61
|
+
segment: Node<'f32'>;
|
|
62
|
+
}
|
|
63
|
+
export interface SampleAndHoldConfig {
|
|
64
|
+
initial: number;
|
|
65
|
+
}
|
|
66
|
+
export interface RandomModulatorConfig extends SampleAndHoldConfig {
|
|
67
|
+
seed: number;
|
|
68
|
+
}
|
|
69
|
+
export interface RandomModulatorControls {
|
|
70
|
+
/** Level-sensitive sample request: advances once on every high sample. */
|
|
71
|
+
trigger: Node<'bool'>;
|
|
72
|
+
reset: Node<'bool'>;
|
|
73
|
+
/** rho in rho*previous+(1-rho)*uniform; clamped [0,1]. */
|
|
74
|
+
correlation: Node<'f32'>;
|
|
75
|
+
}
|
|
76
|
+
/** Sample clock, no transport or event scheduler. Call exactly once per sample. */
|
|
77
|
+
export declare const musicalClock: import("@unworklet/core").SubgraphDecl<[config: MusicalClockConfig], {
|
|
78
|
+
tick(c: MusicalClockControls): MusicalClockOutput;
|
|
79
|
+
}>;
|
|
80
|
+
/** Fixed step values and gate fractions; no MIDI generation or smoothing. */
|
|
81
|
+
export declare const stepSequence: import("@unworklet/core").SubgraphDecl<[config: StepSequenceConfig], {
|
|
82
|
+
tick(c: MusicalClockControls): StepSequenceOutput;
|
|
83
|
+
}>;
|
|
84
|
+
/**
|
|
85
|
+
* Fixed piecewise envelope. Rising trigger emits the first duration step;
|
|
86
|
+
* held trigger does not retrigger. Reset consumes trigger and emits initial.
|
|
87
|
+
* Zero-length segments collapse at their boundary in list order. Loop jumps
|
|
88
|
+
* from last target to the first segment's first step on the next sample.
|
|
89
|
+
*/
|
|
90
|
+
export declare const mseg: import("@unworklet/core").SubgraphDecl<[config: MsegConfig], {
|
|
91
|
+
tick(c: MsegControls): MsegOutput;
|
|
92
|
+
}>;
|
|
93
|
+
/** Level-sensitive sampling; full finite f32 range. Native state maps -0 to +0. */
|
|
94
|
+
export declare const sampleAndHold: import("@unworklet/core").SubgraphDecl<[config: SampleAndHoldConfig], {
|
|
95
|
+
tick(input: Node<"f32">, trigger: Node<"bool">, reset: Node<"bool">): Node<"f32">;
|
|
96
|
+
}>;
|
|
97
|
+
/**
|
|
98
|
+
* Park-Miller seeded S&H / bounded correlated random. Each high trigger advances
|
|
99
|
+
* the PRNG, including correlation=1. Reset wins, restores seed/initial, and
|
|
100
|
+
* never advances. This is a convex random recurrence, not normalized Gaussian
|
|
101
|
+
* noise or a specified spectral color. All dynamic inputs must be finite.
|
|
102
|
+
*/
|
|
103
|
+
export declare const randomModulator: import("@unworklet/core").SubgraphDecl<[config: RandomModulatorConfig], {
|
|
104
|
+
tick(c: RandomModulatorControls): Node<"f32">;
|
|
105
|
+
}>;
|
|
106
|
+
export interface ArpeggiatorNote {
|
|
107
|
+
note: number;
|
|
108
|
+
velocity: number;
|
|
109
|
+
gate: number;
|
|
110
|
+
}
|
|
111
|
+
export interface FixedArpeggiatorConfig extends Omit<MusicalClockConfig, 'steps'> {
|
|
112
|
+
channel: number;
|
|
113
|
+
/** Fixed order, 1..64 notes. gate=0 is a rest; no incoming chord capture. */
|
|
114
|
+
notes: readonly ArpeggiatorNote[];
|
|
115
|
+
}
|
|
116
|
+
export interface FixedArpeggiatorControls extends MusicalClockControls {
|
|
117
|
+
/** Native unsigned 32-bit absolute timestamp bits, carried by an i32 node. */
|
|
118
|
+
atSample: Node<'i32'>;
|
|
119
|
+
}
|
|
120
|
+
export interface FixedArpeggiatorOutput extends MusicalClockOutput {
|
|
121
|
+
note: Node<'f32'>;
|
|
122
|
+
gate: Node<'bool'>;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Bounded fixed-note arp using a caller-owned native MIDI output. At most one
|
|
126
|
+
* off then one on per sample. Output capacity >=256 plus per-quantum draining
|
|
127
|
+
* is required for the conservative burst bound, not guaranteed host delivery.
|
|
128
|
+
* Rate zero holds a sounding note; reset before stopping/disconnecting.
|
|
129
|
+
*/
|
|
130
|
+
export declare const fixedArpeggiator: import("@unworklet/core").SubgraphDecl<[config: FixedArpeggiatorConfig], {
|
|
131
|
+
tick(output: MidiOutputHandle, c: FixedArpeggiatorControls): FixedArpeggiatorOutput;
|
|
132
|
+
}>;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
|
|
2
|
+
function checkRate(rate) {
|
|
3
|
+
if (!Number.isInteger(rate) || rate < 8000 || rate > 192000)
|
|
4
|
+
throw new RangeError('modulation sampleRate must be an integer in [8000,192000]');
|
|
5
|
+
}
|
|
6
|
+
function boundedNumber(value, lo, hi, name) {
|
|
7
|
+
if (!Number.isFinite(value) || value < lo || value > hi)
|
|
8
|
+
throw new RangeError(`${name} must be finite in [${lo},${hi}]`);
|
|
9
|
+
}
|
|
10
|
+
function checkClock(config) {
|
|
11
|
+
checkRate(config.sampleRate);
|
|
12
|
+
if (config.mode !== 'free' && config.mode !== 'tempo')
|
|
13
|
+
throw new RangeError('clock mode must be free or tempo');
|
|
14
|
+
if (!Number.isInteger(config.steps) || config.steps < 1 || config.steps > 64)
|
|
15
|
+
throw new RangeError('clock steps must be an integer in [1,64]');
|
|
16
|
+
const division = config.stepsPerBeat ?? 1;
|
|
17
|
+
if (!Number.isFinite(division) || division < 1 / 64 || division > 64 || !Number.isInteger(Math.log2(division))) {
|
|
18
|
+
throw new RangeError('stepsPerBeat must be a power of two in [1/64,64]');
|
|
19
|
+
}
|
|
20
|
+
if (config.mode === 'free' && division !== 1)
|
|
21
|
+
throw new RangeError('stepsPerBeat applies only to tempo mode');
|
|
22
|
+
}
|
|
23
|
+
// Core 0.4.1 flushes tiny scalar-state writes. Exact power-of-two scaling
|
|
24
|
+
// preserves every finite f32 control, including subnormals, without a new
|
|
25
|
+
// amplitude threshold. Even the largest finite f32 input remains finite here.
|
|
26
|
+
const SCALE = 2 ** 128;
|
|
27
|
+
const PHASE_MAX = 1 - 2 ** -24;
|
|
28
|
+
const clockCore = defineSubgraph((config) => {
|
|
29
|
+
checkClock(config);
|
|
30
|
+
const threshold = config.sampleRate * (config.mode === 'tempo' ? 60 : 1);
|
|
31
|
+
const division = config.mode === 'tempo' ? config.stepsPerBeat ?? 1 : 1;
|
|
32
|
+
const lastPosition = config.steps - 2 ** (Math.ceil(Math.log2(config.steps)) - 53);
|
|
33
|
+
const position = state.f64(0).named('positionScaled');
|
|
34
|
+
const error = state.f64(0).named('errorScaled');
|
|
35
|
+
const step = state.i32(0).named('step');
|
|
36
|
+
const pending = state.bool(true).named('pending');
|
|
37
|
+
const previousSeek = state.bool(false).named('previousSeek');
|
|
38
|
+
// Explicit materialization keeps capture/compile work bounded for composed
|
|
39
|
+
// consumers, rather than expanding shared arithmetic recursively.
|
|
40
|
+
const current = state.f64(0).named('currentScaled');
|
|
41
|
+
const increment = state.f64(0).named('incrementScaled');
|
|
42
|
+
const sum = state.f64(0).named('sumScaled');
|
|
43
|
+
const nextError = state.f64(0).named('nextErrorScaled');
|
|
44
|
+
return {
|
|
45
|
+
tick(c) {
|
|
46
|
+
const seek = c.seek.and(previousSeek.read().not()).and(c.reset.not());
|
|
47
|
+
const request = f64(c.position.clamp(-1048576, 1048576));
|
|
48
|
+
const wrapped = request.sub(request.div(config.steps).floor().mul(config.steps)).min(lastPosition);
|
|
49
|
+
const targetStep = wrapped.floor();
|
|
50
|
+
const oldStep = select(c.reset, i32(0), select(seek, i32(targetStep), step.read()));
|
|
51
|
+
current.write(select(c.reset, f64(0), select(seek, wrapped.sub(targetStep).mul(threshold * SCALE), position.read())));
|
|
52
|
+
const compensation = select(c.reset.or(seek), f64(0), error.read());
|
|
53
|
+
// A positive compensation at an exactly rounded threshold means the
|
|
54
|
+
// true position is still below the boundary. Preserve that distinction
|
|
55
|
+
// for gate=1 and crossing decisions (no epsilon or early sample tick).
|
|
56
|
+
const phase = current.read().sub(compensation).div(threshold * SCALE).clamp(0, 1 - 2 ** -53);
|
|
57
|
+
const pulse = c.reset.not().and(seek.or(pending.read()));
|
|
58
|
+
// Accumulate frequency units, not rounded samples-per-step or repeated
|
|
59
|
+
// fractional periods. Compensated summation preserves fractional tempos.
|
|
60
|
+
increment.write(f64(c.rate.clamp(0, 1000)).mul(division * SCALE).sub(compensation));
|
|
61
|
+
sum.write(current.read().add(increment.read()));
|
|
62
|
+
nextError.write(sum.read().sub(current.read()).sub(increment.read()));
|
|
63
|
+
const crossed = sum.read().gt(threshold * SCALE).or(sum.read().eq(threshold * SCALE).and(nextError.read().lte(0)));
|
|
64
|
+
const next = sum.read().sub(select(crossed, f64(threshold * SCALE), f64(0)));
|
|
65
|
+
const nextStep = oldStep.add(select(crossed, i32(1), i32(0)));
|
|
66
|
+
error.write(select(c.reset, f64(0), nextError.read()));
|
|
67
|
+
position.write(select(c.reset, f64(0), next));
|
|
68
|
+
step.write(select(c.reset, i32(0), select(nextStep.gte(config.steps), i32(0), nextStep)));
|
|
69
|
+
pending.write(c.reset.or(crossed));
|
|
70
|
+
previousSeek.write(c.seek);
|
|
71
|
+
return { step: f32(oldStep), phase: f32(phase).clamp(0, PHASE_MAX), phase64: phase, tick: pulse };
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
/** Sample clock, no transport or event scheduler. Call exactly once per sample. */
|
|
76
|
+
export const musicalClock = defineSubgraph((config) => {
|
|
77
|
+
const clock = instantiate(clockCore, config, { name: 'clock' });
|
|
78
|
+
return { tick(c) {
|
|
79
|
+
const value = clock.tick(c);
|
|
80
|
+
return { step: value.step, phase: value.phase, tick: value.tick };
|
|
81
|
+
} };
|
|
82
|
+
});
|
|
83
|
+
/** Fixed step values and gate fractions; no MIDI generation or smoothing. */
|
|
84
|
+
export const stepSequence = defineSubgraph((config) => {
|
|
85
|
+
if (!Array.isArray(config.steps) || config.steps.length < 1 || config.steps.length > 64)
|
|
86
|
+
throw new RangeError('sequence requires 1..64 steps');
|
|
87
|
+
const steps = config.steps.map(step => {
|
|
88
|
+
boundedNumber(step.value, -1, 1, 'step value');
|
|
89
|
+
boundedNumber(step.gate, 0, 1, 'step gate');
|
|
90
|
+
return { value: step.value, gate: step.gate };
|
|
91
|
+
});
|
|
92
|
+
const clock = instantiate(clockCore, { ...config, steps: steps.length }, { name: 'clock' });
|
|
93
|
+
return { tick(c) {
|
|
94
|
+
const t = clock.tick(c);
|
|
95
|
+
let value = f32(steps[0].value), gate = f64(steps[0].gate);
|
|
96
|
+
for (let n = 1; n < steps.length; n++) {
|
|
97
|
+
value = select(t.step.eq(n), f32(steps[n].value), value);
|
|
98
|
+
gate = select(t.step.eq(n), f64(steps[n].gate), gate);
|
|
99
|
+
}
|
|
100
|
+
return { value, step: t.step, phase: t.phase, tick: t.tick,
|
|
101
|
+
gate: c.reset.not().and(t.phase64.lt(gate)) };
|
|
102
|
+
} };
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* Fixed piecewise envelope. Rising trigger emits the first duration step;
|
|
106
|
+
* held trigger does not retrigger. Reset consumes trigger and emits initial.
|
|
107
|
+
* Zero-length segments collapse at their boundary in list order. Loop jumps
|
|
108
|
+
* from last target to the first segment's first step on the next sample.
|
|
109
|
+
*/
|
|
110
|
+
export const mseg = defineSubgraph((config) => {
|
|
111
|
+
checkRate(config.sampleRate);
|
|
112
|
+
boundedNumber(config.initial, -1, 1, 'MSEG initial');
|
|
113
|
+
if (!Array.isArray(config.segments) || config.segments.length < 1 || config.segments.length > 16)
|
|
114
|
+
throw new RangeError('MSEG requires 1..16 segments');
|
|
115
|
+
if (config.loop !== undefined && typeof config.loop !== 'boolean')
|
|
116
|
+
throw new RangeError('MSEG loop must be boolean');
|
|
117
|
+
let total = 0, startValue = config.initial;
|
|
118
|
+
const segments = config.segments.map(segment => {
|
|
119
|
+
boundedNumber(segment.seconds, 0, 30, 'MSEG seconds');
|
|
120
|
+
boundedNumber(segment.target, -1, 1, 'MSEG target');
|
|
121
|
+
if (!['linear', 'easeIn', 'easeOut', 'smoothstep'].includes(segment.curve))
|
|
122
|
+
throw new RangeError('unknown MSEG curve');
|
|
123
|
+
const frames = Math.round(segment.seconds * config.sampleRate);
|
|
124
|
+
const result = { ...segment, start: total, frames, startValue };
|
|
125
|
+
total += frames;
|
|
126
|
+
startValue = segment.target;
|
|
127
|
+
return result;
|
|
128
|
+
});
|
|
129
|
+
if (config.loop && total === 0)
|
|
130
|
+
throw new RangeError('looped MSEG needs at least one duration sample');
|
|
131
|
+
const frame = state.i32(0).named('frame');
|
|
132
|
+
const started = state.bool(false).named('started');
|
|
133
|
+
const previousTrigger = state.bool(false).named('previousTrigger');
|
|
134
|
+
return { tick(c) {
|
|
135
|
+
const trigger = c.trigger.and(previousTrigger.read().not()).and(c.reset.not());
|
|
136
|
+
const active = c.reset.not().and(started.read().or(trigger));
|
|
137
|
+
const advance = config.loop ? select(frame.read().gte(total), i32(1), frame.read().add(1)) : frame.read().add(1).min(total);
|
|
138
|
+
frame.write(select(active, select(trigger, i32(Math.min(1, total)), advance), i32(0)));
|
|
139
|
+
const elapsed = frame.read();
|
|
140
|
+
let value = f64(config.initial), index = i32(0);
|
|
141
|
+
for (let n = 0; n < segments.length; n++) {
|
|
142
|
+
const s = segments[n];
|
|
143
|
+
const t = f64(elapsed.sub(s.start)).div(Math.max(1, s.frames)).clamp(0, 1);
|
|
144
|
+
const curve = s.curve === 'easeIn' ? t.mul(t) : s.curve === 'easeOut' ? t.mul(f64(2).sub(t)) : s.curve === 'smoothstep' ? t.mul(t).mul(f64(3).sub(t.mul(2))) : t;
|
|
145
|
+
const applicable = active.and(s.frames === 0 ? elapsed.gte(s.start) : elapsed.gt(s.start));
|
|
146
|
+
const next = s.frames === 0 ? f64(s.target) : f64(s.startValue).add(curve.mul(s.target - s.startValue));
|
|
147
|
+
value = select(applicable, next, value);
|
|
148
|
+
index = select(applicable, i32(n), index);
|
|
149
|
+
}
|
|
150
|
+
started.write(active);
|
|
151
|
+
previousTrigger.write(c.trigger);
|
|
152
|
+
return { value: f32(value).clamp(-1, 1), segment: f32(index), done: active.not().or(bool(!config.loop).and(elapsed.gte(total))) };
|
|
153
|
+
} };
|
|
154
|
+
});
|
|
155
|
+
/** Level-sensitive sampling; full finite f32 range. Native state maps -0 to +0. */
|
|
156
|
+
export const sampleAndHold = defineSubgraph((config) => {
|
|
157
|
+
boundedNumber(config.initial, -1, 1, 'sampleAndHold initial');
|
|
158
|
+
const held = state.f64(config.initial * SCALE).named('heldScaled');
|
|
159
|
+
return { tick(input, trigger, reset) {
|
|
160
|
+
held.write(select(reset, f64(config.initial * SCALE), select(trigger, f64(input).mul(SCALE), held.read())));
|
|
161
|
+
return f32(held.read().div(SCALE));
|
|
162
|
+
} };
|
|
163
|
+
});
|
|
164
|
+
/**
|
|
165
|
+
* Park-Miller seeded S&H / bounded correlated random. Each high trigger advances
|
|
166
|
+
* the PRNG, including correlation=1. Reset wins, restores seed/initial, and
|
|
167
|
+
* never advances. This is a convex random recurrence, not normalized Gaussian
|
|
168
|
+
* noise or a specified spectral color. All dynamic inputs must be finite.
|
|
169
|
+
*/
|
|
170
|
+
export const randomModulator = defineSubgraph((config) => {
|
|
171
|
+
boundedNumber(config.initial, -1, 1, 'random initial');
|
|
172
|
+
if (!Number.isInteger(config.seed) || config.seed < 1 || config.seed > 2147483646)
|
|
173
|
+
throw new RangeError('random seed must be an integer in [1,2147483646]');
|
|
174
|
+
const seed = state.f64(config.seed).named('seed');
|
|
175
|
+
const value = state.f64(config.initial * SCALE).named('valueScaled');
|
|
176
|
+
return { tick(c) {
|
|
177
|
+
// Product <= 3.61e13 < 2^53; all integer arithmetic is exactly represented.
|
|
178
|
+
const product = seed.read().mul(16807);
|
|
179
|
+
const nextSeed = product.sub(product.div(2147483647).floor().mul(2147483647));
|
|
180
|
+
seed.write(select(c.reset, f64(config.seed), select(c.trigger, nextSeed, seed.read())));
|
|
181
|
+
const uniform = seed.read().sub(1).div(2147483645).mul(2).sub(1);
|
|
182
|
+
const rho = f64(c.correlation.clamp(0, 1));
|
|
183
|
+
const next = value.read().mul(rho).add(uniform.mul(f64(1).sub(rho)).mul(SCALE));
|
|
184
|
+
value.write(select(c.reset, f64(config.initial * SCALE), select(c.trigger, next, value.read())));
|
|
185
|
+
return f32(value.read().div(SCALE)).clamp(-1, 1);
|
|
186
|
+
} };
|
|
187
|
+
});
|
|
188
|
+
/**
|
|
189
|
+
* Bounded fixed-note arp using a caller-owned native MIDI output. At most one
|
|
190
|
+
* off then one on per sample. Output capacity >=256 plus per-quantum draining
|
|
191
|
+
* is required for the conservative burst bound, not guaranteed host delivery.
|
|
192
|
+
* Rate zero holds a sounding note; reset before stopping/disconnecting.
|
|
193
|
+
*/
|
|
194
|
+
export const fixedArpeggiator = defineSubgraph((config) => {
|
|
195
|
+
if (!Number.isInteger(config.channel) || config.channel < 0 || config.channel > 15)
|
|
196
|
+
throw new RangeError('arp channel must be an integer in [0,15]');
|
|
197
|
+
if (!Array.isArray(config.notes) || config.notes.length < 1 || config.notes.length > 64)
|
|
198
|
+
throw new RangeError('arp requires 1..64 notes');
|
|
199
|
+
const notes = config.notes.map(note => {
|
|
200
|
+
if (!Number.isInteger(note.note) || note.note < 0 || note.note > 127)
|
|
201
|
+
throw new RangeError('arp note must be an integer in [0,127]');
|
|
202
|
+
if (!Number.isInteger(note.velocity) || note.velocity < 1 || note.velocity > 127)
|
|
203
|
+
throw new RangeError('arp velocity must be an integer in [1,127]');
|
|
204
|
+
boundedNumber(note.gate, 0, 1, 'arp gate');
|
|
205
|
+
return { ...note };
|
|
206
|
+
});
|
|
207
|
+
const clock = instantiate(clockCore, { ...config, steps: notes.length }, { name: 'clock' });
|
|
208
|
+
const active = state.bool(false).named('active');
|
|
209
|
+
const activeNote = state.i32(notes[0].note).named('activeNote');
|
|
210
|
+
return { tick(output, c) {
|
|
211
|
+
const t = clock.tick(c);
|
|
212
|
+
let note = i32(notes[0].note), velocity = i32(notes[0].velocity), fraction = f64(notes[0].gate);
|
|
213
|
+
for (let n = 1; n < notes.length; n++) {
|
|
214
|
+
note = select(t.step.eq(n), i32(notes[n].note), note);
|
|
215
|
+
velocity = select(t.step.eq(n), i32(notes[n].velocity), velocity);
|
|
216
|
+
fraction = select(t.step.eq(n), f64(notes[n].gate), fraction);
|
|
217
|
+
}
|
|
218
|
+
const gate = c.reset.not().and(t.phase64.lt(fraction));
|
|
219
|
+
const previous = active.read();
|
|
220
|
+
const off = previous.and(gate.not().or(t.tick));
|
|
221
|
+
const on = gate.and(previous.not().or(t.tick));
|
|
222
|
+
output.emitIf(off, { type: 'noteOff', channel: config.channel, note: activeNote.read(), velocity: 0, atSample: c.atSample });
|
|
223
|
+
output.emitIf(on, { type: 'noteOn', channel: config.channel, note, velocity, atSample: c.atSample });
|
|
224
|
+
activeNote.write(select(on, note, activeNote.read()));
|
|
225
|
+
active.write(gate);
|
|
226
|
+
return { note: f32(note), gate, step: t.step, phase: t.phase, tick: t.tick };
|
|
227
|
+
} };
|
|
228
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type MidiInputHandle, type Node } from '@unworklet/core';
|
|
2
|
+
export interface MpeExpressionConfig {
|
|
3
|
+
/** Fixed lower zone: native channel 0 is master, 1..memberChannels are members. Default 15. */
|
|
4
|
+
memberChannels?: number;
|
|
5
|
+
/** Symmetric semitone range, finite [0,96]. Default 48. No RPN negotiation. */
|
|
6
|
+
memberBendRange?: number;
|
|
7
|
+
/** Symmetric semitone range, finite [0,96]. Default 2. */
|
|
8
|
+
masterBendRange?: number;
|
|
9
|
+
}
|
|
10
|
+
/** Pass the existing allocator voice's active/channel fields without remapping. */
|
|
11
|
+
export interface MpeExpressionVoice {
|
|
12
|
+
active: Node<'bool'>;
|
|
13
|
+
channel: Node<'i32'>;
|
|
14
|
+
}
|
|
15
|
+
/** Lower-zone expression routing only: no note allocator or pedal propagation.
|
|
16
|
+
* Bind once to the same native MIDI input as the existing allocation policy.
|
|
17
|
+
* See docs/mpe-expression.md for channel reuse, reset and unsupported features.
|
|
18
|
+
*/
|
|
19
|
+
export declare const mpeExpression: import("@unworklet/core").SubgraphDecl<[config: MpeExpressionConfig], {
|
|
20
|
+
memberChannels: number;
|
|
21
|
+
memberBendRange: number;
|
|
22
|
+
masterBendRange: number;
|
|
23
|
+
reset: (when?: Node<"bool">) => void;
|
|
24
|
+
bindMidi(input: MidiInputHandle): void;
|
|
25
|
+
read(voice: MpeExpressionVoice): {
|
|
26
|
+
inZone: Node<"bool">;
|
|
27
|
+
memberBend: Node<"f32">;
|
|
28
|
+
masterBend: Node<"f32">;
|
|
29
|
+
/** Already includes both bend ranges. Do not add performancePolicy.bend again. */
|
|
30
|
+
bendSemitones: Node<"f32">;
|
|
31
|
+
memberPressure: Node<"f32">;
|
|
32
|
+
masterPressure: Node<"f32">;
|
|
33
|
+
memberTimbre: Node<"f32">;
|
|
34
|
+
masterTimbre: Node<"f32">;
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { bool, defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
|
|
2
|
+
/** Lower-zone expression routing only: no note allocator or pedal propagation.
|
|
3
|
+
* Bind once to the same native MIDI input as the existing allocation policy.
|
|
4
|
+
* See docs/mpe-expression.md for channel reuse, reset and unsupported features.
|
|
5
|
+
*/
|
|
6
|
+
export const mpeExpression = defineSubgraph((config) => {
|
|
7
|
+
const members = config.memberChannels ?? 15;
|
|
8
|
+
const memberRange = config.memberBendRange ?? 48;
|
|
9
|
+
const masterRange = config.masterBendRange ?? 2;
|
|
10
|
+
if (!Number.isInteger(members) || members < 1 || members > 15) {
|
|
11
|
+
throw new RangeError('mpeExpression memberChannels must be an integer in [1,15]');
|
|
12
|
+
}
|
|
13
|
+
for (const [name, value] of [['memberBendRange', memberRange], ['masterBendRange', masterRange]]) {
|
|
14
|
+
if (!Number.isFinite(value) || value < 0 || value > 96)
|
|
15
|
+
throw new RangeError(`mpeExpression ${name} must be finite in [0,96]`);
|
|
16
|
+
}
|
|
17
|
+
const ints = (name) => state.buffer.i32({ size: members + 1 }).expose({ name, snapshot: 'transient' });
|
|
18
|
+
// Store centered integer MIDI values, so fresh-instance transient defaults
|
|
19
|
+
// represent bend 8192, pressure 0 and CC74 64. In-place restore leaves
|
|
20
|
+
// transient state unchanged; reset() is the explicit clearing operation.
|
|
21
|
+
const bend = ints('bend'), pressure = ints('pressure'), timbre = ints('timbre');
|
|
22
|
+
const validChannel = (channel) => channel.gte(0).and(channel.lte(members));
|
|
23
|
+
const valid7 = (value) => value.gte(0).and(value.lte(127));
|
|
24
|
+
const normalizedBend = (channel) => {
|
|
25
|
+
const value = bend.read(channel);
|
|
26
|
+
return f64(value).div(select(value.lt(0), f64(8192), f64(8191)));
|
|
27
|
+
};
|
|
28
|
+
function reset(when = bool(true)) {
|
|
29
|
+
for (let channel = 0; channel <= members; channel++) {
|
|
30
|
+
bend.write(channel, select(when, i32(0), bend.read(channel)));
|
|
31
|
+
pressure.write(channel, select(when, i32(0), pressure.read(channel)));
|
|
32
|
+
timbre.write(channel, select(when, i32(0), timbre.read(channel)));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
memberChannels: members, memberBendRange: memberRange, masterBendRange: masterRange,
|
|
37
|
+
reset,
|
|
38
|
+
bindMidi(input) {
|
|
39
|
+
input.onEvent('pitchBend', e => {
|
|
40
|
+
const channel = e.channel.clamp(0, members);
|
|
41
|
+
const valid = validChannel(e.channel).and(e.value.gte(0)).and(e.value.lte(16383));
|
|
42
|
+
bend.write(channel, select(valid, e.value.sub(8192), bend.read(channel)));
|
|
43
|
+
});
|
|
44
|
+
input.onEvent('channelPressure', e => {
|
|
45
|
+
const channel = e.channel.clamp(0, members);
|
|
46
|
+
pressure.write(channel, select(validChannel(e.channel).and(valid7(e.pressure)), e.pressure, pressure.read(channel)));
|
|
47
|
+
});
|
|
48
|
+
input.onEvent('cc', e => {
|
|
49
|
+
const channel = e.channel.clamp(0, members), valid = validChannel(e.channel).and(valid7(e.value));
|
|
50
|
+
const clear = valid.and(e.controller.eq(121));
|
|
51
|
+
bend.write(channel, select(clear, i32(0), bend.read(channel)));
|
|
52
|
+
pressure.write(channel, select(clear, i32(0), pressure.read(channel)));
|
|
53
|
+
timbre.write(channel, select(clear, i32(0), select(valid.and(e.controller.eq(74)), e.value.sub(64), timbre.read(channel))));
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
read(voice) {
|
|
57
|
+
const inZone = voice.active.and(voice.channel.gte(1)).and(voice.channel.lte(members));
|
|
58
|
+
const channel = voice.channel.clamp(1, members);
|
|
59
|
+
const member = normalizedBend(channel), master = normalizedBend(i32(0));
|
|
60
|
+
const lane = (value) => select(inZone, value, f32(0));
|
|
61
|
+
return {
|
|
62
|
+
inZone,
|
|
63
|
+
memberBend: lane(f32(member)), masterBend: lane(f32(master)),
|
|
64
|
+
/** Already includes both bend ranges. Do not add performancePolicy.bend again. */
|
|
65
|
+
bendSemitones: lane(f32(member.mul(memberRange).add(master.mul(masterRange)))),
|
|
66
|
+
memberPressure: lane(f32(pressure.read(channel)).div(127)),
|
|
67
|
+
masterPressure: lane(f32(pressure.read(0)).div(127)),
|
|
68
|
+
memberTimbre: lane(f32(timbre.read(channel).add(64)).div(127)),
|
|
69
|
+
masterTimbre: lane(f32(timbre.read(0).add(64)).div(127)),
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
});
|