@hraness/dawg 0.4.1 → 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 (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -0,0 +1,401 @@
1
+ /**
2
+ * Project › Tempo & meter: the song's tempo map, meter changes and
3
+ * fermatas, and the focused track's rate, phase and cycle. Every row runs a
4
+ * prompt command from `src/commands/time.ts`, so the menu and the prompt
5
+ * stay one grammar.
6
+ */
7
+ import {
8
+ barAt,
9
+ bpmAtTick,
10
+ loopSecondsOf,
11
+ loopTicksOf,
12
+ meterSegments,
13
+ TIME_LIMITS,
14
+ type TimeScore,
15
+ } from "../../core/tempo.ts";
16
+ import { parseTimeCommand } from "../commands/time.ts";
17
+ import type { MenuContext, MenuNode } from "./menu.ts";
18
+
19
+ /** The opening meter's note value (a bar-1 meter change can set it). */
20
+ export function openingUnit(score: TimeScore): number {
21
+ return meterSegments(score)[0]?.beatUnit ?? 4;
22
+ }
23
+
24
+ /** Beats per bar from the slider, keeping the opening note value. */
25
+ export function openingMeterCommand(score: TimeScore, value: number): string {
26
+ const unit = openingUnit(score);
27
+ const beats = Math.round(value);
28
+ return unit === 4 ? `meter ${beats}` : `meter ${beats}/${unit}`;
29
+ }
30
+
31
+ /** One-line summary for the Project row and the Tempo & meter row. */
32
+ export function tempoDetail(score: TimeScore): string {
33
+ const time = score.time;
34
+ const parts: string[] = [];
35
+ const events = time?.tempo ?? [];
36
+ if (events.length > 0) {
37
+ const low = Math.min(score.tempoBpm, ...events.map((e) => e.bpm));
38
+ const high = Math.max(score.tempoBpm, ...events.map((e) => e.bpm));
39
+ parts.push(
40
+ `${fmt(low)}–${fmt(high)} BPM · ${events.length} change${events.length === 1 ? "" : "s"}`,
41
+ );
42
+ } else parts.push(`${fmt(score.tempoBpm)} BPM`);
43
+ const meters = time?.meter ?? [];
44
+ const first = meters.find((change) => change.bar === 0);
45
+ parts.push(
46
+ first
47
+ ? `${first.beatsPerBar}/${first.beatUnit ?? 4}`
48
+ : `${score.beatsPerBar}/4`,
49
+ );
50
+ const later = meters.filter((change) => change.bar > 0).length;
51
+ if (later > 0) parts.push(`${later} meter change${later === 1 ? "" : "s"}`);
52
+ const fermatas = time?.fermatas?.length ?? 0;
53
+ if (fermatas > 0)
54
+ parts.push(`${fermatas} fermata${fermatas === 1 ? "" : "s"}`);
55
+ return parts.join(" · ");
56
+ }
57
+
58
+ /** The Tempo & meter submenu node for Project. */
59
+ export function tempoMenuNode(context: MenuContext): MenuNode {
60
+ return {
61
+ kind: "menu",
62
+ id: "tempo",
63
+ label: "tempo & meter",
64
+ detail: tempoDetail(context.score),
65
+ help: "tempo changes, rit. and accel., fermatas, meter changes, track rate and phase",
66
+ build: tempoNodes,
67
+ };
68
+ }
69
+
70
+ /** Rows of Project › Tempo & meter. */
71
+ export function tempoNodes(context: MenuContext): MenuNode[] {
72
+ const score = context.score;
73
+ const tpb = score.ticksPerBeat;
74
+ const time = score.time;
75
+ const nodes: MenuNode[] = [
76
+ {
77
+ kind: "number",
78
+ label: "start tempo",
79
+ help: "beats per minute at the top of the song",
80
+ value: score.tempoBpm,
81
+ min: TIME_LIMITS.minBpm,
82
+ max: TIME_LIMITS.maxBpm,
83
+ step: stepBy(1, TIME_LIMITS.minBpm, TIME_LIMITS.maxBpm),
84
+ format: (value) => `${fmt(value)} BPM`,
85
+ command: (value) => `tempo ${fmt(value)}`,
86
+ },
87
+ ];
88
+ for (const event of time?.tempo ?? []) {
89
+ const at = placeOf(score, event.tick);
90
+ const beat = fmt(event.tick / tpb);
91
+ const ramp = event.ramp ? ` ${event.ramp === "exp" ? "exp" : "ramp"}` : "";
92
+ nodes.push({
93
+ kind: "number",
94
+ label: `tempo @ ${at}`,
95
+ help: `${event.ramp ? (event.ramp === "exp" ? "exponential glide" : "linear glide") : "steps"} to this tempo · x removes it`,
96
+ value: event.bpm,
97
+ min: TIME_LIMITS.minBpm,
98
+ max: TIME_LIMITS.maxBpm,
99
+ step: stepBy(1, TIME_LIMITS.minBpm, TIME_LIMITS.maxBpm),
100
+ format: (value) =>
101
+ `${event.ramp ? (event.ramp === "exp" ? "exp→ " : "→ ") : ""}${fmt(value)} BPM`,
102
+ command: (value) => `tempo ${fmt(value)} at ${beat}${ramp}`,
103
+ reset: `tempo remove ${beat}`,
104
+ });
105
+ nodes.push({
106
+ kind: "choice",
107
+ label: `glide @ ${at}`,
108
+ help: "step: jump to the tempo · ramp: glide in evenly · exp: glide by equal ratios",
109
+ value: event.ramp ? (event.ramp === "exp" ? "exp" : "ramp") : "step",
110
+ options: ["step", "ramp", "exp"],
111
+ command: (option) =>
112
+ `tempo ${fmt(event.bpm)} at ${beat}${option === "step" ? "" : ` ${option}`}`,
113
+ });
114
+ }
115
+ nodes.push(
116
+ entry(
117
+ "add tempo change",
118
+ "<bpm> at <beat>|bar <n> [ramp|exp]",
119
+ "tempo",
120
+ "tempo 90 at bar 9 ramp",
121
+ "a step or glide to a new tempo; beats count from 0, bars from 1",
122
+ ),
123
+ entry(
124
+ "ritardando",
125
+ "[<n> bars] [to <bpm>] [at bar <n>] · empty: last 2 bars to 75%",
126
+ "rit",
127
+ "rit 4 bars to 80",
128
+ "slow down gradually; without a start it closes the song",
129
+ true,
130
+ ),
131
+ entry(
132
+ "accelerando",
133
+ "[<n> bars] [to <bpm>] [at bar <n>] · empty: last 2 bars to 133%",
134
+ "accel",
135
+ "accel 8 bars to 174 at bar 9",
136
+ "speed up gradually, e.g. into a drop",
137
+ true,
138
+ ),
139
+ entry(
140
+ "a tempo",
141
+ "[at bar <n>] · empty: the bar after the last rit/accel",
142
+ "a tempo",
143
+ "a tempo at bar 9",
144
+ "step back to the tempo before the last rit or accel",
145
+ true,
146
+ ),
147
+ entry(
148
+ "tempo primo",
149
+ "[at bar <n>] · empty: the bar after the last rit/accel",
150
+ "tempo primo",
151
+ "tempo primo at bar 17",
152
+ "step back to the start tempo",
153
+ true,
154
+ ),
155
+ );
156
+ for (const fermata of time?.fermatas ?? []) {
157
+ const beat = fmt(fermata.tick / tpb);
158
+ nodes.push({
159
+ kind: "number",
160
+ label: `fermata @ ${beat}`,
161
+ help: "extra beats this beat holds for · x removes it",
162
+ value: fermata.beats,
163
+ min: 0.25,
164
+ max: TIME_LIMITS.maxFermataBeats,
165
+ step: stepBy(0.25, 0.25, TIME_LIMITS.maxFermataBeats),
166
+ format: (value) => `+${fmt(value)} beat${value === 1 ? "" : "s"}`,
167
+ command: (value) => `fermata at ${beat} ${fmt(value)}`,
168
+ reset: `fermata remove ${beat}`,
169
+ });
170
+ }
171
+ nodes.push(
172
+ entry(
173
+ "add fermata",
174
+ "at <beat>|bar <n> [<extra beats>] · empty: the last beat, +2",
175
+ "fermata",
176
+ "fermata at 31 2",
177
+ "hold one beat longer, as a fermata does",
178
+ true,
179
+ ),
180
+ {
181
+ kind: "number",
182
+ label: "beats per bar",
183
+ help: "the song's opening meter; meter changes below override it later",
184
+ value: score.beatsPerBar,
185
+ min: 1,
186
+ max: TIME_LIMITS.maxBeatsPerBar,
187
+ step: stepBy(1, 1, TIME_LIMITS.maxBeatsPerBar),
188
+ format: (value) => `${value}/${openingUnit(score)}`,
189
+ command: (value) => openingMeterCommand(score, value),
190
+ },
191
+ );
192
+ for (const change of time?.meter ?? []) {
193
+ const unit = change.beatUnit ?? 4;
194
+ const bar = change.bar + 1;
195
+ nodes.push({
196
+ kind: "number",
197
+ label: `meter @ bar ${bar}`,
198
+ help: `beats of 1/${unit} per bar from bar ${bar} · x removes the change`,
199
+ value: change.beatsPerBar,
200
+ min: 1,
201
+ max: TIME_LIMITS.maxBeatsPerBar,
202
+ step: stepBy(1, 1, TIME_LIMITS.maxBeatsPerBar),
203
+ format: (value) => `${value}/${unit}`,
204
+ command: (value) => `meter ${Math.round(value)}/${unit} at bar ${bar}`,
205
+ reset: `meter remove bar ${bar}`,
206
+ });
207
+ }
208
+ nodes.push(
209
+ entry(
210
+ "add meter change",
211
+ "<n>/<d> at bar <n>",
212
+ "meter",
213
+ "meter 7/8 at bar 5",
214
+ "a new time signature from a bar line on",
215
+ ),
216
+ );
217
+ const track = context.score.tracks.find(
218
+ (candidate) => candidate.id === context.trackId,
219
+ );
220
+ if (track)
221
+ nodes.push({
222
+ kind: "menu",
223
+ id: "track-time",
224
+ label: `${track.name ?? track.id} time`,
225
+ detail: trackTimeDetail(context),
226
+ help: "the focused track's tempo ratio, phase and cycle (polytempo, polymeter, phasing)",
227
+ build: trackTimeNodes,
228
+ });
229
+ if (time?.tempo?.length)
230
+ nodes.push({
231
+ kind: "action",
232
+ label: "clear tempo changes",
233
+ command: "tempo clear",
234
+ help: `back to ${fmt(score.tempoBpm)} BPM throughout`,
235
+ });
236
+ if (time?.fermatas?.length)
237
+ nodes.push({
238
+ kind: "action",
239
+ label: "clear fermatas",
240
+ command: "fermata clear",
241
+ });
242
+ if (time?.meter?.length)
243
+ nodes.push({
244
+ kind: "action",
245
+ label: "clear meter changes",
246
+ command: "meter clear",
247
+ help: `back to ${score.beatsPerBar}/4 throughout`,
248
+ });
249
+ nodes.push({
250
+ kind: "info",
251
+ label: "song length",
252
+ // The written end tempo: a fermata's hold is not a tempo change.
253
+ value: `${fmt(loopSecondsOf(score))} s · ends at ${fmt(
254
+ bpmAtTick(
255
+ {
256
+ tempoBpm: score.tempoBpm,
257
+ beatsPerBar: score.beatsPerBar,
258
+ bars: score.bars,
259
+ ticksPerBeat: score.ticksPerBeat,
260
+ ...(time?.tempo || time?.meter
261
+ ? { time: { tempo: time.tempo, meter: time.meter } }
262
+ : {}),
263
+ },
264
+ loopTicksOf(score),
265
+ ),
266
+ )} BPM${time?.fermatas?.length ? ` · ${time.fermatas.length} fermata${time.fermatas.length === 1 ? "" : "s"}` : ""}`,
267
+ });
268
+ return nodes;
269
+ }
270
+
271
+ function trackTimeDetail(context: MenuContext): string {
272
+ const track = context.score.tracks.find(
273
+ (candidate) => candidate.id === context.trackId,
274
+ );
275
+ const time = track?.time;
276
+ if (!time) return "follows the song";
277
+ const tpb = context.score.ticksPerBeat;
278
+ const parts: string[] = [];
279
+ if (time.rate !== undefined) parts.push(`rate ${fmt(time.rate, 4)}×`);
280
+ if (time.phase !== undefined) parts.push(`phase ${fmt(time.phase / tpb)}`);
281
+ if (time.cycle !== undefined) parts.push(`cycle ${fmt(time.cycle / tpb)}`);
282
+ return parts.join(" · ");
283
+ }
284
+
285
+ /** Rows of the focused track's time submenu. */
286
+ export function trackTimeNodes(context: MenuContext): MenuNode[] {
287
+ const score = context.score;
288
+ const track = score.tracks.find(
289
+ (candidate) => candidate.id === context.trackId,
290
+ );
291
+ if (!track)
292
+ return [{ kind: "info", label: "no track", value: "/track <name>" }];
293
+ const tpb = score.ticksPerBeat;
294
+ const loopBeats = loopTicksOf(score) / tpb;
295
+ const time = track.time;
296
+ return [
297
+ {
298
+ kind: "number",
299
+ label: "rate",
300
+ help: "tempo ratio against the song: 1.5 plays three beats in two · x resets",
301
+ value: time?.rate ?? 1,
302
+ min: TIME_LIMITS.minRate,
303
+ max: TIME_LIMITS.maxRate,
304
+ step: stepBy(0.01, TIME_LIMITS.minRate, TIME_LIMITS.maxRate),
305
+ format: (value) => `${fmt(value, 4)}×`,
306
+ command: (value) => `track rate ${fmt(value, 6)}`,
307
+ reset: "track rate off",
308
+ },
309
+ {
310
+ kind: "number",
311
+ label: "phase",
312
+ help: "beats the track's pattern starts later (negative: earlier) · x resets",
313
+ value: (time?.phase ?? 0) / tpb,
314
+ min: -loopBeats,
315
+ max: loopBeats,
316
+ step: stepBy(0.25, -loopBeats, loopBeats),
317
+ format: (value) => `${fmt(value)} beat${value === 1 ? "" : "s"}`,
318
+ command: (value) => `track phase ${fmt(value, 4)}`,
319
+ reset: "track phase off",
320
+ },
321
+ {
322
+ kind: "number",
323
+ label: "cycle",
324
+ help: "beats before the track's pattern repeats (polymeter) · x follows the song loop",
325
+ value: time?.cycle !== undefined ? time.cycle / tpb : undefined,
326
+ off: "song loop",
327
+ start: score.beatsPerBar,
328
+ min: 0.25,
329
+ max: TIME_LIMITS.maxTick / tpb,
330
+ step: stepBy(0.25, 0.25, TIME_LIMITS.maxTick / tpb),
331
+ format: (value) => `${fmt(value)} beat${value === 1 ? "" : "s"}`,
332
+ command: (value) => `track cycle ${fmt(value, 4)}`,
333
+ reset: "track cycle off",
334
+ },
335
+ entry(
336
+ "phasing",
337
+ "<cycle beats> [over <beats>] [cycles <n>]",
338
+ "track phasing",
339
+ "track phasing 4",
340
+ "continuous drift (It's Gonna Rain): gain whole cycles per loop and realign at its end",
341
+ ),
342
+ entry(
343
+ "stepped phasing",
344
+ "<cycle beats> [hold <n>] [drift <n>] [shift <beats>]",
345
+ "track phasing",
346
+ "track phasing 3 hold 8",
347
+ "Piano Phase: hold in step, then move a sixteenth ahead, and repeat",
348
+ ),
349
+ {
350
+ kind: "action",
351
+ label: "follow the song",
352
+ command: "track time off",
353
+ help: "clear rate, phase and cycle",
354
+ },
355
+ ];
356
+ }
357
+
358
+ /** A typed-argument row that runs `<verb> <text>` when it parses. */
359
+ function entry(
360
+ label: string,
361
+ placeholder: string,
362
+ verb: string,
363
+ example: string,
364
+ help: string,
365
+ emptyRuns = false,
366
+ ): MenuNode {
367
+ return {
368
+ kind: "entry",
369
+ label,
370
+ value: "—",
371
+ placeholder,
372
+ command: (text) => {
373
+ const typed = text.trim();
374
+ if (!typed && !emptyRuns) return undefined;
375
+ const command = typed ? `${verb} ${typed}` : verb;
376
+ return parseTimeCommand(command) ? command : undefined;
377
+ },
378
+ example,
379
+ help,
380
+ };
381
+ }
382
+
383
+ /** `bar 5` on a bar line, else `beat 17.5`. */
384
+ function placeOf(score: TimeScore, tick: number): string {
385
+ const bar = barAt(score, tick);
386
+ return bar.offset === 0
387
+ ? `bar ${bar.bar + 1}`
388
+ : `beat ${fmt(tick / score.ticksPerBeat)}`;
389
+ }
390
+
391
+ function stepBy(size: number, min: number, max: number) {
392
+ return (value: number, direction: 1 | -1): number => {
393
+ const next = Math.round((value + size * direction) / size) * size;
394
+ return Math.min(max, Math.max(min, Math.round(next * 1e6) / 1e6));
395
+ };
396
+ }
397
+
398
+ function fmt(value: number, digits = 3): string {
399
+ const scale = 10 ** digits;
400
+ return String(Math.round(value * scale) / scale);
401
+ }