@hraness/dawg 0.6.1 → 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 (277) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/DAWG.md +454 -140
  3. package/README.md +30 -26
  4. package/core/autotune.ts +1119 -0
  5. package/core/chords.ts +271 -23
  6. package/core/clips.ts +499 -0
  7. package/core/diff.ts +184 -104
  8. package/core/expression.ts +15 -0
  9. package/core/fx.ts +99 -29
  10. package/core/ids.ts +52 -0
  11. package/core/instruments.ts +19 -0
  12. package/core/keys.ts +3 -3
  13. package/core/loop.ts +8 -0
  14. package/core/lyrics.ts +297 -0
  15. package/core/master.ts +3 -3
  16. package/core/range.ts +581 -0
  17. package/core/resonators.ts +16 -2
  18. package/core/routing.ts +165 -0
  19. package/core/score.ts +955 -14
  20. package/core/sdk/eval-child.ts +7 -2
  21. package/core/sdk/eval.ts +35 -6
  22. package/core/sdk/print.ts +287 -5
  23. package/core/sdk/sync-lyrics.ts +49 -0
  24. package/core/sdk/v1.ts +2132 -46
  25. package/core/sections.ts +480 -35
  26. package/core/sing.ts +815 -0
  27. package/core/style-provenance.ts +80 -0
  28. package/core/styles/africa-mena-southasia.ts +2893 -0
  29. package/core/styles/americas.ts +3810 -0
  30. package/core/styles/art.ts +4993 -0
  31. package/core/styles/base.ts +123 -0
  32. package/core/styles/cycles.ts +106 -0
  33. package/core/styles/electronic.ts +2723 -0
  34. package/core/styles/europe-asia-pacific.ts +2838 -0
  35. package/core/styles/excerpt.ts +29 -0
  36. package/core/styles/gamelan.ts +283 -0
  37. package/core/styles/generate.ts +2199 -0
  38. package/core/styles/index.ts +515 -0
  39. package/core/styles/parts.ts +106 -0
  40. package/core/styles/pop.ts +3189 -0
  41. package/core/styles/rock.ts +2993 -0
  42. package/core/styles/roots.ts +4175 -0
  43. package/core/styles/schema.ts +429 -0
  44. package/core/styles/taxonomy.ts +940 -0
  45. package/core/styles/validate.ts +528 -0
  46. package/core/tempo.ts +32 -2
  47. package/core/tuning.ts +19 -3
  48. package/core/vocoder.ts +524 -0
  49. package/guides/agent.md +29 -0
  50. package/guides/arrange.md +31 -0
  51. package/guides/audio.md +31 -0
  52. package/guides/audition.md +20 -14
  53. package/guides/automation.md +12 -7
  54. package/guides/chords.md +15 -15
  55. package/guides/effects.md +17 -16
  56. package/guides/faders.md +20 -16
  57. package/guides/files.md +13 -9
  58. package/guides/getting-started.md +15 -11
  59. package/guides/keys.md +19 -15
  60. package/guides/media.md +17 -12
  61. package/guides/mix.md +15 -7
  62. package/guides/music.md +23 -8
  63. package/guides/notes.md +15 -9
  64. package/guides/panes.md +32 -0
  65. package/guides/performance.md +15 -13
  66. package/guides/play.md +21 -13
  67. package/guides/project.md +26 -8
  68. package/guides/providers.md +20 -14
  69. package/guides/resample.md +15 -9
  70. package/guides/rhythm.md +18 -13
  71. package/guides/sessions.md +17 -7
  72. package/guides/show-me.md +31 -0
  73. package/guides/sound.md +25 -9
  74. package/guides/sounds.md +15 -13
  75. package/guides/styles.md +31 -0
  76. package/guides/tape.md +32 -0
  77. package/guides/tempo.md +15 -10
  78. package/guides/tracks.md +15 -10
  79. package/guides/tuning.md +32 -0
  80. package/guides/voice.md +31 -0
  81. package/guides/web-search.md +18 -8
  82. package/native/prebuilt/darwin-arm64/libdawg_sink.dylib +0 -0
  83. package/native/prebuilt/darwin-x64/libdawg_sink.dylib +0 -0
  84. package/native/prebuilt/linux-arm64/libdawg_sink.so +0 -0
  85. package/native/prebuilt/linux-x64/libdawg_sink.so +0 -0
  86. package/native/prebuilt/manifest.json +21 -0
  87. package/package.json +6 -2
  88. package/src/agent/agent.ts +130 -18
  89. package/src/agent/calibration-tools.ts +53 -0
  90. package/src/agent/clip-tools.ts +453 -0
  91. package/src/agent/command-agent.ts +378 -0
  92. package/src/agent/drum-tools.ts +2 -2
  93. package/src/agent/expression-tools.ts +1 -1
  94. package/src/agent/gateway.ts +246 -60
  95. package/src/agent/models.ts +53 -12
  96. package/src/agent/ops.ts +27 -1
  97. package/src/agent/pack-tools.ts +1 -1
  98. package/src/agent/planner.ts +25 -0
  99. package/src/agent/portable-schema.ts +80 -0
  100. package/src/agent/preview-tool.ts +4 -1
  101. package/src/agent/provider.ts +22 -8
  102. package/src/agent/range-tools.ts +216 -0
  103. package/src/agent/rhythm-tools.ts +1 -1
  104. package/src/agent/section-tools.ts +1 -1
  105. package/src/agent/show-me.ts +497 -0
  106. package/src/agent/steer.ts +15 -0
  107. package/src/agent/style-tools.ts +217 -0
  108. package/src/agent/tool-error.ts +12 -0
  109. package/src/agent/tools.ts +110 -23
  110. package/src/agent/usage.ts +2 -2
  111. package/src/agent/voice-tools.ts +925 -0
  112. package/src/agent/xcb-agent.ts +13 -10
  113. package/src/argv.ts +38 -0
  114. package/src/audio/analysis.ts +253 -0
  115. package/src/audio/arrange.ts +49 -5
  116. package/src/audio/audio-command.ts +218 -0
  117. package/src/audio/autotune-engine.ts +101 -0
  118. package/src/audio/autotune.ts +640 -0
  119. package/src/audio/clips.ts +240 -0
  120. package/src/audio/devices.ts +264 -0
  121. package/src/audio/doctor.ts +157 -0
  122. package/src/audio/dsp/bandbank.ts +138 -0
  123. package/src/audio/dsp/envelope.ts +10 -0
  124. package/src/audio/dsp/follow.ts +120 -0
  125. package/src/audio/dsp/formant.ts +427 -0
  126. package/src/audio/dsp/glottal.ts +243 -0
  127. package/src/audio/dsp/interp.ts +7 -2
  128. package/src/audio/dsp/lpc.ts +50 -0
  129. package/src/audio/dsp/periodicity.ts +59 -0
  130. package/src/audio/dsp/pitch.ts +995 -0
  131. package/src/audio/dsp/psola.ts +199 -0
  132. package/src/audio/effects/chain.ts +3 -1
  133. package/src/audio/effects/common.ts +43 -0
  134. package/src/audio/effects/convolution.ts +7 -4
  135. package/src/audio/effects/filter.ts +48 -69
  136. package/src/audio/effects/formant.ts +263 -0
  137. package/src/audio/engine.ts +360 -40
  138. package/src/audio/fit.ts +35 -3
  139. package/src/audio/instrument-check.ts +59 -43
  140. package/src/audio/instruments.ts +4 -0
  141. package/src/audio/keys/calibration.ts +56 -0
  142. package/src/audio/keys/electric.ts +8 -1
  143. package/src/audio/keys/engine.ts +13 -1
  144. package/src/audio/keys/piano.ts +22 -2
  145. package/src/audio/kits.ts +135 -6
  146. package/src/audio/live.ts +114 -20
  147. package/src/audio/native.ts +615 -0
  148. package/src/audio/preview.ts +30 -2
  149. package/src/audio/render-worker.ts +2 -0
  150. package/src/audio/renderer.ts +2 -0
  151. package/src/audio/resample.ts +2 -1
  152. package/src/audio/sampler.ts +85 -4
  153. package/src/audio/samples.ts +20 -2
  154. package/src/audio/sing/analysis.ts +193 -0
  155. package/src/audio/sing/engine.ts +949 -0
  156. package/src/audio/strings/bow.ts +48 -5
  157. package/src/audio/strings/engine.ts +8 -2
  158. package/src/audio/synth/oscillators.ts +31 -21
  159. package/src/audio/synth/voice.ts +34 -1
  160. package/src/audio/vocoder/bank.ts +314 -0
  161. package/src/audio/vocoder/carrier.ts +165 -0
  162. package/src/audio/vocoder/control.ts +68 -0
  163. package/src/audio/vocoder/detect.ts +50 -0
  164. package/src/audio/vocoder/index.ts +304 -0
  165. package/src/audio/vocoder/talkbox.ts +143 -0
  166. package/src/audio/wav.ts +500 -77
  167. package/src/audio/winds/engine.ts +5 -1
  168. package/src/audio/winds/trim.ts +28 -4
  169. package/src/audio/winds/trims1.ts +297 -0
  170. package/src/audio/winds/voice.ts +15 -2
  171. package/src/auth/cli.ts +38 -36
  172. package/src/auth/credentials.ts +30 -1
  173. package/src/auth/login.ts +15 -9
  174. package/src/auth/tui.ts +19 -10
  175. package/src/commands/arrange.ts +82 -32
  176. package/src/commands/autotune.ts +421 -0
  177. package/src/commands/calibration.ts +74 -0
  178. package/src/commands/clips.ts +887 -0
  179. package/src/commands/drums.ts +3 -2
  180. package/src/commands/edit.ts +11 -4
  181. package/src/commands/expression.ts +1 -1
  182. package/src/commands/formant.ts +221 -0
  183. package/src/commands/fx.ts +101 -32
  184. package/src/commands/grammar.ts +560 -0
  185. package/src/commands/help.ts +774 -366
  186. package/src/commands/history.ts +139 -10
  187. package/src/commands/keys.ts +25 -8
  188. package/src/commands/modal.ts +1 -1
  189. package/src/commands/music.ts +1 -1
  190. package/src/commands/nearest.ts +53 -0
  191. package/src/commands/pack.ts +9 -2
  192. package/src/commands/param-range.ts +56 -0
  193. package/src/commands/parses.ts +135 -0
  194. package/src/commands/progression.ts +170 -0
  195. package/src/commands/range.ts +763 -0
  196. package/src/commands/resample.ts +13 -10
  197. package/src/commands/rhythm.ts +3 -0
  198. package/src/commands/rig.ts +3 -24
  199. package/src/commands/sample.ts +11 -1
  200. package/src/commands/sing.ts +474 -0
  201. package/src/commands/strum.ts +13 -1
  202. package/src/commands/style.ts +415 -0
  203. package/src/commands/time.ts +6 -3
  204. package/src/commands/tuning.ts +3 -3
  205. package/src/commands/vocal-pitch.ts +617 -0
  206. package/src/commands/vocal.ts +147 -0
  207. package/src/commands/vocoder.ts +627 -0
  208. package/src/commands/wind.ts +2 -2
  209. package/src/fs/durable.ts +50 -0
  210. package/src/identity/actor.ts +127 -0
  211. package/src/lang/glossary.ts +511 -0
  212. package/src/launch-args.ts +267 -0
  213. package/src/main.ts +2508 -308
  214. package/src/media/cli.ts +20 -3
  215. package/src/media/import.ts +3 -1
  216. package/src/project/check.ts +21 -2
  217. package/src/project/clip-pins.ts +72 -0
  218. package/src/project/init.ts +23 -8
  219. package/src/project/sync.ts +418 -86
  220. package/src/render.ts +20 -1
  221. package/src/session/client.ts +107 -3
  222. package/src/session/clipboard.ts +79 -0
  223. package/src/session/daemon.ts +199 -5
  224. package/src/session/live-host.ts +232 -0
  225. package/src/session/meta.ts +14 -0
  226. package/src/session/origin.ts +174 -0
  227. package/src/session/port.ts +109 -14
  228. package/src/session/presence.ts +34 -4
  229. package/src/session/protocol.ts +365 -7
  230. package/src/session/rebase.ts +63 -12
  231. package/src/session/receipt.ts +265 -0
  232. package/src/session/shared-live.ts +131 -0
  233. package/src/session/store.ts +126 -45
  234. package/src/tui/arrange-menu.ts +225 -23
  235. package/src/tui/audition.ts +1 -1
  236. package/src/tui/euclid.ts +113 -27
  237. package/src/tui/fader.ts +282 -42
  238. package/src/tui/granular-menu.ts +2 -4
  239. package/src/tui/knob-fields.ts +107 -0
  240. package/src/tui/knob-map.ts +198 -0
  241. package/src/tui/menu-clips.ts +297 -0
  242. package/src/tui/menu-time.ts +20 -13
  243. package/src/tui/menu-voice.ts +405 -0
  244. package/src/tui/menu.ts +987 -179
  245. package/src/tui/modal-menu.ts +6 -6
  246. package/src/tui/performance-menu.ts +5 -2
  247. package/src/tui/play-chords.ts +4 -2
  248. package/src/tui/play-mode.ts +15 -1
  249. package/src/tui/play-session.ts +243 -28
  250. package/src/tui/sing-menu.ts +278 -0
  251. package/src/tui/style-menu.ts +104 -0
  252. package/src/tui/tape-mode.ts +580 -0
  253. package/src/tui/tape-view.ts +263 -0
  254. package/src/tui/vocoder-menu.ts +244 -0
  255. package/src/tui/wind-menu.ts +3 -3
  256. package/src/version.ts +8 -0
  257. package/src/web/fetch.ts +115 -29
  258. package/tui/activity.ts +180 -9
  259. package/tui/app.ts +491 -84
  260. package/tui/clip-row.ts +132 -0
  261. package/tui/delight.ts +193 -0
  262. package/tui/drawer.ts +233 -27
  263. package/tui/frame-gate.ts +76 -0
  264. package/tui/grammar.ts +143 -65
  265. package/tui/guide.ts +50 -5
  266. package/tui/highway.ts +309 -30
  267. package/tui/hints.ts +197 -0
  268. package/tui/hits.ts +7 -1
  269. package/tui/input.ts +60 -9
  270. package/tui/keys.ts +1 -1
  271. package/tui/knobs.ts +268 -0
  272. package/tui/play-strip.ts +80 -14
  273. package/tui/prompt.ts +1 -1
  274. package/tui/screen.ts +151 -18
  275. package/tui/tape.ts +439 -0
  276. package/tui/text.ts +35 -2
  277. package/tui/theme.ts +46 -1
package/core/sections.ts CHANGED
@@ -13,9 +13,19 @@
13
13
  */
14
14
  import { DRUM_VOICES, isDrumInstrument } from "./drums.ts";
15
15
  import { FX_LANES } from "./fx.ts";
16
+ import { synthKit } from "./kits.ts";
16
17
  import { resolveString } from "./strings.ts";
17
18
  import { modalSettings, windSettings } from "./resonators.ts";
18
- import { slicePedals } from "./expression.ts";
19
+ import { resolveSing } from "./sing.ts";
20
+ import { slicePedals, type PedalEvent, type PedalState } from "./expression.ts";
21
+ import {
22
+ bakeClipTime,
23
+ copyClipBars,
24
+ deleteClipBars,
25
+ insertClipBars,
26
+ sliceClips,
27
+ type ClipPiece,
28
+ } from "./clips.ts";
19
29
  import {
20
30
  SCORE_LIMITS,
21
31
  withNoteCap,
@@ -23,6 +33,7 @@ import {
23
33
  TrackScore,
24
34
  isSamplerInstrument,
25
35
  wavetableOf,
36
+ type AudioClip,
26
37
  type AutomationPoint,
27
38
  type FormEntry,
28
39
  type Note,
@@ -32,10 +43,10 @@ import {
32
43
  } from "./score.ts";
33
44
  import {
34
45
  barStartTick,
35
- bpmAtTick,
36
46
  hasTrackTime,
37
47
  normalizeSongTime,
38
48
  performedNotes,
49
+ timeMapFor,
39
50
  type Fermata,
40
51
  type SongTime,
41
52
  type TempoEvent,
@@ -61,6 +72,11 @@ export function sliceSongTime(
61
72
  };
62
73
  const clampBpm = (bpm: number) =>
63
74
  Math.min(SCORE_LIMITS.maxTempoBpm, Math.max(SCORE_LIMITS.minTempoBpm, bpm));
75
+ // The written tempo: a fermata's hold belongs to its own beat and moves
76
+ // with it, so it must not leak into the tempo restated at a seam.
77
+ const map = timeMapFor(score);
78
+ const bpmAtTick = (_score: TrackScore, tick: number) =>
79
+ map ? map.tempoAt(tick) : score.tempoBpm;
64
80
  const sorted = [...pieces].sort((a, b) => a.offset - b.offset);
65
81
  const first = sorted[0];
66
82
  const tempoBpm = clampBpm(
@@ -147,7 +163,10 @@ export function bakeTrackTime(
147
163
  () =>
148
164
  new TrackScore({
149
165
  ...score.toJSON(),
150
- tracks: score.tracks.map(({ time: _time, ...track }) => track),
166
+ tracks: score.tracks.map((track) => {
167
+ const { time: _time, ...rest } = bakeClipTime(track);
168
+ return rest;
169
+ }),
151
170
  notes,
152
171
  }),
153
172
  );
@@ -394,8 +413,73 @@ export function applySectionChanges(score: TrackScore): TrackScore {
394
413
  }
395
414
  notes.push(cut);
396
415
  }
416
+ // Clips (0.7): a section that mutes a track silences its clips there, its
417
+ // variation gain scales them; a clip crossing such an edge is cut.
418
+ const total = Math.max(
419
+ score.bars,
420
+ ...score.sections.map((s) => s.startBar + s.bars),
421
+ );
422
+ const clipTracks = score.tracks.map((track) => {
423
+ if (!track.clips) return track;
424
+ const pieces: ClipPiece[] = [];
425
+ for (let bar = 0; bar < total; bar += 1) {
426
+ const section = sectionAtBar(score, bar);
427
+ pieces.push({
428
+ from: bar * ticks,
429
+ to: (bar + 1) * ticks,
430
+ offset: bar * ticks,
431
+ ...clipPieceState(section, track.id),
432
+ });
433
+ }
434
+ // The last bar runs on, so a clip sounding past the end is not cut.
435
+ if (pieces.length > 0)
436
+ pieces[pieces.length - 1] = {
437
+ ...pieces[pieces.length - 1]!,
438
+ to: SCORE_LIMITS.maxTick + 1,
439
+ };
440
+ const next = withClips(track, sliceClips(track.clips, pieces, score));
441
+ if (JSON.stringify(next.clips) !== JSON.stringify(track.clips))
442
+ changed = true;
443
+ return next;
444
+ });
397
445
  if (!changed) return score;
398
- return new TrackScore({ ...score.toJSON(), notes, sections: [], form: [] });
446
+ return new TrackScore({
447
+ ...score.toJSON(),
448
+ tracks: clipTracks,
449
+ notes,
450
+ sections: [],
451
+ form: [],
452
+ });
453
+ }
454
+
455
+ /** A section's mute and variation gain for one track's clips. */
456
+ function clipPieceState(
457
+ section: Section | undefined,
458
+ trackId: string,
459
+ ): Pick<ClipPiece, "mute" | "gain"> {
460
+ if (!section) return {};
461
+ if (section.mute?.includes(trackId)) return { mute: true };
462
+ const gain = section.vary?.[trackId]?.gain;
463
+ return gain === undefined || gain === 1 ? {} : { gain };
464
+ }
465
+
466
+ /** `track` with `clips` (absent when empty). */
467
+ function withClips(
468
+ track: Track,
469
+ clips: readonly AudioClip[] | undefined,
470
+ ): Track {
471
+ const { clips: _clips, ...rest } = track;
472
+ return clips && clips.length > 0 ? { ...rest, clips } : rest;
473
+ }
474
+
475
+ /** A track's clips cut to `pieces` (sections, forms, windows). */
476
+ function sliceTrackClips(
477
+ track: Track,
478
+ pieces: readonly ClipPiece[],
479
+ time: TrackScore,
480
+ ): Track {
481
+ if (!track.clips) return track;
482
+ return withClips(track, sliceClips(track.clips, pieces, time));
399
483
  }
400
484
 
401
485
  /**
@@ -433,9 +517,13 @@ export function sectionScore(score: TrackScore, section: Section): TrackScore {
433
517
  time: timed.time ?? null,
434
518
  bars: section.bars,
435
519
  tracks: score.tracks.map((track) =>
436
- slicePedals(
437
- mapAutomation(track, (points) => cropPoints(points, from, to)),
438
- [{ from, to, offset: 0 }],
520
+ sliceTrackClips(
521
+ slicePedals(
522
+ mapAutomation(track, (points) => cropPoints(points, from, to)),
523
+ [{ from, to, offset: 0 }],
524
+ ),
525
+ [{ from, to, offset: 0, ...clipPieceState(section, track.id) }],
526
+ score,
439
527
  ),
440
528
  ),
441
529
  notes,
@@ -447,9 +535,9 @@ export function sectionScore(score: TrackScore, section: Section): TrackScore {
447
535
  // ---------------------------------------------------------------------------
448
536
  // Automation over time edits
449
537
 
450
- type Points = readonly AutomationPoint[];
538
+ export type Points = readonly AutomationPoint[];
451
539
 
452
- const TRACK_LANES = [
540
+ export const TRACK_LANES = [
453
541
  "volumeAutomation",
454
542
  "panAutomation",
455
543
  "filterAutomation",
@@ -460,7 +548,10 @@ const TRACK_LANES = [
460
548
  ] as const;
461
549
 
462
550
  /** Applies `edit` to every automation lane of `track`. */
463
- function mapAutomation(track: Track, edit: (points: Points) => Points): Track {
551
+ export function mapAutomation(
552
+ track: Track,
553
+ edit: (points: Points) => Points,
554
+ ): Track {
464
555
  const out: Record<string, unknown> = { ...track };
465
556
  let changed = false;
466
557
  for (const field of TRACK_LANES) {
@@ -490,7 +581,7 @@ function mapAutomation(track: Track, edit: (points: Points) => Points): Track {
490
581
  }
491
582
 
492
583
  /** The lane value at `tick` (undefined before the first point). */
493
- function valueAt(points: Points, tick: number): number | undefined {
584
+ export function valueAt(points: Points, tick: number): number | undefined {
494
585
  const first = points[0];
495
586
  if (!first || tick < first.tick) return undefined;
496
587
  for (let index = 1; index < points.length; index += 1) {
@@ -505,7 +596,7 @@ function valueAt(points: Points, tick: number): number | undefined {
505
596
  return points[points.length - 1]!.value;
506
597
  }
507
598
 
508
- function sortPoints(points: AutomationPoint[]): Points {
599
+ export function sortPoints(points: AutomationPoint[]): Points {
509
600
  const byTick = new Map<number, AutomationPoint>();
510
601
  for (const point of points) byTick.set(point.tick, point);
511
602
  return [...byTick.values()].sort((a, b) => a.tick - b.tick);
@@ -594,7 +685,7 @@ function copyPoints(
594
685
  // ---------------------------------------------------------------------------
595
686
  // Ripple edits
596
687
 
597
- function checkBars(bars: number): void {
688
+ export function checkBars(bars: number): void {
598
689
  if (bars > SCORE_LIMITS.maxBars)
599
690
  throw new ScoreValidationError(
600
691
  `the song would be ${bars} bars; the limit is ${SCORE_LIMITS.maxBars}`,
@@ -602,6 +693,77 @@ function checkBars(bars: number): void {
602
693
  );
603
694
  }
604
695
 
696
+ /**
697
+ * The song tempo map, fermatas and pedal lanes for a ripple edit that lays
698
+ * `pieces` of the score end to end: they move with the music they belong
699
+ * to, as notes and automation do.
700
+ */
701
+ function rippleTime(
702
+ score: TrackScore,
703
+ pieces: readonly { from: number; to: number; offset: number }[],
704
+ ): Readonly<{
705
+ tempoBpm: number;
706
+ time: SongTime | null;
707
+ pedals: (track: Track) => Track;
708
+ }> {
709
+ const kept = pieces.filter((piece) => piece.to > piece.from);
710
+ const timed = sliceSongTime(
711
+ score,
712
+ kept.map((piece) => ({
713
+ ...piece,
714
+ to: Math.min(piece.to, SCORE_LIMITS.maxTick + 1),
715
+ })),
716
+ );
717
+ return {
718
+ tempoBpm: timed.tempoBpm,
719
+ time: timed.time ?? null,
720
+ pedals: (track) => {
721
+ if (!track.pedal && !track.softPedal && !track.sostenuto) return track;
722
+ const out: Record<string, unknown> = { ...track };
723
+ for (const field of ["pedal", "softPedal", "sostenuto"] as const) {
724
+ const events = track[field];
725
+ if (!events) continue;
726
+ const next = ripplePedal(events, kept);
727
+ if (next) out[field] = next;
728
+ else delete out[field];
729
+ }
730
+ return out as Track;
731
+ },
732
+ };
733
+ }
734
+
735
+ /**
736
+ * A pedal lane for `pieces` laid end to end with no lift at the seams (unlike
737
+ * a render slice): each piece starts in the state its source was in, and a
738
+ * gap left between pieces (inserted bars) holds the state before it.
739
+ */
740
+ function ripplePedal(
741
+ events: readonly PedalEvent[],
742
+ pieces: readonly { from: number; to: number; offset: number }[],
743
+ ): readonly PedalEvent[] | undefined {
744
+ const byTick = new Map<number, PedalState>();
745
+ for (const { from, to, offset } of pieces) {
746
+ let state: PedalState = "up";
747
+ for (const event of events) {
748
+ if (event.tick > from) break;
749
+ state = event.state;
750
+ }
751
+ byTick.set(offset, state);
752
+ for (const event of events)
753
+ if (event.tick > from && event.tick < to)
754
+ byTick.set(event.tick - from + offset, event.state);
755
+ }
756
+ const out: PedalEvent[] = [];
757
+ let last: PedalState = "up";
758
+ for (const tick of [...byTick.keys()].sort((a, b) => a - b)) {
759
+ const state = byTick.get(tick)!;
760
+ if (state === last) continue;
761
+ out.push({ tick, state });
762
+ last = state;
763
+ }
764
+ return out.length > 0 ? out : undefined;
765
+ }
766
+
605
767
  /** Insert `count` empty bars at `atBar`, shifting later music and sections. */
606
768
  export function insertBars(
607
769
  score: TrackScore,
@@ -613,16 +775,32 @@ export function insertBars(
613
775
  const ticks = barTicks(score);
614
776
  const at = atBar * ticks;
615
777
  const shift = count * ticks;
778
+ const ripple = rippleTime(score, [
779
+ { from: 0, to: at, offset: 0 },
780
+ { from: at, to: Infinity, offset: at + shift },
781
+ ]);
616
782
  return new TrackScore({
617
783
  ...score.toJSON(),
784
+ tempoBpm: ripple.tempoBpm,
785
+ time: ripple.time,
618
786
  bars: score.bars + count,
619
787
  tracks: score.tracks.map((track) =>
620
- mapAutomation(track, (points) => insertPoints(points, at, shift)),
788
+ insertClipBars(
789
+ mapAutomation(ripple.pedals(track), (points) =>
790
+ insertPoints(points, at, shift),
791
+ ),
792
+ at,
793
+ shift,
794
+ ),
621
795
  ),
796
+ // A note's start and end stay with the music they sit in, so one held
797
+ // across the insertion point sounds on through the new bars.
622
798
  notes: score.notes.map((note) =>
623
799
  note.startTick >= at
624
800
  ? { ...note, startTick: note.startTick + shift }
625
- : note,
801
+ : note.startTick + note.durationTicks > at
802
+ ? { ...note, durationTicks: note.durationTicks + shift }
803
+ : note,
626
804
  ),
627
805
  sections: score.sections.map((section) =>
628
806
  section.startBar >= atBar
@@ -631,6 +809,14 @@ export function insertBars(
631
809
  ? { ...section, bars: section.bars + count }
632
810
  : section,
633
811
  ),
812
+ // The loop range moves (or grows) like a section.
813
+ loop: score.loop
814
+ ? score.loop.startBar >= atBar
815
+ ? { ...score.loop, startBar: score.loop.startBar + count }
816
+ : score.loop.startBar + score.loop.bars > atBar
817
+ ? { ...score.loop, bars: score.loop.bars + count }
818
+ : score.loop
819
+ : null,
634
820
  });
635
821
  }
636
822
 
@@ -640,6 +826,12 @@ export function deleteBars(
640
826
  atBar: number,
641
827
  count: number,
642
828
  ): TrackScore {
829
+ if (!Number.isInteger(atBar) || atBar < 0 || !Number.isInteger(count))
830
+ throw new ScoreValidationError(
831
+ `bars to delete must start at a whole bar inside the song`,
832
+ );
833
+ // Only bars inside the song can go (a section may sit past its end).
834
+ count = Math.min(atBar + count, score.bars) - atBar;
643
835
  if (count <= 0) return score;
644
836
  if (count >= score.bars)
645
837
  throw new ScoreValidationError("a song keeps at least one bar");
@@ -647,16 +839,21 @@ export function deleteBars(
647
839
  const at = atBar * ticks;
648
840
  const end = (atBar + count) * ticks;
649
841
  const shift = count * ticks;
842
+ // Each note's start and end stay with the music they sit in; the part
843
+ // inside the deleted bars goes, so a note running out of them keeps its
844
+ // tail and one running into them is cut where they start.
845
+ const place = (tick: number) =>
846
+ tick < at ? tick : tick < end ? at : tick - shift;
650
847
  const notes: Note[] = [];
651
848
  for (const note of score.notes) {
652
- if (note.startTick >= end)
653
- notes.push({ ...note, startTick: note.startTick - shift });
654
- else if (note.startTick < at)
655
- notes.push(
656
- note.startTick + note.durationTicks > at
657
- ? { ...note, durationTicks: at - note.startTick }
658
- : note,
659
- );
849
+ const startTick = place(note.startTick);
850
+ const stop = place(note.startTick + note.durationTicks);
851
+ if (stop <= startTick) continue;
852
+ notes.push(
853
+ startTick === note.startTick && stop - startTick === note.durationTicks
854
+ ? note
855
+ : { ...note, startTick, durationTicks: stop - startTick },
856
+ );
660
857
  }
661
858
  const sections: Section[] = [];
662
859
  for (const section of score.sections) {
@@ -677,18 +874,48 @@ export function deleteBars(
677
874
  }
678
875
  }
679
876
  const names = new Set(sections.map((section) => foldName(section.name)));
877
+ const ripple = rippleTime(score, [
878
+ { from: 0, to: at, offset: 0 },
879
+ { from: end, to: Infinity, offset: at },
880
+ ]);
680
881
  return new TrackScore({
681
882
  ...score.toJSON(),
883
+ tempoBpm: ripple.tempoBpm,
884
+ time: ripple.time,
682
885
  bars: score.bars - count,
683
886
  tracks: score.tracks.map((track) =>
684
- mapAutomation(track, (points) => deletePoints(points, at, shift)),
887
+ deleteClipBars(
888
+ mapAutomation(ripple.pedals(track), (points) =>
889
+ deletePoints(points, at, shift),
890
+ ),
891
+ at,
892
+ end,
893
+ ),
685
894
  ),
686
895
  notes,
687
896
  sections,
688
897
  form: score.form.filter((entry) => names.has(foldName(entry.section))),
898
+ loop: score.loop ? shrinkRange(score.loop, atBar, count) : null,
689
899
  });
690
900
  }
691
901
 
902
+ /** A bar range after bars `atBar..atBar+count` go (null when it goes too). */
903
+ function shrinkRange(
904
+ range: Readonly<{ startBar: number; bars: number }>,
905
+ atBar: number,
906
+ count: number,
907
+ ): { startBar: number; bars: number } | null {
908
+ const until = range.startBar + range.bars;
909
+ if (until <= atBar) return range;
910
+ if (range.startBar >= atBar + count)
911
+ return { startBar: range.startBar - count, bars: range.bars };
912
+ const left = Math.max(0, atBar - range.startBar);
913
+ const right = Math.max(0, until - (atBar + count));
914
+ return left + right > 0
915
+ ? { startBar: Math.min(range.startBar, atBar), bars: left + right }
916
+ : null;
917
+ }
918
+
692
919
  /** Unused note ids `prefix1`, `prefix2`, ... */
693
920
  function idMaker(score: TrackScore, prefix: string): () => string {
694
921
  const used = new Set(score.notes.map((note) => note.id));
@@ -865,6 +1092,15 @@ export function duplicateSection(
865
1092
  if (options.as !== undefined && findSection(score, options.as))
866
1093
  throw new ScoreValidationError(`a section named ${options.as} exists`);
867
1094
  const at = options.toBar ?? section.startBar + section.bars;
1095
+ if (
1096
+ !Number.isInteger(at) ||
1097
+ at < 0 ||
1098
+ at > score.bars ||
1099
+ (at > section.startBar && at < section.startBar + section.bars)
1100
+ )
1101
+ throw new ScoreValidationError(
1102
+ `a copy of ${section.name} goes at a bar 1..${score.bars + 1} outside it`,
1103
+ );
868
1104
  const opened = insertBars(score, at, section.bars);
869
1105
  const source =
870
1106
  section.startBar >= at ? section.startBar + section.bars : section.startBar;
@@ -881,24 +1117,49 @@ export function duplicateSection(
881
1117
  );
882
1118
  }
883
1119
 
884
- /** Copy the music of bars `from..from+count` onto bars `to..` (replacing it). */
1120
+ /** Options for `copyBars`: which tracks, and whether the copy overdubs. */
1121
+ export type CopyBarsOptions = Readonly<{
1122
+ /** Only these tracks (default all). A track subset leaves tempo alone. */
1123
+ trackIds?: ReadonlySet<string>;
1124
+ /** Overdub: keep the destination's notes (default replaces them). */
1125
+ merge?: boolean;
1126
+ }>;
1127
+
1128
+ /**
1129
+ * Copy the music of bars `from..from+count` onto bars `to..` (replacing it,
1130
+ * or with `merge` overdubbing it). All tracks bring the song's tempo and
1131
+ * fermatas with them; a track subset copies only its own notes, clips,
1132
+ * pedal and automation.
1133
+ */
885
1134
  export function copyBars(
886
1135
  score: TrackScore,
887
1136
  fromBar: number,
888
1137
  count: number,
889
1138
  toBar: number,
890
1139
  idPrefix = "copy-",
1140
+ options: CopyBarsOptions = {},
891
1141
  ): TrackScore {
892
1142
  const ticks = barTicks(score);
893
1143
  const from = fromBar * ticks;
894
1144
  const length = count * ticks;
895
1145
  const to = toBar * ticks;
896
1146
  const nextId = idMaker(score, idPrefix);
1147
+ const picked = (trackId: string): boolean =>
1148
+ options.trackIds === undefined || options.trackIds.has(trackId);
897
1149
  const kept = score.notes.filter(
898
- (note) => note.startTick < to || note.startTick >= to + length,
1150
+ (note) =>
1151
+ options.merge ||
1152
+ !picked(note.trackId) ||
1153
+ note.startTick < to ||
1154
+ note.startTick >= to + length,
899
1155
  );
900
1156
  const copies = score.notes
901
- .filter((note) => note.startTick >= from && note.startTick < from + length)
1157
+ .filter(
1158
+ (note) =>
1159
+ picked(note.trackId) &&
1160
+ note.startTick >= from &&
1161
+ note.startTick < from + length,
1162
+ )
902
1163
  .map((note) => ({
903
1164
  ...note,
904
1165
  id: nextId(),
@@ -908,10 +1169,28 @@ export function copyBars(
908
1169
  Math.min(note.durationTicks, from + length - note.startTick),
909
1170
  ),
910
1171
  }));
1172
+ // The copied bars bring their tempo, fermatas and pedal; the music after
1173
+ // them goes on as it was.
1174
+ const ripple = rippleTime(score, [
1175
+ { from: 0, to, offset: 0 },
1176
+ { from, to: from + length, offset: to },
1177
+ { from: to + length, to: Infinity, offset: to + length },
1178
+ ]);
1179
+ const song = options.trackIds === undefined;
911
1180
  return new TrackScore({
912
1181
  ...score.toJSON(),
1182
+ ...(song ? { tempoBpm: ripple.tempoBpm, time: ripple.time } : {}),
913
1183
  tracks: score.tracks.map((track) =>
914
- mapAutomation(track, (points) => copyPoints(points, from, length, to)),
1184
+ picked(track.id)
1185
+ ? copyClipBars(
1186
+ mapAutomation(ripple.pedals(track), (points) =>
1187
+ copyPoints(points, from, length, to),
1188
+ ),
1189
+ from,
1190
+ length,
1191
+ to,
1192
+ )
1193
+ : track,
915
1194
  ),
916
1195
  notes: [...kept, ...copies],
917
1196
  });
@@ -1027,6 +1306,129 @@ export function resetSection(score: TrackScore, name: string): TrackScore {
1027
1306
  });
1028
1307
  }
1029
1308
 
1309
+ /**
1310
+ * Split a section at `atBar` (0-based, strictly inside it) into two: the
1311
+ * first keeps the name, the second is `<name> 2` (or `as`). Both keep the
1312
+ * mutes and variations; the form plays both halves wherever it played the
1313
+ * section, and a loop on it becomes the loop range over the same bars.
1314
+ */
1315
+ export function splitSection(
1316
+ score: TrackScore,
1317
+ name: string,
1318
+ atBar: number,
1319
+ as?: string,
1320
+ ): TrackScore {
1321
+ const section = requireSection(score, name);
1322
+ const end = section.startBar + section.bars;
1323
+ if (!Number.isInteger(atBar) || atBar <= section.startBar || atBar >= end)
1324
+ throw new ScoreValidationError(
1325
+ section.bars < 2
1326
+ ? `${section.name} is one bar; nothing to split`
1327
+ : `split ${section.name} at a bar ${section.startBar + 2}..${end}`,
1328
+ );
1329
+ if (as !== undefined && findSection(score, as))
1330
+ throw new ScoreValidationError(`a section named ${as} exists`);
1331
+ const second = uniqueSectionName(score, as ?? `${section.name} 2`);
1332
+ const form: FormEntry[] = [];
1333
+ for (const entry of score.form) {
1334
+ if (foldName(entry.section) !== foldName(section.name)) {
1335
+ form.push(entry);
1336
+ continue;
1337
+ }
1338
+ for (let pass = 0; pass < (entry.repeat ?? 1); pass += 1)
1339
+ form.push({ section: section.name }, { section: second });
1340
+ }
1341
+ if (form.length > SCORE_LIMITS.maxFormEntries)
1342
+ throw new ScoreValidationError(
1343
+ `the form would have ${form.length} entries; the limit is ${SCORE_LIMITS.maxFormEntries}`,
1344
+ "score-limit",
1345
+ );
1346
+ const looping = score.loopSection === section.name;
1347
+ const split = score.withSections(
1348
+ score.sections.flatMap((candidate) =>
1349
+ candidate === section
1350
+ ? [
1351
+ { ...section, bars: atBar - section.startBar },
1352
+ { ...section, name: second, startBar: atBar, bars: end - atBar },
1353
+ ]
1354
+ : [candidate],
1355
+ ),
1356
+ form,
1357
+ looping ? null : score.loopSection,
1358
+ );
1359
+ return looping
1360
+ ? split.withLoop({ startBar: section.startBar, bars: section.bars })
1361
+ : split;
1362
+ }
1363
+
1364
+ /**
1365
+ * Join a section with the one that starts where it ends; it keeps the
1366
+ * first one's name, mutes and variations. Refused when the two mute or vary
1367
+ * differently (joining would change the sound) unless `force`, or when
1368
+ * the form plays the second anywhere but right after the first.
1369
+ */
1370
+ export function joinSection(
1371
+ score: TrackScore,
1372
+ name: string,
1373
+ options: Readonly<{ force?: boolean }> = {},
1374
+ ): TrackScore {
1375
+ const section = requireSection(score, name);
1376
+ const end = section.startBar + section.bars;
1377
+ const next = score.sections.find((candidate) => candidate.startBar === end);
1378
+ if (!next)
1379
+ throw new ScoreValidationError(
1380
+ `no section starts at bar ${end + 1}, right after ${section.name}`,
1381
+ );
1382
+ const sameMute =
1383
+ JSON.stringify([...(section.mute ?? [])].sort()) ===
1384
+ JSON.stringify([...(next.mute ?? [])].sort());
1385
+ if (
1386
+ !options.force &&
1387
+ (!sameMute ||
1388
+ JSON.stringify(section.vary ?? {}) !== JSON.stringify(next.vary ?? {}))
1389
+ )
1390
+ throw new ScoreValidationError(
1391
+ `${section.name} and ${next.name} mute or vary differently · section join ${section.name} force`,
1392
+ );
1393
+ const form: FormEntry[] = [];
1394
+ const entries = score.form;
1395
+ for (let index = 0; index < entries.length; index += 1) {
1396
+ const entry = entries[index]!;
1397
+ const isFirst = foldName(entry.section) === foldName(section.name);
1398
+ const isSecond = foldName(entry.section) === foldName(next.name);
1399
+ const following = entries[index + 1];
1400
+ if (
1401
+ isFirst &&
1402
+ (entry.repeat ?? 1) === 1 &&
1403
+ following &&
1404
+ foldName(following.section) === foldName(next.name) &&
1405
+ (following.repeat ?? 1) === 1
1406
+ ) {
1407
+ form.push({ section: section.name });
1408
+ index += 1;
1409
+ continue;
1410
+ }
1411
+ if (isFirst || isSecond)
1412
+ throw new ScoreValidationError(
1413
+ `the form plays ${section.name} and ${next.name} apart · change the form first`,
1414
+ );
1415
+ form.push(entry);
1416
+ }
1417
+ const looping =
1418
+ score.loopSection === section.name || score.loopSection === next.name;
1419
+ return score.withSections(
1420
+ score.sections
1421
+ .filter((candidate) => candidate !== next)
1422
+ .map((candidate) =>
1423
+ candidate === section
1424
+ ? { ...section, bars: section.bars + next.bars }
1425
+ : candidate,
1426
+ ),
1427
+ form,
1428
+ looping ? section.name : score.loopSection,
1429
+ );
1430
+ }
1431
+
1030
1432
  function omit<T extends object, K extends keyof T>(
1031
1433
  value: T,
1032
1434
  key: K,
@@ -1235,17 +1637,38 @@ export function arrangedSlice(
1235
1637
  ): TrackScore {
1236
1638
  const ticks = barTicks(score);
1237
1639
  const pieces: { from: number; to: number; offset: number }[] = [];
1640
+ // Clips (0.7) are cut per section pass; a window's own edges inside a
1641
+ // pass (or where the form plays straight on) are seams, never cuts, so a
1642
+ // windowed render plays them as one pass does.
1643
+ const clipPieces: (ClipPiece & { section: Section })[] = [];
1238
1644
  const end = fromBar + bars;
1645
+ let previous: FormSegment | undefined;
1239
1646
  for (const segment of formSegments(score)) {
1240
1647
  const segEnd = segment.startBar + segment.bars;
1648
+ const before = previous;
1649
+ previous = segment;
1241
1650
  if (segEnd <= fromBar || segment.startBar >= end) continue;
1242
1651
  const start = Math.max(segment.startBar, fromBar);
1243
1652
  const stop = Math.min(segEnd, end);
1244
1653
  const from = (segment.section.startBar + start - segment.startBar) * ticks;
1245
- pieces.push({
1654
+ const piece = {
1246
1655
  from,
1247
1656
  to: from + (stop - start) * ticks,
1248
1657
  offset: (start - fromBar) * ticks,
1658
+ };
1659
+ pieces.push(piece);
1660
+ const straight =
1661
+ before !== undefined &&
1662
+ before.section.startBar + before.bars === segment.section.startBar;
1663
+ clipPieces.push({
1664
+ ...piece,
1665
+ section: segment.section,
1666
+ ...(stop < segEnd ? { to: from + (segEnd - start) * ticks } : {}),
1667
+ ...(clipPieces.length === 0 &&
1668
+ fromBar > 0 &&
1669
+ (start > segment.startBar || straight)
1670
+ ? { seam: true }
1671
+ : {}),
1249
1672
  });
1250
1673
  }
1251
1674
  const shift = fromBar * ticks;
@@ -1256,7 +1679,14 @@ export function arrangedSlice(
1256
1679
  time: timed.time ?? null,
1257
1680
  bars,
1258
1681
  tracks: score.tracks.map((track) =>
1259
- slicePedals(sliceLanes(track, pieces), pieces),
1682
+ sliceTrackClips(
1683
+ slicePedals(sliceLanes(track, pieces), pieces),
1684
+ clipPieces.map(({ section, ...piece }) => ({
1685
+ ...piece,
1686
+ ...clipPieceState(section, track.id),
1687
+ })),
1688
+ score,
1689
+ ),
1260
1690
  ),
1261
1691
  notes: arrangedNotes(score, fromBar, fromBar + noteBars).map((note) => ({
1262
1692
  ...note,
@@ -1341,7 +1771,11 @@ function laneFallback(track: Track, lane: string): number | undefined {
1341
1771
  ? track.modal && modalSettings(track.modal)
1342
1772
  : spec.effect === "wind"
1343
1773
  ? track.wind && windSettings(track.wind)
1344
- : (track.fx as Record<string, unknown> | undefined)?.[spec.effect]
1774
+ : spec.effect === "sing"
1775
+ ? track.sing && resolveSing(track.sing)
1776
+ : (track.fx as Record<string, unknown> | undefined)?.[
1777
+ spec.effect
1778
+ ]
1345
1779
  ) as Record<string, unknown> | undefined;
1346
1780
  const stored = source?.[spec.param];
1347
1781
  return typeof stored === "number" ? stored : spec.spec.default;
@@ -1364,6 +1798,8 @@ const TOM = DRUM_VOICES.find((voice) => voice.voice === "tom")!.pitch;
1364
1798
  const TOMS = [50, 47, TOM] as const;
1365
1799
  const KICK = DRUM_VOICES.find((voice) => voice.voice === "kick")!.pitch;
1366
1800
  const OPEN_HAT = DRUM_VOICES.find((voice) => voice.voice === "openhat")!.pitch;
1801
+ /** GM crash 1, which calibration 1+ renders as a cymbal. */
1802
+ const CRASH = 49;
1367
1803
 
1368
1804
  /** Generator tracks, reused by later builds and drops. */
1369
1805
  export const GENERATOR_TRACKS = Object.freeze({
@@ -1955,7 +2391,10 @@ export type FillOptions = Readonly<{
1955
2391
  /** Beats the fill lasts, default 1. */
1956
2392
  beats?: number;
1957
2393
  style?: FillStyle;
1958
- /** Crash (open hat with kick) on the next downbeat, default on. */
2394
+ /**
2395
+ * Crash with kick on the next downbeat, default on: GM 49 on a
2396
+ * calibrated song's synth kit, else an open hat.
2397
+ */
1959
2398
  crash?: boolean;
1960
2399
  }>;
1961
2400
 
@@ -2000,6 +2439,12 @@ export function generateFill(
2000
2439
  drums = GENERATOR_TRACKS.fills;
2001
2440
  }
2002
2441
  const style = options.style ?? "toms";
2442
+ // Sample kits and legacy songs have no cymbal voice: keep the open hat.
2443
+ const kit = next.tracks.find((track) => track.id === drums)?.kit;
2444
+ const crash =
2445
+ (next.calibration ?? 0) >= 1 && (kit === undefined || synthKit(kit))
2446
+ ? CRASH
2447
+ : OPEN_HAT;
2003
2448
  const clamped: string[] = [];
2004
2449
  const beats = clampNote(
2005
2450
  options.beats ?? 1,
@@ -2030,8 +2475,8 @@ export function generateFill(
2030
2475
  : SNARE
2031
2476
  : progress < 0.25
2032
2477
  ? SNARE
2033
- : // High, mid, low tom down the fill (GM 50/47/45); the
2034
- // built-in kit folds all three onto its tom.
2478
+ : // High, mid, low tom down the fill (GM 50/47/45); a
2479
+ // calibrated song pitches them, older songs fold them.
2035
2480
  TOMS[Math.min(2, Math.floor(((progress - 0.25) / 0.75) * 3))]!;
2036
2481
  fill.push({
2037
2482
  id: nextId(),
@@ -2043,7 +2488,7 @@ export function generateFill(
2043
2488
  });
2044
2489
  }
2045
2490
  if ((options.crash ?? true) && end < next.bars * ticks)
2046
- for (const pitch of [OPEN_HAT, KICK])
2491
+ for (const pitch of [crash, KICK])
2047
2492
  fill.push({
2048
2493
  id: nextId(),
2049
2494
  trackId: drums,