@hraness/dawg 0.5.0 → 0.6.1
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/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pitch shift at constant duration (0.6 B2 `shift`, `formant`), ported from
|
|
3
|
+
* the granular prototype (`proto/granular/stretch.ts` `pitchShift`):
|
|
4
|
+
* phase-vocoder stretch by rho = 2^(st/12) onto an intermediate axis (bins
|
|
5
|
+
* above Nyquist/rho dropped so the read-back cannot alias), then read it
|
|
6
|
+
* rho times faster with a 4-point Hermite, so the length is kept exactly.
|
|
7
|
+
*
|
|
8
|
+
* Formants: absent follows the pitch (like a tape); a number moves them
|
|
9
|
+
* that many semitones from where they were, so `0` keeps them (cepstral
|
|
10
|
+
* envelope from `envelope.ts`: magnitudes are divided by the frame's
|
|
11
|
+
* envelope and multiplied by the envelope warped to where it must land
|
|
12
|
+
* after the read-back). Identity phase locking after Laroche and Dolson,
|
|
13
|
+
* "Improved phase vocoder time-scale modification of audio", IEEE TSAP 1999.
|
|
14
|
+
* Strudel's `stretch` (superdough's phase-vocoder worklet) is the same idea.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
ENVELOPE_ITERATIONS,
|
|
18
|
+
ENVELOPE_LIFTER_SECONDS,
|
|
19
|
+
cepstralEnvelope,
|
|
20
|
+
envelopeScratch,
|
|
21
|
+
peakEnvelope,
|
|
22
|
+
} from "./envelope.ts";
|
|
23
|
+
import { hermite4 } from "./interp.ts";
|
|
24
|
+
import {
|
|
25
|
+
HANN_SQUARED_OLA_QUARTER,
|
|
26
|
+
analyse,
|
|
27
|
+
frameSize,
|
|
28
|
+
hann,
|
|
29
|
+
inverse,
|
|
30
|
+
princarg,
|
|
31
|
+
sampleAt,
|
|
32
|
+
} from "./stft.ts";
|
|
33
|
+
|
|
34
|
+
/** Analysis frame, as the `tones` fit (85 ms). */
|
|
35
|
+
export const SHIFT_FRAME_SECONDS = 0.085;
|
|
36
|
+
/** Vocoder hops between yields of `pitchShiftJob`. */
|
|
37
|
+
const SHIFT_YIELD_HOPS = 16;
|
|
38
|
+
/** Largest per-frame level restore after formant correction (12 dB). */
|
|
39
|
+
const FORMANT_LEVEL_CAP = 4;
|
|
40
|
+
/** Largest formant correction gain (24 dB), so a deep envelope dip cannot explode. */
|
|
41
|
+
const MAX_FORMANT_GAIN = 16;
|
|
42
|
+
|
|
43
|
+
export type ShiftOptions = Readonly<{
|
|
44
|
+
/** Formant shift in semitones; 0 keeps them; absent follows the pitch. */
|
|
45
|
+
formant?: number;
|
|
46
|
+
/** Frame length in seconds (rounded up to a power of two). */
|
|
47
|
+
frame?: number;
|
|
48
|
+
/** Cepstral lifter in seconds. */
|
|
49
|
+
lifter?: number;
|
|
50
|
+
/** True-envelope passes (0 is the plain cepstral envelope; default 16). */
|
|
51
|
+
iterations?: number;
|
|
52
|
+
/** Shrink the lifter to a quarter pitch period per frame (default on). */
|
|
53
|
+
adaptive?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Envelope used to keep formants: "peaks" (default) joins the harmonic
|
|
56
|
+
* peaks; "cepstral" is the true cepstral envelope alone.
|
|
57
|
+
*/
|
|
58
|
+
envelope?: "peaks" | "cepstral";
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Phase vocoder reading `x` at `t / rho` for `outLength` frames, with
|
|
63
|
+
* bins above Nyquist/rho removed and optional formant correction for a
|
|
64
|
+
* later read-back at `rho`.
|
|
65
|
+
*/
|
|
66
|
+
function* stretchForShift(
|
|
67
|
+
x: ArrayLike<number>,
|
|
68
|
+
sampleRate: number,
|
|
69
|
+
rho: number,
|
|
70
|
+
outLength: number,
|
|
71
|
+
formantRatio: number | undefined,
|
|
72
|
+
options: ShiftOptions,
|
|
73
|
+
): Generator<void, Float64Array> {
|
|
74
|
+
const n = frameSize(options.frame ?? SHIFT_FRAME_SECONDS, sampleRate);
|
|
75
|
+
const hs = n / 4;
|
|
76
|
+
const half = n / 2;
|
|
77
|
+
const w = hann(n);
|
|
78
|
+
const norm = 1 / HANN_SQUARED_OLA_QUARTER;
|
|
79
|
+
const keep = rho > 1 ? Math.floor(half / rho) : half;
|
|
80
|
+
const lifter = Math.max(
|
|
81
|
+
4,
|
|
82
|
+
Math.round((options.lifter ?? ENVELOPE_LIFTER_SECONDS) * sampleRate),
|
|
83
|
+
);
|
|
84
|
+
const periodRange = [sampleRate / 800, sampleRate / 50] as const;
|
|
85
|
+
const out = new Float64Array(outLength + n);
|
|
86
|
+
const reA = new Float64Array(n);
|
|
87
|
+
const imA = new Float64Array(n);
|
|
88
|
+
const reB = new Float64Array(n);
|
|
89
|
+
const imB = new Float64Array(n);
|
|
90
|
+
const mag = new Float64Array(half + 1);
|
|
91
|
+
const phA = new Float64Array(half + 1);
|
|
92
|
+
const syn = new Float64Array(half + 1);
|
|
93
|
+
const next = new Float64Array(half + 1);
|
|
94
|
+
const omega = new Float64Array(half + 1);
|
|
95
|
+
const env = new Float64Array(half + 1);
|
|
96
|
+
const scratch = envelopeScratch(n);
|
|
97
|
+
const smooth = new Float64Array(half + 1);
|
|
98
|
+
const envPeaks: number[] = [];
|
|
99
|
+
const peaks: number[] = [];
|
|
100
|
+
for (let m = 0; m * hs < outLength; m += 1) {
|
|
101
|
+
if (m % SHIFT_YIELD_HOPS === SHIFT_YIELD_HOPS - 1) yield;
|
|
102
|
+
const at = Math.round((m * hs + half) / rho - half);
|
|
103
|
+
analyse(x, at, w, reA, imA);
|
|
104
|
+
analyse(x, at - hs, w, reB, imB);
|
|
105
|
+
for (let k = 0; k <= half; k += 1) {
|
|
106
|
+
mag[k] = Math.hypot(reA[k]!, imA[k]!);
|
|
107
|
+
phA[k] = Math.atan2(imA[k]!, reA[k]!);
|
|
108
|
+
const phB = Math.atan2(imB[k]!, reB[k]!);
|
|
109
|
+
const bin = (2 * Math.PI * k * hs) / n;
|
|
110
|
+
omega[k] = bin + princarg(phA[k]! - phB - bin);
|
|
111
|
+
}
|
|
112
|
+
if (formantRatio !== undefined)
|
|
113
|
+
cepstralEnvelope(
|
|
114
|
+
mag,
|
|
115
|
+
n,
|
|
116
|
+
lifter,
|
|
117
|
+
env,
|
|
118
|
+
scratch,
|
|
119
|
+
options.iterations ?? ENVELOPE_ITERATIONS,
|
|
120
|
+
options.adaptive === false ? undefined : periodRange,
|
|
121
|
+
);
|
|
122
|
+
if (formantRatio !== undefined && options.envelope !== "cepstral") {
|
|
123
|
+
smooth.set(env);
|
|
124
|
+
peakEnvelope(mag, half, env, smooth, envPeaks);
|
|
125
|
+
}
|
|
126
|
+
if (m === 0) syn.set(phA);
|
|
127
|
+
else {
|
|
128
|
+
peaks.length = 0;
|
|
129
|
+
for (let k = 2; k <= half - 2; k += 1) {
|
|
130
|
+
const v = mag[k]!;
|
|
131
|
+
if (
|
|
132
|
+
v > mag[k - 1]! &&
|
|
133
|
+
v >= mag[k + 1]! &&
|
|
134
|
+
v > mag[k - 2]! &&
|
|
135
|
+
v >= mag[k + 2]!
|
|
136
|
+
)
|
|
137
|
+
peaks.push(k);
|
|
138
|
+
}
|
|
139
|
+
if (peaks.length === 0)
|
|
140
|
+
for (let k = 0; k <= half; k += 1) syn[k] = syn[k]! + omega[k]!;
|
|
141
|
+
else {
|
|
142
|
+
for (const p of peaks) next[p] = syn[p]! + omega[p]!;
|
|
143
|
+
let j = 0;
|
|
144
|
+
for (let k = 0; k <= half; k += 1) {
|
|
145
|
+
while (
|
|
146
|
+
j + 1 < peaks.length &&
|
|
147
|
+
Math.abs(peaks[j + 1]! - k) < Math.abs(peaks[j]! - k)
|
|
148
|
+
)
|
|
149
|
+
j += 1;
|
|
150
|
+
const p = peaks[j]!;
|
|
151
|
+
if (k !== p) next[k] = next[p]! + phA[k]! - phA[p]!;
|
|
152
|
+
}
|
|
153
|
+
syn.set(next);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
let before = 0;
|
|
157
|
+
let after = 0;
|
|
158
|
+
for (let k = 0; k <= half; k += 1) {
|
|
159
|
+
let a = k > keep ? 0 : mag[k]!;
|
|
160
|
+
before += a * a;
|
|
161
|
+
if (formantRatio !== undefined && a > 0) {
|
|
162
|
+
// Bin k lands at k*rho after the read-back; give it the envelope
|
|
163
|
+
// found at k*rho/formantRatio in the source frame.
|
|
164
|
+
const src = (k * rho) / formantRatio;
|
|
165
|
+
const i0 = Math.floor(src);
|
|
166
|
+
const target =
|
|
167
|
+
i0 + 1 <= half
|
|
168
|
+
? env[i0]! + (env[i0 + 1]! - env[i0]!) * (src - i0)
|
|
169
|
+
: env[half]! * 1e-3;
|
|
170
|
+
a *= Math.min(target / env[k]!, MAX_FORMANT_GAIN);
|
|
171
|
+
}
|
|
172
|
+
after += a * a;
|
|
173
|
+
reA[k] = a * Math.cos(syn[k]!);
|
|
174
|
+
imA[k] = a * Math.sin(syn[k]!);
|
|
175
|
+
if (k > 0 && k < half) {
|
|
176
|
+
reA[n - k] = reA[k]!;
|
|
177
|
+
imA[n - k] = -imA[k]!;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
imA[0] = 0;
|
|
181
|
+
imA[half] = 0;
|
|
182
|
+
// Formant correction reshapes the spectrum, not the loudness: give the
|
|
183
|
+
// frame back its energy (a scale moves no peak), capped at 12 dB.
|
|
184
|
+
if (formantRatio !== undefined && after > 0) {
|
|
185
|
+
const gain = Math.min(Math.sqrt(before / after), FORMANT_LEVEL_CAP);
|
|
186
|
+
for (let k = 0; k < n; k += 1) {
|
|
187
|
+
reA[k] = reA[k]! * gain;
|
|
188
|
+
imA[k] = imA[k]! * gain;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
inverse(reA, imA);
|
|
192
|
+
const o0 = m * hs;
|
|
193
|
+
for (let i = 0; i < n; i += 1)
|
|
194
|
+
out[o0 + i] = out[o0 + i]! + reA[i]! * w[i]! * norm;
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* `x` shifted by `semitones` at the same length. `formant` (semitones)
|
|
201
|
+
* moves the formants independently; 0 keeps them.
|
|
202
|
+
*/
|
|
203
|
+
export function pitchShift(
|
|
204
|
+
x: ArrayLike<number>,
|
|
205
|
+
sampleRate: number,
|
|
206
|
+
semitones: number,
|
|
207
|
+
options: ShiftOptions = {},
|
|
208
|
+
): Float32Array {
|
|
209
|
+
const job = pitchShiftJob(x, sampleRate, semitones, options);
|
|
210
|
+
for (;;) {
|
|
211
|
+
const step = job.next();
|
|
212
|
+
if (step.done) return step.value;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* `pitchShift` as resumable work: yields every few vocoder hops so a live
|
|
218
|
+
* engine can spread a long shift over idle time (`src/audio/fit.ts`).
|
|
219
|
+
*/
|
|
220
|
+
export function* pitchShiftJob(
|
|
221
|
+
x: ArrayLike<number>,
|
|
222
|
+
sampleRate: number,
|
|
223
|
+
semitones: number,
|
|
224
|
+
options: ShiftOptions = {},
|
|
225
|
+
): Generator<void, Float32Array> {
|
|
226
|
+
const length = x.length;
|
|
227
|
+
const out = new Float32Array(length);
|
|
228
|
+
if (semitones === 0 && options.formant === undefined) {
|
|
229
|
+
for (let i = 0; i < length; i += 1) out[i] = x[i]!;
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
const rho = 2 ** (semitones / 12);
|
|
233
|
+
const formantRatio =
|
|
234
|
+
options.formant === undefined ? undefined : 2 ** (options.formant / 12);
|
|
235
|
+
const midLength = Math.ceil(length * rho) + 4;
|
|
236
|
+
const y = yield* stretchForShift(
|
|
237
|
+
x,
|
|
238
|
+
sampleRate,
|
|
239
|
+
rho,
|
|
240
|
+
midLength,
|
|
241
|
+
formantRatio,
|
|
242
|
+
options,
|
|
243
|
+
);
|
|
244
|
+
for (let i = 0; i < length; i += 1) {
|
|
245
|
+
const pos = i * rho;
|
|
246
|
+
const j = Math.floor(pos);
|
|
247
|
+
out[i] = hermite4(
|
|
248
|
+
sampleAt(y, j - 1),
|
|
249
|
+
sampleAt(y, j),
|
|
250
|
+
sampleAt(y, j + 1),
|
|
251
|
+
sampleAt(y, j + 2),
|
|
252
|
+
pos - j,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Short-time Fourier helpers on the shared radix-2 FFT (`fft.ts`): frame
|
|
3
|
+
* sizes, a periodic Hann window, windowed frame reads and magnitude spectra.
|
|
4
|
+
* Pure functions on Float64Array buffers; zero outside the signal.
|
|
5
|
+
*/
|
|
6
|
+
import { fftInPlace } from "./fft.ts";
|
|
7
|
+
|
|
8
|
+
/** Smallest power of two at or above `value` (at least 1). */
|
|
9
|
+
export function nextPow2(value: number): number {
|
|
10
|
+
let n = 1;
|
|
11
|
+
while (n < value) n <<= 1;
|
|
12
|
+
return n;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Power-of-two frame length covering `seconds` at `sampleRate`. */
|
|
16
|
+
export function frameSize(seconds: number, sampleRate: number): number {
|
|
17
|
+
return nextPow2(Math.max(2, seconds * sampleRate));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Periodic Hann window: sums to a constant at 50 % and 75 % overlap. */
|
|
21
|
+
export function hann(n: number): Float64Array {
|
|
22
|
+
const w = new Float64Array(n);
|
|
23
|
+
for (let i = 0; i < n; i += 1)
|
|
24
|
+
w[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / n);
|
|
25
|
+
return w;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Sum of a periodic Hann squared over its hops at hop n/4: 1.5. */
|
|
29
|
+
export const HANN_SQUARED_OLA_QUARTER = 1.5;
|
|
30
|
+
|
|
31
|
+
/** Sample `index` of `x`, zero outside it. */
|
|
32
|
+
export function sampleAt(x: ArrayLike<number>, index: number): number {
|
|
33
|
+
return index >= 0 && index < x.length ? x[index]! : 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Wrap a phase to (-π, π]. */
|
|
37
|
+
export function princarg(phase: number): number {
|
|
38
|
+
return phase - 2 * Math.PI * Math.round(phase / (2 * Math.PI));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Frames of hop `hop` and length `n` that fit in `length` samples. */
|
|
42
|
+
export function frameCount(length: number, n: number, hop: number): number {
|
|
43
|
+
return Math.max(0, Math.floor((length - n) / hop) + 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Windowed frame of `x` starting at `at` into `re` (imaginary cleared),
|
|
48
|
+
* transformed in place: `re`/`im` hold the spectrum afterwards.
|
|
49
|
+
*/
|
|
50
|
+
export function analyse(
|
|
51
|
+
x: ArrayLike<number>,
|
|
52
|
+
at: number,
|
|
53
|
+
window: Float64Array,
|
|
54
|
+
re: Float64Array,
|
|
55
|
+
im: Float64Array,
|
|
56
|
+
): void {
|
|
57
|
+
const n = window.length;
|
|
58
|
+
for (let i = 0; i < n; i += 1) {
|
|
59
|
+
re[i] = sampleAt(x, at + i) * window[i]!;
|
|
60
|
+
im[i] = 0;
|
|
61
|
+
}
|
|
62
|
+
fftInPlace(re, im);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Inverse transform with 1/n scaling (the shared FFT does not scale). */
|
|
66
|
+
export function inverse(re: Float64Array, im: Float64Array): void {
|
|
67
|
+
fftInPlace(re, im, true);
|
|
68
|
+
const scale = 1 / re.length;
|
|
69
|
+
for (let i = 0; i < re.length; i += 1) {
|
|
70
|
+
re[i] = re[i]! * scale;
|
|
71
|
+
im[i] = im[i]! * scale;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grain window tables (0.6 granular, shared): 2048 + 1 entries over 0..1,
|
|
3
|
+
* read by index with linear interpolation. Ported from the 0.6 granular
|
|
4
|
+
* prototype (`proto/granular/dsp.ts`, `windowTable`).
|
|
5
|
+
*
|
|
6
|
+
* - hann: raised cosine, the smooth default (Roads, Microsound, 2001).
|
|
7
|
+
* - tukey: flat over the middle half, cosine edges (denser, louder grains).
|
|
8
|
+
* - gauss: sigma 0.15 with the edges pulled to zero.
|
|
9
|
+
* - tri: Bartlett.
|
|
10
|
+
* - perc: 2 % attack then a cubic decay (plucky grains).
|
|
11
|
+
* - rperc: perc reversed (swelling grains).
|
|
12
|
+
*/
|
|
13
|
+
export const WINDOW_SHAPES = Object.freeze([
|
|
14
|
+
"hann",
|
|
15
|
+
"tukey",
|
|
16
|
+
"gauss",
|
|
17
|
+
"tri",
|
|
18
|
+
"perc",
|
|
19
|
+
"rperc",
|
|
20
|
+
] as const);
|
|
21
|
+
export type WindowShape = (typeof WINDOW_SHAPES)[number];
|
|
22
|
+
|
|
23
|
+
/** Table resolution; tables hold `WINDOW_TABLE + 1` points (both ends). */
|
|
24
|
+
export const WINDOW_TABLE = 2048;
|
|
25
|
+
|
|
26
|
+
export type GrainWindowTable = Readonly<{
|
|
27
|
+
table: Float64Array;
|
|
28
|
+
/** Mean of w² over the window, for the 1/sqrt(overlap·mean w²) gain. */
|
|
29
|
+
meanSq: number;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
function shapeAt(shape: WindowShape, u: number): number {
|
|
33
|
+
switch (shape) {
|
|
34
|
+
case "hann":
|
|
35
|
+
return 0.5 - 0.5 * Math.cos(2 * Math.PI * u);
|
|
36
|
+
case "tukey": {
|
|
37
|
+
const edge = 0.25;
|
|
38
|
+
if (u < edge) return 0.5 - 0.5 * Math.cos((Math.PI * u) / edge);
|
|
39
|
+
if (u > 1 - edge) return 0.5 - 0.5 * Math.cos((Math.PI * (1 - u)) / edge);
|
|
40
|
+
return 1;
|
|
41
|
+
}
|
|
42
|
+
case "gauss": {
|
|
43
|
+
const g = Math.exp(-0.5 * ((u - 0.5) / 0.15) ** 2);
|
|
44
|
+
const e = Math.exp(-0.5 * (0.5 / 0.15) ** 2);
|
|
45
|
+
return (g - e) / (1 - e);
|
|
46
|
+
}
|
|
47
|
+
case "tri":
|
|
48
|
+
return 1 - Math.abs(2 * u - 1);
|
|
49
|
+
case "perc":
|
|
50
|
+
return (u < 0.02 ? u / 0.02 : 1) * (1 - u) ** 3;
|
|
51
|
+
case "rperc":
|
|
52
|
+
return (u > 0.98 ? (1 - u) / 0.02 : 1) * u ** 3;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A fresh table for `shape` (`WINDOW_TABLE + 1` points). */
|
|
57
|
+
export function windowTable(shape: WindowShape): Float64Array {
|
|
58
|
+
const table = new Float64Array(WINDOW_TABLE + 1);
|
|
59
|
+
for (let i = 0; i <= WINDOW_TABLE; i += 1)
|
|
60
|
+
table[i] = shapeAt(shape, i / WINDOW_TABLE);
|
|
61
|
+
return table;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const cache = new Map<WindowShape, GrainWindowTable>();
|
|
65
|
+
|
|
66
|
+
/** The shared (cached, never mutated) table and its mean square. */
|
|
67
|
+
export function grainWindow(shape: WindowShape): GrainWindowTable {
|
|
68
|
+
let entry = cache.get(shape);
|
|
69
|
+
if (!entry) {
|
|
70
|
+
const table = windowTable(shape);
|
|
71
|
+
let sq = 0;
|
|
72
|
+
for (let i = 0; i < WINDOW_TABLE; i += 1) sq += table[i]! * table[i]!;
|
|
73
|
+
entry = Object.freeze({ table, meanSq: sq / WINDOW_TABLE });
|
|
74
|
+
cache.set(shape, entry);
|
|
75
|
+
}
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The fixed per-track effects chain (`FX_CHAIN` in core/fx.ts):
|
|
3
3
|
*
|
|
4
|
-
* filter → djf → autofilter → vowel → crush → distort →
|
|
5
|
-
*
|
|
4
|
+
* filter → djf → autofilter → vowel → crush → distort → stomp → head →
|
|
5
|
+
* cab → wobble → bloom → swell → tremolo →
|
|
6
|
+
* compressor → pan → double → phaser → chorus → leslie → postgain →
|
|
7
|
+
* delay → reverb
|
|
6
8
|
*
|
|
7
9
|
* Stages before pan run on the mono voice sum; pan spreads it to stereo;
|
|
8
10
|
* the rest run on the stereo pair. Disabled stages cost nothing.
|
|
@@ -11,6 +13,7 @@ import { FX_CHAIN, type FxName, type FxValues } from "../../../core/fx.ts";
|
|
|
11
13
|
import type { Track } from "../../../core/score.ts";
|
|
12
14
|
import type { EffectContext } from "./common.ts";
|
|
13
15
|
import { applyCrush, applyDistort } from "./drive.ts";
|
|
16
|
+
import { applyBloom, applyDouble, applySwell, applyWobble } from "./gaze.ts";
|
|
14
17
|
import { applyCompressor, applyPostgain, applyTremolo } from "./dynamics.ts";
|
|
15
18
|
import {
|
|
16
19
|
applyAutoFilter,
|
|
@@ -19,6 +22,7 @@ import {
|
|
|
19
22
|
applyVowel,
|
|
20
23
|
} from "./filter.ts";
|
|
21
24
|
import { applyChorus, applyLeslie, applyPhaser } from "./modulation.ts";
|
|
25
|
+
import { applyCab, applyHead, applyStomp } from "./rig/index.ts";
|
|
22
26
|
import { applyDelay, applyReverb } from "./space.ts";
|
|
23
27
|
|
|
24
28
|
export {
|
|
@@ -30,7 +34,16 @@ export {
|
|
|
30
34
|
reverbWet,
|
|
31
35
|
type ReverbWet,
|
|
32
36
|
} from "./space.ts";
|
|
33
|
-
export {
|
|
37
|
+
export {
|
|
38
|
+
interpolateAutomation,
|
|
39
|
+
type EffectContext,
|
|
40
|
+
type EffectNote,
|
|
41
|
+
} from "./common.ts";
|
|
42
|
+
|
|
43
|
+
/** Whether `track` has a stage that reads `EffectContext.notes`. */
|
|
44
|
+
export function needsEffectNotes(track: Track | undefined): boolean {
|
|
45
|
+
return Boolean(track?.fx?.bloom || track?.fx?.swell);
|
|
46
|
+
}
|
|
34
47
|
|
|
35
48
|
type MonoStage = (
|
|
36
49
|
buffer: Float64Array,
|
|
@@ -52,11 +65,18 @@ const MONO: Readonly<Partial<Record<FxName, MonoStage>>> = Object.freeze({
|
|
|
52
65
|
vowel: applyVowel,
|
|
53
66
|
crush: applyCrush,
|
|
54
67
|
distort: applyDistort,
|
|
68
|
+
stomp: applyStomp,
|
|
69
|
+
head: applyHead,
|
|
70
|
+
cab: applyCab,
|
|
71
|
+
wobble: applyWobble,
|
|
72
|
+
bloom: applyBloom,
|
|
73
|
+
swell: applySwell,
|
|
55
74
|
tremolo: applyTremolo,
|
|
56
75
|
compressor: applyCompressor,
|
|
57
76
|
});
|
|
58
77
|
|
|
59
78
|
const STEREO: Readonly<Partial<Record<FxName, StereoStage>>> = Object.freeze({
|
|
79
|
+
double: applyDouble,
|
|
60
80
|
phaser: applyPhaser,
|
|
61
81
|
chorus: applyChorus,
|
|
62
82
|
leslie: applyLeslie,
|
|
@@ -25,6 +25,20 @@ export type EffectContext = Readonly<{
|
|
|
25
25
|
irs?: ReadonlyMap<string, DecodedSample>;
|
|
26
26
|
/** The song's tempo map in samples; absent: constant tempo. */
|
|
27
27
|
warp?: SampleWarp;
|
|
28
|
+
/**
|
|
29
|
+
* The track's notes in buffer samples, for note-aware stages (bloom,
|
|
30
|
+
* swell; 0.6.1). Filled only when such a stage is on.
|
|
31
|
+
*/
|
|
32
|
+
notes?: readonly EffectNote[];
|
|
33
|
+
}>;
|
|
34
|
+
|
|
35
|
+
/** One note as a note-aware effect sees it: where it sounds and its pitch. */
|
|
36
|
+
export type EffectNote = Readonly<{
|
|
37
|
+
id: string;
|
|
38
|
+
start: number;
|
|
39
|
+
length: number;
|
|
40
|
+
hz: number;
|
|
41
|
+
velocity: number;
|
|
28
42
|
}>;
|
|
29
43
|
|
|
30
44
|
/** Score tick at sample `index`, through the tempo map when there is one. */
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* right impulses share one transform: G = FFT(hL + i·hR), and because the
|
|
11
11
|
* input is real, Re(IFFT(X·G)) is the left output and Im(…) the right.
|
|
12
12
|
*
|
|
13
|
-
* Built-in impulses (`builtin:room`, `
|
|
13
|
+
* Built-in impulses (`builtin:room`, `hall`, `plate`, `reverse`, `gate`, `spring`) are
|
|
14
14
|
* generated from a seeded integer PRNG, so they need no files and are
|
|
15
15
|
* identical on every render path. Everything here is plain float64
|
|
16
16
|
* arithmetic over fixed loops: deterministic. Clean-room, from standard DSP
|
|
@@ -236,6 +236,31 @@ export const BUILTIN_IMPULSES = Object.freeze({
|
|
|
236
236
|
attack: 0.06,
|
|
237
237
|
},
|
|
238
238
|
plate: { title: "plate", seconds: 1.8, damp: 9000, early: 0, attack: 0 },
|
|
239
|
+
// 0.6.1 shaped impulses (`shape`, see `shapedSide`).
|
|
240
|
+
reverse: {
|
|
241
|
+
title: "reverse",
|
|
242
|
+
seconds: 1.2,
|
|
243
|
+
damp: 7000,
|
|
244
|
+
early: 0,
|
|
245
|
+
attack: 0,
|
|
246
|
+
shape: "rise",
|
|
247
|
+
},
|
|
248
|
+
gate: {
|
|
249
|
+
title: "gated",
|
|
250
|
+
seconds: 0.45,
|
|
251
|
+
damp: 8000,
|
|
252
|
+
early: 6,
|
|
253
|
+
attack: 0.005,
|
|
254
|
+
shape: "gate",
|
|
255
|
+
},
|
|
256
|
+
spring: {
|
|
257
|
+
title: "spring tank",
|
|
258
|
+
seconds: 2.2,
|
|
259
|
+
damp: 4500,
|
|
260
|
+
early: 0,
|
|
261
|
+
attack: 0,
|
|
262
|
+
shape: "spring",
|
|
263
|
+
},
|
|
239
264
|
} as const);
|
|
240
265
|
export type BuiltinImpulse = keyof typeof BUILTIN_IMPULSES;
|
|
241
266
|
|
|
@@ -259,6 +284,15 @@ export function builtinImpulse(
|
|
|
259
284
|
if (cached) return cached;
|
|
260
285
|
const spec = BUILTIN_IMPULSES[name as BuiltinImpulse];
|
|
261
286
|
const length = Math.max(1, Math.round(spec.seconds * sampleRate));
|
|
287
|
+
if ("shape" in spec) {
|
|
288
|
+
const impulse = normalizeImpulse(
|
|
289
|
+
`builtin:${name}`,
|
|
290
|
+
shapedSide(name, "L", spec, sampleRate),
|
|
291
|
+
shapedSide(name, "R", spec, sampleRate),
|
|
292
|
+
);
|
|
293
|
+
BUILTIN_CACHE.set(key, impulse);
|
|
294
|
+
return impulse;
|
|
295
|
+
}
|
|
262
296
|
const sides = ["L", "R"].map((side) => {
|
|
263
297
|
const random = seededRandom(`dawg-ir:${name}:${side}`);
|
|
264
298
|
const out = new Float64Array(length);
|
|
@@ -289,6 +323,77 @@ export function builtinImpulse(
|
|
|
289
323
|
return impulse;
|
|
290
324
|
}
|
|
291
325
|
|
|
326
|
+
/**
|
|
327
|
+
* One side of a shaped built-in (0.6.1), all seeded noise through a
|
|
328
|
+
* one-pole low-pass at `damp`:
|
|
329
|
+
* - rise: the Yamaha SPX90 "reverse gate": energy rises exponentially to a
|
|
330
|
+
* hard cut at `seconds`, so the wash swells into the cut (a reverse
|
|
331
|
+
* reverb that stays causal and works in play mode).
|
|
332
|
+
* - gate: dense flat tail cut hard at `seconds` (the 1980s gated snare).
|
|
333
|
+
* - spring: a decaying noise tail plus a train of dispersive chirps every
|
|
334
|
+
* ~33 ms (high frequencies arrive first, the drip of a spring tank).
|
|
335
|
+
*/
|
|
336
|
+
function shapedSide(
|
|
337
|
+
name: string,
|
|
338
|
+
side: string,
|
|
339
|
+
spec: Readonly<{
|
|
340
|
+
seconds: number;
|
|
341
|
+
damp: number;
|
|
342
|
+
early: number;
|
|
343
|
+
attack: number;
|
|
344
|
+
shape: string;
|
|
345
|
+
}>,
|
|
346
|
+
sampleRate: number,
|
|
347
|
+
): Float64Array {
|
|
348
|
+
const random = seededRandom(`dawg-ir:${name}:${side}`);
|
|
349
|
+
const length = Math.max(16, Math.round(spec.seconds * sampleRate));
|
|
350
|
+
const out = new Float64Array(length);
|
|
351
|
+
const coefficient =
|
|
352
|
+
1 -
|
|
353
|
+
Math.exp(
|
|
354
|
+
(-2 * Math.PI * Math.min(spec.damp, sampleRate * 0.45)) / sampleRate,
|
|
355
|
+
);
|
|
356
|
+
const fadeOut = Math.min(length >> 2, Math.round(0.003 * sampleRate));
|
|
357
|
+
let state = 0;
|
|
358
|
+
for (let index = 0; index < length; index += 1) {
|
|
359
|
+
const x = (index + 0.5) / length;
|
|
360
|
+
const t = index / sampleRate;
|
|
361
|
+
state += coefficient * (random() * 2 - 1 - state);
|
|
362
|
+
let env: number;
|
|
363
|
+
if (spec.shape === "rise") env = Math.exp(4 * (x - 1));
|
|
364
|
+
else if (spec.shape === "gate")
|
|
365
|
+
env = Math.min(1, t / Math.max(1e-4, spec.attack)) * (1 - 0.3 * x);
|
|
366
|
+
else env = 0.35 * Math.exp((-Math.log(1000) * t) / spec.seconds);
|
|
367
|
+
// A short fade into the cut, so it clicks no more than a real gate.
|
|
368
|
+
if (spec.shape !== "spring" && index >= length - fadeOut)
|
|
369
|
+
env *= (length - index) / fadeOut;
|
|
370
|
+
out[index] = state * env;
|
|
371
|
+
}
|
|
372
|
+
for (let tap = 0; tap < spec.early; tap += 1) {
|
|
373
|
+
const at = Math.floor((0.004 + random() * 0.036) * sampleRate);
|
|
374
|
+
if (at < length) out[at]! += (random() < 0.5 ? -1 : 1) * (0.5 - tap * 0.05);
|
|
375
|
+
}
|
|
376
|
+
if (spec.shape === "spring") {
|
|
377
|
+
// Dispersive chirps: 4 kHz falling to 300 Hz over 25 ms, each echo
|
|
378
|
+
// -1.6 dB down on the last, the period jittered per side.
|
|
379
|
+
const period = (0.031 + 0.004 * random()) * sampleRate;
|
|
380
|
+
const chirp = 0.025;
|
|
381
|
+
const decay = Math.log(1000) / spec.seconds;
|
|
382
|
+
for (let echo = 0; echo * period < length; echo += 1) {
|
|
383
|
+
const start = Math.round(echo * period);
|
|
384
|
+
const level = Math.exp((-decay * start) / sampleRate);
|
|
385
|
+
let phase = random() * 2 * Math.PI;
|
|
386
|
+
for (let k = 0; k < chirp * sampleRate && start + k < length; k += 1) {
|
|
387
|
+
const u = k / (chirp * sampleRate);
|
|
388
|
+
const hz = 4000 * (300 / 4000) ** u;
|
|
389
|
+
phase += (2 * Math.PI * hz) / sampleRate;
|
|
390
|
+
out[start + k]! += level * Math.sin(phase) * Math.sin(Math.PI * u);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
292
397
|
/**
|
|
293
398
|
* An impulse from a decoded sample (mono, any rate): resampled linearly to
|
|
294
399
|
* the render rate, cut to `MAX_IMPULSE_SECONDS`, energy-normalized; both
|