@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.
Files changed (81) hide show
  1. package/CHANGELOG.md +122 -0
  2. package/DAWG.md +605 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +99 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +301 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +337 -0
  42. package/src/audio/engine.ts +27 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +470 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +280 -278
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1119 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +501 -0
  72. package/src/tui/euclid.ts +472 -0
  73. package/src/tui/menu.ts +1286 -244
  74. package/src/tui/play-chords.ts +538 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +281 -0
  80. package/tui/highway.ts +7 -1
  81. 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
+ }
@@ -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 keeps its short lead: renders run off-thread, so a slow
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
+ }