@hraness/dawg 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Ctrl-k menu rows for modal percussion (0.6): the "Mallets and bells"
3
+ * group under Sound › browse sounds, and the MODAL_PARAMS rows under
4
+ * Sound › Parameters on a modal track. Every row runs a `modal …` command.
5
+ */
6
+ import type { EnumParam, NumberParam } from "../../core/params.ts";
7
+ import {
8
+ GAMELAN_PRESETS,
9
+ MODAL_INSTRUMENT,
10
+ MODAL_MALLET_NAMES,
11
+ MODAL_PARAMS,
12
+ MODAL_PRESETS,
13
+ MODAL_PRESET_NAMES,
14
+ MODAL_SIMPLE,
15
+ modalSettings,
16
+ type ModalPresetName,
17
+ } from "../../core/resonators.ts";
18
+ import type { Track } from "../../core/score.ts";
19
+ import { num, specStep, type MenuNode } from "./menu.ts";
20
+
21
+ /** Sound › browse sounds › Mallets and bells: one row per preset. */
22
+ export function malletsMenu(): MenuNode {
23
+ return {
24
+ kind: "menu",
25
+ id: "mallets",
26
+ label: "Mallets and bells",
27
+ detail: `${MODAL_PRESET_NAMES.length} modal presets · marimba, vibes, gong …`,
28
+ help: "struck bars, bells and bowls on the modal resonator engine",
29
+ build: () => [
30
+ // The core presets keep their rows (gong too); the gamelan bronzes
31
+ // added in 0.6.1 sit in their own sub-group.
32
+ ...MODAL_PRESET_NAMES.filter(
33
+ (name) => name === "gong" || !GAMELAN_PRESETS.includes(name),
34
+ ).map(presetRow),
35
+ gamelanMenu(),
36
+ ],
37
+ };
38
+ }
39
+
40
+ function presetRow(name: ModalPresetName): MenuNode {
41
+ return {
42
+ kind: "action",
43
+ label: `${name} ${MODAL_PRESETS[name].doc}`,
44
+ command: `modal ${name}`,
45
+ help: MODAL_PRESETS[name].styles,
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Mallets and bells › Gamelan (f061-gamelan-winds): the Javanese and
51
+ * Balinese bronzes. `modal gamelan` points here.
52
+ */
53
+ export function gamelanMenu(): MenuNode {
54
+ return {
55
+ kind: "menu",
56
+ id: "gamelan",
57
+ label: "Gamelan",
58
+ detail: `${GAMELAN_PRESETS.length} bronzes · saron, gangsa (ombak), gong …`,
59
+ help: "Javanese and Balinese gamelan; pair two gangsa tracks with modal pair, tune with tuning slendro or pelog",
60
+ build: () => GAMELAN_PRESETS.map(presetRow),
61
+ };
62
+ }
63
+
64
+ function numberRow(
65
+ name: string,
66
+ spec: NumberParam,
67
+ value: number,
68
+ fallback: number,
69
+ ): MenuNode {
70
+ return {
71
+ kind: "number",
72
+ label: name,
73
+ value,
74
+ min: spec.min,
75
+ max: spec.max,
76
+ step: specStep(spec),
77
+ format: (v) => (spec.unit ? `${num(v)} ${spec.unit}` : num(v)),
78
+ command: (v) => `modal ${name} ${num(v)}`,
79
+ reset: `modal ${name} off`,
80
+ help: `${spec.doc} · preset ${num(fallback)} · x resets`,
81
+ };
82
+ }
83
+
84
+ /** Sound › Parameters rows on a modal track. */
85
+ export function modalParameterNodes(track: Track): MenuNode[] {
86
+ if (track.instrument !== MODAL_INSTRUMENT) return [];
87
+ const modal = track.modal;
88
+ const preset = MODAL_PRESETS[modal?.preset ?? "marimba"].settings;
89
+ const resolved = modalSettings(modal) as Readonly<
90
+ Record<string, number | string>
91
+ >;
92
+ const row = (name: string): MenuNode | undefined => {
93
+ const spec = MODAL_PARAMS[name]!;
94
+ if (name === "mallet")
95
+ return {
96
+ kind: "choice",
97
+ label: "mallet",
98
+ value: modal?.mallet ?? "preset",
99
+ options: ["preset", ...MODAL_MALLET_NAMES],
100
+ command: (option) =>
101
+ option === "preset" ? "modal mallet off" : `modal mallet ${option}`,
102
+ help: (spec as EnumParam).doc,
103
+ };
104
+ if (spec.kind !== "number") return undefined;
105
+ return numberRow(
106
+ name,
107
+ spec,
108
+ Number(resolved[name]),
109
+ Number(preset[name as keyof typeof preset]),
110
+ );
111
+ };
112
+ const nodes: MenuNode[] = [
113
+ {
114
+ kind: "choice",
115
+ label: "preset",
116
+ value: modal?.preset ?? "marimba",
117
+ options: MODAL_PRESET_NAMES,
118
+ command: (option) => `modal preset ${option}`,
119
+ help: "the instrument: mode table, mallet and ring",
120
+ },
121
+ ];
122
+ for (const name of MODAL_SIMPLE) {
123
+ const node = row(name);
124
+ if (node) nodes.push(node);
125
+ }
126
+ const rest = Object.keys(MODAL_PARAMS).filter(
127
+ (name) => !(MODAL_SIMPLE as readonly string[]).includes(name),
128
+ );
129
+ nodes.push({
130
+ kind: "menu",
131
+ id: "modal:advanced",
132
+ label: "advanced",
133
+ detail: `motor, ombak, buzz, click, strike glide, gain`,
134
+ help: "every modal parameter",
135
+ build: () => rest.flatMap((name) => row(name) ?? []),
136
+ });
137
+ if (modal && Object.keys(modal).some((key) => key !== "preset"))
138
+ nodes.push({
139
+ kind: "action",
140
+ label: "reset to preset",
141
+ command: "modal reset",
142
+ help: "clear overrides, keep the preset",
143
+ });
144
+ return nodes;
145
+ }
@@ -12,6 +12,7 @@ import {
12
12
  VELOCITY_CURVES,
13
13
  } from "../../core/expression.ts";
14
14
  import type { Note, Track } from "../../core/score.ts";
15
+ import { isPianoFamily } from "../../core/keys.ts";
15
16
  import {
16
17
  BEND_SHAPES,
17
18
  DEFAULT_HUMANIZE,
@@ -50,12 +51,50 @@ export function performanceDetail(track: Track | undefined): string {
50
51
  const parts = [
51
52
  track.glide ? `glide ${track.glide.mode}` : "",
52
53
  track.pedal?.length ? "pedal" : "",
54
+ track.softPedal?.length ? "soft" : "",
55
+ track.sostenuto?.length ? "sost" : "",
53
56
  track.velocityCurve ? `vel ${track.velocityCurve.curve}` : "",
54
57
  track.humanize ? "humanized" : "",
55
58
  ].filter(Boolean);
56
59
  return parts.length ? parts.join(" · ") : "as written";
57
60
  }
58
61
 
62
+ /** Soft pedal and Sostenuto rows: off, the current lane, or the whole loop. */
63
+ function pianoPedalNodes(track: Track): MenuNode[] {
64
+ const rows = [
65
+ {
66
+ lane: "soft",
67
+ label: "soft pedal",
68
+ events: track.softPedal,
69
+ help: "una corda: the hammers shift to strike fewer strings, softer and darker; read at each note's onset",
70
+ },
71
+ {
72
+ lane: "sost",
73
+ label: "sostenuto",
74
+ events: track.sostenuto,
75
+ help: "holds only the keys already down when it presses (the middle pedal); later notes damp normally",
76
+ },
77
+ ] as const;
78
+ return rows
79
+ .filter((row) => isPianoFamily(track.instrument) || row.events?.length)
80
+ .map((row): MenuNode => {
81
+ const value = row.events?.length ? `${row.events.length} events` : "off";
82
+ return {
83
+ kind: "choice",
84
+ label: row.label,
85
+ value,
86
+ options: ["off", ...(value === "off" ? [] : [value]), "bars"],
87
+ command: (option) =>
88
+ option === "bars"
89
+ ? `pedal ${row.lane} bars`
90
+ : option === "off"
91
+ ? `pedal ${row.lane} off`
92
+ : `pedal ${row.lane}`,
93
+ help: row.help,
94
+ };
95
+ });
96
+ }
97
+
59
98
  export function performanceNodes(context: MenuContext): MenuNode[] {
60
99
  const track = context.score.tracks.find(
61
100
  (item) => item.id === context.trackId,
@@ -148,6 +187,9 @@ export function performanceNodes(context: MenuContext): MenuNode[] {
148
187
  : "pedal",
149
188
  help: "bars replaces the lane with a re-pedal on each downbeat; in play mode press Tab (latch) or hold Shift while recording",
150
189
  },
190
+ // keys-electric (0.6.1): the piano's other two pedals, on modelled piano
191
+ // tracks (or wherever a lane is already set).
192
+ ...pianoPedalNodes(track),
151
193
  {
152
194
  kind: "choice",
153
195
  label: "velocity curve",
@@ -35,6 +35,10 @@ import {
35
35
  EXTENSIONS,
36
36
  MAX_VOICING_STEP,
37
37
  PERFORM_MODES,
38
+ DEFAULT_STROKE_SPEED,
39
+ STROKE_PATTERN_NAMES,
40
+ strokeGrid,
41
+ type PerformOptions,
38
42
  PROGRESSION_PRESETS,
39
43
  PROGRESSION_STYLES,
40
44
  SPREADS,
@@ -100,6 +104,10 @@ export type ChordSettings = {
100
104
  /** `none` or a PROGRESSION_PRESETS name; drives the "next" suggestion. */
101
105
  preset: string;
102
106
  style: ProgressionStyle;
107
+ /** Guitar perform mode: a STROKE_PATTERNS name or a D U d u x - . grid. */
108
+ strokes: string;
109
+ /** Guitar perform mode: ms a full six-string down stroke takes. */
110
+ speed: number;
103
111
  };
104
112
 
105
113
  export function defaultChordSettings(): ChordSettings {
@@ -116,9 +124,45 @@ export function defaultChordSettings(): ChordSettings {
116
124
  sevenths: false,
117
125
  preset: "none",
118
126
  style: "pop",
127
+ strokes: "down",
128
+ speed: DEFAULT_STROKE_SPEED * 1000,
119
129
  };
120
130
  }
121
131
 
132
+ /** Play mode's `[` / `]` strum speed step in ms. */
133
+ export const STROKE_SPEED_STEP = 5;
134
+
135
+ /** Strum speed bounds in ms (0 = every string at once). */
136
+ export const STROKE_SPEED_MS = Object.freeze({ min: 0, max: 200 });
137
+
138
+ /**
139
+ * A strum speed from `/chords speed` text: milliseconds (`22`, `22ms`) or
140
+ * beats (`1/32b`, `0.05b`) at `bpm`. Undefined when not a speed.
141
+ */
142
+ export function parseStrokeSpeed(
143
+ text: string | undefined,
144
+ bpm: number,
145
+ ): number | undefined {
146
+ const raw = (text ?? "").trim().toLowerCase();
147
+ const beats = /^(\d+(?:\.\d+)?)(?:\/(\d+))?\s*(?:b|beats?)$/.exec(raw);
148
+ let ms: number;
149
+ if (beats) {
150
+ const value = Number(beats[1]) / (beats[2] ? Number(beats[2]) : 1);
151
+ ms = (value * 60_000) / bpm;
152
+ } else {
153
+ const plain = /^(\d+(?:\.\d+)?)\s*(?:ms)?$/.exec(raw);
154
+ if (!plain) return undefined;
155
+ ms = Number(plain[1]);
156
+ }
157
+ if (
158
+ !Number.isFinite(ms) ||
159
+ ms < STROKE_SPEED_MS.min ||
160
+ ms > STROKE_SPEED_MS.max
161
+ )
162
+ return undefined;
163
+ return Math.round(ms * 10) / 10;
164
+ }
165
+
122
166
  /**
123
167
  * Tracks where auto chords are the default: anything pitched that is not a
124
168
  * bass, a drum kit or a one-shot sampler.
@@ -236,6 +280,16 @@ export class ChordPad {
236
280
  s.perform = PERFORM_MODES[(index + 1) % PERFORM_MODES.length]!;
237
281
  return { type: "status", status: `perform ${s.perform}` };
238
282
  }
283
+ if ((value === "[" || value === "]") && s.perform === "guitar") {
284
+ // Strum speed: slower ([) or faster (]) in 5 ms steps.
285
+ const wanted =
286
+ s.speed + (value === "[" ? STROKE_SPEED_STEP : -STROKE_SPEED_STEP);
287
+ s.speed = Math.max(
288
+ STROKE_SPEED_MS.min,
289
+ Math.min(STROKE_SPEED_MS.max, Math.round(wanted)),
290
+ );
291
+ return { type: "status", status: `strum speed ${s.speed}ms` };
292
+ }
239
293
  if (value === "b") {
240
294
  const index = BASS_MODES.indexOf(s.bass);
241
295
  s.bass = BASS_MODES[(index + 1) % BASS_MODES.length]!;
@@ -392,6 +446,9 @@ export class ChordPad {
392
446
  { key: "0", label: "clear", on: false },
393
447
  { key: "-=", label: `voicing ${signed(s.inversion)}`, on: false },
394
448
  { key: "9", label: perform, on: false },
449
+ ...(s.perform === "guitar"
450
+ ? [{ key: "[]", label: `speed ${s.speed}ms`, on: false }]
451
+ : []),
395
452
  { key: "b", label: `bass ${s.bass}`, on: false },
396
453
  { key: "n", label: "next", on: false },
397
454
  { key: "q", label: s.mode === "auto" ? "auto" : "manual", on: false },
@@ -412,6 +469,8 @@ export class ChordPad {
412
469
  if (s.spread !== "close") parts.push(s.spread);
413
470
  if (s.perform === "pattern")
414
471
  parts.push(`pattern ${patternLabel(s.pattern)}`);
472
+ else if (s.perform === "guitar")
473
+ parts.push(`guitar ${s.strokes} ${s.speed}ms`);
415
474
  else if (s.perform !== "block") parts.push(s.perform);
416
475
  if (s.bass !== "off") parts.push(`bass ${s.bass}`);
417
476
  return parts.join(" · ");
@@ -435,15 +494,18 @@ function signed(value: number): string {
435
494
  /**
436
495
  * `/chords …`: `auto|manual|off`, `voicing <-12..12>`, `spread <close|open|
437
496
  * wide>`, `bass off|chords|unison|single|solo` (`on` = chords), `perform
438
- * <mode>`, `pattern <1..13|name>`, `rate <grid|1/8…>`, `octaves
497
+ * <mode>`, `pattern <1..13|name>`, `strokes <name|D-DU-UDU>` (guitar
498
+ * mode), `speed <ms|1/32b>`, `rate <grid|1/8…>`, `octaves
439
499
  * <1..4>`, `sevenths on|off`, `preset <name|none>`, `style <pop|jazz|…>`.
440
500
  * Mutates `settings`; returns the receipt or an error.
441
501
  */
442
502
  export function applyChordsCommand(
443
503
  settings: ChordSettings,
444
504
  argument: string,
505
+ bpm = 120,
445
506
  ): { ok: boolean; message: string } {
446
- const words = argument.trim().toLowerCase().split(/\s+/).filter(Boolean);
507
+ const raw = argument.trim().split(/\s+/).filter(Boolean);
508
+ const words = raw.map((word) => word.toLowerCase());
447
509
  const [head, value] = words;
448
510
  const done = (message: string) => ({ ok: true, message });
449
511
  const bad = (message: string) => ({ ok: false, message });
@@ -505,6 +567,28 @@ export function applyChordsCommand(
505
567
  return bad(`rate takes ${ARP_RATES.join("|")}`);
506
568
  settings.rate = value as ArpRate;
507
569
  return done(`chords rate ${value}`);
570
+ case "strokes": {
571
+ // Case matters: D is a full down stroke, d a light one.
572
+ const grid = raw.slice(1).join("");
573
+ if (!strokeGrid(grid))
574
+ return bad(
575
+ `strokes takes ${STROKE_PATTERN_NAMES.join("|")} or a grid of D U d u x - .`,
576
+ );
577
+ settings.strokes = (STROKE_PATTERN_NAMES as readonly string[]).includes(
578
+ grid.toLowerCase(),
579
+ )
580
+ ? grid.toLowerCase()
581
+ : grid;
582
+ settings.perform = "guitar";
583
+ return done(`chords strokes ${settings.strokes}`);
584
+ }
585
+ case "speed": {
586
+ const ms = parseStrokeSpeed(words.slice(1).join(""), bpm);
587
+ if (ms === undefined)
588
+ return bad("speed takes 0..200 ms (22, 22ms) or beats (1/32b)");
589
+ settings.speed = ms;
590
+ return done(`chords speed ${ms}ms`);
591
+ }
508
592
  case "octaves": {
509
593
  const n = Number(value);
510
594
  if (!Number.isInteger(n) || n < 1 || n > 4)
@@ -526,7 +610,7 @@ export function applyChordsCommand(
526
610
  return done(`chords style ${value}`);
527
611
  default:
528
612
  return bad(
529
- "/chords auto|manual|off · voicing · spread · bass · perform · pattern · rate · octaves · sevenths · preset · style",
613
+ "/chords auto|manual|off · voicing · spread · bass · perform · pattern · strokes · speed · rate · octaves · sevenths · preset · style",
530
614
  );
531
615
  }
532
616
  }
@@ -539,6 +623,8 @@ export function chordsSummary(settings: ChordSettings): string {
539
623
  s.spread,
540
624
  s.perform,
541
625
  `pattern ${patternLabel(s.pattern)}`,
626
+ `strokes ${s.strokes}`,
627
+ `speed ${s.speed}ms`,
542
628
  `rate ${s.rate}`,
543
629
  `octaves ${s.octaves}`,
544
630
  `bass ${s.bass}`,
@@ -603,6 +689,7 @@ export function chordPhrase(
603
689
  seed: 1,
604
690
  velocity: 0.8,
605
691
  ...(mode === "pattern" ? { pattern: settings.pattern } : {}),
692
+ ...(mode === "guitar" ? guitarPerform(settings, score, track) : {}),
606
693
  },
607
694
  });
608
695
  // With chords off a key plays one note: the root, in the chord's octave.
@@ -630,3 +717,17 @@ export function chordPhrase(
630
717
  }
631
718
  return notes;
632
719
  }
720
+
721
+ /** Perform options for the guitar mode: strokes, speed, tempo and fretting. */
722
+ export function guitarPerform(
723
+ settings: Pick<ChordSettings, "strokes" | "speed">,
724
+ score: Pick<TrackScore, "tempoBpm">,
725
+ track: Pick<Track, "guitar"> | undefined,
726
+ ): Partial<PerformOptions> {
727
+ return {
728
+ strokes: settings.strokes,
729
+ speed: settings.speed / 1000,
730
+ tempo: score.tempoBpm,
731
+ ...(track?.guitar ? { guitar: track.guitar } : {}),
732
+ };
733
+ }
@@ -12,6 +12,7 @@
12
12
  * Shift sustain while held Tab sustain latch Esc leave the mode
13
13
  * (while recording, sustain changes record as track pedal events)
14
14
  * I scale degrees: the home row plays the key's scale (any tuning)
15
+ * [ / ] strum slower / faster (chords perform guitar, 5 ms steps)
15
16
  *
16
17
  * Terminals report key-down only, so a held key is synthesized from the
17
18
  * keyboard's auto-repeat: a press sounds for the gate (one grid step by
@@ -48,21 +48,25 @@ import {
48
48
  transportBarStart,
49
49
  } from "../../core/tempo.ts";
50
50
  import {
51
+ LiveFullRenderer,
51
52
  LiveSynth,
52
53
  MAX_LIVE_NOTE_SECONDS,
53
54
  type LiveNotePcm,
54
55
  } from "../audio/live.ts";
55
56
  import type { SampleBank } from "../audio/samples.ts";
57
+ import { fitting, onFitReady } from "../audio/fit.ts";
56
58
  import {
57
59
  DEFAULT_STRUM,
58
60
  perform,
59
61
  type PerformMode,
62
+ type PerformOptions,
60
63
  type PerformedNote,
61
64
  } from "../../core/chords.ts";
62
65
  import {
63
66
  ChordPad,
64
67
  chordCapable,
65
68
  defaultChordSettings,
69
+ guitarPerform,
66
70
  songKey,
67
71
  type ChordSettings,
68
72
  type PlayedChord,
@@ -91,6 +95,8 @@ export interface LiveEngine {
91
95
  setLeadMs(ms: number | undefined): void;
92
96
  noteOn(id: number, note: LiveNotePcm): number;
93
97
  noteOff(id: number): void;
98
+ /** Frames of voice `id` mixed so far, or undefined once it has ended. */
99
+ voicePosition?(id: number): number | undefined;
94
100
  setClick(click: ClickBus | undefined): void;
95
101
  }
96
102
 
@@ -134,6 +140,8 @@ export const GRIDS: readonly Readonly<{ label: string; beats: number }>[] =
134
140
  export const DEFAULT_GRID = "1/16";
135
141
  /** Play mode's queue lead: short enough that a key feels immediate. */
136
142
  export const PLAY_LEAD_MS = 60;
143
+ /** Status while a long fitted sample window computes (fit.ts). */
144
+ const FITTING_STATUS = "fitting · the sample plays once ready";
137
145
  export const FLASH_MS = 110;
138
146
 
139
147
  export function gridBeats(label: string): number | undefined {
@@ -175,6 +183,8 @@ export type RecordedChord = Readonly<{
175
183
  seed: number;
176
184
  /** CHORD_PATTERNS name when `mode` is `pattern`. */
177
185
  pattern?: string;
186
+ /** Guitar mode: strokes, speed, tempo, fretting and the chord root. */
187
+ guitar?: Partial<PerformOptions>;
178
188
  }>;
179
189
 
180
190
  /** A chord sounding live: its voices start and stop from `tick()`. */
@@ -221,11 +231,16 @@ export class PlaySession {
221
231
  public countInBars = 1;
222
232
  public grid = DEFAULT_GRID;
223
233
  public status: string | undefined;
234
+ /** Unsubscribes the pending "fit ready" listener (fit.ts). */
235
+ private stopFitWait: (() => void) | undefined;
224
236
  /** Scale-degree layout on (`i`); follows key and tuning changes. */
225
237
  public degreesOn = false;
226
238
  private degreeKey: string | undefined;
227
239
  private active = false;
228
240
  private synth: LiveSynth | undefined;
241
+ /** Rig voices awaiting their full render: id → released since. */
242
+ private readonly windows = new Map<number, boolean>();
243
+ private readonly fullRenderer = new LiveFullRenderer();
229
244
  private readonly pending = new Map<number, Pending>();
230
245
  /** Sustain changes while recording (Tab latch or Shift), as pedal events. */
231
246
  private pendingPedal: { beat: number; state: PedalState }[] = [];
@@ -597,7 +612,7 @@ export class PlaySession {
597
612
  if (this.synth?.rate !== engine.sampleRate)
598
613
  this.synth = new LiveSynth(engine.sampleRate);
599
614
  const score = this.host.score();
600
- const pcm = this.synth.render({
615
+ const request = {
601
616
  score,
602
617
  trackId: this.trackId,
603
618
  pitch: note.pitch,
@@ -607,16 +622,53 @@ export class PlaySession {
607
622
  ...(score.time?.tempo
608
623
  ? { tick: loopTickAt(score, this.host.beatAt(note.atMs)) }
609
624
  : {}),
610
- });
625
+ };
626
+ const synth = this.synth;
627
+ const pcm = synth.render(request);
611
628
  if (!pcm) return;
629
+ if (pcm.fitting) {
630
+ // A long fitted sample window is computing: silent, never off-pitch.
631
+ this.status = FITTING_STATUS;
632
+ this.stopFitWait ??= onFitReady(() => {
633
+ if (fitting()) return;
634
+ this.stopFitWait?.();
635
+ this.stopFitWait = undefined;
636
+ if (this.status === FITTING_STATUS)
637
+ this.status = "fit ready · play the key again";
638
+ });
639
+ return;
640
+ }
612
641
  const scheduled = engine.noteOn(id, pcm);
613
642
  this.lastLatencyMs = Math.max(0, scheduled - this.host.now());
643
+ // A guitar rig sounds its first window now; the rest renders after the
644
+ // key event and replaces the voice in place while it is still sounding.
645
+ this.windows.delete(id);
646
+ if (pcm.partial) this.windows.set(id, false);
647
+ // The full pass runs on a worker, in key order, so a strummed chord or a
648
+ // fast run never stalls key handling behind an oversampled render.
649
+ if (pcm.partial)
650
+ void this.fullRenderer
651
+ .full(
652
+ synth,
653
+ pcm.clock === undefined ? request : { ...request, clock: pcm.clock },
654
+ )
655
+ .then((full) => {
656
+ // Not after note-off: a swap would cancel the release fade.
657
+ if (
658
+ full &&
659
+ this.windows.get(id) === false &&
660
+ engine.voicePosition?.(id) !== undefined
661
+ )
662
+ engine.noteOn(id, full);
663
+ this.windows.delete(id);
664
+ });
614
665
  }
615
666
 
616
667
  private release(ids: readonly number[], atMs: number): void {
617
668
  const engine = this.host.engine();
618
669
  for (const id of ids) {
619
670
  engine?.noteOff(id);
671
+ if (this.windows.has(id)) this.windows.set(id, true);
620
672
  const live = this.liveChords.get(id);
621
673
  if (live) live.releaseAtMs = Math.min(live.releaseAtMs, atMs);
622
674
  const pending = this.pending.get(id);
@@ -686,6 +738,20 @@ export class PlaySession {
686
738
  octaves: settings.octaves,
687
739
  seed,
688
740
  ...(mode === "pattern" ? { pattern: settings.pattern } : {}),
741
+ ...(mode === "guitar" ? { guitar: this.guitarOptions(played) } : {}),
742
+ };
743
+ }
744
+
745
+ /** Guitar-mode perform options for a played chord (strokes, speed, frets). */
746
+ private guitarOptions(played: PlayedChord): Partial<PerformOptions> {
747
+ return {
748
+ ...guitarPerform(
749
+ this.chords.settings,
750
+ { tempoBpm: this.currentBpm() },
751
+ this.trackData(),
752
+ ),
753
+ root: played.chord.root,
754
+ ...(played.chord.bass !== undefined ? { slash: played.chord.bass } : {}),
689
755
  };
690
756
  }
691
757
 
@@ -706,6 +772,7 @@ export class PlaySession {
706
772
  seed: note.id,
707
773
  pattern: settings.pattern,
708
774
  velocity: 1,
775
+ ...(mode === "guitar" ? this.guitarOptions(played) : {}),
709
776
  });
710
777
  if (played.bass !== undefined)
711
778
  plan.push({
@@ -1170,6 +1237,7 @@ export function chordNotes(
1170
1237
  seed: chord.seed,
1171
1238
  ...(chord.pattern !== undefined ? { pattern: chord.pattern } : {}),
1172
1239
  velocity: 1,
1240
+ ...chord.guitar,
1173
1241
  }).map((note) => ({
1174
1242
  pitch: note.pitch,
1175
1243
  velocity: scaleVelocity(press.velocity, note.velocity),