@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
package/src/audio/warp.ts CHANGED
@@ -84,3 +84,64 @@ export function warpedSpan(
84
84
  length: Math.max(1, Math.floor(to - from)),
85
85
  };
86
86
  }
87
+
88
+ /**
89
+ * A fitted sample window's position map (0.6 `bpm`/`len`/`fit`): output
90
+ * frames after the note start to window frames and back, both at the
91
+ * render rate. Through a tempo map the window follows ramps and steps;
92
+ * at constant tempo both directions are linear.
93
+ */
94
+ export type FitMap = Readonly<{
95
+ /** Output frames the whole window takes. */
96
+ length: number;
97
+ /** Window frame sounding `e` output frames after the start. */
98
+ at(e: number): number;
99
+ /** Output frame (after the start) where window frame `p` sounds. */
100
+ outAt(p: number): number;
101
+ }>;
102
+
103
+ /**
104
+ * The map for a window of `frames` render-rate frames that advances
105
+ * `perBeat` window frames per song beat from `startTick`. Without a warp the
106
+ * song runs at `tempoBpm` throughout.
107
+ */
108
+ export function beatFitMap(
109
+ warp: SampleWarp | undefined,
110
+ timing: Readonly<{
111
+ tempoBpm: number;
112
+ ticksPerBeat: number;
113
+ sampleRate: number;
114
+ }>,
115
+ startTick: number,
116
+ frames: number,
117
+ perBeat: number,
118
+ ): FitMap {
119
+ const { tempoBpm, ticksPerBeat, sampleRate } = timing;
120
+ if (!warp) {
121
+ const ratio = perBeat / ((sampleRate * 60) / tempoBpm);
122
+ return Object.freeze({
123
+ length: frames / ratio,
124
+ at: (e: number) => e * ratio,
125
+ outAt: (p: number) => p / ratio,
126
+ });
127
+ }
128
+ const origin = warp.sample(startTick);
129
+ const outAt = (p: number) =>
130
+ warp.sample(startTick + (p / perBeat) * ticksPerBeat) - origin;
131
+ return Object.freeze({
132
+ length: outAt(frames),
133
+ at: (e: number) =>
134
+ ((warp.tick(origin + e) - startTick) / ticksPerBeat) * perBeat,
135
+ outAt,
136
+ });
137
+ }
138
+
139
+ /** `fit`: the window fills `held` output frames, linearly. */
140
+ export function spanFitMap(frames: number, held: number): FitMap {
141
+ const ratio = frames / Math.max(1, held);
142
+ return Object.freeze({
143
+ length: held,
144
+ at: (e: number) => e * ratio,
145
+ outAt: (p: number) => p / ratio,
146
+ });
147
+ }
package/src/audio/wav.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from "./sampler.ts";
24
24
  import { EFFECT_NAMES, FX_PRESETS, effectSpec } from "../../core/fx.ts";
25
25
  import { seededRandom } from "./random.ts";
26
+ import { engineFor, engineTailSeconds } from "./instruments.ts";
26
27
  import {
27
28
  isStereoVoice,
28
29
  renderSynthNote,
@@ -139,6 +140,9 @@ export const AVAILABLE_INSTRUMENTS = Object.freeze([
139
140
  "z_square",
140
141
  "z_tan",
141
142
  "z_noise",
143
+ // 0.6 instruments (src/audio/instruments.ts), one line per lane.
144
+ "granular",
145
+ "modal",
142
146
  ] as const);
143
147
 
144
148
  /** Per-track effects understood by the renderer and the agent, in chain
@@ -180,8 +184,32 @@ function oneShotSeconds(
180
184
  }
181
185
  /** Loop renders fold at most this much tail back onto the loop start. */
182
186
  const MAX_LOOP_TAIL_SECONDS = 8;
187
+ /** A 0.6 engine's one-shot ring-out is capped here, not at the loop fold. */
188
+ const MAX_ENGINE_TAIL_SECONDS = 30;
183
189
 
184
- type RenderContext = Readonly<{
190
+ /**
191
+ * The longest 0.6 engine ring-out in `score`, capped at 30 s. One-shot
192
+ * renders (exports, previews) let engine tails reach their own cap; the
193
+ * 8 s cap applies only to loop folding. Zero without an engine, so legacy
194
+ * renders are unchanged.
195
+ */
196
+ function oneShotEngineTail(score: TrackScore): number {
197
+ let tail = 0;
198
+ for (const track of score.tracks) {
199
+ if (!engineFor(track)) continue;
200
+ let lowest: number | undefined;
201
+ for (const note of score.notes)
202
+ if (
203
+ note.trackId === track.id &&
204
+ (lowest === undefined || note.pitch < lowest)
205
+ )
206
+ lowest = note.pitch;
207
+ tail = Math.max(tail, engineTailSeconds(track, lowest));
208
+ }
209
+ return Math.min(MAX_ENGINE_TAIL_SECONDS, tail);
210
+ }
211
+
212
+ export type RenderContext = Readonly<{
185
213
  score: TrackScore;
186
214
  sampleRate: number;
187
215
  samples: number;
@@ -314,9 +342,15 @@ export class StemRenderer {
314
342
  Math.max(
315
343
  samplerTailSeconds(score, bank, sampleRate),
316
344
  ...score.tracks.map(synthTailSeconds),
345
+ // Zero without a registered 0.6 instrument engine.
346
+ ...score.tracks.map((track) => engineTailSeconds(track)),
317
347
  ),
318
348
  );
319
- return oneShotSeconds(loopSecondsOf(score), samplerTail, reverbTail);
349
+ return oneShotSeconds(
350
+ loopSecondsOf(score),
351
+ Math.max(samplerTail, oneShotEngineTail(score)),
352
+ reverbTail,
353
+ );
320
354
  }
321
355
 
322
356
  public render(score: TrackScore, options: RenderOptions = {}): RenderedAudio {
@@ -338,6 +372,8 @@ export class StemRenderer {
338
372
  Math.max(
339
373
  samplerTailSeconds(score, bank, sampleRate),
340
374
  ...score.tracks.map(synthTailSeconds),
375
+ // Zero without a registered 0.6 instrument engine.
376
+ ...score.tracks.map((track) => engineTailSeconds(track)),
341
377
  ),
342
378
  );
343
379
  let frames: number;
@@ -367,7 +403,11 @@ export class StemRenderer {
367
403
  } else {
368
404
  const seconds = Math.min(
369
405
  maxSeconds,
370
- oneShotSeconds(loopSeconds, samplerTail, reverbTail),
406
+ oneShotSeconds(
407
+ loopSeconds,
408
+ Math.max(samplerTail, oneShotEngineTail(score)),
409
+ reverbTail,
410
+ ),
371
411
  );
372
412
  frames = Math.max(1, Math.ceil(seconds * sampleRate));
373
413
  samples = frames;
@@ -437,6 +477,18 @@ export class StemRenderer {
437
477
  const played = performed.get(trackId)!;
438
478
  const busOrbit = sharedOrbitOf(track);
439
479
  const sampler = isSamplerInstrument(track?.instrument);
480
+ // A 0.6 instrument engine (src/audio/instruments.ts), if registered
481
+ // and the track carries its field; undefined keeps today's voices.
482
+ const engine = engineFor(track);
483
+ // The song key always joins an engine's stem key: engines may tune to
484
+ // it (sympathetic strings) without a song tuning.
485
+ const engineDigests =
486
+ engine && track
487
+ ? [
488
+ ...(engine.assetDigests?.(track, bank) ?? []),
489
+ `key:${score.key ?? ""}`,
490
+ ]
491
+ : undefined;
440
492
  // Wavetable hook: the oscillator factory for a wavetable track (its
441
493
  // table id joins the stem key), undefined for every other instrument.
442
494
  const wavetable = track ? wavetableHook(track, bank, context) : undefined;
@@ -449,6 +501,7 @@ export class StemRenderer {
449
501
  sampler ? bank : undefined,
450
502
  wavetable?.id,
451
503
  track ? reverbImpulse(track, sampleRate, bank.irs)?.id : undefined,
504
+ engineDigests,
452
505
  );
453
506
  let stem = this.stems.get(trackId);
454
507
  const caching = this.maxCacheBytes > 0;
@@ -464,6 +517,7 @@ export class StemRenderer {
464
517
  sampler ? bank : undefined,
465
518
  wavetable?.id,
466
519
  reverbImpulse(track, sampleRate, bank.irs)?.id,
520
+ engineDigests,
467
521
  )
468
522
  : undefined;
469
523
  if (stem?.key === key) stem.used = this.renders;
@@ -490,10 +544,25 @@ export class StemRenderer {
490
544
  }
491
545
  : { left, right };
492
546
  dry.fill(0);
493
- const synthVoice = !sampler && usesSynthVoice(track);
494
- const stereo = synthVoice && isStereoVoice(track);
547
+ const synthVoice = !engine && !sampler && usesSynthVoice(track);
548
+ const stereo = engine
549
+ ? engine.stereo(track!)
550
+ : synthVoice && isStereoVoice(track);
495
551
  if (stereo) dryR.fill(0);
496
- if (sampler) {
552
+ if (engine && track) {
553
+ engine.render(
554
+ dry,
555
+ stereo ? dryR : undefined,
556
+ played,
557
+ track,
558
+ {
559
+ ...context,
560
+ ticksPerBeat: score.ticksPerBeat,
561
+ ...(tuning ? { tuning } : {}),
562
+ },
563
+ bank,
564
+ );
565
+ } else if (sampler) {
497
566
  if (track) renderSamplerNotes(dry, played, track, context, bank);
498
567
  } else if (synthVoice && track) {
499
568
  const gainAt = (tick: number) => trackGainAt(track, tick);
@@ -826,6 +895,7 @@ function stemKey(
826
895
  bank?: SampleBank,
827
896
  wavetableId?: string,
828
897
  impulseId?: string,
898
+ engineDigests?: readonly string[],
829
899
  ): string {
830
900
  let settings: Record<string, unknown> | null = null;
831
901
  if (track) {
@@ -849,6 +919,8 @@ function stemKey(
849
919
  ...(wavetableId ? [wavetableId] : []),
850
920
  // A convolution reverb's impulse (its sha256 for a sample).
851
921
  ...(impulseId ? [impulseId] : []),
922
+ // Assets a 0.6 instrument engine reads (absent without an engine).
923
+ ...(engineDigests ? [engineDigests] : []),
852
924
  // The song tuning, and the key whose tonic is the default root.
853
925
  ...(context.score.tuning || track?.tuning
854
926
  ? [context.score.tuning ?? null, context.score.key]
@@ -832,12 +832,19 @@ export function applyExpressionCommand(
832
832
  return { ok: false, message: `pedal · ${error.message}` };
833
833
  throw error;
834
834
  }
835
- const downs = (pedal ?? []).filter((event) => event.state !== "up");
835
+ const downs = (pedal ?? []).filter((event) => event.state === "down");
836
+ const halves = (pedal ?? []).filter((event) => event.state === "half");
837
+ const held = [
838
+ ...(downs.length > 0 || halves.length === 0
839
+ ? [`${downs.length} down`]
840
+ : []),
841
+ ...(halves.length > 0 ? [`${halves.length} half`] : []),
842
+ ].join(", ");
836
843
  return trackResult(
837
844
  score,
838
845
  track,
839
846
  { pedal: pedal ?? null },
840
- `pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${downs.length} down) · ${trackId}`,
847
+ `pedal · ${pedal?.length ?? 0} event${pedal?.length === 1 ? "" : "s"} (${held}) · ${trackId}`,
841
848
  );
842
849
  }
843
850
  case "velcurve": {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `/fitmode <mode>`, `/bpm <n>` and `/len <beats>` (0.6): fit a sampler
3
+ * voice to the song's time. They act on the focused sampler track's voice
4
+ * (named, or its only voice) through `setSampleControls`, so validation and
5
+ * printing stay the sampler's. `/fitmode` with no mode suggests one from
6
+ * the sample's content (`suggestFitMode`); song tempo stays `tempo <n>`.
7
+ */
8
+ import {
9
+ SAMPLE_FIT_MODES,
10
+ isSamplerInstrument,
11
+ samplerVoiceSlots,
12
+ type SampleFitMode,
13
+ type TrackScore,
14
+ } from "../../core/score.ts";
15
+ import { setSampleControls, type SetSampleResult } from "./sample.ts";
16
+
17
+ export type FitCommand = Readonly<{
18
+ control: "fitmode" | "bpm" | "len";
19
+ /** Undefined: `/fitmode` alone (suggest) or `off`-style unset is null. */
20
+ value: SampleFitMode | number | null | undefined;
21
+ voice?: string;
22
+ }>;
23
+
24
+ const VOICE = "([a-z][a-z0-9_]{0,31})";
25
+
26
+ /**
27
+ * Parse `/fitmode [mode|auto|off [voice]]`, `/bpm <n|off> [voice]`,
28
+ * `/len <beats|off> [voice]`. `fitmode` and `len` also work without the
29
+ * slash; a bare `bpm 120` stays the song's tempo word, so `/bpm` needs it.
30
+ */
31
+ export function parseFitCommand(command: string): FitCommand | undefined {
32
+ const text = command.trim();
33
+ const mode = text.match(
34
+ new RegExp(
35
+ `^/?fitmode(?:\\s+(repitch|beats|tones|auto|off|none)(?:\\s+${VOICE})?)?$`,
36
+ "i",
37
+ ),
38
+ );
39
+ if (mode) {
40
+ const raw = mode[1]?.toLowerCase();
41
+ return {
42
+ control: "fitmode",
43
+ value:
44
+ raw === undefined || raw === "auto"
45
+ ? undefined
46
+ : raw === "off" || raw === "none"
47
+ ? null
48
+ : (raw as SampleFitMode),
49
+ ...(mode[2] ? { voice: mode[2].toLowerCase() } : {}),
50
+ };
51
+ }
52
+ const number = text.match(
53
+ new RegExp(
54
+ `^(/bpm|/?len)\\s+(\\d+(?:\\.\\d+)?|off|none)(?:\\s+${VOICE})?$`,
55
+ "i",
56
+ ),
57
+ );
58
+ if (!number) return undefined;
59
+ const raw = number[2]!.toLowerCase();
60
+ return {
61
+ control: number[1]!.toLowerCase().replace("/", "") as "bpm" | "len",
62
+ value: raw === "off" || raw === "none" ? null : Number(raw),
63
+ ...(number[3] ? { voice: number[3].toLowerCase() } : {}),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The voice a fit command acts on: the named one, else the track's only
69
+ * voice (lowest slot first when it has several and none is named).
70
+ */
71
+ export function fitVoice(
72
+ score: TrackScore,
73
+ trackId: string,
74
+ voice: string | undefined,
75
+ ): { voice: string } | { error: string } {
76
+ const track = score.tracks.find((item) => item.id === trackId);
77
+ const sampler = track?.sampler;
78
+ if (!track || !sampler || !isSamplerInstrument(track.instrument))
79
+ return {
80
+ error: `fit · ${trackId} is not a sampler track · /sample <path> first · song tempo is tempo <bpm>`,
81
+ };
82
+ if (voice) return { voice };
83
+ const names = Object.keys(sampler.voices);
84
+ if (names.length === 1) return { voice: names[0]! };
85
+ const slots = samplerVoiceSlots(sampler);
86
+ const sorted = names.sort(
87
+ (a, b) => (slots.get(a) ?? 0) - (slots.get(b) ?? 0) || (a < b ? -1 : 1),
88
+ );
89
+ return {
90
+ error: `fit · ${trackId} has ${names.length} voices · name one: ${sorted.join(" ")}`,
91
+ };
92
+ }
93
+
94
+ /** Apply a parsed fit command with a known value (a suggested mode resolved). */
95
+ export function applyFitCommand(
96
+ score: TrackScore,
97
+ trackId: string,
98
+ command: FitCommand,
99
+ mode?: SampleFitMode,
100
+ ): SetSampleResult {
101
+ const target = fitVoice(score, trackId, command.voice);
102
+ if ("error" in target) return { ok: false, message: target.error };
103
+ const value = command.value === undefined ? (mode ?? null) : command.value;
104
+ if (command.control === "fitmode" && value !== null) {
105
+ const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
106
+ target.voice
107
+ ];
108
+ if (
109
+ ref &&
110
+ value !== "repitch" &&
111
+ ref.bpm === undefined &&
112
+ ref.len === undefined &&
113
+ ref.fit !== true
114
+ )
115
+ return {
116
+ ok: false,
117
+ message: `fit · fitmode ${String(value)} needs the sample's tempo first · /bpm <n> or /len <beats>`,
118
+ };
119
+ }
120
+ const values: Record<string, number | string | null> = {
121
+ [command.control]: value,
122
+ };
123
+ // Unsetting bpm or len leaves a fitmode with nothing to fit: unset both.
124
+ if (value === null && command.control !== "fitmode") {
125
+ const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
126
+ target.voice
127
+ ];
128
+ const other = command.control === "bpm" ? ref?.len : ref?.bpm;
129
+ if (ref?.fitmode && other === undefined && ref.fit !== true)
130
+ values.fitmode = null;
131
+ }
132
+ return setSampleControls(score, trackId, target.voice, values);
133
+ }
134
+
135
+ export const FIT_MODES = SAMPLE_FIT_MODES;
@@ -18,6 +18,7 @@
18
18
  * on/off/true/false; enums take one of their values. Each command is one
19
19
  * `updateTrack` revision and one undo step.
20
20
  */
21
+ import { nearestWord } from "../audio/instrument-check.ts";
21
22
  import {
22
23
  EFFECT_NAMES,
23
24
  FX_PRESETS,
@@ -190,6 +191,31 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
190
191
  return { type: "fx-set", effect, values };
191
192
  }
192
193
 
194
+ /**
195
+ * A short `fx <word> …` whose word is no effect (`fx wobble on`, `fx dela
196
+ * mix 0.3`): the local answer, so a typo never goes to the agent. Longer
197
+ * free text after `fx` still does.
198
+ */
199
+ export function unknownFxMessage(prompt: string): string | undefined {
200
+ const words = prompt.trim().split(/\s+/);
201
+ if (words[0]?.toLowerCase() !== "fx" || words.length < 2) return undefined;
202
+ const name = words[1]!.toLowerCase();
203
+ if (parseEffectName(name) || ["ir", "iresponse", "amp"].includes(name))
204
+ return undefined;
205
+ const near = nearestWord(name, [
206
+ ...EFFECT_NAMES,
207
+ ...Object.keys(EFFECT_ALIASES),
208
+ "reverb",
209
+ ]);
210
+ const rest = words.slice(2).map((word) => word.toLowerCase());
211
+ const commandShaped =
212
+ rest.length <= 1 ||
213
+ ["on", "off", "reset", "preset"].includes(rest[0]!) ||
214
+ rest.every((word, index) => index % 2 === 0 || /^-?[\d.]+/.test(word));
215
+ if (!near && !commandShaped) return undefined;
216
+ return `unknown effect ${name.slice(0, 24)}${near ? ` · did you mean ${near}?` : ""} · effects ${EFFECT_NAMES.join(", ")}`;
217
+ }
218
+
193
219
  function irCommand(value: string): FxCommand | undefined {
194
220
  const lower = value.toLowerCase();
195
221
  if (lower === "off" || lower === "none") return { type: "fx-ir", ir: null };
@@ -208,6 +234,11 @@ export function effectDefaults(effect: EffectName): FxValues {
208
234
  if (!(spec.optional && spec.default === false)) out[key] = spec.default;
209
235
  // A zero `time` means "follow beats"; the canonical form omits it.
210
236
  if (effect === "delay") delete out.time;
237
+ // A head's sag follows its type and its noise gate is off until set.
238
+ if (effect === "head") {
239
+ delete out.sag;
240
+ delete out.gate;
241
+ }
211
242
  return out;
212
243
  }
213
244