@hraness/dawg 0.7.0 → 0.8.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 (81) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/DAWG.md +117 -30
  3. package/README.md +7 -5
  4. package/core/diff.ts +2 -0
  5. package/core/ids.ts +52 -0
  6. package/core/loop.ts +3 -0
  7. package/core/range.ts +581 -0
  8. package/core/score.ts +123 -1
  9. package/core/sdk/print.ts +11 -2
  10. package/core/sdk/v1.ts +281 -2
  11. package/core/sections.ts +198 -19
  12. package/guides/arrange.md +9 -8
  13. package/guides/audio.md +31 -0
  14. package/guides/mix.md +3 -1
  15. package/guides/panes.md +32 -0
  16. package/guides/project.md +3 -2
  17. package/guides/rhythm.md +2 -1
  18. package/guides/tape.md +32 -0
  19. package/package.json +2 -1
  20. package/src/agent/agent.ts +4 -4
  21. package/src/agent/command-agent.ts +19 -10
  22. package/src/agent/ops.ts +15 -0
  23. package/src/agent/planner.ts +12 -0
  24. package/src/agent/range-tools.ts +216 -0
  25. package/src/agent/show-me.ts +1 -1
  26. package/src/agent/tools.ts +2 -0
  27. package/src/agent/xcb-agent.ts +2 -3
  28. package/src/audio/arrange.ts +12 -2
  29. package/src/audio/audio-command.ts +218 -0
  30. package/src/audio/devices.ts +264 -0
  31. package/src/audio/doctor.ts +84 -13
  32. package/src/audio/engine.ts +216 -28
  33. package/src/commands/arrange.ts +60 -25
  34. package/src/commands/grammar.ts +3 -1
  35. package/src/commands/help.ts +187 -9
  36. package/src/commands/history.ts +122 -11
  37. package/src/commands/keys.ts +17 -0
  38. package/src/commands/parses.ts +6 -1
  39. package/src/commands/range.ts +763 -0
  40. package/src/commands/resample.ts +13 -10
  41. package/src/commands/sample.ts +11 -1
  42. package/src/commands/sing.ts +2 -6
  43. package/src/commands/vocal-pitch.ts +2 -1
  44. package/src/identity/actor.ts +127 -0
  45. package/src/lang/glossary.ts +18 -0
  46. package/src/launch-args.ts +105 -1
  47. package/src/main.ts +1142 -86
  48. package/src/session/client.ts +107 -3
  49. package/src/session/clipboard.ts +79 -0
  50. package/src/session/daemon.ts +196 -5
  51. package/src/session/live-host.ts +232 -0
  52. package/src/session/origin.ts +20 -0
  53. package/src/session/port.ts +87 -10
  54. package/src/session/protocol.ts +360 -6
  55. package/src/session/rebase.ts +46 -8
  56. package/src/session/receipt.ts +7 -0
  57. package/src/session/shared-live.ts +131 -0
  58. package/src/session/store.ts +61 -2
  59. package/src/tui/arrange-menu.ts +169 -1
  60. package/src/tui/euclid.ts +95 -14
  61. package/src/tui/fader.ts +55 -2
  62. package/src/tui/knob-fields.ts +107 -0
  63. package/src/tui/knob-map.ts +198 -0
  64. package/src/tui/menu.ts +228 -2
  65. package/src/tui/play-session.ts +196 -21
  66. package/src/tui/tape-mode.ts +580 -0
  67. package/src/tui/tape-view.ts +263 -0
  68. package/tui/app.ts +217 -44
  69. package/tui/delight.ts +49 -0
  70. package/tui/drawer.ts +167 -9
  71. package/tui/grammar.ts +34 -5
  72. package/tui/guide.ts +8 -1
  73. package/tui/highway.ts +40 -5
  74. package/tui/hints.ts +5 -0
  75. package/tui/hits.ts +7 -1
  76. package/tui/knobs.ts +268 -0
  77. package/tui/play-strip.ts +12 -0
  78. package/tui/screen.ts +7 -4
  79. package/tui/tape.ts +439 -0
  80. package/tui/text.ts +35 -2
  81. package/tui/theme.ts +19 -1
package/core/range.ts ADDED
@@ -0,0 +1,581 @@
1
+ /**
2
+ * Range edits (op1-ux §6): copy, move, clear, paste and reverse bars of one
3
+ * track or of all of them, plus the rule for which bars a gesture acts on.
4
+ * Everything is pure: it reads a `TrackScore` and returns a new one, so the
5
+ * prompt, the menu, the agent (typing the commands) and the SDK share one
6
+ * behaviour.
7
+ *
8
+ * - A range is whole bars, 0-based like sections (`{ startBar, bars }`).
9
+ * - The range a gesture acts on is the loop range, else the section under
10
+ * the playhead, else the bar under it (`rangeOf`).
11
+ * - Copy and paste overwrite the destination; `merge` overdubs it and
12
+ * `insert` opens new bars first (sections, clips, automation and tempo
13
+ * points after it move right). Copy never touches tempo.
14
+ * - Clear leaves silence; the bars stay. Closing a gap is `deleteBars`.
15
+ * - A paste into bars of a different meter is refused.
16
+ */
17
+ import { newId } from "./ids.ts";
18
+ import { barStartTick, meterSegments, meterLabel } from "./tempo.ts";
19
+ import {
20
+ checkBars,
21
+ deleteBars,
22
+ findSection,
23
+ insertBars,
24
+ mapAutomation,
25
+ sectionAtBar,
26
+ sortPoints,
27
+ TRACK_LANES,
28
+ valueAt,
29
+ type Points,
30
+ } from "./sections.ts";
31
+ import {
32
+ SCORE_LIMITS,
33
+ ScoreValidationError,
34
+ TrackScore,
35
+ type AudioClip,
36
+ type AutomationPoint,
37
+ type Note,
38
+ type Track,
39
+ } from "./score.ts";
40
+
41
+ /** Whole bars from `startBar` (0-based). */
42
+ export type BarRange = Readonly<{ startBar: number; bars: number }>;
43
+
44
+ /** Where a resolved range came from (the status line names it). */
45
+ export type RangeSource = "loop" | "section" | "bar" | "typed";
46
+
47
+ export type ResolvedRange = Readonly<{
48
+ range: BarRange;
49
+ source: RangeSource;
50
+ /** The section, when the range is one. */
51
+ section?: string;
52
+ }>;
53
+
54
+ /** What a gesture acts on: the loop range, the section under the playhead, the bar. */
55
+ export function rangeOf(score: TrackScore, playheadBar: number): ResolvedRange {
56
+ if (score.loop) return { range: score.loop, source: "loop" };
57
+ const bar = Math.max(0, Math.min(score.bars - 1, Math.floor(playheadBar)));
58
+ if (score.loopSection) {
59
+ const looped = findSection(score, score.loopSection);
60
+ if (looped)
61
+ return {
62
+ range: { startBar: looped.startBar, bars: looped.bars },
63
+ source: "loop",
64
+ section: looped.name,
65
+ };
66
+ }
67
+ const section = sectionAtBar(score, bar);
68
+ if (section)
69
+ return {
70
+ range: { startBar: section.startBar, bars: section.bars },
71
+ source: "section",
72
+ section: section.name,
73
+ };
74
+ return { range: { startBar: bar, bars: 1 }, source: "bar" };
75
+ }
76
+
77
+ /** `5–6`, or `5` for one bar (1-based, as typed). */
78
+ export function rangeLabel(range: BarRange): string {
79
+ const first = range.startBar + 1;
80
+ const last = range.startBar + range.bars;
81
+ return first === last ? `${first}` : `${first}–${last}`;
82
+ }
83
+
84
+ /** `bars 5–6` / `bar 5`. */
85
+ export function barsWord(range: BarRange): string {
86
+ return `${range.bars === 1 ? "bar" : "bars"} ${rangeLabel(range)}`;
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // The clipboard: tracks × bars, re-based to 0
91
+
92
+ export type ClipTrack = Readonly<{
93
+ trackId: string;
94
+ notes: readonly Note[];
95
+ clips: readonly AudioClip[];
96
+ /** Lane field (`volumeAutomation`, …) to points. */
97
+ automation: Readonly<Record<string, Points>>;
98
+ fxAutomation: Readonly<Record<string, Points>>;
99
+ }>;
100
+
101
+ /** A lifted range (per pane; never in the score file). */
102
+ export type RangeClip = Readonly<{
103
+ /** True when it holds every track (it pastes onto its own tracks). */
104
+ all: boolean;
105
+ bars: number;
106
+ /** Tick length of each bar, for the meter check. */
107
+ barTicks: readonly number[];
108
+ /** Meter label of its first bar, such as `4/4`. */
109
+ meter: string;
110
+ source: BarRange;
111
+ tracks: readonly ClipTrack[];
112
+ }>;
113
+
114
+ function rangeTicks(score: TrackScore, range: BarRange): [number, number] {
115
+ return [
116
+ barStartTick(score, range.startBar),
117
+ barStartTick(score, range.startBar + range.bars),
118
+ ];
119
+ }
120
+
121
+ function barLength(score: TrackScore, bar: number): number {
122
+ return barStartTick(score, bar + 1) - barStartTick(score, bar);
123
+ }
124
+
125
+ function meterAt(score: TrackScore, bar: number): string {
126
+ let found = meterSegments(score)[0]!;
127
+ for (const segment of meterSegments(score)) {
128
+ if (segment.bar > bar) break;
129
+ found = segment;
130
+ }
131
+ return meterLabel(found);
132
+ }
133
+
134
+ /** The curve over `from..to`, re-based to 0 (a value at each edge it has one). */
135
+ function cropCurve(points: Points, from: number, to: number): Points {
136
+ const out: AutomationPoint[] = [];
137
+ const start = valueAt(points, from);
138
+ if (start !== undefined) out.push({ tick: 0, value: start });
139
+ for (const point of points)
140
+ if (point.tick > from && point.tick < to)
141
+ out.push({ tick: point.tick - from, value: point.value });
142
+ const end = valueAt(points, to - 1);
143
+ if (end !== undefined && to - from > 1)
144
+ out.push({ tick: to - 1 - from, value: end });
145
+ return sortPoints(out);
146
+ }
147
+
148
+ function requireTrack(score: TrackScore, trackId: string): Track {
149
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
150
+ if (!track) throw new ScoreValidationError(`no track ${trackId}`);
151
+ return track;
152
+ }
153
+
154
+ function checkRange(score: TrackScore, range: BarRange): void {
155
+ if (
156
+ !Number.isInteger(range.startBar) ||
157
+ !Number.isInteger(range.bars) ||
158
+ range.startBar < 0 ||
159
+ range.bars < 1
160
+ )
161
+ throw new ScoreValidationError("a range is whole bars from 1");
162
+ if (range.startBar >= score.bars)
163
+ throw new ScoreValidationError(
164
+ `bar ${range.startBar + 1} is past the song's ${score.bars} bars`,
165
+ );
166
+ }
167
+
168
+ /** Lift `range` of `trackIds` (undefined: every track) into a clipboard. */
169
+ export function extractRange(
170
+ score: TrackScore,
171
+ trackIds: readonly string[] | undefined,
172
+ range: BarRange,
173
+ ): RangeClip {
174
+ checkRange(score, range);
175
+ const ids = trackIds ?? score.tracks.map((track) => track.id);
176
+ const [from, to] = rangeTicks(score, range);
177
+ const tracks = ids.map((trackId): ClipTrack => {
178
+ const track = requireTrack(score, trackId);
179
+ const notes = score.notes
180
+ .filter(
181
+ (note) =>
182
+ note.trackId === trackId &&
183
+ note.startTick >= from &&
184
+ note.startTick < to,
185
+ )
186
+ .map((note) => ({
187
+ ...note,
188
+ startTick: note.startTick - from,
189
+ durationTicks: Math.max(
190
+ 1,
191
+ Math.min(note.durationTicks, to - note.startTick),
192
+ ),
193
+ }));
194
+ const clips = (track.clips ?? [])
195
+ .filter((clip) => clip.startTick >= from && clip.startTick < to)
196
+ .map((clip) => ({ ...clip, startTick: clip.startTick - from }));
197
+ const automation: Record<string, Points> = {};
198
+ for (const field of TRACK_LANES) {
199
+ const points = track[field];
200
+ if (points && points.length > 0)
201
+ automation[field] = cropCurve(points, from, to);
202
+ }
203
+ const fxAutomation: Record<string, Points> = {};
204
+ for (const [lane, points] of Object.entries(track.fxAutomation ?? {}))
205
+ if (points && points.length > 0)
206
+ fxAutomation[lane] = cropCurve(points, from, to);
207
+ return { trackId, notes, clips, automation, fxAutomation };
208
+ });
209
+ return {
210
+ all: trackIds === undefined,
211
+ bars: range.bars,
212
+ barTicks: Array.from({ length: range.bars }, (_, index) =>
213
+ barLength(score, range.startBar + index),
214
+ ),
215
+ meter: meterAt(score, range.startBar),
216
+ source: range,
217
+ tracks,
218
+ };
219
+ }
220
+
221
+ /** Notes in a clipboard. */
222
+ export function clipNoteCount(clip: RangeClip): number {
223
+ return clip.tracks.reduce((sum, track) => sum + track.notes.length, 0);
224
+ }
225
+
226
+ export type PasteMode = "overwrite" | "merge" | "insert";
227
+
228
+ export type PlaceOptions = Readonly<{
229
+ /** Tiles laid end to end (default 1). */
230
+ times?: number;
231
+ mode?: PasteMode;
232
+ /** A one-track clipboard lands here (default its own track). */
233
+ target?: string;
234
+ }>;
235
+
236
+ /**
237
+ * Fresh ids that the score does not use yet, collision-free across actors
238
+ * (core/ids.ts): two windows copying the same bars at once never clash.
239
+ */
240
+ function freshIds(used: Set<string>, prefix: string): (stem: string) => string {
241
+ return (stem) => {
242
+ for (;;) {
243
+ const id = newId(prefix);
244
+ if (!used.has(id)) {
245
+ used.add(id);
246
+ return id;
247
+ }
248
+ }
249
+ };
250
+ }
251
+
252
+ /** Remove points in `from..to` and hold the old values at its edges. */
253
+ function blankCurve(
254
+ points: Points,
255
+ from: number,
256
+ to: number,
257
+ ): AutomationPoint[] {
258
+ const out = points.filter((point) => point.tick < from || point.tick >= to);
259
+ const after = valueAt(points, to);
260
+ if (after !== undefined && !points.some((point) => point.tick === to))
261
+ out.push({ tick: to, value: after });
262
+ return out;
263
+ }
264
+
265
+ /**
266
+ * Lay `clip` down at `toBar`, `times` over. Overwrite (default) replaces the
267
+ * music there; merge overdubs it; insert opens the bars first. The song
268
+ * grows when the paste runs past its end.
269
+ */
270
+ export function placeRange(
271
+ score: TrackScore,
272
+ clip: RangeClip,
273
+ toBar: number,
274
+ options: PlaceOptions = {},
275
+ ): TrackScore {
276
+ const times = options.times ?? 1;
277
+ const mode = options.mode ?? "overwrite";
278
+ if (!Number.isInteger(times) || times < 1 || times > 64)
279
+ throw new ScoreValidationError("tile 1 to 64 times (x3)");
280
+ if (!Number.isInteger(toBar) || toBar < 0)
281
+ throw new ScoreValidationError("paste at a bar from 1");
282
+ if (toBar > score.bars)
283
+ throw new ScoreValidationError(
284
+ `bar ${toBar + 1} is past the song's ${score.bars} bars`,
285
+ );
286
+ const total = clip.bars * times;
287
+ let next = score;
288
+ if (mode === "insert") next = insertBars(next, toBar, total);
289
+ if (toBar + total > next.bars) {
290
+ checkBars(toBar + total);
291
+ next = next.withBars(toBar + total);
292
+ }
293
+ for (let bar = 0; bar < total; bar += 1) {
294
+ const want = clip.barTicks[bar % clip.bars]!;
295
+ if (barLength(next, toBar + bar) !== want)
296
+ throw new ScoreValidationError(
297
+ `clipboard is ${clip.meter} · bar ${toBar + bar + 1} is ${meterAt(next, toBar + bar)}`,
298
+ );
299
+ }
300
+ // Which track each clipboard row lands on.
301
+ const landing = clip.tracks.map((row) => ({
302
+ row,
303
+ trackId:
304
+ !clip.all && clip.tracks.length === 1 && options.target
305
+ ? options.target
306
+ : row.trackId,
307
+ }));
308
+ for (const { trackId } of landing) requireTrack(next, trackId);
309
+ const from = barStartTick(next, toBar);
310
+ const to = barStartTick(next, toBar + total);
311
+ const tiles = Array.from({ length: times }, (_, index) =>
312
+ barStartTick(next, toBar + index * clip.bars),
313
+ );
314
+ const targets = new Set(landing.map((entry) => entry.trackId));
315
+ const usedNotes = new Set(next.notes.map((note) => note.id));
316
+ const noteId = freshIds(usedNotes, "n");
317
+ const kept = next.notes.filter(
318
+ (note) =>
319
+ mode === "merge" ||
320
+ !targets.has(note.trackId) ||
321
+ note.startTick < from ||
322
+ note.startTick >= to,
323
+ );
324
+ const added: Note[] = [];
325
+ for (const { row, trackId } of landing)
326
+ for (const at of tiles)
327
+ for (const note of row.notes)
328
+ added.push({
329
+ ...note,
330
+ id: noteId(note.id),
331
+ trackId,
332
+ startTick: note.startTick + at,
333
+ });
334
+ const tracks = next.tracks.map((track) => {
335
+ const rows = landing.filter((entry) => entry.trackId === track.id);
336
+ if (rows.length === 0) return track;
337
+ const usedClips = new Set((track.clips ?? []).map((clip) => clip.id));
338
+ const clipId = freshIds(usedClips, "clip");
339
+ const overwrite = mode !== "merge";
340
+ let clips = (track.clips ?? []).filter(
341
+ (clip) => !overwrite || clip.startTick < from || clip.startTick >= to,
342
+ );
343
+ let out: Track = overwrite
344
+ ? mapAutomation(track, (points) => blankCurve(points, from, to))
345
+ : track;
346
+ for (const { row } of rows) {
347
+ for (const at of tiles)
348
+ clips = [
349
+ ...clips,
350
+ ...row.clips.map((piece) => ({
351
+ ...piece,
352
+ id: clipId(piece.id),
353
+ startTick: piece.startTick + at,
354
+ })),
355
+ ];
356
+ const lanes: Record<string, unknown> = { ...out };
357
+ for (const [field, points] of Object.entries(row.automation)) {
358
+ const base = (lanes[field] as Points | undefined) ?? [];
359
+ lanes[field] = sortPoints([
360
+ ...base,
361
+ ...tiles.flatMap((at) =>
362
+ points.map((point) => ({
363
+ tick: point.tick + at,
364
+ value: point.value,
365
+ })),
366
+ ),
367
+ ]);
368
+ }
369
+ const fxRows = Object.entries(row.fxAutomation);
370
+ if (fxRows.length > 0) {
371
+ const fx: Record<string, Points> = {
372
+ ...((out.fxAutomation as Record<string, Points> | undefined) ?? {}),
373
+ };
374
+ for (const [lane, points] of fxRows)
375
+ fx[lane] = sortPoints([
376
+ ...(fx[lane] ?? []),
377
+ ...tiles.flatMap((at) =>
378
+ points.map((point) => ({
379
+ tick: point.tick + at,
380
+ value: point.value,
381
+ })),
382
+ ),
383
+ ]);
384
+ lanes.fxAutomation = fx;
385
+ }
386
+ out = lanes as Track;
387
+ }
388
+ return {
389
+ ...out,
390
+ ...(clips.length > 0 || track.clips ? { clips } : {}),
391
+ } as Track;
392
+ });
393
+ return new TrackScore({
394
+ ...next.toJSON(),
395
+ tracks,
396
+ notes: [...kept, ...added],
397
+ });
398
+ }
399
+
400
+ export type CopyOptions = Readonly<{
401
+ times?: number;
402
+ mode?: PasteMode;
403
+ }>;
404
+
405
+ /** Copy `range` of `trackIds` (undefined: every track) to `toBar`. */
406
+ export function copyRange(
407
+ score: TrackScore,
408
+ trackIds: readonly string[] | undefined,
409
+ range: BarRange,
410
+ toBar: number,
411
+ options: CopyOptions = {},
412
+ ): TrackScore {
413
+ const clip = extractRange(score, trackIds, range);
414
+ const times = options.times ?? 1;
415
+ const end = range.startBar + range.bars;
416
+ if (options.mode === "insert" && toBar > range.startBar && toBar < end)
417
+ throw new ScoreValidationError(
418
+ `bar ${toBar + 1} is inside ${barsWord(range)} · insert before or after them`,
419
+ );
420
+ if (
421
+ times > 1 &&
422
+ options.mode !== "insert" &&
423
+ toBar < end &&
424
+ toBar + range.bars * times > range.startBar
425
+ )
426
+ throw new ScoreValidationError(
427
+ `the tiles would cover ${barsWord(range)} · copy them past bar ${end}`,
428
+ );
429
+ return placeRange(score, clip, toBar, {
430
+ times,
431
+ mode: options.mode ?? "overwrite",
432
+ });
433
+ }
434
+
435
+ /** Silence `range` of `trackIds` (undefined: every track); the bars stay. */
436
+ export function clearRange(
437
+ score: TrackScore,
438
+ trackIds: readonly string[] | undefined,
439
+ range: BarRange,
440
+ ): TrackScore {
441
+ checkRange(score, range);
442
+ const ids = new Set(trackIds ?? score.tracks.map((track) => track.id));
443
+ for (const id of ids) requireTrack(score, id);
444
+ const [from, to] = rangeTicks(score, range);
445
+ const notes: Note[] = [];
446
+ for (const note of score.notes) {
447
+ if (!ids.has(note.trackId)) {
448
+ notes.push(note);
449
+ continue;
450
+ }
451
+ if (note.startTick >= from && note.startTick < to) continue;
452
+ // A note held into the range stops where it starts.
453
+ if (note.startTick < from && note.startTick + note.durationTicks > from)
454
+ notes.push({ ...note, durationTicks: from - note.startTick });
455
+ else notes.push(note);
456
+ }
457
+ const tracks = score.tracks.map((track) => {
458
+ if (!ids.has(track.id)) return track;
459
+ const held = mapAutomation(track, (points) => {
460
+ const start = valueAt(points, from);
461
+ const out = blankCurve(points, from, to);
462
+ if (start !== undefined && !out.some((point) => point.tick === from))
463
+ out.push({ tick: from, value: start });
464
+ return sortPoints(out);
465
+ });
466
+ if (!track.clips) return held;
467
+ return {
468
+ ...held,
469
+ clips: track.clips.filter(
470
+ (clip) => clip.startTick < from || clip.startTick >= to,
471
+ ),
472
+ };
473
+ });
474
+ return new TrackScore({ ...score.toJSON(), tracks, notes });
475
+ }
476
+
477
+ export type MoveOptions = Readonly<{ insert?: boolean }>;
478
+
479
+ /**
480
+ * Move `range` of `trackIds` to `toBar`: the source goes silent and the
481
+ * destination is overwritten. With `insert`, bars open at the destination;
482
+ * moving every track then closes the source gap too (a ripple move).
483
+ */
484
+ export function moveRange(
485
+ score: TrackScore,
486
+ trackIds: readonly string[] | undefined,
487
+ range: BarRange,
488
+ toBar: number,
489
+ options: MoveOptions = {},
490
+ ): TrackScore {
491
+ const clip = extractRange(score, trackIds, range);
492
+ const end = range.startBar + range.bars;
493
+ if (toBar === range.startBar)
494
+ throw new ScoreValidationError(`${barsWord(range)} already start there`);
495
+ if (!options.insert)
496
+ return placeRange(clearRange(score, trackIds, range), clip, toBar);
497
+ if (toBar > range.startBar && toBar < end)
498
+ throw new ScoreValidationError(
499
+ `bar ${toBar + 1} is inside ${barsWord(range)} · move before or after them`,
500
+ );
501
+ const opened = placeRange(score, clip, toBar, { mode: "insert" });
502
+ const source =
503
+ toBar <= range.startBar
504
+ ? { startBar: range.startBar + range.bars, bars: range.bars }
505
+ : range;
506
+ return trackIds === undefined
507
+ ? deleteBars(opened, source.startBar, source.bars)
508
+ : clearRange(opened, trackIds, source);
509
+ }
510
+
511
+ /** Play `range` of `trackIds` backwards: notes and automation mirror. */
512
+ export function reverseRange(
513
+ score: TrackScore,
514
+ trackIds: readonly string[] | undefined,
515
+ range: BarRange,
516
+ ): TrackScore {
517
+ checkRange(score, range);
518
+ const ids = new Set(trackIds ?? score.tracks.map((track) => track.id));
519
+ for (const id of ids) requireTrack(score, id);
520
+ const [from, to] = rangeTicks(score, range);
521
+ const notes = score.notes.map((note) => {
522
+ if (!ids.has(note.trackId) || note.startTick < from || note.startTick >= to)
523
+ return note;
524
+ const duration = Math.min(note.durationTicks, to - note.startTick);
525
+ return {
526
+ ...note,
527
+ startTick: from + to - (note.startTick + duration),
528
+ durationTicks: duration,
529
+ };
530
+ });
531
+ const tracks = score.tracks.map((track) =>
532
+ ids.has(track.id)
533
+ ? mapAutomation(track, (points) => {
534
+ const piece = cropCurve(points, from, to);
535
+ if (piece.length === 0) return points;
536
+ const last = to - from - 1;
537
+ const outside = points.filter(
538
+ (point) => point.tick < from || point.tick >= to,
539
+ );
540
+ return sortPoints([
541
+ ...outside,
542
+ ...piece.map((point) => ({
543
+ tick: from + last - point.tick,
544
+ value: point.value,
545
+ })),
546
+ ]);
547
+ })
548
+ : track,
549
+ );
550
+ return new TrackScore({ ...score.toJSON(), tracks, notes });
551
+ }
552
+
553
+ /** Notes of `trackIds` that start inside `range`. */
554
+ export function notesInRange(
555
+ score: TrackScore,
556
+ trackIds: readonly string[] | undefined,
557
+ range: BarRange,
558
+ ): number {
559
+ const ids = trackIds ? new Set(trackIds) : undefined;
560
+ const [from, to] = rangeTicks(score, range);
561
+ return score.notes.filter(
562
+ (note) =>
563
+ (!ids || ids.has(note.trackId)) &&
564
+ note.startTick >= from &&
565
+ note.startTick < to,
566
+ ).length;
567
+ }
568
+
569
+ /**
570
+ * The loop range moved by its own length (`loop next` / `loop prev`),
571
+ * kept inside the song; undefined when it cannot move that way.
572
+ */
573
+ export function steppedLoop(
574
+ score: TrackScore,
575
+ loop: BarRange,
576
+ direction: 1 | -1,
577
+ ): BarRange | undefined {
578
+ const startBar = loop.startBar + direction * loop.bars;
579
+ if (startBar < 0 || startBar >= score.bars) return undefined;
580
+ return { startBar, bars: Math.min(loop.bars, score.bars - startBar) };
581
+ }