@hraness/dawg 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -0,0 +1,390 @@
1
+ /**
2
+ * The ctrl-k menu's Arrange section (0.5): song sections, the form, and
3
+ * build/drop/fill generators. Every row runs a `section …`, `form …`,
4
+ * `build …`, `drop …` or `fill …` prompt command (src/commands/arrange.ts),
5
+ * so the menu, the prompt and the agent tools share one code path.
6
+ */
7
+ import type { Section } from "../../core/score.ts";
8
+ import { formatForm } from "../../core/sections.ts";
9
+ import { barsLabel } from "../commands/arrange.ts";
10
+ import type { MenuContext, MenuNode } from "./menu.ts";
11
+
12
+ /** `3 sections · form A×2 B · loop chorus`, or how to start. */
13
+ export function arrangeDetail(context: MenuContext): string {
14
+ const score = context.score;
15
+ if (score.sections.length === 0) return "no sections · mark bars";
16
+ const count = score.sections.length;
17
+ const parts = [`${count} section${count === 1 ? "" : "s"}`];
18
+ if (score.form.length) parts.push(`form ${formatForm(score.form)}`);
19
+ if (score.loopSection) parts.push(`loop ${score.loopSection}`);
20
+ return parts.join(" · ");
21
+ }
22
+
23
+ export function arrangeNodes(context: MenuContext): MenuNode[] {
24
+ const score = context.score;
25
+ const nodes: MenuNode[] = score.sections.map((section) => ({
26
+ kind: "menu",
27
+ id: `section:${section.name}`,
28
+ label: section.name,
29
+ detail: sectionDetail(context, section),
30
+ help: "loop, jump, mute, vary, move, rename, transitions",
31
+ build: (next) => sectionNodes(next, section.name),
32
+ }));
33
+ nodes.push(
34
+ {
35
+ kind: "entry",
36
+ label: "mark bars",
37
+ value: "",
38
+ placeholder: "name and bars, e.g. verse 1-8",
39
+ example: "section verse 1-8",
40
+ help: "name a bar range; re-marking a name moves its marker",
41
+ command: (text) => {
42
+ const trimmed = text.trim();
43
+ return /\S\s+\d{1,4}(?:[-–]\d{1,4})?$/u.test(trimmed)
44
+ ? `section ${trimmed}`
45
+ : undefined;
46
+ },
47
+ },
48
+ {
49
+ kind: "entry",
50
+ label: "add section",
51
+ value: "",
52
+ placeholder: "name and bars, e.g. chorus 8 (empty: next name, 8 bars)",
53
+ example: "section add chorus 8",
54
+ help: "a new section after the last one (extends the song)",
55
+ command: (text) => `section add ${text.trim()}`.trim(),
56
+ },
57
+ );
58
+ if (score.sections.length > 0) {
59
+ nodes.push({
60
+ kind: "entry",
61
+ label: "form",
62
+ value: score.form.length ? formatForm(score.form) : "bars in order",
63
+ placeholder: "sections in play order, e.g. intro verse chorus*2 outro",
64
+ example: "form intro verse chorus*2 outro",
65
+ help: "the order playback and export follow; empty clears it",
66
+ command: (text) => (text.trim() ? `form ${text.trim()}` : "form off"),
67
+ });
68
+ if (score.form.length)
69
+ nodes.push({
70
+ kind: "action",
71
+ label: "bake form",
72
+ command: "form bake",
73
+ help: "write the form out as plain bars, then clear it",
74
+ });
75
+ if (score.loopSection)
76
+ nodes.push({
77
+ kind: "action",
78
+ label: `stop looping ${score.loopSection}`,
79
+ command: "section loop off",
80
+ help: "play the whole song (or form) again",
81
+ });
82
+ }
83
+ return nodes;
84
+ }
85
+
86
+ function sectionDetail(context: MenuContext, section: Section): string {
87
+ const parts = [barsLabel(section)];
88
+ if (context.score.loopSection === section.name) parts.push("looping");
89
+ if (section.mute?.length) parts.push(`mutes ${section.mute.join(" ")}`);
90
+ const varied = Object.keys(section.vary ?? {});
91
+ if (varied.length) parts.push(`varies ${varied.join(" ")}`);
92
+ return parts.join(" · ");
93
+ }
94
+
95
+ function sectionNodes(context: MenuContext, name: string): MenuNode[] {
96
+ const score = context.score;
97
+ const section = score.sections.find((entry) => entry.name === name);
98
+ if (!section)
99
+ return [{ kind: "info", label: "gone", value: `${name} was removed` }];
100
+ const looping = score.loopSection === name;
101
+ const track = context.trackId;
102
+ const vary = section.vary?.[track];
103
+ const mutes: MenuNode[] = score.tracks.map((entry) => ({
104
+ kind: "toggle",
105
+ label: `mute ${entry.id}`,
106
+ value: section.mute?.includes(entry.id) ?? false,
107
+ help: `silence ${entry.name} in this section only`,
108
+ command: (on) => `section ${on ? "mute" : "unmute"} ${name} ${entry.id}`,
109
+ }));
110
+ return [
111
+ {
112
+ kind: "action",
113
+ label: looping ? "stop looping" : "loop",
114
+ command: looping ? "section loop off" : `section loop ${name}`,
115
+ help: "loop this section in playback (export still plays the song)",
116
+ },
117
+ {
118
+ kind: "action",
119
+ label: "jump here",
120
+ command: `section jump ${name}`,
121
+ help: "move the playhead to its first bar",
122
+ },
123
+ ...mutes,
124
+ {
125
+ kind: "number",
126
+ label: `transpose ${track}`,
127
+ help: "shift the focused track here, in semitones (x resets)",
128
+ // As written is +0, so the first nudge already moves a semitone.
129
+ value: vary?.transpose ?? 0,
130
+ min: -24,
131
+ max: 24,
132
+ step: (value, direction) =>
133
+ Math.max(-24, Math.min(24, Math.round(value) + direction)),
134
+ format: (value) => `${value > 0 ? "+" : ""}${Math.round(value)} st`,
135
+ command: (value) => {
136
+ const rounded = Math.round(value);
137
+ return `section vary ${name} ${track} ${rounded < 0 ? "" : "+"}${rounded}`;
138
+ },
139
+ reset: `section vary ${name} ${track} off`,
140
+ },
141
+ {
142
+ kind: "number",
143
+ label: `gain ${track}`,
144
+ help: "scale the focused track's velocity here (x resets)",
145
+ value: vary?.gain ?? 1,
146
+ min: 0,
147
+ max: 2,
148
+ step: (value, direction) =>
149
+ Math.max(
150
+ 0,
151
+ Math.min(2, Math.round((value + 0.1 * direction) * 10) / 10),
152
+ ),
153
+ format: (value) => `×${value.toFixed(1)}`,
154
+ command: (value) =>
155
+ `section vary ${name} ${track} gain ${(Math.round(value * 10) / 10).toString()}`,
156
+ reset: `section vary ${name} ${track} off`,
157
+ },
158
+ {
159
+ kind: "menu",
160
+ id: `section:${name}:build`,
161
+ label: "build",
162
+ detail: section.startBar > 0 ? "into it or over it" : "over it",
163
+ help: "riser, snare roll, filter sweep and uplifter",
164
+ build: () => buildNodes(section),
165
+ },
166
+ ...(section.startBar > 0
167
+ ? ([
168
+ {
169
+ kind: "menu",
170
+ id: `section:${name}:drop`,
171
+ label: "drop",
172
+ detail: "cut and impact",
173
+ help: "a pre-drop cut before it and an impact on its downbeat",
174
+ build: () => dropNodes(section),
175
+ },
176
+ {
177
+ kind: "menu",
178
+ id: `section:${name}:fill`,
179
+ label: "fill",
180
+ detail: "toms, roll or kick",
181
+ help: "a drum fill on the beats before it and a crash on its downbeat",
182
+ build: () => fillNodes(section),
183
+ },
184
+ ] satisfies MenuNode[])
185
+ : []),
186
+ {
187
+ kind: "action",
188
+ label: "duplicate",
189
+ command: `section dup ${name}`,
190
+ help: "copy it and its bars right after it",
191
+ },
192
+ {
193
+ kind: "entry",
194
+ label: "duplicate as",
195
+ value: "",
196
+ placeholder: "the copy's name",
197
+ example: `section dup ${name} as ${name} 2`,
198
+ help: "copy it and its bars right after it, under a new name",
199
+ command: (text) =>
200
+ text.trim() ? `section dup ${name} as ${text.trim()}` : undefined,
201
+ },
202
+ {
203
+ kind: "entry",
204
+ label: "move to",
205
+ value: "",
206
+ placeholder: "a bar, or before/after a section, e.g. 17 or after chorus",
207
+ example: `section move ${name} to 17`,
208
+ help: "move it and its bars; later sections make room",
209
+ command: (text) => {
210
+ const trimmed = text.trim();
211
+ if (/^\d{1,4}$/u.test(trimmed))
212
+ return `section move ${name} to ${trimmed}`;
213
+ return /^(before|after)\s+\S/u.test(trimmed)
214
+ ? `section move ${name} ${trimmed}`
215
+ : undefined;
216
+ },
217
+ },
218
+ {
219
+ kind: "action",
220
+ label: "move left",
221
+ command: `section move ${name} left`,
222
+ help: "swap it with the section before (its bars move too)",
223
+ },
224
+ {
225
+ kind: "action",
226
+ label: "move right",
227
+ command: `section move ${name} right`,
228
+ help: "swap it with the section after (its bars move too)",
229
+ },
230
+ {
231
+ kind: "entry",
232
+ label: "rename",
233
+ value: name,
234
+ placeholder: "new name",
235
+ example: `section rename ${name} to hook`,
236
+ help: "the form and the loop follow the new name",
237
+ command: (text) =>
238
+ text.trim() ? `section rename ${name} to ${text.trim()}` : undefined,
239
+ },
240
+ {
241
+ kind: "action",
242
+ label: "clear mutes and variations",
243
+ command: `section reset ${name}`,
244
+ help: "every track plays as written here",
245
+ },
246
+ {
247
+ kind: "action",
248
+ label: "unmark",
249
+ command: `section unmark ${name}`,
250
+ help: "remove the marker; the music stays",
251
+ },
252
+ {
253
+ kind: "action",
254
+ label: "delete with its bars",
255
+ command: `section delete ${name}`,
256
+ help: "remove it and its bars; later bars move up",
257
+ },
258
+ ];
259
+ }
260
+
261
+ /** Build rows: into the section (the bars before it) or over it. */
262
+ export function buildNodes(section: Section): MenuNode[] {
263
+ const name = section.name;
264
+ const nodes: MenuNode[] = [];
265
+ if (section.startBar > 0) {
266
+ const bars = Math.min(4, section.startBar);
267
+ nodes.push({
268
+ kind: "action",
269
+ label: `into it (${bars} bar${bars === 1 ? "" : "s"} before)`,
270
+ command: `build into ${name}`,
271
+ help: `riser, snare roll, filter sweep and uplifter over bars ${section.startBar - bars + 1}–${section.startBar}, landing on its downbeat`,
272
+ });
273
+ }
274
+ nodes.push(
275
+ {
276
+ kind: "action",
277
+ label: "over it",
278
+ command: `build ${name}`,
279
+ help: "riser, snare roll, filter sweep and uplifter over this section, landing on the bar after",
280
+ },
281
+ {
282
+ kind: "entry",
283
+ label: "custom",
284
+ value: "",
285
+ placeholder:
286
+ "bars and layers, e.g. 8 bars riser sweep (layers: riser roll sweep uplifter)",
287
+ example: `build into ${name} 8 bars riser sweep`,
288
+ help: "choose the length and which layers play",
289
+ command: (text) => {
290
+ const words = text.trim().toLowerCase().split(/\s+/u).filter(Boolean);
291
+ const ok = words.every(
292
+ (word, index) =>
293
+ /^(riser|roll|sweep|uplifter)s?$/u.test(word) ||
294
+ (/^\d{1,3}$/u.test(word) &&
295
+ /^bars?$/u.test(words[index + 1] ?? "")) ||
296
+ (/^bars?$/u.test(word) &&
297
+ /^\d{1,3}$/u.test(words[index - 1] ?? "")),
298
+ );
299
+ if (!ok) return undefined;
300
+ const target = section.startBar > 0 ? `into ${name}` : name;
301
+ return `build ${target} ${words.join(" ")}`.trim();
302
+ },
303
+ },
304
+ );
305
+ return nodes;
306
+ }
307
+
308
+ /** Drop rows: cut length and the impact. */
309
+ export function dropNodes(section: Section): MenuNode[] {
310
+ const name = section.name;
311
+ return [
312
+ {
313
+ kind: "action",
314
+ label: "cut 1 beat and impact",
315
+ command: `drop ${name}`,
316
+ help: "a one-beat cut before it and an impact on its downbeat",
317
+ },
318
+ {
319
+ kind: "action",
320
+ label: "cut 2 beats and impact",
321
+ command: `drop ${name} cut 2`,
322
+ help: "a two-beat cut before it and an impact on its downbeat",
323
+ },
324
+ {
325
+ kind: "action",
326
+ label: "impact only",
327
+ command: `drop ${name} no cut`,
328
+ help: "an impact on its downbeat, no cut",
329
+ },
330
+ {
331
+ kind: "action",
332
+ label: "cut only",
333
+ command: `drop ${name} no impact`,
334
+ help: "a one-beat cut before it, no impact",
335
+ },
336
+ {
337
+ kind: "entry",
338
+ label: "custom",
339
+ value: "",
340
+ placeholder: "cut beats and impact, e.g. cut 4 no impact",
341
+ example: `drop ${name} cut 4 no impact`,
342
+ help: "cut 0 up to two bars of beats; no impact skips the hit",
343
+ command: (text) =>
344
+ /^(cut\s+\d+(\.\d+)?|no\s+cut|no\s+impact|impact|\s)*$/iu.test(
345
+ text.trim(),
346
+ )
347
+ ? `drop ${name} ${text.trim()}`.trim()
348
+ : undefined,
349
+ },
350
+ ];
351
+ }
352
+
353
+ /** Fill rows: a style each, plus length and crash. */
354
+ export function fillNodes(section: Section): MenuNode[] {
355
+ const name = section.name;
356
+ return [
357
+ {
358
+ kind: "action",
359
+ label: "toms",
360
+ command: `fill ${name} toms`,
361
+ help: "snare into high, mid and low toms on the beat before it, crash on its downbeat",
362
+ },
363
+ {
364
+ kind: "action",
365
+ label: "snare roll",
366
+ command: `fill ${name} roll`,
367
+ help: "snare 16ths on the beat before it, crash on its downbeat",
368
+ },
369
+ {
370
+ kind: "action",
371
+ label: "kick and snare",
372
+ command: `fill ${name} kick`,
373
+ help: "kick and snare 16ths on the beat before it, crash on its downbeat",
374
+ },
375
+ {
376
+ kind: "entry",
377
+ label: "custom",
378
+ value: "",
379
+ placeholder: "style, beats and crash, e.g. toms 2 beats no crash",
380
+ example: `fill ${name} toms 2 beats no crash`,
381
+ help: "styles toms, roll, kick; half a beat up to two bars",
382
+ command: (text) =>
383
+ /^(toms|roll|kick|\d+(\.\d+)?\s+beats?|no\s+crash|crash|\s)*$/iu.test(
384
+ text.trim(),
385
+ )
386
+ ? `fill ${name} ${text.trim()}`.trim()
387
+ : undefined,
388
+ },
389
+ ];
390
+ }
@@ -68,6 +68,11 @@ export type AuditionHost = {
68
68
  * settings screen plays a progression with its settings this way.
69
69
  */
70
70
  phrase?(showing: "A" | "B"): PreviewOptions["phrase"];
71
+ /**
72
+ * The drive the song's master solved for its target on the full mix (the
73
+ * last song loop), so previews hear a fixed master gain.
74
+ */
75
+ masterGainDb?(): number | undefined;
71
76
  };
72
77
 
73
78
  export type AuditionOptions = Readonly<{
@@ -108,6 +113,8 @@ export class Audition {
108
113
  private staged: Staged[] = [];
109
114
  private work: Promise<unknown> = Promise.resolve();
110
115
  private rendering = false;
116
+ /** The song master's solved drive when the loop started (see host). */
117
+ private masterGainDb: number | undefined;
111
118
  private dirty = false;
112
119
  private pendingKeyAt: number | undefined;
113
120
  private lastRenderAt = -Infinity;
@@ -166,6 +173,7 @@ export class Audition {
166
173
 
167
174
  public start(): void {
168
175
  if (this.looping) return;
176
+ this.masterGainDb = this.host.masterGainDb?.();
169
177
  this.looping = true;
170
178
  this.lastRenderAt = -Infinity;
171
179
  this.request();
@@ -192,6 +200,18 @@ export class Audition {
192
200
  this.host.changed?.();
193
201
  }
194
202
 
203
+ /**
204
+ * Whether the loop plays the full mix: the `c` toggle, or forced while a
205
+ * master edit is staged, because the master works on the whole mix and a
206
+ * solo track says nothing about its loudness.
207
+ */
208
+ public get inContext(): boolean {
209
+ return (
210
+ this.context ||
211
+ this.staged.some((entry) => MASTER_COMMAND.test(entry.command))
212
+ );
213
+ }
214
+
195
215
  /** A/B; a no-op (stays on B) while nothing is staged. */
196
216
  public toggleAB(): void {
197
217
  if (!this.dirtyEdits) {
@@ -412,10 +432,18 @@ export class Audition {
412
432
  if (!this.looping || this.rendering) return;
413
433
  this.cancelTimer();
414
434
  const phrase = this.host.phrase?.(this.dirtyEdits ? this.showing : "A");
435
+ // A staged master edit re-solves its target on the mix; otherwise the
436
+ // master plays at the song's solved drive, so a track edit is audible.
437
+ const masterStaged = this.staged.some((entry) =>
438
+ MASTER_COMMAND.test(entry.command),
439
+ );
415
440
  const preview = previewScore(this.sounding, this.trackId, {
416
- context: this.context,
441
+ context: this.inContext,
417
442
  beat: this.host.beat?.() ?? 0,
418
443
  ...(phrase ? { phrase } : {}),
444
+ ...(this.masterGainDb !== undefined && !masterStaged
445
+ ? { masterGainDb: this.masterGainDb }
446
+ : {}),
419
447
  });
420
448
  if (!preview) return;
421
449
  this.rendering = true;
@@ -451,7 +479,7 @@ export class Audition {
451
479
  public status(): string | undefined {
452
480
  if (!this.looping && !this.dirtyEdits && !this.fetching) return undefined;
453
481
  const parts: string[] = [];
454
- if (this.looping) parts.push(`♪ ${this.context ? "in context" : "solo"}`);
482
+ if (this.looping) parts.push(`♪ ${this.inContext ? "in context" : "solo"}`);
455
483
  else parts.push("loop off");
456
484
  if (this.fetching) parts.push("fetching…");
457
485
  if (this.dirtyEdits)
@@ -500,13 +528,18 @@ export function auditionKey(value: string): AuditionKey | undefined {
500
528
  * before.
501
529
  */
502
530
  const STAGEABLE =
503
- /^\/?(?:(fx|effects|filter|lowpass|synth|wt|wavetable|kit|instrument|vol|volume|pan|gain|speed|warpmode|root|pattern|euclid)\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;
504
532
  /** Subcommands that list or show instead of changing the sound. */
505
- const READ_ONLY = /^\/?\S+\s+(list|show|info|help)\s*$/i;
533
+ const READ_ONLY = /^\/?\S+\s+(list|show|info|help|measure|meter|presets?)\s*$/i;
534
+ /** `master <unit>` alone only shows the unit. */
535
+ const MASTER_SHOW = /^\/?master\s+(eq|glue|tape|width|limiter)\s*$/i;
536
+ const MASTER_COMMAND = /^\/?master\s/i;
506
537
 
507
538
  export function isStageable(command: string): boolean {
508
539
  const text = command.trim();
509
- return STAGEABLE.test(text) && !READ_ONLY.test(text);
540
+ return (
541
+ STAGEABLE.test(text) && !READ_ONLY.test(text) && !MASTER_SHOW.test(text)
542
+ );
510
543
  }
511
544
 
512
545
  /**