@hraness/dawg 0.4.0 → 0.5.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 +86 -0
- package/DAWG.md +300 -35
- package/README.md +4 -4
- package/core/chords.ts +288 -7
- package/core/diff.ts +37 -12
- package/core/expression.ts +1241 -0
- package/core/loop.ts +23 -0
- package/core/master.ts +455 -0
- package/core/midi.ts +452 -0
- package/core/rhythm.ts +7 -2
- package/core/score.ts +536 -56
- package/core/sdk/eval-child.ts +38 -3
- package/core/sdk/print.ts +445 -14
- package/core/sdk/v1.ts +1709 -23
- package/core/sections.ts +2046 -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 +27 -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 +25 -0
- package/guides/tempo.md +26 -0
- package/guides/tracks.md +25 -0
- package/guides/web-search.md +21 -0
- package/package.json +3 -1
- package/src/agent/agent.ts +2 -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/master-tools.ts +299 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +29 -4
- package/src/agent/planner.ts +45 -2
- package/src/agent/preview-tool.ts +21 -3
- package/src/agent/section-tools.ts +411 -0
- package/src/agent/time-tools.ts +290 -0
- package/src/agent/tools.ts +36 -2
- package/src/agent/tuning-tools.ts +301 -0
- package/src/agent/xcb-agent.ts +2 -0
- package/src/audio/arrange.ts +471 -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/effects/bus.ts +5 -4
- package/src/audio/effects/chain.ts +12 -2
- package/src/audio/effects/common.ts +30 -1
- package/src/audio/effects/convolution.ts +43 -5
- 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/space.ts +197 -50
- package/src/audio/engine.ts +175 -20
- package/src/audio/live.ts +33 -6
- package/src/audio/loudness.ts +551 -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 +87 -16
- package/src/audio/render-worker.ts +6 -1
- package/src/audio/renderer.ts +7 -1
- package/src/audio/sampler.ts +108 -19
- package/src/audio/synth/voice.ts +60 -13
- package/src/audio/synth/zzfx.ts +10 -4
- package/src/audio/warp.ts +86 -0
- package/src/audio/wav.ts +448 -59
- package/src/commands/arrange.ts +949 -0
- package/src/commands/expression.ts +934 -0
- package/src/commands/help.ts +281 -24
- package/src/commands/master.ts +361 -0
- package/src/commands/music.ts +6 -1
- package/src/commands/synth.ts +11 -1
- package/src/commands/time.ts +964 -0
- package/src/commands/tuning.ts +490 -0
- package/src/main.ts +826 -47
- package/src/render.ts +109 -7
- 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 +58 -5
- package/src/tui/euclid.ts +50 -6
- package/src/tui/fader.ts +409 -0
- package/src/tui/menu-time.ts +401 -0
- package/src/tui/menu.ts +656 -16
- package/src/tui/performance-menu.ts +235 -0
- package/src/tui/play-chords.ts +95 -1
- package/src/tui/play-mode.ts +150 -6
- package/src/tui/play-session.ts +414 -41
- package/tui/app.ts +262 -13
- package/tui/arrange-strip.ts +174 -0
- package/tui/drawer.ts +478 -0
- package/tui/grammar.ts +79 -12
- 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
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
import type { Track } from "../../../core/score.ts";
|
|
6
6
|
import type { DecodedSample } from "../samples.ts";
|
|
7
7
|
import {
|
|
8
|
+
addConvolved,
|
|
8
9
|
builtinImpulse,
|
|
9
|
-
|
|
10
|
+
convolveRaw,
|
|
10
11
|
sampleImpulse,
|
|
11
12
|
type Impulse,
|
|
12
13
|
} from "./convolution.ts";
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
OnePole,
|
|
16
17
|
Param,
|
|
17
18
|
interpolateAutomation,
|
|
19
|
+
tickAtSample,
|
|
18
20
|
type EffectContext,
|
|
19
21
|
} from "./common.ts";
|
|
20
22
|
|
|
@@ -48,7 +50,12 @@ export function applyDelay(
|
|
|
48
50
|
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
49
51
|
const mixLane = track.delayMixAutomation ?? [];
|
|
50
52
|
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
51
|
-
const { sampleRate
|
|
53
|
+
const { sampleRate } = context;
|
|
54
|
+
const synced = !(delay.time !== undefined && delay.time > 0);
|
|
55
|
+
if (context.warp && synced) {
|
|
56
|
+
applyWarpedDelay(left, right, track, context, wetOnly);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
52
59
|
const length = Math.max(
|
|
53
60
|
1,
|
|
54
61
|
Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
|
|
@@ -68,7 +75,7 @@ export function applyDelay(
|
|
|
68
75
|
];
|
|
69
76
|
for (let index = 0; index < left.length; index += 1) {
|
|
70
77
|
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
71
|
-
const tick = index
|
|
78
|
+
const tick = tickAtSample(context, index);
|
|
72
79
|
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
73
80
|
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
74
81
|
}
|
|
@@ -97,6 +104,89 @@ export function applyDelay(
|
|
|
97
104
|
}
|
|
98
105
|
}
|
|
99
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The beat-synced delay under a tempo map: the read head trails the write
|
|
109
|
+
* head by `beats` of score time at every control block, read with linear
|
|
110
|
+
* interpolation, so each echo lands `beats` later on the grid through tempo
|
|
111
|
+
* steps, ramps and fermatas. Same routing and lanes as `applyDelay`.
|
|
112
|
+
*/
|
|
113
|
+
function applyWarpedDelay(
|
|
114
|
+
left: Float64Array,
|
|
115
|
+
right: Float64Array,
|
|
116
|
+
track: Track,
|
|
117
|
+
context: EffectContext,
|
|
118
|
+
wetOnly: boolean,
|
|
119
|
+
): void {
|
|
120
|
+
const delay = track.delay!;
|
|
121
|
+
const warp = context.warp!;
|
|
122
|
+
const { sampleRate } = context;
|
|
123
|
+
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
124
|
+
const mixLane = track.delayMixAutomation ?? [];
|
|
125
|
+
// Ticks per beat, recovered from the context's start-tempo sample rate.
|
|
126
|
+
const ticksPerBeat = Math.round(
|
|
127
|
+
(sampleRate * 60) / (context.tempoBpm * context.samplesPerTick),
|
|
128
|
+
);
|
|
129
|
+
const lag = delay.beats * ticksPerBeat;
|
|
130
|
+
const lagAt = (index: number) =>
|
|
131
|
+
Math.max(2, index - warp.sample(warp.tick(index) - lag));
|
|
132
|
+
// Longest lag over the buffer sizes the lines.
|
|
133
|
+
let longest = 1;
|
|
134
|
+
for (let index = 0; index <= left.length; index += CONTROL_SAMPLES)
|
|
135
|
+
longest = Math.max(longest, lagAt(index));
|
|
136
|
+
const size = Math.ceil(longest) + 2;
|
|
137
|
+
const lineL = new Float64Array(size);
|
|
138
|
+
const lineR = new Float64Array(size);
|
|
139
|
+
const pingpong = delay.pingpong === true;
|
|
140
|
+
const highcut =
|
|
141
|
+
delay.highcut === undefined
|
|
142
|
+
? undefined
|
|
143
|
+
: [
|
|
144
|
+
new OnePole(delay.highcut, sampleRate),
|
|
145
|
+
new OnePole(delay.highcut, sampleRate),
|
|
146
|
+
];
|
|
147
|
+
let feedback = delay.feedback;
|
|
148
|
+
let mix = delay.mix;
|
|
149
|
+
let lagNow = lagAt(0);
|
|
150
|
+
let lagStep = 0;
|
|
151
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
152
|
+
if (index % CONTROL_SAMPLES === 0) {
|
|
153
|
+
const tick = tickAtSample(context, index);
|
|
154
|
+
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
155
|
+
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
156
|
+
lagNow = lagAt(index);
|
|
157
|
+
lagStep = (lagAt(index + CONTROL_SAMPLES) - lagNow) / CONTROL_SAMPLES;
|
|
158
|
+
} else lagNow += lagStep;
|
|
159
|
+
const read = index - Math.min(size - 2, lagNow);
|
|
160
|
+
const base = Math.floor(read);
|
|
161
|
+
const fraction = read - base;
|
|
162
|
+
const at = (line: Float64Array, position: number) =>
|
|
163
|
+
position < 0 ? 0 : line[position % size]!;
|
|
164
|
+
const wetL =
|
|
165
|
+
at(lineL, base) * (1 - fraction) + at(lineL, base + 1) * fraction;
|
|
166
|
+
const wetR =
|
|
167
|
+
at(lineR, base) * (1 - fraction) + at(lineR, base + 1) * fraction;
|
|
168
|
+
const dryL = left[index]!;
|
|
169
|
+
const dryR = right[index]!;
|
|
170
|
+
let writeL: number;
|
|
171
|
+
let writeR: number;
|
|
172
|
+
if (pingpong) {
|
|
173
|
+
writeL = (dryL + dryR) * 0.5 + wetR * feedback;
|
|
174
|
+
writeR = wetL * feedback;
|
|
175
|
+
} else {
|
|
176
|
+
writeL = dryL + wetR * feedback;
|
|
177
|
+
writeR = dryR + wetL * feedback;
|
|
178
|
+
}
|
|
179
|
+
if (highcut) {
|
|
180
|
+
writeL = highcut[0]!.process(writeL);
|
|
181
|
+
writeR = highcut[1]!.process(writeR);
|
|
182
|
+
}
|
|
183
|
+
lineL[index % size] = writeL;
|
|
184
|
+
lineR[index % size] = writeR;
|
|
185
|
+
left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
|
|
186
|
+
right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
100
190
|
/** Seconds until the track's delay decays below -60 dB, bounded. */
|
|
101
191
|
export function delayTailFor(track: Track, tempoBpm: number): number {
|
|
102
192
|
if (!track.delay) return 0;
|
|
@@ -181,6 +271,10 @@ export function reverbTailFor(
|
|
|
181
271
|
* feedback for an exact -60 dB decay time; `dim` sets the in-loop damping
|
|
182
272
|
* frequency, `lowpass` filters the input and `predelay` delays it.
|
|
183
273
|
* `wetOnly` (a shared orbit bus) replaces the input with the tail alone.
|
|
274
|
+
*
|
|
275
|
+
* It runs in two steps: `reverbWet` (the tail before its mix gain, which
|
|
276
|
+
* does not depend on `mix`) and `addReverbWet`. The stem cache keeps the
|
|
277
|
+
* first, so a mix-only edit skips the room entirely.
|
|
184
278
|
*/
|
|
185
279
|
export function applyReverb(
|
|
186
280
|
left: Float64Array,
|
|
@@ -189,28 +283,51 @@ export function applyReverb(
|
|
|
189
283
|
context: EffectContext,
|
|
190
284
|
wetOnly = false,
|
|
191
285
|
): void {
|
|
192
|
-
const
|
|
193
|
-
if (
|
|
194
|
-
|
|
195
|
-
|
|
286
|
+
const wet = reverbWet(left, right, track, context);
|
|
287
|
+
if (wet) addReverbWet(left, right, wet, track, context, wetOnly);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A reverb's tail before its mix gain (`scale` for an impulse response). */
|
|
291
|
+
export type ReverbWet = Readonly<{
|
|
292
|
+
left: Float64Array;
|
|
293
|
+
right: Float64Array;
|
|
294
|
+
/** The convolution's inverse-FFT scale; absent for the algorithmic room. */
|
|
295
|
+
scale?: number;
|
|
296
|
+
}>;
|
|
297
|
+
|
|
298
|
+
function mixParamOf(track: Track, context: EffectContext): Param {
|
|
299
|
+
return new Param(
|
|
300
|
+
track.reverb?.mix ?? 0,
|
|
196
301
|
track.fxAutomation?.["reverb-mix"],
|
|
197
302
|
context.samplesPerTick,
|
|
303
|
+
context.warp,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Whether the track's reverb sounds at all (a mix above 0, or a lane). */
|
|
308
|
+
export function reverbActive(track: Track): boolean {
|
|
309
|
+
const reverb = track.reverb;
|
|
310
|
+
if (!reverb) return false;
|
|
311
|
+
return (
|
|
312
|
+
reverb.mix > 0 || (track.fxAutomation?.["reverb-mix"] ?? []).length > 0
|
|
198
313
|
);
|
|
199
|
-
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The reverb tail of the pair, before the mix gain; `left`/`right` are
|
|
318
|
+
* read, not changed. Undefined when the reverb is off.
|
|
319
|
+
*/
|
|
320
|
+
export function reverbWet(
|
|
321
|
+
left: Float64Array,
|
|
322
|
+
right: Float64Array,
|
|
323
|
+
track: Track,
|
|
324
|
+
context: EffectContext,
|
|
325
|
+
): ReverbWet | undefined {
|
|
326
|
+
const reverb = track.reverb;
|
|
327
|
+
if (!reverb || !reverbActive(track)) return undefined;
|
|
200
328
|
const { sampleRate } = context;
|
|
201
329
|
const impulse = reverbImpulse(track, sampleRate, context.irs);
|
|
202
|
-
if (impulse)
|
|
203
|
-
applyConvolutionReverb(
|
|
204
|
-
left,
|
|
205
|
-
right,
|
|
206
|
-
track,
|
|
207
|
-
context,
|
|
208
|
-
impulse,
|
|
209
|
-
mixParam,
|
|
210
|
-
wetOnly,
|
|
211
|
-
);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
330
|
+
if (impulse) return convolutionWet(left, right, track, context, impulse);
|
|
214
331
|
const scale = sampleRate / 44_100;
|
|
215
332
|
const sizeFeedback = 0.7 + 0.28 * reverb.size;
|
|
216
333
|
const damp =
|
|
@@ -219,7 +336,6 @@ export function applyReverb(
|
|
|
219
336
|
: Math.exp(
|
|
220
337
|
(-2 * Math.PI * Math.min(reverb.dim, sampleRate * 0.45)) / sampleRate,
|
|
221
338
|
);
|
|
222
|
-
let wet = reverb.mix * REVERB_WET_SCALE;
|
|
223
339
|
const channel = (spread: number) => ({
|
|
224
340
|
combs: COMB_TUNING.map((tuning) => {
|
|
225
341
|
const length = Math.max(1, Math.round((tuning + spread) * scale));
|
|
@@ -266,9 +382,9 @@ export function applyReverb(
|
|
|
266
382
|
const predelaySamples = Math.round((reverb.predelay ?? 0) * sampleRate);
|
|
267
383
|
const predelay =
|
|
268
384
|
predelaySamples > 0 ? new Float64Array(predelaySamples) : undefined;
|
|
385
|
+
const wetL = new Float64Array(left.length);
|
|
386
|
+
const wetR = new Float64Array(left.length);
|
|
269
387
|
for (let index = 0; index < left.length; index += 1) {
|
|
270
|
-
if (mixParam.automated && index % CONTROL_SAMPLES === 0)
|
|
271
|
-
wet = mixParam.at(index) * REVERB_WET_SCALE;
|
|
272
388
|
let input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
|
|
273
389
|
if (inputFilter) input = inputFilter.process(input);
|
|
274
390
|
if (predelay) {
|
|
@@ -277,12 +393,60 @@ export function applyReverb(
|
|
|
277
393
|
predelay[slot] = input;
|
|
278
394
|
input = delayed;
|
|
279
395
|
}
|
|
396
|
+
wetL[index] = process(sides[0], input);
|
|
397
|
+
wetR[index] = process(sides[1], input);
|
|
398
|
+
}
|
|
399
|
+
return { left: wetL, right: wetR };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Add (or with `wetOnly`, write) a tail from `reverbWet` at the track's
|
|
404
|
+
* mix and mix lane, in the order the one-pass reverb always used, so a
|
|
405
|
+
* cached tail mixes to the same samples.
|
|
406
|
+
*/
|
|
407
|
+
export function addReverbWet(
|
|
408
|
+
left: Float64Array,
|
|
409
|
+
right: Float64Array,
|
|
410
|
+
tail: ReverbWet,
|
|
411
|
+
track: Track,
|
|
412
|
+
context: EffectContext,
|
|
413
|
+
wetOnly = false,
|
|
414
|
+
): void {
|
|
415
|
+
const mixParam = mixParamOf(track, context);
|
|
416
|
+
if (tail.scale !== undefined) {
|
|
417
|
+
if (wetOnly) {
|
|
418
|
+
left.fill(0);
|
|
419
|
+
right.fill(0);
|
|
420
|
+
}
|
|
421
|
+
if (tail.scale === 0) return;
|
|
422
|
+
let wet = mixParam.fallback * IR_WET_SCALE;
|
|
423
|
+
addConvolved(
|
|
424
|
+
tail.left,
|
|
425
|
+
tail.right,
|
|
426
|
+
tail.scale,
|
|
427
|
+
left,
|
|
428
|
+
right,
|
|
429
|
+
wet,
|
|
430
|
+
mixParam.automated
|
|
431
|
+
? (index) => {
|
|
432
|
+
if (index % CONTROL_SAMPLES === 0)
|
|
433
|
+
wet = mixParam.at(index) * IR_WET_SCALE;
|
|
434
|
+
return wet;
|
|
435
|
+
}
|
|
436
|
+
: undefined,
|
|
437
|
+
);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
let wet = mixParam.fallback * REVERB_WET_SCALE;
|
|
441
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
442
|
+
if (mixParam.automated && index % CONTROL_SAMPLES === 0)
|
|
443
|
+
wet = mixParam.at(index) * REVERB_WET_SCALE;
|
|
280
444
|
if (wetOnly) {
|
|
281
|
-
left[index] =
|
|
282
|
-
right[index] =
|
|
445
|
+
left[index] = tail.left[index]! * wet;
|
|
446
|
+
right[index] = tail.right[index]! * wet;
|
|
283
447
|
} else {
|
|
284
|
-
left[index]! +=
|
|
285
|
-
right[index]! +=
|
|
448
|
+
left[index]! += tail.left[index]! * wet;
|
|
449
|
+
right[index]! += tail.right[index]! * wet;
|
|
286
450
|
}
|
|
287
451
|
}
|
|
288
452
|
}
|
|
@@ -293,15 +457,13 @@ export function applyReverb(
|
|
|
293
457
|
* `size`, `fade` and `dim` describe the algorithmic tail and do nothing
|
|
294
458
|
* here: the impulse is the room.
|
|
295
459
|
*/
|
|
296
|
-
function
|
|
460
|
+
function convolutionWet(
|
|
297
461
|
left: Float64Array,
|
|
298
462
|
right: Float64Array,
|
|
299
463
|
track: Track,
|
|
300
464
|
context: EffectContext,
|
|
301
465
|
impulse: Impulse,
|
|
302
|
-
|
|
303
|
-
wetOnly: boolean,
|
|
304
|
-
): void {
|
|
466
|
+
): ReverbWet {
|
|
305
467
|
const reverb = track.reverb!;
|
|
306
468
|
const { sampleRate } = context;
|
|
307
469
|
const input = new Float64Array(left.length);
|
|
@@ -315,23 +477,8 @@ function applyConvolutionReverb(
|
|
|
315
477
|
if (inputFilter) value = inputFilter.process(value);
|
|
316
478
|
input[index + offset] = value;
|
|
317
479
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
let wet = reverb.mix * IR_WET_SCALE;
|
|
323
|
-
convolveStereo(
|
|
324
|
-
input,
|
|
325
|
-
impulse,
|
|
326
|
-
left,
|
|
327
|
-
right,
|
|
328
|
-
wet,
|
|
329
|
-
mixParam.automated
|
|
330
|
-
? (index) => {
|
|
331
|
-
if (index % CONTROL_SAMPLES === 0)
|
|
332
|
-
wet = mixParam.at(index) * IR_WET_SCALE;
|
|
333
|
-
return wet;
|
|
334
|
-
}
|
|
335
|
-
: undefined,
|
|
336
|
-
);
|
|
480
|
+
const wetL = new Float64Array(left.length);
|
|
481
|
+
const wetR = new Float64Array(left.length);
|
|
482
|
+
const scale = convolveRaw(input, impulse, wetL, wetR);
|
|
483
|
+
return { left: wetL, right: wetR, scale };
|
|
337
484
|
}
|
package/src/audio/engine.ts
CHANGED
|
@@ -2,10 +2,19 @@ import type { TrackScore } from "../../core/score.ts";
|
|
|
2
2
|
import { PlaybackLock } from "./lock.ts";
|
|
3
3
|
import { LoopPlayer } from "./player.ts";
|
|
4
4
|
import { LoopRenderer, type LoopRender } from "./renderer.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
clickSounds,
|
|
7
|
+
clicksIn,
|
|
8
|
+
type ClickGrid,
|
|
9
|
+
type ClickLevel,
|
|
10
|
+
} from "./click.ts";
|
|
6
11
|
import type { LiveNotePcm } from "./live.ts";
|
|
12
|
+
import { measureLoudness, pcmChannels } from "./loudness.ts";
|
|
13
|
+
import type { MasterReport } from "./master.ts";
|
|
7
14
|
import { levelOf, type SoundLevel } from "./preview.ts";
|
|
8
15
|
import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
|
|
16
|
+
import { playbackTime } from "./arrange.ts";
|
|
17
|
+
import { transportMapFor, type TransportMap } from "./clock.ts";
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
20
|
* How dawgd (or a file-mode window) makes sound.
|
|
@@ -170,6 +179,17 @@ function parseCommand(value: string): string[] {
|
|
|
170
179
|
return parts;
|
|
171
180
|
}
|
|
172
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Swaps crossfade old to new over this window with equal-power gains
|
|
184
|
+
* (cos/sin), reading both loops at the same beat. 20 ms is long enough that
|
|
185
|
+
* the step a hard cut makes is spread below ~50 Hz (no click, even on a
|
|
186
|
+
* full-scale low bass), short enough to sit inside the transport's 30 ms
|
|
187
|
+
* resync tolerance and below the shortest audible echo (~30-50 ms), so an
|
|
188
|
+
* edit never sounds smeared or doubled. Playback only: renders, exports and
|
|
189
|
+
* the loop buffers themselves never pass through it.
|
|
190
|
+
*/
|
|
191
|
+
export const SWAP_FADE_SECONDS = 0.02;
|
|
192
|
+
|
|
173
193
|
/** Swaps re-anchor to the transport only past this disagreement. */
|
|
174
194
|
const RESYNC_SECONDS = 0.03;
|
|
175
195
|
|
|
@@ -226,8 +246,45 @@ type Loop = Readonly<{
|
|
|
226
246
|
pcm: Int16Array;
|
|
227
247
|
frames: number;
|
|
228
248
|
framesPerBeat: number;
|
|
249
|
+
/** The song's tempo map; absent, a beat is `framesPerBeat` frames. */
|
|
250
|
+
map?: TransportMap;
|
|
251
|
+
sampleRate: number;
|
|
229
252
|
}>;
|
|
230
253
|
|
|
254
|
+
/** Loop frame (unwrapped without a map) where transport `beat` sounds. */
|
|
255
|
+
function frameOfBeat(loop: Loop, beat: number): number {
|
|
256
|
+
const map = loop.map;
|
|
257
|
+
if (!map) return beat * loop.framesPerBeat;
|
|
258
|
+
const inner = ((beat % map.loopBeats) + map.loopBeats) % map.loopBeats;
|
|
259
|
+
return map.seconds(inner) * loop.sampleRate;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Transport beat at loop `frame` (inside one pass with a map). */
|
|
263
|
+
function beatOfFrame(loop: Loop, frame: number): number {
|
|
264
|
+
const map = loop.map;
|
|
265
|
+
if (!map) return frame / loop.framesPerBeat;
|
|
266
|
+
const seconds = frame / loop.sampleRate;
|
|
267
|
+
const inner =
|
|
268
|
+
((seconds % map.loopSeconds) + map.loopSeconds) % map.loopSeconds;
|
|
269
|
+
return map.beat(inner);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The beat `frames` stream frames after `beat`. */
|
|
273
|
+
function beatAfter(loop: Loop, beat: number, frames: number): number {
|
|
274
|
+
if (!loop.map) return beat + frames / loop.framesPerBeat;
|
|
275
|
+
return beatOfFrame(loop, frameOfBeat(loop, beat) + frames);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** A loop fading out after a swap, read on from where it was. */
|
|
279
|
+
type FadeOut = {
|
|
280
|
+
loop: Loop;
|
|
281
|
+
cursor: number;
|
|
282
|
+
/** Its gain when the fade began (below 1 when swapped out mid-fade). */
|
|
283
|
+
from: number;
|
|
284
|
+
/** Frames of the fade already written. */
|
|
285
|
+
done: number;
|
|
286
|
+
};
|
|
287
|
+
|
|
231
288
|
/**
|
|
232
289
|
* The click bus: the engine asks `beatAt` for the transport beat at a stream
|
|
233
290
|
* frame's wall time (undefined while the transport is stopped; negative
|
|
@@ -238,6 +295,8 @@ export type ClickBus = Readonly<{
|
|
|
238
295
|
volume: number;
|
|
239
296
|
beatsPerBar: number;
|
|
240
297
|
subdivision: number;
|
|
298
|
+
/** Meter-aware clicks (core/tempo.ts) replacing the fixed grid. */
|
|
299
|
+
grid?: ClickGrid;
|
|
241
300
|
beatAt: (monotonicMs: number) => number | undefined;
|
|
242
301
|
}>;
|
|
243
302
|
|
|
@@ -293,6 +352,11 @@ export class AudioEngine {
|
|
|
293
352
|
private child: PlayerProcess | undefined;
|
|
294
353
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
295
354
|
private loop: Loop | undefined;
|
|
355
|
+
/** Loops fading out after swaps (see `SWAP_FADE_SECONDS`). */
|
|
356
|
+
private fades: FadeOut[] = [];
|
|
357
|
+
/** Frames of the current loop's fade-in already written, while fading. */
|
|
358
|
+
private fadeIn: number | undefined;
|
|
359
|
+
private readonly fadeFrames: number;
|
|
296
360
|
/** Monotonic ms that stream frame 0 belongs to. */
|
|
297
361
|
private startMs = 0;
|
|
298
362
|
/** Stream frames written since `startMs`. */
|
|
@@ -308,6 +372,9 @@ export class AudioEngine {
|
|
|
308
372
|
/** The loop last swapped in, for `level` (measured on demand, once). */
|
|
309
373
|
private lastLoopPcm: Int16Array | undefined;
|
|
310
374
|
private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
|
|
375
|
+
private loudnessOf: { pcm: Int16Array; report: MasterReport } | undefined;
|
|
376
|
+
/** Loudness the song master reached on the loop now playing. */
|
|
377
|
+
private lastMaster: MasterReport | undefined;
|
|
311
378
|
private respawns = 0;
|
|
312
379
|
private respawnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
313
380
|
/** Play mode: keep a player running without a loop for live voices. */
|
|
@@ -328,6 +395,10 @@ export class AudioEngine {
|
|
|
328
395
|
((options.leadMs ?? 200) * this.sampleRate) / 1000,
|
|
329
396
|
);
|
|
330
397
|
this.defaultLeadFrames = this.leadFrames;
|
|
398
|
+
this.fadeFrames = Math.max(
|
|
399
|
+
1,
|
|
400
|
+
Math.round(SWAP_FADE_SECONDS * this.sampleRate),
|
|
401
|
+
);
|
|
331
402
|
this.tickMs = options.tickMs ?? 20;
|
|
332
403
|
this.now = options.now ?? (() => performance.now());
|
|
333
404
|
this.useTimer = options.timer ?? true;
|
|
@@ -391,6 +462,33 @@ export class AudioEngine {
|
|
|
391
462
|
return this.measured.level;
|
|
392
463
|
}
|
|
393
464
|
|
|
465
|
+
/**
|
|
466
|
+
* Integrated loudness and true peak of the loop now playing, after the
|
|
467
|
+
* song master when there is one (the header meter). Without a master the
|
|
468
|
+
* loop's PCM is measured once per render, on first read. Undefined before
|
|
469
|
+
* any render.
|
|
470
|
+
*/
|
|
471
|
+
public get loudness(): MasterReport | undefined {
|
|
472
|
+
const pcm = this.lastLoopPcm;
|
|
473
|
+
if (!pcm) return undefined;
|
|
474
|
+
if (this.lastMaster) return this.lastMaster;
|
|
475
|
+
if (this.loudnessOf?.pcm !== pcm) {
|
|
476
|
+
const [left, right] = pcmChannels(pcm);
|
|
477
|
+
const measured = measureLoudness(left, right, this.sampleRate, {
|
|
478
|
+
loop: true,
|
|
479
|
+
});
|
|
480
|
+
this.loudnessOf = {
|
|
481
|
+
pcm,
|
|
482
|
+
report: {
|
|
483
|
+
gainDb: 0,
|
|
484
|
+
integrated: measured.integrated,
|
|
485
|
+
truePeak: measured.truePeak,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
return this.loudnessOf.report;
|
|
490
|
+
}
|
|
491
|
+
|
|
394
492
|
public get canMonitor(): boolean {
|
|
395
493
|
return this.info.backend !== "none" && !this.fallback;
|
|
396
494
|
}
|
|
@@ -522,6 +620,7 @@ export class AudioEngine {
|
|
|
522
620
|
if (request.generation === this.generation) {
|
|
523
621
|
await this.apply(this.toLoop(render, request.score), request);
|
|
524
622
|
this.lastLoopPcm = render.pcm;
|
|
623
|
+
this.lastMaster = render.master;
|
|
525
624
|
}
|
|
526
625
|
for (const waiter of request.waiters) waiter.resolve();
|
|
527
626
|
} catch (error) {
|
|
@@ -541,10 +640,11 @@ export class AudioEngine {
|
|
|
541
640
|
const beat =
|
|
542
641
|
request.beat === undefined
|
|
543
642
|
? undefined
|
|
544
|
-
:
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
643
|
+
: beatAfter(
|
|
644
|
+
loop,
|
|
645
|
+
request.beat,
|
|
646
|
+
((this.now() - request.atMs) * this.sampleRate) / 1000,
|
|
647
|
+
);
|
|
548
648
|
if (this.child) {
|
|
549
649
|
this.swap(loop, beat);
|
|
550
650
|
return;
|
|
@@ -565,19 +665,10 @@ export class AudioEngine {
|
|
|
565
665
|
if (!this.loop || !this.child) return;
|
|
566
666
|
this.cursor = this.frameForBeat(
|
|
567
667
|
this.loop,
|
|
568
|
-
beat
|
|
668
|
+
beatAfter(this.loop, beat, this.queuedFrames()),
|
|
569
669
|
);
|
|
570
670
|
}
|
|
571
671
|
|
|
572
|
-
/**
|
|
573
|
-
* Beats already queued ahead of the wall clock. The next frame written
|
|
574
|
-
* sounds this far after now, so a transport beat maps to the write head
|
|
575
|
-
* by adding it.
|
|
576
|
-
*/
|
|
577
|
-
private queuedBeats(loop: Loop): number {
|
|
578
|
-
return this.queuedFrames() / loop.framesPerBeat;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
672
|
private queuedFrames(): number {
|
|
582
673
|
const elapsed = Math.floor(
|
|
583
674
|
((this.now() - this.startMs) * this.sampleRate) / 1000,
|
|
@@ -592,6 +683,7 @@ export class AudioEngine {
|
|
|
592
683
|
public async stopAsync(): Promise<void> {
|
|
593
684
|
this.generation += 1;
|
|
594
685
|
this.lastLoopPcm = undefined;
|
|
686
|
+
this.lastMaster = undefined;
|
|
595
687
|
this.respawns = 0;
|
|
596
688
|
if (this.respawnTimer) clearTimeout(this.respawnTimer);
|
|
597
689
|
this.respawnTimer = undefined;
|
|
@@ -603,6 +695,7 @@ export class AudioEngine {
|
|
|
603
695
|
if (this.monitoring && this.child) {
|
|
604
696
|
// Play mode: the transport stopped, the live player keeps running.
|
|
605
697
|
this.loop = undefined;
|
|
698
|
+
this.endFades();
|
|
606
699
|
return;
|
|
607
700
|
}
|
|
608
701
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -610,6 +703,7 @@ export class AudioEngine {
|
|
|
610
703
|
const child = this.child;
|
|
611
704
|
this.child = undefined;
|
|
612
705
|
this.loop = undefined;
|
|
706
|
+
this.endFades();
|
|
613
707
|
if (child) {
|
|
614
708
|
try {
|
|
615
709
|
child.stdin.end?.();
|
|
@@ -646,6 +740,7 @@ export class AudioEngine {
|
|
|
646
740
|
if (remaining > this.sampleRate) {
|
|
647
741
|
const skipped = remaining - this.sampleRate;
|
|
648
742
|
if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
|
|
743
|
+
this.endFades();
|
|
649
744
|
this.written += skipped;
|
|
650
745
|
remaining = this.sampleRate;
|
|
651
746
|
}
|
|
@@ -663,6 +758,7 @@ export class AudioEngine {
|
|
|
663
758
|
offset += take;
|
|
664
759
|
this.cursor = (this.cursor + take) % loop.frames;
|
|
665
760
|
}
|
|
761
|
+
if (loop) this.crossfade(out, remaining);
|
|
666
762
|
this.mixLive(out, this.written, remaining);
|
|
667
763
|
this.written += remaining;
|
|
668
764
|
try {
|
|
@@ -673,6 +769,49 @@ export class AudioEngine {
|
|
|
673
769
|
}
|
|
674
770
|
}
|
|
675
771
|
|
|
772
|
+
/** Equal-power gain of a fade-in `done` frames along (0 to 1). */
|
|
773
|
+
private rampGain(done: number): number {
|
|
774
|
+
return Math.sin((Math.PI / 2) * Math.min(1, done / this.fadeFrames));
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Blend the start of a block (already the current loop) with the loops
|
|
779
|
+
* fading out: new·sin + old·cos over `fadeFrames`, both read at the same
|
|
780
|
+
* beat, so a swap never steps the waveform.
|
|
781
|
+
*/
|
|
782
|
+
private crossfade(out: Int16Array, frames: number): void {
|
|
783
|
+
if (this.fadeIn === undefined) return;
|
|
784
|
+
const start = this.fadeIn;
|
|
785
|
+
const count = Math.min(frames, this.fadeFrames - start);
|
|
786
|
+
for (let index = 0; index < count; index += 1) {
|
|
787
|
+
const gain = this.rampGain(start + index);
|
|
788
|
+
const at = index * RENDER_CHANNELS;
|
|
789
|
+
let left = out[at]! * gain;
|
|
790
|
+
let right = out[at + 1]! * gain;
|
|
791
|
+
for (const fade of this.fades) {
|
|
792
|
+
if (fade.done >= this.fadeFrames) continue;
|
|
793
|
+
const g =
|
|
794
|
+
fade.from * Math.cos((Math.PI / 2) * (fade.done / this.fadeFrames));
|
|
795
|
+
const from = fade.cursor * RENDER_CHANNELS;
|
|
796
|
+
left += fade.loop.pcm[from]! * g;
|
|
797
|
+
right += fade.loop.pcm[from + 1]! * g;
|
|
798
|
+
fade.cursor = (fade.cursor + 1) % fade.loop.frames;
|
|
799
|
+
fade.done += 1;
|
|
800
|
+
}
|
|
801
|
+
out[at] = clamp16(left);
|
|
802
|
+
out[at + 1] = clamp16(right);
|
|
803
|
+
}
|
|
804
|
+
this.fadeIn = start + count;
|
|
805
|
+
// Older fades that began earlier finish before the newest fade-in does.
|
|
806
|
+
this.fades = this.fades.filter((fade) => fade.done < this.fadeFrames);
|
|
807
|
+
if (this.fadeIn >= this.fadeFrames) this.endFades();
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private endFades(): void {
|
|
811
|
+
this.fades = [];
|
|
812
|
+
this.fadeIn = undefined;
|
|
813
|
+
}
|
|
814
|
+
|
|
676
815
|
/** Wall time (monotonic ms) stream frame `frame` sounds at. */
|
|
677
816
|
private frameMs(frame: number): number {
|
|
678
817
|
return this.startMs + (frame * 1000) / this.sampleRate;
|
|
@@ -736,15 +875,19 @@ export class AudioEngine {
|
|
|
736
875
|
}
|
|
737
876
|
|
|
738
877
|
private toLoop(render: LoopRender, score: TrackScore): Loop {
|
|
878
|
+
const timeline = playbackTime(score);
|
|
879
|
+
const map = transportMapFor(timeline);
|
|
739
880
|
return {
|
|
740
881
|
pcm: render.pcm,
|
|
741
882
|
frames: render.frames,
|
|
742
|
-
framesPerBeat: (60 * this.sampleRate) /
|
|
883
|
+
framesPerBeat: (60 * this.sampleRate) / timeline.tempoBpm,
|
|
884
|
+
sampleRate: this.sampleRate,
|
|
885
|
+
...(map ? { map } : {}),
|
|
743
886
|
};
|
|
744
887
|
}
|
|
745
888
|
|
|
746
889
|
private frameForBeat(loop: Loop, beat: number): number {
|
|
747
|
-
const frame = Math.round(Math.max(0, beat)
|
|
890
|
+
const frame = Math.round(frameOfBeat(loop, Math.max(0, beat)));
|
|
748
891
|
return ((frame % loop.frames) + loop.frames) % loop.frames;
|
|
749
892
|
}
|
|
750
893
|
|
|
@@ -752,17 +895,28 @@ export class AudioEngine {
|
|
|
752
895
|
const previous = this.loop;
|
|
753
896
|
// Keep the musical position: the same beat inside the new loop.
|
|
754
897
|
let cursor = previous
|
|
755
|
-
? this.frameForBeat(next, this.cursor
|
|
898
|
+
? this.frameForBeat(next, beatOfFrame(previous, this.cursor))
|
|
756
899
|
: 0;
|
|
757
900
|
if (beat !== undefined) {
|
|
758
901
|
// Re-anchor to the transport only when it disagrees audibly (a seek
|
|
759
902
|
// or a tempo change); sub-frame rounding must not click on edits.
|
|
760
|
-
|
|
903
|
+
// The next frame written sounds `queuedFrames` after now.
|
|
904
|
+
const anchored = this.frameForBeat(
|
|
905
|
+
next,
|
|
906
|
+
beatAfter(next, beat, this.queuedFrames()),
|
|
907
|
+
);
|
|
761
908
|
const distance = Math.abs(anchored - cursor);
|
|
762
909
|
const wrapped = Math.min(distance, next.frames - distance);
|
|
763
910
|
if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
|
|
764
911
|
cursor = anchored;
|
|
765
912
|
}
|
|
913
|
+
if (previous && this.child) {
|
|
914
|
+
// The outgoing loop keeps its current gain (below 1 when it was
|
|
915
|
+
// itself still fading in) and fades from there.
|
|
916
|
+
const from = this.fadeIn === undefined ? 1 : this.rampGain(this.fadeIn);
|
|
917
|
+
this.fades.push({ loop: previous, cursor: this.cursor, from, done: 0 });
|
|
918
|
+
this.fadeIn = 0;
|
|
919
|
+
}
|
|
766
920
|
this.cursor = cursor;
|
|
767
921
|
this.loop = next;
|
|
768
922
|
}
|
|
@@ -784,6 +938,7 @@ export class AudioEngine {
|
|
|
784
938
|
this.spawns += 1;
|
|
785
939
|
this.child = child;
|
|
786
940
|
this.loop = loop;
|
|
941
|
+
this.endFades();
|
|
787
942
|
this.startMs = this.now();
|
|
788
943
|
this.written = 0;
|
|
789
944
|
this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
|
|
@@ -835,7 +990,7 @@ export class AudioEngine {
|
|
|
835
990
|
if (generation !== this.generation || this.child || this.starting)
|
|
836
991
|
return;
|
|
837
992
|
const frame = heard + ((this.now() - diedAt) * this.sampleRate) / 1000;
|
|
838
|
-
const beat = loop ? frame
|
|
993
|
+
const beat = loop ? beatOfFrame(loop, frame) : 0;
|
|
839
994
|
this.starting = this.start(loop, beat).finally(() => {
|
|
840
995
|
this.starting = undefined;
|
|
841
996
|
});
|