@hraness/dawg 0.5.0 → 0.6.1

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 (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
package/src/main.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  #!/usr/bin/env bun
2
+ import {
3
+ isUnknownInstrument,
4
+ plainSineAdvice,
5
+ } from "./audio/instrument-check.ts";
2
6
  import { randomUUID } from "node:crypto";
3
7
  import { readFileSync, writeSync } from "node:fs";
4
8
  import { readFile, stat, writeFile } from "node:fs/promises";
@@ -54,8 +58,47 @@ import {
54
58
  applyExpressionCommand,
55
59
  parseExpressionCommand,
56
60
  } from "./commands/expression.ts";
57
- import { applyFxCommand, parseFxCommand } from "./commands/fx.ts";
61
+ import {
62
+ applyFxCommand,
63
+ parseFxCommand,
64
+ unknownFxMessage,
65
+ } from "./commands/fx.ts";
66
+ import {
67
+ applyRigCommand,
68
+ parseRigCommand,
69
+ rigTrackFields,
70
+ rigWordPatch,
71
+ } from "./commands/rig.ts";
72
+ import {
73
+ applyGuitarCommand,
74
+ applyStrumCommand,
75
+ parseGuitarCommand,
76
+ parseStrumCommand,
77
+ } from "./commands/strum.ts";
78
+ import {
79
+ applyModalCommand,
80
+ modalListLines,
81
+ parseModalCommand,
82
+ } from "./commands/modal.ts";
83
+ import {
84
+ applyWindCommand,
85
+ parseWindCommand,
86
+ windListLines,
87
+ } from "./commands/wind.ts";
58
88
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
89
+ import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
90
+ import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
91
+ import {
92
+ applyGranularCommand,
93
+ granularTrackPreset,
94
+ grainSrcHint,
95
+ parseGranularCommand,
96
+ } from "./commands/granular.ts";
97
+ import {
98
+ applyKeysCommand,
99
+ newPianoTrack,
100
+ parseKeysCommand,
101
+ } from "./commands/keys.ts";
59
102
  import {
60
103
  applyTuningCommand,
61
104
  importTuningFile,
@@ -94,9 +137,14 @@ import {
94
137
  samplerTarget,
95
138
  voiceNameFrom,
96
139
  } from "./commands/sample.ts";
140
+ import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
141
+ import { applyShiftCommand, parseShiftCommand } from "./commands/shift.ts";
142
+ import { parseResampleCommand, runResample } from "./commands/resample.ts";
143
+ import { suggestFitMode } from "./audio/dsp/onset.ts";
97
144
  import {
98
145
  SampleLibrary,
99
146
  hasSamplerTracks,
147
+ sampleKey,
100
148
  type SampleBank,
101
149
  type SampleProblem,
102
150
  } from "./audio/samples.ts";
@@ -216,6 +264,7 @@ import {
216
264
  addNote,
217
265
  applyScoreOperation,
218
266
  createScore,
267
+ isSamplerInstrument,
219
268
  PACK_PREFIX,
220
269
  scoreFromJSON,
221
270
  SCORE_LIMITS,
@@ -260,6 +309,9 @@ function parsesLocally(text: string): boolean {
260
309
  parseRhythmCommand,
261
310
  parseFxCommand,
262
311
  parseSynthCommand,
312
+ parseStringCommand,
313
+ parseGranularCommand,
314
+ parseKeysCommand,
263
315
  parseExpressionCommand,
264
316
  parseMasterCommand,
265
317
  (value: string) => parseSectionCommand(value, score),
@@ -267,9 +319,17 @@ function parsesLocally(text: string): boolean {
267
319
  parseKitCommand,
268
320
  parsePackCommand,
269
321
  parseSampleCommand,
322
+ parseFitCommand,
323
+ parseShiftCommand,
324
+ parseResampleCommand,
270
325
  parseWavetableCommand,
271
326
  parseTimeCommand,
272
327
  parseTuningCommand,
328
+ parseRigCommand,
329
+ parseModalCommand,
330
+ parseGuitarCommand,
331
+ parseStrumCommand,
332
+ parseWindCommand,
273
333
  ].some((parse) => parse(text) !== undefined);
274
334
  }
275
335
 
@@ -416,7 +476,13 @@ if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
416
476
 
417
477
  const initial = createScore({
418
478
  tracks: [
419
- { id: requestedTrack, name: requestedTrack, instrument: initialInstrument },
479
+ {
480
+ id: requestedTrack,
481
+ name: requestedTrack,
482
+ instrument: initialInstrument,
483
+ // `dawg jangle`: a guitar alias starts with its voice and rig.
484
+ ...rigTrackFields(requestedTrack),
485
+ },
420
486
  ],
421
487
  });
422
488
  const sessionOptions: { sessionId?: string; setCurrent: boolean } = {
@@ -439,6 +505,22 @@ const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
439
505
  () => true,
440
506
  () => false,
441
507
  ));
508
+ // Decode the import before a session exists, so a bad file is one line.
509
+ let importedScore: TrackScore | undefined;
510
+ if (importPath) {
511
+ try {
512
+ importedScore = decodeLoop(await readLoopFile(importPath));
513
+ } catch (error) {
514
+ const reason = error instanceof Error ? error.message : String(error);
515
+ const midi = /\.midi?$/i.test(importPath)
516
+ ? " · MIDI files are not imported; --import takes a .track.json loop"
517
+ : "";
518
+ process.stderr.write(
519
+ `dawg: cannot import ${importPath} · ${reason.split("\n")[0]!.slice(0, 160)}${midi}\n`,
520
+ );
521
+ process.exit(1);
522
+ }
523
+ }
442
524
  const session = await ensureSession(initial.toJSON(), sessionOptions);
443
525
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
444
526
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
@@ -451,9 +533,8 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
451
533
  let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
452
534
  port.mode === "daemon" ? await port.load() : session.record;
453
535
  let score = scoreFromJSON(record.composition);
454
- if (importPath) {
455
- const imported = decodeLoop(await readLoopFile(importPath));
456
- score = imported;
536
+ if (importPath && importedScore) {
537
+ score = importedScore;
457
538
  record = await port.append(
458
539
  record,
459
540
  {
@@ -1608,7 +1689,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
1608
1689
  }
1609
1690
  const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
1610
1691
  if (chordsCommand) {
1611
- const result = applyChordsCommand(chordSettings, chordsCommand[1] ?? "");
1692
+ const result = applyChordsCommand(
1693
+ chordSettings,
1694
+ chordsCommand[1] ?? "",
1695
+ score.tempoBpm,
1696
+ );
1612
1697
  return result.ok ? ok(result.message) : fail(result.message);
1613
1698
  }
1614
1699
  const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
@@ -1660,6 +1745,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1660
1745
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1661
1746
  );
1662
1747
  if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
1748
+ // `track cloud grain hold`: focus (or create) a track and grain it.
1749
+ const grainTrack = command.match(
1750
+ /^\/?track\s+([a-z0-9._-]{1,64})\s+(grain\s+.+)$/i,
1751
+ );
1752
+ if (grainTrack && parseGranularCommand(grainTrack[2]!)) {
1753
+ const focused = await focusTrack(grainTrack[1]!.toLowerCase());
1754
+ if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
1755
+ return submit(grainTrack[2]!);
1756
+ }
1663
1757
  // `/track piano b`: a name with spaces focuses the track of that name, or
1664
1758
  // creates `piano-b` named "piano b".
1665
1759
  const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
@@ -1672,6 +1766,18 @@ async function submit(prompt: string): Promise<string | Receipt> {
1672
1766
  }
1673
1767
  const sample = parseSampleCommand(command);
1674
1768
  if (sample) return sampleCommand(sample);
1769
+ const fit = parseFitCommand(command);
1770
+ if (fit) return fitCommand(fit);
1771
+ const shift = parseShiftCommand(command);
1772
+ if (shift) {
1773
+ const result = applyShiftCommand(score, requestedTrack, shift);
1774
+ if (!result.ok) return fail(result.message);
1775
+ await commitScore(result.next, "sample.set", { trackId: requestedTrack });
1776
+ await projectSync?.flushScore();
1777
+ return ok(result.message);
1778
+ }
1779
+ const resample = parseResampleCommand(command);
1780
+ if (resample) return resampleCommand(resample);
1675
1781
  const pack = parsePackCommand(command);
1676
1782
  if (pack) return packCommand(pack);
1677
1783
  const pattern = parsePatternCommand(command);
@@ -1706,6 +1812,34 @@ async function submit(prompt: string): Promise<string | Receipt> {
1706
1812
  await commitScore(result.next, result.kind, result.payload);
1707
1813
  return result.ok ? ok(result.message) : fail(result.message);
1708
1814
  }
1815
+ const guitar = parseGuitarCommand(command);
1816
+ if (guitar) {
1817
+ if (guitar.type !== "guitar-show" && guitar.type !== "guitar-hint")
1818
+ await materializeDraft();
1819
+ const result = applyGuitarCommand(score, requestedTrack, guitar);
1820
+ if (result.next && result.kind)
1821
+ await commitScore(result.next, result.kind, result.payload);
1822
+ return result.ok ? ok(result.message) : fail(result.message);
1823
+ }
1824
+ const strum = parseStrumCommand(command);
1825
+ if (strum) {
1826
+ if (strum.type === "strum") await materializeDraft();
1827
+ const result = applyStrumCommand(score, requestedTrack, strum, () =>
1828
+ randomUUID().slice(0, 12),
1829
+ );
1830
+ if (result.next && result.kind)
1831
+ await commitScore(result.next, result.kind, result.payload);
1832
+ return result.ok ? ok(result.message) : fail(result.message);
1833
+ }
1834
+ const rig = parseRigCommand(command);
1835
+ if (rig) {
1836
+ if (rig.type !== "rig-show" && rig.type !== "rig-hint")
1837
+ await materializeDraft();
1838
+ const result = applyRigCommand(score, requestedTrack, rig);
1839
+ if (result.next && result.kind)
1840
+ await commitScore(result.next, result.kind, result.payload);
1841
+ return result.ok ? ok(result.message) : fail(result.message);
1842
+ }
1709
1843
  const fx = parseFxCommand(command);
1710
1844
  if (fx) {
1711
1845
  if (fx.type !== "fx-list") await materializeDraft();
@@ -1725,6 +1859,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1725
1859
  await commitScore(result.next, result.kind, result.payload);
1726
1860
  return result.ok ? ok(result.message) : fail(result.message);
1727
1861
  }
1862
+ const unknownFx = unknownFxMessage(command);
1863
+ if (unknownFx) return fail(unknownFx);
1728
1864
  const expression = parseExpressionCommand(command);
1729
1865
  if (expression) {
1730
1866
  if (expression.type !== "show" && expression.type !== "invalid")
@@ -1736,6 +1872,38 @@ async function submit(prompt: string): Promise<string | Receipt> {
1736
1872
  }
1737
1873
  const tuning = parseTuningCommand(command);
1738
1874
  if (tuning) return tuningCommand(tuning);
1875
+ const keysCommand = parseKeysCommand(command);
1876
+ if (keysCommand) {
1877
+ const reads =
1878
+ keysCommand.type === "keys-list" || keysCommand.type === "keys-presets";
1879
+ if (!reads) await materializeDraft();
1880
+ const result = applyKeysCommand(score, requestedTrack, keysCommand);
1881
+ if (result.next && result.kind)
1882
+ await commitScore(result.next, result.kind, result.payload);
1883
+ return result.ok ? ok(result.message) : fail(result.message);
1884
+ }
1885
+ const modal = parseModalCommand(command);
1886
+ if (modal) {
1887
+ if (modal.type === "modal-list")
1888
+ tui.openText("modal presets", modalListLines());
1889
+ if (modal.type !== "modal-show" && modal.type !== "modal-list")
1890
+ await materializeDraft();
1891
+ const result = applyModalCommand(score, requestedTrack, modal);
1892
+ if (result.next && result.kind)
1893
+ await commitScore(result.next, result.kind, result.payload);
1894
+ return result.ok ? ok(result.message) : fail(result.message);
1895
+ }
1896
+ const windCommand = parseWindCommand(command);
1897
+ if (windCommand) {
1898
+ if (windCommand.type === "wind-list")
1899
+ tui.openText("wind presets", windListLines());
1900
+ if (windCommand.type !== "wind-show" && windCommand.type !== "wind-list")
1901
+ await materializeDraft();
1902
+ const result = applyWindCommand(score, requestedTrack, windCommand);
1903
+ if (result.next && result.kind)
1904
+ await commitScore(result.next, result.kind, result.payload);
1905
+ return result.ok ? ok(result.message) : fail(result.message);
1906
+ }
1739
1907
  const synth = parseSynthCommand(command);
1740
1908
  if (synth) {
1741
1909
  if (synth.type !== "synth-list") await materializeDraft();
@@ -1744,6 +1912,29 @@ async function submit(prompt: string): Promise<string | Receipt> {
1744
1912
  await commitScore(result.next, result.kind, result.payload);
1745
1913
  return result.ok ? ok(result.message) : fail(result.message);
1746
1914
  }
1915
+ const stringCommand = parseStringCommand(command);
1916
+ if (stringCommand) {
1917
+ if (
1918
+ stringCommand.type !== "string-list" &&
1919
+ stringCommand.type !== "string-presets"
1920
+ )
1921
+ await materializeDraft();
1922
+ const result = applyStringCommand(score, requestedTrack, stringCommand);
1923
+ if (result.next && result.kind)
1924
+ await commitScore(result.next, result.kind, result.payload);
1925
+ return result.ok ? ok(result.message) : fail(result.message);
1926
+ }
1927
+ const grainHint = grainSrcHint(command);
1928
+ if (grainHint) return fail(grainHint);
1929
+ const grain = parseGranularCommand(command);
1930
+ if (grain) {
1931
+ if (grain.type !== "grain-list" && grain.type !== "grain-presets")
1932
+ await materializeDraft();
1933
+ const result = applyGranularCommand(score, requestedTrack, grain);
1934
+ if (result.next && result.kind)
1935
+ await commitScore(result.next, result.kind, result.payload);
1936
+ return result.ok ? ok(result.message) : fail(result.message);
1937
+ }
1747
1938
  const masterCommand = parseMasterCommand(command);
1748
1939
  if (masterCommand) {
1749
1940
  if (masterCommand.type === "master-measure")
@@ -1911,7 +2102,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1911
2102
  const next = score.withTempo(parsed.tempoBpm);
1912
2103
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
1913
2104
  clock.follow(next);
1914
- return `tempo · ${next.tempoBpm} BPM`;
2105
+ // A bare `bpm <n>` stays song tempo; on a sampler track say where the
2106
+ // sample's own tempo lives (`/bpm`, 0.6 fit).
2107
+ const focused = next.tracks.find((t) => t.id === requestedTrack);
2108
+ const samplerHint =
2109
+ /^\s*bpm\b/i.test(prompt) &&
2110
+ focused?.sampler &&
2111
+ isSamplerInstrument(focused.instrument)
2112
+ ? ` · song tempo · the sample's own tempo is /bpm ${parsed.tempoBpm}`
2113
+ : "";
2114
+ return `tempo · ${next.tempoBpm} BPM${samplerHint}`;
1915
2115
  }
1916
2116
  if (parsed.type === "add-track") return focusTrack(parsed.trackId);
1917
2117
  if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
@@ -1924,15 +2124,45 @@ async function submit(prompt: string): Promise<string | Receipt> {
1924
2124
  return `bars · ${bars}`;
1925
2125
  }
1926
2126
  if (parsed.type === "track") {
2127
+ // An unknown word would store and play a plain sine: say so instead.
2128
+ const word = parsed.patch.instrument;
2129
+ if (word !== undefined && isUnknownInstrument(word)) {
2130
+ const advice = plainSineAdvice(word) ?? "";
2131
+ const near = advice.match(/did you mean (\S+)\?/)?.[1];
2132
+ return fail(
2133
+ `instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
2134
+ );
2135
+ }
2136
+ // `instrument jangle`: a guitar alias also loads its rig.
2137
+ const patch = parsed.word
2138
+ ? {
2139
+ ...parsed.patch,
2140
+ ...rigWordPatch(
2141
+ parsed.word,
2142
+ score.tracks.find((t) => t.id === requestedTrack)?.fx,
2143
+ ),
2144
+ }
2145
+ : parsed.patch;
1927
2146
  const next = applyScoreOperation(score, {
1928
2147
  type: "updateTrack",
1929
2148
  trackId: requestedTrack,
1930
- patch: parsed.patch,
2149
+ patch,
1931
2150
  });
1932
2151
  await commitScore(next, "score.track", {
1933
2152
  trackId: requestedTrack,
1934
- patch: parsed.patch,
2153
+ patch,
1935
2154
  });
2155
+ // Plain `marimba` keeps the legacy tone; point at the mallet engine.
2156
+ if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
2157
+ return `track · ${requestedTrack} · marimba (legacy tone) · modal marimba for the mallet engine`;
2158
+ // Plain `wind` keeps the legacy tone; point at the wind engine.
2159
+ if (parsed.patch.instrument === "wind" && !("wind" in parsed.patch))
2160
+ return `track · ${requestedTrack} · wind (legacy tone) · wind flute (or sax, trumpet …) for the wind engine`;
2161
+ const sine =
2162
+ word !== undefined && !("string" in parsed.patch)
2163
+ ? plainSineAdvice(word)
2164
+ : undefined;
2165
+ if (sine) return `track · ${requestedTrack} · ${sine}`;
1936
2166
  return `track · ${requestedTrack}`;
1937
2167
  }
1938
2168
  if (parsed.type === "automation") {
@@ -2072,14 +2302,28 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2072
2302
  )
2073
2303
  )
2074
2304
  return warn(`${trackId} is open in another window`);
2075
- const exists = score.tracks.some((track) => track.id === trackId);
2305
+ const existing = score.tracks.find((track) => track.id === trackId);
2306
+ const exists = existing !== undefined;
2307
+ // `track cloud`, `track hold-2`: a new granular track with that preset.
2308
+ const grainPreset = granularTrackPreset(trackId);
2076
2309
  if (!exists) {
2077
2310
  const next = applyScoreOperation(score, {
2078
2311
  type: "addTrack",
2079
2312
  track: {
2080
2313
  id: trackId,
2081
2314
  name: trackId,
2082
- instrument: isDrumInstrument(trackId) ? "kit" : "sine",
2315
+ instrument: grainPreset
2316
+ ? "granular"
2317
+ : isDrumInstrument(trackId)
2318
+ ? "kit"
2319
+ : "sine",
2320
+ // `track jangle`, `track gtr-metal`…: a guitar voice and its rig.
2321
+ ...rigTrackFields(trackId),
2322
+ ...(grainPreset ? { granular: { preset: grainPreset } } : {}),
2323
+ // `/track piano` (or grand, felt…) starts on the modelled piano.
2324
+ ...newPianoTrack(trackId),
2325
+ // `track vibes`: a 0.6 modal word names the track and its preset.
2326
+ ...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
2083
2327
  },
2084
2328
  });
2085
2329
  await commitScore(next, "track.create", { trackId });
@@ -2087,7 +2331,17 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2087
2331
  await port.focus(trackId);
2088
2332
  requestedTrack = trackId;
2089
2333
  draftTrack = false;
2090
- return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
2334
+ if (exists && grainPreset && existing.instrument !== "granular")
2335
+ return warn(
2336
+ `track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
2337
+ );
2338
+ return ok(
2339
+ exists
2340
+ ? `track · ${trackId}`
2341
+ : grainPreset
2342
+ ? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
2343
+ : `track created · ${trackId}`,
2344
+ );
2091
2345
  }
2092
2346
 
2093
2347
  /** Decode every sampler voice (cached) and return what failed to load. */
@@ -2173,6 +2427,64 @@ async function tuningCommand(command: TuningCommand): Promise<Receipt> {
2173
2427
  return result.ok ? ok(result.message) : fail(result.message);
2174
2428
  }
2175
2429
 
2430
+ /** `/fitmode`, `/bpm`, `/len` on the focused sampler voice (0.6). */
2431
+ async function fitCommand(
2432
+ command: NonNullable<ReturnType<typeof parseFitCommand>>,
2433
+ ): Promise<Receipt> {
2434
+ let suggested: "beats" | "tones" | undefined;
2435
+ if (command.control === "fitmode" && command.value === undefined) {
2436
+ const target = fitVoice(score, requestedTrack, command.voice);
2437
+ if ("error" in target) return fail(target.error);
2438
+ const decoded = liveSampleBank?.voices.get(
2439
+ sampleKey(requestedTrack, target.voice),
2440
+ );
2441
+ if (!decoded)
2442
+ return warn(
2443
+ "fit · the sample is still loading · fitmode repitch|beats|tones",
2444
+ );
2445
+ suggested = suggestFitMode(decoded.mono, decoded.sampleRate);
2446
+ }
2447
+ const result = applyFitCommand(score, requestedTrack, command, suggested);
2448
+ if (!result.ok) return fail(result.message);
2449
+ await commitScore(result.next, "sample.set", { trackId: requestedTrack });
2450
+ await projectSync?.flushScore();
2451
+ return ok(
2452
+ suggested
2453
+ ? `${result.message} · suggested from the sound (${suggested === "beats" ? "hits" : "held tones"})`
2454
+ : result.message,
2455
+ );
2456
+ }
2457
+
2458
+ async function resampleCommand(
2459
+ command: NonNullable<ReturnType<typeof parseResampleCommand>>,
2460
+ ): Promise<Receipt> {
2461
+ await materializeDraft();
2462
+ // The source's own samples must be loaded for the render.
2463
+ if (hasSamplerTracks(score)) await sampleProblems(score);
2464
+ const result = await runResample({
2465
+ projectRoot: process.cwd(),
2466
+ score,
2467
+ command,
2468
+ ...(liveSampleBank ? { samples: liveSampleBank } : {}),
2469
+ });
2470
+ if (!result.ok) return fail(result.message);
2471
+ const problems = (await sampleProblems(result.next)).filter(
2472
+ (problem) =>
2473
+ problem.trackId === result.trackId && problem.level === "error",
2474
+ );
2475
+ if (problems.length > 0) return fail(`resample · ${problems[0]!.message}`);
2476
+ await commitScore(result.next, "resample", {
2477
+ trackId: result.trackId,
2478
+ src: result.src,
2479
+ sha256: result.sha256,
2480
+ });
2481
+ await port.focus(result.trackId);
2482
+ requestedTrack = result.trackId;
2483
+ draftTrack = false;
2484
+ await projectSync?.flushScore();
2485
+ return ok(result.message);
2486
+ }
2487
+
2176
2488
  async function sampleCommand(
2177
2489
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
2178
2490
  ): Promise<Receipt> {
@@ -2965,7 +3277,11 @@ function stagedChordSettings(): ChordSettings | undefined {
2965
3277
  if (chords.length === 0) return undefined;
2966
3278
  const settings = { ...chordSettings };
2967
3279
  for (const command of chords)
2968
- applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
3280
+ applyChordsCommand(
3281
+ settings,
3282
+ command.match(CHORDS_COMMAND)![1]!,
3283
+ score.tempoBpm,
3284
+ );
2969
3285
  return settings;
2970
3286
  }
2971
3287
 
@@ -3022,7 +3338,11 @@ async function applyStaged(
3022
3338
  const chords = command.trim().match(CHORDS_COMMAND);
3023
3339
  if (chords) {
3024
3340
  // Window state: checked on a copy; kept settings apply on Enter.
3025
- const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
3341
+ const result = applyChordsCommand(
3342
+ { ...chordSettings },
3343
+ chords[1]!,
3344
+ base.tempoBpm,
3345
+ );
3026
3346
  return { next: base, ok: result.ok, message: result.message };
3027
3347
  }
3028
3348
  await prefetchStaged(base, command);
@@ -3215,7 +3535,11 @@ async function keepStaged(): Promise<Receipt> {
3215
3535
  CHORDS_COMMAND.test(command),
3216
3536
  );
3217
3537
  for (const command of chordCommands)
3218
- applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
3538
+ applyChordsCommand(
3539
+ chordSettings,
3540
+ command.match(CHORDS_COMMAND)![1]!,
3541
+ score.tempoBpm,
3542
+ );
3219
3543
  const scoreCommands = taken.commands.filter(
3220
3544
  (command) => !CHORDS_COMMAND.test(command),
3221
3545
  );
@@ -8,6 +8,11 @@ import {
8
8
  formatDiagnostic,
9
9
  type Diagnostic,
10
10
  } from "../../core/sdk/eval.ts";
11
+ import {
12
+ legacyResonatorWarnings,
13
+ modalPairWarnings,
14
+ } from "../../core/resonators.ts";
15
+ import { plainSineWarnings } from "../audio/instrument-check.ts";
11
16
  import { isProject } from "./init.ts";
12
17
  import { typecheckProject } from "./typecheck.ts";
13
18
 
@@ -23,6 +28,8 @@ export type CheckReport = Readonly<{
23
28
  evalMs: number;
24
29
  tracks: number;
25
30
  notes: number;
31
+ /** Advice that does not fail the check (0.6: legacy instrument words). */
32
+ warnings: readonly string[];
26
33
  }>;
27
34
 
28
35
  /** Runs both checks on `project`; never throws for project problems. */
@@ -45,6 +52,15 @@ export async function checkProject(
45
52
  evalMs: evaluated.ms,
46
53
  tracks: evaluated.ok ? evaluated.score.tracks.length : 0,
47
54
  notes: evaluated.ok ? evaluated.score.notes.length : 0,
55
+ warnings: Object.freeze(
56
+ evaluated.ok
57
+ ? [
58
+ ...legacyResonatorWarnings(evaluated.score.tracks),
59
+ ...modalPairWarnings(evaluated.score.tracks),
60
+ ...plainSineWarnings(evaluated.score.tracks),
61
+ ]
62
+ : [],
63
+ ),
48
64
  });
49
65
  }
50
66
 
@@ -65,6 +81,7 @@ export async function runCheckCommand(
65
81
  const report = await checkProject(project, deps);
66
82
  for (const diagnostic of report.diagnostics)
67
83
  stderr.write(`${formatDiagnostic(diagnostic)}\n`);
84
+ for (const warning of report.warnings) stderr.write(`warning: ${warning}\n`);
68
85
  if (report.ok) {
69
86
  stdout.write(
70
87
  `ok · ${report.tracks} track${report.tracks === 1 ? "" : "s"}, ${report.notes} note${
package/src/render.ts CHANGED
@@ -8,7 +8,12 @@
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
18
  import { scoreToMidi } from "../core/midi.ts";
14
19
  import { RENDER_CHANNELS, encodeWav, withWavCues } from "./audio/wav.ts";
@@ -19,7 +24,12 @@ import {
19
24
  measurementLine,
20
25
  parseMasterCommand,
21
26
  } from "./commands/master.ts";
22
- import { bakeTrackTime, findSection, sectionScore } from "../core/sections.ts";
27
+ import {
28
+ BAKED_NOTE_CAP,
29
+ bakeTrackTime,
30
+ findSection,
31
+ sectionScore,
32
+ } from "../core/sections.ts";
23
33
  import {
24
34
  exportScore,
25
35
  renderArrangedPcm,
@@ -109,7 +119,9 @@ export async function runRenderCommand(
109
119
  return 1;
110
120
  }
111
121
  // One section as it plays when looped: its mutes and variations, no form.
112
- score = sectionScore(bakeTrackTime(score), section);
122
+ score = withNoteCap(BAKED_NOTE_CAP, () =>
123
+ sectionScore(bakeTrackTime(score), section),
124
+ );
113
125
  }
114
126
  if (/\.midi?$/i.test(target)) {
115
127
  // MIDI carries notes, not audio: the master and loudness flags do not apply.
@@ -156,6 +168,16 @@ export async function runRenderCommand(
156
168
  samples = await new SampleLibrary({ projectRoot: workspace }).load(score);
157
169
  for (const problem of samples.problems)
158
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
+ }
159
181
  }
160
182
  // A song with a master is a deliverable: 48 kHz unless --rate says
161
183
  // otherwise; a plain song keeps the engine's rate, as dawg 0.4 wrote it.
@@ -172,7 +194,14 @@ export async function runRenderCommand(
172
194
  );
173
195
  return 2;
174
196
  }
175
- const audio = renderArrangedPcm(score, { samples, sampleRate });
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
+ }
176
205
  let wav = encodeWav(audio.pcm, audio.sampleRate, RENDER_CHANNELS);
177
206
  // Section starts as cue points (a --section render is one section).
178
207
  if (only === undefined)
@@ -200,6 +229,15 @@ export async function runRenderCommand(
200
229
  `rendered · ${target} · ${seconds.toFixed(2)} s · ${wav.byteLength} bytes · ${sha}\n`,
201
230
  );
202
231
  if (credits) stdout.write(`credits · ${credits}\n`);
232
+ const clipped = clippedSamples(audio.pcm);
233
+ if (clipped > 0)
234
+ stderr.write(
235
+ `warning · ${clipped} ${clipped === 1 ? "sample clips" : "samples clip"} at full scale · ${
236
+ audio.master
237
+ ? "add master limiter or lower the master gain"
238
+ : "lower track volumes or add a master with a limiter (master streaming, or master: { limiter: {} } in song.ts)"
239
+ }\n`,
240
+ );
203
241
  if (options.has("--measure"))
204
242
  stdout.write(
205
243
  `loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
@@ -209,6 +247,13 @@ export async function runRenderCommand(
209
247
  return 0;
210
248
  }
211
249
 
250
+ /** Samples at or past 16-bit full scale: the mix bus hit the rails. */
251
+ export function clippedSamples(pcm: Int16Array): number {
252
+ let count = 0;
253
+ for (const value of pcm) if (value >= 32_767 || value <= -32_768) count += 1;
254
+ return count;
255
+ }
256
+
212
257
  async function loadScore(
213
258
  workspace: string,
214
259
  options: ReadonlyMap<string, string>,