@hraness/dawg 0.6.0 → 0.7.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 (275) hide show
  1. package/CHANGELOG.md +126 -0
  2. package/DAWG.md +655 -141
  3. package/README.md +27 -25
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +759 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +182 -99
  8. package/core/expression.ts +158 -6
  9. package/core/fx.ts +354 -32
  10. package/core/granular.ts +91 -0
  11. package/core/instruments.ts +180 -0
  12. package/core/keys.ts +573 -4
  13. package/core/loop.ts +5 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/resonators.ts +306 -4
  17. package/core/routing.ts +165 -0
  18. package/core/score.ts +1181 -16
  19. package/core/sdk/eval-child.ts +7 -2
  20. package/core/sdk/eval.ts +35 -6
  21. package/core/sdk/print.ts +354 -6
  22. package/core/sdk/sync-lyrics.ts +49 -0
  23. package/core/sdk/v1.ts +3160 -69
  24. package/core/sections.ts +298 -24
  25. package/core/sing.ts +815 -0
  26. package/core/strings.ts +237 -2
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/core/winds.ts +652 -0
  50. package/guides/agent.md +29 -0
  51. package/guides/arrange.md +30 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -6
  54. package/guides/chords.md +16 -14
  55. package/guides/effects.md +17 -15
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +12 -6
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/performance.md +15 -10
  65. package/guides/play.md +21 -13
  66. package/guides/project.md +25 -8
  67. package/guides/providers.md +20 -14
  68. package/guides/resample.md +32 -0
  69. package/guides/rhythm.md +17 -13
  70. package/guides/sessions.md +17 -7
  71. package/guides/show-me.md +31 -0
  72. package/guides/sound.md +25 -9
  73. package/guides/sounds.md +15 -12
  74. package/guides/styles.md +31 -0
  75. package/guides/tempo.md +15 -10
  76. package/guides/tracks.md +15 -10
  77. package/guides/tuning.md +32 -0
  78. package/guides/voice.md +31 -0
  79. package/guides/web-search.md +18 -8
  80. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  81. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  82. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  83. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  84. package/native/prebuilt/manifest.json +21 -0
  85. package/package.json +5 -2
  86. package/src/agent/agent.ts +131 -13
  87. package/src/agent/calibration-tools.ts +53 -0
  88. package/src/agent/chord-tools.ts +153 -1
  89. package/src/agent/clip-tools.ts +453 -0
  90. package/src/agent/command-agent.ts +369 -0
  91. package/src/agent/drum-tools.ts +2 -2
  92. package/src/agent/expression-tools.ts +91 -0
  93. package/src/agent/gateway.ts +246 -60
  94. package/src/agent/models.ts +53 -12
  95. package/src/agent/ops.ts +15 -1
  96. package/src/agent/pack-tools.ts +1 -1
  97. package/src/agent/planner.ts +13 -0
  98. package/src/agent/portable-schema.ts +80 -0
  99. package/src/agent/preview-tool.ts +5 -1
  100. package/src/agent/provider.ts +22 -8
  101. package/src/agent/resample-tool.ts +131 -0
  102. package/src/agent/rhythm-tools.ts +1 -1
  103. package/src/agent/section-tools.ts +1 -1
  104. package/src/agent/show-me.ts +497 -0
  105. package/src/agent/steer.ts +15 -0
  106. package/src/agent/style-tools.ts +217 -0
  107. package/src/agent/tool-error.ts +12 -0
  108. package/src/agent/tools.ts +307 -37
  109. package/src/agent/usage.ts +2 -2
  110. package/src/agent/voice-tools.ts +925 -0
  111. package/src/agent/xcb-agent.ts +16 -6
  112. package/src/argv.ts +38 -0
  113. package/src/audio/analysis.ts +253 -0
  114. package/src/audio/arrange.ts +58 -3
  115. package/src/audio/autotune-engine.ts +101 -0
  116. package/src/audio/autotune.ts +640 -0
  117. package/src/audio/clips.ts +240 -0
  118. package/src/audio/doctor.ts +86 -0
  119. package/src/audio/dsp/bandbank.ts +138 -0
  120. package/src/audio/dsp/envelope.ts +171 -0
  121. package/src/audio/dsp/follow.ts +120 -0
  122. package/src/audio/dsp/formant.ts +427 -0
  123. package/src/audio/dsp/glottal.ts +243 -0
  124. package/src/audio/dsp/interp.ts +7 -2
  125. package/src/audio/dsp/lpc.ts +50 -0
  126. package/src/audio/dsp/periodicity.ts +59 -0
  127. package/src/audio/dsp/pitch.ts +995 -0
  128. package/src/audio/dsp/psola.ts +199 -0
  129. package/src/audio/dsp/shift.ts +256 -0
  130. package/src/audio/effects/chain.ts +21 -4
  131. package/src/audio/effects/common.ts +57 -0
  132. package/src/audio/effects/convolution.ts +113 -5
  133. package/src/audio/effects/filter.ts +48 -69
  134. package/src/audio/effects/formant.ts +263 -0
  135. package/src/audio/effects/gaze.ts +354 -0
  136. package/src/audio/engine.ts +206 -33
  137. package/src/audio/fit.ts +80 -3
  138. package/src/audio/granular.ts +352 -21
  139. package/src/audio/instrument-check.ts +67 -47
  140. package/src/audio/instruments.ts +36 -3
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +427 -0
  143. package/src/audio/keys/engine.ts +156 -20
  144. package/src/audio/keys/organ.ts +1335 -0
  145. package/src/audio/keys/piano.ts +72 -8
  146. package/src/audio/keys/sympathetic.ts +127 -0
  147. package/src/audio/kits.ts +135 -6
  148. package/src/audio/live-worker.ts +3 -1
  149. package/src/audio/live.ts +247 -26
  150. package/src/audio/native.ts +615 -0
  151. package/src/audio/preview.ts +45 -3
  152. package/src/audio/render-worker.ts +2 -0
  153. package/src/audio/renderer.ts +2 -0
  154. package/src/audio/resample.ts +286 -0
  155. package/src/audio/resonators.ts +10 -2
  156. package/src/audio/sampler.ts +252 -24
  157. package/src/audio/samples.ts +46 -3
  158. package/src/audio/sing/analysis.ts +193 -0
  159. package/src/audio/sing/engine.ts +949 -0
  160. package/src/audio/strings/body.ts +29 -16
  161. package/src/audio/strings/bow.ts +699 -0
  162. package/src/audio/strings/engine.ts +274 -22
  163. package/src/audio/strings/measure.test-helpers.ts +2 -0
  164. package/src/audio/synth/oscillators.ts +31 -21
  165. package/src/audio/synth/voice.ts +34 -1
  166. package/src/audio/vocoder/bank.ts +314 -0
  167. package/src/audio/vocoder/carrier.ts +165 -0
  168. package/src/audio/vocoder/control.ts +68 -0
  169. package/src/audio/vocoder/detect.ts +50 -0
  170. package/src/audio/vocoder/index.ts +304 -0
  171. package/src/audio/vocoder/talkbox.ts +143 -0
  172. package/src/audio/wav.ts +608 -85
  173. package/src/audio/winds/engine.ts +306 -0
  174. package/src/audio/winds/filters.ts +153 -0
  175. package/src/audio/winds/pitch.ts +104 -0
  176. package/src/audio/winds/trim.ts +104 -0
  177. package/src/audio/winds/trims.ts +917 -0
  178. package/src/audio/winds/trims1.ts +297 -0
  179. package/src/audio/winds/voice.ts +492 -0
  180. package/src/auth/cli.ts +38 -36
  181. package/src/auth/credentials.ts +30 -1
  182. package/src/auth/login.ts +15 -9
  183. package/src/auth/tui.ts +19 -10
  184. package/src/commands/arrange.ts +44 -29
  185. package/src/commands/autotune.ts +421 -0
  186. package/src/commands/calibration.ts +74 -0
  187. package/src/commands/clips.ts +887 -0
  188. package/src/commands/drums.ts +3 -2
  189. package/src/commands/edit.ts +11 -4
  190. package/src/commands/expression.ts +104 -23
  191. package/src/commands/formant.ts +221 -0
  192. package/src/commands/fx.ts +105 -33
  193. package/src/commands/grammar.ts +558 -0
  194. package/src/commands/granular.ts +7 -2
  195. package/src/commands/help.ts +652 -339
  196. package/src/commands/history.ts +18 -0
  197. package/src/commands/keys.ts +293 -17
  198. package/src/commands/modal.ts +67 -4
  199. package/src/commands/music.ts +1 -1
  200. package/src/commands/nearest.ts +53 -0
  201. package/src/commands/pack.ts +9 -2
  202. package/src/commands/param-range.ts +56 -0
  203. package/src/commands/parses.ts +130 -0
  204. package/src/commands/progression.ts +170 -0
  205. package/src/commands/resample.ts +281 -0
  206. package/src/commands/rhythm.ts +3 -0
  207. package/src/commands/rig.ts +8 -25
  208. package/src/commands/sample.ts +49 -2
  209. package/src/commands/shift.ts +119 -0
  210. package/src/commands/sing.ts +478 -0
  211. package/src/commands/string.ts +28 -2
  212. package/src/commands/strum.ts +485 -0
  213. package/src/commands/style.ts +415 -0
  214. package/src/commands/time.ts +6 -3
  215. package/src/commands/tuning.ts +3 -3
  216. package/src/commands/vocal-pitch.ts +616 -0
  217. package/src/commands/vocal.ts +147 -0
  218. package/src/commands/vocoder.ts +627 -0
  219. package/src/commands/wind.ts +244 -0
  220. package/src/fs/durable.ts +50 -0
  221. package/src/lang/glossary.ts +493 -0
  222. package/src/launch-args.ts +163 -0
  223. package/src/main.ts +1521 -255
  224. package/src/media/cli.ts +20 -3
  225. package/src/media/import.ts +3 -1
  226. package/src/project/check.ts +26 -3
  227. package/src/project/clip-pins.ts +72 -0
  228. package/src/project/init.ts +23 -8
  229. package/src/project/sync.ts +418 -86
  230. package/src/render.ts +36 -1
  231. package/src/session/daemon.ts +3 -0
  232. package/src/session/meta.ts +14 -0
  233. package/src/session/origin.ts +154 -0
  234. package/src/session/port.ts +22 -4
  235. package/src/session/presence.ts +50 -7
  236. package/src/session/protocol.ts +5 -1
  237. package/src/session/rebase.ts +18 -5
  238. package/src/session/receipt.ts +258 -0
  239. package/src/session/store.ts +65 -43
  240. package/src/tui/arrange-menu.ts +65 -31
  241. package/src/tui/audition.ts +1 -1
  242. package/src/tui/euclid.ts +18 -13
  243. package/src/tui/fader.ts +228 -41
  244. package/src/tui/granular-menu.ts +2 -4
  245. package/src/tui/menu-clips.ts +297 -0
  246. package/src/tui/menu-time.ts +20 -13
  247. package/src/tui/menu-voice.ts +405 -0
  248. package/src/tui/menu.ts +1377 -193
  249. package/src/tui/modal-menu.ts +38 -11
  250. package/src/tui/performance-menu.ts +46 -1
  251. package/src/tui/play-chords.ts +108 -5
  252. package/src/tui/play-mode.ts +16 -1
  253. package/src/tui/play-session.ts +81 -16
  254. package/src/tui/sing-menu.ts +278 -0
  255. package/src/tui/style-menu.ts +104 -0
  256. package/src/tui/vocoder-menu.ts +244 -0
  257. package/src/tui/wind-menu.ts +144 -0
  258. package/src/version.ts +8 -0
  259. package/src/web/fetch.ts +115 -29
  260. package/tui/activity.ts +180 -9
  261. package/tui/app.ts +274 -40
  262. package/tui/clip-row.ts +132 -0
  263. package/tui/delight.ts +144 -0
  264. package/tui/drawer.ts +70 -22
  265. package/tui/frame-gate.ts +76 -0
  266. package/tui/grammar.ts +112 -63
  267. package/tui/guide.ts +42 -4
  268. package/tui/highway.ts +269 -25
  269. package/tui/hints.ts +192 -0
  270. package/tui/input.ts +60 -9
  271. package/tui/keys.ts +1 -1
  272. package/tui/play-strip.ts +68 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +144 -14
  275. package/tui/theme.ts +27 -0
@@ -0,0 +1,485 @@
1
+ /**
2
+ * `guitar …` and `strum …` (0.6.1): a track's fretting setup and strummed
3
+ * guitar chords written as notes.
4
+ *
5
+ * guitar show the setup
6
+ * guitar tune dadgad | E A D G B E tuning name or open-string notes
7
+ * guitar capo 2 · hand 5 · ring 0.8 · position 5 · guitar reset
8
+ * strum G D Em C folk chords (symbols or roman numerals)
9
+ * strum I V vi IV strokes D-DU-UDU speed 30ms each 2 at 4
10
+ * strum strum the chords already on the track
11
+ *
12
+ * Strumming runs core/chords.ts perform mode `guitar`, so play mode, the
13
+ * agent's strum_chords and the SDK's strum() write the same notes.
14
+ */
15
+
16
+ import {
17
+ chordName,
18
+ chordPitchClasses,
19
+ DEFAULT_STROKE_SPEED,
20
+ EXTENSIONS,
21
+ GUITAR_TUNING_NAMES,
22
+ guitarStrings,
23
+ keyUsesFlats,
24
+ makeChord,
25
+ parseKey,
26
+ QUALITIES,
27
+ renderProgression,
28
+ resolveChord,
29
+ STROKE_PATTERN_NAMES,
30
+ strokeGrid,
31
+ type Chord,
32
+ type Extension,
33
+ type Key,
34
+ type PerformedNote,
35
+ } from "../../core/chords.ts";
36
+ import { pitchToMidi } from "../../core/pitch.ts";
37
+
38
+ function parsePitch(text: string): number | undefined {
39
+ const midi = pitchToMidi(text);
40
+ return Number.isNaN(midi) ? undefined : midi;
41
+ }
42
+ import {
43
+ normalizeGuitar,
44
+ SCORE_LIMITS,
45
+ ScoreValidationError,
46
+ updateTrack,
47
+ type Note,
48
+ type ScoreOperation,
49
+ type TrackGuitar,
50
+ type TrackScore,
51
+ } from "../../core/score.ts";
52
+ import { resolveTuning, snapToTuning } from "../../core/tuning.ts";
53
+ import { applyScoreOperations } from "../../core/diff.ts";
54
+ import { barTicks } from "../../core/sections.ts";
55
+
56
+ export type GuitarCommand =
57
+ | { type: "guitar-show" }
58
+ | { type: "guitar-reset" }
59
+ | { type: "guitar-set"; patch: Partial<Record<keyof TrackGuitar, unknown>> }
60
+ | { type: "guitar-hint"; message: string };
61
+
62
+ const GUITAR_USAGE =
63
+ "guitar tune <name|E A D G B E> · capo 0..12 · hand 3..6 · ring 0..1 · position 0..12 · reset";
64
+
65
+ /** `guitar …`, or undefined when the prompt is not a guitar command. */
66
+ export function parseGuitarCommand(prompt: string): GuitarCommand | undefined {
67
+ if (prompt.length > 512) return undefined;
68
+ const words = prompt.trim().replace(/^\//, "").split(/\s+/);
69
+ if (words[0]?.toLowerCase() !== "guitar") return undefined;
70
+ const field = words[1]?.toLowerCase();
71
+ const rest = words.slice(2);
72
+ if (field === undefined) return { type: "guitar-show" };
73
+ if (field === "reset" || field === "off") return { type: "guitar-reset" };
74
+ if (field === "tune" || field === "tuning") {
75
+ if (rest.length === 1) {
76
+ const name = rest[0]!.toLowerCase().replace(/[\s_-]/g, "");
77
+ if ((GUITAR_TUNING_NAMES as readonly string[]).includes(name))
78
+ return { type: "guitar-set", patch: { tune: name } };
79
+ }
80
+ const pitches = parseOpenStrings(rest);
81
+ if (pitches) return { type: "guitar-set", patch: { tune: pitches } };
82
+ return {
83
+ type: "guitar-hint",
84
+ message: `guitar tune takes ${GUITAR_TUNING_NAMES.join(" ")} or 3..12 notes low to high (E2 A2 D3 G3 B3 E4, or E A D G B E)`,
85
+ };
86
+ }
87
+ if (
88
+ field === "capo" ||
89
+ field === "hand" ||
90
+ field === "ring" ||
91
+ field === "position"
92
+ ) {
93
+ const value = Number(rest[0]);
94
+ if (rest.length !== 1 || !Number.isFinite(value))
95
+ return { type: "guitar-hint", message: GUITAR_USAGE };
96
+ return { type: "guitar-set", patch: { [field]: value } };
97
+ }
98
+ return { type: "guitar-hint", message: GUITAR_USAGE };
99
+ }
100
+
101
+ /**
102
+ * Open strings low to high: `E2 A2 D3 G3 B3 E4`, MIDI numbers, or bare
103
+ * letters (`D A D G A D`), each placed at or above the previous string from
104
+ * octave 2 (a high re-entrant string needs its octave: `G4 C4 E4 A4`).
105
+ */
106
+ export function parseOpenStrings(
107
+ words: readonly string[],
108
+ ): readonly number[] | undefined {
109
+ if (words.length < 3 || words.length > 12) return undefined;
110
+ const out: number[] = [];
111
+ for (const word of words) {
112
+ if (/^\d+$/.test(word)) {
113
+ const pitch = Number(word);
114
+ if (pitch > 127) return undefined;
115
+ out.push(pitch);
116
+ continue;
117
+ }
118
+ const bare = /^([a-g])([#b]?)$/i.exec(word);
119
+ if (bare) {
120
+ const pitch = parsePitch(`${bare[1]!.toUpperCase()}${bare[2]}2`);
121
+ if (pitch === undefined) return undefined;
122
+ const previous = out.at(-1);
123
+ let placed = pitch;
124
+ while (previous !== undefined && placed <= previous) placed += 12;
125
+ out.push(placed);
126
+ continue;
127
+ }
128
+ const pitch = parsePitch(word.charAt(0).toUpperCase() + word.slice(1));
129
+ if (pitch === undefined) return undefined;
130
+ out.push(pitch);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ export type CommandResult = Readonly<{
136
+ ok: boolean;
137
+ message: string;
138
+ next?: TrackScore;
139
+ kind?: string;
140
+ payload?: Record<string, unknown>;
141
+ }>;
142
+
143
+ /** One-line description of a guitar setup. */
144
+ export function describeGuitar(guitar: TrackGuitar | undefined): string {
145
+ const tune = guitar?.tune ?? "standard";
146
+ const parts = [
147
+ typeof tune === "string" ? tune : tune.join(" "),
148
+ `capo ${guitar?.capo ?? 0}`,
149
+ `hand ${guitar?.hand ?? 4}`,
150
+ `ring ${guitar?.ring ?? 0.5}`,
151
+ ];
152
+ if (guitar?.position !== undefined) parts.push(`position ${guitar.position}`);
153
+ return parts.join(" · ");
154
+ }
155
+
156
+ export function applyGuitarCommand(
157
+ score: TrackScore,
158
+ trackId: string,
159
+ command: GuitarCommand,
160
+ ): CommandResult {
161
+ if (command.type === "guitar-hint")
162
+ return { ok: false, message: command.message };
163
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
164
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
165
+ if (command.type === "guitar-show")
166
+ return {
167
+ ok: true,
168
+ message: `guitar · ${describeGuitar(track.guitar)}${track.guitar ? "" : " (default)"}`,
169
+ };
170
+ let guitar: TrackGuitar | undefined;
171
+ try {
172
+ guitar =
173
+ command.type === "guitar-reset"
174
+ ? undefined
175
+ : normalizeGuitar({ ...track.guitar, ...command.patch });
176
+ } catch (error) {
177
+ if (error instanceof ScoreValidationError)
178
+ return { ok: false, message: error.message.replace(/^track /, "") };
179
+ throw error;
180
+ }
181
+ const next = updateTrack(score, trackId, { guitar: guitar ?? null });
182
+ return {
183
+ ok: true,
184
+ message: guitar ? `guitar · ${describeGuitar(guitar)}` : "guitar · reset",
185
+ next,
186
+ kind: "score.effect",
187
+ payload: { trackId, guitar: guitar ?? null },
188
+ };
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // strum
193
+
194
+ export type StrumOptions = Readonly<{
195
+ /** Chord symbols or roman numerals; empty strums the track's own chords. */
196
+ chords: readonly string[];
197
+ /** STROKE_PATTERNS name or a D U d u x - . grid. */
198
+ strokes?: string;
199
+ /** Ms a full down stroke takes. */
200
+ speedMs?: number;
201
+ /** Grid step in beats. */
202
+ step?: number;
203
+ /** Beats per chord (default one bar). */
204
+ each?: number;
205
+ /** First beat (default 0). */
206
+ at?: number;
207
+ velocity?: number;
208
+ seed?: number;
209
+ }>;
210
+
211
+ export type StrumCommand =
212
+ | { type: "strum"; options: StrumOptions }
213
+ | { type: "strum-hint"; message: string };
214
+
215
+ const STRUM_USAGE = `strum <chords> [${STROKE_PATTERN_NAMES.join("|")}] [strokes D-DU-UDU] [speed 22ms] [each 4] [at 0] · strum alone strums the track's chords`;
216
+
217
+ /** `strum …`, or undefined when the prompt is not a strum command. */
218
+ export function parseStrumCommand(prompt: string): StrumCommand | undefined {
219
+ if (prompt.length > 1_024) return undefined;
220
+ const words = prompt.trim().replace(/^\//, "").split(/\s+/);
221
+ if (words[0]?.toLowerCase() !== "strum") return undefined;
222
+ const chords: string[] = [];
223
+ const options: Record<string, unknown> = {};
224
+ for (let i = 1; i < words.length; i += 1) {
225
+ const word = words[i]!;
226
+ const lower = word.toLowerCase();
227
+ const next = words[i + 1];
228
+ if (lower === "strokes") {
229
+ if (!next || !strokeGrid(next))
230
+ return { type: "strum-hint", message: STRUM_USAGE };
231
+ options.strokes = next;
232
+ i += 1;
233
+ } else if (lower === "speed") {
234
+ const ms = /^(\d+(?:\.\d+)?)(?:ms)?$/i.exec(next ?? "");
235
+ if (!ms || Number(ms[1]) > 200)
236
+ return { type: "strum-hint", message: "strum speed takes 0..200 ms" };
237
+ options.speedMs = Number(ms[1]);
238
+ i += 1;
239
+ } else if (
240
+ lower === "each" ||
241
+ lower === "at" ||
242
+ lower === "step" ||
243
+ lower === "seed"
244
+ ) {
245
+ const value = Number(next);
246
+ if (
247
+ !Number.isFinite(value) ||
248
+ value < 0 ||
249
+ (lower !== "at" && lower !== "seed" && value <= 0)
250
+ )
251
+ return { type: "strum-hint", message: STRUM_USAGE };
252
+ options[lower] = value;
253
+ i += 1;
254
+ } else if ((STROKE_PATTERN_NAMES as readonly string[]).includes(lower))
255
+ options.strokes = lower;
256
+ else chords.push(word);
257
+ }
258
+ if (chords.length > 64)
259
+ return { type: "strum-hint", message: "strum takes up to 64 chords" };
260
+ return { type: "strum", options: { ...options, chords } as StrumOptions };
261
+ }
262
+
263
+ /** Pitch classes of a chord, as a sorted key. */
264
+ function pcKey(pcs: Iterable<number>): string {
265
+ return [...new Set([...pcs].map((p) => ((p % 12) + 12) % 12))]
266
+ .sort((a, b) => a - b)
267
+ .join(",");
268
+ }
269
+
270
+ /**
271
+ * The chord a set of sounding pitches spells, preferring the lowest note as
272
+ * the root (else a slash chord over it), or undefined.
273
+ */
274
+ export function detectChord(pitches: readonly number[]): Chord | undefined {
275
+ if (pitches.length < 2) return undefined;
276
+ const sorted = [...pitches].sort((a, b) => a - b);
277
+ const want = pcKey(sorted);
278
+ const bass = ((sorted[0]! % 12) + 12) % 12;
279
+ const roots = [...new Set(sorted.map((p) => ((p % 12) + 12) % 12))];
280
+ const subsets: Extension[][] = [];
281
+ for (let mask = 0; mask < 1 << EXTENSIONS.length; mask += 1)
282
+ subsets.push(EXTENSIONS.filter((_, i) => mask & (1 << i)));
283
+ subsets.sort((a, b) => a.length - b.length);
284
+ for (const root of roots)
285
+ for (const extensions of subsets)
286
+ for (const quality of QUALITIES) {
287
+ const chord = makeChord(root, quality, extensions);
288
+ if (pcKey(chordPitchClasses(chord)) === want)
289
+ return root === bass
290
+ ? chord
291
+ : makeChord(root, quality, extensions, bass);
292
+ }
293
+ return undefined;
294
+ }
295
+
296
+ /** Chords already on a track: one per onset with two or more notes. */
297
+ export function trackChords(
298
+ notes: readonly Note[],
299
+ ): { chord: Chord; startTick: number; endTick: number; ids: string[] }[] {
300
+ const byStart = new Map<number, Note[]>();
301
+ for (const note of notes) {
302
+ const group = byStart.get(note.startTick) ?? [];
303
+ group.push(note);
304
+ byStart.set(note.startTick, group);
305
+ }
306
+ const starts = [...byStart.keys()].sort((a, b) => a - b);
307
+ const out: {
308
+ chord: Chord;
309
+ startTick: number;
310
+ endTick: number;
311
+ ids: string[];
312
+ }[] = [];
313
+ starts.forEach((start, index) => {
314
+ const group = byStart.get(start)!;
315
+ const chord = detectChord(group.map((note) => note.pitch));
316
+ if (!chord) return;
317
+ const own = Math.max(...group.map((n) => n.startTick + n.durationTicks));
318
+ const following = starts[index + 1];
319
+ out.push({
320
+ chord,
321
+ startTick: start,
322
+ endTick: following === undefined ? own : Math.min(own, following),
323
+ ids: group.map((note) => note.id),
324
+ });
325
+ });
326
+ return out;
327
+ }
328
+
329
+ export type StrumPlan = Readonly<{
330
+ operations: ScoreOperation[];
331
+ names: string[];
332
+ noteCount: number;
333
+ }>;
334
+
335
+ export class StrumError extends Error {
336
+ constructor(message: string) {
337
+ super(message);
338
+ this.name = "StrumError";
339
+ }
340
+ }
341
+
342
+ /**
343
+ * Strummed notes for `options` on `trackId`, as score operations (removing
344
+ * the block chords when strumming the track's own). Throws StrumError.
345
+ */
346
+ export function planStrum(
347
+ score: TrackScore,
348
+ trackId: string,
349
+ options: StrumOptions,
350
+ newId: (index: number) => string,
351
+ maxNotes = 4096,
352
+ ): StrumPlan {
353
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
354
+ if (!track) throw new StrumError(`no track · ${trackId}`);
355
+ if (options.strokes !== undefined && !strokeGrid(options.strokes))
356
+ throw new StrumError(
357
+ `strokes must be ${STROKE_PATTERN_NAMES.join(", ")} or a grid of D U d u x - .`,
358
+ );
359
+ const key: Key = parseKey(score.key) ?? { tonic: 0, mode: "major" };
360
+ const tpb = score.ticksPerBeat;
361
+ const perform = {
362
+ mode: "guitar" as const,
363
+ ...(options.strokes !== undefined ? { strokes: options.strokes } : {}),
364
+ ...(options.step !== undefined ? { step: options.step } : {}),
365
+ speed: (options.speedMs ?? DEFAULT_STROKE_SPEED * 1000) / 1000,
366
+ tempo: score.tempoBpm,
367
+ velocity: options.velocity ?? 0.8,
368
+ seed: options.seed ?? 0,
369
+ ...(track.guitar ? { guitar: track.guitar } : {}),
370
+ };
371
+ const regions: { chord: Chord; start: number; beats: number }[] = [];
372
+ const removals: string[] = [];
373
+ if (options.chords.length === 0) {
374
+ const found = trackChords(score.notes.filter((n) => n.trackId === trackId));
375
+ if (found.length === 0)
376
+ throw new StrumError(
377
+ "no chords on this track to strum · strum G D Em C, or write chords first",
378
+ );
379
+ for (const region of found) {
380
+ regions.push({
381
+ chord: region.chord,
382
+ start: region.startTick / tpb,
383
+ beats: (region.endTick - region.startTick) / tpb,
384
+ });
385
+ removals.push(...region.ids);
386
+ }
387
+ } else {
388
+ const each = options.each ?? score.beatsPerBar;
389
+ const at = options.at ?? 0;
390
+ options.chords.forEach((text, index) => {
391
+ const chord = resolveChord(key, text);
392
+ if (!chord)
393
+ throw new StrumError(
394
+ `unknown chord ${JSON.stringify(text.slice(0, 32))} (a symbol like Em7 or a numeral like vi)`,
395
+ );
396
+ regions.push({ chord, start: at + index * each, beats: each });
397
+ });
398
+ }
399
+ const performed: PerformedNote[] = [];
400
+ regions.forEach((region, index) => {
401
+ const rendered = renderProgression({
402
+ key,
403
+ chords: [region.chord],
404
+ beatsPerChord: region.beats,
405
+ start: region.start,
406
+ lead: false,
407
+ perform: { ...perform, seed: perform.seed + index },
408
+ });
409
+ performed.push(...rendered.notes);
410
+ });
411
+ if (performed.length > maxNotes)
412
+ throw new StrumError(
413
+ `${performed.length} notes exceed ${maxNotes}; strum fewer chords or a sparser grid`,
414
+ );
415
+ const table = resolveTuning(score.tuning, track.tuning, score.key);
416
+ const operations: ScoreOperation[] = removals.map((noteId) => ({
417
+ type: "removeNote",
418
+ noteId,
419
+ }));
420
+ performed.forEach((note, index) => {
421
+ operations.push({
422
+ type: "addNote",
423
+ note: {
424
+ id: newId(index),
425
+ trackId,
426
+ startTick: Math.round(note.start * tpb),
427
+ durationTicks: Math.max(1, Math.round(note.length * tpb)),
428
+ pitch: snapToTuning(note.pitch, table),
429
+ velocity: note.velocity,
430
+ },
431
+ });
432
+ });
433
+ const flats = keyUsesFlats(key);
434
+ return {
435
+ operations,
436
+ names: regions.map((region) => chordName(region.chord, flats)),
437
+ noteCount: performed.length,
438
+ };
439
+ }
440
+
441
+ /** Runs a strum command against the score. */
442
+ export function applyStrumCommand(
443
+ score: TrackScore,
444
+ trackId: string,
445
+ command: StrumCommand,
446
+ newId: (index: number) => string,
447
+ ): CommandResult {
448
+ if (command.type === "strum-hint")
449
+ return { ok: false, message: command.message };
450
+ let plan: StrumPlan;
451
+ try {
452
+ plan = planStrum(score, trackId, command.options, newId);
453
+ } catch (error) {
454
+ if (error instanceof StrumError)
455
+ return { ok: false, message: error.message };
456
+ throw error;
457
+ }
458
+ const track = score.tracks.find((candidate) => candidate.id === trackId)!;
459
+ let next: TrackScore;
460
+ try {
461
+ next = applyScoreOperations(score, plan.operations);
462
+ } catch (error) {
463
+ if (error instanceof ScoreValidationError)
464
+ return { ok: false, message: `strum · ${error.message}` };
465
+ throw error;
466
+ }
467
+ // Chords that run past the song's end grow it, so they are heard.
468
+ const end = next.notes.reduce(
469
+ (max, note) => Math.max(max, note.startTick + note.durationTicks),
470
+ 0,
471
+ );
472
+ const perBar = barTicks(next);
473
+ const needed = Math.min(SCORE_LIMITS.maxBars, Math.ceil(end / perBar));
474
+ const grew = needed > next.bars;
475
+ if (grew)
476
+ next = applyScoreOperations(next, [{ type: "setBars", bars: needed }]);
477
+ const strings = guitarStrings(track.guitar?.tune).length;
478
+ return {
479
+ ok: true,
480
+ message: `strum · ${plan.names.join(" ")} · ${command.options.strokes ?? "down"} · ${plan.noteCount} notes on ${strings} strings${grew ? ` · song now ${needed} bars` : ""}`,
481
+ next,
482
+ kind: "score.notes",
483
+ payload: { trackId, strum: plan.names },
484
+ };
485
+ }