@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,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wavetables from ordinary audio: a downloaded track, a stem, an imported
|
|
3
|
+
* sample. The agent's `make_wavetable` tool runs this; the TUI only lists
|
|
4
|
+
* the results.
|
|
5
|
+
*
|
|
6
|
+
* Two extraction methods:
|
|
7
|
+
*
|
|
8
|
+
* - `slice`: pitch-synchronous single cycles. The source's period is found
|
|
9
|
+
* with a YIN-style difference function (computed through the FFT), one
|
|
10
|
+
* period is read at each frame's time, resampled to 2048 samples with
|
|
11
|
+
* cubic interpolation, and its endpoint mismatch is spread over the cycle
|
|
12
|
+
* so the loop point does not click.
|
|
13
|
+
* - `spectral`: FFT magnitude snapshots. Each frame's harmonic h takes the
|
|
14
|
+
* RMS magnitude of the source spectrum around h × the reference pitch,
|
|
15
|
+
* and every frame uses the same fixed phase per harmonic, so frames morph
|
|
16
|
+
* without phase cancellation. Good for vocals, pads and noise.
|
|
17
|
+
*
|
|
18
|
+
* `auto` picks `slice` when the region is clearly periodic and `spectral`
|
|
19
|
+
* otherwise. Frames are phase-aligned (the fundamental starts as a rising
|
|
20
|
+
* sine), optionally smoothed across neighbours in the spectral domain, and
|
|
21
|
+
* normalised. All arithmetic is float64 in a fixed order and nothing is
|
|
22
|
+
* random, so the same input and options always give the same bytes.
|
|
23
|
+
*/
|
|
24
|
+
import { midiToPitch } from "../../core/pitch.ts";
|
|
25
|
+
import { fft, MAX_FRAMES } from "./wavetable.ts";
|
|
26
|
+
|
|
27
|
+
/** Samples per frame in tables dawg writes (the Serum/Vital convention). */
|
|
28
|
+
export const MAKE_FRAME_SIZE = 2048;
|
|
29
|
+
export const MAKE_MAX_FRAMES = Math.min(256, MAX_FRAMES);
|
|
30
|
+
export const MAKE_DEFAULT_FRAMES = 64;
|
|
31
|
+
export const MAKE_METHODS = Object.freeze([
|
|
32
|
+
"auto",
|
|
33
|
+
"slice",
|
|
34
|
+
"spectral",
|
|
35
|
+
] as const);
|
|
36
|
+
export type MakeMethod = (typeof MAKE_METHODS)[number];
|
|
37
|
+
export const MAKE_NORMALIZE = Object.freeze(["frame", "table", "off"] as const);
|
|
38
|
+
export type MakeNormalize = (typeof MAKE_NORMALIZE)[number];
|
|
39
|
+
|
|
40
|
+
/** Pitch range the detector searches. */
|
|
41
|
+
const MIN_F0 = 30;
|
|
42
|
+
const MAX_F0 = 2000;
|
|
43
|
+
/** Below this clarity a window counts as unpitched. */
|
|
44
|
+
const TONAL_CLARITY = 0.6;
|
|
45
|
+
/** Longest auto-picked region. */
|
|
46
|
+
const AUTO_REGION_SECONDS = 2;
|
|
47
|
+
/** Analysis windows over the whole source (bounds the cost on long files). */
|
|
48
|
+
const MAX_WINDOWS = 400;
|
|
49
|
+
/** Harmonic count of a 2048-sample frame. */
|
|
50
|
+
const HARMONICS = MAKE_FRAME_SIZE / 2 - 1;
|
|
51
|
+
/** Reference pitch for spectral frames of unpitched material (A2). */
|
|
52
|
+
const SPECTRAL_REFERENCE_HZ = 110;
|
|
53
|
+
|
|
54
|
+
export type MakeOptions = Readonly<{
|
|
55
|
+
/** Frame count 1..256 (default 64). */
|
|
56
|
+
frames?: number;
|
|
57
|
+
/** Region in source seconds; both absent → auto-picked. */
|
|
58
|
+
start?: number;
|
|
59
|
+
end?: number;
|
|
60
|
+
method?: MakeMethod;
|
|
61
|
+
/** Peak-normalise each frame (default), the whole table, or nothing. */
|
|
62
|
+
normalize?: MakeNormalize;
|
|
63
|
+
/** Drop DC (default true; playback ignores DC either way). */
|
|
64
|
+
removeDc?: boolean;
|
|
65
|
+
/** Rotate frames so the fundamental starts as a rising sine (default true). */
|
|
66
|
+
align?: boolean;
|
|
67
|
+
/** 0..1: blend each frame with its neighbours (default 0). */
|
|
68
|
+
smooth?: number;
|
|
69
|
+
}>;
|
|
70
|
+
|
|
71
|
+
export type DetectedPitch = Readonly<{
|
|
72
|
+
hz: number;
|
|
73
|
+
note: string;
|
|
74
|
+
/** 0..1, how periodic the region is (1 - YIN aperiodicity). */
|
|
75
|
+
clarity: number;
|
|
76
|
+
}>;
|
|
77
|
+
|
|
78
|
+
export type MadeWavetable = Readonly<{
|
|
79
|
+
/** `frames` × 2048 samples. */
|
|
80
|
+
frames: readonly Float32Array[];
|
|
81
|
+
method: "slice" | "spectral";
|
|
82
|
+
/** Median pitch over the region; absent for unpitched material. */
|
|
83
|
+
pitch?: DetectedPitch;
|
|
84
|
+
region: Readonly<{ start: number; end: number; auto: boolean }>;
|
|
85
|
+
/** Per-frame spectral centroid in harmonics (power-weighted, 30 dB floor). */
|
|
86
|
+
centroids: readonly number[];
|
|
87
|
+
/** One paragraph on how the timbre moves across the table. */
|
|
88
|
+
sweep: string;
|
|
89
|
+
}>;
|
|
90
|
+
|
|
91
|
+
export class WavetableMakeError extends Error {
|
|
92
|
+
constructor(message: string) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "WavetableMakeError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Pitch
|
|
100
|
+
|
|
101
|
+
type PitchWindow = { time: number; hz: number; clarity: number; rms: number };
|
|
102
|
+
|
|
103
|
+
function nextPow2(n: number): number {
|
|
104
|
+
let size = 1;
|
|
105
|
+
while (size < n) size *= 2;
|
|
106
|
+
return size;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Window length (power of two) that holds two of the longest periods. */
|
|
110
|
+
function pitchWindowSize(sampleRate: number): number {
|
|
111
|
+
return nextPow2(2 * Math.ceil(sampleRate / MIN_F0));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* YIN on `signal[offset, offset + size)`: cumulative-mean-normalised
|
|
116
|
+
* difference over a fixed integration window, via FFT cross-correlation.
|
|
117
|
+
*/
|
|
118
|
+
export function detectWindow(
|
|
119
|
+
signal: Float32Array,
|
|
120
|
+
offset: number,
|
|
121
|
+
sampleRate: number,
|
|
122
|
+
size = pitchWindowSize(sampleRate),
|
|
123
|
+
): { hz: number; clarity: number; rms: number } {
|
|
124
|
+
const maxLag = Math.min(Math.ceil(sampleRate / MIN_F0), size >> 1);
|
|
125
|
+
const minLag = Math.max(2, Math.floor(sampleRate / MAX_F0));
|
|
126
|
+
const integration = size - maxLag;
|
|
127
|
+
const x = new Float64Array(size);
|
|
128
|
+
for (let i = 0; i < size; i += 1) {
|
|
129
|
+
const at = offset + i;
|
|
130
|
+
x[i] = at >= 0 && at < signal.length ? signal[at]! : 0;
|
|
131
|
+
}
|
|
132
|
+
let energy = 0;
|
|
133
|
+
for (let i = 0; i < size; i += 1) energy += x[i]! * x[i]!;
|
|
134
|
+
const rms = Math.sqrt(energy / size);
|
|
135
|
+
if (rms < 1e-6) return { hz: 0, clarity: 0, rms };
|
|
136
|
+
const n = size * 2;
|
|
137
|
+
const aRe = new Float64Array(n);
|
|
138
|
+
const aIm = new Float64Array(n);
|
|
139
|
+
const bRe = new Float64Array(n);
|
|
140
|
+
const bIm = new Float64Array(n);
|
|
141
|
+
for (let i = 0; i < integration; i += 1) aRe[i] = x[i]!;
|
|
142
|
+
for (let i = 0; i < size; i += 1) bRe[i] = x[i]!;
|
|
143
|
+
fft(aRe, aIm);
|
|
144
|
+
fft(bRe, bIm);
|
|
145
|
+
// conj(A)·B → cross-correlation r(τ) = Σ_j a_j b_{j+τ}.
|
|
146
|
+
for (let k = 0; k < n; k += 1) {
|
|
147
|
+
const re = aRe[k]! * bRe[k]! + aIm[k]! * bIm[k]!;
|
|
148
|
+
const im = aRe[k]! * bIm[k]! - aIm[k]! * bRe[k]!;
|
|
149
|
+
aRe[k] = re;
|
|
150
|
+
aIm[k] = im;
|
|
151
|
+
}
|
|
152
|
+
fft(aRe, aIm, true);
|
|
153
|
+
const squares = new Float64Array(size + 1);
|
|
154
|
+
for (let i = 0; i < size; i += 1)
|
|
155
|
+
squares[i + 1] = squares[i]! + x[i]! * x[i]!;
|
|
156
|
+
const head = squares[integration]!;
|
|
157
|
+
const cmnd = new Float64Array(maxLag + 1);
|
|
158
|
+
cmnd[0] = 1;
|
|
159
|
+
let running = 0;
|
|
160
|
+
for (let lag = 1; lag <= maxLag; lag += 1) {
|
|
161
|
+
const tail = squares[lag + integration]! - squares[lag]!;
|
|
162
|
+
const d = Math.max(0, head + tail - (2 * aRe[lag]!) / n);
|
|
163
|
+
running += d;
|
|
164
|
+
cmnd[lag] = running > 0 ? (d * lag) / running : 1;
|
|
165
|
+
}
|
|
166
|
+
let best = -1;
|
|
167
|
+
for (let lag = minLag; lag < maxLag; lag += 1) {
|
|
168
|
+
if (cmnd[lag]! < 0.15) {
|
|
169
|
+
while (lag + 1 < maxLag && cmnd[lag + 1]! < cmnd[lag]!) lag += 1;
|
|
170
|
+
best = lag;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (best < 0) {
|
|
175
|
+
best = minLag;
|
|
176
|
+
for (let lag = minLag; lag < maxLag; lag += 1)
|
|
177
|
+
if (cmnd[lag]! < cmnd[best]!) best = lag;
|
|
178
|
+
}
|
|
179
|
+
const clarity = Math.max(0, Math.min(1, 1 - cmnd[best]!));
|
|
180
|
+
// Parabolic refinement of the dip.
|
|
181
|
+
let period = best;
|
|
182
|
+
if (best > 1 && best < maxLag) {
|
|
183
|
+
const a = cmnd[best - 1]!;
|
|
184
|
+
const b = cmnd[best]!;
|
|
185
|
+
const c = cmnd[best + 1]!;
|
|
186
|
+
const denom = a - 2 * b + c;
|
|
187
|
+
if (denom > 1e-12) period = best + (0.5 * (a - c)) / denom;
|
|
188
|
+
}
|
|
189
|
+
return { hz: sampleRate / period, clarity, rms };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function analyzeSource(
|
|
193
|
+
signal: Float32Array,
|
|
194
|
+
sampleRate: number,
|
|
195
|
+
from: number,
|
|
196
|
+
to: number,
|
|
197
|
+
): PitchWindow[] {
|
|
198
|
+
const size = pitchWindowSize(sampleRate);
|
|
199
|
+
const span = to - from;
|
|
200
|
+
const hop = Math.max(size >> 1, Math.ceil((span - size) / MAX_WINDOWS));
|
|
201
|
+
const windows: PitchWindow[] = [];
|
|
202
|
+
for (let offset = from; ; offset += hop) {
|
|
203
|
+
const start = Math.min(offset, Math.max(from, to - size));
|
|
204
|
+
const result = detectWindow(signal, start, sampleRate, size);
|
|
205
|
+
windows.push({ time: (start + size / 2) / sampleRate, ...result });
|
|
206
|
+
if (offset + size >= to) break;
|
|
207
|
+
}
|
|
208
|
+
return windows;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function median(values: readonly number[]): number {
|
|
212
|
+
if (values.length === 0) return 0;
|
|
213
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
214
|
+
const mid = sorted.length >> 1;
|
|
215
|
+
return sorted.length % 2
|
|
216
|
+
? sorted[mid]!
|
|
217
|
+
: (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pitchOf(windows: readonly PitchWindow[]): DetectedPitch | undefined {
|
|
221
|
+
const voiced = windows.filter((w) => w.clarity >= TONAL_CLARITY && w.hz > 0);
|
|
222
|
+
if (voiced.length === 0) return undefined;
|
|
223
|
+
const hz = 2 ** median(voiced.map((w) => Math.log2(w.hz)));
|
|
224
|
+
const midi = Math.round(69 + 12 * Math.log2(hz / 440));
|
|
225
|
+
return {
|
|
226
|
+
hz: Math.round(hz * 100) / 100,
|
|
227
|
+
note: midiToPitch(Math.max(0, Math.min(127, midi))),
|
|
228
|
+
clarity: Math.round(median(windows.map((w) => w.clarity)) * 1000) / 1000,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Loudness weight 0..1 over the 40 dB below the loudest window. */
|
|
233
|
+
function loudness(windows: readonly PitchWindow[]): number[] {
|
|
234
|
+
const peak = Math.max(...windows.map((w) => w.rms), 1e-9);
|
|
235
|
+
return windows.map((w) => {
|
|
236
|
+
const db = 20 * Math.log10(Math.max(w.rms, 1e-9) / peak);
|
|
237
|
+
return Math.max(0, Math.min(1, (db + 40) / 40));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The region whose windows score best: clarity × loudness for pitched
|
|
243
|
+
* material, loudness alone when nothing in the file is periodic.
|
|
244
|
+
*/
|
|
245
|
+
function pickRegion(
|
|
246
|
+
windows: readonly PitchWindow[],
|
|
247
|
+
durationSeconds: number,
|
|
248
|
+
): { start: number; end: number } {
|
|
249
|
+
const length = Math.min(durationSeconds, AUTO_REGION_SECONDS);
|
|
250
|
+
if (length >= durationSeconds - 1e-9)
|
|
251
|
+
return { start: 0, end: durationSeconds };
|
|
252
|
+
const loud = loudness(windows);
|
|
253
|
+
const tonal = windows.some((w) => w.clarity >= TONAL_CLARITY);
|
|
254
|
+
const score = windows.map((w, i) => (tonal ? w.clarity : 1) * loud[i]!);
|
|
255
|
+
let best = { start: 0, value: -1 };
|
|
256
|
+
for (let i = 0; i < windows.length; i += 1) {
|
|
257
|
+
const start = Math.max(
|
|
258
|
+
0,
|
|
259
|
+
Math.min(durationSeconds - length, windows[i]!.time - length / 2),
|
|
260
|
+
);
|
|
261
|
+
let sum = 0;
|
|
262
|
+
let count = 0;
|
|
263
|
+
for (let j = 0; j < windows.length; j += 1) {
|
|
264
|
+
const t = windows[j]!.time;
|
|
265
|
+
if (t < start || t > start + length) continue;
|
|
266
|
+
sum += score[j]!;
|
|
267
|
+
count += 1;
|
|
268
|
+
}
|
|
269
|
+
const value = count ? sum / count : 0;
|
|
270
|
+
if (value > best.value + 1e-12) best = { start, value };
|
|
271
|
+
}
|
|
272
|
+
return { start: best.start, end: best.start + length };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---------------------------------------------------------------------------
|
|
276
|
+
// Frames
|
|
277
|
+
|
|
278
|
+
type Spectrum = { re: Float64Array; im: Float64Array };
|
|
279
|
+
|
|
280
|
+
function cubic(signal: Float32Array, position: number): number {
|
|
281
|
+
const i = Math.floor(position);
|
|
282
|
+
const t = position - i;
|
|
283
|
+
const at = (k: number) =>
|
|
284
|
+
signal[Math.max(0, Math.min(signal.length - 1, k))]!;
|
|
285
|
+
const p0 = at(i - 1);
|
|
286
|
+
const p1 = at(i);
|
|
287
|
+
const p2 = at(i + 1);
|
|
288
|
+
const p3 = at(i + 2);
|
|
289
|
+
return (
|
|
290
|
+
p1 +
|
|
291
|
+
0.5 *
|
|
292
|
+
t *
|
|
293
|
+
(p2 -
|
|
294
|
+
p0 +
|
|
295
|
+
t * (2 * p0 - 5 * p1 + 4 * p2 - p3 + t * (3 * (p1 - p2) + p3 - p0)))
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** One period at `start` (fractional), resampled to 2048 and made loopable. */
|
|
300
|
+
function sliceCycle(
|
|
301
|
+
signal: Float32Array,
|
|
302
|
+
start: number,
|
|
303
|
+
period: number,
|
|
304
|
+
): Float64Array {
|
|
305
|
+
const n = MAKE_FRAME_SIZE;
|
|
306
|
+
const out = new Float64Array(n);
|
|
307
|
+
for (let i = 0; i < n; i += 1)
|
|
308
|
+
out[i] = cubic(signal, start + (i * period) / n);
|
|
309
|
+
// Spread the loop-point mismatch over the cycle.
|
|
310
|
+
const jump = cubic(signal, start + period) - out[0]!;
|
|
311
|
+
for (let i = 0; i < n; i += 1) out[i] = out[i]! - (jump * i) / n;
|
|
312
|
+
return out;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function forward(cycle: Float64Array): Spectrum {
|
|
316
|
+
const re = Float64Array.from(cycle);
|
|
317
|
+
const im = new Float64Array(cycle.length);
|
|
318
|
+
fft(re, im);
|
|
319
|
+
return { re, im };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function inverse(spectrum: Spectrum): Float64Array {
|
|
323
|
+
const n = spectrum.re.length;
|
|
324
|
+
const re = Float64Array.from(spectrum.re);
|
|
325
|
+
const im = Float64Array.from(spectrum.im);
|
|
326
|
+
fft(re, im, true);
|
|
327
|
+
for (let i = 0; i < n; i += 1) re[i] = re[i]! / n;
|
|
328
|
+
return re;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Rotates a frame in time so its fundamental (or, when that is weak, its
|
|
333
|
+
* strongest low harmonic) starts as a rising sine.
|
|
334
|
+
*/
|
|
335
|
+
function alignPhase(spectrum: Spectrum): void {
|
|
336
|
+
const n = spectrum.re.length;
|
|
337
|
+
const mag = (h: number) => Math.hypot(spectrum.re[h]!, spectrum.im[h]!);
|
|
338
|
+
let anchor = 1;
|
|
339
|
+
for (let h = 2; h <= 4; h += 1) if (mag(h) > 4 * mag(anchor)) anchor = h;
|
|
340
|
+
if (mag(anchor) < 1e-9) return;
|
|
341
|
+
// FFT bin X[h] = (N/2)·(a - i·b) for a·cos + b·sin; a rising sine is arg -π/2.
|
|
342
|
+
const delta =
|
|
343
|
+
(-Math.PI / 2 - Math.atan2(spectrum.im[anchor]!, spectrum.re[anchor]!)) /
|
|
344
|
+
anchor;
|
|
345
|
+
for (let h = 1; h < n / 2; h += 1) {
|
|
346
|
+
const c = Math.cos(h * delta);
|
|
347
|
+
const s = Math.sin(h * delta);
|
|
348
|
+
const re = spectrum.re[h]! * c - spectrum.im[h]! * s;
|
|
349
|
+
const im = spectrum.re[h]! * s + spectrum.im[h]! * c;
|
|
350
|
+
spectrum.re[h] = re;
|
|
351
|
+
spectrum.im[h] = im;
|
|
352
|
+
spectrum.re[n - h] = re;
|
|
353
|
+
spectrum.im[n - h] = -im;
|
|
354
|
+
}
|
|
355
|
+
spectrum.re[n / 2] = 0;
|
|
356
|
+
spectrum.im[n / 2] = 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** A fixed phase per harmonic (same in every frame), from an integer hash. */
|
|
360
|
+
function harmonicPhase(h: number): number {
|
|
361
|
+
if (h === 1) return -Math.PI / 2;
|
|
362
|
+
let x = Math.imul(h ^ 0x9e3779b9, 0x85ebca6b) >>> 0;
|
|
363
|
+
x = Math.imul(x ^ (x >>> 13), 0xc2b2ae35) >>> 0;
|
|
364
|
+
x = (x ^ (x >>> 16)) >>> 0;
|
|
365
|
+
return (x / 2 ** 32) * 2 * Math.PI - Math.PI;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function spectralFrame(
|
|
369
|
+
signal: Float32Array,
|
|
370
|
+
centre: number,
|
|
371
|
+
sampleRate: number,
|
|
372
|
+
referenceHz: number,
|
|
373
|
+
): Spectrum {
|
|
374
|
+
const size = nextPow2(Math.round(sampleRate * 0.085));
|
|
375
|
+
const re = new Float64Array(size);
|
|
376
|
+
const im = new Float64Array(size);
|
|
377
|
+
const offset = Math.round(centre) - size / 2;
|
|
378
|
+
for (let i = 0; i < size; i += 1) {
|
|
379
|
+
const at = offset + i;
|
|
380
|
+
const w = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / size);
|
|
381
|
+
re[i] = at >= 0 && at < signal.length ? signal[at]! * w : 0;
|
|
382
|
+
}
|
|
383
|
+
fft(re, im);
|
|
384
|
+
const binHz = sampleRate / size;
|
|
385
|
+
const n = MAKE_FRAME_SIZE;
|
|
386
|
+
const out: Spectrum = { re: new Float64Array(n), im: new Float64Array(n) };
|
|
387
|
+
for (let h = 1; h <= HARMONICS; h += 1) {
|
|
388
|
+
const lo = (h - 0.5) * referenceHz;
|
|
389
|
+
const hi = (h + 0.5) * referenceHz;
|
|
390
|
+
if (lo >= sampleRate / 2) break;
|
|
391
|
+
const first = Math.max(1, Math.ceil(lo / binHz));
|
|
392
|
+
const last = Math.min(size / 2 - 1, Math.floor(hi / binHz));
|
|
393
|
+
let power = 0;
|
|
394
|
+
let count = 0;
|
|
395
|
+
for (let k = first; k <= last; k += 1) {
|
|
396
|
+
power += re[k]! * re[k]! + im[k]! * im[k]!;
|
|
397
|
+
count += 1;
|
|
398
|
+
}
|
|
399
|
+
if (count === 0) {
|
|
400
|
+
// Band narrower than a bin: interpolate the nearest bin.
|
|
401
|
+
const k = Math.min(
|
|
402
|
+
size / 2 - 1,
|
|
403
|
+
Math.max(1, Math.round((h * referenceHz) / binHz)),
|
|
404
|
+
);
|
|
405
|
+
power = re[k]! * re[k]! + im[k]! * im[k]!;
|
|
406
|
+
count = 1;
|
|
407
|
+
}
|
|
408
|
+
const amp = Math.sqrt(power / count);
|
|
409
|
+
const phase = harmonicPhase(h);
|
|
410
|
+
out.re[h] = amp * Math.cos(phase);
|
|
411
|
+
out.im[h] = amp * Math.sin(phase);
|
|
412
|
+
out.re[n - h] = out.re[h]!;
|
|
413
|
+
out.im[n - h] = -out.im[h]!;
|
|
414
|
+
}
|
|
415
|
+
return out;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function smoothFrames(spectra: Spectrum[], amount: number): Spectrum[] {
|
|
419
|
+
const sigma = (amount * spectra.length) / 8;
|
|
420
|
+
if (sigma < 0.25 || spectra.length < 2) return spectra;
|
|
421
|
+
const reach = Math.ceil(3 * sigma);
|
|
422
|
+
const weights: number[] = [];
|
|
423
|
+
for (let d = -reach; d <= reach; d += 1)
|
|
424
|
+
weights.push(Math.exp(-(d * d) / (2 * sigma * sigma)));
|
|
425
|
+
const n = MAKE_FRAME_SIZE;
|
|
426
|
+
return spectra.map((_, index) => {
|
|
427
|
+
const out: Spectrum = { re: new Float64Array(n), im: new Float64Array(n) };
|
|
428
|
+
let total = 0;
|
|
429
|
+
for (let d = -reach; d <= reach; d += 1) {
|
|
430
|
+
const j = index + d;
|
|
431
|
+
if (j < 0 || j >= spectra.length) continue;
|
|
432
|
+
const w = weights[d + reach]!;
|
|
433
|
+
total += w;
|
|
434
|
+
const src = spectra[j]!;
|
|
435
|
+
for (let k = 0; k < n; k += 1) {
|
|
436
|
+
out.re[k] = out.re[k]! + w * src.re[k]!;
|
|
437
|
+
out.im[k] = out.im[k]! + w * src.im[k]!;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
for (let k = 0; k < n; k += 1) {
|
|
441
|
+
out.re[k] = out.re[k]! / total;
|
|
442
|
+
out.im[k] = out.im[k]! / total;
|
|
443
|
+
}
|
|
444
|
+
return out;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function centroid(spectrum: Spectrum): number {
|
|
449
|
+
// Power-weighted over the harmonics that stand out: within 30 dB of the
|
|
450
|
+
// strongest and well above the noise floor (20x the median bin, which a
|
|
451
|
+
// noise bin exceeds with probability ~1e-6), so a quiet noise floor spread
|
|
452
|
+
// across a thousand harmonics does not outweigh the few loud ones a
|
|
453
|
+
// listener hears.
|
|
454
|
+
const power = new Float64Array(HARMONICS);
|
|
455
|
+
let peak = 0;
|
|
456
|
+
for (let h = 1; h <= HARMONICS; h += 1) {
|
|
457
|
+
power[h - 1] = spectrum.re[h]! ** 2 + spectrum.im[h]! ** 2;
|
|
458
|
+
peak = Math.max(peak, power[h - 1]!);
|
|
459
|
+
}
|
|
460
|
+
const sorted = Float64Array.from(power).sort();
|
|
461
|
+
const median = sorted[sorted.length >> 1]!;
|
|
462
|
+
const floor = Math.max(peak * 1e-3, median * 20);
|
|
463
|
+
let sum = 0;
|
|
464
|
+
let weighted = 0;
|
|
465
|
+
for (let h = 1; h <= HARMONICS; h += 1) {
|
|
466
|
+
const p = power[h - 1]!;
|
|
467
|
+
if (p < floor) continue;
|
|
468
|
+
sum += p;
|
|
469
|
+
weighted += h * p;
|
|
470
|
+
}
|
|
471
|
+
return sum > 1e-24 ? weighted / sum : 0;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function brightness(c: number): string {
|
|
475
|
+
if (c < 1.3) return "near-sine";
|
|
476
|
+
if (c < 3) return "dark";
|
|
477
|
+
if (c < 7) return "warm";
|
|
478
|
+
if (c < 16) return "bright";
|
|
479
|
+
return "buzzy";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function round1(value: number): number {
|
|
483
|
+
return Math.round(value * 10) / 10;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Centroid in up to 8 segments, the overall trend and how smooth the morph is. */
|
|
487
|
+
export function describeSweep(
|
|
488
|
+
centroids: readonly number[],
|
|
489
|
+
changes: readonly number[],
|
|
490
|
+
): string {
|
|
491
|
+
const count = centroids.length;
|
|
492
|
+
if (count === 1)
|
|
493
|
+
return `one frame · centroid h${round1(centroids[0]!)} (${brightness(centroids[0]!)})`;
|
|
494
|
+
const segments = Math.min(8, count);
|
|
495
|
+
const parts: string[] = [];
|
|
496
|
+
for (let s = 0; s < segments; s += 1) {
|
|
497
|
+
const from = Math.floor((s * count) / segments);
|
|
498
|
+
const to = Math.floor(((s + 1) * count) / segments) - 1;
|
|
499
|
+
let sum = 0;
|
|
500
|
+
for (let i = from; i <= to; i += 1) sum += centroids[i]!;
|
|
501
|
+
const c = sum / (to - from + 1);
|
|
502
|
+
parts.push(
|
|
503
|
+
`${from === to ? from : `${from}-${to}`} h${round1(c)} ${brightness(c)}`,
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
const first = centroids[0]!;
|
|
507
|
+
const last = centroids[count - 1]!;
|
|
508
|
+
const lo = Math.min(...centroids);
|
|
509
|
+
const hi = Math.max(...centroids);
|
|
510
|
+
const trend =
|
|
511
|
+
hi - lo < 0.15 * Math.max(1, lo)
|
|
512
|
+
? "steady timbre"
|
|
513
|
+
: last > first * 1.25
|
|
514
|
+
? "brightens"
|
|
515
|
+
: first > last * 1.25
|
|
516
|
+
? "darkens"
|
|
517
|
+
: "moves and returns";
|
|
518
|
+
const step = changes.length
|
|
519
|
+
? changes.reduce((a, b) => a + b, 0) / changes.length
|
|
520
|
+
: 0;
|
|
521
|
+
const morph =
|
|
522
|
+
step < 0.05
|
|
523
|
+
? "very smooth morph"
|
|
524
|
+
: step < 0.2
|
|
525
|
+
? "smooth morph"
|
|
526
|
+
: step < 0.5
|
|
527
|
+
? "audible steps between frames"
|
|
528
|
+
: "jumpy (try smooth 0.3+ or fewer frames)";
|
|
529
|
+
return `${trend} (centroid h${round1(lo)}..h${round1(hi)}), ${morph} (mean frame change ${Math.round(step * 100)}%) · ${parts.join(" · ")}`;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Relative spectral distance between neighbouring frames, 0..~2. */
|
|
533
|
+
function frameChanges(spectra: readonly Spectrum[]): number[] {
|
|
534
|
+
const out: number[] = [];
|
|
535
|
+
for (let i = 1; i < spectra.length; i += 1) {
|
|
536
|
+
const a = spectra[i - 1]!;
|
|
537
|
+
const b = spectra[i]!;
|
|
538
|
+
let diff = 0;
|
|
539
|
+
let norm = 0;
|
|
540
|
+
for (let h = 1; h <= HARMONICS; h += 1) {
|
|
541
|
+
const ma = Math.hypot(a.re[h]!, a.im[h]!);
|
|
542
|
+
const mb = Math.hypot(b.re[h]!, b.im[h]!);
|
|
543
|
+
diff += (ma - mb) * (ma - mb);
|
|
544
|
+
norm += ma * ma + mb * mb;
|
|
545
|
+
}
|
|
546
|
+
out.push(norm > 1e-18 ? Math.sqrt((2 * diff) / norm) : 0);
|
|
547
|
+
}
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
// Entry point
|
|
553
|
+
|
|
554
|
+
export function makeWavetable(
|
|
555
|
+
mono: Float32Array,
|
|
556
|
+
sampleRate: number,
|
|
557
|
+
options: MakeOptions = {},
|
|
558
|
+
): MadeWavetable {
|
|
559
|
+
const count = options.frames ?? MAKE_DEFAULT_FRAMES;
|
|
560
|
+
if (!Number.isInteger(count) || count < 1 || count > MAKE_MAX_FRAMES)
|
|
561
|
+
throw new WavetableMakeError(
|
|
562
|
+
`frames must be an integer 1..${MAKE_MAX_FRAMES}`,
|
|
563
|
+
);
|
|
564
|
+
if (!(sampleRate >= 8000 && sampleRate <= 384_000))
|
|
565
|
+
throw new WavetableMakeError("sample rate must be 8..384 kHz");
|
|
566
|
+
const duration = mono.length / sampleRate;
|
|
567
|
+
if (duration < 0.05)
|
|
568
|
+
throw new WavetableMakeError("the source is shorter than 50 ms");
|
|
569
|
+
const smooth = options.smooth ?? 0;
|
|
570
|
+
if (!(smooth >= 0 && smooth <= 1))
|
|
571
|
+
throw new WavetableMakeError("smooth must be 0..1");
|
|
572
|
+
|
|
573
|
+
let region: { start: number; end: number; auto: boolean };
|
|
574
|
+
if (options.start !== undefined || options.end !== undefined) {
|
|
575
|
+
const start = Math.max(0, options.start ?? 0);
|
|
576
|
+
const end = Math.min(duration, options.end ?? duration);
|
|
577
|
+
if (!(end - start >= 0.05))
|
|
578
|
+
throw new WavetableMakeError(
|
|
579
|
+
`region ${round1(start)}..${round1(end)} s must be at least 50 ms inside the ${round1(duration)} s source`,
|
|
580
|
+
);
|
|
581
|
+
region = { start, end, auto: false };
|
|
582
|
+
} else {
|
|
583
|
+
const all = analyzeSource(mono, sampleRate, 0, mono.length);
|
|
584
|
+
region = { ...pickRegion(all, duration), auto: true };
|
|
585
|
+
}
|
|
586
|
+
const from = Math.floor(region.start * sampleRate);
|
|
587
|
+
const to = Math.min(mono.length, Math.ceil(region.end * sampleRate));
|
|
588
|
+
const windows = analyzeSource(mono, sampleRate, from, to);
|
|
589
|
+
const pitch = pitchOf(windows);
|
|
590
|
+
const tonal =
|
|
591
|
+
pitch !== undefined &&
|
|
592
|
+
median(windows.map((w) => w.clarity)) >= TONAL_CLARITY;
|
|
593
|
+
const requested = options.method ?? "auto";
|
|
594
|
+
const method: "slice" | "spectral" =
|
|
595
|
+
requested === "auto" ? (tonal ? "slice" : "spectral") : requested;
|
|
596
|
+
if (method === "slice" && !pitch)
|
|
597
|
+
throw new WavetableMakeError(
|
|
598
|
+
"no stable pitch in the region · use method spectral, or pick a region with a sustained note",
|
|
599
|
+
);
|
|
600
|
+
|
|
601
|
+
const span = to - from;
|
|
602
|
+
const centres: number[] = [];
|
|
603
|
+
for (let k = 0; k < count; k += 1)
|
|
604
|
+
centres.push(from + ((k + 0.5) * span) / count);
|
|
605
|
+
|
|
606
|
+
let spectra: Spectrum[];
|
|
607
|
+
if (method === "slice") {
|
|
608
|
+
const fallback = sampleRate / pitch!.hz;
|
|
609
|
+
const size = pitchWindowSize(sampleRate);
|
|
610
|
+
spectra = centres.map((centre) => {
|
|
611
|
+
const local = detectWindow(
|
|
612
|
+
mono,
|
|
613
|
+
Math.round(centre) - size / 2,
|
|
614
|
+
sampleRate,
|
|
615
|
+
size,
|
|
616
|
+
);
|
|
617
|
+
const period =
|
|
618
|
+
local.clarity >= TONAL_CLARITY && local.hz > 0
|
|
619
|
+
? sampleRate / local.hz
|
|
620
|
+
: fallback;
|
|
621
|
+
// Start half a period before the centre so the cycle straddles it.
|
|
622
|
+
return forward(
|
|
623
|
+
sliceCycle(mono, Math.max(0, centre - period / 2), period),
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
} else {
|
|
627
|
+
const reference = pitch && tonal ? pitch.hz : SPECTRAL_REFERENCE_HZ;
|
|
628
|
+
spectra = centres.map((centre) =>
|
|
629
|
+
spectralFrame(mono, centre, sampleRate, reference),
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
for (const spectrum of spectra) {
|
|
633
|
+
if (options.removeDc ?? true) {
|
|
634
|
+
spectrum.re[0] = 0;
|
|
635
|
+
spectrum.im[0] = 0;
|
|
636
|
+
}
|
|
637
|
+
if ((options.align ?? true) && method === "slice") alignPhase(spectrum);
|
|
638
|
+
}
|
|
639
|
+
spectra = smoothFrames(spectra, smooth);
|
|
640
|
+
|
|
641
|
+
const cycles = spectra.map(inverse);
|
|
642
|
+
const normalize = options.normalize ?? "frame";
|
|
643
|
+
if (normalize === "table") {
|
|
644
|
+
let peak = 0;
|
|
645
|
+
for (const cycle of cycles)
|
|
646
|
+
for (const v of cycle) peak = Math.max(peak, Math.abs(v));
|
|
647
|
+
if (peak > 1e-12)
|
|
648
|
+
for (const cycle of cycles)
|
|
649
|
+
for (let i = 0; i < cycle.length; i += 1)
|
|
650
|
+
cycle[i] = (cycle[i]! * 0.98) / peak;
|
|
651
|
+
} else if (normalize === "frame") {
|
|
652
|
+
for (const cycle of cycles) {
|
|
653
|
+
let peak = 0;
|
|
654
|
+
for (const v of cycle) peak = Math.max(peak, Math.abs(v));
|
|
655
|
+
if (peak > 1e-12)
|
|
656
|
+
for (let i = 0; i < cycle.length; i += 1)
|
|
657
|
+
cycle[i] = (cycle[i]! * 0.98) / peak;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const frames = cycles.map((cycle) => Float32Array.from(cycle));
|
|
661
|
+
const centroids = spectra.map((s) => round1(centroid(s)));
|
|
662
|
+
return Object.freeze({
|
|
663
|
+
frames: Object.freeze(frames),
|
|
664
|
+
method,
|
|
665
|
+
...(pitch ? { pitch } : {}),
|
|
666
|
+
region: Object.freeze({
|
|
667
|
+
start: Math.round(region.start * 1000) / 1000,
|
|
668
|
+
end: Math.round(region.end * 1000) / 1000,
|
|
669
|
+
auto: region.auto,
|
|
670
|
+
}),
|
|
671
|
+
centroids: Object.freeze(centroids),
|
|
672
|
+
sweep: describeSweep(centroids, frameChanges(spectra)),
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Float32 mono WAV with a `clm ` chunk (`<!>2048 …`) before `data`, the
|
|
678
|
+
* layout `wavetableFromWav` reads back.
|
|
679
|
+
*/
|
|
680
|
+
export function encodeWavetableWav(
|
|
681
|
+
frames: readonly Float32Array[],
|
|
682
|
+
sampleRate = 44_100,
|
|
683
|
+
): Uint8Array {
|
|
684
|
+
const size = frames[0]?.length ?? MAKE_FRAME_SIZE;
|
|
685
|
+
const clm = `<!>${size} 00000000 dawg wavetable`;
|
|
686
|
+
const clmBytes = clm.length + (clm.length & 1);
|
|
687
|
+
const dataBytes = frames.length * size * 4;
|
|
688
|
+
const total = 12 + 8 + 16 + 8 + clmBytes + 8 + dataBytes;
|
|
689
|
+
const bytes = new Uint8Array(total);
|
|
690
|
+
const view = new DataView(bytes.buffer);
|
|
691
|
+
const ascii = (offset: number, text: string) => {
|
|
692
|
+
for (let i = 0; i < text.length; i += 1)
|
|
693
|
+
bytes[offset + i] = text.charCodeAt(i);
|
|
694
|
+
};
|
|
695
|
+
ascii(0, "RIFF");
|
|
696
|
+
view.setUint32(4, total - 8, true);
|
|
697
|
+
ascii(8, "WAVEfmt ");
|
|
698
|
+
view.setUint32(16, 16, true);
|
|
699
|
+
view.setUint16(20, 3, true); // IEEE float
|
|
700
|
+
view.setUint16(22, 1, true);
|
|
701
|
+
view.setUint32(24, sampleRate, true);
|
|
702
|
+
view.setUint32(28, sampleRate * 4, true);
|
|
703
|
+
view.setUint16(32, 4, true);
|
|
704
|
+
view.setUint16(34, 32, true);
|
|
705
|
+
let offset = 36;
|
|
706
|
+
ascii(offset, "clm ");
|
|
707
|
+
view.setUint32(offset + 4, clm.length, true);
|
|
708
|
+
ascii(offset + 8, clm);
|
|
709
|
+
offset += 8 + clmBytes;
|
|
710
|
+
ascii(offset, "data");
|
|
711
|
+
view.setUint32(offset + 4, dataBytes, true);
|
|
712
|
+
offset += 8;
|
|
713
|
+
for (const frame of frames)
|
|
714
|
+
for (let i = 0; i < size; i += 1, offset += 4)
|
|
715
|
+
view.setFloat32(offset, frame[i]!, true);
|
|
716
|
+
return bytes;
|
|
717
|
+
}
|