@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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Send effects on the stereo pair: delay and reverb. Without their
|
|
3
|
+
* optional parameters both render exactly as the original renderer did.
|
|
4
|
+
*/
|
|
5
|
+
import type { Track } from "../../../core/score.ts";
|
|
6
|
+
import type { DecodedSample } from "../samples.ts";
|
|
7
|
+
import {
|
|
8
|
+
addConvolved,
|
|
9
|
+
builtinImpulse,
|
|
10
|
+
convolveRaw,
|
|
11
|
+
sampleImpulse,
|
|
12
|
+
type Impulse,
|
|
13
|
+
} from "./convolution.ts";
|
|
14
|
+
import {
|
|
15
|
+
CONTROL_SAMPLES,
|
|
16
|
+
OnePole,
|
|
17
|
+
Param,
|
|
18
|
+
interpolateAutomation,
|
|
19
|
+
type EffectContext,
|
|
20
|
+
} from "./common.ts";
|
|
21
|
+
|
|
22
|
+
/** Delay length in seconds: `time` when set, otherwise `beats` at the tempo. */
|
|
23
|
+
export function delaySeconds(track: Track, tempoBpm: number): number {
|
|
24
|
+
const delay = track.delay;
|
|
25
|
+
if (!delay) return 0;
|
|
26
|
+
return delay.time !== undefined && delay.time > 0
|
|
27
|
+
? delay.time
|
|
28
|
+
: (delay.beats * 60) / tempoBpm;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Stereo feedback delay. Absent `pingpong`, each side echoes its own input
|
|
33
|
+
* and feeds back into the opposite side (the original sound). With
|
|
34
|
+
* `pingpong`, the mono sum enters the left line only and every repeat
|
|
35
|
+
* crosses over, so echoes alternate left, right, left. `highcut` runs a
|
|
36
|
+
* one-pole low-pass on everything written into the lines, so each repeat
|
|
37
|
+
* is darker than the last. Feedback and mix follow their lanes. `wetOnly`
|
|
38
|
+
* (a shared orbit bus) replaces the input with the repeats alone.
|
|
39
|
+
*/
|
|
40
|
+
export function applyDelay(
|
|
41
|
+
left: Float64Array,
|
|
42
|
+
right: Float64Array,
|
|
43
|
+
track: Track,
|
|
44
|
+
context: EffectContext,
|
|
45
|
+
wetOnly = false,
|
|
46
|
+
): void {
|
|
47
|
+
const delay = track.delay;
|
|
48
|
+
if (!delay) return;
|
|
49
|
+
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
50
|
+
const mixLane = track.delayMixAutomation ?? [];
|
|
51
|
+
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
52
|
+
const { sampleRate, samplesPerTick } = context;
|
|
53
|
+
const length = Math.max(
|
|
54
|
+
1,
|
|
55
|
+
Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
|
|
56
|
+
);
|
|
57
|
+
const lineL = new Float64Array(length);
|
|
58
|
+
const lineR = new Float64Array(length);
|
|
59
|
+
let feedback = delay.feedback;
|
|
60
|
+
let mix = delay.mix;
|
|
61
|
+
const automated = feedbackLane.length > 0 || mixLane.length > 0;
|
|
62
|
+
const pingpong = delay.pingpong === true;
|
|
63
|
+
const highcut =
|
|
64
|
+
delay.highcut === undefined
|
|
65
|
+
? undefined
|
|
66
|
+
: [
|
|
67
|
+
new OnePole(delay.highcut, sampleRate),
|
|
68
|
+
new OnePole(delay.highcut, sampleRate),
|
|
69
|
+
];
|
|
70
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
71
|
+
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
72
|
+
const tick = index / samplesPerTick;
|
|
73
|
+
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
74
|
+
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
75
|
+
}
|
|
76
|
+
const slot = index % length;
|
|
77
|
+
const wetL = lineL[slot]!;
|
|
78
|
+
const wetR = lineR[slot]!;
|
|
79
|
+
const dryL = left[index]!;
|
|
80
|
+
const dryR = right[index]!;
|
|
81
|
+
let writeL: number;
|
|
82
|
+
let writeR: number;
|
|
83
|
+
if (pingpong) {
|
|
84
|
+
writeL = (dryL + dryR) * 0.5 + wetR * feedback;
|
|
85
|
+
writeR = wetL * feedback;
|
|
86
|
+
} else {
|
|
87
|
+
writeL = dryL + wetR * feedback;
|
|
88
|
+
writeR = dryR + wetL * feedback;
|
|
89
|
+
}
|
|
90
|
+
if (highcut) {
|
|
91
|
+
writeL = highcut[0]!.process(writeL);
|
|
92
|
+
writeR = highcut[1]!.process(writeR);
|
|
93
|
+
}
|
|
94
|
+
lineL[slot] = writeL;
|
|
95
|
+
lineR[slot] = writeR;
|
|
96
|
+
left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
|
|
97
|
+
right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Seconds until the track's delay decays below -60 dB, bounded. */
|
|
102
|
+
export function delayTailFor(track: Track, tempoBpm: number): number {
|
|
103
|
+
if (!track.delay) return 0;
|
|
104
|
+
const feedback = Math.max(
|
|
105
|
+
track.delay.feedback,
|
|
106
|
+
...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
|
|
107
|
+
);
|
|
108
|
+
const repeats =
|
|
109
|
+
feedback <= 0.001 ? 1 : Math.min(64, Math.log(0.001) / Math.log(feedback));
|
|
110
|
+
return delaySeconds(track, tempoBpm) * (repeats + 1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Freeverb comb and allpass tunings at 44.1 kHz; the right side is spread. */
|
|
114
|
+
const COMB_TUNING = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617] as const;
|
|
115
|
+
const ALLPASS_TUNING = [556, 441, 341, 225] as const;
|
|
116
|
+
const STEREO_SPREAD = 23;
|
|
117
|
+
const REVERB_INPUT_GAIN = 0.015;
|
|
118
|
+
const REVERB_WET_SCALE = 3;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Convolution wet level: an energy-normalized impulse on the summed pair,
|
|
122
|
+
* scaled so `mix` sounds as loud as the algorithmic tail at the same mix.
|
|
123
|
+
*/
|
|
124
|
+
const IR_WET_SCALE = 2.1;
|
|
125
|
+
|
|
126
|
+
/** Impulses decoded from samples, by sha256 and rate (small: a few IRs). */
|
|
127
|
+
const SAMPLE_IMPULSES = new Map<string, Impulse>();
|
|
128
|
+
const MAX_SAMPLE_IMPULSES = 8;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The impulse a track's `reverb.ir` names at this rate: a generated
|
|
132
|
+
* built-in, or the decoded sample in `irs`. Undefined when the track has no
|
|
133
|
+
* `ir` or its sample did not load (the algorithmic reverb then plays).
|
|
134
|
+
*/
|
|
135
|
+
export function reverbImpulse(
|
|
136
|
+
track: Track,
|
|
137
|
+
sampleRate: number,
|
|
138
|
+
irs?: ReadonlyMap<string, DecodedSample>,
|
|
139
|
+
): Impulse | undefined {
|
|
140
|
+
const ir = track.reverb?.ir;
|
|
141
|
+
if (!ir) return undefined;
|
|
142
|
+
if (ir.src.startsWith("builtin:"))
|
|
143
|
+
return builtinImpulse(ir.src.slice("builtin:".length), sampleRate);
|
|
144
|
+
const sample = irs?.get(track.id);
|
|
145
|
+
if (!sample) return undefined;
|
|
146
|
+
const key = `${sample.sha256}@${sampleRate}`;
|
|
147
|
+
let impulse = SAMPLE_IMPULSES.get(key);
|
|
148
|
+
if (!impulse) {
|
|
149
|
+
impulse = sampleImpulse(
|
|
150
|
+
`${ir.src}#${sample.sha256}`,
|
|
151
|
+
sample.mono,
|
|
152
|
+
sample.sampleRate,
|
|
153
|
+
sampleRate,
|
|
154
|
+
);
|
|
155
|
+
if (SAMPLE_IMPULSES.size >= MAX_SAMPLE_IMPULSES)
|
|
156
|
+
SAMPLE_IMPULSES.delete(SAMPLE_IMPULSES.keys().next().value!);
|
|
157
|
+
SAMPLE_IMPULSES.set(key, impulse);
|
|
158
|
+
}
|
|
159
|
+
return impulse;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Seconds of reverb tail a render keeps for this track. */
|
|
163
|
+
export function reverbTailFor(
|
|
164
|
+
track: Track,
|
|
165
|
+
sampleRate = 44_100,
|
|
166
|
+
irs?: ReadonlyMap<string, DecodedSample>,
|
|
167
|
+
): number {
|
|
168
|
+
const reverb = track.reverb;
|
|
169
|
+
const lane = track.fxAutomation?.["reverb-mix"] ?? [];
|
|
170
|
+
if (!reverb || (reverb.mix <= 0 && lane.length === 0)) return 0;
|
|
171
|
+
const impulse = reverbImpulse(track, sampleRate, irs);
|
|
172
|
+
const decay = impulse
|
|
173
|
+
? impulse.left.length / sampleRate
|
|
174
|
+
: (reverb.fade ?? 1 + 3 * reverb.size);
|
|
175
|
+
return Math.min(20, decay + (reverb.predelay ?? 0));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Schroeder–Moorer reverb in the Freeverb arrangement: eight damped
|
|
180
|
+
* feedback combs then four allpasses per side, the right side detuned.
|
|
181
|
+
* `size` sets comb feedback and damping; `fade` instead sets each comb's
|
|
182
|
+
* feedback for an exact -60 dB decay time; `dim` sets the in-loop damping
|
|
183
|
+
* frequency, `lowpass` filters the input and `predelay` delays it.
|
|
184
|
+
* `wetOnly` (a shared orbit bus) replaces the input with the tail alone.
|
|
185
|
+
*
|
|
186
|
+
* It runs in two steps: `reverbWet` (the tail before its mix gain, which
|
|
187
|
+
* does not depend on `mix`) and `addReverbWet`. The stem cache keeps the
|
|
188
|
+
* first, so a mix-only edit skips the room entirely.
|
|
189
|
+
*/
|
|
190
|
+
export function applyReverb(
|
|
191
|
+
left: Float64Array,
|
|
192
|
+
right: Float64Array,
|
|
193
|
+
track: Track,
|
|
194
|
+
context: EffectContext,
|
|
195
|
+
wetOnly = false,
|
|
196
|
+
): void {
|
|
197
|
+
const wet = reverbWet(left, right, track, context);
|
|
198
|
+
if (wet) addReverbWet(left, right, wet, track, context, wetOnly);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** A reverb's tail before its mix gain (`scale` for an impulse response). */
|
|
202
|
+
export type ReverbWet = Readonly<{
|
|
203
|
+
left: Float64Array;
|
|
204
|
+
right: Float64Array;
|
|
205
|
+
/** The convolution's inverse-FFT scale; absent for the algorithmic room. */
|
|
206
|
+
scale?: number;
|
|
207
|
+
}>;
|
|
208
|
+
|
|
209
|
+
function mixParamOf(track: Track, context: EffectContext): Param {
|
|
210
|
+
return new Param(
|
|
211
|
+
track.reverb?.mix ?? 0,
|
|
212
|
+
track.fxAutomation?.["reverb-mix"],
|
|
213
|
+
context.samplesPerTick,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Whether the track's reverb sounds at all (a mix above 0, or a lane). */
|
|
218
|
+
export function reverbActive(track: Track): boolean {
|
|
219
|
+
const reverb = track.reverb;
|
|
220
|
+
if (!reverb) return false;
|
|
221
|
+
return (
|
|
222
|
+
reverb.mix > 0 || (track.fxAutomation?.["reverb-mix"] ?? []).length > 0
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The reverb tail of the pair, before the mix gain; `left`/`right` are
|
|
228
|
+
* read, not changed. Undefined when the reverb is off.
|
|
229
|
+
*/
|
|
230
|
+
export function reverbWet(
|
|
231
|
+
left: Float64Array,
|
|
232
|
+
right: Float64Array,
|
|
233
|
+
track: Track,
|
|
234
|
+
context: EffectContext,
|
|
235
|
+
): ReverbWet | undefined {
|
|
236
|
+
const reverb = track.reverb;
|
|
237
|
+
if (!reverb || !reverbActive(track)) return undefined;
|
|
238
|
+
const { sampleRate } = context;
|
|
239
|
+
const impulse = reverbImpulse(track, sampleRate, context.irs);
|
|
240
|
+
if (impulse) return convolutionWet(left, right, track, context, impulse);
|
|
241
|
+
const scale = sampleRate / 44_100;
|
|
242
|
+
const sizeFeedback = 0.7 + 0.28 * reverb.size;
|
|
243
|
+
const damp =
|
|
244
|
+
reverb.dim === undefined
|
|
245
|
+
? 0.2 + 0.3 * reverb.size
|
|
246
|
+
: Math.exp(
|
|
247
|
+
(-2 * Math.PI * Math.min(reverb.dim, sampleRate * 0.45)) / sampleRate,
|
|
248
|
+
);
|
|
249
|
+
const channel = (spread: number) => ({
|
|
250
|
+
combs: COMB_TUNING.map((tuning) => {
|
|
251
|
+
const length = Math.max(1, Math.round((tuning + spread) * scale));
|
|
252
|
+
return {
|
|
253
|
+
buffer: new Float64Array(length),
|
|
254
|
+
index: 0,
|
|
255
|
+
store: 0,
|
|
256
|
+
feedback:
|
|
257
|
+
reverb.fade === undefined
|
|
258
|
+
? sizeFeedback
|
|
259
|
+
: Math.min(0.995, 10 ** ((-3 * length) / sampleRate / reverb.fade)),
|
|
260
|
+
};
|
|
261
|
+
}),
|
|
262
|
+
allpasses: ALLPASS_TUNING.map((tuning) => ({
|
|
263
|
+
buffer: new Float64Array(
|
|
264
|
+
Math.max(1, Math.round((tuning + spread) * scale)),
|
|
265
|
+
),
|
|
266
|
+
index: 0,
|
|
267
|
+
})),
|
|
268
|
+
});
|
|
269
|
+
const sides = [channel(0), channel(STEREO_SPREAD)] as const;
|
|
270
|
+
const process = (side: (typeof sides)[number], input: number): number => {
|
|
271
|
+
let output = 0;
|
|
272
|
+
for (const comb of side.combs) {
|
|
273
|
+
const delayed = comb.buffer[comb.index]!;
|
|
274
|
+
comb.store = delayed * (1 - damp) + comb.store * damp;
|
|
275
|
+
comb.buffer[comb.index] = input + comb.store * comb.feedback;
|
|
276
|
+
comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
|
|
277
|
+
output += delayed;
|
|
278
|
+
}
|
|
279
|
+
for (const allpass of side.allpasses) {
|
|
280
|
+
const delayed = allpass.buffer[allpass.index]!;
|
|
281
|
+
allpass.buffer[allpass.index] = output + delayed * 0.5;
|
|
282
|
+
output = delayed - output;
|
|
283
|
+
allpass.index =
|
|
284
|
+
allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
|
|
285
|
+
}
|
|
286
|
+
return output;
|
|
287
|
+
};
|
|
288
|
+
const inputFilter =
|
|
289
|
+
reverb.lowpass === undefined
|
|
290
|
+
? undefined
|
|
291
|
+
: new OnePole(reverb.lowpass, sampleRate);
|
|
292
|
+
const predelaySamples = Math.round((reverb.predelay ?? 0) * sampleRate);
|
|
293
|
+
const predelay =
|
|
294
|
+
predelaySamples > 0 ? new Float64Array(predelaySamples) : undefined;
|
|
295
|
+
const wetL = new Float64Array(left.length);
|
|
296
|
+
const wetR = new Float64Array(left.length);
|
|
297
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
298
|
+
let input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
|
|
299
|
+
if (inputFilter) input = inputFilter.process(input);
|
|
300
|
+
if (predelay) {
|
|
301
|
+
const slot = index % predelaySamples;
|
|
302
|
+
const delayed = predelay[slot]!;
|
|
303
|
+
predelay[slot] = input;
|
|
304
|
+
input = delayed;
|
|
305
|
+
}
|
|
306
|
+
wetL[index] = process(sides[0], input);
|
|
307
|
+
wetR[index] = process(sides[1], input);
|
|
308
|
+
}
|
|
309
|
+
return { left: wetL, right: wetR };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Add (or with `wetOnly`, write) a tail from `reverbWet` at the track's
|
|
314
|
+
* mix and mix lane, in the order the one-pass reverb always used, so a
|
|
315
|
+
* cached tail mixes to the same samples.
|
|
316
|
+
*/
|
|
317
|
+
export function addReverbWet(
|
|
318
|
+
left: Float64Array,
|
|
319
|
+
right: Float64Array,
|
|
320
|
+
tail: ReverbWet,
|
|
321
|
+
track: Track,
|
|
322
|
+
context: EffectContext,
|
|
323
|
+
wetOnly = false,
|
|
324
|
+
): void {
|
|
325
|
+
const mixParam = mixParamOf(track, context);
|
|
326
|
+
if (tail.scale !== undefined) {
|
|
327
|
+
if (wetOnly) {
|
|
328
|
+
left.fill(0);
|
|
329
|
+
right.fill(0);
|
|
330
|
+
}
|
|
331
|
+
if (tail.scale === 0) return;
|
|
332
|
+
let wet = mixParam.fallback * IR_WET_SCALE;
|
|
333
|
+
addConvolved(
|
|
334
|
+
tail.left,
|
|
335
|
+
tail.right,
|
|
336
|
+
tail.scale,
|
|
337
|
+
left,
|
|
338
|
+
right,
|
|
339
|
+
wet,
|
|
340
|
+
mixParam.automated
|
|
341
|
+
? (index) => {
|
|
342
|
+
if (index % CONTROL_SAMPLES === 0)
|
|
343
|
+
wet = mixParam.at(index) * IR_WET_SCALE;
|
|
344
|
+
return wet;
|
|
345
|
+
}
|
|
346
|
+
: undefined,
|
|
347
|
+
);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
let wet = mixParam.fallback * REVERB_WET_SCALE;
|
|
351
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
352
|
+
if (mixParam.automated && index % CONTROL_SAMPLES === 0)
|
|
353
|
+
wet = mixParam.at(index) * REVERB_WET_SCALE;
|
|
354
|
+
if (wetOnly) {
|
|
355
|
+
left[index] = tail.left[index]! * wet;
|
|
356
|
+
right[index] = tail.right[index]! * wet;
|
|
357
|
+
} else {
|
|
358
|
+
left[index]! += tail.left[index]! * wet;
|
|
359
|
+
right[index]! += tail.right[index]! * wet;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* `reverb.ir`: the summed pair (through `lowpass` and `predelay`, as the
|
|
366
|
+
* algorithmic reverb) convolved with the impulse and added at `mix`.
|
|
367
|
+
* `size`, `fade` and `dim` describe the algorithmic tail and do nothing
|
|
368
|
+
* here: the impulse is the room.
|
|
369
|
+
*/
|
|
370
|
+
function convolutionWet(
|
|
371
|
+
left: Float64Array,
|
|
372
|
+
right: Float64Array,
|
|
373
|
+
track: Track,
|
|
374
|
+
context: EffectContext,
|
|
375
|
+
impulse: Impulse,
|
|
376
|
+
): ReverbWet {
|
|
377
|
+
const reverb = track.reverb!;
|
|
378
|
+
const { sampleRate } = context;
|
|
379
|
+
const input = new Float64Array(left.length);
|
|
380
|
+
const inputFilter =
|
|
381
|
+
reverb.lowpass === undefined
|
|
382
|
+
? undefined
|
|
383
|
+
: new OnePole(reverb.lowpass, sampleRate);
|
|
384
|
+
const offset = Math.round((reverb.predelay ?? 0) * sampleRate);
|
|
385
|
+
for (let index = 0; index + offset < left.length; index += 1) {
|
|
386
|
+
let value = (left[index]! + right[index]!) * 0.5;
|
|
387
|
+
if (inputFilter) value = inputFilter.process(value);
|
|
388
|
+
input[index + offset] = value;
|
|
389
|
+
}
|
|
390
|
+
const wetL = new Float64Array(left.length);
|
|
391
|
+
const wetR = new Float64Array(left.length);
|
|
392
|
+
const scale = convolveRaw(input, impulse, wetL, wetR);
|
|
393
|
+
return { left: wetL, right: wetR, scale };
|
|
394
|
+
}
|
package/src/audio/engine.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { LoopPlayer } from "./player.ts";
|
|
|
4
4
|
import { LoopRenderer, type LoopRender } from "./renderer.ts";
|
|
5
5
|
import { clickSounds, clicksIn, type ClickLevel } from "./click.ts";
|
|
6
6
|
import type { LiveNotePcm } from "./live.ts";
|
|
7
|
+
import { levelOf, type SoundLevel } from "./preview.ts";
|
|
7
8
|
import { DEFAULT_SAMPLE_RATE, RENDER_CHANNELS } from "./wav.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -169,6 +170,17 @@ function parseCommand(value: string): string[] {
|
|
|
169
170
|
return parts;
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Swaps crossfade old to new over this window with equal-power gains
|
|
175
|
+
* (cos/sin), reading both loops at the same beat. 20 ms is long enough that
|
|
176
|
+
* the step a hard cut makes is spread below ~50 Hz (no click, even on a
|
|
177
|
+
* full-scale low bass), short enough to sit inside the transport's 30 ms
|
|
178
|
+
* resync tolerance and below the shortest audible echo (~30-50 ms), so an
|
|
179
|
+
* edit never sounds smeared or doubled. Playback only: renders, exports and
|
|
180
|
+
* the loop buffers themselves never pass through it.
|
|
181
|
+
*/
|
|
182
|
+
export const SWAP_FADE_SECONDS = 0.02;
|
|
183
|
+
|
|
172
184
|
/** Swaps re-anchor to the transport only past this disagreement. */
|
|
173
185
|
const RESYNC_SECONDS = 0.03;
|
|
174
186
|
|
|
@@ -227,6 +239,16 @@ type Loop = Readonly<{
|
|
|
227
239
|
framesPerBeat: number;
|
|
228
240
|
}>;
|
|
229
241
|
|
|
242
|
+
/** A loop fading out after a swap, read on from where it was. */
|
|
243
|
+
type FadeOut = {
|
|
244
|
+
loop: Loop;
|
|
245
|
+
cursor: number;
|
|
246
|
+
/** Its gain when the fade began (below 1 when swapped out mid-fade). */
|
|
247
|
+
from: number;
|
|
248
|
+
/** Frames of the fade already written. */
|
|
249
|
+
done: number;
|
|
250
|
+
};
|
|
251
|
+
|
|
230
252
|
/**
|
|
231
253
|
* The click bus: the engine asks `beatAt` for the transport beat at a stream
|
|
232
254
|
* frame's wall time (undefined while the transport is stopped; negative
|
|
@@ -292,6 +314,11 @@ export class AudioEngine {
|
|
|
292
314
|
private child: PlayerProcess | undefined;
|
|
293
315
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
294
316
|
private loop: Loop | undefined;
|
|
317
|
+
/** Loops fading out after swaps (see `SWAP_FADE_SECONDS`). */
|
|
318
|
+
private fades: FadeOut[] = [];
|
|
319
|
+
/** Frames of the current loop's fade-in already written, while fading. */
|
|
320
|
+
private fadeIn: number | undefined;
|
|
321
|
+
private readonly fadeFrames: number;
|
|
295
322
|
/** Monotonic ms that stream frame 0 belongs to. */
|
|
296
323
|
private startMs = 0;
|
|
297
324
|
/** Stream frames written since `startMs`. */
|
|
@@ -304,10 +331,15 @@ export class AudioEngine {
|
|
|
304
331
|
private queued: PlayRequest | undefined;
|
|
305
332
|
private draining = false;
|
|
306
333
|
private lastRenderMs = 0;
|
|
334
|
+
/** The loop last swapped in, for `level` (measured on demand, once). */
|
|
335
|
+
private lastLoopPcm: Int16Array | undefined;
|
|
336
|
+
private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
|
|
307
337
|
private respawns = 0;
|
|
308
338
|
private respawnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
309
339
|
/** Play mode: keep a player running without a loop for live voices. */
|
|
310
340
|
private monitoring = false;
|
|
341
|
+
/** A lead set by `setLeadMs` (play mode, auditions) is not adapted. */
|
|
342
|
+
private leadPinned = false;
|
|
311
343
|
private readonly voices = new Map<number, LiveVoice>();
|
|
312
344
|
private click: ClickBus | undefined;
|
|
313
345
|
private clickVoices: { sound: Float32Array; position: number }[] = [];
|
|
@@ -322,6 +354,10 @@ export class AudioEngine {
|
|
|
322
354
|
((options.leadMs ?? 200) * this.sampleRate) / 1000,
|
|
323
355
|
);
|
|
324
356
|
this.defaultLeadFrames = this.leadFrames;
|
|
357
|
+
this.fadeFrames = Math.max(
|
|
358
|
+
1,
|
|
359
|
+
Math.round(SWAP_FADE_SECONDS * this.sampleRate),
|
|
360
|
+
);
|
|
325
361
|
this.tickMs = options.tickMs ?? 20;
|
|
326
362
|
this.now = options.now ?? (() => performance.now());
|
|
327
363
|
this.useTimer = options.timer ?? true;
|
|
@@ -359,6 +395,7 @@ export class AudioEngine {
|
|
|
359
395
|
* after it is pressed); `undefined` restores the constructed lead.
|
|
360
396
|
*/
|
|
361
397
|
public setLeadMs(ms: number | undefined): void {
|
|
398
|
+
this.leadPinned = ms !== undefined;
|
|
362
399
|
this.leadFrames =
|
|
363
400
|
ms === undefined
|
|
364
401
|
? this.defaultLeadFrames
|
|
@@ -371,6 +408,19 @@ export class AudioEngine {
|
|
|
371
408
|
}
|
|
372
409
|
|
|
373
410
|
/** Whether live voices and the click can sound (a streaming backend). */
|
|
411
|
+
/**
|
|
412
|
+
* RMS, peak and clips of the loop now playing (the audition meter);
|
|
413
|
+
* measured once per render, on first read. Undefined before any render
|
|
414
|
+
* or with no audio device.
|
|
415
|
+
*/
|
|
416
|
+
public get level(): SoundLevel | undefined {
|
|
417
|
+
const pcm = this.lastLoopPcm;
|
|
418
|
+
if (!pcm) return undefined;
|
|
419
|
+
if (this.measured?.pcm !== pcm)
|
|
420
|
+
this.measured = { pcm, level: levelOf(pcm) };
|
|
421
|
+
return this.measured.level;
|
|
422
|
+
}
|
|
423
|
+
|
|
374
424
|
public get canMonitor(): boolean {
|
|
375
425
|
return this.info.backend !== "none" && !this.fallback;
|
|
376
426
|
}
|
|
@@ -451,10 +501,10 @@ export class AudioEngine {
|
|
|
451
501
|
|
|
452
502
|
/** Frames kept queued ahead of the clock right now. */
|
|
453
503
|
public get lead(): number {
|
|
454
|
-
// Play mode
|
|
455
|
-
// render delays the swap, not the stream.
|
|
504
|
+
// Play mode and auditions keep their short lead: renders run
|
|
505
|
+
// off-thread, so a slow render delays the swap, not the stream.
|
|
456
506
|
const adaptive =
|
|
457
|
-
this.monitoring && this.renderer.offThread
|
|
507
|
+
(this.monitoring || this.leadPinned) && this.renderer.offThread
|
|
458
508
|
? 0
|
|
459
509
|
: Math.round(
|
|
460
510
|
(LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
|
|
@@ -499,8 +549,10 @@ export class AudioEngine {
|
|
|
499
549
|
const render = await this.renderer.render(request.score);
|
|
500
550
|
this.lastRenderMs = render.renderMs;
|
|
501
551
|
// Stopped while rendering: the result is stale, not an error.
|
|
502
|
-
if (request.generation === this.generation)
|
|
552
|
+
if (request.generation === this.generation) {
|
|
503
553
|
await this.apply(this.toLoop(render, request.score), request);
|
|
554
|
+
this.lastLoopPcm = render.pcm;
|
|
555
|
+
}
|
|
504
556
|
for (const waiter of request.waiters) waiter.resolve();
|
|
505
557
|
} catch (error) {
|
|
506
558
|
for (const waiter of request.waiters)
|
|
@@ -569,6 +621,7 @@ export class AudioEngine {
|
|
|
569
621
|
|
|
570
622
|
public async stopAsync(): Promise<void> {
|
|
571
623
|
this.generation += 1;
|
|
624
|
+
this.lastLoopPcm = undefined;
|
|
572
625
|
this.respawns = 0;
|
|
573
626
|
if (this.respawnTimer) clearTimeout(this.respawnTimer);
|
|
574
627
|
this.respawnTimer = undefined;
|
|
@@ -580,6 +633,7 @@ export class AudioEngine {
|
|
|
580
633
|
if (this.monitoring && this.child) {
|
|
581
634
|
// Play mode: the transport stopped, the live player keeps running.
|
|
582
635
|
this.loop = undefined;
|
|
636
|
+
this.endFades();
|
|
583
637
|
return;
|
|
584
638
|
}
|
|
585
639
|
if (this.timer) clearInterval(this.timer);
|
|
@@ -587,6 +641,7 @@ export class AudioEngine {
|
|
|
587
641
|
const child = this.child;
|
|
588
642
|
this.child = undefined;
|
|
589
643
|
this.loop = undefined;
|
|
644
|
+
this.endFades();
|
|
590
645
|
if (child) {
|
|
591
646
|
try {
|
|
592
647
|
child.stdin.end?.();
|
|
@@ -623,6 +678,7 @@ export class AudioEngine {
|
|
|
623
678
|
if (remaining > this.sampleRate) {
|
|
624
679
|
const skipped = remaining - this.sampleRate;
|
|
625
680
|
if (loop) this.cursor = (this.cursor + skipped) % loop.frames;
|
|
681
|
+
this.endFades();
|
|
626
682
|
this.written += skipped;
|
|
627
683
|
remaining = this.sampleRate;
|
|
628
684
|
}
|
|
@@ -640,6 +696,7 @@ export class AudioEngine {
|
|
|
640
696
|
offset += take;
|
|
641
697
|
this.cursor = (this.cursor + take) % loop.frames;
|
|
642
698
|
}
|
|
699
|
+
if (loop) this.crossfade(out, remaining);
|
|
643
700
|
this.mixLive(out, this.written, remaining);
|
|
644
701
|
this.written += remaining;
|
|
645
702
|
try {
|
|
@@ -650,6 +707,49 @@ export class AudioEngine {
|
|
|
650
707
|
}
|
|
651
708
|
}
|
|
652
709
|
|
|
710
|
+
/** Equal-power gain of a fade-in `done` frames along (0 to 1). */
|
|
711
|
+
private rampGain(done: number): number {
|
|
712
|
+
return Math.sin((Math.PI / 2) * Math.min(1, done / this.fadeFrames));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Blend the start of a block (already the current loop) with the loops
|
|
717
|
+
* fading out: new·sin + old·cos over `fadeFrames`, both read at the same
|
|
718
|
+
* beat, so a swap never steps the waveform.
|
|
719
|
+
*/
|
|
720
|
+
private crossfade(out: Int16Array, frames: number): void {
|
|
721
|
+
if (this.fadeIn === undefined) return;
|
|
722
|
+
const start = this.fadeIn;
|
|
723
|
+
const count = Math.min(frames, this.fadeFrames - start);
|
|
724
|
+
for (let index = 0; index < count; index += 1) {
|
|
725
|
+
const gain = this.rampGain(start + index);
|
|
726
|
+
const at = index * RENDER_CHANNELS;
|
|
727
|
+
let left = out[at]! * gain;
|
|
728
|
+
let right = out[at + 1]! * gain;
|
|
729
|
+
for (const fade of this.fades) {
|
|
730
|
+
if (fade.done >= this.fadeFrames) continue;
|
|
731
|
+
const g =
|
|
732
|
+
fade.from * Math.cos((Math.PI / 2) * (fade.done / this.fadeFrames));
|
|
733
|
+
const from = fade.cursor * RENDER_CHANNELS;
|
|
734
|
+
left += fade.loop.pcm[from]! * g;
|
|
735
|
+
right += fade.loop.pcm[from + 1]! * g;
|
|
736
|
+
fade.cursor = (fade.cursor + 1) % fade.loop.frames;
|
|
737
|
+
fade.done += 1;
|
|
738
|
+
}
|
|
739
|
+
out[at] = clamp16(left);
|
|
740
|
+
out[at + 1] = clamp16(right);
|
|
741
|
+
}
|
|
742
|
+
this.fadeIn = start + count;
|
|
743
|
+
// Older fades that began earlier finish before the newest fade-in does.
|
|
744
|
+
this.fades = this.fades.filter((fade) => fade.done < this.fadeFrames);
|
|
745
|
+
if (this.fadeIn >= this.fadeFrames) this.endFades();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private endFades(): void {
|
|
749
|
+
this.fades = [];
|
|
750
|
+
this.fadeIn = undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
653
753
|
/** Wall time (monotonic ms) stream frame `frame` sounds at. */
|
|
654
754
|
private frameMs(frame: number): number {
|
|
655
755
|
return this.startMs + (frame * 1000) / this.sampleRate;
|
|
@@ -740,6 +840,13 @@ export class AudioEngine {
|
|
|
740
840
|
if (!previous || wrapped > this.sampleRate * RESYNC_SECONDS)
|
|
741
841
|
cursor = anchored;
|
|
742
842
|
}
|
|
843
|
+
if (previous && this.child) {
|
|
844
|
+
// The outgoing loop keeps its current gain (below 1 when it was
|
|
845
|
+
// itself still fading in) and fades from there.
|
|
846
|
+
const from = this.fadeIn === undefined ? 1 : this.rampGain(this.fadeIn);
|
|
847
|
+
this.fades.push({ loop: previous, cursor: this.cursor, from, done: 0 });
|
|
848
|
+
this.fadeIn = 0;
|
|
849
|
+
}
|
|
743
850
|
this.cursor = cursor;
|
|
744
851
|
this.loop = next;
|
|
745
852
|
}
|
|
@@ -761,6 +868,7 @@ export class AudioEngine {
|
|
|
761
868
|
this.spawns += 1;
|
|
762
869
|
this.child = child;
|
|
763
870
|
this.loop = loop;
|
|
871
|
+
this.endFades();
|
|
764
872
|
this.startMs = this.now();
|
|
765
873
|
this.written = 0;
|
|
766
874
|
this.cursor = loop ? this.frameForBeat(loop, beat) : 0;
|