@hraness/dawg 0.4.1 → 0.6.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 +99 -0
- package/DAWG.md +551 -33
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +41 -12
- package/core/expression.ts +1241 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/resonators.ts +574 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +717 -62
- package/core/sdk/eval-child.ts +40 -3
- package/core/sdk/print.ts +614 -27
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2824 -45
- package/core/sections.ts +2072 -0
- package/core/strings.ts +845 -0
- package/core/synth.ts +11 -1
- package/core/tempo.ts +1318 -0
- package/core/tuning.ts +1180 -0
- package/guides/audition.md +26 -0
- package/guides/automation.md +26 -0
- package/guides/chords.md +28 -0
- package/guides/effects.md +29 -0
- package/guides/faders.md +29 -0
- package/guides/files.md +28 -0
- package/guides/getting-started.md +26 -0
- package/guides/index.ts +75 -0
- package/guides/keys.md +27 -0
- package/guides/media.md +27 -0
- package/guides/mix.md +23 -0
- package/guides/music.md +14 -0
- package/guides/notes.md +26 -0
- package/guides/performance.md +26 -0
- package/guides/play.md +24 -0
- package/guides/project.md +13 -0
- package/guides/providers.md +25 -0
- package/guides/rhythm.md +26 -0
- package/guides/sessions.md +21 -0
- package/guides/sound.md +15 -0
- package/guides/sounds.md +29 -0
- package/guides/tempo.md +27 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +9 -0
- package/src/agent/brief.ts +77 -3
- package/src/agent/chord-tools.ts +9 -1
- package/src/agent/expression-tools.ts +336 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +68 -6
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +27 -4
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +574 -16
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +9 -0
- package/src/audio/arrange.ts +489 -0
- package/src/audio/audition.ts +16 -3
- package/src/audio/click.ts +113 -1
- package/src/audio/clock.ts +71 -5
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +6 -1
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/dynamics.ts +2 -1
- package/src/audio/effects/filter.ts +5 -3
- package/src/audio/effects/modulation.ts +5 -1
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/effects/space.ts +92 -2
- package/src/audio/engine.ts +100 -21
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +263 -17
- package/src/audio/loudness.ts +596 -0
- package/src/audio/master.ts +660 -0
- package/src/audio/measure-worker.ts +45 -0
- package/src/audio/measure.ts +110 -0
- package/src/audio/player.ts +11 -6
- package/src/audio/preview.ts +76 -14
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +269 -34
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +147 -0
- package/src/audio/wav.ts +377 -30
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +941 -0
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +355 -27
- package/src/commands/keys.ts +353 -0
- package/src/commands/master.ts +361 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +967 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +919 -41
- package/src/project/check.ts +13 -0
- package/src/render.ts +139 -8
- package/src/session/daemon.ts +18 -8
- package/src/session/naming.ts +8 -1
- package/src/session/rebase.ts +21 -0
- package/src/tui/arrange-menu.ts +390 -0
- package/src/tui/audition.ts +38 -5
- package/src/tui/fader.ts +409 -0
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +967 -23
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +3 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +459 -43
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +63 -1
- package/tui/guide.ts +351 -0
- package/tui/highway.ts +87 -4
- package/tui/hits.ts +68 -0
- package/tui/input.ts +7 -0
- package/tui/keys.ts +72 -0
package/src/audio/wav.ts
CHANGED
|
@@ -10,6 +10,12 @@ import {
|
|
|
10
10
|
type TrackScore,
|
|
11
11
|
} from "../../core/score.ts";
|
|
12
12
|
import { drumVoiceForPitch, isDrumInstrument } from "../../core/drums.ts";
|
|
13
|
+
import {
|
|
14
|
+
performanceTimingFor,
|
|
15
|
+
tunedTiming,
|
|
16
|
+
performNotes,
|
|
17
|
+
type PerformedNote,
|
|
18
|
+
} from "../../core/expression.ts";
|
|
13
19
|
import {
|
|
14
20
|
planSamplerVoices,
|
|
15
21
|
renderSamplerVoices,
|
|
@@ -17,6 +23,7 @@ import {
|
|
|
17
23
|
} from "./sampler.ts";
|
|
18
24
|
import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
|
|
19
25
|
import { seededRandom } from "./random.ts";
|
|
26
|
+
import { engineFor, engineTailSeconds } from "./instruments.ts";
|
|
20
27
|
import {
|
|
21
28
|
isStereoVoice,
|
|
22
29
|
renderSynthNote,
|
|
@@ -53,12 +60,21 @@ import {
|
|
|
53
60
|
type OrbitBus,
|
|
54
61
|
} from "./effects/bus.ts";
|
|
55
62
|
import { tableFor, wavetableOscillator } from "./wavetable.ts";
|
|
63
|
+
import { noteHz, resolveTuning, type TuningTable } from "../../core/tuning.ts";
|
|
56
64
|
import {
|
|
57
65
|
duckGains,
|
|
58
66
|
duckSettings,
|
|
59
67
|
orbitOf,
|
|
60
68
|
type Ducker,
|
|
61
69
|
} from "./effects/duck.ts";
|
|
70
|
+
import {
|
|
71
|
+
loopSecondsOf,
|
|
72
|
+
performedNotes,
|
|
73
|
+
slowestBpmOf,
|
|
74
|
+
} from "../../core/tempo.ts";
|
|
75
|
+
import { sampleWarpFor, warpedSpan, type SampleWarp } from "./warp.ts";
|
|
76
|
+
import { applyMaster, type MasterReport } from "./master.ts";
|
|
77
|
+
import type { SongMaster } from "../../core/master.ts";
|
|
62
78
|
|
|
63
79
|
export type WavOptions = Readonly<{ sampleRate?: number; maxSeconds?: number }>;
|
|
64
80
|
|
|
@@ -75,6 +91,12 @@ export type RenderOptions = WavOptions &
|
|
|
75
91
|
* tracks render silent; scores without samplers are unaffected.
|
|
76
92
|
*/
|
|
77
93
|
samples?: SampleBank;
|
|
94
|
+
/**
|
|
95
|
+
* Ticks added to each note's start when seeding its noise. An arranged
|
|
96
|
+
* render window passes its origin so seeded voices sound as they do in
|
|
97
|
+
* a single pass of the whole song. Zero (the default) changes nothing.
|
|
98
|
+
*/
|
|
99
|
+
seedTick?: number;
|
|
78
100
|
}>;
|
|
79
101
|
|
|
80
102
|
/** Interleaved stereo 16-bit PCM plus its frame count. */
|
|
@@ -83,6 +105,8 @@ export type RenderedAudio = Readonly<{
|
|
|
83
105
|
channels: 2;
|
|
84
106
|
frames: number;
|
|
85
107
|
pcm: Int16Array;
|
|
108
|
+
/** Loudness the song master reached; absent without a master. */
|
|
109
|
+
master?: MasterReport;
|
|
86
110
|
}>;
|
|
87
111
|
|
|
88
112
|
/** Output channel count for every render and export. */
|
|
@@ -116,6 +140,9 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
|
|
|
116
140
|
"z_square",
|
|
117
141
|
"z_tan",
|
|
118
142
|
"z_noise",
|
|
143
|
+
// 0.6 instruments (src/audio/instruments.ts), one line per lane.
|
|
144
|
+
"granular",
|
|
145
|
+
"modal",
|
|
119
146
|
] as const);
|
|
120
147
|
|
|
121
148
|
/** Per-track effects understood by the renderer and the agent, in chain
|
|
@@ -145,20 +172,58 @@ const MAX_DRUM_SECONDS = 0.6;
|
|
|
145
172
|
const CONTROL_SAMPLES = 32;
|
|
146
173
|
/** One-shot renders keep this much ring-out after the last bar. */
|
|
147
174
|
const ONE_SHOT_TAIL_SECONDS = 0.35;
|
|
175
|
+
|
|
176
|
+
function oneShotSeconds(
|
|
177
|
+
loopSeconds: number,
|
|
178
|
+
samplerTail: number,
|
|
179
|
+
reverbTail: number,
|
|
180
|
+
): number {
|
|
181
|
+
return (
|
|
182
|
+
loopSeconds + Math.max(ONE_SHOT_TAIL_SECONDS, samplerTail) + reverbTail
|
|
183
|
+
);
|
|
184
|
+
}
|
|
148
185
|
/** Loop renders fold at most this much tail back onto the loop start. */
|
|
149
186
|
const MAX_LOOP_TAIL_SECONDS = 8;
|
|
187
|
+
/** A 0.6 engine's one-shot ring-out is capped here, not at the loop fold. */
|
|
188
|
+
const MAX_ENGINE_TAIL_SECONDS = 30;
|
|
150
189
|
|
|
151
|
-
|
|
190
|
+
/**
|
|
191
|
+
* The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
|
|
192
|
+
* renders (exports, previews) let engine tails reach their own cap; the
|
|
193
|
+
* 8 s cap applies only to loop folding. Zero without an engine, so legacy
|
|
194
|
+
* renders are unchanged.
|
|
195
|
+
*/
|
|
196
|
+
function oneShotEngineTail(score: TrackScore): number {
|
|
197
|
+
let tail = 0;
|
|
198
|
+
for (const track of score.tracks) {
|
|
199
|
+
if (!engineFor(track)) continue;
|
|
200
|
+
let lowest: number | undefined;
|
|
201
|
+
for (const note of score.notes)
|
|
202
|
+
if (
|
|
203
|
+
note.trackId === track.id &&
|
|
204
|
+
(lowest === undefined || note.pitch < lowest)
|
|
205
|
+
)
|
|
206
|
+
lowest = note.pitch;
|
|
207
|
+
tail = Math.max(tail, engineTailSeconds(track, lowest));
|
|
208
|
+
}
|
|
209
|
+
return Math.min(MAX_ENGINE_TAIL_SECONDS, tail);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export type RenderContext = Readonly<{
|
|
152
213
|
score: TrackScore;
|
|
153
214
|
sampleRate: number;
|
|
154
215
|
samples: number;
|
|
155
216
|
samplesPerTick: number;
|
|
156
217
|
tempoBpm: number;
|
|
157
218
|
irs?: ReadonlyMap<string, DecodedSample>;
|
|
219
|
+
/** Tempo map in samples (core/tempo.ts); absent: constant tempo. */
|
|
220
|
+
warp?: SampleWarp;
|
|
221
|
+
seedTick?: number;
|
|
158
222
|
}>;
|
|
159
223
|
|
|
160
224
|
/** Exact (fractional) loop length in frames at a sample rate. */
|
|
161
225
|
export function loopFrames(score: TrackScore, sampleRate: number): number {
|
|
226
|
+
if (score.time) return loopSecondsOf(score) * sampleRate;
|
|
162
227
|
return (score.bars * score.beatsPerBar * 60 * sampleRate) / score.tempoBpm;
|
|
163
228
|
}
|
|
164
229
|
|
|
@@ -256,11 +321,44 @@ export class StemRenderer {
|
|
|
256
321
|
return { stems: this.stems.size, bytes: this.cacheBytes };
|
|
257
322
|
}
|
|
258
323
|
|
|
324
|
+
/**
|
|
325
|
+
* Seconds a one-shot render of `score` needs (song plus tail) before the
|
|
326
|
+
* renderer's cap, so callers can tell when a single pass would cut it.
|
|
327
|
+
*/
|
|
328
|
+
public oneShotSeconds(
|
|
329
|
+
score: TrackScore,
|
|
330
|
+
options: RenderOptions = {},
|
|
331
|
+
): number {
|
|
332
|
+
const sampleRate = clampSampleRate(options.sampleRate);
|
|
333
|
+
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
334
|
+
const reverbTail = Math.max(
|
|
335
|
+
0,
|
|
336
|
+
...score.tracks.map((track) =>
|
|
337
|
+
reverbTailFor(track, sampleRate, bank.irs),
|
|
338
|
+
),
|
|
339
|
+
);
|
|
340
|
+
const samplerTail = Math.min(
|
|
341
|
+
MAX_LOOP_TAIL_SECONDS,
|
|
342
|
+
Math.max(
|
|
343
|
+
samplerTailSeconds(score, bank, sampleRate),
|
|
344
|
+
...score.tracks.map(synthTailSeconds),
|
|
345
|
+
// Zero without a registered 0.6 instrument engine.
|
|
346
|
+
...score.tracks.map((track) => engineTailSeconds(track)),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
return oneShotSeconds(
|
|
350
|
+
loopSecondsOf(score),
|
|
351
|
+
Math.max(samplerTail, oneShotEngineTail(score)),
|
|
352
|
+
reverbTail,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
259
356
|
public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
|
|
260
357
|
const sampleRate = clampSampleRate(options.sampleRate);
|
|
261
358
|
const maxSeconds = Math.max(1, Math.min(60, options.maxSeconds ?? 30));
|
|
262
|
-
const loopSeconds = (score
|
|
359
|
+
const loopSeconds = loopSecondsOf(score);
|
|
263
360
|
const bank = options.samples ?? EMPTY_SAMPLE_BANK;
|
|
361
|
+
const warp = sampleWarpFor(score, sampleRate);
|
|
264
362
|
const reverbTail = Math.max(
|
|
265
363
|
0,
|
|
266
364
|
...score.tracks.map((track) =>
|
|
@@ -274,6 +372,8 @@ export class StemRenderer {
|
|
|
274
372
|
Math.max(
|
|
275
373
|
samplerTailSeconds(score, bank, sampleRate),
|
|
276
374
|
...score.tracks.map(synthTailSeconds),
|
|
375
|
+
// Zero without a registered 0.6 instrument engine.
|
|
376
|
+
...score.tracks.map((track) => engineTailSeconds(track)),
|
|
277
377
|
),
|
|
278
378
|
);
|
|
279
379
|
let frames: number;
|
|
@@ -293,14 +393,21 @@ export class StemRenderer {
|
|
|
293
393
|
reverbTail +
|
|
294
394
|
Math.max(
|
|
295
395
|
0,
|
|
296
|
-
|
|
396
|
+
// The slowest tempo in the map gives the longest echoes.
|
|
397
|
+
...score.tracks.map((track) =>
|
|
398
|
+
delayTailFor(track, slowestBpmOf(score)),
|
|
399
|
+
),
|
|
297
400
|
),
|
|
298
401
|
);
|
|
299
402
|
samples = frames + Math.ceil(tailSeconds * sampleRate);
|
|
300
403
|
} else {
|
|
301
404
|
const seconds = Math.min(
|
|
302
405
|
maxSeconds,
|
|
303
|
-
|
|
406
|
+
oneShotSeconds(
|
|
407
|
+
loopSeconds,
|
|
408
|
+
Math.max(samplerTail, oneShotEngineTail(score)),
|
|
409
|
+
reverbTail,
|
|
410
|
+
),
|
|
304
411
|
);
|
|
305
412
|
frames = Math.max(1, Math.ceil(seconds * sampleRate));
|
|
306
413
|
samples = frames;
|
|
@@ -312,6 +419,8 @@ export class StemRenderer {
|
|
|
312
419
|
samplesPerTick: (sampleRate * 60) / (score.tempoBpm * score.ticksPerBeat),
|
|
313
420
|
tempoBpm: score.tempoBpm,
|
|
314
421
|
...(bank.irs ? { irs: bank.irs } : {}),
|
|
422
|
+
...(warp ? { warp } : {}),
|
|
423
|
+
...(options.seedTick ? { seedTick: options.seedTick } : {}),
|
|
315
424
|
};
|
|
316
425
|
this.renders += 1;
|
|
317
426
|
const { dry, dryR, left, right, mixL, mixR } = this.scratchFor(samples);
|
|
@@ -319,20 +428,34 @@ export class StemRenderer {
|
|
|
319
428
|
mixR.fill(0);
|
|
320
429
|
const tracks = new Map(score.tracks.map((track) => [track.id, track]));
|
|
321
430
|
const groups = new Map<string, Note[]>();
|
|
322
|
-
|
|
431
|
+
// Track time (rate, phase, cycle) places notes on the song timeline.
|
|
432
|
+
for (const note of performedNotes(score)) {
|
|
323
433
|
const group = groups.get(note.trackId);
|
|
324
434
|
if (group) group.push(note);
|
|
325
435
|
else groups.set(note.trackId, [note]);
|
|
326
436
|
}
|
|
437
|
+
// Note expression and track performance (core/expression.ts): the
|
|
438
|
+
// notes as played. A track with neither gets its notes back unchanged.
|
|
439
|
+
const timing = performanceTimingFor(score);
|
|
440
|
+
const performed = new Map<string, readonly PerformedNote[]>();
|
|
441
|
+
for (const [trackId, notes] of groups) {
|
|
442
|
+
const track = tracks.get(trackId);
|
|
443
|
+
performed.set(
|
|
444
|
+
trackId,
|
|
445
|
+
performNotes(track, notes, tunedTiming(timing, score, track)),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
327
448
|
// Orbit ducking is a gain on finished stems (src/audio/effects/duck.ts).
|
|
328
449
|
const duckers: Ducker[] = [];
|
|
329
|
-
for (const [trackId
|
|
450
|
+
for (const [trackId] of groups) {
|
|
330
451
|
const settings = duckSettings(tracks.get(trackId));
|
|
331
452
|
if (settings && isTrackAudible(score, trackId))
|
|
332
453
|
duckers.push({
|
|
333
454
|
trackId,
|
|
334
455
|
...settings,
|
|
335
|
-
onsets:
|
|
456
|
+
onsets: performed
|
|
457
|
+
.get(trackId)!
|
|
458
|
+
.map((note) => noteSpan(note, context).start),
|
|
336
459
|
});
|
|
337
460
|
}
|
|
338
461
|
const ducked = duckGains(
|
|
@@ -349,11 +472,28 @@ export class StemRenderer {
|
|
|
349
472
|
for (const [trackId, notes] of groups) {
|
|
350
473
|
if (!isTrackAudible(score, trackId)) continue;
|
|
351
474
|
const track = tracks.get(trackId);
|
|
475
|
+
// Stems key on the written notes (with the track's performance
|
|
476
|
+
// settings); the voices render the performed ones.
|
|
477
|
+
const played = performed.get(trackId)!;
|
|
352
478
|
const busOrbit = sharedOrbitOf(track);
|
|
353
479
|
const sampler = isSamplerInstrument(track?.instrument);
|
|
480
|
+
// A 0.6 instrument engine (src/audio/instruments.ts), if registered
|
|
481
|
+
// and the track carries its field; undefined keeps today's voices.
|
|
482
|
+
const engine = engineFor(track);
|
|
483
|
+
// The song key always joins an engine's stem key: engines may tune to
|
|
484
|
+
// it (sympathetic strings) without a song tuning.
|
|
485
|
+
const engineDigests =
|
|
486
|
+
engine && track
|
|
487
|
+
? [
|
|
488
|
+
...(engine.assetDigests?.(track, bank) ?? []),
|
|
489
|
+
`key:${score.key ?? ""}`,
|
|
490
|
+
]
|
|
491
|
+
: undefined;
|
|
354
492
|
// Wavetable hook: the oscillator factory for a wavetable track (its
|
|
355
493
|
// table id joins the stem key), undefined for every other instrument.
|
|
356
494
|
const wavetable = track ? wavetableHook(track, bank, context) : undefined;
|
|
495
|
+
// The merged song and track tuning; undefined keeps 12-TET untouched.
|
|
496
|
+
const tuning = resolveTuning(score.tuning, track?.tuning, score.key);
|
|
357
497
|
const key = stemKey(
|
|
358
498
|
track,
|
|
359
499
|
notes,
|
|
@@ -361,6 +501,7 @@ export class StemRenderer {
|
|
|
361
501
|
sampler ? bank : undefined,
|
|
362
502
|
wavetable?.id,
|
|
363
503
|
track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
|
|
504
|
+
engineDigests,
|
|
364
505
|
);
|
|
365
506
|
let stem = this.stems.get(trackId);
|
|
366
507
|
const caching = this.maxCacheBytes > 0;
|
|
@@ -376,6 +517,7 @@ export class StemRenderer {
|
|
|
376
517
|
sampler ? bank : undefined,
|
|
377
518
|
wavetable?.id,
|
|
378
519
|
reverbImpulse(track, sampleRate, bank.irs)?.id,
|
|
520
|
+
engineDigests,
|
|
379
521
|
)
|
|
380
522
|
: undefined;
|
|
381
523
|
if (stem?.key === key) stem.used = this.renders;
|
|
@@ -402,20 +544,47 @@ export class StemRenderer {
|
|
|
402
544
|
}
|
|
403
545
|
: { left, right };
|
|
404
546
|
dry.fill(0);
|
|
405
|
-
const synthVoice = !sampler && usesSynthVoice(track);
|
|
406
|
-
const stereo =
|
|
547
|
+
const synthVoice = !engine && !sampler && usesSynthVoice(track);
|
|
548
|
+
const stereo = engine
|
|
549
|
+
? engine.stereo(track!)
|
|
550
|
+
: synthVoice && isStereoVoice(track);
|
|
407
551
|
if (stereo) dryR.fill(0);
|
|
408
|
-
if (
|
|
409
|
-
|
|
552
|
+
if (engine && track) {
|
|
553
|
+
engine.render(
|
|
554
|
+
dry,
|
|
555
|
+
stereo ? dryR : undefined,
|
|
556
|
+
played,
|
|
557
|
+
track,
|
|
558
|
+
{
|
|
559
|
+
...context,
|
|
560
|
+
ticksPerBeat: score.ticksPerBeat,
|
|
561
|
+
...(tuning ? { tuning } : {}),
|
|
562
|
+
},
|
|
563
|
+
bank,
|
|
564
|
+
);
|
|
565
|
+
} else if (sampler) {
|
|
566
|
+
if (track) renderSamplerNotes(dry, played, track, context, bank);
|
|
410
567
|
} else if (synthVoice && track) {
|
|
411
568
|
const gainAt = (tick: number) => trackGainAt(track, tick);
|
|
412
569
|
const voice = {
|
|
413
570
|
...context,
|
|
414
571
|
ticksPerBeat: score.ticksPerBeat,
|
|
415
572
|
...(wavetable ? { oscillatorFor: wavetable.oscillatorFor } : {}),
|
|
573
|
+
...(tuning ? { tuning } : {}),
|
|
416
574
|
};
|
|
417
|
-
|
|
575
|
+
const warp = context.warp;
|
|
576
|
+
for (const note of played) {
|
|
418
577
|
const { start, length } = noteSpan(note, context);
|
|
578
|
+
// The voice asks for `startTick + elapsed / samplesPerTick`;
|
|
579
|
+
// through a tempo map that sample's tick comes from the map.
|
|
580
|
+
const noteGainAt = warp
|
|
581
|
+
? (tick: number) =>
|
|
582
|
+
gainAt(
|
|
583
|
+
warp.tick(
|
|
584
|
+
start + (tick - note.startTick) * context.samplesPerTick,
|
|
585
|
+
),
|
|
586
|
+
)
|
|
587
|
+
: gainAt;
|
|
419
588
|
renderSynthNote(
|
|
420
589
|
dry,
|
|
421
590
|
stereo ? dryR : undefined,
|
|
@@ -424,14 +593,14 @@ export class StemRenderer {
|
|
|
424
593
|
start,
|
|
425
594
|
length,
|
|
426
595
|
voice,
|
|
427
|
-
|
|
596
|
+
noteGainAt,
|
|
428
597
|
);
|
|
429
598
|
}
|
|
430
599
|
} else {
|
|
431
600
|
const drums = isDrumInstrument(track?.instrument);
|
|
432
|
-
for (const note of
|
|
601
|
+
for (const note of played) {
|
|
433
602
|
if (drums) renderDrumNote(dry, note, track, context);
|
|
434
|
-
else renderToneNote(dry, note, track, context);
|
|
603
|
+
else renderToneNote(dry, note, track, context, tuning);
|
|
435
604
|
}
|
|
436
605
|
}
|
|
437
606
|
if (track) applyMonoChain(dry, track, context);
|
|
@@ -526,12 +695,26 @@ export class StemRenderer {
|
|
|
526
695
|
// Linear effects superpose, so folding the tail onto the start yields
|
|
527
696
|
// the steady state of the loop playing forever.
|
|
528
697
|
if (samples > frames) foldTail(mixL, mixR, frames, samples);
|
|
529
|
-
|
|
698
|
+
// The song master (src/audio/master.ts); none leaves the mix untouched.
|
|
699
|
+
const mastered = applyMaster(
|
|
700
|
+
mixL,
|
|
701
|
+
mixR,
|
|
702
|
+
frames,
|
|
703
|
+
sampleRate,
|
|
704
|
+
score.master,
|
|
705
|
+
options.loop === true,
|
|
706
|
+
);
|
|
707
|
+
// A master's output is a finished level, so its reduction to 16 bits is
|
|
708
|
+
// TPDF-dithered (seeded: renders stay byte-identical run to run).
|
|
709
|
+
const pcm = mastered
|
|
710
|
+
? toPcm(mastered.left, mastered.right, frames, true)
|
|
711
|
+
: toPcm(mixL, mixR, frames);
|
|
530
712
|
return Object.freeze({
|
|
531
713
|
sampleRate,
|
|
532
714
|
channels: RENDER_CHANNELS,
|
|
533
715
|
frames,
|
|
534
716
|
pcm,
|
|
717
|
+
...(mastered ? { master: mastered.report } : {}),
|
|
535
718
|
});
|
|
536
719
|
}
|
|
537
720
|
|
|
@@ -630,12 +813,59 @@ function foldTail(
|
|
|
630
813
|
}
|
|
631
814
|
|
|
632
815
|
/** Interleaved 16-bit PCM of the first `frames` mix samples. */
|
|
816
|
+
/**
|
|
817
|
+
* The song master over an already summed 16-bit mix (an arrangement
|
|
818
|
+
* rendered in windows), so the whole song is limited and normalized as one
|
|
819
|
+
* pass is. Undefined when the master is absent or bypassed.
|
|
820
|
+
*/
|
|
821
|
+
export function masterSummedPcm(
|
|
822
|
+
pcm: Int16Array,
|
|
823
|
+
frames: number,
|
|
824
|
+
sampleRate: number,
|
|
825
|
+
master: SongMaster | undefined,
|
|
826
|
+
loop: boolean,
|
|
827
|
+
): { pcm: Int16Array; report: MasterReport } | undefined {
|
|
828
|
+
const left = new Float64Array(frames);
|
|
829
|
+
const right = new Float64Array(frames);
|
|
830
|
+
for (let index = 0; index < frames; index += 1) {
|
|
831
|
+
left[index] = pcm[index * 2]! / 32767;
|
|
832
|
+
right[index] = pcm[index * 2 + 1]! / 32767;
|
|
833
|
+
}
|
|
834
|
+
const mastered = applyMaster(left, right, frames, sampleRate, master, loop);
|
|
835
|
+
if (!mastered) return undefined;
|
|
836
|
+
return {
|
|
837
|
+
pcm: toPcm(mastered.left, mastered.right, frames, true),
|
|
838
|
+
report: mastered.report,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
633
842
|
function toPcm(
|
|
634
843
|
mixL: Float64Array,
|
|
635
844
|
mixR: Float64Array,
|
|
636
845
|
frames: number,
|
|
846
|
+
dither = false,
|
|
637
847
|
): Int16Array {
|
|
638
848
|
const pcm = new Int16Array(frames * RENDER_CHANNELS);
|
|
849
|
+
if (dither) {
|
|
850
|
+
// Triangular dither of +-1 LSB: the sum of two uniform values, from a
|
|
851
|
+
// fixed-seed xorshift so every render of a score dithers the same way.
|
|
852
|
+
let state = 0x9e3779b9;
|
|
853
|
+
const uniform = () => {
|
|
854
|
+
state ^= state << 13;
|
|
855
|
+
state ^= state >>> 17;
|
|
856
|
+
state ^= state << 5;
|
|
857
|
+
return (state >>> 0) / 4_294_967_296;
|
|
858
|
+
};
|
|
859
|
+
for (let index = 0; index < frames; index += 1) {
|
|
860
|
+
pcm[index * 2] = clamp16(
|
|
861
|
+
Math.round(mixL[index]! * 32767 + uniform() - uniform()),
|
|
862
|
+
);
|
|
863
|
+
pcm[index * 2 + 1] = clamp16(
|
|
864
|
+
Math.round(mixR[index]! * 32767 + uniform() - uniform()),
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
return pcm;
|
|
868
|
+
}
|
|
639
869
|
for (let index = 0; index < frames; index += 1) {
|
|
640
870
|
pcm[index * 2] = clamp16(mixL[index]! * 32767);
|
|
641
871
|
pcm[index * 2 + 1] = clamp16(mixR[index]! * 32767);
|
|
@@ -665,6 +895,7 @@ function stemKey(
|
|
|
665
895
|
bank?: SampleBank,
|
|
666
896
|
wavetableId?: string,
|
|
667
897
|
impulseId?: string,
|
|
898
|
+
engineDigests?: readonly string[],
|
|
668
899
|
): string {
|
|
669
900
|
let settings: Record<string, unknown> | null = null;
|
|
670
901
|
if (track) {
|
|
@@ -680,12 +911,22 @@ function stemKey(
|
|
|
680
911
|
context.score.beatsPerBar,
|
|
681
912
|
context.sampleRate,
|
|
682
913
|
context.samples,
|
|
914
|
+
// Tempo map, meter changes and fermatas move every note.
|
|
915
|
+
...(context.score.time ? [context.score.time] : []),
|
|
683
916
|
// Sampler stems also depend on the decoded files: a replaced or missing
|
|
684
917
|
// sample changes the key even when the score did not change.
|
|
685
918
|
...(bank && track?.sampler ? [samplerVoiceDigest(track, bank)] : []),
|
|
686
919
|
...(wavetableId ? [wavetableId] : []),
|
|
687
920
|
// A convolution reverb's impulse (its sha256 for a sample).
|
|
688
921
|
...(impulseId ? [impulseId] : []),
|
|
922
|
+
// Assets a 0.6 instrument engine reads (absent without an engine).
|
|
923
|
+
...(engineDigests ? [engineDigests] : []),
|
|
924
|
+
// The song tuning, and the key whose tonic is the default root.
|
|
925
|
+
...(context.score.tuning || track?.tuning
|
|
926
|
+
? [context.score.tuning ?? null, context.score.key]
|
|
927
|
+
: []),
|
|
928
|
+
// Seeded noise follows the window origin of an arranged render.
|
|
929
|
+
...(context.seedTick ? [`seed:${context.seedTick}`] : []),
|
|
689
930
|
]);
|
|
690
931
|
}
|
|
691
932
|
|
|
@@ -708,7 +949,11 @@ function renderSamplerNotes(
|
|
|
708
949
|
context: RenderContext,
|
|
709
950
|
bank: SampleBank,
|
|
710
951
|
): void {
|
|
711
|
-
const timing = {
|
|
952
|
+
const timing = {
|
|
953
|
+
score: context.score,
|
|
954
|
+
sampleRate: context.sampleRate,
|
|
955
|
+
...(context.warp ? { warp: context.warp } : {}),
|
|
956
|
+
};
|
|
712
957
|
renderSamplerVoices(
|
|
713
958
|
target,
|
|
714
959
|
planSamplerVoices(track, notes, bank, timing),
|
|
@@ -732,7 +977,8 @@ function noteSpan(
|
|
|
732
977
|
note: Note,
|
|
733
978
|
context: RenderContext,
|
|
734
979
|
): { start: number; length: number } {
|
|
735
|
-
const { score, sampleRate } = context;
|
|
980
|
+
const { score, sampleRate, warp } = context;
|
|
981
|
+
if (warp) return warpedSpan(warp, note.startTick, note.durationTicks);
|
|
736
982
|
const start = Math.max(
|
|
737
983
|
0,
|
|
738
984
|
Math.floor(
|
|
@@ -755,13 +1001,18 @@ function renderToneNote(
|
|
|
755
1001
|
note: Note,
|
|
756
1002
|
track: Track | undefined,
|
|
757
1003
|
context: RenderContext,
|
|
1004
|
+
tuning?: TuningTable,
|
|
758
1005
|
): void {
|
|
759
1006
|
const { sampleRate, samples, samplesPerTick } = context;
|
|
760
1007
|
const instrument = track?.instrument ?? "sine";
|
|
761
1008
|
const { start, length } = noteSpan(note, context);
|
|
762
1009
|
const end = Math.min(samples, start + length);
|
|
763
|
-
const frequency =
|
|
1010
|
+
const frequency = noteHz(note.pitch, note.cents, tuning);
|
|
1011
|
+
if (!(frequency > 0)) return;
|
|
764
1012
|
const velocity = Math.max(0, Math.min(1, note.velocity));
|
|
1013
|
+
const performance = (note as PerformedNote).performance;
|
|
1014
|
+
const cents = performance?.cents;
|
|
1015
|
+
let bent = 0;
|
|
765
1016
|
for (let index = start; index < end; index += 1) {
|
|
766
1017
|
const elapsed = index - start;
|
|
767
1018
|
const remaining = end - index;
|
|
@@ -771,9 +1022,24 @@ function renderToneNote(
|
|
|
771
1022
|
Math.min(attack, release) *
|
|
772
1023
|
velocity *
|
|
773
1024
|
0.28 *
|
|
774
|
-
trackGainAt(
|
|
775
|
-
|
|
776
|
-
|
|
1025
|
+
trackGainAt(
|
|
1026
|
+
track,
|
|
1027
|
+
context.warp
|
|
1028
|
+
? context.warp.tick(index)
|
|
1029
|
+
: note.startTick + elapsed / samplesPerTick,
|
|
1030
|
+
);
|
|
1031
|
+
// Plain notes keep the closed-form phase (byte-identical output).
|
|
1032
|
+
let phase = (frequency * elapsed) / sampleRate;
|
|
1033
|
+
if (cents) {
|
|
1034
|
+
phase = bent;
|
|
1035
|
+
bent +=
|
|
1036
|
+
(frequency * 2 ** (cents(elapsed / sampleRate) / 1200)) / sampleRate;
|
|
1037
|
+
}
|
|
1038
|
+
const damp = performance?.damp;
|
|
1039
|
+
const t = elapsed / sampleRate;
|
|
1040
|
+
const held =
|
|
1041
|
+
damp && t > damp.from ? Math.exp(-(t - damp.from) / damp.tau) : 1;
|
|
1042
|
+
target[index]! += legacyWave(instrument, phase) * envelope * held;
|
|
777
1043
|
}
|
|
778
1044
|
}
|
|
779
1045
|
|
|
@@ -799,7 +1065,9 @@ function renderDrumNote(
|
|
|
799
1065
|
start + Math.ceil((kit?.seconds ?? MAX_DRUM_SECONDS) * sampleRate),
|
|
800
1066
|
);
|
|
801
1067
|
const voice = drumVoiceForPitch(note.pitch);
|
|
802
|
-
const random = seededRandom(
|
|
1068
|
+
const random = seededRandom(
|
|
1069
|
+
`${note.id}:${note.startTick + (context.seedTick ?? 0)}`,
|
|
1070
|
+
);
|
|
803
1071
|
const velocity = Math.max(0, Math.min(1, note.velocity));
|
|
804
1072
|
let phase = 0;
|
|
805
1073
|
let previousNoise = 0;
|
|
@@ -865,7 +1133,12 @@ function renderDrumNote(
|
|
|
865
1133
|
release *
|
|
866
1134
|
velocity *
|
|
867
1135
|
0.5 *
|
|
868
|
-
trackGainAt(
|
|
1136
|
+
trackGainAt(
|
|
1137
|
+
track,
|
|
1138
|
+
context.warp
|
|
1139
|
+
? context.warp.tick(index)
|
|
1140
|
+
: note.startTick + (index - start) / samplesPerTick,
|
|
1141
|
+
);
|
|
869
1142
|
}
|
|
870
1143
|
}
|
|
871
1144
|
|
|
@@ -892,7 +1165,13 @@ function applyPan(
|
|
|
892
1165
|
for (let index = 0; index < dry.length; index += 1) {
|
|
893
1166
|
if (lane.length > 0 && index % CONTROL_SAMPLES === 0)
|
|
894
1167
|
[gainL, gainR] = gains(
|
|
895
|
-
interpolateAutomation(
|
|
1168
|
+
interpolateAutomation(
|
|
1169
|
+
lane,
|
|
1170
|
+
context.warp
|
|
1171
|
+
? context.warp.tick(index)
|
|
1172
|
+
: index / context.samplesPerTick,
|
|
1173
|
+
staticPan,
|
|
1174
|
+
),
|
|
896
1175
|
);
|
|
897
1176
|
const sample = dry[index]!;
|
|
898
1177
|
left[index] = sample * gainL;
|
|
@@ -930,16 +1209,29 @@ function wavetableHook(
|
|
|
930
1209
|
lane.length > 0
|
|
931
1210
|
? (tick: number) => interpolateAutomation(lane, tick, settings.wt ?? 0)
|
|
932
1211
|
: undefined;
|
|
1212
|
+
const warp = context.warp;
|
|
933
1213
|
return {
|
|
934
1214
|
id: table.id,
|
|
935
|
-
oscillatorFor: (note) =>
|
|
936
|
-
|
|
1215
|
+
oscillatorFor: (note) => {
|
|
1216
|
+
const span = noteSpan(note, context);
|
|
1217
|
+
return wavetableOscillator(table, settings, note, {
|
|
937
1218
|
sampleRate: context.sampleRate,
|
|
938
1219
|
samplesPerTick: context.samplesPerTick,
|
|
939
1220
|
secondsPerTick: context.samplesPerTick / context.sampleRate,
|
|
940
|
-
gate:
|
|
941
|
-
|
|
942
|
-
|
|
1221
|
+
gate: span.length / context.sampleRate,
|
|
1222
|
+
// Through a tempo map the lane is read at the sample's song tick.
|
|
1223
|
+
positionAt:
|
|
1224
|
+
warp && positionAt
|
|
1225
|
+
? (tick: number) =>
|
|
1226
|
+
positionAt(
|
|
1227
|
+
warp.tick(
|
|
1228
|
+
span.start +
|
|
1229
|
+
(tick - note.startTick) * context.samplesPerTick,
|
|
1230
|
+
),
|
|
1231
|
+
)
|
|
1232
|
+
: positionAt,
|
|
1233
|
+
});
|
|
1234
|
+
},
|
|
943
1235
|
};
|
|
944
1236
|
}
|
|
945
1237
|
|
|
@@ -975,3 +1267,58 @@ function writeAscii(target: Uint8Array, offset: number, value: string): void {
|
|
|
975
1267
|
function clamp16(value: number): number {
|
|
976
1268
|
return Math.max(-32_768, Math.min(32_767, Math.round(value)));
|
|
977
1269
|
}
|
|
1270
|
+
|
|
1271
|
+
/** A named point in a WAV export: a sample frame and its label. */
|
|
1272
|
+
export type WavCue = Readonly<{ frame: number; label: string }>;
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Append a `cue ` chunk and a `LIST adtl` chunk of `labl` labels, so DAWs
|
|
1276
|
+
* and DJ tools show the cues (dawg writes section starts). No cues returns
|
|
1277
|
+
* the bytes unchanged.
|
|
1278
|
+
*/
|
|
1279
|
+
export function withWavCues(
|
|
1280
|
+
bytes: Uint8Array,
|
|
1281
|
+
cues: readonly WavCue[],
|
|
1282
|
+
): Uint8Array {
|
|
1283
|
+
if (cues.length === 0 || bytes.byteLength < 12) return bytes;
|
|
1284
|
+
const encoder = new TextEncoder();
|
|
1285
|
+
const labels = cues.map((cue) =>
|
|
1286
|
+
encoder.encode(`${cue.label.slice(0, 200)}\0`),
|
|
1287
|
+
);
|
|
1288
|
+
const cueSize = 4 + 24 * cues.length;
|
|
1289
|
+
const lablSizes = labels.map((text) => 4 + text.byteLength);
|
|
1290
|
+
const adtlSize =
|
|
1291
|
+
4 + lablSizes.reduce((sum, size) => sum + 8 + size + (size % 2), 0);
|
|
1292
|
+
const base = bytes.byteLength + (bytes.byteLength % 2);
|
|
1293
|
+
const out = new Uint8Array(base + 8 + cueSize + 8 + adtlSize);
|
|
1294
|
+
out.set(bytes);
|
|
1295
|
+
const view = new DataView(out.buffer);
|
|
1296
|
+
let at = base;
|
|
1297
|
+
writeAscii(out, at, "cue ");
|
|
1298
|
+
view.setUint32(at + 4, cueSize, true);
|
|
1299
|
+
view.setUint32(at + 8, cues.length, true);
|
|
1300
|
+
at += 12;
|
|
1301
|
+
cues.forEach((cue, index) => {
|
|
1302
|
+
const frame = Math.max(0, Math.round(cue.frame));
|
|
1303
|
+
view.setUint32(at, index + 1, true);
|
|
1304
|
+
view.setUint32(at + 4, frame, true);
|
|
1305
|
+
writeAscii(out, at + 8, "data");
|
|
1306
|
+
view.setUint32(at + 12, 0, true);
|
|
1307
|
+
view.setUint32(at + 16, 0, true);
|
|
1308
|
+
view.setUint32(at + 20, frame, true);
|
|
1309
|
+
at += 24;
|
|
1310
|
+
});
|
|
1311
|
+
writeAscii(out, at, "LIST");
|
|
1312
|
+
view.setUint32(at + 4, adtlSize, true);
|
|
1313
|
+
writeAscii(out, at + 8, "adtl");
|
|
1314
|
+
at += 12;
|
|
1315
|
+
labels.forEach((text, index) => {
|
|
1316
|
+
writeAscii(out, at, "labl");
|
|
1317
|
+
view.setUint32(at + 4, lablSizes[index]!, true);
|
|
1318
|
+
view.setUint32(at + 8, index + 1, true);
|
|
1319
|
+
out.set(text, at + 12);
|
|
1320
|
+
at += 8 + lablSizes[index]! + (lablSizes[index]! % 2);
|
|
1321
|
+
});
|
|
1322
|
+
view.setUint32(4, out.byteLength - 8, true);
|
|
1323
|
+
return out;
|
|
1324
|
+
}
|