@hraness/dawg 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/DAWG.md +605 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +99 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +301 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +337 -0
- package/src/audio/engine.ts +27 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +470 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +280 -278
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1119 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +501 -0
- package/src/tui/euclid.ts +472 -0
- package/src/tui/menu.ts +1286 -244
- package/src/tui/play-chords.ts +538 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +281 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,337 @@
|
|
|
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
|
+
builtinImpulse,
|
|
9
|
+
convolveStereo,
|
|
10
|
+
sampleImpulse,
|
|
11
|
+
type Impulse,
|
|
12
|
+
} from "./convolution.ts";
|
|
13
|
+
import {
|
|
14
|
+
CONTROL_SAMPLES,
|
|
15
|
+
OnePole,
|
|
16
|
+
Param,
|
|
17
|
+
interpolateAutomation,
|
|
18
|
+
type EffectContext,
|
|
19
|
+
} from "./common.ts";
|
|
20
|
+
|
|
21
|
+
/** Delay length in seconds: `time` when set, otherwise `beats` at the tempo. */
|
|
22
|
+
export function delaySeconds(track: Track, tempoBpm: number): number {
|
|
23
|
+
const delay = track.delay;
|
|
24
|
+
if (!delay) return 0;
|
|
25
|
+
return delay.time !== undefined && delay.time > 0
|
|
26
|
+
? delay.time
|
|
27
|
+
: (delay.beats * 60) / tempoBpm;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Stereo feedback delay. Absent `pingpong`, each side echoes its own input
|
|
32
|
+
* and feeds back into the opposite side (the original sound). With
|
|
33
|
+
* `pingpong`, the mono sum enters the left line only and every repeat
|
|
34
|
+
* crosses over, so echoes alternate left, right, left. `highcut` runs a
|
|
35
|
+
* one-pole low-pass on everything written into the lines, so each repeat
|
|
36
|
+
* is darker than the last. Feedback and mix follow their lanes. `wetOnly`
|
|
37
|
+
* (a shared orbit bus) replaces the input with the repeats alone.
|
|
38
|
+
*/
|
|
39
|
+
export function applyDelay(
|
|
40
|
+
left: Float64Array,
|
|
41
|
+
right: Float64Array,
|
|
42
|
+
track: Track,
|
|
43
|
+
context: EffectContext,
|
|
44
|
+
wetOnly = false,
|
|
45
|
+
): void {
|
|
46
|
+
const delay = track.delay;
|
|
47
|
+
if (!delay) return;
|
|
48
|
+
const feedbackLane = track.delayFeedbackAutomation ?? [];
|
|
49
|
+
const mixLane = track.delayMixAutomation ?? [];
|
|
50
|
+
if (delay.mix <= 0 && mixLane.length === 0) return;
|
|
51
|
+
const { sampleRate, samplesPerTick } = context;
|
|
52
|
+
const length = Math.max(
|
|
53
|
+
1,
|
|
54
|
+
Math.round(delaySeconds(track, context.tempoBpm) * sampleRate),
|
|
55
|
+
);
|
|
56
|
+
const lineL = new Float64Array(length);
|
|
57
|
+
const lineR = new Float64Array(length);
|
|
58
|
+
let feedback = delay.feedback;
|
|
59
|
+
let mix = delay.mix;
|
|
60
|
+
const automated = feedbackLane.length > 0 || mixLane.length > 0;
|
|
61
|
+
const pingpong = delay.pingpong === true;
|
|
62
|
+
const highcut =
|
|
63
|
+
delay.highcut === undefined
|
|
64
|
+
? undefined
|
|
65
|
+
: [
|
|
66
|
+
new OnePole(delay.highcut, sampleRate),
|
|
67
|
+
new OnePole(delay.highcut, sampleRate),
|
|
68
|
+
];
|
|
69
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
70
|
+
if (automated && index % CONTROL_SAMPLES === 0) {
|
|
71
|
+
const tick = index / samplesPerTick;
|
|
72
|
+
feedback = interpolateAutomation(feedbackLane, tick, delay.feedback);
|
|
73
|
+
mix = interpolateAutomation(mixLane, tick, delay.mix);
|
|
74
|
+
}
|
|
75
|
+
const slot = index % length;
|
|
76
|
+
const wetL = lineL[slot]!;
|
|
77
|
+
const wetR = lineR[slot]!;
|
|
78
|
+
const dryL = left[index]!;
|
|
79
|
+
const dryR = right[index]!;
|
|
80
|
+
let writeL: number;
|
|
81
|
+
let writeR: number;
|
|
82
|
+
if (pingpong) {
|
|
83
|
+
writeL = (dryL + dryR) * 0.5 + wetR * feedback;
|
|
84
|
+
writeR = wetL * feedback;
|
|
85
|
+
} else {
|
|
86
|
+
writeL = dryL + wetR * feedback;
|
|
87
|
+
writeR = dryR + wetL * feedback;
|
|
88
|
+
}
|
|
89
|
+
if (highcut) {
|
|
90
|
+
writeL = highcut[0]!.process(writeL);
|
|
91
|
+
writeR = highcut[1]!.process(writeR);
|
|
92
|
+
}
|
|
93
|
+
lineL[slot] = writeL;
|
|
94
|
+
lineR[slot] = writeR;
|
|
95
|
+
left[index] = (wetOnly ? 0 : dryL) + wetL * mix;
|
|
96
|
+
right[index] = (wetOnly ? 0 : dryR) + wetR * mix;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Seconds until the track's delay decays below -60 dB, bounded. */
|
|
101
|
+
export function delayTailFor(track: Track, tempoBpm: number): number {
|
|
102
|
+
if (!track.delay) return 0;
|
|
103
|
+
const feedback = Math.max(
|
|
104
|
+
track.delay.feedback,
|
|
105
|
+
...(track.delayFeedbackAutomation ?? []).map((point) => point.value),
|
|
106
|
+
);
|
|
107
|
+
const repeats =
|
|
108
|
+
feedback <= 0.001 ? 1 : Math.min(64, Math.log(0.001) / Math.log(feedback));
|
|
109
|
+
return delaySeconds(track, tempoBpm) * (repeats + 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Freeverb comb and allpass tunings at 44.1 kHz; the right side is spread. */
|
|
113
|
+
const COMB_TUNING = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617] as const;
|
|
114
|
+
const ALLPASS_TUNING = [556, 441, 341, 225] as const;
|
|
115
|
+
const STEREO_SPREAD = 23;
|
|
116
|
+
const REVERB_INPUT_GAIN = 0.015;
|
|
117
|
+
const REVERB_WET_SCALE = 3;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Convolution wet level: an energy-normalized impulse on the summed pair,
|
|
121
|
+
* scaled so `mix` sounds as loud as the algorithmic tail at the same mix.
|
|
122
|
+
*/
|
|
123
|
+
const IR_WET_SCALE = 2.1;
|
|
124
|
+
|
|
125
|
+
/** Impulses decoded from samples, by sha256 and rate (small: a few IRs). */
|
|
126
|
+
const SAMPLE_IMPULSES = new Map<string, Impulse>();
|
|
127
|
+
const MAX_SAMPLE_IMPULSES = 8;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The impulse a track's `reverb.ir` names at this rate: a generated
|
|
131
|
+
* built-in, or the decoded sample in `irs`. Undefined when the track has no
|
|
132
|
+
* `ir` or its sample did not load (the algorithmic reverb then plays).
|
|
133
|
+
*/
|
|
134
|
+
export function reverbImpulse(
|
|
135
|
+
track: Track,
|
|
136
|
+
sampleRate: number,
|
|
137
|
+
irs?: ReadonlyMap<string, DecodedSample>,
|
|
138
|
+
): Impulse | undefined {
|
|
139
|
+
const ir = track.reverb?.ir;
|
|
140
|
+
if (!ir) return undefined;
|
|
141
|
+
if (ir.src.startsWith("builtin:"))
|
|
142
|
+
return builtinImpulse(ir.src.slice("builtin:".length), sampleRate);
|
|
143
|
+
const sample = irs?.get(track.id);
|
|
144
|
+
if (!sample) return undefined;
|
|
145
|
+
const key = `${sample.sha256}@${sampleRate}`;
|
|
146
|
+
let impulse = SAMPLE_IMPULSES.get(key);
|
|
147
|
+
if (!impulse) {
|
|
148
|
+
impulse = sampleImpulse(
|
|
149
|
+
`${ir.src}#${sample.sha256}`,
|
|
150
|
+
sample.mono,
|
|
151
|
+
sample.sampleRate,
|
|
152
|
+
sampleRate,
|
|
153
|
+
);
|
|
154
|
+
if (SAMPLE_IMPULSES.size >= MAX_SAMPLE_IMPULSES)
|
|
155
|
+
SAMPLE_IMPULSES.delete(SAMPLE_IMPULSES.keys().next().value!);
|
|
156
|
+
SAMPLE_IMPULSES.set(key, impulse);
|
|
157
|
+
}
|
|
158
|
+
return impulse;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Seconds of reverb tail a render keeps for this track. */
|
|
162
|
+
export function reverbTailFor(
|
|
163
|
+
track: Track,
|
|
164
|
+
sampleRate = 44_100,
|
|
165
|
+
irs?: ReadonlyMap<string, DecodedSample>,
|
|
166
|
+
): number {
|
|
167
|
+
const reverb = track.reverb;
|
|
168
|
+
const lane = track.fxAutomation?.["reverb-mix"] ?? [];
|
|
169
|
+
if (!reverb || (reverb.mix <= 0 && lane.length === 0)) return 0;
|
|
170
|
+
const impulse = reverbImpulse(track, sampleRate, irs);
|
|
171
|
+
const decay = impulse
|
|
172
|
+
? impulse.left.length / sampleRate
|
|
173
|
+
: (reverb.fade ?? 1 + 3 * reverb.size);
|
|
174
|
+
return Math.min(20, decay + (reverb.predelay ?? 0));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Schroeder–Moorer reverb in the Freeverb arrangement: eight damped
|
|
179
|
+
* feedback combs then four allpasses per side, the right side detuned.
|
|
180
|
+
* `size` sets comb feedback and damping; `fade` instead sets each comb's
|
|
181
|
+
* feedback for an exact -60 dB decay time; `dim` sets the in-loop damping
|
|
182
|
+
* frequency, `lowpass` filters the input and `predelay` delays it.
|
|
183
|
+
* `wetOnly` (a shared orbit bus) replaces the input with the tail alone.
|
|
184
|
+
*/
|
|
185
|
+
export function applyReverb(
|
|
186
|
+
left: Float64Array,
|
|
187
|
+
right: Float64Array,
|
|
188
|
+
track: Track,
|
|
189
|
+
context: EffectContext,
|
|
190
|
+
wetOnly = false,
|
|
191
|
+
): void {
|
|
192
|
+
const reverb = track.reverb;
|
|
193
|
+
if (!reverb) return;
|
|
194
|
+
const mixParam = new Param(
|
|
195
|
+
reverb.mix,
|
|
196
|
+
track.fxAutomation?.["reverb-mix"],
|
|
197
|
+
context.samplesPerTick,
|
|
198
|
+
);
|
|
199
|
+
if (reverb.mix <= 0 && !mixParam.automated) return;
|
|
200
|
+
const { sampleRate } = context;
|
|
201
|
+
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
|
+
}
|
|
214
|
+
const scale = sampleRate / 44_100;
|
|
215
|
+
const sizeFeedback = 0.7 + 0.28 * reverb.size;
|
|
216
|
+
const damp =
|
|
217
|
+
reverb.dim === undefined
|
|
218
|
+
? 0.2 + 0.3 * reverb.size
|
|
219
|
+
: Math.exp(
|
|
220
|
+
(-2 * Math.PI * Math.min(reverb.dim, sampleRate * 0.45)) / sampleRate,
|
|
221
|
+
);
|
|
222
|
+
let wet = reverb.mix * REVERB_WET_SCALE;
|
|
223
|
+
const channel = (spread: number) => ({
|
|
224
|
+
combs: COMB_TUNING.map((tuning) => {
|
|
225
|
+
const length = Math.max(1, Math.round((tuning + spread) * scale));
|
|
226
|
+
return {
|
|
227
|
+
buffer: new Float64Array(length),
|
|
228
|
+
index: 0,
|
|
229
|
+
store: 0,
|
|
230
|
+
feedback:
|
|
231
|
+
reverb.fade === undefined
|
|
232
|
+
? sizeFeedback
|
|
233
|
+
: Math.min(0.995, 10 ** ((-3 * length) / sampleRate / reverb.fade)),
|
|
234
|
+
};
|
|
235
|
+
}),
|
|
236
|
+
allpasses: ALLPASS_TUNING.map((tuning) => ({
|
|
237
|
+
buffer: new Float64Array(
|
|
238
|
+
Math.max(1, Math.round((tuning + spread) * scale)),
|
|
239
|
+
),
|
|
240
|
+
index: 0,
|
|
241
|
+
})),
|
|
242
|
+
});
|
|
243
|
+
const sides = [channel(0), channel(STEREO_SPREAD)] as const;
|
|
244
|
+
const process = (side: (typeof sides)[number], input: number): number => {
|
|
245
|
+
let output = 0;
|
|
246
|
+
for (const comb of side.combs) {
|
|
247
|
+
const delayed = comb.buffer[comb.index]!;
|
|
248
|
+
comb.store = delayed * (1 - damp) + comb.store * damp;
|
|
249
|
+
comb.buffer[comb.index] = input + comb.store * comb.feedback;
|
|
250
|
+
comb.index = comb.index + 1 === comb.buffer.length ? 0 : comb.index + 1;
|
|
251
|
+
output += delayed;
|
|
252
|
+
}
|
|
253
|
+
for (const allpass of side.allpasses) {
|
|
254
|
+
const delayed = allpass.buffer[allpass.index]!;
|
|
255
|
+
allpass.buffer[allpass.index] = output + delayed * 0.5;
|
|
256
|
+
output = delayed - output;
|
|
257
|
+
allpass.index =
|
|
258
|
+
allpass.index + 1 === allpass.buffer.length ? 0 : allpass.index + 1;
|
|
259
|
+
}
|
|
260
|
+
return output;
|
|
261
|
+
};
|
|
262
|
+
const inputFilter =
|
|
263
|
+
reverb.lowpass === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: new OnePole(reverb.lowpass, sampleRate);
|
|
266
|
+
const predelaySamples = Math.round((reverb.predelay ?? 0) * sampleRate);
|
|
267
|
+
const predelay =
|
|
268
|
+
predelaySamples > 0 ? new Float64Array(predelaySamples) : undefined;
|
|
269
|
+
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
|
+
let input = (left[index]! + right[index]!) * REVERB_INPUT_GAIN;
|
|
273
|
+
if (inputFilter) input = inputFilter.process(input);
|
|
274
|
+
if (predelay) {
|
|
275
|
+
const slot = index % predelaySamples;
|
|
276
|
+
const delayed = predelay[slot]!;
|
|
277
|
+
predelay[slot] = input;
|
|
278
|
+
input = delayed;
|
|
279
|
+
}
|
|
280
|
+
if (wetOnly) {
|
|
281
|
+
left[index] = process(sides[0], input) * wet;
|
|
282
|
+
right[index] = process(sides[1], input) * wet;
|
|
283
|
+
} else {
|
|
284
|
+
left[index]! += process(sides[0], input) * wet;
|
|
285
|
+
right[index]! += process(sides[1], input) * wet;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* `reverb.ir`: the summed pair (through `lowpass` and `predelay`, as the
|
|
292
|
+
* algorithmic reverb) convolved with the impulse and added at `mix`.
|
|
293
|
+
* `size`, `fade` and `dim` describe the algorithmic tail and do nothing
|
|
294
|
+
* here: the impulse is the room.
|
|
295
|
+
*/
|
|
296
|
+
function applyConvolutionReverb(
|
|
297
|
+
left: Float64Array,
|
|
298
|
+
right: Float64Array,
|
|
299
|
+
track: Track,
|
|
300
|
+
context: EffectContext,
|
|
301
|
+
impulse: Impulse,
|
|
302
|
+
mixParam: Param,
|
|
303
|
+
wetOnly: boolean,
|
|
304
|
+
): void {
|
|
305
|
+
const reverb = track.reverb!;
|
|
306
|
+
const { sampleRate } = context;
|
|
307
|
+
const input = new Float64Array(left.length);
|
|
308
|
+
const inputFilter =
|
|
309
|
+
reverb.lowpass === undefined
|
|
310
|
+
? undefined
|
|
311
|
+
: new OnePole(reverb.lowpass, sampleRate);
|
|
312
|
+
const offset = Math.round((reverb.predelay ?? 0) * sampleRate);
|
|
313
|
+
for (let index = 0; index + offset < left.length; index += 1) {
|
|
314
|
+
let value = (left[index]! + right[index]!) * 0.5;
|
|
315
|
+
if (inputFilter) value = inputFilter.process(value);
|
|
316
|
+
input[index + offset] = value;
|
|
317
|
+
}
|
|
318
|
+
if (wetOnly) {
|
|
319
|
+
left.fill(0);
|
|
320
|
+
right.fill(0);
|
|
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
|
+
);
|
|
337
|
+
}
|
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
|
/**
|
|
@@ -304,10 +305,15 @@ export class AudioEngine {
|
|
|
304
305
|
private queued: PlayRequest | undefined;
|
|
305
306
|
private draining = false;
|
|
306
307
|
private lastRenderMs = 0;
|
|
308
|
+
/** The loop last swapped in, for `level` (measured on demand, once). */
|
|
309
|
+
private lastLoopPcm: Int16Array | undefined;
|
|
310
|
+
private measured: { pcm: Int16Array; level: SoundLevel } | undefined;
|
|
307
311
|
private respawns = 0;
|
|
308
312
|
private respawnTimer: ReturnType<typeof setTimeout> | undefined;
|
|
309
313
|
/** Play mode: keep a player running without a loop for live voices. */
|
|
310
314
|
private monitoring = false;
|
|
315
|
+
/** A lead set by `setLeadMs` (play mode, auditions) is not adapted. */
|
|
316
|
+
private leadPinned = false;
|
|
311
317
|
private readonly voices = new Map<number, LiveVoice>();
|
|
312
318
|
private click: ClickBus | undefined;
|
|
313
319
|
private clickVoices: { sound: Float32Array; position: number }[] = [];
|
|
@@ -359,6 +365,7 @@ export class AudioEngine {
|
|
|
359
365
|
* after it is pressed); `undefined` restores the constructed lead.
|
|
360
366
|
*/
|
|
361
367
|
public setLeadMs(ms: number | undefined): void {
|
|
368
|
+
this.leadPinned = ms !== undefined;
|
|
362
369
|
this.leadFrames =
|
|
363
370
|
ms === undefined
|
|
364
371
|
? this.defaultLeadFrames
|
|
@@ -371,6 +378,19 @@ export class AudioEngine {
|
|
|
371
378
|
}
|
|
372
379
|
|
|
373
380
|
/** Whether live voices and the click can sound (a streaming backend). */
|
|
381
|
+
/**
|
|
382
|
+
* RMS, peak and clips of the loop now playing (the audition meter);
|
|
383
|
+
* measured once per render, on first read. Undefined before any render
|
|
384
|
+
* or with no audio device.
|
|
385
|
+
*/
|
|
386
|
+
public get level(): SoundLevel | undefined {
|
|
387
|
+
const pcm = this.lastLoopPcm;
|
|
388
|
+
if (!pcm) return undefined;
|
|
389
|
+
if (this.measured?.pcm !== pcm)
|
|
390
|
+
this.measured = { pcm, level: levelOf(pcm) };
|
|
391
|
+
return this.measured.level;
|
|
392
|
+
}
|
|
393
|
+
|
|
374
394
|
public get canMonitor(): boolean {
|
|
375
395
|
return this.info.backend !== "none" && !this.fallback;
|
|
376
396
|
}
|
|
@@ -451,10 +471,10 @@ export class AudioEngine {
|
|
|
451
471
|
|
|
452
472
|
/** Frames kept queued ahead of the clock right now. */
|
|
453
473
|
public get lead(): number {
|
|
454
|
-
// Play mode
|
|
455
|
-
// render delays the swap, not the stream.
|
|
474
|
+
// Play mode and auditions keep their short lead: renders run
|
|
475
|
+
// off-thread, so a slow render delays the swap, not the stream.
|
|
456
476
|
const adaptive =
|
|
457
|
-
this.monitoring && this.renderer.offThread
|
|
477
|
+
(this.monitoring || this.leadPinned) && this.renderer.offThread
|
|
458
478
|
? 0
|
|
459
479
|
: Math.round(
|
|
460
480
|
(LEAD_RENDER_FACTOR * this.lastRenderMs * this.sampleRate) / 1000,
|
|
@@ -499,8 +519,10 @@ export class AudioEngine {
|
|
|
499
519
|
const render = await this.renderer.render(request.score);
|
|
500
520
|
this.lastRenderMs = render.renderMs;
|
|
501
521
|
// Stopped while rendering: the result is stale, not an error.
|
|
502
|
-
if (request.generation === this.generation)
|
|
522
|
+
if (request.generation === this.generation) {
|
|
503
523
|
await this.apply(this.toLoop(render, request.score), request);
|
|
524
|
+
this.lastLoopPcm = render.pcm;
|
|
525
|
+
}
|
|
504
526
|
for (const waiter of request.waiters) waiter.resolve();
|
|
505
527
|
} catch (error) {
|
|
506
528
|
for (const waiter of request.waiters)
|
|
@@ -569,6 +591,7 @@ export class AudioEngine {
|
|
|
569
591
|
|
|
570
592
|
public async stopAsync(): Promise<void> {
|
|
571
593
|
this.generation += 1;
|
|
594
|
+
this.lastLoopPcm = undefined;
|
|
572
595
|
this.respawns = 0;
|
|
573
596
|
if (this.respawnTimer) clearTimeout(this.respawnTimer);
|
|
574
597
|
this.respawnTimer = undefined;
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drum kits: the synthesized kits from `core/kits.ts` rendered sample by
|
|
3
|
+
* sample, and the one catalog `/kit`, `/menu` and the agent list.
|
|
4
|
+
*
|
|
5
|
+
* The renderer calls `kitDrumSample` only for a track with `kit` set, so the
|
|
6
|
+
* default voices in `wav.ts` stay byte-identical. Everything here is plain
|
|
7
|
+
* arithmetic on the seeded noise `wav.ts` passes in, so a kit renders the
|
|
8
|
+
* same buffer on every run.
|
|
9
|
+
*/
|
|
10
|
+
import type { DrumVoice } from "../../core/drums.ts";
|
|
11
|
+
import {
|
|
12
|
+
SYNTH_KITS,
|
|
13
|
+
type DrumBodyParams,
|
|
14
|
+
type HatParams,
|
|
15
|
+
type SynthKit,
|
|
16
|
+
synthKit,
|
|
17
|
+
} from "../../core/kits.ts";
|
|
18
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
19
|
+
import { DEFAULT_KITS } from "./packs.ts";
|
|
20
|
+
|
|
21
|
+
/** Per-hit oscillator and filter state. */
|
|
22
|
+
export type KitVoiceState = {
|
|
23
|
+
phase: number;
|
|
24
|
+
band: number;
|
|
25
|
+
dark: number;
|
|
26
|
+
metalPrevious: number;
|
|
27
|
+
holdLeft: number;
|
|
28
|
+
held: number;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export function newKitVoiceState(): KitVoiceState {
|
|
32
|
+
return {
|
|
33
|
+
phase: 0,
|
|
34
|
+
band: 0,
|
|
35
|
+
dark: 0,
|
|
36
|
+
metalPrevious: 0,
|
|
37
|
+
holdLeft: 0,
|
|
38
|
+
held: 0,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 808 hi-hat oscillator frequencies (six detuned squares). */
|
|
43
|
+
const METAL_HZ = [205.3, 304.4, 369.6, 522.7, 540, 800] as const;
|
|
44
|
+
|
|
45
|
+
function body(
|
|
46
|
+
params: DrumBodyParams,
|
|
47
|
+
t: number,
|
|
48
|
+
noise: number,
|
|
49
|
+
state: KitVoiceState,
|
|
50
|
+
sampleRate: number,
|
|
51
|
+
): number {
|
|
52
|
+
const frequency =
|
|
53
|
+
params.base + params.sweep * Math.exp(-t * params.sweepRate);
|
|
54
|
+
state.phase += frequency / sampleRate;
|
|
55
|
+
return (
|
|
56
|
+
Math.sin(2 * Math.PI * state.phase) *
|
|
57
|
+
Math.exp(-t * params.decay) *
|
|
58
|
+
params.level +
|
|
59
|
+
noise * params.click * Math.exp(-t * params.clickDecay)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hat(
|
|
64
|
+
params: HatParams,
|
|
65
|
+
t: number,
|
|
66
|
+
noise: number,
|
|
67
|
+
bright: number,
|
|
68
|
+
state: KitVoiceState,
|
|
69
|
+
): number {
|
|
70
|
+
state.dark += 0.3 * (noise - state.dark);
|
|
71
|
+
const noisy = params.tone * bright + (1 - params.tone) * state.dark * 0.8;
|
|
72
|
+
let source = noisy;
|
|
73
|
+
if (params.metal > 0) {
|
|
74
|
+
let square = 0;
|
|
75
|
+
for (const hz of METAL_HZ) square += (t * hz) % 1 < 0.5 ? 1 : -1;
|
|
76
|
+
// First difference: keep the metallic top end, drop the low beating.
|
|
77
|
+
const metal = (square / METAL_HZ.length - state.metalPrevious) * 1.6;
|
|
78
|
+
state.metalPrevious = square / METAL_HZ.length;
|
|
79
|
+
source = params.metal * metal + (1 - params.metal) * noisy;
|
|
80
|
+
}
|
|
81
|
+
return source * params.level * Math.exp(-t * params.decay);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** One sample of `voice` in `kit` at `t` seconds after the hit. */
|
|
85
|
+
export function kitDrumSample(
|
|
86
|
+
kit: SynthKit,
|
|
87
|
+
voice: DrumVoice,
|
|
88
|
+
t: number,
|
|
89
|
+
noise: number,
|
|
90
|
+
bright: number,
|
|
91
|
+
state: KitVoiceState,
|
|
92
|
+
sampleRate: number,
|
|
93
|
+
): number {
|
|
94
|
+
let sample: number;
|
|
95
|
+
if (voice === "kick") sample = body(kit.kick, t, noise, state, sampleRate);
|
|
96
|
+
else if (voice === "tom") sample = body(kit.tom, t, noise, state, sampleRate);
|
|
97
|
+
else if (voice === "snare") {
|
|
98
|
+
const p = kit.snare;
|
|
99
|
+
state.phase += p.tone / sampleRate;
|
|
100
|
+
const wires = p.snap * bright + (1 - p.snap) * noise;
|
|
101
|
+
sample =
|
|
102
|
+
Math.sin(2 * Math.PI * state.phase) *
|
|
103
|
+
p.toneLevel *
|
|
104
|
+
Math.exp(-t * p.toneDecay) +
|
|
105
|
+
wires * p.noise * Math.exp(-t * p.noiseDecay);
|
|
106
|
+
} else if (voice === "clap") {
|
|
107
|
+
const p = kit.clap;
|
|
108
|
+
state.band += p.band * (bright - state.band);
|
|
109
|
+
const span = p.gap * 3;
|
|
110
|
+
const burst = t < span ? (Math.floor(t / p.gap) % 2 === 0 ? 1 : 0.35) : 0;
|
|
111
|
+
sample =
|
|
112
|
+
state.band *
|
|
113
|
+
p.level *
|
|
114
|
+
(burst + 0.8 * Math.exp(-(t - span) * p.tail) * +(t >= span));
|
|
115
|
+
} else if (voice === "hat") sample = hat(kit.hat, t, noise, bright, state);
|
|
116
|
+
else if (voice === "openhat")
|
|
117
|
+
sample = hat(kit.openhat, t, noise, bright, state);
|
|
118
|
+
else {
|
|
119
|
+
const p = kit.rim;
|
|
120
|
+
sample =
|
|
121
|
+
(Math.sin(2 * Math.PI * p.high * t) * 0.6 +
|
|
122
|
+
Math.sin(2 * Math.PI * p.low * t) * 0.4) *
|
|
123
|
+
Math.exp(-t * p.decay) *
|
|
124
|
+
p.level;
|
|
125
|
+
}
|
|
126
|
+
if (kit.drive > 0) {
|
|
127
|
+
const amount = 1 + kit.drive * 4;
|
|
128
|
+
sample = Math.tanh(sample * amount) / Math.tanh(amount);
|
|
129
|
+
}
|
|
130
|
+
if (kit.bits > 0) {
|
|
131
|
+
const steps = 2 ** (kit.bits - 1);
|
|
132
|
+
sample = Math.round(sample * steps) / steps;
|
|
133
|
+
}
|
|
134
|
+
if (kit.hold > 1) {
|
|
135
|
+
if (state.holdLeft <= 0) {
|
|
136
|
+
state.held = sample;
|
|
137
|
+
state.holdLeft = kit.hold;
|
|
138
|
+
}
|
|
139
|
+
state.holdLeft -= 1;
|
|
140
|
+
sample = state.held;
|
|
141
|
+
}
|
|
142
|
+
return sample * kit.gain;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Longest synth-kit one-shot in the score; 0 without kits (so renders without kits are unchanged). */
|
|
146
|
+
export function kitTailSeconds(score: TrackScore): number {
|
|
147
|
+
let seconds = 0;
|
|
148
|
+
for (const track of score.tracks)
|
|
149
|
+
seconds = Math.max(seconds, synthKit(track.kit)?.seconds ?? 0);
|
|
150
|
+
return seconds;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── catalog ────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* One entry of the kit catalog. `synth` kits render offline from the
|
|
157
|
+
* parameters in `core/kits.ts`; `sample` kits are pack banks fetched on
|
|
158
|
+
* first use (`src/audio/packs.ts`). New kinds (a user's own folder of
|
|
159
|
+
* samples, more packs) slot in here without touching the pickers.
|
|
160
|
+
*/
|
|
161
|
+
export type KitEntry = Readonly<{
|
|
162
|
+
name: string;
|
|
163
|
+
kind: "synth" | "sample";
|
|
164
|
+
label: string;
|
|
165
|
+
detail: string;
|
|
166
|
+
/** The prompt command that puts this kit on the focused track. */
|
|
167
|
+
command: string;
|
|
168
|
+
}>;
|
|
169
|
+
|
|
170
|
+
/** The default synth voices, listed first. */
|
|
171
|
+
export const DEFAULT_SYNTH_KIT = "default";
|
|
172
|
+
|
|
173
|
+
export function kitCatalog(): readonly KitEntry[] {
|
|
174
|
+
const synth: KitEntry[] = [
|
|
175
|
+
{
|
|
176
|
+
name: DEFAULT_SYNTH_KIT,
|
|
177
|
+
kind: "synth",
|
|
178
|
+
label: "Default (synth)",
|
|
179
|
+
detail: "the built-in voices",
|
|
180
|
+
command: `/kit ${DEFAULT_SYNTH_KIT}`,
|
|
181
|
+
},
|
|
182
|
+
...SYNTH_KITS.map((entry): KitEntry => ({
|
|
183
|
+
name: entry.name,
|
|
184
|
+
kind: "synth",
|
|
185
|
+
label: entry.label,
|
|
186
|
+
detail: entry.description,
|
|
187
|
+
command: `/kit ${entry.name}`,
|
|
188
|
+
})),
|
|
189
|
+
];
|
|
190
|
+
const sample = Object.entries(DEFAULT_KITS).map(
|
|
191
|
+
([name, entry]): KitEntry => ({
|
|
192
|
+
name,
|
|
193
|
+
kind: "sample",
|
|
194
|
+
label: `${name} ${entry.bank || entry.pack}`,
|
|
195
|
+
detail: `${entry.pack} samples (fetched once)`,
|
|
196
|
+
command: `/kit ${name}`,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
return Object.freeze([...synth, ...sample]);
|
|
200
|
+
}
|