@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
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,50 @@ 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
|
+
addReverbWet,
|
|
38
|
+
applyMonoChain,
|
|
39
|
+
applyStereoChain,
|
|
40
|
+
delayTailFor,
|
|
41
|
+
interpolateAutomation,
|
|
42
|
+
reverbActive,
|
|
43
|
+
reverbImpulse,
|
|
44
|
+
reverbTailFor,
|
|
45
|
+
reverbWet,
|
|
46
|
+
type ReverbWet,
|
|
47
|
+
} from "./effects/chain.ts";
|
|
48
|
+
import {
|
|
49
|
+
createOrbitBus,
|
|
50
|
+
renderOrbitBus,
|
|
51
|
+
sendToBus,
|
|
52
|
+
sharedOrbitOf,
|
|
53
|
+
type OrbitBus,
|
|
54
|
+
} from "./effects/bus.ts";
|
|
55
|
+
import { tableFor, wavetableOscillator } from "./wavetable.ts";
|
|
56
|
+
import {
|
|
57
|
+
duckGains,
|
|
58
|
+
duckSettings,
|
|
59
|
+
orbitOf,
|
|
60
|
+
type Ducker,
|
|
61
|
+
} from "./effects/duck.ts";
|
|
17
62
|
|
|
18
63
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
19
64
|
|
|
@@ -53,16 +98,46 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
|
53
98
|
"saw",
|
|
54
99
|
"square",
|
|
55
100
|
"triangle",
|
|
101
|
+
"wavetable",
|
|
56
102
|
"kit",
|
|
103
|
+
// Synth-voice sounds (core/synth.ts), Strudel names; `synth` shapes them.
|
|
104
|
+
"sawtooth",
|
|
105
|
+
"supersaw",
|
|
106
|
+
"pulse",
|
|
107
|
+
"user",
|
|
108
|
+
"white",
|
|
109
|
+
"pink",
|
|
110
|
+
"brown",
|
|
111
|
+
"crackle",
|
|
112
|
+
// ZzFX sounds (src/audio/synth/zzfx.ts).
|
|
113
|
+
"z_sine",
|
|
114
|
+
"z_triangle",
|
|
115
|
+
"z_sawtooth",
|
|
116
|
+
"z_square",
|
|
117
|
+
"z_tan",
|
|
118
|
+
"z_noise",
|
|
57
119
|
] as const);
|
|
58
120
|
|
|
59
|
-
/** Per-track effects understood by the renderer and the agent
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
121
|
+
/** Per-track effects understood by the renderer and the agent, in chain
|
|
122
|
+
* order, with each effect's simple parameters (`fx` takes the rest). */
|
|
123
|
+
export const AVAILABLE_EFFECTS: readonly string[] = Object.freeze([
|
|
124
|
+
"pan(-1..1 equal-power)",
|
|
125
|
+
...EFFECT_NAMES.map((name) => {
|
|
126
|
+
const spec = effectSpec(name);
|
|
127
|
+
return `${name}(${spec.simple.join(" ")})`;
|
|
128
|
+
}),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
/** `fx <effect> preset <name>` names, for the agent brief. */
|
|
132
|
+
export const AVAILABLE_FX_PRESETS: Readonly<Record<string, string>> =
|
|
133
|
+
Object.freeze(
|
|
134
|
+
Object.fromEntries(
|
|
135
|
+
EFFECT_NAMES.filter((name) => FX_PRESETS[name]).map((name) => [
|
|
136
|
+
name,
|
|
137
|
+
Object.keys(FX_PRESETS[name]!).join(" "),
|
|
138
|
+
]),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
66
141
|
|
|
67
142
|
/** Longest drum one-shot, in seconds; hits ring past their note length. */
|
|
68
143
|
const MAX_DRUM_SECONDS = 0.6;
|
|
@@ -78,6 +153,8 @@ type RenderContext = Readonly<{
|
|
|
78
153
|
sampleRate: number;
|
|
79
154
|
samples: number;
|
|
80
155
|
samplesPerTick: number;
|
|
156
|
+
tempoBpm: number;
|
|
157
|
+
irs?: ReadonlyMap<string, DecodedSample>;
|
|
81
158
|
}>;
|
|
82
159
|
|
|
83
160
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
@@ -120,8 +197,16 @@ type Stem = {
|
|
|
120
197
|
key: string;
|
|
121
198
|
left: Float64Array;
|
|
122
199
|
right: Float64Array;
|
|
200
|
+
/** Stem, pre-reverb pair and tail (counted in the cache budget). */
|
|
123
201
|
bytes: number;
|
|
124
202
|
used: number;
|
|
203
|
+
/** For a track with a reverb: its stem before the reverb, and the tail. */
|
|
204
|
+
room?: Readonly<{
|
|
205
|
+
/** `stemKey` of the track with its reverb mix and mix lane left out. */
|
|
206
|
+
key: string;
|
|
207
|
+
pre: Readonly<{ left: Float64Array; right: Float64Array }>;
|
|
208
|
+
wet: ReverbWet;
|
|
209
|
+
}>;
|
|
125
210
|
};
|
|
126
211
|
|
|
127
212
|
/**
|
|
@@ -136,16 +221,23 @@ type Stem = {
|
|
|
136
221
|
export class StemRenderer {
|
|
137
222
|
private readonly maxCacheBytes: number;
|
|
138
223
|
private readonly stems = new Map<string, Stem>();
|
|
224
|
+
/** Shared orbit bus outputs by orbit, keyed by their members' stems. */
|
|
225
|
+
private readonly buses = new Map<
|
|
226
|
+
number,
|
|
227
|
+
{ key: string; left: Float64Array; right: Float64Array }
|
|
228
|
+
>();
|
|
139
229
|
private cacheBytes = 0;
|
|
140
230
|
private renders = 0;
|
|
141
231
|
private scratch: {
|
|
142
232
|
dry: Float64Array;
|
|
233
|
+
dryR: Float64Array;
|
|
143
234
|
left: Float64Array;
|
|
144
235
|
right: Float64Array;
|
|
145
236
|
mixL: Float64Array;
|
|
146
237
|
mixR: Float64Array;
|
|
147
238
|
} = {
|
|
148
239
|
dry: new Float64Array(0),
|
|
240
|
+
dryR: new Float64Array(0),
|
|
149
241
|
left: new Float64Array(0),
|
|
150
242
|
right: new Float64Array(0),
|
|
151
243
|
mixL: new Float64Array(0),
|
|
@@ -168,17 +260,21 @@ export class StemRenderer {
|
|
|
168
260
|
const sampleRate = clampSampleRate(options.sampleRate);
|
|
169
261
|
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
170
262
|
const loopSeconds = (score.bars * score.beatsPerBar * 60) / score.tempoBpm;
|
|
263
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
171
264
|
const reverbTail = Math.max(
|
|
172
265
|
0,
|
|
173
266
|
...score.tracks.map((track) =>
|
|
174
|
-
track
|
|
267
|
+
reverbTailFor(track, sampleRate, bank.irs),
|
|
175
268
|
),
|
|
176
269
|
);
|
|
177
|
-
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
178
270
|
// Zero without sampler voices, so synth-only renders are unchanged.
|
|
271
|
+
// Zero without synth-voice tracks, so legacy renders are unchanged.
|
|
179
272
|
const samplerTail = Math.min(
|
|
180
273
|
MAX_LOOP_TAIL_SECONDS,
|
|
181
|
-
|
|
274
|
+
Math.max(
|
|
275
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
276
|
+
...score.tracks.map(synthTailSeconds),
|
|
277
|
+
),
|
|
182
278
|
);
|
|
183
279
|
let frames: number;
|
|
184
280
|
let samples: number;
|
|
@@ -192,10 +288,13 @@ export class StemRenderer {
|
|
|
192
288
|
);
|
|
193
289
|
const tailSeconds = Math.min(
|
|
194
290
|
MAX_LOOP_TAIL_SECONDS,
|
|
195
|
-
Math.max(MAX_DRUM_SECONDS, samplerTail) +
|
|
291
|
+
Math.max(MAX_DRUM_SECONDS, samplerTail, kitTailSeconds(score)) +
|
|
196
292
|
0.1 +
|
|
197
293
|
reverbTail +
|
|
198
|
-
|
|
294
|
+
Math.max(
|
|
295
|
+
0,
|
|
296
|
+
...score.tracks.map((track) => delayTailFor(track, score.tempoBpm)),
|
|
297
|
+
),
|
|
199
298
|
);
|
|
200
299
|
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
201
300
|
} else {
|
|
@@ -211,9 +310,11 @@ export class StemRenderer {
|
|
|
211
310
|
sampleRate,
|
|
212
311
|
samples,
|
|
213
312
|
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
313
|
+
tempoBpm: score.tempoBpm,
|
|
314
|
+
...(bank.irs ? { irs: bank.irs } : {}),
|
|
214
315
|
};
|
|
215
316
|
this.renders += 1;
|
|
216
|
-
const { dry, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
317
|
+
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
217
318
|
mixL.fill(0);
|
|
218
319
|
mixR.fill(0);
|
|
219
320
|
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
@@ -223,17 +324,77 @@ export class StemRenderer {
|
|
|
223
324
|
if (group) group.push(note);
|
|
224
325
|
else groups.set(note.trackId, [note]);
|
|
225
326
|
}
|
|
327
|
+
// Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
|
|
328
|
+
const duckers: Ducker[] = [];
|
|
329
|
+
for (const [trackId, notes] of groups) {
|
|
330
|
+
const settings = duckSettings(tracks.get(trackId));
|
|
331
|
+
if (settings && isTrackAudible(score, trackId))
|
|
332
|
+
duckers.push({
|
|
333
|
+
trackId,
|
|
334
|
+
...settings,
|
|
335
|
+
onsets: notes.map((note) => noteSpan(note, context).start),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
const ducked = duckGains(
|
|
339
|
+
[...groups.keys()].map((id) => ({ id, orbit: orbitOf(tracks.get(id)) })),
|
|
340
|
+
duckers,
|
|
341
|
+
samples,
|
|
342
|
+
sampleRate,
|
|
343
|
+
options.loop ? frames : undefined,
|
|
344
|
+
);
|
|
345
|
+
// Shared orbit buses (src/audio/effects/bus.ts), created on first send.
|
|
346
|
+
const buses = new Map<number, OrbitBus>();
|
|
226
347
|
// Tracks render one at a time; the sum order (first note per track) is
|
|
227
348
|
// part of the output, so cached and cold renders keep it.
|
|
228
349
|
for (const [trackId, notes] of groups) {
|
|
229
350
|
if (!isTrackAudible(score, trackId)) continue;
|
|
230
351
|
const track = tracks.get(trackId);
|
|
352
|
+
const busOrbit = sharedOrbitOf(track);
|
|
231
353
|
const sampler = isSamplerInstrument(track?.instrument);
|
|
232
|
-
|
|
354
|
+
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
355
|
+
// table id joins the stem key), undefined for every other instrument.
|
|
356
|
+
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
357
|
+
const key = stemKey(
|
|
358
|
+
track,
|
|
359
|
+
notes,
|
|
360
|
+
context,
|
|
361
|
+
sampler ? bank : undefined,
|
|
362
|
+
wavetable?.id,
|
|
363
|
+
track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
|
|
364
|
+
);
|
|
233
365
|
let stem = this.stems.get(trackId);
|
|
366
|
+
const caching = this.maxCacheBytes > 0;
|
|
367
|
+
// A cached track with a reverb also keeps its pre-reverb pair and the
|
|
368
|
+
// tail before its mix gain, keyed without the mix: a mix-only edit
|
|
369
|
+
// re-adds the tail instead of re-running the voices and the room.
|
|
370
|
+
const room =
|
|
371
|
+
caching && track && busOrbit === undefined && reverbActive(track)
|
|
372
|
+
? stemKey(
|
|
373
|
+
roomTrack(track),
|
|
374
|
+
notes,
|
|
375
|
+
context,
|
|
376
|
+
sampler ? bank : undefined,
|
|
377
|
+
wavetable?.id,
|
|
378
|
+
reverbImpulse(track, sampleRate, bank.irs)?.id,
|
|
379
|
+
)
|
|
380
|
+
: undefined;
|
|
234
381
|
if (stem?.key === key) stem.used = this.renders;
|
|
235
|
-
else {
|
|
236
|
-
const
|
|
382
|
+
else if (track && room !== undefined && stem?.room?.key === room) {
|
|
383
|
+
const { pre, wet } = stem.room;
|
|
384
|
+
const target = {
|
|
385
|
+
left: Float64Array.from(pre.left),
|
|
386
|
+
right: Float64Array.from(pre.right),
|
|
387
|
+
};
|
|
388
|
+
addReverbWet(target.left, target.right, wet, track, context);
|
|
389
|
+
stem = {
|
|
390
|
+
...stem,
|
|
391
|
+
key,
|
|
392
|
+
left: target.left,
|
|
393
|
+
right: target.right,
|
|
394
|
+
used: this.renders,
|
|
395
|
+
};
|
|
396
|
+
this.store(trackId, stem);
|
|
397
|
+
} else {
|
|
237
398
|
const target = caching
|
|
238
399
|
? {
|
|
239
400
|
left: new Float64Array(samples),
|
|
@@ -241,8 +402,31 @@ export class StemRenderer {
|
|
|
241
402
|
}
|
|
242
403
|
: { left, right };
|
|
243
404
|
dry.fill(0);
|
|
405
|
+
const synthVoice = !sampler && usesSynthVoice(track);
|
|
406
|
+
const stereo = synthVoice && isStereoVoice(track);
|
|
407
|
+
if (stereo) dryR.fill(0);
|
|
244
408
|
if (sampler) {
|
|
245
409
|
if (track) renderSamplerNotes(dry, notes, track, context, bank);
|
|
410
|
+
} else if (synthVoice && track) {
|
|
411
|
+
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
412
|
+
const voice = {
|
|
413
|
+
...context,
|
|
414
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
415
|
+
...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
|
|
416
|
+
};
|
|
417
|
+
for (const note of notes) {
|
|
418
|
+
const { start, length } = noteSpan(note, context);
|
|
419
|
+
renderSynthNote(
|
|
420
|
+
dry,
|
|
421
|
+
stereo ? dryR : undefined,
|
|
422
|
+
note,
|
|
423
|
+
track,
|
|
424
|
+
start,
|
|
425
|
+
length,
|
|
426
|
+
voice,
|
|
427
|
+
gainAt,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
246
430
|
} else {
|
|
247
431
|
const drums = isDrumInstrument(track?.instrument);
|
|
248
432
|
for (const note of notes) {
|
|
@@ -250,43 +434,99 @@ export class StemRenderer {
|
|
|
250
434
|
else renderToneNote(dry, note, track, context);
|
|
251
435
|
}
|
|
252
436
|
}
|
|
253
|
-
if (track)
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
437
|
+
if (track) applyMonoChain(dry, track, context);
|
|
438
|
+
if (stereo && track) applyMonoChain(dryR, track, context);
|
|
439
|
+
applyPan(
|
|
440
|
+
dry,
|
|
441
|
+
target.left,
|
|
442
|
+
target.right,
|
|
443
|
+
track,
|
|
444
|
+
context,
|
|
445
|
+
stereo ? dryR : undefined,
|
|
446
|
+
);
|
|
447
|
+
let saved: Stem["room"];
|
|
448
|
+
if (track && room !== undefined) {
|
|
449
|
+
// Same chain, the reverb split in two (see `applyReverb`).
|
|
450
|
+
applyStereoChain(
|
|
451
|
+
target.left,
|
|
452
|
+
target.right,
|
|
453
|
+
track,
|
|
454
|
+
context,
|
|
455
|
+
true,
|
|
456
|
+
false,
|
|
457
|
+
);
|
|
458
|
+
const pre = {
|
|
459
|
+
left: Float64Array.from(target.left),
|
|
460
|
+
right: Float64Array.from(target.right),
|
|
461
|
+
};
|
|
462
|
+
const wet = reverbWet(target.left, target.right, track, context);
|
|
463
|
+
if (wet) {
|
|
464
|
+
addReverbWet(target.left, target.right, wet, track, context);
|
|
465
|
+
saved = { key: room, pre, wet };
|
|
466
|
+
}
|
|
467
|
+
} else if (track)
|
|
468
|
+
applyStereoChain(
|
|
469
|
+
target.left,
|
|
470
|
+
target.right,
|
|
471
|
+
track,
|
|
472
|
+
context,
|
|
473
|
+
busOrbit === undefined,
|
|
474
|
+
);
|
|
259
475
|
stem = {
|
|
260
476
|
key,
|
|
261
477
|
left: target.left,
|
|
262
478
|
right: target.right,
|
|
263
|
-
bytes: target.left.byteLength
|
|
479
|
+
bytes: target.left.byteLength * (saved ? 6 : 2),
|
|
264
480
|
used: this.renders,
|
|
481
|
+
...(saved ? { room: saved } : {}),
|
|
265
482
|
};
|
|
266
483
|
if (caching) this.store(trackId, stem);
|
|
267
484
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
485
|
+
if (track && busOrbit !== undefined) {
|
|
486
|
+
let bus = buses.get(busOrbit);
|
|
487
|
+
if (!bus)
|
|
488
|
+
buses.set(busOrbit, (bus = createOrbitBus(busOrbit, samples)));
|
|
489
|
+
sendToBus(bus, track, stem.left, stem.right, context, key);
|
|
271
490
|
}
|
|
491
|
+
addStem(mixL, mixR, stem.left, stem.right, samples, ducked.get(trackId));
|
|
272
492
|
}
|
|
493
|
+
// Bus returns join after every stem, in orbit order; the orbit's ducking
|
|
494
|
+
// applies to them as to its tracks.
|
|
495
|
+
const busGains = duckGains(
|
|
496
|
+
[...buses.keys()].map((orbit) => ({ id: `bus:${orbit}`, orbit })),
|
|
497
|
+
duckers,
|
|
498
|
+
samples,
|
|
499
|
+
sampleRate,
|
|
500
|
+
options.loop ? frames : undefined,
|
|
501
|
+
);
|
|
502
|
+
for (const orbit of [...buses.keys()].sort((a, b) => a - b)) {
|
|
503
|
+
const bus = buses.get(orbit)!;
|
|
504
|
+
const key = JSON.stringify(bus.keys);
|
|
505
|
+
let wet = this.buses.get(orbit);
|
|
506
|
+
if (wet?.key !== key) {
|
|
507
|
+
wet = { key, ...renderOrbitBus(bus, context) };
|
|
508
|
+
if (this.maxCacheBytes > 0) this.buses.set(orbit, wet);
|
|
509
|
+
}
|
|
510
|
+
// (x * 1 === x, so an unducked bus adds its plain samples.)
|
|
511
|
+
addStem(
|
|
512
|
+
mixL,
|
|
513
|
+
mixR,
|
|
514
|
+
wet.left,
|
|
515
|
+
wet.right,
|
|
516
|
+
samples,
|
|
517
|
+
busGains.get(`bus:${orbit}`),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
for (const orbit of [...this.buses.keys()])
|
|
521
|
+
if (!buses.has(orbit)) this.buses.delete(orbit);
|
|
273
522
|
// Stems of tracks that left the score are not worth keeping; muted and
|
|
274
523
|
// unsoloed tracks keep theirs so toggling them back is free.
|
|
275
524
|
for (const trackId of [...this.stems.keys()])
|
|
276
525
|
if (!groups.has(trackId)) this.evict(trackId);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
mixL[index % frames]! += mixL[index]!;
|
|
282
|
-
mixR[index % frames]! += mixR[index]!;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
286
|
-
for (let index = 0; index < frames; index += 1) {
|
|
287
|
-
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
288
|
-
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
289
|
-
}
|
|
526
|
+
// Linear effects superpose, so folding the tail onto the start yields
|
|
527
|
+
// the steady state of the loop playing forever.
|
|
528
|
+
if (samples > frames) foldTail(mixL, mixR, frames, samples);
|
|
529
|
+
const pcm = toPcm(mixL, mixR, frames);
|
|
290
530
|
return Object.freeze({
|
|
291
531
|
sampleRate,
|
|
292
532
|
channels: RENDER_CHANNELS,
|
|
@@ -299,6 +539,7 @@ export class StemRenderer {
|
|
|
299
539
|
if (this.scratch.dry.length < samples) {
|
|
300
540
|
this.scratch = {
|
|
301
541
|
dry: new Float64Array(samples),
|
|
542
|
+
dryR: new Float64Array(samples),
|
|
302
543
|
left: new Float64Array(samples),
|
|
303
544
|
right: new Float64Array(samples),
|
|
304
545
|
mixL: new Float64Array(samples),
|
|
@@ -308,6 +549,7 @@ export class StemRenderer {
|
|
|
308
549
|
const view = (buffer: Float64Array) => buffer.subarray(0, samples);
|
|
309
550
|
return {
|
|
310
551
|
dry: view(this.scratch.dry),
|
|
552
|
+
dryR: view(this.scratch.dryR),
|
|
311
553
|
left: view(this.scratch.left),
|
|
312
554
|
right: view(this.scratch.right),
|
|
313
555
|
mixL: view(this.scratch.mixL),
|
|
@@ -347,12 +589,82 @@ export class StemRenderer {
|
|
|
347
589
|
}
|
|
348
590
|
}
|
|
349
591
|
|
|
592
|
+
/*
|
|
593
|
+
* The mix loops live in small functions of their own: inside `render` (a
|
|
594
|
+
* large function the JIT optimizes late) they ran several times slower.
|
|
595
|
+
* Same operations in the same order, so the output is unchanged.
|
|
596
|
+
*/
|
|
597
|
+
|
|
598
|
+
/** Add a stem (times its ducking gain, if any) into the mix. */
|
|
599
|
+
function addStem(
|
|
600
|
+
mixL: Float64Array,
|
|
601
|
+
mixR: Float64Array,
|
|
602
|
+
left: Float64Array,
|
|
603
|
+
right: Float64Array,
|
|
604
|
+
samples: number,
|
|
605
|
+
gain: Float64Array | undefined,
|
|
606
|
+
): void {
|
|
607
|
+
if (gain)
|
|
608
|
+
for (let index = 0; index < samples; index += 1) {
|
|
609
|
+
mixL[index]! += left[index]! * gain[index]!;
|
|
610
|
+
mixR[index]! += right[index]! * gain[index]!;
|
|
611
|
+
}
|
|
612
|
+
else
|
|
613
|
+
for (let index = 0; index < samples; index += 1) {
|
|
614
|
+
mixL[index]! += left[index]!;
|
|
615
|
+
mixR[index]! += right[index]!;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Fold everything past `frames` back onto the loop start. */
|
|
620
|
+
function foldTail(
|
|
621
|
+
mixL: Float64Array,
|
|
622
|
+
mixR: Float64Array,
|
|
623
|
+
frames: number,
|
|
624
|
+
samples: number,
|
|
625
|
+
): void {
|
|
626
|
+
for (let index = frames; index < samples; index += 1) {
|
|
627
|
+
mixL[index % frames]! += mixL[index]!;
|
|
628
|
+
mixR[index % frames]! += mixR[index]!;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Interleaved 16-bit PCM of the first `frames` mix samples. */
|
|
633
|
+
function toPcm(
|
|
634
|
+
mixL: Float64Array,
|
|
635
|
+
mixR: Float64Array,
|
|
636
|
+
frames: number,
|
|
637
|
+
): Int16Array {
|
|
638
|
+
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
639
|
+
for (let index = 0; index < frames; index += 1) {
|
|
640
|
+
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
641
|
+
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
642
|
+
}
|
|
643
|
+
return pcm;
|
|
644
|
+
}
|
|
645
|
+
|
|
350
646
|
/** Everything a track's stem depends on, except mute and solo. */
|
|
647
|
+
/**
|
|
648
|
+
* The track as its reverb tail sees it: everything but the reverb's mix
|
|
649
|
+
* and mix lane, which only scale the tail.
|
|
650
|
+
*/
|
|
651
|
+
function roomTrack(track: Track): Track {
|
|
652
|
+
const lanes = { ...track.fxAutomation };
|
|
653
|
+
delete lanes["reverb-mix"];
|
|
654
|
+
return {
|
|
655
|
+
...track,
|
|
656
|
+
reverb: { ...track.reverb!, mix: 1 },
|
|
657
|
+
fxAutomation: lanes,
|
|
658
|
+
} as Track;
|
|
659
|
+
}
|
|
660
|
+
|
|
351
661
|
function stemKey(
|
|
352
662
|
track: Track | undefined,
|
|
353
663
|
notes: readonly Note[],
|
|
354
664
|
context: RenderContext,
|
|
355
665
|
bank?: SampleBank,
|
|
666
|
+
wavetableId?: string,
|
|
667
|
+
impulseId?: string,
|
|
356
668
|
): string {
|
|
357
669
|
let settings: Record<string, unknown> | null = null;
|
|
358
670
|
if (track) {
|
|
@@ -364,11 +676,16 @@ function stemKey(
|
|
|
364
676
|
notes,
|
|
365
677
|
context.score.tempoBpm,
|
|
366
678
|
context.score.ticksPerBeat,
|
|
679
|
+
// Bar length: sampler `unit: "c"` stretches a window to whole bars.
|
|
680
|
+
context.score.beatsPerBar,
|
|
367
681
|
context.sampleRate,
|
|
368
682
|
context.samples,
|
|
369
683
|
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
370
684
|
// sample changes the key even when the score did not change.
|
|
371
685
|
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
686
|
+
...(wavetableId ? [wavetableId] : []),
|
|
687
|
+
// A convolution reverb's impulse (its sha256 for a sample).
|
|
688
|
+
...(impulseId ? [impulseId] : []),
|
|
372
689
|
]);
|
|
373
690
|
}
|
|
374
691
|
|
|
@@ -400,27 +717,6 @@ function renderSamplerNotes(
|
|
|
400
717
|
);
|
|
401
718
|
}
|
|
402
719
|
|
|
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
720
|
/** Track volume and volume automation at a score tick (pan is applied in stereo). */
|
|
425
721
|
function trackGainAt(track: Track | undefined, tick: number): number {
|
|
426
722
|
const trackGain = Math.max(0, Math.min(1, track?.volume ?? 1));
|
|
@@ -477,8 +773,7 @@ function renderToneNote(
|
|
|
477
773
|
0.28 *
|
|
478
774
|
trackGainAt(track, note.startTick + elapsed / samplesPerTick);
|
|
479
775
|
const phase = (frequency * elapsed) / sampleRate;
|
|
480
|
-
target[index]! +=
|
|
481
|
-
synthSample(instrument, phase, frequency, sampleRate) * envelope;
|
|
776
|
+
target[index]! += legacyWave(instrument, phase) * envelope;
|
|
482
777
|
}
|
|
483
778
|
}
|
|
484
779
|
|
|
@@ -495,9 +790,13 @@ function renderDrumNote(
|
|
|
495
790
|
): void {
|
|
496
791
|
const { sampleRate, samples, samplesPerTick } = context;
|
|
497
792
|
const { start } = noteSpan(note, context);
|
|
793
|
+
// A track `kit` swaps in a synthesized kit (src/audio/kits.ts); without
|
|
794
|
+
// one the voices below render exactly as they always have.
|
|
795
|
+
const kit = synthKit(track?.kit);
|
|
796
|
+
const kitState = kit ? newKitVoiceState() : undefined;
|
|
498
797
|
const end = Math.min(
|
|
499
798
|
samples,
|
|
500
|
-
start + Math.ceil(MAX_DRUM_SECONDS * sampleRate),
|
|
799
|
+
start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
|
|
501
800
|
);
|
|
502
801
|
const voice = drumVoiceForPitch(note.pitch);
|
|
503
802
|
const random = seededRandom(`${note.id}:${note.startTick}`);
|
|
@@ -512,7 +811,17 @@ function renderDrumNote(
|
|
|
512
811
|
const bright = noise - previousNoise;
|
|
513
812
|
previousNoise = noise;
|
|
514
813
|
let sample = 0;
|
|
515
|
-
if (
|
|
814
|
+
if (kit && kitState)
|
|
815
|
+
sample = kitDrumSample(
|
|
816
|
+
kit,
|
|
817
|
+
voice,
|
|
818
|
+
t,
|
|
819
|
+
noise,
|
|
820
|
+
bright,
|
|
821
|
+
kitState,
|
|
822
|
+
sampleRate,
|
|
823
|
+
);
|
|
824
|
+
else if (voice === "kick") {
|
|
516
825
|
const frequency = 45 + 105 * Math.exp(-t * 28);
|
|
517
826
|
phase += frequency / sampleRate;
|
|
518
827
|
sample =
|
|
@@ -560,64 +869,6 @@ function renderDrumNote(
|
|
|
560
869
|
}
|
|
561
870
|
}
|
|
562
871
|
|
|
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
872
|
/**
|
|
622
873
|
* Equal-power pan of the mono track into the stereo pair: left = cos(θ),
|
|
623
874
|
* right = sin(θ) with θ = (pan + 1)·π/4, so the summed power is constant
|
|
@@ -629,6 +880,7 @@ function applyPan(
|
|
|
629
880
|
right: Float64Array,
|
|
630
881
|
track: Track | undefined,
|
|
631
882
|
context: RenderContext,
|
|
883
|
+
dryRight?: Float64Array,
|
|
632
884
|
): void {
|
|
633
885
|
const lane = track?.panAutomation ?? [];
|
|
634
886
|
const staticPan = track?.pan ?? 0;
|
|
@@ -644,189 +896,53 @@ function applyPan(
|
|
|
644
896
|
);
|
|
645
897
|
const sample = dry[index]!;
|
|
646
898
|
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;
|
|
899
|
+
// A stereo voice pans its own right channel the same way.
|
|
900
|
+
right[index] = (dryRight ? dryRight[index]! : sample) * gainR;
|
|
692
901
|
}
|
|
693
902
|
}
|
|
694
903
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
const REVERB_WET_SCALE = 3;
|
|
904
|
+
type WavetableHook = Readonly<{
|
|
905
|
+
/** Joins the stem key: a different or missing table re-renders. */
|
|
906
|
+
id: string;
|
|
907
|
+
oscillatorFor: NonNullable<VoiceContext["oscillatorFor"]>;
|
|
908
|
+
}>;
|
|
701
909
|
|
|
702
910
|
/**
|
|
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`.
|
|
911
|
+
* Wavetable hook: for a wavetable track, each note's oscillator reads the
|
|
912
|
+
* track's table (`src/audio/wavetable.ts`); the synth voice does the rest.
|
|
707
913
|
*/
|
|
708
|
-
function
|
|
709
|
-
left: Float64Array,
|
|
710
|
-
right: Float64Array,
|
|
914
|
+
function wavetableHook(
|
|
711
915
|
track: Track,
|
|
916
|
+
bank: SampleBank,
|
|
712
917
|
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;
|
|
918
|
+
): WavetableHook | undefined {
|
|
919
|
+
if (!isWavetableInstrument(track.instrument)) return undefined;
|
|
920
|
+
const settings = wavetableOf(track);
|
|
921
|
+
const table = tableFor(track, settings, bank.wavetables);
|
|
922
|
+
// A pack table that failed to load renders silent (the problem is reported).
|
|
923
|
+
if (!table)
|
|
924
|
+
return {
|
|
925
|
+
id: `missing:${settings.table.src}`,
|
|
926
|
+
oscillatorFor: () => () => () => 0,
|
|
927
|
+
};
|
|
928
|
+
const lane = track.wtAutomation ?? [];
|
|
929
|
+
const positionAt =
|
|
930
|
+
lane.length > 0
|
|
931
|
+
? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
|
|
932
|
+
: undefined;
|
|
933
|
+
return {
|
|
934
|
+
id: table.id,
|
|
935
|
+
oscillatorFor: (note) =>
|
|
936
|
+
wavetableOscillator(table, settings, note, {
|
|
937
|
+
sampleRate: context.sampleRate,
|
|
938
|
+
samplesPerTick: context.samplesPerTick,
|
|
939
|
+
secondsPerTick: context.samplesPerTick / context.sampleRate,
|
|
940
|
+
gate: noteSpan(note, context).length / context.sampleRate,
|
|
941
|
+
positionAt,
|
|
942
|
+
}),
|
|
774
943
|
};
|
|
775
944
|
}
|
|
776
945
|
|
|
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
946
|
export function encodeWav(
|
|
831
947
|
pcm: Int16Array,
|
|
832
948
|
sampleRate: number,
|