@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
package/src/audio/wav.ts
CHANGED
|
@@ -2,6 +2,8 @@ import {
|
|
|
2
2
|
SCORE_LIMITS,
|
|
3
3
|
isSamplerInstrument,
|
|
4
4
|
isTrackAudible,
|
|
5
|
+
isWavetableInstrument,
|
|
6
|
+
wavetableOf,
|
|
5
7
|
type AutomationPoint,
|
|
6
8
|
type Note,
|
|
7
9
|
type Track,
|
|
@@ -13,7 +15,46 @@ import {
|
|
|
13
15
|
renderSamplerVoices,
|
|
14
16
|
samplerTailSeconds,
|
|
15
17
|
} from "./sampler.ts";
|
|
16
|
-
import {
|
|
18
|
+
import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
|
|
19
|
+
import { seededRandom } from "./random.ts";
|
|
20
|
+
import {
|
|
21
|
+
isStereoVoice,
|
|
22
|
+
renderSynthNote,
|
|
23
|
+
synthTailSeconds,
|
|
24
|
+
usesSynthVoice,
|
|
25
|
+
type VoiceContext,
|
|
26
|
+
} from "./synth/voice.ts";
|
|
27
|
+
import { legacyWave } from "./synth/oscillators.ts";
|
|
28
|
+
import {
|
|
29
|
+
EMPTY_SAMPLE_BANK,
|
|
30
|
+
sampleKey,
|
|
31
|
+
type DecodedSample,
|
|
32
|
+
type SampleBank,
|
|
33
|
+
} from "./samples.ts";
|
|
34
|
+
import { synthKit } from "../../core/kits.ts";
|
|
35
|
+
import { kitDrumSample, kitTailSeconds, newKitVoiceState } from "./kits.ts";
|
|
36
|
+
import {
|
|
37
|
+
applyMonoChain,
|
|
38
|
+
applyStereoChain,
|
|
39
|
+
delayTailFor,
|
|
40
|
+
interpolateAutomation,
|
|
41
|
+
reverbImpulse,
|
|
42
|
+
reverbTailFor,
|
|
43
|
+
} from "./effects/chain.ts";
|
|
44
|
+
import {
|
|
45
|
+
createOrbitBus,
|
|
46
|
+
renderOrbitBus,
|
|
47
|
+
sendToBus,
|
|
48
|
+
sharedOrbitOf,
|
|
49
|
+
type OrbitBus,
|
|
50
|
+
} from "./effects/bus.ts";
|
|
51
|
+
import { tableFor, wavetableOscillator } from "./wavetable.ts";
|
|
52
|
+
import {
|
|
53
|
+
duckGains,
|
|
54
|
+
duckSettings,
|
|
55
|
+
orbitOf,
|
|
56
|
+
type Ducker,
|
|
57
|
+
} from "./effects/duck.ts";
|
|
17
58
|
|
|
18
59
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
19
60
|
|
|
@@ -53,16 +94,46 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
|
53
94
|
"saw",
|
|
54
95
|
"square",
|
|
55
96
|
"triangle",
|
|
97
|
+
"wavetable",
|
|
56
98
|
"kit",
|
|
99
|
+
// Synth-voice sounds (core/synth.ts), Strudel names; `synth` shapes them.
|
|
100
|
+
"sawtooth",
|
|
101
|
+
"supersaw",
|
|
102
|
+
"pulse",
|
|
103
|
+
"user",
|
|
104
|
+
"white",
|
|
105
|
+
"pink",
|
|
106
|
+
"brown",
|
|
107
|
+
"crackle",
|
|
108
|
+
// ZzFX sounds (src/audio/synth/zzfx.ts).
|
|
109
|
+
"z_sine",
|
|
110
|
+
"z_triangle",
|
|
111
|
+
"z_sawtooth",
|
|
112
|
+
"z_square",
|
|
113
|
+
"z_tan",
|
|
114
|
+
"z_noise",
|
|
57
115
|
] as const);
|
|
58
116
|
|
|
59
|
-
/** Per-track effects understood by the renderer and the agent
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
117
|
+
/** Per-track effects understood by the renderer and the agent, in chain
|
|
118
|
+
* order, with each effect's simple parameters (`fx` takes the rest). */
|
|
119
|
+
export const AVAILABLE_EFFECTS: readonly string[] = Object.freeze([
|
|
120
|
+
"pan(-1..1 equal-power)",
|
|
121
|
+
...EFFECT_NAMES.map((name) => {
|
|
122
|
+
const spec = effectSpec(name);
|
|
123
|
+
return `${name}(${spec.simple.join(" ")})`;
|
|
124
|
+
}),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
/** `fx <effect> preset <name>` names, for the agent brief. */
|
|
128
|
+
export const AVAILABLE_FX_PRESETS: Readonly<Record<string, string>> =
|
|
129
|
+
Object.freeze(
|
|
130
|
+
Object.fromEntries(
|
|
131
|
+
EFFECT_NAMES.filter((name) => FX_PRESETS[name]).map((name) => [
|
|
132
|
+
name,
|
|
133
|
+
Object.keys(FX_PRESETS[name]!).join(" "),
|
|
134
|
+
]),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
66
137
|
|
|
67
138
|
/** Longest drum one-shot, in seconds; hits ring past their note length. */
|
|
68
139
|
const MAX_DRUM_SECONDS = 0.6;
|
|
@@ -78,6 +149,8 @@ type RenderContext = Readonly<{
|
|
|
78
149
|
sampleRate: number;
|
|
79
150
|
samples: number;
|
|
80
151
|
samplesPerTick: number;
|
|
152
|
+
tempoBpm: number;
|
|
153
|
+
irs?: ReadonlyMap<string, DecodedSample>;
|
|
81
154
|
}>;
|
|
82
155
|
|
|
83
156
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
@@ -136,16 +209,23 @@ type Stem = {
|
|
|
136
209
|
export class StemRenderer {
|
|
137
210
|
private readonly maxCacheBytes: number;
|
|
138
211
|
private readonly stems = new Map<string, Stem>();
|
|
212
|
+
/** Shared orbit bus outputs by orbit, keyed by their members' stems. */
|
|
213
|
+
private readonly buses = new Map<
|
|
214
|
+
number,
|
|
215
|
+
{ key: string; left: Float64Array; right: Float64Array }
|
|
216
|
+
>();
|
|
139
217
|
private cacheBytes = 0;
|
|
140
218
|
private renders = 0;
|
|
141
219
|
private scratch: {
|
|
142
220
|
dry: Float64Array;
|
|
221
|
+
dryR: Float64Array;
|
|
143
222
|
left: Float64Array;
|
|
144
223
|
right: Float64Array;
|
|
145
224
|
mixL: Float64Array;
|
|
146
225
|
mixR: Float64Array;
|
|
147
226
|
} = {
|
|
148
227
|
dry: new Float64Array(0),
|
|
228
|
+
dryR: new Float64Array(0),
|
|
149
229
|
left: new Float64Array(0),
|
|
150
230
|
right: new Float64Array(0),
|
|
151
231
|
mixL: new Float64Array(0),
|
|
@@ -168,17 +248,21 @@ export class StemRenderer {
|
|
|
168
248
|
const sampleRate = clampSampleRate(options.sampleRate);
|
|
169
249
|
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
170
250
|
const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
|
|
251
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
171
252
|
const reverbTail = Math.max(
|
|
172
253
|
0,
|
|
173
254
|
...score.tracks.map((track) =>
|
|
174
|
-
track
|
|
255
|
+
reverbTailFor(track, sampleRate, bank.irs),
|
|
175
256
|
),
|
|
176
257
|
);
|
|
177
|
-
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
178
258
|
// Zero without sampler voices, so synth-only renders are unchanged.
|
|
259
|
+
// Zero without synth-voice tracks, so legacy renders are unchanged.
|
|
179
260
|
const samplerTail = Math.min(
|
|
180
261
|
MAX_LOOP_TAIL_SECONDS,
|
|
181
|
-
|
|
262
|
+
Math.max(
|
|
263
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
264
|
+
...score.tracks.map(synthTailSeconds),
|
|
265
|
+
),
|
|
182
266
|
);
|
|
183
267
|
let frames: number;
|
|
184
268
|
let samples: number;
|
|
@@ -192,10 +276,13 @@ export class StemRenderer {
|
|
|
192
276
|
);
|
|
193
277
|
const tailSeconds = Math.min(
|
|
194
278
|
MAX_LOOP_TAIL_SECONDS,
|
|
195
|
-
Math.max(MAX_DRUM_SECONDS, samplerTail) +
|
|
279
|
+
Math.max(MAX_DRUM_SECONDS, samplerTail, kitTailSeconds(score)) +
|
|
196
280
|
0.1 +
|
|
197
281
|
reverbTail +
|
|
198
|
-
|
|
282
|
+
Math.max(
|
|
283
|
+
0,
|
|
284
|
+
...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
|
|
285
|
+
),
|
|
199
286
|
);
|
|
200
287
|
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
201
288
|
} else {
|
|
@@ -211,9 +298,11 @@ export class StemRenderer {
|
|
|
211
298
|
sampleRate,
|
|
212
299
|
samples,
|
|
213
300
|
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
301
|
+
tempoBpm: score.tempoBpm,
|
|
302
|
+
...(bank.irs ? { irs: bank.irs } : {}),
|
|
214
303
|
};
|
|
215
304
|
this.renders += 1;
|
|
216
|
-
const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
305
|
+
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
217
306
|
mixL.fill(0);
|
|
218
307
|
mixR.fill(0);
|
|
219
308
|
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
@@ -223,13 +312,44 @@ export class StemRenderer {
|
|
|
223
312
|
if (group) group.push(note);
|
|
224
313
|
else groups.set(note.trackId, [note]);
|
|
225
314
|
}
|
|
315
|
+
// Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
|
|
316
|
+
const duckers: Ducker[] = [];
|
|
317
|
+
for (const [trackId, notes] of groups) {
|
|
318
|
+
const settings = duckSettings(tracks.get(trackId));
|
|
319
|
+
if (settings && isTrackAudible(score, trackId))
|
|
320
|
+
duckers.push({
|
|
321
|
+
trackId,
|
|
322
|
+
...settings,
|
|
323
|
+
onsets: notes.map((note) => noteSpan(note, context).start),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
const ducked = duckGains(
|
|
327
|
+
[...groups.keys()].map((id) => ({ id, orbit: orbitOf(tracks.get(id)) })),
|
|
328
|
+
duckers,
|
|
329
|
+
samples,
|
|
330
|
+
sampleRate,
|
|
331
|
+
options.loop ? frames : undefined,
|
|
332
|
+
);
|
|
333
|
+
// Shared orbit buses (src/audio/effects/bus.ts), created on first send.
|
|
334
|
+
const buses = new Map<number, OrbitBus>();
|
|
226
335
|
// Tracks render one at a time; the sum order (first note per track) is
|
|
227
336
|
// part of the output, so cached and cold renders keep it.
|
|
228
337
|
for (const [trackId, notes] of groups) {
|
|
229
338
|
if (!isTrackAudible(score, trackId)) continue;
|
|
230
339
|
const track = tracks.get(trackId);
|
|
340
|
+
const busOrbit = sharedOrbitOf(track);
|
|
231
341
|
const sampler = isSamplerInstrument(track?.instrument);
|
|
232
|
-
|
|
342
|
+
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
343
|
+
// table id joins the stem key), undefined for every other instrument.
|
|
344
|
+
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
345
|
+
const key = stemKey(
|
|
346
|
+
track,
|
|
347
|
+
notes,
|
|
348
|
+
context,
|
|
349
|
+
sampler ? bank : undefined,
|
|
350
|
+
wavetable?.id,
|
|
351
|
+
track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
|
|
352
|
+
);
|
|
233
353
|
let stem = this.stems.get(trackId);
|
|
234
354
|
if (stem?.key === key) stem.used = this.renders;
|
|
235
355
|
else {
|
|
@@ -241,8 +361,31 @@ export class StemRenderer {
|
|
|
241
361
|
}
|
|
242
362
|
: { left, right };
|
|
243
363
|
dry.fill(0);
|
|
364
|
+
const synthVoice = !sampler && usesSynthVoice(track);
|
|
365
|
+
const stereo = synthVoice && isStereoVoice(track);
|
|
366
|
+
if (stereo) dryR.fill(0);
|
|
244
367
|
if (sampler) {
|
|
245
368
|
if (track) renderSamplerNotes(dry, notes, track, context, bank);
|
|
369
|
+
} else if (synthVoice && track) {
|
|
370
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
371
|
+
const voice = {
|
|
372
|
+
...context,
|
|
373
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
374
|
+
...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
|
|
375
|
+
};
|
|
376
|
+
for (const note of notes) {
|
|
377
|
+
const { start, length } = noteSpan(note, context);
|
|
378
|
+
renderSynthNote(
|
|
379
|
+
dry,
|
|
380
|
+
stereo ? dryR : undefined,
|
|
381
|
+
note,
|
|
382
|
+
track,
|
|
383
|
+
start,
|
|
384
|
+
length,
|
|
385
|
+
voice,
|
|
386
|
+
gainAt,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
246
389
|
} else {
|
|
247
390
|
const drums = isDrumInstrument(track?.instrument);
|
|
248
391
|
for (const note of notes) {
|
|
@@ -250,12 +393,24 @@ export class StemRenderer {
|
|
|
250
393
|
else renderToneNote(dry, note, track, context);
|
|
251
394
|
}
|
|
252
395
|
}
|
|
253
|
-
if (track)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
396
|
+
if (track) applyMonoChain(dry, track, context);
|
|
397
|
+
if (stereo && track) applyMonoChain(dryR, track, context);
|
|
398
|
+
applyPan(
|
|
399
|
+
dry,
|
|
400
|
+
target.left,
|
|
401
|
+
target.right,
|
|
402
|
+
track,
|
|
403
|
+
context,
|
|
404
|
+
stereo ? dryR : undefined,
|
|
405
|
+
);
|
|
406
|
+
if (track)
|
|
407
|
+
applyStereoChain(
|
|
408
|
+
target.left,
|
|
409
|
+
target.right,
|
|
410
|
+
track,
|
|
411
|
+
context,
|
|
412
|
+
busOrbit === undefined,
|
|
413
|
+
);
|
|
259
414
|
stem = {
|
|
260
415
|
key,
|
|
261
416
|
left: target.left,
|
|
@@ -265,11 +420,50 @@ export class StemRenderer {
|
|
|
265
420
|
};
|
|
266
421
|
if (caching) this.store(trackId, stem);
|
|
267
422
|
}
|
|
423
|
+
if (track && busOrbit !== undefined) {
|
|
424
|
+
let bus = buses.get(busOrbit);
|
|
425
|
+
if (!bus)
|
|
426
|
+
buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
|
|
427
|
+
sendToBus(bus, track, stem.left, stem.right, context, key);
|
|
428
|
+
}
|
|
429
|
+
const gain = ducked.get(trackId);
|
|
430
|
+
if (gain)
|
|
431
|
+
for (let index = 0; index < samples; index += 1) {
|
|
432
|
+
mixL[index]! += stem.left[index]! * gain[index]!;
|
|
433
|
+
mixR[index]! += stem.right[index]! * gain[index]!;
|
|
434
|
+
}
|
|
435
|
+
else
|
|
436
|
+
for (let index = 0; index < samples; index += 1) {
|
|
437
|
+
mixL[index]! += stem.left[index]!;
|
|
438
|
+
mixR[index]! += stem.right[index]!;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Bus returns join after every stem, in orbit order; the orbit's ducking
|
|
442
|
+
// applies to them as to its tracks.
|
|
443
|
+
const busGains = duckGains(
|
|
444
|
+
[...buses.keys()].map((orbit) => ({ id: `bus:${orbit}`, orbit })),
|
|
445
|
+
duckers,
|
|
446
|
+
samples,
|
|
447
|
+
sampleRate,
|
|
448
|
+
options.loop ? frames : undefined,
|
|
449
|
+
);
|
|
450
|
+
for (const orbit of [...buses.keys()].sort((a, b) => a - b)) {
|
|
451
|
+
const bus = buses.get(orbit)!;
|
|
452
|
+
const key = JSON.stringify(bus.keys);
|
|
453
|
+
let wet = this.buses.get(orbit);
|
|
454
|
+
if (wet?.key !== key) {
|
|
455
|
+
wet = { key, ...renderOrbitBus(bus, context) };
|
|
456
|
+
if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
|
|
457
|
+
}
|
|
458
|
+
const gain = busGains.get(`bus:${orbit}`);
|
|
268
459
|
for (let index = 0; index < samples; index += 1) {
|
|
269
|
-
|
|
270
|
-
|
|
460
|
+
const g = gain ? gain[index]! : 1;
|
|
461
|
+
mixL[index]! += wet.left[index]! * g;
|
|
462
|
+
mixR[index]! += wet.right[index]! * g;
|
|
271
463
|
}
|
|
272
464
|
}
|
|
465
|
+
for (const orbit of [...this.buses.keys()])
|
|
466
|
+
if (!buses.has(orbit)) this.buses.delete(orbit);
|
|
273
467
|
// Stems of tracks that left the score are not worth keeping; muted and
|
|
274
468
|
// unsoloed tracks keep theirs so toggling them back is free.
|
|
275
469
|
for (const trackId of [...this.stems.keys()])
|
|
@@ -299,6 +493,7 @@ export class StemRenderer {
|
|
|
299
493
|
if (this.scratch.dry.length < samples) {
|
|
300
494
|
this.scratch = {
|
|
301
495
|
dry: new Float64Array(samples),
|
|
496
|
+
dryR: new Float64Array(samples),
|
|
302
497
|
left: new Float64Array(samples),
|
|
303
498
|
right: new Float64Array(samples),
|
|
304
499
|
mixL: new Float64Array(samples),
|
|
@@ -308,6 +503,7 @@ export class StemRenderer {
|
|
|
308
503
|
const view = (buffer: Float64Array) => buffer.subarray(0, samples);
|
|
309
504
|
return {
|
|
310
505
|
dry: view(this.scratch.dry),
|
|
506
|
+
dryR: view(this.scratch.dryR),
|
|
311
507
|
left: view(this.scratch.left),
|
|
312
508
|
right: view(this.scratch.right),
|
|
313
509
|
mixL: view(this.scratch.mixL),
|
|
@@ -353,6 +549,8 @@ function stemKey(
|
|
|
353
549
|
notes: readonly Note[],
|
|
354
550
|
context: RenderContext,
|
|
355
551
|
bank?: SampleBank,
|
|
552
|
+
wavetableId?: string,
|
|
553
|
+
impulseId?: string,
|
|
356
554
|
): string {
|
|
357
555
|
let settings: Record<string, unknown> | null = null;
|
|
358
556
|
if (track) {
|
|
@@ -364,11 +562,16 @@ function stemKey(
|
|
|
364
562
|
notes,
|
|
365
563
|
context.score.tempoBpm,
|
|
366
564
|
context.score.ticksPerBeat,
|
|
565
|
+
// Bar length: sampler `unit: "c"` stretches a window to whole bars.
|
|
566
|
+
context.score.beatsPerBar,
|
|
367
567
|
context.sampleRate,
|
|
368
568
|
context.samples,
|
|
369
569
|
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
370
570
|
// sample changes the key even when the score did not change.
|
|
371
571
|
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
572
|
+
...(wavetableId ? [wavetableId] : []),
|
|
573
|
+
// A convolution reverb's impulse (its sha256 for a sample).
|
|
574
|
+
...(impulseId ? [impulseId] : []),
|
|
372
575
|
]);
|
|
373
576
|
}
|
|
374
577
|
|
|
@@ -400,27 +603,6 @@ function renderSamplerNotes(
|
|
|
400
603
|
);
|
|
401
604
|
}
|
|
402
605
|
|
|
403
|
-
/** Seconds until the slowest delay decays below -60 dB, bounded. */
|
|
404
|
-
function delayTailSeconds(score: TrackScore): number {
|
|
405
|
-
let longest = 0;
|
|
406
|
-
for (const track of score.tracks) {
|
|
407
|
-
if (!track.delay) continue;
|
|
408
|
-
const feedback = Math.max(
|
|
409
|
-
track.delay.feedback,
|
|
410
|
-
...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
|
|
411
|
-
);
|
|
412
|
-
const repeats =
|
|
413
|
-
feedback <= 0.001
|
|
414
|
-
? 1
|
|
415
|
-
: Math.min(64, Math.log(0.001) / Math.log(feedback));
|
|
416
|
-
longest = Math.max(
|
|
417
|
-
longest,
|
|
418
|
-
((track.delay.beats * 60) / score.tempoBpm) * (repeats + 1),
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
return longest;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
606
|
/** Track volume and volume automation at a score tick (pan is applied in stereo). */
|
|
425
607
|
function trackGainAt(track: Track | undefined, tick: number): number {
|
|
426
608
|
const trackGain = Math.max(0, Math.min(1, track?.volume ?? 1));
|
|
@@ -477,8 +659,7 @@ function renderToneNote(
|
|
|
477
659
|
0.28 *
|
|
478
660
|
trackGainAt(track, note.startTick + elapsed / samplesPerTick);
|
|
479
661
|
const phase = (frequency * elapsed) / sampleRate;
|
|
480
|
-
target[index]! +=
|
|
481
|
-
synthSample(instrument, phase, frequency, sampleRate) * envelope;
|
|
662
|
+
target[index]! += legacyWave(instrument, phase) * envelope;
|
|
482
663
|
}
|
|
483
664
|
}
|
|
484
665
|
|
|
@@ -495,9 +676,13 @@ function renderDrumNote(
|
|
|
495
676
|
): void {
|
|
496
677
|
const { sampleRate, samples, samplesPerTick } = context;
|
|
497
678
|
const { start } = noteSpan(note, context);
|
|
679
|
+
// A track `kit` swaps in a synthesized kit (src/audio/kits.ts); without
|
|
680
|
+
// one the voices below render exactly as they always have.
|
|
681
|
+
const kit = synthKit(track?.kit);
|
|
682
|
+
const kitState = kit ? newKitVoiceState() : undefined;
|
|
498
683
|
const end = Math.min(
|
|
499
684
|
samples,
|
|
500
|
-
start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
|
|
685
|
+
start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
|
|
501
686
|
);
|
|
502
687
|
const voice = drumVoiceForPitch(note.pitch);
|
|
503
688
|
const random = seededRandom(`${note.id}:${note.startTick}`);
|
|
@@ -512,7 +697,17 @@ function renderDrumNote(
|
|
|
512
697
|
const bright = noise - previousNoise;
|
|
513
698
|
previousNoise = noise;
|
|
514
699
|
let sample = 0;
|
|
515
|
-
if (
|
|
700
|
+
if (kit && kitState)
|
|
701
|
+
sample = kitDrumSample(
|
|
702
|
+
kit,
|
|
703
|
+
voice,
|
|
704
|
+
t,
|
|
705
|
+
noise,
|
|
706
|
+
bright,
|
|
707
|
+
kitState,
|
|
708
|
+
sampleRate,
|
|
709
|
+
);
|
|
710
|
+
else if (voice === "kick") {
|
|
516
711
|
const frequency = 45 + 105 * Math.exp(-t * 28);
|
|
517
712
|
phase += frequency / sampleRate;
|
|
518
713
|
sample =
|
|
@@ -560,64 +755,6 @@ function renderDrumNote(
|
|
|
560
755
|
}
|
|
561
756
|
}
|
|
562
757
|
|
|
563
|
-
/**
|
|
564
|
-
* RBJ biquad low-pass with static or automated cutoff and resonance.
|
|
565
|
-
* Resonance 0..1 maps to Q 0.707..8 and the cutoff is kept under 45% of the
|
|
566
|
-
* sample rate, so the filter is stable for every value the score accepts.
|
|
567
|
-
*/
|
|
568
|
-
function applyLowPass(
|
|
569
|
-
buffer: Float64Array,
|
|
570
|
-
track: Track,
|
|
571
|
-
context: RenderContext,
|
|
572
|
-
): void {
|
|
573
|
-
const automation = track.filterAutomation ?? [];
|
|
574
|
-
const resonanceLane = track.resonanceAutomation ?? [];
|
|
575
|
-
if (!track.filter && automation.length === 0 && resonanceLane.length === 0)
|
|
576
|
-
return;
|
|
577
|
-
const { sampleRate, samplesPerTick } = context;
|
|
578
|
-
const staticCutoff = track.filter?.cutoff ?? SCORE_LIMITS.maxFilterCutoff;
|
|
579
|
-
const staticResonance = track.filter?.resonance ?? 0;
|
|
580
|
-
const automated = automation.length > 0 || resonanceLane.length > 0;
|
|
581
|
-
let b0 = 0;
|
|
582
|
-
let b1 = 0;
|
|
583
|
-
let b2 = 0;
|
|
584
|
-
let a1 = 0;
|
|
585
|
-
let a2 = 0;
|
|
586
|
-
let z1 = 0;
|
|
587
|
-
let z2 = 0;
|
|
588
|
-
const update = (cutoff: number, resonance: number) => {
|
|
589
|
-
const q = 0.707 + Math.max(0, Math.min(1, resonance)) * 7.293;
|
|
590
|
-
const frequency = Math.max(
|
|
591
|
-
SCORE_LIMITS.minFilterCutoff,
|
|
592
|
-
Math.min(cutoff, sampleRate * 0.45),
|
|
593
|
-
);
|
|
594
|
-
const omega = (2 * Math.PI * frequency) / sampleRate;
|
|
595
|
-
const alpha = Math.sin(omega) / (2 * q);
|
|
596
|
-
const cos = Math.cos(omega);
|
|
597
|
-
const a0 = 1 + alpha;
|
|
598
|
-
b0 = (1 - cos) / 2 / a0;
|
|
599
|
-
b1 = (1 - cos) / a0;
|
|
600
|
-
b2 = b0;
|
|
601
|
-
a1 = (-2 * cos) / a0;
|
|
602
|
-
a2 = (1 - alpha) / a0;
|
|
603
|
-
};
|
|
604
|
-
update(staticCutoff, staticResonance);
|
|
605
|
-
for (let index = 0; index < buffer.length; index += 1) {
|
|
606
|
-
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
607
|
-
const tick = index / samplesPerTick;
|
|
608
|
-
update(
|
|
609
|
-
interpolateAutomation(automation, tick, staticCutoff),
|
|
610
|
-
interpolateAutomation(resonanceLane, tick, staticResonance),
|
|
611
|
-
);
|
|
612
|
-
}
|
|
613
|
-
const input = buffer[index]!;
|
|
614
|
-
const output = b0 * input + z1;
|
|
615
|
-
z1 = b1 * input - a1 * output + z2;
|
|
616
|
-
z2 = b2 * input - a2 * output;
|
|
617
|
-
buffer[index] = output;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
|
|
621
758
|
/**
|
|
622
759
|
* Equal-power pan of the mono track into the stereo pair: left = cos(θ),
|
|
623
760
|
* right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
|
|
@@ -629,6 +766,7 @@ function applyPan(
|
|
|
629
766
|
right: Float64Array,
|
|
630
767
|
track: Track | undefined,
|
|
631
768
|
context: RenderContext,
|
|
769
|
+
dryRight?: Float64Array,
|
|
632
770
|
): void {
|
|
633
771
|
const lane = track?.panAutomation ?? [];
|
|
634
772
|
const staticPan = track?.pan ?? 0;
|
|
@@ -644,189 +782,53 @@ function applyPan(
|
|
|
644
782
|
);
|
|
645
783
|
const sample = dry[index]!;
|
|
646
784
|
left[index] = sample * gainL;
|
|
647
|
-
right
|
|
648
|
-
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
/**
|
|
652
|
-
* Tempo-synced stereo feedback delay. Each side echoes its own input and
|
|
653
|
-
* feeds back into the opposite side, so repeats ping-pong across the field.
|
|
654
|
-
* Feedback and mix follow their automation lanes when present.
|
|
655
|
-
*/
|
|
656
|
-
function applyDelay(
|
|
657
|
-
left: Float64Array,
|
|
658
|
-
right: Float64Array,
|
|
659
|
-
track: Track,
|
|
660
|
-
context: RenderContext,
|
|
661
|
-
): void {
|
|
662
|
-
const delay = track.delay;
|
|
663
|
-
if (!delay) return;
|
|
664
|
-
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
665
|
-
const mixLane = track.delayMixAutomation ?? [];
|
|
666
|
-
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
667
|
-
const { score, sampleRate, samplesPerTick } = context;
|
|
668
|
-
const length = Math.max(
|
|
669
|
-
1,
|
|
670
|
-
Math.round(((delay.beats * 60) / score.tempoBpm) * sampleRate),
|
|
671
|
-
);
|
|
672
|
-
const lineL = new Float64Array(length);
|
|
673
|
-
const lineR = new Float64Array(length);
|
|
674
|
-
let feedback = delay.feedback;
|
|
675
|
-
let mix = delay.mix;
|
|
676
|
-
const automated = feedbackLane.length > 0 || mixLane.length > 0;
|
|
677
|
-
for (let index = 0; index < left.length; index += 1) {
|
|
678
|
-
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
679
|
-
const tick = index / samplesPerTick;
|
|
680
|
-
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
681
|
-
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
682
|
-
}
|
|
683
|
-
const slot = index % length;
|
|
684
|
-
const wetL = lineL[slot]!;
|
|
685
|
-
const wetR = lineR[slot]!;
|
|
686
|
-
const dryL = left[index]!;
|
|
687
|
-
const dryR = right[index]!;
|
|
688
|
-
lineL[slot] = dryL + wetR * feedback;
|
|
689
|
-
lineR[slot] = dryR + wetL * feedback;
|
|
690
|
-
left[index] = dryL + wetL * mix;
|
|
691
|
-
right[index] = dryR + wetR * mix;
|
|
785
|
+
// A stereo voice pans its own right channel the same way.
|
|
786
|
+
right[index] = (dryRight ? dryRight[index]! : sample) * gainR;
|
|
692
787
|
}
|
|
693
788
|
}
|
|
694
789
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const REVERB_WET_SCALE = 3;
|
|
790
|
+
type WavetableHook = Readonly<{
|
|
791
|
+
/** Joins the stem key: a different or missing table re-renders. */
|
|
792
|
+
id: string;
|
|
793
|
+
oscillatorFor: NonNullable<VoiceContext["oscillatorFor"]>;
|
|
794
|
+
}>;
|
|
701
795
|
|
|
702
796
|
/**
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
* feedback (0.7..0.98) and darkens the tail; the wet signal is added to the
|
|
706
|
-
* dry stereo pair at `mix`.
|
|
797
|
+
* Wavetable hook: for a wavetable track, each note's oscillator reads the
|
|
798
|
+
* track's table (`src/audio/wavetable.ts`); the synth voice does the rest.
|
|
707
799
|
*/
|
|
708
|
-
function
|
|
709
|
-
left: Float64Array,
|
|
710
|
-
right: Float64Array,
|
|
800
|
+
function wavetableHook(
|
|
711
801
|
track: Track,
|
|
802
|
+
bank: SampleBank,
|
|
712
803
|
context: RenderContext,
|
|
713
|
-
):
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
const
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
for (const comb of side.combs) {
|
|
739
|
-
const delayed = comb.buffer[comb.index]!;
|
|
740
|
-
comb.store = delayed * (1 - damp) + comb.store * damp;
|
|
741
|
-
comb.buffer[comb.index] = input + comb.store * feedback;
|
|
742
|
-
comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
|
|
743
|
-
output += delayed;
|
|
744
|
-
}
|
|
745
|
-
for (const allpass of side.allpasses) {
|
|
746
|
-
const delayed = allpass.buffer[allpass.index]!;
|
|
747
|
-
allpass.buffer[allpass.index] = output + delayed * 0.5;
|
|
748
|
-
output = delayed - output;
|
|
749
|
-
allpass.index =
|
|
750
|
-
allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
|
|
751
|
-
}
|
|
752
|
-
return output;
|
|
753
|
-
};
|
|
754
|
-
for (let index = 0; index < left.length; index += 1) {
|
|
755
|
-
const input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
|
|
756
|
-
left[index]! += process(sides[0], input) * wet;
|
|
757
|
-
right[index]! += process(sides[1], input) * wet;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/** FNV-1a seeded mulberry32: integer-only, so identical on every platform. */
|
|
762
|
-
function seededRandom(seed: string): () => number {
|
|
763
|
-
let hash = 0x811c9dc5;
|
|
764
|
-
for (let index = 0; index < seed.length; index += 1) {
|
|
765
|
-
hash ^= seed.charCodeAt(index);
|
|
766
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
767
|
-
}
|
|
768
|
-
let state = hash;
|
|
769
|
-
return () => {
|
|
770
|
-
state = (state + 0x6d2b79f5) >>> 0;
|
|
771
|
-
let value = Math.imul(state ^ (state >>> 15), 1 | state);
|
|
772
|
-
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;
|
|
773
|
-
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296;
|
|
804
|
+
): WavetableHook | undefined {
|
|
805
|
+
if (!isWavetableInstrument(track.instrument)) return undefined;
|
|
806
|
+
const settings = wavetableOf(track);
|
|
807
|
+
const table = tableFor(track, settings, bank.wavetables);
|
|
808
|
+
// A pack table that failed to load renders silent (the problem is reported).
|
|
809
|
+
if (!table)
|
|
810
|
+
return {
|
|
811
|
+
id: `missing:${settings.table.src}`,
|
|
812
|
+
oscillatorFor: () => () => () => 0,
|
|
813
|
+
};
|
|
814
|
+
const lane = track.wtAutomation ?? [];
|
|
815
|
+
const positionAt =
|
|
816
|
+
lane.length > 0
|
|
817
|
+
? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
|
|
818
|
+
: undefined;
|
|
819
|
+
return {
|
|
820
|
+
id: table.id,
|
|
821
|
+
oscillatorFor: (note) =>
|
|
822
|
+
wavetableOscillator(table, settings, note, {
|
|
823
|
+
sampleRate: context.sampleRate,
|
|
824
|
+
samplesPerTick: context.samplesPerTick,
|
|
825
|
+
secondsPerTick: context.samplesPerTick / context.sampleRate,
|
|
826
|
+
gate: noteSpan(note, context).length / context.sampleRate,
|
|
827
|
+
positionAt,
|
|
828
|
+
}),
|
|
774
829
|
};
|
|
775
830
|
}
|
|
776
831
|
|
|
777
|
-
/** Resolve a piecewise-linear automation lane, holding the static value before its first point. */
|
|
778
|
-
function interpolateAutomation(
|
|
779
|
-
points: readonly AutomationPoint[],
|
|
780
|
-
tick: number,
|
|
781
|
-
fallback: number,
|
|
782
|
-
): number {
|
|
783
|
-
if (points.length === 0 || !Number.isFinite(tick)) return fallback;
|
|
784
|
-
const first = points[0]!;
|
|
785
|
-
if (tick < first.tick) return fallback;
|
|
786
|
-
const last = points[points.length - 1]!;
|
|
787
|
-
if (tick >= last.tick) return last.value;
|
|
788
|
-
for (let index = 1; index < points.length; index += 1) {
|
|
789
|
-
const right = points[index]!;
|
|
790
|
-
if (tick > right.tick) continue;
|
|
791
|
-
const left = points[index - 1]!;
|
|
792
|
-
const span = right.tick - left.tick;
|
|
793
|
-
const ratio = span <= 0 ? 1 : (tick - left.tick) / span;
|
|
794
|
-
return left.value + (right.value - left.value) * ratio;
|
|
795
|
-
}
|
|
796
|
-
return last.value;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
/** A tiny deterministic instrument bank. Names come from the score's track metadata. */
|
|
800
|
-
function synthSample(
|
|
801
|
-
instrument: string,
|
|
802
|
-
phase: number,
|
|
803
|
-
frequency: number,
|
|
804
|
-
sampleRate: number,
|
|
805
|
-
): number {
|
|
806
|
-
const name = instrument.trim().toLowerCase();
|
|
807
|
-
const cycle = phase - Math.floor(phase);
|
|
808
|
-
const sine = Math.sin(2 * Math.PI * phase);
|
|
809
|
-
if (name.includes("square")) return cycle < 0.5 ? 1 : -1;
|
|
810
|
-
if (name.includes("saw")) return 2 * cycle - 1;
|
|
811
|
-
if (name.includes("triangle")) return 1 - 4 * Math.abs(cycle - 0.5);
|
|
812
|
-
if (name.includes("bass")) {
|
|
813
|
-
// A rounded fundamental plus a quiet octave gives bass tracks useful weight.
|
|
814
|
-
return Math.tanh(
|
|
815
|
-
0.9 * Math.sin(2 * Math.PI * phase) +
|
|
816
|
-
0.25 * Math.sin(4 * Math.PI * phase),
|
|
817
|
-
);
|
|
818
|
-
}
|
|
819
|
-
if (name.includes("piano") || name.includes("pluck")) {
|
|
820
|
-
// Add stable harmonics; the envelope above supplies the note decay.
|
|
821
|
-
const harmonic =
|
|
822
|
-
Math.sin(4 * Math.PI * phase) * 0.28 +
|
|
823
|
-
Math.sin(6 * Math.PI * phase) * 0.12;
|
|
824
|
-
return Math.tanh(sine + harmonic);
|
|
825
|
-
}
|
|
826
|
-
// Unknown instruments deliberately fall back to the original sine voice.
|
|
827
|
-
return sine;
|
|
828
|
-
}
|
|
829
|
-
|
|
830
832
|
export function encodeWav(
|
|
831
833
|
pcm: Int16Array,
|
|
832
834
|
sampleRate: number,
|