@hraness/dawg 0.3.0 → 0.4.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 +138 -0
- package/DAWG.md +608 -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 +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -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 +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -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 +411 -295
- 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 +1222 -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 +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -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 +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wavetable oscillator: tables, band-limited mipmaps and the per-note voice.
|
|
3
|
+
*
|
|
4
|
+
* A wavetable is a stack of single-cycle frames. dawg keeps each frame as its
|
|
5
|
+
* harmonic spectrum (up to `MAX_HARMONICS`), and builds playable tables from
|
|
6
|
+
* it lazily: one table per frame per octave, each holding only the harmonics
|
|
7
|
+
* that stay below Nyquist for notes in that octave. A note reads the level
|
|
8
|
+
* whose top harmonic times the note frequency is at most Nyquist, so nothing
|
|
9
|
+
* folds back. Tables are oversampled 8× (4× for the 1024-harmonic level) and
|
|
10
|
+
* read with 4-point Hermite interpolation; neighbouring frames are mixed
|
|
11
|
+
* linearly by the fractional position, which makes `wt` sweeps smooth.
|
|
12
|
+
*
|
|
13
|
+
* Strudel names: `wt` (position 0..1), `wtenv`/`wtattack`/`wtdecay`/
|
|
14
|
+
* `wtsustain`/`wtrelease` (position envelope), `wtrate`/`wtdepth` (position
|
|
15
|
+
* LFO), `warp`/`warpmode` and `wtphaserand`. The maths here is dawg's own,
|
|
16
|
+
* written from Strudel's public parameter docs; no Strudel code is used.
|
|
17
|
+
*
|
|
18
|
+
* File tables follow the Serum/Vital WAV convention: a `clm ` chunk reading
|
|
19
|
+
* `<!>2048 …` gives the frame length; without it a file whose length is a
|
|
20
|
+
* multiple of 2048 samples is 2048-sample frames, and anything else is one
|
|
21
|
+
* single-cycle frame (the AKWF convention).
|
|
22
|
+
*
|
|
23
|
+
* Everything is plain float64 arithmetic in a fixed order, so the same table
|
|
24
|
+
* and score render byte-identical audio inline, in the worker and from a
|
|
25
|
+
* warm or cold cache.
|
|
26
|
+
*/
|
|
27
|
+
import {
|
|
28
|
+
BUILTIN_TABLE_PREFIX,
|
|
29
|
+
WAVETABLE_INSTRUMENT,
|
|
30
|
+
WAVETABLE_PARAMS,
|
|
31
|
+
type Note,
|
|
32
|
+
type Track,
|
|
33
|
+
type TrackWavetable,
|
|
34
|
+
type WarpMode,
|
|
35
|
+
} from "../../core/score.ts";
|
|
36
|
+
import { decodeWav, monoMix, nativeFormat } from "./samples.ts";
|
|
37
|
+
import {
|
|
38
|
+
registerOscillatorResolver,
|
|
39
|
+
type OscillatorFactory,
|
|
40
|
+
} from "./synth/oscillators.ts";
|
|
41
|
+
|
|
42
|
+
/** Harmonics kept per frame (a 2048-sample frame holds 1024). */
|
|
43
|
+
export const MAX_HARMONICS = 1024;
|
|
44
|
+
/** Frames kept per table; longer files are truncated. */
|
|
45
|
+
export const MAX_FRAMES = 512;
|
|
46
|
+
/** Default frame length when a file has no `clm ` chunk. */
|
|
47
|
+
export const DEFAULT_FRAME = 2048;
|
|
48
|
+
/** Longest single-cycle frame accepted. */
|
|
49
|
+
const MAX_FRAME = 8192;
|
|
50
|
+
/** Octave levels: level k keeps `MAX_HARMONICS >> k` harmonics. */
|
|
51
|
+
const LEVELS = 11;
|
|
52
|
+
|
|
53
|
+
export class WavetableError extends Error {
|
|
54
|
+
constructor(message: string) {
|
|
55
|
+
super(message);
|
|
56
|
+
this.name = "WavetableError";
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One frame's spectrum: cosine and sine amplitudes for harmonics 1..H. */
|
|
61
|
+
type Spectrum = Readonly<{ re: Float64Array; im: Float64Array }>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A loaded table. `spectrum(i)` is cheap after the first call; mipmap levels
|
|
65
|
+
* are built on demand and memoized on the instance.
|
|
66
|
+
*/
|
|
67
|
+
export class Wavetable {
|
|
68
|
+
public readonly name: string;
|
|
69
|
+
public readonly frames: number;
|
|
70
|
+
/** Content hash of the source file, or `builtin:<name>`. */
|
|
71
|
+
public readonly id: string;
|
|
72
|
+
private readonly spectra: (Spectrum | undefined)[];
|
|
73
|
+
private readonly source: (index: number) => Spectrum;
|
|
74
|
+
private readonly levels: (Float64Array | undefined)[][] = [];
|
|
75
|
+
private scale: number | undefined;
|
|
76
|
+
|
|
77
|
+
public constructor(
|
|
78
|
+
name: string,
|
|
79
|
+
id: string,
|
|
80
|
+
frames: number,
|
|
81
|
+
source: (index: number) => Spectrum,
|
|
82
|
+
) {
|
|
83
|
+
if (!Number.isInteger(frames) || frames < 1 || frames > MAX_FRAMES)
|
|
84
|
+
throw new WavetableError(`${name} · needs 1..${MAX_FRAMES} frames`);
|
|
85
|
+
this.name = name;
|
|
86
|
+
this.id = id;
|
|
87
|
+
this.frames = frames;
|
|
88
|
+
this.source = source;
|
|
89
|
+
this.spectra = new Array<Spectrum | undefined>(frames);
|
|
90
|
+
for (let level = 0; level < LEVELS; level += 1)
|
|
91
|
+
this.levels.push(new Array<Float64Array | undefined>(frames));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
public spectrum(index: number): Spectrum {
|
|
95
|
+
let spectrum = this.spectra[index];
|
|
96
|
+
if (!spectrum) {
|
|
97
|
+
spectrum = this.source(index);
|
|
98
|
+
this.spectra[index] = spectrum;
|
|
99
|
+
}
|
|
100
|
+
return spectrum;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Gain that brings the loudest frame (full bandwidth) to peak 1. Computed
|
|
105
|
+
* once over every frame so position sweeps keep their relative levels.
|
|
106
|
+
*/
|
|
107
|
+
public gain(): number {
|
|
108
|
+
if (this.scale === undefined) {
|
|
109
|
+
let peak = 0;
|
|
110
|
+
for (let frame = 0; frame < this.frames; frame += 1) {
|
|
111
|
+
const table = this.table(frame, 0);
|
|
112
|
+
for (let i = 0; i < table.length; i += 1)
|
|
113
|
+
peak = Math.max(peak, Math.abs(table[i]!));
|
|
114
|
+
}
|
|
115
|
+
this.scale = peak > 1e-9 ? 1 / peak : 0;
|
|
116
|
+
}
|
|
117
|
+
return this.scale;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The playable table for one frame at one octave level. */
|
|
121
|
+
public table(frame: number, level: number): Float64Array {
|
|
122
|
+
const row = this.levels[level]!;
|
|
123
|
+
let table = row[frame];
|
|
124
|
+
if (!table) {
|
|
125
|
+
table = synthesizeLevel(this.spectrum(frame), MAX_HARMONICS >> level);
|
|
126
|
+
row[frame] = table;
|
|
127
|
+
}
|
|
128
|
+
return table;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Table length for a level: oversampled so Hermite reads stay clean. */
|
|
133
|
+
function levelSize(harmonics: number): number {
|
|
134
|
+
return Math.min(4096, Math.max(256, harmonics * 8));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function synthesizeLevel(spectrum: Spectrum, harmonics: number): Float64Array {
|
|
138
|
+
const size = levelSize(harmonics);
|
|
139
|
+
const re = new Float64Array(size);
|
|
140
|
+
const im = new Float64Array(size);
|
|
141
|
+
const top = Math.min(harmonics, spectrum.re.length - 1, size / 2 - 1);
|
|
142
|
+
// x[n] = Σ re_h cos(2πhn/N) + im_h sin(2πhn/N). With X[h] = (re - i·im)/2
|
|
143
|
+
// and its conjugate at N-h, the inverse DFT gives exactly that sum.
|
|
144
|
+
for (let h = 1; h <= top; h += 1) {
|
|
145
|
+
re[h] = spectrum.re[h]! / 2;
|
|
146
|
+
im[h] = -spectrum.im[h]! / 2;
|
|
147
|
+
re[size - h] = spectrum.re[h]! / 2;
|
|
148
|
+
im[size - h] = spectrum.im[h]! / 2;
|
|
149
|
+
}
|
|
150
|
+
fft(re, im, true);
|
|
151
|
+
return re;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* In-place iterative radix-2 FFT. `inverse` uses e^{+i} and no scaling, so
|
|
156
|
+
* inverse(forward(x)) = N·x.
|
|
157
|
+
*/
|
|
158
|
+
export function fft(re: Float64Array, im: Float64Array, inverse = false): void {
|
|
159
|
+
const n = re.length;
|
|
160
|
+
if (n & (n - 1)) throw new WavetableError("fft size must be a power of two");
|
|
161
|
+
for (let i = 1, j = 0; i < n; i += 1) {
|
|
162
|
+
let bit = n >> 1;
|
|
163
|
+
for (; j & bit; bit >>= 1) j ^= bit;
|
|
164
|
+
j ^= bit;
|
|
165
|
+
if (i < j) {
|
|
166
|
+
const tr = re[i]!;
|
|
167
|
+
re[i] = re[j]!;
|
|
168
|
+
re[j] = tr;
|
|
169
|
+
const ti = im[i]!;
|
|
170
|
+
im[i] = im[j]!;
|
|
171
|
+
im[j] = ti;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const sign = inverse ? 1 : -1;
|
|
175
|
+
for (let length = 2; length <= n; length <<= 1) {
|
|
176
|
+
const half = length >> 1;
|
|
177
|
+
const step = (sign * 2 * Math.PI) / length;
|
|
178
|
+
for (let k = 0; k < half; k += 1) {
|
|
179
|
+
const wr = Math.cos(step * k);
|
|
180
|
+
const wi = Math.sin(step * k);
|
|
181
|
+
for (let start = k; start < n; start += length) {
|
|
182
|
+
const a = start;
|
|
183
|
+
const b = start + half;
|
|
184
|
+
const xr = re[b]! * wr - im[b]! * wi;
|
|
185
|
+
const xi = re[b]! * wi + im[b]! * wr;
|
|
186
|
+
re[b] = re[a]! - xr;
|
|
187
|
+
im[b] = im[a]! - xi;
|
|
188
|
+
re[a]! += xr;
|
|
189
|
+
im[a]! += xi;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Spectrum of one time-domain cycle (any length; FFT when a power of two). */
|
|
196
|
+
export function analyzeFrame(samples: Float32Array | Float64Array): Spectrum {
|
|
197
|
+
const n = samples.length;
|
|
198
|
+
const harmonics = Math.min(MAX_HARMONICS, Math.floor((n - 1) / 2));
|
|
199
|
+
const outRe = new Float64Array(harmonics + 1);
|
|
200
|
+
const outIm = new Float64Array(harmonics + 1);
|
|
201
|
+
if ((n & (n - 1)) === 0) {
|
|
202
|
+
const re = Float64Array.from(samples);
|
|
203
|
+
const im = new Float64Array(n);
|
|
204
|
+
fft(re, im);
|
|
205
|
+
for (let h = 1; h <= harmonics; h += 1) {
|
|
206
|
+
outRe[h] = (2 * re[h]!) / n;
|
|
207
|
+
outIm[h] = (-2 * im[h]!) / n;
|
|
208
|
+
}
|
|
209
|
+
} else {
|
|
210
|
+
for (let h = 1; h <= harmonics; h += 1) {
|
|
211
|
+
let sumRe = 0;
|
|
212
|
+
let sumIm = 0;
|
|
213
|
+
const w = (2 * Math.PI * h) / n;
|
|
214
|
+
for (let i = 0; i < n; i += 1) {
|
|
215
|
+
sumRe += samples[i]! * Math.cos(w * i);
|
|
216
|
+
sumIm += samples[i]! * Math.sin(w * i);
|
|
217
|
+
}
|
|
218
|
+
outRe[h] = (2 * sumRe) / n;
|
|
219
|
+
outIm[h] = (2 * sumIm) / n;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return Object.freeze({ re: outRe, im: outIm });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// File tables
|
|
227
|
+
|
|
228
|
+
/** Frame length a WAV announces in a Serum-style `clm ` chunk (`<!>2048 …`). */
|
|
229
|
+
export function clmFrameLength(bytes: Uint8Array): number | undefined {
|
|
230
|
+
if (nativeFormat(bytes) !== "wav") return undefined;
|
|
231
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
232
|
+
let offset = 12;
|
|
233
|
+
while (offset + 8 <= bytes.byteLength) {
|
|
234
|
+
const id = String.fromCharCode(...bytes.subarray(offset, offset + 4));
|
|
235
|
+
const size = view.getUint32(offset + 4, true);
|
|
236
|
+
if (id === "clm ") {
|
|
237
|
+
const text = String.fromCharCode(
|
|
238
|
+
...bytes.subarray(
|
|
239
|
+
offset + 8,
|
|
240
|
+
Math.min(bytes.byteLength, offset + 8 + Math.min(size, 64)),
|
|
241
|
+
),
|
|
242
|
+
);
|
|
243
|
+
const match = /^<!>(\d{1,5})/.exec(text);
|
|
244
|
+
return match ? Number(match[1]) : undefined;
|
|
245
|
+
}
|
|
246
|
+
offset += 8 + size + (size & 1);
|
|
247
|
+
}
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Splits a mono signal into frames the way the module doc describes. */
|
|
252
|
+
export function sliceFrames(
|
|
253
|
+
mono: Float32Array,
|
|
254
|
+
clm?: number,
|
|
255
|
+
): readonly Float32Array[] {
|
|
256
|
+
let length: number;
|
|
257
|
+
if (clm !== undefined && clm >= 8 && clm <= MAX_FRAME) length = clm;
|
|
258
|
+
else if (mono.length >= DEFAULT_FRAME && mono.length % DEFAULT_FRAME === 0)
|
|
259
|
+
length = DEFAULT_FRAME;
|
|
260
|
+
else length = mono.length;
|
|
261
|
+
if (length < 8)
|
|
262
|
+
throw new WavetableError("too short for a single-cycle frame (8 samples)");
|
|
263
|
+
if (length > MAX_FRAME)
|
|
264
|
+
throw new WavetableError(
|
|
265
|
+
`a ${mono.length}-sample file without a clm chunk is not a wavetable · frames are at most ${MAX_FRAME} samples`,
|
|
266
|
+
);
|
|
267
|
+
const count = Math.min(MAX_FRAMES, Math.floor(mono.length / length));
|
|
268
|
+
const frames: Float32Array[] = [];
|
|
269
|
+
for (let index = 0; index < count; index += 1)
|
|
270
|
+
frames.push(mono.subarray(index * length, (index + 1) * length));
|
|
271
|
+
return frames;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** A table from WAV bytes (decoded natively; the frame layout from `clm `). */
|
|
275
|
+
export function wavetableFromWav(
|
|
276
|
+
name: string,
|
|
277
|
+
sha256: string,
|
|
278
|
+
bytes: Uint8Array,
|
|
279
|
+
): Wavetable {
|
|
280
|
+
if (nativeFormat(bytes) !== "wav")
|
|
281
|
+
throw new WavetableError(`${name} · wavetables must be WAV files`);
|
|
282
|
+
const mono = monoMix(decodeWav(bytes));
|
|
283
|
+
const frames = sliceFrames(mono, clmFrameLength(bytes));
|
|
284
|
+
return new Wavetable(name, sha256, frames.length, (index) =>
|
|
285
|
+
analyzeFrame(frames[index]!),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Built-in tables (generated, so wavetables work offline)
|
|
291
|
+
|
|
292
|
+
type BuiltinSpec = Readonly<{
|
|
293
|
+
title: string;
|
|
294
|
+
frames: number;
|
|
295
|
+
/** Amplitude of harmonic h (sine phase) at morph position 0..1. */
|
|
296
|
+
harmonic: (h: number, x: number) => number;
|
|
297
|
+
}>;
|
|
298
|
+
|
|
299
|
+
const PI = Math.PI;
|
|
300
|
+
|
|
301
|
+
function shapes(h: number): [number, number, number, number] {
|
|
302
|
+
const odd = h % 2 === 1;
|
|
303
|
+
const sine = h === 1 ? 1 : 0;
|
|
304
|
+
const triangle = odd
|
|
305
|
+
? ((((h - 1) / 2) % 2 === 0 ? 1 : -1) * 8) / (PI * PI * h * h)
|
|
306
|
+
: 0;
|
|
307
|
+
const saw = ((h % 2 === 1 ? 1 : -1) * 2) / (PI * h);
|
|
308
|
+
const square = odd ? 4 / (PI * h) : 0;
|
|
309
|
+
return [sine, triangle, saw, square];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Vowel formant centres (Hz) for a, e, i, o, u, assuming a 110 Hz cycle. */
|
|
313
|
+
const VOWELS: readonly (readonly [number, number, number])[] = [
|
|
314
|
+
[800, 1150, 2900],
|
|
315
|
+
[400, 1600, 2700],
|
|
316
|
+
[350, 2300, 3000],
|
|
317
|
+
[450, 800, 2830],
|
|
318
|
+
[325, 700, 2530],
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
export const BUILTIN_TABLES: Readonly<Record<string, BuiltinSpec>> =
|
|
322
|
+
Object.freeze({
|
|
323
|
+
basic: {
|
|
324
|
+
title: "sine → triangle → saw → square",
|
|
325
|
+
frames: 64,
|
|
326
|
+
harmonic: (h, x) => {
|
|
327
|
+
const s = shapes(h);
|
|
328
|
+
const at = x * 3;
|
|
329
|
+
const i = Math.min(2, Math.floor(at));
|
|
330
|
+
const t = at - i;
|
|
331
|
+
return s[i]! * (1 - t) + s[i + 1]! * t;
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
pwm: {
|
|
335
|
+
title: "pulse width 50% → 5%",
|
|
336
|
+
frames: 64,
|
|
337
|
+
// A pulse of duty d, centred so it stays in sine phase: 4/(πh)·sin(πhd).
|
|
338
|
+
harmonic: (h, x) => {
|
|
339
|
+
const duty = 0.5 - 0.45 * x;
|
|
340
|
+
return (4 / (PI * h)) * Math.sin(PI * h * duty) * 0.5;
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
formant: {
|
|
344
|
+
title: "vowels a → e → i → o → u",
|
|
345
|
+
frames: 64,
|
|
346
|
+
harmonic: (h, x) => {
|
|
347
|
+
const at = x * (VOWELS.length - 1);
|
|
348
|
+
const i = Math.min(VOWELS.length - 2, Math.floor(at));
|
|
349
|
+
const t = at - i;
|
|
350
|
+
const hz = h * 110;
|
|
351
|
+
let amplitude = 0;
|
|
352
|
+
VOWELS[i]!.forEach((centre, k) => {
|
|
353
|
+
const c = centre * (1 - t) + VOWELS[i + 1]![k]! * t;
|
|
354
|
+
const width = 90 + 40 * k;
|
|
355
|
+
amplitude += Math.exp(-(((hz - c) / width) ** 2)) / (1 + k);
|
|
356
|
+
});
|
|
357
|
+
return h > 64 ? 0 : amplitude * (h % 2 === 0 ? -1 : 1);
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
harmonics: {
|
|
361
|
+
title: "additive: 1 → 32 harmonics",
|
|
362
|
+
frames: 32,
|
|
363
|
+
harmonic: (h, x) => {
|
|
364
|
+
const count = 1 + Math.round(x * 31);
|
|
365
|
+
return h <= count ? 1 / h : 0;
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
export const BUILTIN_TABLE_NAMES = Object.freeze(Object.keys(BUILTIN_TABLES));
|
|
371
|
+
|
|
372
|
+
const builtinCache = new Map<string, Wavetable>();
|
|
373
|
+
|
|
374
|
+
export function builtinWavetable(name: string): Wavetable | undefined {
|
|
375
|
+
const spec = BUILTIN_TABLES[name];
|
|
376
|
+
if (!spec) return undefined;
|
|
377
|
+
let table = builtinCache.get(name);
|
|
378
|
+
if (!table) {
|
|
379
|
+
table = new Wavetable(
|
|
380
|
+
name,
|
|
381
|
+
`${BUILTIN_TABLE_PREFIX}${name}`,
|
|
382
|
+
spec.frames,
|
|
383
|
+
(index) => {
|
|
384
|
+
const x = spec.frames === 1 ? 0 : index / (spec.frames - 1);
|
|
385
|
+
const re = new Float64Array(MAX_HARMONICS + 1);
|
|
386
|
+
const im = new Float64Array(MAX_HARMONICS + 1);
|
|
387
|
+
for (let h = 1; h <= MAX_HARMONICS; h += 1) im[h] = spec.harmonic(h, x);
|
|
388
|
+
return Object.freeze({ re, im });
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
builtinCache.set(name, table);
|
|
392
|
+
}
|
|
393
|
+
return table;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Voice
|
|
398
|
+
|
|
399
|
+
/** Wavetables loaded for a render, keyed by track id. */
|
|
400
|
+
export type WavetableSet = ReadonlyMap<string, Wavetable>;
|
|
401
|
+
|
|
402
|
+
/** The table a wavetable track plays: loaded pack table, else a built-in. */
|
|
403
|
+
export function tableFor(
|
|
404
|
+
track: Track,
|
|
405
|
+
settings: TrackWavetable,
|
|
406
|
+
loaded: WavetableSet | undefined,
|
|
407
|
+
): Wavetable | undefined {
|
|
408
|
+
const src = settings.table.src;
|
|
409
|
+
if (src.startsWith(BUILTIN_TABLE_PREFIX))
|
|
410
|
+
return builtinWavetable(src.slice(BUILTIN_TABLE_PREFIX.length));
|
|
411
|
+
return loaded?.get(track.id);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function param(settings: TrackWavetable, name: keyof typeof WAVETABLE_PARAMS) {
|
|
415
|
+
return settings[name] ?? WAVETABLE_PARAMS[name][2];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The phase map for a warp mode at amount `w` (0..1), and the steepest slope
|
|
420
|
+
* it reaches (the factor the band limit must allow for).
|
|
421
|
+
*/
|
|
422
|
+
export function warpPhase(
|
|
423
|
+
mode: WarpMode,
|
|
424
|
+
w: number,
|
|
425
|
+
): { map: (phase: number) => number; slope: number } {
|
|
426
|
+
if (w <= 0 || mode === "none") return { map: (p) => p, slope: 1 };
|
|
427
|
+
switch (mode) {
|
|
428
|
+
case "asym": {
|
|
429
|
+
// Casio-style phase distortion: the first half of the cycle is read
|
|
430
|
+
// in a shrinking share of the period.
|
|
431
|
+
const knee = 0.5 - 0.49 * w;
|
|
432
|
+
return {
|
|
433
|
+
map: (p) =>
|
|
434
|
+
p < knee ? (0.5 * p) / knee : 0.5 + (0.5 * (p - knee)) / (1 - knee),
|
|
435
|
+
slope: 0.5 / knee,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
case "bendp": {
|
|
439
|
+
const k = 1 + 3 * w;
|
|
440
|
+
return { map: (p) => p ** k, slope: k };
|
|
441
|
+
}
|
|
442
|
+
case "bendm": {
|
|
443
|
+
const k = 1 + 3 * w;
|
|
444
|
+
return { map: (p) => 1 - (1 - p) ** k, slope: k };
|
|
445
|
+
}
|
|
446
|
+
case "bendmp": {
|
|
447
|
+
// Both halves bend toward the middle of the cycle.
|
|
448
|
+
const k = 1 + 3 * w;
|
|
449
|
+
return {
|
|
450
|
+
map: (p) => (p < 0.5 ? 0.5 * (2 * p) ** k : 1 - 0.5 * (2 - 2 * p) ** k),
|
|
451
|
+
slope: k,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
case "sync": {
|
|
455
|
+
// Hard sync: the table restarts up to 16 times per cycle.
|
|
456
|
+
const ratio = 1 + 15 * w;
|
|
457
|
+
return { map: (p) => (p * ratio) % 1, slope: ratio };
|
|
458
|
+
}
|
|
459
|
+
case "quant": {
|
|
460
|
+
// Phase quantised to 2^(11-10w) steps: a stepped, bit-crushed read.
|
|
461
|
+
const steps = 2 ** Math.round(11 - 10 * w);
|
|
462
|
+
return { map: (p) => Math.floor(p * steps) / steps, slope: 1 };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Highest level index whose harmonics stay under Nyquist at `frequency`. */
|
|
468
|
+
export function levelFor(frequency: number, sampleRate: number): number {
|
|
469
|
+
const nyquist = sampleRate / 2;
|
|
470
|
+
for (let level = 0; level < LEVELS; level += 1)
|
|
471
|
+
if ((MAX_HARMONICS >> level) * frequency <= nyquist) return level;
|
|
472
|
+
return -1;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function hermite(table: Float64Array, position: number): number {
|
|
476
|
+
const size = table.length;
|
|
477
|
+
const mask = size - 1;
|
|
478
|
+
const i = Math.floor(position);
|
|
479
|
+
const t = position - i;
|
|
480
|
+
const y0 = table[(i - 1) & mask]!;
|
|
481
|
+
const y1 = table[i & mask]!;
|
|
482
|
+
const y2 = table[(i + 1) & mask]!;
|
|
483
|
+
const y3 = table[(i + 2) & mask]!;
|
|
484
|
+
const c1 = 0.5 * (y2 - y0);
|
|
485
|
+
const c2 = y0 - 2.5 * y1 + 2 * y2 - 0.5 * y3;
|
|
486
|
+
const c3 = 0.5 * (y3 - y0) + 1.5 * (y1 - y2);
|
|
487
|
+
return ((c3 * t + c2) * t + c1) * t + y1;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Position envelope value 0..1 at `seconds` into a note held `gate` seconds. */
|
|
491
|
+
function positionEnvelope(
|
|
492
|
+
settings: TrackWavetable,
|
|
493
|
+
seconds: number,
|
|
494
|
+
gate: number,
|
|
495
|
+
): number {
|
|
496
|
+
const attack = param(settings, "wtattack");
|
|
497
|
+
const decay = param(settings, "wtdecay");
|
|
498
|
+
const sustain = param(settings, "wtsustain");
|
|
499
|
+
const release = param(settings, "wtrelease");
|
|
500
|
+
const held = (t: number) => {
|
|
501
|
+
if (t < attack) return attack > 0 ? t / attack : 1;
|
|
502
|
+
const d = t - attack;
|
|
503
|
+
if (d < decay) return 1 - (1 - sustain) * (decay > 0 ? d / decay : 1);
|
|
504
|
+
return sustain;
|
|
505
|
+
};
|
|
506
|
+
if (seconds <= gate) return held(seconds);
|
|
507
|
+
const after = seconds - gate;
|
|
508
|
+
return release > 0 ? held(gate) * Math.max(0, 1 - after / release) : 0;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export type WavetableVoiceContext = Readonly<{
|
|
512
|
+
sampleRate: number;
|
|
513
|
+
samplesPerTick: number;
|
|
514
|
+
/** Seconds per score tick. */
|
|
515
|
+
secondsPerTick: number;
|
|
516
|
+
/** How long the note's key is held, in seconds (the envelope's gate). */
|
|
517
|
+
gate: number;
|
|
518
|
+
/** Track position automation (`wt`) at a tick, if the lane has points. */
|
|
519
|
+
positionAt: ((tick: number) => number) | undefined;
|
|
520
|
+
}>;
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* The oscillator factory for one note of a wavetable track, in the shape the
|
|
524
|
+
* synth voice's seam takes (`src/audio/synth/oscillators.ts`): the voice
|
|
525
|
+
* supplies pitch, unison, FM, filters and the amplitude envelope, and calls
|
|
526
|
+
* each instance with a phase in cycles and this sample's phase increment.
|
|
527
|
+
* The band-limit level follows the increment, so vibrato, pitch envelopes
|
|
528
|
+
* and detuned unison voices stay alias-free too.
|
|
529
|
+
*/
|
|
530
|
+
export function wavetableOscillator(
|
|
531
|
+
table: Wavetable,
|
|
532
|
+
settings: TrackWavetable,
|
|
533
|
+
note: Note,
|
|
534
|
+
context: WavetableVoiceContext,
|
|
535
|
+
): OscillatorFactory {
|
|
536
|
+
const warp = warpPhase(settings.warpmode ?? "none", param(settings, "warp"));
|
|
537
|
+
const gain = table.gain();
|
|
538
|
+
const base = param(settings, "wt");
|
|
539
|
+
const envAmount = param(settings, "wtenv");
|
|
540
|
+
const lfoRate = param(settings, "wtrate");
|
|
541
|
+
const lfoDepth = param(settings, "wtdepth");
|
|
542
|
+
const phaseRand = param(settings, "wtphaserand");
|
|
543
|
+
const last = table.frames - 1;
|
|
544
|
+
const startSeconds = note.startTick * context.secondsPerTick;
|
|
545
|
+
const ticksPerSecond = context.sampleRate / context.samplesPerTick;
|
|
546
|
+
return (init) => {
|
|
547
|
+
const startPhase = phaseRand > 0 ? phaseRand * init.random() : 0;
|
|
548
|
+
let lastIncrement = Number.NaN;
|
|
549
|
+
let level = -1;
|
|
550
|
+
return (phase, increment) => {
|
|
551
|
+
if (increment !== lastIncrement) {
|
|
552
|
+
lastIncrement = increment;
|
|
553
|
+
level = levelFor(
|
|
554
|
+
Math.abs(increment) * init.sampleRate * warp.slope,
|
|
555
|
+
init.sampleRate,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
if (level < 0) return 0;
|
|
559
|
+
const seconds = init.time();
|
|
560
|
+
let position = context.positionAt
|
|
561
|
+
? context.positionAt(note.startTick + seconds * ticksPerSecond)
|
|
562
|
+
: base;
|
|
563
|
+
if (envAmount !== 0)
|
|
564
|
+
position +=
|
|
565
|
+
envAmount * positionEnvelope(settings, seconds, context.gate);
|
|
566
|
+
if (lfoDepth !== 0 && lfoRate > 0)
|
|
567
|
+
position +=
|
|
568
|
+
0.5 *
|
|
569
|
+
lfoDepth *
|
|
570
|
+
Math.sin(2 * PI * lfoRate * (startSeconds + seconds));
|
|
571
|
+
position = Math.max(0, Math.min(1, position));
|
|
572
|
+
const at = position * last;
|
|
573
|
+
const lower = Math.floor(at);
|
|
574
|
+
const mix = at - lower;
|
|
575
|
+
const cycle = startPhase + phase;
|
|
576
|
+
const read = warp.map(cycle - Math.floor(cycle));
|
|
577
|
+
const a = table.table(lower, level);
|
|
578
|
+
let value = hermite(a, read * a.length);
|
|
579
|
+
if (mix > 0 && lower < last) {
|
|
580
|
+
const b = table.table(lower + 1, level);
|
|
581
|
+
value += (hermite(b, read * b.length) - value) * mix;
|
|
582
|
+
}
|
|
583
|
+
return value * gain;
|
|
584
|
+
};
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* `instrument: "wavetable"` is a synth-voice sound. Registering it makes the
|
|
590
|
+
* voice treat wavetable tracks like any synth (ADSR, unison, FM, filters);
|
|
591
|
+
* the renderer passes each note the track's own table through
|
|
592
|
+
* `VoiceContext.oscillatorFor`. This fallback (the built-in `basic` table at
|
|
593
|
+
* the track's stored settings) is used only when no table is passed.
|
|
594
|
+
*/
|
|
595
|
+
registerOscillatorResolver((sound) =>
|
|
596
|
+
sound === WAVETABLE_INSTRUMENT
|
|
597
|
+
? (init) => {
|
|
598
|
+
const table = builtinWavetable("basic")!;
|
|
599
|
+
const stored = init.param("wt");
|
|
600
|
+
return wavetableOscillator(
|
|
601
|
+
table,
|
|
602
|
+
{
|
|
603
|
+
table: { src: `${BUILTIN_TABLE_PREFIX}basic` },
|
|
604
|
+
...(typeof stored === "number" ? { wt: stored } : {}),
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
id: "",
|
|
608
|
+
trackId: "",
|
|
609
|
+
startTick: 0,
|
|
610
|
+
durationTicks: 1,
|
|
611
|
+
pitch: 60,
|
|
612
|
+
velocity: 1,
|
|
613
|
+
},
|
|
614
|
+
{
|
|
615
|
+
sampleRate: init.sampleRate,
|
|
616
|
+
samplesPerTick: 1,
|
|
617
|
+
secondsPerTick: 1 / init.sampleRate,
|
|
618
|
+
gate: Number.POSITIVE_INFINITY,
|
|
619
|
+
positionAt: undefined,
|
|
620
|
+
},
|
|
621
|
+
)(init);
|
|
622
|
+
}
|
|
623
|
+
: undefined,
|
|
624
|
+
);
|