@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.
Files changed (191) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/LICENSE-APACHE +202 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +107 -2
  5. package/dist/biquad-eq.d.ts +11 -0
  6. package/dist/biquad-eq.js +35 -0
  7. package/dist/catalog-filter-math.d.ts +4 -0
  8. package/dist/catalog-filter-math.js +30 -0
  9. package/dist/character-filter.d.ts +13 -0
  10. package/dist/character-filter.js +53 -0
  11. package/dist/convolution-spectrum.d.ts +25 -0
  12. package/dist/convolution-spectrum.js +82 -0
  13. package/dist/convolution.d.ts +17 -0
  14. package/dist/convolution.js +87 -0
  15. package/dist/crossover.d.ts +11 -0
  16. package/dist/crossover.js +17 -0
  17. package/dist/curve-shaper.d.ts +26 -0
  18. package/dist/curve-shaper.js +73 -0
  19. package/dist/delay-fx.d.ts +37 -0
  20. package/dist/delay-fx.js +84 -0
  21. package/dist/delay-readhead.d.ts +19 -0
  22. package/dist/delay-readhead.js +54 -0
  23. package/dist/delay-settings.d.ts +55 -0
  24. package/dist/delay-settings.js +20 -0
  25. package/dist/drive.d.ts +24 -0
  26. package/dist/drive.js +116 -0
  27. package/dist/dual-head-delay.d.ts +28 -0
  28. package/dist/dual-head-delay.js +77 -0
  29. package/dist/dynamics.d.ts +61 -0
  30. package/dist/dynamics.js +130 -0
  31. package/dist/envelope.d.ts +34 -0
  32. package/dist/envelope.js +62 -0
  33. package/dist/filter.d.ts +9 -0
  34. package/dist/filter.js +34 -0
  35. package/dist/filterbank-vocoder.d.ts +35 -0
  36. package/dist/filterbank-vocoder.js +52 -0
  37. package/dist/formant-bank.d.ts +15 -0
  38. package/dist/formant-bank.js +28 -0
  39. package/dist/freeze-reverb.d.ts +35 -0
  40. package/dist/freeze-reverb.js +61 -0
  41. package/dist/frequency-shifter.d.ts +24 -0
  42. package/dist/frequency-shifter.js +57 -0
  43. package/dist/gate.d.ts +2 -0
  44. package/dist/gate.js +12 -0
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +12 -0
  47. package/dist/instrument-example.d.ts +12 -0
  48. package/dist/instrument-example.js +29 -0
  49. package/dist/instrument-settings.d.ts +95 -0
  50. package/dist/instrument-settings.js +36 -0
  51. package/dist/instrument.d.ts +42 -0
  52. package/dist/instrument.js +166 -0
  53. package/dist/lfo.d.ts +19 -0
  54. package/dist/lfo.js +55 -0
  55. package/dist/live-buffer.d.ts +39 -0
  56. package/dist/live-buffer.js +52 -0
  57. package/dist/live-granular.d.ts +41 -0
  58. package/dist/live-granular.js +64 -0
  59. package/dist/lookahead-limiter.d.ts +31 -0
  60. package/dist/lookahead-limiter.js +67 -0
  61. package/dist/loop-crossfade.d.ts +28 -0
  62. package/dist/loop-crossfade.js +72 -0
  63. package/dist/modulation-fx.d.ts +47 -0
  64. package/dist/modulation-fx.js +58 -0
  65. package/dist/modulation.d.ts +132 -0
  66. package/dist/modulation.js +228 -0
  67. package/dist/mpe-expression.d.ts +36 -0
  68. package/dist/mpe-expression.js +73 -0
  69. package/dist/multiband-dynamics.d.ts +50 -0
  70. package/dist/multiband-dynamics.js +60 -0
  71. package/dist/musical-controls.d.ts +76 -0
  72. package/dist/musical-controls.js +125 -0
  73. package/dist/musical-examples.d.ts +21 -0
  74. package/dist/musical-examples.js +142 -0
  75. package/dist/musical-materials.d.ts +110 -0
  76. package/dist/musical-materials.js +56 -0
  77. package/dist/oscillator.d.ts +10 -0
  78. package/dist/oscillator.js +35 -0
  79. package/dist/oversampled-drive.d.ts +19 -0
  80. package/dist/oversampled-drive.js +99 -0
  81. package/dist/performance.d.ts +61 -0
  82. package/dist/performance.js +241 -0
  83. package/dist/pitch-quantizer.d.ts +26 -0
  84. package/dist/pitch-quantizer.js +88 -0
  85. package/dist/prepared-convolution.d.ts +26 -0
  86. package/dist/prepared-convolution.js +104 -0
  87. package/dist/resident-recorder.d.ts +28 -0
  88. package/dist/resident-recorder.js +68 -0
  89. package/dist/resident-time-stretch.d.ts +46 -0
  90. package/dist/resident-time-stretch.js +136 -0
  91. package/dist/resonator.d.ts +32 -0
  92. package/dist/resonator.js +63 -0
  93. package/dist/reverb.d.ts +27 -0
  94. package/dist/reverb.js +52 -0
  95. package/dist/sample.d.ts +113 -0
  96. package/dist/sample.js +212 -0
  97. package/dist/source.d.ts +58 -0
  98. package/dist/source.js +113 -0
  99. package/dist/spatial-chains.d.ts +50 -0
  100. package/dist/spatial-chains.js +76 -0
  101. package/dist/spectral-fft.d.ts +27 -0
  102. package/dist/spectral-fft.js +84 -0
  103. package/dist/spectral-freeze.d.ts +24 -0
  104. package/dist/spectral-freeze.js +74 -0
  105. package/dist/spectral-gate.d.ts +23 -0
  106. package/dist/spectral-gate.js +58 -0
  107. package/dist/spectral-stft.d.ts +14 -0
  108. package/dist/spectral-stft.js +70 -0
  109. package/dist/spectral-texture.d.ts +46 -0
  110. package/dist/spectral-texture.js +141 -0
  111. package/dist/spectral.d.ts +18 -0
  112. package/dist/spectral.js +10 -0
  113. package/dist/state-variable-filter.d.ts +17 -0
  114. package/dist/state-variable-filter.js +25 -0
  115. package/dist/stereo-delay.d.ts +45 -0
  116. package/dist/stereo-delay.js +70 -0
  117. package/dist/virtual-analog.d.ts +26 -0
  118. package/dist/virtual-analog.js +66 -0
  119. package/dist/voice-policy.d.ts +37 -0
  120. package/dist/voice-policy.js +245 -0
  121. package/dist/wavetable-bands.d.ts +37 -0
  122. package/dist/wavetable-bands.js +145 -0
  123. package/dist/wavetable-read.d.ts +6 -0
  124. package/dist/wavetable-read.js +11 -0
  125. package/dist/wavetable.d.ts +29 -0
  126. package/dist/wavetable.js +49 -0
  127. package/dist/windowed-pitch-shift.d.ts +34 -0
  128. package/dist/windowed-pitch-shift.js +51 -0
  129. package/docs/catalog-audition.md +76 -0
  130. package/docs/catalog-filters.md +125 -0
  131. package/docs/catalog-modulation-fx.md +220 -0
  132. package/docs/catalog-status.md +433 -0
  133. package/docs/character-filter.md +139 -0
  134. package/docs/contracts.md +141 -0
  135. package/docs/control-modulation.md +194 -0
  136. package/docs/convolution.md +50 -0
  137. package/docs/curve-shaper.md +117 -0
  138. package/docs/delay-fx-capture-findings.json +134 -0
  139. package/docs/delay-fx.md +202 -0
  140. package/docs/delay-readhead.md +121 -0
  141. package/docs/delay-settings-candidate.json +333 -0
  142. package/docs/delay-settings.md +111 -0
  143. package/docs/dependency-findings.md +58 -0
  144. package/docs/deployment-status.md +37 -0
  145. package/docs/drive.md +152 -0
  146. package/docs/dual-head-delay.md +131 -0
  147. package/docs/dynamics.md +180 -0
  148. package/docs/envelope-integer-staging.md +64 -0
  149. package/docs/filter-materialization-results.json +71 -0
  150. package/docs/filter-materialization.md +135 -0
  151. package/docs/filter-oscillator.md +126 -0
  152. package/docs/filterbank-vocoder-entry.md +58 -0
  153. package/docs/freeze-reverb.md +179 -0
  154. package/docs/frequency-shifter.md +133 -0
  155. package/docs/frontend-audition.md +22 -0
  156. package/docs/gain-schedule-verification.md +53 -0
  157. package/docs/initial-acceptance.md +79 -0
  158. package/docs/instrument-performance.md +191 -0
  159. package/docs/instrument-settings.md +130 -0
  160. package/docs/instrument.md +204 -0
  161. package/docs/integration-candidate.md +114 -0
  162. package/docs/live-buffer.md +163 -0
  163. package/docs/live-granular.md +179 -0
  164. package/docs/lookahead-multiband.md +183 -0
  165. package/docs/loop-crossfade.md +99 -0
  166. package/docs/mpe-expression.md +65 -0
  167. package/docs/musical-clock-seek-boundary.md +31 -0
  168. package/docs/musical-controls-contract.md +144 -0
  169. package/docs/musical-examples.md +255 -0
  170. package/docs/oversampled-drive.md +153 -0
  171. package/docs/performance.md +53 -0
  172. package/docs/pitch-quantizer.md +55 -0
  173. package/docs/prepared-convolution.md +197 -0
  174. package/docs/realtime-audition.md +63 -0
  175. package/docs/resident-recorder.md +151 -0
  176. package/docs/resident-time-stretch.md +196 -0
  177. package/docs/sample.md +191 -0
  178. package/docs/sound-audition-headroom.md +103 -0
  179. package/docs/sources.md +81 -0
  180. package/docs/spatial-chains-entry.md +251 -0
  181. package/docs/spectral-freeze-entry.md +132 -0
  182. package/docs/spectral-gate-entry.md +134 -0
  183. package/docs/spectral-large-candidate.md +81 -0
  184. package/docs/spectral-texture-entry.md +178 -0
  185. package/docs/spectral.md +127 -0
  186. package/docs/virtual-analog.md +77 -0
  187. package/docs/voice-policy.md +38 -0
  188. package/docs/wavetable-bands.md +138 -0
  189. package/docs/wavetable.md +101 -0
  190. package/docs/windowed-pitch-shift.md +112 -0
  191. package/package.json +269 -4
@@ -0,0 +1,166 @@
1
+ import { audioOutput, i32, defineProcessor, event, f32, forSample, instantiate, param, select, state } from '@unworklet/core';
2
+ import { envelope } from './envelope.js';
3
+ import { lfo, modulatePitch, modulateCutoff } from './lfo.js';
4
+ import { oscillator } from './oscillator.js';
5
+ import { filter } from './filter.js';
6
+ import { voicePolicy } from './voice-policy.js';
7
+ export { bassConfig, bassParameters, percussionConfig, percussionParameters, padConfig, padParameters } from './instrument-settings.js';
8
+ /** Diagnostic initial AudioParam values, not an approved preset or a preset format. */
9
+ export const diagnosticInstrumentParameters = {
10
+ gain: 0.15,
11
+ ampAttack: 0.005,
12
+ ampDecay: 0.08,
13
+ ampSustain: 0.7,
14
+ ampRelease: 0.15,
15
+ pitchAttack: 0,
16
+ pitchDecay: 0.1,
17
+ pitchSustain: 0,
18
+ pitchRelease: 0.1,
19
+ filterAttack: 0.01,
20
+ filterDecay: 0.15,
21
+ filterSustain: 0.2,
22
+ filterRelease: 0.15,
23
+ cutoff: 2500,
24
+ resonance: 0.707,
25
+ pitchEnvelopeDepth: 0,
26
+ filterEnvelopeDepth: 1,
27
+ lfoRate: 2,
28
+ lfoAmpDepth: 0,
29
+ lfoPitchDepth: 0,
30
+ lfoFilterDepth: 0,
31
+ bypass: 0,
32
+ };
33
+ /** MIDI input `midi`, stereo dual-mono output `main`, panic message `reset`.
34
+ * Capacities/mode/waveform/parts are construction choices; all musical controls
35
+ * use native AudioParams. See docs/instrument.md for timing and reset semantics.
36
+ */
37
+ export function createInstrument(config) {
38
+ // Keep the tested compilation ceiling after the initial 32-voice graph
39
+ // exceeded the WASM function-size limit. This is not a real-time guarantee.
40
+ if ((config.capacity ?? (config.mode === 'mono' ? 1 : 4)) > 16) {
41
+ throw new RangeError('instrument capacity must be <= 16 (compiled graph limit)');
42
+ }
43
+ return defineProcessor(({ sampleRate }) => {
44
+ const policy = instantiate(voicePolicy, { ...config, capacity: config.capacity ?? (config.mode === 'mono' ? 1 : 4) }, { name: 'voices' });
45
+ const output = audioOutput({ channels: 2, name: 'main' });
46
+ const midi = event.midi({ from: 'main', name: 'midi' });
47
+ // A persisted marker paired with a transient marker distinguishes restore
48
+ // from a pristine instance even when MIDI already reactivated a voice.
49
+ const rendered = state.bool(false).named('rendered');
50
+ const live = state.bool(false).expose({ name: 'live', snapshot: 'transient' });
51
+ const panic = state.bool(false).expose({ name: 'panic', snapshot: 'transient' });
52
+ const voiceControls = state.buffer.bool({ size: 3 * policy.capacity }).expose({ name: 'voiceControls', snapshot: 'transient' });
53
+ const deferredRetrigger = state.buffer.bool({ size: policy.capacity }).expose({ name: 'deferredRetrigger', snapshot: 'transient' });
54
+ const completed = state.buffer.bool({ size: policy.capacity }).expose({ name: 'completed', snapshot: 'transient' });
55
+ const cleared = state.buffer.bool({ size: policy.capacity }).expose({ name: 'cleared', snapshot: 'transient' });
56
+ const tuning = state.buffer.f32({ size: 128 }).expose({ name: 'tuning', snapshot: 'transient' });
57
+ const modulation = instantiate(lfo, { sampleRate }, { name: 'lfo' });
58
+ // Materialize intermediate signals: 0.4.1 analysis recursively expands DAGs.
59
+ // Only cache musical exponents: below the state flush threshold (1e-30),
60
+ // their octave ratio already rounds to exactly 1. Do not cache final audio,
61
+ // where a finite sub-threshold result must survive downstream gain.
62
+ const depths = state.buffer.f32({ size: 2 * policy.capacity }).expose({ name: 'depths', snapshot: 'transient' });
63
+ const signals = state.buffer.f32({ size: 4 * policy.capacity + 1 }).expose({ name: 'signals', snapshot: 'transient' });
64
+ const gain = param.f32({ default: diagnosticInstrumentParameters.gain, min: 0, max: 1, automationRate: 'a-rate' }).named('gain');
65
+ const ampAttack = param.f32({ default: diagnosticInstrumentParameters.ampAttack, min: 0, max: 30, automationRate: 'a-rate' }).named('ampAttack');
66
+ const ampDecay = param.f32({ default: diagnosticInstrumentParameters.ampDecay, min: 0, max: 30, automationRate: 'a-rate' }).named('ampDecay');
67
+ const ampSustain = param.f32({ default: diagnosticInstrumentParameters.ampSustain, min: 0, max: 1, automationRate: 'a-rate' }).named('ampSustain');
68
+ const ampRelease = param.f32({ default: diagnosticInstrumentParameters.ampRelease, min: 0, max: 30, automationRate: 'a-rate' }).named('ampRelease');
69
+ const pitchAttack = param.f32({ default: diagnosticInstrumentParameters.pitchAttack, min: 0, max: 30, automationRate: 'a-rate' }).named('pitchAttack');
70
+ const pitchDecay = param.f32({ default: diagnosticInstrumentParameters.pitchDecay, min: 0, max: 30, automationRate: 'a-rate' }).named('pitchDecay');
71
+ const pitchSustain = param.f32({ default: diagnosticInstrumentParameters.pitchSustain, min: 0, max: 1, automationRate: 'a-rate' }).named('pitchSustain');
72
+ const pitchRelease = param.f32({ default: diagnosticInstrumentParameters.pitchRelease, min: 0, max: 30, automationRate: 'a-rate' }).named('pitchRelease');
73
+ const filterAttack = param.f32({ default: diagnosticInstrumentParameters.filterAttack, min: 0, max: 30, automationRate: 'a-rate' }).named('filterAttack');
74
+ const filterDecay = param.f32({ default: diagnosticInstrumentParameters.filterDecay, min: 0, max: 30, automationRate: 'a-rate' }).named('filterDecay');
75
+ const filterSustain = param.f32({ default: diagnosticInstrumentParameters.filterSustain, min: 0, max: 1, automationRate: 'a-rate' }).named('filterSustain');
76
+ const filterRelease = param.f32({ default: diagnosticInstrumentParameters.filterRelease, min: 0, max: 30, automationRate: 'a-rate' }).named('filterRelease');
77
+ const cutoff = param.f32({ default: diagnosticInstrumentParameters.cutoff, min: 20, max: 20000, automationRate: 'a-rate' }).named('cutoff');
78
+ const resonance = param.f32({ default: diagnosticInstrumentParameters.resonance, min: 0.5, max: 10, automationRate: 'a-rate' }).named('resonance');
79
+ const pitchEnvelopeDepth = param.f32({ default: diagnosticInstrumentParameters.pitchEnvelopeDepth, min: -24, max: 24, automationRate: 'a-rate' }).named('pitchEnvelopeDepth');
80
+ const filterEnvelopeDepth = param.f32({ default: diagnosticInstrumentParameters.filterEnvelopeDepth, min: -8, max: 8, automationRate: 'a-rate' }).named('filterEnvelopeDepth');
81
+ const lfoRate = param.f32({ default: diagnosticInstrumentParameters.lfoRate, min: 0, max: 20, automationRate: 'a-rate' }).named('lfoRate');
82
+ const lfoAmpDepth = param.f32({ default: diagnosticInstrumentParameters.lfoAmpDepth, min: 0, max: 1, automationRate: 'a-rate' }).named('lfoAmpDepth');
83
+ const lfoPitchDepth = param.f32({ default: diagnosticInstrumentParameters.lfoPitchDepth, min: -24, max: 24, automationRate: 'a-rate' }).named('lfoPitchDepth');
84
+ const lfoFilterDepth = param.f32({ default: diagnosticInstrumentParameters.lfoFilterDepth, min: -8, max: 8, automationRate: 'a-rate' }).named('lfoFilterDepth');
85
+ const bypass = param.f32({ default: diagnosticInstrumentParameters.bypass, min: 0, max: 1, automationRate: 'a-rate' }).named('bypass');
86
+ const parts = policy.voices.map((_, index) => ({
87
+ amp: instantiate(envelope, { sampleRate }, { name: `amp${index}` }),
88
+ pitch: instantiate(envelope, { sampleRate }, { name: `pitch${index}` }),
89
+ tone: instantiate(envelope, { sampleRate }, { name: `tone${index}` }),
90
+ oscillator: instantiate(config.oscillator ?? oscillator, { sampleRate, waveform: config.waveform ?? 'sine' }, { name: `oscillator${index}` }),
91
+ filter: instantiate(config.filter ?? filter, { sampleRate }, { name: `filter${index}` }),
92
+ }));
93
+ midi.onEvent('noteOn', e => policy.noteOn(e.note, e.channel, e.velocity));
94
+ midi.onEvent('noteOff', e => policy.noteOff(e.note, e.channel));
95
+ midi.onEvent('cc', e => {
96
+ // Capture ownership before CC120 frees/reassigns voices. A subsequent note
97
+ // in this quantum starts cold, even when it reuses the same physical slot.
98
+ policy.voices.forEach((voice, n) => cleared.write(n, cleared.read(n).or(e.controller.eq(120).and(voice.read().channel.eq(e.channel)))));
99
+ // These policy methods have no conditional argument. CC routing uses the
100
+ // policy's validated channel guard for unsupported controller messages.
101
+ policy.allNotesOff(select(e.controller.eq(123), e.channel, i32(-1)));
102
+ policy.allSoundOff(select(e.controller.eq(120), e.channel, i32(-1)));
103
+ });
104
+ event({ from: 'main', name: 'reset' }).onReceive(() => panic.write(true));
105
+ return { process() {
106
+ // Reset wins over MIDI delivered in the same block. Its DSP pulse is only
107
+ // the first sample; subsequent blocks may accept fresh notes normally.
108
+ policy.reset(panic.read());
109
+ for (let note = 0; note < 128; note++)
110
+ tuning.write(note, 440 * 2 ** ((note - 69) / 12));
111
+ forSample(i => {
112
+ const reset = panic.read();
113
+ signals.write(4 * policy.capacity, modulation.tick(lfoRate.at(i), reset, f32(0)));
114
+ let sum = f32(0);
115
+ policy.voices.forEach((voice, n) => {
116
+ const v = voice.read();
117
+ voiceControls.write(n * 3, reset.or(rendered.read().and(live.read().not())).or(v.active.not()).or(cleared.read(n)));
118
+ voiceControls.write(n * 3 + 1, v.gate.and(voiceControls.read(n * 3).not()));
119
+ voiceControls.write(n * 3 + 2, voice.takeRetrigger().or(deferredRetrigger.read(n)));
120
+ const clear = voiceControls.read(n * 3), gate = voiceControls.read(n * 3 + 1), trigger = voiceControls.read(n * 3 + 2);
121
+ // CC120 can suppress the note's first sample. Keep its reset pulse
122
+ // until the first audible sample, so oscillator phase starts at zero.
123
+ deferredRetrigger.write(n, clear.and(trigger).and(v.active).and(v.gate));
124
+ const p = parts[n];
125
+ const common = { gate, retrigger: trigger, reset: clear };
126
+ const amp = p.amp.tick({ ...common, attack: ampAttack.at(i), decay: ampDecay.at(i), sustain: ampSustain.at(i), release: ampRelease.at(i) });
127
+ const pitch = p.pitch.tick({ ...common, attack: pitchAttack.at(i), decay: pitchDecay.at(i), sustain: pitchSustain.at(i), release: pitchRelease.at(i) });
128
+ const tone = p.tone.tick({ ...common, attack: filterAttack.at(i), decay: filterDecay.at(i), sustain: filterSustain.at(i), release: filterRelease.at(i) });
129
+ signals.write(n * 4, pitch.level);
130
+ signals.write(n * 4 + 1, tone.level);
131
+ const wave = signals.read(4 * policy.capacity);
132
+ // Combine depths in musical units before the single destination clamp.
133
+ const semitones = signals.read(n * 4).mul(pitchEnvelopeDepth.at(i)).add(wave.mul(lfoPitchDepth.at(i)));
134
+ const octaves = signals.read(n * 4 + 1).mul(filterEnvelopeDepth.at(i)).add(wave.mul(lfoFilterDepth.at(i)));
135
+ depths.write(n * 2, semitones);
136
+ depths.write(n * 2 + 1, octaves);
137
+ signals.write(n * 4 + 2, modulatePitch(tuning.read(v.note.max(0)), f32(1), depths.read(n * 2), 0.45 * sampleRate));
138
+ signals.write(n * 4 + 3, modulateCutoff(cutoff.at(i), f32(1), depths.read(n * 2 + 1), Math.min(20000, 0.45 * sampleRate)));
139
+ // Preserve recoverable finite values across custom parts and amp gain.
140
+ // Native state writes flush tiny values, so these paths stay expressions.
141
+ const oscillation = p.oscillator.tick(signals.read(n * 4 + 2), clear.or(trigger));
142
+ const filtered = p.filter.tick(select(clear, f32(0), oscillation), signals.read(n * 4 + 3), resonance.at(i), clear.or(trigger));
143
+ // Unipolar tremolo: depth 0 is unity; depth 1 spans 0..1.
144
+ const tremolo = f32(1).sub(lfoAmpDepth.at(i).mul(f32(1).sub(wave)).mul(0.5));
145
+ const sample = select(v.active.and(clear.not()), filtered.mul(amp.level).mul(v.velocity).mul(tremolo), f32(0));
146
+ sum = sum.add(sample);
147
+ completed.write(n, amp.done);
148
+ cleared.write(n, 0);
149
+ });
150
+ const result = select(bypass.at(i).gte(0.5).or(reset), f32(0), sum.mul(gain.at(i)));
151
+ output.ch(0).at(i).write(result);
152
+ output.ch(1).at(i).write(result);
153
+ panic.write(false);
154
+ rendered.write(true);
155
+ live.write(true);
156
+ });
157
+ // MIDI is drained only before process(). Once the amp reaches zero it
158
+ // stays done for the rest of this block. Free completed allocations here,
159
+ // before the next MIDI dispatch, instead of doing O(voices²) rank updates
160
+ // on every sample. Audio termination remains on the exact envelope sample.
161
+ policy.voices.forEach((voice, n) => voice.releaseFinished(completed.read(n)));
162
+ } };
163
+ });
164
+ }
165
+ /** Diagnostic default: four voices, configurable at construction. */
166
+ export const instrument = createInstrument({ mode: 'poly' });
package/dist/lfo.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface LfoConfig {
3
+ sampleRate: number;
4
+ }
5
+ /**
6
+ * Free-running sine source; tick once per sample. Rate is clamped to [0,20] Hz.
7
+ * Output uses the current phase, then advances by rate/sampleRate. Rate zero
8
+ * holds phase; changing rate never resets phase. Reset emits the supplied phase
9
+ * on that sample and advances from it. Reset phase is in cycles, wrapped to [0,1).
10
+ * f64 phase prevents a small rate from stalling against a large f32 phase.
11
+ * Finite inputs required. No implicit note reset, depth, smoothing or routing.
12
+ */
13
+ export declare const lfo: import("@unworklet/core").SubgraphDecl<[config: LfoConfig], {
14
+ tick(rate: Node<"f32">, reset: Node<"bool">, resetPhase: Node<"f32">): Node<"f32">;
15
+ }>;
16
+ /** Destination-unit depth, with final bounds enforced by the destination. */
17
+ export declare function modulatePitch(baseHz: Node<'f32'>, signal: Node<'f32'>, semitones: Node<'f32'>, maxHz: number): Node<"f32">;
18
+ export declare function modulateCutoff(baseHz: Node<'f32'>, signal: Node<'f32'>, octaves: Node<'f32'>, maxHz: number): Node<"f32">;
19
+ export declare function modulateDelay(baseSeconds: Node<'f32'>, signal: Node<'f32'>, depthSeconds: Node<'f32'>, minSeconds: number, maxSeconds: number): Node<"f32">;
package/dist/lfo.js ADDED
@@ -0,0 +1,55 @@
1
+ import { defineSubgraph, f32, f64, select, state } from '@unworklet/core';
2
+ /**
3
+ * Free-running sine source; tick once per sample. Rate is clamped to [0,20] Hz.
4
+ * Output uses the current phase, then advances by rate/sampleRate. Rate zero
5
+ * holds phase; changing rate never resets phase. Reset emits the supplied phase
6
+ * on that sample and advances from it. Reset phase is in cycles, wrapped to [0,1).
7
+ * f64 phase prevents a small rate from stalling against a large f32 phase.
8
+ * Finite inputs required. No implicit note reset, depth, smoothing or routing.
9
+ */
10
+ export const lfo = defineSubgraph((config) => {
11
+ if (!Number.isInteger(config.sampleRate) || config.sampleRate < 1) {
12
+ throw new RangeError('lfo sampleRate must be a positive integer');
13
+ }
14
+ const phase = state.f64(0).named('phase');
15
+ return {
16
+ tick(rate, reset, resetPhase) {
17
+ const requested = f64(resetPhase);
18
+ const current = select(reset, requested.sub(requested.floor()), phase.read());
19
+ const next = current.add(f64(rate.clamp(0, 20)).div(config.sampleRate));
20
+ phase.write(next.sub(next.floor()));
21
+ // Reflect cycles into [-1/4,1/4] before polynomial evaluation. The
22
+ // degree-13 sine remainder is <= (pi/2)^15 / 15! < 7e-10.
23
+ // unworklet 0.4.1 sin demotes f64 to its f32 degree-9 approximation;
24
+ // direct f64 arithmetic preserves slow phase changes and the error bound.
25
+ const centered = current.sub(select(current.gt(0.5), f64(1), f64(0)));
26
+ const folded = select(centered.gt(0.25), f64(0.5).sub(centered), select(centered.lt(-0.25), f64(-0.5).sub(centered), centered));
27
+ const x = folded.mul(2 * Math.PI), x2 = x.mul(x);
28
+ const polynomial = f64(1 / 6227020800).mul(x2).sub(1 / 39916800)
29
+ .mul(x2).add(1 / 362880).mul(x2).sub(1 / 5040)
30
+ .mul(x2).add(1 / 120).mul(x2).sub(1 / 6).mul(x2).add(1);
31
+ return f32(x.mul(polynomial)).clamp(-1, 1);
32
+ },
33
+ };
34
+ });
35
+ // The musical octave ratio has a bounded exponent [-8,8]. Evaluate exp(x/16)
36
+ // through degree 8, then square four times: |x/16| <= ln(2)/2, giving < 5e-9
37
+ // relative truncation error. This avoids 0.4.1's approximate f32 pow/log path.
38
+ function octaveRatio(octaves) {
39
+ const x = f64(octaves).mul(Math.LN2 / 16);
40
+ const p = f64(1 / 40320).mul(x).add(1 / 5040).mul(x).add(1 / 720)
41
+ .mul(x).add(1 / 120).mul(x).add(1 / 24).mul(x).add(1 / 6)
42
+ .mul(x).add(0.5).mul(x).add(1).mul(x).add(1);
43
+ const p2 = p.mul(p), p4 = p2.mul(p2), p8 = p4.mul(p4);
44
+ return f32(p8.mul(p8));
45
+ }
46
+ /** Destination-unit depth, with final bounds enforced by the destination. */
47
+ export function modulatePitch(baseHz, signal, semitones, maxHz) {
48
+ return baseHz.mul(octaveRatio(signal.clamp(-1, 1).mul(semitones.clamp(-24, 24)).div(12))).clamp(0, maxHz);
49
+ }
50
+ export function modulateCutoff(baseHz, signal, octaves, maxHz) {
51
+ return baseHz.mul(octaveRatio(signal.clamp(-1, 1).mul(octaves.clamp(-8, 8)))).clamp(20, maxHz);
52
+ }
53
+ export function modulateDelay(baseSeconds, signal, depthSeconds, minSeconds, maxSeconds) {
54
+ return baseSeconds.add(signal.clamp(-1, 1).mul(depthSeconds.clamp(0, 0.05))).clamp(minSeconds, maxSeconds);
55
+ }
@@ -0,0 +1,39 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface LiveSampleBufferConfig {
3
+ /** Fixed mono history capacity, 1..65,536 accepted writes. */
4
+ capacity: number;
5
+ /** Incoming processor rate, 8,000..192,000 Hz. No resampling. */
6
+ sampleRate: number;
7
+ }
8
+ export interface LiveSampleBufferControls {
9
+ input: Node<'f32'>;
10
+ /** Accept one frame per tick. False freezes history, including its ages. */
11
+ record: Node<'bool'>;
12
+ /** Level-sensitive logical clear, with priority over record. Not secure erasure. */
13
+ reset: Node<'bool'>;
14
+ }
15
+ export interface LiveSampleBufferRead {
16
+ output: Node<'f32'>;
17
+ /** True only for finite age in [0,length-1], including both interpolation taps. */
18
+ available: Node<'bool'>;
19
+ }
20
+ export interface LiveSampleBufferStatus {
21
+ written: Node<'bool'>;
22
+ full: Node<'bool'>;
23
+ length: Node<'i32'>;
24
+ }
25
+ export interface LiveSampleBuffer {
26
+ readonly capacity: number;
27
+ readonly sampleRate: number;
28
+ tick(controls: LiveSampleBufferControls): LiveSampleBufferStatus;
29
+ /** Age 0 is the latest accepted write at this statement position. Linear
30
+ * interpolation toward older writes; unavailable ages are silent, not wrapped. */
31
+ readAge(ageFrames: Node<'f64'>): LiveSampleBufferRead;
32
+ length(): Node<'i32'>;
33
+ /** Wrapping i32 mutation serial: one increment per accepted write or reset. */
34
+ revision(): Node<'i32'>;
35
+ }
36
+ /** Bounded rolling mono history with explicit accepted-write ages. Intentionally
37
+ * not a ResidentSample: rotating age coordinates must not silently change the
38
+ * meaning of existing sample-player/grain positions. No device or host ingress. */
39
+ export declare const liveSampleBuffer: import("@unworklet/core").SubgraphDecl<[config: LiveSampleBufferConfig], LiveSampleBuffer>;
@@ -0,0 +1,52 @@
1
+ import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
2
+ const PCM_SCALE = 2 ** 128;
3
+ const AGE_SCALE = 2 ** 192;
4
+ const F32_MAX = 3.4028234663852886e38;
5
+ /** Bounded rolling mono history with explicit accepted-write ages. Intentionally
6
+ * not a ResidentSample: rotating age coordinates must not silently change the
7
+ * meaning of existing sample-player/grain positions. No device or host ingress. */
8
+ export const liveSampleBuffer = defineSubgraph((config) => {
9
+ const { capacity, sampleRate } = config;
10
+ if (!Number.isInteger(capacity) || capacity < 1 || capacity > 65536)
11
+ throw new RangeError('live buffer capacity must be an integer in [1,65536]');
12
+ if (!Number.isFinite(sampleRate) || sampleRate < 8000 || sampleRate > 192000)
13
+ throw new RangeError('sampleRate must be in [8000,192000] Hz');
14
+ // Exact binary scaling preserves every finite f32, including subnormals,
15
+ // across core 0.4.1's 1e-30 scalar-store floor. The extra cell is private.
16
+ const pcm = state.buffer.f64({ size: capacity + 1 }).expose({ name: 'pcm', snapshot: 'persistent' });
17
+ const head = state.i32(0).named('head'), length = state.i32(0).named('length');
18
+ const revision = state.i32(0).named('revision');
19
+ const readAge = state.f64(0).expose({ name: 'readAge', snapshot: 'transient' });
20
+ return {
21
+ capacity, sampleRate,
22
+ tick(c) {
23
+ const written = c.record.and(c.reset.not());
24
+ const finite = c.input.eq(c.input).and(c.input.abs().lte(F32_MAX));
25
+ // select evaluates both branches. Paused/reset ticks touch the sentinel,
26
+ // not exposed history, and no clearing pass runs on reset.
27
+ pcm.write(select(written, head.read(), i32(capacity)), select(written.and(finite), f64(c.input).mul(PCM_SCALE), f64(0)));
28
+ head.write(select(c.reset, i32(0), select(written, head.read().add(1).mod(capacity), head.read())));
29
+ length.write(select(c.reset, i32(0), length.read().add(select(written, i32(1), i32(0))).min(capacity)));
30
+ revision.write(revision.read().add(select(written.or(c.reset), i32(1), i32(0))));
31
+ const current = length.read();
32
+ return { written, full: current.eq(capacity), length: current };
33
+ },
34
+ readAge(ageFrames) {
35
+ const count = length.read();
36
+ const available = ageFrames.gte(0).and(ageFrames.lte(f64(count.sub(1))));
37
+ // Keep invalid ages out of integer conversion and address arithmetic.
38
+ // Scale the cache before its native store so tiny valid fractions still
39
+ // contribute when adjacent samples span the full finite f32 range.
40
+ readAge.write(select(available, ageFrames, f64(0)).mul(AGE_SCALE));
41
+ const age = readAge.read().div(AGE_SCALE), whole = i32(age.floor());
42
+ const fraction = age.sub(f64(whole));
43
+ const older = whole.add(1).min(count.sub(1).max(0));
44
+ const newest = head.read().add(capacity - 1);
45
+ const a = pcm.read(newest.sub(whole).mod(capacity)).div(PCM_SCALE);
46
+ const b = pcm.read(newest.sub(older).mod(capacity)).div(PCM_SCALE);
47
+ return { output: select(available, f32(a.add(b.sub(a).mul(fraction))), f32(0)), available };
48
+ },
49
+ length() { return length.read(); },
50
+ revision() { return revision.read(); },
51
+ };
52
+ });
@@ -0,0 +1,41 @@
1
+ import { type Node } from '@unworklet/core';
2
+ import type { LiveSampleBuffer } from './live-buffer.js';
3
+ export interface LiveGranularConfig {
4
+ /** Shared accepted-write history; its sampleRate must match the processor. */
5
+ history: LiveSampleBuffer;
6
+ /** Immutable first-free pool size, integer 1..8. */
7
+ maxGrains: number;
8
+ }
9
+ export interface LiveGranularControls {
10
+ /** Exactly the immediately preceding history.tick() status.written. */
11
+ written: Node<'bool'>;
12
+ /** The same reset passed to history.tick(); level-sensitive, highest priority. */
13
+ reset: Node<'bool'>;
14
+ /** One request per true sample; held true requests every sample. */
15
+ trigger: Node<'bool'>;
16
+ /** Onset age after the current write, in retained accepted-write frames. */
17
+ ageFrames: Node<'f32'>;
18
+ /** Signed frames toward newer source material per output sample, [-16,16]. */
19
+ rate: Node<'f32'>;
20
+ /** [0,2] seconds, rounded to frames with minimum 3. Zero is valid. */
21
+ durationSeconds: Node<'f32'>;
22
+ }
23
+ export interface LiveGranularResult {
24
+ output: Node<'f32'>;
25
+ /** Valid reads this sample, including both zero-weight window endpoints. */
26
+ activeGrains: Node<'i32'>;
27
+ launched: Node<'bool'>;
28
+ /** Valid request with a full pool; no stealing or queue. */
29
+ dropped: Node<'bool'>;
30
+ /** Invalid controls or unavailable onset. Does not interrupt existing grains. */
31
+ rejected: Node<'bool'>;
32
+ /** Existing grains whose current read became unavailable; excludes reset/EOF. */
33
+ expiredGrains: Node<'i32'>;
34
+ }
35
+ export interface LiveGranularSource {
36
+ tick(controls: LiveGranularControls): LiveGranularResult;
37
+ }
38
+ /** Fixed mono live-history grain reader. Call the writer, then this reader once
39
+ * per sample with the writer's written flag and shared reset. No density clock,
40
+ * source ownership, resampling filter, implicit safety margin or host ingress. */
41
+ export declare const liveGranularSource: import("@unworklet/core").SubgraphDecl<[config: LiveGranularConfig], LiveGranularSource>;
@@ -0,0 +1,64 @@
1
+ import { defineSubgraph, f32, f64, i32, select, state } from '@unworklet/core';
2
+ // Preserve small representable scalar values across core 0.4.1's 1e-30
3
+ // state-store floor. This does not extend f64 precision at a large cursor.
4
+ const SCALE = 2 ** 192;
5
+ /** Fixed mono live-history grain reader. Call the writer, then this reader once
6
+ * per sample with the writer's written flag and shared reset. No density clock,
7
+ * source ownership, resampling filter, implicit safety margin or host ingress. */
8
+ export const liveGranularSource = defineSubgraph((config) => {
9
+ const { history, maxGrains } = config;
10
+ if (!Number.isInteger(maxGrains) || maxGrains < 1 || maxGrains > 8)
11
+ throw new RangeError('liveGranularSource maxGrains must be an integer in [1,8]');
12
+ if (!Number.isInteger(history.capacity) || history.capacity < 1 || history.capacity > 65536 || !Number.isFinite(history.sampleRate) || history.sampleRate < 8000 || history.sampleRate > 192000)
13
+ throw new RangeError('liveGranularSource history requires capacity [1,65536] and sampleRate [8000,192000]');
14
+ const allocated = state.bool(false).expose({ name: 'allocated', snapshot: 'transient' });
15
+ const grains = Array.from({ length: maxGrains }, (_, n) => ({
16
+ active: state.bool(false).named(`grain${n}Active`),
17
+ index: state.i32(0).named(`grain${n}Index`),
18
+ duration: state.i32(3).named(`grain${n}Duration`),
19
+ age: state.f64(0).named(`grain${n}AgeScaled`),
20
+ rate: state.f64(0).named(`grain${n}RateScaled`),
21
+ }));
22
+ return { tick(c) {
23
+ const requested = c.trigger.and(c.reset.not());
24
+ const valid = c.ageFrames.gte(0).and(c.ageFrames.lte(history.capacity - 1))
25
+ .and(f64(c.ageFrames).lte(f64(history.length().sub(1))))
26
+ .and(c.rate.gte(-16)).and(c.rate.lte(16))
27
+ .and(c.durationSeconds.gte(0)).and(c.durationSeconds.lte(2));
28
+ const attempt = requested.and(valid);
29
+ // select is eager. Sanitize before conversion even on rejected requests.
30
+ const duration = i32(f64(select(valid, c.durationSeconds, f32(0))).mul(history.sampleRate).add(.5).floor()).clamp(3, Math.round(2 * history.sampleRate));
31
+ const onsetAge = f64(select(valid, c.ageFrames, f32(0))), onsetRate = f64(select(valid, c.rate, f32(0)));
32
+ allocated.write(false);
33
+ let sum = f64(0), count = i32(0), expired = i32(0);
34
+ for (const grain of grains) {
35
+ const activeBefore = grain.active.read().and(c.reset.not());
36
+ // Add the increment as one expression: (tinyAge + 1) - 1 would erase
37
+ // representable tiny onset fractions while continuously writing at rate 1.
38
+ const increment = select(c.written, f64(1), f64(0)).sub(grain.rate.read().div(SCALE));
39
+ const currentAge = grain.age.read().div(SCALE).add(increment);
40
+ const available = currentAge.gte(0).and(currentAge.lte(f64(history.length().sub(1))));
41
+ const existing = activeBefore.and(available);
42
+ expired = expired.add(select(activeBefore.and(available.not()), i32(1), i32(0)));
43
+ const alreadyAllocated = allocated.read();
44
+ const launch = attempt.and(alreadyAllocated.not()).and(existing.not());
45
+ allocated.write(alreadyAllocated.or(launch));
46
+ const active = existing.or(launch);
47
+ grain.index.write(select(c.reset.or(launch), i32(0), grain.index.read()));
48
+ grain.duration.write(select(c.reset, i32(3), select(launch, duration, grain.duration.read())));
49
+ grain.rate.write(select(c.reset, f64(0), select(launch, onsetRate.mul(SCALE), grain.rate.read())));
50
+ grain.age.write(select(active, select(launch, onsetAge, currentAge).mul(SCALE), f64(0)));
51
+ const index = grain.index.read(), length = grain.duration.read();
52
+ const window = f64(1).sub(f64(index).mul(2).div(f64(length.sub(1))).sub(1).abs()).max(0);
53
+ const read = history.readAge(grain.age.read().div(SCALE));
54
+ sum = sum.add(select(active, f64(read.output).mul(window), f64(0)));
55
+ count = count.add(select(active, i32(1), i32(0)));
56
+ grain.index.write(select(active, index.add(1), i32(0)));
57
+ // Natural completion is recorded now, before next sample's age validity.
58
+ grain.active.write(active.and(index.add(1).lt(length)));
59
+ }
60
+ const launched = allocated.read();
61
+ return { output: f32(sum.div(maxGrains)), activeGrains: count, launched,
62
+ dropped: attempt.and(launched.not()), rejected: requested.and(valid.not()), expiredGrains: expired };
63
+ } };
64
+ });
@@ -0,0 +1,31 @@
1
+ import { type Node } from '@unworklet/core';
2
+ export interface LookaheadLimiterConfig {
3
+ sampleRate: number;
4
+ /** Fixed integer latency/allocation, 0..2048 samples. No implicit default. */
5
+ lookaheadSamples: number;
6
+ }
7
+ export interface LookaheadLimiterControls {
8
+ /** Current-output sample ceiling in dBFS, clamped to [-120,0]; nonfinite -> 0. */
9
+ ceilingDb: Node<'f32'>;
10
+ /** Peak-detector release, 0..30 seconds; nonfinite -> 0. Attack is immediate. */
11
+ release: Node<'f32'>;
12
+ /** Invalidate all history before accepting this sample. */
13
+ reset: Node<'bool'>;
14
+ }
15
+ /** Bounded stereo-linked sample-peak limiter. Not a true-peak limiter. */
16
+ export declare const lookaheadLimiter: import("@unworklet/core").SubgraphDecl<[config: LookaheadLimiterConfig], {
17
+ latencySamples: number;
18
+ latencySeconds: number;
19
+ /** Exactly once per sample. Dry outputs have the same fixed latency. */
20
+ tick(left: Node<"f32">, right: Node<"f32">, controls: LookaheadLimiterControls): {
21
+ left: Node<"f32">;
22
+ right: Node<"f32">;
23
+ dryLeft: Node<"f32">;
24
+ dryRight: Node<"f32">;
25
+ envelope: Node<"f32">;
26
+ windowPeak: Node<"f32">;
27
+ ceiling: Node<"f32">;
28
+ /** Diagnostic only: this f32 conversion may underflow for extreme audio. */
29
+ gain: Node<"f32">;
30
+ };
31
+ }>;
@@ -0,0 +1,67 @@
1
+ import { defineSubgraph, f32, f64, i32, instantiate, select, state } from '@unworklet/core';
2
+ import { envelopeFollower } from './dynamics.js';
3
+ const SCALE = 2 ** 128;
4
+ function finite(input) {
5
+ return select(input.eq(input).and(input.abs().lte(3.4028234663852886e38)), input, f32(0));
6
+ }
7
+ /** Bounded stereo-linked sample-peak limiter. Not a true-peak limiter. */
8
+ export const lookaheadLimiter = defineSubgraph((config) => {
9
+ const { sampleRate, lookaheadSamples: delay } = config;
10
+ if (!Number.isInteger(sampleRate) || sampleRate < 8000 || sampleRate > 192000) {
11
+ throw new RangeError('lookaheadLimiter sampleRate must be an integer in [8000,192000]');
12
+ }
13
+ if (!Number.isInteger(delay) || delay < 0 || delay > 2048) {
14
+ throw new RangeError('lookaheadLimiter lookaheadSamples must be an integer in [0,2048]');
15
+ }
16
+ const size = delay + 1, capacity = 2 ** Math.ceil(Math.log2(size));
17
+ // Native buffer writes flush magnitudes below 1e-30. Exact binary scaling
18
+ // preserves every finite f32 value, including subnormals, without overflow.
19
+ const leftHistory = state.buffer.f64({ size }).expose({ name: 'leftScaled', snapshot: 'persistent' });
20
+ const rightHistory = state.buffer.f64({ size }).expose({ name: 'rightScaled', snapshot: 'persistent' });
21
+ const peaks = state.buffer.f64({ size: capacity * 2 }).expose({ name: 'peakTreeScaled', snapshot: 'persistent' });
22
+ const cursor = state.i32(0).named('cursor'), valid = state.i32(0).named('valid');
23
+ const follower = instantiate(envelopeFollower, { sampleRate, mode: 'peak' }, { name: 'detector' });
24
+ return {
25
+ latencySamples: delay,
26
+ latencySeconds: delay / sampleRate,
27
+ /** Exactly once per sample. Dry outputs have the same fixed latency. */
28
+ tick(left, right, controls) {
29
+ const write = select(controls.reset, i32(0), cursor.read());
30
+ const available = select(controls.reset, i32(0), valid.read()).add(1).min(size);
31
+ const l = f64(finite(left)).mul(SCALE), r = f64(finite(right)).mul(SCALE);
32
+ leftHistory.write(write, l);
33
+ rightHistory.write(write, r);
34
+ peaks.write(write.add(capacity), l.abs().max(r.abs()));
35
+ // At reset the valid leaves form an initially empty prefix. Sequential
36
+ // refill ensures each included sibling was rebuilt since that reset.
37
+ // Other siblings (including permanently unused leaves) are read as zero.
38
+ // Once full, all real leaves stay valid across arbitrary ring wraps.
39
+ for (let width = 2; width <= capacity; width *= 2) {
40
+ const block = write.div(width), first = block.mul(width), node = block.add(capacity / width);
41
+ const low = select(first.lt(available), peaks.read(node.mul(2)), f64(0));
42
+ const high = select(first.add(width / 2).lt(available), peaks.read(node.mul(2).add(1)), f64(0));
43
+ peaks.write(node, low.max(high));
44
+ }
45
+ const windowPeak = f32(peaks.read(1).div(SCALE));
46
+ const envelope = follower.tick(windowPeak, { attack: f32(0), release: controls.release, reset: controls.reset });
47
+ const ceiling = finite(controls.ceilingDb).clamp(-120, 0).mul(Math.LN10 / 20).exp();
48
+ const gain = select(envelope.gt(0), f64(ceiling).div(select(envelope.gt(0), f64(envelope), f64(1))).min(1), f64(1));
49
+ const read = write.add(1).mod(size);
50
+ const ready = available.eq(size);
51
+ const dryLeft = select(ready, leftHistory.read(read).div(SCALE), f64(0));
52
+ const dryRight = select(ready, rightHistory.read(read).div(SCALE), f64(0));
53
+ cursor.write(read);
54
+ valid.write(available);
55
+ return {
56
+ // Keep the gain f64 through multiplication: full-range f32 input at a
57
+ // -120dB ceiling would otherwise quantize its tiny gain to a few ulps.
58
+ left: f32(dryLeft.mul(gain)).clamp(ceiling.neg(), ceiling),
59
+ right: f32(dryRight.mul(gain)).clamp(ceiling.neg(), ceiling),
60
+ dryLeft: f32(dryLeft), dryRight: f32(dryRight),
61
+ envelope, windowPeak, ceiling,
62
+ /** Diagnostic only: this f32 conversion may underflow for extreme audio. */
63
+ gain: f32(gain),
64
+ };
65
+ },
66
+ };
67
+ });
@@ -0,0 +1,28 @@
1
+ import { type Node } from '@unworklet/core';
2
+ import type { ResidentSample, SamplePlayerControls } from './sample.js';
3
+ export interface CrossfadedLoopConfig {
4
+ sampleRate: number;
5
+ sample: ResidentSample;
6
+ /** Fixed source slice [startFrame,endFrame), intersected with loaded length. */
7
+ startFrame?: number;
8
+ endFrame?: number;
9
+ /** Source-frame overlap, 0..floor(slice length/2). Effective period is L-F,
10
+ * and forward playback starts at startFrame+F. Zero disables the overlap. */
11
+ crossfadeFrames: number;
12
+ /** Linear gate-off ramp in output samples; zero/one stops on first off sample. */
13
+ releaseFrames?: number;
14
+ }
15
+ /** One mono cyclic waveform with a unity-sum linear tail/head overlap. Shortens
16
+ * the source period from L to L-F; reverse traverses that same waveform backward.
17
+ * Not a dry-attack, time-stretching, antialiasing or arbitrary-content seam promise. */
18
+ export declare const crossfadedLoopPlayer: import("@unworklet/core").SubgraphDecl<[config: CrossfadedLoopConfig], {
19
+ tick(c: SamplePlayerControls): {
20
+ output: Node<"f32">;
21
+ active: Node<"bool">;
22
+ position: Node<"f32">;
23
+ phase: Node<"f32">;
24
+ periodFrames: Node<"i32">;
25
+ crossfadeFrames: Node<"i32">;
26
+ missing: Node<"bool">;
27
+ };
28
+ }>;