@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/score.ts CHANGED
@@ -555,6 +555,34 @@ export type Sampler = Readonly<{
555
555
  mode: "oneshot" | "keyed";
556
556
  }>;
557
557
 
558
+ /**
559
+ * A sample reference as created by capture, import or resample: always
560
+ * content-pinned, so another machine can fetch the exact bytes by hash.
561
+ * Older refs may lack `sha256`; new ones may not.
562
+ */
563
+ export type PinnedSampleRef = SampleRef & Readonly<{ sha256: string }>;
564
+
565
+ /** Rejects a newly created local sample ref that carries no valid sha256. */
566
+ export function requirePinnedRef(
567
+ ref: SampleRef,
568
+ label: string,
569
+ ): PinnedSampleRef {
570
+ if (ref.src.startsWith(PACK_PREFIX)) {
571
+ if (ref.url === undefined && ref.sha256 === undefined)
572
+ throw new ScoreValidationError(
573
+ `${label} pack sound must be pinned by url or sha256`,
574
+ "invalid-track",
575
+ );
576
+ return ref as PinnedSampleRef;
577
+ }
578
+ if (typeof ref.sha256 !== "string" || !SHA256_HEX.test(ref.sha256))
579
+ throw new ScoreValidationError(
580
+ `${label} needs a sha256 content pin`,
581
+ "invalid-track",
582
+ );
583
+ return ref as PinnedSampleRef;
584
+ }
585
+
558
586
  export type SampleRef = Readonly<{
559
587
  /**
560
588
  * Project-relative path, normally `tracks/<slug>/samples/<file>`, or a
@@ -1115,10 +1143,19 @@ export type TrackScoreData = Readonly<{
1115
1143
  * the song (or its form). A name that matches no section is dropped.
1116
1144
  */
1117
1145
  loopSection?: string | null;
1146
+ /**
1147
+ * The loop range (op1-ux): an ad hoc run of bars playback loops, set by
1148
+ * `loop 5-6`. It creates no section; when present it wins over
1149
+ * `loopSection`. Absent or null plays the song.
1150
+ */
1151
+ loop?: LoopRange | null;
1118
1152
  /** Sound calibration (0.7), 0..CALIBRATION_LATEST; absent or 0 is legacy. */
1119
1153
  calibration?: number | null;
1120
1154
  }>;
1121
1155
 
1156
+ /** A loop range: bars `startBar..startBar+bars` (0-based, like sections). */
1157
+ export type LoopRange = Readonly<{ startBar: number; bars: number }>;
1158
+
1122
1159
  /** Canonical immutable score. Use `addNote`/`removeNote` to create a revision. */
1123
1160
  export class TrackScore {
1124
1161
  readonly version = SCORE_VERSION;
@@ -1142,6 +1179,8 @@ export class TrackScore {
1142
1179
  readonly form: readonly FormEntry[];
1143
1180
  /** The section playback loops; undefined plays the song. */
1144
1181
  readonly loopSection: string | undefined;
1182
+ /** The loop range; absent without one (it wins over `loopSection`). */
1183
+ declare readonly loop?: LoopRange;
1145
1184
  /** Sound calibration revision; absent is 0 (legacy engines). */
1146
1185
  declare readonly calibration?: number;
1147
1186
 
@@ -1254,7 +1293,11 @@ export class TrackScore {
1254
1293
  this.notes = freezeArray(notes);
1255
1294
  this.sections = freezeArray(sections);
1256
1295
  this.form = freezeArray(form);
1257
- this.loopSection = normalizeLoopSection(data.loopSection, sections);
1296
+ // One loop at a time: a range wins over a looped section.
1297
+ const loop = normalizeLoopRange(data.loop);
1298
+ const looped = normalizeLoopSection(data.loopSection, sections);
1299
+ this.loopSection = loop ? undefined : looped;
1300
+ if (loop) this.loop = loop;
1258
1301
  const calibration = normalizeCalibration(data.calibration);
1259
1302
  if (calibration) this.calibration = calibration;
1260
1303
  Object.freeze(this);
@@ -1278,6 +1321,18 @@ export class TrackScore {
1278
1321
  });
1279
1322
  }
1280
1323
 
1324
+ /**
1325
+ * Set the loop range (or clear it with null). A range clears the looped
1326
+ * section, so one loop plays at a time.
1327
+ */
1328
+ withLoop(loop: LoopRange | null): TrackScore {
1329
+ return new TrackScore({
1330
+ ...this.toJSON(),
1331
+ loop,
1332
+ ...(loop ? { loopSection: null } : {}),
1333
+ });
1334
+ }
1335
+
1281
1336
  withTempo(tempoBpm: number): TrackScore {
1282
1337
  return new TrackScore({ ...this.toJSON(), tempoBpm });
1283
1338
  }
@@ -1296,6 +1351,8 @@ export class TrackScore {
1296
1351
  sections,
1297
1352
  form,
1298
1353
  loopSection: loopSection ?? null,
1354
+ // Looping a section ends the loop range.
1355
+ ...(loopSection ? { loop: null } : {}),
1299
1356
  });
1300
1357
  }
1301
1358
 
@@ -1379,6 +1436,7 @@ export class TrackScore {
1379
1436
  ...(this.loopSection === undefined
1380
1437
  ? {}
1381
1438
  : { loopSection: this.loopSection }),
1439
+ ...(this.loop ? { loop: this.loop } : {}),
1382
1440
  ...(this.calibration ? { calibration: this.calibration } : {}),
1383
1441
  };
1384
1442
  }
@@ -1736,6 +1794,11 @@ export type ScoreOperation =
1736
1794
  form: readonly FormEntry[];
1737
1795
  /** The looped section; absent or null plays the song. */
1738
1796
  loopSection?: string | null;
1797
+ }>
1798
+ | Readonly<{
1799
+ /** The loop range (op1-ux); null clears it. */
1800
+ type: "setLoop";
1801
+ loop: LoopRange | null;
1739
1802
  }>;
1740
1803
 
1741
1804
  export function applyScoreOperation(
@@ -1761,6 +1824,7 @@ export function applyScoreOperation(
1761
1824
  operation.form,
1762
1825
  operation.loopSection ?? null,
1763
1826
  );
1827
+ if (operation.type === "setLoop") return score.withLoop(operation.loop);
1764
1828
  if (operation.type === "addTrack") return addTrack(score, operation.track);
1765
1829
  if (operation.type === "addNote") return addNote(score, operation.note);
1766
1830
  if (operation.type === "removeNote")
@@ -1816,6 +1880,7 @@ export function scoreFromJSON(value: unknown): TrackScore {
1816
1880
  sections?: readonly Section[];
1817
1881
  form?: readonly FormEntry[];
1818
1882
  loopSection?: string | null;
1883
+ loop?: LoopRange | null;
1819
1884
  calibration?: number | null;
1820
1885
  } = {
1821
1886
  tracks: optionalArray(value.tracks).map(parseTrack),
@@ -1855,6 +1920,8 @@ export function scoreFromJSON(value: unknown): TrackScore {
1855
1920
  // The constructor checks the type and drops a name matching no section.
1856
1921
  if (value.loopSection !== undefined)
1857
1922
  data.loopSection = value.loopSection as string | null;
1923
+ if (value.loop !== undefined) data.loop = value.loop as LoopRange | null;
1924
+ migrateHiddenLoop(data);
1858
1925
  // The constructor checks the range.
1859
1926
  if (value.calibration !== undefined)
1860
1927
  data.calibration = value.calibration as number | null;
@@ -2002,6 +2069,61 @@ function normalizeCalibration(value: unknown): number | undefined {
2002
2069
  return value;
2003
2070
  }
2004
2071
 
2072
+ /** A loop range, checked: whole bars from 0, at least one, inside the limit. */
2073
+ function normalizeLoopRange(value: unknown): LoopRange | undefined {
2074
+ if (value === undefined || value === null) return undefined;
2075
+ const range = value as { startBar?: unknown; bars?: unknown };
2076
+ const { startBar, bars } = range;
2077
+ if (
2078
+ typeof value !== "object" ||
2079
+ typeof startBar !== "number" ||
2080
+ typeof bars !== "number" ||
2081
+ !Number.isInteger(startBar) ||
2082
+ !Number.isInteger(bars) ||
2083
+ startBar < 0 ||
2084
+ bars < 1 ||
2085
+ startBar + bars > SCORE_LIMITS.maxBars
2086
+ )
2087
+ throw new ScoreValidationError(
2088
+ `loop must be {startBar, bars}: whole bars from 0, 1..${SCORE_LIMITS.maxBars}`,
2089
+ "invalid-score",
2090
+ );
2091
+ return Object.freeze({ startBar, bars });
2092
+ }
2093
+
2094
+ /**
2095
+ * f07 stored `loop 5-6` as a hidden section named `loop`. On load it becomes
2096
+ * the loop range and the section (and its form entries) go.
2097
+ */
2098
+ function migrateHiddenLoop(data: {
2099
+ sections?: readonly Section[];
2100
+ form?: readonly FormEntry[];
2101
+ loopSection?: string | null;
2102
+ loop?: LoopRange | null;
2103
+ }): void {
2104
+ if (data.loop || typeof data.loopSection !== "string") return;
2105
+ if (data.loopSection.trim().toLowerCase() !== "loop") return;
2106
+ const sections = data.sections ?? [];
2107
+ const hidden = sections.find(
2108
+ (section) =>
2109
+ typeof section?.name === "string" &&
2110
+ section.name.trim().toLowerCase() === "loop",
2111
+ );
2112
+ if (
2113
+ !hidden ||
2114
+ !Number.isInteger(hidden.startBar) ||
2115
+ !Number.isInteger(hidden.bars)
2116
+ )
2117
+ return;
2118
+ data.loop = { startBar: hidden.startBar, bars: hidden.bars };
2119
+ data.loopSection = null;
2120
+ data.sections = sections.filter((section) => section !== hidden);
2121
+ if (data.form)
2122
+ data.form = data.form.filter(
2123
+ (entry) => entry?.section?.trim?.().toLowerCase() !== "loop",
2124
+ );
2125
+ }
2126
+
2005
2127
  function normalizeLoopSection(
2006
2128
  value: unknown,
2007
2129
  sections: readonly Section[],
package/core/sdk/print.ts CHANGED
@@ -263,8 +263,17 @@ export function printSong(
263
263
  );
264
264
  if (score.form.length > 0)
265
265
  entries.push(`form: ${printForm(score.form, INDENT)}`);
266
- if (score.loopSection !== undefined)
267
- entries.push(`loopSection: ${str(score.loopSection)}`);
266
+ // One field for both: `loop: "5-6"` (1-based bars) or `loop: "chorus"`.
267
+ if (score.loop)
268
+ entries.push(
269
+ `loop: ${str(
270
+ score.loop.bars === 1
271
+ ? `${score.loop.startBar + 1}`
272
+ : `${score.loop.startBar + 1}-${score.loop.startBar + score.loop.bars}`,
273
+ )}`,
274
+ );
275
+ else if (score.loopSection !== undefined)
276
+ entries.push(`loop: ${str(score.loopSection)}`);
268
277
  if (score.calibration) entries.push(`calibration: ${num(score.calibration)}`);
269
278
  lines.push("export default song({");
270
279
  for (const entry of entries) lines.push(`${INDENT}${entry},`);
package/core/sdk/v1.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  */
28
28
 
29
29
  /** SDK release; dawg refreshes the vendored copy when its own is newer. */
30
- export const SDK_VERSION = "1.33.0";
30
+ export const SDK_VERSION = "1.34.0";
31
31
  /** Major of `SDK_VERSION`; `dawg.json` records it as `sdk`. */
32
32
  export const SDK_MAJOR = 1;
33
33
 
@@ -4564,8 +4564,13 @@ export type SongInput = Readonly<{
4564
4564
  * Absent plays the bars straight through.
4565
4565
  */
4566
4566
  form?: string | readonly (string | SongFormEntry)[];
4567
- /** The section playback loops (SDK 1.18.0); export ignores it. */
4567
+ /** The section playback loops (SDK 1.18.0); `loop: "chorus"` says the same. */
4568
4568
  loopSection?: string;
4569
+ /**
4570
+ * What playback loops (SDK 1.34.0): bars `"5-6"` (1-based, as the prompt
4571
+ * shows them) or a section `"chorus"`. Export ignores it.
4572
+ */
4573
+ loop?: string;
4569
4574
  /**
4570
4575
  * Sound calibration (SDK 1.32.0): `1` renders the 0.7 level, pitch and
4571
4576
  * drum-kit fixes (hat choke, tuned toms, crash and ride, level keys,
@@ -4771,6 +4776,8 @@ export type Song = Readonly<{
4771
4776
  form?: readonly SongFormEntry[];
4772
4777
  /** Present only when a section loops (SDK 1.18.0). */
4773
4778
  loopSection?: string;
4779
+ /** Present only when bars loop (SDK 1.34.0): 0-based `startBar`. */
4780
+ loop?: Readonly<{ startBar: number; bars: number }>;
4774
4781
  /** Present only when the song sets one (SDK 1.32.0). */
4775
4782
  calibration?: number;
4776
4783
  }>;
@@ -5009,6 +5016,270 @@ function songSections(
5009
5016
  /** The newest `song({ calibration })` (mirrors core CALIBRATION_LATEST). */
5010
5017
  export const SONG_CALIBRATION_LATEST = 1;
5011
5018
 
5019
+ /** `song({ loop })`: bars `"5-6"` → a 0-based range, else a section name. */
5020
+ function songLoop(
5021
+ input: unknown,
5022
+ bars: number,
5023
+ ): string | Readonly<{ startBar: number; bars: number }> {
5024
+ if (typeof input !== "string" || input.trim() === "")
5025
+ throw new DawgSdkError('song loop must be bars "5-6" or a section name');
5026
+ const match = /^\s*(\d{1,4})\s*(?:-|–|\.\.)?\s*(\d{1,4})?\s*$/u.exec(input);
5027
+ if (!match) return input.trim();
5028
+ const from = Number(match[1]);
5029
+ const to = match[2] === undefined ? from : Number(match[2]);
5030
+ if (from < 1 || to < from)
5031
+ throw new DawgSdkError(`song loop "${input}" runs low-high from bar 1`);
5032
+ if (to > bars)
5033
+ throw new DawgSdkError(
5034
+ `song loop "${input}" is past the song's ${bars} bars`,
5035
+ );
5036
+ return Object.freeze({ startBar: from - 1, bars: to - from + 1 });
5037
+ }
5038
+
5039
+ // ---------------------------------------------------------------------------
5040
+ // Range helpers (SDK 1.34.0): the prompt's copy, move and bars insert as
5041
+ // pure functions over notes, for files written by hand. Bars are 1-based.
5042
+
5043
+ type Timed = Readonly<{ start: number; length: number }>;
5044
+ type RangeHelperOptions = Readonly<{ beatsPerBar?: number }>;
5045
+
5046
+ function perBar(options: RangeHelperOptions | undefined, what: string): number {
5047
+ return positive(options?.beatsPerBar ?? 4, `${what} beatsPerBar`);
5048
+ }
5049
+
5050
+ function wholeBar(value: unknown, what: string): number {
5051
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1)
5052
+ throw new DawgSdkError(`${what} must be a bar from 1`);
5053
+ return value;
5054
+ }
5055
+
5056
+ /**
5057
+ * The notes (or hits) that start in bars `from..to`, re-based to beat 0
5058
+ * and cut at the range's end: `bars(chorus, 5, 6)`.
5059
+ */
5060
+ export function bars<T extends Timed>(
5061
+ notes: readonly T[],
5062
+ from: number,
5063
+ to: number = from,
5064
+ options?: RangeHelperOptions,
5065
+ ): readonly T[] {
5066
+ if (!Array.isArray(notes))
5067
+ throw new DawgSdkError("bars needs an array of notes");
5068
+ const each = perBar(options, "bars");
5069
+ const first = wholeBar(from, "bars from");
5070
+ const last = wholeBar(to, "bars to");
5071
+ if (last < first)
5072
+ throw new DawgSdkError("bars runs low-high: bars(notes, 5, 6)");
5073
+ const start = (first - 1) * each;
5074
+ const end = last * each;
5075
+ return Object.freeze(
5076
+ notes
5077
+ .filter((item) => item.start >= start && item.start < end)
5078
+ .map((item) =>
5079
+ Object.freeze({
5080
+ ...item,
5081
+ start: item.start - start,
5082
+ length: Math.min(item.length, end - item.start),
5083
+ }),
5084
+ ),
5085
+ );
5086
+ }
5087
+
5088
+ /**
5089
+ * Notes laid down at bar `at`, repeated `times` times end to end:
5090
+ * `place(bars(bass, 5, 6), { at: 7, times: 2 })`. Each repeat spans
5091
+ * `bars` bars (default: the whole bars the notes reach).
5092
+ */
5093
+ export function place<T extends Timed>(
5094
+ notes: readonly T[],
5095
+ options: Readonly<{ at: number; times?: number; bars?: number }> &
5096
+ RangeHelperOptions,
5097
+ ): readonly T[] {
5098
+ if (!Array.isArray(notes))
5099
+ throw new DawgSdkError("place needs an array of notes");
5100
+ if (!isRecord(options)) throw new DawgSdkError("place needs { at: <bar> }");
5101
+ const each = perBar(options, "place");
5102
+ const at = (wholeBar(options.at, "place at") - 1) * each;
5103
+ const times = options.times ?? 1;
5104
+ if (!Number.isInteger(times) || times < 1 || times > 64)
5105
+ throw new DawgSdkError("place times must be 1..64");
5106
+ const reach = notes.reduce(
5107
+ (most, item) => Math.max(most, item.start + item.length),
5108
+ 0,
5109
+ );
5110
+ const span =
5111
+ (options.bars === undefined
5112
+ ? Math.max(1, Math.ceil(reach / each - 1e-9))
5113
+ : wholeBar(options.bars, "place bars")) * each;
5114
+ const out: T[] = [];
5115
+ for (let pass = 0; pass < times; pass += 1)
5116
+ for (const item of notes)
5117
+ out.push(
5118
+ Object.freeze({ ...item, start: at + pass * span + item.start }),
5119
+ );
5120
+ return Object.freeze(out);
5121
+ }
5122
+
5123
+ /**
5124
+ * The notes mirrored in time over `bars` bars from beat 0 (a note ending
5125
+ * at the span's end starts at 0): `reversed(bars(lead, 5, 6), { bars: 2 })`.
5126
+ */
5127
+ export function reversed<T extends Timed>(
5128
+ notes: readonly T[],
5129
+ options: Readonly<{ bars: number }> & RangeHelperOptions,
5130
+ ): readonly T[] {
5131
+ if (!Array.isArray(notes))
5132
+ throw new DawgSdkError("reversed needs an array of notes");
5133
+ if (!isRecord(options))
5134
+ throw new DawgSdkError("reversed needs { bars: <n> }");
5135
+ const span =
5136
+ wholeBar(options.bars, "reversed bars") * perBar(options, "reversed");
5137
+ return Object.freeze(
5138
+ notes
5139
+ .filter((item) => item.start < span)
5140
+ .map((item) => {
5141
+ const length = Math.min(item.length, span - item.start);
5142
+ return Object.freeze({
5143
+ ...item,
5144
+ start: span - item.start - length,
5145
+ length,
5146
+ });
5147
+ })
5148
+ .sort((a, b) => a.start - b.start),
5149
+ );
5150
+ }
5151
+
5152
+ /** Shift every `tick` field at or past `at` in an array of points. */
5153
+ function shiftTicks(value: unknown, at: number, shift: number): unknown {
5154
+ if (!Array.isArray(value)) return value;
5155
+ if (!value.every((item) => isRecord(item) && typeof item.tick === "number"))
5156
+ return value;
5157
+ return Object.freeze(
5158
+ value.map((item) =>
5159
+ (item as { tick: number }).tick >= at
5160
+ ? Object.freeze({
5161
+ ...(item as object),
5162
+ tick: (item as { tick: number }).tick + shift,
5163
+ })
5164
+ : item,
5165
+ ),
5166
+ );
5167
+ }
5168
+
5169
+ /**
5170
+ * `bars` empty bars inserted before bar `at` of a `song()` result: later
5171
+ * notes, sections, the loop, tempo marks, fermatas, automation points and
5172
+ * clips move right; a note held across `at` sounds on through the gap.
5173
+ * `export default insertBars(song({...}), { at: 7, bars: 2 })`.
5174
+ */
5175
+ export function insertBars(
5176
+ input: Song,
5177
+ options: Readonly<{ at: number; bars: number }>,
5178
+ ): Song {
5179
+ if (!isRecord(input) || input.format !== "track.loop/v1")
5180
+ throw new DawgSdkError("insertBars needs a song() result");
5181
+ if (!isRecord(options))
5182
+ throw new DawgSdkError("insertBars needs { at, bars }");
5183
+ const atBar = wholeBar(options.at, "insertBars at") - 1;
5184
+ const count = wholeBar(options.bars, "insertBars bars");
5185
+ if (atBar > input.bars)
5186
+ throw new DawgSdkError(
5187
+ `insertBars at ${atBar + 1} is past the song's ${input.bars} bars`,
5188
+ );
5189
+ if (input.bars + count > 256)
5190
+ throw new DawgSdkError("a song has at most 256 bars");
5191
+ if (input.time?.meter?.some((mark) => mark.bar > 0))
5192
+ throw new DawgSdkError(
5193
+ "insertBars needs one meter · use dawg's bars insert",
5194
+ );
5195
+ const barTicks = input.beatsPerBar * input.ticksPerBeat;
5196
+ const at = atBar * barTicks;
5197
+ const shift = count * barTicks;
5198
+ const atBeat = atBar * input.beatsPerBar;
5199
+ const beats = count * input.beatsPerBar;
5200
+ const moveRange = <R extends { startBar: number; bars: number }>(
5201
+ range: R,
5202
+ ): R =>
5203
+ range.startBar >= atBar
5204
+ ? { ...range, startBar: range.startBar + count }
5205
+ : range.startBar + range.bars > atBar
5206
+ ? { ...range, bars: range.bars + count }
5207
+ : range;
5208
+ const tracks = input.tracks.map((track) => {
5209
+ const out: Record<string, unknown> = { ...track };
5210
+ for (const [field, value] of Object.entries(track)) {
5211
+ if (field === "fxAutomation" && isRecord(value)) {
5212
+ out[field] = Object.freeze(
5213
+ Object.fromEntries(
5214
+ Object.entries(value).map(([lane, points]) => [
5215
+ lane,
5216
+ shiftTicks(points, at, shift),
5217
+ ]),
5218
+ ),
5219
+ );
5220
+ } else if (field === "clips" && Array.isArray(value)) {
5221
+ out[field] = Object.freeze(
5222
+ value.map((clip) =>
5223
+ isRecord(clip) && typeof clip.at === "number" && clip.at >= atBeat
5224
+ ? Object.freeze({ ...clip, at: clip.at + beats })
5225
+ : clip,
5226
+ ),
5227
+ );
5228
+ } else out[field] = shiftTicks(value, at, shift);
5229
+ }
5230
+ return Object.freeze(out) as ScoreTrack;
5231
+ });
5232
+ const time = input.time
5233
+ ? Object.freeze({
5234
+ ...input.time,
5235
+ ...(input.time.tempo
5236
+ ? {
5237
+ tempo: shiftTicks(
5238
+ input.time.tempo,
5239
+ at,
5240
+ shift,
5241
+ ) as ScoreTime["tempo"],
5242
+ }
5243
+ : {}),
5244
+ ...(input.time.fermatas
5245
+ ? {
5246
+ fermatas: shiftTicks(
5247
+ input.time.fermatas,
5248
+ at,
5249
+ shift,
5250
+ ) as ScoreTime["fermatas"],
5251
+ }
5252
+ : {}),
5253
+ })
5254
+ : undefined;
5255
+ return Object.freeze({
5256
+ ...input,
5257
+ bars: input.bars + count,
5258
+ ...(time ? { time } : {}),
5259
+ tracks: Object.freeze(tracks),
5260
+ notes: Object.freeze(
5261
+ input.notes.map((item) =>
5262
+ item.startTick >= at
5263
+ ? Object.freeze({ ...item, startTick: item.startTick + shift })
5264
+ : item.startTick + item.durationTicks > at
5265
+ ? Object.freeze({
5266
+ ...item,
5267
+ durationTicks: item.durationTicks + shift,
5268
+ })
5269
+ : item,
5270
+ ),
5271
+ ),
5272
+ ...(input.sections
5273
+ ? {
5274
+ sections: Object.freeze(
5275
+ input.sections.map((section) => Object.freeze(moveRange(section))),
5276
+ ),
5277
+ }
5278
+ : {}),
5279
+ ...(input.loop ? { loop: Object.freeze(moveRange(input.loop)) } : {}),
5280
+ });
5281
+ }
5282
+
5012
5283
  export function song(input: SongInput): Song {
5013
5284
  if (!isRecord(input)) throw new DawgSdkError("song() needs an object");
5014
5285
  const tempoBpm = finite(input.tempo ?? 120, "song tempo");
@@ -5238,6 +5509,7 @@ export function song(input: SongInput): Song {
5238
5509
  sections?: readonly SongSection[];
5239
5510
  form?: readonly SongFormEntry[];
5240
5511
  loopSection?: string;
5512
+ loop?: Readonly<{ startBar: number; bars: number }>;
5241
5513
  } = {};
5242
5514
  if (input.sections !== undefined) {
5243
5515
  const sections = songSections(input.sections);
@@ -5252,6 +5524,13 @@ export function song(input: SongInput): Song {
5252
5524
  throw new DawgSdkError("song loopSection must be a section name");
5253
5525
  arrangement.loopSection = input.loopSection;
5254
5526
  }
5527
+ if (input.loop !== undefined) {
5528
+ if (input.loopSection !== undefined)
5529
+ throw new DawgSdkError("song sets loop or loopSection, not both");
5530
+ const looped = songLoop(input.loop, bars);
5531
+ if (typeof looped === "string") arrangement.loopSection = looped;
5532
+ else arrangement.loop = looped;
5533
+ }
5255
5534
  return Object.freeze({
5256
5535
  format: "track.loop/v1",
5257
5536
  version: 1,