@hraness/dawg 0.5.0 → 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 (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. package/src/tui/play-session.ts +45 -2
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,36 @@ 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
+ applyModalCommand,
74
+ modalListLines,
75
+ parseModalCommand,
76
+ } from "./commands/modal.ts";
58
77
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
78
+ import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
79
+ import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
80
+ import {
81
+ applyGranularCommand,
82
+ granularTrackPreset,
83
+ grainSrcHint,
84
+ parseGranularCommand,
85
+ } from "./commands/granular.ts";
86
+ import {
87
+ applyKeysCommand,
88
+ newPianoTrack,
89
+ parseKeysCommand,
90
+ } from "./commands/keys.ts";
59
91
  import {
60
92
  applyTuningCommand,
61
93
  importTuningFile,
@@ -94,9 +126,12 @@ import {
94
126
  samplerTarget,
95
127
  voiceNameFrom,
96
128
  } from "./commands/sample.ts";
129
+ import { applyFitCommand, fitVoice, parseFitCommand } from "./commands/fit.ts";
130
+ import { suggestFitMode } from "./audio/dsp/onset.ts";
97
131
  import {
98
132
  SampleLibrary,
99
133
  hasSamplerTracks,
134
+ sampleKey,
100
135
  type SampleBank,
101
136
  type SampleProblem,
102
137
  } from "./audio/samples.ts";
@@ -216,6 +251,7 @@ import {
216
251
  addNote,
217
252
  applyScoreOperation,
218
253
  createScore,
254
+ isSamplerInstrument,
219
255
  PACK_PREFIX,
220
256
  scoreFromJSON,
221
257
  SCORE_LIMITS,
@@ -260,6 +296,9 @@ function parsesLocally(text: string): boolean {
260
296
  parseRhythmCommand,
261
297
  parseFxCommand,
262
298
  parseSynthCommand,
299
+ parseStringCommand,
300
+ parseGranularCommand,
301
+ parseKeysCommand,
263
302
  parseExpressionCommand,
264
303
  parseMasterCommand,
265
304
  (value: string) => parseSectionCommand(value, score),
@@ -267,9 +306,12 @@ function parsesLocally(text: string): boolean {
267
306
  parseKitCommand,
268
307
  parsePackCommand,
269
308
  parseSampleCommand,
309
+ parseFitCommand,
270
310
  parseWavetableCommand,
271
311
  parseTimeCommand,
272
312
  parseTuningCommand,
313
+ parseRigCommand,
314
+ parseModalCommand,
273
315
  ].some((parse) => parse(text) !== undefined);
274
316
  }
275
317
 
@@ -416,7 +458,13 @@ if (!demo && process.env.DAWG_AI !== "0" && stdout.isTTY)
416
458
 
417
459
  const initial = createScore({
418
460
  tracks: [
419
- { id: requestedTrack, name: requestedTrack, instrument: initialInstrument },
461
+ {
462
+ id: requestedTrack,
463
+ name: requestedTrack,
464
+ instrument: initialInstrument,
465
+ // `dawg jangle`: a guitar alias starts with its voice and rig.
466
+ ...rigTrackFields(requestedTrack),
467
+ },
420
468
  ],
421
469
  });
422
470
  const sessionOptions: { sessionId?: string; setCurrent: boolean } = {
@@ -1660,6 +1708,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1660
1708
  /^\/?(?:add\s+)?track\s+([a-z0-9._-]{1,64})$/i,
1661
1709
  );
1662
1710
  if (trackCommand) return focusTrack(trackCommand[1]!.toLowerCase());
1711
+ // `track cloud grain hold`: focus (or create) a track and grain it.
1712
+ const grainTrack = command.match(
1713
+ /^\/?track\s+([a-z0-9._-]{1,64})\s+(grain\s+.+)$/i,
1714
+ );
1715
+ if (grainTrack && parseGranularCommand(grainTrack[2]!)) {
1716
+ const focused = await focusTrack(grainTrack[1]!.toLowerCase());
1717
+ if (requestedTrack !== grainTrack[1]!.toLowerCase()) return focused;
1718
+ return submit(grainTrack[2]!);
1719
+ }
1663
1720
  // `/track piano b`: a name with spaces focuses the track of that name, or
1664
1721
  // creates `piano-b` named "piano b".
1665
1722
  const namedTrack = command.match(/^\/track\s+([a-z0-9._ -]{1,64})$/i);
@@ -1672,6 +1729,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1672
1729
  }
1673
1730
  const sample = parseSampleCommand(command);
1674
1731
  if (sample) return sampleCommand(sample);
1732
+ const fit = parseFitCommand(command);
1733
+ if (fit) return fitCommand(fit);
1675
1734
  const pack = parsePackCommand(command);
1676
1735
  if (pack) return packCommand(pack);
1677
1736
  const pattern = parsePatternCommand(command);
@@ -1706,6 +1765,15 @@ async function submit(prompt: string): Promise<string | Receipt> {
1706
1765
  await commitScore(result.next, result.kind, result.payload);
1707
1766
  return result.ok ? ok(result.message) : fail(result.message);
1708
1767
  }
1768
+ const rig = parseRigCommand(command);
1769
+ if (rig) {
1770
+ if (rig.type !== "rig-show" && rig.type !== "rig-hint")
1771
+ await materializeDraft();
1772
+ const result = applyRigCommand(score, requestedTrack, rig);
1773
+ if (result.next && result.kind)
1774
+ await commitScore(result.next, result.kind, result.payload);
1775
+ return result.ok ? ok(result.message) : fail(result.message);
1776
+ }
1709
1777
  const fx = parseFxCommand(command);
1710
1778
  if (fx) {
1711
1779
  if (fx.type !== "fx-list") await materializeDraft();
@@ -1725,6 +1793,8 @@ async function submit(prompt: string): Promise<string | Receipt> {
1725
1793
  await commitScore(result.next, result.kind, result.payload);
1726
1794
  return result.ok ? ok(result.message) : fail(result.message);
1727
1795
  }
1796
+ const unknownFx = unknownFxMessage(command);
1797
+ if (unknownFx) return fail(unknownFx);
1728
1798
  const expression = parseExpressionCommand(command);
1729
1799
  if (expression) {
1730
1800
  if (expression.type !== "show" && expression.type !== "invalid")
@@ -1736,6 +1806,27 @@ async function submit(prompt: string): Promise<string | Receipt> {
1736
1806
  }
1737
1807
  const tuning = parseTuningCommand(command);
1738
1808
  if (tuning) return tuningCommand(tuning);
1809
+ const keysCommand = parseKeysCommand(command);
1810
+ if (keysCommand) {
1811
+ const reads =
1812
+ keysCommand.type === "keys-list" || keysCommand.type === "keys-presets";
1813
+ if (!reads) await materializeDraft();
1814
+ const result = applyKeysCommand(score, requestedTrack, keysCommand);
1815
+ if (result.next && result.kind)
1816
+ await commitScore(result.next, result.kind, result.payload);
1817
+ return result.ok ? ok(result.message) : fail(result.message);
1818
+ }
1819
+ const modal = parseModalCommand(command);
1820
+ if (modal) {
1821
+ if (modal.type === "modal-list")
1822
+ tui.openText("modal presets", modalListLines());
1823
+ if (modal.type !== "modal-show" && modal.type !== "modal-list")
1824
+ await materializeDraft();
1825
+ const result = applyModalCommand(score, requestedTrack, modal);
1826
+ if (result.next && result.kind)
1827
+ await commitScore(result.next, result.kind, result.payload);
1828
+ return result.ok ? ok(result.message) : fail(result.message);
1829
+ }
1739
1830
  const synth = parseSynthCommand(command);
1740
1831
  if (synth) {
1741
1832
  if (synth.type !== "synth-list") await materializeDraft();
@@ -1744,6 +1835,29 @@ async function submit(prompt: string): Promise<string | Receipt> {
1744
1835
  await commitScore(result.next, result.kind, result.payload);
1745
1836
  return result.ok ? ok(result.message) : fail(result.message);
1746
1837
  }
1838
+ const stringCommand = parseStringCommand(command);
1839
+ if (stringCommand) {
1840
+ if (
1841
+ stringCommand.type !== "string-list" &&
1842
+ stringCommand.type !== "string-presets"
1843
+ )
1844
+ await materializeDraft();
1845
+ const result = applyStringCommand(score, requestedTrack, stringCommand);
1846
+ if (result.next && result.kind)
1847
+ await commitScore(result.next, result.kind, result.payload);
1848
+ return result.ok ? ok(result.message) : fail(result.message);
1849
+ }
1850
+ const grainHint = grainSrcHint(command);
1851
+ if (grainHint) return fail(grainHint);
1852
+ const grain = parseGranularCommand(command);
1853
+ if (grain) {
1854
+ if (grain.type !== "grain-list" && grain.type !== "grain-presets")
1855
+ await materializeDraft();
1856
+ const result = applyGranularCommand(score, requestedTrack, grain);
1857
+ if (result.next && result.kind)
1858
+ await commitScore(result.next, result.kind, result.payload);
1859
+ return result.ok ? ok(result.message) : fail(result.message);
1860
+ }
1747
1861
  const masterCommand = parseMasterCommand(command);
1748
1862
  if (masterCommand) {
1749
1863
  if (masterCommand.type === "master-measure")
@@ -1911,7 +2025,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1911
2025
  const next = score.withTempo(parsed.tempoBpm);
1912
2026
  await commitScore(next, "score.tempo", { tempoBpm: parsed.tempoBpm });
1913
2027
  clock.follow(next);
1914
- return `tempo · ${next.tempoBpm} BPM`;
2028
+ // A bare `bpm <n>` stays song tempo; on a sampler track say where the
2029
+ // sample's own tempo lives (`/bpm`, 0.6 fit).
2030
+ const focused = next.tracks.find((t) => t.id === requestedTrack);
2031
+ const samplerHint =
2032
+ /^\s*bpm\b/i.test(prompt) &&
2033
+ focused?.sampler &&
2034
+ isSamplerInstrument(focused.instrument)
2035
+ ? ` · song tempo · the sample's own tempo is /bpm ${parsed.tempoBpm}`
2036
+ : "";
2037
+ return `tempo · ${next.tempoBpm} BPM${samplerHint}`;
1915
2038
  }
1916
2039
  if (parsed.type === "add-track") return focusTrack(parsed.trackId);
1917
2040
  if (parsed.type === "set-bars" || parsed.type === "extend-bars") {
@@ -1924,15 +2047,42 @@ async function submit(prompt: string): Promise<string | Receipt> {
1924
2047
  return `bars · ${bars}`;
1925
2048
  }
1926
2049
  if (parsed.type === "track") {
2050
+ // An unknown word would store and play a plain sine: say so instead.
2051
+ const word = parsed.patch.instrument;
2052
+ if (word !== undefined && isUnknownInstrument(word)) {
2053
+ const advice = plainSineAdvice(word) ?? "";
2054
+ const near = advice.match(/did you mean (\S+)\?/)?.[1];
2055
+ return fail(
2056
+ `instrument ${truncateForCard(word)} · not a dawg instrument${near ? ` · did you mean ${near}?` : ""} · /menu sounds lists them`,
2057
+ );
2058
+ }
2059
+ // `instrument jangle`: a guitar alias also loads its rig.
2060
+ const patch = parsed.word
2061
+ ? {
2062
+ ...parsed.patch,
2063
+ ...rigWordPatch(
2064
+ parsed.word,
2065
+ score.tracks.find((t) => t.id === requestedTrack)?.fx,
2066
+ ),
2067
+ }
2068
+ : parsed.patch;
1927
2069
  const next = applyScoreOperation(score, {
1928
2070
  type: "updateTrack",
1929
2071
  trackId: requestedTrack,
1930
- patch: parsed.patch,
2072
+ patch,
1931
2073
  });
1932
2074
  await commitScore(next, "score.track", {
1933
2075
  trackId: requestedTrack,
1934
- patch: parsed.patch,
2076
+ patch,
1935
2077
  });
2078
+ // Plain `marimba` keeps the legacy tone; point at the mallet engine.
2079
+ if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
2080
+ return `track · ${requestedTrack} · marimba (legacy tone) · modal marimba for the mallet engine`;
2081
+ const sine =
2082
+ word !== undefined && !("string" in parsed.patch)
2083
+ ? plainSineAdvice(word)
2084
+ : undefined;
2085
+ if (sine) return `track · ${requestedTrack} · ${sine}`;
1936
2086
  return `track · ${requestedTrack}`;
1937
2087
  }
1938
2088
  if (parsed.type === "automation") {
@@ -2072,14 +2222,28 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2072
2222
  )
2073
2223
  )
2074
2224
  return warn(`${trackId} is open in another window`);
2075
- const exists = score.tracks.some((track) => track.id === trackId);
2225
+ const existing = score.tracks.find((track) => track.id === trackId);
2226
+ const exists = existing !== undefined;
2227
+ // `track cloud`, `track hold-2`: a new granular track with that preset.
2228
+ const grainPreset = granularTrackPreset(trackId);
2076
2229
  if (!exists) {
2077
2230
  const next = applyScoreOperation(score, {
2078
2231
  type: "addTrack",
2079
2232
  track: {
2080
2233
  id: trackId,
2081
2234
  name: trackId,
2082
- instrument: isDrumInstrument(trackId) ? "kit" : "sine",
2235
+ instrument: grainPreset
2236
+ ? "granular"
2237
+ : isDrumInstrument(trackId)
2238
+ ? "kit"
2239
+ : "sine",
2240
+ // `track jangle`, `track gtr-metal`…: a guitar voice and its rig.
2241
+ ...rigTrackFields(trackId),
2242
+ ...(grainPreset ? { granular: { preset: grainPreset } } : {}),
2243
+ // `/track piano` (or grand, felt…) starts on the modelled piano.
2244
+ ...newPianoTrack(trackId),
2245
+ // `track vibes`: a 0.6 modal word names the track and its preset.
2246
+ ...(isModalWord(trackId) ? instrumentPatchForWord(trackId) : {}),
2083
2247
  },
2084
2248
  });
2085
2249
  await commitScore(next, "track.create", { trackId });
@@ -2087,7 +2251,17 @@ async function focusTrack(trackId: string): Promise<Receipt> {
2087
2251
  await port.focus(trackId);
2088
2252
  requestedTrack = trackId;
2089
2253
  draftTrack = false;
2090
- return ok(exists ? `track · ${trackId}` : `track created · ${trackId}`);
2254
+ if (exists && grainPreset && existing.instrument !== "granular")
2255
+ return warn(
2256
+ `track · ${trackId} is a ${existing.instrument} track · grain ${grainPreset} makes it granular`,
2257
+ );
2258
+ return ok(
2259
+ exists
2260
+ ? `track · ${trackId}`
2261
+ : grainPreset
2262
+ ? `track created · ${trackId} · granular ${grainPreset} · nothing to download`
2263
+ : `track created · ${trackId}`,
2264
+ );
2091
2265
  }
2092
2266
 
2093
2267
  /** Decode every sampler voice (cached) and return what failed to load. */
@@ -2173,6 +2347,34 @@ async function tuningCommand(command: TuningCommand): Promise<Receipt> {
2173
2347
  return result.ok ? ok(result.message) : fail(result.message);
2174
2348
  }
2175
2349
 
2350
+ /** `/fitmode`, `/bpm`, `/len` on the focused sampler voice (0.6). */
2351
+ async function fitCommand(
2352
+ command: NonNullable<ReturnType<typeof parseFitCommand>>,
2353
+ ): Promise<Receipt> {
2354
+ let suggested: "beats" | "tones" | undefined;
2355
+ if (command.control === "fitmode" && command.value === undefined) {
2356
+ const target = fitVoice(score, requestedTrack, command.voice);
2357
+ if ("error" in target) return fail(target.error);
2358
+ const decoded = liveSampleBank?.voices.get(
2359
+ sampleKey(requestedTrack, target.voice),
2360
+ );
2361
+ if (!decoded)
2362
+ return warn(
2363
+ "fit · the sample is still loading · fitmode repitch|beats|tones",
2364
+ );
2365
+ suggested = suggestFitMode(decoded.mono, decoded.sampleRate);
2366
+ }
2367
+ const result = applyFitCommand(score, requestedTrack, command, suggested);
2368
+ if (!result.ok) return fail(result.message);
2369
+ await commitScore(result.next, "sample.set", { trackId: requestedTrack });
2370
+ await projectSync?.flushScore();
2371
+ return ok(
2372
+ suggested
2373
+ ? `${result.message} · suggested from the sound (${suggested === "beats" ? "hits" : "held tones"})`
2374
+ : result.message,
2375
+ );
2376
+ }
2377
+
2176
2378
  async function sampleCommand(
2177
2379
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
2178
2380
  ): Promise<Receipt> {
@@ -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
@@ -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)
@@ -528,9 +528,9 @@ export function auditionKey(value: string): AuditionKey | undefined {
528
528
  * before.
529
529
  */
530
530
  const STAGEABLE =
531
- /^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master)\s+\S|pack\s+use\s+\S)/i;
531
+ /^\/?(?:(fx|effects|filter|lowpass|synth|string|modal|wt|wavetable|grain|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid|art|articulation|bend|vibrato|glide|portamento|velcurve|vel-curve|pedal|sustain|humanize|tuning|tune|master|keys|piano)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$)/i;
532
532
  /** Subcommands that list or show instead of changing the sound. */
533
- const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter)\s*$/i;
533
+ const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i;
534
534
  /** `master <unit>` alone only shows the unit. */
535
535
  const MASTER_SHOW = /^\/?master\s+(eq|glue|tape|width|limiter)\s*$/i;
536
536
  const MASTER_COMMAND = /^\/?master\s/i;