@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
@@ -8,6 +8,8 @@ import {
8
8
  formatDiagnostic,
9
9
  type Diagnostic,
10
10
  } from "../../core/sdk/eval.ts";
11
+ import { legacyResonatorWarnings } from "../../core/resonators.ts";
12
+ import { plainSineWarnings } from "../audio/instrument-check.ts";
11
13
  import { isProject } from "./init.ts";
12
14
  import { typecheckProject } from "./typecheck.ts";
13
15
 
@@ -23,6 +25,8 @@ export type CheckReport = Readonly<{
23
25
  evalMs: number;
24
26
  tracks: number;
25
27
  notes: number;
28
+ /** Advice that does not fail the check (0.6: legacy instrument words). */
29
+ warnings: readonly string[];
26
30
  }>;
27
31
 
28
32
  /** Runs both checks on `project`; never throws for project problems. */
@@ -45,6 +49,14 @@ export async function checkProject(
45
49
  evalMs: evaluated.ms,
46
50
  tracks: evaluated.ok ? evaluated.score.tracks.length : 0,
47
51
  notes: evaluated.ok ? evaluated.score.notes.length : 0,
52
+ warnings: Object.freeze(
53
+ evaluated.ok
54
+ ? [
55
+ ...legacyResonatorWarnings(evaluated.score.tracks),
56
+ ...plainSineWarnings(evaluated.score.tracks),
57
+ ]
58
+ : [],
59
+ ),
48
60
  });
49
61
  }
50
62
 
@@ -65,6 +77,7 @@ export async function runCheckCommand(
65
77
  const report = await checkProject(project, deps);
66
78
  for (const diagnostic of report.diagnostics)
67
79
  stderr.write(`${formatDiagnostic(diagnostic)}\n`);
80
+ for (const warning of report.warnings) stderr.write(`warning: ${warning}\n`);
68
81
  if (report.ok) {
69
82
  stdout.write(
70
83
  `ok · ${report.tracks} track${report.tracks === 1 ? "" : "s"}, ${report.notes} note${
package/src/render.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `dawg render <out.wav>`: renders a session, the project files (`song.ts`,
2
+ * `dawg render <out.wav>` (or `<out.mid>`): renders a session, the project files (`song.ts`,
3
3
  * in a project with no `--session`), or a `track.loop/v1` file to
4
4
  * a stereo 16-bit PCM WAV through the same deterministic renderer playback
5
5
  * uses, so two renders of one score are byte-identical. It reads the session
@@ -8,9 +8,33 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import { readFile, rename, writeFile } from "node:fs/promises";
10
10
  import { resolve } from "node:path";
11
- import { scoreFromJSON, type TrackScore } from "../core/score.ts";
11
+ import {
12
+ scoreFromJSON,
13
+ ScoreValidationError,
14
+ withNoteCap,
15
+ type TrackScore,
16
+ } from "../core/score.ts";
12
17
  import { decodeLoop } from "../core/loop.ts";
13
- import { renderScoreWav } from "./audio/wav.ts";
18
+ import { scoreToMidi } from "../core/midi.ts";
19
+ import { RENDER_CHANNELS, encodeWav, withWavCues } from "./audio/wav.ts";
20
+ import { exportSampleRate, measureRendered } from "./audio/measure.ts";
21
+ import {
22
+ applyMasterCommand,
23
+ loudnessLine,
24
+ measurementLine,
25
+ parseMasterCommand,
26
+ } from "./commands/master.ts";
27
+ import {
28
+ BAKED_NOTE_CAP,
29
+ bakeTrackTime,
30
+ findSection,
31
+ sectionScore,
32
+ } from "../core/sections.ts";
33
+ import {
34
+ exportScore,
35
+ renderArrangedPcm,
36
+ sectionCues,
37
+ } from "./audio/arrange.ts";
14
38
  import { SampleLibrary, hasSamplerTracks } from "./audio/samples.ts";
15
39
  import {
16
40
  PackStore,
@@ -29,7 +53,7 @@ import {
29
53
  } from "./session/store.ts";
30
54
 
31
55
  export const RENDER_USAGE =
32
- "usage: dawg render <out.wav> [--session <name|id>] [--import <file.track.json>]";
56
+ "usage: dawg render <out.wav> [--session <name|id>] [--import <file.track.json>] [--section <name>] [--normalize <lufs|streaming|club|loud|…>] [--measure] [--rate <hz>] · <out.mid> writes MIDI";
33
57
 
34
58
  const MAX_LOOP_FILE_BYTES = 512 * 1024;
35
59
 
@@ -50,7 +74,14 @@ export async function runRenderCommand(
50
74
  const positional: string[] = [];
51
75
  for (let index = 0; index < rest.length; index += 1) {
52
76
  const arg = rest[index]!;
53
- if (arg === "--session" || arg === "--import") {
77
+ if (arg === "--measure") options.set(arg, "");
78
+ else if (
79
+ arg === "--session" ||
80
+ arg === "--import" ||
81
+ arg === "--normalize" ||
82
+ arg === "--rate" ||
83
+ arg === "--section"
84
+ ) {
54
85
  const value = rest[index + 1];
55
86
  if (value === undefined || value.startsWith("--")) {
56
87
  stderr.write(`${arg} needs a value · ${RENDER_USAGE}\n`);
@@ -64,7 +95,7 @@ export async function runRenderCommand(
64
95
  } else positional.push(arg);
65
96
  }
66
97
  const target = positional[0];
67
- if (positional.length !== 1 || !target || !/\.wav$/i.test(target)) {
98
+ if (positional.length !== 1 || !target || !/\.(wav|midi?)$/i.test(target)) {
68
99
  stderr.write(`${RENDER_USAGE}\n`);
69
100
  return 2;
70
101
  }
@@ -77,13 +108,104 @@ export async function runRenderCommand(
77
108
  );
78
109
  return 1;
79
110
  }
111
+ const only = options.get("--section");
112
+ if (only !== undefined) {
113
+ const section = findSection(score, only);
114
+ if (!section) {
115
+ const names = score.sections.map((entry) => entry.name).join(", ");
116
+ stderr.write(
117
+ `render failed · no section "${only}"${names ? ` · sections: ${names}` : ""}\n`,
118
+ );
119
+ return 1;
120
+ }
121
+ // One section as it plays when looped: its mutes and variations, no form.
122
+ score = withNoteCap(BAKED_NOTE_CAP, () =>
123
+ sectionScore(bakeTrackTime(score), section),
124
+ );
125
+ }
126
+ if (/\.midi?$/i.test(target)) {
127
+ // MIDI carries notes, not audio: the master and loudness flags do not apply.
128
+ const audioOnly = ["--normalize", "--measure", "--rate"].find((flag) =>
129
+ options.has(flag),
130
+ );
131
+ if (audioOnly) {
132
+ stderr.write(`render failed · ${audioOnly} applies to .wav only\n`);
133
+ return 2;
134
+ }
135
+ const midi = scoreToMidi(exportScore(score));
136
+ const midiPath = resolve(workspace, target);
137
+ const midiTemporary = `${midiPath}.${process.pid}.tmp`;
138
+ await writeFile(midiTemporary, midi);
139
+ await rename(midiTemporary, midiPath);
140
+ const midiSha = createHash("sha256").update(midi).digest("hex");
141
+ stdout.write(
142
+ `rendered · ${target} · ${midi.byteLength} bytes · ${midiSha}\n`,
143
+ );
144
+ return 0;
145
+ }
146
+ // `--normalize` is the song master's loudness target for this export only:
147
+ // a named target also brings the limiter in at that target's ceiling.
148
+ const normalize = options.get("--normalize");
149
+ if (normalize !== undefined) {
150
+ const command = parseMasterCommand(`master target ${normalize}`);
151
+ const result = command && applyMasterCommand(score, command);
152
+ if (!result?.ok) {
153
+ stderr.write(
154
+ `render failed · --normalize takes LUFS (-40 to -3) or a target name · ${RENDER_USAGE}\n`,
155
+ );
156
+ return 2;
157
+ }
158
+ score = result.next ?? score;
159
+ const flipped =
160
+ command?.type === "master-target" ? command.flipped : undefined;
161
+ if (flipped !== undefined)
162
+ stderr.write(
163
+ `render · --normalize ${normalize} read as ${-flipped} LUFS\n`,
164
+ );
165
+ }
80
166
  let samples;
81
167
  if (hasSamplerTracks(score)) {
82
168
  samples = await new SampleLibrary({ projectRoot: workspace }).load(score);
83
169
  for (const problem of samples.problems)
84
170
  stderr.write(`sample ${problem.level} · ${problem.message}\n`);
171
+ // A voice that cannot load (a missing file, a pin that no longer
172
+ // matches) would render as silence: fail rather than write a song with
173
+ // a part missing.
174
+ const failed = samples.problems.filter((p) => p.level === "error").length;
175
+ if (failed > 0) {
176
+ stderr.write(
177
+ `render failed · ${failed} sample ${failed === 1 ? "voice" : "voices"} could not load · fix or re-pick ${failed === 1 ? "it" : "them"} and render again\n`,
178
+ );
179
+ return 1;
180
+ }
181
+ }
182
+ // A song with a master is a deliverable: 48 kHz unless --rate says
183
+ // otherwise; a plain song keeps the engine's rate, as dawg 0.4 wrote it.
184
+ const rateArg = options.get("--rate");
185
+ const sampleRate =
186
+ rateArg === undefined ? exportSampleRate(score) : Number(rateArg);
187
+ if (
188
+ !Number.isInteger(sampleRate) ||
189
+ sampleRate < 8_000 ||
190
+ sampleRate > 48_000
191
+ ) {
192
+ stderr.write(
193
+ `render failed · --rate takes 8000 to 48000 Hz · ${RENDER_USAGE}\n`,
194
+ );
195
+ return 2;
85
196
  }
86
- let wav = renderScoreWav(score, { samples });
197
+ let audio;
198
+ try {
199
+ audio = renderArrangedPcm(score, { samples, sampleRate });
200
+ } catch (error) {
201
+ if (!(error instanceof ScoreValidationError)) throw error;
202
+ stderr.write(`render failed · ${error.message}\n`);
203
+ return 1;
204
+ }
205
+ let wav = encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
206
+ // Section starts as cue points (a --section render is one section).
207
+ if (only === undefined)
208
+ wav = withWavCues(wav, sectionCues(score, audio.sampleRate));
87
209
  // Pack sounds: name the packs (and CC-BY attributions) in the WAV's INFO
88
210
  // comment and on stdout; CREDITS.md in a project keeps the attributions.
89
211
  const refs = score.tracks.flatMap((track) =>
@@ -102,8 +224,17 @@ export async function runRenderCommand(
102
224
  await writeFile(temporary, wav);
103
225
  await rename(temporary, path);
104
226
  const sha = createHash("sha256").update(wav).digest("hex");
105
- stdout.write(`rendered · ${target} · ${wav.byteLength} bytes · ${sha}\n`);
227
+ const seconds = audio.frames / audio.sampleRate;
228
+ stdout.write(
229
+ `rendered · ${target} · ${seconds.toFixed(2)} s · ${wav.byteLength} bytes · ${sha}\n`,
230
+ );
106
231
  if (credits) stdout.write(`credits · ${credits}\n`);
232
+ if (options.has("--measure"))
233
+ stdout.write(
234
+ `loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
235
+ );
236
+ else if (audio.master)
237
+ stdout.write(`loudness · ${loudnessLine(audio.master)}\n`);
107
238
  return 0;
108
239
  }
109
240
 
@@ -6,7 +6,7 @@ import {
6
6
  type ScoreOperation,
7
7
  type TrackScore,
8
8
  } from "../../core/score.ts";
9
- import { TransportClock } from "../audio/clock.ts";
9
+ import { TransportClock, transportMapFor } from "../audio/clock.ts";
10
10
  import { AudioEngine } from "../audio/engine.ts";
11
11
  import { acquireSessionLock } from "./lock.ts";
12
12
  import {
@@ -127,7 +127,7 @@ export class DawgDaemon {
127
127
  }
128
128
  try {
129
129
  await this.reload();
130
- this.clock.setTempo(this.score.tempoBpm);
130
+ this.clock.follow(this.score);
131
131
  // We hold the daemon lock, so any socket file left here belongs to a
132
132
  // crashed daemon and is safe to reclaim.
133
133
  await rm(this.socketPath, { force: true });
@@ -340,7 +340,7 @@ export class DawgDaemon {
340
340
  );
341
341
  if (result.record.revision !== this.record.revision) {
342
342
  // A file-fallback window wrote the score too; adopt everything.
343
- const previousTempo = this.score.tempoBpm;
343
+ const previousTempo = this.timeKey();
344
344
  await this.reload();
345
345
  this.broadcast({
346
346
  v: 1,
@@ -454,7 +454,7 @@ export class DawgDaemon {
454
454
  rebasedFrom: message.base,
455
455
  }
456
456
  : message.payload;
457
- const previousTempo = this.score.tempoBpm;
457
+ const previousTempo = this.timeKey();
458
458
  try {
459
459
  this.record = await appendSessionEvent(
460
460
  this.paths,
@@ -528,9 +528,14 @@ export class DawgDaemon {
528
528
  }
529
529
  }
530
530
 
531
- private afterScoreChange(previousTempo: number): void {
532
- if (this.score.tempoBpm !== previousTempo) {
533
- this.clock.setTempo(this.score.tempoBpm);
531
+ /** Tempo and tempo map, compared to tell when the transport must follow. */
532
+ private timeKey(): string {
533
+ return JSON.stringify([this.score.tempoBpm, this.score.time ?? null]);
534
+ }
535
+
536
+ private afterScoreChange(previousTempo: string): void {
537
+ if (this.timeKey() !== previousTempo) {
538
+ this.clock.follow(this.score);
534
539
  this.broadcastTransport();
535
540
  }
536
541
  // Gapless: a streaming engine swaps the loop at the current beat
@@ -560,6 +565,7 @@ export class DawgDaemon {
560
565
  this.audio.seek(this.clock.beatAt());
561
566
  } else if (action === "tempo" && bpm !== undefined) {
562
567
  this.clock.setTempo(bpm);
568
+ this.clock.setTimeMap(transportMapFor(this.score));
563
569
  }
564
570
  this.broadcastTransport();
565
571
  }
@@ -641,7 +647,7 @@ export class DawgDaemon {
641
647
  }
642
648
  return;
643
649
  }
644
- const previousTempo = this.score.tempoBpm;
650
+ const previousTempo = this.timeKey();
645
651
  await this.reload();
646
652
  this.broadcast({
647
653
  v: 1,
@@ -695,6 +701,10 @@ const OPERATION_TYPES = new Set([
695
701
  "moveTrack",
696
702
  "setKey",
697
703
  "setMeter",
704
+ "setTime",
705
+ "setTuning",
706
+ "setMaster",
707
+ "setSections",
698
708
  ]);
699
709
 
700
710
  /** Shape gate before the reducer, which validates every field it reads. */
@@ -107,7 +107,14 @@ export function musicalFingerprint(score: TrackScore): Fingerprint {
107
107
  : `${role}(${instrument},${register},${density})`,
108
108
  );
109
109
  }
110
- const key = score.key ? score.key.toLowerCase() : estimateKey(histogram);
110
+ // A tuned song is named by its tuning (`pelog`), not a guessed 12-TET key.
111
+ const tuned = score.tuning
112
+ ? (score.tuning.name ??
113
+ (score.tuning.edo ? `${score.tuning.edo}-edo` : "tuned"))
114
+ : undefined;
115
+ const key = score.key
116
+ ? score.key.toLowerCase()
117
+ : (tuned?.toLowerCase() ?? estimateKey(histogram));
111
118
  const line = [
112
119
  `${bpm} bpm`,
113
120
  key ?? "no key",
@@ -16,6 +16,7 @@ import {
16
16
  type TrackScore,
17
17
  } from "../../core/score.ts";
18
18
  import { rewindComposition, type Rewind } from "./delta.ts";
19
+ import { deepEqual } from "../../core/diff.ts";
19
20
 
20
21
  /** How far behind a base may be and still be rebased. */
21
22
  export const MAX_REBASE_DISTANCE = 64;
@@ -114,6 +115,26 @@ export function rebaseOperations(
114
115
  if (base.beatsPerBar !== current.beatsPerBar)
115
116
  return { ok: false, reason: "meter changed" };
116
117
  break;
118
+ case "setTime":
119
+ if (!same(base.time, current.time))
120
+ return { ok: false, reason: "tempo map changed" };
121
+ break;
122
+ case "setTuning":
123
+ if (!same(base.tuning ?? null, current.tuning ?? null))
124
+ return { ok: false, reason: "tuning changed" };
125
+ break;
126
+ case "setMaster":
127
+ if (!deepEqual(base.master, current.master))
128
+ return { ok: false, reason: "master changed" };
129
+ break;
130
+ case "setSections":
131
+ if (
132
+ JSON.stringify(base.sections) !== JSON.stringify(current.sections) ||
133
+ JSON.stringify(base.form) !== JSON.stringify(current.form) ||
134
+ base.loopSection !== current.loopSection
135
+ )
136
+ return { ok: false, reason: "sections changed" };
137
+ break;
117
138
  case "removeTrack": {
118
139
  if (added.tracks.has(operation.trackId)) break;
119
140
  if (