@hraness/dawg 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
@@ -0,0 +1,361 @@
1
+ /**
2
+ * The keys engine (0.6, lane keys-piano): renders a track whose instrument
3
+ * is a piano family (grand upright felt honkytonk prepared) AND that
4
+ * carries `Track.keys`. Notes are performed by `performNotes` first, so the
5
+ * 0.5 sustain pedal (down, half, up), velocity curves, humanize, bends and
6
+ * vibrato all arrive on the notes; this file turns them into voices.
7
+ *
8
+ * - Voices run in 32-sample control blocks (bend, vibrato and the half
9
+ * pedal are read per block; `keys-<param>` lanes at each onset).
10
+ * - Polyphony: 64 voices per track; beyond that the oldest released voice
11
+ * is stolen, else the oldest, with a 5 ms fade. Notes whose frequency is
12
+ * not positive (an unmapped degree of a tuning table) are dropped.
13
+ * - Every voice ends inside the renderer's 8 s tail window with a 250 ms
14
+ * raised-cosine taper, so a long bass or pedalled note never hard-cuts
15
+ * at the window or the loop fold.
16
+ * - Post: the family's body EQ per channel (the minimal per-track post
17
+ * hook), then the track volume, before the existing effects chain.
18
+ */
19
+ import type { PerformedNote } from "../../../core/expression.ts";
20
+ import { isPianoFamily, resolvedKeys } from "../../../core/keys.ts";
21
+ import type { Track } from "../../../core/score.ts";
22
+ import { noteHz } from "../../../core/tuning.ts";
23
+ import type { FxLane } from "../../../core/fx.ts";
24
+ import { interpolateAutomation } from "../effects/common.ts";
25
+ import type { EngineContext, InstrumentEngine } from "../instruments.ts";
26
+ import { Biquad2, clamp, CONTROL, LN1000 } from "./dsp.ts";
27
+ import {
28
+ damperT60,
29
+ physicalKey,
30
+ pianoF1,
31
+ undamped,
32
+ PianoVoice,
33
+ pianoBody,
34
+ trackSeedOf,
35
+ type PianoParams,
36
+ } from "./piano.ts";
37
+
38
+ export const KEYS_LIMITS = Object.freeze({
39
+ /** Per-track polyphony cap (voice stealing beyond it). */
40
+ voices: 64,
41
+ /** Seconds a voice may ring after its note ends (the wav.ts window). */
42
+ tailSeconds: 8,
43
+ /** Raised-cosine taper ending each voice inside the window. */
44
+ taperSeconds: 0.25,
45
+ /** Fade of a stolen voice. */
46
+ stealSeconds: 0.005,
47
+ });
48
+
49
+ /** Lane parameters read at a note's onset. */
50
+ const LANE_PARAMS = Object.freeze([
51
+ "hardness",
52
+ "touch",
53
+ "decay",
54
+ "release",
55
+ "knock",
56
+ "noise",
57
+ "felt",
58
+ ] as const);
59
+
60
+ type Values = Readonly<Record<string, number | string>>;
61
+
62
+ function num(values: Values, key: string): number {
63
+ const value = values[key];
64
+ return typeof value === "number" ? value : 0;
65
+ }
66
+
67
+ /** Voice parameters for a note: the resolved keys plus lanes at `tick`. */
68
+ export function pianoParamsAt(
69
+ track: Track,
70
+ values: Values,
71
+ tick: number,
72
+ ): PianoParams {
73
+ const lane = (name: string) => {
74
+ const points = track.fxAutomation?.[`keys-${name}` as FxLane];
75
+ const base = num(values, name);
76
+ return points && points.length > 0
77
+ ? interpolateAutomation(points, tick, base)
78
+ : base;
79
+ };
80
+ const lanes = Object.fromEntries(
81
+ LANE_PARAMS.map((name) => [name, lane(name)]),
82
+ );
83
+ return {
84
+ hardness: clamp(lanes.hardness!, 0, 1),
85
+ touch: clamp(lanes.touch!, 0, 1),
86
+ inharm: num(values, "inharm"),
87
+ unison: num(values, "unison"),
88
+ decay: clamp(lanes.decay!, 0.1, 4),
89
+ release: clamp(lanes.release!, 0.1, 4),
90
+ strike: num(values, "strike"),
91
+ after: num(values, "after"),
92
+ knock: clamp(lanes.knock!, 0, 1),
93
+ noise: clamp(lanes.noise!, 0, 1),
94
+ felt: clamp(lanes.felt!, 0, 1),
95
+ prep: num(values, "prep"),
96
+ width: num(values, "width"),
97
+ stretch: num(values, "stretch"),
98
+ };
99
+ }
100
+
101
+ /** Body voicing name for a track (the stored `body`, else the family's). */
102
+ export function bodyOf(values: Values): string {
103
+ const body = values.body;
104
+ return typeof body === "string" ? body : "grand";
105
+ }
106
+
107
+ type Live = {
108
+ voice: PianoVoice;
109
+ /** Sample index the voice started at. */
110
+ start: number;
111
+ /** Sample index of key-up. */
112
+ off: number;
113
+ /** Sample index the voice must be silent by (taper end). */
114
+ end: number;
115
+ order: number;
116
+ /** Per-block bend and vibrato, cents at seconds from the note start. */
117
+ cents?: (t: number) => number;
118
+ damp?: Readonly<{ from: number; tau: number }>;
119
+ damped: boolean;
120
+ /** Being stolen: fading out over `fadeLength`, then removed. */
121
+ stolen: boolean;
122
+ /** Steal fade samples left. */
123
+ fade: number;
124
+ fadeLength: number;
125
+ };
126
+
127
+ type Onset = Readonly<{
128
+ note: PerformedNote;
129
+ start: number;
130
+ off: number;
131
+ hz: number;
132
+ order: number;
133
+ }>;
134
+
135
+ function spanOf(
136
+ note: PerformedNote,
137
+ context: EngineContext,
138
+ ): { start: number; off: number } {
139
+ const { warp, samplesPerTick } = context;
140
+ if (warp) {
141
+ const start = Math.max(0, Math.floor(warp.sample(note.startTick)));
142
+ const end = Math.floor(warp.sample(note.startTick + note.durationTicks));
143
+ return { start, off: Math.max(start + 1, end) };
144
+ }
145
+ const start = Math.max(0, Math.floor(note.startTick * samplesPerTick));
146
+ const length = Math.max(1, Math.floor(note.durationTicks * samplesPerTick));
147
+ return { start, off: start + length };
148
+ }
149
+
150
+ /**
151
+ * Renders a keys track's performed notes into `left` and `right` (both
152
+ * already zeroed or holding earlier output; voices add).
153
+ */
154
+ export function renderKeysTrack(
155
+ left: Float64Array,
156
+ right: Float64Array,
157
+ notes: readonly PerformedNote[],
158
+ track: Track,
159
+ context: EngineContext,
160
+ ): void {
161
+ const sr = context.sampleRate;
162
+ const total = left.length;
163
+ const values = resolvedKeys(track.instrument ?? "grand", track.keys);
164
+ const trackSeed = trackSeedOf(track.id);
165
+ const seedTick = context.seedTick ?? 0;
166
+ const window = Math.round(KEYS_LIMITS.tailSeconds * sr);
167
+ const taper = Math.round(KEYS_LIMITS.taperSeconds * sr);
168
+ const onsets: Onset[] = [];
169
+ notes.forEach((note) => {
170
+ const hz = noteHz(note.pitch, note.cents, context.tuning);
171
+ // An unmapped degree of a tuning table is silent.
172
+ if (!(hz > 0) || !Number.isFinite(hz)) return;
173
+ const { start, off } = spanOf(note, context);
174
+ if (start >= total) return;
175
+ onsets.push({ note, start, off, hz, order: 0 });
176
+ });
177
+ onsets.sort(
178
+ (a, b) =>
179
+ a.start - b.start ||
180
+ a.note.startTick - b.note.startTick ||
181
+ (a.note.id < b.note.id ? -1 : a.note.id > b.note.id ? 1 : 0),
182
+ );
183
+ const live: Live[] = [];
184
+ const block = new Float64Array(CONTROL);
185
+ let next = 0;
186
+ let order = 0;
187
+ for (let i = 0; i < total; i += CONTROL) {
188
+ const count = Math.min(CONTROL, total - i);
189
+ while (next < onsets.length && onsets[next]!.start < i + count) {
190
+ const onset = onsets[next]!;
191
+ next += 1;
192
+ const playing = live.filter((x) => !x.stolen);
193
+ if (playing.length >= KEYS_LIMITS.voices) {
194
+ // Steal the oldest released voice, else the oldest.
195
+ const released = playing.filter((x) => x.voice.released);
196
+ const pool = released.length > 0 ? released : playing;
197
+ let victim = pool[0]!;
198
+ for (const x of pool) if (x.order < victim.order) victim = x;
199
+ victim.fadeLength = Math.max(
200
+ 1,
201
+ Math.round(KEYS_LIMITS.stealSeconds * sr),
202
+ );
203
+ victim.fade = victim.fadeLength;
204
+ victim.stolen = true;
205
+ }
206
+ const note = onset.note;
207
+ const params = pianoParamsAt(track, values, note.startTick);
208
+ const voice = new PianoVoice(
209
+ {
210
+ pitch: note.pitch,
211
+ hz: onset.hz,
212
+ velocity: clamp(note.velocity, 0, 1),
213
+ trackSeed,
214
+ noteSeed: `${track.id}:${note.id}:${note.startTick + seedTick}`,
215
+ },
216
+ params,
217
+ sr,
218
+ );
219
+ const performance = note.performance;
220
+ // The `vib`/`vibmod` wow (lofi) unless the note brings its own vibrato.
221
+ const vib = num(values, "vib");
222
+ const depth = num(values, "vibmod") * 100;
223
+ const wow =
224
+ vib > 0 && depth > 0 && !performance?.replaceVibrato
225
+ ? (t: number) => depth * Math.sin(2 * Math.PI * vib * t)
226
+ : undefined;
227
+ const bend = performance?.cents;
228
+ const cents =
229
+ bend && wow ? (t: number) => bend(t) + wow(t) : (bend ?? wow);
230
+ live.push({
231
+ voice,
232
+ start: onset.start,
233
+ off: onset.off,
234
+ end: onset.off + window,
235
+ order: order++,
236
+ ...(cents ? { cents } : {}),
237
+ ...(performance?.damp ? { damp: performance.damp } : {}),
238
+ damped: false,
239
+ stolen: false,
240
+ fade: 0,
241
+ fadeLength: 0,
242
+ });
243
+ }
244
+ for (let k = live.length - 1; k >= 0; k -= 1) {
245
+ const x = live[k]!;
246
+ // Voices that start inside this block begin at their own sample.
247
+ const from = Math.max(i, x.start);
248
+ const n = i + count - from;
249
+ if (n <= 0) continue;
250
+ const t = (from - x.start) / sr;
251
+ if (x.cents) x.voice.setCents(x.cents(t));
252
+ if (x.damp && !x.damped && t >= x.damp.from) {
253
+ x.voice.damp(x.damp.tau);
254
+ x.damped = true;
255
+ }
256
+ if (!x.voice.released && x.off < from + n) x.voice.noteOff();
257
+ block.fill(0, 0, n);
258
+ let alive = x.voice.process(block, 0, n);
259
+ const { gl, gr } = x.voice;
260
+ for (let s = 0; s < n; s += 1) {
261
+ const index = from + s;
262
+ let gain = 1;
263
+ const left0 = x.end - index;
264
+ if (left0 <= 0) gain = 0;
265
+ else if (left0 < taper)
266
+ gain = 0.5 - 0.5 * Math.cos((Math.PI * left0) / taper);
267
+ if (x.stolen) {
268
+ gain *= Math.max(0, x.fade) / x.fadeLength;
269
+ x.fade -= 1;
270
+ }
271
+ const y = block[s]! * gain;
272
+ left[index]! += y * gl;
273
+ right[index]! += y * gr;
274
+ }
275
+ if (x.stolen && x.fade <= 0) alive = false;
276
+ if (from + n >= x.end) alive = false;
277
+ if (!alive) live.splice(k, 1);
278
+ }
279
+ }
280
+ // The per-track post hook: body EQ, then the track volume.
281
+ const body = bodyOf(values);
282
+ const eqL = pianoBody(body, sr);
283
+ const eqR = pianoBody(body, sr);
284
+ applyEq(left, eqL);
285
+ applyEq(right, eqR);
286
+ applyVolume(left, right, track, context);
287
+ }
288
+
289
+ function applyEq(buffer: Float64Array, filters: readonly Biquad2[]): void {
290
+ for (const filter of filters)
291
+ for (let i = 0; i < buffer.length; i += 1)
292
+ buffer[i] = filter.process(buffer[i]!);
293
+ }
294
+
295
+ function applyVolume(
296
+ left: Float64Array,
297
+ right: Float64Array,
298
+ track: Track,
299
+ context: EngineContext,
300
+ ): void {
301
+ const volume = clamp(track.volume ?? 1, 0, 1);
302
+ const points = track.volumeAutomation ?? [];
303
+ if (points.length === 0) {
304
+ if (volume === 1) return;
305
+ for (let i = 0; i < left.length; i += 1) {
306
+ left[i]! *= volume;
307
+ right[i]! *= volume;
308
+ }
309
+ return;
310
+ }
311
+ for (let i = 0; i < left.length; i += CONTROL) {
312
+ const tick = context.warp
313
+ ? context.warp.tick(i)
314
+ : i / context.samplesPerTick;
315
+ const gain = volume * interpolateAutomation(points, tick, 1);
316
+ const end = Math.min(left.length, i + CONTROL);
317
+ for (let s = i; s < end; s += 1) {
318
+ left[s]! *= gain;
319
+ right[s]! *= gain;
320
+ }
321
+ }
322
+ }
323
+
324
+ /**
325
+ * The live note-off fade for a key sounding at `hz`: a linear fade with the
326
+ * same energy as the damper's exponential decay (0.217 x its T60, at least
327
+ * 10 ms); keys with no damper (F6 up) ring on through the 8 s window.
328
+ */
329
+ export function keysReleaseSeconds(track: Track, hz: number): number {
330
+ if (!(hz > 0) || !Number.isFinite(hz)) return 0.01;
331
+ const values = resolvedKeys(track.instrument ?? "grand", track.keys);
332
+ const p = pianoParamsAt(track, values, 0);
333
+ const key = physicalKey(hz);
334
+ if (undamped(key)) return KEYS_LIMITS.tailSeconds;
335
+ const t60 = damperT60(key, pianoF1(hz, p), p.release);
336
+ return Math.max(0.01, (3 / (2 * LN1000)) * t60);
337
+ }
338
+
339
+ function engineFor(id: string): InstrumentEngine {
340
+ return {
341
+ id,
342
+ field: "keys",
343
+ render(dry, dryR, notes, track, context) {
344
+ const right = dryR ?? new Float64Array(dry.length);
345
+ renderKeysTrack(dry, right, notes, track, context);
346
+ if (!dryR)
347
+ for (let i = 0; i < dry.length; i += 1)
348
+ dry[i] = 0.5 * (dry[i]! + right[i]!);
349
+ },
350
+ tailSeconds: () => KEYS_LIMITS.tailSeconds,
351
+ releaseSeconds: (track, _pitch, hz) => keysReleaseSeconds(track, hz),
352
+ stereo: () => true,
353
+ };
354
+ }
355
+
356
+ /** One engine per piano family (the registry dispatches by instrument id). */
357
+ export const KEYS_ENGINES: readonly InstrumentEngine[] = Object.freeze(
358
+ ["grand", "upright", "felt", "honkytonk", "prepared"]
359
+ .filter((id) => isPianoFamily(id))
360
+ .map(engineFor),
361
+ );