@hraness/dawg 0.2.0 → 0.4.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 (140) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/DAWG.md +762 -11
  3. package/README.md +79 -30
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +261 -0
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/key.ts +43 -0
  9. package/core/kits.ts +320 -0
  10. package/core/params.ts +111 -0
  11. package/core/pitch.ts +60 -0
  12. package/core/rhythm.ts +287 -0
  13. package/core/score.ts +1054 -14
  14. package/core/sdk/eval-child.ts +125 -0
  15. package/core/sdk/eval.ts +260 -0
  16. package/core/sdk/print.ts +675 -0
  17. package/core/sdk/sync-chords.ts +54 -0
  18. package/core/sdk/v1.ts +4159 -0
  19. package/core/slug.ts +19 -0
  20. package/core/synth.ts +1001 -0
  21. package/package.json +8 -5
  22. package/src/agent/agent.ts +317 -16
  23. package/src/agent/brief.ts +50 -5
  24. package/src/agent/chord-tools.ts +357 -0
  25. package/src/agent/drum-tools.ts +135 -0
  26. package/src/agent/gateway.ts +215 -41
  27. package/src/agent/models.ts +633 -0
  28. package/src/agent/ops.ts +3 -18
  29. package/src/agent/pack-tools.ts +369 -0
  30. package/src/agent/planner.ts +55 -2
  31. package/src/agent/preview-tool.ts +270 -0
  32. package/src/agent/provider.ts +234 -106
  33. package/src/agent/rhythm-tools.ts +145 -0
  34. package/src/agent/sse.ts +31 -7
  35. package/src/agent/tools.ts +705 -14
  36. package/src/agent/usage.ts +296 -0
  37. package/src/agent/workspace.ts +683 -0
  38. package/src/agent/xcb-agent.ts +35 -14
  39. package/src/agent/xcb.ts +233 -21
  40. package/src/audio/audition.ts +93 -0
  41. package/src/audio/cache.ts +160 -0
  42. package/src/audio/click.ts +125 -0
  43. package/src/audio/effects/bus.ts +147 -0
  44. package/src/audio/effects/chain.ts +99 -0
  45. package/src/audio/effects/common.ts +251 -0
  46. package/src/audio/effects/convolution.ts +301 -0
  47. package/src/audio/effects/drive.ts +142 -0
  48. package/src/audio/effects/duck.ts +122 -0
  49. package/src/audio/effects/dynamics.ts +121 -0
  50. package/src/audio/effects/filter.ts +319 -0
  51. package/src/audio/effects/modulation.ts +174 -0
  52. package/src/audio/effects/space.ts +337 -0
  53. package/src/audio/engine.ts +430 -33
  54. package/src/audio/kits.ts +200 -0
  55. package/src/audio/live.ts +152 -0
  56. package/src/audio/packs.ts +1787 -0
  57. package/src/audio/player.ts +19 -4
  58. package/src/audio/preview.ts +470 -0
  59. package/src/audio/random.ts +15 -0
  60. package/src/audio/render-worker.ts +68 -0
  61. package/src/audio/renderer.ts +174 -0
  62. package/src/audio/sampler.ts +420 -0
  63. package/src/audio/samples.ts +1025 -0
  64. package/src/audio/synth/oscillators.ts +268 -0
  65. package/src/audio/synth/voice.ts +555 -0
  66. package/src/audio/synth/zzfx.ts +137 -0
  67. package/src/audio/wav.ts +550 -334
  68. package/src/audio/wavetable-maker.ts +717 -0
  69. package/src/audio/wavetable.ts +624 -0
  70. package/src/auth/cli.ts +146 -27
  71. package/src/auth/credentials.ts +167 -41
  72. package/src/auth/discover.ts +481 -0
  73. package/src/auth/login.ts +885 -128
  74. package/src/auth/openrouter.ts +206 -0
  75. package/src/auth/picker.ts +282 -0
  76. package/src/auth/runner.ts +25 -2
  77. package/src/auth/tui.ts +60 -43
  78. package/src/commands/drums.ts +299 -0
  79. package/src/commands/edit.ts +192 -0
  80. package/src/commands/fx.ts +360 -0
  81. package/src/commands/help.ts +535 -0
  82. package/src/commands/history.ts +32 -19
  83. package/src/commands/music.ts +34 -15
  84. package/src/commands/pack.ts +423 -0
  85. package/src/commands/rhythm.ts +230 -0
  86. package/src/commands/sample.ts +462 -0
  87. package/src/commands/synth.ts +229 -0
  88. package/src/commands/wavetable.ts +370 -0
  89. package/src/main.ts +2136 -170
  90. package/src/media/analyze.ts +364 -0
  91. package/src/media/backend.ts +253 -0
  92. package/src/media/cli.ts +186 -0
  93. package/src/media/download.ts +281 -0
  94. package/src/media/dsp.ts +281 -0
  95. package/src/media/import.ts +130 -0
  96. package/src/media/lyrics.ts +201 -0
  97. package/src/media/notes.ts +363 -0
  98. package/src/media/paths.ts +168 -0
  99. package/src/media/process.ts +226 -0
  100. package/src/media/registry.ts +9 -0
  101. package/src/media/sidecar.ts +72 -0
  102. package/src/media/stemdeck.ts +254 -0
  103. package/src/media/stems.ts +173 -0
  104. package/src/media/tools.ts +397 -0
  105. package/src/media/types.ts +92 -0
  106. package/src/media/vendor/basic-pitch.ts +261 -0
  107. package/src/media/vendor/drums.ts +817 -0
  108. package/src/media/vendor/grid.ts +203 -0
  109. package/src/media/vendor/util.ts +139 -0
  110. package/src/media/vendor/wav.ts +233 -0
  111. package/src/media/wavetable.ts +202 -0
  112. package/src/project/check.ts +80 -0
  113. package/src/project/init.ts +253 -0
  114. package/src/project/sync.ts +432 -0
  115. package/src/project/typecheck.ts +149 -0
  116. package/src/render.ts +49 -6
  117. package/src/session/attach.ts +3 -4
  118. package/src/session/daemon.ts +25 -8
  119. package/src/session/delta.ts +249 -0
  120. package/src/session/naming.ts +6 -38
  121. package/src/session/port.ts +26 -6
  122. package/src/session/rebase.ts +38 -8
  123. package/src/session/store.ts +116 -21
  124. package/src/tui/audition.ts +501 -0
  125. package/src/tui/euclid.ts +472 -0
  126. package/src/tui/menu.ts +2125 -0
  127. package/src/tui/play-chords.ts +538 -0
  128. package/src/tui/play-mode.ts +442 -0
  129. package/src/tui/play-session.ts +984 -0
  130. package/src/tui/sketch.ts +108 -0
  131. package/src/web/fetch.ts +340 -0
  132. package/src/web/http.ts +137 -0
  133. package/src/web/search.ts +684 -0
  134. package/tui/activity.ts +42 -3
  135. package/tui/app.ts +398 -33
  136. package/tui/drums.ts +44 -0
  137. package/tui/grammar.ts +281 -0
  138. package/tui/highway.ts +24 -2
  139. package/tui/layers.ts +14 -2
  140. package/tui/play-strip.ts +188 -0
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Filter stages: the track filter (`track.filter`, lpf/hpf/bpf with 12db,
3
+ * 24db or ladder slope), the one-knob DJ filter, the LFO auto filter and the
4
+ * vowel formant bank. All run on the mono track signal before pan.
5
+ */
6
+ import { SCORE_LIMITS, type Track } from "../../../core/score.ts";
7
+ import type { FxValues } from "../../../core/fx.ts";
8
+ import {
9
+ Biquad,
10
+ CONTROL_SAMPLES,
11
+ OnePole,
12
+ Phasor,
13
+ clamp,
14
+ fxReader,
15
+ interpolateAutomation,
16
+ lfo,
17
+ lfoHz,
18
+ resonanceQ,
19
+ type EffectContext,
20
+ type FilterType,
21
+ } from "./common.ts";
22
+
23
+ /** Butterworth stage Qs for a 4-pole response; the second carries resonance. */
24
+ const BUTTERWORTH_Q1 = 0.5412;
25
+ const BUTTERWORTH_Q2_SCALE = 1.3066 / 0.707;
26
+
27
+ /**
28
+ * A filter of any type and slope whose cutoff and resonance may change at
29
+ * control rate. `12db` is exactly the original track filter (one RBJ
30
+ * biquad), so documents without `type`/`ftype` render unchanged.
31
+ */
32
+ export class SlopeFilter {
33
+ private readonly first = new Biquad();
34
+ private readonly second = new Biquad();
35
+ private readonly ladder = new Float64Array(4);
36
+ private ladderG = 0;
37
+ private ladderK = 0;
38
+
39
+ constructor(
40
+ private readonly type: FilterType,
41
+ private readonly slope: string,
42
+ private readonly sampleRate: number,
43
+ ) {}
44
+
45
+ private get isLadder(): boolean {
46
+ return this.slope === "ladder" && this.type === "lpf";
47
+ }
48
+
49
+ update(cutoff: number, resonance: number): void {
50
+ if (this.isLadder) {
51
+ const frequency = clamp(cutoff, 20, this.sampleRate * 0.45);
52
+ this.ladderG = 1 - Math.exp((-2 * Math.PI * frequency) / this.sampleRate);
53
+ this.ladderK = 3.9 * clamp(resonance, 0, 1);
54
+ return;
55
+ }
56
+ if (this.slope === "12db") {
57
+ this.first.set(this.type, cutoff, resonanceQ(resonance), this.sampleRate);
58
+ return;
59
+ }
60
+ this.first.set(this.type, cutoff, BUTTERWORTH_Q1, this.sampleRate);
61
+ this.second.set(
62
+ this.type,
63
+ cutoff,
64
+ resonanceQ(resonance) * BUTTERWORTH_Q2_SCALE,
65
+ this.sampleRate,
66
+ );
67
+ }
68
+
69
+ process(input: number): number {
70
+ if (this.isLadder) {
71
+ // Four one-pole stages with a saturated global feedback path
72
+ // (Moog-style ladder after Stilson & Smith / Huovilainen).
73
+ const stages = this.ladder;
74
+ const g = this.ladderG;
75
+ const k = this.ladderK;
76
+ const driven = Math.tanh(input - k * stages[3]!);
77
+ stages[0]! += g * (driven - stages[0]!);
78
+ stages[1]! += g * (stages[0]! - stages[1]!);
79
+ stages[2]! += g * (stages[1]! - stages[2]!);
80
+ stages[3]! += g * (stages[2]! - stages[3]!);
81
+ return stages[3]! * (1 + 0.5 * k);
82
+ }
83
+ const once = this.first.process(input);
84
+ return this.slope === "12db" ? once : this.second.process(once);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * The track filter with static or automated cutoff and resonance (lanes
90
+ * `filter` and `resonance`). A lane alone, with no static filter, filters
91
+ * from a fully open low-pass, as the original renderer did.
92
+ */
93
+ export function applyTrackFilter(
94
+ buffer: Float64Array,
95
+ track: Track,
96
+ context: EffectContext,
97
+ ): void {
98
+ const automation = track.filterAutomation ?? [];
99
+ const resonanceLane = track.resonanceAutomation ?? [];
100
+ if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
101
+ return;
102
+ const { sampleRate, samplesPerTick } = context;
103
+ const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
104
+ const staticResonance = track.filter?.resonance ?? 0;
105
+ const automated = automation.length > 0 || resonanceLane.length > 0;
106
+ const filter = new SlopeFilter(
107
+ track.filter?.type ?? "lpf",
108
+ track.filter?.ftype ?? "12db",
109
+ sampleRate,
110
+ );
111
+ filter.update(staticCutoff, staticResonance);
112
+ for (let index = 0; index < buffer.length; index += 1) {
113
+ if (automated && index % CONTROL_SAMPLES === 0) {
114
+ const tick = index / samplesPerTick;
115
+ filter.update(
116
+ interpolateAutomation(automation, tick, staticCutoff),
117
+ interpolateAutomation(resonanceLane, tick, staticResonance),
118
+ );
119
+ }
120
+ buffer[index] = filter.process(buffer[index]!);
121
+ }
122
+ }
123
+
124
+ /** Cutoff for a DJ-filter position: 0 → 20 Hz low-pass … 1 → 20 kHz high-pass. */
125
+ function djCutoff(value: number): { type: FilterType; cutoff: number } {
126
+ return value < 0.5
127
+ ? { type: "lpf", cutoff: 20 * 1000 ** (value / 0.5) }
128
+ : { type: "hpf", cutoff: 20 * 1000 ** ((value - 0.5) / 0.5) };
129
+ }
130
+
131
+ /**
132
+ * One-knob DJ filter (Strudel `djf`): below 0.5 a low-pass closes toward
133
+ * 20 Hz, above 0.5 a high-pass rises toward 20 kHz; 0.5 passes unchanged.
134
+ */
135
+ export function applyDjFilter(
136
+ buffer: Float64Array,
137
+ track: Track,
138
+ values: FxValues,
139
+ context: EffectContext,
140
+ ): void {
141
+ const value = fxReader(track, "djf", values, context).param("value");
142
+ const lowpass = new Biquad();
143
+ const highpass = new Biquad();
144
+ let position = -1;
145
+ for (let index = 0; index < buffer.length; index += 1) {
146
+ if (index % CONTROL_SAMPLES === 0) {
147
+ const next = value.at(index);
148
+ if (next !== position) {
149
+ position = next;
150
+ const { type, cutoff } = djCutoff(position);
151
+ // Both filters stay warm so sweeping across 0.5 never clicks.
152
+ lowpass.set(
153
+ "lpf",
154
+ type === "lpf" ? cutoff : 20_000,
155
+ 0.8,
156
+ context.sampleRate,
157
+ );
158
+ highpass.set(
159
+ "hpf",
160
+ type === "hpf" ? cutoff : 20,
161
+ 0.8,
162
+ context.sampleRate,
163
+ );
164
+ }
165
+ }
166
+ buffer[index] = highpass.process(lowpass.process(buffer[index]!));
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Auto filter: an LFO sweeps the cutoff `depth` octaves around `cutoff`
172
+ * (half above, half below). `follow` adds an envelope follower that moves
173
+ * the cutoff by up to `follow` octaves at full input level.
174
+ */
175
+ export function applyAutoFilter(
176
+ buffer: Float64Array,
177
+ track: Track,
178
+ values: FxValues,
179
+ context: EffectContext,
180
+ ): void {
181
+ const read = fxReader(track, "autofilter", values, context);
182
+ const cutoff = read.param("cutoff");
183
+ const resonance = read.param("resonance");
184
+ const depth = read.param("depth");
185
+ const follow = read.param("follow");
186
+ const rate = read.param("rate");
187
+ const sync = read.number("sync");
188
+ const shape = read.text("shape");
189
+ const filter = new SlopeFilter(
190
+ read.text("type") as FilterType,
191
+ "12db",
192
+ context.sampleRate,
193
+ );
194
+ const phasor = new Phasor(read.number("phase"));
195
+ const attack = Math.exp(-1 / (0.005 * context.sampleRate));
196
+ const release = Math.exp(-1 / (0.12 * context.sampleRate));
197
+ let envelope = 0;
198
+ let followOctaves = follow.at(0);
199
+ for (let index = 0; index < buffer.length; index += 1) {
200
+ const input = buffer[index]!;
201
+ const level = Math.abs(input);
202
+ envelope =
203
+ level > envelope
204
+ ? attack * envelope + (1 - attack) * level
205
+ : release * envelope + (1 - release) * level;
206
+ if (index % CONTROL_SAMPLES === 0) {
207
+ phasor.setHz(
208
+ lfoHz(sync, rate.at(index), context.tempoBpm),
209
+ context.sampleRate,
210
+ );
211
+ followOctaves = follow.at(index);
212
+ const sweep = lfo(shape, phasor.phase, 0x5eed) * depth.at(index) * 0.5;
213
+ const envelopeOctaves = followOctaves * Math.min(1, envelope * 2);
214
+ filter.update(
215
+ cutoff.at(index) * 2 ** (sweep + envelopeOctaves),
216
+ resonance.at(index),
217
+ );
218
+ }
219
+ phasor.next();
220
+ buffer[index] = filter.process(input);
221
+ }
222
+ }
223
+
224
+ type Formants = readonly (readonly [number, number, number])[];
225
+
226
+ /**
227
+ * Formant frequency (Hz), level (dB) and bandwidth (Hz). The five plain
228
+ * vowels use the classic tenor formant table (published acoustic
229
+ * phonetics data); the extended set approximates F1–F3 from standard
230
+ * vowel charts with tenor-typical F4/F5.
231
+ */
232
+ const VOWEL_FORMANTS: Readonly<Record<string, Formants>> = Object.freeze({
233
+ a: [
234
+ [650, 0, 80],
235
+ [1080, -6, 90],
236
+ [2650, -7, 120],
237
+ [2900, -8, 130],
238
+ [3250, -22, 140],
239
+ ],
240
+ e: [
241
+ [400, 0, 70],
242
+ [1700, -14, 80],
243
+ [2600, -12, 100],
244
+ [3200, -14, 120],
245
+ [3580, -20, 120],
246
+ ],
247
+ i: [
248
+ [290, 0, 40],
249
+ [1870, -15, 90],
250
+ [2800, -18, 100],
251
+ [3250, -20, 120],
252
+ [3540, -30, 120],
253
+ ],
254
+ o: [
255
+ [400, 0, 40],
256
+ [800, -10, 80],
257
+ [2600, -12, 100],
258
+ [2800, -12, 120],
259
+ [3000, -26, 120],
260
+ ],
261
+ u: [
262
+ [350, 0, 40],
263
+ [600, -20, 60],
264
+ [2700, -17, 100],
265
+ [2900, -14, 120],
266
+ [3300, -26, 120],
267
+ ],
268
+ ae: extended(660, 1720, 2410),
269
+ aa: extended(710, 1100, 2540),
270
+ oe: extended(390, 1680, 2400),
271
+ ue: extended(300, 1600, 2200),
272
+ y: extended(250, 1750, 2160),
273
+ uh: extended(600, 1170, 2390),
274
+ un: extended(500, 1400, 2500),
275
+ en: extended(550, 1650, 2500),
276
+ an: extended(650, 1050, 2550),
277
+ on: extended(450, 850, 2550),
278
+ });
279
+
280
+ function extended(f1: number, f2: number, f3: number): Formants {
281
+ return [
282
+ [f1, 0, 70],
283
+ [f2, -8, 90],
284
+ [f3, -14, 110],
285
+ [3300, -16, 130],
286
+ [3750, -24, 140],
287
+ ];
288
+ }
289
+
290
+ /** Gain that brings the summed formant bank near unity for speech-band input. */
291
+ const VOWEL_GAIN = 2.2;
292
+
293
+ /** Vowel formant filter (Strudel `vowel`): five parallel band-passes. */
294
+ export function applyVowel(
295
+ buffer: Float64Array,
296
+ track: Track,
297
+ values: FxValues,
298
+ context: EffectContext,
299
+ ): void {
300
+ const read = fxReader(track, "vowel", values, context);
301
+ const formants = VOWEL_FORMANTS[read.text("vowel")] ?? VOWEL_FORMANTS.a!;
302
+ const mix = read.param("mix");
303
+ const bank = formants.map(([frequency, level, bandwidth]) => {
304
+ const filter = new Biquad();
305
+ filter.set("bpf", frequency, frequency / bandwidth, context.sampleRate);
306
+ return { filter, gain: 10 ** (level / 20) };
307
+ });
308
+ // Formant peaks are narrow; a gentle pre-emphasis keeps the vowel bright.
309
+ const tilt = new OnePole(8000, context.sampleRate);
310
+ let wet = mix.at(0);
311
+ for (let index = 0; index < buffer.length; index += 1) {
312
+ if (mix.automated && index % CONTROL_SAMPLES === 0) wet = mix.at(index);
313
+ const input = buffer[index]!;
314
+ const emphasized = input + 0.5 * (input - tilt.process(input));
315
+ let sum = 0;
316
+ for (const band of bank) sum += band.filter.process(emphasized) * band.gain;
317
+ buffer[index] = input + (sum * VOWEL_GAIN - input) * wet;
318
+ }
319
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Stereo modulation stages after pan: phaser, chorus and leslie.
3
+ */
4
+ import type { Track } from "../../../core/score.ts";
5
+ import type { FxValues } from "../../../core/fx.ts";
6
+ import {
7
+ CONTROL_SAMPLES,
8
+ Phasor,
9
+ clamp,
10
+ fxReader,
11
+ lfoHz,
12
+ readFractional,
13
+ type EffectContext,
14
+ } from "./common.ts";
15
+
16
+ const PHASER_STAGES = 4;
17
+ const PHASER_FEEDBACK = 0.3;
18
+
19
+ /**
20
+ * Four first-order all-pass stages whose break frequency an LFO sweeps
21
+ * `sweep` Hz around `center`; summing with the dry signal cuts moving
22
+ * notches. Both channels share the LFO so the pan image holds.
23
+ */
24
+ export function applyPhaser(
25
+ left: Float64Array,
26
+ right: Float64Array,
27
+ track: Track,
28
+ values: FxValues,
29
+ context: EffectContext,
30
+ ): void {
31
+ const read = fxReader(track, "phaser", values, context);
32
+ const rate = read.param("rate");
33
+ const depth = read.param("depth");
34
+ const sync = read.number("sync");
35
+ const center = read.number("center");
36
+ const sweep = read.number("sweep");
37
+ const { sampleRate } = context;
38
+ const states = [
39
+ new Float64Array(PHASER_STAGES),
40
+ new Float64Array(PHASER_STAGES),
41
+ ];
42
+ const feedback = [0, 0];
43
+ const phasor = new Phasor(0);
44
+ let coefficient = 0;
45
+ let amount = depth.at(0);
46
+ const channel = (side: 0 | 1, input: number): number => {
47
+ const state = states[side]!;
48
+ let signal = input + feedback[side]! * PHASER_FEEDBACK;
49
+ for (let stage = 0; stage < PHASER_STAGES; stage += 1) {
50
+ // y = a·x + x[n-1] − a·y[n-1], kept as one state per stage.
51
+ const output = coefficient * signal + state[stage]!;
52
+ state[stage] = signal - coefficient * output;
53
+ signal = output;
54
+ }
55
+ feedback[side] = signal;
56
+ return input * (1 - amount / 2) + signal * (amount / 2);
57
+ };
58
+ for (let index = 0; index < left.length; index += 1) {
59
+ if (index % CONTROL_SAMPLES === 0) {
60
+ phasor.setHz(lfoHz(sync, rate.at(index), context.tempoBpm), sampleRate);
61
+ if (depth.automated) amount = depth.at(index);
62
+ const frequency = clamp(
63
+ center + (sweep / 2) * Math.sin(2 * Math.PI * phasor.phase),
64
+ 20,
65
+ sampleRate * 0.45,
66
+ );
67
+ const t = Math.tan((Math.PI * frequency) / sampleRate);
68
+ coefficient = (t - 1) / (t + 1);
69
+ }
70
+ phasor.next();
71
+ left[index] = channel(0, left[index]!);
72
+ right[index] = channel(1, right[index]!);
73
+ }
74
+ }
75
+
76
+ const CHORUS_BASE_MS = 12;
77
+ const CHORUS_SWING_MS = 6;
78
+
79
+ /**
80
+ * Stereo chorus: each side reads a delay line modulated by a sine LFO, the
81
+ * right a quarter cycle behind the left, blended with the dry signal.
82
+ */
83
+ export function applyChorus(
84
+ left: Float64Array,
85
+ right: Float64Array,
86
+ track: Track,
87
+ values: FxValues,
88
+ context: EffectContext,
89
+ ): void {
90
+ const read = fxReader(track, "chorus", values, context);
91
+ const rate = read.param("rate");
92
+ const depth = read.param("depth");
93
+ const mix = read.param("mix");
94
+ const { sampleRate } = context;
95
+ const size =
96
+ Math.ceil(((CHORUS_BASE_MS + CHORUS_SWING_MS) / 1000) * sampleRate) + 4;
97
+ const lines = [new Float64Array(size), new Float64Array(size)];
98
+ const phasor = new Phasor(0);
99
+ const base = (CHORUS_BASE_MS / 1000) * sampleRate;
100
+ const swing = (CHORUS_SWING_MS / 1000) * sampleRate;
101
+ let amount = depth.at(0);
102
+ let wet = mix.at(0);
103
+ for (let index = 0; index < left.length; index += 1) {
104
+ if (index % CONTROL_SAMPLES === 0) {
105
+ phasor.setHz(rate.at(index), sampleRate);
106
+ if (depth.automated) amount = depth.at(index);
107
+ if (mix.automated) wet = mix.at(index);
108
+ }
109
+ const phase = phasor.next();
110
+ const write = index % size;
111
+ for (let side = 0; side < 2; side += 1) {
112
+ const buffer = side === 0 ? left : right;
113
+ const line = lines[side]!;
114
+ const input = buffer[index]!;
115
+ line[write] = input;
116
+ const delay =
117
+ base +
118
+ swing *
119
+ amount *
120
+ 0.5 *
121
+ (1 + Math.sin(2 * Math.PI * (phase + side * 0.25)));
122
+ const delayed = readFractional(line, write, delay);
123
+ buffer[index] = input * (1 - wet) + delayed * wet;
124
+ }
125
+ }
126
+ }
127
+
128
+ const LESLIE_BASE_MS = 3;
129
+ const LESLIE_DOPPLER_MS = 1.5;
130
+ const LESLIE_AM = 0.35;
131
+
132
+ /**
133
+ * Rotary speaker: the mono sum feeds a horn whose distance from two
134
+ * microphones (left, right) swings with the rotation, giving opposed
135
+ * Doppler pitch warble and amplitude rotation; `size` scales the Doppler.
136
+ */
137
+ export function applyLeslie(
138
+ left: Float64Array,
139
+ right: Float64Array,
140
+ track: Track,
141
+ values: FxValues,
142
+ context: EffectContext,
143
+ ): void {
144
+ const read = fxReader(track, "leslie", values, context);
145
+ const rate = read.param("rate");
146
+ const mix = read.param("mix");
147
+ const size = read.number("size");
148
+ const { sampleRate } = context;
149
+ const base = (LESLIE_BASE_MS / 1000) * sampleRate;
150
+ const doppler = (LESLIE_DOPPLER_MS / 1000) * sampleRate * size;
151
+ const length = Math.ceil(base + doppler) + 4;
152
+ const line = new Float64Array(length);
153
+ const phasor = new Phasor(0);
154
+ let wet = mix.at(0);
155
+ for (let index = 0; index < left.length; index += 1) {
156
+ if (index % CONTROL_SAMPLES === 0) {
157
+ phasor.setHz(rate.at(index), sampleRate);
158
+ if (mix.automated) wet = mix.at(index);
159
+ }
160
+ const angle = 2 * Math.PI * phasor.next();
161
+ const write = index % length;
162
+ const dryL = left[index]!;
163
+ const dryR = right[index]!;
164
+ line[write] = (dryL + dryR) / 2;
165
+ const sin = Math.sin(angle);
166
+ const cos = Math.cos(angle);
167
+ const hornL = readFractional(line, write, base + doppler * 0.5 * (1 + sin));
168
+ const hornR = readFractional(line, write, base + doppler * 0.5 * (1 - sin));
169
+ const wetL = hornL * (1 + LESLIE_AM * cos);
170
+ const wetR = hornR * (1 - LESLIE_AM * cos);
171
+ left[index] = dryL * (1 - wet) + wetL * wet;
172
+ right[index] = dryR * (1 - wet) + wetR * wet;
173
+ }
174
+ }