@hraness/dawg 0.3.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.
- package/CHANGELOG.md +122 -0
- package/DAWG.md +605 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +27 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +280 -278
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1119 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +1286 -244
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -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
|
+
}
|