@hraness/dawg 0.6.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 (93) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/DAWG.md +338 -51
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +5 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +258 -6
  8. package/core/granular.ts +91 -0
  9. package/core/instruments.ts +161 -0
  10. package/core/keys.ts +573 -4
  11. package/core/resonators.ts +291 -3
  12. package/core/score.ts +349 -3
  13. package/core/sdk/print.ts +78 -3
  14. package/core/sdk/v1.ts +1320 -36
  15. package/core/sections.ts +12 -4
  16. package/core/strings.ts +237 -2
  17. package/core/winds.ts +652 -0
  18. package/guides/automation.md +1 -0
  19. package/guides/chords.md +3 -1
  20. package/guides/effects.md +3 -2
  21. package/guides/media.md +1 -1
  22. package/guides/performance.md +3 -0
  23. package/guides/resample.md +26 -0
  24. package/guides/sounds.md +7 -6
  25. package/package.json +1 -1
  26. package/src/agent/agent.ts +8 -2
  27. package/src/agent/chord-tools.ts +153 -1
  28. package/src/agent/expression-tools.ts +91 -0
  29. package/src/agent/ops.ts +4 -1
  30. package/src/agent/preview-tool.ts +2 -1
  31. package/src/agent/resample-tool.ts +131 -0
  32. package/src/agent/tools.ts +200 -15
  33. package/src/agent/xcb-agent.ts +8 -2
  34. package/src/audio/arrange.ts +21 -0
  35. package/src/audio/dsp/envelope.ts +161 -0
  36. package/src/audio/dsp/shift.ts +256 -0
  37. package/src/audio/effects/chain.ts +18 -3
  38. package/src/audio/effects/common.ts +14 -0
  39. package/src/audio/effects/convolution.ts +106 -1
  40. package/src/audio/effects/gaze.ts +354 -0
  41. package/src/audio/engine.ts +46 -5
  42. package/src/audio/fit.ts +45 -0
  43. package/src/audio/granular.ts +352 -21
  44. package/src/audio/instrument-check.ts +9 -5
  45. package/src/audio/instruments.ts +32 -3
  46. package/src/audio/keys/electric.ts +420 -0
  47. package/src/audio/keys/engine.ts +143 -19
  48. package/src/audio/keys/organ.ts +1335 -0
  49. package/src/audio/keys/piano.ts +50 -6
  50. package/src/audio/keys/sympathetic.ts +127 -0
  51. package/src/audio/live-worker.ts +3 -1
  52. package/src/audio/live.ts +136 -9
  53. package/src/audio/preview.ts +15 -1
  54. package/src/audio/resample.ts +285 -0
  55. package/src/audio/resonators.ts +10 -2
  56. package/src/audio/sampler.ts +168 -21
  57. package/src/audio/samples.ts +26 -1
  58. package/src/audio/strings/body.ts +29 -16
  59. package/src/audio/strings/bow.ts +656 -0
  60. package/src/audio/strings/engine.ts +266 -20
  61. package/src/audio/strings/measure.test-helpers.ts +2 -0
  62. package/src/audio/wav.ts +119 -19
  63. package/src/audio/winds/engine.ts +302 -0
  64. package/src/audio/winds/filters.ts +153 -0
  65. package/src/audio/winds/pitch.ts +104 -0
  66. package/src/audio/winds/trim.ts +80 -0
  67. package/src/audio/winds/trims.ts +917 -0
  68. package/src/audio/winds/voice.ts +479 -0
  69. package/src/commands/expression.ts +103 -22
  70. package/src/commands/fx.ts +4 -1
  71. package/src/commands/granular.ts +7 -2
  72. package/src/commands/help.ts +91 -8
  73. package/src/commands/keys.ts +287 -11
  74. package/src/commands/modal.ts +67 -4
  75. package/src/commands/resample.ts +281 -0
  76. package/src/commands/rig.ts +5 -1
  77. package/src/commands/sample.ts +49 -2
  78. package/src/commands/shift.ts +119 -0
  79. package/src/commands/string.ts +28 -2
  80. package/src/commands/strum.ts +473 -0
  81. package/src/commands/wind.ts +244 -0
  82. package/src/main.ts +129 -7
  83. package/src/project/check.ts +5 -1
  84. package/src/render.ts +16 -0
  85. package/src/session/presence.ts +16 -3
  86. package/src/tui/audition.ts +1 -1
  87. package/src/tui/menu.ts +637 -35
  88. package/src/tui/modal-menu.ts +34 -7
  89. package/src/tui/performance-menu.ts +42 -0
  90. package/src/tui/play-chords.ts +104 -3
  91. package/src/tui/play-mode.ts +1 -0
  92. package/src/tui/play-session.ts +34 -9
  93. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The `wind` prompt command (0.6.1): blown waveguides on the focused track
3
+ * (core/winds.ts). The menu and the `set_wind` agent tool build the same
4
+ * edits.
5
+ *
6
+ * wind show the track's preset and overrides
7
+ * wind <preset> play a preset: flute recorder whistle
8
+ * ney shakuhachi panpipe suling bansuri
9
+ * clarinet bassclarinet oboe bassoon sax
10
+ * altosax barisax trumpet harmon plunger
11
+ * trombone tuba horn (and aliases such
12
+ * as saxophone frenchhorn panflute)
13
+ * wind <preset> <param> <value>… a preset with overrides
14
+ * wind preset <name> the same, spelled out
15
+ * wind mute <open|straight|cup|harmon|plunger>
16
+ * wind <param> <value> [<param> <value>…] breath 0.8, players 4 …
17
+ * wind <param> off back to the preset's value
18
+ * wind reset clear overrides (keep the preset)
19
+ * wind off back to the legacy wind tone
20
+ * wind list | presets wind the presets with one-line characters
21
+ *
22
+ * Any edit turns the track into a wind-engine track (`instrument "wind"`
23
+ * plus a `wind` field); a stored `wind` with no field keeps the legacy tone.
24
+ * One `updateTrack` revision and one undo step per command.
25
+ */
26
+ import { FxValidationError } from "../../core/params.ts";
27
+ import {
28
+ DEFAULT_WIND_PRESET,
29
+ normalizeWind,
30
+ WIND_INSTRUMENT,
31
+ WIND_PARAMS,
32
+ WIND_PRESETS,
33
+ WIND_PRESET_NAMES,
34
+ WIND_SIMPLE,
35
+ windParamName,
36
+ windPresetFor,
37
+ type TrackWind,
38
+ } from "../../core/resonators.ts";
39
+ import {
40
+ ScoreValidationError,
41
+ updateTrack,
42
+ type TrackScore,
43
+ } from "../../core/score.ts";
44
+ import { nearestWord } from "../audio/instrument-check.ts";
45
+ import { parseParamValue } from "./fx.ts";
46
+
47
+ export type WindCommand =
48
+ | { type: "wind-show" }
49
+ | { type: "wind-list" }
50
+ | { type: "wind-reset" }
51
+ | { type: "wind-off" }
52
+ | { type: "wind-preset"; preset: string }
53
+ | { type: "wind-usage"; message: string }
54
+ | {
55
+ type: "wind-set";
56
+ /** A preset applied before the values (`wind trumpet mute harmon`). */
57
+ preset?: string;
58
+ /** `null` returns a parameter to the preset's value. */
59
+ values: Readonly<Record<string, number | string | boolean | null>>;
60
+ };
61
+
62
+ export const WIND_USAGE =
63
+ "wind <preset> | wind <param> <value> | wind mute <name> | wind reset | wind off | wind presets";
64
+
65
+ function rangeOf(name: string): string {
66
+ const spec = WIND_PARAMS[name]!;
67
+ if (spec.kind === "number") return `${name} is ${spec.min}..${spec.max}`;
68
+ if (spec.kind === "enum") return `${name} is one of ${spec.values.join(" ")}`;
69
+ return `${name} is on or off`;
70
+ }
71
+
72
+ export function parseWindCommand(prompt: string): WindCommand | undefined {
73
+ const words = prompt.trim().toLowerCase().split(/\s+/);
74
+ if (words[0] === "presets" && words[1] === "wind" && words.length === 2)
75
+ return { type: "wind-list" };
76
+ if (words[0] !== "wind") return undefined;
77
+ if (words.length === 1) return { type: "wind-show" };
78
+ if (prompt.length > 512) return undefined;
79
+ const rest = words.slice(1);
80
+ if (rest.length === 1 && rest[0] === "reset") return { type: "wind-reset" };
81
+ if (rest.length === 1 && rest[0] === "off") return { type: "wind-off" };
82
+ if (rest.length === 1 && (rest[0] === "list" || rest[0] === "presets"))
83
+ return { type: "wind-list" };
84
+ if (rest[0] === "preset") {
85
+ const preset = rest.length === 2 ? windPresetFor(rest[1]!) : undefined;
86
+ return preset
87
+ ? { type: "wind-preset", preset }
88
+ : {
89
+ type: "wind-usage",
90
+ message: `wind preset is one of ${WIND_PRESET_NAMES.join(" ")}`,
91
+ };
92
+ }
93
+ if (rest.length === 1) {
94
+ const preset = windPresetFor(rest[0]!);
95
+ if (preset) return { type: "wind-preset", preset };
96
+ const mute = WIND_PARAMS.mute;
97
+ if (mute?.kind === "enum" && mute.values.includes(rest[0]!))
98
+ return { type: "wind-set", values: { mute: rest[0]! } };
99
+ return { type: "wind-usage", message: WIND_USAGE };
100
+ }
101
+ // `wind <preset> <param> <value>…`: the preset, then its overrides.
102
+ const lead = rest.length % 2 === 1 ? windPresetFor(rest[0]!) : undefined;
103
+ if (lead) rest.shift();
104
+ if (rest.length % 2 !== 0) return { type: "wind-usage", message: WIND_USAGE };
105
+ const values: Record<string, number | string | boolean | null> = {};
106
+ for (let index = 0; index < rest.length; index += 2) {
107
+ const name = windParamName(rest[index]!);
108
+ if (!name) {
109
+ const word = rest[index]!.slice(0, 24);
110
+ const near = nearestWord(word, Object.keys(WIND_PARAMS));
111
+ return {
112
+ type: "wind-usage",
113
+ message: `wind has no parameter ${word}${near ? ` · did you mean ${near}?` : ""} · ${WIND_SIMPLE.join(" ")} …`,
114
+ };
115
+ }
116
+ const word = rest[index + 1]!;
117
+ if (word === "off" && name !== "stopped") {
118
+ values[name] = null;
119
+ continue;
120
+ }
121
+ if (word === "unset") {
122
+ values[name] = null;
123
+ continue;
124
+ }
125
+ const value = parseParamValue(WIND_PARAMS[name]!, word);
126
+ if (value === undefined)
127
+ return { type: "wind-usage", message: rangeOf(name) };
128
+ values[name] = value;
129
+ }
130
+ return lead
131
+ ? { type: "wind-set", preset: lead, values }
132
+ : { type: "wind-set", values };
133
+ }
134
+
135
+ /** `preset sax · breath 0.8 · players 4`, overrides in canonical order. */
136
+ export function describeWind(wind: TrackWind | undefined): string {
137
+ if (!wind) return "not a wind-engine track";
138
+ const preset = wind.preset ?? DEFAULT_WIND_PRESET;
139
+ const overrides = Object.entries(wind)
140
+ .filter(([key]) => key !== "preset")
141
+ .map(([key, value]) => `${key} ${value}`);
142
+ return [`preset ${preset}`, ...overrides].join(" · ");
143
+ }
144
+
145
+ /** One line per preset, for `wind list` and the agent brief. */
146
+ export function windListLines(): string[] {
147
+ return WIND_PRESET_NAMES.map(
148
+ (name) => `${name.padEnd(13)} ${WIND_PRESETS[name].doc}`,
149
+ );
150
+ }
151
+
152
+ export type WindResult = Readonly<{
153
+ ok: boolean;
154
+ message: string;
155
+ next?: TrackScore;
156
+ kind?: string;
157
+ payload?: Record<string, unknown>;
158
+ }>;
159
+
160
+ /**
161
+ * The wind field a command leaves on a track (pure; the agent tool shares
162
+ * it). A preset switch keeps the overrides; `reset` keeps the preset.
163
+ */
164
+ export function nextWind(
165
+ current: TrackWind | undefined,
166
+ command: Extract<
167
+ WindCommand,
168
+ { type: "wind-reset" | "wind-preset" | "wind-set" }
169
+ >,
170
+ ): TrackWind {
171
+ if (command.type === "wind-reset")
172
+ return normalizeWind(current?.preset ? { preset: current.preset } : {})!;
173
+ const base: Record<string, unknown> = { ...current };
174
+ if (command.type === "wind-preset") base.preset = command.preset;
175
+ else {
176
+ if (command.preset) base.preset = command.preset;
177
+ for (const [key, value] of Object.entries(command.values)) {
178
+ if (value === null) delete base[key];
179
+ else base[key] = value;
180
+ }
181
+ }
182
+ return normalizeWind(base)!;
183
+ }
184
+
185
+ export function applyWindCommand(
186
+ score: TrackScore,
187
+ trackId: string,
188
+ command: WindCommand,
189
+ ): WindResult {
190
+ if (command.type === "wind-usage")
191
+ return { ok: false, message: `wind · ${command.message}` };
192
+ if (command.type === "wind-list")
193
+ return {
194
+ ok: true,
195
+ message: `wind presets · ${WIND_PRESET_NAMES.join(" ")}`,
196
+ };
197
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
198
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
199
+ const current = track.instrument === WIND_INSTRUMENT ? track.wind : undefined;
200
+ if (command.type === "wind-show")
201
+ return {
202
+ ok: true,
203
+ message: current
204
+ ? `wind · ${describeWind(current)}`
205
+ : `wind · ${trackId} plays ${track.instrument} · wind <preset> to switch (${WIND_PRESET_NAMES.join(" ")})`,
206
+ };
207
+ if (track.sampler || track.instrument === "kit")
208
+ return {
209
+ ok: false,
210
+ message: `wind · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track`,
211
+ };
212
+ if (command.type === "wind-off") {
213
+ if (!current) return { ok: true, message: "wind · already off" };
214
+ // The stored word stays `wind`, which is the legacy tone without a field.
215
+ const next = updateTrack(score, trackId, { wind: null });
216
+ return {
217
+ ok: true,
218
+ message: `wind · off (legacy wind tone) · wind ${current.preset ?? DEFAULT_WIND_PRESET} turns it back on`,
219
+ next,
220
+ kind: "score.wind",
221
+ payload: { trackId, instrument: WIND_INSTRUMENT, wind: null },
222
+ };
223
+ }
224
+ let wind: TrackWind;
225
+ let next: TrackScore;
226
+ try {
227
+ wind = nextWind(current, command);
228
+ next = updateTrack(score, trackId, { instrument: WIND_INSTRUMENT, wind });
229
+ } catch (error) {
230
+ if (
231
+ error instanceof ScoreValidationError ||
232
+ error instanceof FxValidationError
233
+ )
234
+ return { ok: false, message: `wind · ${error.message}` };
235
+ throw error;
236
+ }
237
+ return {
238
+ ok: true,
239
+ message: `wind · ${describeWind(wind)}`,
240
+ next,
241
+ kind: "score.wind",
242
+ payload: { trackId, instrument: WIND_INSTRUMENT, wind },
243
+ };
244
+ }
package/src/main.ts CHANGED
@@ -69,11 +69,22 @@ import {
69
69
  rigTrackFields,
70
70
  rigWordPatch,
71
71
  } from "./commands/rig.ts";
72
+ import {
73
+ applyGuitarCommand,
74
+ applyStrumCommand,
75
+ parseGuitarCommand,
76
+ parseStrumCommand,
77
+ } from "./commands/strum.ts";
72
78
  import {
73
79
  applyModalCommand,
74
80
  modalListLines,
75
81
  parseModalCommand,
76
82
  } from "./commands/modal.ts";
83
+ import {
84
+ applyWindCommand,
85
+ parseWindCommand,
86
+ windListLines,
87
+ } from "./commands/wind.ts";
77
88
  import { applySynthCommand, parseSynthCommand } from "./commands/synth.ts";
78
89
  import { applyStringCommand, parseStringCommand } from "./commands/string.ts";
79
90
  import { instrumentPatchForWord, isModalWord } from "../core/resonators.ts";
@@ -127,6 +138,8 @@ import {
127
138
  voiceNameFrom,
128
139
  } from "./commands/sample.ts";
129
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";
130
143
  import { suggestFitMode } from "./audio/dsp/onset.ts";
131
144
  import {
132
145
  SampleLibrary,
@@ -307,11 +320,16 @@ function parsesLocally(text: string): boolean {
307
320
  parsePackCommand,
308
321
  parseSampleCommand,
309
322
  parseFitCommand,
323
+ parseShiftCommand,
324
+ parseResampleCommand,
310
325
  parseWavetableCommand,
311
326
  parseTimeCommand,
312
327
  parseTuningCommand,
313
328
  parseRigCommand,
314
329
  parseModalCommand,
330
+ parseGuitarCommand,
331
+ parseStrumCommand,
332
+ parseWindCommand,
315
333
  ].some((parse) => parse(text) !== undefined);
316
334
  }
317
335
 
@@ -487,6 +505,22 @@ const freshWorkspace = !(await stat(join(process.cwd(), ".dawg")).then(
487
505
  () => true,
488
506
  () => false,
489
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
+ }
490
524
  const session = await ensureSession(initial.toJSON(), sessionOptions);
491
525
  // dawgd when connected, the file-lock path otherwise (see src/session/port.ts).
492
526
  let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
@@ -499,9 +533,8 @@ let port = await openSessionPort<ReturnType<TrackScore["toJSON"]>>({
499
533
  let record: SessionRecord<ReturnType<TrackScore["toJSON"]>> =
500
534
  port.mode === "daemon" ? await port.load() : session.record;
501
535
  let score = scoreFromJSON(record.composition);
502
- if (importPath) {
503
- const imported = decodeLoop(await readLoopFile(importPath));
504
- score = imported;
536
+ if (importPath && importedScore) {
537
+ score = importedScore;
505
538
  record = await port.append(
506
539
  record,
507
540
  {
@@ -1656,7 +1689,11 @@ async function submit(prompt: string): Promise<string | Receipt> {
1656
1689
  }
1657
1690
  const chordsCommand = command.match(/^\/chords(?:\s+(.+))?$/i);
1658
1691
  if (chordsCommand) {
1659
- const result = applyChordsCommand(chordSettings, chordsCommand[1] ?? "");
1692
+ const result = applyChordsCommand(
1693
+ chordSettings,
1694
+ chordsCommand[1] ?? "",
1695
+ score.tempoBpm,
1696
+ );
1660
1697
  return result.ok ? ok(result.message) : fail(result.message);
1661
1698
  }
1662
1699
  const countIn = command.match(/^\/count-?in\s+([0-2])$/i);
@@ -1731,6 +1768,16 @@ async function submit(prompt: string): Promise<string | Receipt> {
1731
1768
  if (sample) return sampleCommand(sample);
1732
1769
  const fit = parseFitCommand(command);
1733
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);
1734
1781
  const pack = parsePackCommand(command);
1735
1782
  if (pack) return packCommand(pack);
1736
1783
  const pattern = parsePatternCommand(command);
@@ -1765,6 +1812,25 @@ async function submit(prompt: string): Promise<string | Receipt> {
1765
1812
  await commitScore(result.next, result.kind, result.payload);
1766
1813
  return result.ok ? ok(result.message) : fail(result.message);
1767
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
+ }
1768
1834
  const rig = parseRigCommand(command);
1769
1835
  if (rig) {
1770
1836
  if (rig.type !== "rig-show" && rig.type !== "rig-hint")
@@ -1827,6 +1893,17 @@ async function submit(prompt: string): Promise<string | Receipt> {
1827
1893
  await commitScore(result.next, result.kind, result.payload);
1828
1894
  return result.ok ? ok(result.message) : fail(result.message);
1829
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
+ }
1830
1907
  const synth = parseSynthCommand(command);
1831
1908
  if (synth) {
1832
1909
  if (synth.type !== "synth-list") await materializeDraft();
@@ -2078,6 +2155,9 @@ async function submit(prompt: string): Promise<string | Receipt> {
2078
2155
  // Plain `marimba` keeps the legacy tone; point at the mallet engine.
2079
2156
  if (parsed.patch.instrument === "marimba" && !("modal" in parsed.patch))
2080
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`;
2081
2161
  const sine =
2082
2162
  word !== undefined && !("string" in parsed.patch)
2083
2163
  ? plainSineAdvice(word)
@@ -2375,6 +2455,36 @@ async function fitCommand(
2375
2455
  );
2376
2456
  }
2377
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
+
2378
2488
  async function sampleCommand(
2379
2489
  command: NonNullable<ReturnType<typeof parseSampleCommand>>,
2380
2490
  ): Promise<Receipt> {
@@ -3167,7 +3277,11 @@ function stagedChordSettings(): ChordSettings | undefined {
3167
3277
  if (chords.length === 0) return undefined;
3168
3278
  const settings = { ...chordSettings };
3169
3279
  for (const command of chords)
3170
- applyChordsCommand(settings, command.match(CHORDS_COMMAND)![1]!);
3280
+ applyChordsCommand(
3281
+ settings,
3282
+ command.match(CHORDS_COMMAND)![1]!,
3283
+ score.tempoBpm,
3284
+ );
3171
3285
  return settings;
3172
3286
  }
3173
3287
 
@@ -3224,7 +3338,11 @@ async function applyStaged(
3224
3338
  const chords = command.trim().match(CHORDS_COMMAND);
3225
3339
  if (chords) {
3226
3340
  // Window state: checked on a copy; kept settings apply on Enter.
3227
- const result = applyChordsCommand({ ...chordSettings }, chords[1]!);
3341
+ const result = applyChordsCommand(
3342
+ { ...chordSettings },
3343
+ chords[1]!,
3344
+ base.tempoBpm,
3345
+ );
3228
3346
  return { next: base, ok: result.ok, message: result.message };
3229
3347
  }
3230
3348
  await prefetchStaged(base, command);
@@ -3417,7 +3535,11 @@ async function keepStaged(): Promise<Receipt> {
3417
3535
  CHORDS_COMMAND.test(command),
3418
3536
  );
3419
3537
  for (const command of chordCommands)
3420
- applyChordsCommand(chordSettings, command.match(CHORDS_COMMAND)![1]!);
3538
+ applyChordsCommand(
3539
+ chordSettings,
3540
+ command.match(CHORDS_COMMAND)![1]!,
3541
+ score.tempoBpm,
3542
+ );
3421
3543
  const scoreCommands = taken.commands.filter(
3422
3544
  (command) => !CHORDS_COMMAND.test(command),
3423
3545
  );
@@ -8,7 +8,10 @@ import {
8
8
  formatDiagnostic,
9
9
  type Diagnostic,
10
10
  } from "../../core/sdk/eval.ts";
11
- import { legacyResonatorWarnings } from "../../core/resonators.ts";
11
+ import {
12
+ legacyResonatorWarnings,
13
+ modalPairWarnings,
14
+ } from "../../core/resonators.ts";
12
15
  import { plainSineWarnings } from "../audio/instrument-check.ts";
13
16
  import { isProject } from "./init.ts";
14
17
  import { typecheckProject } from "./typecheck.ts";
@@ -53,6 +56,7 @@ export async function checkProject(
53
56
  evaluated.ok
54
57
  ? [
55
58
  ...legacyResonatorWarnings(evaluated.score.tracks),
59
+ ...modalPairWarnings(evaluated.score.tracks),
56
60
  ...plainSineWarnings(evaluated.score.tracks),
57
61
  ]
58
62
  : [],
package/src/render.ts CHANGED
@@ -229,6 +229,15 @@ export async function runRenderCommand(
229
229
  `rendered · ${target} · ${seconds.toFixed(2)} s · ${wav.byteLength} bytes · ${sha}\n`,
230
230
  );
231
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
+ );
232
241
  if (options.has("--measure"))
233
242
  stdout.write(
234
243
  `loudness · ${measurementLine(measureRendered(audio, false).mix, audio.master)}\n`,
@@ -238,6 +247,13 @@ export async function runRenderCommand(
238
247
  return 0;
239
248
  }
240
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
+
241
257
  async function loadScore(
242
258
  workspace: string,
243
259
  options: ReadonlyMap<string, string>,
@@ -32,6 +32,8 @@ export class FilePresence {
32
32
  private readonly lock: string;
33
33
  private entry: PresenceEntry;
34
34
  private timer: ReturnType<typeof setInterval> | undefined;
35
+ private writing: Promise<void> = Promise.resolve();
36
+ private writes = 0;
35
37
 
36
38
  public constructor(paths: SessionPaths, entry: PresenceEntry) {
37
39
  this.dir = `${paths.record}.presence`;
@@ -56,7 +58,8 @@ export class FilePresence {
56
58
 
57
59
  public async focus(trackId: string | null): Promise<void> {
58
60
  this.entry = { ...this.entry, focusedTrackId: trackId };
59
- await this.write();
61
+ // Presence is advisory: a failed heartbeat write never refuses a focus change.
62
+ await this.write().catch(() => undefined);
60
63
  }
61
64
 
62
65
  public async claim(
@@ -133,9 +136,19 @@ export class FilePresence {
133
136
  return join(this.dir, `${this.entry.clientId}.json`);
134
137
  }
135
138
 
136
- private async write(): Promise<void> {
139
+ /** Writes are serialized so the heartbeat and focus() never share a rename. */
140
+ private write(): Promise<void> {
141
+ const next = this.writing.then(
142
+ () => this.writeNow(),
143
+ () => this.writeNow(),
144
+ );
145
+ this.writing = next.catch(() => undefined);
146
+ return next;
147
+ }
148
+
149
+ private async writeNow(): Promise<void> {
137
150
  await mkdir(this.dir, { recursive: true });
138
- const temporary = `${this.file()}.${process.pid}.tmp`;
151
+ const temporary = `${this.file()}.${process.pid}.${++this.writes}.tmp`;
139
152
  await writeFile(
140
153
  temporary,
141
154
  JSON.stringify({ ...this.entry, at: Date.now() }),
@@ -528,7 +528,7 @@ export function auditionKey(value: string): AuditionKey | undefined {
528
528
  * before.
529
529
  */
530
530
  const STAGEABLE =
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;
531
+ /^\/?(?:(fx|effects|filter|lowpass|synth|string|bowed|modal|wind|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|epiano|rhodes|wurli|wurlitzer|clav|clavinet)\s+\S|pack\s+use\s+\S|(?:piano|grand|upright|felt|honkytonk|prepared|ballad)(?:\s+piano)?\s*$|(?:epiano|rhodes|suitcase|dyno|wurli|wurlitzer|clav|clavinet|funkclav)\s*$|(?:tonewheel|hammond|b3|gospel|jazzorgan|combo|farfisa|vox|pipe|church|pipeorgan|churchorgan|flutes|cornet|reeds|celeste)(?:\s+\S.*)?$|rotary\s+(?:slow|fast|stop)\s*$)/i;
532
532
  /** Subcommands that list or show instead of changing the sound. */
533
533
  const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i;
534
534
  /** `master <unit>` alone only shows the unit. */